Add a Textual chat interface with threaded SSE streaming, slash commands, interrupt handling, and bare nexus dispatch. Package it behind the tui extra, document usage, and cover command routing, dependencies, and headless interaction with tests.
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""Guard the wheel's dependency list against drift.
|
|
|
|
There are now two dependency declarations: requirements-base.txt (what the
|
|
desktop installers pip -r) and pyproject.toml (what the wheel ships). They will
|
|
drift. What actually breaks a user is narrower than "they differ", though: it
|
|
is an import that no declared distribution provides, so that is what this pins.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SHIPPED_PACKAGES = ("synapse", "nexusos_cli", "modules")
|
|
|
|
# Import name -> distribution name, where PyPI disagrees with the module.
|
|
DISTRIBUTION_OF = {
|
|
"docx": "python-docx",
|
|
"dotenv": "python-dotenv",
|
|
"faster_whisper": "faster-whisper",
|
|
"imap_tools": "imap-tools",
|
|
"sqlite_vec": "sqlite-vec",
|
|
"yaml": "pyyaml",
|
|
"PIL": "pillow",
|
|
}
|
|
|
|
# Provided by another declared distribution rather than named directly.
|
|
# rich: Textual depends on it, so the tui extra already pulls it in.
|
|
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
|
|
|
# Modules that ship inside this repo.
|
|
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
|
|
|
|
|
def _pyproject() -> dict:
|
|
return tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _requirement_name(spec: str) -> str:
|
|
"""'pypdf>=5,<7' -> 'pypdf'; strips extras and environment markers."""
|
|
head = spec.split(";", 1)[0].strip()
|
|
for sep in ("[", "=", ">", "<", "!", "~", " "):
|
|
head = head.split(sep, 1)[0]
|
|
return head.strip().lower().replace("_", "-")
|
|
|
|
|
|
def _declared() -> set[str]:
|
|
project = _pyproject()["project"]
|
|
specs = list(project.get("dependencies", []))
|
|
for extra in project.get("optional-dependencies", {}).values():
|
|
specs.extend(extra)
|
|
return {_requirement_name(s) for s in specs}
|
|
|
|
|
|
def test_wheel_includes_every_shipped_package():
|
|
wheel = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]
|
|
configured = set(wheel["packages"])
|
|
assert configured == set(SHIPPED_PACKAGES)
|
|
|
|
|
|
def _imported_modules() -> set[str]:
|
|
"""Top-level module names imported anywhere in the shipped packages."""
|
|
found: set[str] = set()
|
|
for package in SHIPPED_PACKAGES:
|
|
for path in (ROOT / package).rglob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
found.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
# level > 0 is a relative (first-party) import.
|
|
if node.level == 0 and node.module:
|
|
found.add(node.module.split(".")[0])
|
|
return found
|
|
|
|
|
|
def _third_party() -> set[str]:
|
|
return {
|
|
module for module in _imported_modules()
|
|
if module not in sys.stdlib_module_names
|
|
and module not in FIRST_PARTY
|
|
and module not in TRANSITIVE
|
|
and not module.startswith("_")
|
|
}
|
|
|
|
|
|
def test_every_third_party_import_is_a_declared_dependency():
|
|
declared = _declared()
|
|
missing = sorted(
|
|
module for module in _third_party()
|
|
if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared
|
|
)
|
|
assert not missing, (
|
|
"a shipped package imports these, but pyproject.toml declares no "
|
|
f"distribution for them: {missing}. Add them to [project] dependencies "
|
|
"or an extra (and to DISTRIBUTION_OF here if the names differ)."
|
|
)
|
|
|
|
|
|
def test_all_extra_is_the_union_of_the_capability_extras():
|
|
extras = _pyproject()["project"]["optional-dependencies"]
|
|
combined: set[str] = set()
|
|
for name, specs in extras.items():
|
|
if name in ("all", "dev", "standard"):
|
|
continue
|
|
combined.update(_requirement_name(s) for s in specs)
|
|
everything = {_requirement_name(s) for s in extras["all"]}
|
|
assert combined == everything, (
|
|
"the 'all' extra drifted from the capability extras; "
|
|
f"missing={sorted(combined - everything)} extra={sorted(everything - combined)}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["fastapi", "uvicorn", "httpx", "pydantic", "pyyaml"])
|
|
def test_core_runtime_is_a_hard_dependency_not_an_extra(name):
|
|
"""These are imported at module scope, so the base install must carry them."""
|
|
base = {_requirement_name(s) for s in _pyproject()["project"]["dependencies"]}
|
|
assert name in base
|
|
|
|
|
|
def test_optional_imports_are_lazy():
|
|
"""Anything only in an extra must not be imported at module scope.
|
|
|
|
A base `pip install nexusos-ai` has none of the extras, so a top-level
|
|
`import psutil` in synapse would make the backend unimportable.
|
|
"""
|
|
base = {_requirement_name(s) for s in _pyproject()["project"]["dependencies"]}
|
|
offenders: list[str] = []
|
|
for package in SHIPPED_PACKAGES:
|
|
for path in (ROOT / package).rglob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
for node in tree.body: # module scope only
|
|
names: list[str] = []
|
|
if isinstance(node, ast.Import):
|
|
names = [a.name.split(".")[0] for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
names = [node.module.split(".")[0]]
|
|
for module in names:
|
|
if module in sys.stdlib_module_names or module in FIRST_PARTY:
|
|
continue
|
|
dist = DISTRIBUTION_OF.get(module, module).lower().replace("_", "-")
|
|
if dist not in base and module not in TRANSITIVE:
|
|
offenders.append(f"{path.relative_to(ROOT)}: {module}")
|
|
assert not offenders, (
|
|
"optional dependencies imported at module scope (wrap in try/ImportError "
|
|
f"or import inside the function): {offenders}"
|
|
)
|