Skip to content

Commit 2bd51eb

Browse files
committed
MOD: Update Python client to use batch.download
1 parent deb6a0d commit 2bd51eb

3 files changed

Lines changed: 83 additions & 134 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
#### Enhancements
66
- Added new venues, datasets, and publishers for US Equities Securities Information Processors
7+
- Changed the `batch.download` method to use the API's `batch.download` endpoint for
8+
downloading a batch job as a ZIP archive
79

810
## 0.78.0 - 2026-05-12
911

databento/historical/api/batch.py

Lines changed: 77 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ def download(
268268
Will automatically generate any necessary directories if they do not
269269
already exist.
270270
271-
Makes one or many `GET /batch/download/{job_id}/{filename}` HTTP request(s).
271+
Makes a `GET /batch.download` HTTP request for full job downloads,
272+
or `GET /batch/download/{job_id}/{filename}` for individual files.
272273
273274
Parameters
274275
----------
@@ -303,20 +304,28 @@ def download(
303304
"Cannot specify an individual file to download when `keep_zip=True`",
304305
)
305306

306-
batch_download = _BatchJob(
307-
self,
308-
job_id=job_id,
309-
output_dir=output_dir,
310-
)
307+
if output_dir is None:
308+
output_dir = Path.cwd()
309+
else:
310+
output_dir = validate_path(output_dir, "output_dir")
311+
312+
job_output_dir = output_dir / job_id
313+
job_output_dir.mkdir(exist_ok=True, parents=True)
311314

312315
if filename_to_download is None:
313-
filenames_to_download = None
316+
# Download all files as ZIP using batch.download endpoint
317+
zip_path = job_output_dir / f"{job_id}.zip"
318+
downloaded_files = [self._download_batch_zip(job_id, zip_path)]
314319
else:
315-
filenames_to_download = [filename_to_download]
316-
317-
downloaded_files = batch_download.download(
318-
filenames_to_download=filenames_to_download,
319-
)
320+
# Download specific file using _BatchJob
321+
batch_download = _BatchJob(
322+
self,
323+
job_id=job_id,
324+
output_dir=output_dir,
325+
)
326+
downloaded_files = batch_download.download(
327+
filenames_to_download=[filename_to_download],
328+
)
320329

321330
if keep_zip:
322331
return downloaded_files
@@ -348,7 +357,8 @@ async def download_async(
348357
Will automatically generate any necessary directories if they do not
349358
already exist.
350359
351-
Makes one or many `GET /batch/download/{job_id}/{filename}` HTTP request(s).
360+
Makes a `GET /batch.download` HTTP request for full job downloads,
361+
or `GET /batch/download/{job_id}/{filename}` for individual files.
352362
353363
Parameters
354364
----------
@@ -382,20 +392,31 @@ async def download_async(
382392
"Cannot specify an individual file to download when `keep_zip=True`",
383393
)
384394

385-
batch_download = _BatchJob(
386-
self,
387-
job_id=job_id,
388-
output_dir=output_dir,
389-
)
395+
output_dir = validate_path(output_dir, "output_dir")
396+
job_output_dir = output_dir / job_id
397+
job_output_dir.mkdir(exist_ok=True, parents=True)
390398

391399
if filename_to_download is None:
392-
filenames_to_download = None
400+
# Download all files as ZIP using batch.download endpoint
401+
zip_path = job_output_dir / f"{job_id}.zip"
402+
downloaded_files = [
403+
await asyncio.get_running_loop().run_in_executor(
404+
_BatchJob._executor,
405+
self._download_batch_zip,
406+
job_id,
407+
zip_path,
408+
),
409+
]
393410
else:
394-
filenames_to_download = [filename_to_download]
395-
396-
downloaded_files = await batch_download.download_async(
397-
filenames_to_download=filenames_to_download,
398-
)
411+
# Download specific file using _BatchJob
412+
batch_download = _BatchJob(
413+
self,
414+
job_id=job_id,
415+
output_dir=output_dir,
416+
)
417+
downloaded_files = await batch_download.download_async(
418+
filenames_to_download=[filename_to_download],
419+
)
399420

400421
if keep_zip:
401422
return downloaded_files
@@ -520,16 +541,16 @@ def _download_batch_file(
520541

521542
def _download_batch_zip(
522543
self,
523-
batch_download_url: str,
544+
job_id: str,
524545
output_path: Path,
525546
) -> Path:
526547
"""
527548
Download all batch files as a .zip archive.
528549
529550
Parameters
530551
----------
531-
batch_download_url : _BatchDownloadFile
532-
The batch download URL for the zipfile, should contain the job ID appended with .zip.
552+
job_id : str
553+
The job ID of the batch job to download.
533554
output_path : Path
534555
The output path of the file.
535556
@@ -553,7 +574,8 @@ def _download_batch_zip(
553574

554575
try:
555576
with requests.get(
556-
url=batch_download_url,
577+
url=f"{self._base_url}.download",
578+
params={"job_id": job_id},
557579
headers=headers,
558580
auth=HTTPBasicAuth(username=self._key, password=""),
559581
allow_redirects=True,
@@ -586,7 +608,7 @@ def _download_batch_zip(
586608

587609
class _BatchJob:
588610
"""
589-
Helper class for downloading multiple batch files from a job.
611+
Helper class for downloading individual batch files from a job.
590612
591613
Supports sync and async downloads using a shared
592614
`ThreadPoolExecutor`.
@@ -619,9 +641,6 @@ def __init__(
619641
logger.error(error_message)
620642
raise RuntimeError(error_message)
621643

622-
zip_filename = f"{job_id}.zip"
623-
zip_url = None
624-
625644
batch_files = []
626645
for file_detail in job_details:
627646
try:
@@ -645,9 +664,6 @@ def __init__(
645664
"'download' delivery is not available for this job.",
646665
) from None
647666

648-
if zip_url is None:
649-
zip_url = urls["https"].replace(filename, zip_filename)
650-
651667
batch_files.append(
652668
_BatchJob._BatchJobFile(
653669
filename=filename,
@@ -657,18 +673,16 @@ def __init__(
657673
),
658674
)
659675

660-
if not batch_files or not zip_url:
676+
if not batch_files:
661677
raise ValueError(f"No job files for {job_id}.")
662678

663-
self._zip_filename = zip_filename
664-
self._zip_url = zip_url
665679
self._batch_http_api = batch_http_api
666680
self._output_dir = validate_path(output_dir, "output_dir") / job_id
667681
self._batch_files = batch_files
668682

669683
def download(
670684
self,
671-
filenames_to_download: Iterable[str] | None,
685+
filenames_to_download: Iterable[str],
672686
) -> list[Path]:
673687
self._output_dir.mkdir(
674688
exist_ok=True,
@@ -678,31 +692,22 @@ def download(
678692
file_paths = []
679693
tasks = []
680694

681-
if filenames_to_download is None:
695+
filenames_to_download = set(filenames_to_download)
696+
for batch_file in self._batch_files:
697+
if not filenames_to_download:
698+
break
699+
700+
if batch_file.filename not in filenames_to_download:
701+
continue
702+
682703
tasks.append(
683704
self._executor.submit(
684-
self._batch_http_api._download_batch_zip,
685-
self._zip_url,
686-
self._output_dir / self._zip_filename,
705+
self._batch_http_api._download_batch_file,
706+
batch_file,
707+
self._output_dir / batch_file.filename,
687708
),
688709
)
689-
else:
690-
filenames_to_download = set(filenames_to_download)
691-
for batch_file in self._batch_files:
692-
if not filenames_to_download:
693-
break
694-
695-
if batch_file.filename not in filenames_to_download:
696-
continue
697-
698-
tasks.append(
699-
self._executor.submit(
700-
self._batch_http_api._download_batch_file,
701-
batch_file,
702-
self._output_dir / batch_file.filename,
703-
),
704-
)
705-
filenames_to_download.remove(batch_file.filename)
710+
filenames_to_download.remove(batch_file.filename)
706711

707712
for completed in as_completed(tasks):
708713
path = completed.result()
@@ -712,7 +717,7 @@ def download(
712717

713718
async def download_async(
714719
self,
715-
filenames_to_download: Iterable[str] | None,
720+
filenames_to_download: Iterable[str],
716721
) -> list[Path]:
717722
self._output_dir.mkdir(
718723
exist_ok=True,
@@ -722,34 +727,22 @@ async def download_async(
722727
file_paths: list[Path] = []
723728
tasks = []
724729

725-
if filenames_to_download is None:
730+
filenames_to_download = set(filenames_to_download)
731+
for batch_file in self._batch_files:
732+
if not filenames_to_download:
733+
break
734+
735+
if batch_file.filename not in filenames_to_download:
736+
continue
737+
726738
tasks.append(
727739
asyncio.get_running_loop().run_in_executor(
728740
self._executor,
729-
self._batch_http_api._download_batch_zip,
730-
self._zip_url,
731-
self._output_dir / self._zip_filename,
741+
self._batch_http_api._download_batch_file,
742+
batch_file,
743+
self._output_dir / batch_file.filename,
732744
),
733745
)
734-
else:
735-
filenames_to_download = set(filenames_to_download)
736-
737-
tasks = []
738-
for batch_file in self._batch_files:
739-
if not filenames_to_download:
740-
break
741-
742-
if batch_file.filename not in filenames_to_download:
743-
continue
744-
745-
tasks.append(
746-
asyncio.get_running_loop().run_in_executor(
747-
self._executor,
748-
self._batch_http_api._download_batch_file,
749-
batch_file,
750-
self._output_dir / batch_file.filename,
751-
),
752-
)
753746

754747
for completed in asyncio.as_completed(tasks):
755748
try:

tests/test_historical_batch.py

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -450,28 +450,6 @@ def test_batch_download_all_files(
450450
stub.writestr("testfile.dbn", testfile_data)
451451

452452
job_id = "GLBX-20220610-5DEFXVTMSM"
453-
file_content = stub_zip_path.read_bytes()
454-
file_hash = f"sha256:{hashlib.sha256(file_content).hexdigest()}"
455-
file_size = len(file_content)
456-
457-
# Mock the call to list files so it returns a test manifest
458-
monkeypatch.setattr(
459-
historical_client.batch,
460-
"list_files",
461-
mocked_batch_list_files := MagicMock(
462-
return_value=[
463-
{
464-
"filename": "testfile.dbn",
465-
"hash": file_hash,
466-
"size": file_size,
467-
"urls": {
468-
"https": f"localhost:442/v0/batch/download/TESTUSER/{job_id}/testfile.dbn",
469-
"ftp": "",
470-
},
471-
},
472-
],
473-
),
474-
)
475453

476454
# Mock the call for get, so we can simulate a ZIP response
477455
zip_response = MagicMock()
@@ -497,13 +475,12 @@ def test_batch_download_all_files(
497475
)
498476

499477
# Assert
500-
assert mocked_batch_list_files.call_args.args == (job_id,)
501-
502478
call = mocked_get.call_args.kwargs
503479
assert call["allow_redirects"]
504480
assert call["headers"]["accept"] == "application/json"
505481
assert call["stream"]
506-
assert call["url"] == f"localhost:442/v0/batch/download/TESTUSER/{job_id}/{job_id}.zip"
482+
assert call["url"] == "https://localhost/v0/batch.download"
483+
assert call["params"] == {"job_id": job_id}
507484

508485
if keep_zip:
509486
assert (tmp_path / job_id / f"{job_id}.zip").exists()
@@ -539,28 +516,6 @@ async def test_batch_download_all_files_async(
539516
stub.writestr("testfile.dbn", testfile_data)
540517

541518
job_id = "GLBX-20220610-5DEFXVTMSM"
542-
file_content = stub_zip_path.read_bytes()
543-
file_hash = f"sha256:{hashlib.sha256(file_content).hexdigest()}"
544-
file_size = len(file_content)
545-
546-
# Mock the call to list files so it returns a test manifest
547-
monkeypatch.setattr(
548-
historical_client.batch,
549-
"list_files",
550-
mocked_batch_list_files := MagicMock(
551-
return_value=[
552-
{
553-
"filename": "testfile.dbn",
554-
"hash": file_hash,
555-
"size": file_size,
556-
"urls": {
557-
"https": f"localhost:442/v0/batch/download/TESTUSER/{job_id}/testfile.dbn",
558-
"ftp": "",
559-
},
560-
},
561-
],
562-
),
563-
)
564519

565520
# Mock the call for get, so we can simulate a ZIP response
566521
zip_response = MagicMock()
@@ -586,13 +541,12 @@ async def test_batch_download_all_files_async(
586541
)
587542

588543
# Assert
589-
assert mocked_batch_list_files.call_args.args == (job_id,)
590-
591544
call = mocked_get.call_args.kwargs
592545
assert call["allow_redirects"]
593546
assert call["headers"]["accept"] == "application/json"
594547
assert call["stream"]
595-
assert call["url"] == f"localhost:442/v0/batch/download/TESTUSER/{job_id}/{job_id}.zip"
548+
assert call["url"] == "https://localhost/v0/batch.download"
549+
assert call["params"] == {"job_id": job_id}
596550

597551
if keep_zip:
598552
assert (tmp_path / job_id / f"{job_id}.zip").exists()

0 commit comments

Comments
 (0)