Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ and **Merged pull requests**. Critical items to know are:
The versions coincide with releases on pip. Only major versions will be released as tags on Github.

## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x)
- restructure the copy engine into a layered call stack `provider -> copy -> content` (0.2.43)
- the DAG copy algorithm (`copy_graph`, `StatusTracker`, `successors`) lives in `oras.copy` (`oras.copy.graph`, `oras.copy.tracker`), removing the `content <-> copy` circular import
- the storage/target `Protocol` contract plus the general-purpose `CacheProxy` cache and `FetcherFunc` adapter moved to `oras.content.storage`, the in-memory store (`MemoryStorage`) to `oras.content.memory`, and the concrete adapters `RegistryTarget`/`LayoutTarget` to `oras.content.registry`/`oras.content.layout`
- **backward incompatible:** import `Target`/`ReadOnlyTarget`/`Storage`/`ReadOnlyStorage`/`ReferencePusher`/`ReferenceFetcher`/`Mounter` (also re-exported from `oras.copy`) and `CacheProxy`/`FetcherFunc` from `oras.content.storage`, and `MemoryStorage` from `oras.content.memory`; `RegistryTarget` moved from `oras.provider` to `oras.content.registry`; `LayoutTarget` moved from `oras.layout.layout` to `oras.content.layout`
- the descriptor primitives (`is_manifest`, `is_foreign_layer`, `descriptor_key`, `descriptors_equal`, `remove_foreign_layers`) moved from `oras.content.descriptor` to `oras.copy.descriptor`, and `oras.content.registry` no longer depends on the descriptor module — **backward incompatible:** import these helpers from `oras.copy.descriptor`
- the unused `Fetcher`/`Pusher`/`Resolver`/`Tagger`/`TagResolver` protocols were removed
- add Layout `copy` for pull_from_registry capability (0.2.42)
- make `get_manifest()` validation optional, fix `Accept` header join, and expand default `Accept` header types to cover all supported response types for the `/v2/<name>/manifests/<reference>` endpoint (0.2.41)
- fix preemptive exit in non-empty `auths` lookup when `credsStore` or `credHelpers` is used (0.2.40)
Expand Down
12 changes: 12 additions & 0 deletions oras/content/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
OCI content layer for the copy engine.

Holds the storage/target Protocol contract and its general-purpose helpers
(read-through cache, callable-fetcher adapter) in :mod:`oras.content.storage`,
and the in-memory store in :mod:`oras.content.memory`, shared by the copy
algorithm and the concrete target adapters.
"""

__author__ = "The ORAS Authors"
__copyright__ = "Copyright The ORAS Authors."
__license__ = "Apache-2.0"
145 changes: 145 additions & 0 deletions oras/content/layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
OCI layout target adapter for the copy engine.

Adapts a :class:`oras.layout.layout.Layout` directory into the copy engine's
``Target`` protocol, providing content-addressable blob storage plus
reference resolution/tagging via the layout's index.json.
"""

__author__ = "The ORAS Authors"
__copyright__ = "Copyright The ORAS Authors."
__license__ = "Apache-2.0"

import os
import pathlib
import re
import shutil
import tempfile
import threading
from typing import TYPE_CHECKING, BinaryIO

import oras.defaults
from oras.types import Descriptor
from oras.utils.fileio import read_json, write_json

if TYPE_CHECKING:
from oras.layout.layout import Layout


_VALID_DIGEST_RE = re.compile(r"^[a-z0-9]+:[a-f0-9]+$")


class LayoutTarget:
"""
Adapts a :class:`Layout` directory into the copy engine's Target protocol.

Provides full read/write access to the OCI layout's content-addressable
blobs and manages references via the layout's index.json annotations.

Implements Target (fetch, exists, push, tag, resolve), so it can serve
as both source and destination in :func:`oras.copy.copy`.
"""

def __init__(self, layout: "Layout"):
self._layout = layout
self._index_lock = threading.Lock()

@staticmethod
def _validate_digest(digest: str) -> None:
if not _VALID_DIGEST_RE.match(digest):
raise ValueError(f"invalid digest format: {digest!r}")

def fetch(self, desc: Descriptor) -> BinaryIO:
"""Fetch blob content by digest, returning an open file handle."""
digest = desc["digest"]
self._validate_digest(digest)
path = self._layout.digest_to_blob_path(digest)
return open(path, "rb")

def exists(self, desc: Descriptor) -> bool:
"""Check if a blob exists on disk."""
digest = desc["digest"]
self._validate_digest(digest)
return self._layout.blob_exists(digest)

def push(self, desc: Descriptor, content: BinaryIO) -> None:
"""Write blob content to the layout (content-addressed, deduplicated).

Matching oras-go's Store.Push: skips silently if the blob already
exists (same digest), otherwise writes atomically via a temp file +
rename so concurrent writers never observe a partial blob.
"""
digest = desc["digest"]
self._validate_digest(digest)

self._layout.init()

path = self._layout.digest_to_blob_path(digest)
if path.exists():
return # already present — deduplicate silently

path.parent.mkdir(parents=True, exist_ok=True)

tmp_fd, tmp_name = tempfile.mkstemp(dir=str(path.parent))
try:
# Stream rather than buffer: large blobs must not be fully
# materialized in memory.
with os.fdopen(tmp_fd, "wb") as f:
shutil.copyfileobj(content, f)
os.replace(tmp_name, str(path))
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise

def tag(self, desc: Descriptor, reference: str) -> None:
"""Update index.json to associate the descriptor with a reference tag.

Matching oras-go's Store.Tag: finds an existing index.json entry for
this reference and replaces it, or appends a new one.
Thread-safe via _index_lock.
"""
self._layout.init()

layout_dir = pathlib.Path(self._layout._oci_layout_path)
index_path = layout_dir / oras.defaults.oci_image_index_file

new_entry = {
"mediaType": desc.get("mediaType", ""),
"digest": desc["digest"],
"size": desc.get("size", 0),
"annotations": {oras.defaults.oci_ref_name_annotation: reference},
}

with self._index_lock:
index_data = read_json(str(index_path))
manifests = index_data.setdefault("manifests", [])

for i, entry in enumerate(manifests):
if (
entry.get("annotations", {}).get(
oras.defaults.oci_ref_name_annotation
)
== reference
):
manifests[i] = new_entry
break
else:
manifests.append(new_entry)

write_json(index_data, str(index_path))

def resolve(self, reference: str) -> Descriptor:
"""Resolve a reference tag to a descriptor via index.json."""
entry = self._layout.find_index_entry(reference)
if entry is None:
raise FileNotFoundError(
f"Reference not found in layout index: {reference}"
)
return {
"mediaType": entry.get("mediaType", ""),
"digest": entry.get("digest", ""),
"size": entry.get("size", 0),
}
52 changes: 52 additions & 0 deletions oras/content/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
In-memory content storage implementation.

``MemoryStorage`` is a standalone, thread-safe in-memory content-addressable
store — a concrete realization of the storage protocols in
:mod:`oras.content.storage`, where the general-purpose ``FetcherFunc`` adapter
and the read-through ``CacheProxy`` cache also live.

Matches oras-go's content.Memory.
"""

__author__ = "The ORAS Authors"
__copyright__ = "Copyright The ORAS Authors."
__license__ = "Apache-2.0"

import io
import threading
from typing import BinaryIO

from oras.types import Descriptor


# ---------------------------------------------------------------------------
# MemoryStorage: Thread-safe in-memory content-addressable storage
# ---------------------------------------------------------------------------


class MemoryStorage:
"""Thread-safe in-memory content storage keyed by digest."""

def __init__(self):
self._lock = threading.Lock()
self._content: dict = {} # digest -> bytes

def exists(self, desc: Descriptor) -> bool:
digest = desc.get("digest", "")
with self._lock:
return digest in self._content

def fetch(self, desc: Descriptor) -> BinaryIO:
digest = desc.get("digest", "")
with self._lock:
data = self._content.get(digest)
if data is None:
raise FileNotFoundError(f"content not found: {digest}")
return io.BytesIO(data)

def push(self, desc: Descriptor, content: BinaryIO) -> None:
data = content.read()
digest = desc.get("digest", "")
with self._lock:
self._content[digest] = data
Loading
Loading