Files
Jyotisha/scripts/vedastro_python_bridge.py
T

481 lines
15 KiB
Python

#!/usr/bin/env python3
"""Thin bridge for the official VedAstro Python package.
This bridge exists to accelerate broad method coverage through the official
Python SDK while keeping the local adjudicator and REST adapter boundaries
separate. It stays deliberately thin:
- discover the real VedAstro runtime (current interpreter or local venv)
- route generic method calls into the official Python surface
- support a tiny typed-parameter contract for enums and Time/GeoLocation
- fail in a controlled way when the package is unavailable
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
PACKAGE_NAME = "VedAstro"
MODULE_CANDIDATES = ("vedastro", "VedAstro")
FORCE_UNAVAILABLE_ENV = "VEDASTRO_PYTHON_FORCE_UNAVAILABLE"
STUB_RESULT_ENV = "VEDASTRO_PYTHON_BRIDGE_STUB_RESULT"
PYTHON_BIN_ENV = "VEDASTRO_PYTHON_BIN"
DEFAULT_VENV_PYTHONS = (
ROOT / "venv_vedastro" / "bin" / "python3.11",
ROOT / "venv_vedastro" / "bin" / "python3",
ROOT / "venv_vedastro" / "bin" / "python",
)
CHILD_RUNNER = r"""
import contextlib
import enum
import importlib
import io
import json
import sys
MODULE_CANDIDATES = ("vedastro", "VedAstro")
def _offset_to_string(offset):
if isinstance(offset, str):
raw = offset.strip()
if not raw:
return "+00:00"
if raw[0] in "+-" and ":" in raw:
return raw
try:
offset = float(raw)
except ValueError:
return raw
if isinstance(offset, (int, float)):
sign = "+" if offset >= 0 else "-"
absolute = abs(float(offset))
hours = int(absolute)
minutes = int(round((absolute - hours) * 60))
if minutes == 60:
hours += 1
minutes = 0
return f"{sign}{hours:02d}:{minutes:02d}"
return "+00:00"
def _import_module():
for name in MODULE_CANDIDATES:
with contextlib.redirect_stdout(io.StringIO()):
try:
return name, importlib.import_module(name)
except ModuleNotFoundError:
continue
return None, None
def _coerce(module, value):
if isinstance(value, list):
return [_coerce(module, item) for item in value]
if isinstance(value, dict):
enum_name = value.get("__vedastro_enum__")
if enum_name:
enum_type = getattr(module, enum_name)
return getattr(enum_type, value["value"])
type_name = value.get("__vedastro_type__")
if type_name == "GeoLocation":
return module.GeoLocation(
value["location_name"],
value["longitude"],
value["latitude"],
)
if type_name == "Time":
geolocation = _coerce(module, value["geolocation"]) if value.get("geolocation") else None
if value.get("time_string") is not None:
return module.Time(value["time_string"], geolocation)
time_string = (
f"{int(value['hour']):02d}:{int(value['minute']):02d} "
f"{int(value['day']):02d}/{int(value['month']):02d}/{int(value['year'])} "
f"{_offset_to_string(value.get('offset', '+00:00'))}"
)
return module.Time(time_string, geolocation)
if type_name:
target = getattr(module, type_name)
args = [_coerce(module, item) for item in value.get("args", [])]
kwargs = {
key: _coerce(module, item)
for key, item in value.get("kwargs", {}).items()
}
if args or kwargs:
return target(*args, **kwargs)
payload = {
key: _coerce(module, item)
for key, item in value.items()
if not key.startswith("__")
}
return target(**payload)
return {
key: _coerce(module, item)
for key, item in value.items()
}
return value
def _serialize(value):
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, enum.Enum):
return {
"type": type(value).__name__,
"name": value.name,
"value": value.value,
}
if isinstance(value, (list, tuple, set)):
return [_serialize(item) for item in value]
if isinstance(value, dict):
return {str(key): _serialize(item) for key, item in value.items()}
if hasattr(value, "to_json"):
try:
payload = value.to_json()
if isinstance(payload, str):
try:
return json.loads(payload)
except json.JSONDecodeError:
return payload
return _serialize(payload)
except Exception:
pass
if hasattr(value, "__dict__"):
return {
key: _serialize(item)
for key, item in vars(value).items()
if not key.startswith("_")
}
return str(value)
def _resolve_callable(module, method_name):
parts = [part for part in method_name.split(".") if part]
if not parts:
raise AttributeError("empty_method_name")
if len(parts) == 1:
return getattr(module.Calculate, parts[0])
current = module
for part in parts:
current = getattr(current, part)
return current
def main():
method = sys.argv[1]
params = json.loads(sys.argv[2])
module_name, module = _import_module()
if module is None:
print(json.dumps({
"available": False,
"status": "python_package_not_installed",
"method": method,
"source": "vedastro_python_bridge_child",
}))
return
call_target = _resolve_callable(module, method)
args = []
kwargs = {}
if isinstance(params, dict) and ("args" in params or "kwargs" in params):
args = [_coerce(module, item) for item in params.get("args", [])]
kwargs = {
key: _coerce(module, item)
for key, item in params.get("kwargs", {}).items()
}
elif isinstance(params, dict):
kwargs = {
key: _coerce(module, item)
for key, item in params.items()
}
elif isinstance(params, list):
args = [_coerce(module, item) for item in params]
elif params is not None:
args = [_coerce(module, params)]
with contextlib.redirect_stdout(io.StringIO()):
result = call_target(*args, **kwargs)
print(json.dumps({
"available": True,
"status": "ok",
"method": method,
"module_name": module_name,
"result": _serialize(result),
"source": "vedastro_python_bridge",
}))
if __name__ == "__main__":
main()
"""
def _package_available() -> bool:
if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}:
return False
return _discover_module_name() is not None
def _discover_module_name() -> str | None:
for candidate in MODULE_CANDIDATES:
if importlib.util.find_spec(candidate) is not None:
return candidate
return None
def _candidate_python_bins() -> list[str]:
bins: list[str] = []
configured = os.environ.get(PYTHON_BIN_ENV, "").strip()
if configured:
bins.append(configured)
for candidate in DEFAULT_VENV_PYTHONS:
if candidate.exists():
bins.append(str(candidate))
bins.append(sys.executable)
seen: set[str] = set()
ordered: list[str] = []
for item in bins:
if item and item not in seen:
ordered.append(item)
seen.add(item)
return ordered
def _python_supports_vedastro(python_bin: str) -> bool:
probe = (
"import importlib.util; "
"mods=('vedastro','VedAstro'); "
"raise SystemExit(0 if any(importlib.util.find_spec(m) for m in mods) else 1)"
)
try:
completed = subprocess.run(
[python_bin, "-c", probe],
check=False,
capture_output=True,
text=True,
timeout=20,
)
except OSError:
return False
return completed.returncode == 0
def _select_python_bin() -> str | None:
for python_bin in _candidate_python_bins():
if _python_supports_vedastro(python_bin):
return python_bin
return None
def _call_via_child_python(python_bin: str, method: str, params: dict[str, Any]) -> dict[str, Any]:
child_env = os.environ.copy()
completed = subprocess.run(
[python_bin, "-c", CHILD_RUNNER, method, json.dumps(params, ensure_ascii=False)],
check=False,
capture_output=True,
text=True,
timeout=180,
env=child_env,
)
if completed.returncode != 0:
return {
"available": False,
"status": "bridge_runtime_error",
"method": method,
"python_bin": python_bin,
"stderr": (completed.stderr or "").strip(),
"stdout_excerpt": (completed.stdout or "").strip()[:500],
"source": "vedastro_python_bridge",
}
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError:
return {
"available": False,
"status": "bridge_invalid_json",
"method": method,
"python_bin": python_bin,
"stdout_excerpt": (completed.stdout or "").strip()[:500],
"stderr": (completed.stderr or "").strip()[:500],
"source": "vedastro_python_bridge",
}
payload["python_bin"] = python_bin
return payload
def schema() -> dict[str, Any]:
return {
"bridge": "vedastro_python_bridge",
"package_name": PACKAGE_NAME,
"module_candidates": list(MODULE_CANDIDATES),
"intended_role": "python_sdk_bulk_calculation_bridge",
"operations": ["call_method"],
"request_contract": ["method", "params_json"],
"typed_param_contract": {
"enum": {"__vedastro_enum__": "PlanetName", "value": "Sun"},
"geo": {
"__vedastro_type__": "GeoLocation",
"location_name": "REDACTED_PLACE",
"longitude": 114.46,
"latitude": 36.6,
},
"time": {
"__vedastro_type__": "Time",
"year": REDACTED_YEAR,
"month": 4,
"day": 17,
"hour": 14,
"minute": 49,
"offset": 8,
"geolocation": {
"__vedastro_type__": "GeoLocation",
"location_name": "REDACTED_PLACE",
"longitude": 114.46,
"latitude": 36.6,
},
},
},
"response_contract": ["available", "status", "method", "result", "source", "python_bin"],
"boundaries": [
"Generic method routing only; local adjudicator remains authoritative.",
"Do not treat Python SDK output as score/dominant label override by default.",
],
}
def _missing_package_result(method: str) -> dict[str, Any]:
return {
"available": False,
"status": "python_package_not_installed",
"method": method,
"reason": (
f"Official Python package {PACKAGE_NAME} is not installed in the current runtime "
"or detected project venv."
),
"source": "vedastro_python_bridge",
}
def call_method(method: str, params: dict[str, Any]) -> dict[str, Any]:
stub = os.environ.get(STUB_RESULT_ENV, "").strip()
if stub:
payload = json.loads(stub)
return {
"available": True,
"status": "ok",
"method": payload.get("method") or method,
"result": payload.get("result"),
"source": "stubbed_bridge_result",
}
if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}:
return _missing_package_result(method)
python_bin = _select_python_bin()
if not python_bin:
return _missing_package_result(method)
return _call_via_child_python(python_bin, method, params)
def call_high_value(method_key: str, payload: dict[str, Any]) -> dict[str, Any]:
if method_key == "event_tag_catalog":
return call_method("GetAllEventDataGroupedByTag", {})
if method_key == "planet_longitude":
planet = payload["planet"]
time = payload["time"]
return call_method(
"PlanetNirayanaLongitude",
{
"args": [
{"__vedastro_enum__": "PlanetName", "value": planet},
time,
]
},
)
if method_key == "vimshottari_snapshot":
birth_time = payload["birth_time"]
check_time = payload["check_time"]
levels = int(payload.get("levels", 3))
return call_method("DasaAtTime", {"args": [birth_time, check_time, levels]})
if method_key == "chara_dasha_snapshot":
birth_time = payload["birth_time"]
check_time = payload["check_time"]
return call_method("GetCharaDasaAtTime", {"args": [birth_time, check_time]})
return {
"available": False,
"status": "unsupported_high_value_method",
"method": method_key,
"source": "vedastro_python_bridge",
}
def main() -> int:
parser = argparse.ArgumentParser(description="VedAstro Python bridge")
parser.add_argument("--print-schema", action="store_true")
parser.add_argument("--method", default="")
parser.add_argument("--params-json", default="{}")
parser.add_argument("--high-value", default="")
args = parser.parse_args()
if args.print_schema:
result = schema()
result["high_value_methods"] = {
"event_tag_catalog": {
"maps_to": "GetAllEventDataGroupedByTag",
"request_contract": [],
},
"planet_longitude": {
"maps_to": "PlanetNirayanaLongitude",
"request_contract": ["planet", "time"],
},
"vimshottari_snapshot": {
"maps_to": "DasaAtTime",
"request_contract": ["birth_time", "check_time", "levels?"],
},
"chara_dasha_snapshot": {
"maps_to": "GetCharaDasaAtTime",
"request_contract": ["birth_time", "check_time"],
},
}
elif args.high_value:
params = json.loads(args.params_json or "{}")
result = call_high_value(args.high_value, params)
else:
params = json.loads(args.params_json or "{}")
result = call_method(args.method, params)
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())