simulacrum-sdk 0.2.0__tar.gz → 0.2.2__tar.gz

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.

Potentially problematic release.


This version of simulacrum-sdk might be problematic. Click here for more details.

Files changed (22) hide show
  1. {simulacrum_sdk-0.2.0/simulacrum_sdk.egg-info → simulacrum_sdk-0.2.2}/PKG-INFO +2 -2
  2. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/pyproject.toml +2 -2
  3. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/__init__.py +1 -1
  4. simulacrum_sdk-0.2.2/simulacrum/_errors.py +102 -0
  5. simulacrum_sdk-0.2.2/simulacrum/api.py +45 -0
  6. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/client.py +1 -11
  7. simulacrum_sdk-0.2.2/simulacrum/exceptions.py +126 -0
  8. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/models.py +18 -35
  9. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2/simulacrum_sdk.egg-info}/PKG-INFO +2 -2
  10. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/SOURCES.txt +3 -1
  11. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/requires.txt +1 -1
  12. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/tests/test_client.py +9 -21
  13. simulacrum_sdk-0.2.2/tests/test_errors.py +112 -0
  14. simulacrum_sdk-0.2.0/simulacrum/api.py +0 -48
  15. simulacrum_sdk-0.2.0/simulacrum/exceptions.py +0 -49
  16. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/LICENSE +0 -0
  17. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/MANIFEST.in +0 -0
  18. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/README.md +0 -0
  19. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/setup.cfg +0 -0
  20. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/config.py +0 -0
  21. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/dependency_links.txt +0 -0
  22. {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulacrum-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Official Python SDK for accessing the Simulacrum API.
5
5
  Author-email: "Simulacrum, Inc." <support@smlcrm.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Requires-Python: >=3.8
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests<3,>=2.28
25
+ Requires-Dist: httpx<1,>=0.27
26
26
  Requires-Dist: pydantic<3,>=2.0
27
27
  Requires-Dist: numpy>=1.24
28
28
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulacrum-sdk"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Official Python SDK for accessing the Simulacrum API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Topic :: Scientific/Engineering :: Artificial Intelligence"
28
28
  ]
29
29
  dependencies = [
30
- "requests>=2.28,<3",
30
+ "httpx>=0.27,<1",
31
31
  "pydantic>=2.0,<3",
32
32
  "numpy>=1.24"
33
33
  ]
@@ -12,4 +12,4 @@ Example:
12
12
  from .client import Simulacrum
13
13
 
14
14
  __all__ = ["Simulacrum", "__version__"]
15
- __version__: str = "0.2.0"
15
+ __version__: str = "0.1.0"
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Optional
5
+
6
+ from .exceptions import (
7
+ ConflictError,
8
+ ForbiddenError,
9
+ HTTPClientError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ ServerError,
13
+ SimulacrumError,
14
+ UnauthorizedError,
15
+ ValidationError,
16
+ )
17
+
18
+
19
+ def _parse_retry_after(value: str) -> Optional[float]:
20
+ try:
21
+ return float(value)
22
+ except Exception:
23
+ try:
24
+ dt = datetime.strptime(value, "%a, %d %b %Y %H:%M:%S %Z")
25
+ return max(
26
+ 0.0,
27
+ (dt.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)).total_seconds(),
28
+ )
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def parse_error(response, *, service: str, endpoint: str) -> SimulacrumError:
34
+ status = getattr(response, "status_code")
35
+ headers = getattr(response, "headers", {}) or {}
36
+ req_id = headers.get("X-Request-ID")
37
+
38
+ error_type = "http_error"
39
+ message = f"HTTP {status}"
40
+ details = None
41
+ trace_id = None
42
+
43
+ # Try parse JSON body according to ErrorEnvelope
44
+ try:
45
+ data = response.json()
46
+ err = (data or {}).get("error") or {}
47
+ error_type = str(err.get("type") or error_type)
48
+ message = str(err.get("message") or message)
49
+ details = err.get("details")
50
+ trace_id = (data or {}).get("trace_id")
51
+ except Exception:
52
+ try:
53
+ text = response.text
54
+ except Exception:
55
+ text = None
56
+ if text:
57
+ message = str(text).strip()[:500]
58
+
59
+ common = dict(
60
+ service=service,
61
+ endpoint=endpoint,
62
+ status_code=status,
63
+ error_type=error_type,
64
+ message=message,
65
+ details=details,
66
+ trace_id_body=trace_id,
67
+ request_id_header=req_id,
68
+ response=response,
69
+ )
70
+
71
+ if status == 401:
72
+ return UnauthorizedError(**common)
73
+ if status == 403:
74
+ return ForbiddenError(**common)
75
+ if status == 404:
76
+ return NotFoundError(**common)
77
+ if status == 409:
78
+ return ConflictError(**common)
79
+ if status in (400, 422):
80
+ return ValidationError(**common)
81
+ if status == 429:
82
+ ra = headers.get("Retry-After")
83
+ return RateLimitError(**common, retry_after_seconds=_parse_retry_after(ra) if ra else None)
84
+ if 500 <= status <= 599:
85
+ return ServerError(**common)
86
+ if 400 <= status <= 499:
87
+ return HTTPClientError(**common)
88
+ return HTTPClientError(**common)
89
+
90
+
91
+ def is_retryable(exc: SimulacrumError) -> bool:
92
+ return exc.status_code in {429, 500, 502, 503, 504}
93
+
94
+
95
+ def retry_after(exc: RateLimitError) -> Optional[float]:
96
+ return getattr(exc, "retry_after_seconds", None)
97
+
98
+
99
+ def extract_trace_id(exc: SimulacrumError) -> Optional[str]:
100
+ return exc.trace_id()
101
+
102
+
@@ -0,0 +1,45 @@
1
+ """Low-level HTTP helpers used by the Simulacrum client."""
2
+
3
+ from typing import Any, Dict, Mapping, Optional
4
+
5
+ import uuid
6
+ import httpx
7
+
8
+ from simulacrum._errors import parse_error
9
+
10
+
11
+ def _infer_service_from_url(url: str) -> str:
12
+ lowered = url.lower()
13
+ if "/onsiteiq/" in lowered:
14
+ return "onsiteiq"
15
+ if "/tempo/" in lowered:
16
+ return "tempo"
17
+ return "tempo"
18
+
19
+
20
+ def send_request(method: str, url: str, headers: Mapping[str, str], json: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
21
+ """Execute an HTTP request against the Simulacrum API and raise rich typed errors on failure.
22
+
23
+ Args:
24
+ method (str): HTTP method to invoke ("GET", "POST", ...).
25
+ url (str): Fully-qualified endpoint URL.
26
+ headers (Mapping[str, str]): HTTP headers that include authorization and content type.
27
+ json (Mapping[str, Any] | None): JSON-serialisable payload for the request body.
28
+
29
+ Returns:
30
+ dict[str, Any]: Parsed JSON payload returned by the API.
31
+ """
32
+ # Ensure we always send an X-Request-ID
33
+ merged_headers: Dict[str, str] = dict(headers)
34
+ merged_headers.setdefault("X-Request-ID", str(uuid.uuid4()))
35
+
36
+ with httpx.Client(timeout=30) as client:
37
+ response = client.request(method=method, url=url, headers=merged_headers, json=json)
38
+
39
+ if 200 <= response.status_code < 300:
40
+ # Let JSON decode errors propagate as ValueError to be consistent with prior behavior
41
+ return response.json() # type: ignore[return-value]
42
+
43
+ service = _infer_service_from_url(url)
44
+ endpoint = httpx.URL(url).path
45
+ raise parse_error(response, service=service, endpoint=endpoint)
@@ -120,14 +120,4 @@ class Simulacrum:
120
120
  json=None,
121
121
  )
122
122
 
123
- validation: ValidateAPIKeyResponse = ValidateAPIKeyResponse.model_validate(
124
- response_data
125
- )
126
-
127
- status_message = "valid" if validation.valid else "invalid"
128
- print(
129
- "Simulacrum API key validation: "
130
- f"{status_message} (status={validation.status.value}, env={validation.env}) "
131
- )
132
-
133
- return validation
123
+ return ValidateAPIKeyResponse.model_validate(response_data)
@@ -0,0 +1,126 @@
1
+ """Custom exceptions raised by the Simulacrum SDK.
2
+
3
+ This module defines a rich, typed hierarchy aligned with Simulacrum services' ErrorEnvelope
4
+ and preserves backward compatibility with previously exported exception names.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional, Type
10
+
11
+
12
+ class SimulacrumError(Exception):
13
+ """Base exception for all SDK errors with structured fields."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ service: str,
19
+ endpoint: str,
20
+ status_code: int,
21
+ error_type: str,
22
+ message: str,
23
+ details: Optional[Dict[str, Any]] = None,
24
+ trace_id_body: Optional[str] = None,
25
+ request_id_header: Optional[str] = None,
26
+ response: Any = None,
27
+ context: Optional[Dict[str, Any]] = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.service = service
31
+ self.endpoint = endpoint
32
+ self.status_code = status_code
33
+ self.error_type = error_type
34
+ self.message = message
35
+ self.details = details
36
+ self.trace_id_body = trace_id_body
37
+ self.request_id_header = request_id_header
38
+ self.response = response
39
+ self.context = context or {}
40
+
41
+ def trace_id(self) -> Optional[str]:
42
+ return self.trace_id_body or self.request_id_header
43
+
44
+ def __str__(self) -> str:
45
+ tid = self.trace_id()
46
+ tid_str = f" trace_id={tid}" if tid else ""
47
+ return f"[{self.service}] {self.status_code} {self.error_type}: {self.message}{tid_str}"
48
+
49
+
50
+ class UnauthorizedError(SimulacrumError):
51
+ pass
52
+
53
+
54
+ class ForbiddenError(SimulacrumError):
55
+ pass
56
+
57
+
58
+ class NotFoundError(SimulacrumError):
59
+ pass
60
+
61
+
62
+ class ConflictError(SimulacrumError):
63
+ pass
64
+
65
+
66
+ class ValidationError(SimulacrumError):
67
+ pass
68
+
69
+
70
+ class RateLimitError(SimulacrumError):
71
+ def __init__(
72
+ self, *args, retry_after_seconds: Optional[float] = None, **kwargs: Any
73
+ ) -> None:
74
+ super().__init__(*args, **kwargs)
75
+ self.retry_after_seconds = retry_after_seconds
76
+
77
+
78
+ class ServerError(SimulacrumError):
79
+ pass
80
+
81
+
82
+ class HTTPClientError(SimulacrumError):
83
+ pass
84
+
85
+
86
+ # Backward compatibility aliases/classes
87
+ class AuthError(UnauthorizedError):
88
+ """Raised when authentication with the API fails (back-compat)."""
89
+
90
+
91
+ class ApiKeyExpiredError(ForbiddenError):
92
+ """Raised when the API key has expired (back-compat)."""
93
+
94
+
95
+ class ApiKeyInactiveError(ForbiddenError):
96
+ """Raised when the API key has been deactivated (back-compat)."""
97
+
98
+
99
+ class ApiKeyInvalidError(UnauthorizedError):
100
+ """Raised when the API key is not recognised (back-compat)."""
101
+
102
+
103
+ class ForecastAlreadyRunningError(ConflictError):
104
+ """Raised when a forecast job is already in progress (back-compat)."""
105
+
106
+
107
+ class InvalidRequestError(ValidationError):
108
+ """Raised when the request payload is malformed (back-compat)."""
109
+
110
+
111
+ class QuotaExceededError(RateLimitError):
112
+ """Raised when the API usage quota has been exhausted (back-compat)."""
113
+
114
+
115
+ class ApiError(HTTPClientError):
116
+ """Catch-all for unclassified API errors (back-compat)."""
117
+
118
+
119
+ ERROR_CODE_MAP: Dict[str, Type[SimulacrumError]] = {
120
+ "API_KEY_EXPIRED": ApiKeyExpiredError,
121
+ "API_KEY_INVALID": ApiKeyInvalidError,
122
+ "API_KEY_INACTIVE": ApiKeyInactiveError,
123
+ "API_USAGE_LIMIT": QuotaExceededError,
124
+ "REQUEST_INVALID": InvalidRequestError,
125
+ "FORECAST_ALREADY_RUNNING": ForecastAlreadyRunningError,
126
+ }
@@ -2,10 +2,9 @@
2
2
 
3
3
  from datetime import datetime
4
4
  from typing import List, Optional, Sequence, Union
5
- from enum import Enum
6
5
 
7
6
  import numpy as np
8
- from pydantic import BaseModel, field_validator, Field, constr
7
+ from pydantic import BaseModel, field_validator, model_validator
9
8
 
10
9
 
11
10
  class ForecastRequest(BaseModel):
@@ -68,39 +67,23 @@ class ForecastResponse(BaseModel):
68
67
  return np.array(self.forecast, dtype=float)
69
68
 
70
69
 
71
- class ApiKeyStatus(str, Enum):
72
- """Possible statuses for an API key."""
73
-
74
- active = "active"
75
- deprecated = "deprecated"
76
- disabled = "disabled"
77
-
78
-
79
- class ApiKeyStatusHistoryEntry(BaseModel):
80
- """API key status history entry."""
81
-
82
- status: ApiKeyStatus
83
- changed_at: datetime
84
-
85
- class Config:
86
- extra = "forbid"
87
-
88
-
89
70
  class ValidateAPIKeyResponse(BaseModel):
90
- """Structured representation of an API key validation response."""
71
+ """Metadata describing the validity of an API key.
72
+
73
+ Attributes:
74
+ valid (bool): Indicates whether the API key is currently valid.
75
+ client (str): Identifier of the owning client account.
76
+ expires_at (datetime | None): Expiration timestamp if provided by the API.
77
+ """
91
78
 
92
79
  valid: bool
93
- key_id: constr(pattern=r"^[A-Za-z0-9]+$")
94
- status: ApiKeyStatus
95
- env: constr(pattern=r"^(live|test)$")
96
- project_id: str
97
- organization_id: str
98
- user_id: str
99
- product_id: str
100
- name: constr(min_length=1, max_length=255)
101
- expires_at: Optional[datetime] = None
102
- rate_limit_per_minute: int = Field(ge=1)
103
- rate_limit_per_hour: int = Field(ge=1)
104
-
105
- class Config:
106
- extra = "forbid"
80
+ client: Optional[str] = None
81
+ key_id: Optional[str] = None
82
+ expires_at: Optional[datetime]
83
+
84
+ @model_validator(mode="after")
85
+ def _coalesce_client(self):
86
+ # Prefer explicit client; fall back to key_id when provided by API
87
+ if not self.client and self.key_id:
88
+ self.client = self.key_id
89
+ return self
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulacrum-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Official Python SDK for accessing the Simulacrum API.
5
5
  Author-email: "Simulacrum, Inc." <support@smlcrm.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Requires-Python: >=3.8
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests<3,>=2.28
25
+ Requires-Dist: httpx<1,>=0.27
26
26
  Requires-Dist: pydantic<3,>=2.0
27
27
  Requires-Dist: numpy>=1.24
28
28
  Provides-Extra: dev
@@ -3,6 +3,7 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  simulacrum/__init__.py
6
+ simulacrum/_errors.py
6
7
  simulacrum/api.py
7
8
  simulacrum/client.py
8
9
  simulacrum/config.py
@@ -13,4 +14,5 @@ simulacrum_sdk.egg-info/SOURCES.txt
13
14
  simulacrum_sdk.egg-info/dependency_links.txt
14
15
  simulacrum_sdk.egg-info/requires.txt
15
16
  simulacrum_sdk.egg-info/top_level.txt
16
- tests/test_client.py
17
+ tests/test_client.py
18
+ tests/test_errors.py
@@ -1,4 +1,4 @@
1
- requests<3,>=2.28
1
+ httpx<1,>=0.27
2
2
  pydantic<3,>=2.0
3
3
  numpy>=1.24
4
4
 
@@ -1,22 +1,20 @@
1
- from pathlib import Path
2
- import sys
3
-
4
- import numpy as np
1
+ import os
5
2
  import pytest
6
- from pydantic import ValidationError
3
+ import numpy as np
7
4
 
8
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
+ from pydantic import ValidationError
9
6
 
10
7
  import simulacrum.client as simulacrum_client
11
8
 
12
9
 
13
10
  @pytest.fixture
14
11
  def client():
15
- return simulacrum_client.Simulacrum("super-secret-key", base_url="https://api.test")
12
+ base_url = os.environ.get("TEST_API")
13
+ return simulacrum_client.Simulacrum(os.environ.get("TEST_API_KEY"), base_url=base_url)
16
14
 
17
15
 
18
16
  def test_client_initializes_expected_headers(client):
19
- assert client.headers["Authorization"] == "Bearer super-secret-key"
17
+ assert client.headers["Authorization"] == f"Bearer {os.environ.get('TEST_API_KEY')}"
20
18
  assert client.headers["Content-Type"] == "application/json"
21
19
 
22
20
 
@@ -30,17 +28,8 @@ def test_validate_invokes_send_request_and_parses_response(client, monkeypatch):
30
28
  captured["json"] = json
31
29
  return {
32
30
  "valid": True,
33
- "key_id": "key123",
34
- "status": "active",
35
- "env": "test",
36
- "project_id": "project-123",
37
- "organization_id": "org-456",
38
- "user_id": "user-789",
39
- "product_id": "product-abc",
40
- "name": "Test Key",
31
+ "client": "client-123",
41
32
  "expires_at": "2024-01-01T00:00:00Z",
42
- "rate_limit_per_minute": 60,
43
- "rate_limit_per_hour": 1000,
44
33
  }
45
34
 
46
35
  monkeypatch.setattr(simulacrum_client, "send_request", fake_send_request)
@@ -49,13 +38,12 @@ def test_validate_invokes_send_request_and_parses_response(client, monkeypatch):
49
38
 
50
39
  assert captured == {
51
40
  "method": "GET",
52
- "url": "https://api.test/tempo/v1/validate",
41
+ "url": f"{client.base_url}/tempo/v1/validate",
53
42
  "headers": client.headers,
54
43
  "json": None,
55
44
  }
56
45
  assert response.valid is True
57
- assert response.key_id == "key123"
58
- assert response.status.value == "active"
46
+ assert response.client == "client-123"
59
47
 
60
48
 
61
49
  def test_forecast_builds_payload_and_returns_numpy_array(client, monkeypatch):
@@ -0,0 +1,112 @@
1
+ import json
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from simulacrum._errors import parse_error
5
+ from simulacrum.exceptions import (
6
+ ConflictError,
7
+ ForbiddenError,
8
+ NotFoundError,
9
+ RateLimitError,
10
+ ServerError,
11
+ UnauthorizedError,
12
+ ValidationError,
13
+ )
14
+
15
+
16
+ class DummyResponse:
17
+ def __init__(self, *, status_code: int, headers: dict | None = None, body: object | None = None, text: str | None = None):
18
+ self.status_code = status_code
19
+ self.headers = headers or {}
20
+ self._body = body
21
+ self._text = text
22
+
23
+ def json(self):
24
+ if self._body is None:
25
+ raise ValueError("no json body")
26
+ return self._body
27
+
28
+ @property
29
+ def text(self):
30
+ if self._text is not None:
31
+ return self._text
32
+ return json.dumps(self._body) if self._body is not None else ""
33
+
34
+
35
+ def mk_envelope(error_type: str, message: str, details: dict | None = None, trace_id: str | None = "t-123"):
36
+ return {
37
+ "error": {"type": error_type, "message": message, "details": details},
38
+ "trace_id": trace_id,
39
+ }
40
+
41
+
42
+ def test_401_unauthorized_json_and_plain_text():
43
+ r1 = DummyResponse(status_code=401, headers={"X-Request-ID": "abc"}, body=mk_envelope("unauthorized", "bad key"))
44
+ exc = parse_error(r1, service="tempo", endpoint="/v1/validate")
45
+ assert isinstance(exc, UnauthorizedError)
46
+ assert exc.trace_id() in {"t-123", "abc"}
47
+
48
+ r2 = DummyResponse(status_code=401, headers={}, body=None, text="Unauthorized")
49
+ exc2 = parse_error(r2, service="tempo", endpoint="/v1/validate")
50
+ assert isinstance(exc2, UnauthorizedError)
51
+
52
+
53
+ def test_403_forbidden_variations():
54
+ r = DummyResponse(status_code=403, headers={}, body=mk_envelope("forbidden", "revoked"))
55
+ exc = parse_error(r, service="tempo", endpoint="/v1/validate")
56
+ assert isinstance(exc, ForbiddenError)
57
+
58
+
59
+ def test_422_and_400_validation_details_list():
60
+ details = {"problems": [
61
+ {"field": "series[5]", "message": "invalid value"},
62
+ {"field": "horizon", "message": "too large"},
63
+ ]}
64
+ r = DummyResponse(status_code=422, headers={}, body=mk_envelope("validation_error", "schema error", details))
65
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
66
+ assert isinstance(exc, ValidationError)
67
+ s = str(exc)
68
+ assert "validation_error" in s and "schema error" in s
69
+
70
+
71
+ def test_400_domain_validation_details_specific_keys():
72
+ details = {"invalid_index": 7, "invalid_value": -1}
73
+ r = DummyResponse(status_code=400, headers={}, body=mk_envelope("validation_error", "domain error", details))
74
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
75
+ assert isinstance(exc, ValidationError)
76
+
77
+
78
+ def test_404_and_409_mapping():
79
+ r404 = DummyResponse(status_code=404, headers={}, body=mk_envelope("http_error", "not found"))
80
+ r409 = DummyResponse(status_code=409, headers={}, body=mk_envelope("http_error", "conflict"))
81
+ assert isinstance(parse_error(r404, service="tempo", endpoint="/x"), NotFoundError)
82
+ assert isinstance(parse_error(r409, service="tempo", endpoint="/x"), ConflictError)
83
+
84
+
85
+ def test_429_retry_after_seconds_and_date():
86
+ r = DummyResponse(status_code=429, headers={"Retry-After": "30"}, body=mk_envelope("rate_limit", "slow down"))
87
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
88
+ assert isinstance(exc, RateLimitError)
89
+ assert exc.retry_after_seconds == 30.0
90
+
91
+ http_date = (datetime.now(timezone.utc) + timedelta(seconds=45)).strftime("%a, %d %b %Y %H:%M:%S %Z")
92
+ r2 = DummyResponse(status_code=429, headers={"Retry-After": http_date}, body=mk_envelope("rate_limit", "slow down"))
93
+ exc2 = parse_error(r2, service="tempo", endpoint="/v1/forecast")
94
+ assert isinstance(exc2, RateLimitError)
95
+ assert exc2.retry_after_seconds is not None and exc2.retry_after_seconds >= 0.0
96
+
97
+
98
+ def test_500_server_error_json_and_plain():
99
+ r1 = DummyResponse(status_code=500, headers={}, body=mk_envelope("internal_error", "boom"))
100
+ r2 = DummyResponse(status_code=500, headers={}, body=None, text="internal error")
101
+ assert isinstance(parse_error(r1, service="tempo", endpoint="/x"), ServerError)
102
+ assert isinstance(parse_error(r2, service="tempo", endpoint="/x"), ServerError)
103
+
104
+
105
+ def test_trace_id_from_body_or_header_and_str_formatting():
106
+ r = DummyResponse(status_code=401, headers={"X-Request-ID": "rid-1"}, body=mk_envelope("unauthorized", "bad key", trace_id=None))
107
+ exc = parse_error(r, service="onsiteiq", endpoint="/v1/validate")
108
+ assert exc.trace_id() == "rid-1"
109
+ s = str(exc)
110
+ assert "[onsiteiq]" in s and "401 unauthorized".split()[1] in s.lower()
111
+
112
+
@@ -1,48 +0,0 @@
1
- """Low-level HTTP helpers used by the Simulacrum client."""
2
-
3
- from typing import Any, Dict, Mapping, Optional
4
-
5
- import requests
6
-
7
- from simulacrum.exceptions import ApiError, AuthError, ERROR_CODE_MAP
8
-
9
-
10
- def send_request(method: str, url: str, headers: Mapping[str, str], json: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
11
- """Execute an HTTP request against the Simulacrum API and handle common errors.
12
-
13
- Args:
14
- method (str): HTTP method to invoke (``"GET"``, ``"POST"``, ...).
15
- url (str): Fully-qualified endpoint URL.
16
- headers (Mapping[str, str]): HTTP headers that include authorization and content type.
17
- json (Mapping[str, Any] | None): JSON-serialisable payload for the request body.
18
-
19
- Returns:
20
- dict[str, Any]: Parsed JSON payload returned by the API.
21
-
22
- Raises:
23
- AuthError: Raised when the API reports an authentication failure.
24
- ApiError: Raised for all other non-success responses or malformed data.
25
- """
26
- response = requests.request(method=method, url=url, headers=dict(headers), json=json)
27
-
28
- if not response.ok:
29
- try:
30
- data: Dict[str, Any] = response.json()
31
- error_code: Optional[str] = data.get("error_code")
32
- message: str = data.get("message", "Unknown error")
33
-
34
- if error_code in ERROR_CODE_MAP:
35
- raise ERROR_CODE_MAP[error_code](message)
36
-
37
- if response.status_code == 401:
38
- raise AuthError(message)
39
-
40
- raise ApiError(f"API error {response.status_code}: {message}")
41
-
42
- except ValueError as exc:
43
- raise ApiError(f"Unexpected API error: {response.text}") from exc
44
-
45
- try:
46
- return response.json() # type: ignore[return-value]
47
- except ValueError as exc: # requests raises ValueError for JSON decode errors
48
- raise ApiError(f"Failed to parse response JSON: {exc}") from exc
@@ -1,49 +0,0 @@
1
- """Custom exceptions raised by the Simulacrum SDK."""
2
-
3
- from typing import Dict, Type
4
-
5
-
6
- class SimulacrumError(Exception):
7
- """Base exception for all SDK errors."""
8
-
9
-
10
- class AuthError(SimulacrumError):
11
- """Raised when authentication with the API fails."""
12
-
13
-
14
- class ApiKeyExpiredError(SimulacrumError):
15
- """Raised when the API key has expired."""
16
-
17
-
18
- class ApiKeyInactiveError(SimulacrumError):
19
- """Raised when the API key has been deactivated."""
20
-
21
-
22
- class ApiKeyInvalidError(AuthError):
23
- """Raised when the API key is not recognised."""
24
-
25
-
26
- class ForecastAlreadyRunningError(SimulacrumError):
27
- """Raised when a forecast job is already in progress."""
28
-
29
-
30
- class InvalidRequestError(SimulacrumError):
31
- """Raised when the request payload is malformed."""
32
-
33
-
34
- class QuotaExceededError(SimulacrumError):
35
- """Raised when the API usage quota has been exhausted."""
36
-
37
-
38
- class ApiError(SimulacrumError):
39
- """Catch-all for unclassified API errors."""
40
-
41
-
42
- ERROR_CODE_MAP: Dict[str, Type[SimulacrumError]] = {
43
- "API_KEY_EXPIRED": ApiKeyExpiredError,
44
- "API_KEY_INVALID": ApiKeyInvalidError,
45
- "API_KEY_INACTIVE": ApiKeyInactiveError,
46
- "API_USAGE_LIMIT": QuotaExceededError,
47
- "REQUEST_INVALID": InvalidRequestError,
48
- "FORECAST_ALREADY_RUNNING": ForecastAlreadyRunningError,
49
- }
File without changes
File without changes
File without changes