dataquery-sdk 1.2.0__tar.gz → 1.2.1__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 (49) hide show
  1. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/CHANGELOG.md +4 -0
  2. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/PKG-INFO +1 -1
  3. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/__init__.py +3 -1
  4. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/cli.py +4 -4
  5. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/env.py +1 -1
  6. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/download.py +9 -2
  7. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/_mixins.py +1 -1
  8. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/client.py +22 -27
  9. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/dataquery.py +18 -9
  10. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/parallel.py +23 -1
  11. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/client.py +14 -1
  12. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/subscriber.py +78 -2
  13. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/auth.py +3 -3
  14. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/models.py +47 -15
  15. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/utils.py +292 -28
  16. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/PKG-INFO +1 -1
  17. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/CONTRIBUTING.md +0 -0
  18. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/LICENSE +0 -0
  19. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/MANIFEST.in +0 -0
  20. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/README.md +0 -0
  21. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/__init__.py +0 -0
  22. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/logging.py +0 -0
  23. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/__init__.py +0 -0
  24. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/api.py +0 -0
  25. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/rate_limit.py +0 -0
  26. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/sse.py +0 -0
  27. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/__init__.py +0 -0
  28. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/_sync.py +0 -0
  29. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/data/function.json +0 -0
  30. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/__init__.py +0 -0
  31. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/utils.py +0 -0
  32. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/export.py +0 -0
  33. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/function_registry.py +0 -0
  34. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/py.typed +0 -0
  35. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/__init__.py +0 -0
  36. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/event_store.py +0 -0
  37. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/__init__.py +0 -0
  38. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/connection_pool.py +0 -0
  39. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/rate_limiter.py +0 -0
  40. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/retry.py +0 -0
  41. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/__init__.py +0 -0
  42. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/exceptions.py +0 -0
  43. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/SOURCES.txt +0 -0
  44. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/dependency_links.txt +0 -0
  45. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/entry_points.txt +0 -0
  46. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/requires.txt +0 -0
  47. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/top_level.txt +0 -0
  48. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/pyproject.toml +0 -0
  49. {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/setup.cfg +0 -0
@@ -40,5 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
40
40
  - SSE: jittered reconnect backoff to avoid synchronized reconnect storms; stop reconnecting on fatal 403/404 and bound retries on 401; idle `sock_read` timeout distinguished from the heartbeat watchdog; honor the server `retry:` hint; strip a leading UTF-8 BOM; larger read buffer guards against `LineTooLong`; `stop()` is await-safe under concurrent callers
41
41
  ## [1.2.0] - 2026-06-29
42
42
  - DataQuery functions: new `function-help` command for local lookup of all 158 DQ function syntaxes, parameters, and categories (no API call); backed by a static, frozen `dataquery/data/function.json` dataset
43
+ ## [1.2.1] - 2026-07-14
44
+ - Written research: new `download_zip_async` helper that downloads a group over a date range (split into calendar-month windows to fit the available-files endpoint limit) and safely extracts ZIP archives as each download completes, overlapping unzip with in-flight downloads
45
+ - Group downloads: `run_group_download_async` accepts an `on_file_complete` async callback awaited per file on `completed`/`already_exists`
46
+ - Extraction is Zip Slip-guarded, skips current-day archives, and surfaces failures via `extraction_errors` (downgrading overall status to `partial`); date windows with no available files no longer mark a multi-window run as `partial`
43
47
 
44
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataquery-sdk
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Python SDK for DATAQUERY Data API - Query, download, and check availability of economic data files
5
5
  Author-email: DATAQUERY SDK Team <dataquery_support@jpmorgan.com>
6
6
  Project-URL: Homepage, https://github.com/dataquery/dataquery-sdk
@@ -14,7 +14,7 @@ Quick Start:
14
14
  For more information, visit: https://github.com/dataquery/dataquery-sdk
15
15
  """
16
16
 
17
- __version__ = "1.2.0"
17
+ __version__ = "1.2.1"
18
18
  __author__ = "DATAQUERY SDK Team"
19
19
  __email__ = "dataquery_support@jpmorgan.com"
20
20
  __license__ = "MIT"
@@ -63,6 +63,7 @@ from .types.models import (
63
63
  Group,
64
64
  GroupList,
65
65
  )
66
+ from .utils import download_zip_async
66
67
 
67
68
  __all__ = [
68
69
  "DataQuery",
@@ -103,6 +104,7 @@ __all__ = [
103
104
  "get_function_registry",
104
105
  "list_functions_by_category",
105
106
  "lookup_function",
107
+ "download_zip_async",
106
108
  ]
107
109
 
108
110
  __version_info__ = tuple(int(x) for x in __version__.split("."))
@@ -183,7 +183,7 @@ def create_parser() -> argparse.ArgumentParser:
183
183
  _ = auth_sub.add_parser("test", help="Test authentication by listing groups")
184
184
 
185
185
  p_search = subparsers.add_parser(
186
- "text-search",
186
+ "search",
187
187
  help="Search the DataQuery catalog using a natural-language query (POST /search)",
188
188
  )
189
189
  p_search.add_argument("--query", required=True, help="Free-text search query")
@@ -483,9 +483,9 @@ async def cmd_auth_test(args: argparse.Namespace) -> int:
483
483
  return 0
484
484
 
485
485
 
486
- async def cmd_text_search(args: argparse.Namespace) -> int:
486
+ async def cmd_search(args: argparse.Namespace) -> int:
487
487
  async with DataQuery(args.env_file) as dq:
488
- result = await dq.text_search_async(args.query)
488
+ result = await dq.search_async(args.query)
489
489
 
490
490
  if args.json:
491
491
  print(json.dumps(result, indent=2))
@@ -734,7 +734,7 @@ _ASYNC_COMMANDS = {
734
734
  "availability": cmd_availability,
735
735
  "download": cmd_download,
736
736
  "download-group": cmd_download_group,
737
- "text-search": cmd_text_search,
737
+ "search": cmd_search,
738
738
  "groups-search": cmd_groups_search,
739
739
  "instruments": cmd_instruments,
740
740
  "instruments-search": cmd_instruments_search,
@@ -247,7 +247,7 @@ class EnvConfig:
247
247
  if config.oauth_enabled:
248
248
  if not config.client_id:
249
249
  errors.append("CLIENT_ID is required when OAuth is enabled")
250
- if not config.client_secret:
250
+ if not config.get_client_secret():
251
251
  errors.append("CLIENT_SECRET is required when OAuth is enabled")
252
252
  if not config.oauth_token_url:
253
253
  errors.append("OAUTH_TOKEN_URL is required when OAuth is enabled")
@@ -28,6 +28,13 @@ MBPS_TO_BYTES_PER_SECOND = 125_000
28
28
 
29
29
  # Default size, in days, of each chunk when splitting a date range so that
30
30
  # the available-files endpoint is queried over a smaller window per call.
31
- # Used by ``run_group_download_chunked_async`` to honour group-level
32
- # date-range limits (e.g. RESEARCH_EQUITY_ALL is restricted to 14 days).
31
+ # Used by ``split_date_range_into_chunks``. The endpoint caps each call at
32
+ # one calendar month; smaller windows just keep responses lighter. (The old
33
+ # group-level limits, e.g. 14 days for RESEARCH_EQUITY_ALL, were lifted
34
+ # server-side.)
33
35
  DEFAULT_WRITTEN_RESEARCH_CHUNK_DAYS: int = 7
36
+
37
+ # Error string set on the OperationReport when the available-files endpoint
38
+ # returns nothing for a date window. Chunked workflows match on it to tell
39
+ # a quiet window apart from a real failure.
40
+ NO_FILES_FOUND_ERROR: str = "No available files found for date range"
@@ -609,7 +609,7 @@ class GridMixin(_RequestProto):
609
609
  class SearchMixin(_RequestProto):
610
610
  """Natural-language catalog search via POST /search."""
611
611
 
612
- async def text_search_async(self, query: str) -> Dict[str, Any]:
612
+ async def search_async(self, query: str) -> Dict[str, Any]:
613
613
  """Search the DataQuery catalog using a natural-language query.
614
614
 
615
615
  POSTs ``{"query": query}`` to ``/search`` and returns the parsed JSON body.
@@ -181,7 +181,7 @@ class DataQueryClient(
181
181
  raise ConfigurationError("Invalid base_url format")
182
182
 
183
183
  if strict_oauth_check and self.config.oauth_enabled:
184
- if not self.config.client_id or not self.config.client_secret:
184
+ if not self.config.client_id or not self.config.get_client_secret():
185
185
  raise ConfigurationError("client_id and client_secret are required")
186
186
 
187
187
  if not self.auth_manager.is_authenticated():
@@ -494,33 +494,28 @@ class DataQueryClient(
494
494
  if self.session is None:
495
495
  raise NetworkError("Failed to establish connection")
496
496
 
497
- try:
498
- response = await self.session.request(method, url, **kwargs)
499
-
500
- # Raise 429 inside the retry scope so the retry manager backs off and retries.
501
- if response.status == 429:
502
- self.rate_limiter.handle_rate_limit_response(dict(response.headers))
503
- retry_after = self._parse_retry_after(response.headers)
504
- # Drain body so the connection can be reused; ignore decode errors.
505
- try:
506
- await response.text()
507
- except (UnicodeDecodeError, aiohttp.ClientPayloadError):
508
- pass
509
- raise RateLimitError(f"Rate limit exceeded: {response.status}", retry_after=retry_after)
510
-
511
- if response.status >= 500:
512
- # Drain body so the connection can be reused; ignore decode errors.
513
- try:
514
- await response.text()
515
- except (UnicodeDecodeError, aiohttp.ClientPayloadError):
516
- pass
517
- raise NetworkError(f"Server error: {response.status}", status_code=response.status)
497
+ response = await self.session.request(method, url, **kwargs)
518
498
 
519
- return response
520
- except (NetworkError, RateLimitError):
521
- raise
522
- except Exception:
523
- raise
499
+ # Raise 429 inside the retry scope so the retry manager backs off and retries.
500
+ if response.status == 429:
501
+ self.rate_limiter.handle_rate_limit_response(dict(response.headers))
502
+ retry_after = self._parse_retry_after(response.headers)
503
+ # Drain body so the connection can be reused; ignore decode errors.
504
+ try:
505
+ await response.text()
506
+ except (UnicodeDecodeError, aiohttp.ClientPayloadError):
507
+ pass
508
+ raise RateLimitError(f"Rate limit exceeded: {response.status}", retry_after=retry_after)
509
+
510
+ if response.status >= 500:
511
+ # Drain body so the connection can be reused; ignore decode errors.
512
+ try:
513
+ await response.text()
514
+ except (UnicodeDecodeError, aiohttp.ClientPayloadError):
515
+ pass
516
+ raise NetworkError(f"Server error: {response.status}", status_code=response.status)
517
+
518
+ return response
524
519
 
525
520
  async def list_groups_async(self, limit: Optional[int] = None) -> List[Group]:
526
521
  """
@@ -10,12 +10,14 @@ import time
10
10
  from calendar import monthrange
11
11
  from datetime import date, datetime
12
12
  from pathlib import Path
13
- from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
13
+ from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple, Union
14
14
 
15
15
  import structlog
16
16
  from dotenv import load_dotenv
17
+ from pydantic import SecretStr
17
18
 
18
19
  from .config import EnvConfig
20
+ from .constants.download import NO_FILES_FOUND_ERROR
19
21
  from .core._sync import SyncRunner
20
22
  from .core.client import DataQueryClient
21
23
  from .types.exceptions import ConfigurationError
@@ -186,7 +188,7 @@ class DataQuery:
186
188
  if client_id:
187
189
  self.client_config.client_id = client_id
188
190
  if client_secret:
189
- self.client_config.client_secret = client_secret
191
+ self.client_config.client_secret = SecretStr(client_secret)
190
192
  if not self.client_config.oauth_token_url and self.client_config.base_url:
191
193
  self.client_config.oauth_token_url = f"{self.client_config.base_url.rstrip('/')}/oauth/token"
192
194
 
@@ -717,7 +719,7 @@ class DataQuery:
717
719
  client = self._ensure_client()
718
720
  return await client.get_grid_data_async(expr, grid_id, date)
719
721
 
720
- async def text_search_async(self, query: str) -> Dict[str, Any]:
722
+ async def search_async(self, query: str) -> Dict[str, Any]:
721
723
  """Search the DataQuery catalog using a natural-language query.
722
724
 
723
725
  Args:
@@ -728,7 +730,7 @@ class DataQuery:
728
730
  """
729
731
  await self.connect_async()
730
732
  client = self._ensure_client()
731
- return await client.text_search_async(query)
733
+ return await client.search_async(query)
732
734
 
733
735
  async def run_groups_async(self, max_concurrent: int = 5) -> OperationReport:
734
736
  """Run complete operation for listing all groups."""
@@ -907,6 +909,7 @@ class DataQuery:
907
909
  delay_between_downloads: float = 0.2,
908
910
  max_retries: int = 3,
909
911
  file_group_id: Optional[Union[str, List[str]]] = None,
912
+ on_file_complete: Optional[Callable[["DownloadResult"], Awaitable[None]]] = None,
910
913
  ) -> OperationReport:
911
914
  """
912
915
  Download all files in a group for a date range using parallel HTTP range requests.
@@ -929,6 +932,11 @@ class DataQuery:
929
932
  file_group_id: Optional restriction to specific file-group-id(s). Accepts a
930
933
  single id or a list of ids. When a list is supplied, availability is
931
934
  queried in parallel per id and the union of dates is downloaded.
935
+ on_file_complete: Optional async callback awaited for each file as soon as
936
+ it finishes downloading (status ``completed``/``already_exists``). Runs
937
+ concurrently with the downloads still in flight, so post-processing such
938
+ as unzipping overlaps with subsequent downloads rather than waiting for
939
+ the whole batch.
932
940
 
933
941
  Returns:
934
942
  Dictionary with download results and statistics
@@ -1006,7 +1014,7 @@ class DataQuery:
1006
1014
  total_time_minutes = total_time_seconds / 60.0
1007
1015
 
1008
1016
  logger.warning(
1009
- "No available files found for date range",
1017
+ NO_FILES_FOUND_ERROR,
1010
1018
  group_id=group_id,
1011
1019
  start_date=start_date,
1012
1020
  end_date=end_date,
@@ -1014,7 +1022,7 @@ class DataQuery:
1014
1022
  return OperationReport(
1015
1023
  operation="group_download",
1016
1024
  status="error",
1017
- error="No available files found for date range",
1025
+ error=NO_FILES_FOUND_ERROR,
1018
1026
  subject={"group_id": group_id, "start_date": start_date, "end_date": end_date},
1019
1027
  counts={"total_files": 0, "successful_downloads": 0, "failed_downloads": 0},
1020
1028
  timing={
@@ -1071,6 +1079,7 @@ class DataQuery:
1071
1079
  base_retry_delay=delay_between_downloads,
1072
1080
  max_retries=max_retries,
1073
1081
  progress_callback=progress_callback,
1082
+ on_file_complete=on_file_complete,
1074
1083
  )
1075
1084
 
1076
1085
  operation_end_time = time.time()
@@ -2047,9 +2056,9 @@ class DataQuery:
2047
2056
  """
2048
2057
  return self._run_sync(self.get_grid_data_async(expr, grid_id, date))
2049
2058
 
2050
- def text_search(self, query: str) -> Dict[str, Any]:
2051
- """Synchronous wrapper for :meth:`text_search_async`."""
2052
- return self._run_sync(self.text_search_async(query))
2059
+ def search(self, query: str) -> Dict[str, Any]:
2060
+ """Synchronous wrapper for :meth:`search_async`."""
2061
+ return self._run_sync(self.search_async(query))
2053
2062
 
2054
2063
  def run_groups(self, max_concurrent: int = 5) -> OperationReport:
2055
2064
  """Synchronous wrapper for run_groups_async."""
@@ -16,7 +16,7 @@ import time
16
16
  from dataclasses import dataclass
17
17
  from datetime import datetime
18
18
  from pathlib import Path
19
- from typing import IO, TYPE_CHECKING, Callable, Optional
19
+ from typing import IO, TYPE_CHECKING, Awaitable, Callable, Optional
20
20
 
21
21
  import aiohttp
22
22
  import structlog
@@ -530,6 +530,7 @@ async def _download_one_with_stagger(
530
530
  global_semaphore: asyncio.Semaphore,
531
531
  delay_seconds: float,
532
532
  progress_callback: Optional[Callable],
533
+ on_file_complete: Optional[Callable[[DownloadResult], Awaitable[None]]] = None,
533
534
  ) -> Optional[DownloadResult]:
534
535
  file_group_id = _file_id(file_info)
535
536
  file_datetime = _file_dt(file_info)
@@ -554,6 +555,21 @@ async def _download_one_with_stagger(
554
555
  file_datetime=file_datetime,
555
556
  status=result.status.value if result else "failed",
556
557
  )
558
+ if (
559
+ on_file_complete is not None
560
+ and result is not None
561
+ and result.status.value in ("completed", "already_exists")
562
+ ):
563
+ # Post-processing (e.g. unzip) runs here so it overlaps with the
564
+ # downloads of other files still in flight under the gather().
565
+ try:
566
+ await on_file_complete(result)
567
+ except Exception as cb_err: # pragma: no cover - defensive
568
+ logger.warning(
569
+ "on_file_complete callback failed",
570
+ file_group_id=file_group_id,
571
+ error=str(cb_err),
572
+ )
557
573
  return result
558
574
  except Exception as e:
559
575
  logger.error(
@@ -596,6 +612,7 @@ async def download_files_with_retry(
596
612
  base_retry_delay: float,
597
613
  max_retries: int,
598
614
  progress_callback: Optional[Callable] = None,
615
+ on_file_complete: Optional[Callable[[DownloadResult], Awaitable[None]]] = None,
599
616
  ) -> tuple[list[DownloadResult], list[dict], int]:
600
617
  """Run a staggered, retrying batch of parallel-range downloads.
601
618
 
@@ -605,6 +622,10 @@ async def download_files_with_retry(
605
622
  spread the burst against the API rate limiter. Failures are retried up
606
623
  to ``max_retries`` times with exponential backoff (``base_retry_delay``).
607
624
 
625
+ When ``on_file_complete`` is provided it is awaited for each successfully
626
+ downloaded file as soon as that file finishes, so post-processing (e.g.
627
+ unzipping) overlaps with the downloads of files still in flight.
628
+
608
629
  Returns ``(successful, failed, retry_count)``.
609
630
  """
610
631
 
@@ -618,6 +639,7 @@ async def download_files_with_retry(
618
639
  global_semaphore=global_semaphore,
619
640
  delay_seconds=i * intelligent_delay,
620
641
  progress_callback=progress_callback,
642
+ on_file_complete=on_file_complete,
621
643
  )
622
644
  for i, fi in enumerate(batch)
623
645
  ]
@@ -111,6 +111,7 @@ class SSEClient:
111
111
  params: Optional[dict] = None,
112
112
  event_id_store: Optional[SSEEventIdStore] = None,
113
113
  heartbeat_timeout: float = 0.0,
114
+ defer_event_id_persistence: bool = False,
114
115
  ):
115
116
  """
116
117
  Initialise the SSE client.
@@ -147,6 +148,16 @@ class SSEClient:
147
148
  larger than the server's keep-alive interval. ``0`` (the
148
149
  default) disables the watchdog and relies on the server
149
150
  closing the stream cleanly.
151
+ defer_event_id_persistence: When ``True``, do not persist parsed
152
+ event ids to ``event_id_store`` as they arrive. The owner
153
+ (e.g. :class:`NotificationDownloadManager`) instead commits
154
+ a low-water-mark only after the matching download has
155
+ settled, so a crash mid-download replays the event instead
156
+ of skipping it (at-least-once). ``event_id_store`` is still
157
+ read once on construction to seed the first
158
+ ``Last-Event-ID``. Defaults to ``False`` — persist at parse
159
+ time, which is correct for standalone use where there is no
160
+ download step to gate persistence on.
150
161
  """
151
162
  self.config = config
152
163
  self.auth_manager = auth_manager
@@ -158,6 +169,7 @@ class SSEClient:
158
169
  self.params = dict(params) if params else None
159
170
  self.event_id_store = event_id_store
160
171
  self.heartbeat_timeout = heartbeat_timeout
172
+ self._defer_event_id_persistence = defer_event_id_persistence
161
173
 
162
174
  self._running = False
163
175
  self._task: Optional[asyncio.Task] = None
@@ -481,7 +493,8 @@ class SSEClient:
481
493
  event_id = field_value
482
494
  if field_value and field_value.isdigit():
483
495
  self._last_event_id = field_value
484
- self._persist_event_id(field_value)
496
+ if not self._defer_event_id_persistence:
497
+ self._persist_event_id(field_value)
485
498
  elif field_name == "retry":
486
499
  try:
487
500
  retry_ms = int(field_value)
@@ -6,8 +6,10 @@ downloads files as notifications arrive. Each SSE event uses the standard
6
6
  SSE format with the event type in the ``event:`` field (e.g., ``file-updated``)
7
7
  and the file details in the JSON ``data:`` payload.
8
8
 
9
- The SSE ``id:`` field is persisted to disk so that cross-process replay via
10
- the ``last-event-id`` query parameter works correctly on reconnection.
9
+ The SSE ``id:`` field is persisted to disk as a low-water-mark advanced past
10
+ an event only once its download has settled — so cross-process replay via the
11
+ ``last-event-id`` query parameter re-delivers any file whose download did not
12
+ complete before a restart (at-least-once delivery).
11
13
  """
12
14
 
13
15
  import asyncio
@@ -245,6 +247,13 @@ class NotificationDownloadManager:
245
247
  self._download_semaphore: Optional[asyncio.Semaphore] = None
246
248
  self._inflight: Set[asyncio.Task] = set()
247
249
 
250
+ # Replay low-water-mark. The persisted last-event-id is only advanced
251
+ # past an event once its download has settled, so a crash mid-download
252
+ # replays that event on restart instead of silently skipping it.
253
+ self._uncommitted_ids: Set[int] = set()
254
+ self._highest_seen_event_id: int = 0
255
+ self._committed_event_id: int = 0
256
+
248
257
  self.stats: Dict[str, Any] = {
249
258
  "start_time": None,
250
259
  "notifications_received": 0,
@@ -285,6 +294,12 @@ class NotificationDownloadManager:
285
294
  if self._event_id_store is not None:
286
295
  stored_event_id = self._event_id_store.load()
287
296
 
297
+ if stored_event_id is not None and stored_event_id.isdigit():
298
+ # Seed the low-water-mark so we neither re-persist the stored id nor
299
+ # ever regress below it as newly received events settle.
300
+ self._committed_event_id = int(stored_event_id)
301
+ self._highest_seen_event_id = int(stored_event_id)
302
+
288
303
  if stored_event_id is not None:
289
304
  logger.info(
290
305
  "Event replay enabled — resuming from event-id %s; skipping initial bulk check.",
@@ -307,6 +322,9 @@ class NotificationDownloadManager:
307
322
  params=self.subscription.query_params(),
308
323
  event_id_store=self._event_id_store,
309
324
  heartbeat_timeout=self._heartbeat_timeout,
325
+ # This manager commits a low-water-mark after downloads settle,
326
+ # so the client must not persist ids itself at parse time.
327
+ defer_event_id_persistence=True,
310
328
  )
311
329
  await self._sse_client.start()
312
330
 
@@ -393,6 +411,7 @@ class NotificationDownloadManager:
393
411
  if not self._running:
394
412
  return
395
413
 
414
+ self._register_event_id(event)
396
415
  task = asyncio.create_task(self._run_handler(event), name="sse-download")
397
416
  self._inflight.add(task)
398
417
  task.add_done_callback(self._inflight.discard)
@@ -407,6 +426,63 @@ class NotificationDownloadManager:
407
426
  except Exception as exc:
408
427
  logger.error("Error handling notification: %s", exc)
409
428
  await self._dispatch_error(exc)
429
+ # The download has settled (success, skip, or handled failure); advance
430
+ # the replay cursor. Deliberately not in a ``finally``: if the task is
431
+ # cancelled mid-download the cursor stays behind this event so it is
432
+ # replayed on restart rather than skipped.
433
+ await self._commit_settled_event(event)
434
+
435
+ def _register_event_id(self, event: SSEEvent) -> None:
436
+ """Record a numeric event id as in-flight before its download starts.
437
+
438
+ The replay cursor is never advanced past an in-flight id, so a crash
439
+ before the download settles leaves the event to be replayed.
440
+ """
441
+ if self._event_id_store is None:
442
+ return
443
+ eid = event.id
444
+ if not eid or not eid.isdigit():
445
+ return
446
+ value = int(eid)
447
+ self._uncommitted_ids.add(value)
448
+ if value > self._highest_seen_event_id:
449
+ self._highest_seen_event_id = value
450
+
451
+ def _next_commit_id(self) -> Optional[str]:
452
+ """Return the highest replay id now safe to persist, or ``None``.
453
+
454
+ The safe id is the low-water-mark: everything below the oldest
455
+ still-in-flight event has settled, so commit ``min(in-flight) - 1``
456
+ (or the highest id seen when nothing is in flight). Fully synchronous
457
+ so two concurrent settlers can't persist a decreasing watermark across
458
+ the ``await`` in :meth:`_commit_settled_event`.
459
+ """
460
+ if self._uncommitted_ids:
461
+ watermark = min(self._uncommitted_ids) - 1
462
+ else:
463
+ watermark = self._highest_seen_event_id
464
+ if watermark > self._committed_event_id:
465
+ self._committed_event_id = watermark
466
+ return str(watermark)
467
+ return None
468
+
469
+ async def _commit_settled_event(self, event: SSEEvent) -> None:
470
+ """Advance the persisted replay cursor after an event's handler settles.
471
+
472
+ Runs whether the download succeeded, was skipped, or failed — the id is
473
+ no longer in-flight either way. A terminal download failure is already
474
+ surfaced via ``error_callback``/``stats``; the cursor still advances so a
475
+ genuinely unfetchable file can't stall replay forever.
476
+ """
477
+ store = self._event_id_store
478
+ if store is None:
479
+ return
480
+ eid = event.id
481
+ if eid and eid.isdigit():
482
+ self._uncommitted_ids.discard(int(eid))
483
+ to_save = self._next_commit_id()
484
+ if to_save is not None:
485
+ await store.save(to_save)
410
486
 
411
487
  async def _on_sse_error(self, exc: Exception) -> None:
412
488
  """Called by SSEClient on connection errors."""
@@ -72,7 +72,7 @@ class TokenManager:
72
72
  Bearer token string or None if no valid token available
73
73
  """
74
74
  if self.config.has_bearer_token:
75
- return f"Bearer {self.config.bearer_token}"
75
+ return f"Bearer {self.config.get_bearer_token()}"
76
76
 
77
77
  if not self.config.has_oauth_credentials:
78
78
  logger.warning("No OAuth credentials or bearer token configured")
@@ -111,7 +111,7 @@ class TokenManager:
111
111
  if not self.config.oauth_token_url:
112
112
  raise ConfigurationError("OAuth token URL not configured")
113
113
 
114
- if not self.config.client_id or not self.config.client_secret:
114
+ if not self.config.client_id or not self.config.get_client_secret():
115
115
  raise ConfigurationError("client_id and client_secret are required for OAuth")
116
116
 
117
117
  token_request = TokenRequest(
@@ -172,7 +172,7 @@ class TokenManager:
172
172
  "grant_type": "refresh_token",
173
173
  "refresh_token": self.current_token.refresh_token,
174
174
  "client_id": self.config.client_id,
175
- "client_secret": self.config.client_secret,
175
+ "client_secret": self.config.get_client_secret(),
176
176
  }
177
177
 
178
178
  if not self.config.oauth_token_url:
@@ -5,16 +5,30 @@ Data models for the DATAQUERY SDK based on the OpenAPI specification.
5
5
  import asyncio
6
6
  import base64
7
7
  import time
8
- from datetime import date, datetime, timedelta
8
+ from datetime import date, datetime, timedelta, timezone
9
9
  from enum import Enum
10
10
  from pathlib import Path
11
11
  from typing import Any, Dict, List, Literal, Optional, Union, cast
12
12
 
13
- from pydantic import BaseModel, ConfigDict, Field, field_validator
13
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
14
14
 
15
15
  from .. import constants as C
16
16
 
17
17
 
18
+ def _reveal_secret(value: Union[str, SecretStr, None]) -> Optional[str]:
19
+ """Return the plain-text value of a secret field, or ``None``.
20
+
21
+ Accepts a ``SecretStr`` (the normal case), a bare ``str`` (e.g. assigned
22
+ directly to a config attribute, which bypasses pydantic's coercion), or
23
+ ``None`` — so callers never have to care which form is stored.
24
+ """
25
+ if value is None:
26
+ return None
27
+ if isinstance(value, SecretStr):
28
+ return value.get_secret_value()
29
+ return value
30
+
31
+
18
32
  class DownloadStatus(str, Enum):
19
33
  """Status of a download operation."""
20
34
 
@@ -59,14 +73,14 @@ class ClientConfig(BaseModel):
59
73
  description="OAuth token endpoint URL",
60
74
  )
61
75
  client_id: Optional[str] = Field(default=None, description="OAuth client ID")
62
- client_secret: Optional[str] = Field(default=None, description="OAuth client secret")
76
+ client_secret: Optional[SecretStr] = Field(default=None, description="OAuth client secret")
63
77
  aud: Optional[str] = Field(
64
78
  default="JPMC:URI:RS-06785-DataQueryExternalApi-PROD",
65
79
  description="OAuth audience (aud)",
66
80
  )
67
81
  grant_type: str = Field(default="client_credentials", description="OAuth grant type")
68
82
 
69
- bearer_token: Optional[str] = Field(default=None, description="Bearer token for API access")
83
+ bearer_token: Optional[SecretStr] = Field(default=None, description="Bearer token for API access")
70
84
  token_refresh_threshold: int = Field(default=300, description="Seconds before expiry to refresh token")
71
85
 
72
86
  timeout: float = Field(default=600.0, description="Default request timeout in seconds")
@@ -86,7 +100,7 @@ class ClientConfig(BaseModel):
86
100
  description="Proxy URL (e.g., http://proxy:8080, socks5://proxy:1080)",
87
101
  )
88
102
  proxy_username: Optional[str] = Field(default="", description="Proxy username for authentication")
89
- proxy_password: Optional[str] = Field(default="", description="Proxy password for authentication")
103
+ proxy_password: Optional[SecretStr] = Field(default=None, description="Proxy password for authentication")
90
104
  proxy_verify_ssl: bool = Field(default=True, description="Verify SSL certificates for proxy connections")
91
105
 
92
106
  log_level: str = Field(default="INFO", description="Logging level")
@@ -205,12 +219,25 @@ class ClientConfig(BaseModel):
205
219
  @property
206
220
  def has_bearer_token(self) -> bool:
207
221
  """Check if bearer token is configured."""
208
- return self.bearer_token is not None and self.bearer_token.strip() != ""
222
+ token = self.get_bearer_token()
223
+ return bool(token and token.strip())
209
224
 
210
225
  @property
211
226
  def has_proxy_credentials(self) -> bool:
212
227
  """Check if proxy credentials are configured."""
213
- return bool(self.proxy_username) and bool(self.proxy_password)
228
+ return bool(self.proxy_username) and bool(self.get_proxy_password())
229
+
230
+ def get_client_secret(self) -> Optional[str]:
231
+ """Return the plain-text OAuth client secret, or ``None``."""
232
+ return _reveal_secret(self.client_secret)
233
+
234
+ def get_bearer_token(self) -> Optional[str]:
235
+ """Return the plain-text bearer token, or ``None``."""
236
+ return _reveal_secret(self.bearer_token)
237
+
238
+ def get_proxy_password(self) -> Optional[str]:
239
+ """Return the plain-text proxy password, or ``None``."""
240
+ return _reveal_secret(self.proxy_password)
214
241
 
215
242
  def get_proxy_kwargs(self) -> Dict[str, Any]:
216
243
  """Return aiohttp request/session kwargs for proxy routing.
@@ -227,7 +254,7 @@ class ClientConfig(BaseModel):
227
254
  kwargs: Dict[str, Any] = {"proxy": self.proxy_url}
228
255
  if self.has_proxy_credentials:
229
256
  # Encode Basic proxy auth ourselves: aiohttp.BasicAuth is removed in 4.0 (latin1).
230
- raw = f"{self.proxy_username or ''}:{self.proxy_password or ''}".encode("latin1")
257
+ raw = f"{self.proxy_username or ''}:{self.get_proxy_password() or ''}".encode("latin1")
231
258
  token = base64.b64encode(raw).decode("ascii")
232
259
  kwargs["proxy_headers"] = {"Proxy-Authorization": f"Basic {token}"}
233
260
  return kwargs
@@ -263,9 +290,14 @@ class OAuthToken(BaseModel):
263
290
 
264
291
  @property
265
292
  def expires_at(self) -> Optional[datetime]:
266
- """Get token expiry time."""
293
+ """Get token expiry time (timezone-aware, UTC)."""
267
294
  if self.expires_in and self.issued_at:
268
- return self.issued_at + timedelta(seconds=self.expires_in)
295
+ issued = self.issued_at
296
+ if issued.tzinfo is None:
297
+ # Older SDK versions persisted naive local timestamps; interpret
298
+ # them in the system timezone before normalizing to UTC.
299
+ issued = issued.astimezone(timezone.utc)
300
+ return issued + timedelta(seconds=self.expires_in)
269
301
  return None
270
302
 
271
303
  @property
@@ -273,13 +305,13 @@ class OAuthToken(BaseModel):
273
305
  """Check if token is expired."""
274
306
  if not self.expires_at:
275
307
  return False
276
- return datetime.now() >= self.expires_at
308
+ return datetime.now(timezone.utc) >= self.expires_at
277
309
 
278
310
  def is_expiring_soon(self, threshold: int = 300) -> bool:
279
311
  """Check if token is expiring soon."""
280
312
  if not self.expires_at:
281
313
  return False
282
- remaining_time = (self.expires_at - datetime.now()).total_seconds()
314
+ remaining_time = (self.expires_at - datetime.now(timezone.utc)).total_seconds()
283
315
  if self.expires_in and threshold > self.expires_in:
284
316
  return False
285
317
  return remaining_time < threshold
@@ -301,7 +333,7 @@ class TokenRequest(BaseModel):
301
333
 
302
334
  grant_type: str = Field("client_credentials", description="OAuth grant type")
303
335
  client_id: Optional[str] = Field(None, description="OAuth client ID")
304
- client_secret: Optional[str] = Field(None, description="OAuth client secret")
336
+ client_secret: Optional[SecretStr] = Field(None, description="OAuth client secret")
305
337
  aud: Optional[str] = Field(None, description="OAuth audience (aud)")
306
338
 
307
339
  model_config = ConfigDict(extra="allow", populate_by_name=True)
@@ -314,7 +346,7 @@ class TokenRequest(BaseModel):
314
346
  if self.client_id:
315
347
  data["client_id"] = cast(str, self.client_id)
316
348
  if self.client_secret:
317
- data["client_secret"] = cast(str, self.client_secret)
349
+ data["client_secret"] = self.client_secret.get_secret_value()
318
350
  if getattr(self, "aud", None):
319
351
  data["aud"] = cast(str, self.aud)
320
352
  return data
@@ -337,7 +369,7 @@ class TokenResponse(BaseModel):
337
369
  token_type=(self.token_type or "Bearer"),
338
370
  expires_in=self.expires_in,
339
371
  refresh_token=self.refresh_token,
340
- issued_at=datetime.now(),
372
+ issued_at=datetime.now(timezone.utc),
341
373
  )
342
374
 
343
375
 
@@ -2,22 +2,26 @@
2
2
  Utility functions for the DATAQUERY SDK.
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import os
6
7
  import re
7
8
  import urllib.parse
8
- from datetime import datetime, timedelta
9
+ import zipfile
10
+ from calendar import monthrange
11
+ from datetime import date, datetime, timedelta
9
12
  from pathlib import Path
10
13
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
11
14
 
12
15
  import aiohttp
13
16
  import structlog
14
17
 
15
- from .constants.download import DEFAULT_WRITTEN_RESEARCH_CHUNK_DAYS
18
+ from .constants.download import DEFAULT_WRITTEN_RESEARCH_CHUNK_DAYS, NO_FILES_FOUND_ERROR
16
19
  from .types.exceptions import ValidationError
17
20
  from .types.models import ClientConfig
18
21
 
19
22
  if TYPE_CHECKING:
20
23
  from .dataquery import DataQuery
24
+ from .types.models import DownloadResult
21
25
 
22
26
  logger = structlog.get_logger(__name__)
23
27
 
@@ -174,8 +178,8 @@ DATAQUERY_DEFAULT_DIR=files
174
178
  # =============================================================================
175
179
 
176
180
  # User agent string for HTTP requests
177
- # Default: DATAQUERY-SDK/1.2.0
178
- DATAQUERY_USER_AGENT=DATAQUERY-SDK/1.2.0
181
+ # Default: DATAQUERY-SDK/1.2.1
182
+ DATAQUERY_USER_AGENT=DATAQUERY-SDK/1.2.1
179
183
 
180
184
  # Enable HTTP/2 support (true/false)
181
185
  # Default: true
@@ -252,12 +256,12 @@ DATAQUERY_BASE_URL={config.base_url}
252
256
  DATAQUERY_OAUTH_ENABLED={str(config.oauth_enabled).lower()}
253
257
  DATAQUERY_OAUTH_TOKEN_URL={config.oauth_token_url or ""}
254
258
  DATAQUERY_CLIENT_ID={config.client_id or ""}
255
- DATAQUERY_CLIENT_SECRET={config.client_secret or ""}
259
+ DATAQUERY_CLIENT_SECRET={config.get_client_secret() or ""}
256
260
  DATAQUERY_OAUTH_AUD={getattr(config, "aud", "") or ""}
257
261
  DATAQUERY_GRANT_TYPE={config.grant_type}
258
262
 
259
263
  # Bearer Token Configuration
260
- DATAQUERY_BEARER_TOKEN={config.bearer_token or ""}
264
+ DATAQUERY_BEARER_TOKEN={config.get_bearer_token() or ""}
261
265
  DATAQUERY_TOKEN_REFRESH_THRESHOLD={config.token_refresh_threshold}
262
266
 
263
267
  # HTTP Configuration
@@ -712,6 +716,287 @@ def split_date_range_into_chunks(
712
716
  return chunks
713
717
 
714
718
 
719
+ def _split_into_monthly_ranges(from_date: str, to_date: str) -> List[Tuple[str, str]]:
720
+ """Split an inclusive YYYYMMDD date range into calendar-month windows.
721
+
722
+ Each returned window spans at most one calendar month, matching the
723
+ ``available-files`` endpoint's one-month range limit.
724
+
725
+ Args:
726
+ from_date: Inclusive start date in YYYYMMDD format.
727
+ to_date: Inclusive end date in YYYYMMDD format.
728
+
729
+ Returns:
730
+ Ordered list of ``(chunk_start, chunk_end)`` YYYYMMDD tuples covering
731
+ ``[from_date, to_date]`` with no gaps or overlaps.
732
+ """
733
+ try:
734
+ start = datetime.strptime(from_date, "%Y%m%d").date()
735
+ end = datetime.strptime(to_date, "%Y%m%d").date()
736
+ except ValueError as exc:
737
+ raise ValidationError(f"Invalid date format (expected YYYYMMDD): {exc}")
738
+
739
+ if end < start:
740
+ raise ValidationError("end_date must be on or after start_date")
741
+
742
+ ranges: List[Tuple[str, str]] = []
743
+ current = start
744
+ while current <= end:
745
+ last_day = monthrange(current.year, current.month)[1]
746
+ month_end = date(current.year, current.month, last_day)
747
+ chunk_end = min(month_end, end)
748
+ ranges.append((current.strftime("%Y%m%d"), chunk_end.strftime("%Y%m%d")))
749
+ if current.month == 12:
750
+ current = date(current.year + 1, 1, 1)
751
+ else:
752
+ current = date(current.year, current.month + 1, 1)
753
+ return ranges
754
+
755
+
756
+ def _extract_zip_safely(zip_path: Path, target_dir: Path) -> List[str]:
757
+ """Extract a ZIP archive into ``target_dir``, guarding against Zip Slip.
758
+
759
+ Args:
760
+ zip_path: Path to the ``.zip`` archive to extract.
761
+ target_dir: Directory the archive contents are written to.
762
+
763
+ Returns:
764
+ List of member names that were extracted.
765
+
766
+ Raises:
767
+ ValidationError: If any archive member would be written outside
768
+ ``target_dir`` (path traversal attempt).
769
+ """
770
+ target_dir.mkdir(parents=True, exist_ok=True)
771
+ resolved_target = target_dir.resolve()
772
+
773
+ with zipfile.ZipFile(zip_path) as zf:
774
+ members = zf.namelist()
775
+ for member in members:
776
+ member_path = (target_dir / member).resolve()
777
+ if resolved_target not in member_path.parents and member_path != resolved_target:
778
+ raise ValidationError(f"Unsafe path in archive '{zip_path.name}': {member}")
779
+ zf.extractall(target_dir)
780
+ return members
781
+
782
+
783
+ def _extract_single_zip(
784
+ zip_path: Path,
785
+ today: str,
786
+ remove_zip_after_extract: bool,
787
+ ) -> Optional[Dict[str, Any]]:
788
+ """Extract one top-level archive, returning a record or ``None`` if skipped.
789
+
790
+ Leaves current-day archives untouched (their content may still be updating)
791
+ and silently skips archives that are not valid ZIP files.
792
+ """
793
+ # The embedded file-datetime (YYYYMMDDThhmm[ss]) is the last such token in
794
+ # the name; earlier digit runs (e.g. the GPS id) must not be matched.
795
+ matches = re.findall(r"(\d{8})T\d{4,6}", zip_path.stem)
796
+ if matches and matches[-1] == today:
797
+ logger.info("Skipping current-day zip archive", zip=str(zip_path))
798
+ return None
799
+
800
+ target_dir = zip_path.parent
801
+ try:
802
+ members = _extract_zip_safely(zip_path, target_dir)
803
+ except zipfile.BadZipFile:
804
+ logger.warning("Skipping invalid zip archive", zip=str(zip_path))
805
+ return None
806
+
807
+ if remove_zip_after_extract:
808
+ zip_path.unlink()
809
+
810
+ logger.info("Extracted zip archive", zip=str(zip_path), files=len(members))
811
+ return {
812
+ "zip": str(zip_path),
813
+ "target_dir": str(target_dir),
814
+ "files": members,
815
+ }
816
+
817
+
818
+ async def _run_download_over_ranges(
819
+ dq: "DataQuery",
820
+ group_id: str,
821
+ ranges: List[Tuple[str, str]],
822
+ **kwargs: Any,
823
+ ) -> Tuple[Dict[str, int], List[Dict[str, Any]]]:
824
+ """Run :meth:`DataQuery.run_group_download_async` once per date window.
825
+
826
+ Shared by :func:`download_zip_async` and
827
+ :func:`run_group_download_chunked_async`.
828
+
829
+ Returns:
830
+ ``(totals, per_chunk)`` where ``totals`` aggregates the download counts
831
+ across all windows and ``per_chunk`` holds one summary dict (dates,
832
+ status, error, counts) per window, in order.
833
+ """
834
+ totals = {"total_files": 0, "successful_downloads": 0, "failed_downloads": 0}
835
+ per_chunk: List[Dict[str, Any]] = []
836
+
837
+ for chunk_start, chunk_end in ranges:
838
+ report = await dq.run_group_download_async(
839
+ group_id=group_id,
840
+ start_date=chunk_start,
841
+ end_date=chunk_end,
842
+ **kwargs,
843
+ )
844
+ counts = report.counts or {}
845
+ for key in totals:
846
+ totals[key] += int(counts.get(key, 0) or 0)
847
+ per_chunk.append(
848
+ {
849
+ "start_date": chunk_start,
850
+ "end_date": chunk_end,
851
+ "status": report.status,
852
+ "error": report.error,
853
+ "counts": counts,
854
+ }
855
+ )
856
+
857
+ return totals, per_chunk
858
+
859
+
860
+ def _aggregate_chunk_status(per_chunk: List[Dict[str, Any]]) -> str:
861
+ """Combine per-window statuses into one ``success``/``partial``/``error``.
862
+
863
+ Windows that merely had no available files (a quiet window, reported by
864
+ the API as an error) are ignored so they don't mark an otherwise clean
865
+ multi-window run as ``partial``.
866
+ """
867
+ relevant = [
868
+ c["status"] for c in per_chunk if not (c["status"] == "error" and c.get("error") == NO_FILES_FOUND_ERROR)
869
+ ]
870
+ if not relevant or all(s == "success" for s in relevant):
871
+ return "success"
872
+ if all(s == "error" for s in relevant):
873
+ return "error"
874
+ return "partial"
875
+
876
+
877
+ async def download_zip_async(
878
+ dq: "DataQuery",
879
+ group_id: str,
880
+ start_date: str,
881
+ end_date: str,
882
+ destination_dir: Path = Path("./downloads"),
883
+ remove_zip_after_extract: bool = True,
884
+ **kwargs: Any,
885
+ ) -> Dict[str, Any]:
886
+ """Download all files in a group and unzip any ZIP archives as they arrive.
887
+
888
+ Runs :meth:`DataQuery.run_group_download_async` over the
889
+ ``[start_date, end_date]`` range, splitting it into calendar-month windows
890
+ because the ``available-files`` endpoint rejects ranges longer than one
891
+ month. Each ``.zip`` archive is extracted in a worker thread the moment its
892
+ download completes, so unzipping overlaps with the downloads of the files
893
+ still in flight instead of waiting for the whole batch to finish. A final
894
+ sweep extracts any archives not already handled by the per-file hook.
895
+
896
+ Note that with ``remove_zip_after_extract=True`` the archives are gone
897
+ after the run, so re-running the same range downloads them again (the
898
+ downloader can't see they were already fetched).
899
+
900
+ Args:
901
+ dq: An already-constructed ``DataQuery`` instance (used inside its own
902
+ ``async with`` context by the caller).
903
+ group_id: Group ID to download files from.
904
+ start_date: Inclusive start date in YYYYMMDD format.
905
+ end_date: Inclusive end date in YYYYMMDD format.
906
+ destination_dir: Base destination directory for downloads. Files are
907
+ written to ``destination_dir / group_id``.
908
+ remove_zip_after_extract: When True (default), delete each ``.zip``
909
+ archive after it has been successfully extracted.
910
+ **kwargs: Forwarded as-is to ``DataQuery.run_group_download_async``.
911
+
912
+ Returns:
913
+ Dict with the aggregated ``status``/``counts``, a ``chunks`` list of
914
+ per-window summaries, an ``extracted`` list describing each archive
915
+ that was unzipped, and an ``extraction_errors`` list for archives that
916
+ downloaded fine but could not be extracted (any entry here downgrades
917
+ an overall ``success`` to ``partial``).
918
+ """
919
+ ranges = _split_into_monthly_ranges(start_date, end_date)
920
+
921
+ today = datetime.now().strftime("%Y%m%d")
922
+ extracted: List[Dict[str, Any]] = []
923
+ extraction_errors: List[Dict[str, str]] = []
924
+ processed: set[Path] = set()
925
+
926
+ async def _on_file_complete(result: "DownloadResult") -> None:
927
+ local_path = result.local_path
928
+ if not local_path or local_path.suffix.lower() != ".zip":
929
+ return
930
+ key = local_path.resolve()
931
+ if key in processed:
932
+ return
933
+ processed.add(key)
934
+ # Run the blocking unzip off the event loop so concurrent downloads
935
+ # keep progressing while this archive is extracted.
936
+ try:
937
+ record = await asyncio.to_thread(_extract_single_zip, local_path, today, remove_zip_after_extract)
938
+ except Exception as exc:
939
+ # Leave the archive for the final sweep to retry; only the sweep
940
+ # records a definitive extraction error.
941
+ processed.discard(key)
942
+ logger.warning(
943
+ "Extraction failed, will retry in final sweep",
944
+ zip=str(local_path),
945
+ error=str(exc),
946
+ )
947
+ return
948
+ if record is not None:
949
+ extracted.append(record)
950
+
951
+ totals, per_chunk = await _run_download_over_ranges(
952
+ dq,
953
+ group_id,
954
+ ranges,
955
+ destination_dir=destination_dir,
956
+ on_file_complete=_on_file_complete,
957
+ **kwargs,
958
+ )
959
+ status = _aggregate_chunk_status(per_chunk)
960
+
961
+ group_dir = destination_dir / group_id
962
+
963
+ # Fallback sweep: extract any archives the per-file hook did not handle
964
+ # (e.g. files whose local path was unavailable, or first-attempt extraction
965
+ # failures). Already-extracted zips were removed/deduplicated, so this is
966
+ # idempotent.
967
+ if group_dir.is_dir():
968
+ # Snapshot the top-level archives before extracting so nested zips
969
+ # unpacked from these archives are left untouched (no recursion).
970
+ top_level_zips = sorted(group_dir.glob("*.zip"))
971
+ for zip_path in top_level_zips:
972
+ key = zip_path.resolve()
973
+ if key in processed:
974
+ continue
975
+ processed.add(key)
976
+ try:
977
+ record = await asyncio.to_thread(_extract_single_zip, zip_path, today, remove_zip_after_extract)
978
+ except Exception as exc:
979
+ logger.error("Extraction failed", zip=str(zip_path), error=str(exc))
980
+ extraction_errors.append({"zip": str(zip_path), "error": str(exc)})
981
+ continue
982
+ if record is not None:
983
+ extracted.append(record)
984
+
985
+ if extraction_errors and status == "success":
986
+ status = "partial"
987
+
988
+ return {
989
+ "group_id": group_id,
990
+ "start_date": start_date,
991
+ "end_date": end_date,
992
+ "status": status,
993
+ "counts": totals,
994
+ "chunks": per_chunk,
995
+ "extracted": extracted,
996
+ "extraction_errors": extraction_errors,
997
+ }
998
+
999
+
715
1000
  async def run_group_download_chunked_async(
716
1001
  dq: "DataQuery",
717
1002
  group_id: str,
@@ -742,28 +1027,7 @@ async def run_group_download_chunked_async(
742
1027
  ``OperationReport`` dumps.
743
1028
  """
744
1029
  chunks = split_date_range_into_chunks(start_date, end_date, chunk_days)
745
-
746
- totals = {"total_files": 0, "successful_downloads": 0, "failed_downloads": 0}
747
- per_chunk: List[Dict[str, Any]] = []
748
-
749
- for chunk_start, chunk_end in chunks:
750
- report = await dq.run_group_download_async(
751
- group_id=group_id,
752
- start_date=chunk_start,
753
- end_date=chunk_end,
754
- **kwargs,
755
- )
756
- counts = report.counts or {}
757
- for key in totals:
758
- totals[key] += int(counts.get(key, 0) or 0)
759
- per_chunk.append(
760
- {
761
- "start_date": chunk_start,
762
- "end_date": chunk_end,
763
- "status": report.status,
764
- "counts": counts,
765
- }
766
- )
1030
+ totals, per_chunk = await _run_download_over_ranges(dq, group_id, chunks, **kwargs)
767
1031
 
768
1032
  return {
769
1033
  "group_id": group_id,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataquery-sdk
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Python SDK for DATAQUERY Data API - Query, download, and check availability of economic data files
5
5
  Author-email: DATAQUERY SDK Team <dataquery_support@jpmorgan.com>
6
6
  Project-URL: Homepage, https://github.com/dataquery/dataquery-sdk
File without changes
File without changes
File without changes
File without changes