freeastroapi 0.2.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.
freeastroapi/client.py ADDED
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ import httpx
12
+
13
+ from ._generated import AsyncGeneratedResources, GeneratedResources
14
+ from .errors import NetworkError, TimeoutError, error_from_response
15
+ from .resources import (
16
+ AsyncGeoResource,
17
+ AsyncMoonResource,
18
+ AsyncSkyEventsResource,
19
+ AsyncWesternResource,
20
+ GeoResource,
21
+ MoonResource,
22
+ SkyEventsResource,
23
+ WesternResource,
24
+ )
25
+
26
+ T = TypeVar("T")
27
+
28
+ DEFAULT_BASE_URL = "https://api.freeastroapi.com"
29
+ DEFAULT_RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
30
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class RetryConfig:
35
+ retries: int = 2
36
+ base_delay: float = 0.25
37
+ max_delay: float = 5.0
38
+ statuses: frozenset[int] = DEFAULT_RETRY_STATUSES
39
+
40
+
41
+ DEFAULT_RETRY = RetryConfig()
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class RequestOptions:
46
+ headers: Mapping[str, str] = field(default_factory=dict)
47
+ idempotency_key: str | None = None
48
+ timeout: float | None = None
49
+ retry: bool | RetryConfig | None = None
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class SDKResponse(Generic[T]):
54
+ data: T
55
+ response: httpx.Response
56
+
57
+
58
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
59
+ value = response.headers.get("retry-after")
60
+ if not value:
61
+ return None
62
+ try:
63
+ return max(0.0, float(value))
64
+ except ValueError:
65
+ try:
66
+ retry_at = parsedate_to_datetime(value)
67
+ if retry_at.tzinfo is None:
68
+ retry_at = retry_at.replace(tzinfo=UTC)
69
+ return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
70
+ except (TypeError, ValueError, OverflowError):
71
+ return None
72
+
73
+
74
+ def _response_data(response: httpx.Response) -> Any:
75
+ if response.status_code == 204:
76
+ return None
77
+ content_type = response.headers.get("content-type", "")
78
+ if "application/json" in content_type or "+json" in content_type:
79
+ return response.json()
80
+ return response.text
81
+
82
+
83
+ class _ClientSettings:
84
+ def __init__(
85
+ self,
86
+ *,
87
+ api_key: str,
88
+ base_url: str,
89
+ timeout: float,
90
+ retry: RetryConfig | None,
91
+ headers: Mapping[str, str] | None,
92
+ ) -> None:
93
+ if not api_key.strip():
94
+ raise ValueError("api_key is required")
95
+ self.api_key = api_key
96
+ self.base_url = base_url.rstrip("/")
97
+ self.timeout = timeout
98
+ self.retry = retry
99
+ self.headers = dict(headers or {})
100
+
101
+ def request_headers(self, options: RequestOptions) -> dict[str, str]:
102
+ headers = {key.lower(): value for key, value in {**self.headers, **options.headers}.items()}
103
+ headers.update({"accept": "application/json", "x-api-key": self.api_key})
104
+ if options.idempotency_key:
105
+ headers["idempotency-key"] = options.idempotency_key
106
+ return headers
107
+
108
+ def retry_config(self, method: str, options: RequestOptions) -> RetryConfig | None:
109
+ if self.retry is None or options.retry is False:
110
+ return None
111
+ explicitly_enabled = options.retry is True or isinstance(options.retry, RetryConfig)
112
+ replay_safe = method in IDEMPOTENT_METHODS or bool(options.idempotency_key)
113
+ if not explicitly_enabled and not replay_safe:
114
+ return None
115
+ return options.retry if isinstance(options.retry, RetryConfig) else self.retry
116
+
117
+
118
+ class FreeAstro(GeneratedResources):
119
+ """FreeAstroAPI client.
120
+
121
+ Resources follow the API paths: ``client.western.natal.insights(...)`` calls
122
+ ``POST /api/v1/western/natal/insights``. Where an operation exists in several API versions,
123
+ the plain method name calls the latest one and older ones carry a suffix
124
+ (``client.vedic.chart`` is v2, ``client.vedic.chart_v1`` is v1).
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ *,
130
+ api_key: str,
131
+ base_url: str = DEFAULT_BASE_URL,
132
+ timeout: float = 20.0,
133
+ retry: RetryConfig | None = DEFAULT_RETRY,
134
+ headers: Mapping[str, str] | None = None,
135
+ transport: httpx.BaseTransport | None = None,
136
+ ) -> None:
137
+ self._settings = _ClientSettings(
138
+ api_key=api_key,
139
+ base_url=base_url,
140
+ timeout=timeout,
141
+ retry=retry,
142
+ headers=headers,
143
+ )
144
+ self._http = httpx.Client(base_url=self._settings.base_url, transport=transport)
145
+ self.geo = GeoResource(self)
146
+ self.moon = MoonResource(self)
147
+ self.sky_events = SkyEventsResource(self)
148
+ self.western = WesternResource(self)
149
+ self._init_generated_resources(self)
150
+
151
+ def __enter__(self) -> FreeAstro:
152
+ return self
153
+
154
+ def __exit__(self, *_: object) -> None:
155
+ self.close()
156
+
157
+ def close(self) -> None:
158
+ self._http.close()
159
+
160
+ def request(
161
+ self,
162
+ method: str,
163
+ path: str,
164
+ *,
165
+ params: Mapping[str, Any] | None = None,
166
+ json: Any = None,
167
+ options: RequestOptions | None = None,
168
+ ) -> Any:
169
+ return self.request_raw(method, path, params=params, json=json, options=options).data
170
+
171
+ def request_raw(
172
+ self,
173
+ method: str,
174
+ path: str,
175
+ *,
176
+ params: Mapping[str, Any] | None = None,
177
+ json: Any = None,
178
+ options: RequestOptions | None = None,
179
+ ) -> SDKResponse[Any]:
180
+ options = options or RequestOptions()
181
+ method = method.upper()
182
+ retry = self._settings.retry_config(method, options)
183
+ attempt = 0
184
+
185
+ while True:
186
+ try:
187
+ response = self._http.request(
188
+ method,
189
+ path,
190
+ params=params,
191
+ json=json,
192
+ headers=self._settings.request_headers(options),
193
+ timeout=options.timeout if options.timeout is not None else self._settings.timeout,
194
+ )
195
+ except httpx.TimeoutException as exc:
196
+ if retry and attempt < retry.retries:
197
+ time.sleep(min(retry.base_delay * 2**attempt, retry.max_delay))
198
+ attempt += 1
199
+ continue
200
+ raise TimeoutError("FreeAstroAPI request timed out") from exc
201
+ except httpx.HTTPError as exc:
202
+ if retry and attempt < retry.retries:
203
+ time.sleep(min(retry.base_delay * 2**attempt, retry.max_delay))
204
+ attempt += 1
205
+ continue
206
+ raise NetworkError("Unable to reach FreeAstroAPI") from exc
207
+
208
+ if response.is_success:
209
+ return SDKResponse(data=_response_data(response), response=response)
210
+ if retry and response.status_code in retry.statuses and attempt < retry.retries:
211
+ delay = _retry_after_seconds(response)
212
+ time.sleep(min(delay if delay is not None else retry.base_delay * 2**attempt, retry.max_delay))
213
+ attempt += 1
214
+ continue
215
+ raise error_from_response(response)
216
+
217
+
218
+ class AsyncFreeAstro(AsyncGeneratedResources):
219
+ """Async FreeAstroAPI client with the same resources as :class:`FreeAstro`."""
220
+
221
+ def __init__(
222
+ self,
223
+ *,
224
+ api_key: str,
225
+ base_url: str = DEFAULT_BASE_URL,
226
+ timeout: float = 20.0,
227
+ retry: RetryConfig | None = DEFAULT_RETRY,
228
+ headers: Mapping[str, str] | None = None,
229
+ transport: httpx.AsyncBaseTransport | None = None,
230
+ ) -> None:
231
+ self._settings = _ClientSettings(
232
+ api_key=api_key,
233
+ base_url=base_url,
234
+ timeout=timeout,
235
+ retry=retry,
236
+ headers=headers,
237
+ )
238
+ self._http = httpx.AsyncClient(base_url=self._settings.base_url, transport=transport)
239
+ self.geo = AsyncGeoResource(self)
240
+ self.moon = AsyncMoonResource(self)
241
+ self.sky_events = AsyncSkyEventsResource(self)
242
+ self.western = AsyncWesternResource(self)
243
+ self._init_generated_resources(self)
244
+
245
+ async def __aenter__(self) -> AsyncFreeAstro:
246
+ return self
247
+
248
+ async def __aexit__(self, *_: object) -> None:
249
+ await self.close()
250
+
251
+ async def close(self) -> None:
252
+ await self._http.aclose()
253
+
254
+ async def request(
255
+ self,
256
+ method: str,
257
+ path: str,
258
+ *,
259
+ params: Mapping[str, Any] | None = None,
260
+ json: Any = None,
261
+ options: RequestOptions | None = None,
262
+ ) -> Any:
263
+ return (await self.request_raw(method, path, params=params, json=json, options=options)).data
264
+
265
+ async def request_raw(
266
+ self,
267
+ method: str,
268
+ path: str,
269
+ *,
270
+ params: Mapping[str, Any] | None = None,
271
+ json: Any = None,
272
+ options: RequestOptions | None = None,
273
+ ) -> SDKResponse[Any]:
274
+ options = options or RequestOptions()
275
+ method = method.upper()
276
+ retry = self._settings.retry_config(method, options)
277
+ attempt = 0
278
+
279
+ while True:
280
+ try:
281
+ response = await self._http.request(
282
+ method,
283
+ path,
284
+ params=params,
285
+ json=json,
286
+ headers=self._settings.request_headers(options),
287
+ timeout=options.timeout if options.timeout is not None else self._settings.timeout,
288
+ )
289
+ except httpx.TimeoutException as exc:
290
+ if retry and attempt < retry.retries:
291
+ await asyncio.sleep(min(retry.base_delay * 2**attempt, retry.max_delay))
292
+ attempt += 1
293
+ continue
294
+ raise TimeoutError("FreeAstroAPI request timed out") from exc
295
+ except httpx.HTTPError as exc:
296
+ if retry and attempt < retry.retries:
297
+ await asyncio.sleep(min(retry.base_delay * 2**attempt, retry.max_delay))
298
+ attempt += 1
299
+ continue
300
+ raise NetworkError("Unable to reach FreeAstroAPI") from exc
301
+
302
+ if response.is_success:
303
+ return SDKResponse(data=_response_data(response), response=response)
304
+ if retry and response.status_code in retry.statuses and attempt < retry.retries:
305
+ delay = _retry_after_seconds(response)
306
+ await asyncio.sleep(min(delay if delay is not None else retry.base_delay * 2**attempt, retry.max_delay))
307
+ attempt += 1
308
+ continue
309
+ raise error_from_response(response)
freeastroapi/errors.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from email.utils import parsedate_to_datetime
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class FreeAstroError(Exception):
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int | None = None,
16
+ code: str | None = None,
17
+ request_id: str | None = None,
18
+ details: Any = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.status = status
22
+ self.code = code
23
+ self.request_id = request_id
24
+ self.details = details
25
+
26
+
27
+ class AuthenticationError(FreeAstroError):
28
+ pass
29
+
30
+
31
+ class PermissionError(FreeAstroError):
32
+ pass
33
+
34
+
35
+ class ValidationError(FreeAstroError):
36
+ pass
37
+
38
+
39
+ class ConflictError(FreeAstroError):
40
+ pass
41
+
42
+
43
+ class RateLimitError(FreeAstroError):
44
+ def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
45
+ super().__init__(message, **kwargs)
46
+ self.retry_after = retry_after
47
+
48
+
49
+ class ServerError(FreeAstroError):
50
+ pass
51
+
52
+
53
+ class NetworkError(FreeAstroError):
54
+ pass
55
+
56
+
57
+ class TimeoutError(NetworkError):
58
+ pass
59
+
60
+
61
+ def _retry_after_seconds(value: str | None) -> float | None:
62
+ if value is None:
63
+ return None
64
+ try:
65
+ return max(0.0, float(value))
66
+ except ValueError:
67
+ try:
68
+ retry_at = parsedate_to_datetime(value)
69
+ if retry_at.tzinfo is None:
70
+ retry_at = retry_at.replace(tzinfo=UTC)
71
+ return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
72
+ except (TypeError, ValueError, OverflowError):
73
+ return None
74
+
75
+
76
+ def error_from_response(response: httpx.Response) -> FreeAstroError:
77
+ try:
78
+ payload: dict[str, Any] = response.json()
79
+ except (ValueError, TypeError):
80
+ payload = {}
81
+
82
+ detail = payload.get("detail")
83
+ message_value = (
84
+ detail
85
+ if isinstance(detail, str) and detail.strip()
86
+ else payload.get("message") or payload.get("error") or response.reason_phrase or f"HTTP {response.status_code}"
87
+ )
88
+ message = message_value if isinstance(message_value, str) else str(message_value)
89
+ code_value = payload.get("code")
90
+ code = code_value if isinstance(code_value, str) else None
91
+ request_id_value = response.headers.get("x-request-id") or payload.get("trace_id")
92
+ request_id = request_id_value if isinstance(request_id_value, str) else None
93
+
94
+ def error(error_type: type[FreeAstroError]) -> FreeAstroError:
95
+ return error_type(
96
+ message,
97
+ status=response.status_code,
98
+ code=code,
99
+ request_id=request_id,
100
+ details=payload,
101
+ )
102
+
103
+ if response.status_code == 401:
104
+ return error(AuthenticationError)
105
+ if response.status_code == 403:
106
+ return error(PermissionError)
107
+ if response.status_code in (400, 422):
108
+ return error(ValidationError)
109
+ if response.status_code == 409:
110
+ return error(ConflictError)
111
+ if response.status_code == 429:
112
+ retry_after = _retry_after_seconds(response.headers.get("retry-after"))
113
+ return RateLimitError(
114
+ message,
115
+ retry_after=retry_after,
116
+ status=response.status_code,
117
+ code=code,
118
+ request_id=request_id,
119
+ details=payload,
120
+ )
121
+ if response.status_code >= 500:
122
+ return error(ServerError)
123
+ return error(FreeAstroError)
freeastroapi/models.py ADDED
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
+
7
+
8
+ class RequestModel(BaseModel):
9
+ model_config = ConfigDict(extra="forbid")
10
+
11
+
12
+ class ResponseModel(BaseModel):
13
+ # Additive API response fields remain forward compatible and are preserved.
14
+ model_config = ConfigDict(extra="allow")
15
+
16
+
17
+ class GeoSearchParams(RequestModel):
18
+ q: str = Field(min_length=2, max_length=100)
19
+ limit: int = Field(default=10, ge=1, le=50)
20
+ country: str | None = Field(default=None, pattern=r"^[A-Z]{2}$")
21
+
22
+
23
+ class City(ResponseModel):
24
+ name: str
25
+ country: str
26
+ state: str | None = None
27
+ district: str | None = None
28
+ lat: float
29
+ lng: float
30
+ timezone: str
31
+ population: int
32
+
33
+
34
+ class CitySearchResponse(ResponseModel):
35
+ results: list[City]
36
+ count: int
37
+
38
+
39
+ class MoonLocationParams(RequestModel):
40
+ city: str | None = Field(default=None, min_length=1, max_length=200)
41
+ lat: float | None = Field(default=None, ge=-90, le=90)
42
+ lon: float | None = Field(default=None, ge=-180, le=180)
43
+ tz_str: str = "AUTO"
44
+ include_eclipse: bool = False
45
+ include_forecast: bool = False
46
+
47
+
48
+ class MoonPhaseParams(MoonLocationParams):
49
+ date: str | None = None
50
+
51
+
52
+ class MoonMonthParams(MoonLocationParams):
53
+ year: int = Field(ge=1, le=9999)
54
+ month: int = Field(ge=1, le=12)
55
+
56
+
57
+ MoonPhaseName = Literal[
58
+ "New Moon",
59
+ "Waxing Crescent",
60
+ "First Quarter",
61
+ "Waxing Gibbous",
62
+ "Full Moon",
63
+ "Waning Gibbous",
64
+ "Last Quarter",
65
+ "Waning Crescent",
66
+ ]
67
+
68
+
69
+ class MoonPhaseData(ResponseModel):
70
+ name: MoonPhaseName
71
+ phase_angle_deg: float
72
+ illumination: float
73
+ age_days: float
74
+ distance_km: float
75
+ is_waxing: bool
76
+
77
+
78
+ class MaximumLocation(ResponseModel):
79
+ lat: float
80
+ lon: float
81
+
82
+
83
+ class EclipseData(ResponseModel):
84
+ is_eclipse: bool
85
+ is_blood_moon: bool
86
+ kind: Literal["solar", "lunar"] | None = None
87
+ type: Literal["total", "annular", "hybrid", "partial", "penumbral"] | None = None
88
+ date: str | None = None
89
+ starts_at: str | None = None
90
+ ends_at: str | None = None
91
+ central_phase_starts_at: str | None = None
92
+ central_phase_ends_at: str | None = None
93
+ visibility: str | None = None
94
+ visible_at_location: bool | None = None
95
+ type_at_location: Literal["total", "annular", "partial"] | None = None
96
+ days_from_query: float | None = None
97
+ magnitude: float | None = None
98
+ obscuration: float | None = Field(default=None, ge=0, le=1)
99
+ maximum_location: MaximumLocation | None = None
100
+ local_starts_at: str | None = None
101
+ local_maximum_at: str | None = None
102
+ local_ends_at: str | None = None
103
+ magnitude_at_location: float | None = None
104
+ obscuration_at_location: float | None = Field(default=None, ge=0, le=1)
105
+
106
+
107
+ class NextMoonPhases(ResponseModel):
108
+ new_moon: str
109
+ first_quarter: str
110
+ full_moon: str
111
+ last_quarter: str
112
+
113
+
114
+ class MoonPhaseResponse(ResponseModel):
115
+ timestamp: str
116
+ phase: MoonPhaseData
117
+ eclipse: EclipseData | None = None
118
+ next_phases: NextMoonPhases
119
+
120
+
121
+ class MoonMonthDay(MoonPhaseResponse):
122
+ calendar_date: str
123
+
124
+
125
+ class MoonMonthResponse(ResponseModel):
126
+ year: int
127
+ month: int
128
+ month_name: str
129
+ days_in_month: int
130
+ calendar_basis: Literal["local", "utc"]
131
+ calendar_timezone: str
132
+ days: list[MoonMonthDay]
133
+
134
+
135
+ class SkyEventsParams(RequestModel):
136
+ date: str
137
+ timezone: str = "UTC"
138
+ include_moon: bool = False
139
+ include_exact_hits: bool = True
140
+ include_moon_exact_hits: bool = False
141
+ include_ingresses: bool = True
142
+ include_retrogrades: bool = False
143
+ include_lunations: bool = False
144
+ include_formulation: bool = False
145
+
146
+
147
+ class SkyEvent(ResponseModel):
148
+ id: str
149
+ type: Literal["moon_ingress", "planet_ingress", "exact_aspect", "retrograde_station", "lunation"]
150
+ title: str
151
+ formulation: str | None = None
152
+ exact_time: str
153
+ importance: Literal["daily", "medium", "major"]
154
+ body: str | None = None
155
+ sign: str | None = None
156
+ bodies: list[str] | None = None
157
+ aspect: Literal["conjunction", "sextile", "square", "trine", "opposition"] | None = None
158
+ angle: Literal[0, 60, 90, 120, 180] | None = None
159
+ orb: int | None = None
160
+ direction: Literal["retrograde", "direct"] | None = None
161
+ phase: Literal["new_moon", "full_moon"] | None = None
162
+
163
+
164
+ class SkyEventsResponse(ResponseModel):
165
+ date: str
166
+ timezone: str
167
+ events: list[SkyEvent]
168
+
169
+
170
+ class WesternSignRequest(RequestModel):
171
+ name: str = Field(default="Example User", max_length=200)
172
+ year: int = Field(ge=1, le=9999)
173
+ month: int = Field(ge=1, le=12)
174
+ day: int = Field(ge=1, le=31)
175
+ hour: int = Field(default=12, ge=0, le=23)
176
+ minute: int = Field(default=0, ge=0, le=59)
177
+ time_known: bool = True
178
+ city: str | None = Field(default=None, min_length=1, max_length=200)
179
+ lat: float | None = Field(default=None, ge=-90, le=90)
180
+ lng: float | None = Field(default=None, ge=-180, le=180)
181
+ tz_str: str = "AUTO"
182
+ house_system: Literal["placidus", "whole_sign", "equal", "regiomontanus", "koch", "porphyry"] = "placidus"
183
+ zodiac_type: Literal["tropical", "sidereal"] = "tropical"
184
+ sidereal_ayanamsa: Literal["lahiri", "raman", "kp", "fagan_bradley"] = "lahiri"
185
+
186
+ @model_validator(mode="after")
187
+ def require_location(self) -> WesternSignRequest:
188
+ if not self.city and (self.lat is None or self.lng is None):
189
+ raise ValueError("provide city or both lat and lng")
190
+ return self
191
+
192
+
193
+ class WesternSignResponse(ResponseModel):
194
+ point: str
195
+ sign: str
196
+ sign_id: Literal[
197
+ "aries",
198
+ "taurus",
199
+ "gemini",
200
+ "cancer",
201
+ "leo",
202
+ "virgo",
203
+ "libra",
204
+ "scorpio",
205
+ "sagittarius",
206
+ "capricorn",
207
+ "aquarius",
208
+ "pisces",
209
+ ]
210
+ degree: float
211
+ abs_pos: float
212
+ house: int
213
+ interpretation: None = None
214
+
215
+
216
+ class ErrorResponse(ResponseModel):
217
+ detail: Any | None = None
218
+ error: str | None = None
219
+ message: str | None = None
220
+ code: str | None = None
221
+ trace_id: str | None = None
freeastroapi/py.typed ADDED
@@ -0,0 +1 @@
1
+