Skip to content

Commit 4f89dc6

Browse files
committed
feat: implement Target that support read and write
Signed-off-by: jiwangCHEN <hyper1char@gmail.com>
1 parent be653f0 commit 4f89dc6

10 files changed

Lines changed: 908 additions & 76 deletions

File tree

oras/copy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
2727
Adapters:
2828
RegistryTarget - Adapts Registry + container to Target/ReferencePusher/Mounter
29-
LayoutTarget - Adapts Layout directory to ReadOnlyTarget
29+
LayoutTarget - Adapts Layout directory to Target (full read/write)
3030
"""
3131

3232
__author__ = "The ORAS Authors"

oras/copy/adapters.py

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
RegistryTarget wraps a Registry + container string into a Target +
88
ReferencePusher + Mounter scoped to a single repository.
99
10-
LayoutTarget wraps a Layout directory into a ReadOnlyTarget.
10+
LayoutTarget wraps a Layout directory into a full read/write Target.
1111
"""
1212

1313
__author__ = "The ORAS Authors"
@@ -17,16 +17,23 @@
1717
import os
1818
import pathlib
1919
import re
20+
import shutil
2021
import tempfile
21-
from typing import BinaryIO, Callable
22+
import threading
23+
from typing import TYPE_CHECKING, BinaryIO, Callable, Optional
2224

2325
import oras.defaults
2426
import oras.utils
2527
from oras.copy.descriptor import is_manifest
2628
from oras.layout.layout import Layout
2729
from oras.provider import Registry
2830
from oras.types import Descriptor
29-
from oras.utils.fileio import read_json
31+
from oras.utils.fileio import read_json, write_json
32+
33+
if TYPE_CHECKING:
34+
import requests
35+
36+
from oras.copy.options import CopyOptions
3037

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

@@ -50,10 +57,20 @@ class RegistryTarget:
5057
by the container string passed at construction time.
5158
"""
5259

53-
def __init__(self, registry: Registry, container: str):
60+
def __init__(
61+
self,
62+
registry: Registry,
63+
container: str,
64+
opts: "Optional[CopyOptions]" = None,
65+
):
5466
self._registry = registry
5567
self._container = registry.get_container(container)
5668
self._registry.auth.load_configs(self._container)
69+
self._opts = opts
70+
# Records the most recent manifest PUT response (from push /
71+
# push_reference) so callers like Registry.push can return the real
72+
# upload response rather than issuing a follow-up GET.
73+
self.last_manifest_response: "Optional[requests.Response]" = None
5774

5875
def _manifest_url(self, ref: str) -> str:
5976
"""Build full manifest URL for a reference (tag or digest)."""
@@ -95,15 +112,31 @@ def push(self, desc: Descriptor, content: BinaryIO) -> None:
95112
response = self._registry.do_request(
96113
url, "PUT", data=data, headers=headers
97114
)
115+
self.last_manifest_response = response
98116
self._registry._check_200_response(response)
99117
else:
100-
data = content.read()
101118
tmp = None
102119
try:
103120
tmp = tempfile.NamedTemporaryFile(delete=False)
104-
tmp.write(data)
121+
# Stream rather than buffer: large layers (and chunked
122+
# uploads) must not be fully materialized in memory.
123+
shutil.copyfileobj(content, tmp)
105124
tmp.close()
106-
self._registry.upload_blob(tmp.name, self._container, desc)
125+
do_chunked = False
126+
chunk_size = oras.defaults.default_chunksize
127+
if self._opts is not None:
128+
do_chunked = self._opts.graph.do_chunked
129+
chunk_size = (
130+
self._opts.graph.chunk_size
131+
or oras.defaults.default_chunksize
132+
)
133+
self._registry.upload_blob(
134+
tmp.name,
135+
self._container,
136+
desc,
137+
do_chunked=do_chunked,
138+
chunk_size=chunk_size,
139+
)
107140
finally:
108141
if tmp is not None:
109142
try:
@@ -144,6 +177,7 @@ def push_reference(
144177
response = self._registry.do_request(
145178
url, "PUT", data=data, headers=headers
146179
)
180+
self.last_manifest_response = response
147181
self._registry._check_200_response(response)
148182

149183
def mount(
@@ -189,20 +223,47 @@ def mount(
189223

190224
class LayoutTarget:
191225
"""
192-
Adapts a Layout directory into the copy engine's ReadOnlyTarget protocol.
226+
Adapts a Layout directory into the copy engine's Target protocol.
193227
194-
Provides read-only access to the OCI layout's content-addressable blobs
195-
and resolves references via the layout's index.json annotations.
228+
Provides full read/write access to the OCI layout's content-addressable
229+
blobs and manages references via the layout's index.json annotations.
230+
231+
Implements Target (fetch, exists, push, tag, resolve), so it can serve
232+
as both source and destination in :func:`oras.copy.copy`.
196233
"""
197234

198235
def __init__(self, layout: Layout):
199236
self._layout = layout
237+
self._index_lock = threading.Lock()
200238

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

244+
def _ensure_initialized(self) -> None:
245+
"""Create OCI layout structure (oci-layout, index.json, blobs/) if missing."""
246+
layout_dir = pathlib.Path(self._layout._oci_layout_path)
247+
layout_dir.mkdir(parents=True, exist_ok=True)
248+
(layout_dir / oras.defaults.oci_blobs_dir).mkdir(exist_ok=True)
249+
250+
oci_layout_path = layout_dir / oras.defaults.oci_layout_file
251+
if not oci_layout_path.exists():
252+
write_json(
253+
{"imageLayoutVersion": oras.defaults.oci_layout_version_pin},
254+
str(oci_layout_path),
255+
)
256+
257+
index_path = layout_dir / oras.defaults.oci_image_index_file
258+
if not index_path.exists():
259+
write_json(
260+
{
261+
"schemaVersion": oras.defaults.oci_index_schema_version,
262+
"manifests": [],
263+
},
264+
str(index_path),
265+
)
266+
206267
def fetch(self, desc: Descriptor) -> BinaryIO:
207268
"""Fetch blob content by digest, returning an open file handle."""
208269
digest = desc["digest"]
@@ -217,6 +278,75 @@ def exists(self, desc: Descriptor) -> bool:
217278
path = self._layout.digest_to_blob_path(digest)
218279
return path.exists()
219280

281+
def push(self, desc: Descriptor, content: BinaryIO) -> None:
282+
"""Write blob content to the layout (content-addressed, deduplicated).
283+
284+
Matching oras-go's Store.Push: skips silently if the blob already
285+
exists (same digest), otherwise writes atomically via a temp file +
286+
rename so concurrent writers never observe a partial blob.
287+
"""
288+
digest = desc["digest"]
289+
self._validate_digest(digest)
290+
291+
self._ensure_initialized()
292+
293+
path = self._layout.digest_to_blob_path(digest)
294+
if path.exists():
295+
return # already present — deduplicate silently
296+
297+
path.parent.mkdir(parents=True, exist_ok=True)
298+
299+
tmp_fd, tmp_name = tempfile.mkstemp(dir=str(path.parent))
300+
try:
301+
# Stream rather than buffer: large blobs must not be fully
302+
# materialized in memory.
303+
with os.fdopen(tmp_fd, "wb") as f:
304+
shutil.copyfileobj(content, f)
305+
os.replace(tmp_name, str(path))
306+
except Exception:
307+
try:
308+
os.unlink(tmp_name)
309+
except OSError:
310+
pass
311+
raise
312+
313+
def tag(self, desc: Descriptor, reference: str) -> None:
314+
"""Update index.json to associate the descriptor with a reference tag.
315+
316+
Matching oras-go's Store.Tag: finds an existing index.json entry for
317+
this reference and replaces it, or appends a new one.
318+
Thread-safe via _index_lock.
319+
"""
320+
self._ensure_initialized()
321+
322+
layout_dir = pathlib.Path(self._layout._oci_layout_path)
323+
index_path = layout_dir / oras.defaults.oci_image_index_file
324+
325+
new_entry = {
326+
"mediaType": desc.get("mediaType", ""),
327+
"digest": desc["digest"],
328+
"size": desc.get("size", 0),
329+
"annotations": {oras.defaults.oci_ref_name_annotation: reference},
330+
}
331+
332+
with self._index_lock:
333+
index_data = read_json(str(index_path))
334+
manifests = index_data.setdefault("manifests", [])
335+
336+
for i, entry in enumerate(manifests):
337+
if (
338+
entry.get("annotations", {}).get(
339+
oras.defaults.oci_ref_name_annotation
340+
)
341+
== reference
342+
):
343+
manifests[i] = new_entry
344+
break
345+
else:
346+
manifests.append(new_entry)
347+
348+
write_json(index_data, str(index_path))
349+
220350
def resolve(self, reference: str) -> Descriptor:
221351
"""Resolve a reference tag to a descriptor via index.json."""
222352
layout_dir = pathlib.Path(self._layout._oci_layout_path)

oras/copy/copy.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -246,14 +246,16 @@ def _copy_cached_node_with_reference(
246246
reference: str,
247247
) -> None:
248248
"""
249-
Push a cached node to the destination with a reference tag.
250-
251-
The content must already be in the proxy cache (as is the case
252-
for manifests/indexes that were fetched during graph traversal).
253-
254-
Matches oras-go's copyCachedNodeWithReference.
249+
Push a node to the destination with a reference tag.
250+
251+
The content is normally already in the proxy cache (fetched during
252+
graph traversal). When the root already exists at the destination its
253+
sub-DAG is skipped and never fetched, so the cache may miss; in that
254+
case we fall back to fetching from the source via the proxy. This
255+
matches oras-go's copyCachedNodeWithReference, which uses FetchCached
256+
(cache-or-source), not a cache-only read.
255257
"""
256-
rc = proxy.cache.fetch(desc)
258+
rc = proxy.fetch(desc)
257259
try:
258260
data = rc.read()
259261
finally:

oras/copy/options.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class CopyGraphOptions:
4242
on_mounted: Called when a blob is successfully mounted.
4343
find_successors: Custom function to discover child nodes of
4444
a descriptor. If None, content.successors is used.
45+
do_chunked: If True, blob uploads to a registry destination use
46+
chunked upload. Honored by RegistryTarget.push.
47+
chunk_size: Chunk size in bytes for chunked uploads. If <= 0,
48+
defaults to oras.defaults.default_chunksize.
4549
"""
4650

4751
concurrency: int = 0
@@ -52,6 +56,8 @@ class CopyGraphOptions:
5256
mount_from: Optional[Callable[[Descriptor], List[str]]] = None
5357
on_mounted: Optional[Callable[[Descriptor], None]] = None
5458
find_successors: Optional[Callable] = None
59+
do_chunked: bool = False
60+
chunk_size: int = 0
5561

5662

5763
@dataclass

oras/layout/layout.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -524,12 +524,12 @@ def push_to_registry(
524524

525525
def as_target(self) -> LayoutTarget:
526526
"""
527-
Return a copy-engine ReadOnlyTarget adapter for this layout.
527+
Return a copy-engine Target adapter for this layout.
528528
529529
The returned LayoutTarget can be passed directly to
530-
:func:`oras.copy.copy` as a source.
530+
:func:`oras.copy.copy` as either source or destination.
531531
532-
:return: a ReadOnlyTarget adapter wrapping this layout
532+
:return: a Target adapter wrapping this layout
533533
:rtype: oras.copy.adapters.LayoutTarget
534534
"""
535535
from oras.copy.adapters import LayoutTarget
@@ -552,7 +552,10 @@ def copy_to_registry(
552552
553553
:param provider: Registry provider instance
554554
:type provider: oras.provider.Registry
555-
:param target: target registry/repository (e.g., "ghcr.io/user/repo:v1.0")
555+
:param target: target registry/repository with the destination tag
556+
(e.g., "ghcr.io/user/repo:v1.0"). The tag/digest in this URL
557+
determines the destination reference at the registry. When no
558+
tag is present, "latest" is used.
556559
:type target: str
557560
:param tag: source tag to read from the layout's index.json annotations (default: "latest")
558561
:type tag: str
@@ -563,12 +566,52 @@ def copy_to_registry(
563566
:raises FileNotFoundError: if layout or blobs don't exist
564567
:raises ValueError: if layout is invalid or tag not found
565568
"""
569+
from oras.container import Container
566570
from oras.copy import copy as copy_fn
567571
from oras.copy.adapters import LayoutTarget, RegistryTarget
568572

569573
src = LayoutTarget(self)
570-
dst = RegistryTarget(provider, target)
571-
return copy_fn(src, tag, dst, tag, opts)
574+
dst = RegistryTarget(provider, target, opts)
575+
dst_container = Container(target)
576+
dst_ref = dst_container.digest or dst_container.tag
577+
return copy_fn(src, tag, dst, dst_ref, opts)
578+
579+
def copy_from_registry(
580+
self,
581+
provider: Registry,
582+
source: str,
583+
tag: str = "latest",
584+
opts: CopyOptions = None,
585+
) -> Descriptor:
586+
"""
587+
Copy content from a remote registry into this layout using the copy engine.
588+
589+
Uses the copy engine's DAG-aware graph traversal to pull all content
590+
from the source registry into this layout directory.
591+
This is the recommended way to pull a registry artifact into a layout.
592+
593+
:param provider: Registry provider instance
594+
:type provider: oras.provider.Registry
595+
:param source: source registry/repository (e.g., "ghcr.io/user/repo:v1.0")
596+
:type source: str
597+
:param tag: tag to write in the layout's index.json annotation (default: "latest")
598+
:type tag: str
599+
:param opts: copy options (default: None for default settings)
600+
:type opts: oras.copy.options.CopyOptions
601+
:return: the root descriptor that was copied
602+
:rtype: dict
603+
:raises FileNotFoundError: if source tag/digest is not found in registry
604+
:raises ValueError: if source is invalid
605+
"""
606+
from oras.container import Container
607+
from oras.copy import copy as copy_fn
608+
from oras.copy.adapters import LayoutTarget, RegistryTarget
609+
610+
src = RegistryTarget(provider, source, opts)
611+
dst = LayoutTarget(self)
612+
src_container = Container(source)
613+
src_ref = src_container.digest or src_container.tag
614+
return copy_fn(src, src_ref, dst, tag, opts)
572615

573616
def pull_from_registry(
574617
self,

0 commit comments

Comments
 (0)