77RegistryTarget wraps a Registry + container string into a Target +
88ReferencePusher + 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"
1717import os
1818import pathlib
1919import re
20+ import shutil
2021import tempfile
21- from typing import BinaryIO , Callable
22+ import threading
23+ from typing import TYPE_CHECKING , BinaryIO , Callable , Optional
2224
2325import oras .defaults
2426import oras .utils
2527from oras .copy .descriptor import is_manifest
2628from oras .layout .layout import Layout
2729from oras .provider import Registry
2830from 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
190224class 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 )
0 commit comments