simulacrum-sdk 0.2.0__py3-none-any.whl → 0.2.4__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.

Potentially problematic release.


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

simulacrum/__init__.py CHANGED
@@ -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"
simulacrum/_errors.py ADDED
@@ -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
+
simulacrum/api.py CHANGED
@@ -2,47 +2,44 @@
2
2
 
3
3
  from typing import Any, Dict, Mapping, Optional
4
4
 
5
- import requests
5
+ import uuid
6
+ import httpx
6
7
 
7
- from simulacrum.exceptions import ApiError, AuthError, ERROR_CODE_MAP
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"
8
18
 
9
19
 
10
20
  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.
21
+ """Execute an HTTP request against the Simulacrum API and raise rich typed errors on failure.
12
22
 
13
23
  Args:
14
- method (str): HTTP method to invoke (``"GET"``, ``"POST"``, ...).
24
+ method (str): HTTP method to invoke ("GET", "POST", ...).
15
25
  url (str): Fully-qualified endpoint URL.
16
26
  headers (Mapping[str, str]): HTTP headers that include authorization and content type.
17
27
  json (Mapping[str, Any] | None): JSON-serialisable payload for the request body.
18
28
 
19
29
  Returns:
20
30
  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
31
  """
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")
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()))
33
35
 
34
- if error_code in ERROR_CODE_MAP:
35
- raise ERROR_CODE_MAP[error_code](message)
36
+ with httpx.Client(timeout=30) as client:
37
+ response = client.request(method=method, url=url, headers=merged_headers, json=json)
36
38
 
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:
39
+ if 200 <= response.status_code < 300:
40
+ # Let JSON decode errors propagate as ValueError to be consistent with prior behavior
46
41
  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
42
+
43
+ service = _infer_service_from_url(url)
44
+ endpoint = httpx.URL(url).path
45
+ raise parse_error(response, service=service, endpoint=endpoint)
simulacrum/client.py CHANGED
@@ -41,7 +41,7 @@ class Simulacrum:
41
41
  }
42
42
 
43
43
  def forecast(
44
- self, series: Sequence[float] | np.ndarray, horizon: int, model: str = "default"
44
+ self, series: Sequence[float] | np.ndarray, horizon: int | None = None, model: str = "default"
45
45
  ) -> np.ndarray:
46
46
  """Request a forecast for the provided time series.
47
47
 
@@ -86,6 +86,8 @@ class Simulacrum:
86
86
  series=series_to_send, horizon=horizon, model=model
87
87
  )
88
88
  request_body: Dict[str, Any] = payload.model_dump()
89
+ # Exclude optional fields that are None (e.g., horizon for onsiteiq)
90
+ request_body = payload.model_dump(exclude_none=True)
89
91
  response_data: Dict[str, Any] = send_request(
90
92
  method="POST",
91
93
  url=f"{self.base_url}/{model}/v1/forecast",
@@ -120,14 +122,4 @@ class Simulacrum:
120
122
  json=None,
121
123
  )
122
124
 
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
125
+ return ValidateAPIKeyResponse.model_validate(response_data)
simulacrum/exceptions.py CHANGED
@@ -1,42 +1,119 @@
1
- """Custom exceptions raised by the Simulacrum SDK."""
1
+ """Custom exceptions raised by the Simulacrum SDK.
2
2
 
3
- from typing import Dict, Type
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
4
10
 
5
11
 
6
12
  class SimulacrumError(Exception):
7
- """Base exception for all SDK errors."""
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
8
84
 
9
85
 
10
- class AuthError(SimulacrumError):
11
- """Raised when authentication with the API fails."""
86
+ # Backward compatibility aliases/classes
87
+ class AuthError(UnauthorizedError):
88
+ """Raised when authentication with the API fails (back-compat)."""
12
89
 
13
90
 
14
- class ApiKeyExpiredError(SimulacrumError):
15
- """Raised when the API key has expired."""
91
+ class ApiKeyExpiredError(ForbiddenError):
92
+ """Raised when the API key has expired (back-compat)."""
16
93
 
17
94
 
18
- class ApiKeyInactiveError(SimulacrumError):
19
- """Raised when the API key has been deactivated."""
95
+ class ApiKeyInactiveError(ForbiddenError):
96
+ """Raised when the API key has been deactivated (back-compat)."""
20
97
 
21
98
 
22
- class ApiKeyInvalidError(AuthError):
23
- """Raised when the API key is not recognised."""
99
+ class ApiKeyInvalidError(UnauthorizedError):
100
+ """Raised when the API key is not recognised (back-compat)."""
24
101
 
25
102
 
26
- class ForecastAlreadyRunningError(SimulacrumError):
27
- """Raised when a forecast job is already in progress."""
103
+ class ForecastAlreadyRunningError(ConflictError):
104
+ """Raised when a forecast job is already in progress (back-compat)."""
28
105
 
29
106
 
30
- class InvalidRequestError(SimulacrumError):
31
- """Raised when the request payload is malformed."""
107
+ class InvalidRequestError(ValidationError):
108
+ """Raised when the request payload is malformed (back-compat)."""
32
109
 
33
110
 
34
- class QuotaExceededError(SimulacrumError):
35
- """Raised when the API usage quota has been exhausted."""
111
+ class QuotaExceededError(RateLimitError):
112
+ """Raised when the API usage quota has been exhausted (back-compat)."""
36
113
 
37
114
 
38
- class ApiError(SimulacrumError):
39
- """Catch-all for unclassified API errors."""
115
+ class ApiError(HTTPClientError):
116
+ """Catch-all for unclassified API errors (back-compat)."""
40
117
 
41
118
 
42
119
  ERROR_CODE_MAP: Dict[str, Type[SimulacrumError]] = {
simulacrum/models.py CHANGED
@@ -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):
@@ -13,7 +12,7 @@ class ForecastRequest(BaseModel):
13
12
 
14
13
  Attributes:
15
14
  series (list[float]): Historical observations used as forecast input.
16
- horizon (int): Number of future periods to predict.
15
+ horizon (int | None): Number of future periods to predict. Optional for the ``"onsiteiq"`` model.
17
16
  model (str | None): Identifier of the forecasting model, defaults to ``"default"``.
18
17
 
19
18
  Example:
@@ -23,8 +22,9 @@ class ForecastRequest(BaseModel):
23
22
  {'series': [1.0, 2.0, 3.0], 'horizon': 2, 'model': 'default'}
24
23
  """
25
24
 
26
- series: List[float]
27
- horizon: int
25
+ # TODO: It must be List[List[float]] (num_steps, num_series) remove after refactoring
26
+ series: Union[List[float], List[List[float]]]
27
+ horizon: Optional[int] = None
28
28
  model: Optional[str] = "default"
29
29
 
30
30
  @field_validator("series", mode="before")
@@ -42,6 +42,23 @@ class ForecastRequest(BaseModel):
42
42
  return value.astype(float).tolist()
43
43
  return list(value)
44
44
 
45
+ @model_validator(mode="after")
46
+ def _validate_horizon_requirement(self):
47
+ # Horizon is required for all models except onsiteiq
48
+ model_name = (self.model or "").lower()
49
+ if model_name != "onsiteiq" and self.horizon is None:
50
+ raise ValueError("horizon is required and must be a positive integer for this model")
51
+
52
+ # When provided, horizon must be an integer >= 1
53
+ if self.horizon is not None:
54
+ if not isinstance(self.horizon, int):
55
+ raise ValueError(
56
+ f"horizon must be an integer (got {type(self.horizon).__name__})"
57
+ )
58
+ if self.horizon < 1:
59
+ raise ValueError("horizon must be >= 1")
60
+ return self
61
+
45
62
 
46
63
  class ForecastResponse(BaseModel):
47
64
  """Forecast output returned by the API.
@@ -57,7 +74,7 @@ class ForecastResponse(BaseModel):
57
74
  [4.2, 4.8]
58
75
  """
59
76
 
60
- forecast: List[float]
77
+ forecast: Union[List[float], List[List[float]]]
61
78
 
62
79
  def get_forecast(self) -> np.ndarray:
63
80
  """Return forecast values as a numpy array for downstream processing.
@@ -68,39 +85,23 @@ class ForecastResponse(BaseModel):
68
85
  return np.array(self.forecast, dtype=float)
69
86
 
70
87
 
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
88
  class ValidateAPIKeyResponse(BaseModel):
90
- """Structured representation of an API key validation response."""
89
+ """Metadata describing the validity of an API key.
90
+
91
+ Attributes:
92
+ valid (bool): Indicates whether the API key is currently valid.
93
+ client (str): Identifier of the owning client account.
94
+ expires_at (datetime | None): Expiration timestamp if provided by the API.
95
+ """
91
96
 
92
97
  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"
98
+ client: Optional[str] = None
99
+ key_id: Optional[str] = None
100
+ expires_at: Optional[datetime]
101
+
102
+ @model_validator(mode="after")
103
+ def _coalesce_client(self):
104
+ # Prefer explicit client; fall back to key_id when provided by API
105
+ if not self.client and self.key_id:
106
+ self.client = self.key_id
107
+ 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.4
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
@@ -0,0 +1,12 @@
1
+ simulacrum/__init__.py,sha256=RUetyuqgZ35UgPPcKHJAxqyh8aNkuB8AMWIevCc3iew,437
2
+ simulacrum/_errors.py,sha256=lE6I8c4uL7HC6orHBJ5B1kz7x5NKDHd3mP2yjpQjeIg,2803
3
+ simulacrum/api.py,sha256=dj1xF6KfBLHMML0AicnoHElRzaNjPxooQxBARX219c0,1645
4
+ simulacrum/client.py,sha256=uV3OlCCetni-O3-ESYtQcT8SR3MMj5MercatbH6YkrI,5026
5
+ simulacrum/config.py,sha256=n3iN-cVUF3ucINS3iydDgMnFoMtVxDeVBIQwGiMKNk8,157
6
+ simulacrum/exceptions.py,sha256=YrqoQjaPcZI31PmXd5evOl4fQjzxmz2sc8a3dSzkAMI,3408
7
+ simulacrum/models.py,sha256=_lRcIc0kSwrlTkJmsfj2SK9O2fB7oQz9FACbS_s8B2o,3906
8
+ simulacrum_sdk-0.2.4.dist-info/licenses/LICENSE,sha256=A7B9zAs2uCAzJoZPPyJW0G86yM-BTyum90vD4nSsOe0,1084
9
+ simulacrum_sdk-0.2.4.dist-info/METADATA,sha256=hPAAYiBNWUrnvozwCrCGsir1c8M1sS9Nxagbi0iQVjk,6326
10
+ simulacrum_sdk-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ simulacrum_sdk-0.2.4.dist-info/top_level.txt,sha256=CLUzfwEa7vZDf6zBAH8eXC2lZLMtj92MlXHOc3-V16o,11
12
+ simulacrum_sdk-0.2.4.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- simulacrum/__init__.py,sha256=yzQa-yO8unFv9uKF0gj-o3VoYT_t51-SAatr-KTKyzk,437
2
- simulacrum/api.py,sha256=IZ8qNikv81Wd2l3VuaiGcLTjLOIzoxMfrbkQLwK0usg,1889
3
- simulacrum/client.py,sha256=kjPnb0nAuf9djDCR8DNJ3VtPASrr7aFGDx6upN48ygk,5183
4
- simulacrum/config.py,sha256=n3iN-cVUF3ucINS3iydDgMnFoMtVxDeVBIQwGiMKNk8,157
5
- simulacrum/exceptions.py,sha256=6VjdyJaFx9c8nE8iIezJH6pWkdRBHRs5ORdEZUrbJmg,1289
6
- simulacrum/models.py,sha256=V14jf_dcRGaBbYyKdy8c9w0yDLD5yqqE5qYvgG0ocTk,3157
7
- simulacrum_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=A7B9zAs2uCAzJoZPPyJW0G86yM-BTyum90vD4nSsOe0,1084
8
- simulacrum_sdk-0.2.0.dist-info/METADATA,sha256=gFjEmlvV5UALjRP2M5NZpzm6sdIJQdKBlka8cd5NPmY,6329
9
- simulacrum_sdk-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- simulacrum_sdk-0.2.0.dist-info/top_level.txt,sha256=CLUzfwEa7vZDf6zBAH8eXC2lZLMtj92MlXHOc3-V16o,11
11
- simulacrum_sdk-0.2.0.dist-info/RECORD,,