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.
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/CHANGELOG.md +4 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/PKG-INFO +1 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/__init__.py +3 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/cli.py +4 -4
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/env.py +1 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/download.py +9 -2
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/_mixins.py +1 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/client.py +22 -27
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/dataquery.py +18 -9
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/parallel.py +23 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/client.py +14 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/subscriber.py +78 -2
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/auth.py +3 -3
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/models.py +47 -15
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/utils.py +292 -28
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/PKG-INFO +1 -1
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/CONTRIBUTING.md +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/LICENSE +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/MANIFEST.in +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/README.md +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/config/logging.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/api.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/rate_limit.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/constants/sse.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/core/_sync.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/data/function.json +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/download/utils.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/export.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/function_registry.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/py.typed +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/sse/event_store.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/connection_pool.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/rate_limiter.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/transport/retry.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/__init__.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery/types/exceptions.py +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/SOURCES.txt +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/dependency_links.txt +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/entry_points.txt +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/requires.txt +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/dataquery_sdk.egg-info/top_level.txt +0 -0
- {dataquery_sdk-1.2.0 → dataquery_sdk-1.2.1}/pyproject.toml +0 -0
- {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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
486
|
+
async def cmd_search(args: argparse.Namespace) -> int:
|
|
487
487
|
async with DataQuery(args.env_file) as dq:
|
|
488
|
-
result = await dq.
|
|
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
|
-
"
|
|
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.
|
|
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 ``
|
|
32
|
-
#
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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=
|
|
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
|
|
2051
|
-
"""Synchronous wrapper for :meth:`
|
|
2052
|
-
return self._run_sync(self.
|
|
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.
|
|
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
|
|
10
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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[
|
|
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"] =
|
|
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
|
-
|
|
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.
|
|
178
|
-
DATAQUERY_USER_AGENT=DATAQUERY-SDK/1.2.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|