[Config] One writer for the declaration stash; no exception to the write seal (#38752)
This commit is contained in:
@@ -148,16 +148,19 @@ class TestTheModelConfigCache(CustomTestCase):
|
||||
second_checkpoint = self._checkpoint()
|
||||
|
||||
server_args = self._resolved(model_path=first_checkpoint)
|
||||
copy_ = server_args.replace_resolved(
|
||||
self.assertEqual(model_config_of(server_args).model_path, first_checkpoint)
|
||||
|
||||
# Declaring a new `model_path` moves what the memo is keyed on, so the
|
||||
# next read rebuilds rather than handing back a configuration that
|
||||
# describes the previous checkpoint.
|
||||
declare_resolution(
|
||||
server_args,
|
||||
"test_the_cache_refills_on_a_resolved_record",
|
||||
model_path=second_checkpoint,
|
||||
)
|
||||
|
||||
rebuilt = model_config_of(copy_)
|
||||
rebuilt = model_config_of(server_args)
|
||||
self.assertEqual(rebuilt.model_path, second_checkpoint)
|
||||
self.assertIs(model_config_of(copy_), rebuilt)
|
||||
# The parent keeps the configuration it resolved with.
|
||||
self.assertEqual(model_config_of(server_args).model_path, first_checkpoint)
|
||||
self.assertIs(model_config_of(server_args), rebuilt)
|
||||
|
||||
def test_a_supplied_configuration_is_handed_back(self):
|
||||
"""A configuration nothing in here built carries no key, so nothing
|
||||
|
||||
@@ -436,7 +436,7 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
|
||||
The parser detection and the LoRA normalization run at launcher stage --
|
||||
they need a tokenizer, a chat template, an adapter directory -- and they
|
||||
declare through `declare_late_resolution`. The declaration is the only
|
||||
declare through `declare_resolution`. The declaration is the only
|
||||
home for what they decide: the record keeps `--reasoning-parser auto`,
|
||||
and the bags a process publishes carry the detected parser.
|
||||
|
||||
@@ -444,14 +444,12 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
so its `resolve_once` re-runs and re-snapshots the raw input from
|
||||
already-late-resolved fields, which hides exactly this.
|
||||
"""
|
||||
from sglang.srt.arg_groups.overrides import declare_late_resolution
|
||||
from sglang.srt.arg_groups.overrides import declare_resolution
|
||||
from sglang.srt.runtime_context import get_serving, publish, reset_context
|
||||
|
||||
server_args = self._resolve({"reasoning_parser": "auto"})
|
||||
self.addCleanup(reset_context)
|
||||
declare_late_resolution(
|
||||
server_args, "template-detection", reasoning_parser="qwen3"
|
||||
)
|
||||
declare_resolution(server_args, "template-detection", reasoning_parser="qwen3")
|
||||
self.assertEqual(
|
||||
resolution_result(server_args, "reasoning_parser"),
|
||||
"qwen3",
|
||||
@@ -469,10 +467,10 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
|
||||
def test_pre_engine_late_resolution_reaches_the_projection(self):
|
||||
"""A launcher declaration survives the engine's first resolution pass."""
|
||||
from sglang.srt.arg_groups.overrides import declare_late_resolution
|
||||
from sglang.srt.arg_groups.overrides import declare_resolution
|
||||
|
||||
server_args = ServerArgs(model_path="dummy")
|
||||
declare_late_resolution(
|
||||
declare_resolution(
|
||||
server_args,
|
||||
"launcher",
|
||||
enable_forward_pass_metrics=True,
|
||||
@@ -695,11 +693,13 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
server_args.attention_backend = "triton"
|
||||
server_args.schedule_conservativeness = 0.5
|
||||
|
||||
from sglang.srt.arg_groups import pipeline as pipeline_module
|
||||
from sglang.srt import platforms as platforms_module
|
||||
|
||||
# The write capture runs in the dispatcher, so that is the namespace the
|
||||
# plugin has to be installed in.
|
||||
with unittest.mock.patch.object(pipeline_module, "current_platform", _Plugin()):
|
||||
# `handle_platform_defaults` imports `current_platform` when it runs, so
|
||||
# the platform module is the namespace to install the plugin in.
|
||||
with unittest.mock.patch.object(
|
||||
platforms_module, "current_platform", _Plugin()
|
||||
):
|
||||
server_args = self._resolve({})
|
||||
self.assertEqual(
|
||||
(
|
||||
@@ -738,7 +738,7 @@ class TestDeclaredValuesAreNotEditedLater(CustomTestCase):
|
||||
|
||||
The property is about the stash, so the seam is the stash: a list that
|
||||
snapshots on append. Every declaration path -- `declare_resolution`,
|
||||
`declare_late_resolution`, `declare_direct_writes` and the passes --
|
||||
`declare_resolution`, `record_foreign_defaults` and the passes --
|
||||
reaches it through `.append`, whatever it was imported as.
|
||||
"""
|
||||
recorded = []
|
||||
|
||||
@@ -35,7 +35,10 @@ import unittest.mock
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.arg_groups.overrides import model_config_of, resolution_result
|
||||
from sglang.srt.arg_groups.overrides import (
|
||||
declare_resolution,
|
||||
resolution_result,
|
||||
)
|
||||
from sglang.srt.environ import EnvField, envs
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils import is_cuda
|
||||
@@ -480,15 +483,15 @@ class TestResolutionIsReproducible(_RestoresProcessState, CustomTestCase):
|
||||
self.assertEqual(getattr(first, "_resolved_overrides", None), first_provenance)
|
||||
|
||||
|
||||
class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
class TestALateDeclarationKeepsTheResolution(_RestoresProcessState, CustomTestCase):
|
||||
"""A resolved record copied with `dataclasses.replace` loses what makes it
|
||||
resolved, and the next publish resolves it a second time -- over values it
|
||||
already decided. The Ray paths copy a resolved record to set
|
||||
`dist_init_addr`, which is how they reach this.
|
||||
already decided. The Ray paths declare `dist_init_addr` on a record that
|
||||
has already resolved, which is how they reach this.
|
||||
"""
|
||||
|
||||
def _resolved(self):
|
||||
config_dir = tempfile.mkdtemp(prefix="replace_resolved_")
|
||||
config_dir = tempfile.mkdtemp(prefix="late_declaration_")
|
||||
self.addCleanup(shutil.rmtree, config_dir, ignore_errors=True)
|
||||
with open(os.path.join(config_dir, "config.json"), "w") as handle:
|
||||
json.dump(_MINI_CONFIG, handle)
|
||||
@@ -509,9 +512,9 @@ class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
|
||||
`dataclasses.replace` copies the fields, so a bare copy re-runs
|
||||
resolution over the *same input* the parent got -- the DP-attention
|
||||
halving and the conservativeness scaling apply once. `replace_resolved`
|
||||
buys something else: it carries the parent's declarations and its
|
||||
`model_config`, so the copy answers without resolving at all.
|
||||
halving and the conservativeness scaling apply once. This is why the Ray
|
||||
paths declare on the record they were handed instead of copying it: the
|
||||
record arrives resolved, and a copy would throw that away.
|
||||
"""
|
||||
parent = self._resolved()
|
||||
bare = dataclasses.replace(parent, dist_init_addr="1.2.3.4:5000")
|
||||
@@ -537,56 +540,34 @@ class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
"reading its own output again",
|
||||
)
|
||||
|
||||
def test_replace_resolved_keeps_the_parents_resolution(self):
|
||||
parent = self._resolved()
|
||||
copy_ = parent.replace_resolved("ray.test", dist_init_addr="1.2.3.4:5000")
|
||||
self.assertTrue(getattr(copy_, "_resolution_finished", False))
|
||||
drifted = {
|
||||
field.name: (getattr(parent, field.name), getattr(copy_, field.name))
|
||||
for field in dataclasses.fields(parent)
|
||||
if field.name != "dist_init_addr"
|
||||
and getattr(parent, field.name) != getattr(copy_, field.name)
|
||||
}
|
||||
self.assertEqual(
|
||||
drifted,
|
||||
{},
|
||||
f"the copy differs from its parent beyond the change: {drifted}",
|
||||
)
|
||||
self.assertEqual(copy_.dist_init_addr, "1.2.3.4:5000")
|
||||
def test_a_late_change_leaves_the_rest_of_the_resolution_alone(self):
|
||||
"""What the Ray paths do: declare one field on a record that has already
|
||||
resolved, then hand it to the process that will publish it.
|
||||
|
||||
def test_the_copy_carries_what_resolution_left_on_the_record(self):
|
||||
"""Not just the stash and the flag.
|
||||
|
||||
`model_config_of()` memoizes on the record, and that cache is filled
|
||||
during resolution. A copy that is marked resolved but arrives without it
|
||||
cannot fill it -- the read-only guard refuses the cache write -- so the
|
||||
first `model_config_of()` raises. That is what killed the Ray
|
||||
schedulers, and it is why the carry is enumerated from the instance
|
||||
rather than from a list of names.
|
||||
The record stays resolved, so nothing re-derives; the field stays the
|
||||
operator's input, because resolution does not write fields; and the
|
||||
decision is what `resolution_result` answers.
|
||||
"""
|
||||
parent = self._resolved()
|
||||
copy_ = parent.replace_resolved("ray.test", dist_init_addr="1.2.3.4:5000")
|
||||
fields = {field.name for field in dataclasses.fields(parent)}
|
||||
missing = sorted(
|
||||
name
|
||||
for name in vars(parent)
|
||||
if name not in fields and name not in vars(copy_)
|
||||
)
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
f"the copy did not carry what resolution left on the record: {missing}",
|
||||
)
|
||||
self.assertIsNotNone(model_config_of(copy_))
|
||||
# Containers are copied, so the copy's declaration stays with it.
|
||||
self.assertEqual(
|
||||
len(parent._resolved_overrides) + 1, len(copy_._resolved_overrides)
|
||||
declare_resolution(parent, "ray.test", dist_init_addr="1.2.3.4:5000")
|
||||
|
||||
self.assertTrue(getattr(parent, "_resolution_finished", False))
|
||||
self.assertIsNone(
|
||||
parent.dist_init_addr,
|
||||
"the declaration wrote the field; the record is the operator's input",
|
||||
)
|
||||
self.assertEqual(resolution_result(parent, "dist_init_addr"), "1.2.3.4:5000")
|
||||
|
||||
def test_the_change_reaches_the_bags(self):
|
||||
"""The projection reads the raw snapshot plus the declarations, so a
|
||||
change the copy only wrote to the field would publish the parent's raw
|
||||
value."""
|
||||
change written only to the field would publish the raw value instead.
|
||||
|
||||
This is the Ray hop: the actor receives the record by pickle, declares
|
||||
its own `dist_init_addr`, and publishes. Nothing else may move --
|
||||
publishing must not re-run resolution.
|
||||
"""
|
||||
import pickle
|
||||
|
||||
from sglang.srt.runtime_context import (
|
||||
get_parallel,
|
||||
get_schedule,
|
||||
@@ -595,16 +576,17 @@ class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
)
|
||||
|
||||
parent = self._resolved()
|
||||
copy_ = parent.replace_resolved("ray.test", dist_init_addr="1.2.3.4:5000")
|
||||
arrived = pickle.loads(pickle.dumps(parent))
|
||||
declare_resolution(arrived, "ray.test", dist_init_addr="1.2.3.4:5000")
|
||||
self.addCleanup(reset_context)
|
||||
reset_context()
|
||||
publish(copy_, role="scheduler")
|
||||
publish(arrived, role="scheduler")
|
||||
self.assertEqual(get_parallel().dist_init_addr, "1.2.3.4:5000")
|
||||
self.assertEqual(
|
||||
get_schedule().chunked_prefill_size,
|
||||
resolution_result(parent, "chunked_prefill_size"),
|
||||
"publishing the copy re-ran resolution; the bag disagrees with what "
|
||||
"the parent's resolution decided",
|
||||
"publishing re-ran resolution; the bag disagrees with what the "
|
||||
"parent's resolution decided",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -86,8 +86,7 @@ def _field_reads(fn, holders):
|
||||
_DECLARERS = frozenset(
|
||||
{
|
||||
"declare_resolution",
|
||||
"declare_late_resolution",
|
||||
"declare_direct_writes",
|
||||
"record_foreign_defaults",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -271,7 +270,7 @@ def _record_aliases(function):
|
||||
aliases.add(target.id)
|
||||
elif (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr in ("from_cli_args", "replace_resolved")
|
||||
and func.attr == "from_cli_args"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "ServerArgs"
|
||||
):
|
||||
|
||||
@@ -2886,17 +2886,32 @@ class TestTheInputIsSealedDuringResolution(CustomTestCase):
|
||||
with self.assertRaisesRegex(AttributeError, "after resolution"):
|
||||
server_args.tp_size = 4
|
||||
|
||||
def test_the_named_exception_lifts_it(self):
|
||||
"""`declare_direct_writes` hands the record to an out-of-tree platform
|
||||
plugin that sets fields on it; that is the only channel."""
|
||||
from sglang.srt.server_args import record_writable
|
||||
def test_it_has_no_exception(self):
|
||||
"""A resolver from outside this tree assigns fields -- an interface this
|
||||
tree does not own -- and it still does not reach the record.
|
||||
|
||||
`record_foreign_defaults` hands it a stand-in: the assignment is
|
||||
captured and declared, the field keeps the operator's input, and the
|
||||
seal stays armed for the whole call. There used to be a named lift for
|
||||
this, which made the record the one thing resolution could write.
|
||||
"""
|
||||
from sglang.srt.arg_groups.overrides import (
|
||||
record_foreign_defaults,
|
||||
resolution_result,
|
||||
)
|
||||
|
||||
server_args = ServerArgs(model_path="dummy", device="cuda")
|
||||
object.__setattr__(server_args, "_input_frozen", True)
|
||||
with record_writable(server_args):
|
||||
server_args.tp_size = 4
|
||||
self.assertEqual(server_args.tp_size, 4)
|
||||
# and it goes back on afterwards
|
||||
|
||||
def foreign(config):
|
||||
# What a plugin does: read what is decided, assign a default.
|
||||
assert config.tp_size == 1
|
||||
config.tp_size = 4
|
||||
|
||||
record_foreign_defaults(server_args, "platform:probe", foreign)
|
||||
|
||||
self.assertEqual(resolution_result(server_args, "tp_size"), 4)
|
||||
self.assertEqual(server_args.tp_size, 1, "the record is the input")
|
||||
with self.assertRaisesRegex(AttributeError, "during resolution"):
|
||||
server_args.tp_size = 8
|
||||
|
||||
@@ -2942,15 +2957,6 @@ class TestLaunchCommand(CustomTestCase):
|
||||
server_args.launch_command,
|
||||
)
|
||||
|
||||
def test_a_copy_keeps_it(self):
|
||||
"""`replace_resolved` is how the Ray paths rewrite `dist_init_addr`;
|
||||
the copy was launched by whatever launched its parent."""
|
||||
server_args = prepare_server_args(["--model-path", "/tmp/x"])
|
||||
self.assertEqual(
|
||||
server_args.replace_resolved("test").launch_command,
|
||||
server_args.launch_command,
|
||||
)
|
||||
|
||||
def test_it_is_not_a_config_field(self):
|
||||
"""It describes how the configuration was asked for, so it is not part
|
||||
of the configuration: no CLI flag, no namespace, not in the bags."""
|
||||
|
||||
@@ -40,7 +40,7 @@ _OWNERS = ("server_args.py", "runtime_context.py", "arg_groups/")
|
||||
# startup default wherever it is written, and `benchmark/` ships too.
|
||||
_READS_SCANNED = _PACKAGE
|
||||
|
||||
_DECLARERS = ("declare_resolution", "declare_late_resolution")
|
||||
_DECLARERS = ("declare_resolution",)
|
||||
|
||||
|
||||
def _declared_by_keyword():
|
||||
@@ -239,27 +239,6 @@ def _declared_by_registry_and_passes():
|
||||
return fields
|
||||
|
||||
|
||||
def _declared_by_late_resolution():
|
||||
"""Keywords of `declare_late_resolution(record, ...)`, the late spelling.
|
||||
|
||||
The fields sit at the call sites rather than in the declarer, so a scan
|
||||
that only knew the declarer's own definition would find none of them.
|
||||
"""
|
||||
# The record plus `arg_groups/`: a hook calls it on the record it was
|
||||
# handed, so scanning the record's file alone finds nothing.
|
||||
sources = [_SRT / "server_args.py", *sorted((_SRT / "arg_groups").rglob("*.py"))]
|
||||
fields = set()
|
||||
for source in sources:
|
||||
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8-sig"))):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "declare_late_resolution"
|
||||
):
|
||||
fields |= {keyword.arg for keyword in node.keywords if keyword.arg}
|
||||
return fields
|
||||
|
||||
|
||||
def _written_after_publish():
|
||||
"""Fields the runtime overrides once the bags exist.
|
||||
|
||||
@@ -286,7 +265,6 @@ def _resolution_written():
|
||||
return (
|
||||
_declared_by_keyword()
|
||||
| _declared_by_registry_and_passes()
|
||||
| _declared_by_late_resolution()
|
||||
| _written_after_publish()
|
||||
)
|
||||
|
||||
@@ -561,7 +539,6 @@ class TestNoChainReadsOfResolvedConfig(CustomTestCase):
|
||||
|
||||
by_keyword = _declared_by_keyword()
|
||||
by_data = _declared_by_registry_and_passes()
|
||||
by_late = _declared_by_late_resolution()
|
||||
|
||||
self.assertGreater(
|
||||
len(by_keyword),
|
||||
@@ -586,24 +563,10 @@ class TestNoChainReadsOfResolvedConfig(CustomTestCase):
|
||||
f"{len(overrides.POST_PROCESS_PASSES)} passes; the scan of the "
|
||||
"dict-key channel broke",
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
len(by_late),
|
||||
3,
|
||||
f"only {len(by_late)} fields are declared late; the "
|
||||
"`declare_late_resolution` keyword scan broke",
|
||||
)
|
||||
# The data channel is not the keyword scan's subset: if it became one,
|
||||
# that scan would be doing all the work and a regression here would be
|
||||
# invisible. The late channel *is* a subset, and deliberately so --
|
||||
# `declare_late_resolution` is a keyword declarer like the others now
|
||||
# that the record hosts no forwarding member, so its own floor above is
|
||||
# what pins it.
|
||||
# invisible.
|
||||
self.assertTrue(by_data - by_keyword, "the data channel adds nothing")
|
||||
self.assertTrue(
|
||||
by_late <= by_keyword,
|
||||
"late resolution declares outside the keyword channel; it is the "
|
||||
"same spelling, so the two cannot disagree",
|
||||
)
|
||||
|
||||
def test_nothing_reads_a_resolved_field_off_a_borrowed_record(self):
|
||||
found = _chain_reads(_resolution_written())
|
||||
|
||||
@@ -112,7 +112,7 @@ _MATRIX = (
|
||||
{"enable_mis": True, "attention_backend": "flashinfer"},
|
||||
)
|
||||
|
||||
# `declare_late_resolution` call sites whose keyword expansion is built
|
||||
# `declare_resolution` call sites whose keyword expansion is built
|
||||
# dynamically; the written fields are spelled out here and drift-guarded.
|
||||
_LATE_RESOLUTION_DYNAMIC_SITES = {
|
||||
"parser/template_detection.py": frozenset({"reasoning_parser", "tool_call_parser"}),
|
||||
@@ -341,10 +341,10 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
union does not depend on matrix order; and the ambient CI marker is
|
||||
cleared, so a runner's identity cannot leak into the measurement --
|
||||
the CI-conditioned writes come from `_ENV_MATRIX`'s explicit entry.
|
||||
Late resolution counts too: `declare_late_resolution` writers run at
|
||||
launcher stage (LoRA normalization, parser auto-detection), so their
|
||||
target fields are collected statically from the call sites -- they are
|
||||
resolution writes by definition, just staged after `__post_init__`.
|
||||
Declarers outside `arg_groups/` count too: the parser auto-detection
|
||||
runs at launcher stage and the NPU helper is called by the pipeline, so
|
||||
their target fields are collected statically from the call sites --
|
||||
resolution writes by definition, just not reached by the matrix.
|
||||
"""
|
||||
pristine = (dict(os.environ), self._env_field_flags())
|
||||
written = set()
|
||||
@@ -383,7 +383,7 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
for extra, env in _ENV_MATRIX:
|
||||
resolve_one(extra, env)
|
||||
self._restore_process_state(pristine)
|
||||
written |= self._late_resolution_written_fields()
|
||||
written |= self._declared_outside_the_pipeline()
|
||||
written |= self._hook_assignment_targets()
|
||||
written |= self._record_method_assignment_targets()
|
||||
written |= self._declarative_override_fields()
|
||||
@@ -607,22 +607,32 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
fields.add(key.value)
|
||||
return fields
|
||||
|
||||
def _late_resolution_written_fields(self) -> set:
|
||||
"""Fields `declare_late_resolution` writes, collected statically.
|
||||
def _declared_outside_the_pipeline(self) -> set:
|
||||
"""Fields declared by a `declare_resolution` caller outside
|
||||
`arg_groups/`, collected statically.
|
||||
|
||||
These are resolution's launcher-stage writes (they need a tokenizer or
|
||||
adapter load, so they cannot run in `__post_init__`), which the
|
||||
construct-and-diff pass above never sees. The keywords at the call
|
||||
sites are the written fields; an expansion this cannot resolve fails
|
||||
loudly like the override collector's, except the named dynamic sites
|
||||
below, whose field sets are spelled out and drift-guarded (each name
|
||||
must still appear as a constant in the file)."""
|
||||
Resolution's launcher-stage writes live here -- the auto-detected
|
||||
parsers need a tokenizer or chat-template load, so they cannot run in
|
||||
`__post_init__` -- alongside the NPU default helper and the expert-pack
|
||||
loader, which the pipeline calls the same way. The construct-and-diff
|
||||
pass above never sees any of them.
|
||||
|
||||
`arg_groups/` is deliberately excluded: `_hook_assignment_targets`
|
||||
covers it exactly, and it resolves the pipeline's own computed
|
||||
expansions (`record_foreign_defaults` declares a `**` dict this
|
||||
collector's resolver cannot read). The keywords at the call sites are
|
||||
the written fields; an expansion this cannot resolve fails loudly like
|
||||
the override collector's, except the named dynamic sites below, whose
|
||||
field sets are spelled out and drift-guarded (each name must still
|
||||
appear as a constant in the file)."""
|
||||
written = set()
|
||||
root = _PACKAGE_ROOT
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
rel = path.relative_to(root).as_posix()
|
||||
if rel.startswith("arg_groups/"):
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if "declare_late_resolution" not in source:
|
||||
if "declare_resolution" not in source:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
@@ -634,12 +644,12 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
and (
|
||||
(
|
||||
isinstance(node.func, ast.Name)
|
||||
and node.func.id == "declare_late_resolution"
|
||||
and node.func.id == "declare_resolution"
|
||||
)
|
||||
or (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr
|
||||
in ("declare_late_resolution", "_late_resolution")
|
||||
in ("declare_resolution", "_declare_resolution")
|
||||
)
|
||||
)
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user