simudyne-pulse 0.6.0.dev1__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.
- simudyne/__init__.py +3 -0
- simudyne/client.py +121 -0
- simudyne/exceptions.py +7 -0
- simudyne/resources/__init__.py +0 -0
- simudyne/resources/api_keys.py +12 -0
- simudyne/resources/data.py +9 -0
- simudyne/resources/historical.py +1 -0
- simudyne/resources/profile.py +25 -0
- simudyne/resources/simulation.py +710 -0
- simudyne/resources/simulator_gym.py +118 -0
- simudyne/resources/validation.py +234 -0
- simudyne_pulse-0.6.0.dev1.dist-info/METADATA +147 -0
- simudyne_pulse-0.6.0.dev1.dist-info/RECORD +16 -0
- simudyne_pulse-0.6.0.dev1.dist-info/WHEEL +5 -0
- simudyne_pulse-0.6.0.dev1.dist-info/licenses/LICENSE +21 -0
- simudyne_pulse-0.6.0.dev1.dist-info/top_level.txt +1 -0
simudyne/__init__.py
ADDED
simudyne/client.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import requests
|
|
5
|
+
import io
|
|
6
|
+
import polars as pl
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
import tempfile
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from simudyne.exceptions import PulseAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PulseABM:
|
|
15
|
+
DEFAULT_BASE_URL = "https://pulse-api.simudyne.com"
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str = None,
|
|
22
|
+
base_url: str = None,
|
|
23
|
+
timeout: int = None,
|
|
24
|
+
max_retries: int = 3,
|
|
25
|
+
):
|
|
26
|
+
self.api_key = api_key or os.getenv("SIMUDYNE_API_KEY")
|
|
27
|
+
if not self.api_key:
|
|
28
|
+
raise ValueError("SIMUDYNE_API_KEY is not set -> pass api_key or set SIMUDYNE_API_KEY in environment")
|
|
29
|
+
self.base_url = base_url or os.getenv("SIMUDYNE_BASE_URL", self.DEFAULT_BASE_URL)
|
|
30
|
+
self.timeout = timeout or self.DEFAULT_TIMEOUT
|
|
31
|
+
self.max_retries = max_retries
|
|
32
|
+
self.session = requests.Session()
|
|
33
|
+
self.session.headers.update({"X-API-Key": self.api_key})
|
|
34
|
+
self.session.verify = True
|
|
35
|
+
|
|
36
|
+
from simudyne.resources.profile import ProfileResource
|
|
37
|
+
from simudyne.resources.api_keys import ApiKeysResource
|
|
38
|
+
from simudyne.resources.data import DataResource
|
|
39
|
+
from simudyne.resources.simulation import SimulationResource
|
|
40
|
+
from simudyne.resources.simulator_gym import SimulatorGymResource
|
|
41
|
+
from simudyne.resources.validation import ValidationResource
|
|
42
|
+
|
|
43
|
+
self.profile = ProfileResource(self)
|
|
44
|
+
self.api_keys = ApiKeysResource(self)
|
|
45
|
+
self.data = DataResource(self)
|
|
46
|
+
self.simulation = SimulationResource(self)
|
|
47
|
+
self.simulator_gym = SimulatorGymResource(self)
|
|
48
|
+
self.validation = ValidationResource(self)
|
|
49
|
+
|
|
50
|
+
def _request_with_retries(self, method: str, url: str, **kwargs):
|
|
51
|
+
"""Execute request with timeout and exponential backoff for transient errors."""
|
|
52
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
53
|
+
last_exception = None
|
|
54
|
+
|
|
55
|
+
for attempt in range(self.max_retries + 1):
|
|
56
|
+
try:
|
|
57
|
+
response = self.session.request(method, url, **kwargs)
|
|
58
|
+
|
|
59
|
+
if response.ok:
|
|
60
|
+
return response
|
|
61
|
+
|
|
62
|
+
if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
63
|
+
delay = 2 ** attempt
|
|
64
|
+
time.sleep(delay)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
detail = response.json().get("detail", response.text)
|
|
69
|
+
except ValueError:
|
|
70
|
+
detail = response.text
|
|
71
|
+
raise PulseAPIError(response.status_code, detail)
|
|
72
|
+
|
|
73
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
74
|
+
last_exception = e
|
|
75
|
+
if attempt < self.max_retries:
|
|
76
|
+
delay = 2 ** attempt
|
|
77
|
+
time.sleep(delay)
|
|
78
|
+
continue
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
raise last_exception
|
|
82
|
+
|
|
83
|
+
def _request(self, method: str, endpoint: str, **kwargs):
|
|
84
|
+
url = f"{self.base_url}{endpoint}"
|
|
85
|
+
response = self._request_with_retries(method, url, **kwargs)
|
|
86
|
+
return response.json()
|
|
87
|
+
|
|
88
|
+
def _request_csv(self, method, endpoint, **kwargs):
|
|
89
|
+
PAGE_SIZE = 100000
|
|
90
|
+
params = kwargs.get("params", {})
|
|
91
|
+
|
|
92
|
+
params["limit"] = PAGE_SIZE
|
|
93
|
+
params["offset"] = 0
|
|
94
|
+
kwargs["params"] = params
|
|
95
|
+
|
|
96
|
+
url = f"{self.base_url}{endpoint}"
|
|
97
|
+
response = self._request_with_retries(method, url, **kwargs)
|
|
98
|
+
|
|
99
|
+
total_rows = int(response.headers.get("X-Total-Rows", 0))
|
|
100
|
+
total_pages = math.ceil(total_rows / PAGE_SIZE) if total_rows > 0 else 1
|
|
101
|
+
|
|
102
|
+
frames = []
|
|
103
|
+
df = pl.read_csv(response.text.encode(), infer_schema_length=10000)
|
|
104
|
+
frames.append(df)
|
|
105
|
+
schema = df.schema
|
|
106
|
+
|
|
107
|
+
if total_pages > 1:
|
|
108
|
+
with tqdm(total=total_pages, initial=1, unit="page", desc=f"Downloading ({total_rows:,} rows)") as pbar:
|
|
109
|
+
for page in range(1, total_pages):
|
|
110
|
+
params["offset"] = page * PAGE_SIZE
|
|
111
|
+
kwargs["params"] = params
|
|
112
|
+
response = self._request_with_retries(method, url, **kwargs)
|
|
113
|
+
df = pl.read_csv(response.text.encode(), schema_overrides=schema, infer_schema_length=10000)
|
|
114
|
+
if df.is_empty():
|
|
115
|
+
break
|
|
116
|
+
frames.append(df)
|
|
117
|
+
pbar.update(1)
|
|
118
|
+
|
|
119
|
+
result = pl.concat(frames) if len(frames) > 1 else frames[0]
|
|
120
|
+
print(f"Done: {result.shape[0]:,} rows, {result.shape[1]} columns", file=sys.stderr)
|
|
121
|
+
return result
|
simudyne/exceptions.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class ApiKeysResource:
|
|
2
|
+
def __init__(self, client):
|
|
3
|
+
self._client = client
|
|
4
|
+
|
|
5
|
+
def create(self, name: str):
|
|
6
|
+
return self._client._request("POST", "/api-keys", json={"name": name})
|
|
7
|
+
|
|
8
|
+
def list(self):
|
|
9
|
+
return self._client._request("GET", "/api-keys")
|
|
10
|
+
|
|
11
|
+
def revoke(self, key_id: str):
|
|
12
|
+
return self._client._request("DELETE", f"/api-keys/{key_id}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Historical endpoints have been removed. Use data.get_available() instead.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class ProfileResource:
|
|
2
|
+
def __init__(self, client):
|
|
3
|
+
self._client = client
|
|
4
|
+
|
|
5
|
+
def get(self):
|
|
6
|
+
return self._client._request("GET", "/profile")
|
|
7
|
+
|
|
8
|
+
def usage(self):
|
|
9
|
+
return self._client._request("GET", "/profile/usage")
|
|
10
|
+
|
|
11
|
+
def downloads(self):
|
|
12
|
+
"""Bulk-download quota usage for the current rolling 24-hour window.
|
|
13
|
+
|
|
14
|
+
Returns a dict with ``limit``, ``used``, ``remaining`` and
|
|
15
|
+
``window_hours``. ``limit`` and ``remaining`` are ``None`` on the
|
|
16
|
+
pro and demo tiers (unlimited); on the free tier they reflect the
|
|
17
|
+
account's daily download allowance. A "download" is one simulation
|
|
18
|
+
group (all Monte Carlo runs of one scenario) — re-fetching a group
|
|
19
|
+
already downloaded in the window, in any file format, is free.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> quota = client.profile.downloads()
|
|
23
|
+
>>> print(f"{quota['used']}/{quota['limit']} used, {quota['remaining']} left")
|
|
24
|
+
"""
|
|
25
|
+
return self._client._request("GET", "/profile/downloads")
|