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.
Files changed (106) hide show
  1. {dccd-3.6.2 → dccd-3.6.3}/CHANGELOG.md +15 -0
  2. {dccd-3.6.2 → dccd-3.6.3}/PKG-INFO +1 -1
  3. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/api/app.py +6 -1
  4. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_api.py +9 -0
  5. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_transport.py +44 -0
  6. {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/http.py +12 -4
  7. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/PKG-INFO +1 -1
  8. {dccd-3.6.2 → dccd-3.6.3}/pyproject.toml +1 -1
  9. {dccd-3.6.2 → dccd-3.6.3}/CLAUDE.md +0 -0
  10. {dccd-3.6.2 → dccd-3.6.3}/CONTRIBUTING.md +0 -0
  11. {dccd-3.6.2 → dccd-3.6.3}/LICENSE.txt +0 -0
  12. {dccd-3.6.2 → dccd-3.6.3}/MANIFEST.in +0 -0
  13. {dccd-3.6.2 → dccd-3.6.3}/README.md +0 -0
  14. {dccd-3.6.2 → dccd-3.6.3}/dccd/__init__.py +0 -0
  15. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/__init__.py +0 -0
  16. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/config.py +0 -0
  17. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/events.py +0 -0
  18. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/jobs.py +0 -0
  19. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/monitor.py +0 -0
  20. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/operations.py +0 -0
  21. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/registry.py +0 -0
  22. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/scheduler.py +0 -0
  23. {dccd-3.6.2 → dccd-3.6.3}/dccd/application/service_factory.py +0 -0
  24. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/__init__.py +0 -0
  25. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/capability.py +0 -0
  26. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/dataset.py +0 -0
  27. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/errors.py +0 -0
  28. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/records.py +0 -0
  29. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/symbol.py +0 -0
  30. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/timeutils.py +0 -0
  31. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/transforms.py +0 -0
  32. {dccd-3.6.2 → dccd-3.6.3}/dccd/domain/types.py +0 -0
  33. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/__init__.py +0 -0
  34. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/api/__init__.py +0 -0
  35. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/cli/__init__.py +0 -0
  36. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/cli/main.py +0 -0
  37. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/__init__.py +0 -0
  38. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/favicon.svg +0 -0
  39. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
  40. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
  41. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
  42. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
  43. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
  44. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
  45. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/static/logo.svg +0 -0
  46. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/base.html +0 -0
  47. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/config.html +0 -0
  48. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/dashboard.html +0 -0
  49. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/data.html +0 -0
  50. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/historical.html +0 -0
  51. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/live.html +0 -0
  52. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/login.html +0 -0
  53. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/logs.html +0 -0
  54. {dccd-3.6.2 → dccd-3.6.3}/dccd/interfaces/ui/templates/storage.html +0 -0
  55. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/__init__.py +0 -0
  56. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/base.py +0 -0
  57. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/binance.py +0 -0
  58. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bitfinex.py +0 -0
  59. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bitmex.py +0 -0
  60. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/bybit.py +0 -0
  61. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/coinbase.py +0 -0
  62. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/kraken.py +0 -0
  63. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/okx.py +0 -0
  64. {dccd-3.6.2 → dccd-3.6.3}/dccd/sources/registry.py +0 -0
  65. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/__init__.py +0 -0
  66. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/coverage_sqlite.py +0 -0
  67. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/parquet.py +0 -0
  68. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/purge.py +0 -0
  69. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/remote.py +0 -0
  70. {dccd-3.6.2 → dccd-3.6.3}/dccd/storage/runs_sqlite.py +0 -0
  71. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/__init__.py +0 -0
  72. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/__init__.py +0 -0
  73. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_adapter_parsing.py +0 -0
  74. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_application.py +0 -0
  75. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_backfill_lookback.py +0 -0
  76. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_cli.py +0 -0
  77. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_client.py +0 -0
  78. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_coverage.py +0 -0
  79. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_domain.py +0 -0
  80. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_domain_extended.py +0 -0
  81. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_monitor_webhook.py +0 -0
  82. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_network.py +0 -0
  83. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
  84. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_purge.py +0 -0
  85. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_ratelimit.py +0 -0
  86. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_remote_sync.py +0 -0
  87. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_restart.py +0 -0
  88. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_restore.py +0 -0
  89. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
  90. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_sources.py +0 -0
  91. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_storage.py +0 -0
  92. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_storage_extended.py +0 -0
  93. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_end_state.py +0 -0
  94. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_flush.py +0 -0
  95. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_stream_nocapability.py +0 -0
  96. {dccd-3.6.2 → dccd-3.6.3}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
  97. {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/__init__.py +0 -0
  98. {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/paginate.py +0 -0
  99. {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/ratelimit.py +0 -0
  100. {dccd-3.6.2 → dccd-3.6.3}/dccd/transport/ws.py +0 -0
  101. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/SOURCES.txt +0 -0
  102. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/dependency_links.txt +0 -0
  103. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/entry_points.txt +0 -0
  104. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/requires.txt +0 -0
  105. {dccd-3.6.2 → dccd-3.6.3}/dccd.egg-info/top_level.txt +0 -0
  106. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.6.2
3
+ Version: 3.6.3
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -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
- if not token or not secrets.compare_digest(submitted, token):
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. Safe
76
- # under asyncio: the counter is mutated without intervening awaits.
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
- await self._client.aclose()
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."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.6.2
3
+ Version: 3.6.3
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dccd"
7
- version = "3.6.2"
7
+ version = "3.6.3"
8
8
  description = "Download Crypto Currency Data — hexagonal architecture, async-first."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
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