firstrate-data 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,630 @@
1
+ import shutil
2
+ import zipfile
3
+ from collections.abc import Generator, Iterable
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+ from itertools import batched
8
+ from pathlib import Path
9
+ from tempfile import mkdtemp
10
+ from types import TracebackType
11
+ from typing import Self
12
+
13
+ import duckdb
14
+
15
+ from firstrate_data import config
16
+ from firstrate_data.domain import (
17
+ Adjustment,
18
+ AssetType,
19
+ BarType,
20
+ OtherData,
21
+ TickerListing,
22
+ Timeframe,
23
+ TradingHours,
24
+ )
25
+ from firstrate_data.download.requests import (
26
+ BarsRequest,
27
+ OtherDataRequest,
28
+ Request,
29
+ )
30
+ from firstrate_data.store import _sql
31
+ from firstrate_data.store._parquet_table import ParquetTable
32
+
33
+ # How many tickers one ``COPY`` may write. DuckDB buffers every open partition,
34
+ # so peak memory tracks distinct bar types rather than input size: 10,000 in one
35
+ # statement exhausted 12.7 GiB, 250 completed comfortably.
36
+ # See docs/notes/duckdb/partitioned-copy-ooms-past-a-few-thousand-partitions.md.
37
+ _TICKERS_PER_COPY = 250
38
+
39
+ # macOS drops an AppleDouble sidecar (``._name``) beside every file written to
40
+ # a non-native filesystem (exFAT, NTFS, network shares); it matches a bare
41
+ # ``*.parquet`` glob but isn't parquet. Store file names always start with the
42
+ # date that produced them, so anchoring on that digit excludes the sidecar.
43
+ PARQUET_FILES = "[0-9]*.parquet"
44
+
45
+ TIMEZONE = "America/New_York"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Ingested:
50
+ # tickers is None for a metafile, which has no ticker dimension to count
51
+ tickers: int | None
52
+ rows: int
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class TickerSpan:
57
+ """The bars the store holds for one ticker, as one unbroken span.
58
+
59
+ ``first_ts`` and ``last_ts`` are the ends of what is filed, not of what
60
+ the vendor has: a store holding two disjoint stretches of a recycled
61
+ symbol reports one span covering the gap between them.
62
+ """
63
+
64
+ ticker: str
65
+ first_ts: datetime
66
+ last_ts: datetime
67
+ rows: int
68
+
69
+
70
+ class ConflictingBarsError(ValueError):
71
+ """An archive's bars are not an update of the bars already filed for a ticker."""
72
+
73
+ def __init__(
74
+ self,
75
+ bar_type: BarType,
76
+ conflicts: Iterable[tuple[TickerSpan, TickerSpan]],
77
+ ) -> None:
78
+ self.bar_type = bar_type
79
+ self.conflicts = tuple(conflicts)
80
+ named = "; ".join(
81
+ f"{held.ticker} holds {held.first_ts:%Y-%m-%d}..{held.last_ts:%Y-%m-%d}, "
82
+ f"archive carries {arriving.first_ts:%Y-%m-%d}..{arriving.last_ts:%Y-%m-%d}"
83
+ for held, arriving in self.conflicts
84
+ )
85
+ super().__init__(
86
+ f"{len(self.conflicts)} tickers hold bars this archive neither starts "
87
+ f"with nor reaches past: {named}. Nothing was filed.",
88
+ )
89
+
90
+
91
+ class Store:
92
+ """A parquet store of market data, created at ``directory/firstrate_data``."""
93
+
94
+ def __init__(self, directory: Path) -> None:
95
+ self._directory = directory / "firstrate_data"
96
+ self._bars_directory = self._directory / "bars"
97
+ self._spool = self._directory / "spool"
98
+
99
+ self._bars_directory.mkdir(parents=True, exist_ok=True)
100
+ self._spool.mkdir(parents=True, exist_ok=True)
101
+
102
+ # DuckDB spills to `.tmp` in the working directory by default, which is
103
+ # the machine's boot disk. We make it spill inside the store.
104
+ # https://duckdb.org/docs/stable/guides/performance/how_to_tune_workloads#spilling-to-disk
105
+ # https://duckdb.org/docs/stable/configuration/overview#global-configuration-options
106
+ spill = self._directory / ".duckdb_temp"
107
+ spill.mkdir(parents=True, exist_ok=True)
108
+
109
+ self._connection = duckdb.connect()
110
+ self._connection.execute(f"SET temp_directory = '{spill}'")
111
+ # nothing here reads the tree in input order, so preserving that order
112
+ # only costs memory -- and it costs it in proportion to the archive
113
+ self._connection.execute("SET preserve_insertion_order = false")
114
+ self._connection.execute(f"SET TimeZone = '{TIMEZONE}'")
115
+
116
+ self._catalog_table = ParquetTable(
117
+ self._connection,
118
+ self._directory / "catalog.parquet",
119
+ _sql.CATALOG_SCHEMA,
120
+ )
121
+
122
+ @classmethod
123
+ def from_env(cls) -> Self:
124
+ """Return a Store with directory set from the .env file.
125
+
126
+ Raises
127
+ ------
128
+ MissingSettingError
129
+ If ``FIRSTRATE_DATA_PATH`` is not set.
130
+
131
+ """
132
+ return cls(config.firstrate_data_path())
133
+
134
+ def close(self) -> None:
135
+ self._connection.close()
136
+
137
+ def __enter__(self) -> Self:
138
+ return self
139
+
140
+ def __exit__(
141
+ self,
142
+ exc_type: type[BaseException] | None,
143
+ exc: BaseException | None,
144
+ traceback: TracebackType | None,
145
+ ) -> None:
146
+ self.close()
147
+
148
+ @property
149
+ def spool(self) -> Path:
150
+ return self._spool
151
+
152
+ def write(self, file: Path, request: Request) -> Ingested:
153
+ try:
154
+ if isinstance(request, OtherDataRequest):
155
+ return self._write_other_data(file, request.other_data)
156
+ return self._write_bars(file, request)
157
+ finally:
158
+ # parquet is the only copy
159
+ file.unlink(missing_ok=True)
160
+
161
+ def write_ticker_listing(
162
+ self,
163
+ asset_type: AssetType,
164
+ listing: Iterable[TickerListing],
165
+ ) -> int:
166
+ table = self._ticker_listing_table(asset_type)
167
+ staged = table.stage(
168
+ (
169
+ row.ticker,
170
+ row.full_name,
171
+ row.start_date,
172
+ row.end_date,
173
+ row.is_delisted,
174
+ )
175
+ for row in listing
176
+ )
177
+ if staged is None:
178
+ return 0
179
+
180
+ # the staging table's name is the store's own
181
+ return table.rewrite(f"SELECT * FROM {staged}") # noqa: S608
182
+
183
+ def _write_bars(self, archive: Path, request: BarsRequest) -> Ingested:
184
+ bar_type = request.bar_type
185
+
186
+ # minted once for the whole archive, not per batch: the point of the id
187
+ # is that one glob afterwards finds everything this archive wrote
188
+ ingest = _sql.ingest_id()
189
+
190
+ with self._unzipped(archive) as staging:
191
+ payloads = self._payloads_by_ticker(staging)
192
+ held = self._held_spans(bar_type, payloads)
193
+ columns_in_payload = _sql.payload_columns(
194
+ payload for files in payloads.values() for payload in files
195
+ )
196
+
197
+ staged_tree = staging / "bars"
198
+ rows = 0
199
+ for batch in batched(payloads.items(), _TICKERS_PER_COPY, strict=False):
200
+ rows += self._copy_batch(
201
+ bar_type,
202
+ staged_tree,
203
+ payloads=dict(batch),
204
+ columns_in_payload=columns_in_payload,
205
+ ingest=ingest,
206
+ )
207
+
208
+ arriving = self._written_spans(staged_tree, bar_type, ingest)
209
+ self._file_over(bar_type, held, arriving, staged_tree)
210
+
211
+ return Ingested(len(payloads), rows)
212
+
213
+ def _write_other_data(self, content: Path, other_data: OtherData) -> Ingested:
214
+ # replaced whole rather than reconciled: a metafile is small, the vendor
215
+ # serves the entire history each time, and it carries no key to merge on
216
+ with self._other_data_payloads(content, other_data) as (payloads, per_ticker):
217
+ rows = self._other_data_table(other_data).rewrite(
218
+ _sql.other_data_select(payloads, other_data, per_ticker),
219
+ )
220
+ return Ingested(None, rows)
221
+
222
+ def bars( # noqa: PLR0913
223
+ self,
224
+ *,
225
+ asset_type: AssetType | None = None,
226
+ timeframe: Timeframe | None = None,
227
+ adjustment: Adjustment | None = None,
228
+ ticker: str | Iterable[str] | None = None,
229
+ start: date | None = None,
230
+ end: date | None = None,
231
+ hours: TradingHours = TradingHours.ALL,
232
+ ) -> duckdb.DuckDBPyRelation:
233
+ """Read bars across the tree; every omitted selector spans all its values.
234
+
235
+ Raises
236
+ ------
237
+ ValueError
238
+ If ``hours`` names a session and ``asset_type`` defines none.
239
+
240
+ """
241
+ return self._read(
242
+ BarType(asset_type, timeframe=timeframe, adjustment=adjustment),
243
+ ticker,
244
+ start=start,
245
+ end=end,
246
+ hours=hours,
247
+ )
248
+
249
+ def last_bar(
250
+ self,
251
+ bar_type: BarType,
252
+ ticker_prefix: str | None = None,
253
+ ) -> datetime | None:
254
+ """Return the timestamp of the newest bar under `bar_type`, narrowed to `ticker_prefix`, or None."""
255
+ # max, not min: an ingest writes an archive as one unit, so the newest
256
+ # bar dates the whole ingest. A min would return the last bar of the
257
+ # most long-dead ticker in the range, which no later download updates
258
+ # https://duckdb.org/docs/stable/sql/functions/aggregates#maxarg
259
+ named = (
260
+ ""
261
+ if ticker_prefix is None
262
+ else f" AND starts_with(ticker, {_sql.sql_literal(ticker_prefix.upper())})"
263
+ )
264
+ latest = self._connection.sql(
265
+ f"SELECT max(last_ts) FROM ({self._catalog_table.select()}) " # noqa: S608
266
+ f"WHERE {_sql.bar_type_where(bar_type)}{named}",
267
+ ).fetchone()
268
+ return latest[0] if latest else None
269
+
270
+ def catalog(self) -> duckdb.DuckDBPyRelation:
271
+ return self._catalog_table.relation()
272
+
273
+ def ticker_listing(
274
+ self,
275
+ *,
276
+ asset_type: AssetType | None = None,
277
+ ticker: str | None = None,
278
+ ) -> duckdb.DuckDBPyRelation:
279
+ """Read the ticker listing.
280
+
281
+ Raises
282
+ ------
283
+ FileNotFoundError
284
+ If no ticker listing is in the store.
285
+
286
+ """
287
+ asset_types = [asset_type] if asset_type is not None else list(AssetType)
288
+ stored = [
289
+ table
290
+ for one in asset_types
291
+ if (table := self._ticker_listing_table(one)).exists()
292
+ ]
293
+ if not stored:
294
+ msg = "no ticker listing in this store -- download it first"
295
+ raise FileNotFoundError(msg)
296
+
297
+ # the asset type is a level of the path rather than a column of the
298
+ # file, and DuckDB reads that level back as a column of the relation
299
+ listing = self._connection.sql(
300
+ "\nUNION ALL BY NAME\n".join(
301
+ f"SELECT * FROM ({table.select()})" # noqa: S608
302
+ for table in stored
303
+ ),
304
+ )
305
+ if ticker is not None:
306
+ listing = listing.filter(f"ticker = {_sql.sql_literal(ticker)}")
307
+ return listing
308
+
309
+ def splits(self) -> duckdb.DuckDBPyRelation:
310
+ return self._other_data(OtherData.SPLITS)
311
+
312
+ def dividends(self) -> duckdb.DuckDBPyRelation:
313
+ return self._other_data(OtherData.DIVIDENDS)
314
+
315
+ def contract_dates(self) -> duckdb.DuckDBPyRelation:
316
+ return self._other_data(OtherData.CONTRACT_DATES)
317
+
318
+ @contextmanager
319
+ def _unzipped(self, archive: Path) -> Generator[Path]:
320
+ staging = Path(mkdtemp(dir=self._directory, prefix=".ingest-"))
321
+ try:
322
+ with zipfile.ZipFile(archive) as opened:
323
+ opened.extractall(staging)
324
+ yield staging
325
+ finally:
326
+ # only once the write has committed or failed -- an ingest that
327
+ # unlinked first would be reading files it had already promised to
328
+ # delete. A failed ingest costs a re-unzip, not a re-fetch.
329
+ shutil.rmtree(staging, ignore_errors=True)
330
+
331
+ def _payloads_by_ticker(self, directory: Path) -> dict[str, list[Path]]:
332
+ return {
333
+ ticker: [Path(file) for file in files]
334
+ for ticker, files in self._connection.sql(
335
+ _sql.payload_tickers_select(directory),
336
+ ).fetchall()
337
+ }
338
+
339
+ def _copy_batch(
340
+ self,
341
+ bar_type: BarType,
342
+ destination: Path,
343
+ *,
344
+ payloads: dict[str, list[Path]],
345
+ columns_in_payload: int,
346
+ ingest: str,
347
+ ) -> int:
348
+ select = _sql.bars_select(
349
+ [payload for files in payloads.values() for payload in files],
350
+ # no ticker: one COPY writes a batch of them, and each row's is
351
+ # read back out of the payload that carried it
352
+ bar_type,
353
+ columns_in_payload,
354
+ )
355
+ levels = ", ".join(bar_type.levels())
356
+ written = self._connection.execute(f"""
357
+ COPY ({select})
358
+ TO {_sql.sql_literal(str(destination))}
359
+ (FORMAT PARQUET,
360
+ COMPRESSION ZSTD,
361
+ PARTITION_BY ({levels}),
362
+ FILENAME_PATTERN '{_sql.filename_pattern(ingest)}',
363
+ APPEND)
364
+ """).fetchall()
365
+ return int(written[0][0]) if written else 0
366
+
367
+ def _held_spans(
368
+ self,
369
+ bar_type: BarType,
370
+ tickers: Iterable[str],
371
+ ) -> dict[str, TickerSpan]:
372
+ """The span the catalog holds for each of `tickers` under `bar_type`."""
373
+ named = _sql.sql_list(tickers)
374
+ held = self._connection.sql(
375
+ f"SELECT ticker, first_ts, last_ts, rows " # noqa: S608
376
+ f"FROM ({self._catalog_table.select()}) "
377
+ f"WHERE {_sql.bar_type_where(bar_type)} AND ticker IN {named}",
378
+ ).fetchall()
379
+ return {
380
+ ticker: TickerSpan(ticker, first_ts, last_ts, int(rows))
381
+ for ticker, first_ts, last_ts, rows in held
382
+ }
383
+
384
+ def _written_spans(
385
+ self,
386
+ root: Path,
387
+ bar_type: BarType,
388
+ ingest: str,
389
+ ) -> dict[str, TickerSpan]:
390
+ # leads with the same [0-9] as PARQUET_FILES, so an ingest's files are
391
+ # ordinary files of the tree that a read finds without knowing this exists
392
+ files = sorted(
393
+ str(file)
394
+ for file in self._bar_files(
395
+ bar_type,
396
+ files=f"[0-9]*_{ingest}_*.parquet",
397
+ root=root,
398
+ )
399
+ )
400
+ if not files:
401
+ return {}
402
+ ticker = _sql.hive_ticker_expression("file")
403
+ spans = self._connection.sql(
404
+ f"SELECT {ticker} AS ticker, min(first_ts), max(last_ts), sum(rows) " # noqa: S608
405
+ f"FROM ({_sql.footer_file_spans_select(files)}) GROUP BY 1",
406
+ ).fetchall()
407
+ return {
408
+ named: TickerSpan(named, first_ts, last_ts, int(rows))
409
+ for named, first_ts, last_ts, rows in spans
410
+ }
411
+
412
+ def _file_over(
413
+ self,
414
+ bar_type: BarType,
415
+ held: dict[str, TickerSpan],
416
+ arriving: dict[str, TickerSpan],
417
+ staged_tree: Path,
418
+ ) -> None:
419
+ """Move a staged ingest into the tree, or refuse it before it gets there.
420
+
421
+ Raises
422
+ ------
423
+ ConflictingBarsError
424
+ If any ticker's arriving bars are not an update of the filed ones.
425
+ The tree and the catalog are untouched.
426
+
427
+ """
428
+ # an update is the same history downloaded again: the same first bar,
429
+ # and a last one no earlier than the held one. A different start is a
430
+ # different history under one name, and an archive ending before what
431
+ # is filed is less than the store holds. An archive ending on the same
432
+ # bar is the same archive, re-fetched -- filing it over replaces the
433
+ # ticker's files with identical ones, which is what makes a re-run of
434
+ # an interrupted bundle resume rather than refuse
435
+ conflicts = [
436
+ (held[ticker], span)
437
+ for ticker, span in sorted(arriving.items())
438
+ if ticker in held
439
+ and not (
440
+ span.first_ts == held[ticker].first_ts
441
+ and span.last_ts >= held[ticker].last_ts
442
+ )
443
+ ]
444
+ if conflicts:
445
+ raise ConflictingBarsError(bar_type, conflicts)
446
+
447
+ # the ticker's held bars go only once their replacement is in the tree,
448
+ # and only the files this ingest did not write: an update carries the
449
+ # whole history again, so what it lands beside is the previous copy of it
450
+ landed = self._move_into_tree(staged_tree)
451
+ for ticker in arriving.keys() & held.keys():
452
+ for file in self._bar_files(bar_type.from_ticker(ticker)):
453
+ if file not in landed:
454
+ file.unlink(missing_ok=True)
455
+
456
+ self._record_spans(bar_type, [span for _, span in sorted(arriving.items())])
457
+
458
+ def _record_spans(self, bar_type: BarType, spans: Iterable[TickerSpan]) -> None:
459
+ """File `spans` under `bar_type`, over whatever the catalog held for them.
460
+
461
+ `bar_type` names every level but the ticker, which each span carries.
462
+ """
463
+ levels = bar_type.from_ticker(None).levels()
464
+ staged = self._catalog_table.stage(
465
+ (
466
+ *(levels[level] for level in BarType.fields() if level != "ticker"),
467
+ span.ticker,
468
+ span.first_ts,
469
+ span.last_ts,
470
+ span.rows,
471
+ )
472
+ for span in spans
473
+ )
474
+ if staged is None:
475
+ return
476
+
477
+ matched = " AND ".join(
478
+ f"held.{level} IS NOT DISTINCT FROM staged.{level}"
479
+ for level in BarType.fields()
480
+ )
481
+ self._catalog_table.rewrite(f"""
482
+ SELECT * FROM ({self._catalog_table.select()}) AS held
483
+ WHERE NOT EXISTS (
484
+ SELECT 1 FROM {staged} AS staged WHERE {matched}
485
+ )
486
+ UNION ALL BY NAME
487
+ SELECT * FROM {staged}
488
+ """) # noqa: S608
489
+
490
+ def _move_into_tree(self, staged_tree: Path) -> set[Path]:
491
+ # a rename rather than a copy: the staging directory lives inside the
492
+ # store, so the bars never cross a filesystem however large the archive
493
+ landed = set()
494
+ for staged in staged_tree.rglob(PARQUET_FILES):
495
+ landing = self._bars_directory / staged.relative_to(staged_tree)
496
+ landing.parent.mkdir(parents=True, exist_ok=True)
497
+ staged.replace(landing)
498
+ landed.add(landing)
499
+ return landed
500
+
501
+ @contextmanager
502
+ def _other_data_payloads(
503
+ self,
504
+ content: Path,
505
+ other_data: OtherData,
506
+ ) -> Generator[tuple[list[Path], bool]]:
507
+ # yields the payloads and whether each is named for one ticker -- an
508
+ # archive's are, a bare CSV's is not -- which decides whether the ticker
509
+ # can be read back at all
510
+ staging = Path(mkdtemp(dir=self._directory, prefix=".ingest-"))
511
+ try:
512
+ if not zipfile.is_zipfile(content):
513
+ # copied rather than read in place: the staging directory is
514
+ # what this scope promises to clean up, and the spooled file is
515
+ # the caller's
516
+ bare = staging / f"{other_data.value}.csv"
517
+ shutil.copyfile(content, bare)
518
+ yield [bare], False
519
+ else:
520
+ with zipfile.ZipFile(content) as opened:
521
+ opened.extractall(staging)
522
+ payloads = sorted(
523
+ path
524
+ for path in staging.iterdir()
525
+ if path.is_file() and _sql.is_other_data_payload(path.name)
526
+ )
527
+ if not payloads:
528
+ msg = f"{other_data.value} archive holds no payload"
529
+ raise ValueError(msg)
530
+ yield payloads, True
531
+ finally:
532
+ shutil.rmtree(staging, ignore_errors=True)
533
+
534
+ def _read(
535
+ self,
536
+ bar_type: BarType,
537
+ ticker: str | Iterable[str] | None,
538
+ *,
539
+ start: date | None = None,
540
+ end: date | None = None,
541
+ hours: TradingHours = TradingHours.ALL,
542
+ ) -> duckdb.DuckDBPyRelation:
543
+ # DuckDB raises on a glob matching nothing, and an empty store, an
544
+ # unwritten bar type, or an unheld ticker matches nothing
545
+ glob = self._matching_glob(bar_type, ticker)
546
+ if glob is None:
547
+ return self._connection.sql(_sql.empty_select(_sql.STORED_BAR_SCHEMA))
548
+
549
+ bars_relation = self._connection.sql(_sql.stored_bars_select(glob))
550
+
551
+ # both are ``WHERE``, not selectors: neither the clock nor the calendar
552
+ # is a level of the tree, so no glob can narrow them
553
+ dates = _sql.date_range_where(start, end)
554
+ if dates is not None:
555
+ bars_relation = bars_relation.filter(dates)
556
+ if hours is TradingHours.REGULAR:
557
+ bars_relation = bars_relation.filter(
558
+ _sql.regular_trading_hours_where(bar_type.asset_type),
559
+ )
560
+ return bars_relation
561
+
562
+ def _ticker_listing_table(self, asset_type: AssetType) -> ParquetTable:
563
+ return ParquetTable(
564
+ self._connection,
565
+ self._bars_directory
566
+ / f"asset_type={asset_type.value}"
567
+ / "ticker_listing.parquet",
568
+ _sql.TICKER_LISTING_SCHEMA,
569
+ )
570
+
571
+ def _other_data_table(self, other_data: OtherData) -> ParquetTable:
572
+ # no declared schema: the vendor's columns are whatever the sniffer
573
+ # read, so `_other_data` refuses a missing metafile rather than
574
+ # answering with an empty relation of a shape it cannot know
575
+ return ParquetTable(
576
+ self._connection,
577
+ self._directory / f"{other_data.value}.parquet",
578
+ {},
579
+ )
580
+
581
+ def _other_data(self, other_data: OtherData) -> duckdb.DuckDBPyRelation:
582
+ table = self._other_data_table(other_data)
583
+ if not table.exists():
584
+ msg = f"no {other_data.value} metafile in this store -- fetch it first"
585
+ raise FileNotFoundError(msg)
586
+ return table.relation()
587
+
588
+ def _matching_glob(
589
+ self,
590
+ bar_type: BarType,
591
+ ticker: str | Iterable[str] | None,
592
+ ) -> str | list[str] | None:
593
+ """AI: Return the glob(s) for the files `bar_type` and `ticker` match.
594
+
595
+ Returns ``None`` when the store has no file for any of them.
596
+ """
597
+ if ticker is None:
598
+ if next(self._bar_files(bar_type), None) is None:
599
+ return None
600
+ return self._bar_glob(bar_type)
601
+
602
+ named = [ticker] if isinstance(ticker, str) else list(ticker)
603
+ scoped = [bar_type.from_ticker(one) for one in named]
604
+ held = [one for one in scoped if next(self._bar_files(one), None) is not None]
605
+ return [self._bar_glob(one) for one in held] or None
606
+
607
+ def _bar_files(
608
+ self,
609
+ bar_type: BarType,
610
+ files: str = PARQUET_FILES,
611
+ root: Path | None = None,
612
+ ) -> Generator[Path]:
613
+ # `root` names a staged tree an ingest is still being validated in
614
+ return (root or self._bars_directory).glob(_bar_pattern(bar_type, files))
615
+
616
+ def _bar_glob(self, bar_type: BarType) -> str:
617
+ path = self._bars_directory / _bar_pattern(bar_type, PARQUET_FILES)
618
+ # forward slashes, which DuckDB's glob and Python's both read on every
619
+ # platform. A backslash is the pattern language's escape character, so a
620
+ # native Windows path would read ``\[0-9]`` as the literal ``[0-9]``.
621
+ return path.as_posix()
622
+
623
+
624
+ def _bar_pattern(bar_type: BarType, files: str) -> str:
625
+ """Build the tree-relative glob matching every file `bar_type` addresses."""
626
+ levels = (
627
+ f"{key}={'*' if value is None else value}"
628
+ for key, value in bar_type.levels().items()
629
+ )
630
+ return "/".join([*levels, files])