deer-flow/backend/tests/test_detect_blocking_io_static.py
PerryLink c7b65c5c36
test(blocking-io): compare the emitted path portably (#6427)
test_json_output_uses_concise_review_record_schema asserts the JSON record
against str(source_file). The detector builds location.path through
relative_to_repo, which is as_posix() on both of its branches, so on POSIX the
two strings are equal and the assertion holds. On Windows str(path) yields
backslashes, so the test fails against output that is behaving as documented.

One word: str(source_file) becomes source_file.as_posix(). On POSIX the two are
the same string, so this is a no-op for CI; on Windows it makes the assertion
match what the detector emits. Every other path field in the repository is
written with as_posix() as well.

Before: 1 failed, 50 passed. After: 51 passed, on Windows with Python 3.12.
The three sibling detector suites still pass (35 passed), and ruff check and
ruff format --check pass on the file.
2026-10-07 18:22:15 +08:00

1414 lines
49 KiB
Python

from __future__ import annotations
import json
import textwrap
from pathlib import Path
from support.detectors import blocking_io_static as detector
def _write_python(path: Path, source: str) -> Path:
path.write_text(textwrap.dedent(source).strip() + "\n", encoding="utf-8")
return path
def _payload(path: Path, repo_root: Path) -> list[dict[str, object]]:
return [finding.to_dict() for finding in detector.scan_file(path, repo_root=repo_root)]
def test_scan_file_detects_direct_blocking_calls_in_async_code(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import subprocess
import time
import urllib.request
from pathlib import Path
async def handler(path: Path):
time.sleep(1)
subprocess.run(["echo", "ok"])
path.read_text(encoding="utf-8")
with open(path, encoding="utf-8") as handle:
return urllib.request.urlopen(handle.read())
""",
)
findings = _payload(source_file, tmp_path)
categories = {finding["blocking_call"]["category"] for finding in findings}
symbols = {finding["blocking_call"]["symbol"] for finding in findings}
assert categories == {
"BLOCKING_FILE_IO",
"BLOCKING_HTTP_IO",
"BLOCKING_SLEEP",
"BLOCKING_SUBPROCESS",
}
assert {"time.sleep", "subprocess.run", "path.read_text", "open", "urllib.request.urlopen"}.issubset(symbols)
assert {finding["event_loop_exposure"] for finding in findings} == {"DIRECT_ASYNC"}
def test_scan_file_detects_blocking_calls_in_sync_helper_reached_from_async_code(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from pathlib import Path
def load_payload(path: Path) -> bytes:
return path.read_bytes()
async def route(path: Path) -> bytes:
return load_payload(path)
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["blocking_call"]["category"] == "BLOCKING_FILE_IO"
assert findings[0]["location"]["function"] == "load_payload"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "path.read_bytes"
def test_scan_file_omits_sync_only_blocking_calls_from_default_results(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from pathlib import Path
def load_payload(path: Path) -> str:
return path.read_text()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_detects_self_helper_reached_from_async_method(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
class ArtifactRouter:
def read_payload(self, path):
return path.read_text(encoding="utf-8")
async def get(self, path):
return self.read_payload(path)
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "ArtifactRouter.read_payload"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
def test_scan_file_detects_self_attribute_chain_helper_reached_from_async_method(tmp_path: Path) -> None:
"""self.store.flush() is one hop deeper than the self.flush() case above.
The receiver (`self.store`) is not the literal Name `self`, so it used to
fall through every branch in `_record_call_ref` and be silently dropped
from the call graph -- a false negative for a real blocking call reachable
from async code.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self):
return self.store.flush()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "open"
def test_scan_file_detects_local_variable_alias_of_self_attribute(tmp_path: Path) -> None:
"""store = self.store; store.flush() must resolve the same as self.store.flush().
The local variable is traced back, within the same function, to a
self.-rooted attribute -- the same one-hop-back scope as the constructor
parameter case below, just through an intermediate local name.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self):
store = self.store
return store.flush()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "open"
def test_scan_file_detects_constructor_parameter_used_directly_as_receiver(tmp_path: Path) -> None:
"""A function parameter (e.g. a constructor-injected dependency) used
directly as a call receiver, with no self./cls. attribute in between,
is the other local-alias origin the fix traces (same function only).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def _flush_via(self, store):
store.flush()
async def get(self, store):
return self._flush_via(store)
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "open"
def test_scan_file_does_not_trace_unrelated_local_variables_as_receivers(tmp_path: Path) -> None:
"""Scope guardrail: a local variable with no traceable origin (not a
parameter, not assigned from a self./cls. attribute or another traced
name) must NOT be resolved through the bare-method-name fallback, or the
fix would reintroduce broad false positives the tool already avoids.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Unrelated:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
def helper():
data = compute()
data.flush()
async def route():
return helper()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_json_output_uses_concise_review_record_schema(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import subprocess
async def handler():
subprocess.run(["echo", "ok"])
""",
)
exit_code = detector.main(["--format", "json", str(source_file)])
assert exit_code == 0
payload = json.loads(capsys.readouterr().out)
assert payload == [
{
"priority": "HIGH",
"location": {
"path": source_file.as_posix(),
"line": 4,
"column": 5,
"function": "handler",
},
"blocking_call": {
"category": "BLOCKING_SUBPROCESS",
"operation": "SUBPROCESS",
"symbol": "subprocess.run",
},
"event_loop_exposure": "DIRECT_ASYNC",
"reason": "SUBPROCESS is called directly inside an async function.",
"code": 'subprocess.run(["echo", "ok"])',
}
]
assert "confidence" not in payload[0]
assert "severity" not in payload[0]
assert "event_loop_risk" not in payload[0]
def test_summary_output_writes_json_report(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import subprocess
async def handler():
subprocess.run(["echo", "ok"])
""",
)
output_path = tmp_path / "reports" / "blocking-io.json"
exit_code = detector.main(["--output", str(output_path), str(source_file)])
assert exit_code == 0
stdout = capsys.readouterr().out
assert "Static blocking IO event-loop risk findings: 1" in stdout
assert "By category:" in stdout
assert "BLOCKING_SUBPROCESS" in stdout
assert "Full JSON report:" in stdout
payload = json.loads(output_path.read_text(encoding="utf-8"))
assert [finding["blocking_call"]["category"] for finding in payload] == ["BLOCKING_SUBPROCESS"]
def test_json_output_ranks_operations_without_confidence_noise(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import shutil
async def handler(path):
path.exists()
path.read_text()
shutil.rmtree(path)
""",
)
exit_code = detector.main(["--format", "json", str(source_file)])
assert exit_code == 0
payload = json.loads(capsys.readouterr().out)
by_symbol = {finding["blocking_call"]["symbol"]: finding for finding in payload}
assert by_symbol["path.exists"]["blocking_call"]["operation"] == "FILE_METADATA"
assert by_symbol["path.exists"]["priority"] == "LOW"
assert by_symbol["path.read_text"]["blocking_call"]["operation"] == "FILE_READ"
assert by_symbol["path.read_text"]["priority"] == "MEDIUM"
assert by_symbol["shutil.rmtree"]["blocking_call"]["operation"] == "FILE_TREE_DELETE"
assert by_symbol["shutil.rmtree"]["priority"] == "HIGH"
assert {finding["event_loop_exposure"] for finding in payload} == {"DIRECT_ASYNC"}
assert all("confidence" not in finding for finding in payload)
def test_path_receiver_detection_uses_path_annotations(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from pathlib import Path
async def typed(path: Path):
return path.read_text()
async def constructed():
return Path("payload.txt").read_text()
""",
)
findings = _payload(source_file, tmp_path)
assert {finding["blocking_call"]["symbol"] for finding in findings} == {"path.read_text", "pathlib.Path.read_text"}
assert {finding["priority"] for finding in findings} == {"MEDIUM"}
def test_summary_groups_findings_by_priority_and_operation(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
from pathlib import Path
def load_payload(path: Path) -> str:
return path.read_text()
async def handler(path: Path) -> str:
path.exists()
list(os.walk(path))
return load_payload(path)
""",
)
exit_code = detector.main([str(source_file)])
assert exit_code == 0
stdout = capsys.readouterr().out
assert "By priority:" in stdout
assert "HIGH" in stdout
assert "MEDIUM" in stdout
assert "By operation:" in stdout
assert "FILE_ENUMERATION" in stdout
assert "FILE_METADATA" in stdout
assert "FILE_READ" in stdout
assert "By event-loop exposure:" in stdout
assert "DIRECT_ASYNC" in stdout
assert "ASYNC_REACHABLE_SAME_FILE" in stdout
def test_source_code_snippet_is_truncated_for_json_output(tmp_path: Path) -> None:
long_suffix = " + ".join('"chunk"' for _ in range(80))
source_file = _write_python(
tmp_path / "sample.py",
f"""
async def handler(path):
return path.read_text() + {long_suffix}
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert len(findings[0]["code"]) <= 203
assert findings[0]["code"].endswith("...")
def test_cli_default_filters_sync_only_inventory_items(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from pathlib import Path
def load_payload(path: Path) -> str:
return path.read_text()
""",
)
exit_code = detector.main(["--format", "json", str(source_file)])
assert exit_code == 0
assert json.loads(capsys.readouterr().out) == []
def test_sync_only_agent_middleware_hook_gets_event_loop_exposure(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from langchain.agents.middleware import AgentMiddleware
from pathlib import Path
class UploadsMiddleware(AgentMiddleware):
def before_agent(self, state, runtime):
return self._load(Path("uploads"))
def _load(self, path: Path) -> str:
return path.read_text()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "UploadsMiddleware._load"
assert findings[0]["event_loop_exposure"] == "SYNC_AGENT_MIDDLEWARE_HOOK"
assert "statically reachable from a sync AgentMiddleware hook" in findings[0]["reason"]
def test_sync_agent_middleware_hook_with_async_counterpart_is_not_reported(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
from langchain.agents.middleware import AgentMiddleware
from pathlib import Path
class UploadsMiddleware(AgentMiddleware):
def before_agent(self, state, runtime):
return Path("uploads").read_text()
async def abefore_agent(self, state, runtime):
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_detects_sync_httpx_client_methods_in_async_code(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import httpx
async def search() -> str:
with httpx.Client(timeout=30) as client:
return client.post("https://example.invalid").text
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["blocking_call"]["category"] == "BLOCKING_HTTP_IO"
assert findings[0]["location"]["function"] == "search"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "httpx.Client.post"
def test_scan_file_detects_chained_sync_http_client_methods_in_async_code(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import httpx
import requests
async def fetch() -> tuple[object, object]:
return (
httpx.Client().get("https://example.invalid"),
requests.Session().post("https://example.invalid"),
)
""",
)
findings = _payload(source_file, tmp_path)
symbols = {finding["blocking_call"]["symbol"] for finding in findings}
assert symbols == {"httpx.Client.get", "requests.Session.post"}
assert {finding["blocking_call"]["category"] for finding in findings} == {"BLOCKING_HTTP_IO"}
def test_scan_file_detects_os_walk_and_path_resolve_in_async_code(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
from pathlib import Path
async def inspect_tree(path: Path) -> list[str]:
root = path.resolve()
return [name for _, _, names in os.walk(root) for name in names]
""",
)
findings = _payload(source_file, tmp_path)
symbols = {finding["blocking_call"]["symbol"] for finding in findings}
assert symbols == {"path.resolve", "os.walk"}
assert {finding["blocking_call"]["category"] for finding in findings} == {"BLOCKING_FILE_IO"}
def test_scan_file_does_not_treat_string_replace_as_file_io(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "sample.py",
"""
def _path_variants(path: str) -> set[str]:
return {path, path.replace("\\\\", "/"), path.replace("/", "\\\\")}
async def normalize(text: str) -> str:
return text.replace("a", "b")
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_parse_errors_are_reported_as_findings(tmp_path: Path) -> None:
source_file = _write_python(
tmp_path / "broken.py",
"""
async def broken(:
pass
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["blocking_call"]["category"] == "PARSE_ERROR"
assert findings[0]["priority"] == "MEDIUM"
assert f"{source_file.name}:1:18" in detector.format_text(detector.scan_file(source_file, repo_root=tmp_path))
def test_scan_file_does_not_treat_call_result_as_receiver_alias(tmp_path: Path) -> None:
"""factory().flush() must not resolve like a traced self./cls./parameter alias.
`dotted_name()` intentionally unwraps `ast.Call` to build a symbolic name for
blocking-call pattern matching elsewhere in this module, but reusing that
unwrap for alias/receiver extraction would treat a call's result as if it
inherited the callee's own alias-worthiness. `factory` is traceable here (a
parameter of `get`), so `dotted_name(factory())` unwraps to "factory" and
used to incorrectly link this call to `Store.flush` below.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
async def get(self, factory):
return factory().flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_does_not_treat_call_result_assigned_to_local_as_receiver_alias(tmp_path: Path) -> None:
"""client = factory(); client.flush() must not alias client to a traced receiver.
Same unwrap problem as the direct-call case above, one assignment removed:
`dotted_name(factory())` returns "factory" (a traced parameter), so `client`
was incorrectly added to the same-function alias set.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
async def get(self, factory):
client = factory()
return client.flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_does_not_treat_subscript_result_as_receiver_alias(tmp_path: Path) -> None:
"""client = clients[0]; client.flush() must not alias client either.
`dotted_name()` also unwraps `ast.Subscript`, so `dotted_name(clients[0])`
returned "clients" (a traced parameter), incorrectly aliasing `client`.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
async def get(self, clients):
client = clients[0]
return client.flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_kills_alias_after_non_traceable_reassignment(tmp_path: Path) -> None:
"""A non-traceable reassignment must clear a name's existing alias, not just fail to add one.
`client` starts aliased to `self.store` (traceable), then is reassigned to
`NonBlockingClient()` (not traceable). The old code only ever added names to
the alias set and never removed them, so `client` stayed aliased forever and
`client.flush()` after the reassignment still resolved to `Store.flush`.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class NonBlockingClient:
def close(self):
pass
class Router:
def __init__(self, store):
self.store = store
async def get(self):
client = self.store
client = NonBlockingClient()
return client.flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_if_else_alias_tracking_is_order_independent_across_branches(tmp_path: Path) -> None:
"""Reversing which branch aliases client vs. uses it must not change the result.
Without a `visit_If` override, `body` and `orelse` shared one mutable alias
set with no isolation or restore between them, so an alias assigned in
whichever branch is visited first (always `body`, then `orelse`) leaked into
the other -- making the finding depend on branch position instead of
program semantics. Both variants below (same statements, swapped between
the `if` and `else`) must produce identical output; in this pair neither
reference is preceded by its assignment on the same execution path, so the
correct output for both is empty.
"""
body_first_root = tmp_path / "body_first"
orelse_first_root = tmp_path / "orelse_first"
body_first_root.mkdir()
orelse_first_root.mkdir()
variant_assign_in_body = _write_python(
body_first_root / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self, flag):
if flag:
client = self.store
else:
client.flush()
return None
""",
)
variant_assign_in_orelse = _write_python(
orelse_first_root / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self, flag):
if flag:
client.flush()
else:
client = self.store
return None
""",
)
findings_body_first = _payload(variant_assign_in_body, body_first_root)
findings_orelse_first = _payload(variant_assign_in_orelse, orelse_first_root)
assert findings_body_first == findings_orelse_first == []
def test_if_else_alias_union_carries_forward_after_the_if_statement(tmp_path: Path) -> None:
"""A name aliased in only one branch is still (conservatively) aliased after the if.
This is the other half of the may-alias join: `client` is only assigned
inside the `if` body, but since either branch might have run, code after
the whole `if` statement must still treat `client` as possibly aliased.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self, flag):
if flag:
client = self.store
else:
pass
return client.flush()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
def test_scan_file_does_not_treat_default_value_expression_as_inside_new_function(tmp_path: Path) -> None:
"""A default value referencing an outer variable must not be misattributed to the function being defined.
`receiver` here is a module-level object, unrelated to `route`'s own
parameter that happens to share its name. `_visit_function` used to push
the new function's context -- and its parameter-seeded alias set, which
includes the literal name "receiver" -- before visiting defaults, so
`receiver.flush()` incorrectly looked like route's own traced parameter
calling its own method.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
receiver = Store()
async def route(receiver=receiver.flush()):
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_definition_time_expressions_are_still_attributed_to_the_enclosing_function(tmp_path: Path) -> None:
"""Decorators/defaults/annotations must move to the enclosing scope, not disappear.
`helper` is a nested, never-called, sync function -- if its default value's
blocking call were (incorrectly) attributed to `helper` itself, the finding
would be silently dropped (helper is neither async nor reachable from
anything async). Fixing the scope must move the finding to the actual
enclosing scope that runs it (`outer_async`, which is async), not just make
it disappear.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import time
async def outer_async():
def helper(x=time.sleep(1)):
return x
return None
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "outer_async"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "time.sleep"
def test_decorator_default_annotation_and_return_annotation_all_use_enclosing_scope(tmp_path: Path) -> None:
"""All four definition-time expression positions must resolve in the enclosing scope.
Covers the decorator list, a parameter default, a parameter annotation, and
the return annotation in one signature -- each pinned to a distinct,
unconditionally-recognized blocking symbol so a regression in any one
position produces a non-empty result.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
import shutil
import time
def _mark(value):
def _wrap(fn):
return fn
return _wrap
@_mark(time.sleep(1))
async def route(x=os.listdir("."), y: shutil.rmtree("z") = None) -> time.sleep(2):
return x
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_pep695_type_parameter_bound_is_never_treated_as_definition_time_eager(tmp_path: Path) -> None:
"""PEP 695 type-parameter bounds are lazily evaluated, not definition-time code.
CPython compiles a `type_params` bound (`T` in `def helper[T: <bound>](...)`)
into its own hidden, lazily-invoked function: it only runs if and when
something accesses `T.__bound__` (e.g. a type checker or `typing` runtime
introspection), never as part of executing the `def` statement itself --
not in the function's own scope, and not in the enclosing one either. An
earlier version of this fix moved the previous `generic_visit` walk of
`type_params` into the enclosing scope alongside decorators/defaults/
annotations, on the assumption it was equally eager; that assumption was
wrong, so bounds are not visited in either scope now.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import time
async def outer_async():
def helper[T: time.sleep(1)](x: T) -> T:
return x
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_traces_call_result_reassignment_using_pre_assignment_alias_state(tmp_path: Path) -> None:
"""client = client.flush() must resolve the RHS against the PRE-assignment alias state.
Python evaluates an assignment's RHS before binding its target. `client`
starts aliased to `self.store` (traceable); reassigning it to
`client.flush()`'s result correctly kills the alias for code AFTER this
statement, but the call `client.flush()` itself happens first, while
`client` was still aliased, and must still be recorded -- updating/killing
the target's alias before visiting the RHS made this call invisible.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self):
client = self.store
client = client.flush()
return client
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "open"
def test_scan_file_traces_annassign_call_result_reassignment_using_pre_assignment_alias_state(tmp_path: Path) -> None:
"""Same pre-assignment-alias-state requirement, through an annotated assignment.
`visit_AnnAssign` shares `visit_Assign`'s RHS-then-target-update ordering
requirement; this pins it separately so a fix to one path cannot silently
leave the other regressed.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
def __init__(self, store):
self.store = store
async def get(self):
client: object = self.store
client: object = client.flush()
return client
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "Store.flush"
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
assert findings[0]["blocking_call"]["symbol"] == "open"
def test_scan_file_does_not_treat_call_rooted_attribute_chain_as_receiver_alias(tmp_path: Path) -> None:
"""factory().client.flush() must not collapse to the traced name "client".
`_simple_receiver_name` fell back to returning the trailing attribute name
whenever the recursive parent lookup came back unsupported (e.g. a Call),
instead of refusing the whole chain. That let `factory().client` collapse
to plain "client", which -- because "client" is also a parameter here --
was then treated exactly like a bare `client.flush()` call on that
parameter, incorrectly linking this call to `Store.flush` below even
though `client` is never referenced in this expression at all.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
async def get(self, factory, client):
return factory().client.flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_does_not_treat_subscript_rooted_attribute_chain_as_receiver_alias(tmp_path: Path) -> None:
"""clients[0].client.flush() must not collapse to the traced name "client" either.
Same fallback gap as the Call-rooted case above, with `ast.Subscript`
(rather than `ast.Call`) underneath the final attribute access.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
class Store:
def flush(self):
with open("out.txt", "w") as handle:
handle.write("x")
class Router:
async def get(self, clients, client):
return clients[0].client.flush()
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_does_not_treat_postponed_annotation_as_definition_time_eager(tmp_path: Path) -> None:
"""`from __future__ import annotations` defers ALL annotation evaluation.
With this future import active, CPython never evaluates parameter/return
annotations at runtime at all (they are kept as unevaluated strings in
`__annotations__`) -- so a blocking call written in one must not be
attributed to definition time in any scope, unlike the same shape without
the future import (see
`test_decorator_default_annotation_and_return_annotation_all_use_enclosing_scope`).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
from __future__ import annotations
import os
async def outer_async():
def helper(x: os.listdir(".")) -> os.listdir("."):
return x
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_does_not_treat_lambda_body_as_definition_time_eager(tmp_path: Path) -> None:
"""A lambda default's BODY must not be treated as running at definition time.
`helper`'s default value creates a lambda object (eager), but
`os.listdir(".")` inside its body only runs if and when that lambda is
later called -- which this snippet never does.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def outer_async():
def helper(callback=lambda: os.listdir(".")):
return callback
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_still_treats_lambda_own_default_as_definition_time_eager(tmp_path: Path) -> None:
"""Contrast case: a lambda's OWN parameter default is genuinely eager.
Unlike the lambda's body above, its parameter defaults are evaluated
immediately, when the lambda object itself is created -- so this must
still be attributed to the enclosing scope like any other default.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def outer_async():
def helper(callback=lambda flag=os.listdir("."): flag):
return callback
return None
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "outer_async"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_does_not_treat_generator_element_as_definition_time_eager(tmp_path: Path) -> None:
"""A bare generator expression's element only runs once iterated, if ever.
`helper`'s default builds a generator object (eager), but `os.listdir(x)`
-- the element expression -- is only evaluated lazily as the generator is
iterated, which this snippet never does.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def outer_async():
def helper(items=(os.listdir(x) for x in [1])):
return items
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_still_treats_generator_outer_iterable_as_definition_time_eager(tmp_path: Path) -> None:
"""Contrast case: a generator's OUTERMOST iterable IS evaluated eagerly.
Only the first `for`'s iterable runs immediately, to build the generator
object -- so a blocking call there (unlike the element case above) must
still be attributed to the enclosing scope.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def outer_async():
def helper(items=(x for x in os.listdir("."))):
return items
return None
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "outer_async"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_immediately_invoked_lambda_as_eager(tmp_path: Path) -> None:
"""An immediately invoked lambda's body is scanned like any other expression.
`(lambda: ...)()` calls the lambda at the exact expression that defines
it, in ordinary function-body code -- outside another function's
definition-time expressions (see `_visit_function`), a lambda's body is
always scanned regardless of how or whether it is invoked (see also
`test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
return (lambda: os.listdir("."))()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_generator_passed_to_list_as_eager(tmp_path: Path) -> None:
"""A generator passed to `list(...)` is scanned like any other expression.
In ordinary function-body code -- outside another function's
definition-time expressions (see `_visit_function`) -- a generator's
element is always scanned regardless of what, if anything, consumes it
(see also `test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`
and `test_scan_file_treats_generator_wrapped_in_map_as_eager`).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
return list(os.listdir(path) for path in ["."])
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_reviewer_repro_both_eager_shapes_together(tmp_path: Path) -> None:
"""Integration check mirroring the exact review repro.
Both eager shapes -- an immediately invoked lambda and a generator
consumed by `list(...)` -- appear together in one function, each on its
own line, and each must produce its own independent finding.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
first = (lambda: os.listdir("."))()
second = list(os.listdir(path) for path in ["."])
return first, second
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 2
assert {finding["location"]["function"] for finding in findings} == {"route"}
assert {finding["event_loop_exposure"] for finding in findings} == {"DIRECT_ASYNC"}
assert {finding["blocking_call"]["symbol"] for finding in findings} == {"os.listdir"}
assert len({finding["location"]["line"] for finding in findings}) == 2
def test_scan_file_treats_generator_consumed_by_any_builtin_as_eager(tmp_path: Path) -> None:
"""A generator's element is scanned no matter what consumes it.
In ordinary function-body code, this file does not try to distinguish a
builtin that eagerly materializes its argument into a container
(`list`, `set`, `tuple`, `sorted`, `frozenset`, `dict`) from one that
eagerly reduces it to a scalar (`sum`, `any`, `all`, `min`, `max`) --
every one of them is scanned the same way, unconditionally, along with a
builtin that instead wraps the generator in another lazy iterator
without consuming it at all (see
`test_scan_file_treats_generator_wrapped_in_map_as_eager`).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
a = set(os.listdir(p) for p in ["a"])
b = tuple(os.listdir(p) for p in ["b"])
c = sorted(os.listdir(p) for p in ["c"])
d = frozenset(os.listdir(p) for p in ["d"])
e = dict((p, os.listdir(p)) for p in ["e"])
f = sum(len(os.listdir(p)) for p in ["f"])
g = any(os.listdir(p) for p in ["g"])
h = all(os.listdir(p) for p in ["h"])
i = min(len(os.listdir(p)) for p in ["i"])
j = max(len(os.listdir(p)) for p in ["j"])
return a, b, c, d, e, f, g, h, i, j
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 10
assert {finding["location"]["function"] for finding in findings} == {"route"}
assert {finding["blocking_call"]["symbol"] for finding in findings} == {"os.listdir"}
assert {finding["event_loop_exposure"] for finding in findings} == {"DIRECT_ASYNC"}
def test_scan_file_treats_generator_reduced_by_sum_as_eager(tmp_path: Path) -> None:
"""`sum(...)` iterates its generator argument immediately, same as `list(...)`.
A reducer builtin that folds a generator down to a scalar still consumes
it eagerly to do so, so ordinary code scans it the same as any other
generator (see also
`test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`).
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
return sum(len(os.listdir(path)) for path in ["."])
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_does_not_treat_immediately_invoked_lambda_in_definition_time_default_as_eager(tmp_path: Path) -> None:
"""Definition-time expressions never scan a nested lambda's body, even an IIFE.
`helper`'s default value is itself an immediately invoked lambda, so its
body does run the instant `outer_async` evaluates the default. This file
does not special-case that: any lambda body or generator element reached
while walking another function's decorators, parameter defaults/
annotations, or return annotation is always excluded, full stop (see
`_visit_function`), the same as an uninvoked one (see
`test_scan_file_does_not_treat_lambda_body_as_definition_time_eager`
above). This is a narrow, intentional limitation specific to
definition-time expressions -- contrast with
`test_scan_file_treats_immediately_invoked_lambda_as_eager`, where the
identical shape outside a definition-time context is scanned.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def outer_async():
def helper(flag=(lambda: os.listdir("."))()):
return flag
return None
""",
)
assert detector.scan_file(source_file, repo_root=tmp_path) == []
def test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code(tmp_path: Path) -> None:
"""Ordinary code scans a lambda's body even if it is never called.
Unlike a lambda used as another function's decorator/default/annotation
value (see
`test_scan_file_does_not_treat_lambda_body_as_definition_time_eager`
above), a lambda created inside a function body is not part of that
narrow, definition-time-only exclusion. It is scanned unconditionally --
the same conservative, over-report-rather-than-infer stance this file
takes for reachability everywhere else: it does not try to prove that a
lambda (or any other reachable code) is never actually invoked.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
callback = lambda: os.listdir(".")
return callback
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_lambda_invoked_through_stored_variable_as_eager(tmp_path: Path) -> None:
"""A lambda stored in a local and called through that name is still scanned.
This is the same unconditional lambda-body scan as
`test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`
just above: ordinary code does not track which variable a lambda value
ends up in or whether/how it is later called, so calling it through the
stored name makes no difference to the outcome.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
callback = lambda: os.listdir(".")
return callback()
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_bare_unconsumed_generator_as_eager_in_ordinary_code(tmp_path: Path) -> None:
"""Ordinary code scans a generator's element even if it is never iterated.
Unlike a generator used as another function's decorator/default/
annotation value (see
`test_scan_file_does_not_treat_generator_element_as_definition_time_eager`
above), a generator expression created inside a function body is not
part of that narrow, definition-time-only exclusion. It is scanned
unconditionally -- the same stance as
`test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
items = (os.listdir(path) for path in ["."])
return items
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
def test_scan_file_treats_generator_wrapped_in_map_as_eager(tmp_path: Path) -> None:
"""`map(...)` wraps a generator without consuming it, but is still scanned.
`map(str, gen)` returns another lazy iterator -- it does not iterate
`gen` at all until the map object itself is consumed, later or never.
Ordinary code does not try to tell this apart from a builtin that
consumes the generator eagerly (see
`test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`) or a
bare, unconsumed generator (see
`test_scan_file_treats_bare_unconsumed_generator_as_eager_in_ordinary_code`)
-- all three are scanned the same way, since telling them apart in
general means inferring evaluation order across arbitrary code rather
than reading a fixed, structural fact.
"""
source_file = _write_python(
tmp_path / "sample.py",
"""
import os
async def route():
return map(str, (os.listdir(path) for path in ["."]))
""",
)
findings = _payload(source_file, tmp_path)
assert len(findings) == 1
assert findings[0]["location"]["function"] == "route"
assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
assert findings[0]["blocking_call"]["symbol"] == "os.listdir"