diff --git a/olot/backend/oras_cp.py b/olot/backend/oras_cp.py index 65804e4..d4c36f5 100644 --- a/olot/backend/oras_cp.py +++ b/olot/backend/oras_cp.py @@ -3,11 +3,12 @@ import subprocess import typing + def is_oras() -> bool : return shutil.which("oras") is not None -def oras_pull(base_image: str, dest: typing.Union[str, os.PathLike], params: typing.Sequence[str]=[]): +def oras_pull(base_image: str, dest: str | os.PathLike, params: typing.Sequence[str]=[]): if isinstance(dest, os.PathLike): dest = str(dest) subprocess.run(["oras", "copy", "--to-oci-layout", *params, base_image, dest+":latest"], check=True) @@ -18,7 +19,7 @@ def oras_pull(base_image: str, dest: typing.Union[str, os.PathLike], params: typ -def oras_push(src: typing.Union[str, os.PathLike], oci_ref: str, params: typing.Sequence[str]=[]): +def oras_push(src: str | os.PathLike, oci_ref: str, params: typing.Sequence[str]=[]): if isinstance(src, os.PathLike): src = str(src) return subprocess.run(["oras", "copy", "--from-oci-layout", *params, src+":latest", oci_ref], check=True) diff --git a/olot/backend/oras_py.py b/olot/backend/oras_py.py index 55a58aa..c32ca7f 100644 --- a/olot/backend/oras_py.py +++ b/olot/backend/oras_py.py @@ -34,10 +34,10 @@ def _normalize_docker_hub(reference: str) -> str: return reference.replace("docker.io/", "registry-1.docker.io/", 1) -def oras_py_pull(base_image: str, dest: typing.Union[str, os.PathLike], *, insecure: bool = False, tls_verify: bool = True) -> None: +def oras_py_pull(base_image: str, dest: str | os.PathLike, *, insecure: bool = False, tls_verify: bool = True) -> None: """Pull an image from a registry to a local OCI layout directory using oras-py.""" - from oras.provider import Registry from oras.layout.layout import NewLayoutFromRegistry + from oras.provider import Registry if isinstance(dest, os.PathLike): dest = str(dest) @@ -48,10 +48,10 @@ def oras_py_pull(base_image: str, dest: typing.Union[str, os.PathLike], *, insec NewLayoutFromRegistry(path=dest, provider=registry, target=base_image, tag="latest") -def oras_py_push(src: typing.Union[str, os.PathLike], oci_ref: str, *, insecure: bool = False, tls_verify: bool = True) -> None: +def oras_py_push(src: str | os.PathLike, oci_ref: str, *, insecure: bool = False, tls_verify: bool = True) -> None: """Push a local OCI layout directory to a registry using oras-py.""" - from oras.provider import Registry from oras.layout.layout import NewLayout + from oras.provider import Registry if isinstance(src, os.PathLike): src = str(src) diff --git a/olot/backend/skopeo.py b/olot/backend/skopeo.py index dba9942..cd4e7f3 100644 --- a/olot/backend/skopeo.py +++ b/olot/backend/skopeo.py @@ -8,13 +8,13 @@ def is_skopeo() -> bool : return shutil.which("skopeo") is not None -def skopeo_pull(base_image: str, dest: typing.Union[str, os.PathLike], params: typing.Sequence[str]=()): +def skopeo_pull(base_image: str, dest: str | os.PathLike, params: typing.Sequence[str]=()): if isinstance(dest, os.PathLike): dest = str(dest) return subprocess.run(["skopeo", "copy", "--multi-arch", "all", *params, "--remove-signatures", "docker://"+base_image, "oci:"+dest+":latest"], check=True) -def skopeo_push(src: typing.Union[str, os.PathLike], oci_ref: str, params: typing.Sequence[str]=()): +def skopeo_push(src: str | os.PathLike, oci_ref: str, params: typing.Sequence[str]=()): if isinstance(src, os.PathLike): src = str(src) return subprocess.run(["skopeo", "copy", "--multi-arch", "all", *params, "oci:"+src+":latest", "docker://"+oci_ref], check=True) diff --git a/olot/basics.py b/olot/basics.py index bc96300..e0c6aac 100644 --- a/olot/basics.py +++ b/olot/basics.py @@ -1,46 +1,65 @@ import datetime import logging import os +import tarfile +from collections.abc import Sequence from pathlib import Path from pprint import pformat -import tarfile -from typing import Dict, List, Sequence -import typing from olot.constants import ( ANNOTATION_LAYER_CONTENT_DIGEST, - ANNOTATION_LAYER_CONTENT_TYPE, ANNOTATION_LAYER_CONTENT_INLAYERPATH, ANNOTATION_LAYER_CONTENT_NAME, + ANNOTATION_LAYER_CONTENT_TYPE, +) +from olot.dockerdist.convert import ( + check_if_oci_layout_contains_docker_manifests, + convert_docker_manifests_to_oci, ) -from olot.dockerdist.convert import check_if_oci_layout_contains_docker_manifests, convert_docker_manifests_to_oci from olot.enums import RemoveOriginals -from olot.modelpack.model_config import Model, ModelConfig, ModelDescriptor, ModelFS, Type +from olot.modelpack import const as modelpack_consts +from olot.modelpack.model_config import ( + Model, + ModelConfig, + ModelDescriptor, + ModelFS, + Type, +) +from olot.oci.oci_common import MediaTypes from olot.oci.oci_config import HistoryItem, OCIManifestConfig - -from olot.oci.oci_image_index import Manifest, OCIImageIndex, Platform, read_ocilayout_root_index -from olot.oci.oci_image_manifest import OCIImageManifest, ContentDescriptor, create_oci_image_manifest +from olot.oci.oci_image_index import ( + Manifest, + OCIImageIndex, + Platform, + read_ocilayout_root_index, +) from olot.oci.oci_image_layout import verify_ocilayout -from olot.oci.oci_common import MediaTypes - -from olot.utils.files import LayerStats, handle_remove, tarball_from_file, targz_from_file +from olot.oci.oci_image_manifest import ( + ContentDescriptor, + OCIImageManifest, + create_oci_image_manifest, +) +from olot.utils.files import ( + LayerStats, + handle_remove, + tarball_from_file, + targz_from_file, +) from olot.utils.types import compute_hash_of_str -from olot.modelpack import const as modelpack_consts - logger = logging.getLogger(__name__) def oci_layers_on_top( - ocilayout: typing.Union[str, os.PathLike], + ocilayout: str | os.PathLike, model_files: Sequence[os.PathLike], - modelcard: typing.Union[os.PathLike, None] = None, + modelcard: os.PathLike | None = None, *, - labels: typing.Union[dict[str, str], None] = None, - annotations: typing.Union[dict[str, str], None] = None, - root_dir: typing.Union[str, os.PathLike, None] = None, - remove_originals: typing.Union[RemoveOriginals, None] = None, - add_modelpack: typing.Union[bool, None] = None): + labels: dict[str, str] | None = None, + annotations: dict[str, str] | None = None, + root_dir: str | os.PathLike | None = None, + remove_originals: RemoveOriginals | None = None, + add_modelpack: bool | None = None): """ Add contents to an oci-layout directory as new blob layers @@ -83,9 +102,9 @@ def oci_layers_on_top( logger.warning("OCI layout contains Docker distribution manifests, converting them to OCI format") convert_docker_manifests_to_oci(ocilayout) ocilayout_root_index: OCIImageIndex = read_ocilayout_root_index(ocilayout) - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout, ocilayout_root_index) - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(ocilayout, ocilayout_indexes, ocilayout_root_index) - new_layers: Dict[str, LayerStats] = {} # layer digest : diff_id + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(ocilayout, ocilayout_indexes, ocilayout_root_index) + new_layers: dict[str, LayerStats] = {} # layer digest : diff_id # check configuration is consistent add_modelpack = check_and_sanitize_flag_add_modelpack(add_modelpack, ocilayout_indexes, ocilayout_manifests) @@ -117,7 +136,7 @@ def oci_layers_on_top( if remove_originals == RemoveOriginals.ALL: handle_remove(modelcard) - new_ocilayout_manifests: Dict[str, str] = {} + new_ocilayout_manifests: dict[str, str] = {} for manifest_hash, manifest in ocilayout_manifests.items(): logger.debug("manifest_hash: %s, manifest.mediaType: %s", manifest_hash, manifest.mediaType) config_sha = manifest.config.digest.removeprefix("sha256:") @@ -196,12 +215,12 @@ def oci_layers_on_top( if add_modelpack: modelpack_manifest_hash = add_modelpack_manifest(ocilayout, new_layers) - new_ocilayout_indexes: Dict[str, str] = {} + new_ocilayout_indexes: dict[str, str] = {} for index_hash, index in ocilayout_indexes.items(): logger.debug("index_hash: %s, index.mediaType: %s", index_hash, index.mediaType) for m in index.manifests: digest = m.digest.removeprefix("sha256:") - if digest in new_ocilayout_manifests.keys(): + if digest in new_ocilayout_manifests: lookup_new_hash = new_ocilayout_manifests[m.digest.removeprefix("sha256:")] logger.info("old manifest %s is now at %s", m.digest, lookup_new_hash) m.digest = "sha256:" + lookup_new_hash @@ -241,7 +260,7 @@ def oci_layers_on_top( entry.size = os.stat(ocilayout / "blobs" / "sha256" / lookup_new_hash).st_size elif entry.mediaType == MediaTypes.manifest: digest = entry.digest.removeprefix("sha256:") - if digest in new_ocilayout_manifests.keys(): + if digest in new_ocilayout_manifests: lookup_new_hash = new_ocilayout_manifests[entry.digest.removeprefix("sha256:")] logger.info("old manifest %s is now at %s", entry.digest, lookup_new_hash) entry.digest = "sha256:" + lookup_new_hash @@ -270,12 +289,12 @@ def oci_layers_on_top( root_idx_f.write(ocilayout_root_index.model_dump_json(exclude_none=True)) -def add_modelpack_manifest(ocilayout: Path, new_layers: Dict[str, LayerStats]) -> str: +def add_modelpack_manifest(ocilayout: Path, new_layers: dict[str, LayerStats]) -> str: """add a ModelPack manifest to the oci-layout """ model_config = Model( descriptor=ModelDescriptor(name=None), # eventually config and metadata will be provided programmatically - modelfs=ModelFS(type=Type.layers, diffIds=list(b.diff_id for b in new_layers.values())), + modelfs=ModelFS(type=Type.layers, diffIds=[b.diff_id for b in new_layers.values()]), config=ModelConfig(), ) model_config_json = model_config.model_dump_json(exclude_none=True) @@ -320,7 +339,7 @@ def add_modelpack_manifest(ocilayout: Path, new_layers: Dict[str, LayerStats]) - return manifest_hash -def check_and_sanitize_flag_add_modelpack(add_modelpack: typing.Union[bool, None], ocilayout_indexes: Dict[str, OCIImageIndex], ocilayout_manifests: Dict[str, OCIImageManifest]) -> bool: +def check_and_sanitize_flag_add_modelpack(add_modelpack: bool | None, ocilayout_indexes: dict[str, OCIImageIndex], ocilayout_manifests: dict[str, OCIImageManifest]) -> bool: """Check and sanitize the add_modelpack flag - check if the oci-layout contains an Index manifest for multi-arch, othewise fail: can't add a ModelPack manifest to a single-arch oci-layout this is because a single-arch is a single OCI Image Manifest and not introducing an Index; hence, can't add a ModelPack manifest to the non-existing Index which is tagged (`:latest`) by the oci-layout root index @@ -340,17 +359,17 @@ def check_and_sanitize_flag_add_modelpack(add_modelpack: typing.Union[bool, None def check_manifest(manifest: OCIImageManifest, config: OCIManifestConfig): """perform some sanity check on the manifests required for additional scenarios of usage """ - ch_count = len(list(x for x in config.history if not x.empty_layer)) if config.history else 0 + ch_count = len([x for x in config.history if not x.empty_layer]) if config.history else 0 layers_count = len(manifest.layers) if layers_count != ch_count: raise ValueError(f"history lists {ch_count} non-empty layers, but there are {layers_count} layers in the image manifest") -def crawl_ocilayout_manifests(ocilayout: Path, ocilayout_indexes: Dict[str, OCIImageIndex], ocilayout_root_index: typing.Union[OCIImageIndex, None] = None) -> Dict[str, OCIImageManifest]: +def crawl_ocilayout_manifests(ocilayout: Path, ocilayout_indexes: dict[str, OCIImageIndex], ocilayout_root_index: OCIImageIndex | None = None) -> dict[str, OCIImageManifest]: """crawl Manifests from referred OCI Index(es) and Manifests in the root index of the oci-layout """ - ocilayout_manifests: Dict[str, OCIImageManifest] = {} - for _, mi in ocilayout_indexes.items(): + ocilayout_manifests: dict[str, OCIImageManifest] = {} + for mi in ocilayout_indexes.values(): for m in mi.manifests: logger.debug("Parsing manifest %s", m) if m.mediaType != MediaTypes.manifest: @@ -369,7 +388,7 @@ def crawl_ocilayout_manifests(ocilayout: Path, ocilayout_indexes: Dict[str, OCII ocilayout_manifests[target_hash] = OCIImageManifest.model_validate_json(ip.read()) # filter out non-runnable OCI Images, like Vendor'd Attestations format, and log it out - filtered: Dict[str, OCIImageManifest] = {} + filtered: dict[str, OCIImageManifest] = {} for k, v in ocilayout_manifests.items(): if v.layers[0].mediaType == "application/vnd.in-toto+json" or v.artifactType == "application/vnd.docker.attestation.manifest.v1+json": logger.info("skipping %s as it's an Attestation manifest", k) # not adding this to filtered list of manifests. @@ -388,8 +407,8 @@ def write_empty_config_in_ocilayoyt(ocilayout: Path): f.write("{}") -def crawl_ocilayout_indexes(ocilayout: Path, ocilayout_root_index: OCIImageIndex) -> Dict[str, OCIImageIndex] : - ocilayout_indexes: Dict[str, OCIImageIndex] = {} +def crawl_ocilayout_indexes(ocilayout: Path, ocilayout_root_index: OCIImageIndex) -> dict[str, OCIImageIndex] : + ocilayout_indexes: dict[str, OCIImageIndex] = {} for m in ocilayout_root_index.manifests: if m.mediaType == MediaTypes.index: target_hash = m.digest.removeprefix("sha256:") @@ -401,7 +420,7 @@ def crawl_ocilayout_indexes(ocilayout: Path, ocilayout_root_index: OCIImageIndex def crawl_ocilayout_blobs_to_extract(ocilayout: Path, output_path: Path, - tar_filter_dir: str = "/models") -> List[str]: + tar_filter_dir: str = "/models") -> list[str]: """ Extract from OCI Image/ModelCar only the contents from a specific directory. @@ -413,7 +432,7 @@ def crawl_ocilayout_blobs_to_extract(ocilayout: Path, Returns: The list of extracted ML contents from the OCI Image/ModelCar. """ - extracted: List[str] = [] + extracted: list[str] = [] tar_filter_dir= tar_filter_dir.lstrip("/") blobs_path = ocilayout / "blobs" / "sha256" if not os.path.exists(output_path): diff --git a/olot/cli.py b/olot/cli.py index c34e7fc..007300b 100644 --- a/olot/cli.py +++ b/olot/cli.py @@ -1,6 +1,7 @@ +import logging from os import PathLike + import click -import logging from .basics import RemoveOriginals, oci_layers_on_top diff --git a/olot/dockerdist/convert.py b/olot/dockerdist/convert.py index 97d9e6f..6138533 100644 --- a/olot/dockerdist/convert.py +++ b/olot/dockerdist/convert.py @@ -2,12 +2,12 @@ import logging import os from pathlib import Path -from typing import Dict + +from olot.oci.oci_common import MediaTypes from olot.oci.oci_config import OCIManifestConfig from olot.oci.oci_image_index import OCIImageIndex +from olot.oci.oci_image_manifest import ContentDescriptor, OCIImageManifest from olot.utils.types import compute_hash_of_str -from olot.oci.oci_image_manifest import OCIImageManifest, ContentDescriptor -from olot.oci.oci_common import MediaTypes DOCKER_LIST_V2 = "application/vnd.docker.distribution.manifest.list.v2+json" DOCKER_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json" @@ -29,12 +29,12 @@ def check_if_oci_layout_contains_docker_manifests(directory: Path) -> bool: data = json.load(f) if data.get("mediaType") == DOCKER_MANIFEST_V2: return True - except Exception: + except (json.JSONDecodeError, UnicodeDecodeError, OSError): # not a manifest continue return False -def convert_docker_manifests_to_oci(directory: Path) -> Dict[str, str]: +def convert_docker_manifests_to_oci(directory: Path) -> dict[str, str]: """ Scan directory for Docker distribution manifests and convert them to OCI format. @@ -56,7 +56,7 @@ def convert_docker_manifests_to_oci(directory: Path) -> Dict[str, str]: data = json.load(f) if data.get("mediaType") == DOCKER_MANIFEST_V2: img_manifest_files.append(blob) - except Exception: + except (json.JSONDecodeError, UnicodeDecodeError, OSError): # not a manifest continue if not img_manifest_files: @@ -71,7 +71,7 @@ def convert_docker_manifests_to_oci(directory: Path) -> Dict[str, str]: data = json.load(f) if data.get("mediaType") == DOCKER_LIST_V2: list_manifest_files.append(blob) - except Exception: + except (json.JSONDecodeError, UnicodeDecodeError, OSError): # not a manifest continue for blob_file in list_manifest_files: with open(blob_file, 'r') as file_handle: diff --git a/olot/enums.py b/olot/enums.py index d4b6224..1821a3d 100644 --- a/olot/enums.py +++ b/olot/enums.py @@ -1,5 +1,5 @@ +from collections.abc import Sequence from enum import Enum -from typing import Sequence class CustomStrEnum(str, Enum): diff --git a/olot/modelpack/__init__.py b/olot/modelpack/__init__.py index f6ec947..e34db94 100644 --- a/olot/modelpack/__init__.py +++ b/olot/modelpack/__init__.py @@ -1,5 +1,13 @@ """Modelpack module for handling model artifact configurations.""" -from .model_config import Model, ModelConfig, ModelDescriptor, ModelFS, Type, Modality, ModelCapabilities +from .model_config import ( + Modality, + Model, + ModelCapabilities, + ModelConfig, + ModelDescriptor, + ModelFS, + Type, +) -__all__ = ["Model", "ModelConfig", "ModelDescriptor", "ModelFS", "Type", "Modality", "ModelCapabilities"] \ No newline at end of file +__all__ = ["Modality", "Model", "ModelCapabilities", "ModelConfig", "ModelDescriptor", "ModelFS", "Type"] \ No newline at end of file diff --git a/olot/modelpack/model_config.py b/olot/modelpack/model_config.py index 5ec8300..18f5866 100644 --- a/olot/modelpack/model_config.py +++ b/olot/modelpack/model_config.py @@ -1,4 +1,4 @@ -# generated by datamodel-codegen using https://github.com/modelpack/model-spec/blob/8f6beb752394e5c6653cfcb3d2cd21950296dfac/schema/config-schema.json +# originally generated by datamodel-codegen using https://github.com/modelpack/model-spec/blob/8f6beb752394e5c6653cfcb3d2cd21950296dfac/schema/config-schema.json # filename: config-schema.json # timestamp: 2025-07-04T09:20:40+00:00 @@ -6,7 +6,6 @@ from datetime import datetime from enum import Enum -from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -14,18 +13,18 @@ class ModelDescriptor(BaseModel): model_config = ConfigDict(extra='forbid') - createdAt: Optional[datetime] = None - authors: Optional[List[str]] = None - family: Optional[str] = None - name: Optional[str] = Field(None, min_length=1) - docURL: Optional[str] = None - sourceURL: Optional[str] = None - version: Optional[str] = None - revision: Optional[str] = None - vendor: Optional[str] = None - licenses: Optional[List[str]] = None - title: Optional[str] = None - description: Optional[str] = None + createdAt: datetime | None = None + authors: list[str] | None = None + family: str | None = None + name: str | None = Field(None, min_length=1) + docURL: str | None = None + sourceURL: str | None = None + version: str | None = None + revision: str | None = None + vendor: str | None = None + licenses: list[str] | None = None + title: str | None = None + description: str | None = None class Type(Enum): @@ -36,7 +35,7 @@ class ModelFS(BaseModel): model_config = ConfigDict(extra='forbid') type: Type - diffIds: List[str] = Field(..., min_length=1) + diffIds: list[str] = Field(..., min_length=1) class Modality(Enum): @@ -49,22 +48,22 @@ class Modality(Enum): class ModelCapabilities(BaseModel): - inputTypes: Optional[List[Modality]] = None - outputTypes: Optional[List[Modality]] = None - knowledgeCutoff: Optional[datetime] = None - reasoning: Optional[bool] = None - toolUsage: Optional[bool] = None + inputTypes: list[Modality] | None = None + outputTypes: list[Modality] | None = None + knowledgeCutoff: datetime | None = None + reasoning: bool | None = None + toolUsage: bool | None = None class ModelConfig(BaseModel): model_config = ConfigDict(extra='forbid') - architecture: Optional[str] = None - format: Optional[str] = None - paramSize: Optional[str] = None - precision: Optional[str] = None - quantization: Optional[str] = None - capabilities: Optional[ModelCapabilities] = None + architecture: str | None = None + format: str | None = None + paramSize: str | None = None + precision: str | None = None + quantization: str | None = None + capabilities: ModelCapabilities | None = None class Model(BaseModel): diff --git a/olot/oci/oci_common.py b/olot/oci/oci_common.py index 33f4d03..74cb49f 100644 --- a/olot/oci/oci_common.py +++ b/olot/oci/oci_common.py @@ -1,7 +1,7 @@ -from typing import Annotated, List -from pydantic import AnyUrl, Field +from typing import Annotated +from pydantic import AnyUrl, Field MediaType = Annotated[str, Field( ..., @@ -36,7 +36,7 @@ class MediaTypes: )] -Urls = Annotated[List[AnyUrl],Field( +Urls = Annotated[list[AnyUrl],Field( ..., description='a list of urls from which this object may be downloaded' )] diff --git a/olot/oci/oci_config.py b/olot/oci/oci_config.py index 4fd0f98..88b585c 100644 --- a/olot/oci/oci_config.py +++ b/olot/oci/oci_config.py @@ -1,4 +1,4 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: config-schema.json # timestamp: 2024-12-04T08:15:20+00:00 @@ -6,11 +6,10 @@ from datetime import datetime from enum import Enum -from typing import List, Optional from pydantic import BaseModel, Field -from olot.utils.types import MapStringString, MapStringObject +from olot.utils.types import MapStringObject, MapStringString class Type(Enum): @@ -18,16 +17,16 @@ class Type(Enum): class Rootfs(BaseModel): - diff_ids: List[str] + diff_ids: list[str] type: Type class HistoryItem(BaseModel): - created: Optional[str] = None # A combined date and time at which the layer was created, formatted as defined by RFC 3339, section 5.6. - author: Optional[str] = None - created_by: Optional[str] = None - comment: Optional[str] = None - empty_layer: Optional[bool] = None + created: str | None = None # A combined date and time at which the layer was created, formatted as defined by RFC 3339, section 5.6. + author: str | None = None + created_by: str | None = None + comment: str | None = None + empty_layer: bool | None = None # class MapStringObject(BaseModel): @@ -87,26 +86,26 @@ class HistoryItem(BaseModel): class Config(BaseModel): - User: Optional[str] = None - ExposedPorts: Optional[MapStringObject] = None - Env: Optional[List[str]] = None - Entrypoint: Optional[List[str]] = None - Cmd: Optional[List[str]] = None - Volumes: Optional[MapStringObject] = None - WorkingDir: Optional[str] = None - Labels: Optional[MapStringString] = None - StopSignal: Optional[str] = None - ArgsEscaped: Optional[bool] = None + User: str | None = None + ExposedPorts: MapStringObject | None = None + Env: list[str] | None = None + Entrypoint: list[str] | None = None + Cmd: list[str] | None = None + Volumes: MapStringObject | None = None + WorkingDir: str | None = None + Labels: MapStringString | None = None + StopSignal: str | None = None + ArgsEscaped: bool | None = None class OCIManifestConfig(BaseModel): - created: Optional[datetime] = None - author: Optional[str] = None + created: datetime | None = None + author: str | None = None architecture: str - variant: Optional[str] = None + variant: str | None = None os: str - os_version: Optional[str] = Field(None, alias='os.version') - os_features: Optional[List[str]] = Field(None, alias='os.features') - config: Optional[Config] = None + os_version: str | None = Field(None, alias='os.version') + os_features: list[str] | None = Field(None, alias='os.features') + config: Config | None = None rootfs: Rootfs - history: Optional[List[HistoryItem]] = None + history: list[HistoryItem] | None = None diff --git a/olot/oci/oci_defs.py b/olot/oci/oci_defs.py index 4b363bc..e7201a4 100644 --- a/olot/oci/oci_defs.py +++ b/olot/oci/oci_defs.py @@ -1,11 +1,9 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: defs.json # timestamp: 2024-12-05T10:30:59+00:00 from __future__ import annotations -from typing import List, Optional - from pydantic import BaseModel, Field @@ -58,16 +56,16 @@ class Uint64(BaseModel): class OneOfItem(BaseModel): - field_ref: Optional[str] = Field(None, alias='$ref') - type: Optional[str] = None + field_ref: str | None = Field(None, alias='$ref') + type: str | None = None class Uint16Pointer(BaseModel): - oneOf: List[OneOfItem] + oneOf: list[OneOfItem] class Uint64Pointer(BaseModel): - oneOf: List[OneOfItem] + oneOf: list[OneOfItem] class Media(BaseModel): @@ -84,7 +82,7 @@ class OneOfItem2(BaseModel): class StringPointer(BaseModel): - oneOf: List[OneOfItem2] + oneOf: list[OneOfItem2] class Field1(BaseModel): diff --git a/olot/oci/oci_defs_descriptor.py b/olot/oci/oci_defs_descriptor.py index 3ac080a..bb593db 100644 --- a/olot/oci/oci_defs_descriptor.py +++ b/olot/oci/oci_defs_descriptor.py @@ -1,4 +1,4 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: defs-descriptor.json # timestamp: 2024-12-05T11:15:48+00:00 diff --git a/olot/oci/oci_image_index.py b/olot/oci/oci_image_index.py index 2f72c9f..bfc4fd3 100644 --- a/olot/oci/oci_image_index.py +++ b/olot/oci/oci_image_index.py @@ -1,23 +1,24 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: image-index-schema.json # timestamp: 2024-12-04T08:16:48+00:00 from __future__ import annotations -from typing import Annotated, List, Optional from pathlib import Path +from typing import Annotated from pydantic import BaseModel, Field -from olot.oci.oci_common import MediaTypes, MediaType, Digest, Urls -from olot.utils.types import Int64, Base64, Annotations +from olot.oci.oci_common import Digest, MediaType, MediaTypes, Urls +from olot.utils.types import Annotations, Base64, Int64 + class Platform(BaseModel): architecture: str os: str - os_version: Optional[str] = Field(None, alias='os.version') - os_features: Optional[List[str]] = Field(None, alias='os.features') - variant: Optional[str] = None + os_version: str | None = Field(None, alias='os.version') + os_features: list[str] | None = Field(None, alias='os.features') + variant: str | None = None # class MediaType(BaseModel): @@ -150,16 +151,16 @@ class ContentDescriptor(BaseModel): ..., description="the cryptographic checksum digest of the object, in the pattern ':'", ) - urls: Optional[Urls] = Field( + urls: Urls | None = Field( None, description='a list of urls from which this object may be downloaded' ) - data: Optional[Base64] = Field( + data: Base64 | None = Field( None, description='an embedding of the targeted content (base64 encoded)' ) - artifactType: Optional[MediaType] = Field( + artifactType: MediaType | None = Field( None, description='the IANA media type of this artifact' ) - annotations: Optional[Annotations] = None + annotations: Annotations | None = None class Manifest(BaseModel): @@ -171,11 +172,11 @@ class Manifest(BaseModel): ..., description="the cryptographic checksum digest of the object, in the pattern ':'", ) - urls: Optional[Urls] = Field( + urls: Urls | None = Field( None, description='a list of urls from which this object may be downloaded' ) - platform: Optional[Platform] = None - annotations: Optional[Annotations] = None + platform: Platform | None = None + annotations: Annotations | None = None class OCIImageIndex(BaseModel): @@ -183,15 +184,15 @@ class OCIImageIndex(BaseModel): ..., description='This field specifies the image index schema version as an integer', ) - mediaType: Optional[MediaType] = Field( + mediaType: MediaType | None = Field( None, description='the mediatype of the referenced object' ) - artifactType: Optional[MediaType] = Field( + artifactType: MediaType | None = Field( None, description='the artifact mediatype of the referenced object' ) - subject: Optional[ContentDescriptor] = None - manifests: List[Manifest] - annotations: Optional[Annotations] = None + subject: ContentDescriptor | None = None + manifests: list[Manifest] + annotations: Annotations | None = None def read_ocilayout_root_index(ocilayout: Path) -> OCIImageIndex: @@ -203,15 +204,17 @@ def read_ocilayout_root_index(ocilayout: Path) -> OCIImageIndex: def create_oci_image_index( schemaVersion: int = 2, - mediaType: Optional[str] = MediaTypes.index, - artifactType: Optional[str] = None, - subject: Optional[ContentDescriptor] = None, - manifests: List[Manifest] = [], - annotations: Optional[Annotations] = None + mediaType: str | None = MediaTypes.index, + artifactType: str | None = None, + subject: ContentDescriptor | None = None, + manifests: list[Manifest] | None = None, + annotations: Annotations | None = None ) -> OCIImageIndex: """ Create an OCI image index object. """ + if manifests is None: + manifests = [] return OCIImageIndex( schemaVersion=schemaVersion, mediaType=mediaType, diff --git a/olot/oci/oci_image_layout.py b/olot/oci/oci_image_layout.py index 0adca3c..50ef084 100644 --- a/olot/oci/oci_image_layout.py +++ b/olot/oci/oci_image_layout.py @@ -1,4 +1,4 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: image-layout-schema.json # timestamp: 2024-12-04T11:33:36+00:00 diff --git a/olot/oci/oci_image_manifest.py b/olot/oci/oci_image_manifest.py index b2e665f..33b4275 100644 --- a/olot/oci/oci_image_manifest.py +++ b/olot/oci/oci_image_manifest.py @@ -1,20 +1,20 @@ -# generated by datamodel-codegen: +# originally generated by datamodel-codegen: # filename: image-manifest-schema.json # timestamp: 2024-12-04T11:34:21+00:00 from __future__ import annotations -from typing import Annotated, List, Optional, Dict import logging import os import subprocess from pathlib import Path +from typing import Annotated from pydantic import BaseModel, Field -from olot.oci.oci_common import Urls, Keys, Values, MediaTypes, MediaType -from olot.utils.types import Int64, Base64, Annotations +from olot.oci.oci_common import Keys, MediaType, MediaTypes, Urls, Values from olot.utils.files import MIMETypes +from olot.utils.types import Annotations, Base64, Int64 logger = logging.getLogger(__name__) @@ -107,16 +107,16 @@ class ContentDescriptor(BaseModel): digest: str = Field( ..., description="The cryptographic checksum digest of the object, in the pattern ':'" ) - urls: Optional[Urls] = Field( + urls: Urls | None = Field( None, description="A list of URLs from which this object may be downloaded" ) - data: Optional[Base64] = Field( + data: Base64 | None = Field( None, description="An embedding of the targeted content (base64 encoded)" ) - artifactType: Optional[MediaType] = Field( + artifactType: MediaType | None = Field( None, description="The IANA media type of this artifact" ) - annotations: Optional[Dict[str, str]] = None + annotations: dict[str, str] | None = None class Config: exclude_none = True @@ -126,16 +126,16 @@ class OCIImageManifest(BaseModel): ..., description='This field specifies the image manifest schema version as an integer', ) - mediaType: Optional[MediaType] = Field( + mediaType: MediaType | None = Field( None, description='the mediatype of the referenced object' ) - artifactType: Optional[MediaType] = Field( + artifactType: MediaType | None = Field( None, description='the artifact mediatype of the referenced object' ) config: ContentDescriptor - subject: Optional[ContentDescriptor] = None - layers: List[ContentDescriptor] = Field(..., min_length=1) - annotations: Optional[Annotations] = None + subject: ContentDescriptor | None = None + layers: list[ContentDescriptor] = Field(..., min_length=1) + annotations: Annotations | None = None def empty_config() -> ContentDescriptor: @@ -151,13 +151,17 @@ def empty_config() -> ContentDescriptor: def create_oci_image_manifest( schemaVersion: int = 2, - mediaType: Optional[str] = MediaTypes.manifest, - artifactType: Optional[str] = None, - config: ContentDescriptor = empty_config(), - subject: Optional[ContentDescriptor] = None, - layers: List[ContentDescriptor] = [], - annotations: Optional[Annotations] = None, + mediaType: str | None = MediaTypes.manifest, + artifactType: str | None = None, + config: ContentDescriptor | None = None, + subject: ContentDescriptor | None = None, + layers: list[ContentDescriptor] | None = None, + annotations: Annotations | None = None, ) -> OCIImageManifest: + if config is None: + config = empty_config() + if layers is None: + layers = [] return OCIImageManifest( schemaVersion=schemaVersion, mediaType=mediaType, @@ -174,18 +178,18 @@ def get_file_media_type(file_path: os.PathLike) -> str: Get the MIME type of a file using the `file` command. """ try: - result = subprocess.run(['file', '--mime-type', '-b', file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + result = subprocess.run(['file', '--mime-type', '-b', file_path], capture_output=True, check=True) mime_type = result.stdout.decode('utf-8').strip() return mime_type except subprocess.CalledProcessError as e: logger.warning("Error occurred while getting MIME type: %s", e) return MIMETypes.octet_stream - except Exception as e: + except OSError as e: logger.warning("Unexpected error: %s", e) return MIMETypes.octet_stream -def create_manifest_layers(files: List[Path], blob_layers: dict) -> List[ContentDescriptor]: +def create_manifest_layers(files: list[Path], blob_layers: dict) -> list[ContentDescriptor]: """ Create a list of ContentDescriptor objects representing the layers of an OCI image manifest. @@ -194,7 +198,7 @@ def create_manifest_layers(files: List[Path], blob_layers: dict) -> List[Content Returns: List[ContentDescriptor]: A list of ContentDescriptor objects representing the layers of the manifest """ - layers: List[ContentDescriptor] = [] + layers: list[ContentDescriptor] = [] for file in files: precomp, postcomp = blob_layers[os.path.basename(file)] file_digest = postcomp if postcomp != "" else precomp diff --git a/olot/oci/oci_utils.py b/olot/oci/oci_utils.py index 873e4e6..35afc65 100644 --- a/olot/oci/oci_utils.py +++ b/olot/oci/oci_utils.py @@ -1,5 +1,5 @@ import json -from typing import Union + from olot.oci.oci_common import MediaTypes from olot.oci.oci_image_index import OCIImageIndex from olot.oci.oci_image_manifest import ContentDescriptor, OCIImageManifest @@ -14,7 +14,7 @@ def get_descriptor_from_manifest(manifest: str) -> ContentDescriptor: """ size = len(manifest) data = json.loads(manifest) - model: Union[OCIImageManifest, OCIImageIndex] + model: OCIImageManifest | OCIImageIndex mediaType = data.get("mediaType") if mediaType == MediaTypes.manifest: model = OCIImageManifest.model_validate_json(manifest) diff --git a/olot/oci_artifact.py b/olot/oci_artifact.py index 3189cd5..e362a5d 100644 --- a/olot/oci_artifact.py +++ b/olot/oci_artifact.py @@ -1,18 +1,26 @@ import datetime -from pathlib import Path -import os import json -from typing import List, Union +import os +from pathlib import Path from olot.constants import ANNOTATION_LAYER_CONTENT_NAME -from olot.oci.oci_config import HistoryItem, OCIManifestConfig, Rootfs, Type -from olot.oci.oci_image_manifest import ContentDescriptor, create_oci_image_manifest, create_manifest_layers -from olot.oci.oci_image_layout import ImageLayoutVersion, OCIImageLayout, create_ocilayout from olot.oci.oci_common import MediaTypes, Values +from olot.oci.oci_config import HistoryItem, OCIManifestConfig, Rootfs, Type from olot.oci.oci_image_index import Manifest, OCIImageIndex, create_oci_image_index +from olot.oci.oci_image_layout import ( + ImageLayoutVersion, + OCIImageLayout, + create_ocilayout, +) +from olot.oci.oci_image_manifest import ( + ContentDescriptor, + create_manifest_layers, + create_oci_image_manifest, +) from olot.utils.files import MIMETypes, tarball_from_file, targz_from_file, walk_files from olot.utils.types import compute_hash_of_str + def create_oci_artifact_from_model(source_dir: Path, dest_dir: Path): """ Create an OCI artifact from a model directory. @@ -80,7 +88,7 @@ def create_oci_artifact_from_model(source_dir: Path, dest_dir: Path): raise ValueError(f"Invalid empty_digest format: {Values.empty_digest}") -def create_blobs(model_files: List[Path], dest_dir: Path): +def create_blobs(model_files: list[Path], dest_dir: Path): """ Create the blobs directory for an OCI artifact. """ @@ -104,9 +112,9 @@ def create_blobs(model_files: List[Path], dest_dir: Path): def create_simple_oci_artifact(source_path: Path, oci_layout_path: Path, - artifact_type: Union[str, None] = None, - subject: Union[ContentDescriptor, None] = None, - annotations: Union[dict[str, str], None] = None): + artifact_type: str | None = None, + subject: ContentDescriptor | None = None, + annotations: dict[str, str] | None = None): """ Create a simple OCI artifact from a source directory. """ diff --git a/olot/utils/files.py b/olot/utils/files.py index 3590161..7ac0e65 100644 --- a/olot/utils/files.py +++ b/olot/utils/files.py @@ -1,12 +1,11 @@ -from dataclasses import dataclass +import gzip import hashlib import logging +import os import shutil import tarfile +from dataclasses import dataclass from pathlib import Path -import gzip -import os -from typing import List from olot.enums import LayerInputType @@ -219,7 +218,7 @@ def handle_remove(path: os.PathLike): os.remove(path) -def walk_files(root_path: os.PathLike) -> List[Path]: +def walk_files(root_path: os.PathLike) -> list[Path]: """ Recursively walks a directory and returns all files as relative paths, skipping any symlinks and the `lost+found` directory. diff --git a/olot/utils/types.py b/olot/utils/types.py index 4426966..01b5dc9 100644 --- a/olot/utils/types.py +++ b/olot/utils/types.py @@ -1,10 +1,11 @@ -from typing import Annotated, Any, Dict -from pydantic import Field import hashlib +from typing import Annotated, Any + +from pydantic import Field NonEmptyString = Annotated[str, Field(..., pattern=r".{1,}")] -MapStringString = Annotated[Dict[NonEmptyString, str], Field(...)] -MapStringObject = Annotated[Dict[NonEmptyString, Any], Field(...)] +MapStringString = Annotated[dict[NonEmptyString, str], Field(...)] +MapStringObject = Annotated[dict[NonEmptyString, Any], Field(...)] Int8 = Annotated[int, Field(ge=-128, le=127)] Int64 = Annotated[int, Field(ge=-9223372036854776000, le=9223372036854776000)] diff --git a/olot/utils/validation.py b/olot/utils/validation.py index 47c5f24..504ba3c 100644 --- a/olot/utils/validation.py +++ b/olot/utils/validation.py @@ -121,7 +121,7 @@ def is_valid_oci_reference(reference: str) -> bool: # If it's empty, invalid if not potential_tag: return False - if '/' not in potential_tag: + if '/' not in potential_tag: # noqa: SIM102 # Could be a tag or a port number # If there's no / in potential_tag and it's a valid tag, it must be a tag # (registry:port MUST be followed by /repository) @@ -141,9 +141,8 @@ def is_valid_oci_reference(reference: str) -> bool: repository = rest # Validate registry if present - if registry is not None: - if not is_valid_registry_host_port(registry): - return False + if registry is not None and not is_valid_registry_host_port(registry): + return False # Validate repository (required) if not repository: @@ -157,13 +156,11 @@ def is_valid_oci_reference(reference: str) -> bool: return False # Validate tag if present - if tag is not None: - if not _tag_re.match(tag): - return False + if tag is not None and not _tag_re.match(tag): + return False # Validate digest if present - if digest is not None: - if not _digest_re.match(digest): - return False + if digest is not None and not _digest_re.match(digest): # noqa: SIM103 + return False return True diff --git a/pyproject.toml b/pyproject.toml index 9b24a8a..9210fef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ olot = "olot.cli:cli" [dependency-groups] dev = [ "pytest>=9.1.1,<10", - "ruff>=0.15.22,<0.16.0", + "ruff>=0.16.0,<0.17.0", "mypy>=2.3.0,<3", "docker>=7.2.0,<8", ] diff --git a/scripts/test_oras_py.py b/scripts/test_oras_py.py index dc5b75a..ceee7f4 100644 --- a/scripts/test_oras_py.py +++ b/scripts/test_oras_py.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +# uv run python scripts/test_oras_py.py import argparse import shutil import tempfile diff --git a/tests/backend/test_oras_cp.py b/tests/backend/test_oras_cp.py index b5ed9f9..71d69b5 100644 --- a/tests/backend/test_oras_cp.py +++ b/tests/backend/test_oras_cp.py @@ -2,15 +2,18 @@ import shutil import subprocess import time -import docker # type: ignore from pathlib import Path + +import docker # type: ignore import pytest + from olot.backend.oras_cp import is_oras, oras_pull, oras_push from olot.basics import oci_layers_on_top -from olot.oci.oci_image_layout import verify_ocilayout from olot.oci.oci_image_index import read_ocilayout_root_index +from olot.oci.oci_image_layout import verify_ocilayout from tests.common import sample_model_path + @pytest.mark.e2e_oras def test_is_oras(): assert is_oras() @@ -74,7 +77,7 @@ def test_oras_scenario(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: @@ -130,7 +133,7 @@ def test_oras_scenario_modelcard(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: diff --git a/tests/backend/test_oras_py.py b/tests/backend/test_oras_py.py index 2881238..3330d34 100644 --- a/tests/backend/test_oras_py.py +++ b/tests/backend/test_oras_py.py @@ -2,15 +2,18 @@ import shutil import subprocess import time -import docker # type: ignore from pathlib import Path + +import docker # type: ignore import pytest + from olot.backend.oras_py import is_oras_py, oras_py_pull, oras_py_push from olot.basics import oci_layers_on_top -from olot.oci.oci_image_layout import verify_ocilayout from olot.oci.oci_image_index import read_ocilayout_root_index +from olot.oci.oci_image_layout import verify_ocilayout from tests.common import sample_model_path + @pytest.mark.e2e_oras_py def test_is_oras_py(): assert is_oras_py() @@ -69,7 +72,7 @@ def test_oras_py_scenario(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: @@ -125,7 +128,7 @@ def test_oras_py_scenario_modelcard(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: diff --git a/tests/backend/test_skopeo.py b/tests/backend/test_skopeo.py index 4191049..c7cc3ef 100644 --- a/tests/backend/test_skopeo.py +++ b/tests/backend/test_skopeo.py @@ -2,15 +2,18 @@ import shutil import subprocess import time -import docker # type: ignore from pathlib import Path + +import docker # type: ignore import pytest -from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push, skopeo_inspect + +from olot.backend.skopeo import is_skopeo, skopeo_inspect, skopeo_pull, skopeo_push from olot.basics import oci_layers_on_top -from olot.oci.oci_image_layout import verify_ocilayout from olot.oci.oci_image_index import read_ocilayout_root_index +from olot.oci.oci_image_layout import verify_ocilayout from tests.common import get_test_data_path, sample_model_path + @pytest.mark.e2e_skopeo def test_is_skopeo(): assert is_skopeo() @@ -53,8 +56,7 @@ def test_skopeo_scenario(tmp_path): subprocess.run(["skopeo","list-tags","--tls-verify=false","docker://localhost:5001/nstestorg/modelcar"], check=True) # copy from Container Registry to Docker daemon for local running the modelcar as-is - result = subprocess.run("skopeo inspect --tls-verify=false --raw docker://localhost:5001/nstestorg/modelcar | jq -r '.manifests[] | select(.platform.architecture == \"amd64\") | .digest'", shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - assert result.returncode == 0 + result = subprocess.run("skopeo inspect --tls-verify=false --raw docker://localhost:5001/nstestorg/modelcar | jq -r '.manifests[] | select(.platform.architecture == \"amd64\") | .digest'", shell=True, text=True, capture_output=True, check=True) digest = result.stdout.strip() print(digest) # use by convention the linux/amd64 @@ -79,7 +81,7 @@ def test_skopeo_scenario(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: @@ -108,8 +110,7 @@ def test_skopeo_scenario_modelcard(tmp_path): subprocess.run(["skopeo","list-tags","--tls-verify=false","docker://localhost:5001/nstestorg/modelcar"], check=True) # copy from Container Registry to Docker daemon for local running the modelcar as-is - result = subprocess.run("skopeo inspect --tls-verify=false --raw docker://localhost:5001/nstestorg/modelcar | jq -r '.manifests[] | select(.platform.architecture == \"amd64\") | .digest'", shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - assert result.returncode == 0 + result = subprocess.run("skopeo inspect --tls-verify=false --raw docker://localhost:5001/nstestorg/modelcar | jq -r '.manifests[] | select(.platform.architecture == \"amd64\") | .digest'", shell=True, text=True, capture_output=True, check=True) digest = result.stdout.strip() print(digest) # use by convention the linux/amd64 @@ -141,7 +142,7 @@ def test_skopeo_scenario_modelcard(tmp_path): except docker.errors.NotFound: print("test container terminated") break - except Exception as e: + except docker.errors.DockerException as e: # other, potentially transient, docker exception print(f"Attempt to terminate {attempt + 1} failed: {e}") attempt += 1 if attempt == max_attempts: diff --git a/tests/basic_test.py b/tests/basic_test.py index 200a08b..7444637 100644 --- a/tests/basic_test.py +++ b/tests/basic_test.py @@ -1,26 +1,32 @@ +import logging import os +import shutil import tarfile from pathlib import Path -from olot.utils.files import get_file_hash + import pytest -import shutil -from typing import Dict -import logging -from olot.basics import RemoveOriginals, crawl_ocilayout_blobs_to_extract, crawl_ocilayout_indexes, crawl_ocilayout_manifests, oci_layers_on_top, write_empty_config_in_ocilayoyt +from olot.basics import ( + RemoveOriginals, + crawl_ocilayout_blobs_to_extract, + crawl_ocilayout_indexes, + crawl_ocilayout_manifests, + oci_layers_on_top, + write_empty_config_in_ocilayoyt, +) from olot.constants import ( ANNOTATION_LAYER_CONTENT_DIGEST, - ANNOTATION_LAYER_CONTENT_TYPE, ANNOTATION_LAYER_CONTENT_INLAYERPATH, ANNOTATION_LAYER_CONTENT_NAME, + ANNOTATION_LAYER_CONTENT_TYPE, ) +from olot.modelpack import const as modelpack_consts from olot.oci.oci_config import OCIManifestConfig from olot.oci.oci_image_index import OCIImageIndex, read_ocilayout_root_index from olot.oci.oci_image_manifest import OCIImageManifest +from olot.utils.files import get_file_hash from olot.utils.types import compute_hash_of_str -from tests.common import sample_model_path, get_test_data_path - -from olot.modelpack import const as modelpack_consts +from tests.common import get_test_data_path, sample_model_path def test_remove_originals(): @@ -32,18 +38,18 @@ def test_crawl_ocilayout_indexes(): """Crawl for indexes models (the index content itself, not a manifest ref) in given oci-layout """ ocilayout3_path = Path(__file__).parent / "data" / "ocilayout3" - mut: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout3_path, read_ocilayout_root_index(ocilayout3_path)) + mut: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout3_path, read_ocilayout_root_index(ocilayout3_path)) assert len(mut.keys()) == 1 - assert "d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b" in mut.keys() + assert "d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b" in mut index0 = mut["d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b"] assert index0.mediaType == "application/vnd.oci.image.index.v1+json" assert len(index0.manifests) == 2 # I will redo the same fo ocilayout2 which is simplified from ocilayout3 as a sanity check ocilayout2_path = Path(__file__).parent / "data" / "ocilayout2" - mut: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout2_path, read_ocilayout_root_index(ocilayout2_path)) + mut: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout2_path, read_ocilayout_root_index(ocilayout2_path)) assert len(mut.keys()) == 1 - assert "d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b" in mut.keys() + assert "d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b" in mut index0 = mut["d437889e826ecce2116ac711469bd09b1bb3c64d45055cbf23a6f8f3db223b8b"] assert index0.mediaType == "application/vnd.oci.image.index.v1+json" assert len(index0.manifests) == 2 @@ -54,11 +60,11 @@ def test_crawl_ocilayout_manifests(): """ ocilayout3_path = Path(__file__).parent / "data" / "ocilayout3" ocilayout_root_index = read_ocilayout_root_index(ocilayout3_path) - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout3_path, ocilayout_root_index) - mut: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(ocilayout3_path, ocilayout_indexes, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(ocilayout3_path, ocilayout_root_index) + mut: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(ocilayout3_path, ocilayout_indexes, ocilayout_root_index) assert len(mut.keys()) == 2 - assert "c23ed8b7e30f5edd2417e1dd99fedad4445f3e835edb58760b2f83f2c0517878" in mut.keys() + assert "c23ed8b7e30f5edd2417e1dd99fedad4445f3e835edb58760b2f83f2c0517878" in mut image0 = mut["c23ed8b7e30f5edd2417e1dd99fedad4445f3e835edb58760b2f83f2c0517878"] assert image0.mediaType == "application/vnd.oci.image.manifest.v1+json" assert len(image0.layers) == 1 @@ -207,15 +213,15 @@ def test_oci_layers_on_top_single_manifest_and_check_annotations(tmp_path: Path) oci_layers_on_top(target_ocilayout, models, modelcard) ocilayout_root_index: OCIImageIndex = read_ocilayout_root_index(target_ocilayout) - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) assert len(ocilayout_manifests) == 1 manifest0: OCIImageManifest = next(iter(ocilayout_manifests.values())) assert len(manifest0.layers) == 1 + len(models) + 1 # original value (only 1 layer in original oci-layout) + now added model files + now added modelcarD for layer in manifest0.layers[1:]: # skip original first layer in original oci-layout assert layer.annotations - assert "org.opencontainers.image.title" in layer.annotations.keys() + assert "org.opencontainers.image.title" in layer.annotations assert manifest0.layers[1].annotations assert manifest0.layers[1].annotations["org.opencontainers.image.title"] == "model.joblib" assert manifest0.layers[2].annotations @@ -223,7 +229,7 @@ def test_oci_layers_on_top_single_manifest_and_check_annotations(tmp_path: Path) # identify the ModelCarD layer by means of annotation(s) on the layer assert manifest0.layers[3].annotations assert manifest0.layers[3].annotations["org.opencontainers.image.title"] == "README.md" - assert "io.opendatahub.modelcar.layer.type" in manifest0.layers[3].annotations.keys() + assert "io.opendatahub.modelcar.layer.type" in manifest0.layers[3].annotations assert manifest0.layers[3].annotations["io.opendatahub.modelcar.layer.type"] == "modelcard" # identify the ModelCarD by means of annotation from the Image Manifest @@ -237,7 +243,7 @@ def test_oci_layers_on_top_single_manifest_and_check_annotations(tmp_path: Path) mc = OCIManifestConfig.model_validate_json(f.read()) assert mc.history assert len(mc.history) == 5 # check we preserved also previous history, 2 elements, + 3 new history items for the 3 new layers - assert len(list(x for x in mc.history if not x.empty_layer)) == len(manifest0.layers) + assert len([x for x in mc.history if not x.empty_layer]) == len(manifest0.layers) def test_add_modelpack_manifest_using_ocilayout3(tmp_path: Path): @@ -262,9 +268,9 @@ def test_add_modelpack_manifest_using_ocilayout3(tmp_path: Path): ocilayout_root_index = read_ocilayout_root_index(target_ocilayout) assert len(ocilayout_root_index.manifests) == 3 - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) assert len(ocilayout_indexes) == 1 - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) assert len(ocilayout_manifests) == 2 # add modelpack manifest @@ -321,9 +327,9 @@ def test_add_modelpack_manifest_using_ocilayout2(tmp_path: Path): ocilayout_root_index = read_ocilayout_root_index(target_ocilayout) assert len(ocilayout_root_index.manifests) == 1 - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) assert len(ocilayout_indexes) == 1 - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) assert len(ocilayout_manifests) == 2 # add modelpack manifest @@ -381,9 +387,9 @@ def test_add_modelpack_manifest_using_ocilayout5(tmp_path: Path): ocilayout_root_index = read_ocilayout_root_index(target_ocilayout) assert len(ocilayout_root_index.manifests) == 1 - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) assert len(ocilayout_indexes) == 0 - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) assert len(ocilayout_manifests) == 1 # attempt to add modelpack manifest @@ -454,9 +460,9 @@ def test_add_labels_and_annotations(tmp_path: Path): oci_layers_on_top(target_ocilayout, models, modelcard, labels={"a": "b"}, annotations={"c": "d"}) ocilayout_root_index = read_ocilayout_root_index(target_ocilayout) assert len(ocilayout_root_index.manifests) == 1 - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) assert len(ocilayout_indexes) == 0 - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) assert len(ocilayout_manifests) == 1 manifest0: OCIImageManifest = next(iter(ocilayout_manifests.values())) assert manifest0.annotations @@ -522,8 +528,8 @@ def test_oci_layers_on_top_nested_files(tmp_path: Path, use_root_dir): # Extract archive paths from every new layer ocilayout_root_index = read_ocilayout_root_index(target_ocilayout) - ocilayout_indexes: Dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) - ocilayout_manifests: Dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) + ocilayout_indexes: dict[str, OCIImageIndex] = crawl_ocilayout_indexes(target_ocilayout, ocilayout_root_index) + ocilayout_manifests: dict[str, OCIImageManifest] = crawl_ocilayout_manifests(target_ocilayout, ocilayout_indexes, ocilayout_root_index) manifest0: OCIImageManifest = next(iter(ocilayout_manifests.values())) new_layers = manifest0.layers[1:] # skip the 1 original base layer diff --git a/tests/common.py b/tests/common.py index 3c6b953..a96cded 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,9 +1,11 @@ -from pathlib import Path -import tarfile import gzip import shutil +import tarfile +from pathlib import Path + from olot.utils.files import HashingWriter, get_file_hash, tar_filter_fn + def get_test_path() -> Path: # this must be inside tests/common.py file, ie just under tests/ directory of the repo. return Path(__file__).parent diff --git a/tests/conftest.py b/tests/conftest.py index d639f74..249077d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import logging + import pytest logging.basicConfig(level=logging.INFO) diff --git a/tests/dockerdist/convert_test.py b/tests/dockerdist/convert_test.py index f7650c6..1164c13 100644 --- a/tests/dockerdist/convert_test.py +++ b/tests/dockerdist/convert_test.py @@ -1,13 +1,15 @@ -#!/usr/bin/env python3 """ Test for Docker distribution manifest to OCI conversion. """ +import shutil from pathlib import Path from pprint import pprint -import shutil -from olot.dockerdist.convert import check_if_oci_layout_contains_docker_manifests, convert_docker_manifests_to_oci +from olot.dockerdist.convert import ( + check_if_oci_layout_contains_docker_manifests, + convert_docker_manifests_to_oci, +) from tests.common import get_test_data_path diff --git a/tests/oci/oci_artifact_test.py b/tests/oci/oci_artifact_test.py index dc839d9..386d5c2 100644 --- a/tests/oci/oci_artifact_test.py +++ b/tests/oci/oci_artifact_test.py @@ -1,24 +1,33 @@ +import logging +import os +from pathlib import Path + +import pytest + from olot.backend.oras_cp import oras_push from olot.backend.skopeo import skopeo_inspect, skopeo_push from olot.basics import write_empty_config_in_ocilayoyt from olot.oci.oci_common import MediaTypes from olot.oci.oci_image_index import Manifest, OCIImageIndex from olot.oci.oci_image_layout import OCIImageLayout -from olot.oci.oci_image_manifest import ContentDescriptor, OCIImageManifest, create_oci_image_manifest, empty_config +from olot.oci.oci_image_manifest import ( + ContentDescriptor, + OCIImageManifest, + create_oci_image_manifest, + empty_config, +) from olot.oci.oci_utils import get_descriptor_from_manifest +from olot.oci_artifact import create_blobs, create_simple_oci_artifact from olot.utils.files import targz_from_file, walk_files from olot.utils.types import compute_hash_of_str -from tests.common import get_test_data_path, sample_model_path, file_checksums_with_compression, file_checksums_without_compression -from olot.oci_artifact import create_blobs, create_simple_oci_artifact -import pytest -import logging - -import os -from pathlib import Path - +from tests.common import ( + file_checksums_with_compression, + file_checksums_without_compression, + get_test_data_path, + sample_model_path, +) from tests.conftest import registry_addr - logger = logging.getLogger(__name__) diff --git a/tests/oci/oci_image_index_test.py b/tests/oci/oci_image_index_test.py index ec0d9fa..d3294aa 100644 --- a/tests/oci/oci_image_index_test.py +++ b/tests/oci/oci_image_index_test.py @@ -1,7 +1,7 @@ from olot.oci.oci_image_index import read_ocilayout_root_index - from tests.common import get_test_data_path + def test_read_ocilayout_root_index(): """Read correctly the ocilayout_root_index in a given oci-layout """ diff --git a/tests/oci/oci_image_layout_test.py b/tests/oci/oci_image_layout_test.py index d302472..20fb638 100644 --- a/tests/oci/oci_image_layout_test.py +++ b/tests/oci/oci_image_layout_test.py @@ -1,8 +1,9 @@ import pytest -from olot.oci.oci_image_layout import verify_ocilayout +from olot.oci.oci_image_layout import verify_ocilayout from tests.common import get_test_data_path + def test_verify_ocilayout(): """Test verify_ocilayout() fn on known oci-layout and not """ @@ -10,5 +11,5 @@ def test_verify_ocilayout(): verify_ocilayout(data_path / "ocilayout1") verify_ocilayout(data_path / "ocilayout2") verify_ocilayout(data_path / "ocilayout3") - with pytest.raises(Exception): + with pytest.raises((FileNotFoundError, ValueError)): verify_ocilayout(data_path) diff --git a/tests/oci/oci_utils_test.py b/tests/oci/oci_utils_test.py index 17a5ddc..a0f2a5c 100644 --- a/tests/oci/oci_utils_test.py +++ b/tests/oci/oci_utils_test.py @@ -2,6 +2,7 @@ from olot.oci.oci_utils import get_descriptor_from_manifest from tests.common import get_test_data_path + def test_get_descriptor_from_manifest(): data_path = get_test_data_path() diff --git a/tests/oci/test_oci.py b/tests/oci/test_oci.py index 5d7048a..3eaf08b 100644 --- a/tests/oci/test_oci.py +++ b/tests/oci/test_oci.py @@ -4,12 +4,11 @@ from pydantic import TypeAdapter -from olot.oci.oci_config import OCIManifestConfig from olot.oci.oci_common import MediaType +from olot.oci.oci_config import OCIManifestConfig from olot.oci.oci_image_index import OCIImageIndex from olot.oci.oci_image_layout import OCIImageLayout from olot.oci.oci_image_manifest import OCIImageManifest - from tests.common import sha256_path diff --git a/tests/utils/files_test.py b/tests/utils/files_test.py index 710f172..5ed8e0c 100644 --- a/tests/utils/files_test.py +++ b/tests/utils/files_test.py @@ -1,16 +1,24 @@ -from pathlib import Path +import gzip +import os +import shutil import subprocess import tarfile import time -import gzip -import shutil -import os +from pathlib import Path import pytest -from olot.utils.files import get_file_hash, HashingWriter, HashingFileReader, tarball_from_file, targz_from_file, walk_files +from olot.utils.files import ( + HashingFileReader, + HashingWriter, + get_file_hash, + tarball_from_file, + targz_from_file, + walk_files, +) from tests.common import get_test_data_path, sample_model_path, sha256_path + def test_get_file_hash(): """As get_file_hash() function is used in other test, making sure it is generating the expected digest for known data """ @@ -204,7 +212,7 @@ def test_targz_from_file(tmp_path): assert found uncompressed_tar = write_dest / "uncompressed.tar" - with gzip.open(write_dest / postcomp_chksum, "rb") as g_in: + with gzip.open(write_dest / postcomp_chksum, "rb") as g_in: # noqa: SIM117 with open(uncompressed_tar, "wb") as f_out: shutil.copyfileobj(g_in, f_out) for file in tmp_path.rglob('*'): diff --git a/tests/utils/types_test.py b/tests/utils/types_test.py index d0ea835..532bbfd 100644 --- a/tests/utils/types_test.py +++ b/tests/utils/types_test.py @@ -1,5 +1,6 @@ from olot.utils.types import compute_hash_of_str + def test_compute_hash_of_str(): """Basis compute_hash_of_str() fn testing """ diff --git a/tests/utils/validation_test.py b/tests/utils/validation_test.py index 39220f3..aeef085 100644 --- a/tests/utils/validation_test.py +++ b/tests/utils/validation_test.py @@ -1,4 +1,4 @@ -from olot.utils.validation import is_valid_registry_host_port, is_valid_oci_reference +from olot.utils.validation import is_valid_oci_reference, is_valid_registry_host_port def test_valid_ipv4_address(): diff --git a/uv.lock b/uv.lock index 9e2f34b..5c12d33 100644 --- a/uv.lock +++ b/uv.lock @@ -477,7 +477,7 @@ dev = [ { name = "docker", specifier = ">=7.2.0,<8" }, { name = "mypy", specifier = ">=2.3.0,<3" }, { name = "pytest", specifier = ">=9.1.1,<10" }, - { name = "ruff", specifier = ">=0.15.22,<0.16.0" }, + { name = "ruff", specifier = ">=0.16.0,<0.17.0" }, ] [[package]] @@ -1001,27 +1001,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]]