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 ADDED
@@ -0,0 +1,3 @@
1
+ from simudyne.client import PulseABM, PulseAPIError
2
+
3
+ __all__ = ["PulseABM", "PulseAPIError"]
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
@@ -0,0 +1,7 @@
1
+ class PulseAPIError(Exception):
2
+ """Exception raised for Pulse API errors."""
3
+
4
+ def __init__(self, status_code: int, detail: str):
5
+ self.status_code = status_code
6
+ self.detail = detail
7
+ super().__init__(f"API Error ({status_code}): {detail}")
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,9 @@
1
+ AVAILABLE_SYMBOLS_PATH = "/data/available-symbols"
2
+
3
+
4
+ class DataResource:
5
+ def __init__(self, client):
6
+ self._client = client
7
+
8
+ def get_available_symbols(self):
9
+ return self._client._request("GET", AVAILABLE_SYMBOLS_PATH)
@@ -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")