diff --git a/README.md b/README.md index dbb1ea2..134eae2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,28 @@ # Python-Ort -Python-Ort is a pydantic based library to serialize OSS Review Toolkit generated reports using the default models. +Python-Ort is a pydantic v2 based library to serialize [OSS Review Toolkit](https://oss-review-toolkit.org/ort/) generated reports using the default models. + +## Install + +```bash +pip install python-ort +``` + +## Simple usage example based on a report in yml format: + +```python +from pprint import pprint +from pathlib import Path +from pydantic import ValidationError + +from ort import OrtResult, ort_yaml_load + + +try: + with Path("some-result.yml").open() as fd: + data = ort_yaml_load(fd) + parsed = OrtResult(**data) + pprint(parsed) +except ValidationError as e: + print(e) +``` diff --git a/prek.toml b/prek.toml index 34cf931..3e292ae 100644 --- a/prek.toml +++ b/prek.toml @@ -22,7 +22,7 @@ hooks = [ [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.4" +rev = "v0.15.6" hooks = [ { id = "ruff", @@ -43,21 +43,21 @@ hooks = [ [[repos]] repo = "https://github.com/astral-sh/uv-pre-commit" -rev = "0.10.8" +rev = "0.10.9" hooks = [ { id = "uv-lock" } ] [[repos]] repo = "https://github.com/codespell-project/codespell" -rev = "v2.4.1" +rev = "v2.4.2" hooks = [ { id = "codespell" } ] [[repos]] repo = "https://github.com/allganize/ty-pre-commit" -rev = "v0.0.20" +rev = "v0.0.22" hooks = [ { id = "ty-check", @@ -72,6 +72,7 @@ hooks = [ "packageurl-python", "click", "rich", + "license-expression", ] } ] diff --git a/pyproject.toml b/pyproject.toml index 9a10fee..e5457be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,14 @@ build-backend = "uv_build" [project] name = "python-ort" -version = "0.7.0" +version = "0.8.0" description = "A Python Ort model serialization library" readme = "README.md" license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.10" dependencies = [ + "license-expression>=30.4.4", "packageurl-python>=0.17.6", "pydantic>=2.12.5", ] @@ -38,8 +39,8 @@ dev = [ "datamodel-code-generator[http]>=0.55.0", "pytest>=9.0.2", "rich>=14.3.3", - "ruff>=0.15.5", - "ty>=0.0.21", + "ruff>=0.15.6", + "ty>=0.0.22", "types-pyyaml>=6.0.12.20250915", ] @@ -144,9 +145,7 @@ extend-select = [ "S", # bandit ] ignore = [ - 'N802', # function name should be lowercase 'SIM105', # Suggest contextlib instead of try/except with pass - 'A004', # Python shadow builtins ] # Unlike Flake8, default to a complexity level of 10. mccabe.max-complexity = 10 diff --git a/src/ort/models/base_run.py b/src/ort/models/base_run.py index 2e0026c..4ef17d6 100644 --- a/src/ort/models/base_run.py +++ b/src/ort/models/base_run.py @@ -1,4 +1,5 @@ -# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2025 Helio Chissini de Castro +# # SPDX-FileCopyrightText: 2026 CARIAD SE # SPDX-License-Identifier: MIT from datetime import datetime diff --git a/src/ort/models/config/file_archiver_configuration.py b/src/ort/models/config/file_archiver_configuration.py new file mode 100644 index 0000000..aba3b5c --- /dev/null +++ b/src/ort/models/config/file_archiver_configuration.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .file_storage_configuration import FileStorageConfiguration +from .scan_storage_configuration import PostgresStorageConfiguration + + +class FileArchiverConfiguration(BaseModel): + """ + The configuration model for a FileArchiver. + """ + + model_config = ConfigDict( + extra="forbid", + ) + + enabled: bool = Field( + default=True, + description="Toggle to enable or disable the file archiver functionality altogether.", + ) + file_storage: FileStorageConfiguration | None = Field( + default=None, + description="Configuration of the FileStorage used for archiving the files.", + ) + postgres_storage: PostgresStorageConfiguration | None = Field( + default=None, + description="Configuration of the PostgresProvenanceFileStorage used for archiving the files.", + ) diff --git a/src/ort/models/config/file_list_storage_configuration.py b/src/ort/models/config/file_list_storage_configuration.py new file mode 100644 index 0000000..4243c88 --- /dev/null +++ b/src/ort/models/config/file_list_storage_configuration.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .file_storage_configuration import FileStorageConfiguration +from .scan_storage_configuration import PostgresStorageConfiguration + + +class FileListStorageConfiguration(BaseModel): + """ + Configuration for the storage backends used for persisting file lists. + """ + + model_config = ConfigDict(extra="forbid") + + file_storage: FileStorageConfiguration | None = Field( + default=None, + description=("Configuration of the FileStorage used for storing the file lists."), + ) + postgres_storage: PostgresStorageConfiguration | None = Field( + default=None, + description="Configuration of the PostgresProvenanceFileStorage used for storing the file lists.", + ) diff --git a/src/ort/models/config/file_storage_configuration.py b/src/ort/models/config/file_storage_configuration.py new file mode 100644 index 0000000..39ef50a --- /dev/null +++ b/src/ort/models/config/file_storage_configuration.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .http_file_storage_configuration import HttpFileStorageConfiguration +from .local_file_storage_configuration import LocalFileStorageConfiguration +from .s3_file_storage_configuration import S3FileStorageConfiguration + + +class FileStorageConfiguration(BaseModel): + """ + The configuration model for a FileStorage. Only one of the storage options + can be configured. + """ + + model_config = ConfigDict( + extra="forbid", + ) + + http_file_storage: HttpFileStorageConfiguration | None = Field( + default=None, + description="The configuration of a HttpFileStorage.", + ) + local_file_storage: LocalFileStorageConfiguration | None = Field( + default=None, + description="The configuration of a LocalFileStorage.", + ) + s3_file_storage: S3FileStorageConfiguration | None = Field( + default=None, + description="The configuration of a S3FileStorage.", + ) diff --git a/src/ort/models/config/http_file_storage_configuration.py b/src/ort/models/config/http_file_storage_configuration.py new file mode 100644 index 0000000..181aae0 --- /dev/null +++ b/src/ort/models/config/http_file_storage_configuration.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + +from pydantic import BaseModel, ConfigDict, Field + + +class HttpFileStorageConfiguration(BaseModel): + """ + Configuration for HTTP-based file storage. + """ + + url: str = Field( + description='The URL of the HTTP server, e.g. "https://example.com/storage".', + ) + query: str = Field( + default="", + description='Query string appended to the URL and path. Can contain auth data, e.g. "?user=standard&pwd=123".', + ) + headers: dict[str, str] = Field( + default_factory=dict, + description="Custom headers added to all HTTP requests. Values may contain credentials.", + ) + + model_config = ConfigDict(extra="forbid") diff --git a/src/ort/models/config/license_finding_curation_reason.py b/src/ort/models/config/license_finding_curation_reason.py index 193d286..f4fd7a8 100644 --- a/src/ort/models/config/license_finding_curation_reason.py +++ b/src/ort/models/config/license_finding_curation_reason.py @@ -3,10 +3,10 @@ # SPDX-License-Identifier: MIT -from enum import Enum +from ...utils.validated_enum import ValidatedIntEnum -class LicenseFindingCurationReason(Enum): +class LicenseFindingCurationReason(ValidatedIntEnum): """ A curation for a license finding. Use it to correct a license finding or to add a license that was not previously detected. @@ -20,9 +20,9 @@ class LicenseFindingCurationReason(Enum): REFERENCE: The findings reference a file or URL, e.g. SEE LICENSE IN LICENSE or https://jquery.org/license/. """ - CODE = "CODE" - DATA_OF = "DATA_OF" - DOCUMENTATION_OF = "DOCUMENTATION_OF" - INCORRECT = "INCORRECT" - NOT_DETECTED = "NOT_DETECTED" - REFERENCE = "REFERENCE" + CODE = 1 + DATA_OF = 2 + DOCUMENTATION_OF = 3 + INCORRECT = 4 + NOT_DETECTED = 5 + REFERENCE = 6 diff --git a/src/ort/models/config/local_file_storage_configuration.py b/src/ort/models/config/local_file_storage_configuration.py new file mode 100644 index 0000000..107a808 --- /dev/null +++ b/src/ort/models/config/local_file_storage_configuration.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + +from pydantic import BaseModel, ConfigDict, Field + + +class LocalFileStorageConfiguration(BaseModel): + """ + A class to hold the configuration for using local files as a storage. + """ + + model_config = ConfigDict(extra="forbid") + + directory: str = Field( + ..., + description="The directory to use as a storage root.", + ) + compression: bool = Field( + default=True, + description="Whether to use compression for storing files or not. Defaults to true.", + ) diff --git a/src/ort/models/config/postgres_connection.py b/src/ort/models/config/postgres_connection.py new file mode 100644 index 0000000..d309ae7 --- /dev/null +++ b/src/ort/models/config/postgres_connection.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + + +class PostgresConnection(BaseModel): + """ + PostgreSQL connection configuration and HikariCP pool settings. + """ + + model_config = ConfigDict(extra="forbid") + + url: str = Field( + ..., + description=("The database URL in JDBC format."), + ) + + provider_schema: str = Field( + default="public", + alias="schema", + description=("The name of the database to use."), + ) + + username: str = Field( + ..., + description=("The username to use for authentication."), + ) + + password: str = Field( + default_factory=str, + description=("The password to use for authentication."), + ) + + sslmode: str = Field( + default="verify-full", + description='The SSL mode to use, one of "disable", "allow", "prefer", "require", ' + '"verify-ca" or "verify-full". See: ' + "https://jdbc.postgresql.org/documentation/ssl/#configuring-the-client", + ) + + sslcert: str | None = Field( + None, + description="The full path of the certificate file. See: https://jdbc.postgresql.org/documentation/head/connect.html", + ) + + sslkey: str | None = Field( + None, + description="The full path of the key file. See: https://jdbc.postgresql.org/documentation/head/connect.html", + ) + + sslrootcert: str | None = Field( + None, + description="The full path of the root certificate file. See: " + "https://jdbc.postgresql.org/documentation/head/connect.html", + ) + + connection_timeout: int | None = Field( + None, + description="Maximum milliseconds to wait for connections from the pool. See: " + "https://github.com/brettwooldridge/HikariCP#frequently-used", + ) + + idle_timeout: int | None = Field( + None, + description="Maximum milliseconds a connection may sit idle in the pool. Requires " + "minimum_idle < maximum_pool_size. See: " + "https://github.com/brettwooldridge/HikariCP#frequently-used", + ) + + keepalive_time: int | None = Field( + None, + description="Frequency in milliseconds that the pool will keep an idle connection " + "alive. Must be lower than max_lifetime. See: " + "https://github.com/brettwooldridge/HikariCP#frequently-used", + ) + + max_lifetime: int | None = Field( + None, + description=( + "Maximum lifetime of a connection in milliseconds. See: " + "https://github.com/brettwooldridge/HikariCP#frequently-used" + ), + ) + + maximum_pool_size: int | None = Field( + None, + description="Maximum size of the connection pool. See: https://github.com/brettwooldridge/HikariCP#frequently-used", + ) + + minimum_idle: int | None = Field( + None, + description="Minimum number of idle connections that the pool tries to maintain. " + "See: https://github.com/brettwooldridge/HikariCP#frequently-used", + ) diff --git a/src/ort/models/config/provenance_storage_configuration.py b/src/ort/models/config/provenance_storage_configuration.py new file mode 100644 index 0000000..8ca8a9e --- /dev/null +++ b/src/ort/models/config/provenance_storage_configuration.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .file_storage_configuration import FileStorageConfiguration +from .scan_storage_configuration import PostgresStorageConfiguration + + +class ProvenanceStorageConfiguration(BaseModel): + """ + Configuration of the storage to use for provenance information. + """ + + model_config = ConfigDict(extra="forbid") + + file_storage: FileStorageConfiguration | None = Field( + default=None, + description="Configuration of a file storage.", + ) + postgres_storage: PostgresStorageConfiguration | None = Field( + default=None, + description="Configuration of a PostgreSQL storage.", + ) diff --git a/src/ort/models/config/s3_file_storage_configuration.py b/src/ort/models/config/s3_file_storage_configuration.py new file mode 100644 index 0000000..32e4b30 --- /dev/null +++ b/src/ort/models/config/s3_file_storage_configuration.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + + +class S3FileStorageConfiguration(BaseModel): + """ + A class to hold the configuration for using an AWS S3 bucket as a storage. + """ + + model_config = ConfigDict(extra="forbid") + + access_key_id: str | None = Field( + default=None, + description="The AWS access key.", + ) + aws_region: str | None = Field( + default=None, + description="The AWS region to be used.", + ) + bucket_name: str = Field( + description="The name of the S3 bucket used to store files in.", + ) + compression: bool = Field( + default=False, + description="Whether to use compression for storing files or not.", + ) + custom_endpoint: str | None = Field( + default=None, + description="Custom endpoint to perform AWS API requests.", + ) + path_style_access: bool = Field( + default=False, + description="Whether to enable path style access or not. Required for many non-AWS S3 providers.", + ) + secret_access_key: str | None = Field( + default=None, + description="The AWS secret for the access key.", + ) diff --git a/src/ort/models/config/scan_storage_configuration.py b/src/ort/models/config/scan_storage_configuration.py new file mode 100644 index 0000000..52a7e0c --- /dev/null +++ b/src/ort/models/config/scan_storage_configuration.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ...utils.validated_enum import ValidatedIntEnum +from .file_storage_configuration import FileStorageConfiguration +from .postgres_connection import PostgresConnection + + +class StorageType(ValidatedIntEnum): + """ + An enum to describe different types of storages. + + Properties: + PACKAGE_BASED: A storage that stores scan results by [Package]. + PROVENANCE_BASED: A storage that stores scan results by [Provenance]. + """ + + PACKAGE_BASED = 1 + PROVENANCE_BASED = 2 + + +class ScanStorageConfiguration(BaseModel): + """ + Root of a class hierarchy for configuration classes for scan storage + implementations. + + Based on this hierarchy, it is possible to have multiple different scan + storages enabled and to configure them dynamically. + """ + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def validate_provenance(cls, v): + if not isinstance(v, dict): + raise ValueError("Config must be a dictionary.") + # Return the dict as-is; ScanStorageConfiguration with extra="allow" + # will store all fields without needing to instantiate subclasses. + return v + + +class ClearlyDefinedStorageConfiguration(ScanStorageConfiguration): + """ + The configuration model of a storage based on ClearlyDefined. + """ + + server_url: str = Field( + description="The URL of the ClearlyDefined server.", + ) + + +class FileBasedStorageConfiguration(ScanStorageConfiguration): + """ + The configuration model of a file based storage. + """ + + backend: FileStorageConfiguration = Field( + description="The configuration of the FileStorage used to store the files." + ) + ort_type: StorageType = Field( + alias="type", + default="PROVENANCE_BASED", + description=("The way that scan results are stored, defaults to StorageType.PROVENANCE_BASED."), + ) + + +class PostgresStorageConfiguration(ScanStorageConfiguration): + """ + A class to hold the configuration for using Postgres as a storage. + """ + + connection: PostgresConnection = Field( + description="The configuration of the PostgreSQL database.", + ) + ort_type: StorageType = Field( + alias="type", + default="PROVENANCE_BASED", + description=("The way that scan results are stored, defaults to StorageType.PROVENANCE_BASED."), + ) diff --git a/src/ort/models/config/scanner_configuration.py b/src/ort/models/config/scanner_configuration.py new file mode 100644 index 0000000..1bc5fae --- /dev/null +++ b/src/ort/models/config/scanner_configuration.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from .file_archiver_configuration import FileArchiverConfiguration +from .file_list_storage_configuration import FileListStorageConfiguration +from .provenance_storage_configuration import ProvenanceStorageConfiguration +from .scan_storage_configuration import ScanStorageConfiguration + + +class ScannerConfiguration(BaseModel): + """ + The configuration model of the scanner. + + This is deserialized from "config.yml" as part of OrtConfiguration and + (de-)serialized as part of org.ossreviewtoolkit.model.OrtResult. + """ + + model_config = ConfigDict(extra="forbid") + + skip_concluded: bool = Field( + default=False, + description="Skip packages with concluded license and authors (for copyrights) and use only declared info.", + ) + + skip_excluded: bool = Field( + default=False, + description="Control whether excluded scopes and paths are skipped during the scan.", + ) + + include_files_without_findings: bool = Field( + default=False, + description="Whether the scanner should add files without license to the scanner results.", + ) + + archive: "FileArchiverConfiguration | None" = Field( + default=None, + description="Configuration of a FileArchiver that archives selected scanned files in external storage.", + ) + + # Use empty dict instead of upstream defaults as this class is not intended to provide defaults as upstream + # Kotlin counterpart, but just do proper parsing of existing pre created result + detected_license_mapping: dict[str, str] = Field( + default_factory=dict, + description="Mappings from scanner-returned licenses to valid SPDX licenses; only applied to new scans.", + ) + + file_list_storage: FileListStorageConfiguration | None = Field( + default=None, + description="The storage to store the file lists by provenance.", + ) + + scanners: dict[str, Any] | None = Field( + default=None, + description="Scanner-specific configuration options. The key needs to match the name of the scanner" + "class, e.g. 'ScanCode' for the ScanCode wrapper. See the documentation of the scanner for available options.", + ) + + storages: dict[str, ScanStorageConfiguration] | None = Field( + default=None, + description="A map with the configurations of the scan result storages available." + "Based on this information the actual storages are created." + "Storages can be configured as readers or writers of scan results. Having " + "this map makes it possible for storage instances to act in both roles without having to duplicate " + "configuration.", + ) + + storage_readers: list[str] | None = Field( + default=None, + description="A list with the IDs of scan storages that are queried for existing scan results." + "The strings in this list must match keys in the storages map.", + ) + + storage_writers: list[str] | None = Field( + default=None, + description="A list with the IDs of scan storages that are called to persist scan results." + "The strings in this list must match keys in the [storages] map.", + ) + + ignore_patterns: list[str] = Field( + default_factory=list, + description="A list of glob expressions that match file paths which are to be excluded from scan results.", + ) + + provenance_storage: ProvenanceStorageConfiguration | None = Field( + default=None, + description="Configuration of the storage for provenance information.", + ) diff --git a/src/ort/models/copyright_finding.py b/src/ort/models/copyright_finding.py new file mode 100644 index 0000000..0879fa6 --- /dev/null +++ b/src/ort/models/copyright_finding.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# # SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .text_location import TextLocation + + +class CopyrightFinding(BaseModel): + """ + A class representing a single copyright finding. + """ + + model_config = ConfigDict(extra="forbid") + + statement: str = Field( + description="The copyright statement.", + ) + location: TextLocation = Field( + description="The text location where the copyright statement was found.", + ) diff --git a/src/ort/models/file_list.py b/src/ort/models/file_list.py new file mode 100644 index 0000000..986381d --- /dev/null +++ b/src/ort/models/file_list.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .provenance import ArtifactProvenance, RepositoryProvenance + + +class Entry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + path: str = Field( + ..., + description="The path of the file relative to the root of the provenance corresponding" + "to the enclosing [FileList].", + ) + sha1: str = Field(..., description="The sha1 checksum of the file, consisting of 40lowercase hexadecimal digits.") + + def __hash__(self) -> int: + return hash(self.path) + + def __eq__(self, other) -> bool: + if not isinstance(other, Entry): + return NotImplemented + return self.path == other.path + + +class FileList(BaseModel): + """ + The file info for files contained in [provenance]. + """ + + model_config = ConfigDict( + extra="forbid", + ) + provenance: RepositoryProvenance | ArtifactProvenance = Field( + ..., + description="The provenance this file list corresponds to.", + ) + files: set[Entry] = Field( + ..., + description="The files contained in [provenance], excluding directories which are certainly irrelevant" + "like e.g. the `.git` directory.", + ) + + def __hash__(self) -> int: + return hash(self.provenance) + + def __eq__(self, other) -> bool: + if not isinstance(other, FileList): + return NotImplemented + return self.provenance == other.provenance diff --git a/src/ort/models/issue.py b/src/ort/models/issue.py index fa9249f..aead66b 100644 --- a/src/ort/models/issue.py +++ b/src/ort/models/issue.py @@ -34,3 +34,11 @@ class Issue(BaseModel): default=None, description="The affected file or directory the issue is limited to, if any.", ) + + def __hash__(self) -> int: + return hash(self.affected_path) + + def __eq__(self, other) -> bool: + if not isinstance(other, Issue): + return NotImplemented + return self.affected_path == other.affected_path diff --git a/src/ort/models/license_finding.py b/src/ort/models/license_finding.py new file mode 100644 index 0000000..bdddc47 --- /dev/null +++ b/src/ort/models/license_finding.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from license_expression import ExpressionError, get_spdx_licensing +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .text_location import TextLocation + + +class LicenseFinding(BaseModel): + """ + A class representing a license finding. License findings can point to single + licenses or to complex SPDX expressions, depending on the capabilities of + the used license scanner. License finding curations can also be used to + create findings with complex expressions. + """ + + model_config = ConfigDict(extra="forbid") + + license: str = Field(description=("The found license as an SPDX expression.")) + + location: "TextLocation" = Field(description=("The text location where the license was found.")) + + score: float | None = Field( + default=None, + description=( + "The score of a license finding. Its exact meaning is scanner-" + "specific, but it should give some hint at how much the finding " + "can be relied on or how confident the scanner is to be right. In " + "most cases this is a percentage where 100.0 means that the " + "scanner is 100% confident that the finding is correct." + ), + ) + + @field_validator("license", mode="before") + @classmethod + def validate_spdx(cls, value): + try: + licensing = get_spdx_licensing() + licensing.parse(value) + return value + except ExpressionError as e: + raise ValueError(str(e)) from e diff --git a/src/ort/models/ort_result.py b/src/ort/models/ort_result.py index 80ad442..cf7300d 100644 --- a/src/ort/models/ort_result.py +++ b/src/ort/models/ort_result.py @@ -8,6 +8,7 @@ from .analyzer_run import AnalyzerRun from .evaluator_run import EvaluatorRun from .repository import Repository +from .scanner_run import ScannerRun class OrtResult(BaseModel): @@ -28,9 +29,15 @@ class OrtResult(BaseModel): repository: Repository = Field( description="Information about the repository that was used as input.", ) - analyzer: AnalyzerRun = Field( + analyzer: AnalyzerRun | None = Field( + default=None, description="An [AnalyzerRun] containing details about the analyzer that was run using [repository]" - "as input. Can be null if the [repository] was not yet analyzed." + "as input. Can be null if the [repository] was not yet analyzed.", + ) + scanner: ScannerRun | None = Field( + default=None, + description="A [ScannerRun] containing details about the scanner that was run using the result" + "from [analyzer] as input. Can be null if no scanner was run.", ) advisor: AdvisorRun | None = Field( default=None, diff --git a/src/ort/models/provenance.py b/src/ort/models/provenance.py index 8fc86d2..09a8370 100644 --- a/src/ort/models/provenance.py +++ b/src/ort/models/provenance.py @@ -1,13 +1,15 @@ # SPDX-FileCopyrightText: 2026 Helio Chissini de Castro # SPDX-License-Identifier: MIT -from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag from .remote_artifact import RemoteArtifact from .vcsinfo import VcsInfo -class SnippetProvenance(BaseModel): +class Provenance(BaseModel): """ Provenance information about the origin of source code. @@ -19,35 +21,21 @@ class SnippetProvenance(BaseModel): model_config = ConfigDict(extra="allow") - @model_validator(mode="before") - @classmethod - def validate_provenance(cls, v): - print(v) - breakpoint() - if not isinstance(v, dict): - raise ValueError("Provenance must be a dictionary.") - if "source_artifact" in v: - return ArtifactProvenance(**v) - elif "vcs_info" in v and "resolved_revision" in v: - return RepositoryProvenance(**v) - else: - return UnknownProvenance() - -class UnknownProvenance(BaseModel): +class UnknownProvenance(Provenance): """ Provenance information about the origin of source code. """ - model_config = ConfigDict(extra="forbid") + pass -class KnownProvenance(BaseModel): +class KnownProvenance(Provenance): """ Provenance information about the origin of source code. """ - model_config = ConfigDict(extra="forbid") + pass class RemoteProvenance(KnownProvenance): @@ -55,7 +43,7 @@ class RemoteProvenance(KnownProvenance): Provenance information about the origin of source code. """ - model_config = ConfigDict(extra="forbid") + pass class ArtifactProvenance(RemoteProvenance): @@ -63,23 +51,57 @@ class ArtifactProvenance(RemoteProvenance): Provenance information for a source artifact. """ - model_config = ConfigDict(extra="forbid") - source_artifact: RemoteArtifact = Field( description="The source artifact that was downloaded.", ) + def __hash__(self) -> int: + return hash(self.source_artifact.url) + + def __eq__(self, other) -> bool: + if not isinstance(other, ArtifactProvenance): + return NotImplemented + return self.source_artifact.url == other.source_artifact.url + -class RepositoryProvenance(BaseModel): +class RepositoryProvenance(RemoteProvenance): """ Provenance information for a Version Control System location. """ - model_config = ConfigDict(extra="forbid") - vcs_info: VcsInfo = Field( description="VCS info used to resolve the revision. May still contain a moving revision like a branch.", ) resolved_revision: str = Field( description="Resolved fixed VCS revision, not blank and not moving (e.g. Git commit SHA1)." ) + + def __hash__(self) -> int: + return hash((self.vcs_info.url, self.resolved_revision)) + + def __eq__(self, other) -> bool: + if not isinstance(other, RepositoryProvenance): + return NotImplemented + return self.vcs_info.url == other.vcs_info.url and self.resolved_revision == other.resolved_revision + + +def _provenance_discriminator(v: dict | Provenance) -> str: + if isinstance(v, dict): + if "source_artifact" in v: + return "artifact" + elif "vcs_info" in v and "resolved_revision" in v: + return "repository" + return "unknown" + if isinstance(v, ArtifactProvenance): + return "artifact" + if isinstance(v, RepositoryProvenance): + return "repository" + return "unknown" + + +ProvenanceType = Annotated[ + Annotated[RepositoryProvenance, Tag("repository")] + | Annotated[ArtifactProvenance, Tag("artifact")] + | Annotated[UnknownProvenance, Tag("unknown")], + Discriminator(_provenance_discriminator), +] diff --git a/src/ort/models/provenance_resolution_result.py b/src/ort/models/provenance_resolution_result.py new file mode 100644 index 0000000..b1b901d --- /dev/null +++ b/src/ort/models/provenance_resolution_result.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + +from .identifier import Identifier +from .issue import Issue +from .provenance import ArtifactProvenance, RepositoryProvenance +from .vcsinfo import VcsInfo + + +class ProvenanceResolutionResult(BaseModel): + """ + Hold the results of provenance resolution for the package denoted by ``id``. + + The provenance resolution consists of root provenance resolution and nested + provenance resolution, i.e. determining the sub-repositories of the root + provenance. The information tells what has been scanned, or in case of an + issue, what problems happened during provenance resolution. + """ + + model_config = ConfigDict(extra="forbid") + + id: Identifier = Field(description="The identifier of the package.") + + package_provenance: RepositoryProvenance | ArtifactProvenance | None = Field( + default=None, + description=( + "The resolved provenance of the package. Can be null only if a " + "`package_provenance_resolution_issue` occurred." + ), + ) + + sub_repositories: dict[str, VcsInfo] = Field( + default_factory=dict, + description=( + "The (recursive) sub-repositories of `package_provenance`. The " + "map can be empty only if a `package_provenance_resolution_issue` " + "or a `nested_provenance_resolution_issue` occurred." + ), + ) + + package_provenance_resolution_issue: Issue | None = Field( + default=None, + description=("The issue that happened during package provenance resolution, if any."), + ) + + nested_provenance_resolution_issue: Issue | None = Field( + default=None, + description=("The issue that happened during nested provenance resolution, if any."), + ) + + def __hash__(self) -> int: + return hash(str(self.id)) + + def __eq__(self, other) -> bool: + if not isinstance(other, ProvenanceResolutionResult): + return NotImplemented + return self.id == other.id diff --git a/src/ort/models/resolutions.py b/src/ort/models/resolutions.py index 7760a17..1cb59c0 100644 --- a/src/ort/models/resolutions.py +++ b/src/ort/models/resolutions.py @@ -3,35 +3,36 @@ # SPDX-License-Identifier: MIT -from enum import Enum from typing import Annotated from pydantic import BaseModel, Field, RootModel +from ..utils.validated_enum import ValidatedIntEnum -class IssueResolutionReason(Enum): - BUILD_TOOL_ISSUE = "BUILD_TOOL_ISSUE" - CANT_FIX_ISSUE = "CANT_FIX_ISSUE" - SCANNER_ISSUE = "SCANNER_ISSUE" +class IssueResolutionReason(ValidatedIntEnum): + BUILD_TOOL_ISSUE = 1 + CANT_FIX_ISSUE = 2 + SCANNER_ISSUE = 3 -class RuleViolationResolutionReason(Enum): - CANT_FIX_EXCEPTION = "CANT_FIX_EXCEPTION" - DYNAMIC_LINKAGE_EXCEPTION = "DYNAMIC_LINKAGE_EXCEPTION" - EXAMPLE_OF_EXCEPTION = "EXAMPLE_OF_EXCEPTION" - LICENSE_ACQUIRED_EXCEPTION = "LICENSE_ACQUIRED_EXCEPTION" - NOT_MODIFIED_EXCEPTION = "NOT_MODIFIED_EXCEPTION" - PATENT_GRANT_EXCEPTION = "PATENT_GRANT_EXCEPTION" +class RuleViolationResolutionReason(ValidatedIntEnum): + CANT_FIX_EXCEPTION = 1 + DYNAMIC_LINKAGE_EXCEPTION = 2 + EXAMPLE_OF_EXCEPTION = 3 + LICENSE_ACQUIRED_EXCEPTION = 4 + NOT_MODIFIED_EXCEPTION = 5 + PATENT_GRANT_EXCEPTION = 6 -class VulnerabilityResolutionReason(Enum): - CANT_FIX_VULNERABILITY = "CANT_FIX_VULNERABILITY" - INEFFECTIVE_VULNERABILITY = "INEFFECTIVE_VULNERABILITY" - INVALID_MATCH_VULNERABILITY = "INVALID_MATCH_VULNERABILITY" - MITIGATED_VULNERABILITY = "MITIGATED_VULNERABILITY" - NOT_A_VULNERABILITY = "NOT_A_VULNERABILITY" - WILL_NOT_FIX_VULNERABILITY = "WILL_NOT_FIX_VULNERABILITY" - WORKAROUND_FOR_VULNERABILITY = "WORKAROUND_FOR_VULNERABILITY" + +class VulnerabilityResolutionReason(ValidatedIntEnum): + CANT_FIX_VULNERABILITY = 1 + INEFFECTIVE_VULNERABILITY = 2 + INVALID_MATCH_VULNERABILITY = 3 + MITIGATED_VULNERABILITY = 4 + NOT_A_VULNERABILITY = 5 + WILL_NOT_FIX_VULNERABILITY = 6 + WORKAROUND_FOR_VULNERABILITY = 7 class Issue(BaseModel): diff --git a/src/ort/models/scan_result.py b/src/ort/models/scan_result.py new file mode 100644 index 0000000..b18f3e2 --- /dev/null +++ b/src/ort/models/scan_result.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .provenance import ProvenanceType +from .scan_summary import ScanSummary +from .scanner_details import ScannerDetails + + +class ScanResult(BaseModel): + """ + The result of a single scan of a single package. + """ + + model_config = ConfigDict( + extra="forbid", + ) + + provenance: ProvenanceType = Field( + description=("Provenance information about the scanned source code."), + ) + + scanner: ScannerDetails = Field( + description=("Details about the used scanner."), + ) + + summary: ScanSummary = Field( + description=("A summary of the scan results."), + ) + + additional_data: dict[str, str] = Field( + default_factory=dict, + description=("Scanner-specific data that cannot be mapped into a generalized property but must be stored."), + ) + + def __hash__(self) -> int: + return hash(self.provenance) + + def __eq__(self, other) -> bool: + if not isinstance(other, ScanResult): + return NotImplemented + return self.provenance == other.provenance diff --git a/src/ort/models/scan_summary.py b/src/ort/models/scan_summary.py new file mode 100644 index 0000000..11a3224 --- /dev/null +++ b/src/ort/models/scan_summary.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from .copyright_finding import CopyrightFinding +from .issue import Issue +from .license_finding import LicenseFinding +from .snippet_finding import SnippetFinding + + +class ScanSummary(BaseModel): + """ + Summary of a scan including timings, findings and issues. + """ + + model_config = ConfigDict( + extra="forbid", + ) + start_time: datetime = Field( + description="The time the scan was started.", + ) + end_time: datetime = Field( + description="The time the scan has finished.", + ) + license_findings: set[LicenseFinding] = Field( + default_factory=set, + alias="licenses", + description="The detected license findings.", + ) + copyright_findings: set[CopyrightFinding] = Field( + default_factory=set, + alias="copyrights", + description="The detected copyright findings.", + ) + snippet_findings: set[SnippetFinding] = Field( + default_factory=set, + alias="snippets", + description="The detected snippet findings.", + ) + issues: list[Issue] = Field( + default_factory=list, + description=( + "The list of issues that occurred during the scan. This property is " + "not serialized if the list is empty to reduce the size of the result " + "file." + ), + ) diff --git a/src/ort/models/scanner_details.py b/src/ort/models/scanner_details.py new file mode 100644 index 0000000..508b859 --- /dev/null +++ b/src/ort/models/scanner_details.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, ConfigDict, Field + + +class ScannerDetails(BaseModel): + """ + Details about the used source code scanner. + """ + + model_config = ConfigDict( + extra="forbid", + ) + + name: str = Field( + description="The name of the scanner.", + ) + version: str = Field( + description="The version of the scanner.", + ) + configuration: str = Field( + description="Configuration that ensures reproducible results. For command line scanners " + "include options significant for the scan results.", + ) diff --git a/src/ort/models/scanner_run.py b/src/ort/models/scanner_run.py new file mode 100644 index 0000000..c0dd7fc --- /dev/null +++ b/src/ort/models/scanner_run.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import Field + +from .base_run import BaseRun +from .config.scanner_configuration import ScannerConfiguration +from .file_list import FileList +from .identifier import Identifier +from .issue import Issue +from .provenance_resolution_result import ProvenanceResolutionResult +from .scan_result import ScanResult + + +class ScannerRun(BaseRun): + """ + The summary of a single run of the scanner. + + """ + + config: ScannerConfiguration = Field( + ..., + description="The [ScannerConfiguration] used for this run.", + ) + + provenances: set[ProvenanceResolutionResult] = Field( + description="The results of the provenance resolution for all projects and packages.", + ) + + scan_results: set[ScanResult] | None = Field( + default=None, + description="The result of this run.", + ) + + issues: dict[Identifier, set[Issue]] = Field( + default_factory=dict, + description="A map of [Identifier]s associated with a set of [Issue]s that occurred during a scan besides the" + "issues created by the scanners themselves as part of the [ScanSummary].", + ) + + scanners: dict[Identifier, set[str]] = Field( + ..., + description="The project / package identifiers that have been scanned, associated with " + "the names of the scanners used.", + ) + + files: set[FileList] = Field( + ..., + description="The list of files for each resolved provenance.", + ) diff --git a/src/ort/models/snippet.py b/src/ort/models/snippet.py new file mode 100644 index 0000000..dde59d6 --- /dev/null +++ b/src/ort/models/snippet.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from license_expression import ExpressionError, get_spdx_licensing +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .provenance import ArtifactProvenance, RepositoryProvenance +from .text_location import TextLocation + + +class Snippet(BaseModel): + """ + A code snippet detected by a snippet scanner, together with its provenance, + license and additional scanner specific metadata. + """ + + model_config = ConfigDict(extra="forbid") + + score: float = Field( + ..., + description=( + "The matching score between the code being scanned and the code " + "snippet. This is scanner-specific (e.g. for ScanOSS this is a " + "percentage)." + ), + ) + location: TextLocation = Field( + ..., + description="The text location in the snippet that has matched.", + ) + provenance: RepositoryProvenance | ArtifactProvenance = Field( + ..., + description="The provenance of the snippet, either an artifact or a repository.", + ) + purl: str = Field( + ..., + description="The purl representing the author/vendor, artifact, version of the " + "code snippet. If the snippet scanner does not natively support " + "purls, it will be generated by ORT.", + ) + license: str = Field( + ..., + description="The SPDX license expression of the component the code snippet is coming from.", + ) + additional_data: dict[str, str] = Field( + default_factory=dict, + description=( + "A map for scanner-specific snippet data that cannot be mapped " + "into any generalized property, but still needs to be made " + "available in the scan summary." + ), + ) + + def __hash__(self) -> int: + return hash(self.purl) + + def __eq__(self, other) -> bool: + if not isinstance(other, Snippet): + return NotImplemented + return self.purl == other.purl + + @field_validator("license", mode="before") + @classmethod + def validate_spdx(cls, value): + try: + licensing = get_spdx_licensing() + licensing.parse(value) + return value + except ExpressionError as e: + raise ValueError(f"Invalid SPDX license expression: {e}") from e diff --git a/src/ort/models/snippet_finding.py b/src/ort/models/snippet_finding.py new file mode 100644 index 0000000..e1a5c8d --- /dev/null +++ b/src/ort/models/snippet_finding.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-FileCopyrightText: 2026 CARIAD SE +# SPDX-License-Identifier: MIT + + +from pydantic import BaseModel, Field +from pydantic.config import ConfigDict + +from .snippet import Snippet +from .text_location import TextLocation + + +class SnippetFinding(BaseModel): + """ + Snippet findings for a source file location. + + A snippet finding is a code snippet from another origin that matches the + code being scanned. It is meant to be reviewed by an operator as it could + be a false positive. + """ + + model_config = ConfigDict( + extra="forbid", + ) + + source_location: TextLocation = Field( + description="Text location in the scanned source file where the snippet matched.", + ) + snippets: set[Snippet] = Field( + description="The corresponding snippets.", + ) + + def __hash__(self) -> int: + return hash(self.source_location) + + def __eq__(self, other) -> bool: + if not isinstance(other, SnippetFinding): + return NotImplemented + return self.source_location == other.source_location diff --git a/src/ort/models/source_code_origin.py b/src/ort/models/source_code_origin.py index 5bb7622..bd5c7db 100644 --- a/src/ort/models/source_code_origin.py +++ b/src/ort/models/source_code_origin.py @@ -1,13 +1,13 @@ # SPDX-FileCopyrightText: 2025 Helio Chissini de Castro # SPDX-License-Identifier: MIT -from enum import Enum +from ..utils.validated_enum import ValidatedIntEnum -class SourceCodeOrigin(Enum): +class SourceCodeOrigin(ValidatedIntEnum): """ An enumeration of supported source code origins. """ - vcs = "VCS" - artifact = "ARTIFACT" + VCS = 1 + ARTIFACT = 2 diff --git a/src/ort/models/text_location.py b/src/ort/models/text_location.py index f3ec526..1602c66 100644 --- a/src/ort/models/text_location.py +++ b/src/ort/models/text_location.py @@ -34,3 +34,11 @@ def validate_line_numbers(cls, value): if value < 0: raise ValueError("Line numbers must be greater than or equal to 0.") return value + + def __hash__(self) -> int: + return hash((self.path, self.start_line, self.end_line)) + + def __eq__(self, other) -> bool: + if not isinstance(other, TextLocation): + return NotImplemented + return self.path == other.path and self.start_line == other.start_line and self.end_line == other.end_line diff --git a/src/ort/models/vcstype.py b/src/ort/models/vcstype.py index 824f73a..a7b94e3 100644 --- a/src/ort/models/vcstype.py +++ b/src/ort/models/vcstype.py @@ -27,7 +27,7 @@ class VcsType(BaseModel): @model_validator(mode="before") @classmethod - def _forName(cls, value): + def for_name(cls, value): # Allow direct string input (e.g., "Git" or "gitlab") if isinstance(value, str): if any(item.lower() == value.lower() for item in KNOWN_TYPES): diff --git a/src/ort/utils/yaml_loader.py b/src/ort/utils/yaml_loader.py index 56faad1..1e279d4 100644 --- a/src/ort/utils/yaml_loader.py +++ b/src/ort/utils/yaml_loader.py @@ -5,14 +5,10 @@ import yaml -# Prefer the C-accelerated SafeLoader when available (5-10x faster). -try: - _BaseLoader = yaml.CSafeLoader -except AttributeError: - _BaseLoader = yaml.SafeLoader +_BaseLoader = getattr(yaml, "CSafeLoader", yaml.SafeLoader) -class OrtYamlLoader(_BaseLoader): # type: ignore[misc] +class OrtYamlLoader(_BaseLoader): """A YAML loader that handles ORT-specific custom tags. ORT result files may contain custom YAML tags like diff --git a/tests/test_scan_result.py b/tests/test_scan_result.py new file mode 100644 index 0000000..9f8315e --- /dev/null +++ b/tests/test_scan_result.py @@ -0,0 +1,399 @@ +# SPDX-FileCopyrightText: 2026 Helio Chissini de Castro +# SPDX-License-Identifier: MIT + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from ort.models.provenance import RepositoryProvenance +from ort.models.scan_result import ScanResult +from ort.models.scan_summary import ScanSummary +from ort.models.scanner_details import ScannerDetails +from ort.models.scanner_run import ScannerRun +from ort.models.snippet import Snippet +from ort.models.snippet_finding import SnippetFinding +from ort.models.text_location import TextLocation +from ort.models.vcsinfo import VcsInfo +from ort.models.vcstype import VcsType +from tests.utils.load_yaml_config import load_yaml_config + + +def _make_scanner_details() -> ScannerDetails: + """Create a minimal ScannerDetails for testing.""" + return ScannerDetails( + name="SCANOSS", + version="0.12.1", + configuration="", + ) + + +def _make_provenance() -> RepositoryProvenance: + """Create a minimal RepositoryProvenance for testing.""" + return RepositoryProvenance( + vcs_info=VcsInfo( + type=VcsType(name="Git"), + url="https://github.com/heliocastro/python-ort.git", + revision="15544ad032100f4f6bda18c9db6be0f489c50070", + path="", + ), + resolved_revision="15544ad032100f4f6bda18c9db6be0f489c50070", + ) + + +def _make_summary() -> ScanSummary: + """Create a minimal ScanSummary for testing.""" + return ScanSummary( + start_time=datetime(2026, 3, 4, 17, 47, 21, tzinfo=timezone.utc), + end_time=datetime(2026, 3, 4, 17, 47, 23, tzinfo=timezone.utc), + ) + + +def _make_snippet() -> Snippet: + """Create a Snippet for testing.""" + return Snippet( + score=60.0, + location=TextLocation( + path="model/src/main/kotlin/AnalyzerResult.kt", + start_line=31, + end_line=56, + ), + provenance=RepositoryProvenance( + vcs_info=VcsInfo( + type=VcsType(name="Git"), + url="https://github.com/oss-review-toolkit/ort.git", + revision="", + path="", + ), + resolved_revision=".", + ), + purl="pkg:github/oss-review-toolkit/ort", + license="Apache-2.0", + additional_data={ + "file_hash": "86eb0bcdef039e1cde377c92f5b7c44c", + }, + ) + + +def _make_snippet_finding() -> SnippetFinding: + """Create a SnippetFinding for testing.""" + return SnippetFinding( + source_location=TextLocation( + path="src/ort/models/analyzer_result.py", + start_line=16, + end_line=41, + ), + snippets={_make_snippet()}, + ) + + +# --- Valid construction tests --- + + +def test_scan_result_valid_minimal(): + """Test creating a minimal valid ScanResult.""" + result = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + if result.scanner.name != "SCANOSS": + pytest.fail(f"Expected scanner name 'SCANOSS', got '{result.scanner.name}'") + if result.scanner.version != "0.12.1": + pytest.fail(f"Expected scanner version '0.12.1', got '{result.scanner.version}'") + if result.additional_data != {}: + pytest.fail(f"Expected empty additional_data, got {result.additional_data}") + + +def test_scan_result_with_additional_data(): + """Test creating a ScanResult with additional_data.""" + result = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + additional_data={"key": "value"}, + ) + if result.additional_data != {"key": "value"}: + pytest.fail(f"Expected {{'key': 'value'}}, got {result.additional_data}") + + +def test_scan_result_with_snippet_findings(): + """Test creating a ScanResult with snippet findings in the summary.""" + finding = _make_snippet_finding() + summary = ScanSummary( + start_time=datetime(2026, 3, 4, 17, 47, 21, tzinfo=timezone.utc), + end_time=datetime(2026, 3, 4, 17, 47, 23, tzinfo=timezone.utc), + snippets={finding}, + ) + result = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=summary, + ) + if len(result.summary.snippet_findings) != 1: + pytest.fail(f"Expected 1 snippet finding, got {len(result.summary.snippet_findings)}") + snippet_finding = next(iter(result.summary.snippet_findings)) + if snippet_finding.source_location.path != "src/ort/models/analyzer_result.py": + pytest.fail( + f"Expected source path 'src/ort/models/analyzer_result.py', got '{snippet_finding.source_location.path}'" + ) + snippet = next(iter(snippet_finding.snippets)) + if snippet.score != 60.0: + pytest.fail(f"Expected snippet score 60.0, got {snippet.score}") + if snippet.license != "Apache-2.0": + pytest.fail(f"Expected license 'Apache-2.0', got '{snippet.license}'") + + +def test_scan_result_provenance_with_vcs_info(): + """Test that provenance correctly resolves to RepositoryProvenance.""" + result = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + if not isinstance(result.provenance, RepositoryProvenance): + pytest.fail(f"Expected RepositoryProvenance. Got {result.provenance}") + if result.provenance.vcs_info.url != "https://github.com/heliocastro/python-ort.git": + pytest.fail(f"Unexpected VCS URL: {result.provenance.vcs_info.url}") + if result.provenance.resolved_revision != "15544ad032100f4f6bda18c9db6be0f489c50070": + pytest.fail(f"Unexpected resolved_revision: {result.provenance.resolved_revision}") + + +# --- Hash and equality tests --- + + +def test_scan_result_hash_and_equality(): + """Test that ScanResult __hash__ and __eq__ work correctly.""" + result1 = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + result2 = ScanResult( + provenance=_make_provenance(), + scanner=ScannerDetails(name="ScanCode", version="1.0", configuration=""), + summary=_make_summary(), + ) + if result1 != result2: + pytest.fail("Expected ScanResults with same provenance to be equal") + if hash(result1) != hash(result2): + pytest.fail("Expected ScanResults with same provenance to have same hash") + + +def test_scan_result_in_set(): + """Test that ScanResult can be used in a set.""" + result1 = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + result2 = ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + results: set[ScanResult] = {result1, result2} + if len(results) != 1: + pytest.fail(f"Expected set with 1 element (dedup by provenance), got {len(results)}") + + +def test_snippet_finding_hash_and_equality(): + """Test that SnippetFinding can be used in sets.""" + finding1 = _make_snippet_finding() + finding2 = _make_snippet_finding() + if finding1 != finding2: + pytest.fail("Expected SnippetFindings with same source_location to be equal") + findings: set[SnippetFinding] = {finding1, finding2} + if len(findings) != 1: + pytest.fail(f"Expected set with 1 element, got {len(findings)}") + + +def test_snippet_hash_and_equality(): + """Test that Snippet can be used in sets.""" + snippet1 = _make_snippet() + snippet2 = _make_snippet() + if snippet1 != snippet2: + pytest.fail("Expected Snippets with same purl to be equal") + snippets: set[Snippet] = {snippet1, snippet2} + if len(snippets) != 1: + pytest.fail(f"Expected set with 1 element, got {len(snippets)}") + + +# --- Validation error tests --- + + +def test_scan_result_missing_provenance(): + """Test that missing provenance raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + scanner=_make_scanner_details(), + summary=_make_summary(), + ) # ty: ignore[missing-argument] + + +def test_scan_result_missing_scanner(): + """Test that missing scanner raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + provenance=_make_provenance(), + summary=_make_summary(), + ) # ty: ignore[missing-argument] + + +def test_scan_result_missing_summary(): + """Test that missing summary raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + ) # ty: ignore[missing-argument] + + +def test_scan_result_invalid_extra_field(): + """Test that extra fields are rejected due to extra='forbid'.""" + with pytest.raises(ValidationError): + ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + unknown_field="value", # ty: ignore[unknown-argument] + ) + + +def test_scan_result_invalid_provenance_type(): + """Test that an invalid provenance type raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + provenance="not_a_provenance", # ty: ignore[invalid-argument-type] + scanner=_make_scanner_details(), + summary=_make_summary(), + ) + + +def test_scan_result_invalid_scanner_type(): + """Test that an invalid scanner type raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + provenance=_make_provenance(), + scanner="not_a_scanner", # ty: ignore[invalid-argument-type] + summary=_make_summary(), + ) + + +def test_scan_result_invalid_additional_data_type(): + """Test that a non-dict additional_data raises ValidationError.""" + with pytest.raises(ValidationError): + ScanResult( + provenance=_make_provenance(), + scanner=_make_scanner_details(), + summary=_make_summary(), + additional_data="not_a_dict", # ty: ignore[invalid-argument-type] + ) + + +def test_scanner_details_missing_name(): + """Test that missing scanner name raises ValidationError.""" + with pytest.raises(ValidationError): + ScannerDetails( + version="1.0", + configuration="", + ) # ty: ignore[missing-argument] + + +def test_scan_summary_missing_start_time(): + """Test that missing start_time raises ValidationError.""" + with pytest.raises(ValidationError): + ScanSummary( + end_time=datetime(2026, 3, 4, 17, 47, 23, tzinfo=timezone.utc), + ) # ty: ignore[missing-argument] + + +def test_scan_summary_invalid_start_time(): + """Test that an invalid start_time type raises ValidationError.""" + with pytest.raises(ValidationError): + ScanSummary( + start_time="not-a-date", # ty: ignore[invalid-argument-type] + end_time=datetime(2026, 3, 4, 17, 47, 23, tzinfo=timezone.utc), + ) + + +# --- YAML loading test --- + + +def test_scan_result_from_yaml(): + """Test loading ScanResult from the evaluation-result.yml test data.""" + config_data = load_yaml_config("evaluation-result.yml") + + scanner_data = config_data.get("scanner") + if scanner_data is None: + pytest.fail("Expected 'scanner' key in YAML data") + + try: + run = ScannerRun(**scanner_data) + except ValidationError as e: + pytest.fail(f"Failed to instantiate ScannerRun from YAML: {e}") + + # Verify basic run properties + if run.environment.ort_version != "80.0.0": + pytest.fail(f"Expected ort_version '80.0.0', got '{run.environment.ort_version}'") + if run.environment.os != "Mac OS X": + pytest.fail(f"Expected os 'Mac OS X', got '{run.environment.os}'") + if run.environment.processors != 12: + pytest.fail(f"Expected 12 processors, got {run.environment.processors}") + + # Verify scanner config + if not run.config.skip_concluded: + pytest.fail("Expected skip_concluded to be True") + if not run.config.skip_excluded: + pytest.fail("Expected skip_excluded to be True") + if run.config.scanners is None: + pytest.fail("Expected scanners config to be present") + if "ScanCode" not in run.config.scanners: + pytest.fail("Expected 'ScanCode' in scanners config") + if "SCANOSS" not in run.config.scanners: + pytest.fail("Expected 'SCANOSS' in scanners config") + + # Verify scan_results + if run.scan_results is None: + pytest.fail("Expected scan_results to be present") + if len(run.scan_results) != 1: + pytest.fail(f"Expected 1 scan result, got {len(run.scan_results)}") + + scan_result = next(iter(run.scan_results)) + if scan_result.scanner.name != "SCANOSS": + pytest.fail(f"Expected scanner name 'SCANOSS', got '{scan_result.scanner.name}'") + if scan_result.scanner.version != "0.12.1": + pytest.fail(f"Expected scanner version '0.12.1', got '{scan_result.scanner.version}'") + + # Verify scan summary has snippet findings + if len(scan_result.summary.snippet_findings) < 1: + pytest.fail(f"Expected at least 1 snippet finding, got {len(scan_result.summary.snippet_findings)}") + + # Verify a snippet finding has the expected structure + first_finding = next(iter(scan_result.summary.snippet_findings)) + if first_finding.source_location.path == "": + pytest.fail("Expected snippet finding source_location path to be non-empty") + if len(first_finding.snippets) < 1: + pytest.fail("Expected at least 1 snippet in the finding") + first_snippet = next(iter(first_finding.snippets)) + if first_snippet.license == "": + pytest.fail("Expected snippet license to be non-empty") + if first_snippet.purl == "": + pytest.fail("Expected snippet purl to be non-empty") + + # Verify scanners mapping + if len(run.scanners) != 1: + pytest.fail(f"Expected 1 scanner mapping entry, got {len(run.scanners)}") + + # Verify files + if len(run.files) != 1: + pytest.fail(f"Expected 1 file list entry, got {len(run.files)}") + file_list = next(iter(run.files)) + if len(file_list.files) < 1: + pytest.fail("Expected at least 1 file entry") + + # Verify storage config parsed correctly + if run.config.storages is None: + pytest.fail("Expected storages config to be present") + if "postgres" not in run.config.storages: + pytest.fail("Expected 'postgres' in storages config") diff --git a/uv.lock b/uv.lock index e16dccd..94eed24 100644 --- a/uv.lock +++ b/uv.lock @@ -82,6 +82,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -247,6 +256,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -578,9 +599,10 @@ wheels = [ [[package]] name = "python-ort" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ + { name = "license-expression" }, { name = "packageurl-python" }, { name = "pydantic" }, ] @@ -597,6 +619,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "license-expression", specifier = ">=30.4.4" }, { name = "packageurl-python", specifier = ">=0.17.6" }, { name = "pydantic", specifier = ">=2.12.5" }, ] @@ -606,8 +629,8 @@ dev = [ { name = "datamodel-code-generator", extras = ["http"], specifier = ">=0.55.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "rich", specifier = ">=14.3.3" }, - { name = "ruff", specifier = ">=0.15.5" }, - { name = "ty", specifier = ">=0.0.21" }, + { name = "ruff", specifier = ">=0.15.6" }, + { name = "ty", specifier = ">=0.0.22" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] @@ -729,27 +752,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] @@ -808,26 +831,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, - { url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, - { url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, - { url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, - { url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, - { url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, - { url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, +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" }, ] [[package]]