diff --git a/integrations/python/dataloader/pyproject.toml b/integrations/python/dataloader/pyproject.toml index b0b3bcf17..b36d4c4e2 100644 --- a/integrations/python/dataloader/pyproject.toml +++ b/integrations/python/dataloader/pyproject.toml @@ -2,9 +2,6 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [project] name = "openhouse.dataloader" dynamic = ["version"] @@ -13,7 +10,7 @@ readme = "README.md" requires-python = ">=3.12" license = {text = "BSD-2-Clause"} keywords = ["openhouse", "data-loader", "lakehouse", "iceberg", "datafusion"] -dependencies = ["datafusion==51.0.0", "pyiceberg @ git+https://github.com/sumedhsakdeo/iceberg-python@75ba28bfc6d8bbeac398357c6db80327632a2dc8", "requests>=2.31.0", "sqlglot>=29.0.0", "tenacity>=8.0.0"] +dependencies = ["datafusion==51.0.0", "pyiceberg~=0.11.0", "requests>=2.31.0", "sqlglot>=29.0.0", "tenacity>=8.0.0"] [project.optional-dependencies] dev = ["responses>=0.25.0", "ruff>=0.9.0", "pytest>=8.0.0", "twine>=6.0.0", "mypy>=1.14.0", "types-requests>=2.31.0"] diff --git a/integrations/python/dataloader/src/openhouse/dataloader/data_loader.py b/integrations/python/dataloader/src/openhouse/dataloader/data_loader.py index 6bcfc5912..8ed5c4ad6 100644 --- a/integrations/python/dataloader/src/openhouse/dataloader/data_loader.py +++ b/integrations/python/dataloader/src/openhouse/dataloader/data_loader.py @@ -79,7 +79,6 @@ def __init__( filters: Filter | None = None, context: DataLoaderContext | None = None, max_attempts: int = 3, - batch_size: int | None = None, ): """ Args: @@ -92,10 +91,6 @@ def __init__( filters: Row filter expression, defaults to always_true() (all rows) context: Data loader context max_attempts: Total number of attempts including the initial try (default 3) - batch_size: Maximum number of rows per RecordBatch yielded by each split. - Passed to PyArrow's Scanner which produces batches of at most this many - rows. Smaller values reduce peak memory but increase per-batch overhead. - None uses the PyArrow default (~131K rows). """ if branch is not None and branch.strip() == "": raise ValueError("branch must not be empty or whitespace") @@ -108,7 +103,6 @@ def __init__( self._filters = filters if filters is not None else always_true() self._context = context or DataLoaderContext() self._max_attempts = max_attempts - self._batch_size = batch_size @cached_property def _iceberg_table(self) -> Table: @@ -203,5 +197,4 @@ def __iter__(self) -> Iterator[DataLoaderSplit]: scan_context=scan_context, transform_sql=transform_sql, udf_registry=self._context.udf_registry, - batch_size=self._batch_size, ) diff --git a/integrations/python/dataloader/src/openhouse/dataloader/data_loader_split.py b/integrations/python/dataloader/src/openhouse/dataloader/data_loader_split.py index 381d34d38..8c5e94f95 100644 --- a/integrations/python/dataloader/src/openhouse/dataloader/data_loader_split.py +++ b/integrations/python/dataloader/src/openhouse/dataloader/data_loader_split.py @@ -7,7 +7,7 @@ from datafusion.context import SessionContext from pyarrow import RecordBatch from pyiceberg.io.pyarrow import ArrowScan -from pyiceberg.table import ArrivalOrder, FileScanTask +from pyiceberg.table import FileScanTask from openhouse.dataloader._table_scan_context import TableScanContext from openhouse.dataloader.table_identifier import TableIdentifier @@ -56,13 +56,11 @@ def __init__( scan_context: TableScanContext, transform_sql: str | None = None, udf_registry: UDFRegistry | None = None, - batch_size: int | None = None, ): self._file_scan_task = file_scan_task self._scan_context = scan_context self._transform_sql = transform_sql self._udf_registry = udf_registry or NoOpRegistry() - self._batch_size = batch_size @property def id(self) -> str: @@ -81,8 +79,7 @@ def __iter__(self) -> Iterator[RecordBatch]: """Reads the file scan task and yields Arrow RecordBatches. Uses PyIceberg's ArrowScan to handle format dispatch, schema resolution, - delete files, and partition spec lookups. The number of batches loaded - into memory at once is bounded to prevent using too much memory at once. + delete files, and partition spec lookups. """ ctx = self._scan_context arrow_scan = ArrowScan( @@ -92,10 +89,7 @@ def __iter__(self) -> Iterator[RecordBatch]: row_filter=ctx.row_filter, ) - batches = arrow_scan.to_record_batches( - [self._file_scan_task], - order=ArrivalOrder(concurrent_streams=1, batch_size=self._batch_size), - ) + batches = arrow_scan.to_record_batches([self._file_scan_task]) if self._transform_sql is None: yield from batches diff --git a/integrations/python/dataloader/tests/integration_tests.py b/integrations/python/dataloader/tests/integration_tests.py index dca7cba76..adfc920c1 100644 --- a/integrations/python/dataloader/tests/integration_tests.py +++ b/integrations/python/dataloader/tests/integration_tests.py @@ -166,18 +166,13 @@ def read_token() -> str: snap1 = OpenHouseDataLoader(catalog=catalog, database=DATABASE_ID, table=TABLE_ID).snapshot_id assert snap1 is not None - # 4. Read all data with batch_size and verify batch count - loader = OpenHouseDataLoader(catalog=catalog, database=DATABASE_ID, table=TABLE_ID, batch_size=2) - batches = [batch for split in loader for batch in split] - assert len(batches) == 2, f"Expected 2 batches (3 rows, batch_size=2), got {len(batches)}" - for batch in batches: - assert batch.num_rows <= 2 - result = pa.concat_tables([pa.Table.from_batches([b]) for b in batches]).sort_by(COL_ID) + # 4. Read all data + result = _read_all(OpenHouseDataLoader(catalog=catalog, database=DATABASE_ID, table=TABLE_ID)) assert result.num_rows == 3 assert result.column(COL_ID).to_pylist() == [1, 2, 3] assert result.column(COL_NAME).to_pylist() == ["alice", "bob", "charlie"] assert result.column(COL_SCORE).to_pylist() == [1.1, 2.2, 3.3] - print(f"PASS: read all {result.num_rows} rows in {len(batches)} batches (batch_size=2)") + print(f"PASS: read all {result.num_rows} rows") # 5a. Row filter loader = OpenHouseDataLoader(catalog=catalog, database=DATABASE_ID, table=TABLE_ID, filters=col(COL_ID) > 1) diff --git a/integrations/python/dataloader/tests/test_arrival_order.py b/integrations/python/dataloader/tests/test_arrival_order.py deleted file mode 100644 index 21e3870f2..000000000 --- a/integrations/python/dataloader/tests/test_arrival_order.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Tests verifying the ArrivalOrder API from pyiceberg PR #3046 is available and functional. - -These tests confirm that the openhouse dataloader can access the new ScanOrder class hierarchy -added upstream (apache/iceberg-python#3046) and that ArrowScan.to_record_batches accepts the -order parameter. -""" - -import os - -import pyarrow as pa -import pyarrow.parquet as pq -import pytest -from pyiceberg.expressions import AlwaysTrue -from pyiceberg.io import load_file_io -from pyiceberg.io.pyarrow import ArrowScan -from pyiceberg.manifest import DataFile, FileFormat -from pyiceberg.partitioning import UNPARTITIONED_PARTITION_SPEC -from pyiceberg.schema import Schema -from pyiceberg.table import ArrivalOrder, FileScanTask, ScanOrder, TaskOrder -from pyiceberg.table.metadata import new_table_metadata -from pyiceberg.table.sorting import UNSORTED_SORT_ORDER -from pyiceberg.types import LongType, NestedField, StringType - -_SCHEMA = Schema( - NestedField(field_id=1, name="id", field_type=LongType(), required=False), - NestedField(field_id=2, name="name", field_type=StringType(), required=False), -) - - -def _write_parquet(tmp_path: object, table: pa.Table) -> str: - """Write a parquet file with Iceberg field IDs and return its path.""" - file_path = str(tmp_path / "test.parquet") # type: ignore[operator] - fields = [field.with_metadata({b"PARQUET:field_id": str(i + 1).encode()}) for i, field in enumerate(table.schema)] - pq.write_table(table.cast(pa.schema(fields)), file_path) - return file_path - - -def _make_arrow_scan(tmp_path: object, file_path: str) -> ArrowScan: - metadata = new_table_metadata( - schema=_SCHEMA, - partition_spec=UNPARTITIONED_PARTITION_SPEC, - sort_order=UNSORTED_SORT_ORDER, - location=str(tmp_path), - properties={}, - ) - return ArrowScan( - table_metadata=metadata, - io=load_file_io(properties={}, location=file_path), - projected_schema=_SCHEMA, - row_filter=AlwaysTrue(), - ) - - -def _make_file_scan_task(file_path: str, table: pa.Table) -> FileScanTask: - data_file = DataFile.from_args( - file_path=file_path, - file_format=FileFormat.PARQUET, - record_count=table.num_rows, - file_size_in_bytes=os.path.getsize(file_path), - ) - data_file._spec_id = 0 - return FileScanTask(data_file=data_file) - - -def _sample_table() -> pa.Table: - return pa.table( - { - "id": pa.array([1, 2, 3], type=pa.int64()), - "name": pa.array(["alice", "bob", "charlie"], type=pa.string()), - } - ) - - -class TestScanOrderImports: - """Verify the ScanOrder class hierarchy is importable from pyiceberg.table.""" - - def test_scan_order_base_class_exists(self) -> None: - assert ScanOrder is not None - - def test_task_order_is_scan_order(self) -> None: - assert issubclass(TaskOrder, ScanOrder) - - def test_arrival_order_is_scan_order(self) -> None: - assert issubclass(ArrivalOrder, ScanOrder) - - def test_arrival_order_default_params(self) -> None: - ao = ArrivalOrder() - assert ao.concurrent_streams == 8 - assert ao.batch_size is None - assert ao.max_buffered_batches == 16 - - def test_arrival_order_custom_params(self) -> None: - ao = ArrivalOrder(concurrent_streams=4, batch_size=32768, max_buffered_batches=8) - assert ao.concurrent_streams == 4 - assert ao.batch_size == 32768 - assert ao.max_buffered_batches == 8 - - def test_arrival_order_rejects_invalid_concurrent_streams(self) -> None: - with pytest.raises(ValueError, match="concurrent_streams"): - ArrivalOrder(concurrent_streams=0) - - def test_arrival_order_rejects_invalid_max_buffered_batches(self) -> None: - with pytest.raises(ValueError, match="max_buffered_batches"): - ArrivalOrder(max_buffered_batches=0) - - -class TestToRecordBatchesOrder: - """Verify ArrowScan.to_record_batches accepts the order parameter and returns correct data.""" - - def test_default_order_returns_all_rows(self, tmp_path: object) -> None: - """Default (TaskOrder) still works — backward compatible.""" - table = _sample_table() - file_path = _write_parquet(tmp_path, table) - arrow_scan = _make_arrow_scan(tmp_path, file_path) - task = _make_file_scan_task(file_path, table) - batches = list(arrow_scan.to_record_batches([task])) - result = pa.Table.from_batches(batches).sort_by("id") - assert result.column("id").to_pylist() == [1, 2, 3] - - def test_explicit_task_order_returns_all_rows(self, tmp_path: object) -> None: - table = _sample_table() - file_path = _write_parquet(tmp_path, table) - arrow_scan = _make_arrow_scan(tmp_path, file_path) - task = _make_file_scan_task(file_path, table) - batches = list(arrow_scan.to_record_batches([task], order=TaskOrder())) - result = pa.Table.from_batches(batches).sort_by("id") - assert result.column("id").to_pylist() == [1, 2, 3] - - def test_arrival_order_returns_all_rows(self, tmp_path: object) -> None: - table = _sample_table() - file_path = _write_parquet(tmp_path, table) - arrow_scan = _make_arrow_scan(tmp_path, file_path) - task = _make_file_scan_task(file_path, table) - batches = list(arrow_scan.to_record_batches([task], order=ArrivalOrder(concurrent_streams=2))) - result = pa.Table.from_batches(batches).sort_by("id") - assert result.column("id").to_pylist() == [1, 2, 3] - assert result.column("name").to_pylist() == ["alice", "bob", "charlie"] diff --git a/integrations/python/dataloader/tests/test_data_loader.py b/integrations/python/dataloader/tests/test_data_loader.py index 0d40fa03b..357f20150 100644 --- a/integrations/python/dataloader/tests/test_data_loader.py +++ b/integrations/python/dataloader/tests/test_data_loader.py @@ -541,30 +541,3 @@ def fake_scan(**kwargs): branch_splits = list(OpenHouseDataLoader(catalog=catalog, database="db", table="tbl", branch="my-branch")) assert len(branch_splits) == 1 assert branch_splits[0]._file_scan_task.file.file_path == "branch.parquet" - - -# --- batch_size tests --- - - -def test_batch_size_forwarded_to_splits(tmp_path): - """batch_size is correctly passed through to each DataLoaderSplit.""" - catalog = _make_real_catalog(tmp_path) - - loader = OpenHouseDataLoader(catalog=catalog, database="db", table="tbl", batch_size=32768) - splits = list(loader) - - assert len(splits) >= 1 - for split in splits: - assert split._batch_size == 32768 - - -def test_batch_size_default_is_none(tmp_path): - """Omitting batch_size defaults to None in each split.""" - catalog = _make_real_catalog(tmp_path) - - loader = OpenHouseDataLoader(catalog=catalog, database="db", table="tbl") - splits = list(loader) - - assert len(splits) >= 1 - for split in splits: - assert split._batch_size is None diff --git a/integrations/python/dataloader/tests/test_data_loader_split.py b/integrations/python/dataloader/tests/test_data_loader_split.py index 3ef308792..dd382b7cb 100644 --- a/integrations/python/dataloader/tests/test_data_loader_split.py +++ b/integrations/python/dataloader/tests/test_data_loader_split.py @@ -43,7 +43,6 @@ def _create_test_split( transform_sql: str | None = None, table_id: TableIdentifier = _DEFAULT_TABLE_ID, udf_registry: UDFRegistry | None = None, - batch_size: int | None = None, ) -> DataLoaderSplit: """Create a DataLoaderSplit for testing by writing data to disk. @@ -104,7 +103,6 @@ def _create_test_split( scan_context=scan_context, transform_sql=transform_sql, udf_registry=udf_registry, - batch_size=batch_size, ) @@ -398,47 +396,3 @@ def test_transform_with_quoted_identifier(tmp_path): assert result.num_rows == 1 assert result.column("name").to_pylist() == ["MASKED"] - - -# --- batch_size tests --- - -_BATCH_SCHEMA = Schema( - NestedField(field_id=1, name="id", field_type=LongType(), required=False), -) - - -def _make_table(num_rows: int) -> pa.Table: - return pa.table({"id": pa.array(list(range(num_rows)), type=pa.int64())}) - - -def test_split_batch_size_limits_rows_per_batch(tmp_path): - """When batch_size is set, each RecordBatch has at most that many rows.""" - table = _make_table(100) - split = _create_test_split(tmp_path, table, FileFormat.PARQUET, _BATCH_SCHEMA, batch_size=10) - - batches = list(split) - - assert len(batches) >= 2, "Expected multiple batches with batch_size=10 and 100 rows" - for batch in batches: - assert batch.num_rows <= 10 - assert sum(b.num_rows for b in batches) == 100 - - -def test_split_batch_size_none_returns_all_rows(tmp_path): - """Default batch_size (None) returns all data correctly.""" - table = _make_table(50) - split = _create_test_split(tmp_path, table, FileFormat.PARQUET, _BATCH_SCHEMA) - - result = pa.Table.from_batches(list(split)) - assert result.num_rows == 50 - assert sorted(result.column("id").to_pylist()) == list(range(50)) - - -def test_split_batch_size_preserves_data(tmp_path): - """batch_size controls chunking but all data is preserved.""" - table = _make_table(25) - split = _create_test_split(tmp_path, table, FileFormat.PARQUET, _BATCH_SCHEMA, batch_size=7) - - result = pa.Table.from_batches(list(split)) - assert result.num_rows == 25 - assert sorted(result.column("id").to_pylist()) == list(range(25)) diff --git a/integrations/python/dataloader/uv.lock b/integrations/python/dataloader/uv.lock index 74d649c9f..063a4d135 100644 --- a/integrations/python/dataloader/uv.lock +++ b/integrations/python/dataloader/uv.lock @@ -572,7 +572,7 @@ dev = [ requires-dist = [ { name = "datafusion", specifier = "==51.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, - { name = "pyiceberg", git = "https://github.com/sumedhsakdeo/iceberg-python?rev=75ba28bfc6d8bbeac398357c6db80327632a2dc8" }, + { name = "pyiceberg", specifier = "~=0.11.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "requests", specifier = ">=2.31.0" }, { name = "responses", marker = "extra == 'dev'", specifier = ">=0.25.0" }, @@ -761,7 +761,7 @@ wheels = [ [[package]] name = "pyiceberg" version = "0.11.0" -source = { git = "https://github.com/sumedhsakdeo/iceberg-python?rev=75ba28bfc6d8bbeac398357c6db80327632a2dc8#75ba28bfc6d8bbeac398357c6db80327632a2dc8" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "click" }, @@ -776,6 +776,23 @@ dependencies = [ { name = "tenacity" }, { name = "zstandard" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/bd/22/3d02ad39710bf51834d108e6d548cee9c1916850460ccba80db47a982567/pyiceberg-0.11.0.tar.gz", hash = "sha256:095bbafc87d204cf8d3ffc1c434e07cf9a67a709192ac0b11dcb0f8251f7ad4e", size = 1074873, upload-time = "2026-02-10T02:28:20.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/37/b5a818444f5563ee2dacac93cc690e63396ab60308be353502dc7008168b/pyiceberg-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6fc89c9581d42ff2383cc9ba3f443ab9f175d8e85216ecbd819e955e9069bc46", size = 532694, upload-time = "2026-02-10T02:28:01.298Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f9/ef76d6cf62a7ba9d61a5e20216000d4b366d8eac3be5c89c2ce5c8eb38f9/pyiceberg-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e2dfdf5438cc5ad8eb8b2e3f7a41ab6f286fe8b6fd6f5c1407381f627097e2e0", size = 532901, upload-time = "2026-02-10T02:28:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/15/2a/bcec7d0ca75259cdb83ddceee1c59cdad619d2dfe36cee802c7e7207d96a/pyiceberg-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4543e93c78bb4fd78da7093c8232d62487a68661ba6bff0bafc0b346b34ca38c", size = 729261, upload-time = "2026-02-10T02:28:03.694Z" }, + { url = "https://files.pythonhosted.org/packages/99/ff/db75a2062a0b4b64ad0a6c677cab5b6e3ac19e0820584c597e1822f2cf7c/pyiceberg-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8dda2ad8d57e3af743ab67d976a23ca1cd54a4849110b5c2375f5d9466a4ae80", size = 729979, upload-time = "2026-02-10T02:28:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/d8/eb/453e8c4a7e6eb698bf1402337e3cd3516f20c4bbe0f06961d3e6c5031cca/pyiceberg-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b5999fb41ea0b4b153a5c80d56512ef0596f95fdd62512d1806b8db89fd4a5f9", size = 723778, upload-time = "2026-02-10T02:28:06.573Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7b/4f38016722ecc04f97000f7b7f80ba1d74e66dcbf630a4c2b620b5393ce0/pyiceberg-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:63c76f882ad30bda5b5fc685c6ab053e5b5585eadab04d1afc515eec4e272b14", size = 726955, upload-time = "2026-02-10T02:28:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/56/14/dc689c0637d7f6716cae614afcce5782903cc87a781dfd47e6d6e72ce104/pyiceberg-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:4bb26a9308e8bb97c1d3518209d221f2a790a37b9806b8b91fee4c47be4919a6", size = 531019, upload-time = "2026-02-10T02:28:10.333Z" }, + { url = "https://files.pythonhosted.org/packages/c6/72/ef1e816d79d703eec1182398947a6b72f502eefeee01c4484bd5e1493b07/pyiceberg-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c707f4463dd9c1ca664d41d5ddd38babadf1bf5fa1946cb591c033a6a2827eb4", size = 532359, upload-time = "2026-02-10T02:28:11.473Z" }, + { url = "https://files.pythonhosted.org/packages/1f/41/ec85279b1b8ed57d0d27d4675203d314b8f5d69383e1df68f615f45e9dda/pyiceberg-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f1c944969fda799a2d26dc6f57448ace44ee07e334306ba6f5110df1aadeeef1", size = 532496, upload-time = "2026-02-10T02:28:13.19Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/02861c450057c9a6e2f2e1eb0ef735c2e28473cff60b2747c50d0427ec1c/pyiceberg-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1be075b9ecc175b8dd76822b081b379ce33cda33d6403eaf607268f6061f3275", size = 721917, upload-time = "2026-02-10T02:28:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/16/cf/924b7b14267d47f5055bb5d032c7d24eb9542ac3631b460e1398fe9935ea/pyiceberg-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3507d079d43d724bffb80e75201f2995822af844b674642dcf73c19d5303994", size = 723754, upload-time = "2026-02-10T02:28:15.77Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/df2d73af6dc3ee301e727d0bef4421c57de02b5030cf38e39ed25ef36154/pyiceberg-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb3719cd61a0512596b4306283072de443d84ec7b68654f565b0d7c2d7cdeeeb", size = 715749, upload-time = "2026-02-10T02:28:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0a/c3cdcd5ed417aceb2f73e8463d97e8dd7e3f7021015d0c8d51394a5c5a63/pyiceberg-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9a71fd6b1c3c625ed2a9ca2cecf0dc8713acc5814e78c9becde3b1f42315c35", size = 720600, upload-time = "2026-02-10T02:28:18.275Z" }, + { url = "https://files.pythonhosted.org/packages/01/b8/29ec7281fb831ab983f953b00924c1cc3ebc21e9f67a1466af9b63767ba4/pyiceberg-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:bed2df9eb7e1496af22fa2307dbd13f29865b98ba5851695ffd1f4436edc05f9", size = 530631, upload-time = "2026-02-10T02:28:19.561Z" }, +] [[package]] name = "pyparsing"