mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-01 09:22:19 +00:00
Rewrite a variety of .contains()
calls as matches!
statements (#5432)
## Summary These have the potential to be much more efficient, as we've seen in the past.
This commit is contained in:
parent
aa887d5a1d
commit
a973019358
10 changed files with 183 additions and 169 deletions
|
@ -4,50 +4,57 @@ use ruff_diagnostics::{Diagnostic, DiagnosticKind};
|
||||||
|
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
|
|
||||||
pub(super) const FUNC_CALL_NAME_ALLOWLIST: &[&str] = &[
|
/// Returns `true` if a function call is allowed to use a boolean trap.
|
||||||
"append",
|
pub(super) fn is_allowed_func_call(name: &str) -> bool {
|
||||||
"assertEqual",
|
matches!(
|
||||||
"assertEquals",
|
name,
|
||||||
"assertNotEqual",
|
"append"
|
||||||
"assertNotEquals",
|
| "assertEqual"
|
||||||
"bool",
|
| "assertEquals"
|
||||||
"bytes",
|
| "assertNotEqual"
|
||||||
"count",
|
| "assertNotEquals"
|
||||||
"failIfEqual",
|
| "bool"
|
||||||
"failUnlessEqual",
|
| "bytes"
|
||||||
"float",
|
| "count"
|
||||||
"fromkeys",
|
| "failIfEqual"
|
||||||
"get",
|
| "failUnlessEqual"
|
||||||
"getattr",
|
| "float"
|
||||||
"getboolean",
|
| "fromkeys"
|
||||||
"getfloat",
|
| "get"
|
||||||
"getint",
|
| "getattr"
|
||||||
"index",
|
| "getboolean"
|
||||||
"insert",
|
| "getfloat"
|
||||||
"int",
|
| "getint"
|
||||||
"param",
|
| "index"
|
||||||
"pop",
|
| "insert"
|
||||||
"remove",
|
| "int"
|
||||||
"set_blocking",
|
| "param"
|
||||||
"set_enabled",
|
| "pop"
|
||||||
"setattr",
|
| "remove"
|
||||||
"__setattr__",
|
| "set_blocking"
|
||||||
"setdefault",
|
| "set_enabled"
|
||||||
"str",
|
| "setattr"
|
||||||
];
|
| "__setattr__"
|
||||||
|
| "setdefault"
|
||||||
|
| "str"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) const FUNC_DEF_NAME_ALLOWLIST: &[&str] = &["__setitem__"];
|
/// Returns `true` if a function definition is allowed to use a boolean trap.
|
||||||
|
pub(super) fn is_allowed_func_def(name: &str) -> bool {
|
||||||
|
matches!(name, "__setitem__")
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if an argument is allowed to use a boolean trap. To return
|
/// Returns `true` if an argument is allowed to use a boolean trap. To return
|
||||||
/// `true`, the function name must be explicitly allowed, and the argument must
|
/// `true`, the function name must be explicitly allowed, and the argument must
|
||||||
/// be either the first or second argument in the call.
|
/// be either the first or second argument in the call.
|
||||||
pub(super) fn allow_boolean_trap(func: &Expr) -> bool {
|
pub(super) fn allow_boolean_trap(func: &Expr) -> bool {
|
||||||
if let Expr::Attribute(ast::ExprAttribute { attr, .. }) = func {
|
if let Expr::Attribute(ast::ExprAttribute { attr, .. }) = func {
|
||||||
return FUNC_CALL_NAME_ALLOWLIST.contains(&attr.as_ref());
|
return is_allowed_func_call(attr);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Expr::Name(ast::ExprName { id, .. }) = func {
|
if let Expr::Name(ast::ExprName { id, .. }) = func {
|
||||||
return FUNC_CALL_NAME_ALLOWLIST.contains(&id.as_ref());
|
return is_allowed_func_call(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
use rustpython_parser::ast::{ArgWithDefault, Arguments, Decorator};
|
use rustpython_parser::ast::{ArgWithDefault, Arguments, Decorator};
|
||||||
|
|
||||||
use ruff_diagnostics::Violation;
|
use ruff_diagnostics::Violation;
|
||||||
|
|
||||||
use ruff_macros::{derive_message_formats, violation};
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_ast::call_path::collect_call_path;
|
use ruff_python_ast::call_path::collect_call_path;
|
||||||
|
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
use crate::rules::flake8_boolean_trap::helpers::add_if_boolean;
|
use crate::rules::flake8_boolean_trap::helpers::{add_if_boolean, is_allowed_func_def};
|
||||||
|
|
||||||
use super::super::helpers::FUNC_DEF_NAME_ALLOWLIST;
|
|
||||||
|
|
||||||
/// ## What it does
|
/// ## What it does
|
||||||
/// Checks for the use of booleans as default values in function definitions.
|
/// Checks for the use of booleans as default values in function definitions.
|
||||||
|
@ -64,7 +61,7 @@ pub(crate) fn check_boolean_default_value_in_function_definition(
|
||||||
decorator_list: &[Decorator],
|
decorator_list: &[Decorator],
|
||||||
arguments: &Arguments,
|
arguments: &Arguments,
|
||||||
) {
|
) {
|
||||||
if FUNC_DEF_NAME_ALLOWLIST.contains(&name) {
|
if is_allowed_func_def(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_ast::call_path::collect_call_path;
|
use ruff_python_ast::call_path::collect_call_path;
|
||||||
|
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
use crate::rules::flake8_boolean_trap::helpers::FUNC_DEF_NAME_ALLOWLIST;
|
use crate::rules::flake8_boolean_trap::helpers::is_allowed_func_def;
|
||||||
|
|
||||||
/// ## What it does
|
/// ## What it does
|
||||||
/// Checks for boolean positional arguments in function definitions.
|
/// Checks for boolean positional arguments in function definitions.
|
||||||
|
@ -82,7 +82,7 @@ pub(crate) fn check_positional_boolean_in_def(
|
||||||
decorator_list: &[Decorator],
|
decorator_list: &[Decorator],
|
||||||
arguments: &Arguments,
|
arguments: &Arguments,
|
||||||
) {
|
) {
|
||||||
if FUNC_DEF_NAME_ALLOWLIST.contains(&name) {
|
if is_allowed_func_def(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -155,7 +155,7 @@ pub(crate) fn non_self_return_type(
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-place methods that are expected to return `Self`.
|
// In-place methods that are expected to return `Self`.
|
||||||
if INPLACE_BINOP_METHODS.contains(&name) {
|
if is_inplace_bin_op(name) {
|
||||||
if !is_self(returns, checker.semantic()) {
|
if !is_self(returns, checker.semantic()) {
|
||||||
checker.diagnostics.push(Diagnostic::new(
|
checker.diagnostics.push(Diagnostic::new(
|
||||||
NonSelfReturnType {
|
NonSelfReturnType {
|
||||||
|
@ -214,21 +214,25 @@ pub(crate) fn non_self_return_type(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPLACE_BINOP_METHODS: &[&str] = &[
|
/// Returns `true` if the method is an in-place binary operator.
|
||||||
"__iadd__",
|
fn is_inplace_bin_op(name: &str) -> bool {
|
||||||
"__isub__",
|
matches!(
|
||||||
"__imul__",
|
name,
|
||||||
"__imatmul__",
|
"__iadd__"
|
||||||
"__itruediv__",
|
| "__isub__"
|
||||||
"__ifloordiv__",
|
| "__imul__"
|
||||||
"__imod__",
|
| "__imatmul__"
|
||||||
"__ipow__",
|
| "__itruediv__"
|
||||||
"__ilshift__",
|
| "__ifloordiv__"
|
||||||
"__irshift__",
|
| "__imod__"
|
||||||
"__iand__",
|
| "__ipow__"
|
||||||
"__ixor__",
|
| "__ilshift__"
|
||||||
"__ior__",
|
| "__irshift__"
|
||||||
];
|
| "__iand__"
|
||||||
|
| "__ixor__"
|
||||||
|
| "__ior__"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `true` if the given expression resolves to the given name.
|
/// Return `true` if the given expression resolves to the given name.
|
||||||
fn is_name(expr: &Expr, name: &str) -> bool {
|
fn is_name(expr: &Expr, name: &str) -> bool {
|
||||||
|
|
|
@ -3,7 +3,7 @@ use rustpython_parser::ast::{self, Expr, Keyword, Ranged};
|
||||||
|
|
||||||
use ruff_diagnostics::{Diagnostic, Violation};
|
use ruff_diagnostics::{Diagnostic, Violation};
|
||||||
use ruff_macros::{derive_message_formats, violation};
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_ast::call_path::compose_call_path;
|
use ruff_python_ast::call_path::collect_call_path;
|
||||||
use ruff_python_ast::helpers::{collect_arg_names, SimpleCallArgs};
|
use ruff_python_ast::helpers::{collect_arg_names, SimpleCallArgs};
|
||||||
use ruff_python_ast::visitor;
|
use ruff_python_ast::visitor;
|
||||||
use ruff_python_ast::visitor::Visitor;
|
use ruff_python_ast::visitor::Visitor;
|
||||||
|
@ -18,26 +18,6 @@ impl Violation for PytestPatchWithLambda {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATCH_NAMES: &[&str] = &[
|
|
||||||
"mocker.patch",
|
|
||||||
"class_mocker.patch",
|
|
||||||
"module_mocker.patch",
|
|
||||||
"package_mocker.patch",
|
|
||||||
"session_mocker.patch",
|
|
||||||
"mock.patch",
|
|
||||||
"unittest.mock.patch",
|
|
||||||
];
|
|
||||||
|
|
||||||
const PATCH_OBJECT_NAMES: &[&str] = &[
|
|
||||||
"mocker.patch.object",
|
|
||||||
"class_mocker.patch.object",
|
|
||||||
"module_mocker.patch.object",
|
|
||||||
"package_mocker.patch.object",
|
|
||||||
"session_mocker.patch.object",
|
|
||||||
"mock.patch.object",
|
|
||||||
"unittest.mock.patch.object",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
/// Visitor that checks references the argument names in the lambda body.
|
/// Visitor that checks references the argument names in the lambda body.
|
||||||
struct LambdaBodyVisitor<'a> {
|
struct LambdaBodyVisitor<'a> {
|
||||||
|
@ -98,14 +78,35 @@ pub(crate) fn patch_with_lambda(
|
||||||
args: &[Expr],
|
args: &[Expr],
|
||||||
keywords: &[Keyword],
|
keywords: &[Keyword],
|
||||||
) -> Option<Diagnostic> {
|
) -> Option<Diagnostic> {
|
||||||
if let Some(call_path) = compose_call_path(call) {
|
let call_path = collect_call_path(call)?;
|
||||||
if PATCH_NAMES.contains(&call_path.as_str()) {
|
|
||||||
check_patch_call(call, args, keywords, 1)
|
if matches!(
|
||||||
} else if PATCH_OBJECT_NAMES.contains(&call_path.as_str()) {
|
call_path.as_slice(),
|
||||||
check_patch_call(call, args, keywords, 2)
|
[
|
||||||
} else {
|
"mocker"
|
||||||
None
|
| "class_mocker"
|
||||||
}
|
| "module_mocker"
|
||||||
|
| "package_mocker"
|
||||||
|
| "session_mocker"
|
||||||
|
| "mock",
|
||||||
|
"patch"
|
||||||
|
] | ["unittest", "mock", "patch"]
|
||||||
|
) {
|
||||||
|
check_patch_call(call, args, keywords, 1)
|
||||||
|
} else if matches!(
|
||||||
|
call_path.as_slice(),
|
||||||
|
[
|
||||||
|
"mocker"
|
||||||
|
| "class_mocker"
|
||||||
|
| "module_mocker"
|
||||||
|
| "package_mocker"
|
||||||
|
| "session_mocker"
|
||||||
|
| "mock",
|
||||||
|
"patch",
|
||||||
|
"object"
|
||||||
|
] | ["unittest", "mock", "patch", "object"]
|
||||||
|
) {
|
||||||
|
check_patch_call(call, args, keywords, 2)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,7 +119,12 @@ impl AlwaysAutofixableViolation for DoubleNegation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DUNDER_METHODS: &[&str] = &["__eq__", "__ne__", "__lt__", "__le__", "__gt__", "__ge__"];
|
fn is_dunder_method(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"__eq__" | "__ne__" | "__lt__" | "__le__" | "__gt__" | "__ge__"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_exception_check(stmt: &Stmt) -> bool {
|
fn is_exception_check(stmt: &Stmt) -> bool {
|
||||||
let Stmt::If(ast::StmtIf {test: _, body, orelse: _, range: _ })= stmt else {
|
let Stmt::If(ast::StmtIf {test: _, body, orelse: _, range: _ })= stmt else {
|
||||||
|
@ -159,7 +164,7 @@ pub(crate) fn negation_with_equal_op(
|
||||||
| ScopeKind::AsyncFunction(ast::StmtAsyncFunctionDef { name, .. }) =
|
| ScopeKind::AsyncFunction(ast::StmtAsyncFunctionDef { name, .. }) =
|
||||||
&checker.semantic().scope().kind
|
&checker.semantic().scope().kind
|
||||||
{
|
{
|
||||||
if DUNDER_METHODS.contains(&name.as_str()) {
|
if is_dunder_method(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -211,7 +216,7 @@ pub(crate) fn negation_with_not_equal_op(
|
||||||
| ScopeKind::AsyncFunction(ast::StmtAsyncFunctionDef { name, .. }) =
|
| ScopeKind::AsyncFunction(ast::StmtAsyncFunctionDef { name, .. }) =
|
||||||
&checker.semantic().scope().kind
|
&checker.semantic().scope().kind
|
||||||
{
|
{
|
||||||
if DUNDER_METHODS.contains(&name.as_str()) {
|
if is_dunder_method(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ use rustpython_parser::ast::{Alias, Ranged};
|
||||||
|
|
||||||
use ruff_diagnostics::{Diagnostic, Violation};
|
use ruff_diagnostics::{Diagnostic, Violation};
|
||||||
use ruff_macros::{derive_message_formats, violation};
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_stdlib::future::ALL_FEATURE_NAMES;
|
use ruff_python_stdlib::future::is_feature_name;
|
||||||
|
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
|
|
||||||
|
@ -30,12 +30,14 @@ impl Violation for FutureFeatureNotDefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn future_feature_not_defined(checker: &mut Checker, alias: &Alias) {
|
pub(crate) fn future_feature_not_defined(checker: &mut Checker, alias: &Alias) {
|
||||||
if !ALL_FEATURE_NAMES.contains(&alias.name.as_str()) {
|
if is_feature_name(&alias.name) {
|
||||||
checker.diagnostics.push(Diagnostic::new(
|
return;
|
||||||
FutureFeatureNotDefined {
|
|
||||||
name: alias.name.to_string(),
|
|
||||||
},
|
|
||||||
alias.range(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checker.diagnostics.push(Diagnostic::new(
|
||||||
|
FutureFeatureNotDefined {
|
||||||
|
name: alias.name.to_string(),
|
||||||
|
},
|
||||||
|
alias.range(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,15 +94,13 @@ impl Violation for DeprecatedImport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A list of modules that may involve import rewrites.
|
/// Returns `true` if the module may contain deprecated imports.
|
||||||
const RELEVANT_MODULES: &[&str] = &[
|
fn is_relevant_module(module: &str) -> bool {
|
||||||
"collections",
|
matches!(
|
||||||
"pipes",
|
module,
|
||||||
"mypy_extensions",
|
"collections" | "pipes" | "mypy_extensions" | "typing_extensions" | "typing" | "typing.re"
|
||||||
"typing_extensions",
|
)
|
||||||
"typing",
|
}
|
||||||
"typing.re",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Members of `collections` that were moved to `collections.abc`.
|
// Members of `collections` that were moved to `collections.abc`.
|
||||||
const COLLECTIONS_TO_ABC: &[&str] = &[
|
const COLLECTIONS_TO_ABC: &[&str] = &[
|
||||||
|
@ -560,7 +558,7 @@ pub(crate) fn deprecated_import(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !RELEVANT_MODULES.contains(&module) {
|
if !is_relevant_module(module) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,37 +52,6 @@ impl AlwaysAutofixableViolation for UnnecessaryBuiltinImport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILTINS: &[&str] = &[
|
|
||||||
"*",
|
|
||||||
"ascii",
|
|
||||||
"bytes",
|
|
||||||
"chr",
|
|
||||||
"dict",
|
|
||||||
"filter",
|
|
||||||
"hex",
|
|
||||||
"input",
|
|
||||||
"int",
|
|
||||||
"isinstance",
|
|
||||||
"list",
|
|
||||||
"map",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"next",
|
|
||||||
"object",
|
|
||||||
"oct",
|
|
||||||
"open",
|
|
||||||
"pow",
|
|
||||||
"range",
|
|
||||||
"round",
|
|
||||||
"str",
|
|
||||||
"super",
|
|
||||||
"zip",
|
|
||||||
];
|
|
||||||
const IO: &[&str] = &["open"];
|
|
||||||
const SIX_MOVES_BUILTINS: &[&str] = BUILTINS;
|
|
||||||
const SIX: &[&str] = &["callable", "next"];
|
|
||||||
const SIX_MOVES: &[&str] = &["filter", "input", "map", "range", "zip"];
|
|
||||||
|
|
||||||
/// UP029
|
/// UP029
|
||||||
pub(crate) fn unnecessary_builtin_import(
|
pub(crate) fn unnecessary_builtin_import(
|
||||||
checker: &mut Checker,
|
checker: &mut Checker,
|
||||||
|
@ -90,26 +59,53 @@ pub(crate) fn unnecessary_builtin_import(
|
||||||
module: &str,
|
module: &str,
|
||||||
names: &[Alias],
|
names: &[Alias],
|
||||||
) {
|
) {
|
||||||
let deprecated_names = match module {
|
// Ignore irrelevant modules.
|
||||||
"builtins" => BUILTINS,
|
if !matches!(
|
||||||
"io" => IO,
|
module,
|
||||||
"six" => SIX,
|
"builtins" | "io" | "six" | "six.moves" | "six.moves.builtins"
|
||||||
"six.moves" => SIX_MOVES,
|
) {
|
||||||
"six.moves.builtins" => SIX_MOVES_BUILTINS,
|
return;
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Do this with a filter?
|
|
||||||
let mut unused_imports: Vec<&Alias> = vec![];
|
|
||||||
for alias in names {
|
|
||||||
if alias.asname.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if deprecated_names.contains(&alias.name.as_str()) {
|
|
||||||
unused_imports.push(alias);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identify unaliased, builtin imports.
|
||||||
|
let unused_imports: Vec<&Alias> = names
|
||||||
|
.iter()
|
||||||
|
.filter(|alias| alias.asname.is_none())
|
||||||
|
.filter(|alias| {
|
||||||
|
matches!(
|
||||||
|
(module, alias.name.as_str()),
|
||||||
|
(
|
||||||
|
"builtins" | "six.moves.builtins",
|
||||||
|
"*" | "ascii"
|
||||||
|
| "bytes"
|
||||||
|
| "chr"
|
||||||
|
| "dict"
|
||||||
|
| "filter"
|
||||||
|
| "hex"
|
||||||
|
| "input"
|
||||||
|
| "int"
|
||||||
|
| "isinstance"
|
||||||
|
| "list"
|
||||||
|
| "map"
|
||||||
|
| "max"
|
||||||
|
| "min"
|
||||||
|
| "next"
|
||||||
|
| "object"
|
||||||
|
| "oct"
|
||||||
|
| "open"
|
||||||
|
| "pow"
|
||||||
|
| "range"
|
||||||
|
| "round"
|
||||||
|
| "str"
|
||||||
|
| "super"
|
||||||
|
| "zip"
|
||||||
|
) | ("io", "open")
|
||||||
|
| ("six", "callable" | "next")
|
||||||
|
| ("six.moves", "filter" | "input" | "map" | "range" | "zip")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
if unused_imports.is_empty() {
|
if unused_imports.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
/// A copy of `__future__.all_feature_names`.
|
/// Returns `true` if `name` is a valid `__future__` feature name, as defined by
|
||||||
pub const ALL_FEATURE_NAMES: &[&str] = &[
|
/// `__future__.all_feature_names`.
|
||||||
"nested_scopes",
|
pub fn is_feature_name(name: &str) -> bool {
|
||||||
"generators",
|
matches!(
|
||||||
"division",
|
name,
|
||||||
"absolute_import",
|
"nested_scopes"
|
||||||
"with_statement",
|
| "generators"
|
||||||
"print_function",
|
| "division"
|
||||||
"unicode_literals",
|
| "absolute_import"
|
||||||
"barry_as_FLUFL",
|
| "with_statement"
|
||||||
"generator_stop",
|
| "print_function"
|
||||||
"annotations",
|
| "unicode_literals"
|
||||||
];
|
| "barry_as_FLUFL"
|
||||||
|
| "generator_stop"
|
||||||
|
| "annotations"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue