Files
Jyotisha/tests/test_api_server_growth_contract.py
T
ClaudeandClaude Opus 5.5 eb5e9adcf9 fix(shadbala): move /api/shadbala into shadbala_service; birth data no longer 500 (BUG-1463)
_compute_shadbala called swe.julday with no swe in scope, so any request with
year/month/day/lat/lon raised NameError. The endpoint now lives in
scripts/shadbala_service/api.py, which imports Swiss Ephemeris itself; the
handler only forwards. _shadbala_rule_variants and
_compute_shadbala_advanced_layer moved with it (handler methods 217 -> 215,
baseline lowered). New real-HTTP regression test with ruff F821 on the server,
added to the quick gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 23:29:30 +08:00

237 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Freeze coupling in scripts/jyotish_api_server.py.
New endpoints and features must live in new modules and be thinly registered
from the main file. Line count is only a coarse guardrail; the live gates are
handler method count and JyotishAPIHandler.__new__ forgery sites.
Baselines re-measured 2026-10-16 after TASK-api-server-service-extract-20261013
moved HTTP methods onto JyotishAPIHandler and left calculation on
JyotishComputeService. Production forgeries went from 2 to 0. The two classes
stay in this file so existing module-attribute patches still land.
"""
from __future__ import annotations
import ast
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
AGENTS = ROOT / "AGENTS.md"
# Live `wc -l scripts/jyotish_api_server.py` equivalent:
# Path.read_bytes().count(b"\n"), measured 2026-09-16 on origin/staging @ 51a65d92.
# New features must not consume this budget; open a module instead.
# 原值:10924
# 新值:10924
# 原因:行数基线按任务书保持不动。拆类只加了类声明,实测行数仍不超过这条基线。
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 10924
JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# Whole-file indent match `^ (?:async )?def \w+`, same count as
# TASK-freeze-metric-change-20260915 §1. Re-measured 2026-09-16 after
# TASK-api-server-backdoor-close-20260916 lifted 7 compute-only methods into
# scripts/offline_compute_mixins.py (225 -> 218).
# 原值:218(删占位回退之前)
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。
# 原值:217
# 新值:215
# 原因:BUG-1463 把 /api/shadbala 移到 scripts/shadbala_service/api.py,
# 删除 _shadbala_rule_variants 与 _compute_shadbala_advanced_layer,_compute_shadbala 只转发
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 215
# 原值:31
# 新值:5
# 原因:生产 2 处和只用计算方法的测试改为 JyotishComputeService()。留下的 5 处要碰 HTTP,本单不改。
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 5
# Production forgeries only. Offline callers instantiate JyotishComputeService.
# 原值:2
# 新值:0
# 原因:consultation_workflow_service 与 capture_report_blocked_repairs_golden 改为正常实例化计算类。
JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 0
# Compute-only mixins lifted out of the handler must never depend back on the
# HTTP monolith, or the backdoor simply moves house.
OFFLINE_MIXINS = ROOT / "scripts" / "offline_compute_mixins.py"
HANDLER_METHOD_RE = re.compile(r"^ (?:async )?def \w+", re.MULTILINE)
NEW_MARKER = "JyotishAPIHandler.__new__"
def _handler_method_count(source: str) -> int:
return len(HANDLER_METHOD_RE.findall(source))
def _new_hits(*folders: Path) -> Counter[str]:
hits: Counter[str] = Counter()
skip = Path(__file__).resolve()
for folder in folders or (ROOT / "scripts", ROOT / "tests"):
for path in sorted(folder.rglob("*.py")):
if path.resolve() == skip:
continue
count = path.read_text(encoding="utf-8").count(NEW_MARKER)
if count:
hits[path.relative_to(ROOT).as_posix()] = count
return hits
def test_jyotish_api_handler_method_count_must_not_grow() -> None:
source = API_SERVER.read_text(encoding="utf-8")
count = _handler_method_count(source)
assert count <= JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE, (
f"{API_SERVER.as_posix()} has {count} four-space def methods; "
f"cap is {JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE}. Move behaviour "
"into a dedicated module; thinly registered from this file."
)
def test_jyotish_api_handler_new_count_must_not_grow() -> None:
hits = _new_hits()
total = sum(hits.values())
listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items()))
assert total <= JYOTISH_API_HANDLER_NEW_COUNT_BASELINE, (
f"{NEW_MARKER} appears {total} times under scripts/ and tests/; "
f"cap is {JYOTISH_API_HANDLER_NEW_COUNT_BASELINE}. Hits: {listed}"
)
def test_scripts_handler_forgeries_must_not_grow() -> None:
hits = _new_hits(ROOT / "scripts")
total = sum(hits.values())
listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items()))
assert total <= JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE, (
f"{NEW_MARKER} appears {total} times under scripts/; cap is "
f"{JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE} and the target is 0. "
f"Offline callers must instantiate a compute mixin instead. Hits: {listed}"
)
def test_offline_mixins_must_not_import_the_http_monolith() -> None:
tree = ast.parse(OFFLINE_MIXINS.read_text(encoding="utf-8"))
imported: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
imported.append(node.module or "")
offenders = [name for name in imported if "jyotish_api_server" in name]
assert not offenders, (
f"{OFFLINE_MIXINS.as_posix()} imports {offenders}; the compute mixins must "
"not depend on the HTTP monolith or the __new__ backdoor just moves house."
)
def test_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None:
line_count = API_SERVER.read_bytes().count(b"\n")
assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, (
f"{API_SERVER.as_posix()} has {line_count} lines; cap is "
f"{JYOTISH_API_SERVER_LINE_COUNT_CAP} ({JYOTISH_API_SERVER_LINE_COUNT_BASELINE} "
"baseline + 300 coarse guardrail). New endpoints and features must be new "
"modules, thinly registered from this file."
)
def test_agents_forbids_growing_jyotish_api_server() -> None:
agents = AGENTS.read_text(encoding="utf-8")
assert "scripts/jyotish_api_server.py" in agents
assert "must not grow" in agents
assert "thinly registered" in agents
def test_quality_gate_runs_api_server_growth_contract() -> None:
from scripts.run_quality_gate import CORE_PYTEST_TARGETS
assert "tests/test_api_server_growth_contract.py" in CORE_PYTEST_TARGETS
quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")
assert '"tests/test_api_server_growth_contract.py"' in quality_gate
_HTTP_ATTRS = frozenset({
"headers",
"wfile",
"rfile",
"client_address",
"send_response",
"send_header",
"end_headers",
"path",
"command",
})
_HTTP_METHOD_NAMES = frozenset({
"_json",
"_html",
"_send_cors_headers",
"_read_json_body",
"_enforce_request_security",
"_job_access_token",
"do_GET",
"do_POST",
"do_OPTIONS",
"_error_json",
"_get_chart_job",
"_get_high_rigor_job",
"_require_dynamic_rectification_token",
"_compute_dynamic_rectification_score",
"_compute_dynamic_rectification_opportunities",
})
def test_compute_service_is_not_an_http_handler() -> None:
"""JyotishComputeService can be constructed. HTTP stays on the subclass."""
from http.server import BaseHTTPRequestHandler
from scripts.jyotish_api_server import JyotishAPIHandler, JyotishComputeService
assert not issubclass(JyotishComputeService, BaseHTTPRequestHandler)
assert issubclass(JyotishAPIHandler, JyotishComputeService)
assert issubclass(JyotishAPIHandler, BaseHTTPRequestHandler)
mro_names = [item.__name__ for item in JyotishAPIHandler.__mro__]
assert mro_names.index("BaseHTTPRequestHandler") < mro_names.index("JyotishComputeService")
assert JyotishAPIHandler.server_version == "JyotishAPI/6.9.14"
assert "server_version" not in JyotishComputeService.__dict__
for name in _HTTP_METHOD_NAMES:
assert name in JyotishAPIHandler.__dict__, name
assert name not in JyotishComputeService.__dict__, name
def _http_touches(fn: ast.AST) -> list[str]:
hits: list[str] = []
for node in ast.walk(fn):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:{node.attr}")
if (
isinstance(node, ast.Call)
and 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 == "self"
and isinstance(node.args[1], ast.Constant)
and node.args[1].value in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:getattr:{node.args[1].value}")
return hits
def test_compute_service_methods_do_not_touch_http() -> None:
tree = ast.parse(API_SERVER.read_text(encoding="utf-8"))
compute = next(
node for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "JyotishComputeService"
)
offenders: list[str] = []
for node in compute.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
offenders.extend(f"{node.name}:{hit}" for hit in _http_touches(node))
assert offenders == []