Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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.types`, and `oras.content.registry` no longer depends on the descriptor module — **backward incompatible:** import these helpers from `oras.types`
- 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