diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 00000000..695ad345 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,63 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name: Python Models CI + +on: + push: + branches: [ "main" ] + paths: + - 'python/**' + - '.github/**' + pull_request: + branches: [ "main" ] + paths: + - 'python/**' + - '.github/**' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Sync dependencies + working-directory: python + run: | + uv sync + + - name: Unit Tests + working-directory: python + run: | + uv run pytest diff --git a/python/pyproject.toml b/python/pyproject.toml index 7cc1326f..7085954e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,6 +19,11 @@ requires = ["hatchling"] build-backend = "hatchling.build" +[dependency-groups] +dev = [ + "pytest>=8.0", +] + [project] name = "apache-ossie" version = "0.2.0.dev0" @@ -45,3 +50,6 @@ packages = ["src/ossie"] [tool.uv] required-version = ">=0.9.0" +default-groups = [ + "dev" +] diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 00000000..1cc28ac7 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest + +from ossie import OSIDialect, OSIExpression + + +def _expression_data(value: str = "value") -> dict: + return {"dialects": [{"dialect": OSIDialect.ANSI_SQL, "expression": value}]} + + +def _expression(value: str = "value") -> OSIExpression: + return OSIExpression.model_validate(_expression_data(value)) + + +@pytest.fixture +def document_data() -> dict: + return { + "version": "0.2.0.dev0", + "semantic_model": [ + { + "name": "typed_model", + "datasets": [ + { + "name": "events", + "source": "catalog.schema.events", + "fields": [ + { + "name": "occurred_at", + "expression": _expression_data("occurred_at"), + "dimension": {}, + "datatype": "DateTimeTz", + } + ], + } + ], + "metrics": [ + { + "name": "revenue", + "expression": _expression_data("SUM(events.revenue)"), + "datatype": "Decimal", + } + ], + } + ], + } diff --git a/python/tests/test_models.py b/python/tests/test_models.py index 749a226c..947ee75b 100644 --- a/python/tests/test_models.py +++ b/python/tests/test_models.py @@ -22,53 +22,21 @@ import yaml from pydantic import ValidationError +from conftest import _expression + from ossie import ( + OSIAIContextObject, OSIDataType, OSIDimension, OSIDocument, - OSIExpression, OSIField, + OSIRelationship, ) -def _expression_data(value: str = "value") -> dict: - return {"dialects": [{"dialect": "ANSI_SQL", "expression": value}]} - - -def _expression(value: str = "value") -> OSIExpression: - return OSIExpression.model_validate(_expression_data(value)) - - -def _document() -> dict: - return { - "version": "0.2.0.dev0", - "semantic_model": [ - { - "name": "typed_model", - "datasets": [ - { - "name": "events", - "source": "catalog.schema.events", - "fields": [ - { - "name": "occurred_at", - "expression": _expression_data("occurred_at"), - "dimension": {}, - "datatype": "DateTimeTz", - } - ], - } - ], - "metrics": [ - { - "name": "revenue", - "expression": _expression_data("SUM(events.revenue)"), - "datatype": "Decimal", - } - ], - } - ], - } +# --------------------------------------------------------------------------- +# Data type tests +# --------------------------------------------------------------------------- def test_data_type_enum_matches_core_schema() -> None: @@ -86,8 +54,8 @@ def test_data_type_enum_matches_core_schema() -> None: } -def test_field_and_metric_datatypes_survive_serialization() -> None: - document = OSIDocument.model_validate(_document()) +def test_field_and_metric_datatypes_survive_serialization(document_data: dict) -> None: + document = OSIDocument.model_validate(document_data) field = document.semantic_model[0].datasets[0].fields[0] metric = document.semantic_model[0].metrics[0] @@ -102,13 +70,12 @@ def test_field_and_metric_datatypes_survive_serialization() -> None: assert model["metrics"][0]["datatype"] == "Decimal" -def test_invalid_datatype_is_rejected() -> None: - document = _document() - field = document["semantic_model"][0]["datasets"][0]["fields"][0] +def test_invalid_datatype_is_rejected(document_data: dict) -> None: + field = document_data["semantic_model"][0]["datasets"][0]["fields"][0] field["datatype"] = "timestamp" with pytest.raises(ValidationError): - OSIDocument.model_validate(document) + OSIDocument.model_validate(document_data) @pytest.mark.parametrize( @@ -135,3 +102,52 @@ def test_effective_time_dimension_role( ) assert field.is_time_dimension() is expected + + +# --------------------------------------------------------------------------- +# Model behavior +# --------------------------------------------------------------------------- + + +def test_ai_context_object_allows_extra() -> None: + ai_ctx = OSIAIContextObject(custom_field="custom_value") + assert ai_ctx.custom_field == "custom_value" + + +def test_ai_context_accepts_string(document_data: dict) -> None: + document_data["semantic_model"][0]["ai_context"] = "Plain text context" + document = OSIDocument.model_validate(document_data) + assert document.semantic_model[0].ai_context == "Plain text context" + + +def test_ai_context_accepts_object(document_data: dict) -> None: + document_data["semantic_model"][0]["ai_context"] = { + "instructions": "Use this model for analytics" + } + document = OSIDocument.model_validate(document_data) + ai_ctx = document.semantic_model[0].ai_context + assert isinstance(ai_ctx, OSIAIContextObject) + assert ai_ctx.instructions == "Use this model for analytics" + + +def test_relationship_with_alias() -> None: + relationship = OSIRelationship( + name="order_customer", + **{"from": "orders"}, + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ) + assert relationship.from_dataset == "orders" + assert relationship.to == "customers" + + +def test_relationship_with_python_name() -> None: + relationship = OSIRelationship( + name="order_customer", + from_dataset="orders", + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ) + assert relationship.from_dataset == "orders" diff --git a/python/tests/test_serialization.py b/python/tests/test_serialization.py new file mode 100644 index 00000000..cef7e009 --- /dev/null +++ b/python/tests/test_serialization.py @@ -0,0 +1,119 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json + +import yaml + +from ossie import ( + OSIDialect, + OSIDocument, + OSIVendor, +) + + +def test_to_osi_yaml_uses_alias(document_data: dict) -> None: + document_data["semantic_model"][0]["relationships"] = [ + { + "name": "order_customer", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ] + document = OSIDocument.model_validate(document_data) + output = document.to_osi_yaml() + assert "from: orders" in output + assert "from_dataset" not in output + + +def test_to_osi_yaml_includes_dialects_and_vendors(document_data: dict) -> None: + document_data["dialects"] = [OSIDialect.ANSI_SQL, OSIDialect.DATABRICKS] + document_data["vendors"] = [OSIVendor.DATABRICKS] + document = OSIDocument.model_validate(document_data) + output = document.to_osi_yaml() + parsed = yaml.safe_load(output) + assert parsed["dialects"] == ["ANSI_SQL", "DATABRICKS"] + assert parsed["vendors"] == ["DATABRICKS"] + + +def test_to_osi_json_includes_dialects_and_vendors(document_data: dict) -> None: + document_data["dialects"] = [OSIDialect.SNOWFLAKE] + document_data["vendors"] = [OSIVendor.SALESFORCE, OSIVendor.COMMON] + document = OSIDocument.model_validate(document_data) + output = document.to_osi_json() + parsed = json.loads(output) + assert parsed["dialects"] == ["SNOWFLAKE"] + assert parsed["vendors"] == ["SALESFORCE", "COMMON"] + + +def test_to_osi_yaml_excludes_none(document_data: dict) -> None: + document_data["dialects"] = [OSIDialect.ANSI_SQL] + document = OSIDocument.model_validate(document_data) + output = document.to_osi_yaml() + parsed = yaml.safe_load(output) + assert parsed["dialects"] == ["ANSI_SQL"] + assert "vendors" not in parsed + semantic_model = parsed["semantic_model"][0] + assert semantic_model["name"] == "typed_model" + assert "description" not in semantic_model + + +def test_to_osi_json_uses_alias(document_data: dict) -> None: + document_data["semantic_model"][0]["relationships"] = [ + { + "name": "order_customer", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ] + document = OSIDocument.model_validate(document_data) + output = document.to_osi_json() + parsed = json.loads(output) + assert parsed["semantic_model"][0]["relationships"][0]["from"] == "orders" + + +def test_to_osi_json_excludes_none(document_data: dict) -> None: + document = OSIDocument.model_validate(document_data) + output = document.to_osi_json() + parsed = json.loads(output) + semantic_model = parsed["semantic_model"][0] + assert "description" not in semantic_model + assert "relationships" not in semantic_model + assert "vendors" not in semantic_model + + +def test_to_osi_yaml_validates_as_yaml(document_data: dict) -> None: + document = OSIDocument.model_validate(document_data) + output = document.to_osi_yaml() + parsed = yaml.safe_load(output) + assert parsed["semantic_model"][0]["name"] == "typed_model" + + +def test_to_osi_json_roundtrip(document_data: dict) -> None: + document_data["dialects"] = [OSIDialect.DATABRICKS] + document_data["semantic_model"][0]["datasets"][0]["fields"][0]["expression"] = { + "dialects": [{"dialect": OSIDialect.DATABRICKS, "expression": "order_id"}] + } + document = OSIDocument.model_validate(document_data) + json_str = document.to_osi_json() + parsed = json.loads(json_str) + document2 = OSIDocument(**parsed) + assert document2.to_osi_json() == json_str diff --git a/python/tests/test_validation.py b/python/tests/test_validation.py new file mode 100644 index 00000000..46310fcc --- /dev/null +++ b/python/tests/test_validation.py @@ -0,0 +1,249 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest +from pydantic import ValidationError + +from conftest import _expression + +from ossie import ( + OSIAIContextObject, + OSICustomExtension, + OSIDataset, + OSIDialect, + OSIDialectExpression, + OSIDimension, + OSIDocument, + OSIExpression, + OSIField, + OSIMetric, + OSIRelationship, + OSISemanticModel, +) + + +# --------------------------------------------------------------------------- +# Missing required fields +# --------------------------------------------------------------------------- + + +def test_dataset_missing_required() -> None: + with pytest.raises(ValidationError): + OSIDataset() + + +def test_field_missing_name() -> None: + with pytest.raises(ValidationError): + OSIField(expression=_expression("order_id")) + + +def test_field_missing_expression() -> None: + with pytest.raises(ValidationError): + OSIField(name="order_id") + + +def test_relationship_missing_name() -> None: + with pytest.raises(ValidationError): + OSIRelationship( + **{"from": "orders"}, + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ) + + +def test_relationship_missing_from() -> None: + with pytest.raises(ValidationError): + OSIRelationship( + name="order_customer", + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ) + + +def test_relationship_missing_to() -> None: + with pytest.raises(ValidationError): + OSIRelationship( + name="order_customer", + **{"from": "orders"}, + from_columns=["customer_id"], + to_columns=["id"], + ) + + +def test_relationship_missing_from_columns() -> None: + with pytest.raises(ValidationError): + OSIRelationship( + name="order_customer", + **{"from": "orders"}, + to="customers", + to_columns=["id"], + ) + + +def test_relationship_missing_to_columns() -> None: + with pytest.raises(ValidationError): + OSIRelationship( + name="order_customer", + **{"from": "orders"}, + to="customers", + from_columns=["customer_id"], + ) + + +def test_metric_missing_name() -> None: + with pytest.raises(ValidationError): + OSIMetric(expression=_expression("order_id")) + + +def test_metric_missing_expression() -> None: + with pytest.raises(ValidationError): + OSIMetric(name="total_sales") + + +def test_document_missing_semantic_model() -> None: + with pytest.raises(ValidationError): + OSIDocument() + + +def test_semantic_model_missing_name() -> None: + with pytest.raises(ValidationError): + OSISemanticModel( + datasets=[OSIDataset(name="orders", source="database.schema.orders")] + ) + + +def test_semantic_model_missing_datasets() -> None: + with pytest.raises(ValidationError): + OSISemanticModel(name="sales_model") + + +def test_custom_extension_missing_vendor_name() -> None: + with pytest.raises(ValidationError): + OSICustomExtension(data="{}") + + +def test_custom_extension_missing_data() -> None: + with pytest.raises(ValidationError): + OSICustomExtension(vendor_name="ASF") + + +def test_dialect_expression_missing_dialect() -> None: + with pytest.raises(ValidationError): + OSIDialectExpression(expression="order_id") + + +def test_dialect_expression_missing_expression() -> None: + with pytest.raises(ValidationError): + OSIDialectExpression(dialect=OSIDialect.ANSI_SQL) + + +def test_expression_missing_dialects() -> None: + with pytest.raises(ValidationError): + OSIExpression() + + +def test_invalid_dialect_in_expression() -> None: + with pytest.raises(ValidationError): + OSIDialectExpression(dialect="INVALID", expression="order_id") + + +# --------------------------------------------------------------------------- +# Frozen models +# --------------------------------------------------------------------------- + + +def test_frozen_dataset() -> None: + dataset = OSIDataset(name="orders", source="database.schema.orders") + with pytest.raises(ValidationError): + dataset.name = "other" + + +def test_frozen_field() -> None: + field = OSIField( + name="order_id", + expression=_expression("order_id"), + ) + with pytest.raises(ValidationError): + field.name = "other" + + +def test_frozen_document(document_data: dict) -> None: + document = OSIDocument.model_validate(document_data) + with pytest.raises(ValidationError): + document.version = "new.version" + + +def test_frozen_relationship() -> None: + relationship = OSIRelationship( + name="order_customer", + **{"from": "orders"}, + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ) + with pytest.raises(ValidationError): + relationship.name = "other" + + +def test_frozen_ai_context_object() -> None: + ai_ctx = OSIAIContextObject(instructions="Use this field for filtering") + with pytest.raises(ValidationError): + ai_ctx.instructions = "Updated instructions" + + +def test_frozen_custom_extension() -> None: + custom_ext = OSICustomExtension(vendor_name="ASF", data="{}") + with pytest.raises(ValidationError): + custom_ext.vendor_name = "other" + + +def test_frozen_dialect_expression() -> None: + dialect_expr = OSIDialectExpression( + dialect=OSIDialect.ANSI_SQL, expression="order_id" + ) + with pytest.raises(ValidationError): + dialect_expr.expression = "other_column" + + +def test_frozen_expression() -> None: + expr = _expression("order_id") + with pytest.raises(ValidationError): + expr.dialects = [] + + +def test_frozen_dimension() -> None: + dim = OSIDimension(is_time=True) + with pytest.raises(ValidationError): + dim.is_time = False + + +def test_frozen_metric() -> None: + expr = _expression("order_id") + m = OSIMetric(name="total_sales", expression=expr) + with pytest.raises(ValidationError): + m.name = "other" + + +def test_frozen_semantic_model() -> None: + semantic_model = OSISemanticModel( + name="sales_model", + datasets=[OSIDataset(name="orders", source="database.schema.orders")], + ) + with pytest.raises(ValidationError): + semantic_model.name = "other" diff --git a/python/uv.lock b/python/uv.lock index 17d1769e..a396ede5 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -20,12 +20,56 @@ dependencies = [ { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -143,6 +187,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"