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.
- {simulacrum_sdk-0.2.0/simulacrum_sdk.egg-info → simulacrum_sdk-0.2.2}/PKG-INFO +2 -2
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/pyproject.toml +2 -2
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/__init__.py +1 -1
- simulacrum_sdk-0.2.2/simulacrum/_errors.py +102 -0
- simulacrum_sdk-0.2.2/simulacrum/api.py +45 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/client.py +1 -11
- simulacrum_sdk-0.2.2/simulacrum/exceptions.py +126 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/models.py +18 -35
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2/simulacrum_sdk.egg-info}/PKG-INFO +2 -2
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/SOURCES.txt +3 -1
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/requires.txt +1 -1
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/tests/test_client.py +9 -21
- simulacrum_sdk-0.2.2/tests/test_errors.py +112 -0
- simulacrum_sdk-0.2.0/simulacrum/api.py +0 -48
- simulacrum_sdk-0.2.0/simulacrum/exceptions.py +0 -49
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/LICENSE +0 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/MANIFEST.in +0 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/README.md +0 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/setup.cfg +0 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum/config.py +0 -0
- {simulacrum_sdk-0.2.0 → simulacrum_sdk-0.2.2}/simulacrum_sdk.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
30
|
+
"httpx>=0.27,<1",
|
|
31
31
|
"pydantic>=2.0,<3",
|
|
32
32
|
"numpy>=1.24"
|
|
33
33
|
]
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
"""
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
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:
|
|
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,22 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
import numpy as np
|
|
1
|
+
import os
|
|
5
2
|
import pytest
|
|
6
|
-
|
|
3
|
+
import numpy as np
|
|
7
4
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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": "
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|