dccd 3.6.2__tar.gz → 3.6.3__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.
- {dccd-3.6.2 → dccd-3.6.3}/CHANGELOG.md +15 -0
- {dccd-3.6.2 → dccd-3.6.3}/PKG-INFO +1 -1
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/api/app.py +6 -1
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_api.py +9 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_transport.py +44 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/http.py +12 -4
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-3.6.2 → dccd-3.6.3}/pyproject.toml +1 -1
- {dccd-3.6.2 → dccd-3.6.3}/CLAUDE.md +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/CONTRIBUTING.md +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/LICENSE.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/MANIFEST.in +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/README.md +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/config.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/events.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/jobs.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/monitor.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/operations.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/registry.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/scheduler.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/application/service_factory.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/capability.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/dataset.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/errors.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/records.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/symbol.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/timeutils.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/transforms.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/types.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/api/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/cli/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/cli/main.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/favicon.svg +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/logo.svg +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/base.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/config.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/dashboard.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/data.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/historical.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/live.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/login.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/logs.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/storage.html +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/base.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/binance.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bitfinex.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bitmex.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bybit.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/coinbase.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/kraken.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/okx.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/registry.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/coverage_sqlite.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/parquet.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/purge.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/remote.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/runs_sqlite.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_adapter_parsing.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_application.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_backfill_lookback.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_cli.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_client.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_coverage.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_domain.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_domain_extended.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_monitor_webhook.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_network.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_purge.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_ratelimit.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_remote_sync.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_restart.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_restore.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_sources.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_storage.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_storage_extended.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_end_state.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_flush.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_nocapability.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/__init__.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/paginate.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/ratelimit.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/ws.py +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/SOURCES.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/requires.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/top_level.txt +0 -0
- {dccd-3.6.2 → dccd-3.6.3}/setup.cfg +0 -0
|
@@ -16,6 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
### Removed
|
|
18
18
|
|
|
19
|
+
## [3.6.3] - 2026-06-20
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `POST /login` no longer returns a 500 on a non-ASCII submitted token:
|
|
24
|
+
`secrets.compare_digest` raises `TypeError` on a `str` with non-ASCII
|
|
25
|
+
characters, so the handler now compares UTF-8 bytes — a junk token (scanners POST
|
|
26
|
+
arbitrary bytes) is rejected cleanly with the usual invalid-login response. (#174)
|
|
27
|
+
- `AsyncHTTPClient.__aexit__` now nulls the shared client reference **before**
|
|
28
|
+
awaiting `aclose()`, closing a reference-count race: `aclose()` yields, and a
|
|
29
|
+
concurrent `__aenter__` during that await previously reused the closing client,
|
|
30
|
+
so a scheduled backfill could fail with `Cannot send a request, as the client
|
|
31
|
+
has been closed` (observed ~1×/day across exchanges). The re-entrant user now
|
|
32
|
+
builds a fresh client instead. (#173)
|
|
33
|
+
|
|
19
34
|
## [3.6.2] - 2026-06-20
|
|
20
35
|
|
|
21
36
|
### Fixed
|
|
@@ -918,7 +918,12 @@ def create_app(
|
|
|
918
918
|
fields = parse_qs((await request.body()).decode("utf-8", "replace"))
|
|
919
919
|
submitted = (fields.get("token") or [""])[0]
|
|
920
920
|
nxt = _safe_next((fields.get("next") or ["/"])[0])
|
|
921
|
-
|
|
921
|
+
# Compare UTF-8 bytes: ``compare_digest`` raises ``TypeError`` on a
|
|
922
|
+
# ``str`` containing non-ASCII characters, so a junk token (scanners
|
|
923
|
+
# POST arbitrary bytes) must be rejected cleanly, not 500.
|
|
924
|
+
if not token or not secrets.compare_digest(
|
|
925
|
+
submitted.encode("utf-8"), token.encode("utf-8")
|
|
926
|
+
):
|
|
922
927
|
try:
|
|
923
928
|
ver = _pkg_version("dccd")
|
|
924
929
|
except Exception:
|
|
@@ -121,6 +121,15 @@ class TestAuthSession:
|
|
|
121
121
|
)
|
|
122
122
|
assert r.status_code == 401
|
|
123
123
|
|
|
124
|
+
def test_login_non_ascii_token_rejected_not_500(self, auth_client):
|
|
125
|
+
# A non-ASCII submitted token must be rejected cleanly (401), never crash
|
|
126
|
+
# with a 500 — ``secrets.compare_digest`` raises TypeError on non-ASCII
|
|
127
|
+
# ``str``; the handler compares UTF-8 bytes instead.
|
|
128
|
+
r = auth_client.post(
|
|
129
|
+
"/login", data={"token": "hé€llo", "next": "/"}, follow_redirects=False
|
|
130
|
+
)
|
|
131
|
+
assert r.status_code == 401
|
|
132
|
+
|
|
124
133
|
def test_login_sets_httponly_lax_cookie_and_grants_access(self, cfg):
|
|
125
134
|
with TestClient(create_app(config=cfg)) as c:
|
|
126
135
|
r = c.post(
|
|
@@ -40,6 +40,50 @@ async def test_concurrent_context_does_not_close_client_early():
|
|
|
40
40
|
assert http._client is None
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_reentry_during_close_gets_fresh_client():
|
|
45
|
+
"""A ``__aenter__`` while a previous ``__aexit__`` is awaiting ``aclose()``
|
|
46
|
+
must observe a FRESH client, never the closing one.
|
|
47
|
+
|
|
48
|
+
Reproduces the refcount race behind the production "Cannot send a request,
|
|
49
|
+
as the client has been closed" failures: ``__aexit__`` must null
|
|
50
|
+
``self._client`` *before* awaiting ``aclose()`` (which yields), otherwise the
|
|
51
|
+
re-entrant user reuses the dying client. A fake client whose ``aclose()``
|
|
52
|
+
yields lets us interleave deterministically.
|
|
53
|
+
"""
|
|
54
|
+
created: list[Any] = []
|
|
55
|
+
|
|
56
|
+
class FakeClient:
|
|
57
|
+
def __init__(self, **_: Any) -> None:
|
|
58
|
+
self.closed = False
|
|
59
|
+
created.append(self)
|
|
60
|
+
|
|
61
|
+
async def aclose(self) -> None:
|
|
62
|
+
await asyncio.sleep(0) # yield so a concurrent __aenter__ can run
|
|
63
|
+
self.closed = True
|
|
64
|
+
|
|
65
|
+
with patch("httpx.AsyncClient", FakeClient):
|
|
66
|
+
http = AsyncHTTPClient()
|
|
67
|
+
await http.__aenter__() # depth 1, client = fake #0
|
|
68
|
+
first = http._client
|
|
69
|
+
assert first is created[0]
|
|
70
|
+
|
|
71
|
+
# Start the exit (depth -> 0): it nulls the ref then awaits aclose().
|
|
72
|
+
exit_task = asyncio.create_task(http.__aexit__())
|
|
73
|
+
await asyncio.sleep(0) # let __aexit__ reach the aclose() await
|
|
74
|
+
|
|
75
|
+
# Re-enter concurrently: must build a fresh, open client.
|
|
76
|
+
await http.__aenter__()
|
|
77
|
+
observed = http._client
|
|
78
|
+
assert observed is not None
|
|
79
|
+
assert not observed.closed # the re-entrant user sees a live client
|
|
80
|
+
assert observed is not first # and it is NOT the one being closed
|
|
81
|
+
|
|
82
|
+
await exit_task
|
|
83
|
+
assert first.closed # the original client still got closed
|
|
84
|
+
await http.__aexit__()
|
|
85
|
+
|
|
86
|
+
|
|
43
87
|
@pytest.mark.asyncio
|
|
44
88
|
async def test_operation_level_context_one_pool_per_backfill(monkeypatch):
|
|
45
89
|
"""Operation-level ``async with adapter._http`` creates ONE httpx.AsyncClient.
|
|
@@ -72,8 +72,9 @@ class AsyncHTTPClient:
|
|
|
72
72
|
# one) the first to finish would otherwise close the shared httpx client
|
|
73
73
|
# mid-flight for the other ("Cannot send a request, as the client has
|
|
74
74
|
# been closed"). Reference-count the context so the client is created on
|
|
75
|
-
# first entry and closed only when the last concurrent user exits.
|
|
76
|
-
#
|
|
75
|
+
# first entry and closed only when the last concurrent user exits. The
|
|
76
|
+
# counter is mutated without intervening awaits, but ``__aexit__`` must
|
|
77
|
+
# still null the reference *before* awaiting ``aclose()`` (see there).
|
|
77
78
|
self._depth = 0
|
|
78
79
|
|
|
79
80
|
async def __aenter__(self) -> "AsyncHTTPClient":
|
|
@@ -88,10 +89,17 @@ class AsyncHTTPClient:
|
|
|
88
89
|
|
|
89
90
|
async def __aexit__(self, *args: Any) -> None:
|
|
90
91
|
self._depth -= 1
|
|
91
|
-
if self._depth <= 0 and self._client:
|
|
92
|
+
if self._depth <= 0 and self._client is not None:
|
|
93
|
+
# Ordering matters: null the shared reference BEFORE awaiting
|
|
94
|
+
# ``aclose()``. ``aclose()`` yields, and a concurrent ``__aenter__``
|
|
95
|
+
# during that await would otherwise see the still-set (closing)
|
|
96
|
+
# client, skip creation, bump the depth and ``get()`` a dead client
|
|
97
|
+
# ("Cannot send a request, as the client has been closed"). Nulling
|
|
98
|
+
# first makes that re-entry build a FRESH client instead.
|
|
92
99
|
self._depth = 0
|
|
93
|
-
|
|
100
|
+
client = self._client
|
|
94
101
|
self._client = None
|
|
102
|
+
await client.aclose()
|
|
95
103
|
|
|
96
104
|
async def get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
|
97
105
|
"""Perform a GET request with retry/backoff. Returns parsed JSON."""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|