From 11c1912e20ab2d0d81469449cb13ff8b3aeca901 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 5 Oct 2026 22:07:40 -0700 Subject: [PATCH 1/2] Add a guard against repeated imports Ruff has no rule for a function-local import that repeats a module-level one, and CodeQL's only covers plain `import X` it can resolve. The new script flags any function-local import whose bound name and target match a module-level runtime import, ignoring TYPE_CHECKING-only imports. It runs as a pre-commit hook and in both ci.yml and release.yml beside the conformance manifest check. --- .github/codeql/codeql-config.yml | 15 ++- .github/workflows/ci.yml | 6 ++ .github/workflows/release.yml | 4 + .pre-commit-config.yaml | 5 + scripts/check_repeated_imports.py | 115 ++++++++++++++++++++++ src/openarmature/llm/providers/openai.py | 2 +- tests/conformance/test_checkpoint.py | 6 +- tests/conformance/test_observability.py | 3 - tests/unit/test_langfuse_provider_fake.py | 3 - tests/unit/test_langfuse_sdk_internals.py | 1 - tests/unit/test_llm_provider.py | 31 ++---- tests/unit/test_observability_metadata.py | 25 +---- tests/unit/test_observability_otel.py | 20 ---- tests/unit/test_prompts.py | 20 +--- tests/unit/test_prompts_langfuse.py | 2 +- 15 files changed, 156 insertions(+), 102 deletions(-) create mode 100644 scripts/check_repeated_imports.py diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index c06d566f..069acce2 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -28,15 +28,14 @@ name: "openarmature-python CodeQL config" query-filters: - exclude: id: py/ineffectual-statement - # ``py/unused-import`` produces false positives on three patterns - # this codebase relies on: + # ``py/unused-import`` produces false positives on patterns this + # codebase relies on: # - # - Forward-reference casts: ``cast("FinishReason", x)`` / - # ``cast("Checkpointer", capturing)``. CodeQL doesn't look - # inside the string argument; pyright's strict mode does, AND - # raises ``reportUndefinedVariable`` if the name isn't in scope - # (verified empirically — removing ``FinishReason`` from - # ``openai.py``'s import yields the pyright error). + # - Quoted casts: ``cast("X", value)``. CodeQL doesn't look inside + # the string argument; pyright's strict mode does, and raises + # ``reportUndefinedVariable`` if the name isn't in scope. Where the + # name is already imported at runtime the quotes buy nothing, so + # prefer ``cast(X, value)``, which both tools see as a use. # - Subscripted base classes: ``class _TracingFanOutNode( # FanOutNode[State, State]):``. The base class IS used; the # generic subscription happens at class-definition time. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9108331c..c44aa515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,12 @@ jobs: # entry quietly serves wrong data to the docs site. run: uv run python scripts/check_conformance_manifest.py + - name: Reject repeated imports + # A function-local import of something the module already imports. + # Ruff has no rule for it and CodeQL's only covers plain `import X`. + # Runs here as well as in pre-commit because a hook can be skipped. + run: uv run python scripts/check_repeated_imports.py + - name: Lint (ruff check) run: uv run ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5d4d36c..ce33a0a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,6 +91,10 @@ jobs: # bypassed PR review still fails before publishing. run: uv run python scripts/check_conformance_manifest.py + - name: Reject repeated imports + # Mirrors the equivalent step in ci.yml. + run: uv run python scripts/check_repeated_imports.py + - name: Lint (ruff check) run: uv run ruff check . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a49c3f2e..5fc81c11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,11 @@ repos: - repo: local hooks: + - id: repeated-imports + name: repeated imports + entry: uv run python scripts/check_repeated_imports.py + language: system + types: [python] - id: pyright name: pyright entry: uv run pyright diff --git a/scripts/check_repeated_imports.py b/scripts/check_repeated_imports.py new file mode 100644 index 00000000..9b31bf76 --- /dev/null +++ b/scripts/check_repeated_imports.py @@ -0,0 +1,115 @@ +"""Fail when a function re-imports something its module already imports. + +A function-local ``import asyncio`` in a module that already has +``import asyncio`` at the top does nothing: the name is bound either way. +CodeQL reports it as ``py/repeated-import`` and ruff has no rule for it, so +this check runs where ruff does, at commit time and in CI. + +Function-local imports are not flagged in general; many are deliberate. Only +an import whose binding and target both match a module-level runtime import is +a repeat. That is broader than CodeQL's rule, which considers plain +``import X`` only and only when it can resolve ``X`` to a module, so it misses +``from X import Y`` repeats and some stdlib modules entirely. + +Usage: + python scripts/check_repeated_imports.py [FILE ...] + +With no arguments, checks every Python file under the source, test, example and +script trees. Exits 1 and lists each repeat when any are found. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOTS = ("src", "tests", "examples", "scripts") + +# (bound name, what it is bound to). `import a.b` binds `a` but loads `a.b`, so +# the target is the full dotted path; a `from` import's target is the module, +# its relative level, and the member. +Binding = tuple[str, tuple[object, ...]] + + +def _bindings(node: ast.Import | ast.ImportFrom) -> list[Binding]: + if isinstance(node, ast.Import): + return [(alias.asname or alias.name.split(".")[0], ("import", alias.name)) for alias in node.names] + return [ + (alias.asname or alias.name, ("from", node.level, node.module, alias.name)) + for alias in node.names + if alias.name != "*" + ] + + +def _is_type_checking_guard(node: ast.If) -> bool: + test = node.test + if isinstance(test, ast.Name): + return test.id == "TYPE_CHECKING" + return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + + +def _module_bindings(body: list[ast.stmt]) -> set[Binding]: + # Runtime bindings only. An import under `if TYPE_CHECKING:` binds nothing + # at runtime, and a function re-importing the same name is the standard way + # to use it there without a circular import, so it is not a repeat. + found: set[Binding] = set() + for stmt in body: + if isinstance(stmt, (ast.Import, ast.ImportFrom)): + found.update(_bindings(stmt)) + elif isinstance(stmt, ast.If) and not _is_type_checking_guard(stmt): + found |= _module_bindings(stmt.body) | _module_bindings(stmt.orelse) + elif isinstance(stmt, ast.Try): + for block in (stmt.body, stmt.orelse, stmt.finalbody): + found |= _module_bindings(block) + for handler in stmt.handlers: + found |= _module_bindings(handler.body) + return found + + +def check(path: Path) -> list[str]: + try: + tree = ast.parse(path.read_text(), filename=str(path)) + except SyntaxError: + return [] # not ours to report; the interpreter and ruff will + module_level = _module_bindings(tree.body) + if not module_level: + return [] + # Keyed on the import node, so an import inside a nested function is + # reported once against its innermost function rather than once per + # enclosing one. ast.walk is breadth-first, so the innermost function is + # the last to claim each node. + owner: dict[ast.AST, str] = {} + for fn in ast.walk(tree): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + for node in ast.walk(fn): + if isinstance(node, (ast.Import, ast.ImportFrom)): + owner[node] = fn.name + problems = [] + for node, fn_name in sorted(owner.items(), key=lambda item: item[0].lineno): + assert isinstance(node, (ast.Import, ast.ImportFrom)) + for binding in _bindings(node): + if binding in module_level: + problems.append( + f"{path}:{node.lineno}: `{binding[0]}` is already imported " + f"at module level; this import in `{fn_name}` does nothing" + ) + return problems + + +def main(argv: list[str]) -> int: + if argv: + files = [Path(a) for a in argv if a.endswith(".py")] + else: + files = sorted(p for root in ROOTS if Path(root).is_dir() for p in Path(root).rglob("*.py")) + problems = [line for f in files for line in check(f)] + for line in problems: + print(line) + if problems: + print(f"\n{len(problems)} repeated import(s). Delete the function-local import.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/src/openarmature/llm/providers/openai.py b/src/openarmature/llm/providers/openai.py index ebfc47ed..746d5919 100644 --- a/src/openarmature/llm/providers/openai.py +++ b/src/openarmature/llm/providers/openai.py @@ -1316,7 +1316,7 @@ def _parse_response( finish_reason = "tool_calls" if finish_reason not in {"stop", "length", "tool_calls", "content_filter", "error"}: finish_reason = "error" - finish_reason_typed = cast("FinishReason", finish_reason) + finish_reason_typed = cast(FinishReason, finish_reason) # Build the assistant message. Tool calls under # finish_reason="error" may carry malformed argument JSON per diff --git a/tests/conformance/test_checkpoint.py b/tests/conformance/test_checkpoint.py index 665b4465..a3c90aa8 100644 --- a/tests/conformance/test_checkpoint.py +++ b/tests/conformance/test_checkpoint.py @@ -634,7 +634,7 @@ async def _run_seeded_resume_case(spec: Mapping[str, Any], *, top_level: Mapping subgraphs = _build_subgraphs_for(spec, top_level, leaf_value_recorder=leaf_values) built = build_graph(spec, subgraphs=subgraphs, trace=[], leaf_value_recorder=leaf_values) checkpointer = InMemoryCheckpointer() - built.builder.with_checkpointer(cast("Checkpointer", checkpointer)) + built.builder.with_checkpointer(cast(Checkpointer, checkpointer)) compiled = built.builder.compile() seeded_block = cast("Mapping[str, Any]", spec["seeded_record"]) @@ -710,7 +710,7 @@ async def _run_one_case(spec: Mapping[str, Any], *, top_level: Mapping[str, Any] if declared_schema_version is not None: built.state_cls.schema_version = str(declared_schema_version) - builder.with_checkpointer(cast("Checkpointer", capturing)) + builder.with_checkpointer(cast(Checkpointer, capturing)) compiled = builder.compile() # Per proposal 0028: ``runtime_state_subclass`` constructs a Python @@ -1676,7 +1676,7 @@ async def _seed_and_resume( subgraphs = _build_subgraphs_for(outer_case, top_level, leaf_value_recorder=leaf_values) built = build_graph(outer_case, subgraphs=subgraphs, trace=[], leaf_value_recorder=leaf_values) cp = InMemoryCheckpointer() - built.builder.with_checkpointer(cast("Checkpointer", cp)) + built.builder.with_checkpointer(cast(Checkpointer, cp)) compiled = built.builder.compile() inv = "seeded-cov" await cp.save(inv, _build_seeded_record(seeded, inv)) diff --git a/tests/conformance/test_observability.py b/tests/conformance/test_observability.py index 66a7d43f..f6c006e6 100644 --- a/tests/conformance/test_observability.py +++ b/tests/conformance/test_observability.py @@ -7383,8 +7383,6 @@ async def _run_orphan_fallback_case(case: Mapping[str, Any], spec: Mapping[str, from openarmature.llm import OpenAIProvider, UserMessage # noqa: PLC0415 - from .adapter import build_graph # noqa: PLC0415 - subgraph_specs = _merged_subgraph_specs(case, spec) mock_bodies = _mock_bodies_by_request_content(case, subgraph_specs) @@ -8060,7 +8058,6 @@ async def _run_llm_cache_fixture_case(case: Mapping[str, Any]) -> None: from openarmature.graph import END, GraphBuilder from openarmature.llm import OpenAIProvider, UserMessage from openarmature.llm.response import Response - from openarmature.observability.otel import OTelObserver from .adapter import build_state_cls diff --git a/tests/unit/test_langfuse_provider_fake.py b/tests/unit/test_langfuse_provider_fake.py index 53b16bc3..411df616 100644 --- a/tests/unit/test_langfuse_provider_fake.py +++ b/tests/unit/test_langfuse_provider_fake.py @@ -403,9 +403,6 @@ def test_the_classifier_agrees_with_a_real_langfuse_client() -> None: # on the path the fixture actually drives, while this file's other tests -- all # driving the fake -- passed. A double must not be the only witness for the # behaviour it exists to model, so drive the real SDK here. - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from tests.conformance.harness.langfuse_real_client import ( CONFORMANCE_HOST, diff --git a/tests/unit/test_langfuse_sdk_internals.py b/tests/unit/test_langfuse_sdk_internals.py index d2784cdc..9a2d5504 100644 --- a/tests/unit/test_langfuse_sdk_internals.py +++ b/tests/unit/test_langfuse_sdk_internals.py @@ -167,7 +167,6 @@ def test_the_installed_version_is_within_the_declared_range() -> None: # Non-vacuity for everything above: the checks are only meaningful against a # version we claim to support. This also surfaces drift between what is # installed and what openarmature.org/compatibility records as verified. - import re from importlib.metadata import version installed = version("langfuse") diff --git a/tests/unit/test_llm_provider.py b/tests/unit/test_llm_provider.py index 770f7e35..b7b8a65a 100644 --- a/tests/unit/test_llm_provider.py +++ b/tests/unit/test_llm_provider.py @@ -899,7 +899,6 @@ async def test_complete_leaves_cached_tokens_none_when_provider_silent() -> None def test_runtime_config_from_partial_drops_nones() -> None: - from openarmature.llm import RuntimeConfig config = RuntimeConfig.from_partial(temperature=0.7, max_tokens=None, top_p=0.9, seed=None) @@ -919,7 +918,6 @@ def test_runtime_config_from_partial_forwards_extras() -> None: # `from_partial` drops None-valued entries; it does not route undeclared # names. Extras reach the container the same way they do everywhere else, # so there is one spelling rather than two (0122). - from openarmature.llm import RuntimeConfig config = RuntimeConfig.from_partial(temperature=0.5, extras={"repetition_penalty": 1.05}, top_k=None) @@ -932,7 +930,6 @@ def test_runtime_config_from_partial_forwards_extras() -> None: def test_runtime_config_from_partial_empty() -> None: - from openarmature.llm import RuntimeConfig config = RuntimeConfig.from_partial() @@ -1386,7 +1383,6 @@ async def test_complete_failure_emits_typed_llm_failed_event_only() -> None: # success-only — no LlmCompletionEvent fires on failure. v0.13.0 # dropped sentinel-namespace NodeEvent emission for LLM events # entirely; no NodeEvent fires on success OR failure. - from openarmature.graph.events import LlmCompletionEvent, LlmFailedEvent, NodeEvent def _503(_req: httpx.Request) -> httpx.Response: return httpx.Response(503, json={"error": {"message": "down"}}) @@ -1425,8 +1421,6 @@ async def test_complete_structured_output_failure_event_carries_response_surface # response-side surface (finish_reason for retry triage, output_content, # usage, response identity), and error_message carries the failing # locator (the error_message) rather than just the terse summary. - from openarmature.graph.events import LlmFailedEvent - from openarmature.llm import StructuredOutputInvalid schema = { "type": "object", @@ -1492,7 +1486,6 @@ def test_structured_output_builder_projects_empty_content_to_none() -> None: # the structured path -- an empty assistant message with no tool calls fails # earlier as provider_invalid_response, and a tool_calls response skips # structured validation -- so this pins the projection at the builder. - from openarmature.llm import StructuredOutputInvalid provider = OpenAIProvider(base_url="http://test", model="m", api_key="k") exc = StructuredOutputInvalid( @@ -1729,7 +1722,7 @@ async def test_call_level_retry_per_attempt_override_varies_sampling() -> None: # override[i]. Non-overridden base fields (top_p) carry through every # attempt. The caller's config is never mutated, and each retry attempt # event carries retry_reason="transient" (None on the base attempt). - from openarmature.llm import LlmRetryConfig, RuntimeConfig + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -1767,7 +1760,7 @@ async def test_call_level_retry_per_attempt_override_varies_sampling() -> None: async def test_call_level_retry_per_attempt_override_last_entry_carries_forward() -> None: # Proposal 0095a: when the override schedule is shorter than the retry # count, the last entry carries forward. - from openarmature.llm import LlmRetryConfig, RuntimeConfig + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -1797,7 +1790,7 @@ async def test_call_level_retry_per_attempt_override_last_entry_carries_forward( async def test_call_level_retry_empty_override_uses_base_config() -> None: # An empty per_attempt_override schedule applies no override: every attempt # replays the base config, same as a plain RetryConfig. - from openarmature.llm import LlmRetryConfig, RuntimeConfig + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -1907,7 +1900,7 @@ async def test_reask_off_by_default_raises_without_retry() -> None: # Proposal 0095b (064): absent a reask builder, structured_output_invalid is # non-transient and raises on attempt 0 -- the loop does not iterate even # with max_attempts=2, so the second response is never consumed. - from openarmature.llm import LlmRetryConfig, StructuredOutputInvalid + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -1935,7 +1928,7 @@ async def test_reask_budget_exhausted_raises_final() -> None: # Proposal 0095b (063): reask consumes the max_attempts budget; when # exhausted with every attempt invalid, the final structured_output_invalid # propagates. - from openarmature.llm import LlmRetryConfig, StructuredOutputInvalid + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -1969,7 +1962,7 @@ async def test_reask_composes_with_override_and_accumulates() -> None: # Proposal 0095b (065): reask composes with a per-attempt override, and the # transcript accumulates across reask retries. Two DIFFERENT invalid outputs # then a valid one -> attempt 2 carries both prior (assistant + user) pairs. - from openarmature.llm import LlmRetryConfig, RuntimeConfig + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -2092,8 +2085,7 @@ async def test_reask_builder_failure_surfaces_original_error(bad_reask: Any) -> # exception-isolated. A builder that returns a non-str or raises MUST NOT # leak a non-§7 error; the original structured_output_invalid re-raises # through the terminal path, so exactly one terminal LlmFailedEvent fires. - from openarmature.graph.events import LlmCompletionEvent, LlmFailedEvent - from openarmature.llm import LlmRetryConfig, StructuredOutputInvalid + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -2126,7 +2118,7 @@ async def test_reask_builder_failure_surfaces_original_error(bad_reask: Any) -> async def test_per_attempt_override_none_field_inherits_base() -> None: # Adversarial-review finding: an override field explicitly set to None # inherits the base (per §6 null-skip), rather than clearing it to null. - from openarmature.llm import LlmRetryConfig, RuntimeConfig + from openarmature.llm import LlmRetryConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -2158,7 +2150,6 @@ async def test_per_attempt_override_none_field_inherits_base() -> None: async def test_call_level_retry_plain_config_replays_identically() -> None: # A plain RetryConfig (no per_attempt_override) preserves the byte-identical # replay: every attempt sends the base config unchanged. - from openarmature.llm import RuntimeConfig bodies: list[dict[str, Any]] = [] calls = [0] @@ -2520,7 +2511,6 @@ async def test_llm_completion_event_request_params_only_carries_supplied_keys() # caller-supplied gen_ai.request.* keys appear; unset RuntimeConfig # fields are omitted from the mapping (NOT included with None # values). - from openarmature.llm import RuntimeConfig events, token = _collecting_dispatch() transport = _make_openai_response_with_usage( @@ -2544,7 +2534,6 @@ async def test_llm_completion_event_request_params_only_carries_supplied_keys() async def test_llm_completion_event_request_extras_flows_through() -> None: # Proposal 0057 request_extras: RuntimeConfig extras pass-through # in native mapping form (not JSON-encoded). - from openarmature.llm import RuntimeConfig events, token = _collecting_dispatch() transport = _make_openai_response_with_usage( @@ -2847,7 +2836,6 @@ async def test_wire_byte_equality_across_dict_key_insertion_order_on_tool_parame # key insertion order MUST produce byte-identical wire bytes. # Caller-supplied JSON Schemas are the primary source of byte # drift under APC; locking them down here pins the contract. - from openarmature.llm import Tool captured: list[bytes] = [] @@ -2919,7 +2907,6 @@ async def test_wire_byte_equality_across_runtime_config_extras_dict_order() -> N # insertion order. Catches the vLLM ``guided_decoding={"choice": # ["a", "b"]}``-style extras where dict-typed values are the # primary cache-stability hit. - from openarmature.llm import RuntimeConfig captured: list[bytes] = [] @@ -2967,7 +2954,6 @@ async def test_wire_byte_array_ordering_preserved() -> None: # preserved — only dict KEYS get sorted. Verify that swapping # the order of items in ``stop_sequences`` produces DIFFERENT # wire bytes (the canonicalizer must not silently sort the list). - from openarmature.llm import RuntimeConfig captured: list[bytes] = [] @@ -3373,7 +3359,6 @@ def test_undeclared_fields_must_go_in_the_extras_container(factory: str, declare # # The rejection is the assertion that matters: without it the flat form keeps # working alongside the container and there are two spellings. - from openarmature.llm import RuntimeConfig from openarmature.prompts import SamplingConfig from openarmature.retrieval import EmbeddingRuntimeConfig, RerankRuntimeConfig diff --git a/tests/unit/test_observability_metadata.py b/tests/unit/test_observability_metadata.py index 19e52e81..0df448cf 100644 --- a/tests/unit/test_observability_metadata.py +++ b/tests/unit/test_observability_metadata.py @@ -134,10 +134,7 @@ def test_current_invocation_metadata_empty_outside_invocation() -> None: def test_set_invocation_metadata_augments_existing() -> None: async def _runner() -> dict[str, Any]: # Simulate the engine setting initial metadata. - from openarmature.observability.metadata import ( - _set_invocation_metadata, - validate_invocation_metadata, - ) + from openarmature.observability.metadata import _set_invocation_metadata token = _set_invocation_metadata(validate_invocation_metadata({"tenantId": "acme"})) try: @@ -155,11 +152,7 @@ async def _runner() -> dict[str, Any]: def test_set_invocation_metadata_overwrites_existing_key() -> None: async def _runner() -> dict[str, Any]: - from openarmature.observability.metadata import ( - _reset_invocation_metadata, - _set_invocation_metadata, - validate_invocation_metadata, - ) + from openarmature.observability.metadata import _reset_invocation_metadata, _set_invocation_metadata token = _set_invocation_metadata(validate_invocation_metadata({"phase": "draft"})) try: @@ -712,11 +705,7 @@ async def test_terminal_failure_discards_final_failed_attempt_writes() -> None: # metadata ContextVar is back at the pre-attempt baseline — no # leak of the final failed attempt's writes. from openarmature.graph.middleware import RetryConfig, RetryMiddleware, compose_chain - from openarmature.observability.metadata import ( - _reset_invocation_metadata, - _set_invocation_metadata, - validate_invocation_metadata, - ) + from openarmature.observability.metadata import _reset_invocation_metadata, _set_invocation_metadata attempts: list[int] = [] @@ -753,11 +742,7 @@ async def test_cancellation_discards_in_flight_attempt_writes() -> None: # propagate (no retry, no swallow), so the reset must happen IN # ADDITION to, not instead of, propagating ``CancelledError``. from openarmature.graph.middleware import RetryConfig, RetryMiddleware, compose_chain - from openarmature.observability.metadata import ( - _reset_invocation_metadata, - _set_invocation_metadata, - validate_invocation_metadata, - ) + from openarmature.observability.metadata import _reset_invocation_metadata, _set_invocation_metadata attempts: list[int] = [] @@ -793,7 +778,6 @@ def test_validate_rejects_the_openarmature_underscore_namespace() -> None: # namespace in the same sentence as four new exact names, which makes "a few # more exact matches" the natural misreading; a name no mapping emits, like # the one below, is the case that tells the two readings apart. - from openarmature.observability.metadata import validate_invocation_metadata # Matched on the RULE, not the echoed key. The message interpolates the key, # so `match="openarmature_"` succeeded for any ValueError naming it, @@ -809,7 +793,6 @@ def test_validate_rejects_the_0119_reserved_names(key: str) -> None: # `error_message` absent under the default privacy posture: an unreserved # caller key of that name lands unopposed in the very field 0118 requires to # be absent, reintroducing the leak through the metadata channel. - from openarmature.observability.metadata import validate_invocation_metadata # The exact-name rule specifically, not merely "a rejection happened": these # four are reserved by NAME, and matching the rule keeps the test honest if diff --git a/tests/unit/test_observability_otel.py b/tests/unit/test_observability_otel.py index 65f11168..4e6555dd 100644 --- a/tests/unit/test_observability_otel.py +++ b/tests/unit/test_observability_otel.py @@ -2014,7 +2014,6 @@ async def test_call_level_retry_fixture_per_attempt_spans(fixture_id: str) -> No # observer renders one span per event; in production the engine's # serial queue carries them, here they are captured then replayed. import json - from pathlib import Path import httpx import yaml @@ -2160,7 +2159,6 @@ async def test_call_level_reask_retry_fixture(fixture_id: str) -> None: # harness (which has no observer), mirroring # test_call_level_retry_fixture_per_attempt_spans above. import json - from pathlib import Path import httpx import yaml @@ -2404,7 +2402,6 @@ def test_install_log_bridge_is_idempotent() -> None: the deprecated ``opentelemetry.sdk._logs.LoggingHandler`` used to emit a ``DeprecationWarning``. Any future regression that re-introduces the deprecated path fires here immediately.""" - import warnings from opentelemetry.sdk._logs import LoggerProvider @@ -2533,7 +2530,6 @@ def test_log_bridge_exports_records_with_correlation_id() -> None: Wrapped in ``warnings.catch_warnings("error")`` so the logging-handler migration's "no more deprecation warning" guarantee is asserted on the affirmative export path too.""" - import warnings from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import ( @@ -2606,7 +2602,6 @@ async def test_shared_observer_concurrent_invocations_dont_collide() -> None: namespaces, no longer closes another in-flight invocation's span on a new event, and produces N distinct trace_ids for N concurrent invocations on the same compiled graph.""" - import asyncio exporter = InMemorySpanExporter() observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter)) @@ -2672,7 +2667,6 @@ async def test_concurrent_fan_out_no_lifo_violation() -> None: hazard goes away. This test drives a fan-out with three instances and asserts the run completes without the warnings that the suppressed guards would have produced.""" - import warnings class _ParentState(State): items: list[int] = Field(default_factory=list[int]) @@ -2685,7 +2679,6 @@ class _ChildState(State): async def _double(s: _ChildState) -> dict[str, int]: # Yield to give other instances a chance to interleave their # started/completed events on the observer queue. - import asyncio await asyncio.sleep(0) return {"out": s.item * 2} @@ -2821,7 +2814,6 @@ async def test_concurrent_fan_out_llm_spans_parent_under_calling_instance() -> N calling-node identity (namespace_prefix + attempt_index + fan_out_index threaded via ContextVar onto the LLM event payload) is what makes this attribution correct.""" - import asyncio import httpx @@ -3200,11 +3192,7 @@ async def test_prompt_context_propagates_cross_task_via_provider_complete() -> N from datetime import UTC, datetime import httpx - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) - from openarmature.graph import END, GraphBuilder, State from openarmature.llm import OpenAIProvider, UserMessage from openarmature.prompts import ( PromptResult, @@ -3419,7 +3407,6 @@ async def test_metadata_augmentation_in_fan_out_isolates_per_instance() -> None: # parent span, NOT the invocation span, and NOT sibling instances' # spans. Each ``inner_ask`` span ends up tagged with its own # ``product_id`` only. - import asyncio from openarmature.observability.correlation import current_fan_out_index from openarmature.observability.metadata import set_invocation_metadata @@ -3533,7 +3520,6 @@ async def test_metadata_augmentation_in_parallel_branches_skips_sibling() -> Non # OTel observer's open-span key disambiguates concurrent same- # named inner nodes across sibling branches (pre-fix, both # branches' ``ask`` opens collided on the same _StackKey). - import asyncio from openarmature.graph import BranchSpec from openarmature.observability.metadata import set_invocation_metadata @@ -4582,7 +4568,6 @@ async def test_metadata_augmentation_updates_per_branch_dispatch_span() -> None: # ``_collect_augmentation_targets`` per-branch-dispatch lookup # added in PR 9. Sibling-skip is still enforced — the OTHER # branch's dispatch span MUST NOT carry the augmenter's key. - import asyncio from openarmature.graph import BranchSpec from openarmature.observability.metadata import set_invocation_metadata @@ -4674,7 +4659,6 @@ async def test_nested_fan_out_augmentation_reaches_outer_instance_dispatch_span( # is exercised by the resolver picking the matching outer # dispatch span (and skipping the sibling) on each leaf's # augmentation. - import asyncio from openarmature.observability.metadata import set_invocation_metadata @@ -4800,7 +4784,6 @@ async def test_nested_fan_out_in_fan_out_dispatch_lineage() -> None: # lineage keys, no cross-instance collision -- before the fix the second # collided with the first), and an inner leaf's augmentation reaches its own # outer instance dispatch, not the sibling's, and not the shared NODE spans. - import asyncio from openarmature.observability.metadata import set_invocation_metadata @@ -4906,7 +4889,6 @@ async def test_parallel_branches_in_fan_out_dispatch_lineage() -> None: # Each outer instance gets its own per-branch dispatch spans (distinct keys, # no cross-instance collision); only the augmenting branch + its outer # instance dispatch carry the augmentation, not the sibling branch. - import asyncio from openarmature.graph import BranchSpec from openarmature.observability.metadata import set_invocation_metadata @@ -5309,7 +5291,6 @@ async def test_orphan_parent_survives_populate_caller_metadata_off() -> None: # test that only checked the parent would have caught this one but not the # next thing the openers learn to raise on. import json - import warnings import httpx @@ -5693,7 +5674,6 @@ async def test_failure_isolated_marker_survives_orphan_path_synthesis() -> None: # End-to-end on purpose. Unit-testing the metadata helpers in isolation does # NOT pin this: reverting the openers' defensive read leaves those green, # because they never go through an opener. - import warnings from openarmature.graph import FailureIsolationMiddleware from openarmature.graph.parallel_branches import BranchSpec diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py index a2a232d4..bc9efce5 100644 --- a/tests/unit/test_prompts.py +++ b/tests/unit/test_prompts.py @@ -572,8 +572,6 @@ async def fetch( def test_manager_jinja_undefined_opt_out_renders_empty_for_missing_var() -> None: import jinja2 - from openarmature.prompts import PromptManager - prompt = TextPrompt( name="opt_out", version="v1", @@ -930,7 +928,6 @@ def test_placeholder_segment_accepts_valid_name() -> None: def test_chat_prompt_rejects_duplicate_placeholder_at_construction() -> None: # Spec §3.1: placeholder names MUST be unique within a single # chat_template. Construction-time enforcement. - from datetime import UTC, datetime from pydantic import ValidationError @@ -987,13 +984,8 @@ async def test_chat_segment_template_cache_is_content_stable() -> None: # cache key derives from a SHA-256 of the segment source so it's # stable across process restarts (not the salted built-in # ``hash()``). - from datetime import UTC, datetime - from openarmature.prompts import ( - ChatPrompt, - ContentSegment, - PromptManager, - ) + from openarmature.prompts import ChatPrompt, ContentSegment backend = _DummyBackend() manager = PromptManager(backend) @@ -1050,16 +1042,8 @@ async def test_inline_image_block_rejects_invalid_base64_at_render() -> None: # render-time check raises ``prompt_render_error`` rather than # letting the malformed payload reach the LLM provider where it # would surface as a provider-specific decode error. - from datetime import UTC, datetime - from openarmature.prompts import ( - ChatPrompt, - ContentSegment, - ImageInlineBlockTemplate, - PromptManager, - PromptRenderError, - TextBlockTemplate, - ) + from openarmature.prompts import ChatPrompt, ContentSegment, ImageInlineBlockTemplate, TextBlockTemplate backend = _DummyBackend() manager = PromptManager(backend) diff --git a/tests/unit/test_prompts_langfuse.py b/tests/unit/test_prompts_langfuse.py index d3021dd4..60a22cc8 100644 --- a/tests/unit/test_prompts_langfuse.py +++ b/tests/unit/test_prompts_langfuse.py @@ -204,7 +204,7 @@ async def test_chat_prompt_with_malformed_placeholder_fetches_then_raises_at_ren # ``PlaceholderSegment.model_construct`` to bypass construction- # time validators so the offending name reaches the render path # before surfacing. - from openarmature.prompts import ChatPrompt, PlaceholderSegment, PromptManager, PromptRenderError + from openarmature.prompts import ChatPrompt, PlaceholderSegment, PromptRenderError chat_result = _chat_client_with_raw_prompt( [ From 4ef482eeec523694585d32af55c676ee8a6edb00 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 6 Oct 2026 19:08:49 -0700 Subject: [PATCH 2/2] Flag only imports that are safe to delete The guard told callers to delete imports whose removal could change behaviour. It now counts a module binding only when every runtime path makes it (if/else branches, try bodies and handlers), including the else branch of an `if TYPE_CHECKING:` check. It walks each function's own scope without entering nested classes or functions, and it skips a name bound elsewhere in the function or in an enclosing function, since deleting the import would not fall through to the module binding. --- scripts/check_repeated_imports.py | 101 +++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/scripts/check_repeated_imports.py b/scripts/check_repeated_imports.py index 9b31bf76..fbb20818 100644 --- a/scripts/check_repeated_imports.py +++ b/scripts/check_repeated_imports.py @@ -22,10 +22,13 @@ import ast import sys +from collections.abc import Iterator from pathlib import Path ROOTS = ("src", "tests", "examples", "scripts") +_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + # (bound name, what it is bound to). `import a.b` binds `a` but loads `a.b`, so # the target is the full dotted path; a `from` import's target is the module, # its relative level, and the member. @@ -50,23 +53,58 @@ def _is_type_checking_guard(node: ast.If) -> bool: def _module_bindings(body: list[ast.stmt]) -> set[Binding]: - # Runtime bindings only. An import under `if TYPE_CHECKING:` binds nothing - # at runtime, and a function re-importing the same name is the standard way - # to use it there without a circular import, so it is not a repeat. + # Only bindings every runtime path makes, so deleting a function-local + # import the guard flags can never leave the name undefined. An import + # under `if TYPE_CHECKING:` binds nothing at runtime, and a function + # re-importing the same name is the standard way to use it there without + # a circular import, so it is not a repeat. found: set[Binding] = set() for stmt in body: if isinstance(stmt, (ast.Import, ast.ImportFrom)): found.update(_bindings(stmt)) - elif isinstance(stmt, ast.If) and not _is_type_checking_guard(stmt): - found |= _module_bindings(stmt.body) | _module_bindings(stmt.orelse) + elif isinstance(stmt, ast.If) and _is_type_checking_guard(stmt): + found |= _module_bindings(stmt.orelse) + elif isinstance(stmt, ast.If): + found |= _module_bindings(stmt.body) & _module_bindings(stmt.orelse) elif isinstance(stmt, ast.Try): - for block in (stmt.body, stmt.orelse, stmt.finalbody): - found |= _module_bindings(block) - for handler in stmt.handlers: - found |= _module_bindings(handler.body) + paths = [_module_bindings(stmt.body + stmt.orelse)] + paths += [_module_bindings(handler.body) for handler in stmt.handlers] + found |= paths[0].intersection(*paths[1:]) | _module_bindings(stmt.finalbody) return found +def _own_scope(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]: + # Stops at nested scopes: an import in a nested class body sets a class + # attribute, and nested functions are checked as functions of their own. + stack: list[ast.AST] = list(fn.body) + while stack: + node = stack.pop() + yield node + if not isinstance(node, _SCOPES): + stack.extend(ast.iter_child_nodes(node)) + + +def _scope_names(fn: ast.FunctionDef | ast.AsyncFunctionDef, skip: set[Binding]) -> set[str]: + # Every name the function's own scope binds, except through imports whose + # binding is in `skip`. Comprehension targets are included; that only + # makes the guard report less. + args = fn.args + params = [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg] + names = {a.arg for a in params if a is not None} + for node in _own_scope(fn): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + names.update(name for name, target in _bindings(node) if (name, target) not in skip) + elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name: + names.add(node.name) + elif isinstance(node, ast.MatchMapping) and node.rest: + names.add(node.rest) + return names + + def check(path: Path) -> list[str]: try: tree = ast.parse(path.read_text(), filename=str(path)) @@ -75,26 +113,33 @@ def check(path: Path) -> list[str]: module_level = _module_bindings(tree.body) if not module_level: return [] - # Keyed on the import node, so an import inside a nested function is - # reported once against its innermost function rather than once per - # enclosing one. ast.walk is breadth-first, so the innermost function is - # the last to claim each node. - owner: dict[ast.AST, str] = {} + parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} + problems: list[tuple[int, str]] = [] for fn in ast.walk(tree): - if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - for node in ast.walk(fn): - if isinstance(node, (ast.Import, ast.ImportFrom)): - owner[node] = fn.name - problems = [] - for node, fn_name in sorted(owner.items(), key=lambda item: item[0].lineno): - assert isinstance(node, (ast.Import, ast.ImportFrom)) - for binding in _bindings(node): - if binding in module_level: - problems.append( - f"{path}:{node.lineno}: `{binding[0]}` is already imported " - f"at module level; this import in `{fn_name}` does nothing" - ) - return problems + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + # A name bound anywhere else in this function, or in an enclosing + # function, would not fall through to the module import if this + # import were deleted. Class scopes are skipped by name resolution. + shadowed = _scope_names(fn, module_level) + outer = parents.get(fn) + while outer is not None: + if isinstance(outer, (ast.FunctionDef, ast.AsyncFunctionDef)): + shadowed |= _scope_names(outer, set()) + outer = parents.get(outer) + for node in _own_scope(fn): + if not isinstance(node, (ast.Import, ast.ImportFrom)): + continue + for binding in _bindings(node): + if binding in module_level and binding[0] not in shadowed: + problems.append( + ( + node.lineno, + f"{path}:{node.lineno}: `{binding[0]}` is already imported " + f"at module level; this import in `{fn.name}` does nothing", + ) + ) + return [message for _, message in sorted(problems)] def main(argv: list[str]) -> int: