refactor(annual): move the annual package and report facts into annual_service
Staging's backend gate (run 6338) failed three budgets: handler method count 218 > 217, JyotishAPIHandler.__new__ sites 6 > 5, and the scripts/ root .py count. The package and facts code now live in scripts/annual_service/ (package.py, report_facts.py, api.py with compute_annual_package / compute_annual_report_facts and AnnualServiceInputError). jyotish_api_server.py registers /api/annual mode=package, /api/annual/package and /api/annual/report_facts thinly through a module-level _annual_service helper, like /api/chart_points, with no handler methods: back to 217. Tests call the module functions and keep a thin-registration test; no cap or baseline changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Annual data package and annual report facts (Gitea #152 / #153 / #154).
|
||||
|
||||
- ``package``: the versioned annual data package (``build_annual_package``).
|
||||
- ``report_facts``: the annual report's claim cards (``build_annual_report_facts``).
|
||||
- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for
|
||||
``/api/annual`` (mode package), ``/api/annual/package`` and
|
||||
``/api/annual/report_facts``.
|
||||
"""
|
||||
@@ -0,0 +1,151 @@
|
||||
"""HTTP-facing entry points for the annual package and report facts.
|
||||
|
||||
``scripts/jyotish_api_server.py`` registers these thinly (like
|
||||
``chart_view_points.api``): it loads this module, calls a function with the
|
||||
JSON body and maps ``AnnualServiceInputError`` to HTTP 400. Request parsing
|
||||
lives here, not on the handler class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try: # scripts/ on sys.path (API server, tests)
|
||||
from annual_service import package as _package
|
||||
from annual_service import report_facts as _facts
|
||||
except ImportError: # pragma: no cover - repository-root import
|
||||
from scripts.annual_service import package as _package
|
||||
from scripts.annual_service import report_facts as _facts
|
||||
|
||||
|
||||
class AnnualServiceInputError(ValueError):
|
||||
"""Bad request input; the API maps it to HTTP 400."""
|
||||
|
||||
|
||||
def _number(body: dict[str, Any], key: str, low: float, high: float, *, default: float | None = None,
|
||||
aliases: tuple[str, ...] = ()) -> float:
|
||||
value = body.get(key)
|
||||
for alias in aliases:
|
||||
if value is None:
|
||||
value = body.get(alias)
|
||||
if value is None or value == "":
|
||||
if default is None:
|
||||
raise AnnualServiceInputError(f"{key} is required")
|
||||
return float(default)
|
||||
if isinstance(value, bool):
|
||||
raise AnnualServiceInputError(f"{key} must be a number")
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AnnualServiceInputError(f"{key} must be a number") from exc
|
||||
if not low <= number <= high:
|
||||
raise AnnualServiceInputError(f"{key} must be between {low:g} and {high:g}")
|
||||
return number
|
||||
|
||||
|
||||
def _ayanamsa(body: dict[str, Any]) -> str:
|
||||
try:
|
||||
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name
|
||||
raw = body.get("ayanamsa") or body.get("ayanamsa_name") or body.get("ayanamsa_policy")
|
||||
if raw in (None, ""):
|
||||
return DEFAULT_AYANAMSA_NAME
|
||||
try:
|
||||
return normalize_ayanamsa_name(raw)
|
||||
except UnsupportedAyanamsaError as exc:
|
||||
raise AnnualServiceInputError(str(exc)) from exc
|
||||
|
||||
|
||||
def _node_mode(body: dict[str, Any]) -> str:
|
||||
raw = body.get("node_mode", body.get("nodeMode"))
|
||||
if raw in (None, ""):
|
||||
return "mean"
|
||||
if not isinstance(raw, str) or raw.strip().lower() not in {"mean", "true"}:
|
||||
raise AnnualServiceInputError("node_mode must be mean or true")
|
||||
return raw.strip().lower()
|
||||
|
||||
|
||||
def _timezone_id(body: dict[str, Any]) -> str | None:
|
||||
for key in ("timezone_id", "timezone"):
|
||||
value = body.get(key)
|
||||
if isinstance(value, str) and "/" in value:
|
||||
try:
|
||||
ZoneInfo(value.strip())
|
||||
except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request
|
||||
raise AnnualServiceInputError(f"{key} is not a known IANA timezone") from exc
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _birth(body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise AnnualServiceInputError("JSON body must be an object")
|
||||
year = int(_number(body, "year", 1800, 2400))
|
||||
month = int(_number(body, "month", 1, 12))
|
||||
day = int(_number(body, "day", 1, 31))
|
||||
hour = int(_number(body, "hour", 0, 23))
|
||||
minute = int(_number(body, "minute", 0, 59))
|
||||
second = int(_number(body, "second", 0, 59, default=0, aliases=("birth_second",)))
|
||||
try:
|
||||
local = datetime(year, month, day, hour, minute, second)
|
||||
except ValueError as exc:
|
||||
raise AnnualServiceInputError("Invalid birth date") from exc
|
||||
lat = _number(body, "lat", -90, 90, aliases=("birth_lat",))
|
||||
lon = _number(body, "lon", -180, 180, aliases=("birth_lon",))
|
||||
if body.get("tz") not in (None, ""):
|
||||
tz = _number(body, "tz", -14, 14)
|
||||
else:
|
||||
try:
|
||||
from domain_calculation_service import infer_timezone_offset
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.domain_calculation_service import infer_timezone_offset
|
||||
try:
|
||||
tz = float(infer_timezone_offset(lat=lat, lon=lon, local_datetime=local))
|
||||
except ValueError as exc:
|
||||
raise AnnualServiceInputError(str(exc)) from exc
|
||||
return {"year": year, "month": month, "day": day, "hour": hour, "minute": minute, "second": second,
|
||||
"lat": lat, "lon": lon, "tz": tz, "timezone_id": _timezone_id(body)}
|
||||
|
||||
|
||||
def _request(body: dict[str, Any]) -> dict[str, Any]:
|
||||
birth = _birth(body)
|
||||
try:
|
||||
return _package.normalize_request(
|
||||
birth=birth,
|
||||
ayanamsa=_ayanamsa(body),
|
||||
node_mode=_node_mode(body),
|
||||
report_range=body.get("report_range"),
|
||||
observation_date=body.get("observation_date") or body.get("today"),
|
||||
return_location=body.get("return_location"),
|
||||
)
|
||||
except _package.AnnualPackageInputError as exc:
|
||||
raise AnnualServiceInputError(str(exc)) from exc
|
||||
|
||||
|
||||
def compute_annual_package(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`/api/annual` with mode=package, and `/api/annual/package`."""
|
||||
request = _request(body)
|
||||
detail = body.get("detail") or "full"
|
||||
if detail not in ("full", "slim"):
|
||||
raise AnnualServiceInputError("detail must be full or slim")
|
||||
package = _package.build_annual_package(request, detail=detail)
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "annual",
|
||||
"mode": "package",
|
||||
"annual_package_version": package["annual_package_version"],
|
||||
"package": package,
|
||||
}
|
||||
|
||||
|
||||
def compute_annual_report_facts(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`/api/annual/report_facts`: the annual report writer's only input (#153)."""
|
||||
request = _request(body)
|
||||
confirmed = body.get("birth_time_confirmed")
|
||||
if not isinstance(confirmed, bool):
|
||||
raise AnnualServiceInputError("birth_time_confirmed must be true or false")
|
||||
facts = _facts.build_annual_report_facts(request, birth_time_confirmed=confirmed)
|
||||
return {"success": True, "endpoint": "annual_report_facts", "facts": facts}
|
||||
@@ -617,7 +617,7 @@ def _report_range(ctx: _Context) -> tuple[dict[str, Any], list[int]]:
|
||||
lookback = _local_midnight(_add_months(obs, -LOOKBACK_MONTHS), zone)
|
||||
observed = _local_midnight(obs, zone)
|
||||
out.update(block(
|
||||
"computed" if covered is None else "blocked", "annual_package._report_range",
|
||||
"computed" if covered is None else "blocked", "annual_service.package._report_range",
|
||||
reason=covered,
|
||||
start=instant(start, zone),
|
||||
end=instant(end, zone),
|
||||
@@ -29,10 +29,10 @@ import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import annual_package as ap
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts import annual_package as ap
|
||||
try: # scripts/ on sys.path (API server, tests)
|
||||
from annual_service import package as ap
|
||||
except ImportError: # pragma: no cover - repository-root import
|
||||
from scripts.annual_service import package as ap
|
||||
|
||||
SCHEMA = "jyotish.annual_report_facts.v1"
|
||||
|
||||
@@ -262,7 +262,7 @@ def _annual_house(chart_block: dict, planet: str | None) -> int | None:
|
||||
def build_cards(package: dict, natal: dict) -> dict[str, Any]:
|
||||
zone_jd = _jd(package["report_range"].get("start"))
|
||||
if package["report_range"].get("status") != "computed" or zone_jd is None:
|
||||
return ap.block("blocked", "annual_report_facts.build_cards", reason="report_range_blocked")
|
||||
return ap.block("blocked", "annual_service.report_facts.build_cards", reason="report_range_blocked")
|
||||
range_lo = _jd(package["report_range"]["start"])
|
||||
range_hi = _jd(package["report_range"]["end"])
|
||||
obs_jd = _jd(package["report_range"]["observation"]["instant"])
|
||||
@@ -357,7 +357,7 @@ def build_cards(package: dict, natal: dict) -> dict[str, Any]:
|
||||
domains = _domain_cards(natal, charts, phases, windows, events)
|
||||
conflicts = _conflicts(package, phases, natal)
|
||||
return ap.block(
|
||||
"computed", "annual_report_facts.build_cards",
|
||||
"computed", "annual_service.report_facts.build_cards",
|
||||
config={key: list(value) if isinstance(value, tuple) else value for key, value in CONFIG.items()},
|
||||
house_domains={key: list(value) for key, value in HOUSE_DOMAINS.items()},
|
||||
phases=phases, turning_points=[{k: v for k, v in e.items() if k != "jd"} for e in events],
|
||||
@@ -635,10 +635,10 @@ def build_annual_report_facts(request: dict[str, Any], *, birth_time_confirmed:
|
||||
ctx = ap._Context(request)
|
||||
natal = ap._guard("jyotish_engine._compute_chart_from_args", lambda: _natal_base(ctx, birth_time_confirmed))
|
||||
if natal.get("status") != "computed":
|
||||
cards = ap.block("blocked", "annual_report_facts.build_cards", reason="natal_base_blocked")
|
||||
cards = ap.block("blocked", "annual_service.report_facts.build_cards", reason="natal_base_blocked")
|
||||
audit: list[dict[str, str]] = []
|
||||
else:
|
||||
cards = ap._guard("annual_report_facts.build_cards", lambda: build_cards(package, natal))
|
||||
cards = ap._guard("annual_service.report_facts.build_cards", lambda: build_cards(package, natal))
|
||||
audit = _technique_audit(package, natal)
|
||||
public_natal = {key: value for key, value in natal.items() if not key.startswith("_")}
|
||||
digest = hashlib.sha256(json.dumps(package, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
||||
@@ -2554,6 +2554,15 @@ def _pooled(handler, method, body):
|
||||
return heavy_pool.run(method, body, inline)
|
||||
|
||||
|
||||
def _annual_service(function_name, body):
|
||||
"""Thin registration of scripts/annual_service/api.py (#152 / #153)."""
|
||||
annual_api = _load_local_module('annual_service.api')
|
||||
try:
|
||||
return getattr(annual_api, function_name)(body)
|
||||
except annual_api.AnnualServiceInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
|
||||
def _load_local_module(module_name):
|
||||
cached = _LOCAL_MODULE_CACHE.get(module_name)
|
||||
if cached:
|
||||
@@ -7734,7 +7743,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
|
||||
def _compute_annual(self, body):
|
||||
if isinstance(body, dict) and body.get('mode') == 'package':
|
||||
return self._compute_annual_package(body)
|
||||
return _annual_service('compute_annual_package', body)
|
||||
target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400)
|
||||
birth_dt = self._parse_birth_datetime(body)
|
||||
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
|
||||
@@ -7766,79 +7775,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
)
|
||||
return {'success': True, 'endpoint': 'annual', 'report': report}
|
||||
|
||||
def _compute_annual_package(self, body):
|
||||
"""Annual data package v1 (#152 ruling 2026-11-01): `mode: "package"` or /api/annual/package.
|
||||
|
||||
The default `/api/annual` response (basic solar-return report) keeps its shape.
|
||||
"""
|
||||
birth_dt, lat, lon, tz, timezone_id = self._annual_birth(body)
|
||||
package_mod, request = self._annual_request(body, birth_dt, lat, lon, tz, timezone_id)
|
||||
detail = body.get('detail') or 'full'
|
||||
if detail not in ('full', 'slim'):
|
||||
raise BadRequest('detail must be full or slim')
|
||||
package = package_mod.build_annual_package(request, detail=detail)
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'annual',
|
||||
'mode': 'package',
|
||||
'annual_package_version': package['annual_package_version'],
|
||||
'package': package,
|
||||
}
|
||||
|
||||
def _annual_request(self, body, birth_dt, lat, lon, tz, timezone_id):
|
||||
package_mod = _load_local_module('annual_package')
|
||||
try:
|
||||
request = package_mod.normalize_request(
|
||||
birth={
|
||||
'year': birth_dt.year, 'month': birth_dt.month, 'day': birth_dt.day,
|
||||
'hour': birth_dt.hour, 'minute': birth_dt.minute, 'second': birth_dt.second,
|
||||
'lat': lat, 'lon': lon, 'tz': tz, 'timezone_id': timezone_id,
|
||||
},
|
||||
ayanamsa=_request_ayanamsa(body),
|
||||
node_mode=_request_node_mode(body),
|
||||
report_range=body.get('report_range'),
|
||||
observation_date=body.get('observation_date') or body.get('today'),
|
||||
return_location=body.get('return_location'),
|
||||
)
|
||||
except package_mod.AnnualPackageInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return package_mod, request
|
||||
|
||||
def _annual_birth(self, body):
|
||||
if not isinstance(body, dict):
|
||||
raise BadRequest('JSON body must be an object')
|
||||
birth_dt = self._parse_birth_datetime(body)
|
||||
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
|
||||
lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180)
|
||||
tz = self._parse_timezone(
|
||||
body, lat, lon, birth_dt.year, birth_dt.month, birth_dt.day,
|
||||
birth_dt.hour, birth_dt.minute, birth_dt.second,
|
||||
)
|
||||
timezone_id = None
|
||||
for key in ('timezone_id', 'timezone'):
|
||||
value = body.get(key)
|
||||
if isinstance(value, str) and '/' in value:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
ZoneInfo(value.strip())
|
||||
except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request
|
||||
raise BadRequest(f'{key} is not a known IANA timezone') from exc
|
||||
timezone_id = value.strip()
|
||||
break
|
||||
return birth_dt, lat, lon, tz, timezone_id
|
||||
|
||||
def _compute_annual_report_facts(self, body):
|
||||
"""Annual report facts and claim cards (#153); the report writer's only input."""
|
||||
birth_dt, lat, lon, tz, timezone_id = self._annual_birth(body)
|
||||
_package_mod, request = self._annual_request(body, birth_dt, lat, lon, tz, timezone_id)
|
||||
confirmed = body.get('birth_time_confirmed')
|
||||
if not isinstance(confirmed, bool):
|
||||
raise BadRequest('birth_time_confirmed must be true or false')
|
||||
facts = _load_local_module('annual_report_facts').build_annual_report_facts(
|
||||
request, birth_time_confirmed=confirmed,
|
||||
)
|
||||
return {'success': True, 'endpoint': 'annual_report_facts', 'facts': facts}
|
||||
|
||||
def _compute_tajika(self, body):
|
||||
result = self._compute_annual(body)
|
||||
return {
|
||||
@@ -10721,11 +10657,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
|
||||
result = self._compute_annual(body)
|
||||
self._json(result)
|
||||
elif path == '/api/annual/package':
|
||||
result = self._compute_annual_package(body)
|
||||
self._json(result)
|
||||
self._json(_annual_service('compute_annual_package', body))
|
||||
elif path == '/api/annual/report_facts':
|
||||
result = self._compute_annual_report_facts(body)
|
||||
self._json(result)
|
||||
self._json(_annual_service('compute_annual_report_facts', body))
|
||||
elif path == '/api/tajika':
|
||||
result = self._compute_tajika(body)
|
||||
self._json(result)
|
||||
|
||||
Reference in New Issue
Block a user