From 3d085eaec40033a979d3a2234819a7ed68004c6b Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 7 Jul 2026 15:14:13 -0700 Subject: [PATCH] fix(operator): don't import the server stack on the operator path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_chat/__init__.py eagerly imported run_server (sanic/pydantic) and Client, so any `cmd_chat.*` import — including `python -m cmd_chat.operator` — pulled the server stack at package init. On a phone/Termux operator that installs only the operator deps (and where srp may be unbuildable), this failed before the client's pure-SRP fallback could help. Make both imports lazy inside main()'s serve/connect branches. The operator path now imports cleanly with srp/sanic/pydantic all absent (client falls back to _srp_pure). Add a subprocess regression test to keep it that way. Co-Authored-By: Claude Opus 4.6 --- cmd_chat/__init__.py | 11 ++++++-- tests/test_operator_import_termux.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/test_operator_import_termux.py diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index ef9bc2d..e1e9504 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -2,8 +2,11 @@ import argparse import getpass import os -from cmd_chat.server.server import run_server -from cmd_chat.client.client import Client +# NOTE: the server (run_server) and client (Client) are imported lazily inside +# main() below. Importing them here would pull sanic/pydantic (server) at +# package-init time for *any* `cmd_chat.*` import — including +# `python -m cmd_chat.operator` on a phone/Termux where those server-only deps +# aren't installed. See docs/termux-operator.md (Phase 0). def resolve_password(args_password: str | None, prompt: str = "Room password: ") -> str: @@ -43,6 +46,8 @@ def main(): args = parser.parse_args() if args.command == "serve": + from cmd_chat.server.server import run_server + password = resolve_password(args.password) run_server( host=args.ip_address, @@ -53,6 +58,8 @@ def main(): no_tls=args.no_tls, ) elif args.command == "connect": + from cmd_chat.client.client import Client + password = resolve_password(args.password) Client( server=args.ip_address, diff --git a/tests/test_operator_import_termux.py b/tests/test_operator_import_termux.py new file mode 100644 index 0000000..d4f45a0 --- /dev/null +++ b/tests/test_operator_import_termux.py @@ -0,0 +1,40 @@ +"""Guard the operator's lightweight import surface (docs/termux-operator.md P0). + +A phone/Termux operator installs only the operator deps (requests, rich, +websockets, cryptography, srp) — never the server stack (sanic, pydantic) — and +`srp` may be unbuildable there (pure-Python fallback). So `python -m +cmd_chat.operator` must import with srp/sanic/pydantic ALL absent. This is easy +to regress: any eager `import srp`/sanic on the operator path breaks it. Run the +check in a subprocess so blocking modules can't pollute the test interpreter. +""" + +import subprocess +import sys + + +_SNIPPET = """ +import sys +# Simulate a Termux operator box: no srp C-ext build, no server-only deps. +for missing in ("srp", "sanic", "sanic_ext", "pydantic"): + sys.modules[missing] = None # 'import X' now raises ImportError + +import cmd_chat.operator # must not need srp/sanic/pydantic +assert hasattr(cmd_chat.operator, "OperatorBridge") + +import cmd_chat.client.client as c # client must fall back to the shim +assert c.srp.__name__.endswith("_srp_pure"), c.srp.__name__ +print("OK") +""" + + +def test_operator_imports_without_srp_or_server_deps(): + proc = subprocess.run( + [sys.executable, "-c", _SNIPPET], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, ( + "operator import failed without srp/sanic/pydantic:\n" + f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + ) + assert proc.stdout.strip().endswith("OK"), proc.stdout