112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""Every third-party import must be declared in requirements.txt or pyproject.toml.
|
|
|
|
`requests` — the HTTP client behind EVERY COSMOS call, imported at module level in
|
|
`cosmos/client.py` — was in neither file. A clean `pip install -r requirements.txt` then
|
|
failed on import, not at the first request. `openpyxl`, which reads the competitor workbook on
|
|
the default path, was missing too. Meanwhile `openai` was declared and never imported: the
|
|
real import is `langchain-openai`, so declaring `openai` alone did not enable the LLM path.
|
|
|
|
These tests parse the actual imports instead of trusting a hand-maintained list, so adding a
|
|
dependency without declaring it fails here rather than on someone else's machine.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
SKIP_DIRS = {"__pycache__", ".venv", "new_archtetcure_and frontend", ".mojibake_backup"}
|
|
LOCAL = {"pricing_agent", "dashboard", "config", "app", "legacy_app", "backend", "tests"}
|
|
|
|
# import name -> distribution name, where they differ.
|
|
DIST = {
|
|
"yaml": "pyyaml",
|
|
"dotenv": "python-dotenv",
|
|
"apify_client": "apify-client",
|
|
"langchain_openai": "langchain-openai",
|
|
"pydantic_settings": "pydantic-settings",
|
|
"google": "google-auth",
|
|
"sp_api": "python-amazon-sp-api",
|
|
"PIL": "pillow",
|
|
}
|
|
# Test-only, and declared under [dev].
|
|
TEST_ONLY = {"pytest"}
|
|
|
|
|
|
def _sources():
|
|
for f in ROOT.rglob("*.py"):
|
|
if not SKIP_DIRS.intersection(f.parts):
|
|
yield f
|
|
|
|
|
|
def _third_party_imports() -> dict[str, set[str]]:
|
|
found: dict[str, set[str]] = {}
|
|
for f in _sources():
|
|
try:
|
|
tree = ast.parse(f.read_text(encoding="utf-8"))
|
|
except SyntaxError: # not our problem here
|
|
continue
|
|
for node in ast.walk(tree):
|
|
mods: list[str] = []
|
|
if isinstance(node, ast.Import):
|
|
mods = [a.name.split(".")[0] for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
mods = [node.module.split(".")[0]]
|
|
for m in mods:
|
|
if m and m not in sys.stdlib_module_names and m not in LOCAL:
|
|
found.setdefault(m, set()).add(str(f.relative_to(ROOT)))
|
|
return found
|
|
|
|
|
|
def _declared() -> set[str]:
|
|
text = (ROOT / "requirements.txt").read_text(encoding="utf-8")
|
|
text += (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
names = re.findall(r"^\s*[\"']?([A-Za-z][A-Za-z0-9._-]*)\s*(?:[><=!~]|[\"']|$)",
|
|
text, re.M)
|
|
return {n.lower().replace("_", "-") for n in names}
|
|
|
|
|
|
def test_every_import_is_declared_somewhere():
|
|
declared = _declared()
|
|
missing = {}
|
|
for mod, files in _third_party_imports().items():
|
|
if mod in TEST_ONLY:
|
|
continue
|
|
dist = DIST.get(mod, mod).lower().replace("_", "-")
|
|
if dist not in declared and mod.lower().replace("_", "-") not in declared:
|
|
missing[dist] = sorted(files)[:3]
|
|
assert not missing, f"undeclared dependencies: {missing}"
|
|
|
|
|
|
def test_requests_is_in_requirements_txt():
|
|
"""Module-level in cosmos/client.py — the dashboard cannot import without it."""
|
|
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
|
|
assert re.search(r"^requests\b", req, re.M), "requests missing from requirements.txt"
|
|
|
|
|
|
def test_openpyxl_is_in_requirements_txt():
|
|
"""The default competitor path reads a workbook; without this it raises at runtime."""
|
|
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
|
|
assert re.search(r"^openpyxl\b", req, re.M), "openpyxl missing from requirements.txt"
|
|
|
|
|
|
def test_the_llm_extra_names_the_package_actually_imported():
|
|
"""`openai` alone does not enable the narrative — the import is `langchain_openai`."""
|
|
req = (ROOT / "requirements.txt").read_text(encoding="utf-8")
|
|
assert re.search(r"^langchain-openai\b", req, re.M)
|
|
|
|
|
|
def test_dashboard_requirements_stay_a_subset_of_the_package():
|
|
"""Anything the dashboard needs must also be installable via `pip install -e .`."""
|
|
req_names = {
|
|
m.group(1).lower()
|
|
for m in re.finditer(r"^([A-Za-z][A-Za-z0-9._-]*)",
|
|
(ROOT / "requirements.txt").read_text(encoding="utf-8"), re.M)
|
|
}
|
|
proj = (ROOT / "pyproject.toml").read_text(encoding="utf-8").lower()
|
|
# streamlit/plotly/pandas/numpy live under the [ui] extra; everything else is a core dep.
|
|
for name in req_names:
|
|
assert name in proj, f"{name} is in requirements.txt but not in pyproject.toml"
|