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
This commit is contained in:
2026-11-01 23:29:30 +08:00
co-authored by Claude Opus 5.5
parent 0f1c179eb6
commit eb5e9adcf9
7 changed files with 332 additions and 104 deletions
+9 -9
View File
@@ -19935,19 +19935,19 @@
## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500
- 状态:investigating
- 状态:resolved
- 首次发现:2026-10-27(做 issue #114 时调接口发现)
- 最近更新:2026-10-27
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。
- 最近更新:2026-11-01
- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。进程内的两处调用(咨询的 `_attach_local_consultation_layers`、专题的 `_derive_thematic_evidence`)只在图里还没有 `modules.shadbala` 时才走这里,以前会被各自的 `except` 记成 `unavailable` / `warning`,不报 500。
- 用户现象:接口返回 `ERR_INTERNAL`。
- 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。
- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。
- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。
- 防复发:待修复单补接口测试。
- 相关记录:issue #114。
- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。整份文件只有这一处(`ruff --select F821` 实测只报 7564 行)。只带星体经度的旧测试走不到这条分支,所以一直没拦住。
- 修复:新建 `scripts/shadbala_service/api.py`,模块顶部 `import swisseph as swe`,把 `_compute_shadbala` 的出生时刻分支、`_shadbala_rule_variants`、`_compute_shadbala_advanced_layer` 原样移过去。主文件的 `_compute_shadbala` 只转发,并把不支持的 ayanamsa 转成 400。主文件四空格方法 217 → 215,`tests/test_api_server_growth_contract.py` 基线同步降到 215。
- 验证:`tests/test_shadbala_service_api.py` 在本机端口起真实 `JyotishAPIHandler`,POST 金盘出生资料(1990-06-15 12:00 北京,raman,虚构)→ 200;`method_variants` 为精确 Kala / Chesta;七星 Dig Bala 等于同一时刻 `build_shadbala_context` 的宫中点算出的值。只带星体时仍等于 `chart-insight-golden.json` 的 `chart_modules_shadbala`。坏 ayanamsa、缺日月 → 400。把主文件换回修复前版本,同一测试得到 500 和 `NameError: name 'swe' is not defined`。
- 防复发:上面的接口测试已加进 `scripts/run_quality_gate.py` 的 quick 清单;同一文件里还对 `jyotish_api_server.py`、`offline_compute_mixins.py`、`scripts/shadbala_service/` 跑 `ruff --select F821`,再有用了没导入的名字就失败。
- 相关记录:issue #114;Gitea #119 第 46 项。
- 复发自:无
- 修复版本:分支 `codex/rectification-d60-two-way-20261028`。
- 修复版本:分支 `codex/outage-shadbala-119`。
## BUG-1464 | 存过别人的账户进首页即「页面出错了」
+6 -94
View File
@@ -7545,41 +7545,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
}
def _compute_shadbala(self, body):
planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body)
if not {'Sun', 'Moon'} <= set(planet_lons):
raise BadRequest('planets must include Sun and Moon longitude data')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
birth_second = self._get_birth_second(body)
birth_hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second)
shadbala_module = _load_local_module('shadbala')
context = None
if all(key in body for key in ('year', 'month', 'day', 'lat', 'lon')):
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
jd = swe.julday(year, month, day, birth_hour_decimal - tz)
context = shadbala_module.build_shadbala_context(jd, lat, lon, _request_ayanamsa(body), tz)
result = shadbala_module.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour_decimal,
planet_lons['Sun'],
planet_lons['Moon'],
birth_minute,
context=context,
)
advanced = self._compute_shadbala_advanced_layer(body, planets, result)
return {
'success': True,
'endpoint': 'shadbala',
'rule_variants': self._shadbala_rule_variants(bool(advanced)),
'advanced_layer': advanced,
'result': result,
}
"""Thin registration of scripts/shadbala_service/api.py (BUG-1463)."""
shadbala_api = _load_local_module('shadbala_service.api')
try:
return shadbala_api.compute_shadbala(self, body)
except shadbala_api.ShadbalaInputError as exc:
raise BadRequest(str(exc)) from exc
def _compute_yogas_api(self, body):
planets, _, asc_sign_idx = self._normalized_planets_from_body(body)
@@ -7624,16 +7595,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。',
}
def _shadbala_rule_variants(self, advanced_enabled):
return {
'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'],
'available': [
{'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'},
{'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'},
],
'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。',
}
def _yoga_rule_variants(self, curse_enabled):
return {
'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []),
@@ -7673,55 +7634,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
legacy[planet] = row
return legacy
def _compute_shadbala_advanced_layer(self, body, planets, base_result):
try:
advanced_mod = _load_local_module('shadbala_advanced')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
birth_second = self._get_birth_second(body)
year = self._get_int(body, 'year', datetime.now().year, 1800, 2400)
month = self._get_int(body, 'month', 1, 1, 12)
day = self._get_int(body, 'day', 1, 1, 31)
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, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second)
solar_lon = planets.get('Sun', {}).get('lon', 0)
base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
'degree': pdata.get('lon', pdata.get('degree', 0)),
'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
'source': 'scripts/shadbala_advanced.py',
'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层',
'kala_vmdh': kala_additions,
'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala],
'yuddha_bala': yuddha,
'active_yuddha': active_yuddha,
'sputa_drik_bala': drik_sputa,
'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。',
}
except Exception as exc:
import logging
logging.warning(f"[api_server] shadbala advanced layer failed: {exc}")
return {}
def _compute_aspects(self, body):
planets, planet_lons = self._planet_lons_from_body(body)
if len(planet_lons) < 2:
+2
View File
@@ -137,6 +137,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_runtime_jhora_import_scan.py",
# English edition han leak, 12-chart slice (issue #89 / BUG-1413). The 200-chart file stays off this list.
"tests/test_report_english_sweep_smoke.py",
# /api/shadbala with the birth moment over real HTTP, plus ruff F821 on the API server (BUG-1463).
"tests/test_shadbala_service_api.py",
]
RUNTIME_TRUTH_PYTEST_TARGETS = [
+5
View File
@@ -0,0 +1,5 @@
"""Shadbala endpoint service (BUG-1463).
- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for
``/api/shadbala`` and the in-process callers of ``_compute_shadbala``.
"""
+154
View File
@@ -0,0 +1,154 @@
"""`/api/shadbala`: the six-fold strength core plus the advanced evidence layer.
Moved out of ``JyotishAPIHandler`` for BUG-1463. The handler method used
``swe.julday`` with no ``swe`` in scope, so every request carrying
year/month/day/lat/lon failed with ``NameError`` (HTTP 500). This module
imports Swiss Ephemeris itself; the handler only forwards the body.
The handler is passed in for its existing body parsers (``_get_float``,
``_parse_timezone`` ...). They raise the server's ``BadRequest`` and that
propagates unchanged. This module never imports the HTTP server.
"""
from __future__ import annotations
import logging
from typing import Any
import swisseph as swe
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
BIRTH_KEYS = ("year", "month", "day", "lat", "lon")
logger = logging.getLogger(__name__)
class ShadbalaInputError(ValueError):
"""The request names an ayanamsa the engine does not support."""
def _ayanamsa(body: dict[str, Any]) -> str:
"""Same reading as the server's ``_request_ayanamsa(body)``."""
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 ShadbalaInputError(str(exc)) from exc
def birth_context(handler: Any, body: dict[str, Any], birth_hour: float, birth_minute: float, birth_second: float) -> dict[str, Any] | None:
"""Precise Swiss Ephemeris context when the body carries the full birth moment."""
if not all(key in body for key in BIRTH_KEYS):
return None
import shadbala
year = handler._get_int(body, "year", 1990, 1800, 2400)
month = handler._get_int(body, "month", 6, 1, 12)
day = handler._get_int(body, "day", 15, 1, 31)
lat = handler._get_float(body, "lat", 0, -90, 90)
lon = handler._get_float(body, "lon", 0, -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
jd_ut = swe.julday(year, month, day, hour_decimal - tz)
return shadbala.build_shadbala_context(jd_ut, lat, lon, _ayanamsa(body), tz)
def rule_variants(advanced_enabled: bool) -> dict[str, Any]:
return {
"selected": ["core_sixfold", "advanced_evidence"] if advanced_enabled else ["core_sixfold"],
"available": [
{"key": "core_sixfold", "label": "六重力量主算法", "status": "active", "source": "scripts/shadbala.py"},
{"key": "advanced_evidence", "label": "Kala/Yuddha/Sputa 增强证据", "status": "active" if advanced_enabled else "unavailable", "source": "scripts/shadbala_advanced.py"},
],
"boundary": "增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。",
}
def advanced_layer(handler: Any, body: dict[str, Any], planets: dict[str, Any], base_result: Any) -> dict[str, Any]:
"""Kala sub-items, Yuddha and Sputa Drishti evidence; ``{}`` when it cannot be built."""
from datetime import datetime
try:
import shadbala_advanced as advanced_mod
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
year = handler._get_int(body, "year", datetime.now().year, 1800, 2400)
month = handler._get_int(body, "month", 1, 1, 12)
day = handler._get_int(body, "day", 1, 1, 31)
lat = handler._get_float(body, "lat", body.get("birth_lat", 0), -90, 90)
lon = handler._get_float(body, "lon", body.get("birth_lon", 0), -180, 180)
tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
solar_lon = planets.get("Sun", {}).get("lon", 0)
base_planets = base_result.get("planets", {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
"degree": pdata.get("lon", pdata.get("degree", 0)),
"shadbala_total": base_planets.get(planet, {}).get("total_virupas", 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
"source": "scripts/shadbala_advanced.py",
"method": "Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层",
"kala_vmdh": kala_additions,
"top_kala_support": [{"planet": planet, "virupas": value} for planet, value in top_kala],
"yuddha_bala": yuddha,
"active_yuddha": active_yuddha,
"sputa_drik_bala": drik_sputa,
"next_action": "若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。",
}
except Exception as exc:
logger.warning("[api_server] shadbala advanced layer failed: %s", exc)
return {}
def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
import shadbala
planets, planet_lons, asc_sign_idx = handler._normalized_planets_from_body(body)
if not {"Sun", "Moon"} <= set(planet_lons):
raise ShadbalaInputError("planets must include Sun and Moon longitude data")
birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23)
birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59)
birth_second = handler._get_birth_second(body)
birth_hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second)
context = birth_context(handler, body, birth_hour, birth_minute, birth_second)
result = shadbala.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour_decimal,
planet_lons["Sun"],
planet_lons["Moon"],
birth_minute,
context=context,
)
advanced = advanced_layer(handler, body, planets, result)
return {
"success": True,
"endpoint": "shadbala",
"rule_variants": rule_variants(bool(advanced)),
"advanced_layer": advanced,
"result": result,
}
+5 -1
View File
@@ -38,7 +38,11 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217
# 原值: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
+151
View File
@@ -0,0 +1,151 @@
"""BUG-1463: `/api/shadbala` with the birth moment returned 500 (`NameError: swe`).
The endpoint now lives in scripts/shadbala_service/api.py and the handler only
forwards the body. These tests go through the real HTTP handler on a local
port, so the regression is caught at the same surface the caller saw.
Birth data is the fictional golden profile shared with the frontend tests
(1990-06-15 12:00 Beijing, raman).
"""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
import swisseph as swe
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
for path in (ROOT, SCRIPTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate # noqa: E402
from scripts.shadbala import build_shadbala_context, calc_dig_bala_precise # noqa: E402
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
PLANETS = GOLDEN["chart"]["planets"]
ASCENDANT = GOLDEN["chart"]["ascendant"]
BIRTH = {
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
}
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
@pytest.fixture(scope="module")
def api_base():
import os
from scripts.jyotish_api_server import DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, JyotishAPIHandler
previous = os.environ.get("JYOTISH_API_RATE_LIMIT_PER_MINUTE")
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = "0"
reset_heavy_compute_gate()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
server = ThreadingHTTPServer(("127.0.0.1", port), JyotishAPIHandler)
server.daemon_threads = True
base = f"http://127.0.0.1:{port}"
server.allowed_origins = set(DEFAULT_ALLOWED_ORIGINS) | {base}
server.allowed_hosts = set(DEFAULT_ALLOWED_HOSTS)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield base
finally:
server.shutdown()
server.server_close()
reset_heavy_compute_gate()
if previous is None:
os.environ.pop("JYOTISH_API_RATE_LIMIT_PER_MINUTE", None)
else:
os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = previous
def _post(base: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
request = urllib.request.Request(
f"{base}/api/shadbala",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", "Origin": base, "Host": base.split("://", 1)[1]},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8") or "{}")
def test_birth_data_request_returns_precise_shadbala(api_base) -> None:
status, payload = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert status == 200, payload
assert payload["success"] is True
result = payload["result"]
# The birth moment switches Kala and Chesta to the Swiss Ephemeris context.
assert result["method_variants"] == {
"kala": "bphs_local_solar_events_ahargana_declination",
"chesta": "bphs_bounded_surya_mean_motion_seeghrochcha",
}
assert sorted(result["ranking"]) == sorted(SEVEN)
for planet in SEVEN:
row = result["planets"][planet]
assert row["total_rupas"] > 0
assert row["total_virupas"] == pytest.approx(row["total_rupas"] * 60, abs=0.05)
# Dig Bala is read from the Placidus bhava midpoints of the same instant (UT = 12:00 - 8h).
context = build_shadbala_context(swe.julday(1990, 6, 15, 4.0), 39.9042, 116.4074, "raman", 8.0)
for planet in SEVEN:
expected = calc_dig_bala_precise(planet, float(PLANETS[planet]["lon"]), context["house_midpoints"])
assert result["planets"][planet]["dig_bala"] == expected
assert payload["rule_variants"]["selected"] == ["core_sixfold", "advanced_evidence"]
assert payload["advanced_layer"]["source"] == "scripts/shadbala_advanced.py"
assert set(payload["advanced_layer"]["kala_vmdh"]) == set(SEVEN)
def test_birth_data_changes_the_strength_versus_planets_only(api_base) -> None:
status, payload = _post(api_base, {"planets": PLANETS, "ascendant": ASCENDANT, "hour": 12, "minute": 0})
assert status == 200, payload
legacy = payload["result"]
assert legacy["method_variants"]["kala"] == "legacy_approximation"
# Without the birth moment the answer is the chart page's `modules.shadbala` golden.
golden = GOLDEN["chart_modules_shadbala"]["planets"]
for planet in SEVEN:
assert legacy["planets"][planet]["total_rupas"] == pytest.approx(golden[planet]["total_rupas"], abs=1e-4)
_, precise = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT})
assert any(
precise["result"]["planets"][planet]["dig_bala"] != legacy["planets"][planet]["dig_bala"]
for planet in SEVEN
)
def test_bad_inputs_are_400_not_500(api_base) -> None:
status, _ = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT, "ayanamsa": "not-an-ayanamsa"})
assert status == 400
status, _ = _post(api_base, {**BIRTH, "planets": {"Mars": PLANETS["Mars"]}, "ascendant": ASCENDANT})
assert status == 400
def test_shadbala_service_and_api_server_have_no_undefined_names() -> None:
"""BUG-1463 was a module name used where it was never imported; ruff F821 sees that statically."""
ruff = pytest.importorskip("ruff")
targets = ["scripts/jyotish_api_server.py", "scripts/offline_compute_mixins.py", "scripts/shadbala_service"]
completed = subprocess.run(
[ruff.find_ruff_bin(), "check", "--isolated", "--no-cache", "--select", "F821", *targets],
cwd=ROOT, capture_output=True, text=True, check=False,
)
assert completed.returncode == 0, completed.stdout + completed.stderr