astroapi-io-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,626 @@
1
+ """Bounded client-side shape checks; the server owns astronomical validation."""
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from datetime import date, datetime, timezone
7
+ from typing import Any
8
+
9
+ from .errors import ValidationError
10
+
11
+ MIN_DATE = date(1900, 1, 1)
12
+ MAX_DATE = date(2050, 12, 31)
13
+ MAX_REQUEST_BYTES = 128 * 1024
14
+ _BIRTH_FIELDS = {"date", "time", "lat", "lon", "timezone"}
15
+ _FLAGS = {"zodiac", "ayanamsa"}
16
+ _AYANAMSAS = {"lahiri", "raman", "krishnamurti", "fagan_bradley", "yukteshwar", "sassanian", "true_chitra", "true_revati", "true_pushya", "galactic_center_0_sagittarius", "galactic_alignment_mardyks"}
17
+ _BODIES = {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "rahu", "ketu"}
18
+ _PLANETARY_EVENT_BODIES = {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto"}
19
+ _PLANETARY_EVENT_TYPES = {"sign_ingress", "station", "aspect"}
20
+ _PLANETARY_EVENT_ASPECTS = {"conjunction", "sextile", "square", "trine", "opposition"}
21
+ _FIXED_STARS = {"aldebaran", "antares", "fomalhaut", "regulus", "sirius", "spica"}
22
+ _EVENT_CALENDAR_TYPES = {"moon_phase", "sign_ingress", "station", "aspect", "aspect_window"}
23
+ _SIGNS = {"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"}
24
+ _PHASE_SECTORS = {"new_moon", "waxing_crescent", "first_quarter", "waxing_gibbous", "full_moon", "waning_gibbous", "last_quarter", "waning_crescent"}
25
+ _ASPECT_PROFILES = {"major", "standard", "extended"}
26
+ _CONFIGURABLE_ASPECTS = {
27
+ "conjunction", "semisextile", "semisquare", "sextile", "quintile",
28
+ "square", "trine", "sesquiquadrate", "biquintile", "quincunx", "opposition",
29
+ }
30
+
31
+
32
+ def _invalid(message: str = "Request does not match the route's supported input fields.") -> None:
33
+ raise ValidationError(message)
34
+
35
+
36
+ def _object(value: Any, allowed: set[str], required: set[str]) -> dict[str, Any]:
37
+ if not isinstance(value, dict) or not required <= value.keys() or value.keys() - allowed:
38
+ _invalid()
39
+ return value
40
+
41
+
42
+ def _date(value: Any) -> None:
43
+ valid = False
44
+ if isinstance(value, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
45
+ try:
46
+ valid = MIN_DATE <= date.fromisoformat(value) <= MAX_DATE
47
+ except ValueError:
48
+ pass
49
+ if not valid:
50
+ _invalid("Dates must use YYYY-MM-DD within 1900-01-01 through 2050-12-31.")
51
+
52
+
53
+ def _time(value: Any) -> None:
54
+ if not isinstance(value, str) or not re.fullmatch(
55
+ r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", value
56
+ ):
57
+ _invalid("Times must use valid HH:MM or HH:MM:SS values.")
58
+
59
+
60
+ def _timezone(value: Any) -> None:
61
+ if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(
62
+ ord(char) < 33 or ord(char) == 127 for char in value
63
+ ):
64
+ _invalid("timezone must be an IANA timezone name; use UTC for UTC instants.")
65
+
66
+
67
+ def _birth(value: Any, flags: bool = False) -> None:
68
+ data = _object(value, _BIRTH_FIELDS | (_FLAGS if flags else set()), {"date", "time", "lat", "lon"})
69
+ _date(data["date"])
70
+ _time(data["time"])
71
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
72
+ coordinate = data[field]
73
+ if type(coordinate) not in (int, float) or not lower <= coordinate <= upper:
74
+ _invalid("Coordinates must be finite JSON numbers within latitude/longitude bounds.")
75
+ if "timezone" in data:
76
+ _timezone(data["timezone"])
77
+
78
+
79
+ def _enum(value: Any, choices: set[str], message: str) -> None:
80
+ if not isinstance(value, str) or value not in choices:
81
+ _invalid(message)
82
+
83
+
84
+ def _instant(value: Any) -> datetime:
85
+ result = None
86
+ if isinstance(value, str) and len(value) <= 40 and re.fullmatch(
87
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
88
+ r"(?:\.[0-9]{1,6})?(?:Z|[+-][0-9]{2}:[0-9]{2})", value
89
+ ):
90
+ try:
91
+ result = datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
92
+ except (ValueError, OverflowError):
93
+ pass
94
+ if result is None or not MIN_DATE <= result.date() <= MAX_DATE:
95
+ _invalid("Timeline boundaries require ISO timestamps with UTC offsets within 1900–2050.")
96
+ return result
97
+
98
+
99
+ def _utc_second(value: Any) -> datetime:
100
+ result = None
101
+ if isinstance(value, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", value):
102
+ try:
103
+ result = datetime.fromisoformat(value.replace("Z", "+00:00"))
104
+ except ValueError:
105
+ pass
106
+ lower = datetime(1900, 1, 1, tzinfo=timezone.utc)
107
+ upper = datetime(2051, 1, 1, tzinfo=timezone.utc)
108
+ if result is None or not lower <= result <= upper:
109
+ _invalid("Planetary event boundaries require whole-second UTC timestamps within 1900–2050.")
110
+ return result
111
+
112
+
113
+ def _unique_enum_list(value: Any, choices: set[str], maximum: int, name: str) -> None:
114
+ if (not isinstance(value, list) or not 1 <= len(value) <= maximum
115
+ or any(not isinstance(item, str) or item not in choices for item in value)
116
+ or len(set(value)) != len(value)):
117
+ _invalid(f"{name} must be a bounded array of unique documented enum values.")
118
+
119
+
120
+ def _aspect_configuration(data: dict[str, Any]) -> None:
121
+ if "aspect_profile" in data and "aspects" in data:
122
+ _invalid("aspect_profile and aspects are mutually exclusive.")
123
+ if "aspect_profile" in data:
124
+ _enum(data["aspect_profile"], _ASPECT_PROFILES,
125
+ "aspect_profile must be major, standard or extended.")
126
+ if "aspects" not in data:
127
+ return
128
+ if not isinstance(data["aspects"], list) or not 1 <= len(data["aspects"]) <= len(_CONFIGURABLE_ASPECTS):
129
+ _invalid("Aspects must contain one through eleven configurations.")
130
+ names = set()
131
+ for item in data["aspects"]:
132
+ item = _object(item, {"aspect", "max_orb_degrees"}, {"aspect", "max_orb_degrees"})
133
+ _enum(item["aspect"], _CONFIGURABLE_ASPECTS, "Invalid configurable aspect.")
134
+ if item["aspect"] in names:
135
+ _invalid("Configured aspects must be unique.")
136
+ names.add(item["aspect"])
137
+ orb = item["max_orb_degrees"]
138
+ if (type(orb) not in (int, float) or not math.isfinite(orb)
139
+ or not 0.1 <= orb <= 10
140
+ or abs(orb * 10 - round(orb * 10)) > 1e-9):
141
+ _invalid("Aspect orbs must be 0.1 through 10.0 in 0.1-degree increments.")
142
+
143
+
144
+ def _onboarding_request(route: str, data: dict[str, Any]) -> None:
145
+ if route == "place-search":
146
+ _object(data, {"query", "country_codes", "limit"}, {"query"})
147
+ if type(data["query"]) is not str or not 2 <= len(data["query"]) <= 200:
148
+ _invalid("query must be a string containing 2 through 200 characters.")
149
+ if "country_codes" in data:
150
+ codes = data["country_codes"]
151
+ if (type(codes) is not list or not 1 <= len(codes) <= 5 or len(set(codes)) != len(codes)
152
+ or any(type(code) is not str or re.fullmatch(r"[A-Z]{2}", code) is None for code in codes)):
153
+ _invalid("country_codes must contain 1 through 5 unique uppercase ISO alpha-2 codes.")
154
+ if "limit" in data and (type(data["limit"]) is not int or not 1 <= data["limit"] <= 10):
155
+ _invalid("limit must be an integer from 1 through 10.")
156
+ elif route == "timezone-resolve":
157
+ _object(data, {"place_id", "date", "time"}, {"place_id", "date", "time"})
158
+ if type(data["place_id"]) is not str or re.fullmatch(r"geonames:[1-9][0-9]{0,11}:2026-10-02", data["place_id"]) is None:
159
+ _invalid("place_id must be a current versioned AstroAPI GeoNames place ID.")
160
+ _date(data["date"])
161
+ _time(data["time"])
162
+ else:
163
+ _object(data, {"birth", "zodiac", "ayanamsa", "house_system", "formats"}, {"birth"})
164
+ _birth(data["birth"])
165
+ if "zodiac" in data:
166
+ _enum(data["zodiac"], {"tropical", "sidereal"}, "zodiac must be tropical or sidereal.")
167
+ if "ayanamsa" in data:
168
+ _enum(data["ayanamsa"], _AYANAMSAS, "ayanamsa is not supported.")
169
+ if "house_system" in data:
170
+ _enum(data["house_system"], {"equal", "whole_sign", "placidus"}, "house_system is not supported for natal context.")
171
+ if "formats" in data:
172
+ _unique_enum_list(data["formats"], {"json", "xml", "markdown"}, 3, "formats")
173
+
174
+
175
+ def encode_request(route: str, payload: Any) -> bytes:
176
+ """Serialize once so the worker never retains a caller-owned mutable payload."""
177
+ encoded = None
178
+ try:
179
+ encoded = json.dumps(payload, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
180
+ except (TypeError, ValueError, OverflowError, RecursionError):
181
+ pass
182
+ if encoded is None:
183
+ _invalid("Request must contain only finite, JSON-serializable values.")
184
+ if len(encoded) > MAX_REQUEST_BYTES:
185
+ _invalid("Request exceeds the 128 KiB JSON body limit.")
186
+ # This copy also prevents a concurrent caller mutation from changing validation.
187
+ data = json.loads(encoded)
188
+ if route in {"place-search", "timezone-resolve", "natal-context"}:
189
+ _onboarding_request(route, data)
190
+ return encoded
191
+ if route == "numerology":
192
+ _object(data, {"birth_date", "name", "master_numbers", "y_vowel", "year"}, {"birth_date"})
193
+ _date(data["birth_date"])
194
+ if "name" in data and (type(data["name"]) is not str or re.fullmatch(r"[A-Za-z](?:[A-Za-z '\-]{0,94}[A-Za-z])?", data["name"]) is None):
195
+ _invalid("name requires 1-96 ASCII letters and internal separators; supply explicit transliteration.")
196
+ for flag in ("master_numbers", "y_vowel"):
197
+ if flag in data and type(data[flag]) is not bool: _invalid("Numerology flags must be booleans.")
198
+ if "y_vowel" in data and "name" not in data: _invalid("y_vowel requires name.")
199
+ if "year" in data and (type(data["year"]) is not int or not 1900 <= data["year"] <= 2050): _invalid("year must be an integer in 1900-2050.")
200
+ elif route == "birth-chart":
201
+ _birth(data, flags=True)
202
+ elif route in {"aspects", "houses", "transits", "horoscope"}:
203
+ extra = {"system"} if route == "houses" else set()
204
+ if route in {"transits", "horoscope"}:
205
+ extra |= {"transitDate", "transitTime", "timezone"}
206
+ required = {"birth", "transitDate"} if route == "transits" else {"birth"}
207
+ _object(data, {"birth"} | _FLAGS | extra, required)
208
+ _birth(data["birth"])
209
+ if "system" in data:
210
+ _enum(data["system"], {"equal", "whole_sign", "whole", "placidus", "porphyry", "meridian", "campanus", "regiomontanus", "alcabitius", "koch", "morinus", "topocentric", "sripati", "vehlow", "horizon", "krusinski", "sunshine", "sunshine_alt", "savard", "pullen_sd", "pullen_sr", "carter", "apc", "equal_mc", "natural", "gauquelin"}, "system must be equal, whole_sign, whole, placidus, porphyry, meridian, campanus, regiomontanus, alcabitius, koch, morinus, topocentric, sripati, vehlow, horizon, krusinski, sunshine, sunshine_alt, savard, pullen_sd, pullen_sr, carter, apc, equal_mc, natural, or gauquelin.")
211
+ if "transitDate" in data:
212
+ _date(data["transitDate"])
213
+ if "transitTime" in data:
214
+ _time(data["transitTime"])
215
+ if "timezone" in data:
216
+ _timezone(data["timezone"])
217
+ elif route == "natal-svg":
218
+ _object(data, {"birth", "house_system", "theme", "show_aspects", "additional_points", "aspect_profile", "aspects", "include_png"} | _FLAGS, {"birth"})
219
+ _birth(data["birth"])
220
+ if "house_system" in data:
221
+ _enum(data["house_system"], {"equal", "whole_sign", "placidus", "porphyry", "meridian", "campanus", "regiomontanus", "alcabitius", "koch", "morinus", "topocentric", "sripati", "vehlow", "horizon", "krusinski", "sunshine", "sunshine_alt", "savard", "pullen_sd", "pullen_sr", "carter", "apc", "equal_mc", "natural"}, "house_system must be equal, whole_sign, placidus, porphyry, meridian, campanus, regiomontanus, alcabitius, koch, morinus, topocentric, sripati, vehlow, horizon, krusinski, sunshine, sunshine_alt, savard, pullen_sd, pullen_sr, carter, apc, equal_mc, or natural.")
222
+ if "theme" in data:
223
+ _enum(data["theme"], {"light", "dark", "monochrome"}, "theme must be light, dark, or monochrome.")
224
+ if "show_aspects" in data and type(data["show_aspects"]) is not bool:
225
+ _invalid("show_aspects must be a JSON boolean.")
226
+ if "include_png" in data and type(data["include_png"]) is not bool:
227
+ _invalid("include_png must be a JSON boolean.")
228
+ if "additional_points" in data:
229
+ _unique_enum_list(data["additional_points"], {
230
+ "vertex", "antivertex", "equatorial_ascendant",
231
+ "equatorial_descendant", "lot_of_fortune", "lot_of_spirit",
232
+ }, 6, "additional_points")
233
+ _aspect_configuration(data)
234
+ elif route in {"vargas", "vimshottari", "varga-svg", "ashtakavarga"}:
235
+ allowed = {"birth", "ayanamsa"} | ({"division", "d150_method"} if route in {"vargas", "varga-svg"} else set())
236
+ if route == "varga-svg":
237
+ allowed |= {"layout", "theme", "include_png"}
238
+ if route == "vimshottari":
239
+ allowed |= {"depth"}
240
+ if route == "ashtakavarga":
241
+ allowed |= {"include_reductions"}
242
+ _object(data, allowed, {"birth"})
243
+ _birth(data["birth"])
244
+ if "division" in data and (type(data["division"]) is not int or data["division"] not in {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30, 40, 45, 60, 81, 108, 144, 150}):
245
+ _invalid("division must be one of 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30, 40, 45, 60, 81, 108, 144, or 150.")
246
+ if "d150_method" in data:
247
+ _enum(data["d150_method"], {"pvr_jhora_own_sign_uniform_direct", "deva_keralam_chandra_kala_nadi_non_uniform", "deva_keralam_chandra_kala_nadi_uniform_direct", "parivritti_cyclic", "movable_aries_forward_fixed_taurus_reverse_dual_gemini_forward", "movable_aries_forward_fixed_scorpio_reverse_dual_sagittarius_forward", "movable_aries_forward_fixed_leo_reverse_dual_sagittarius_forward", "movable_source_sign_forward_fixed_source_sign_reverse_dual_source_sign_forward"}, "d150_method is invalid.")
248
+ if data.get("division") != 150:
249
+ _invalid("d150_method requires division 150.")
250
+ if "depth" in data and (type(data["depth"]) is not int or data["depth"] not in {2, 3, 4}):
251
+ _invalid("depth must be the JSON integer 2, 3, or 4.")
252
+ if "include_reductions" in data and type(data["include_reductions"]) is not bool:
253
+ _invalid("include_reductions must be a JSON boolean.")
254
+ if "layout" in data:
255
+ _enum(data["layout"], {"north_indian", "south_indian"}, "layout must be north_indian or south_indian.")
256
+ if "theme" in data:
257
+ _enum(data["theme"], {"light", "dark", "monochrome"}, "theme must be light, dark, or monochrome.")
258
+ if "include_png" in data and type(data["include_png"]) is not bool:
259
+ _invalid("include_png must be a JSON boolean.")
260
+ elif route == "moon-phases":
261
+ _object(data, {"mode", "at", "start", "end", "display_timezone", "include_ical"}, {"mode"})
262
+ _enum(data["mode"], {"instant", "calendar"}, "mode must be instant or calendar.")
263
+ fields = {"at"} if data["mode"] == "instant" else {"start", "end"}
264
+ optional = {"include_ical"} if data["mode"] == "calendar" else set()
265
+ _object(data, {"mode", "display_timezone"} | fields | optional, {"mode"} | fields)
266
+ if "include_ical" in data and type(data["include_ical"]) is not bool:
267
+ _invalid("include_ical must be boolean in calendar mode.")
268
+ for field in fields:
269
+ civil = _object(data[field], {"date", "time", "timezone"}, {"date", "time"})
270
+ _date(civil["date"])
271
+ _time(civil["time"])
272
+ if "timezone" in civil:
273
+ _timezone(civil["timezone"])
274
+ if len(civil["timezone"]) > 100:
275
+ _invalid("Moon phase timezone names must be at most 100 characters.")
276
+ if "display_timezone" in data:
277
+ _timezone(data["display_timezone"])
278
+ if len(data["display_timezone"]) > 100:
279
+ _invalid("Moon phase timezone names must be at most 100 characters.")
280
+ elif route == "panchang":
281
+ _object(data, {"at", "ayanamsa"}, {"at"})
282
+ _birth(data["at"])
283
+ if abs(data["at"]["lat"]) > 88:
284
+ _invalid("Panchang beta latitude must be within inclusive [-88,88].")
285
+ elif route in {"lunar-nodes", "returns"}:
286
+ if route == "lunar-nodes":
287
+ _object(data, {"at"} | _FLAGS, {"at"})
288
+ civil = data["at"]
289
+ else:
290
+ _object(data, {"birth", "body", "after", "house_system", "location"} | _FLAGS, {"birth", "body", "after"})
291
+ _birth(data["birth"])
292
+ _enum(data["body"], {"sun", "moon"}, "body must be sun or moon.")
293
+ civil = data["after"]
294
+ if "house_system" in data:
295
+ _enum(data["house_system"], {"equal", "whole_sign"}, "Return house_system must be equal or whole_sign.")
296
+ if "location" in data:
297
+ location = _object(data["location"], {"lat", "lon"}, {"lat", "lon"})
298
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
299
+ if type(location[field]) not in (int, float) or not lower <= location[field] <= upper:
300
+ _invalid("Coordinates must be finite JSON numbers within latitude/longitude bounds.")
301
+ _object(civil, {"date", "time", "timezone"}, {"date", "time"})
302
+ _date(civil["date"])
303
+ _time(civil["time"])
304
+ if "timezone" in civil:
305
+ _timezone(civil["timezone"])
306
+ elif route in {"composite", "davison"}:
307
+ _object(data, {"personA", "personB", "zodiac", "ayanamsa", "house_system"}, {"personA", "personB"})
308
+ _birth(data["personA"])
309
+ _birth(data["personB"])
310
+ if "house_system" in data:
311
+ _enum(data["house_system"], {"equal", "whole_sign"}, "Relationship house_system must be equal or whole_sign.")
312
+ elif route in {"secondary-progressions", "solar-arcs"}:
313
+ allowed = {"birth", "target", "zodiac", "ayanamsa", "house_system"}
314
+ if route == "solar-arcs":
315
+ allowed.add("method")
316
+ _object(data, allowed, {"birth", "target"})
317
+ _birth(data["birth"])
318
+ target = _object(data["target"], {"date", "time", "timezone"}, {"date", "time"})
319
+ _date(target["date"])
320
+ _time(target["time"])
321
+ if "timezone" in target:
322
+ _timezone(target["timezone"])
323
+ if "house_system" in data:
324
+ _enum(data["house_system"], {"equal", "whole_sign"}, "Natal context house_system must be equal or whole_sign.")
325
+ if route == "solar-arcs" and "method" in data:
326
+ _enum(data["method"], {"true_solar_arc", "mean_naibod"},
327
+ "Solar arc method must be true_solar_arc or mean_naibod.")
328
+ elif route == "planetary-events":
329
+ _object(data, {"start_utc", "end_utc", "event_types", "bodies", "aspects"} | _FLAGS,
330
+ {"start_utc", "end_utc", "event_types", "bodies"})
331
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
332
+ if not 0 < (end - start).total_seconds() <= 31 * 86400:
333
+ _invalid("Planetary event range must be positive and no longer than 31 days.")
334
+ _unique_enum_list(data["event_types"], _PLANETARY_EVENT_TYPES, 3, "event_types")
335
+ _unique_enum_list(data["bodies"], _PLANETARY_EVENT_BODIES, 10, "bodies")
336
+ if "aspect" in data["event_types"]:
337
+ if len(data["bodies"]) < 2:
338
+ _invalid("Aspect searches require at least two bodies.")
339
+ if "aspects" in data:
340
+ _unique_enum_list(data["aspects"], _PLANETARY_EVENT_ASPECTS, 5, "aspects")
341
+ elif "aspects" in data:
342
+ _invalid("aspects is allowed only when event_types includes aspect.")
343
+ elif route == "house-ingresses":
344
+ _object(data, {"reference_birth", "start_utc", "end_utc", "bodies", "house_system"} | _FLAGS,
345
+ {"reference_birth", "start_utc", "end_utc", "bodies"})
346
+ _birth(data["reference_birth"])
347
+ if abs(data["reference_birth"]["lat"]) == 90:
348
+ _invalid("reference_birth latitude must exclude the geographic poles.")
349
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
350
+ if not 0 < (end - start).total_seconds() <= 31 * 86400:
351
+ _invalid("House-ingress range must be positive and no longer than 31 days.")
352
+ _unique_enum_list(data["bodies"], _PLANETARY_EVENT_BODIES, 10, "bodies")
353
+ if "house_system" in data:
354
+ _enum(data["house_system"], {"equal", "whole_sign"},
355
+ "House-ingress house_system must be equal or whole_sign.")
356
+ elif route == "aspect-windows":
357
+ _object(data, {"start_utc", "end_utc", "bodies", "aspects", "orb_degrees", "include_ical"} | _FLAGS,
358
+ {"start_utc", "end_utc", "bodies"})
359
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
360
+ if not 0 < (end - start).total_seconds() <= 31 * 86400:
361
+ _invalid("Aspect-window range must be positive and no longer than 31 days.")
362
+ _unique_enum_list(data["bodies"], _PLANETARY_EVENT_BODIES, 10, "bodies")
363
+ if len(data["bodies"]) < 2:
364
+ _invalid("Aspect-window searches require at least two bodies.")
365
+ if "aspects" in data:
366
+ _unique_enum_list(data["aspects"], _PLANETARY_EVENT_ASPECTS, 5, "aspects")
367
+ if "orb_degrees" in data:
368
+ orb = data["orb_degrees"]
369
+ if type(orb) not in (int, float) or not math.isfinite(orb) or not 0.5 <= orb <= 10 or abs(orb * 10 - round(orb * 10)) > 1e-9:
370
+ _invalid("orb_degrees must be 0.5 through 10.0 in 0.1-degree increments.")
371
+ if "include_ical" in data and type(data["include_ical"]) is not bool:
372
+ _invalid("include_ical must be a boolean.")
373
+ elif route == "event-calendar":
374
+ _object(data, {"start_utc", "end_utc", "event_types", "bodies", "aspects", "orb_degrees", "zodiac", "ayanamsa", "display_timezone", "include_ical", "include_csv", "group_passes", "moon_phase_filter"}, {"start_utc", "end_utc", "event_types"})
375
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
376
+ if not 0 < (end - start).total_seconds() <= 31 * 86400:
377
+ _invalid("Event calendar range must be positive and no longer than 31 days.")
378
+ _unique_enum_list(data["event_types"], _EVENT_CALENDAR_TYPES, 5, "event_types")
379
+ if "moon_phase_filter" in data:
380
+ if "moon_phase" not in data["event_types"]:
381
+ _invalid("moon_phase_filter requires moon_phase events.")
382
+ _unique_enum_list(data["moon_phase_filter"], ("new_moon", "first_quarter", "full_moon", "last_quarter"), 4, "moon_phase_filter")
383
+ needs_bodies = any(item != "moon_phase" for item in data["event_types"])
384
+ if needs_bodies:
385
+ if "bodies" not in data:
386
+ _invalid("bodies is required for selected event types.")
387
+ _unique_enum_list(data["bodies"], _PLANETARY_EVENT_BODIES, 10, "bodies")
388
+ elif "bodies" in data:
389
+ _invalid("bodies is allowed only for planetary event types.")
390
+ needs_aspects = any(item in {"aspect", "aspect_window"} for item in data["event_types"])
391
+ if needs_aspects and len(data["bodies"]) < 2:
392
+ _invalid("Aspect calendar events require at least two bodies.")
393
+ if "aspects" in data:
394
+ if not needs_aspects:
395
+ _invalid("aspects is allowed only for aspect event types.")
396
+ _unique_enum_list(data["aspects"], _PLANETARY_EVENT_ASPECTS, 5, "aspects")
397
+ if "orb_degrees" in data:
398
+ orb = data["orb_degrees"]
399
+ if ("aspect_window" not in data["event_types"] or type(orb) not in (int, float)
400
+ or not math.isfinite(orb) or not 0.5 <= orb <= 10
401
+ or abs(orb * 10 - round(orb * 10)) > 1e-9):
402
+ _invalid("orb_degrees is valid only for aspect_window and must be 0.5 through 10.0 in 0.1-degree increments.")
403
+ if "display_timezone" in data:
404
+ _timezone(data["display_timezone"])
405
+ if len(data["display_timezone"]) > 100:
406
+ _invalid("display_timezone must be at most 100 characters.")
407
+ if "include_ical" in data and type(data["include_ical"]) is not bool:
408
+ _invalid("include_ical must be a boolean.")
409
+ if "include_csv" in data and type(data["include_csv"]) is not bool:
410
+ _invalid("include_csv must be a boolean.")
411
+ if "group_passes" in data:
412
+ if type(data["group_passes"]) is not bool:
413
+ _invalid("group_passes must be a boolean.")
414
+ if data["group_passes"] and not needs_aspects:
415
+ _invalid("group_passes requires aspect or aspect_window events.")
416
+ elif route in {"synastry-svg", "transit-svg", "natal-report"}:
417
+ if route == "synastry-svg":
418
+ _object(data, {"personA", "personB", "zodiac", "ayanamsa", "house_system", "theme", "show_aspects", "include_png"}, {"personA", "personB"})
419
+ _birth(data["personA"]); _birth(data["personB"])
420
+ elif route == "transit-svg":
421
+ _object(data, {"birth", "transitDate", "transitTime", "timezone", "zodiac", "ayanamsa", "house_system", "theme", "show_aspects", "include_png"}, {"birth", "transitDate"})
422
+ _birth(data["birth"]); _date(data["transitDate"])
423
+ if "transitTime" in data: _time(data["transitTime"])
424
+ if "timezone" in data: _timezone(data["timezone"])
425
+ else:
426
+ _object(data, {"birth", "zodiac", "ayanamsa", "house_system", "theme", "include_svg", "include_download", "download_format", "report_branding"}, {"birth"})
427
+ _birth(data["birth"])
428
+ if "house_system" in data:
429
+ _enum(data["house_system"], {"equal", "whole_sign", "placidus"}, "house_system must be equal, whole_sign or placidus.")
430
+ if "theme" in data: _enum(data["theme"], {"light", "dark", "monochrome"}, "theme must be light, dark, or monochrome.")
431
+ boolean_field = "include_svg" if route == "natal-report" else "show_aspects"
432
+ if boolean_field in data and type(data[boolean_field]) is not bool: _invalid(f"{boolean_field} must be a boolean.")
433
+ if route == "natal-report" and "include_download" in data and type(data["include_download"]) is not bool:
434
+ _invalid("include_download must be a boolean.")
435
+ if route == "natal-report" and "download_format" in data:
436
+ _enum(data["download_format"], {"html", "pdf", "docx", "xlsx", "ods", "epub", "odt", "rtf", "json", "csv", "xml", "ndjson", "tsv", "markdown", "text", "zip"}, "download_format must be html, pdf, docx, xlsx, ods, epub, odt, rtf, json, csv, xml, ndjson, tsv, markdown, text, or zip.")
437
+ if data.get("include_download") is not True:
438
+ _invalid("download_format requires include_download true.")
439
+ if route == "natal-report" and "report_branding" in data:
440
+ branding = _object(data["report_branding"], {"display_name", "accent"}, {"display_name"})
441
+ display_name = branding["display_name"]
442
+ if not isinstance(display_name, str) or re.fullmatch(
443
+ r"[A-Za-z0-9](?:[A-Za-z0-9 &'().-]{0,62}[A-Za-z0-9])?", display_name) is None:
444
+ _invalid("report_branding.display_name must use 1-64 documented ASCII characters.")
445
+ if "accent" in branding:
446
+ _enum(branding["accent"], {"amber", "blue", "green"}, "report_branding.accent must be amber, blue, or green.")
447
+ if data.get("download_format", "html") != "html":
448
+ _invalid("report_branding is available only for the HTML report.")
449
+ if route != "natal-report" and "include_png" in data and type(data["include_png"]) is not bool:
450
+ _invalid("include_png must be a JSON boolean.")
451
+ elif route == "birth-time-sensitivity":
452
+ _object(data, {"start", "end", "birth_time", "lat", "lon", "zodiac", "ayanamsa", "house_system"}, {"lat", "lon"})
453
+ if "birth_time" in data:
454
+ if "start" in data or "end" in data:
455
+ _invalid("birth_time is mutually exclusive with start/end.")
456
+ workflow = data["birth_time"]
457
+ if isinstance(workflow, dict) and workflow.get("status") == "unknown":
458
+ workflow = _object(workflow, {"status", "date", "timezone"}, {"status", "date", "timezone"})
459
+ _date(workflow["date"]); _timezone(workflow["timezone"])
460
+ elif isinstance(workflow, dict) and workflow.get("status") == "approximate":
461
+ workflow = _object(workflow, {"status", "date", "time", "timezone", "uncertainty_minutes"}, {"status", "date", "time", "timezone", "uncertainty_minutes"})
462
+ _date(workflow["date"]); _time(workflow["time"]); _timezone(workflow["timezone"])
463
+ uncertainty = workflow["uncertainty_minutes"]
464
+ if type(uncertainty) is not int or not 5 <= uncertainty <= 180 or uncertainty % 5:
465
+ _invalid("uncertainty_minutes must be an integer multiple of 5 from 5 through 180.")
466
+ else:
467
+ _invalid("birth_time status must be unknown or approximate.")
468
+ else:
469
+ if "start" not in data or "end" not in data:
470
+ _invalid("start and end are required when birth_time is omitted.")
471
+ moments = []
472
+ for name in ("start", "end"):
473
+ civil = _object(data[name], {"date", "time", "timezone"}, {"date", "time"})
474
+ _date(civil["date"]); _time(civil["time"])
475
+ if "timezone" in civil: _timezone(civil["timezone"])
476
+ moments.append(datetime.fromisoformat(f'{civil["date"]}T{civil["time"]}'))
477
+ if data["start"].get("timezone", "UTC") != data["end"].get("timezone", "UTC"):
478
+ _invalid("start and end must use the same timezone.")
479
+ if not 0 < (moments[1] - moments[0]).total_seconds() <= 6 * 3600:
480
+ _invalid("Birth-time sensitivity must span more than zero and no more than six civil hours.")
481
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
482
+ if type(data[field]) not in (int, float) or not math.isfinite(data[field]) or not lower <= data[field] <= upper: _invalid("Coordinates are outside supported bounds.")
483
+ if "house_system" in data: _enum(data["house_system"], {"equal", "whole_sign", "placidus"}, "house_system must be equal, whole_sign or placidus.")
484
+ elif route == "relocation-chart":
485
+ _object(data, {"birth", "location", "zodiac", "ayanamsa", "house_system"}, {"birth", "location"})
486
+ _birth(data["birth"])
487
+ if abs(data["birth"]["lat"]) == 90: _invalid("Relocation birth latitude must exclude the poles.")
488
+ location = _object(data["location"], {"lat", "lon"}, {"lat", "lon"})
489
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
490
+ if type(location[field]) not in (int, float) or not math.isfinite(location[field]) or not lower <= location[field] <= upper:
491
+ _invalid("Relocation coordinates are outside supported bounds.")
492
+ if abs(location["lat"]) == 90: _invalid("Relocation latitude must exclude the poles.")
493
+ if "house_system" in data: _enum(data["house_system"], {"equal", "whole_sign", "placidus"}, "house_system must be equal, whole_sign or placidus.")
494
+ elif route == "astrocartography":
495
+ _object(data, {"at", "bodies", "include_svg", "include_png", "theme", "basemap", "include_geojson", "include_geojson_seq", "include_ndjson", "include_topojson", "include_kml", "include_gpx", "include_csv", "include_wkt", "include_wkb", "include_gml"}, {"at", "bodies"})
496
+ at = _object(data["at"], {"date", "time", "timezone"}, {"date", "time"})
497
+ _date(at["date"]); _time(at["time"])
498
+ if "timezone" in at: _timezone(at["timezone"])
499
+ _unique_enum_list(data["bodies"], _PLANETARY_EVENT_BODIES, 10, "bodies")
500
+ if "basemap" in data:
501
+ _enum(data["basemap"], {"coordinate-grid-only", "natural-earth-110m"}, "Invalid basemap.")
502
+ if data.get("include_svg") is not True: _invalid("basemap requires include_svg true.")
503
+ if "include_svg" in data and type(data["include_svg"]) is not bool: _invalid("include_svg must be a boolean.")
504
+ if "include_geojson" in data and type(data["include_geojson"]) is not bool: _invalid("include_geojson must be a boolean.")
505
+ if "include_geojson_seq" in data and type(data["include_geojson_seq"]) is not bool: _invalid("include_geojson_seq must be a boolean.")
506
+ if "include_ndjson" in data and type(data["include_ndjson"]) is not bool: _invalid("include_ndjson must be a boolean.")
507
+ if "include_topojson" in data and type(data["include_topojson"]) is not bool: _invalid("include_topojson must be a boolean.")
508
+ if "include_kml" in data and type(data["include_kml"]) is not bool: _invalid("include_kml must be a boolean.")
509
+ if "include_gpx" in data and type(data["include_gpx"]) is not bool: _invalid("include_gpx must be a boolean.")
510
+ if "include_csv" in data and type(data["include_csv"]) is not bool: _invalid("include_csv must be a boolean.")
511
+ if "include_wkt" in data and type(data["include_wkt"]) is not bool: _invalid("include_wkt must be a boolean.")
512
+ if "include_wkb" in data and type(data["include_wkb"]) is not bool: _invalid("include_wkb must be a boolean.")
513
+ if "include_gml" in data and type(data["include_gml"]) is not bool: _invalid("include_gml must be a boolean.")
514
+ if "include_png" in data and type(data["include_png"]) is not bool: _invalid("include_png must be a boolean.")
515
+ if "theme" in data: _enum(data["theme"], {"light", "dark", "monochrome"}, "theme must be light, dark or monochrome.")
516
+ if data.get("include_svg") is not True and ("include_png" in data or "theme" in data):
517
+ _invalid("include_png and theme require include_svg true.")
518
+ elif route == "eclipse-geometry":
519
+ _object(data, {"start_utc", "end_utc", "eclipse_types", "observer"}, {"start_utc", "end_utc", "eclipse_types"})
520
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
521
+ if not 0 < (end - start).total_seconds() <= 366 * 86400:
522
+ _invalid("Eclipse geometry must span more than zero and at most 366 UTC days.")
523
+ _unique_enum_list(data["eclipse_types"], {"solar", "lunar"}, 2, "eclipse_types")
524
+ if "observer" in data:
525
+ observer = _object(data["observer"], {"lat", "lon"}, {"lat", "lon"})
526
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
527
+ if type(observer[field]) not in (int, float) or not math.isfinite(observer[field]) or not lower <= observer[field] <= upper:
528
+ _invalid("Observer coordinates must be finite numbers within range.")
529
+ elif route == "electional-search":
530
+ _object(data, {"start_utc", "end_utc", "location", "filters", "zodiac", "ayanamsa"}, {"start_utc", "end_utc", "location", "filters"})
531
+ start, end = _utc_second(data["start_utc"]), _utc_second(data["end_utc"])
532
+ if not 0 < (end - start).total_seconds() <= 7 * 86400:
533
+ _invalid("Electional search must span more than zero and at most seven UTC days.")
534
+ location = _object(data["location"], {"lat", "lon"}, {"lat", "lon"})
535
+ for field, lower, upper in (("lat", -90, 90), ("lon", -180, 180)):
536
+ if type(location[field]) not in (int, float) or not math.isfinite(location[field]) or not lower <= location[field] <= upper:
537
+ _invalid("Electional coordinates must be finite numbers within range.")
538
+ if abs(location["lat"]) == 90:
539
+ _invalid("Electional latitude must exclude the poles.")
540
+ filters = _object(data["filters"], {"moon_signs", "moon_phase_sectors", "mercury_motion", "ascendant_signs", "aspects"}, set())
541
+ if not filters:
542
+ _invalid("Electional filters must not be empty.")
543
+ if "moon_signs" in filters: _unique_enum_list(filters["moon_signs"], _SIGNS, 12, "moon_signs")
544
+ if "ascendant_signs" in filters: _unique_enum_list(filters["ascendant_signs"], _SIGNS, 12, "ascendant_signs")
545
+ if "moon_phase_sectors" in filters: _unique_enum_list(filters["moon_phase_sectors"], _PHASE_SECTORS, 8, "moon_phase_sectors")
546
+ if "mercury_motion" in filters: _enum(filters["mercury_motion"], {"direct", "retrograde"}, "mercury_motion must be direct or retrograde.")
547
+ if "aspects" in filters:
548
+ if not isinstance(filters["aspects"], list) or not 1 <= len(filters["aspects"]) <= 4:
549
+ _invalid("Electional aspects must contain one through four filters.")
550
+ identities = set()
551
+ for item in filters["aspects"]:
552
+ item = _object(item, {"body_a", "body_b", "aspect", "max_orb_degrees"}, {"body_a", "body_b", "aspect", "max_orb_degrees"})
553
+ _enum(item["body_a"], _PLANETARY_EVENT_BODIES, "Invalid aspect body.")
554
+ _enum(item["body_b"], _PLANETARY_EVENT_BODIES, "Invalid aspect body.")
555
+ _enum(item["aspect"], _PLANETARY_EVENT_ASPECTS, "Invalid major aspect.")
556
+ if item["body_a"] == item["body_b"]: _invalid("Aspect bodies must differ.")
557
+ orb = item["max_orb_degrees"]
558
+ if type(orb) not in (int, float) or not math.isfinite(orb) or not 0.5 <= orb <= 10 or abs(orb * 10 - round(orb * 10)) > 1e-9:
559
+ _invalid("Electional aspect orb must be 0.5 through 10.0 in 0.1-degree increments.")
560
+ identity = (tuple(sorted((item["body_a"], item["body_b"]))), item["aspect"])
561
+ if identity in identities: _invalid("Electional aspect filters must be unique.")
562
+ identities.add(identity)
563
+ elif route == "natal-analysis":
564
+ _object(data, {"birth", "points", "aspects", "aspect_profile", "include_midpoints", "include_patterns", "include_graha_drishti", "include_shadbala_foundation", "additional_points", "zodiac", "ayanamsa"}, {"birth"})
565
+ _birth(data["birth"])
566
+ if "points" in data:
567
+ _unique_enum_list(data["points"], _PLANETARY_EVENT_BODIES, 10, "points")
568
+ if len(data["points"]) < 3: _invalid("Natal analysis requires at least three points.")
569
+ _aspect_configuration(data)
570
+ for field in ("include_midpoints", "include_patterns", "include_graha_drishti", "include_shadbala_foundation"):
571
+ if field in data and type(data[field]) is not bool: _invalid(f"{field} must be a boolean.")
572
+ if (data.get("include_graha_drishti") is True or data.get("include_shadbala_foundation") is True) and data.get("zodiac") != "sidereal":
573
+ _invalid("Vedic analysis options require explicit zodiac sidereal.")
574
+ if "additional_points" in data:
575
+ _unique_enum_list(data["additional_points"], {
576
+ "vertex", "antivertex", "equatorial_ascendant",
577
+ "equatorial_descendant", "lot_of_fortune", "lot_of_spirit",
578
+ }, 6, "additional_points")
579
+ elif route == "ashtakoota":
580
+ _object(data, {"groom", "bride", "ayanamsa"}, {"groom", "bride"})
581
+ _birth(data["groom"])
582
+ _birth(data["bride"])
583
+ if any(len(data[key].get("timezone", "UTC")) > 100 for key in ("groom", "bride")):
584
+ _invalid("Ashtakoota timezone must be at most 100 characters.")
585
+ if "ayanamsa" in data:
586
+ _enum(data["ayanamsa"], _AYANAMSAS, "ayanamsa must be lahiri, raman, krishnamurti, fagan_bradley, yukteshwar, sassanian or true_chitra, true_revati, true_pushya, galactic_center_0_sagittarius, or galactic_alignment_mardyks.")
587
+ elif route == "planetary-hours":
588
+ _object(data, {"date", "timezone", "lat", "lon"}, {"date", "timezone", "lat", "lon"})
589
+ _date(data["date"])
590
+ if data["date"] == MAX_DATE.isoformat(): _invalid("Planetary hours require a following civil date within the supported range.")
591
+ _timezone(data["timezone"])
592
+ for field, lower, upper in (("lat", -88, 88), ("lon", -180, 180)):
593
+ if type(data[field]) not in (int, float) or not math.isfinite(data[field]) or not lower <= data[field] <= upper:
594
+ _invalid("Planetary-hours coordinates are outside supported bounds.")
595
+ elif route == "fixed-stars":
596
+ _object(data, {"at", "stars", "zodiac", "ayanamsa"}, {"at", "stars"})
597
+ at = _object(data["at"], {"date", "time", "timezone"}, {"date", "time"})
598
+ _date(at["date"]); _time(at["time"])
599
+ if "timezone" in at: _timezone(at["timezone"])
600
+ _unique_enum_list(data["stars"], _FIXED_STARS, 6, "stars")
601
+ elif route == "synastry":
602
+ _object(data, {"personA", "personB"} | _FLAGS, {"personA", "personB"})
603
+ _birth(data["personA"])
604
+ _birth(data["personB"])
605
+ else:
606
+ fields = {"planet", "start_utc", "end_utc", "ayanamsa"}
607
+ _object(data, fields, fields)
608
+ _enum(data["planet"], _BODIES, "planet must be one of the nine supported timeline bodies.")
609
+ start, end = _instant(data["start_utc"]), _instant(data["end_utc"])
610
+ if not 0 < (end - start).total_seconds() <= 180 * 86400:
611
+ _invalid("Timeline range must be positive and no longer than 180 days.")
612
+ if route in {"natal-svg", "vargas", "vimshottari", "varga-svg", "ashtakavarga", "panchang", "composite", "davison", "secondary-progressions", "solar-arcs", "lunar-nodes", "returns"}:
613
+ inputs = ["personA", "personB"] if route in {"composite", "davison"} else ["at"] if route in {"panchang", "lunar-nodes"} else ["birth", "target"] if route in {"secondary-progressions", "solar-arcs"} else ["birth", "after"] if route == "returns" else ["birth"]
614
+ if any(len(data[key].get("timezone", "UTC")) > 100 for key in inputs):
615
+ _invalid("Beta route timezone must be at most 100 characters.")
616
+ if "zodiac" in data:
617
+ _enum(data["zodiac"], {"tropical", "sidereal"}, "zodiac must be tropical or sidereal.")
618
+ if "ayanamsa" in data:
619
+ _enum(data["ayanamsa"], _AYANAMSAS, "ayanamsa must be lahiri, raman, krishnamurti, fagan_bradley, yukteshwar, sassanian or true_chitra, true_revati, true_pushya, galactic_center_0_sagittarius, or galactic_alignment_mardyks.")
620
+ return encoded
621
+
622
+
623
+ def positive_timeout(value: Any) -> float:
624
+ if type(value) not in (int, float) or not 0 < value <= 300 or not math.isfinite(value):
625
+ _invalid("Timeouts must be finite numbers greater than zero and at most 300 seconds.")
626
+ return float(value)