simudyne-pulse 0.8.0.dev2__tar.gz → 0.9.0__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.
Files changed (26) hide show
  1. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/PKG-INFO +1 -3
  2. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/README.md +0 -1
  3. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/pyproject.toml +1 -2
  4. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/client.py +0 -4
  5. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne_pulse.egg-info/PKG-INFO +1 -3
  6. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -2
  7. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne_pulse.egg-info/requires.txt +0 -1
  8. simudyne_pulse-0.8.0.dev2/src/simudyne/resources/simulator_gym.py +0 -304
  9. simudyne_pulse-0.8.0.dev2/tests/test_simulator_gym.py +0 -172
  10. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/LICENSE +0 -0
  11. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/setup.cfg +0 -0
  12. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/__init__.py +0 -0
  13. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/exceptions.py +0 -0
  14. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/__init__.py +0 -0
  15. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/api_keys.py +0 -0
  16. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/data.py +0 -0
  17. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/fix.py +0 -0
  18. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/fm.py +0 -0
  19. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/profile.py +0 -0
  20. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/simulation.py +0 -0
  21. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne/resources/validation.py +0 -0
  22. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  23. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  24. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/tests/test_api_contract.py +0 -0
  25. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/tests/test_new_resources.py +0 -0
  26. {simudyne_pulse-0.8.0.dev2 → simudyne_pulse-0.9.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.8.0.dev2
3
+ Version: 0.9.0
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -19,7 +19,6 @@ License-File: LICENSE
19
19
  Requires-Dist: requests>=2.28.0
20
20
  Requires-Dist: polars>=0.20.0
21
21
  Requires-Dist: tqdm>=4.60.0
22
- Requires-Dist: websocket-client>=1.0.0
23
22
  Provides-Extra: test
24
23
  Requires-Dist: pytest>=8.0.0; extra == "test"
25
24
  Dynamic: license-file
@@ -231,7 +230,6 @@ the user guide is at https://pulse.simudyne.com/docs.
231
230
  | `client.validation` | `run`, `get_job_status`, `get_job`, `list_jobs` — results carry `errors` per failed area |
232
231
  | `client.fm` | `models`, `live`; admin: `registry`, `register`, `activate`, `deactivate` |
233
232
  | `client.fix` | FIX session usage |
234
- | `client.simulator_gym` | the interactive simulator websocket |
235
233
 
236
234
  Failures raise `PulseAPIError` with `status_code`, `detail` (exactly as the
237
235
  API sent it) and `errors`; `str(exc)` renders validation errors as
@@ -205,7 +205,6 @@ the user guide is at https://pulse.simudyne.com/docs.
205
205
  | `client.validation` | `run`, `get_job_status`, `get_job`, `list_jobs` — results carry `errors` per failed area |
206
206
  | `client.fm` | `models`, `live`; admin: `registry`, `register`, `activate`, `deactivate` |
207
207
  | `client.fix` | FIX session usage |
208
- | `client.simulator_gym` | the interactive simulator websocket |
209
208
 
210
209
  Failures raise `PulseAPIError` with `status_code`, `detail` (exactly as the
211
210
  API sent it) and `errors`; `str(exc)` renders validation errors as
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simudyne-pulse"
7
- version = "0.8.0-dev.2"
7
+ version = "0.9.0"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,7 +24,6 @@ dependencies = [
24
24
  "requests>=2.28.0",
25
25
  "polars>=0.20.0",
26
26
  "tqdm>=4.60.0",
27
- "websocket-client>=1.0.0",
28
27
  ]
29
28
 
30
29
  [project.optional-dependencies]
@@ -55,8 +55,6 @@ class PulseABM:
55
55
  Foundation-model generation and its job lifecycle.
56
56
  fix : FixResource
57
57
  FIX gateway usage.
58
- simulator_gym : SimulatorGymResource
59
- Gym-style reinforcement-learning environment over a WebSocket.
60
58
 
61
59
  Raises
62
60
  ------
@@ -109,7 +107,6 @@ class PulseABM:
109
107
  from simudyne.resources.fix import FixResource
110
108
  from simudyne.resources.fm import FmResource
111
109
  from simudyne.resources.simulation import SimulationResource
112
- from simudyne.resources.simulator_gym import SimulatorGymResource
113
110
  from simudyne.resources.validation import ValidationResource
114
111
 
115
112
  self.profile = ProfileResource(self)
@@ -118,7 +115,6 @@ class PulseABM:
118
115
  self.fix = FixResource(self)
119
116
  self.fm = FmResource(self)
120
117
  self.simulation = SimulationResource(self)
121
- self.simulator_gym = SimulatorGymResource(self)
122
118
  self.validation = ValidationResource(self)
123
119
 
124
120
  def _request_with_retries(self, method: str, url: str, **kwargs):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.8.0.dev2
3
+ Version: 0.9.0
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -19,7 +19,6 @@ License-File: LICENSE
19
19
  Requires-Dist: requests>=2.28.0
20
20
  Requires-Dist: polars>=0.20.0
21
21
  Requires-Dist: tqdm>=4.60.0
22
- Requires-Dist: websocket-client>=1.0.0
23
22
  Provides-Extra: test
24
23
  Requires-Dist: pytest>=8.0.0; extra == "test"
25
24
  Dynamic: license-file
@@ -231,7 +230,6 @@ the user guide is at https://pulse.simudyne.com/docs.
231
230
  | `client.validation` | `run`, `get_job_status`, `get_job`, `list_jobs` — results carry `errors` per failed area |
232
231
  | `client.fm` | `models`, `live`; admin: `registry`, `register`, `activate`, `deactivate` |
233
232
  | `client.fix` | FIX session usage |
234
- | `client.simulator_gym` | the interactive simulator websocket |
235
233
 
236
234
  Failures raise `PulseAPIError` with `status_code`, `detail` (exactly as the
237
235
  API sent it) and `errors`; `str(exc)` renders validation errors as
@@ -11,7 +11,6 @@ src/simudyne/resources/fix.py
11
11
  src/simudyne/resources/fm.py
12
12
  src/simudyne/resources/profile.py
13
13
  src/simudyne/resources/simulation.py
14
- src/simudyne/resources/simulator_gym.py
15
14
  src/simudyne/resources/validation.py
16
15
  src/simudyne_pulse.egg-info/PKG-INFO
17
16
  src/simudyne_pulse.egg-info/SOURCES.txt
@@ -20,5 +19,4 @@ src/simudyne_pulse.egg-info/requires.txt
20
19
  src/simudyne_pulse.egg-info/top_level.txt
21
20
  tests/test_api_contract.py
22
21
  tests/test_new_resources.py
23
- tests/test_simulator_gym.py
24
22
  tests/test_validation.py
@@ -1,7 +1,6 @@
1
1
  requests>=2.28.0
2
2
  polars>=0.20.0
3
3
  tqdm>=4.60.0
4
- websocket-client>=1.0.0
5
4
 
6
5
  [test]
7
6
  pytest>=8.0.0
@@ -1,304 +0,0 @@
1
- import json
2
-
3
- import websocket
4
-
5
-
6
- class GymSession:
7
- """A live connection to a Pulse simulator-gym environment session.
8
-
9
- Returned by
10
- :meth:`SimulatorGymResource.connect` — not constructed directly. Unlike
11
- every other object in this SDK the session is stateful and long-lived: it
12
- holds a WebSocket open and is stepped, in the style of a Gymnasium
13
- environment.
14
-
15
- Use it as a context manager so the session is closed even if stepping
16
- raises; otherwise call :meth:`close` yourself.
17
-
18
- Parameters
19
- ----------
20
- ws : websocket.WebSocket
21
- The open socket to the gym service.
22
- session_id : str
23
- Server-assigned id for this session.
24
-
25
- Attributes
26
- ----------
27
- session_id : str
28
- Server-assigned id for this session.
29
-
30
- Examples
31
- --------
32
- >>> with client.simulator_gym.connect(
33
- ... symbol="700.HK", cal_date="2025-09-02", exchange="HKEX.Securities"
34
- ... ) as env:
35
- ... obs = env.reset(seed=42)
36
- ... while True:
37
- ... result = env.step(0)
38
- ... if result["done"]:
39
- ... break
40
- """
41
-
42
- def __init__(self, ws: websocket.WebSocket, session_id: str):
43
- self._ws = ws
44
- self.session_id = session_id
45
-
46
- def reset(self, seed: int = None) -> dict:
47
- """Reset the environment and return the initial observation.
48
-
49
- Parameters
50
- ----------
51
- seed : int, optional
52
- Optional random seed for reproducibility.
53
-
54
- Returns
55
- -------
56
- dict
57
- Initial step result containing:
58
-
59
- - type (str): the message type, "step"
60
- - obs (list of float): the observation, 47 floats
61
- - reward (float): always 0.0 for a reset
62
- - done (bool): always False for a reset
63
- - info (dict): per-step diagnostics from the simulator
64
-
65
- Raises
66
- ------
67
- RuntimeError
68
- If the simulator replies with an error. Note this resource raises
69
- ``RuntimeError`` rather than ``PulseAPIError`` — it speaks
70
- WebSocket, not HTTP.
71
-
72
- See Also
73
- --------
74
- GymSession.step : Advance the episode.
75
- GymSession.close : End the session.
76
-
77
- Examples
78
- --------
79
- >>> obs = env.reset(seed=42)
80
- >>> len(obs["obs"])
81
- 47
82
-
83
- Reset without a seed for a fresh random episode:
84
-
85
- >>> obs = env.reset()
86
- """
87
- self._ws.send(json.dumps({"type": "reset", "seed": seed}))
88
- msg = json.loads(self._ws.recv())
89
- if msg["type"] == "error":
90
- raise RuntimeError(f"Simulator error: {msg['message']}")
91
- return msg
92
-
93
- def step(self, action: int) -> dict:
94
- """Take one step in the environment.
95
-
96
- Parameters
97
- ----------
98
- action : int
99
- Integer action in range 0-13.
100
-
101
- Returns
102
- -------
103
- dict
104
- Step result containing:
105
-
106
- - type (str): the message type, "step"
107
- - obs (list of float): the observation, 47 floats
108
- - reward (float): reward for this step
109
- - done (bool): True once the episode has ended
110
- - info (dict): per-step diagnostics from the simulator
111
-
112
- Raises
113
- ------
114
- RuntimeError
115
- If ``action`` is outside 0-13, or the simulator replies with an
116
- error. Note this resource raises ``RuntimeError`` rather than
117
- ``PulseAPIError`` — it speaks WebSocket, not HTTP.
118
-
119
- See Also
120
- --------
121
- GymSession.reset : Start a new episode.
122
- GymSession.close : End the session.
123
-
124
- Examples
125
- --------
126
- >>> result = env.step(0)
127
- >>> result["reward"], result["done"]
128
- (0.0012, False)
129
-
130
- Run an episode to completion, accumulating reward:
131
-
132
- >>> total = 0.0
133
- >>> obs = env.reset(seed=42)
134
- >>> while not obs["done"]:
135
- ... obs = env.step(0)
136
- ... total += obs["reward"]
137
- """
138
- self._ws.send(json.dumps({"type": "step", "action": action}))
139
- msg = json.loads(self._ws.recv())
140
- if msg["type"] == "error":
141
- raise RuntimeError(f"Simulator error: {msg['message']}")
142
- return msg
143
-
144
- def close(self):
145
- """Close the session and the underlying WebSocket connection.
146
-
147
- Sends a ``close`` message, waits for the acknowledgement, then shuts
148
- the socket down. The socket is closed even if the acknowledgement never
149
- arrives, so this is safe to call on a session that has already failed.
150
- Calling it twice is harmless.
151
-
152
- Called automatically when the session is used as a context manager.
153
-
154
- See Also
155
- --------
156
- SimulatorGymResource.connect : Open a new session.
157
-
158
- Examples
159
- --------
160
- >>> env = client.simulator_gym.connect(
161
- ... symbol="700.HK", cal_date="2025-09-02", exchange="HKEX.Securities"
162
- ... )
163
- >>> try:
164
- ... env.reset(seed=42)
165
- ... finally:
166
- ... env.close()
167
- """
168
- try:
169
- self._ws.send(json.dumps({"type": "close"}))
170
- self._ws.recv()
171
- finally:
172
- self._ws.close()
173
-
174
- def __enter__(self):
175
- return self
176
-
177
- def __exit__(self, *args):
178
- self.close()
179
-
180
-
181
- class SimulatorGymResource:
182
- """Open Gym-style reinforcement-learning environments over a WebSocket.
183
-
184
- Reached as ``client.simulator_gym``. The API authenticates the socket with
185
- your key and proxies it to the gym service, which drives the real engine.
186
-
187
- This is the one resource that is stateful: :meth:`connect` returns a
188
- :class:`GymSession` you hold open and step, rather than a dict.
189
-
190
- Examples
191
- --------
192
- >>> with client.simulator_gym.connect(
193
- ... symbol="700.HK", cal_date="2025-09-02", exchange="HKEX.Securities"
194
- ... ) as env:
195
- ... obs = env.reset(seed=42)
196
- ... result = env.step(0)
197
- """
198
-
199
- def __init__(self, client):
200
- self._client = client
201
-
202
- def _ws_base_url(self) -> str:
203
- url = self._client.base_url
204
- if url.startswith("https://"):
205
- return "wss://" + url[len("https://"):]
206
- if url.startswith("http://"):
207
- return "ws://" + url[len("http://"):]
208
- return url
209
-
210
- def connect(self, symbol: str, cal_date: str, exchange: str) -> GymSession:
211
- """Open a new simulator-gym session.
212
-
213
- Parameters
214
- ----------
215
- symbol : str
216
- Trading symbol, e.g. "700.HK"
217
- cal_date : str
218
- Calibration date, e.g. "2025-09-02"
219
- exchange : str
220
- Exchange identifier, e.g. "HKEX.Securities"
221
-
222
- Returns
223
- -------
224
- GymSession
225
- An open session, ready to :meth:`~GymSession.reset` and
226
- :meth:`~GymSession.step`. Use it as a context manager so it is
227
- closed on the way out.
228
-
229
- Raises
230
- ------
231
- RuntimeError
232
- If the key is rejected, the gym service cannot be reached, or the
233
- service refuses the session — an unknown symbol or date, most
234
- often. This resource raises ``RuntimeError`` rather than
235
- ``PulseAPIError`` because it speaks WebSocket, not HTTP.
236
-
237
- See Also
238
- --------
239
- GymSession.reset : Start an episode on the session.
240
- GymSession.step : Advance it.
241
- GymSession.close : End it.
242
-
243
- Notes
244
- -----
245
- ``exchange`` takes its display form here — ``"HKEX.Securities"`` — not
246
- the protocol slug ``"hkex_securities"`` used by
247
- :meth:`~simudyne.resources.simulation.SimulationResource.run` and the
248
- data catalog.
249
-
250
- Examples
251
- --------
252
- >>> with client.simulator_gym.connect(
253
- ... symbol="700.HK",
254
- ... cal_date="2025-09-02",
255
- ... exchange="HKEX.Securities",
256
- ... ) as env:
257
- ... obs = env.reset(seed=42)
258
- ... result = env.step(0)
259
- ... print(result["reward"], result["done"])
260
-
261
- Without the context manager, close it yourself:
262
-
263
- >>> env = client.simulator_gym.connect(
264
- ... symbol="700.HK", cal_date="2025-09-02", exchange="HKEX.Securities"
265
- ... )
266
- >>> try:
267
- ... env.reset(seed=42)
268
- ... finally:
269
- ... env.close()
270
- """
271
- ws_url = f"{self._ws_base_url()}/ws/simulator-gym"
272
- try:
273
- ws = websocket.create_connection(
274
- ws_url,
275
- header={"X-API-Key": self._client.api_key},
276
- )
277
- except websocket.WebSocketBadStatusException as e:
278
- if "403" in str(e):
279
- raise RuntimeError(
280
- "Authentication failed: invalid or expired API key. "
281
- "Check your API key with client.api_keys.list()."
282
- ) from e
283
- raise RuntimeError(f"Failed to connect to simulator-gym: {e}") from e
284
- except Exception as e:
285
- raise RuntimeError(f"Failed to connect to simulator-gym: {e}") from e
286
-
287
- ws.send(json.dumps({
288
- "type": "create",
289
- "config": {
290
- "symbol": symbol,
291
- "cal_date": cal_date,
292
- "exchange": exchange,
293
- }
294
- }))
295
-
296
- msg = json.loads(ws.recv())
297
- if msg["type"] == "error":
298
- ws.close()
299
- raise RuntimeError(f"Failed to create session: {msg['message']}")
300
- if msg["type"] != "created":
301
- ws.close()
302
- raise RuntimeError(f"Unexpected response type '{msg['type']}' during session creation")
303
-
304
- return GymSession(ws, session_id=msg["session_id"])
@@ -1,172 +0,0 @@
1
- import unittest
2
- from unittest.mock import MagicMock, patch, Mock
3
- import requests
4
-
5
-
6
- class TestSimulatorGymAuth(unittest.TestCase):
7
- """Verify WebSocket authentication uses header instead of query param."""
8
-
9
- @patch("simudyne.resources.simulator_gym.websocket.create_connection")
10
- def test_connect_uses_header_auth(self, mock_create_connection):
11
- mock_ws = MagicMock()
12
- mock_ws.recv.return_value = '{"type": "created", "session_id": "test-123"}'
13
- mock_create_connection.return_value = mock_ws
14
-
15
- from simudyne import PulseABM
16
-
17
- client = PulseABM(api_key="pk_test_secret123")
18
- client.simulator_gym.connect("700.HK", "2025-09-02", "HKEX.Securities")
19
-
20
- mock_create_connection.assert_called_once()
21
- call_args = mock_create_connection.call_args
22
-
23
- url = call_args[0][0]
24
- self.assertNotIn("api_key=", url, "API key should not be in URL query string")
25
- self.assertNotIn("pk_test_secret123", url, "API key should not appear in URL")
26
-
27
- header = call_args[1].get("header", {})
28
- self.assertEqual(header.get("X-API-Key"), "pk_test_secret123", "API key should be in X-API-Key header")
29
-
30
- @patch("simudyne.resources.simulator_gym.websocket.create_connection")
31
- def test_connect_url_format(self, mock_create_connection):
32
- mock_ws = MagicMock()
33
- mock_ws.recv.return_value = '{"type": "created", "session_id": "test-123"}'
34
- mock_create_connection.return_value = mock_ws
35
-
36
- from simudyne import PulseABM
37
-
38
- client = PulseABM(api_key="pk_test_xyz", base_url="https://api.example.com")
39
- client.simulator_gym.connect("700.HK", "2025-09-02", "HKEX.Securities")
40
-
41
- url = mock_create_connection.call_args[0][0]
42
- self.assertEqual(url, "wss://api.example.com/ws/simulator-gym")
43
-
44
-
45
- class TestClientTLSVerification(unittest.TestCase):
46
- """Verify TLS verification is explicitly enabled."""
47
-
48
- def test_session_verify_enabled(self):
49
- from simudyne import PulseABM
50
-
51
- client = PulseABM(api_key="pk_test_123")
52
- self.assertTrue(client.session.verify, "TLS verification should be explicitly True")
53
-
54
-
55
- class TestTimeoutAndRetry(unittest.TestCase):
56
- """Verify timeout and exponential backoff behavior."""
57
-
58
- def test_default_timeout(self):
59
- from simudyne import PulseABM
60
-
61
- client = PulseABM(api_key="pk_test_123")
62
- self.assertEqual(client.timeout, 30)
63
-
64
- def test_custom_timeout(self):
65
- from simudyne import PulseABM
66
-
67
- client = PulseABM(api_key="pk_test_123", timeout=60)
68
- self.assertEqual(client.timeout, 60)
69
-
70
- def test_custom_max_retries(self):
71
- from simudyne import PulseABM
72
-
73
- client = PulseABM(api_key="pk_test_123", max_retries=5)
74
- self.assertEqual(client.max_retries, 5)
75
-
76
- @patch("simudyne.client.time.sleep")
77
- def test_retries_on_503(self, mock_sleep):
78
- from simudyne import PulseABM
79
-
80
- client = PulseABM(api_key="pk_test_123", max_retries=2)
81
-
82
- mock_response_fail = Mock()
83
- mock_response_fail.ok = False
84
- mock_response_fail.status_code = 503
85
-
86
- mock_response_ok = Mock()
87
- mock_response_ok.ok = True
88
- mock_response_ok.json.return_value = {"status": "ok"}
89
-
90
- client.session.request = Mock(side_effect=[mock_response_fail, mock_response_ok])
91
-
92
- result = client._request("GET", "/test")
93
-
94
- self.assertEqual(result, {"status": "ok"})
95
- self.assertEqual(client.session.request.call_count, 2)
96
- mock_sleep.assert_called_once_with(1)
97
-
98
- @patch("simudyne.client.time.sleep")
99
- def test_raises_after_max_retries(self, mock_sleep):
100
- from simudyne import PulseABM, PulseAPIError
101
-
102
- client = PulseABM(api_key="pk_test_123", max_retries=2)
103
-
104
- mock_response = Mock()
105
- mock_response.ok = False
106
- mock_response.status_code = 503
107
- mock_response.text = "Service Unavailable"
108
- mock_response.json.return_value = {"detail": "Service Unavailable"}
109
-
110
- client.session.request = Mock(return_value=mock_response)
111
-
112
- with self.assertRaises(PulseAPIError) as ctx:
113
- client._request("GET", "/test")
114
-
115
- self.assertEqual(ctx.exception.status_code, 503)
116
- self.assertEqual(client.session.request.call_count, 3)
117
-
118
- def test_no_retry_on_400(self):
119
- from simudyne import PulseABM, PulseAPIError
120
-
121
- client = PulseABM(api_key="pk_test_123", max_retries=3)
122
-
123
- mock_response = Mock()
124
- mock_response.ok = False
125
- mock_response.status_code = 400
126
- mock_response.text = "Bad Request"
127
- mock_response.json.return_value = {"detail": "Bad Request"}
128
-
129
- client.session.request = Mock(return_value=mock_response)
130
-
131
- with self.assertRaises(PulseAPIError):
132
- client._request("GET", "/test")
133
-
134
- self.assertEqual(client.session.request.call_count, 1)
135
-
136
- @patch("simudyne.client.time.sleep")
137
- def test_retries_on_429_rate_limit(self, mock_sleep):
138
- from simudyne import PulseABM
139
-
140
- client = PulseABM(api_key="pk_test_123", max_retries=2)
141
-
142
- mock_response_fail = Mock()
143
- mock_response_fail.ok = False
144
- mock_response_fail.status_code = 429
145
-
146
- mock_response_ok = Mock()
147
- mock_response_ok.ok = True
148
- mock_response_ok.json.return_value = {"data": "success"}
149
-
150
- client.session.request = Mock(side_effect=[mock_response_fail, mock_response_ok])
151
-
152
- result = client._request("GET", "/test")
153
-
154
- self.assertEqual(result, {"data": "success"})
155
- self.assertEqual(client.session.request.call_count, 2)
156
-
157
-
158
- class TestPulseAPIError(unittest.TestCase):
159
- """Verify custom exception behavior."""
160
-
161
- def test_exception_attributes(self):
162
- from simudyne import PulseAPIError
163
-
164
- err = PulseAPIError(404, "Not Found")
165
- self.assertEqual(err.status_code, 404)
166
- self.assertEqual(err.detail, "Not Found")
167
- self.assertIn("404", str(err))
168
- self.assertIn("Not Found", str(err))
169
-
170
-
171
- if __name__ == "__main__":
172
- unittest.main()