tardis-dev 3.1.0__tar.gz → 3.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tardis-dev
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Python client for tardis.dev - historical tick-level cryptocurrency market data replay API.
5
5
  Home-page: https://github.com/tardis-dev/tardis-python
6
6
  License: MPL-2.0
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
19
19
  Requires-Dist: aiohttp (>=3.10.0)
20
20
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
21
+ Requires-Dist: zstandard (>=0.19.0)
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  # tardis-dev
@@ -9,7 +9,7 @@ markers = [
9
9
 
10
10
  [tool.poetry]
11
11
  name = "tardis-dev"
12
- version = "3.1.0"
12
+ version = "3.2.0"
13
13
  description = "Python client for tardis.dev - historical tick-level cryptocurrency market data replay API."
14
14
  readme = "README.md"
15
15
  homepage = "https://github.com/tardis-dev/tardis-python"
@@ -30,6 +30,7 @@ python = ">=3.9,<4.0"
30
30
  aiohttp = ">=3.10.0"
31
31
  python-dateutil = "^2.8.2"
32
32
  aiofiles = "^24.1.0"
33
+ zstandard = ">=0.19.0"
33
34
 
34
35
  [tool.poetry.group.dev.dependencies]
35
36
  pytest = "^8.3.5"
@@ -14,11 +14,11 @@ import aiohttp
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- async def create_session(api_key: str, timeout: int) -> aiohttp.ClientSession:
17
+ async def create_session(api_key: str, timeout: int, accept_encoding: str = "gzip") -> aiohttp.ClientSession:
18
18
  from tardis_dev import __version__
19
19
 
20
20
  headers = {
21
- "Accept-Encoding": "gzip",
21
+ "Accept-Encoding": accept_encoding,
22
22
  "User-Agent": f"tardis-dev/{__version__} (+https://github.com/tardis-dev/tardis-python)",
23
23
  }
24
24
  if api_key:
@@ -38,14 +38,20 @@ async def reliable_download(
38
38
  dest_path: str,
39
39
  http_proxy: Optional[str] = None,
40
40
  max_attempts: int = 30,
41
- ) -> None:
41
+ append_content_encoding_extension: bool = False,
42
+ ) -> str:
42
43
  attempts = 0
43
44
 
44
45
  while True:
45
46
  attempts += 1
46
47
  try:
47
- await _download(session, _get_retry_url(url, attempts), dest_path, http_proxy)
48
- return
48
+ return await _download(
49
+ session,
50
+ _get_retry_url(url, attempts),
51
+ dest_path,
52
+ http_proxy,
53
+ append_content_encoding_extension=append_content_encoding_extension,
54
+ )
49
55
  except asyncio.CancelledError:
50
56
  raise
51
57
  except Exception as exc:
@@ -99,12 +105,30 @@ async def _download(
99
105
  url: str,
100
106
  dest_path: str,
101
107
  http_proxy: Optional[str],
102
- ) -> None:
108
+ *,
109
+ append_content_encoding_extension: bool,
110
+ ) -> str:
103
111
  async with session.get(url, proxy=http_proxy) as response:
104
112
  if response.status != 200:
105
113
  error_text = await response.text()
106
114
  raise urllib.error.HTTPError(url, code=response.status, msg=error_text, hdrs=None, fp=None)
107
115
 
116
+ final_path = dest_path
117
+ if append_content_encoding_extension:
118
+ content_encoding = response.headers.get("Content-Encoding")
119
+ if content_encoding == "zstd":
120
+ final_path = f"{dest_path}.zst"
121
+ elif content_encoding == "gzip":
122
+ final_path = f"{dest_path}.gz"
123
+ else:
124
+ raise urllib.error.HTTPError(
125
+ url,
126
+ code=400,
127
+ msg=f"Unsupported data feed content encoding: {content_encoding}",
128
+ hdrs=None,
129
+ fp=None,
130
+ )
131
+
108
132
  pathlib.Path(dest_path).parent.mkdir(parents=True, exist_ok=True)
109
133
  temp_path = f"{dest_path}{secrets.token_hex(8)}.unconfirmed"
110
134
 
@@ -113,7 +137,8 @@ async def _download(
113
137
  async for chunk in response.content.iter_any():
114
138
  await temp_file.write(chunk)
115
139
 
116
- os.replace(temp_path, dest_path)
140
+ os.replace(temp_path, final_path)
141
+ return final_path
117
142
  finally:
118
143
  if os.path.exists(temp_path):
119
144
  os.remove(temp_path)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import gzip
3
3
  import hashlib
4
+ import io
4
5
  import json as json_module
5
6
  import logging
6
7
  import os
@@ -11,6 +12,7 @@ from time import time
11
12
  from typing import Any, AsyncIterator, Dict, List, NamedTuple, Optional, Sequence, Union
12
13
 
13
14
  import dateutil.parser
15
+ import zstandard
14
16
 
15
17
  from tardis_dev._http import create_session, reliable_download
16
18
  from tardis_dev._options import DEFAULT_CACHE_DIR, DEFAULT_ENDPOINT
@@ -84,7 +86,15 @@ async def replay(
84
86
 
85
87
  while current_slice_path is None:
86
88
  await asyncio.sleep(0)
87
- path_to_check = _get_slice_cache_path(
89
+ zstd_path = _get_slice_cache_path(
90
+ cache_dir,
91
+ exchange,
92
+ current_slice_date,
93
+ normalized_filters,
94
+ filters_hash=filters_hash,
95
+ content_encoding="zstd",
96
+ )
97
+ gzip_path = _get_slice_cache_path(
88
98
  cache_dir,
89
99
  exchange,
90
100
  current_slice_date,
@@ -95,26 +105,27 @@ async def replay(
95
105
  if fetch_data_task.done() and fetch_data_task.exception():
96
106
  raise fetch_data_task.exception()
97
107
 
98
- if os.path.isfile(path_to_check):
99
- current_slice_path = path_to_check
108
+ if os.path.isfile(zstd_path):
109
+ current_slice_path = zstd_path
110
+ elif os.path.isfile(gzip_path):
111
+ current_slice_path = gzip_path
100
112
  else:
101
113
  await asyncio.sleep(0.1)
102
114
 
103
- with gzip.open(current_slice_path, "rb") as file:
104
- for line in file:
105
- if len(line) <= 1:
106
- if with_disconnects and not last_message_was_disconnect:
107
- last_message_was_disconnect = True
108
- yield None
109
- continue
115
+ for line in _iterate_slice_lines(current_slice_path):
116
+ if len(line) <= 1:
117
+ if with_disconnects and not last_message_was_disconnect:
118
+ last_message_was_disconnect = True
119
+ yield None
120
+ continue
110
121
 
111
- last_message_was_disconnect = False
122
+ last_message_was_disconnect = False
112
123
 
113
- if decode_response:
114
- timestamp = datetime.fromisoformat(line[0 : DATE_MESSAGE_SPLIT_INDEX - 2].decode("utf-8"))
115
- yield Response(timestamp, json.loads(line[DATE_MESSAGE_SPLIT_INDEX + 1 :]))
116
- else:
117
- yield Response(line[0:DATE_MESSAGE_SPLIT_INDEX], line[DATE_MESSAGE_SPLIT_INDEX + 1 :])
124
+ if decode_response:
125
+ timestamp = datetime.fromisoformat(line[0 : DATE_MESSAGE_SPLIT_INDEX - 2].decode("utf-8"))
126
+ yield Response(timestamp, json.loads(line[DATE_MESSAGE_SPLIT_INDEX + 1 :]))
127
+ else:
128
+ yield Response(line[0:DATE_MESSAGE_SPLIT_INDEX], line[DATE_MESSAGE_SPLIT_INDEX + 1 :])
118
129
 
119
130
  if auto_cleanup:
120
131
  _remove_processed_slice(current_slice_path)
@@ -167,7 +178,7 @@ async def _fetch_data_to_replay(
167
178
  if minutes_diff <= 0:
168
179
  return
169
180
 
170
- async with await create_session(api_key, timeout) as session:
181
+ async with await create_session(api_key, timeout, "zstd, gzip") as session:
171
182
  fetch_data_tasks = set()
172
183
  try:
173
184
  prefetch_offsets = [minutes_diff - 1]
@@ -231,9 +242,23 @@ async def _fetch_slice_if_not_cached(
231
242
  filters_hash: str,
232
243
  ) -> None:
233
244
  slice_date = from_date + timedelta(minutes=offset)
234
- cache_path = _get_slice_cache_path(cache_dir, exchange, slice_date, filters, filters_hash=filters_hash)
245
+ cache_zstd_path = _get_slice_cache_path(
246
+ cache_dir,
247
+ exchange,
248
+ slice_date,
249
+ filters,
250
+ filters_hash=filters_hash,
251
+ content_encoding="zstd",
252
+ )
253
+ cache_gzip_path = _get_slice_cache_path(
254
+ cache_dir,
255
+ exchange,
256
+ slice_date,
257
+ filters,
258
+ filters_hash=filters_hash,
259
+ )
235
260
 
236
- if os.path.isfile(cache_path):
261
+ if os.path.isfile(cache_zstd_path) or os.path.isfile(cache_gzip_path):
237
262
  return
238
263
 
239
264
  fetch_url = f"{endpoint}/data-feeds/{exchange}?from={_format_replay_query_date(from_date)}&offset={offset}"
@@ -242,11 +267,14 @@ async def _fetch_slice_if_not_cached(
242
267
  filters_url_encoded = urllib.parse.quote(filters_serialized, safe="~()*!.'")
243
268
  fetch_url += f"&filters={filters_url_encoded}"
244
269
 
270
+ cache_base_path = cache_gzip_path.removesuffix(".gz")
271
+
245
272
  await reliable_download(
246
273
  session=session,
247
274
  url=fetch_url,
248
- dest_path=cache_path,
275
+ dest_path=cache_base_path,
249
276
  http_proxy=http_proxy,
277
+ append_content_encoding_extension=True,
250
278
  )
251
279
 
252
280
 
@@ -341,16 +369,14 @@ def _get_slice_cache_path(
341
369
  filters: Optional[Sequence[Channel]],
342
370
  *,
343
371
  filters_hash: Optional[str] = None,
372
+ content_encoding: Optional[str] = None,
344
373
  ) -> str:
345
- return (
346
- os.path.join(
347
- cache_dir,
348
- "feeds",
349
- exchange,
350
- filters_hash if filters_hash is not None else _get_filters_hash(filters),
351
- _format_date_to_path(date),
352
- )
353
- + ".json.gz"
374
+ return os.path.join(
375
+ cache_dir,
376
+ "feeds",
377
+ exchange,
378
+ filters_hash if filters_hash is not None else _get_filters_hash(filters),
379
+ f"{_format_date_to_path(date)}.json{'.zst' if content_encoding == 'zstd' else '.gz'}",
354
380
  )
355
381
 
356
382
 
@@ -405,6 +431,18 @@ def _remove_processed_slice(path: str) -> None:
405
431
  os.remove(path)
406
432
 
407
433
 
434
+ def _iterate_slice_lines(path: str):
435
+ if path.endswith(".zst"):
436
+ with open(path, "rb") as compressed_file:
437
+ with zstandard.ZstdDecompressor().stream_reader(compressed_file) as file:
438
+ with io.BufferedReader(file) as buffered_file:
439
+ yield from buffered_file
440
+ return
441
+
442
+ with gzip.open(path, "rb") as file:
443
+ yield from file
444
+
445
+
408
446
  def _clear_replay_cache_range(
409
447
  *,
410
448
  cache_dir: str,
File without changes
File without changes