Skip to content

Commit 1fbaaf1

Browse files
committed
feat: add tests for dependency / package management
1 parent 46935a8 commit 1fbaaf1

2 files changed

Lines changed: 606 additions & 0 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
"""Subprocess helper for dependency isolation tests.
2+
3+
Usage: python _check_dep_isolation.py <group_name> <module1> [module2 ...]
4+
5+
Exits 0 if all imports are from declared dependencies, 1 if violations found.
6+
"""
7+
8+
import importlib
9+
import importlib.metadata
10+
import re
11+
import sys
12+
import tomllib
13+
from pathlib import Path
14+
15+
# Packages that are part of the Python standard library or otherwise
16+
# should never be flagged as undeclared dependencies.
17+
STDLIB_AND_INFRASTRUCTURE = {
18+
# Build/install infrastructure that leaks into sys.modules
19+
"_distutils_hack",
20+
"pkg_resources",
21+
"setuptools",
22+
"pip",
23+
"wheel",
24+
"distutils",
25+
}
26+
27+
# Packages that third-party libraries opportunistically import via
28+
# `try/except ImportError` when installed. These are extras of core
29+
# networking and serialization libraries — not declared by mellea, but
30+
# they appear in sys.modules when present in the environment.
31+
OPPORTUNISTIC_IMPORTS = {
32+
# urllib3 / httpx extras (compression & protocol upgrades)
33+
"brotli",
34+
"brotlicffi",
35+
"zstandard",
36+
"h2",
37+
"hpack",
38+
"hyperframe",
39+
"socksio",
40+
# Widely used utility imported opportunistically by many packages
41+
"packaging",
42+
# Fast JSON — used by pydantic/fastapi when available
43+
"orjson",
44+
}
45+
46+
47+
def parse_dep_name(dep_spec: str) -> str | None:
48+
"""Extract the distribution name from a dependency specifier.
49+
50+
Strips version constraints, extras, and environment markers.
51+
Returns None for self-references like 'mellea[hooks]'.
52+
"""
53+
# Remove environment markers (e.g., "; sys_platform != 'darwin'")
54+
dep_spec = dep_spec.split(";")[0].strip()
55+
# Extract just the package name (before any version/extras specifiers)
56+
match = re.match(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)", dep_spec)
57+
if not match:
58+
return None
59+
name = match.group(1).lower()
60+
# Skip self-references (handled separately by extract_self_ref_groups)
61+
if name == "mellea":
62+
return None
63+
return name
64+
65+
66+
def extract_self_ref_groups(dep_spec: str) -> list[str]:
67+
"""Extract optional-dependency group names from self-references.
68+
69+
e.g. 'mellea[hooks]' → ['hooks'], 'mellea[watsonx,hf,vllm]' → ['watsonx', 'hf', 'vllm']
70+
Returns an empty list for non-self-references.
71+
"""
72+
dep_spec = dep_spec.split(";")[0].strip()
73+
match = re.match(r"^mellea\[([^\]]+)\]", dep_spec, re.IGNORECASE)
74+
if not match:
75+
return []
76+
return [g.strip() for g in match.group(1).split(",")]
77+
78+
79+
def get_top_level_names(dist_name: str) -> set[str]:
80+
"""Get the importable top-level module names for a distribution."""
81+
try:
82+
dist = importlib.metadata.distribution(dist_name)
83+
except importlib.metadata.PackageNotFoundError:
84+
return set()
85+
86+
# Try top_level.txt first
87+
top_level = dist.read_text("top_level.txt")
88+
if top_level:
89+
return {line.strip() for line in top_level.splitlines() if line.strip()}
90+
91+
# Fall back to packages listed in RECORD
92+
names = set()
93+
if dist.files:
94+
for f in dist.files:
95+
parts = str(f).split("/")
96+
if len(parts) > 1 and not parts[0].endswith(".dist-info"):
97+
name = parts[0].replace(".py", "")
98+
if name and not name.startswith("_") and name != "__pycache__":
99+
names.add(name)
100+
if names:
101+
return names
102+
103+
# Last resort: normalize the dist name itself
104+
return {dist_name.replace("-", "_").lower()}
105+
106+
107+
def get_transitive_deps(dist_name: str, seen: set[str] | None = None) -> set[str]:
108+
"""Recursively resolve all transitive dependencies of a distribution.
109+
110+
Returns a set of normalized distribution names.
111+
"""
112+
if seen is None:
113+
seen = set()
114+
115+
normalized = dist_name.lower().replace("-", "_")
116+
if normalized in seen:
117+
return set()
118+
seen.add(normalized)
119+
120+
result = {normalized}
121+
try:
122+
dist = importlib.metadata.distribution(dist_name)
123+
except importlib.metadata.PackageNotFoundError:
124+
return result
125+
126+
reqs = dist.requires
127+
if not reqs:
128+
return result
129+
130+
for req in reqs:
131+
# For extras-only requirements (e.g., 'brotli ; extra == "brotli"'),
132+
# include them if actually installed. These are legitimate transitive
133+
# deps of declared packages — e.g., urllib3[brotli] pulls in brotli,
134+
# datasets[s3] pulls in boto3, transformers[torch] pulls in torchvision.
135+
if "extra ==" in req:
136+
dep = parse_dep_name(req)
137+
if dep:
138+
try:
139+
importlib.metadata.distribution(dep)
140+
result |= get_transitive_deps(dep, seen)
141+
except importlib.metadata.PackageNotFoundError:
142+
pass
143+
continue
144+
dep = parse_dep_name(req)
145+
if dep:
146+
result |= get_transitive_deps(dep, seen)
147+
148+
return result
149+
150+
151+
def build_allowed_set(
152+
group_name: str, also_allow_groups: list[str] | None = None,
153+
) -> set[str]:
154+
"""Build the set of allowed top-level import names for a dependency group.
155+
156+
Args:
157+
group_name: The optional-dependency group (or "core" for base only).
158+
also_allow_groups: Extra optional-dependency groups whose packages
159+
should also be allowed. Use this for groups that are imported
160+
opportunistically via ``try/except ImportError`` guards — the
161+
code works without them, but they *will* appear in
162+
``sys.modules`` when installed.
163+
"""
164+
# Parse pyproject.toml
165+
pyproject_path = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
166+
167+
with open(pyproject_path, "rb") as f:
168+
pyproject = tomllib.load(f)
169+
170+
# Collect declared distribution names: core + specified group
171+
core_deps = pyproject.get("project", {}).get("dependencies", [])
172+
optional_deps = pyproject.get("project", {}).get("optional-dependencies", {})
173+
# "core" is a special pseudo-group meaning core deps only
174+
group_deps = [] if group_name == "core" else optional_deps.get(group_name, [])
175+
176+
# Include deps from additionally-allowed groups
177+
extra_deps: list[str] = []
178+
for g in also_allow_groups or []:
179+
extra_deps.extend(optional_deps.get(g, []))
180+
181+
# Expand self-references like 'mellea[hooks]' into that group's deps
182+
all_dep_specs = core_deps + group_deps + extra_deps
183+
expanded: list[str] = []
184+
seen_groups: set[str] = set()
185+
queue = list(all_dep_specs)
186+
while queue:
187+
spec = queue.pop(0)
188+
refs = extract_self_ref_groups(spec)
189+
if refs:
190+
for ref in refs:
191+
if ref not in seen_groups:
192+
seen_groups.add(ref)
193+
queue.extend(optional_deps.get(ref, []))
194+
else:
195+
expanded.append(spec)
196+
197+
declared_dists: set[str] = set()
198+
for dep_spec in expanded:
199+
name = parse_dep_name(dep_spec)
200+
if name:
201+
declared_dists.add(name)
202+
203+
# Resolve transitive dependencies
204+
all_allowed_dists: set[str] = set()
205+
for dist_name in declared_dists:
206+
all_allowed_dists |= get_transitive_deps(dist_name)
207+
208+
# Map all allowed distributions to their importable top-level names
209+
allowed_imports: set[str] = set()
210+
for dist_name in all_allowed_dists:
211+
allowed_imports |= get_top_level_names(dist_name)
212+
213+
# Also add the normalized dist names themselves (common pattern)
214+
for dist_name in all_allowed_dists:
215+
allowed_imports.add(dist_name.replace("-", "_").lower())
216+
217+
return allowed_imports
218+
219+
220+
def is_third_party(module_name: str) -> bool:
221+
"""Check if a module name appears to be third-party (not stdlib, not local)."""
222+
top = module_name.split(".")[0]
223+
224+
if top in STDLIB_AND_INFRASTRUCTURE or top in OPPORTUNISTIC_IMPORTS:
225+
return False
226+
227+
# Skip internal/private modules
228+
if top.startswith("_"):
229+
return False
230+
231+
# Skip mellea and cli (our own packages)
232+
if top in ("mellea", "cli", "test"):
233+
return False
234+
235+
# Check if it's a known distribution
236+
try:
237+
importlib.metadata.distribution(top)
238+
return True
239+
except importlib.metadata.PackageNotFoundError:
240+
pass
241+
242+
# Try with hyphens replaced
243+
try:
244+
importlib.metadata.distribution(top.replace("_", "-"))
245+
return True
246+
except importlib.metadata.PackageNotFoundError:
247+
pass
248+
249+
# Not a known distribution — likely stdlib
250+
return False
251+
252+
253+
def main() -> int:
254+
# Parse --allow-group flags before positional args
255+
also_allow: list[str] = []
256+
positional: list[str] = []
257+
args = sys.argv[1:]
258+
while args:
259+
# Iterates over all the args until the list is empty.
260+
if args[0] == "--allow-group" and len(args) >= 2:
261+
# Grabs <group> from ["--allow-group", "<group>", ...]
262+
also_allow.append(args[1])
263+
args = args[2:]
264+
else:
265+
positional.append(args[0])
266+
args = args[1:]
267+
268+
if len(positional) < 2:
269+
print(
270+
f"Usage: {sys.argv[0]} [--allow-group GROUP ...] <group_name> <module1> [module2 ...]",
271+
file=sys.stderr,
272+
)
273+
return 2
274+
275+
group_name = positional[0]
276+
target_modules = positional[1:]
277+
278+
# Build allowed set
279+
allowed = build_allowed_set(group_name, also_allow_groups=also_allow)
280+
281+
# Snapshot modules before import
282+
before = set(sys.modules.keys())
283+
284+
# Import target modules
285+
for mod in target_modules:
286+
try:
287+
importlib.import_module(mod)
288+
except ImportError as e:
289+
print(f"IMPORT_ERROR: Could not import {mod}: {e}", file=sys.stderr)
290+
return 2
291+
292+
# Find new third-party modules
293+
after = set(sys.modules.keys())
294+
new_modules = after - before
295+
296+
violations: list[str] = []
297+
for mod in sorted(new_modules):
298+
top = mod.split(".")[0]
299+
if not is_third_party(top):
300+
# It's a standard python package.
301+
continue
302+
if top.lower() in allowed or top.replace("-", "_").lower() in allowed:
303+
# It's allowed by the current group or an explicitly allowed group.
304+
continue
305+
violations.append(top)
306+
307+
# Deduplicate
308+
violations = sorted(set(violations))
309+
310+
if violations:
311+
print(f"VIOLATIONS for group '{group_name}':")
312+
for v in violations:
313+
print(f" - {v}")
314+
return 1
315+
316+
print(f"OK: group '{group_name}' imports only declared dependencies")
317+
return 0
318+
319+
320+
if __name__ == "__main__":
321+
sys.exit(main())

0 commit comments

Comments
 (0)