1891 lines
81 KiB
Python
1891 lines
81 KiB
Python
"""Static checks for Python state shared between AnySystem processes.
|
|||
|
|
|
||
|
|
The checker parses source files only. It never imports or executes a student's
|
||
|
|
solution. Local imports which can be resolved next to the entrypoint are checked
|
||
|
|
recursively; calls through external imports are limited to explicitly reviewed APIs.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import ast
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Callable, Iterable
|
||
|
|
|
||
|
|
|
||
|
|
MUTABLE_NODES = (
|
||
|
|
ast.List,
|
||
|
|
ast.ListComp,
|
||
|
|
ast.Dict,
|
||
|
|
ast.DictComp,
|
||
|
|
ast.Set,
|
||
|
|
ast.SetComp,
|
||
|
|
ast.GeneratorExp,
|
||
|
|
)
|
||
|
|
MUTABLE_CONSTRUCTORS = {
|
||
|
|
"builtins.bytearray",
|
||
|
|
"builtins.dict",
|
||
|
|
"builtins.list",
|
||
|
|
"builtins.set",
|
||
|
|
"collections.ChainMap",
|
||
|
|
"collections.Counter",
|
||
|
|
"collections.OrderedDict",
|
||
|
|
"collections.defaultdict",
|
||
|
|
"collections.deque",
|
||
|
|
"weakref.WeakKeyDictionary",
|
||
|
|
"weakref.WeakValueDictionary",
|
||
|
|
}
|
||
|
|
IMMUTABLE_SCALAR_CONSTRUCTORS = {
|
||
|
|
"builtins.bool",
|
||
|
|
"builtins.bytes",
|
||
|
|
"builtins.complex",
|
||
|
|
"builtins.float",
|
||
|
|
"builtins.int",
|
||
|
|
"builtins.range",
|
||
|
|
"builtins.str",
|
||
|
|
}
|
||
|
|
KNOWN_IMMUTABLE_CALLS = {
|
||
|
|
"os.getcwd",
|
||
|
|
"os.path.abspath",
|
||
|
|
"os.path.basename",
|
||
|
|
"os.path.dirname",
|
||
|
|
"os.path.join",
|
||
|
|
"os.path.normpath",
|
||
|
|
"os.path.realpath",
|
||
|
|
"random.randint",
|
||
|
|
"random.randrange",
|
||
|
|
}
|
||
|
|
TYPE_SUBSCRIPT_NAMES = {
|
||
|
|
"builtins.dict",
|
||
|
|
"builtins.frozenset",
|
||
|
|
"builtins.list",
|
||
|
|
"builtins.set",
|
||
|
|
"builtins.tuple",
|
||
|
|
"builtins.type",
|
||
|
|
"collections.abc.Mapping",
|
||
|
|
"collections.abc.Sequence",
|
||
|
|
"typing.Annotated",
|
||
|
|
"typing.Callable",
|
||
|
|
"typing.ClassVar",
|
||
|
|
"typing.Dict",
|
||
|
|
"typing.FrozenSet",
|
||
|
|
"typing.List",
|
||
|
|
"typing.Literal",
|
||
|
|
"typing.Mapping",
|
||
|
|
"typing.Optional",
|
||
|
|
"typing.Sequence",
|
||
|
|
"typing.Set",
|
||
|
|
"typing.Tuple",
|
||
|
|
"typing.Type",
|
||
|
|
"typing.Union",
|
||
|
|
}
|
||
|
|
TYPE_FACTORIES = {
|
||
|
|
"collections.namedtuple",
|
||
|
|
"typing.NewType",
|
||
|
|
"typing.ParamSpec",
|
||
|
|
"typing.TypeVar",
|
||
|
|
}
|
||
|
|
ENUM_BASE_NAMES = {
|
||
|
|
"enum.Enum",
|
||
|
|
"enum.Flag",
|
||
|
|
"enum.IntEnum",
|
||
|
|
"enum.IntFlag",
|
||
|
|
"enum.StrEnum",
|
||
|
|
}
|
||
|
|
MUTATOR_METHODS = frozenset(
|
||
|
|
"__delitem__ __setitem__ add append clear discard extend insert move_to_end "
|
||
|
|
"pop popitem remove reverse rotate setdefault sort update".split()
|
||
|
|
)
|
||
|
|
UNBOUND_MUTATOR_METHODS = {"__delattr__", "__delitem__", "__setattr__", "__setitem__"}
|
||
|
|
OPERATOR_MUTATORS = frozenset(
|
||
|
|
f"operator.{name}"
|
||
|
|
for name in "delitem iadd iand iconcat imatmul imul ior isub ixor setitem".split()
|
||
|
|
)
|
||
|
|
STATEFUL_DECORATORS = {"functools.cache", "functools.lru_cache"}
|
||
|
|
# Calls through imported objects are denied unless explicitly reviewed for the
|
||
|
|
# course. This is deliberately an allowlist: method names alone cannot
|
||
|
|
# distinguish random.seed() from APIs which construct values, transform their
|
||
|
|
# arguments, or provide the limited random operations used by the assignments.
|
||
|
|
ALLOWED_IMPORTED_CALLS = frozenset(
|
||
|
|
"""
|
||
|
|
anysystem.Message anysystem.Message.from_json anysystem.python.anysystem.Message
|
||
|
|
array.array ast.literal_eval
|
||
|
|
bisect.bisect bisect.bisect_left bisect.bisect_right bisect.insort builtins.print
|
||
|
|
collections.defaultdict collections.deque collections.namedtuple copy.copy copy.deepcopy
|
||
|
|
dataclasses.asdict dataclasses.field dataclasses.fields dataclasses.is_dataclass
|
||
|
|
functools.cmp_to_key functools.reduce
|
||
|
|
hashlib.blake2b hashlib.md5 hashlib.sha1 hashlib.sha512 hashlib.sha256
|
||
|
|
hashlib.sha3_256 hashlib.shake_256
|
||
|
|
heapq.heapify heapq.heappop heapq.heappush heapq.merge importlib.import_module
|
||
|
|
inspect.signature itertools.count json.dumps json.loads
|
||
|
|
logging.critical logging.debug logging.error logging.exception logging.info logging.log
|
||
|
|
logging.warning math.ceil math.log math.log2 math.sqrt
|
||
|
|
os.getcwd os.getenv os.path.abspath os.path.basename os.path.dirname os.path.join
|
||
|
|
os.path.normpath os.path.realpath pathlib.Path pickle.dumps pickle.loads
|
||
|
|
portion.closedopen portion.empty pprint.pprint pydantic.Field
|
||
|
|
random.Random random.choice random.choices random.randint random.random random.randrange
|
||
|
|
random.sample random.shuffle random.uniform re.fullmatch sys.intern threading.Lock
|
||
|
|
types.MappingProxyType time.time typing.cast uuid.uuid4
|
||
|
|
""".split()
|
||
|
|
)
|
||
|
|
IMPORTED_ARGUMENT_MUTATORS = set(
|
||
|
|
"bisect.insort heapq.heapify heapq.heappop heapq.heappush random.shuffle".split()
|
||
|
|
)
|
||
|
|
ALLOWED_MUTABLE_METADATA = {"__all__"}
|
||
|
|
TRUSTED_STAR_EXPORTS = {
|
||
|
|
"dataclasses": {"dataclass", "field"},
|
||
|
|
"enum": {"Enum", "Flag", "IntEnum", "IntFlag", "StrEnum", "auto"},
|
||
|
|
"functools": {"cache", "lru_cache"},
|
||
|
|
"types": {"MappingProxyType"},
|
||
|
|
"typing": {
|
||
|
|
*"Annotated Any Callable ClassVar Dict FrozenSet List Literal Mapping NewType "
|
||
|
|
"Optional ParamSpec Sequence Set Tuple Type TypeVar Union".split(),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
TRUSTED_BUILTINS = set(
|
||
|
|
"bool bytearray bytes complex dict float frozenset int list range set str tuple type".split()
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _resolved_attribute(node: ast.AST) -> tuple[ast.AST, str] | None:
|
||
|
|
if isinstance(node, ast.Attribute):
|
||
|
|
return node.value, node.attr
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "getattr"
|
||
|
|
and len(node.args) >= 2
|
||
|
|
and isinstance(node.args[1], ast.Constant)
|
||
|
|
and isinstance(node.args[1].value, str)
|
||
|
|
):
|
||
|
|
return node.args[0], node.args[1].value
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _call_name(node: ast.AST) -> str | None:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return node.id
|
||
|
|
resolved = _resolved_attribute(node)
|
||
|
|
return resolved[1] if resolved is not None else None
|
||
|
|
|
||
|
|
|
||
|
|
def _symbol(node: ast.AST, symbols: dict[str, str]) -> str | None:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return symbols.get(node.id)
|
||
|
|
resolved = _resolved_attribute(node)
|
||
|
|
if resolved is not None:
|
||
|
|
value, attribute = resolved
|
||
|
|
parent = _symbol(value, symbols)
|
||
|
|
return f"{parent}.{attribute}" if parent is not None else None
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _root_name(node: ast.AST) -> str | None:
|
||
|
|
while (resolved := _resolved_attribute(node)) is not None:
|
||
|
|
node = resolved[0]
|
||
|
|
return node.id if isinstance(node, ast.Name) else None
|
||
|
|
|
||
|
|
|
||
|
|
def _assignment_parts(node: ast.AST) -> tuple[ast.AST, list[ast.AST]] | None:
|
||
|
|
if isinstance(node, ast.Assign):
|
||
|
|
return node.value, node.targets
|
||
|
|
if isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None:
|
||
|
|
return node.value, [node.target]
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _call_returns_shared(
|
||
|
|
node: ast.AST,
|
||
|
|
is_shared: Callable[[ast.AST], bool],
|
||
|
|
shared_functions: set[str],
|
||
|
|
returned_parameters: dict[str, set[int]],
|
||
|
|
) -> bool:
|
||
|
|
if not isinstance(node, ast.Call):
|
||
|
|
return False
|
||
|
|
name = node.func.id if isinstance(node.func, ast.Name) else None
|
||
|
|
if name in shared_functions:
|
||
|
|
return True
|
||
|
|
return any(
|
||
|
|
index < len(node.args) and is_shared(node.args[index])
|
||
|
|
for index in returned_parameters.get(name or "", set())
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_instance_path(node: ast.AST, instance_names: set[str]) -> bool:
|
||
|
|
while True:
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
node = node.value
|
||
|
|
elif (resolved := _resolved_attribute(node)) is not None:
|
||
|
|
node = resolved[0]
|
||
|
|
else:
|
||
|
|
break
|
||
|
|
return isinstance(node, ast.Name) and node.id in instance_names
|
||
|
|
|
||
|
|
|
||
|
|
def _static_string(node: ast.AST, names: dict[str, str]) -> str | None:
|
||
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||
|
|
return node.value
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return names.get(node.id)
|
||
|
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||
|
|
left = _static_string(node.left, names)
|
||
|
|
right = _static_string(node.right, names)
|
||
|
|
return left + right if left is not None and right is not None else None
|
||
|
|
if isinstance(node, ast.JoinedStr):
|
||
|
|
parts: list[str] = []
|
||
|
|
for value in node.values:
|
||
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
||
|
|
parts.append(value.value)
|
||
|
|
elif isinstance(value, ast.FormattedValue):
|
||
|
|
part = _static_string(value.value, names)
|
||
|
|
if part is None:
|
||
|
|
return None
|
||
|
|
parts.append(part)
|
||
|
|
else:
|
||
|
|
return None
|
||
|
|
return "".join(parts)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _static_string_values(tree: ast.Module) -> dict[str, str]:
|
||
|
|
assignments: list[tuple[str, ast.AST]] = []
|
||
|
|
for node in ast.walk(tree):
|
||
|
|
if isinstance(node, ast.Assign):
|
||
|
|
for target in node.targets:
|
||
|
|
assignments.extend((name, node.value) for name in _target_names(target))
|
||
|
|
elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None:
|
||
|
|
assignments.extend((name, node.value) for name in _target_names(node.target))
|
||
|
|
|
||
|
|
values: dict[str, str] = {}
|
||
|
|
ambiguous: set[str] = set()
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for name, value_node in assignments:
|
||
|
|
value = _static_string(value_node, values)
|
||
|
|
if value is None or name in ambiguous:
|
||
|
|
continue
|
||
|
|
if name in values and values[name] != value:
|
||
|
|
values.pop(name)
|
||
|
|
ambiguous.add(name)
|
||
|
|
changed = True
|
||
|
|
elif name not in values:
|
||
|
|
values[name] = value
|
||
|
|
changed = True
|
||
|
|
return values
|
||
|
|
|
||
|
|
|
||
|
|
def _dynamic_import_names(tree: ast.Module) -> tuple[set[str], set[str]]:
|
||
|
|
modules = {"importlib"}
|
||
|
|
functions: set[str] = set()
|
||
|
|
for node in ast.walk(tree):
|
||
|
|
if isinstance(node, ast.Import):
|
||
|
|
for alias in node.names:
|
||
|
|
if alias.name == "importlib":
|
||
|
|
modules.add(alias.asname or "importlib")
|
||
|
|
elif isinstance(node, ast.ImportFrom) and node.module == "importlib":
|
||
|
|
for alias in node.names:
|
||
|
|
if alias.name == "import_module":
|
||
|
|
functions.add(alias.asname or alias.name)
|
||
|
|
assignments = [node for node in ast.walk(tree) if isinstance(node, ast.Assign)]
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for assignment in assignments:
|
||
|
|
names = set().union(*(_target_names(target) for target in assignment.targets))
|
||
|
|
if isinstance(assignment.value, ast.Name):
|
||
|
|
if assignment.value.id in modules:
|
||
|
|
before = len(modules)
|
||
|
|
modules.update(names)
|
||
|
|
changed |= len(modules) != before
|
||
|
|
if assignment.value.id in functions:
|
||
|
|
before = len(functions)
|
||
|
|
functions.update(names)
|
||
|
|
changed |= len(functions) != before
|
||
|
|
elif (
|
||
|
|
isinstance(assignment.value, ast.Attribute)
|
||
|
|
and assignment.value.attr == "import_module"
|
||
|
|
and isinstance(assignment.value.value, ast.Name)
|
||
|
|
and assignment.value.value.id in modules
|
||
|
|
):
|
||
|
|
before = len(functions)
|
||
|
|
functions.update(names)
|
||
|
|
changed |= len(functions) != before
|
||
|
|
return modules, functions
|
||
|
|
|
||
|
|
|
||
|
|
def _is_dynamic_import_call(
|
||
|
|
node: ast.Call, importlib_modules: set[str], import_module_functions: set[str]
|
||
|
|
) -> bool:
|
||
|
|
return (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and (node.func.id == "__import__" or node.func.id in import_module_functions)
|
||
|
|
) or (
|
||
|
|
(resolved := _resolved_attribute(node.func)) is not None
|
||
|
|
and resolved[1] == "import_module"
|
||
|
|
and isinstance(resolved[0], ast.Name)
|
||
|
|
and resolved[0].id in importlib_modules
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _class_has_mutation_escape(class_node: ast.ClassDef) -> bool:
|
||
|
|
for statement in class_node.body:
|
||
|
|
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
continue
|
||
|
|
positional = [*statement.args.posonlyargs, *statement.args.args]
|
||
|
|
if not positional:
|
||
|
|
continue
|
||
|
|
instance_names = {positional[0].arg}
|
||
|
|
for node in _function_nodes(statement):
|
||
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete)):
|
||
|
|
targets = (
|
||
|
|
node.targets
|
||
|
|
if isinstance(node, (ast.Assign, ast.Delete))
|
||
|
|
else [node.target]
|
||
|
|
)
|
||
|
|
if any(_is_instance_path(target, instance_names) for target in targets):
|
||
|
|
return True
|
||
|
|
if not isinstance(node, ast.Call):
|
||
|
|
continue
|
||
|
|
if (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in {"delattr", "setattr"}
|
||
|
|
and node.args
|
||
|
|
and _is_instance_path(node.args[0], instance_names)
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
if (
|
||
|
|
(resolved := _resolved_attribute(node.func)) is not None
|
||
|
|
and resolved[1] in UNBOUND_MUTATOR_METHODS
|
||
|
|
and node.args
|
||
|
|
and _is_instance_path(node.args[0], instance_names)
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
if (
|
||
|
|
(resolved := _resolved_attribute(node.func)) is not None
|
||
|
|
and resolved[1] in MUTATOR_METHODS
|
||
|
|
and _is_instance_path(resolved[0], instance_names)
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _target_names(node: ast.AST) -> set[str]:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return {node.id}
|
||
|
|
if isinstance(node, (ast.Tuple, ast.List)):
|
||
|
|
result: set[str] = set()
|
||
|
|
for element in node.elts:
|
||
|
|
result.update(_target_names(element))
|
||
|
|
return result
|
||
|
|
return set()
|
||
|
|
|
||
|
|
|
||
|
|
def _dict_is_deeply_immutable(
|
||
|
|
node: ast.Dict, safe_names: set[str], symbols: dict[str, str]
|
||
|
|
) -> bool:
|
||
|
|
return all(
|
||
|
|
key is not None
|
||
|
|
and _is_immutable_expr(key, safe_names, symbols)
|
||
|
|
and _is_immutable_expr(value, safe_names, symbols)
|
||
|
|
for key, value in zip(node.keys, node.values)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _sequence_is_deeply_immutable(
|
||
|
|
node: ast.List | ast.Set | ast.Tuple,
|
||
|
|
safe_names: set[str],
|
||
|
|
symbols: dict[str, str],
|
||
|
|
) -> bool:
|
||
|
|
return all(_is_immutable_expr(element, safe_names, symbols) for element in node.elts)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_immutable_expr(
|
||
|
|
node: ast.AST | None, safe_names: set[str], symbols: dict[str, str]
|
||
|
|
) -> bool:
|
||
|
|
if node is None or isinstance(node, ast.Constant):
|
||
|
|
return True
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return node.id in safe_names
|
||
|
|
if isinstance(node, ast.Attribute):
|
||
|
|
root = _root_name(node)
|
||
|
|
if root is not None and root in symbols and symbols[root] != f"builtins.{root}":
|
||
|
|
return node.attr.isupper()
|
||
|
|
return _is_immutable_expr(node.value, safe_names, symbols)
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return _symbol(node.value, symbols) in TYPE_SUBSCRIPT_NAMES
|
||
|
|
if isinstance(node, ast.Tuple):
|
||
|
|
return _sequence_is_deeply_immutable(node, safe_names, symbols)
|
||
|
|
if isinstance(node, ast.UnaryOp):
|
||
|
|
return _is_immutable_expr(node.operand, safe_names, symbols)
|
||
|
|
if isinstance(node, ast.BinOp):
|
||
|
|
return _is_immutable_expr(node.left, safe_names, symbols) and _is_immutable_expr(
|
||
|
|
node.right, safe_names, symbols
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.BoolOp):
|
||
|
|
return all(_is_immutable_expr(value, safe_names, symbols) for value in node.values)
|
||
|
|
if isinstance(node, ast.Compare):
|
||
|
|
return _is_immutable_expr(node.left, safe_names, symbols) and all(
|
||
|
|
_is_immutable_expr(value, safe_names, symbols) for value in node.comparators
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.IfExp):
|
||
|
|
return _is_immutable_expr(node.body, safe_names, symbols) and _is_immutable_expr(
|
||
|
|
node.orelse, safe_names, symbols
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.JoinedStr):
|
||
|
|
return all(
|
||
|
|
not isinstance(value, ast.FormattedValue)
|
||
|
|
or _is_immutable_expr(value.value, safe_names, symbols)
|
||
|
|
for value in node.values
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.Lambda):
|
||
|
|
return True
|
||
|
|
if isinstance(node, ast.Call):
|
||
|
|
name = _symbol(node.func, symbols)
|
||
|
|
if name in TYPE_FACTORIES:
|
||
|
|
return True
|
||
|
|
local_name = _call_name(node.func)
|
||
|
|
if local_name is not None and f"frozen-dataclass:{local_name}" in safe_names:
|
||
|
|
return all(
|
||
|
|
_is_immutable_expr(arg, safe_names, symbols) for arg in node.args
|
||
|
|
) and all(
|
||
|
|
_is_immutable_expr(keyword.value, safe_names, symbols)
|
||
|
|
for keyword in node.keywords
|
||
|
|
)
|
||
|
|
if name in IMMUTABLE_SCALAR_CONSTRUCTORS:
|
||
|
|
return all(_is_immutable_expr(arg, safe_names, symbols) for arg in node.args)
|
||
|
|
if name in KNOWN_IMMUTABLE_CALLS:
|
||
|
|
return True
|
||
|
|
if name in {"builtins.tuple", "builtins.frozenset"}:
|
||
|
|
if not node.args:
|
||
|
|
return True
|
||
|
|
if len(node.args) != 1 or node.keywords:
|
||
|
|
return False
|
||
|
|
value = node.args[0]
|
||
|
|
return isinstance(value, (ast.List, ast.Set, ast.Tuple)) and (
|
||
|
|
_sequence_is_deeply_immutable(value, safe_names, symbols)
|
||
|
|
)
|
||
|
|
if name == "types.MappingProxyType":
|
||
|
|
return (
|
||
|
|
len(node.args) == 1
|
||
|
|
and not node.keywords
|
||
|
|
and isinstance(node.args[0], ast.Dict)
|
||
|
|
and _dict_is_deeply_immutable(node.args[0], safe_names, symbols)
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _is_mutable_expr(
|
||
|
|
node: ast.AST | None, safe_names: set[str], symbols: dict[str, str]
|
||
|
|
) -> bool:
|
||
|
|
if node is None:
|
||
|
|
return False
|
||
|
|
if isinstance(node, MUTABLE_NODES):
|
||
|
|
return True
|
||
|
|
if isinstance(node, ast.Call) and _symbol(node.func, symbols) in MUTABLE_CONSTRUCTORS:
|
||
|
|
return True
|
||
|
|
return not _is_immutable_expr(node, safe_names, symbols)
|
||
|
|
|
||
|
|
|
||
|
|
def _scope_statements(body: Iterable[ast.stmt]) -> Iterable[ast.stmt]:
|
||
|
|
for statement in body:
|
||
|
|
yield statement
|
||
|
|
nested: list[list[ast.stmt]] = []
|
||
|
|
if isinstance(statement, (ast.If, ast.For, ast.AsyncFor, ast.While)):
|
||
|
|
nested.extend([statement.body, statement.orelse])
|
||
|
|
elif isinstance(statement, (ast.With, ast.AsyncWith)):
|
||
|
|
nested.append(statement.body)
|
||
|
|
elif isinstance(statement, (ast.Try, ast.TryStar)):
|
||
|
|
nested.extend([statement.body, statement.orelse, statement.finalbody])
|
||
|
|
nested.extend(handler.body for handler in statement.handlers)
|
||
|
|
elif isinstance(statement, ast.Match):
|
||
|
|
nested.extend(case.body for case in statement.cases)
|
||
|
|
for statements in nested:
|
||
|
|
yield from _scope_statements(statements)
|
||
|
|
|
||
|
|
|
||
|
|
def _function_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]:
|
||
|
|
result: list[ast.AST] = []
|
||
|
|
stack: list[ast.AST] = list(reversed(function.body))
|
||
|
|
while stack:
|
||
|
|
node = stack.pop()
|
||
|
|
result.append(node)
|
||
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
||
|
|
continue
|
||
|
|
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _shared_class_nodes(
|
||
|
|
body: Iterable[ast.stmt], prefix: str = ""
|
||
|
|
) -> Iterable[tuple[str, ast.ClassDef]]:
|
||
|
|
for statement in _scope_statements(body):
|
||
|
|
if not isinstance(statement, ast.ClassDef):
|
||
|
|
continue
|
||
|
|
qualified_name = f"{prefix}.{statement.name}" if prefix else statement.name
|
||
|
|
yield qualified_name, statement
|
||
|
|
yield from _shared_class_nodes(statement.body, qualified_name)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_classvar_annotation(node: ast.AST, symbols: dict[str, str]) -> bool:
|
||
|
|
value = node.value if isinstance(node, ast.Subscript) else node
|
||
|
|
return _symbol(value, symbols) == "typing.ClassVar"
|
||
|
|
|
||
|
|
|
||
|
|
class _FileChecker:
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
tree: ast.Module,
|
||
|
|
path: Path,
|
||
|
|
_target_classes: set[str],
|
||
|
|
root: Path | None = None,
|
||
|
|
):
|
||
|
|
self.tree = tree
|
||
|
|
self.path = path
|
||
|
|
self.root = (root or path.parent).resolve()
|
||
|
|
self.violations: list[tuple[int, int, str]] = []
|
||
|
|
self.safe_names: set[str] = {
|
||
|
|
"False",
|
||
|
|
"None",
|
||
|
|
"True",
|
||
|
|
"bool",
|
||
|
|
"bytes",
|
||
|
|
"complex",
|
||
|
|
"float",
|
||
|
|
"frozenset",
|
||
|
|
"int",
|
||
|
|
"range",
|
||
|
|
"str",
|
||
|
|
"tuple",
|
||
|
|
}
|
||
|
|
self.symbols = {name: f"builtins.{name}" for name in TRUSTED_BUILTINS}
|
||
|
|
self.import_names: set[str] = set()
|
||
|
|
self.definition_names: set[str] = set()
|
||
|
|
self.module_binding_names: set[str] = set()
|
||
|
|
self.module_mutable_names: set[str] = set()
|
||
|
|
self.classes: dict[str, ast.ClassDef] = {}
|
||
|
|
self.shared_classes = list(_shared_class_nodes(tree.body))
|
||
|
|
self.functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
||
|
|
self.lambdas: dict[str, ast.Lambda] = {}
|
||
|
|
self.shared_closure_factories: set[str] = set()
|
||
|
|
self.shared_returning_functions: set[str] = set()
|
||
|
|
self.returned_parameters: dict[str, set[int]] = {}
|
||
|
|
self.stateful_decorator_factories: set[str] = set()
|
||
|
|
self.shared_instance_attributes: dict[str, set[str]] = {}
|
||
|
|
|
||
|
|
def add(self, node: ast.AST, message: str) -> None:
|
||
|
|
self.violations.append(
|
||
|
|
(getattr(node, "lineno", 1), getattr(node, "col_offset", 0) + 1, message)
|
||
|
|
)
|
||
|
|
|
||
|
|
def collect_symbols(self) -> None:
|
||
|
|
shadowed_names: set[str] = set()
|
||
|
|
for statement in _scope_statements(self.tree.body):
|
||
|
|
if isinstance(statement, ast.Import):
|
||
|
|
for alias in statement.names:
|
||
|
|
name = alias.asname or alias.name.split(".")[0]
|
||
|
|
self.import_names.add(name)
|
||
|
|
self.module_binding_names.add(name)
|
||
|
|
if _candidate_module(self.root, alias.name.split(".")) is None:
|
||
|
|
self.symbols[name] = alias.name if alias.asname else name
|
||
|
|
else:
|
||
|
|
self.symbols.pop(name, None)
|
||
|
|
elif isinstance(statement, ast.ImportFrom):
|
||
|
|
local_module = (
|
||
|
|
statement.level > 0
|
||
|
|
or statement.module is None
|
||
|
|
or _candidate_module(self.root, statement.module.split("."))
|
||
|
|
is not None
|
||
|
|
)
|
||
|
|
for alias in statement.names:
|
||
|
|
if alias.name == "*":
|
||
|
|
if statement.module is not None and not local_module:
|
||
|
|
for export in TRUSTED_STAR_EXPORTS.get(
|
||
|
|
statement.module, set()
|
||
|
|
):
|
||
|
|
self.import_names.add(export)
|
||
|
|
self.module_binding_names.add(export)
|
||
|
|
self.symbols[export] = f"{statement.module}.{export}"
|
||
|
|
if statement.module == "typing":
|
||
|
|
self.safe_names.add(export)
|
||
|
|
else:
|
||
|
|
name = alias.asname or alias.name
|
||
|
|
self.import_names.add(name)
|
||
|
|
self.module_binding_names.add(name)
|
||
|
|
if statement.module is not None and not local_module:
|
||
|
|
self.symbols[name] = f"{statement.module}.{alias.name}"
|
||
|
|
else:
|
||
|
|
self.symbols.pop(name, None)
|
||
|
|
if (
|
||
|
|
name.isupper()
|
||
|
|
or statement.module in {"collections.abc", "typing"}
|
||
|
|
or (
|
||
|
|
statement.module == "math"
|
||
|
|
and alias.name in {"e", "inf", "nan", "pi", "tau"}
|
||
|
|
)
|
||
|
|
):
|
||
|
|
self.safe_names.add(name)
|
||
|
|
elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
self.definition_names.add(statement.name)
|
||
|
|
self.module_binding_names.add(statement.name)
|
||
|
|
shadowed_names.add(statement.name)
|
||
|
|
self.functions[statement.name] = statement
|
||
|
|
elif isinstance(statement, ast.ClassDef):
|
||
|
|
self.definition_names.add(statement.name)
|
||
|
|
self.module_binding_names.add(statement.name)
|
||
|
|
shadowed_names.add(statement.name)
|
||
|
|
self.classes[statement.name] = statement
|
||
|
|
elif isinstance(statement, ast.Assign):
|
||
|
|
for target in statement.targets:
|
||
|
|
names = _target_names(target)
|
||
|
|
self.module_binding_names.update(names)
|
||
|
|
shadowed_names.update(names)
|
||
|
|
if isinstance(statement.value, ast.Lambda):
|
||
|
|
for name in names:
|
||
|
|
self.lambdas[name] = statement.value
|
||
|
|
elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)):
|
||
|
|
names = _target_names(statement.target)
|
||
|
|
self.module_binding_names.update(names)
|
||
|
|
shadowed_names.update(names)
|
||
|
|
for name in shadowed_names:
|
||
|
|
self.symbols.pop(name, None)
|
||
|
|
self.safe_names.difference_update(shadowed_names)
|
||
|
|
self.safe_names.update(self.definition_names)
|
||
|
|
for _, class_node in self.shared_classes:
|
||
|
|
frozen = any(
|
||
|
|
isinstance(decorator, ast.Call)
|
||
|
|
and _symbol(decorator.func, self.symbols) == "dataclasses.dataclass"
|
||
|
|
and any(
|
||
|
|
keyword.arg == "frozen"
|
||
|
|
and isinstance(keyword.value, ast.Constant)
|
||
|
|
and keyword.value.value is True
|
||
|
|
for keyword in decorator.keywords
|
||
|
|
)
|
||
|
|
for decorator in class_node.decorator_list
|
||
|
|
)
|
||
|
|
immutable_defaults = all(
|
||
|
|
not isinstance(statement, ast.AnnAssign)
|
||
|
|
or statement.value is None
|
||
|
|
or _is_immutable_expr(statement.value, self.safe_names, self.symbols)
|
||
|
|
for statement in class_node.body
|
||
|
|
)
|
||
|
|
if frozen and immutable_defaults and not _class_has_mutation_escape(class_node):
|
||
|
|
self.safe_names.add(f"frozen-dataclass:{class_node.name}")
|
||
|
|
|
||
|
|
def mark_shared_closure_factories(self) -> None:
|
||
|
|
def consider(value: ast.AST) -> None:
|
||
|
|
if not isinstance(value, ast.Call):
|
||
|
|
return
|
||
|
|
name = _call_name(value.func)
|
||
|
|
function = self.functions.get(name or "")
|
||
|
|
if function is not None and any(
|
||
|
|
isinstance(node, ast.Nonlocal) for node in ast.walk(function)
|
||
|
|
):
|
||
|
|
self.shared_closure_factories.add(function.name)
|
||
|
|
|
||
|
|
scopes = [self.tree.body]
|
||
|
|
scopes.extend(class_node.body for class_node in self.classes.values())
|
||
|
|
for body in scopes:
|
||
|
|
for statement in _scope_statements(body):
|
||
|
|
if isinstance(statement, ast.Assign):
|
||
|
|
consider(statement.value)
|
||
|
|
elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
|
||
|
|
consider(statement.value)
|
||
|
|
|
||
|
|
for node in ast.walk(self.tree):
|
||
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
continue
|
||
|
|
for decorator in node.decorator_list:
|
||
|
|
name = _call_name(decorator.func if isinstance(decorator, ast.Call) else decorator)
|
||
|
|
function = self.functions.get(name or "")
|
||
|
|
if function is not None and any(
|
||
|
|
isinstance(child, ast.Nonlocal) for child in ast.walk(function)
|
||
|
|
):
|
||
|
|
self.shared_closure_factories.add(function.name)
|
||
|
|
|
||
|
|
def collect_function_summaries(self) -> None:
|
||
|
|
"""Summarize simple wrappers without executing student code."""
|
||
|
|
|
||
|
|
callables: dict[
|
||
|
|
str, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
|
||
|
|
] = {**self.functions, **self.lambdas}
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for function_name, function in callables.items():
|
||
|
|
positional = [*function.args.posonlyargs, *function.args.args]
|
||
|
|
local_names = {
|
||
|
|
argument.arg
|
||
|
|
for argument in [*positional, *function.args.kwonlyargs]
|
||
|
|
}
|
||
|
|
if function.args.vararg is not None:
|
||
|
|
local_names.add(function.args.vararg.arg)
|
||
|
|
if function.args.kwarg is not None:
|
||
|
|
local_names.add(function.args.kwarg.arg)
|
||
|
|
|
||
|
|
if isinstance(function, ast.Lambda):
|
||
|
|
nodes = [function.body]
|
||
|
|
return_values = [function.body]
|
||
|
|
else:
|
||
|
|
nodes = _function_nodes(function)
|
||
|
|
return_values = [
|
||
|
|
node.value
|
||
|
|
for node in nodes
|
||
|
|
if isinstance(node, ast.Return) and node.value is not None
|
||
|
|
]
|
||
|
|
assignments = [
|
||
|
|
node
|
||
|
|
for node in nodes
|
||
|
|
if _assignment_parts(node) is not None
|
||
|
|
]
|
||
|
|
for assignment in assignments:
|
||
|
|
_, targets = _assignment_parts(assignment) or (assignment, [])
|
||
|
|
for target in targets:
|
||
|
|
local_names.update(_target_names(target))
|
||
|
|
|
||
|
|
shared_aliases: set[str] = set()
|
||
|
|
parameter_aliases = {
|
||
|
|
argument.arg: {index} for index, argument in enumerate(positional)
|
||
|
|
}
|
||
|
|
|
||
|
|
def returned_parameter_indexes(node: ast.AST) -> set[int]:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return parameter_aliases.get(node.id, set())
|
||
|
|
resolved = _resolved_attribute(node)
|
||
|
|
if resolved is not None:
|
||
|
|
return returned_parameter_indexes(resolved[0])
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return returned_parameter_indexes(node.value)
|
||
|
|
if isinstance(node, ast.Call):
|
||
|
|
name = node.func.id if isinstance(node.func, ast.Name) else None
|
||
|
|
return set().union(
|
||
|
|
*(
|
||
|
|
returned_parameter_indexes(node.args[index])
|
||
|
|
for index in self.returned_parameters.get(
|
||
|
|
name or "", set()
|
||
|
|
)
|
||
|
|
if index < len(node.args)
|
||
|
|
),
|
||
|
|
set(),
|
||
|
|
)
|
||
|
|
return set()
|
||
|
|
|
||
|
|
def returns_shared(node: ast.AST) -> bool:
|
||
|
|
if _is_immutable_expr(node, self.safe_names, self.symbols):
|
||
|
|
return False
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return (
|
||
|
|
node.id in self.module_binding_names
|
||
|
|
and node.id not in local_names
|
||
|
|
) or node.id in shared_aliases
|
||
|
|
resolved = _resolved_attribute(node)
|
||
|
|
if resolved is not None:
|
||
|
|
return returns_shared(resolved[0])
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return returns_shared(node.value)
|
||
|
|
return _call_returns_shared(
|
||
|
|
node,
|
||
|
|
returns_shared,
|
||
|
|
self.shared_returning_functions,
|
||
|
|
self.returned_parameters,
|
||
|
|
) or (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and _symbol(node.func, self.symbols)
|
||
|
|
== "importlib.import_module"
|
||
|
|
)
|
||
|
|
|
||
|
|
aliases_changed = True
|
||
|
|
while aliases_changed:
|
||
|
|
aliases_changed = False
|
||
|
|
for assignment in assignments:
|
||
|
|
value, targets = _assignment_parts(assignment) or (assignment, [])
|
||
|
|
indexes = returned_parameter_indexes(value)
|
||
|
|
shared = returns_shared(value)
|
||
|
|
before = (
|
||
|
|
len(shared_aliases),
|
||
|
|
sum(map(len, parameter_aliases.values())),
|
||
|
|
)
|
||
|
|
for target in targets:
|
||
|
|
for name in _target_names(target):
|
||
|
|
if shared:
|
||
|
|
shared_aliases.add(name)
|
||
|
|
if indexes:
|
||
|
|
parameter_aliases.setdefault(name, set()).update(indexes)
|
||
|
|
aliases_changed |= before != (
|
||
|
|
len(shared_aliases),
|
||
|
|
sum(map(len, parameter_aliases.values())),
|
||
|
|
)
|
||
|
|
|
||
|
|
indexes = set().union(
|
||
|
|
*(returned_parameter_indexes(value) for value in return_values), set()
|
||
|
|
)
|
||
|
|
known_indexes = self.returned_parameters.setdefault(function_name, set())
|
||
|
|
before = len(known_indexes)
|
||
|
|
known_indexes.update(indexes)
|
||
|
|
changed |= len(known_indexes) != before
|
||
|
|
if any(returns_shared(value) for value in return_values):
|
||
|
|
if function_name not in self.shared_returning_functions:
|
||
|
|
self.shared_returning_functions.add(function_name)
|
||
|
|
changed = True
|
||
|
|
for value in return_values:
|
||
|
|
if isinstance(value, ast.Call):
|
||
|
|
called = _symbol(value.func, self.symbols)
|
||
|
|
local_called = (
|
||
|
|
value.func.id
|
||
|
|
if isinstance(value.func, ast.Name)
|
||
|
|
else None
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
called in STATEFUL_DECORATORS
|
||
|
|
or local_called in self.stateful_decorator_factories
|
||
|
|
) and function_name not in self.stateful_decorator_factories:
|
||
|
|
self.stateful_decorator_factories.add(function_name)
|
||
|
|
changed = True
|
||
|
|
|
||
|
|
def decorator_is_stateful(
|
||
|
|
self, decorator: ast.AST, symbols: dict[str, str]
|
||
|
|
) -> bool:
|
||
|
|
expression = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||
|
|
return _symbol(expression, symbols) in STATEFUL_DECORATORS or (
|
||
|
|
isinstance(expression, ast.Name)
|
||
|
|
and expression.id in self.stateful_decorator_factories
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_module_bindings(self) -> None:
|
||
|
|
for statement in _scope_statements(self.tree.body):
|
||
|
|
value: ast.AST | None = None
|
||
|
|
targets: set[str] = set()
|
||
|
|
if isinstance(statement, ast.Assign):
|
||
|
|
value = statement.value
|
||
|
|
for target in statement.targets:
|
||
|
|
targets.update(_target_names(target))
|
||
|
|
elif isinstance(statement, ast.AnnAssign):
|
||
|
|
value = statement.value
|
||
|
|
targets.update(_target_names(statement.target))
|
||
|
|
elif isinstance(statement, ast.AugAssign):
|
||
|
|
value = statement.value
|
||
|
|
targets.update(_target_names(statement.target))
|
||
|
|
else:
|
||
|
|
continue
|
||
|
|
|
||
|
|
if value is None:
|
||
|
|
continue
|
||
|
|
if _is_immutable_expr(value, self.safe_names, self.symbols):
|
||
|
|
self.safe_names.update(targets)
|
||
|
|
continue
|
||
|
|
for name in sorted(targets - ALLOWED_MUTABLE_METADATA):
|
||
|
|
self.module_mutable_names.add(name)
|
||
|
|
self.add(
|
||
|
|
statement,
|
||
|
|
f"module-level value '{name}' is mutable or stateful and can be shared between processes",
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_class_bindings(self) -> None:
|
||
|
|
for class_name, class_node in self.shared_classes:
|
||
|
|
class_safe_names = set(self.safe_names)
|
||
|
|
is_dataclass = any(
|
||
|
|
_symbol(
|
||
|
|
decorator.func if isinstance(decorator, ast.Call) else decorator,
|
||
|
|
self.symbols,
|
||
|
|
)
|
||
|
|
== "dataclasses.dataclass"
|
||
|
|
for decorator in class_node.decorator_list
|
||
|
|
)
|
||
|
|
is_pydantic_model = any(
|
||
|
|
(
|
||
|
|
isinstance(base, ast.Name)
|
||
|
|
and _symbol(base, self.symbols)
|
||
|
|
in {"pydantic.BaseModel", "pydantic.main.BaseModel"}
|
||
|
|
)
|
||
|
|
or (
|
||
|
|
isinstance(base, ast.Attribute)
|
||
|
|
and _symbol(base, self.symbols)
|
||
|
|
in {"pydantic.BaseModel", "pydantic.main.BaseModel"}
|
||
|
|
)
|
||
|
|
for base in class_node.bases
|
||
|
|
)
|
||
|
|
is_enum = any(
|
||
|
|
(
|
||
|
|
isinstance(base, ast.Name)
|
||
|
|
and _symbol(base, self.symbols) in ENUM_BASE_NAMES
|
||
|
|
)
|
||
|
|
or (
|
||
|
|
isinstance(base, ast.Attribute)
|
||
|
|
and _symbol(base, self.symbols) in ENUM_BASE_NAMES
|
||
|
|
)
|
||
|
|
for base in class_node.bases
|
||
|
|
)
|
||
|
|
class_symbols = dict(self.symbols)
|
||
|
|
class_import_symbols: dict[str, str] = {}
|
||
|
|
class_shadowed_names: set[str] = set()
|
||
|
|
for class_statement in _scope_statements(class_node.body):
|
||
|
|
names: set[str] = set()
|
||
|
|
if isinstance(
|
||
|
|
class_statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
||
|
|
):
|
||
|
|
names.add(class_statement.name)
|
||
|
|
elif isinstance(class_statement, ast.Assign):
|
||
|
|
for target in class_statement.targets:
|
||
|
|
names.update(_target_names(target))
|
||
|
|
elif isinstance(class_statement, (ast.AnnAssign, ast.AugAssign)):
|
||
|
|
names.update(_target_names(class_statement.target))
|
||
|
|
elif isinstance(class_statement, ast.Import):
|
||
|
|
for alias in class_statement.names:
|
||
|
|
name = alias.asname or alias.name.split(".")[0]
|
||
|
|
if _candidate_module(self.root, alias.name.split(".")) is None:
|
||
|
|
class_import_symbols[name] = (
|
||
|
|
alias.name if alias.asname else name
|
||
|
|
)
|
||
|
|
elif isinstance(class_statement, ast.ImportFrom):
|
||
|
|
local_module = (
|
||
|
|
class_statement.level > 0
|
||
|
|
or class_statement.module is None
|
||
|
|
or _candidate_module(
|
||
|
|
self.root, class_statement.module.split(".")
|
||
|
|
)
|
||
|
|
is not None
|
||
|
|
)
|
||
|
|
if class_statement.module is not None and not local_module:
|
||
|
|
for alias in class_statement.names:
|
||
|
|
if alias.name == "*":
|
||
|
|
for export in TRUSTED_STAR_EXPORTS.get(
|
||
|
|
class_statement.module, set()
|
||
|
|
):
|
||
|
|
class_import_symbols[export] = (
|
||
|
|
f"{class_statement.module}.{export}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
name = alias.asname or alias.name
|
||
|
|
class_import_symbols[name] = (
|
||
|
|
f"{class_statement.module}.{alias.name}"
|
||
|
|
)
|
||
|
|
class_shadowed_names.update(names)
|
||
|
|
class_symbols.update(
|
||
|
|
(name, symbol)
|
||
|
|
for name, symbol in class_import_symbols.items()
|
||
|
|
if name not in class_shadowed_names
|
||
|
|
)
|
||
|
|
for name in class_shadowed_names:
|
||
|
|
class_symbols.pop(name, None)
|
||
|
|
class_safe_names.discard(name)
|
||
|
|
for class_statement in class_node.body:
|
||
|
|
if not isinstance(
|
||
|
|
class_statement, (ast.FunctionDef, ast.AsyncFunctionDef)
|
||
|
|
):
|
||
|
|
continue
|
||
|
|
for decorator in class_statement.decorator_list:
|
||
|
|
expression = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||
|
|
name = _symbol(expression, class_symbols)
|
||
|
|
if self.decorator_is_stateful(decorator, class_symbols):
|
||
|
|
self.add(
|
||
|
|
decorator,
|
||
|
|
f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances",
|
||
|
|
)
|
||
|
|
for statement in _scope_statements(class_node.body):
|
||
|
|
def targets_class_namespace(target: ast.AST) -> bool:
|
||
|
|
if isinstance(target, ast.Subscript):
|
||
|
|
return (
|
||
|
|
isinstance(target.value, ast.Call)
|
||
|
|
and isinstance(target.value.func, ast.Name)
|
||
|
|
and target.value.func.id in {"globals", "locals"}
|
||
|
|
)
|
||
|
|
if isinstance(target, (ast.Tuple, ast.List)):
|
||
|
|
return any(targets_class_namespace(item) for item in target.elts)
|
||
|
|
return False
|
||
|
|
|
||
|
|
if isinstance(statement, ast.Assign) and any(
|
||
|
|
targets_class_namespace(target) for target in statement.targets
|
||
|
|
):
|
||
|
|
self.add(statement, "assignment mutates class-level shared state")
|
||
|
|
elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)) and (
|
||
|
|
targets_class_namespace(statement.target)
|
||
|
|
):
|
||
|
|
self.add(statement, "assignment mutates class-level shared state")
|
||
|
|
elif isinstance(statement, ast.Delete) and any(
|
||
|
|
targets_class_namespace(target) for target in statement.targets
|
||
|
|
):
|
||
|
|
self.add(statement, "deletion mutates class-level shared state")
|
||
|
|
elif isinstance(statement, ast.Expr) and isinstance(
|
||
|
|
statement.value, ast.Call
|
||
|
|
):
|
||
|
|
call = statement.value
|
||
|
|
if (
|
||
|
|
isinstance(call.func, ast.Attribute)
|
||
|
|
and call.func.attr in MUTATOR_METHODS | {"__setitem__"}
|
||
|
|
and isinstance(call.func.value, ast.Call)
|
||
|
|
and isinstance(call.func.value.func, ast.Name)
|
||
|
|
and call.func.value.func.id in {"globals", "locals"}
|
||
|
|
):
|
||
|
|
self.add(call, "call mutates class-level shared state")
|
||
|
|
|
||
|
|
value: ast.AST | None = None
|
||
|
|
targets: set[str] = set()
|
||
|
|
if isinstance(statement, ast.Assign):
|
||
|
|
value = statement.value
|
||
|
|
for target in statement.targets:
|
||
|
|
targets.update(_target_names(target))
|
||
|
|
elif isinstance(statement, ast.AnnAssign):
|
||
|
|
value = statement.value
|
||
|
|
targets.update(_target_names(statement.target))
|
||
|
|
else:
|
||
|
|
continue
|
||
|
|
if value is None:
|
||
|
|
continue
|
||
|
|
if _is_immutable_expr(value, class_safe_names, class_symbols):
|
||
|
|
class_safe_names.update(targets)
|
||
|
|
for name in targets:
|
||
|
|
class_symbols.pop(name, None)
|
||
|
|
continue
|
||
|
|
if (
|
||
|
|
is_enum
|
||
|
|
and isinstance(value, ast.Call)
|
||
|
|
and _symbol(value.func, class_symbols) == "enum.auto"
|
||
|
|
and not value.args
|
||
|
|
and not value.keywords
|
||
|
|
):
|
||
|
|
class_safe_names.update(targets)
|
||
|
|
continue
|
||
|
|
if (
|
||
|
|
is_dataclass
|
||
|
|
and isinstance(statement, ast.AnnAssign)
|
||
|
|
and not _is_classvar_annotation(statement.annotation, class_symbols)
|
||
|
|
and isinstance(value, ast.Call)
|
||
|
|
and _symbol(value.func, class_symbols) == "dataclasses.field"
|
||
|
|
and not value.args
|
||
|
|
and all(
|
||
|
|
keyword.arg != "default"
|
||
|
|
or not _is_mutable_expr(
|
||
|
|
keyword.value, class_safe_names, class_symbols
|
||
|
|
)
|
||
|
|
for keyword in value.keywords
|
||
|
|
)
|
||
|
|
):
|
||
|
|
continue
|
||
|
|
if (
|
||
|
|
is_pydantic_model
|
||
|
|
and isinstance(statement, ast.AnnAssign)
|
||
|
|
and not _is_classvar_annotation(statement.annotation, class_symbols)
|
||
|
|
and isinstance(value, ast.Call)
|
||
|
|
and _symbol(value.func, class_symbols) == "pydantic.Field"
|
||
|
|
and not value.args
|
||
|
|
and all(
|
||
|
|
keyword.arg != "default"
|
||
|
|
or not _is_mutable_expr(
|
||
|
|
keyword.value, class_safe_names, class_symbols
|
||
|
|
)
|
||
|
|
for keyword in value.keywords
|
||
|
|
)
|
||
|
|
):
|
||
|
|
continue
|
||
|
|
for name in sorted(targets):
|
||
|
|
self.add(
|
||
|
|
statement,
|
||
|
|
f"class attribute '{class_name}.{name}' is mutable or stateful and is shared by all process instances",
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_module_mutations(self) -> None:
|
||
|
|
statements = list(_scope_statements(self.tree.body))
|
||
|
|
class_aliases = set(self.classes)
|
||
|
|
shared_aliases: set[str] = set()
|
||
|
|
global_objects = self.module_binding_names
|
||
|
|
|
||
|
|
def is_class_ref(node: ast.AST) -> bool:
|
||
|
|
return isinstance(node, ast.Name) and node.id in class_aliases
|
||
|
|
|
||
|
|
def is_shared_ref(node: ast.AST) -> bool:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return node.id in global_objects or node.id in shared_aliases
|
||
|
|
if isinstance(node, ast.Attribute):
|
||
|
|
return (
|
||
|
|
is_class_ref(node.value)
|
||
|
|
or is_shared_ref(node.value)
|
||
|
|
or (
|
||
|
|
isinstance(node.value, ast.Name)
|
||
|
|
and node.value.id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return is_shared_ref(node.value)
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "getattr"
|
||
|
|
and node.args
|
||
|
|
):
|
||
|
|
return is_class_ref(node.args[0]) or is_shared_ref(node.args[0])
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "eval"
|
||
|
|
and node.args
|
||
|
|
and isinstance(node.args[0], ast.Constant)
|
||
|
|
and isinstance(node.args[0].value, str)
|
||
|
|
):
|
||
|
|
try:
|
||
|
|
expression = ast.parse(node.args[0].value, mode="eval").body
|
||
|
|
except SyntaxError:
|
||
|
|
return False
|
||
|
|
return is_shared_ref(expression)
|
||
|
|
return (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "vars"
|
||
|
|
and len(node.args) == 1
|
||
|
|
and (is_class_ref(node.args[0]) or is_shared_ref(node.args[0]))
|
||
|
|
) or (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in {"globals", "locals"}
|
||
|
|
)
|
||
|
|
|
||
|
|
assignments = [
|
||
|
|
statement
|
||
|
|
for statement in statements
|
||
|
|
if isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr))
|
||
|
|
]
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for assignment in assignments:
|
||
|
|
value, targets = _assignment_parts(assignment) or (assignment, [])
|
||
|
|
names = set().union(*map(_target_names, targets), set())
|
||
|
|
before = (len(class_aliases), len(shared_aliases))
|
||
|
|
if is_class_ref(value):
|
||
|
|
class_aliases.update(names)
|
||
|
|
if is_shared_ref(value):
|
||
|
|
shared_aliases.update(names)
|
||
|
|
if before != (len(class_aliases), len(shared_aliases)):
|
||
|
|
changed = True
|
||
|
|
|
||
|
|
def target_is_shared(target: ast.AST) -> bool:
|
||
|
|
if isinstance(target, ast.Attribute):
|
||
|
|
return (
|
||
|
|
is_class_ref(target.value)
|
||
|
|
or is_shared_ref(target.value)
|
||
|
|
or (
|
||
|
|
isinstance(target.value, ast.Name)
|
||
|
|
and target.value.id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if isinstance(target, ast.Subscript):
|
||
|
|
return is_shared_ref(target.value)
|
||
|
|
if isinstance(target, (ast.Tuple, ast.List)):
|
||
|
|
return any(target_is_shared(element) for element in target.elts)
|
||
|
|
return False
|
||
|
|
|
||
|
|
for statement in statements:
|
||
|
|
if isinstance(statement, ast.Assign):
|
||
|
|
if any(target_is_shared(target) for target in statement.targets):
|
||
|
|
self.add(statement, "assignment creates or mutates module-level shared state")
|
||
|
|
elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)):
|
||
|
|
if target_is_shared(statement.target):
|
||
|
|
self.add(statement, "assignment creates or mutates module-level shared state")
|
||
|
|
elif isinstance(statement, ast.Delete):
|
||
|
|
if any(target_is_shared(target) for target in statement.targets):
|
||
|
|
self.add(statement, "deletion mutates module-level shared state")
|
||
|
|
elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call):
|
||
|
|
call = statement.value
|
||
|
|
if isinstance(call.func, ast.Name) and call.func.id in {
|
||
|
|
"exec",
|
||
|
|
"globals",
|
||
|
|
"locals",
|
||
|
|
}:
|
||
|
|
self.add(call, f"'{call.func.id}' can access or modify module-level shared state")
|
||
|
|
elif (
|
||
|
|
isinstance(call.func, ast.Name)
|
||
|
|
and call.func.id == "eval"
|
||
|
|
and is_shared_ref(call)
|
||
|
|
):
|
||
|
|
self.add(call, "'eval' accesses module-level shared state")
|
||
|
|
elif (
|
||
|
|
isinstance(call.func, ast.Name)
|
||
|
|
and call.func.id in {"setattr", "delattr"}
|
||
|
|
and call.args
|
||
|
|
and (
|
||
|
|
is_class_ref(call.args[0])
|
||
|
|
or is_shared_ref(call.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(call.args[0], ast.Name)
|
||
|
|
and call.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
self.add(call, f"'{call.func.id}' mutates module-level shared state")
|
||
|
|
elif (
|
||
|
|
_symbol(call.func, self.symbols) in OPERATOR_MUTATORS
|
||
|
|
and call.args
|
||
|
|
and is_shared_ref(call.args[0])
|
||
|
|
):
|
||
|
|
self.add(call, "operator call mutates module-level shared state")
|
||
|
|
elif (
|
||
|
|
(resolved := _resolved_attribute(call.func)) is not None
|
||
|
|
and resolved[1] in UNBOUND_MUTATOR_METHODS
|
||
|
|
and call.args
|
||
|
|
and (
|
||
|
|
is_class_ref(call.args[0])
|
||
|
|
or is_shared_ref(call.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(call.args[0], ast.Name)
|
||
|
|
and call.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
self.add(call, f"'{resolved[1]}' mutates module-level shared state")
|
||
|
|
elif (
|
||
|
|
(resolved := _resolved_attribute(call.func)) is not None
|
||
|
|
and resolved[1] in MUTATOR_METHODS
|
||
|
|
and is_shared_ref(resolved[0])
|
||
|
|
):
|
||
|
|
if not (
|
||
|
|
_symbol(resolved[0], self.symbols) == "sys.path"
|
||
|
|
and resolved[1] in {"append", "insert"}
|
||
|
|
):
|
||
|
|
self.add(
|
||
|
|
call,
|
||
|
|
f"'{resolved[1]}' mutates module-level shared state",
|
||
|
|
)
|
||
|
|
elif (
|
||
|
|
(canonical := _symbol(call.func, self.symbols)) is not None
|
||
|
|
and (_root_name(call.func) or "") in self.import_names
|
||
|
|
and canonical not in ALLOWED_IMPORTED_CALLS
|
||
|
|
and canonical not in STATEFUL_DECORATORS
|
||
|
|
and canonical not in TYPE_FACTORIES
|
||
|
|
and canonical not in MUTABLE_CONSTRUCTORS
|
||
|
|
and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS
|
||
|
|
):
|
||
|
|
self.add(
|
||
|
|
call,
|
||
|
|
f"call to imported API '{canonical}' is not known to be free of shared module-state mutation",
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_defaults(self) -> None:
|
||
|
|
for node in ast.walk(self.tree):
|
||
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
||
|
|
continue
|
||
|
|
defaults = list(node.args.defaults)
|
||
|
|
defaults.extend(default for default in node.args.kw_defaults if default is not None)
|
||
|
|
for default in defaults:
|
||
|
|
if _is_mutable_expr(default, self.safe_names, self.symbols):
|
||
|
|
self.add(default, "mutable default argument is shared between function calls")
|
||
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
for decorator in node.decorator_list:
|
||
|
|
expression = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||
|
|
name = _symbol(expression, self.symbols)
|
||
|
|
if self.decorator_is_stateful(decorator, self.symbols):
|
||
|
|
self.add(
|
||
|
|
decorator,
|
||
|
|
f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances",
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_dynamic_imports(self) -> None:
|
||
|
|
string_values = _static_string_values(self.tree)
|
||
|
|
importlib_modules, import_module_functions = _dynamic_import_names(self.tree)
|
||
|
|
for node in ast.walk(self.tree):
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and node.args
|
||
|
|
and _is_dynamic_import_call(
|
||
|
|
node, importlib_modules, import_module_functions
|
||
|
|
)
|
||
|
|
and _static_string(node.args[0], string_values) is None
|
||
|
|
):
|
||
|
|
self.add(
|
||
|
|
node,
|
||
|
|
"dynamic import name cannot be resolved statically; shared state in the imported module cannot be checked",
|
||
|
|
)
|
||
|
|
|
||
|
|
def collect_shared_instance_attributes(self) -> None:
|
||
|
|
for class_name, class_node in self.classes.items():
|
||
|
|
attributes = self.shared_instance_attributes.setdefault(class_name, set())
|
||
|
|
functions = [
|
||
|
|
statement
|
||
|
|
for statement in class_node.body
|
||
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||
|
|
]
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for function in functions:
|
||
|
|
positional = [*function.args.posonlyargs, *function.args.args]
|
||
|
|
if not positional:
|
||
|
|
continue
|
||
|
|
instance_names = {positional[0].arg}
|
||
|
|
nodes = _function_nodes(function)
|
||
|
|
assignments = [
|
||
|
|
node
|
||
|
|
for node in nodes
|
||
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr))
|
||
|
|
]
|
||
|
|
local_names = {
|
||
|
|
argument.arg
|
||
|
|
for argument in [*positional, *function.args.kwonlyargs]
|
||
|
|
}
|
||
|
|
local_import_names: set[str] = set()
|
||
|
|
for node in nodes:
|
||
|
|
if isinstance(node, ast.Assign):
|
||
|
|
for target in node.targets:
|
||
|
|
local_names.update(_target_names(target))
|
||
|
|
elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)):
|
||
|
|
local_names.update(_target_names(node.target))
|
||
|
|
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
||
|
|
names = {
|
||
|
|
alias.asname or alias.name.split(".")[0]
|
||
|
|
for alias in node.names
|
||
|
|
if alias.name != "*"
|
||
|
|
}
|
||
|
|
local_names.update(names)
|
||
|
|
local_import_names.update(names)
|
||
|
|
|
||
|
|
shared_aliases: set[str] = set()
|
||
|
|
|
||
|
|
def is_instance(node: ast.AST) -> bool:
|
||
|
|
return isinstance(node, ast.Name) and node.id in instance_names
|
||
|
|
|
||
|
|
def is_shared(node: ast.AST) -> bool:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return (
|
||
|
|
node.id in self.module_binding_names
|
||
|
|
and node.id not in local_names
|
||
|
|
) or node.id in shared_aliases | local_import_names
|
||
|
|
if (resolved := _resolved_attribute(node)) is not None:
|
||
|
|
return (
|
||
|
|
is_instance(resolved[0]) and resolved[1] in attributes
|
||
|
|
) or is_shared(resolved[0])
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return is_shared(node.value)
|
||
|
|
return _call_returns_shared(
|
||
|
|
node,
|
||
|
|
is_shared,
|
||
|
|
self.shared_returning_functions,
|
||
|
|
self.returned_parameters,
|
||
|
|
) or (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and _symbol(node.func, self.symbols)
|
||
|
|
== "importlib.import_module"
|
||
|
|
)
|
||
|
|
|
||
|
|
aliases_changed = True
|
||
|
|
while aliases_changed:
|
||
|
|
aliases_changed = False
|
||
|
|
for assignment in assignments:
|
||
|
|
value, targets = _assignment_parts(assignment) or (assignment, [])
|
||
|
|
if is_instance(value):
|
||
|
|
names = set().union(*(_target_names(t) for t in targets))
|
||
|
|
before = len(instance_names)
|
||
|
|
instance_names.update(names)
|
||
|
|
aliases_changed |= len(instance_names) != before
|
||
|
|
if not is_shared(value):
|
||
|
|
continue
|
||
|
|
for target in targets:
|
||
|
|
names = _target_names(target)
|
||
|
|
before = len(shared_aliases)
|
||
|
|
shared_aliases.update(names)
|
||
|
|
aliases_changed |= len(shared_aliases) != before
|
||
|
|
if (
|
||
|
|
isinstance(target, ast.Attribute)
|
||
|
|
and is_instance(target.value)
|
||
|
|
and target.attr not in attributes
|
||
|
|
):
|
||
|
|
attributes.add(target.attr)
|
||
|
|
changed = True
|
||
|
|
|
||
|
|
def check_function(
|
||
|
|
self,
|
||
|
|
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
||
|
|
owner: str | None,
|
||
|
|
shared_closure: bool,
|
||
|
|
) -> None:
|
||
|
|
nodes = _function_nodes(function)
|
||
|
|
all_class_names = set(self.classes)
|
||
|
|
instance_aliases: set[str] = set()
|
||
|
|
class_aliases: set[str] = set()
|
||
|
|
shared_aliases: set[str] = set()
|
||
|
|
shared_instance_attributes = self.shared_instance_attributes.get(owner or "", set())
|
||
|
|
positional = [*function.args.posonlyargs, *function.args.args]
|
||
|
|
local_names = {
|
||
|
|
argument.arg
|
||
|
|
for argument in [*positional, *function.args.kwonlyargs]
|
||
|
|
}
|
||
|
|
if function.args.vararg is not None:
|
||
|
|
local_names.add(function.args.vararg.arg)
|
||
|
|
if function.args.kwarg is not None:
|
||
|
|
local_names.add(function.args.kwarg.arg)
|
||
|
|
local_import_names: set[str] = set()
|
||
|
|
local_import_symbols: dict[str, str] = {}
|
||
|
|
for node in nodes:
|
||
|
|
if isinstance(node, ast.Assign):
|
||
|
|
for target in node.targets:
|
||
|
|
local_names.update(_target_names(target))
|
||
|
|
elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr)):
|
||
|
|
local_names.update(_target_names(node.target))
|
||
|
|
elif isinstance(node, ast.Import):
|
||
|
|
names = {
|
||
|
|
alias.asname or alias.name.split(".")[0]
|
||
|
|
for alias in node.names
|
||
|
|
}
|
||
|
|
local_names.update(names)
|
||
|
|
local_import_names.update(names)
|
||
|
|
for alias in node.names:
|
||
|
|
name = alias.asname or alias.name.split(".")[0]
|
||
|
|
if _candidate_module(self.root, alias.name.split(".")) is None:
|
||
|
|
local_import_symbols[name] = alias.name if alias.asname else name
|
||
|
|
elif isinstance(node, ast.ImportFrom):
|
||
|
|
names = {
|
||
|
|
alias.asname or alias.name
|
||
|
|
for alias in node.names
|
||
|
|
if alias.name != "*"
|
||
|
|
}
|
||
|
|
local_names.update(names)
|
||
|
|
local_import_names.update(names)
|
||
|
|
local_module = (
|
||
|
|
node.level > 0
|
||
|
|
or node.module is None
|
||
|
|
or _candidate_module(self.root, node.module.split(".")) is not None
|
||
|
|
)
|
||
|
|
if node.module is not None and not local_module:
|
||
|
|
for alias in node.names:
|
||
|
|
if alias.name != "*":
|
||
|
|
local_import_symbols[alias.asname or alias.name] = (
|
||
|
|
f"{node.module}.{alias.name}"
|
||
|
|
)
|
||
|
|
function_symbols = dict(self.symbols)
|
||
|
|
for name in local_names - local_import_names:
|
||
|
|
function_symbols.pop(name, None)
|
||
|
|
function_symbols.update(local_import_symbols)
|
||
|
|
imported_callable_names = (self.import_names - local_names) | local_import_names
|
||
|
|
if owner is not None and positional:
|
||
|
|
first = positional[0].arg
|
||
|
|
if first == "cls" or any(
|
||
|
|
isinstance(decorator, ast.Name) and decorator.id == "classmethod"
|
||
|
|
for decorator in function.decorator_list
|
||
|
|
):
|
||
|
|
class_aliases.add(first)
|
||
|
|
else:
|
||
|
|
instance_aliases.add(first)
|
||
|
|
|
||
|
|
def is_instance_ref(node: ast.AST) -> bool:
|
||
|
|
return isinstance(node, ast.Name) and node.id in instance_aliases
|
||
|
|
|
||
|
|
def is_class_ref(node: ast.AST) -> bool:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return (
|
||
|
|
node.id in all_class_names and node.id not in local_names
|
||
|
|
) or node.id in class_aliases
|
||
|
|
if isinstance(node, ast.Attribute):
|
||
|
|
return node.attr == "__class__" and is_instance_ref(node.value)
|
||
|
|
return (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "type"
|
||
|
|
and len(node.args) == 1
|
||
|
|
and is_instance_ref(node.args[0])
|
||
|
|
)
|
||
|
|
|
||
|
|
global_objects = self.module_binding_names
|
||
|
|
|
||
|
|
def is_shared_ref(node: ast.AST) -> bool:
|
||
|
|
if isinstance(node, ast.Name):
|
||
|
|
return (
|
||
|
|
node.id in global_objects and node.id not in local_names
|
||
|
|
) or node.id in shared_aliases | local_import_names
|
||
|
|
if (resolved := _resolved_attribute(node)) is not None:
|
||
|
|
value, attribute = resolved
|
||
|
|
if is_instance_ref(value) and attribute in shared_instance_attributes:
|
||
|
|
return True
|
||
|
|
if attribute == "__dict__" and (
|
||
|
|
is_class_ref(value)
|
||
|
|
or (isinstance(value, ast.Name) and value.id in global_objects)
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
return is_class_ref(value) or is_shared_ref(value) or (
|
||
|
|
isinstance(value, ast.Name) and value.id in global_objects
|
||
|
|
)
|
||
|
|
if isinstance(node, ast.Subscript):
|
||
|
|
return is_shared_ref(node.value)
|
||
|
|
if _call_returns_shared(
|
||
|
|
node,
|
||
|
|
is_shared_ref,
|
||
|
|
self.shared_returning_functions,
|
||
|
|
self.returned_parameters,
|
||
|
|
):
|
||
|
|
return True
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "getattr"
|
||
|
|
and node.args
|
||
|
|
):
|
||
|
|
return (
|
||
|
|
is_class_ref(node.args[0])
|
||
|
|
or is_shared_ref(node.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(node.args[0], ast.Name)
|
||
|
|
and node.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "eval"
|
||
|
|
and node.args
|
||
|
|
and isinstance(node.args[0], ast.Constant)
|
||
|
|
and isinstance(node.args[0].value, str)
|
||
|
|
):
|
||
|
|
try:
|
||
|
|
expression = ast.parse(node.args[0].value, mode="eval").body
|
||
|
|
except SyntaxError:
|
||
|
|
return False
|
||
|
|
return is_shared_ref(expression)
|
||
|
|
return (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "vars"
|
||
|
|
and len(node.args) == 1
|
||
|
|
and (
|
||
|
|
is_class_ref(node.args[0])
|
||
|
|
or is_shared_ref(node.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(node.args[0], ast.Name)
|
||
|
|
and node.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
) or (
|
||
|
|
isinstance(node, ast.Call)
|
||
|
|
and isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "globals"
|
||
|
|
)
|
||
|
|
|
||
|
|
assignments = [
|
||
|
|
node
|
||
|
|
for node in nodes
|
||
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr))
|
||
|
|
]
|
||
|
|
unbound_mutator_aliases: set[str] = set()
|
||
|
|
bound_mutator_aliases: set[str] = set()
|
||
|
|
imported_callable_aliases: dict[str, str] = {}
|
||
|
|
changed = True
|
||
|
|
while changed:
|
||
|
|
changed = False
|
||
|
|
for assignment in assignments:
|
||
|
|
value, targets = _assignment_parts(assignment) or (assignment, [])
|
||
|
|
names: set[str] = set()
|
||
|
|
for target in targets:
|
||
|
|
names.update(_target_names(target))
|
||
|
|
before = (
|
||
|
|
len(instance_aliases),
|
||
|
|
len(class_aliases),
|
||
|
|
len(shared_aliases),
|
||
|
|
len(unbound_mutator_aliases),
|
||
|
|
len(bound_mutator_aliases),
|
||
|
|
len(imported_callable_aliases),
|
||
|
|
)
|
||
|
|
if is_instance_ref(value):
|
||
|
|
instance_aliases.update(names)
|
||
|
|
if is_class_ref(value):
|
||
|
|
class_aliases.update(names)
|
||
|
|
if is_shared_ref(value):
|
||
|
|
shared_aliases.update(names)
|
||
|
|
symbol = _symbol(value, function_symbols)
|
||
|
|
aliased_symbol = (
|
||
|
|
imported_callable_aliases.get(value.id)
|
||
|
|
if isinstance(value, ast.Name)
|
||
|
|
else None
|
||
|
|
)
|
||
|
|
if aliased_symbol is not None:
|
||
|
|
symbol = aliased_symbol
|
||
|
|
if (
|
||
|
|
symbol is not None
|
||
|
|
and (
|
||
|
|
aliased_symbol is not None
|
||
|
|
or (_root_name(value) or "") in imported_callable_names
|
||
|
|
)
|
||
|
|
):
|
||
|
|
for name in names:
|
||
|
|
imported_callable_aliases[name] = symbol
|
||
|
|
if symbol in OPERATOR_MUTATORS or (
|
||
|
|
isinstance(value, ast.Name)
|
||
|
|
and value.id in unbound_mutator_aliases
|
||
|
|
):
|
||
|
|
unbound_mutator_aliases.update(names)
|
||
|
|
if (
|
||
|
|
(resolved := _resolved_attribute(value)) is not None
|
||
|
|
and resolved[1] in MUTATOR_METHODS
|
||
|
|
and is_shared_ref(resolved[0])
|
||
|
|
) or (
|
||
|
|
isinstance(value, ast.Name) and value.id in bound_mutator_aliases
|
||
|
|
):
|
||
|
|
bound_mutator_aliases.update(names)
|
||
|
|
if before != (
|
||
|
|
len(instance_aliases),
|
||
|
|
len(class_aliases),
|
||
|
|
len(shared_aliases),
|
||
|
|
len(unbound_mutator_aliases),
|
||
|
|
len(bound_mutator_aliases),
|
||
|
|
len(imported_callable_aliases),
|
||
|
|
):
|
||
|
|
changed = True
|
||
|
|
|
||
|
|
def target_is_shared(target: ast.AST) -> bool:
|
||
|
|
if isinstance(target, ast.Attribute):
|
||
|
|
return (
|
||
|
|
is_class_ref(target.value)
|
||
|
|
or is_shared_ref(target.value)
|
||
|
|
or (
|
||
|
|
isinstance(target.value, ast.Name)
|
||
|
|
and target.value.id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if isinstance(target, ast.Subscript):
|
||
|
|
return is_shared_ref(target.value)
|
||
|
|
if isinstance(target, (ast.Tuple, ast.List)):
|
||
|
|
return any(target_is_shared(element) for element in target.elts)
|
||
|
|
return False
|
||
|
|
|
||
|
|
for node in nodes:
|
||
|
|
if isinstance(node, ast.Global):
|
||
|
|
self.add(node, "'global' state can be shared between process instances")
|
||
|
|
elif isinstance(node, ast.Nonlocal) and shared_closure:
|
||
|
|
self.add(node, "shared closure state can be accessed by multiple process instances")
|
||
|
|
elif isinstance(node, ast.Assign):
|
||
|
|
if any(target_is_shared(target) for target in node.targets):
|
||
|
|
self.add(node, "assignment mutates state outside the process instance")
|
||
|
|
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
|
||
|
|
if target_is_shared(node.target):
|
||
|
|
self.add(node, "assignment mutates state outside the process instance")
|
||
|
|
elif isinstance(node, ast.Delete):
|
||
|
|
if any(target_is_shared(target) for target in node.targets):
|
||
|
|
self.add(node, "deletion mutates state outside the process instance")
|
||
|
|
elif isinstance(node, ast.Call):
|
||
|
|
if isinstance(node.func, ast.Name) and node.func.id in {"exec", "globals"}:
|
||
|
|
self.add(node, f"'{node.func.id}' can access or modify module-level shared state")
|
||
|
|
elif (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id == "eval"
|
||
|
|
and is_shared_ref(node)
|
||
|
|
):
|
||
|
|
self.add(node, "'eval' accesses module-level shared state")
|
||
|
|
elif (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in {"setattr", "delattr"}
|
||
|
|
and node.args
|
||
|
|
and (
|
||
|
|
is_class_ref(node.args[0])
|
||
|
|
or is_shared_ref(node.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(node.args[0], ast.Name)
|
||
|
|
and node.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
self.add(node, f"'{node.func.id}' mutates shared object state")
|
||
|
|
elif (
|
||
|
|
(
|
||
|
|
_symbol(node.func, function_symbols) in OPERATOR_MUTATORS
|
||
|
|
or (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in unbound_mutator_aliases
|
||
|
|
)
|
||
|
|
)
|
||
|
|
and node.args
|
||
|
|
and is_shared_ref(node.args[0])
|
||
|
|
):
|
||
|
|
self.add(node, "operator call mutates shared object state")
|
||
|
|
elif (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in bound_mutator_aliases
|
||
|
|
):
|
||
|
|
self.add(node, "aliased method mutates shared object state")
|
||
|
|
elif (
|
||
|
|
(
|
||
|
|
imported_callable_aliases.get(
|
||
|
|
node.func.id, _symbol(node.func, function_symbols)
|
||
|
|
)
|
||
|
|
if isinstance(node.func, ast.Name)
|
||
|
|
else _symbol(node.func, function_symbols)
|
||
|
|
)
|
||
|
|
in IMPORTED_ARGUMENT_MUTATORS
|
||
|
|
and node.args
|
||
|
|
and is_shared_ref(node.args[0])
|
||
|
|
):
|
||
|
|
self.add(node, "imported function mutates shared object state")
|
||
|
|
elif (
|
||
|
|
(resolved := _resolved_attribute(node.func)) is not None
|
||
|
|
and resolved[1] in UNBOUND_MUTATOR_METHODS
|
||
|
|
and node.args
|
||
|
|
and (
|
||
|
|
is_class_ref(node.args[0])
|
||
|
|
or is_shared_ref(node.args[0])
|
||
|
|
or (
|
||
|
|
isinstance(node.args[0], ast.Name)
|
||
|
|
and node.args[0].id in global_objects
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
self.add(node, f"'{resolved[1]}' mutates shared object state")
|
||
|
|
elif (
|
||
|
|
(resolved := _resolved_attribute(node.func)) is not None
|
||
|
|
and resolved[1] in MUTATOR_METHODS
|
||
|
|
and is_shared_ref(resolved[0])
|
||
|
|
):
|
||
|
|
self.add(node, f"'{resolved[1]}' mutates state outside the process instance")
|
||
|
|
elif (
|
||
|
|
(
|
||
|
|
canonical := (
|
||
|
|
imported_callable_aliases.get(
|
||
|
|
node.func.id, _symbol(node.func, function_symbols)
|
||
|
|
)
|
||
|
|
if isinstance(node.func, ast.Name)
|
||
|
|
else _symbol(node.func, function_symbols)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
is not None
|
||
|
|
and (
|
||
|
|
(_root_name(node.func) or "") in imported_callable_names
|
||
|
|
or (
|
||
|
|
isinstance(node.func, ast.Name)
|
||
|
|
and node.func.id in imported_callable_aliases
|
||
|
|
)
|
||
|
|
)
|
||
|
|
and canonical not in ALLOWED_IMPORTED_CALLS
|
||
|
|
and canonical not in STATEFUL_DECORATORS
|
||
|
|
and canonical not in TYPE_FACTORIES
|
||
|
|
and canonical not in MUTABLE_CONSTRUCTORS
|
||
|
|
and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS
|
||
|
|
):
|
||
|
|
self.add(
|
||
|
|
node,
|
||
|
|
f"call to imported API '{canonical}' is not known to be free of shared module-state mutation",
|
||
|
|
)
|
||
|
|
|
||
|
|
def check_functions(self) -> None:
|
||
|
|
def visit_body(
|
||
|
|
body: Iterable[ast.stmt],
|
||
|
|
owner: str | None = None,
|
||
|
|
shared_closure: bool = False,
|
||
|
|
) -> None:
|
||
|
|
for statement in body:
|
||
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
function_closure = shared_closure or (
|
||
|
|
owner is None and statement.name in self.shared_closure_factories
|
||
|
|
)
|
||
|
|
self.check_function(statement, owner, function_closure)
|
||
|
|
visit_body(statement.body, None, function_closure)
|
||
|
|
elif isinstance(statement, ast.ClassDef):
|
||
|
|
visit_body(statement.body, statement.name, shared_closure)
|
||
|
|
else:
|
||
|
|
for child in ast.iter_child_nodes(statement):
|
||
|
|
if isinstance(child, ast.stmt):
|
||
|
|
visit_body([child], owner, shared_closure)
|
||
|
|
|
||
|
|
visit_body(self.tree.body)
|
||
|
|
|
||
|
|
def run(self) -> list[tuple[int, int, str]]:
|
||
|
|
self.collect_symbols()
|
||
|
|
self.mark_shared_closure_factories()
|
||
|
|
self.check_module_bindings()
|
||
|
|
self.collect_function_summaries()
|
||
|
|
self.check_class_bindings()
|
||
|
|
self.check_module_mutations()
|
||
|
|
self.check_defaults()
|
||
|
|
self.check_dynamic_imports()
|
||
|
|
self.collect_shared_instance_attributes()
|
||
|
|
self.check_functions()
|
||
|
|
return sorted(set(self.violations))
|
||
|
|
|
||
|
|
|
||
|
|
def _candidate_module(root: Path, parts: list[str]) -> Path | None:
|
||
|
|
if not parts:
|
||
|
|
return None
|
||
|
|
module = root.joinpath(*parts)
|
||
|
|
candidates = [module.with_suffix(".py"), module / "__init__.py"]
|
||
|
|
root = root.resolve()
|
||
|
|
for candidate in candidates:
|
||
|
|
try:
|
||
|
|
resolved = candidate.resolve()
|
||
|
|
resolved.relative_to(root)
|
||
|
|
except (OSError, ValueError):
|
||
|
|
continue
|
||
|
|
if resolved.is_file():
|
||
|
|
return resolved
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _local_imports(tree: ast.Module, current: Path, root: Path) -> set[Path]:
|
||
|
|
result: set[Path] = set()
|
||
|
|
importlib_modules, import_module_functions = _dynamic_import_names(tree)
|
||
|
|
string_values = _static_string_values(tree)
|
||
|
|
|
||
|
|
for node in ast.walk(tree):
|
||
|
|
if isinstance(node, ast.Import):
|
||
|
|
for alias in node.names:
|
||
|
|
candidate = _candidate_module(root, alias.name.split("."))
|
||
|
|
if candidate is not None:
|
||
|
|
result.add(candidate)
|
||
|
|
elif isinstance(node, ast.ImportFrom):
|
||
|
|
if node.level:
|
||
|
|
base = current.parent
|
||
|
|
for _ in range(node.level - 1):
|
||
|
|
base = base.parent
|
||
|
|
else:
|
||
|
|
base = root
|
||
|
|
module_parts = node.module.split(".") if node.module else []
|
||
|
|
candidate = _candidate_module(base, module_parts)
|
||
|
|
if candidate is not None:
|
||
|
|
result.add(candidate)
|
||
|
|
for alias in node.names:
|
||
|
|
if alias.name == "*":
|
||
|
|
continue
|
||
|
|
candidate = _candidate_module(base, [*module_parts, alias.name])
|
||
|
|
if candidate is not None:
|
||
|
|
result.add(candidate)
|
||
|
|
elif isinstance(node, ast.Call) and node.args:
|
||
|
|
module_name = _static_string(node.args[0], string_values)
|
||
|
|
if (
|
||
|
|
_is_dynamic_import_call(
|
||
|
|
node, importlib_modules, import_module_functions
|
||
|
|
)
|
||
|
|
and module_name is not None
|
||
|
|
and not module_name.startswith(".")
|
||
|
|
):
|
||
|
|
candidate = _candidate_module(root, module_name.split("."))
|
||
|
|
if candidate is not None:
|
||
|
|
result.add(candidate)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def validate_source(source: str, filename: str, class_names: list[str]) -> list[str]:
|
||
|
|
tree = ast.parse(source, filename=filename)
|
||
|
|
path = Path(filename)
|
||
|
|
return [
|
||
|
|
f"{filename}:{line}:{column}: {message}"
|
||
|
|
for line, column, message in _FileChecker(
|
||
|
|
tree, path, set(class_names), path.parent
|
||
|
|
).run()
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def validate_solution(path: str, class_names: list[str]) -> list[str]:
|
||
|
|
entrypoint = Path(path).resolve()
|
||
|
|
root = entrypoint.parent
|
||
|
|
pending = [entrypoint]
|
||
|
|
visited: set[Path] = set()
|
||
|
|
violations: list[str] = []
|
||
|
|
while pending:
|
||
|
|
current = pending.pop()
|
||
|
|
if current in visited:
|
||
|
|
continue
|
||
|
|
visited.add(current)
|
||
|
|
source = current.read_text(encoding="utf-8")
|
||
|
|
tree = ast.parse(source, filename=str(current))
|
||
|
|
checker = _FileChecker(
|
||
|
|
tree,
|
||
|
|
current,
|
||
|
|
set(class_names) if current == entrypoint else set(),
|
||
|
|
root,
|
||
|
|
)
|
||
|
|
violations.extend(
|
||
|
|
f"{current}:{line}:{column}: {message}"
|
||
|
|
for line, column, message in checker.run()
|
||
|
|
)
|
||
|
|
pending.extend(sorted(_local_imports(tree, current, root) - visited))
|
||
|
|
return sorted(set(violations))
|