项目文件夹

文件
Ali Khokhar 258ed3e7ed Keep FCC-local traffic off outbound proxies (#1214)
## Problem

FCC-local health and model-catalog requests inherited machine proxy
settings, so an outbound proxy could return HTTP 502 even while
`fcc-server` was healthy. Spawned agents inherited the same missing
loopback bypass. Fixes #1199.

## Changes

| Before | After |
| --- | --- |
| Launcher probes used the process-wide urllib proxy policy. | FCC-local
probes use one proxy-disabled transport owner. |
| Codex catalog loading could leave through an outbound proxy. | Codex
catalog loading uses the same direct local transport. |
| Claude, Codex, Pi, and managed messaging inherited incomplete bypass
lists. | Spawned clients preserve outbound proxies while merging FCC and
loopback hosts into `NO_PROXY` and `no_proxy`. |
| Local and upstream proxy responsibilities overlapped. | CLI-local
transport and provider-specific upstream proxies have explicit separate
owners. |
| Tests mocked launcher reachability without exercising proxy
interception. | A real fake-proxy regression proves loopback traffic
never reaches the proxy, and an ownership contract prevents raw local
transports from returning. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR keeps FCC-local traffic separate from configured outbound
proxies. The main changes are:

- Adds one direct HTTP transport for FCC health and model-catalog
requests.
- Adds FCC and loopback hosts to spawned clients' proxy-bypass
environments.
- Updates Claude, Codex, and Pi launcher integration.
- Adds proxy-interception and transport-ownership tests.
- Documents the boundary and bumps the package version.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

No blocking issues found in the changed code. Updated callers supply the
new Codex environment argument. The direct opener changes proxy handling
while retaining urllib's standard request handlers. Tests cover real
proxy interception and preservation of existing bypass policy.

No changed files need additional attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex validated the contract by comparing preflight and forward-proxy
state before and after HEAD, noting the preflight result moved from HTTP
502 and the forward-proxy hits to a clean health-check state.
- T-Rex confirmed all three tests in tests/cli/test\_local\_http.py
passed.

<a
href="https://app.greptile.com/trex/runs/15066178/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/cli/local_http.py | Adds the shared direct opener
and proxy-bypass environment policy. |
| src/free_claude_code/cli/launchers/common.py | Routes FCC health
checks through the direct local transport. |
| src/free_claude_code/cli/launchers/codex.py | Routes catalog requests
directly and adds local bypass entries to the Codex environment. |
| src/free_claude_code/cli/claude_env.py | Adds FCC and loopback proxy
bypasses to Claude environments. |
| src/free_claude_code/cli/launchers/pi.py | Adds FCC and loopback proxy
bypasses to Pi environments. |
| tests/cli/test_local_http.py | Tests direct local access, proxy
interception, environment merging, and deduplication. |
| tests/contracts/test_import_boundaries.py | Enforces one owner for
direct local transports and proxy-bypass variables. |

</details>

<details open><summary><h3>Flowchart</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
L[Launcher] --> R[FCC health or catalog request]
R --> D[Direct local opener]
D --> F[FCC server]
L --> E[Build child environment]
E --> N[Merge FCC and loopback hosts into NO_PROXY]
N --> C[Claude, Codex, or Pi]
C -->|FCC-local traffic| F
C -->|Other outbound traffic| P[Configured outbound proxy]
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart LR
L[Launcher] --> R[FCC health or catalog request]
R --> D[Direct local opener]
D --> F[FCC server]
L --> E[Build child environment]
E --> N[Merge FCC and loopback hosts into NO_PROXY]
N --> C[Claude, Codex, or Pi]
C -->|FCC-local traffic| F
C -->|Other outbound traffic| P[Configured outbound proxy]
```

</a>
</details>

<sub>Reviews (1): Last reviewed commit: ["Keep FCC-local traffic off
outbound
prox..."](https://github.com/alishahryar1/free-claude-code/commit/072cc4b7474c1b33bfc368d148da5c1aa24914b6)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45589926)</sub>

<!-- /greptile_comment -->
2026-07-20 03:28:56 -07:00

795 行
26 KiB
Python

"""Declarative static import contracts; dynamic imports are deliberately unscanned."""
import ast
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PACKAGE_ROOT = _REPO_ROOT / "src" / "free_claude_code"
_PACKAGE_NAME = "free_claude_code"
ALLOWED_PACKAGE_DEPENDENCIES: dict[str, set[str]] = {
"config": set(),
"core": set(),
"application": {"config", "core"},
"messaging": {"core"},
"providers": {"application", "config", "core"},
"api": {"application", "config", "core"},
"cli": {"config", "core"},
"runtime": {
"api",
"application",
"cli",
"config",
"core",
"messaging",
"providers",
},
}
IMPORT_EXCEPTIONS: dict[tuple[str, str], str] = {
(
"free_claude_code.cli.commands",
"free_claude_code.runtime.bootstrap",
): (
"Owner: installed server command. "
"Reason: the command delegates construction to the process composition root."
),
}
FACADE_ONLY_BOUNDARIES = {
"free_claude_code.core.openai_responses",
"free_claude_code.messaging.trees",
"free_claude_code.providers.openai_chat",
}
OPTIONAL_IMPORT_OWNERS = {
"librosa": "free_claude_code.messaging.transcription",
"torch": "free_claude_code.messaging.transcription",
"transformers": "free_claude_code.messaging.transcription",
"riva": "free_claude_code.providers.nvidia_nim.voice",
}
@dataclass(frozen=True, slots=True)
class ImportRecord:
importer: str
imported: str
path: str
line: int
inside_function: bool
def describe(self) -> str:
return f"{self.path}:{self.line}: {self.importer} -> {self.imported}"
class _ImportVisitor(ast.NodeVisitor):
def __init__(
self,
*,
importer: str,
importer_is_package: bool,
modules: set[str],
path: str,
) -> None:
self._importer = importer
self._importer_is_package = importer_is_package
self._modules = modules
self._path = path
self._function_depth = 0
self.records: list[ImportRecord] = []
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
self._record(alias.name, node.lineno)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
base = _resolve_import_from_base(
importer=self._importer,
importer_is_package=self._importer_is_package,
level=node.level,
module=node.module,
)
if base is None:
return
for alias in node.names:
candidate = f"{base}.{alias.name}"
imported = candidate if candidate in self._modules else base
self._record(imported, node.lineno)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._visit_function(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._visit_function(node)
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
self._function_depth += 1
self.generic_visit(node)
self._function_depth -= 1
def _record(self, imported: str, line: int) -> None:
self.records.append(
ImportRecord(
importer=self._importer,
imported=imported,
path=self._path,
line=line,
inside_function=self._function_depth > 0,
)
)
def test_package_dependencies_follow_declarative_policy() -> None:
modules = _module_paths(_PACKAGE_ROOT)
records = _scan_imports(_PACKAGE_ROOT)
actual_packages = _ownership_roots(set(modules), _PACKAGE_NAME)
assert set(ALLOWED_PACKAGE_DEPENDENCIES) == actual_packages
assert all(
(_PACKAGE_ROOT / package / "__init__.py").is_file()
for package in actual_packages
)
assert all(
dependency in actual_packages and dependency != package
for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items()
for dependency in dependencies
)
observed_dependencies: set[tuple[str, str]] = set()
observed_exceptions: set[tuple[str, str]] = set()
offenders: list[str] = []
for record in records:
source_package = _top_level_package(record.importer)
target_package = _top_level_package(record.imported)
if source_package is None or target_package is None:
continue
if source_package == target_package:
continue
exact_edge = (record.importer, record.imported)
if exact_edge in IMPORT_EXCEPTIONS:
observed_exceptions.add(exact_edge)
continue
if target_package in ALLOWED_PACKAGE_DEPENDENCIES[source_package]:
observed_dependencies.add((source_package, target_package))
continue
offenders.append(record.describe())
declared_dependencies = {
(package, dependency)
for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items()
for dependency in dependencies
}
assert sorted(offenders) == []
assert declared_dependencies - observed_dependencies == set()
assert set(IMPORT_EXCEPTIONS) - observed_exceptions == set()
assert all(
"Owner:" in reason and "Reason:" in reason
for reason in IMPORT_EXCEPTIONS.values()
)
for source, target in IMPORT_EXCEPTIONS:
source_package = _top_level_package(source)
target_package = _top_level_package(target)
assert source_package is not None
assert target_package is not None
assert target_package not in ALLOWED_PACKAGE_DEPENDENCIES[source_package]
expected_package_modules = {
_PACKAGE_NAME,
*(f"{_PACKAGE_NAME}.{package}" for package in actual_packages),
}
assert set(modules) >= expected_package_modules
def test_first_party_imports_use_the_installable_namespace() -> None:
offenders = _legacy_first_party_import_offenders(
_scan_imports(_PACKAGE_ROOT),
set(ALLOWED_PACKAGE_DEPENDENCIES),
)
assert offenders == []
def test_openai_chat_collaborators_have_explicit_ownership_boundaries() -> None:
provider_root = _PACKAGE_ROOT / "providers" / "openai_chat"
assert _provider_backchannel_offenders(provider_root) == []
def test_google_reasoning_wire_fields_have_one_owner() -> None:
owner = _PACKAGE_ROOT / "providers" / "google_openai" / "reasoning.py"
roots = [
_PACKAGE_ROOT / "providers" / "gemini",
_PACKAGE_ROOT / "providers" / "google_openai",
_PACKAGE_ROOT / "providers" / "vertex",
]
owned_fields = {
"include_thoughts",
"reasoning_effort",
"thinking_budget",
"thinking_config",
}
offenders: list[str] = []
for root in roots:
for path in root.rglob("*.py"):
if path == owner:
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
offenders.extend(
f"{path.relative_to(_REPO_ROOT).as_posix()}:{node.lineno}: {node.value}"
for node in ast.walk(tree)
if isinstance(node, ast.Constant) and node.value in owned_fields
)
assert sorted(offenders) == []
def test_cli_local_http_transport_has_one_owner() -> None:
cli_root = _PACKAGE_ROOT / "cli"
owner = cli_root / "local_http.py"
owned_urllib_names = {"ProxyHandler", "build_opener", "urlopen"}
owned_environment_keys = {"NO_PROXY", "no_proxy"}
offenders: list[str] = []
for path in cli_root.rglob("*.py"):
if path == owner:
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
relative_path = path.relative_to(_REPO_ROOT).as_posix()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "urllib.request":
offenders.extend(
f"{relative_path}:{node.lineno}: urllib.request.{alias.name}"
for alias in node.names
if alias.name in owned_urllib_names
)
if isinstance(node, ast.Constant) and node.value in owned_environment_keys:
offenders.append(f"{relative_path}:{node.lineno}: {node.value}")
assert sorted(offenders) == []
def test_provider_backchannel_detector_reports_untyped_private_access(
tmp_path: Path,
) -> None:
provider_root = tmp_path / "openai_chat"
_write_module(
provider_root / "sample" / "runner.py",
"from typing import Any\n"
"\n"
"class Runner:\n"
" def __init__(self, provider: object | Any) -> None:\n"
" self._provider = provider\n"
"\n"
" def run(self) -> object:\n"
" return self._provider._send()\n",
)
assert _provider_backchannel_offenders(provider_root) == [
"sample/runner.py:4: untyped provider collaborator",
"sample/runner.py:8: private provider member _send outside provider.py",
]
def test_legacy_first_party_import_detector_rejects_bare_owner_names() -> None:
record = ImportRecord(
importer="free_claude_code.api.routes",
imported="core.anthropic",
path="free_claude_code/api/routes.py",
line=7,
inside_function=False,
)
assert _legacy_first_party_import_offenders([record], {"api", "core"}) == [
"free_claude_code/api/routes.py:7: "
"free_claude_code.api.routes -> core.anthropic"
]
def test_ownership_root_discovery_includes_modules_and_namespace_directories(
tmp_path: Path,
) -> None:
package_root = tmp_path / "sample"
_write_module(package_root / "__init__.py")
_write_module(package_root / "declared" / "__init__.py")
_write_module(package_root / "namespace" / "module.py")
_write_module(package_root / "root_module.py")
roots = _ownership_roots(set(_module_paths(package_root)), "sample")
assert roots == {"declared", "namespace", "root_module"}
def test_descendants_do_not_import_ancestor_package_facades() -> None:
modules = _module_paths(_PACKAGE_ROOT)
packages = {
module for module, path in modules.items() if path.name == "__init__.py"
}
offenders = _ancestor_facade_offenders(_scan_imports(_PACKAGE_ROOT), packages)
assert offenders == []
def test_static_first_party_import_graph_is_acyclic() -> None:
modules = set(_module_paths(_PACKAGE_ROOT))
graph = {module: set() for module in modules}
for record in _scan_imports(_PACKAGE_ROOT):
if record.imported in modules:
graph[record.importer].add(record.imported)
assert _cyclic_components(graph) == []
def test_cycle_detector_reports_exact_strongly_connected_components() -> None:
graph = {
"package.a": {"package.b"},
"package.b": {"package.a"},
"package.c": set(),
"package.self": {"package.self"},
}
assert _cyclic_components(graph) == [
("package.a", "package.b"),
("package.self",),
]
def test_external_consumers_use_owned_package_facades() -> None:
offenders: list[str] = []
for record in _scan_imports(_PACKAGE_ROOT):
for facade in FACADE_ONLY_BOUNDARIES:
if record.importer == facade or record.importer.startswith(f"{facade}."):
continue
if record.imported.startswith(f"{facade}."):
offenders.append(record.describe())
assert sorted(offenders) == []
def test_import_scanner_resolves_absolute_relative_and_lazy_imports(
tmp_path: Path,
) -> None:
package_root = tmp_path / "sample"
_write_module(package_root / "__init__.py")
_write_module(package_root / "alpha" / "__init__.py", "PUBLIC = object()\n")
_write_module(package_root / "alpha" / "sibling.py")
_write_module(package_root / "beta" / "__init__.py")
_write_module(package_root / "beta" / "absolute.py")
_write_module(package_root / "beta" / "relative.py")
_write_module(package_root / "beta" / "lazy.py")
_write_module(
package_root / "alpha" / "consumer.py",
"\n".join(
(
"import sample.beta.absolute",
"from ..beta import relative",
"from . import sibling",
"from . import PUBLIC",
"def load():",
" import sample.beta.lazy",
"",
)
),
)
records = _scan_imports(package_root)
resolved = {
(record.imported, record.inside_function)
for record in records
if record.importer == "sample.alpha.consumer"
}
assert resolved == {
("sample.alpha", False),
("sample.alpha.sibling", False),
("sample.beta.absolute", False),
("sample.beta.relative", False),
("sample.beta.lazy", True),
}
def test_import_scanner_distinguishes_facade_symbols_from_sibling_modules(
tmp_path: Path,
) -> None:
package_root = tmp_path / "sample"
_write_module(package_root / "__init__.py")
_write_module(package_root / "owner" / "__init__.py", "PUBLIC = object()\n")
_write_module(package_root / "owner" / "sibling.py")
_write_module(
package_root / "owner" / "consumer.py",
"from . import PUBLIC, sibling\n",
)
modules = _module_paths(package_root)
packages = {
module for module, path in modules.items() if path.name == "__init__.py"
}
offenders = _ancestor_facade_offenders(_scan_imports(package_root), packages)
assert offenders == [
"sample/owner/consumer.py:1: sample.owner.consumer -> sample.owner"
]
def test_anthropic_request_boundaries_use_the_protocol_model() -> None:
"""Known Messages fields must not cross core/provider boundaries by duck typing."""
roots = [
_PACKAGE_ROOT / "core" / "anthropic",
_PACKAGE_ROOT / "providers",
]
request_names = {"request", "request_data"}
protocol_fields = {
"extra_body",
"max_tokens",
"messages",
"model",
"stop_sequences",
"system",
"temperature",
"thinking",
"tool_choice",
"tools",
"top_k",
"top_p",
}
offenders: list[str] = []
for root in roots:
for path in root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
relative = path.relative_to(_REPO_ROOT).as_posix()
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
arguments = [
*node.args.posonlyargs,
*node.args.args,
*node.args.kwonlyargs,
]
for argument in arguments:
if argument.arg.lstrip("_") not in request_names:
continue
annotation_names = (
{
child.id
for child in ast.walk(argument.annotation)
if isinstance(child, ast.Name)
}
if argument.annotation is not None
else set()
)
if argument.annotation is None or annotation_names & {
"Any",
"Mapping",
}:
offenders.append(
f"{relative}:{argument.lineno}: "
f"{node.name}({argument.arg}) is not concrete"
)
if not isinstance(node, ast.Call) or not (
isinstance(node.func, ast.Name)
and node.func.id == "getattr"
and len(node.args) >= 2
and isinstance(node.args[0], ast.Name)
and node.args[0].id.lstrip("_") in request_names
and isinstance(node.args[1], ast.Constant)
and node.args[1].value in protocol_fields
):
continue
offenders.append(
f"{relative}:{node.lineno}: "
f"getattr({node.args[0].id}, {node.args[1].value!r})"
)
assert sorted(offenders) == []
def test_core_does_not_import_provider_transport_sdks() -> None:
forbidden_roots = {"aiohttp", "httpx", "openai"}
offenders = [
record.describe()
for record in _scan_imports(_PACKAGE_ROOT)
if (
record.importer == "free_claude_code.core"
or record.importer.startswith("free_claude_code.core.")
)
and record.imported.split(".", 1)[0] in forbidden_roots
]
assert sorted(offenders) == []
def test_providers_do_not_own_wire_error_type_literals() -> None:
wire_types = {
"api_error",
"authentication_error",
"billing_error",
"invalid_request_error",
"not_found_error",
"overloaded_error",
"permission_error",
"rate_limit_error",
"request_too_large",
"timeout_error",
}
offenders: list[str] = []
for path in (_PACKAGE_ROOT / "providers").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
offenders.extend(
f"{path.relative_to(_REPO_ROOT).as_posix()}:{node.lineno}: {node.value}"
for node in ast.walk(tree)
if isinstance(node, ast.Constant) and node.value in wire_types
)
assert sorted(offenders) == []
def test_optional_dependencies_have_one_lazy_owner() -> None:
seen: set[str] = set()
offenders: list[str] = []
for record in _scan_imports(_PACKAGE_ROOT):
dependency = record.imported.split(".", 1)[0]
owner = OPTIONAL_IMPORT_OWNERS.get(dependency)
if owner is None:
continue
seen.add(dependency)
if record.importer != owner or not record.inside_function:
offenders.append(record.describe())
assert seen == set(OPTIONAL_IMPORT_OWNERS)
assert sorted(offenders) == []
def test_runtime_imports_without_optional_voice_dependencies() -> None:
blocked = sorted(OPTIONAL_IMPORT_OWNERS)
script = "\n".join(
(
"import importlib.abc",
"import sys",
f"BLOCKED = {blocked!r}",
"class Blocker(importlib.abc.MetaPathFinder):",
" def find_spec(self, fullname, path=None, target=None):",
" if fullname.split('.', 1)[0] in BLOCKED:",
" raise ModuleNotFoundError(fullname)",
" return None",
"sys.meta_path.insert(0, Blocker())",
"import free_claude_code.runtime.bootstrap",
"import free_claude_code.api.app",
)
)
completed = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
check=False,
text=True,
)
assert completed.returncode == 0, completed.stderr or completed.stdout
def test_supported_messaging_facade_is_explicit() -> None:
import free_claude_code.messaging as facade
from free_claude_code.messaging.managed_protocols import (
ManagedClaudeSessionManagerProtocol,
ManagedClaudeSessionProtocol,
)
from free_claude_code.messaging.models import IncomingMessage, MessageScope
from free_claude_code.messaging.platforms.ports import OutboundMessenger
expected = {
"IncomingMessage": IncomingMessage,
"ManagedClaudeSessionManagerProtocol": ManagedClaudeSessionManagerProtocol,
"ManagedClaudeSessionProtocol": ManagedClaudeSessionProtocol,
"MessageScope": MessageScope,
"OutboundMessenger": OutboundMessenger,
}
assert set(facade.__all__) == set(expected)
assert all(getattr(facade, name) is value for name, value in expected.items())
def test_message_tree_mutability_stays_behind_its_facade() -> None:
import free_claude_code.messaging.trees as facade
for internal_owner in {
"MessageNode",
"MessageTree",
"TreeQueueProcessor",
"TreeRepository",
}:
assert internal_owner not in facade.__all__
assert not hasattr(facade, internal_owner)
def _module_paths(package_root: Path) -> dict[str, Path]:
return {
_module_name(package_root, path): path for path in package_root.rglob("*.py")
}
def _module_name(package_root: Path, path: Path) -> str:
relative = path.relative_to(package_root)
module_parts = (
relative.parent.parts
if path.name == "__init__.py"
else relative.with_suffix("").parts
)
return ".".join((package_root.name, *module_parts))
def _scan_imports(package_root: Path) -> list[ImportRecord]:
module_paths = _module_paths(package_root)
modules = set(module_paths)
records: list[ImportRecord] = []
for importer, path in sorted(module_paths.items()):
visitor = _ImportVisitor(
importer=importer,
importer_is_package=path.name == "__init__.py",
modules=modules,
path=path.relative_to(package_root.parent).as_posix(),
)
visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))
records.extend(visitor.records)
return records
def _resolve_import_from_base(
*,
importer: str,
importer_is_package: bool,
level: int,
module: str | None,
) -> str | None:
if level == 0:
return module
package_parts = importer.split(".")
if not importer_is_package:
package_parts.pop()
parents_to_remove = level - 1
if parents_to_remove > len(package_parts):
return None
if parents_to_remove:
del package_parts[-parents_to_remove:]
if module is not None:
package_parts.extend(module.split("."))
return ".".join(package_parts) or None
def _top_level_package(module: str) -> str | None:
parts = module.split(".")
if len(parts) < 2 or parts[0] != _PACKAGE_NAME:
return None
return parts[1]
def _ownership_roots(modules: set[str], package_name: str) -> set[str]:
roots: set[str] = set()
for module in modules:
parts = module.split(".")
if len(parts) >= 2 and parts[0] == package_name:
roots.add(parts[1])
return roots
def _legacy_first_party_import_offenders(
records: list[ImportRecord],
owner_names: set[str],
) -> list[str]:
offenders = [
record.describe()
for record in records
if record.imported.split(".", 1)[0] in owner_names
]
return sorted(offenders)
def _provider_backchannel_offenders(provider_root: Path) -> list[str]:
offenders: list[str] = []
for path in sorted(provider_root.rglob("*.py")):
relative_path = path.relative_to(provider_root).as_posix()
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
arguments = (
*node.args.posonlyargs,
*node.args.args,
*node.args.kwonlyargs,
)
offenders.extend(
f"{relative_path}:{argument.lineno}: untyped provider collaborator"
for argument in arguments
if argument.arg == "provider"
and _annotation_is_any(argument.annotation)
)
if (
path.name != "provider.py"
and isinstance(node, ast.Attribute)
and node.attr.startswith("_")
and _is_provider_reference(node.value)
):
offenders.append(
f"{relative_path}:{node.lineno}: private provider member "
f"{node.attr} outside provider.py"
)
return sorted(offenders)
def _annotation_is_any(annotation: ast.expr | None) -> bool:
if annotation is None:
return False
return any(
(isinstance(node, ast.Name) and node.id == "Any")
or (isinstance(node, ast.Attribute) and node.attr == "Any")
for node in ast.walk(annotation)
)
def _is_provider_reference(expression: ast.expr) -> bool:
return (isinstance(expression, ast.Name) and expression.id == "provider") or (
isinstance(expression, ast.Attribute)
and isinstance(expression.value, ast.Name)
and expression.value.id == "self"
and expression.attr == "_provider"
)
def _ancestor_facade_offenders(
records: list[ImportRecord],
packages: set[str],
) -> list[str]:
offenders = [
record.describe()
for record in records
if record.imported in packages
and record.importer.startswith(f"{record.imported}.")
]
return sorted(offenders)
def _cyclic_components(graph: dict[str, set[str]]) -> list[tuple[str, ...]]:
index = 0
indices: dict[str, int] = {}
lowlinks: dict[str, int] = {}
stack: list[str] = []
on_stack: set[str] = set()
components: list[tuple[str, ...]] = []
def visit(node: str) -> None:
nonlocal index
indices[node] = index
lowlinks[node] = index
index += 1
stack.append(node)
on_stack.add(node)
for successor in sorted(graph[node]):
if successor not in indices:
visit(successor)
lowlinks[node] = min(lowlinks[node], lowlinks[successor])
elif successor in on_stack:
lowlinks[node] = min(lowlinks[node], indices[successor])
if lowlinks[node] != indices[node]:
return
component: list[str] = []
while True:
member = stack.pop()
on_stack.remove(member)
component.append(member)
if member == node:
break
if len(component) > 1 or node in graph[node]:
components.append(tuple(sorted(component)))
for node in sorted(graph):
if node not in indices:
visit(node)
return sorted(components)
def _write_module(path: Path, text: str = "") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")