astrox-python 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.
astrox/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Python interface for the ASTROX Web API.
2
+
3
+ Functions are organized by domain modules and can use package-level
4
+ configuration without requiring explicit client management:
5
+
6
+ import astrox
7
+ from astrox import access, coverage, components, lighting, orbits, propagator
8
+
9
+ astrox.configure(base_url="http://custom:8765", timeout=120)
10
+ orbit = orbits.keplerian(...)
11
+ period_s, position = propagator.j2(
12
+ start="2026-01-01T00:00:00Z",
13
+ stop="2026-01-01T01:00:00Z",
14
+ orbit_epoch="2026-01-01T00:00:00Z",
15
+ orbit=orbit,
16
+ )
17
+
18
+ For advanced configuration, instantiate Client directly:
19
+
20
+ client = astrox.Client(timeout=60)
21
+
22
+ Raw route access is available for advanced callers:
23
+
24
+ result = astrox.raw.post("/Propagator/J2", json={...})
25
+ """
26
+
27
+ from importlib.metadata import PackageNotFoundError, version
28
+
29
+ from astrox import access, coverage, components, lighting, orbits, propagator, rocket
30
+ from astrox._http import Client, configure, get_session, raw
31
+
32
+ try:
33
+ __version__ = version("astrox-python")
34
+ except PackageNotFoundError:
35
+ __version__ = "0.0.0+unknown"
36
+
37
+ __all__ = [
38
+ "Client",
39
+ "configure",
40
+ "get_session",
41
+ "access",
42
+ "coverage",
43
+ "components",
44
+ "lighting",
45
+ "orbits",
46
+ "propagator",
47
+ "rocket",
48
+ "raw",
49
+ ]
astrox/_http.py ADDED
@@ -0,0 +1,504 @@
1
+ """HTTP client with retry mechanism and ContextVar-based session management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from contextvars import ContextVar
8
+ from typing import Any, TypeVar
9
+
10
+ import requests
11
+
12
+ from astrox import exceptions
13
+
14
+ T = TypeVar("T")
15
+
16
+ # Default configuration
17
+ DEFAULT_BASE_URL = "http://astrox.cn:8765"
18
+ DEFAULT_TIMEOUT = 30.0
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_RETRY_DELAY = 1.0 # seconds
21
+
22
+ # ContextVar for thread-safe default client management
23
+ _default_session: ContextVar[Client | None] = ContextVar("session", default=None)
24
+
25
+
26
+ def _join_url(base_url: str, endpoint: str) -> str:
27
+ return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
28
+
29
+
30
+ def _default_headers() -> dict[str, str]:
31
+ return {
32
+ "Accept": "application/json",
33
+ }
34
+
35
+
36
+ def _json_payload(data: Any) -> Any:
37
+ model_dump_json = getattr(data, "model_dump_json", None)
38
+ if callable(model_dump_json):
39
+ return json.loads(model_dump_json(by_alias=True, exclude_none=True))
40
+ if isinstance(data, (list, tuple)):
41
+ return [_json_payload(item) for item in data]
42
+ if isinstance(data, dict):
43
+ return {key: _json_payload(value) for key, value in data.items()}
44
+ return data
45
+
46
+
47
+ def _validation_errors(exc: Exception) -> list[Any]:
48
+ errors = getattr(exc, "errors", None)
49
+ if callable(errors):
50
+ return errors()
51
+ return []
52
+
53
+
54
+ def _validate_response_model(response_model: type[T], result: Any) -> T:
55
+ model_validate = getattr(response_model, "model_validate", None)
56
+ if not callable(model_validate):
57
+ raise exceptions.AstroxValidationError(
58
+ message="response_model must provide model_validate()",
59
+ errors=[],
60
+ )
61
+
62
+ try:
63
+ return model_validate(result)
64
+ except Exception as exc:
65
+ raise exceptions.AstroxValidationError(
66
+ message=f"Failed to validate response: {exc}",
67
+ errors=_validation_errors(exc),
68
+ )
69
+
70
+
71
+ def _make_request(
72
+ endpoint: str,
73
+ json_body: Any,
74
+ *,
75
+ method: str = "POST",
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = DEFAULT_TIMEOUT,
78
+ max_retries: int = DEFAULT_MAX_RETRIES,
79
+ retry_delay: float = DEFAULT_RETRY_DELAY,
80
+ session: requests.Session | None = None,
81
+ params: dict[str, Any] | None = None,
82
+ headers: dict[str, str] | None = None,
83
+ **request_kwargs: Any,
84
+ ) -> Any:
85
+ """
86
+ Make an HTTP request to the API with retry mechanism.
87
+
88
+ Args:
89
+ endpoint: API endpoint (e.g., "/Coverage/ComputeCoverage")
90
+ json_body: Optional JSON request payload
91
+ method: HTTP method
92
+ base_url: Base URL for the API
93
+ timeout: Request timeout in seconds
94
+ max_retries: Maximum number of retry attempts
95
+ retry_delay: Initial delay between retries (exponential backoff)
96
+ session: Optional requests.Session to use
97
+ params: Optional query parameters
98
+ headers: Optional extra headers
99
+
100
+ Returns:
101
+ Parsed JSON response
102
+
103
+ Raises:
104
+ AstroxAPIError: If IsSuccess=false in response
105
+ AstroxHTTPError: If HTTP status code indicates error
106
+ AstroxTimeoutError: If request times out
107
+ AstroxConnectionError: If connection fails after all retries
108
+ """
109
+ url = _join_url(base_url, endpoint)
110
+ use_session = session or requests.Session()
111
+ request_headers = _default_headers()
112
+ if headers:
113
+ request_headers.update(headers)
114
+ json_data = _json_payload(json_body)
115
+
116
+ last_exception = None
117
+
118
+ total_attempts = max_retries + 1
119
+
120
+ for attempt in range(total_attempts):
121
+ try:
122
+ response = use_session.request(
123
+ method.upper(),
124
+ url,
125
+ json=json_data,
126
+ headers=request_headers,
127
+ timeout=timeout,
128
+ params=params,
129
+ **request_kwargs,
130
+ )
131
+
132
+ # Check HTTP status
133
+ if response.status_code >= 400:
134
+ # Don't retry client errors (4xx), only server errors (5xx)
135
+ if response.status_code < 500:
136
+ raise exceptions.AstroxHTTPError(
137
+ status_code=response.status_code,
138
+ message=response.text or response.reason,
139
+ endpoint=endpoint,
140
+ response=response,
141
+ )
142
+ # Server error - will retry
143
+ last_exception = exceptions.AstroxHTTPError(
144
+ status_code=response.status_code,
145
+ message=response.text or response.reason,
146
+ endpoint=endpoint,
147
+ response=response,
148
+ )
149
+ if attempt < total_attempts - 1:
150
+ time.sleep(retry_delay * (2**attempt))
151
+ continue
152
+ raise last_exception
153
+
154
+ # Parse JSON response
155
+ if (
156
+ response.status_code == 204
157
+ or getattr(response, "content", None) == b""
158
+ ):
159
+ return None
160
+
161
+ try:
162
+ result = response.json()
163
+ except json.JSONDecodeError as e:
164
+ raise exceptions.AstroxAPIError(
165
+ message=f"Failed to parse JSON response: {e}",
166
+ endpoint=endpoint,
167
+ response=response,
168
+ )
169
+
170
+ # Check API-level success (if response has IsSuccess field)
171
+ if isinstance(result, dict) and "IsSuccess" in result:
172
+ if not result.get("IsSuccess"):
173
+ message = result.get("Message", "Unknown error")
174
+ raise exceptions.AstroxAPIError(
175
+ message=message,
176
+ endpoint=endpoint,
177
+ response=response,
178
+ )
179
+
180
+ return result
181
+
182
+ except requests.Timeout:
183
+ last_exception = exceptions.AstroxTimeoutError(
184
+ endpoint=endpoint,
185
+ timeout=timeout,
186
+ )
187
+ if attempt < total_attempts - 1:
188
+ time.sleep(retry_delay * (2**attempt))
189
+ continue
190
+ raise last_exception
191
+
192
+ except requests.ConnectionError as e:
193
+ last_exception = exceptions.AstroxConnectionError(
194
+ message=f"Failed to connect to API: {e}",
195
+ original_error=e,
196
+ )
197
+ if attempt < total_attempts - 1:
198
+ time.sleep(retry_delay * (2**attempt))
199
+ continue
200
+ raise last_exception
201
+
202
+ except requests.RequestException as e:
203
+ last_exception = exceptions.AstroxConnectionError(
204
+ message=f"Request failed: {e}",
205
+ original_error=e,
206
+ )
207
+ if attempt < total_attempts - 1:
208
+ time.sleep(retry_delay * (2**attempt))
209
+ continue
210
+ raise last_exception
211
+
212
+ # Should not reach here, but just in case
213
+ if last_exception:
214
+ raise last_exception
215
+ raise exceptions.AstroxConnectionError(
216
+ message="Request failed after all retries",
217
+ original_error=None,
218
+ )
219
+
220
+
221
+ def post(
222
+ endpoint: str,
223
+ data: Any,
224
+ response_model: type[T] | None = None,
225
+ base_url: str = DEFAULT_BASE_URL,
226
+ timeout: float = DEFAULT_TIMEOUT,
227
+ max_retries: int = DEFAULT_MAX_RETRIES,
228
+ retry_delay: float = DEFAULT_RETRY_DELAY,
229
+ session: requests.Session | None = None,
230
+ ) -> T | dict[str, Any]:
231
+ """
232
+ Make a POST request and optionally parse response into a model object.
233
+
234
+ Args:
235
+ endpoint: API endpoint
236
+ data: Request payload
237
+ response_model: Optional model_validate-compatible class for response
238
+ base_url: Base URL for the API
239
+ timeout: Request timeout in seconds
240
+ max_retries: Maximum number of retry attempts
241
+ retry_delay: Initial delay between retries
242
+ session: Optional requests.Session to use
243
+
244
+ Returns:
245
+ Parsed response model if response_model provided, else dict
246
+
247
+ Raises:
248
+ AstroxValidationError: If response validation fails
249
+ (Plus all exceptions from _make_request)
250
+ """
251
+ result = _make_request(
252
+ endpoint=endpoint,
253
+ json_body=data,
254
+ base_url=base_url,
255
+ timeout=timeout,
256
+ max_retries=max_retries,
257
+ retry_delay=retry_delay,
258
+ session=session,
259
+ )
260
+
261
+ if response_model is None:
262
+ return result
263
+
264
+ return _validate_response_model(response_model, result)
265
+
266
+
267
+ class Client:
268
+ """Client for the ASTROX API with retry mechanism.
269
+
270
+ Wraps the low-level _make_request() function in a class-based interface
271
+ with configurable connection parameters.
272
+
273
+ Example:
274
+ >>> client = Client(timeout=60)
275
+ >>> result = client.raw.post("/Propagator/J2", json={...})
276
+
277
+ >>> # Global configuration
278
+ >>> configure(base_url="http://custom:8765", timeout=120)
279
+ >>> # All subsequent calls use this configuration
280
+ """
281
+
282
+ def __init__(
283
+ self,
284
+ base_url: str = DEFAULT_BASE_URL,
285
+ timeout: float = DEFAULT_TIMEOUT,
286
+ max_retries: int = DEFAULT_MAX_RETRIES,
287
+ retry_delay: float = DEFAULT_RETRY_DELAY,
288
+ ):
289
+ """Initialize HTTP client.
290
+
291
+ Args:
292
+ base_url: Base URL for the API
293
+ timeout: Request timeout in seconds
294
+ max_retries: Maximum number of retry attempts
295
+ retry_delay: Initial delay between retries (exponential backoff)
296
+ """
297
+ self.base_url = base_url
298
+ self.timeout = timeout
299
+ self.max_retries = max_retries
300
+ self.retry_delay = retry_delay
301
+ self._session = requests.Session()
302
+ self.raw = RawClient(self)
303
+
304
+ def request(
305
+ self,
306
+ method: str,
307
+ endpoint: str,
308
+ *,
309
+ json: Any = None,
310
+ params: dict[str, Any] | None = None,
311
+ headers: dict[str, str] | None = None,
312
+ timeout: float | None = None,
313
+ max_retries: int | None = None,
314
+ retry_delay: float | None = None,
315
+ **request_kwargs: Any,
316
+ ) -> Any:
317
+ """Make a raw JSON request to an API endpoint."""
318
+ return _make_request(
319
+ endpoint=endpoint,
320
+ json_body=json,
321
+ method=method,
322
+ base_url=self.base_url,
323
+ timeout=timeout if timeout is not None else self.timeout,
324
+ max_retries=max_retries if max_retries is not None else self.max_retries,
325
+ retry_delay=retry_delay if retry_delay is not None else self.retry_delay,
326
+ session=self._session,
327
+ params=params,
328
+ headers=headers,
329
+ **request_kwargs,
330
+ )
331
+
332
+ def post(
333
+ self,
334
+ endpoint: str,
335
+ data: Any,
336
+ response_model: type[T] | None = None,
337
+ params: dict[str, Any] | None = None,
338
+ ) -> T | dict[str, Any]:
339
+ """Make POST request to API endpoint.
340
+
341
+ Args:
342
+ endpoint: API endpoint (e.g., "/Propagator/J2")
343
+ data: Request payload
344
+ response_model: Optional model_validate-compatible class for response validation
345
+ params: Optional query parameters
346
+
347
+ Returns:
348
+ Parsed response model if response_model provided, else dict
349
+
350
+ Raises:
351
+ AstroxAPIError: If IsSuccess=false in response
352
+ AstroxHTTPError: If HTTP status code indicates error
353
+ AstroxTimeoutError: If request times out
354
+ AstroxConnectionError: If connection fails after all retries
355
+ AstroxValidationError: If response validation fails
356
+ """
357
+ result = self.request("POST", endpoint, json=data, params=params)
358
+
359
+ if response_model is None:
360
+ return result
361
+
362
+ return _validate_response_model(response_model, result)
363
+
364
+
365
+ class RawClient:
366
+ """Advanced raw route access bound to a client or the default client."""
367
+
368
+ def __init__(self, client: Client | None = None) -> None:
369
+ self._client = client
370
+
371
+ def _target(self) -> Client:
372
+ return self._client or get_session()
373
+
374
+ def request(
375
+ self,
376
+ method: str,
377
+ endpoint: str,
378
+ *,
379
+ json: Any = None,
380
+ params: dict[str, Any] | None = None,
381
+ headers: dict[str, str] | None = None,
382
+ timeout: float | None = None,
383
+ max_retries: int | None = None,
384
+ retry_delay: float | None = None,
385
+ client: Client | None = None,
386
+ **request_kwargs: Any,
387
+ ) -> Any:
388
+ target = client or self._target()
389
+ return target.request(
390
+ method,
391
+ endpoint,
392
+ json=json,
393
+ params=params,
394
+ headers=headers,
395
+ timeout=timeout,
396
+ max_retries=max_retries,
397
+ retry_delay=retry_delay,
398
+ **request_kwargs,
399
+ )
400
+
401
+ def get(
402
+ self,
403
+ endpoint: str,
404
+ *,
405
+ params: dict[str, Any] | None = None,
406
+ headers: dict[str, str] | None = None,
407
+ timeout: float | None = None,
408
+ max_retries: int | None = None,
409
+ retry_delay: float | None = None,
410
+ client: Client | None = None,
411
+ **request_kwargs: Any,
412
+ ) -> Any:
413
+ return self.request(
414
+ "GET",
415
+ endpoint,
416
+ params=params,
417
+ headers=headers,
418
+ timeout=timeout,
419
+ max_retries=max_retries,
420
+ retry_delay=retry_delay,
421
+ client=client,
422
+ **request_kwargs,
423
+ )
424
+
425
+ def post(
426
+ self,
427
+ endpoint: str,
428
+ *,
429
+ json: Any = None,
430
+ params: dict[str, Any] | None = None,
431
+ headers: dict[str, str] | None = None,
432
+ timeout: float | None = None,
433
+ max_retries: int | None = None,
434
+ retry_delay: float | None = None,
435
+ client: Client | None = None,
436
+ **request_kwargs: Any,
437
+ ) -> Any:
438
+ return self.request(
439
+ "POST",
440
+ endpoint,
441
+ json=json,
442
+ params=params,
443
+ headers=headers,
444
+ timeout=timeout,
445
+ max_retries=max_retries,
446
+ retry_delay=retry_delay,
447
+ client=client,
448
+ **request_kwargs,
449
+ )
450
+
451
+
452
+ raw = RawClient()
453
+
454
+
455
+ def get_session() -> Client:
456
+ """Get the current default session, creating one if needed.
457
+
458
+ Returns:
459
+ Client instance (either existing default or newly created)
460
+
461
+ Example:
462
+ >>> sess = get_session()
463
+ >>> result = sess.raw.post("/Propagator/J2", json={...})
464
+ """
465
+ sess = _default_session.get()
466
+ if sess is None:
467
+ sess = Client()
468
+ _default_session.set(sess)
469
+ return sess
470
+
471
+
472
+ def configure(
473
+ base_url: str = DEFAULT_BASE_URL,
474
+ timeout: float = DEFAULT_TIMEOUT,
475
+ max_retries: int = DEFAULT_MAX_RETRIES,
476
+ retry_delay: float = DEFAULT_RETRY_DELAY,
477
+ ) -> Client:
478
+ """Configure the default session globally.
479
+
480
+ Args:
481
+ base_url: Base URL for the API
482
+ timeout: Request timeout in seconds
483
+ max_retries: Maximum number of retry attempts
484
+ retry_delay: Initial delay between retries
485
+
486
+ Returns:
487
+ Configured Client instance
488
+
489
+ Example:
490
+ >>> import astrox
491
+ >>> astrox.configure(base_url="http://custom:8765", timeout=120)
492
+ >>> # All subsequent calls use this configuration
493
+ >>> from astrox import orbits, propagator
494
+ >>> orbit = orbits.keplerian(...)
495
+ >>> period_s, position = propagator.j2(..., orbit=orbit)
496
+ """
497
+ sess = Client(
498
+ base_url=base_url,
499
+ timeout=timeout,
500
+ max_retries=max_retries,
501
+ retry_delay=retry_delay,
502
+ )
503
+ _default_session.set(sess)
504
+ return sess