diff --git a/elt-common/pyproject.toml b/elt-common/pyproject.toml index b8a8eee5..43e17582 100644 --- a/elt-common/pyproject.toml +++ b/elt-common/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "dlt[parquet,s3]==1.26.0", # Soon to remove, don't make any more updates: https://github.com/ISISNeutronMuon/analytics-data-platform/issues/321 "pyiceberg[pyiceberg-core]>=0.11.1", "s3fs<2026.2.0", # See https://github.com/ISISNeutronMuon/analytics-data-platform/issues/237 + "pydantic_settings>=2.14.1", + "pydantic>=2.12.5", ] @@ -21,7 +23,6 @@ iceberg-maintenance = [ "click>=8.4.1", "sqlalchemy>=2.0.49", "trino>=0.336.0", - "pydantic_settings>=2.14.1", ] m365 = ["authlib>=1.7.2", "httpx>=0.28.1", "tenacity>=9.1.2"] @@ -36,7 +37,6 @@ iceberg-maintenance = "elt_common.iceberg.maintenance:cli" dev = [ "minio>=7.2.20", "prek>=0.4.1", - "pydantic-settings>=2.14.1", "pytest>=9.0.3", "pytest-httpx>=0.36.2", "pytest-mock>=3.15.1", diff --git a/elt-common/src/elt_common/extract.py b/elt-common/src/elt_common/extract.py new file mode 100644 index 00000000..271e8588 --- /dev/null +++ b/elt-common/src/elt_common/extract.py @@ -0,0 +1,42 @@ +import dataclasses as dc +from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, Optional, get_args + +WriteMode = Literal["append", "merge", "replace"] + +if TYPE_CHECKING: + import pyarrow as pa + + +@dc.dataclass(frozen=True) +class Watermark: + column: str + value: Any + + +@dc.dataclass(frozen=True, kw_only=True) +class ResourceWriteProperties: + # Destination table + merge_on: list[str] = dc.field(default_factory=list) + partition: dict[str, str] = dc.field(default_factory=dict) + sort_order: dict[str, str] = dc.field(default_factory=dict) + write_mode: WriteMode = "append" + + def __post_init__(self): + if self.write_mode not in get_args(WriteMode): + raise ValueError( + f"Invalid write mode '{self.write_mode}'. Allowed values: {get_args(WriteMode)}" + ) + if self.write_mode == "merge" and not self.merge_on: + raise ValueError("'merge_on' must be provided when mode='merge'") + + +@dc.dataclass(frozen=True, kw_only=True) +class ResourceProperties: + """Configuration for a single resource to be extracted.""" + + # Required properties + extractor: Callable[[Optional[Watermark]], "Iterator[pa.Table]"] + write_properties: ResourceWriteProperties + + # Ingestion properties + watermark_column: Optional[str] diff --git a/elt-common/src/elt_common/sources/__init__.py b/elt-common/src/elt_common/sources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/elt-common/src/elt_common/sources/sqldatabase/__init__.py b/elt-common/src/elt_common/sources/sqldatabase/__init__.py new file mode 100644 index 00000000..7a77edd3 --- /dev/null +++ b/elt-common/src/elt_common/sources/sqldatabase/__init__.py @@ -0,0 +1,167 @@ +"""Support for ingesting data from an SQL database.""" + +import logging +from abc import ABC, abstractmethod +from typing import Generator, Iterator, NamedTuple, Optional + +import pyarrow as pa +import sqlalchemy as sa +from pydantic import SecretStr +from pydantic_settings import BaseSettings + +from elt_common.extract import ResourceProperties, ResourceWriteProperties, Watermark + +LOGGER = logging.getLogger(__name__) + + +class SqlDatabaseSourceConfig(BaseSettings): + """Configuration required to connect to a database""" + + # connection + drivername: str + database: str + database_schema: Optional[str] = None + port: Optional[int] = None + host: Optional[str] = None + username: Optional[str] = None + password: Optional[SecretStr] = None + + # loading behaviour + chunk_size: int = 5000 + + @property + def connection_url(self): + return sa.URL.create( + drivername=self.drivername, + username=self.username, + password=self.password.get_secret_value() if self.password else None, + host=self.host, + port=self.port, + database=self.database, + ) + + +class TableInfo(NamedTuple): + """Extra information for controlling how a table is ingested. + + Each table in a DB can have nondefault write properties, a watermark column, + both, or neither. + + :ivar write_properties: properties to control how the table is written to the + destination. If omitted, will default to appending with no partitions or sorting. + :ivar watermark_column: the column to use for watermarking. If omitted, the + entire table will be queried on every run + """ + + write_properties: Optional[ResourceWriteProperties] = None + watermark_column: Optional[str] = None + + +class SqlDatabaseExtract(ABC): + """Base class for defining SQL ingest Extract classes. + + Example usage, for an ingest script that reads from 3 tables:: + + class Extract(SqlDatabaseExtract): + def table_info(self): + return { + "a_table": None, + "a_table_that_watermarks_ingest_progress": TableInfo( + watermark_column="id" + ), + "a_table_to_replace_entirely_every_time": TableInfo( + write_properties=ResourceWriteProperties( + write_mode="replace" + ) + ) + } + """ + + source_config_cls = SqlDatabaseSourceConfig + + def __init__(self, source_config: SqlDatabaseSourceConfig): + self._source_config = source_config + + LOGGER.debug( + f"Creating engine for {source_config.drivername} database at " + f"{source_config.host}:{source_config.port}/{source_config.database}" + ) + self._engine = sa.create_engine(source_config.connection_url) + self._metadata = sa.MetaData(schema=source_config.database_schema) + + @property + def _chunk_size(self): + return self._source_config.chunk_size + + @abstractmethod + def table_info(self) -> dict[str, Optional[TableInfo]]: + """Define the tables to be extracted from the DB. + + Each key in the returned dict is a table name. Their values can include + extra properties for controlling ingestion, see :class:`TableInfo`. + """ + pass + + def resource_properties(self): + """Open a connection to the DB and return ingest properties for tables + defined by :func:`table_info`. + + The extractor functions yielded as part of this function use the DB + connection which is only active whilst this function is executing. + This means the extractors must be called whilst iterating over the + results of this function. + """ + with self._engine.connect() as conn: + yield from self._make_table_properties(conn) + + def _make_table_properties( + self, conn: sa.Connection + ) -> Generator[tuple[str, ResourceProperties]]: + """For each table defined in :func:`table_info`, build a + :class:`ResourceProperties` which can be used to ingest it""" + + for name, table_props in self.table_info().items(): + write_properties = ( + table_props.write_properties + if table_props and table_props.write_properties + else ResourceWriteProperties() + ) + watermark_column = ( + table_props.watermark_column + if table_props and table_props.watermark_column + else None + ) + + def extractor(watermark): + return self._extract_table(name, watermark=watermark, conn=conn) + + properties = ResourceProperties( + extractor=extractor, + write_properties=write_properties, + watermark_column=watermark_column, + ) + + yield name, properties + + def _extract_table( + self, + name: str, + *, + conn: sa.Connection, + watermark: Watermark | None = None, + ) -> Iterator[pa.Table]: + LOGGER.debug(f"Extracting table {name} in chunks of {self._chunk_size} rows.") + table = sa.Table( + name, + self._metadata, + autoload_with=self._engine, + ) + query = sa.select(table) + if watermark is not None: + column, max_value = watermark.column, watermark.value + LOGGER.debug(f"Cursor value detected. Limiting query to {column} > {max_value}") + query = query.where(sa.column(column) > max_value) + + result = conn.execution_options(yield_per=self._chunk_size).execute(query) + for partition in result.mappings().partitions(): + yield pa.Table.from_pylist(partition) diff --git a/elt-common/tests/unit_tests/sources/__init__.py b/elt-common/tests/unit_tests/sources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/elt-common/tests/unit_tests/sources/test_sqldatabase.py b/elt-common/tests/unit_tests/sources/test_sqldatabase.py new file mode 100644 index 00000000..2746d7e0 --- /dev/null +++ b/elt-common/tests/unit_tests/sources/test_sqldatabase.py @@ -0,0 +1,183 @@ +from pathlib import Path +from typing import Optional + +import pyarrow as pa +import pyarrow.lib +import pytest +import sqlalchemy as sa + +from elt_common.extract import ResourceWriteProperties, Watermark +from elt_common.sources.sqldatabase import SqlDatabaseExtract, SqlDatabaseSourceConfig, TableInfo + +person_values = [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25}, + {"name": "Carol", "age": 40}, + {"name": "Dave", "age": 20}, + {"name": "Eve", "age": 35}, +] + +pet_values = [ + {"owner_id": 1, "name": "Rex"}, + {"owner_id": 1, "name": "Fluffy"}, + {"owner_id": 3, "name": "Buddy"}, +] + + +def _create_sqlite_database(db_path: Path) -> sa.engine.Engine: + metadata = sa.MetaData() + people = sa.Table( + "people", + metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String), + sa.Column("age", sa.Integer), + ) + pets = sa.Table( + "pets", + metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("owner_id", sa.Integer), + sa.Column("name", sa.String), + ) + + engine = sa.create_engine(f"sqlite:///{db_path}") + metadata.create_all(engine) + + with engine.begin() as conn: + conn.execute( + people.insert(), + person_values, + ) + conn.execute( + pets.insert(), + pet_values, + ) + + return engine + + +def _create_config(db_path: Path, chunk_size: int = 5000): + return SqlDatabaseSourceConfig( + drivername="sqlite", database=str(db_path), chunk_size=chunk_size, username=None + ) + + +def _table_rows(table: pa.Table) -> list[dict]: + return [dict(row) for row in table.to_pylist()] + + +def test_sql_database_no_table_info_nothing_returned(tmp_path: Path): + db_path = tmp_path / "test.db" + _create_sqlite_database(db_path) + source_config = _create_config(db_path) + + class Extract(SqlDatabaseExtract): + def table_info(self) -> dict[str, Optional[TableInfo]]: + return {} + + e = Extract(source_config) + assert len(list(e.resource_properties())) == 0 + + +@pytest.mark.parametrize("chunk_size", [1, 2, 3, 4, 5, 6, 1000]) +def test_sql_database_reads_table_in_chunks(tmp_path: Path, chunk_size): + db_path = tmp_path / "test.db" + _create_sqlite_database(db_path) + source_config = _create_config(db_path, chunk_size=chunk_size) + + class Extract(SqlDatabaseExtract): + def table_info(self) -> dict[str, Optional[TableInfo]]: + return {"people": None} + + e = Extract(source_config) + + expected_last_chunk_size = ( + len(person_values) % chunk_size if len(person_values) % chunk_size > 0 else chunk_size + ) + + for table_name, props in e.resource_properties(): + data = props.extractor(None) + chunks = list(data) + for i, chunk in enumerate(chunks): + expected_length = chunk_size if i < len(chunks) - 1 else expected_last_chunk_size + assert len(chunk) == expected_length + + +def test_sql_database_reads_multiple_tables(tmp_path: Path): + db_path = tmp_path / "test.db" + _create_sqlite_database(db_path) + source_config = _create_config(db_path) + + class Extract(SqlDatabaseExtract): + def table_info(self) -> dict[str, Optional[TableInfo]]: + return {"people": None, "pets": None} + + e = Extract(source_config) + + result = {} + for table_name, props in e.resource_properties(): + data = pyarrow.lib.concat_tables(table for table in props.extractor(None)) + result[table_name] = data + + assert len(result) == 2 + + people = _table_rows(result["people"]) + pets = _table_rows(result["pets"]) + assert len(people) == len(person_values) + assert len(pets) == len(pet_values) + + people_without_ids = [{k: v for k, v in person.items() if k != "id"} for person in people] + assert people_without_ids == person_values + + pets_without_ids = [{k: v for k, v in pet.items() if k != "id"} for pet in pets] + assert pets_without_ids == pet_values + + +def test_sql_database_write_properties_returned(tmp_path: Path): + db_path = tmp_path / "test.db" + _create_sqlite_database(db_path) + source_config = _create_config(db_path) + + people_write_properties = ResourceWriteProperties() + pet_write_properties = ResourceWriteProperties(write_mode="replace") + + class Extract(SqlDatabaseExtract): + def table_info(self) -> dict[str, Optional[TableInfo]]: + return { + "people": TableInfo(write_properties=people_write_properties), + "pets": TableInfo(write_properties=pet_write_properties), + } + + e = Extract(source_config) + for table_name, props in e.resource_properties(): + if table_name == "people": + assert props.write_properties == people_write_properties + else: + assert props.write_properties == pet_write_properties + + +def test_sql_database_watermarks_filter_results(tmp_path: Path): + db_path = tmp_path / "test.db" + _create_sqlite_database(db_path) + source_config = _create_config(db_path) + + class Extract(SqlDatabaseExtract): + def table_info(self) -> dict[str, Optional[TableInfo]]: + return {"people": None} + + e = Extract(source_config) + + for table_name, props in e.resource_properties(): + id_watermark = 2 + data = pyarrow.lib.concat_tables( + table for table in props.extractor(Watermark("id", id_watermark)) + ) + + assert len(data) == len(person_values) - id_watermark + assert min(data["id"].to_pylist()) > id_watermark + + for table_name, props in e.resource_properties(): + tables = [table for table in props.extractor(Watermark("age", 39))] + data = pyarrow.lib.concat_tables(tables) + assert len(data) == 1, "Only the one person with age > 39 should be returned" diff --git a/elt-common/uv.lock b/elt-common/uv.lock index 4e33360e..8cfc48bb 100644 --- a/elt-common/uv.lock +++ b/elt-common/uv.lock @@ -454,6 +454,8 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "dlt", extra = ["parquet", "s3"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyiceberg", extra = ["pyiceberg-core"] }, { name = "s3fs" }, ] @@ -461,7 +463,6 @@ dependencies = [ [package.optional-dependencies] iceberg-maintenance = [ { name = "click" }, - { name = "pydantic-settings" }, { name = "sqlalchemy" }, { name = "trino" }, ] @@ -475,7 +476,6 @@ m365 = [ dev = [ { name = "minio" }, { name = "prek" }, - { name = "pydantic-settings" }, { name = "pytest" }, { name = "pytest-httpx" }, { name = "pytest-mock" }, @@ -491,7 +491,8 @@ requires-dist = [ { name = "click", marker = "extra == 'iceberg-maintenance'", specifier = ">=8.4.1" }, { name = "dlt", extras = ["parquet", "s3"], specifier = "==1.26.0" }, { name = "httpx", marker = "extra == 'm365'", specifier = ">=0.28.1" }, - { name = "pydantic-settings", marker = "extra == 'iceberg-maintenance'", specifier = ">=2.14.1" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pydantic-settings", specifier = ">=2.14.1" }, { name = "pyiceberg", extras = ["pyiceberg-core"], specifier = ">=0.11.1" }, { name = "s3fs", specifier = "<2026.2.0" }, { name = "sqlalchemy", marker = "extra == 'iceberg-maintenance'", specifier = ">=2.0.49" }, @@ -504,7 +505,6 @@ provides-extras = ["iceberg-maintenance", "m365"] dev = [ { name = "minio", specifier = ">=7.2.20" }, { name = "prek", specifier = ">=0.4.1" }, - { name = "pydantic-settings", specifier = ">=2.14.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-httpx", specifier = ">=0.36.2" }, { name = "pytest-mock", specifier = ">=3.15.1" }, @@ -637,7 +637,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -645,7 +644,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -653,7 +651,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },