From 9c86ba63af402d783a156efba08cf57d0e5c9da0 Mon Sep 17 00:00:00 2001 From: Helio Chissini de Castro Date: Wed, 18 Mar 2026 18:28:59 +0100 Subject: [PATCH] fix(models): Update ValidatedIntEnum class to properly serialize with strings Signed-off-by: Helio Chissini de Castro --- pyproject.toml | 4 +- src/ort/utils/validated_enum.py | 19 ++- .../data/repo_config/bad_license_choices.yml | 11 ++ tests/data/repo_config/license_choices.yml | 11 ++ tests/test_advisor_capability.py | 2 +- tests/test_advisor_details.py | 2 +- tests/test_advisor_result.py | 2 +- tests/test_cvss_ratings.py | 2 +- tests/test_evaluator_run.py | 2 +- tests/test_package_configuration.py | 2 +- tests/test_package_curation.py | 2 +- tests/test_repo_config_curations.py | 2 +- tests/test_repo_config_files.py | 16 +- tests/test_repo_config_license_choices.py | 82 +++++++++ tests/test_repository_analyzer_config.py | 2 +- tests/test_repository_configuration.py | 2 +- tests/test_scan_result.py | 2 +- tests/test_validated_int_enum.py | 159 ++++++++++++++++++ tests/test_vulnerability_reference.py | 2 +- uv.lock | 44 ++--- 20 files changed, 331 insertions(+), 39 deletions(-) create mode 100644 tests/data/repo_config/bad_license_choices.yml create mode 100644 tests/data/repo_config/license_choices.yml create mode 100644 tests/test_repo_config_license_choices.py create mode 100644 tests/test_validated_int_enum.py diff --git a/pyproject.toml b/pyproject.toml index 673bdf5..ce20214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "python-ort" -version = "0.8.4" +version = "0.8.5" description = "A Python Ort model serialization library" readme = "README.md" license = "MIT" @@ -40,7 +40,7 @@ dev = [ "pytest>=9.0.2", "rich>=14.3.3", "ruff>=0.15.6", - "ty>=0.0.22", + "ty>=0.0.23", "types-pyyaml>=6.0.12.20250915", ] diff --git a/src/ort/utils/validated_enum.py b/src/ort/utils/validated_enum.py index 9ea3acf..ba90988 100644 --- a/src/ort/utils/validated_enum.py +++ b/src/ort/utils/validated_enum.py @@ -31,4 +31,21 @@ def validate(value: Any) -> ValidatedIntEnum: return cls(value) raise ValueError(f"Invalid value for {cls.__name__}: {value}") - return core_schema.no_info_plain_validator_function(validate) + enum_names = [member.name for member in cls] + + return core_schema.no_info_wrap_validator_function( + lambda value, handler: validate(value), + core_schema.str_schema(), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda v: v.name, + info_arg=False, + ), + metadata={ + "pydantic_js_functions": [ + lambda _schema, handler: { + "type": "string", + "enum": enum_names, + } + ] + }, + ) diff --git a/tests/data/repo_config/bad_license_choices.yml b/tests/data/repo_config/bad_license_choices.yml new file mode 100644 index 0000000..3f3bd03 --- /dev/null +++ b/tests/data/repo_config/bad_license_choices.yml @@ -0,0 +1,11 @@ +excludes: + scopes: + - pattern: "devDependencies" + reason: "DEV_DEPENDENCY_OF" + comment: "Packages for development only." +license_choices: + package_license_choice: + - package_id: "NPM::promised-io:0.3.6" + license_choices: + - given: AFL-2.1 OR BSD-3-Clause + choice: BSD-3-Clause diff --git a/tests/data/repo_config/license_choices.yml b/tests/data/repo_config/license_choices.yml new file mode 100644 index 0000000..74987f3 --- /dev/null +++ b/tests/data/repo_config/license_choices.yml @@ -0,0 +1,11 @@ +excludes: + scopes: + - pattern: "devDependencies" + reason: "DEV_DEPENDENCY_OF" + comment: "Packages for development only." +license_choices: + package_license_choices: + - package_id: "NPM::promised-io:0.3.6" + license_choices: + - given: AFL-2.1 OR BSD-3-Clause + choice: BSD-3-Clause diff --git a/tests/test_advisor_capability.py b/tests/test_advisor_capability.py index 4659c88..9d74a3f 100644 --- a/tests/test_advisor_capability.py +++ b/tests/test_advisor_capability.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT import pytest diff --git a/tests/test_advisor_details.py b/tests/test_advisor_details.py index 9640ab9..f90ad49 100644 --- a/tests/test_advisor_details.py +++ b/tests/test_advisor_details.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT import pytest diff --git a/tests/test_advisor_result.py b/tests/test_advisor_result.py index 951a72c..4d6fe1e 100644 --- a/tests/test_advisor_result.py +++ b/tests/test_advisor_result.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT from datetime import datetime, timezone diff --git a/tests/test_cvss_ratings.py b/tests/test_cvss_ratings.py index fdc0a84..959f787 100644 --- a/tests/test_cvss_ratings.py +++ b/tests/test_cvss_ratings.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT import pytest diff --git a/tests/test_evaluator_run.py b/tests/test_evaluator_run.py index a1e86e1..18b1e54 100644 --- a/tests/test_evaluator_run.py +++ b/tests/test_evaluator_run.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT from datetime import datetime, timezone diff --git a/tests/test_package_configuration.py b/tests/test_package_configuration.py index 7d4716b..380fe94 100644 --- a/tests/test_package_configuration.py +++ b/tests/test_package_configuration.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro # SPDX-License-Identifier: MIT diff --git a/tests/test_package_curation.py b/tests/test_package_curation.py index 87b9dc5..074995b 100644 --- a/tests/test_package_curation.py +++ b/tests/test_package_curation.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro # SPDX-License-Identifier: MIT diff --git a/tests/test_repo_config_curations.py b/tests/test_repo_config_curations.py index 48e8ba3..075da69 100644 --- a/tests/test_repo_config_curations.py +++ b/tests/test_repo_config_curations.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT diff --git a/tests/test_repo_config_files.py b/tests/test_repo_config_files.py index ca68ce9..a82d622 100644 --- a/tests/test_repo_config_files.py +++ b/tests/test_repo_config_files.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT from pathlib import Path @@ -11,8 +11,12 @@ REPO_CONFIG_DIR = Path(__file__).parent / "data" / "repo_config" +# Files that are expected to FAIL validation +_KNOWN_INVALID = {"only_include_reason_fail.yml", "bad_license_choices.yml"} + # Collect all YAML files that are expected to be valid RepositoryConfiguration documents -_VALID_FILES = [f for f in REPO_CONFIG_DIR.glob("*.yml") if f.name != "only_include_reason_fail.yml"] +_VALID_FILES = [f for f in REPO_CONFIG_DIR.glob("*.yml") if f.name not in _KNOWN_INVALID] +_INVALID_FILES = [f for f in REPO_CONFIG_DIR.glob("*.yml") if f.name in _KNOWN_INVALID] @pytest.mark.parametrize("config_file", _VALID_FILES, ids=lambda f: f.name) @@ -24,3 +28,11 @@ def test_repo_config_file_loads_without_validation_error(config_file: Path) -> N RepositoryConfiguration.model_validate(data or {}) except ValidationError as exc: pytest.fail(f"{config_file.name} raised ValidationError: {exc}") + + +@pytest.mark.parametrize("config_file", _INVALID_FILES, ids=lambda f: f.name) +def test_repo_config_file_raises_validation_error(config_file: Path) -> None: + """Known-invalid files must raise a pydantic ValidationError.""" + data = yaml.safe_load(config_file.read_text()) + with pytest.raises(ValidationError): + RepositoryConfiguration.model_validate(data or {}) diff --git a/tests/test_repo_config_license_choices.py b/tests/test_repo_config_license_choices.py new file mode 100644 index 0000000..c8242e8 --- /dev/null +++ b/tests/test_repo_config_license_choices.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-License-Identifier: MIT + + +import pytest +from pydantic import ValidationError + +from ort.models.config.repository_configuration import RepositoryConfiguration +from tests.utils.load_yaml_config import load_yaml_config + + +def test_license_choices_yml_loads_without_validation_error(): + """ + Test that license_choices.yml loads into RepositoryConfiguration without raising a ValidationError. + """ + config_data = load_yaml_config(filename="license_choices.yml", data_dir="repo_config") + try: + RepositoryConfiguration.model_validate(config_data or {}) + except ValidationError as exc: + pytest.fail(f"license_choices.yml raised ValidationError: {exc}") + + +def test_license_choices_yml_excludes_scopes(): + """ + Test that the excludes.scopes section in license_choices.yml is parsed correctly. + """ + config_data = load_yaml_config(filename="license_choices.yml", data_dir="repo_config") + repo_config = RepositoryConfiguration.model_validate(config_data) + + if repo_config.excludes is None: + pytest.fail("excludes section is missing") + scopes = repo_config.excludes.scopes + if len(scopes) != 1: + pytest.fail(f"Expected 1 scope exclude, got {len(scopes)}") + + if scopes[0].pattern != "devDependencies": + pytest.fail(f"Unexpected pattern: {scopes[0].pattern}") + if scopes[0].reason.name != "DEV_DEPENDENCY_OF": + pytest.fail(f"Unexpected reason: {scopes[0].reason.name}") + if scopes[0].comment != "Packages for development only.": + pytest.fail(f"Unexpected comment: {scopes[0].comment}") + + +def test_license_choices_yml_package_license_choices(): + """ + Test that the license_choices.package_license_choices section is parsed correctly, + including the package ID and the SPDX license choice. + """ + config_data = load_yaml_config(filename="license_choices.yml", data_dir="repo_config") + repo_config = RepositoryConfiguration.model_validate(config_data) + + if repo_config.license_choices is None: + pytest.fail("license_choices section is missing") + + package_choices = repo_config.license_choices.package_license_choices + if len(package_choices) != 1: + pytest.fail(f"Expected 1 package license choice, got {len(package_choices)}") + + if str(package_choices[0].package_id) != "NPM::promised-io:0.3.6": + pytest.fail(f"Unexpected package_id: {package_choices[0].package_id}") + + choices = package_choices[0].license_choices + if len(choices) != 1: + pytest.fail(f"Expected 1 license choice, got {len(choices)}") + + if choices[0].given != "AFL-2.1 OR BSD-3-Clause": + pytest.fail(f"Unexpected given: {choices[0].given}") + if choices[0].choice != "BSD-3-Clause": + pytest.fail(f"Unexpected choice: {choices[0].choice}") + + +def test_bad_license_choices_yml_raises_validation_error(): + """ + Test that bad_license_choices.yml raises a ValidationError due to an invalid field name + (package_license_choice instead of package_license_choices). + """ + config_data = load_yaml_config(filename="bad_license_choices.yml", data_dir="repo_config") + try: + RepositoryConfiguration.model_validate(config_data or {}) + pytest.fail("bad_license_choices.yml should have raised ValidationError") + except ValidationError: + pass diff --git a/tests/test_repository_analyzer_config.py b/tests/test_repository_analyzer_config.py index 1addbd6..be95fda 100644 --- a/tests/test_repository_analyzer_config.py +++ b/tests/test_repository_analyzer_config.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro # SPDX-License-Identifier: MIT diff --git a/tests/test_repository_configuration.py b/tests/test_repository_configuration.py index e7eaf78..7e5c63b 100644 --- a/tests/test_repository_configuration.py +++ b/tests/test_repository_configuration.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro # SPDX-License-Identifier: MIT diff --git a/tests/test_scan_result.py b/tests/test_scan_result.py index 9f8315e..91dbe39 100644 --- a/tests/test_scan_result.py +++ b/tests/test_scan_result.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT from datetime import datetime, timezone diff --git a/tests/test_validated_int_enum.py b/tests/test_validated_int_enum.py new file mode 100644 index 0000000..49ec77e --- /dev/null +++ b/tests/test_validated_int_enum.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-License-Identifier: MIT + +import pytest +from pydantic import BaseModel, ValidationError + +from ort.models.severity import Severity +from ort.utils.validated_enum import ValidatedIntEnum + + +class SampleEnum(ValidatedIntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class SampleModel(BaseModel): + level: SampleEnum + + +class TestValidatedIntEnumFromString: + def test_valid_string_name(self): + result = SampleModel(level="HIGH") # ty: ignore[invalid-argument-type] + if result.level != SampleEnum.HIGH: + pytest.fail(f"Expected SampleEnum.HIGH, got {result.level}") + if result.level.value != 3: + pytest.fail(f"Expected value 3, got {result.level.value}") + + @pytest.mark.parametrize("member", list(SampleEnum)) + def test_all_members_by_name(self, member): + result = SampleModel(level=member.name) + if result.level != member: + pytest.fail(f"Expected {member}, got {result.level}") + + def test_case_sensitive_name(self): + with pytest.raises(ValidationError): + SampleModel(level="high") # ty: ignore[invalid-argument-type] + + def test_invalid_string_name(self): + with pytest.raises(ValidationError): + SampleModel(level="CRITICAL") # ty: ignore[invalid-argument-type] + + def test_empty_string(self): + with pytest.raises(ValidationError): + SampleModel(level="") # ty: ignore[invalid-argument-type] + + +class TestValidatedIntEnumFromInt: + def test_valid_int_value(self): + result = SampleModel(level=1) # ty: ignore[invalid-argument-type] + if result.level != SampleEnum.LOW: + pytest.fail(f"Expected SampleEnum.LOW, got {result.level}") + + @pytest.mark.parametrize("member", list(SampleEnum)) + def test_all_members_by_int(self, member): + result = SampleModel(level=member.value) + if result.level != member: + pytest.fail(f"Expected {member}, got {result.level}") + + def test_invalid_int_value(self): + with pytest.raises(ValidationError): + SampleModel(level=99) # ty: ignore[invalid-argument-type] + + def test_zero_not_a_member(self): + with pytest.raises(ValidationError): + SampleModel(level=0) # ty: ignore[invalid-argument-type] + + def test_negative_int(self): + with pytest.raises(ValidationError): + SampleModel(level=-1) # ty: ignore[invalid-argument-type] + + +class TestValidatedIntEnumFromInstance: + def test_enum_instance(self): + result = SampleModel(level=SampleEnum.MEDIUM) + if result.level != SampleEnum.MEDIUM: + pytest.fail(f"Expected SampleEnum.MEDIUM, got {result.level}") + + def test_severity_enum_instance(self): + """Verify ValidatedIntEnum works with the real Severity model.""" + + class SeverityModel(BaseModel): + severity: Severity + + result = SeverityModel(severity=Severity.ERROR) + if result.severity != Severity.ERROR: + pytest.fail(f"Expected Severity.ERROR, got {result.severity}") + + +class TestValidatedIntEnumInvalidTypes: + def test_float_value(self): + with pytest.raises(ValidationError): + SampleModel(level=1.5) # ty: ignore[invalid-argument-type] + + def test_none_value(self): + with pytest.raises(ValidationError): + SampleModel(level=None) # ty: ignore[invalid-argument-type] + + def test_list_value(self): + with pytest.raises(ValidationError): + SampleModel(level=[1]) # ty: ignore[invalid-argument-type] + + def test_dict_value(self): + with pytest.raises(ValidationError): + SampleModel(level={"name": "HIGH"}) # ty: ignore[invalid-argument-type] + + +class TestValidatedIntEnumSerialization: + def test_serialize_to_name(self): + result = SampleModel(level="HIGH") # ty: ignore[invalid-argument-type] + data = result.model_dump() + if data["level"] != "HIGH": + pytest.fail(f"Expected 'HIGH', got {data['level']}") + + def test_json_round_trip(self): + original = SampleModel(level="MEDIUM") # ty: ignore[invalid-argument-type] + json_str = original.model_dump_json() + restored = SampleModel.model_validate_json(json_str) + if restored.level != SampleEnum.MEDIUM: + pytest.fail(f"Expected SampleEnum.MEDIUM, got {restored.level}") + + @pytest.mark.parametrize("member", list(SampleEnum)) + def test_all_members_round_trip(self, member): + original = SampleModel(level=member) + restored = SampleModel.model_validate_json(original.model_dump_json()) + if restored.level != member: + pytest.fail(f"Expected {member}, got {restored.level}") + + +class TestValidatedIntEnumJsonSchema: + def test_schema_generates_without_error(self): + schema = SampleModel.model_json_schema() + if "properties" not in schema: + pytest.fail("Schema missing 'properties' key") + if "level" not in schema["properties"]: + pytest.fail("Schema missing 'level' property") + + def test_schema_type_is_string(self): + schema = SampleModel.model_json_schema() + level_schema = schema["properties"]["level"] + if level_schema.get("type") != "string": + pytest.fail(f"Expected type 'string', got {level_schema.get('type')}") + + def test_schema_enum_values(self): + schema = SampleModel.model_json_schema() + level_schema = schema["properties"]["level"] + if sorted(level_schema.get("enum", [])) != ["HIGH", "LOW", "MEDIUM"]: + pytest.fail(f"Expected enum ['HIGH', 'LOW', 'MEDIUM'], got {level_schema.get('enum')}") + + def test_severity_schema_enum_values(self): + """Verify JSON schema for the real Severity enum.""" + + class SeverityModel(BaseModel): + severity: Severity + + schema = SeverityModel.model_json_schema() + severity_schema = schema["properties"]["severity"] + if sorted(severity_schema.get("enum", [])) != ["ERROR", "HINT", "WARNING"]: + pytest.fail(f"Expected enum ['ERROR', 'HINT', 'WARNING'], got {severity_schema.get('enum')}") diff --git a/tests/test_vulnerability_reference.py b/tests/test_vulnerability_reference.py index 8e0c9d7..b3ac4a7 100644 --- a/tests/test_vulnerability_reference.py +++ b/tests/test_vulnerability_reference.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT import pytest diff --git a/uv.lock b/uv.lock index 9a14863..272ae3c 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "python-ort" -version = "0.8.4" +version = "0.8.5" source = { editable = "." } dependencies = [ { name = "license-expression" }, @@ -630,7 +630,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "rich", specifier = ">=14.3.3" }, { name = "ruff", specifier = ">=0.15.6" }, - { name = "ty", specifier = ">=0.0.22" }, + { name = "ty", specifier = ">=0.0.23" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] @@ -831,26 +831,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/ee/b73c99daf598ae66a2d5d3ba6de7729d2152ab732dee7ccb8ab9446cc6d7/ty-0.0.22.tar.gz", hash = "sha256:391fc4d3a543950341b750d7f4aa94866a73e7cdbf3e9e4e4e8cfc8b7bef4f10", size = 5333861, upload-time = "2026-03-12T17:40:30.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/f7/078f554f612723597f76cc6af70da4daed63ed721241a3f60788259c9adf/ty-0.0.22-py3-none-linux_armv6l.whl", hash = "sha256:03d37220d81016cb9d2a9c9ec11704d84f2df838f1dbf1296d91ea7fba57f8b5", size = 10328232, upload-time = "2026-03-12T17:40:19.402Z" }, - { url = "https://files.pythonhosted.org/packages/90/0b/4cfe84485d1b20bb50cdbc990f6e66b8c50cff569c7544adf0805b57ddb9/ty-0.0.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3249c65b24829a312cd5cbf722ff5551ffe17b0a9781a8a372ca037d23aa1c71", size = 10148554, upload-time = "2026-03-12T17:40:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/df31baf70d63880c9719d2cc8403b0b99c3c0d0f68f390a1109d9b231933/ty-0.0.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:470778f4335f1660f017fe2970afb7e4ce4f8b608795b19406976b8902b221a5", size = 9627910, upload-time = "2026-03-12T17:40:17.447Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a418bcca9c87083533d6c73b65e56c6ade26b8d76a7558b3d3cc0f0eb52a/ty-0.0.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75649b04b84ace92cb5c6e27013247f220f58a9a30b30eb2301992814deea0c4", size = 10155025, upload-time = "2026-03-12T17:40:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3d/1974c567a58f369602065409d9109c0a81f5abbf1ae552433a89d07141a9/ty-0.0.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc270f2344210cbed7d965ddeade61ffa81d93dffcdc0fded3540dccb860a9e1", size = 10133614, upload-time = "2026-03-12T17:40:23.549Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c1/2da9e27c79a1fe9209589a73c989e416a7380bd77dcdf22960b3d30252bf/ty-0.0.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:548215b226f9726ea4d9beb77055363a8a398eb42809f042895f7a285afcb538", size = 10647101, upload-time = "2026-03-12T17:40:15.569Z" }, - { url = "https://files.pythonhosted.org/packages/c2/93/4e12c2f0ec792fd4ab9c9f70e59465d09345a453ebedb67d3bf99fd75a71/ty-0.0.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0bd1d34eba800b82ebee65269a85a9bbb2a325237e4baaf1413223f69e1899", size = 11231886, upload-time = "2026-03-12T17:40:06.875Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/c255a078e4f2ce135497fffa4a5d3a122e4c49a00416fb78d72d7b79e119/ty-0.0.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbd429a31507da9a1b0873a21215113c42cc683aa5fba96c978794485db5560a", size = 10901527, upload-time = "2026-03-12T17:40:34.429Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/d1bdee7e16d978ea929837fb03463efc116ee8ad05d215a5efd5d80e56d3/ty-0.0.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7eb85a437b3be817796e7c0f84243611de53c7d4ea102a0dca179debfe7cec0", size = 10726505, upload-time = "2026-03-12T17:40:36.342Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/6548d2a353f794582ec94d886b310589c70316fe43476a558e53073ea911/ty-0.0.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b8e32e362e0666cc0769d2862a273def6b61117b8fbb9df493274d536afcd02e", size = 10128777, upload-time = "2026-03-12T17:40:38.517Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d2/eb9185d3fe1fa12decb1c0a045416063bc40122187769b3dfb324da9e51c/ty-0.0.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:667deb1aaf802f396a626cc5a52cd55d935e8d0b46d1be068cf874f7d6f4bdb5", size = 10164992, upload-time = "2026-03-12T17:40:27.833Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ec/067bb6d78cc6f5c4f55f0c3f760eb792b144697b454938fb9d10652caeb2/ty-0.0.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e160681dbf602160e091d94a68207d1393733aedd95e3dc0b2d010bb39a70d78", size = 10342871, upload-time = "2026-03-12T17:40:13.447Z" }, - { url = "https://files.pythonhosted.org/packages/c0/04/dd3a87f54f78ceef5e6ab2add2f3bb85d45829318740f459886654b71a5d/ty-0.0.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:255598763079b80513d98084c4897df688e42666d7e4371349f97d258166389d", size = 10823909, upload-time = "2026-03-12T17:40:11.444Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/4b12e8ff99dec65487ec5342bd5b51fae1482e93a669d098777d55ca5eda/ty-0.0.22-py3-none-win32.whl", hash = "sha256:de0d88d9f788defddfec5507bf356bfc8b90ee301b7d6204f7609e7ac270276f", size = 9746013, upload-time = "2026-03-12T17:40:32.272Z" }, - { url = "https://files.pythonhosted.org/packages/84/16/e246795ed66ff8ee1a47497019f86ea1b4fb238bfca3068f2e08c52ef03b/ty-0.0.22-py3-none-win_amd64.whl", hash = "sha256:c216f750769ac9f3e9e61feabf3fd44c0697dce762bdcd105443d47e1a81c2b9", size = 10709350, upload-time = "2026-03-12T17:40:40.82Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a4/5aafcebc4f597164381b0a82e7a8780d8f9f52df3884b16909a76282a0da/ty-0.0.22-py3-none-win_arm64.whl", hash = "sha256:49795260b9b9e3d6f04424f8ddb34907fac88c33a91b83478a74cead5dde567f", size = 10137248, upload-time = "2026-03-12T17:40:09.244Z" }, +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" }, + { url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" }, + { url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" }, + { url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" }, + { url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" }, + { url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" }, + { url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" }, + { url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" }, + { url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, ] [[package]]