cexy 0.1.0.dev1__tar.gz → 0.1.0.dev3__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 (34) hide show
  1. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/CHANGELOG.md +18 -1
  2. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/PKG-INFO +8 -3
  3. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/README.md +7 -2
  4. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/__init__.py +2 -0
  5. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_async/transport.py +14 -1
  6. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_sync/transport.py +14 -1
  7. cexy-0.1.0.dev3/cexy/_version.py +1 -0
  8. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/errors.py +9 -2
  9. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/pyproject.toml +1 -1
  10. cexy-0.1.0.dev1/cexy/_version.py +0 -1
  11. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/.gitignore +0 -0
  12. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/LICENSE +0 -0
  13. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/SECURITY.md +0 -0
  14. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_async/__init__.py +0 -0
  15. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_async/client.py +0 -0
  16. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_async/pagination.py +0 -0
  17. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_async/resources.py +0 -0
  18. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_common.py +0 -0
  19. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_decimal.py +0 -0
  20. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_enum.py +0 -0
  21. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_generated/__init__.py +0 -0
  22. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_generated/models.py +0 -0
  23. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_generated/operations.py +0 -0
  24. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_opmap.py +0 -0
  25. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_ratelimit.py +0 -0
  26. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_retry.py +0 -0
  27. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_sync/__init__.py +0 -0
  28. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_sync/client.py +0 -0
  29. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_sync/pagination.py +0 -0
  30. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/_sync/resources.py +0 -0
  31. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/auth.py +0 -0
  32. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/models.py +0 -0
  33. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/py.typed +0 -0
  34. {cexy-0.1.0.dev1 → cexy-0.1.0.dev3}/cexy/ws.py +0 -0
@@ -3,7 +3,24 @@
3
3
  All notable changes to this project are documented here. The SDK stays at 0.x until API
4
4
  request signing (HMAC) ships; see "Versioning" in README.md.
5
5
 
6
- ## 0.1.0.dev1 (unreleased)
6
+ ## 0.1.0.dev3 (unreleased)
7
+
8
+ ### Security
9
+ - **Redirects are never followed**, including on a caller-supplied `http_client` created with
10
+ `follow_redirects=True`. Earlier versions set `follow_redirects=False` only on the client they
11
+ created themselves, so a caller's following client re-sent `X-API-Key` and `X-API-Secret` to a
12
+ redirect target (httpx strips only `Authorization`), and a 307/308 re-posted an order. A 3xx
13
+ was also treated as success. Every request now passes `follow_redirects=False`, and a 3xx
14
+ raises `CexyApiError` with code `UNEXPECTED_REDIRECT`, which is not retried. Upgrade from
15
+ 0.1.0.dev2 or earlier if you pass your own `http_client`.
16
+
17
+ ## 0.1.0.dev2 (2026-09-27)
18
+
19
+ - `JurisdictionBlockedError` (a `ForbiddenError` subclass) for `JURISDICTION_BLOCKED` / HTTP 451.
20
+ - README: pre-releases install with `pip install --pre cexy` (the PyPI page for 0.1.0.dev1 still says `pip install cexy`, which does not install a pre-release).
21
+ - `spec/errors.yaml` is now generated upstream from the spec's `ErrorCode` (same 44 codes).
22
+
23
+ ## 0.1.0.dev1 (2026-09-27)
7
24
 
8
25
  First published pre-release. 0.1.0.dev0 was tagged but never reached PyPI: the pinned publish
9
26
  action (v1.12.4, twine 6.1.0) rejected wheel metadata version 2.5. The publish action is now
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cexy
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev3
4
4
  Summary: Python SDK for the CEXY.io exchange REST and WebSocket API
5
5
  Project-URL: Homepage, https://cexy.io
6
6
  Project-URL: Source, https://github.com/cexyio/cexy-python
@@ -44,12 +44,13 @@ Typed Python client for the [CEXY.io](https://cexy.io) exchange REST and WebSock
44
44
  - Client-side rate limiting, cursor pagination
45
45
  - WebSocket client with heartbeat, reconnect and a self-syncing order book
46
46
 
47
- > Status: **0.1.0.dev1, pre-release.** The API may change before 1.0 (see [Versioning](#versioning)).
47
+ > Status: **0.1.0.dev3, pre-release.** The API may change before 1.0 (see [Versioning](#versioning)).
48
+ > Pre-releases need `--pre`: `pip install --pre cexy`.
48
49
 
49
50
  ## Install
50
51
 
51
52
  ```bash
52
- pip install cexy # Python 3.9+
53
+ pip install --pre cexy # Python 3.9+ (--pre while releases are 0.1.0.devN)
53
54
  ```
54
55
 
55
56
  ## Quickstart: public market data
@@ -146,6 +147,7 @@ message.
146
147
  | `ValidationError` | 400, e.g. `VALIDATION_FAILED`, `PRECISION_EXCEEDED` (see `fields`) |
147
148
  | `AuthenticationError` | 401, e.g. `UNAUTHENTICATED`, `INVALID_CREDENTIALS` |
148
149
  | `ForbiddenError` | 403: `FORBIDDEN` (key lacks a scope), `API_KEY_NOT_ALLOWED` (session-only endpoint) |
150
+ | `JurisdictionBlockedError` | 451: `JURISDICTION_BLOCKED` (not available in the caller's jurisdiction); a `ForbiddenError` subclass |
149
151
  | `NotFoundError` | 404 |
150
152
  | `ConflictError` | 409, e.g. `ALREADY_EXISTS`, `IDEMPOTENCY_KEY_CONFLICT` |
151
153
  | `UnprocessableError` | 422, e.g. `INSUFFICIENT_FUNDS`, `MARKET_UNAVAILABLE` |
@@ -283,6 +285,9 @@ client emits `auth_lost`; the socket stays open for public channels.
283
285
  - The SDK redacts keys from `repr()`, logs and exception messages, and never sends an
284
286
  `Authorization` header. If a server response echoes the key or secret, it is replaced
285
287
  with `***` in the exception's message, details, fields and request id.
288
+ - The SDK **never follows HTTP redirects**, even on an `http_client` you created with
289
+ `follow_redirects=True`. A 3xx answer raises `CexyApiError` with code `UNEXPECTED_REDIRECT`
290
+ (not retried), so keys are never re-sent to another host and an order is never re-posted.
286
291
  - Connections use `https://` and `wss://` only. `allow_insecure=True` permits `http://` or
287
292
  `ws://` for a loopback host (localhost, 127.0.0.1, ::1) only, for local testing.
288
293
  - Report vulnerabilities as described in [SECURITY.md](SECURITY.md).
@@ -8,12 +8,13 @@ Typed Python client for the [CEXY.io](https://cexy.io) exchange REST and WebSock
8
8
  - Client-side rate limiting, cursor pagination
9
9
  - WebSocket client with heartbeat, reconnect and a self-syncing order book
10
10
 
11
- > Status: **0.1.0.dev1, pre-release.** The API may change before 1.0 (see [Versioning](#versioning)).
11
+ > Status: **0.1.0.dev3, pre-release.** The API may change before 1.0 (see [Versioning](#versioning)).
12
+ > Pre-releases need `--pre`: `pip install --pre cexy`.
12
13
 
13
14
  ## Install
14
15
 
15
16
  ```bash
16
- pip install cexy # Python 3.9+
17
+ pip install --pre cexy # Python 3.9+ (--pre while releases are 0.1.0.devN)
17
18
  ```
18
19
 
19
20
  ## Quickstart: public market data
@@ -110,6 +111,7 @@ message.
110
111
  | `ValidationError` | 400, e.g. `VALIDATION_FAILED`, `PRECISION_EXCEEDED` (see `fields`) |
111
112
  | `AuthenticationError` | 401, e.g. `UNAUTHENTICATED`, `INVALID_CREDENTIALS` |
112
113
  | `ForbiddenError` | 403: `FORBIDDEN` (key lacks a scope), `API_KEY_NOT_ALLOWED` (session-only endpoint) |
114
+ | `JurisdictionBlockedError` | 451: `JURISDICTION_BLOCKED` (not available in the caller's jurisdiction); a `ForbiddenError` subclass |
113
115
  | `NotFoundError` | 404 |
114
116
  | `ConflictError` | 409, e.g. `ALREADY_EXISTS`, `IDEMPOTENCY_KEY_CONFLICT` |
115
117
  | `UnprocessableError` | 422, e.g. `INSUFFICIENT_FUNDS`, `MARKET_UNAVAILABLE` |
@@ -247,6 +249,9 @@ client emits `auth_lost`; the socket stays open for public channels.
247
249
  - The SDK redacts keys from `repr()`, logs and exception messages, and never sends an
248
250
  `Authorization` header. If a server response echoes the key or secret, it is replaced
249
251
  with `***` in the exception's message, details, fields and request id.
252
+ - The SDK **never follows HTTP redirects**, even on an `http_client` you created with
253
+ `follow_redirects=True`. A 3xx answer raises `CexyApiError` with code `UNEXPECTED_REDIRECT`
254
+ (not retried), so keys are never re-sent to another host and an order is never re-posted.
250
255
  - Connections use `https://` and `wss://` only. `allow_insecure=True` permits `http://` or
251
256
  `ws://` for a loopback host (localhost, 127.0.0.1, ::1) only, for local testing.
252
257
  - Report vulnerabilities as described in [SECURITY.md](SECURITY.md).
@@ -22,6 +22,7 @@ from cexy.errors import (
22
22
  ConfigurationError,
23
23
  ConflictError,
24
24
  ForbiddenError,
25
+ JurisdictionBlockedError,
25
26
  MissingCredentialsError,
26
27
  NotFoundError,
27
28
  RateLimitError,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "ConfigurationError",
45
46
  "ConflictError",
46
47
  "ForbiddenError",
48
+ "JurisdictionBlockedError",
47
49
  "HeaderKeyAuth",
48
50
  "MissingCredentialsError",
49
51
  "NotFoundError",
@@ -114,7 +114,12 @@ class AsyncTransport:
114
114
  if op.auth == "api_key" and self.auth is not None:
115
115
  self.auth.apply(op.method, url, send_headers, content)
116
116
  try:
117
- resp = await self.http.request(op.method, url, params=params, content=content, headers=send_headers)
117
+ # Never follow redirects, even on a caller-supplied client created with
118
+ # follow_redirects=True: httpx would re-send X-API-Key/X-API-Secret to the target
119
+ # (it strips only Authorization), and a 307/308 would re-POST an order.
120
+ resp = await self.http.request(
121
+ op.method, url, params=params, content=content, headers=send_headers, follow_redirects=False
122
+ )
118
123
  except httpx.TransportError as exc:
119
124
  ambiguous = not isinstance(exc, _NOT_SENT)
120
125
  reason = redact_text(f"{type(exc).__name__}: {exc}", self._secrets())
@@ -132,6 +137,14 @@ class AsyncTransport:
132
137
  resp_headers = lower_headers(resp.headers)
133
138
  self.limiter.update_from_headers(resp_headers)
134
139
  logger.debug("cexy: %s %s -> %d (attempt %d)", op.method, op.path, resp.status_code, attempt)
140
+ if 300 <= resp.status_code < 400:
141
+ raise CexyApiError(
142
+ "UNEXPECTED_REDIRECT",
143
+ f"{op.method} {op.path}: the server answered with a redirect (HTTP {resp.status_code}); "
144
+ "the SDK does not follow redirects. Check base_url.",
145
+ status=resp.status_code,
146
+ retryable=False,
147
+ )
135
148
  if resp.status_code < 400:
136
149
  if raw:
137
150
  return resp.content
@@ -116,7 +116,12 @@ class SyncTransport:
116
116
  if op.auth == "api_key" and self.auth is not None:
117
117
  self.auth.apply(op.method, url, send_headers, content)
118
118
  try:
119
- resp = self.http.request(op.method, url, params=params, content=content, headers=send_headers)
119
+ # Never follow redirects, even on a caller-supplied client created with
120
+ # follow_redirects=True: httpx would re-send X-API-Key/X-API-Secret to the target
121
+ # (it strips only Authorization), and a 307/308 would re-POST an order.
122
+ resp = self.http.request(
123
+ op.method, url, params=params, content=content, headers=send_headers, follow_redirects=False
124
+ )
120
125
  except httpx.TransportError as exc:
121
126
  ambiguous = not isinstance(exc, _NOT_SENT)
122
127
  reason = redact_text(f"{type(exc).__name__}: {exc}", self._secrets())
@@ -134,6 +139,14 @@ class SyncTransport:
134
139
  resp_headers = lower_headers(resp.headers)
135
140
  self.limiter.update_from_headers(resp_headers)
136
141
  logger.debug("cexy: %s %s -> %d (attempt %d)", op.method, op.path, resp.status_code, attempt)
142
+ if 300 <= resp.status_code < 400:
143
+ raise CexyApiError(
144
+ "UNEXPECTED_REDIRECT",
145
+ f"{op.method} {op.path}: the server answered with a redirect (HTTP {resp.status_code}); "
146
+ "the SDK does not follow redirects. Check base_url.",
147
+ status=resp.status_code,
148
+ retryable=False,
149
+ )
137
150
  if resp.status_code < 400:
138
151
  if raw:
139
152
  return resp.content
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev3"
@@ -81,6 +81,13 @@ class ForbiddenError(CexyApiError):
81
81
  (``API_KEY_NOT_ALLOWED``), or the account/region is restricted."""
82
82
 
83
83
 
84
+ class JurisdictionBlockedError(ForbiddenError):
85
+ """451: the service is not available in the caller's jurisdiction (``JURISDICTION_BLOCKED``).
86
+
87
+ A subclass of :class:`ForbiddenError`, so existing ``except ForbiddenError`` handlers still
88
+ catch it; catch it directly to show a dedicated message. Retrying will not help."""
89
+
90
+
84
91
  class ValidationError(CexyApiError):
85
92
  """400: the request failed validation. ``fields`` maps field names to problems."""
86
93
 
@@ -139,8 +146,8 @@ for _cls, _codes in {
139
146
  "ACCOUNT_ON_HOLD",
140
147
  "EMAIL_NOT_VERIFIED",
141
148
  "REGION_BLOCKED",
142
- "JURISDICTION_BLOCKED", # HTTP 451
143
149
  ),
150
+ JurisdictionBlockedError: ("JURISDICTION_BLOCKED",),
144
151
  NotFoundError: ("NOT_FOUND",),
145
152
  ConflictError: (
146
153
  "ALREADY_EXISTS",
@@ -170,7 +177,7 @@ _BY_STATUS: Dict[int, Type[CexyApiError]] = {
170
177
  400: ValidationError,
171
178
  401: AuthenticationError,
172
179
  403: ForbiddenError,
173
- 451: ForbiddenError, # unavailable for legal reasons (JURISDICTION_BLOCKED)
180
+ 451: JurisdictionBlockedError, # unavailable for legal reasons
174
181
  404: NotFoundError,
175
182
  409: ConflictError,
176
183
  422: UnprocessableError,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cexy"
7
- version = "0.1.0.dev1"
7
+ version = "0.1.0.dev3"
8
8
  description = "Python SDK for the CEXY.io exchange REST and WebSocket API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0.dev1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes