mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-31 02:23:59 +00:00
* tests: read checked-in files as UTF-8 instead of the platform default Path.read_text() with no encoding uses locale.getpreferredencoding(), which is UTF-8 on the Linux runners and cp1252 on a stock Windows install. Nine module-level reads of checked-in source files were relying on that default. studio/backend/routes/inference.py carries the DeepSeek tool-call token regexes, so it holds U+FF5C and U+2581. Under cp1252 that read raised UnicodeDecodeError on byte 0x81 at position 97806, and because the reads run at import time it took test_cancel_atomicity.py and test_cancel_id_wiring.py out at collection, not as failures. Green on CI, permanently broken for a Windows contributor running the suite locally. Adds a guard: at module scope there is no tmp_path fixture, so a bare read_text()/write_text()/open() there is always touching a checked-in file. That makes the rule mechanical enough to enforce with no allowlist, while staying quiet about temp-dir I/O inside test bodies where the platform default is harmless. The repo already spells this correctly in 464 other places; this only stops the stragglers coming back. * tests: cover import-time helper reads and keep the guard py3.9-safe Follows up on the Codex review: - add `from __future__ import annotations`, since `str | None` in `_offender` is evaluated at import on Python 3.9 and pyproject declares requires-python ">=3.9,<3.15". - widen the guard from module scope to import time. Class bodies and the bodies of module-level helpers called from an executing statement run during collection too, so `CODE = _extract_mixed_precision_code()` was the same hazard as an inline read. `if __name__ == "__main__":` blocks are skipped: pytest never executes them. - scan studio/backend/tests/ as well as tests/. Both trees are collected on Windows by separate CI jobs, and the offender that started this, test_tool_xml_strip.py reading routes/inference.py, lives there. Widening it surfaced seven more import-time reads of checked-in sources; all now name utf-8. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden the import-time encoding guard for PR #7438 Close the detector gaps raised in review, all of which I reproduced against the actual AST before changing anything. False negatives (the guard let a real hazard through): - _is_main_guard ignored the comparison operator, so if __name__ != "__main__" counted as script-only even though its body runs at import. - The else arm of a main guard was discarded with the rest of the If node. - Decorators and argument defaults on a module-level def were skipped with the body, though both are evaluated when the def executes. - Path.open() in text mode was invisible; only builtin open() was matched. - encoding = None and encoding = "locale" both re-select the platform default, but the keyword merely being present counted as pinned. False positives (the guard would have blocked a compliant contributor): - A non-literal mode fell through to the "r" default, so open(p, mode) was flagged even when mode is "rb", where adding encoding= is a ValueError and there is no edit that satisfies the rule. - Same for open(*args) and a **kwargs splat, which hide the mode and can hide an encoding. - Lambda bodies and comprehension elements were walked even though neither runs at definition. Verified: still reports the same 22 offenders on unpatched main, green on this branch and on the tree merged with latest main (557 files), and an adversarial corpus of 33 cases now scores zero false positives and zero false negatives. Also corrected two docstring claims: neither collecting job runs on Windows, and the read is governed by locale.getencoding(). * Walk eager comprehensions and treat io.open as the builtin Two regressions from the previous commit, both reproduced against the AST before changing anything. Lumping list, set and dict comprehensions in with generator expressions was wrong. Only a genexp is lazy; the other three run their element expression, their filters and their nested iterators immediately, so CONTENTS = [p.read_text() for p in PATHS] at module scope is an import-time read the guard was silently missing. Comprehensions are now walked in full and only the genexp keeps the outermost-iterable-only treatment. io was also in the not-a-path-opener list, but io.open is the builtin, with the same mode position and the same platform default. io.open(CHECKED_IN_FILE) is exactly the hazard this guard exists for, so it is matched now, with binary modes and a pinned encoding still exempt. tarfile.open and fitz.open stay exempt since neither has an encoding to name. Verified: 13 targeted cases covering all five eager comprehension forms and io.open in text, binary and pinned shapes all classify correctly; still 22 offenders on unpatched main; green on this branch and on the tree merged with latest main. * Close three more walker gaps in the import-time guard All three reproduced against the AST first. A generator expression handed straight to a call is consumed there, so DATA = "".join(p.read_text() for p in paths) runs its element at import. Only an unconsumed genexp bound to a name stays lazy, so the walker now follows the consumed ones in full and keeps the outermost-iterable-only treatment for the rest. if "__main__" == __name__ is an equivalent and accepted spelling of the main guard, but requiring __name__ on the left meant its body was treated as import-time code. That is a false positive on a block pytest never runs, so both operand orders are recognised now. The helper table was built from module-level defs only, so a def in a class body invoked while the class is constructed was never followed, contradicting the walker's stated coverage of class bodies. Helpers are now collected from the module body and from class bodies at any nesting. Verified: 15 targeted cases including all three fixes and the earlier ones still classify correctly; still 22 offenders on unpatched main; green on this branch and on the tree merged with latest main. * Handle positional read_text encodings, lazy generators and nested helpers * Guard reads reached from test bodies, unbound Path calls and __file__ paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Follow derived paths, skip lazy generator helpers, cover compressed openers * Guard the CLI tests, helper parameters and unbound Path arguments * Discover test roots and follow literal, in-place and tuple-derived paths * Identify module openers by import, unwrap starred paths, pin subprocess snippets * Resolve import origins, seed helper locals, follow named generators and parametrize * Scope imports lexically, list tracked test files, bind unpacked names * Resolve aliased openers, keyword-only params, destructured targets, next() * Pin the encoding on subprocess snippets, workflow lint and CLI output for PR #7438 * Harden the CLI encoding guard against detached streams for PR #7438 * Tighten the encoding guard's path and scope analysis for PR #7438 * Resolve path provenance more precisely and keep POSIX stream encodings for PR #7438 * Resolve qualified path classes and scope conditional imports for PR #7438 * Scope CLI stream setup to the entry point and align two encoding pairs for PR #7438 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
1252 lines
52 KiB
Python
1252 lines
52 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Guard: tests that read checked-in files must name their encoding.
|
|
|
|
`Path.read_text()` and `open()` with no encoding use `locale.getencoding()`:
|
|
UTF-8 on the Linux and macOS runners, cp1252 on a stock Windows install. A test
|
|
that reads a repo file that way passes in CI and raises UnicodeDecodeError for a
|
|
Windows contributor as soon as that file gains a non-ASCII byte, which the
|
|
source-scanning tests do constantly:
|
|
|
|
studio/backend/routes/inference.py carries the DeepSeek tool-call token
|
|
regexes, so it holds U+FF5C and U+2581. Reading it as cp1252 dies on
|
|
"byte 0x81", taking test_cancel_atomicity.py and test_cancel_id_wiring.py
|
|
out at collection time.
|
|
|
|
A call is an offence when it does un-pinned text I/O and either of two things
|
|
holds. It runs at import, where nothing can see a tmp_path fixture yet. Or the
|
|
path it reads anchors on something checked in: a module-level constant or
|
|
import, which a fixture parameter can never be, `__file__`, or a relative
|
|
literal that names a file actually present in the tree. Anchoring is what
|
|
decides the second one, followed through `/` joins, path methods, the locals
|
|
and loop variables of the enclosing function, and the parameters of helpers
|
|
every caller hands a checked-in path. So `for p in (_B / "routes").rglob("*.py")`
|
|
is in scope, `_source(LOADER_PATH)` puts the bare read inside `_source` in
|
|
scope, and anything growing out of a tmp_path stays out. That reaches test
|
|
bodies, where the same failure lands one step later:
|
|
|
|
test_gemma4_chat_template.py opens unsloth/chat_templates.py through a
|
|
helper its tests call, and cp1252 cannot decode that file ("byte 0x90").
|
|
test_consent_gate.py reads routes/inference.py as `(_BACKEND / rel)` and
|
|
test_gguf_load_cache_reuse.py as `Path(__file__).parent.parent / ...`, both
|
|
dying on the same 0x81 the two cancel modules hit at collection.
|
|
|
|
Every question the rules ask is answered by the call, its path expression, or
|
|
the call sites of the helper it sits in, which keeps them mechanical enough to
|
|
enforce with no allowlist and quiet about temp-dir I/O, where the platform
|
|
default is harmless and the test wrote the bytes itself.
|
|
|
|
Three shapes are consequently out of reach, all fixed by hand and none decidable
|
|
from the call. A path a helper hands back rather than takes in, as
|
|
`for path in _iter_caller_files()` does in test_security_gate_consistency.py,
|
|
says nothing about itself at the read. Text read from a checked-in file and
|
|
then written back to a tmp_path, at test_studio_install_workspace_guard.py:851
|
|
and test_scan_packages.py:40, is unsafe only because of where the string came
|
|
from. And a read inside a `python -c` snippet, as test_studio_import_no_torch.py
|
|
and test_e2e_no_torch_sandbox.py build for their subprocess tests, runs in a
|
|
child interpreter this scan never parses: the snippet is an f-string whose paths
|
|
are replacement fields, so recovering it would mean evaluating the
|
|
interpolation. Reviewers have to catch those three; running the suite under
|
|
LC_ALL=C is the cheapest way to find them, since ASCII rejects every byte cp1252
|
|
does and more.
|
|
"""
|
|
|
|
# `str | None` below is evaluated at import on Python 3.9 without this, and
|
|
# pyproject declares requires-python = ">=3.9,<3.15".
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
TESTS = Path(__file__).resolve().parent
|
|
REPO = TESTS.parent
|
|
# Both trees ship to Windows contributors, and separate CI jobs collect them
|
|
# (repo-cpu-tests and the studio-backend matrix), so the rule covers both.
|
|
# Not a hand-written list: studio/backend/hub/tests and unsloth/kernels/moe/tests
|
|
# are already here, and the next one has to be covered the day it lands.
|
|
SKIP_DIRS = {".git", ".venv", "build", "dist", "frontend", "node_modules", "site-packages"}
|
|
|
|
|
|
def _walked_test_files(repo: Path):
|
|
"""Every *.py under a tests directory, found by walking."""
|
|
found = []
|
|
for dirpath, dirnames, filenames in os.walk(repo):
|
|
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
if "tests" not in Path(dirpath).relative_to(repo).parts:
|
|
continue
|
|
found.extend(Path(dirpath) / f for f in filenames if f.endswith(".py"))
|
|
return found
|
|
|
|
|
|
def _tracked_test_files(repo: Path):
|
|
"""The same, but only what git is actually tracking.
|
|
|
|
A walk picks up whatever happens to be lying in the checkout: a scratch
|
|
directory, a nested worktree, a vendored dependency. None of those are ours
|
|
to police, and a single syntax error in one would fail this test for
|
|
everybody who has one. Asking git keeps the promise the docstring makes.
|
|
"""
|
|
try:
|
|
listed = subprocess.run(
|
|
["git", "-C", str(repo), "ls-files", "-z", "--", "*.py"],
|
|
capture_output = True,
|
|
timeout = 60,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if listed.returncode != 0:
|
|
return None # not a checkout, so fall back to walking
|
|
names = listed.stdout.decode("utf-8", errors = "replace").split("\0")
|
|
return [
|
|
repo / name
|
|
for name in names
|
|
if name and "tests" in Path(name).parts and not SKIP_DIRS.intersection(Path(name).parts)
|
|
]
|
|
|
|
|
|
SOURCES = _tracked_test_files(REPO)
|
|
if SOURCES is None:
|
|
SOURCES = _walked_test_files(REPO)
|
|
GUARDED_METHODS = {"read_text", "write_text"}
|
|
# Openers that are somebody else's are recognised by the file's own imports
|
|
# rather than a fixed list, so `import tarfile as tf` and `from PIL import
|
|
# Image` are both covered without naming either.
|
|
# These wrap their stream in a TextIOWrapper for a "t" mode, which takes the
|
|
# platform default exactly like builtin open. Unlike open they default to "rb",
|
|
# so only an explicit text mode is in scope. lzma takes encoding keyword-only.
|
|
COMPRESSED_OPENERS = {"bz2": 3, "gzip": 3, "lzma": None}
|
|
# Wrappers that stay lazy, so draining one drains what it was given.
|
|
LAZY_ADAPTERS = {"enumerate", "filter", "islice", "map", "reversed", "zip"}
|
|
# Callables that drain a generator argument immediately.
|
|
EAGER_CONSUMERS = {
|
|
"all",
|
|
"any",
|
|
"dict",
|
|
"frozenset",
|
|
"list",
|
|
"max",
|
|
"min",
|
|
"next",
|
|
"set",
|
|
"sorted",
|
|
"sum",
|
|
"tuple",
|
|
}
|
|
# Values that re-select the platform default when passed as the encoding.
|
|
PLATFORM_DEFAULT_ENCODINGS = (None, "locale")
|
|
# `Path.read_text(p)` is the unbound spelling of `p.read_text()`: same API, same
|
|
# platform default, but the instance takes the first slot so every argument
|
|
# shifts one place right.
|
|
PATH_CLASSES = {"Path", "PosixPath", "PurePath", "WindowsPath"}
|
|
# Modules whose `open` IS the builtin: same signature, same platform default.
|
|
BUILTIN_OPEN_MODULES = {"builtins", "io"}
|
|
# Receivers `self.SOURCE` and `cls.SOURCE` reach a class attribute through.
|
|
SELF_NAMES = {"cls", "self"}
|
|
# A module-level name is normally an anchor, since a fixture cannot reach one.
|
|
# These build a directory the run owns, so a name rooted in one is temp I/O
|
|
# however it is spelled, and the platform default there is harmless.
|
|
TEMP_FACTORIES = {
|
|
"NamedTemporaryFile",
|
|
"TemporaryDirectory",
|
|
"gettempdir",
|
|
"mkdtemp",
|
|
"mkstemp",
|
|
}
|
|
# Functions that hand back a path still pointing at their first argument. An
|
|
# unlisted call is left unresolved: a helper may well return a temp copy of what
|
|
# it was given, and following it would put test-created files back in scope.
|
|
PATH_FUNCTIONS = {
|
|
"abspath",
|
|
"dirname",
|
|
"expanduser",
|
|
"fspath",
|
|
"join",
|
|
"normpath",
|
|
"realpath",
|
|
"relpath",
|
|
"str",
|
|
}
|
|
# Path methods that hand back another path, so the receiver is still the anchor.
|
|
PATH_METHODS = {
|
|
"absolute",
|
|
"as_posix",
|
|
"expanduser",
|
|
"glob",
|
|
"iterdir",
|
|
"joinpath",
|
|
"resolve",
|
|
"rglob",
|
|
"with_name",
|
|
"with_stem",
|
|
"with_suffix",
|
|
}
|
|
# Where each API takes its encoding positionally, for the bound call.
|
|
ENCODING_POSITION = {"read_text": 0, "write_text": 1, "Path.open": 2, "open": 3}
|
|
# Distinct from None so that "no mode argument at all" still means text.
|
|
UNKNOWN_MODE = object()
|
|
# Stand-in for a file whose imports are not to hand, so every helper can be
|
|
# called on its own without pretending it knows what was imported.
|
|
NO_MODULES: dict = {}
|
|
|
|
|
|
def _static_truth(node: ast.AST):
|
|
"""Whether a condition is a literal true or false, else None for "depends"."""
|
|
return bool(node.value) if isinstance(node, ast.Constant) else None
|
|
|
|
|
|
def _live_branches(node: ast.AST):
|
|
"""The children of a branch that can actually run, or None if it is not one.
|
|
|
|
`if False:` and the right of `False and ...` never execute, so reporting a
|
|
read there is a CI failure with no reachable cause and no correct edit.
|
|
"""
|
|
if isinstance(node, ast.If):
|
|
taken = _static_truth(node.test)
|
|
if taken is None:
|
|
return None
|
|
return [node.test, *(node.body if taken else node.orelse)]
|
|
if isinstance(node, ast.IfExp):
|
|
taken = _static_truth(node.test)
|
|
if taken is None:
|
|
return None
|
|
return [node.test, node.body if taken else node.orelse]
|
|
if isinstance(node, ast.BoolOp) and node.values:
|
|
# `and` stops at the first false operand, `or` at the first true one.
|
|
stops = isinstance(node.op, ast.Or)
|
|
live = []
|
|
for value in node.values:
|
|
live.append(value)
|
|
if _static_truth(value) is stops:
|
|
break
|
|
return live if len(live) < len(node.values) else None
|
|
return None
|
|
|
|
|
|
def _callee_name(func: ast.AST):
|
|
"""The bare name a callee ends in, whether or not it is qualified."""
|
|
return func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)
|
|
|
|
|
|
def _is_main_guard(node: ast.AST) -> bool:
|
|
"""True for `if __name__ == "__main__":`, whose body never runs at import.
|
|
|
|
The operator has to be `==`: `if __name__ != "__main__":` runs its body at
|
|
import, so treating it as script-only would invert the rule.
|
|
"""
|
|
if not isinstance(node, ast.If) or not isinstance(node.test, ast.Compare):
|
|
return False
|
|
if not all(isinstance(op, ast.Eq) for op in node.test.ops):
|
|
return False
|
|
operands = [node.test.left, *node.test.comparators]
|
|
# Either spelling: `__name__ == "__main__"` or `"__main__" == __name__`.
|
|
has_name = any(isinstance(o, ast.Name) and o.id == "__name__" for o in operands)
|
|
has_main = any(isinstance(o, ast.Constant) and o.value == "__main__" for o in operands)
|
|
return has_name and has_main
|
|
|
|
|
|
def _is_eager_consumer(func: ast.expr) -> bool:
|
|
"""True for a callee that drains a generator argument on the spot.
|
|
|
|
iter/zip/map/filter/enumerate/reversed hand back another lazy object, so a
|
|
genexp passed to those still has not run.
|
|
"""
|
|
if isinstance(func, ast.Attribute):
|
|
return func.attr in {"join", "extend", "update", "writelines"}
|
|
return isinstance(func, ast.Name) and func.id in EAGER_CONSUMERS
|
|
|
|
|
|
def _import_time_calls(tree: ast.Module):
|
|
"""Yield Call nodes that run at import time.
|
|
|
|
That is module scope, class bodies, and the bodies of module-level helpers
|
|
invoked from either. A helper is the same hazard as an inline read:
|
|
`CODE = _extract_mixed_precision_code()` runs its `read_text()` during
|
|
collection, so skipping every def would let the Windows failure back in.
|
|
|
|
A def's body waits for a call, but its decorators and argument defaults run
|
|
when the def executes, so those are followed. Lambda bodies are skipped for
|
|
the same reason, as is everything but the outermost iterable of a generator
|
|
expression. List, set and dict comprehensions are walked in full: unlike a
|
|
genexp they run their element, filters and nested iterators immediately.
|
|
|
|
A body is only ever entered through an executed statement, never by walking
|
|
into a def, so the "this definitely runs" property that makes the rule
|
|
allowlist-free holds. Not followed: the body of
|
|
`if __name__ == "__main__":`, which pytest never runs (its `else` arm does,
|
|
so that is walked), and non-name calls, which are left unresolved rather
|
|
than guessed at.
|
|
"""
|
|
# Defs reachable from a scope that executes at import: module body, any
|
|
# class body, and (added when the helper is entered) any def nested inside
|
|
# a helper we follow. `class F: def _load(): ...; DATA = _load()` runs
|
|
# _load while the class is constructed.
|
|
helpers: dict = {}
|
|
|
|
def _collect(body):
|
|
scopes = [body]
|
|
while scopes:
|
|
for node in scopes.pop():
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
helpers.setdefault(node.name, node)
|
|
elif isinstance(node, ast.ClassDef):
|
|
scopes.append(node.body)
|
|
|
|
_collect(tree.body)
|
|
consumed = _eagerly_consumed(tree)
|
|
entered = set()
|
|
frontier = [list(tree.body)]
|
|
while frontier:
|
|
stack = frontier.pop()
|
|
while stack:
|
|
node = stack.pop()
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
# The body waits for a call; these two run right now.
|
|
stack.extend(node.decorator_list)
|
|
stack.extend(d for d in node.args.defaults if d is not None)
|
|
stack.extend(d for d in node.args.kw_defaults if d is not None)
|
|
continue
|
|
if isinstance(node, ast.Lambda):
|
|
stack.extend(d for d in node.args.defaults if d is not None)
|
|
stack.extend(d for d in node.args.kw_defaults if d is not None)
|
|
continue
|
|
if isinstance(node, ast.GeneratorExp) and id(node) not in consumed:
|
|
# Lazy: only the outermost iterable is evaluated where written.
|
|
if node.generators:
|
|
stack.append(node.generators[0].iter)
|
|
continue
|
|
if _is_main_guard(node):
|
|
stack.extend(node.orelse) # the else arm runs at import
|
|
continue
|
|
live = _live_branches(node)
|
|
if live is not None:
|
|
stack.extend(live) # the dead arm never runs, so nothing in it does
|
|
continue
|
|
if isinstance(node, ast.Call):
|
|
yield node
|
|
func = node.func
|
|
if isinstance(func, ast.Name) and func.id in helpers and func.id not in entered:
|
|
helper = helpers[func.id]
|
|
# `READS = _load(paths)` on a generator function only builds
|
|
# the generator, so its body waits for a consumer just as a
|
|
# genexp does.
|
|
if not _is_generator(helper) or id(node) in consumed:
|
|
entered.add(func.id)
|
|
body = list(helper.body)
|
|
_collect(body) # a def nested here is now callable
|
|
frontier.append(body)
|
|
stack.extend(ast.iter_child_nodes(node))
|
|
|
|
|
|
def _eagerly_consumed(tree: ast.Module) -> set:
|
|
"""Nodes whose lazy value is drained right where it is written.
|
|
|
|
Covers both things that defer: a generator expression, and a call to a
|
|
generator function. Neither runs its body until something pulls from it, so
|
|
an unconsumed one has not happened yet.
|
|
"""
|
|
# `texts = (p.read_text() for p in ...)` then `list(texts)` consumes the
|
|
# generator through a name, so the name has to lead back to it.
|
|
named: dict = {}
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
target = node.targets[0]
|
|
if isinstance(target, ast.Name) and isinstance(node.value, ast.GeneratorExp):
|
|
named.setdefault(target.id, node.value)
|
|
|
|
def _resolve(node):
|
|
if isinstance(node, ast.Name) and node.id in named:
|
|
return named[node.id]
|
|
return node
|
|
|
|
consumed = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Call) and _is_eager_consumer(node.func):
|
|
consumed.update(id(_resolve(a)) for a in node.args)
|
|
consumed.update(id(_resolve(k.value)) for k in node.keywords)
|
|
elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
|
|
consumed.add(id(_resolve(node.iter))) # the loop pulls every item
|
|
# `list(enumerate(_paths()))` drains _paths() as well, one wrapper down.
|
|
by_id = {id(n): n for n in ast.walk(tree)}
|
|
queue = [by_id[i] for i in list(consumed) if i in by_id]
|
|
while queue:
|
|
node = queue.pop()
|
|
if isinstance(node, ast.Call) and _callee_name(node.func) in LAZY_ADAPTERS:
|
|
for arg in node.args:
|
|
target = _resolve(arg)
|
|
if id(target) not in consumed:
|
|
consumed.add(id(target))
|
|
queue.append(target)
|
|
return consumed
|
|
|
|
|
|
def _temp_rooted_names(tree: ast.Module) -> set:
|
|
"""Module-level names anchored on a directory the run itself created."""
|
|
names = set()
|
|
for node in tree.body:
|
|
value = node.value if isinstance(node, (ast.Assign, ast.AnnAssign)) else None
|
|
if value is None:
|
|
continue
|
|
if any(
|
|
isinstance(n, ast.Call) and _callee_name(n.func) in TEMP_FACTORIES
|
|
for n in ast.walk(value)
|
|
):
|
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
names.update(t.id for t in targets if isinstance(t, ast.Name))
|
|
return names
|
|
|
|
|
|
def _non_path_names(tree: ast.Module) -> set:
|
|
"""Module-level names bound to a call that plainly does not make a path.
|
|
|
|
`response = requests.get(...)` then `response.read_text()` at import is not
|
|
pathlib I/O, and demanding an encoding there leaves no compliant edit.
|
|
"""
|
|
names = set()
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
|
|
continue
|
|
func = node.value.func
|
|
if _is_path_preserving(func) or _callee_name(func) in PATH_METHODS:
|
|
continue
|
|
names.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
|
return names
|
|
|
|
|
|
def _is_generator(func) -> bool:
|
|
"""True when calling this only builds a generator, leaving the body unrun.
|
|
|
|
Yields inside a nested def belong to that def, so those do not count.
|
|
"""
|
|
stack = list(func.body)
|
|
while stack:
|
|
node = stack.pop()
|
|
if isinstance(node, (ast.Yield, ast.YieldFrom)):
|
|
return True
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
continue
|
|
stack.extend(ast.iter_child_nodes(node))
|
|
return False
|
|
|
|
|
|
def _module_level_names(tree: ast.Module) -> set:
|
|
"""Names assigned at module scope."""
|
|
|
|
def _bound(target):
|
|
# `SOURCE, CONFIG = Path(...), Path(...)` binds both.
|
|
if isinstance(target, ast.Name):
|
|
yield target.id
|
|
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
for element in target.elts:
|
|
yield from _bound(element)
|
|
elif isinstance(target, ast.Starred):
|
|
yield from _bound(target.value)
|
|
|
|
def _is_temp(value) -> bool:
|
|
return value is not None and any(
|
|
isinstance(n, ast.Call) and _callee_name(n.func) in TEMP_FACTORIES
|
|
for n in ast.walk(value)
|
|
)
|
|
|
|
names = set()
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Assign):
|
|
if _is_temp(node.value):
|
|
continue # TMP = Path(tempfile.mkdtemp()) is not checked in
|
|
for target in node.targets:
|
|
names.update(_bound(target))
|
|
elif isinstance(node, ast.AnnAssign):
|
|
if _is_temp(node.value):
|
|
continue
|
|
names.update(_bound(node.target))
|
|
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
# `start._CODEX_FALLBACK_PROMPT` is a path another module defines at
|
|
# its own module scope, so the import is an anchor like any constant.
|
|
names.update((a.asname or a.name).split(".")[0] for a in node.names)
|
|
return names
|
|
|
|
|
|
def _local_names(func) -> set:
|
|
"""Every name the function binds, so a module constant it shadows is skipped.
|
|
|
|
Walking nested defs too over-approximates, which only ever drops a call from
|
|
the scan.
|
|
"""
|
|
args = func.args
|
|
names = {a.arg for a in [*args.posonlyargs, *args.args, *args.kwonlyargs]}
|
|
for extra in (args.vararg, args.kwarg):
|
|
if extra is not None:
|
|
names.add(extra.arg)
|
|
stack = list(ast.iter_child_nodes(func))
|
|
while stack:
|
|
node = stack.pop()
|
|
if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
# A comprehension target binds in its own scope, so it shadows
|
|
# nothing out here; the rest of the comprehension still does.
|
|
for gen in node.generators:
|
|
stack.append(gen.iter)
|
|
stack.extend(gen.ifs)
|
|
stack.extend([node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt])
|
|
continue
|
|
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
names.add(node.id)
|
|
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
names.update((a.asname or a.name).split(".")[0] for a in node.names)
|
|
stack.extend(ast.iter_child_nodes(node))
|
|
return names
|
|
|
|
|
|
def _imported_names(node) -> dict:
|
|
"""Names this scope's own imports bind, mapped to where they came from.
|
|
|
|
The name alone is not enough in either direction. `import gzip as gz` binds
|
|
a name nobody would recognise to an opener that does take an encoding, and
|
|
`from PIL.Image import open` binds a name everybody recognises to one that
|
|
does not. Keeping the origin settles both.
|
|
|
|
Nested function bodies are left out: an import inside one is that
|
|
function's business, and treating it as the module's would let a single
|
|
local `from PIL.Image import open` turn off the builtin check everywhere.
|
|
"""
|
|
bound = {}
|
|
stack = list(ast.iter_child_nodes(node))
|
|
while stack:
|
|
item = stack.pop()
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
continue
|
|
if isinstance(item, (ast.Import, ast.ImportFrom)):
|
|
bound.update(_import_bindings(item))
|
|
else:
|
|
stack.extend(ast.iter_child_nodes(item))
|
|
return bound
|
|
|
|
|
|
def _import_bindings(node) -> dict:
|
|
"""What one import statement binds, mapped to where each name came from."""
|
|
if isinstance(node, ast.Import):
|
|
return {(a.asname or a.name).split(".")[0]: a.name for a in node.names}
|
|
return {
|
|
a.asname or a.name: (f"{node.module}.{a.name}" if node.module else a.name)
|
|
for a in node.names
|
|
}
|
|
|
|
|
|
def _imports_at_each_call(tree: ast.Module) -> dict:
|
|
"""The imports visible at every call, keyed by node id.
|
|
|
|
A function's own imports are added on the way in and go out of view again
|
|
on the way out, which is what keeps a local alias local. Within a scope they
|
|
accumulate in statement order, so `DATA = open(p)` above a later
|
|
`from gzip import open` still resolves to the builtin it actually called.
|
|
"""
|
|
visible_at = {}
|
|
|
|
def walk(node, visible):
|
|
if isinstance(node, ast.Call):
|
|
visible_at[id(node)] = dict(visible)
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
visible.update(_import_bindings(node))
|
|
return
|
|
if isinstance(node, ast.If):
|
|
# Only a branch that certainly runs may bind a name for the code
|
|
# after it; the others are explored with a copy that is thrown away.
|
|
taken = _static_truth(node.test)
|
|
walk(node.test, visible)
|
|
for arm, runs in ((node.body, taken is not False), (node.orelse, taken is not True)):
|
|
if not runs:
|
|
continue
|
|
inner = visible if taken is not None else dict(visible)
|
|
for child in arm:
|
|
walk(child, inner)
|
|
return
|
|
for child in ast.iter_child_nodes(node):
|
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
walk(child, dict(visible)) # its own scope, so its own copy
|
|
else:
|
|
walk(child, visible)
|
|
|
|
walk(tree, {})
|
|
return visible_at
|
|
|
|
|
|
def _open_alias(name, modules):
|
|
"""What a bare callable resolves to: "builtin", a COMPRESSED_OPENERS key, or None.
|
|
|
|
`from io import open as io_open` is the builtin under another name and
|
|
`from gzip import open as gzopen` is gzip's, while `from PIL.Image import
|
|
open` is neither and takes no encoding at all.
|
|
"""
|
|
origin = modules.get(name)
|
|
if origin is None:
|
|
return "builtin" if name == "open" else None
|
|
parts = origin.split(".")
|
|
if parts[-1] != "open":
|
|
return None
|
|
if parts[0] in BUILTIN_OPEN_MODULES or origin == "open":
|
|
return "builtin"
|
|
return parts[0] if parts[0] in COMPRESSED_OPENERS else None
|
|
|
|
|
|
def _origin_root(name, modules) -> str:
|
|
"""The top-level module a bound name came from, or the name itself."""
|
|
return modules.get(name, name).split(".")[0]
|
|
|
|
|
|
def _compressed_key(name, modules):
|
|
"""The COMPRESSED_OPENERS entry this receiver resolves to, if any."""
|
|
for candidate in (name, _origin_root(name, modules)):
|
|
if candidate in COMPRESSED_OPENERS:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _is_path_class(name, modules) -> bool:
|
|
"""True for a pathlib class, including under an alias.
|
|
|
|
`from pathlib import Path as P` still puts the instance in slot 0 of an
|
|
unbound `P.read_text(SOURCE)`, so matching the bare name is not enough.
|
|
"""
|
|
if name is None:
|
|
return False
|
|
return (modules.get(name) or name).split(".")[-1] in PATH_CLASSES
|
|
|
|
|
|
def _is_path_attr(node: ast.AST) -> bool:
|
|
"""True for a qualified path class, as in `pathlib.Path` or `pl.Path`."""
|
|
return isinstance(node, ast.Attribute) and node.attr in PATH_CLASSES
|
|
|
|
|
|
def _is_path_preserving(func) -> bool:
|
|
"""True for a call whose result still points at its first argument.
|
|
|
|
Qualified spellings count: `pathlib.Path(p)` and `os.path.join(p, x)` are
|
|
the same constructors as the bare names.
|
|
"""
|
|
name = _callee_name(func)
|
|
return name in PATH_CLASSES or name in PATH_FUNCTIONS
|
|
|
|
|
|
def _is_module_receiver(name, modules) -> bool:
|
|
"""True for a receiver that is not itself a path."""
|
|
return (
|
|
name in modules
|
|
or _is_path_class(name, modules)
|
|
or _compressed_key(name, modules) is not None
|
|
or _origin_root(name, modules) in BUILTIN_OPEN_MODULES
|
|
)
|
|
|
|
|
|
def _path_expr(call: ast.Call, modules = NO_MODULES):
|
|
"""The expression naming the file the call reads.
|
|
|
|
Usually the receiver, but a module or the Path class in that slot means the
|
|
path is the first argument instead: `Path.read_text(REPO / "x.py")` and
|
|
`gzip.open(path, "rt")` both read their argument, not `Path` or `gzip`.
|
|
"""
|
|
func = call.func
|
|
if isinstance(func, ast.Attribute):
|
|
if _is_path_attr(func.value) or (
|
|
isinstance(func.value, ast.Name) and _is_module_receiver(func.value.id, modules)
|
|
):
|
|
return call.args[0] if call.args else _path_keyword(call)
|
|
return func.value
|
|
if isinstance(func, ast.Name) and _open_alias(func.id, modules) is not None:
|
|
return call.args[0] if call.args else _path_keyword(call)
|
|
return None
|
|
|
|
|
|
def _path_keyword(call: ast.Call):
|
|
"""The path passed by keyword: `file` for open, `filename` for gzip and kin."""
|
|
for kw in call.keywords:
|
|
if kw.arg in ("file", "filename"):
|
|
return kw.value
|
|
return None
|
|
|
|
|
|
def _path_root(node: ast.AST) -> ast.AST:
|
|
"""Follow a path expression back to whatever it is anchored on.
|
|
|
|
`(_BACKEND / rel).read_text()` anchors on _BACKEND and
|
|
`Path(__file__).parent / "routes"` on __file__, so joining a relative name
|
|
onto a checked-in root stays in scope. Anchoring is what decides it, not the
|
|
names further down: `tmp_path / SUBDIR` anchors on the fixture, so a
|
|
constant used as a leaf cannot drag temp-dir I/O in.
|
|
"""
|
|
while True:
|
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
|
|
node = node.left
|
|
elif isinstance(node, (ast.Attribute, ast.Subscript)):
|
|
if (
|
|
isinstance(node, ast.Attribute)
|
|
and isinstance(node.value, ast.Name)
|
|
and node.value.id in SELF_NAMES
|
|
):
|
|
return node # self.SOURCE names the class attribute, not self
|
|
node = node.value
|
|
elif isinstance(node, ast.Call):
|
|
func = node.func
|
|
# `p.rglob("*.py")` anchors on p, not on the pattern, while
|
|
# Path(x), str(x) and os.path.join(x, ...) anchor on the argument.
|
|
if isinstance(func, ast.Attribute) and func.attr in PATH_METHODS:
|
|
node = func.value
|
|
elif _is_path_preserving(func) and node.args:
|
|
node = node.args[0]
|
|
else:
|
|
# An unrecognised call says nothing about where its result
|
|
# points, so tempfile.mkdtemp() and a helper that copies its
|
|
# argument into a temp dir both stop here.
|
|
return node
|
|
else:
|
|
return node
|
|
|
|
|
|
def _is_checked_in_root(
|
|
node: ast.AST,
|
|
module_names: set,
|
|
shadowed,
|
|
derived = (),
|
|
attrs = (),
|
|
) -> bool:
|
|
"""True when a path expression anchors on something that ships in the repo."""
|
|
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
|
# `for path in (MODEL_SELECTOR, APP_SIDEBAR)` is checked in when every
|
|
# element is, which is what makes the loop variable one too.
|
|
return bool(node.elts) and all(
|
|
_is_checked_in_root(
|
|
e.value if isinstance(e, ast.Starred) else e,
|
|
module_names,
|
|
shadowed,
|
|
derived,
|
|
attrs,
|
|
)
|
|
for e in node.elts
|
|
)
|
|
root = _path_root(node)
|
|
if isinstance(root, ast.Constant) and isinstance(root.value, str):
|
|
# A relative literal naming something that exists here is checked in; a
|
|
# path the test creates at runtime is not in the tree to be found.
|
|
value = root.value
|
|
if not value or "\n" in value or "\0" in value or os.path.isabs(value):
|
|
return False
|
|
try:
|
|
return (REPO / value).exists()
|
|
except OSError:
|
|
return False # too long to be a name, so not one
|
|
if isinstance(root, ast.Attribute):
|
|
# `self.SOURCE`, where the class body bound SOURCE to a checked-in path.
|
|
return root.attr in attrs
|
|
if not isinstance(root, ast.Name):
|
|
return False
|
|
if root.id in derived:
|
|
return True
|
|
return root.id == "__file__" or (root.id in module_names and root.id not in shadowed)
|
|
|
|
|
|
def _class_path_attrs(tree: ast.Module, module_names: set) -> set:
|
|
"""Class-body names bound to a checked-in path, read back as `self.NAME`.
|
|
|
|
`class T: _SETUP_SH = ROOT / "setup.sh"` then `self._SETUP_SH.read_text()`
|
|
is as statically provable as the module-level spelling, and the repository
|
|
reads seven real source files exactly that way.
|
|
"""
|
|
attrs, mixed = set(), set()
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.ClassDef):
|
|
continue
|
|
for stmt in node.body:
|
|
if isinstance(stmt, ast.Assign):
|
|
targets = stmt.targets
|
|
elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
targets = [stmt.target]
|
|
else:
|
|
continue
|
|
bound = {t.id for t in targets if isinstance(t, ast.Name)}
|
|
# One attribute name, two classes, two meanings: only one of them is
|
|
# provable, so neither is claimed. Same rule as the local walk.
|
|
found = attrs if _is_checked_in_root(stmt.value, module_names, ()) else mixed
|
|
found.update(bound)
|
|
return attrs - mixed
|
|
|
|
|
|
def _reads_itself(name: str, value: ast.AST) -> bool:
|
|
"""`source = source.read_text()` reads the path before replacing it.
|
|
|
|
The name holds a checked-in path right up to that call, so the assignment
|
|
is not evidence against it; it is the very read we are looking for.
|
|
"""
|
|
if not isinstance(value, ast.Call):
|
|
return False
|
|
expr = _path_expr(value)
|
|
return isinstance(expr, ast.Name) and expr.id == name
|
|
|
|
|
|
def _unpack(target, value, paired: bool):
|
|
"""Yield (name node, the value it is bound to) for one binding.
|
|
|
|
A destructured target contributes every name inside it. Where the two sides
|
|
line up, as in `A, B = P1, P2`, each name takes its own element; where they
|
|
do not, as in `for name, path in CASES`, they all take the iterable, which
|
|
is the thing whose provenance is known.
|
|
"""
|
|
if isinstance(target, ast.Name):
|
|
yield target, value
|
|
return
|
|
if not isinstance(target, (ast.Tuple, ast.List)):
|
|
return
|
|
elements = None
|
|
if paired and isinstance(value, (ast.Tuple, ast.List)) and len(value.elts) == len(target.elts):
|
|
elements = value.elts
|
|
for index, element in enumerate(target.elts):
|
|
if isinstance(element, ast.Starred):
|
|
element = element.value
|
|
yield from _unpack(element, elements[index] if elements else value, paired)
|
|
|
|
|
|
def _checked_in_locals(
|
|
func,
|
|
module_names: set,
|
|
shadowed,
|
|
seed = (),
|
|
) -> set:
|
|
"""Locals that only ever hold a checked-in path.
|
|
|
|
`route = Path(_BACKEND_DIR) / "routes" / "inference.py"` followed by
|
|
`route.read_text()` is the same read one line apart. A name bound any other
|
|
way, or assigned anything else anywhere in the scope, is not tracked, and
|
|
the pass repeats so that a path built up over several locals still counts.
|
|
"""
|
|
assignments = []
|
|
targets = set()
|
|
bad = set()
|
|
for node in ast.walk(func):
|
|
paired = False
|
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
target, value = node.targets[0], node.value
|
|
paired = True # `A, B = P1, P2` lines its sides up element by element
|
|
elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
|
|
# `for p in SRC_DIR.rglob("*.py")` binds p to a checked-in path too,
|
|
# and `for name, path in CASES` binds both to the same iterable.
|
|
target, value = node.target, node.iter
|
|
else:
|
|
continue
|
|
for name_node, bound in _unpack(target, value, paired):
|
|
targets.add(id(name_node))
|
|
if not _reads_itself(name_node.id, bound):
|
|
assignments.append((name_node.id, bound))
|
|
for node in ast.walk(func):
|
|
# A with-as or an augassign says nothing about the value it binds.
|
|
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
if id(node) not in targets:
|
|
bad.add(node.id)
|
|
args = func.args
|
|
bad.update(a.arg for a in [*args.posonlyargs, *args.args, *args.kwonlyargs])
|
|
# A parameter every caller hands a checked-in path is the exception.
|
|
bad -= set(seed)
|
|
good: set = set(seed)
|
|
while True:
|
|
grown = set(good) | {
|
|
name
|
|
for name, value in assignments
|
|
if name not in bad and _is_checked_in_root(value, module_names, shadowed, good)
|
|
}
|
|
# A name assigned a checked-in path somewhere and something else
|
|
# elsewhere stays out, since only one of the two is provable.
|
|
grown -= {
|
|
name
|
|
for name, value in assignments
|
|
if name in grown and not _is_checked_in_root(value, module_names, shadowed, good)
|
|
}
|
|
if grown == good:
|
|
return good
|
|
good = grown
|
|
|
|
|
|
def _unwrap_param(node: ast.AST) -> ast.AST:
|
|
"""`pytest.param(SOURCE, id = "x")` is a wrapper around the real value."""
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "param"
|
|
and node.args
|
|
):
|
|
return node.args[0]
|
|
return node
|
|
|
|
|
|
def _parametrized_values(func) -> dict:
|
|
"""Parameter values supplied by @pytest.mark.parametrize.
|
|
|
|
pytest calls a parametrized test itself, so the decorator is the only call
|
|
site there is; without reading it every such parameter looks unprovable.
|
|
"""
|
|
supplied: dict = {}
|
|
for decorator in func.decorator_list:
|
|
if not isinstance(decorator, ast.Call) or len(decorator.args) < 2:
|
|
continue
|
|
if not isinstance(decorator.func, ast.Attribute) or decorator.func.attr != "parametrize":
|
|
continue
|
|
names, values = decorator.args[0], decorator.args[1]
|
|
if not isinstance(names, ast.Constant) or not isinstance(names.value, str):
|
|
continue
|
|
if not isinstance(values, (ast.List, ast.Tuple, ast.Set)):
|
|
continue
|
|
argnames = [n.strip() for n in names.value.split(",") if n.strip()]
|
|
for element in values.elts:
|
|
paired = len(argnames) > 1 and isinstance(element, (ast.Tuple, ast.List))
|
|
row = element.elts if paired else [element]
|
|
for argname, value in zip(argnames, row):
|
|
supplied.setdefault(argname, []).append(_unwrap_param(value))
|
|
return supplied
|
|
|
|
|
|
def _checked_in_params(tree: ast.Module, module_names: set) -> set:
|
|
"""(function, parameter) pairs that only ever receive a checked-in path.
|
|
|
|
`_source(LOADER_PATH)` is what tells us that the `path` parameter of
|
|
`_source` is reading a file that ships in the repo; the bare
|
|
`path.read_text()` inside it cannot say so on its own. One hop only, and a
|
|
parameter any call leaves out, or passes anything else, is not tracked.
|
|
|
|
Definitions are held by identity, not by name. Two tests that each nest a
|
|
`_read` helper are two different functions, and merging them would let the
|
|
one handed a tmp_path rule out what the other proves.
|
|
"""
|
|
# Every definition, plus which scope it was written in, so a call resolves
|
|
# to the nearest enclosing `def` of that name the way Python resolves it.
|
|
scope_of: dict = {}
|
|
defs_in: dict = {}
|
|
|
|
def _index(node, scope):
|
|
for child in ast.iter_child_nodes(node):
|
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
defs_in.setdefault(id(scope), {}).setdefault(child.name, child)
|
|
scope_of[id(child)] = scope
|
|
_index(child, child)
|
|
elif isinstance(child, ast.ClassDef):
|
|
_index(child, scope) # a class body is not a name lookup scope
|
|
else:
|
|
_index(child, scope)
|
|
|
|
_index(tree, tree)
|
|
|
|
def _lookup(name, scope):
|
|
while scope is not None:
|
|
found = defs_in.get(id(scope), {}).get(name)
|
|
if found is not None:
|
|
return found
|
|
scope = scope_of.get(id(scope))
|
|
return None
|
|
|
|
# Which function each call sits in, so a parameter already known to hold a
|
|
# checked-in path can be passed on to the next helper.
|
|
owner: dict = {}
|
|
|
|
def _mark(node, owning):
|
|
if isinstance(node, ast.Call):
|
|
owner[id(node)] = owning
|
|
for child in ast.iter_child_nodes(node):
|
|
nested = isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
_mark(child, child if nested else owning)
|
|
|
|
_mark(tree, None)
|
|
# Which class body each call sits in, so `self._read(...)` resolves to that
|
|
# class's method and not a same-named one in a sibling class.
|
|
in_class: dict = {}
|
|
|
|
def _mark_class(node, cls):
|
|
if isinstance(node, ast.Call):
|
|
in_class[id(node)] = cls
|
|
for child in ast.iter_child_nodes(node):
|
|
_mark_class(child, child if isinstance(child, ast.ClassDef) else cls)
|
|
|
|
_mark_class(tree, None)
|
|
|
|
def _method(cls, name):
|
|
if cls is None:
|
|
return None
|
|
for stmt in cls.body:
|
|
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)) and stmt.name == name:
|
|
return stmt
|
|
return None
|
|
|
|
good: set = set()
|
|
while True:
|
|
grown, bad = set(), set()
|
|
for fnode in [d for scope in defs_in.values() for d in scope.values()]:
|
|
for argname, values in _parametrized_values(fnode).items():
|
|
ok = all(_is_checked_in_root(v, module_names, ()) for v in values)
|
|
(grown if ok else bad).add((id(fnode), argname))
|
|
# What the calling function itself can prove, recomputed each pass so a
|
|
# parameter resolved last time can feed a local this time.
|
|
scope: dict = {}
|
|
for call in ast.walk(tree):
|
|
if not isinstance(call, ast.Call):
|
|
continue
|
|
caller = owner.get(id(call))
|
|
callee, bound = call.func, False
|
|
if isinstance(callee, ast.Name):
|
|
func = _lookup(callee.id, caller if caller is not None else tree)
|
|
elif (
|
|
isinstance(callee, ast.Attribute)
|
|
and isinstance(callee.value, ast.Name)
|
|
and callee.value.id in SELF_NAMES
|
|
):
|
|
# `self._read(ROOT / "x.py")` seeds `_read`'s path parameter too.
|
|
func, bound = _method(in_class.get(id(call)), callee.attr), True
|
|
else:
|
|
continue
|
|
if func is None or any(isinstance(a, ast.Starred) for a in call.args):
|
|
continue
|
|
if caller is None:
|
|
here = set()
|
|
elif id(caller) in scope:
|
|
here = scope[id(caller)]
|
|
else:
|
|
params = {p for f, p in good if f == id(caller)}
|
|
here = _checked_in_locals(caller, module_names, _local_names(caller), params)
|
|
scope[id(caller)] = here
|
|
positional = [a.arg for a in [*func.args.posonlyargs, *func.args.args]]
|
|
if bound:
|
|
positional = positional[1:] # the receiver already fills `self`
|
|
# A keyword-only parameter never takes a positional slot, so it is
|
|
# matched by name alone.
|
|
params = positional + [a.arg for a in func.args.kwonlyargs]
|
|
supplied = dict(zip(positional, call.args))
|
|
supplied.update({k.arg: k.value for k in call.keywords if k.arg in params})
|
|
for param in params:
|
|
value = supplied.get(param)
|
|
ok = value is not None and _is_checked_in_root(value, module_names, (), here)
|
|
(grown if ok else bad).add((id(func), param))
|
|
grown -= bad
|
|
if grown == good:
|
|
return good
|
|
good = grown
|
|
|
|
|
|
def _checked_in_path_calls(
|
|
tree: ast.Module,
|
|
modules = NO_MODULES,
|
|
visible_at = None,
|
|
):
|
|
"""Yield calls, at any depth, whose path is provably a checked-in file.
|
|
|
|
The import-time walk alone leaves test bodies unguarded, and a bare read
|
|
there is the same Windows failure one step later: `_extract_template()` in
|
|
test_gemma4_chat_template.py opens unsloth/chat_templates.py, which cp1252
|
|
cannot decode ("byte 0x90"), so the test errors rather than the collection.
|
|
|
|
Two spellings qualify. A tmp_path arrives as a fixture parameter and a
|
|
tempfile is built in the body, so neither can be bound at module scope nor
|
|
derived from `__file__`. That keeps temp-dir I/O out of scope without an
|
|
allowlist, since there the platform default is harmless and the test wrote
|
|
the bytes itself.
|
|
"""
|
|
module_names = _module_level_names(tree)
|
|
consumed = _eagerly_consumed(tree)
|
|
visible_at = _imports_at_each_call(tree) if visible_at is None else visible_at
|
|
params = _checked_in_params(tree, module_names)
|
|
attrs = _class_path_attrs(tree, module_names)
|
|
|
|
def visit(
|
|
node,
|
|
shadowed,
|
|
derived = frozenset(),
|
|
):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
shadowed = shadowed | _local_names(node)
|
|
# Seed with the parameters first: `p = root / "x.py"` is only
|
|
# derivable once `root` is known to hold a checked-in path.
|
|
seeded = {p for f, p in params if f == id(node)}
|
|
derived = _checked_in_locals(node, module_names, shadowed, seeded)
|
|
elif _is_main_guard(node):
|
|
# Never runs under pytest, so rule 1 skips it for the same reason.
|
|
for child in node.orelse:
|
|
yield from visit(child, shadowed, derived)
|
|
return
|
|
elif isinstance(node, ast.GeneratorExp) and id(node) not in consumed:
|
|
if node.generators:
|
|
yield from visit(node.generators[0].iter, shadowed, derived)
|
|
return
|
|
elif (live := _live_branches(node)) is not None:
|
|
for child in live:
|
|
yield from visit(child, shadowed, derived)
|
|
return
|
|
elif isinstance(node, ast.Call):
|
|
expr = _path_expr(node, visible_at.get(id(node), modules))
|
|
if expr is not None and _is_checked_in_root(
|
|
expr, module_names, shadowed, derived, attrs
|
|
):
|
|
yield node
|
|
for child in ast.iter_child_nodes(node):
|
|
yield from visit(child, shadowed, derived)
|
|
|
|
yield from visit(tree, frozenset())
|
|
|
|
|
|
def _open_mode(call: ast.Call, mode_index: int):
|
|
"""The literal mode of an open() call, or UNKNOWN_MODE.
|
|
|
|
A splat or a non-literal hides the mode. Defaulting those to "r" would
|
|
demand an encoding on a call that may resolve to "rb", where passing one is
|
|
a ValueError, so the contributor would have no compliant edit.
|
|
"""
|
|
if any(isinstance(a, ast.Starred) for a in call.args):
|
|
return UNKNOWN_MODE
|
|
if any(kw.arg is None for kw in call.keywords):
|
|
return UNKNOWN_MODE
|
|
if len(call.args) > mode_index:
|
|
node = call.args[mode_index]
|
|
return node.value if isinstance(node, ast.Constant) else UNKNOWN_MODE
|
|
for kw in call.keywords:
|
|
if kw.arg == "mode":
|
|
return kw.value.value if isinstance(kw.value, ast.Constant) else UNKNOWN_MODE
|
|
return "r"
|
|
|
|
|
|
def _is_text(call: ast.Call, mode_index: int) -> bool:
|
|
mode = _open_mode(call, mode_index)
|
|
return mode is not UNKNOWN_MODE and "b" not in str(mode)
|
|
|
|
|
|
def _names_encoding(call: ast.Call) -> bool:
|
|
"""True only for an encoding that actually pins one.
|
|
|
|
`encoding = None` and `encoding = "locale"` both re-select the platform
|
|
default, so the keyword being present is not enough. A `**kwargs` may carry
|
|
one we cannot see, so it counts as named rather than risking a false alarm.
|
|
"""
|
|
for kw in call.keywords:
|
|
if kw.arg is None:
|
|
return True
|
|
if kw.arg != "encoding":
|
|
continue
|
|
if isinstance(kw.value, ast.Constant) and kw.value.value in PLATFORM_DEFAULT_ENCODINGS:
|
|
return False
|
|
return True
|
|
return False
|
|
|
|
|
|
def _pins_encoding(call: ast.Call, position: int | None) -> bool:
|
|
"""True when the call names an encoding, positionally or by keyword.
|
|
|
|
`position` is None where the API takes it keyword-only. A splat makes the
|
|
positions meaningless, so it counts as named rather than demanding an edit
|
|
the contributor cannot make correctly.
|
|
"""
|
|
if any(isinstance(a, ast.Starred) for a in call.args):
|
|
return True
|
|
if position is not None and len(call.args) > position:
|
|
node = call.args[position]
|
|
if isinstance(node, ast.Constant):
|
|
return node.value not in PLATFORM_DEFAULT_ENCODINGS
|
|
return True
|
|
return _names_encoding(call)
|
|
|
|
|
|
def _offender(call: ast.Call, modules = NO_MODULES) -> str | None:
|
|
"""The call's name if it reads text without an encoding, else None."""
|
|
func = call.func
|
|
if isinstance(func, ast.Attribute):
|
|
receiver = func.value.id if isinstance(func.value, ast.Name) else None
|
|
# An unbound `Path.read_text(p)` puts the instance in slot 0, and
|
|
# `pathlib.Path.read_text(p)` is the same call fully qualified.
|
|
shift = 1 if _is_path_class(receiver, modules) or _is_path_attr(func.value) else 0
|
|
if func.attr in GUARDED_METHODS:
|
|
if func.attr == "read_text" and not shift and call.args:
|
|
first = call.args[0]
|
|
# Bound read_text takes encoding first, so None or "locale"
|
|
# there is a platform-default read. Any other positional means
|
|
# the receiver is importlib.metadata's Distribution, whose
|
|
# argument is a filename and which takes no encoding at all.
|
|
if isinstance(first, ast.Constant) and first.value in PLATFORM_DEFAULT_ENCODINGS:
|
|
return "read_text()"
|
|
return None
|
|
position = ENCODING_POSITION[func.attr] + shift
|
|
return None if _pins_encoding(call, position) else f"{func.attr}()"
|
|
if func.attr == "open":
|
|
# io.open and builtins.open ARE the builtin, so they take the
|
|
# builtin's argument positions and the same platform default.
|
|
if receiver is not None and _origin_root(receiver, modules) in BUILTIN_OPEN_MODULES:
|
|
if not _is_text(call, 1) or _pins_encoding(call, ENCODING_POSITION["open"]):
|
|
return None
|
|
return f"{receiver}.open()"
|
|
compressed = _compressed_key(receiver, modules) if receiver else None
|
|
if compressed is not None:
|
|
mode = _open_mode(call, 1)
|
|
if mode is UNKNOWN_MODE or "t" not in str(mode):
|
|
return None # "rb" default, so binary unless asked otherwise
|
|
return (
|
|
None
|
|
if _pins_encoding(call, COMPRESSED_OPENERS[compressed])
|
|
else f"{compressed}.open()"
|
|
)
|
|
# Any other module receiver is somebody else's opener: tarfile.open
|
|
# takes a compression mode, Image.open takes a binary file. Neither
|
|
# has an encoding to name, so demanding one leaves no correct edit.
|
|
if (
|
|
receiver is not None
|
|
and receiver in modules
|
|
and not _is_path_class(receiver, modules)
|
|
):
|
|
return None
|
|
if not _is_text(call, shift):
|
|
return None
|
|
return (
|
|
None
|
|
if _pins_encoding(call, ENCODING_POSITION["Path.open"] + shift)
|
|
else "Path.open()"
|
|
)
|
|
return None
|
|
if isinstance(func, ast.Name):
|
|
alias = _open_alias(func.id, modules)
|
|
# Binary handles have no encoding to name.
|
|
if alias == "builtin" and _is_text(call, 1):
|
|
return None if _pins_encoding(call, ENCODING_POSITION["open"]) else "open()"
|
|
if alias is not None and alias != "builtin":
|
|
mode = _open_mode(call, 1)
|
|
if mode is UNKNOWN_MODE or "t" not in str(mode):
|
|
return None # "rb" default, so binary unless asked otherwise
|
|
position = COMPRESSED_OPENERS[alias]
|
|
return None if _pins_encoding(call, position) else f"{alias}.open()"
|
|
return None
|
|
|
|
|
|
def _scan(tree: ast.Module, rel: str):
|
|
"""Offenders from both rules, reported once each and in source order."""
|
|
modules = _imported_names(tree)
|
|
visible_at = _imports_at_each_call(tree)
|
|
calls = {id(c): c for c in _import_time_calls(tree)}
|
|
calls.update({id(c): c for c in _checked_in_path_calls(tree, modules, visible_at)})
|
|
not_paths = _non_path_names(tree)
|
|
temp_roots = _temp_rooted_names(tree)
|
|
for call in sorted(calls.values(), key = lambda c: (c.lineno, c.col_offset)):
|
|
func = call.func
|
|
if (
|
|
isinstance(func, ast.Attribute)
|
|
and (func.attr in GUARDED_METHODS or func.attr == "open")
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id in not_paths
|
|
):
|
|
continue # ZipFile.open and friends have no encoding to name
|
|
expr = _path_expr(call, visible_at.get(id(call), modules))
|
|
root = _path_root(expr) if expr is not None else None
|
|
if isinstance(root, ast.Name) and root.id in temp_roots:
|
|
continue # the run made this file, so the platform default is safe
|
|
name = _offender(call, visible_at.get(id(call), modules))
|
|
if name is not None:
|
|
yield f"{rel}:{call.lineno}: {name}"
|
|
|
|
|
|
def test_checked_in_file_reads_name_an_encoding():
|
|
offenders = []
|
|
for path in sorted(SOURCES):
|
|
tree = ast.parse(path.read_text(encoding = "utf-8"), filename = str(path))
|
|
offenders.extend(_scan(tree, path.relative_to(REPO).as_posix()))
|
|
assert offenders == [], (
|
|
f"{len(offenders)} file reads in the test trees touch a checked-in file "
|
|
"with the platform default encoding, so they break on Windows as soon "
|
|
'as that file gains a non-ASCII byte. Pass encoding = "utf-8": '
|
|
f"{offenders[:10]}"
|
|
)
|