Skip to content

Commit 6c4af0a

Browse files
committed
refactor: delete adapters.py
Signed-off-by: jiwangCHEN <hyper1char@gmail.com>
1 parent 4f89dc6 commit 6c4af0a

6 files changed

Lines changed: 433 additions & 134 deletions

File tree

oras/copy/__init__.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
ReferenceFetcher - Fetch by reference (resolve + fetch in one call)
2525
Mounter - Cross-repo blob mounting
2626
27-
Adapters:
28-
RegistryTarget - Adapts Registry + container to Target/ReferencePusher/Mounter
29-
LayoutTarget - Adapts Layout directory to Target (full read/write)
27+
Concrete adapters that bridge this engine to a registry or an OCI layout
28+
live with the types they adapt: :class:`oras.provider.RegistryTarget` and
29+
:class:`oras.layout.layout.LayoutTarget`.
3030
"""
3131

3232
__author__ = "The ORAS Authors"
@@ -47,7 +47,6 @@
4747
TagResolver,
4848
Target,
4949
)
50-
from oras.copy.adapters import LayoutTarget, RegistryTarget
5150
from oras.copy.copy import copy
5251
from oras.copy.errors import CopyError, CopyErrorOrigin
5352
from oras.copy.graph import SkipNode
@@ -81,7 +80,4 @@
8180
"ReferencePusher",
8281
"ReferenceFetcher",
8382
"Mounter",
84-
# Adapters
85-
"RegistryTarget",
86-
"LayoutTarget",
8783
]

oras/copy/adapters.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,32 @@ def _upload_blob_url(self) -> str:
8080
"""Build full upload blob URL."""
8181
return f"{self._registry.prefix}://{self._container.upload_blob_url()}"
8282

83+
def _put_manifest(
84+
self, reference: str, data: bytes, media_type: str, record: bool
85+
) -> "requests.Response":
86+
"""PUT manifest bytes to a reference (tag or digest).
87+
88+
Shared by :meth:`push` (manifest branch), :meth:`tag`, and
89+
:meth:`push_reference`. When ``record`` is True the response is stored
90+
on ``last_manifest_response`` so callers like ``Registry.push`` can
91+
return the real upload response instead of issuing a follow-up GET.
92+
"""
93+
url = self._manifest_url(reference)
94+
headers = {"Content-Type": media_type}
95+
response = self._registry.do_request(
96+
url, "PUT", data=data, headers=headers
97+
)
98+
if record:
99+
self.last_manifest_response = response
100+
self._registry._check_200_response(response)
101+
return response
102+
103+
def _head_manifest(self, reference: str) -> "requests.Response":
104+
"""Issue a HEAD for a manifest reference with a broad Accept header."""
105+
url = self._manifest_url(reference)
106+
headers = {"Accept": _ACCEPT_ALL_MANIFESTS}
107+
return self._registry.do_request(url, "HEAD", headers=headers)
108+
83109
def fetch(self, desc: Descriptor) -> BinaryIO:
84110
"""Fetch content for a descriptor. Routes blob vs manifest."""
85111
if is_manifest(desc):
@@ -96,24 +122,16 @@ def fetch(self, desc: Descriptor) -> BinaryIO:
96122
def exists(self, desc: Descriptor) -> bool:
97123
"""Check if content exists. Routes blob vs manifest."""
98124
if is_manifest(desc):
99-
url = self._manifest_url(desc["digest"])
100-
headers = {"Accept": _ACCEPT_ALL_MANIFESTS}
101-
response = self._registry.do_request(url, "HEAD", headers=headers)
102-
return response.status_code == 200
103-
else:
104-
return self._registry.blob_exists(desc, self._container)
125+
return self._head_manifest(desc["digest"]).status_code == 200
126+
return self._registry.blob_exists(desc, self._container)
105127

106128
def push(self, desc: Descriptor, content: BinaryIO) -> None:
107129
"""Push content for a descriptor. Routes blob vs manifest."""
108130
if is_manifest(desc):
109131
data = content.read()
110-
url = self._manifest_url(desc["digest"])
111-
headers = {"Content-Type": desc.get("mediaType", "")}
112-
response = self._registry.do_request(
113-
url, "PUT", data=data, headers=headers
132+
self._put_manifest(
133+
desc["digest"], data, desc.get("mediaType", ""), record=True
114134
)
115-
self.last_manifest_response = response
116-
self._registry._check_200_response(response)
117135
else:
118136
tmp = None
119137
try:
@@ -146,20 +164,14 @@ def push(self, desc: Descriptor, content: BinaryIO) -> None:
146164

147165
def tag(self, desc: Descriptor, reference: str) -> None:
148166
"""Tag a descriptor with a reference by re-uploading the manifest."""
149-
rc = self.fetch(desc)
150-
data = rc.read()
151-
url = self._manifest_url(reference)
152-
headers = {"Content-Type": desc.get("mediaType", "")}
153-
response = self._registry.do_request(
154-
url, "PUT", data=data, headers=headers
167+
data = self.fetch(desc).read()
168+
self._put_manifest(
169+
reference, data, desc.get("mediaType", ""), record=False
155170
)
156-
self._registry._check_200_response(response)
157171

158172
def resolve(self, reference: str) -> Descriptor:
159173
"""Resolve a reference to a descriptor via HEAD request."""
160-
url = self._manifest_url(reference)
161-
headers = {"Accept": _ACCEPT_ALL_MANIFESTS}
162-
response = self._registry.do_request(url, "HEAD", headers=headers)
174+
response = self._head_manifest(reference)
163175
self._registry._check_200_response(response)
164176
return {
165177
"mediaType": response.headers.get("Content-Type", ""),
@@ -172,13 +184,9 @@ def push_reference(
172184
) -> None:
173185
"""Atomic push + tag: PUT manifest bytes to the reference URL."""
174186
data = content.read()
175-
url = self._manifest_url(reference)
176-
headers = {"Content-Type": desc.get("mediaType", "")}
177-
response = self._registry.do_request(
178-
url, "PUT", data=data, headers=headers
187+
self._put_manifest(
188+
reference, data, desc.get("mediaType", ""), record=True
179189
)
180-
self.last_manifest_response = response
181-
self._registry._check_200_response(response)
182190

183191
def mount(
184192
self,

oras/layout/layout.py

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
__license__ = "Apache-2.0"
66

77
import json
8+
import os
89
import pathlib
9-
from typing import TYPE_CHECKING
10+
import re
11+
import shutil
12+
import tempfile
13+
import threading
14+
from typing import TYPE_CHECKING, BinaryIO
1015

1116
import jsonschema
1217
import requests
@@ -22,12 +27,15 @@
2227
from oras.utils.fileio import read_json, write_json
2328

2429
if TYPE_CHECKING:
25-
from oras.copy.adapters import LayoutTarget
2630
from oras.copy.descriptor import Descriptor
2731
from oras.copy.options import CopyOptions
2832
from oras.provider import Registry
2933

3034

35+
_VALID_DIGEST_RE = re.compile(r"^[a-z0-9]+:[a-f0-9]+$")
36+
37+
38+
3139
def NewLayout(path: str, validate: bool = True) -> Layout:
3240
"""
3341
Courtesy function to create a Layout from a path on disk.
@@ -530,10 +538,8 @@ def as_target(self) -> LayoutTarget:
530538
:func:`oras.copy.copy` as either source or destination.
531539
532540
:return: a Target adapter wrapping this layout
533-
:rtype: oras.copy.adapters.LayoutTarget
541+
:rtype: oras.layout.layout.LayoutTarget
534542
"""
535-
from oras.copy.adapters import LayoutTarget
536-
537543
return LayoutTarget(self)
538544

539545
def copy_to_registry(
@@ -568,7 +574,7 @@ def copy_to_registry(
568574
"""
569575
from oras.container import Container
570576
from oras.copy import copy as copy_fn
571-
from oras.copy.adapters import LayoutTarget, RegistryTarget
577+
from oras.provider import RegistryTarget
572578

573579
src = LayoutTarget(self)
574580
dst = RegistryTarget(provider, target, opts)
@@ -605,7 +611,7 @@ def copy_from_registry(
605611
"""
606612
from oras.container import Container
607613
from oras.copy import copy as copy_fn
608-
from oras.copy.adapters import LayoutTarget, RegistryTarget
614+
from oras.provider import RegistryTarget
609615

610616
src = RegistryTarget(provider, source, opts)
611617
dst = LayoutTarget(self)
@@ -709,3 +715,150 @@ def pull_from_registry(
709715
logger.debug(
710716
f"Successfully pulled {target} to OCI layout at {self._oci_layout_path}"
711717
)
718+
719+
720+
class LayoutTarget:
721+
"""
722+
Adapts a :class:`Layout` directory into the copy engine's Target protocol.
723+
724+
Provides full read/write access to the OCI layout's content-addressable
725+
blobs and manages references via the layout's index.json annotations.
726+
727+
Implements Target (fetch, exists, push, tag, resolve), so it can serve
728+
as both source and destination in :func:`oras.copy.copy`.
729+
"""
730+
731+
def __init__(self, layout: Layout):
732+
self._layout = layout
733+
self._index_lock = threading.Lock()
734+
735+
@staticmethod
736+
def _validate_digest(digest: str) -> None:
737+
if not _VALID_DIGEST_RE.match(digest):
738+
raise ValueError(f"invalid digest format: {digest!r}")
739+
740+
def _ensure_initialized(self) -> None:
741+
"""Create OCI layout structure (oci-layout, index.json, blobs/) if missing."""
742+
layout_dir = pathlib.Path(self._layout._oci_layout_path)
743+
layout_dir.mkdir(parents=True, exist_ok=True)
744+
(layout_dir / oras.defaults.oci_blobs_dir).mkdir(exist_ok=True)
745+
746+
oci_layout_path = layout_dir / oras.defaults.oci_layout_file
747+
if not oci_layout_path.exists():
748+
write_json(
749+
{"imageLayoutVersion": oras.defaults.oci_layout_version_pin},
750+
str(oci_layout_path),
751+
)
752+
753+
index_path = layout_dir / oras.defaults.oci_image_index_file
754+
if not index_path.exists():
755+
write_json(
756+
{
757+
"schemaVersion": oras.defaults.oci_index_schema_version,
758+
"manifests": [],
759+
},
760+
str(index_path),
761+
)
762+
763+
def fetch(self, desc: Descriptor) -> BinaryIO:
764+
"""Fetch blob content by digest, returning an open file handle."""
765+
digest = desc["digest"]
766+
self._validate_digest(digest)
767+
path = self._layout.digest_to_blob_path(digest)
768+
return open(path, "rb")
769+
770+
def exists(self, desc: Descriptor) -> bool:
771+
"""Check if a blob exists on disk."""
772+
digest = desc["digest"]
773+
self._validate_digest(digest)
774+
path = self._layout.digest_to_blob_path(digest)
775+
return path.exists()
776+
777+
def push(self, desc: Descriptor, content: BinaryIO) -> None:
778+
"""Write blob content to the layout (content-addressed, deduplicated).
779+
780+
Matching oras-go's Store.Push: skips silently if the blob already
781+
exists (same digest), otherwise writes atomically via a temp file +
782+
rename so concurrent writers never observe a partial blob.
783+
"""
784+
digest = desc["digest"]
785+
self._validate_digest(digest)
786+
787+
self._ensure_initialized()
788+
789+
path = self._layout.digest_to_blob_path(digest)
790+
if path.exists():
791+
return # already present — deduplicate silently
792+
793+
path.parent.mkdir(parents=True, exist_ok=True)
794+
795+
tmp_fd, tmp_name = tempfile.mkstemp(dir=str(path.parent))
796+
try:
797+
# Stream rather than buffer: large blobs must not be fully
798+
# materialized in memory.
799+
with os.fdopen(tmp_fd, "wb") as f:
800+
shutil.copyfileobj(content, f)
801+
os.replace(tmp_name, str(path))
802+
except Exception:
803+
try:
804+
os.unlink(tmp_name)
805+
except OSError:
806+
pass
807+
raise
808+
809+
def tag(self, desc: Descriptor, reference: str) -> None:
810+
"""Update index.json to associate the descriptor with a reference tag.
811+
812+
Matching oras-go's Store.Tag: finds an existing index.json entry for
813+
this reference and replaces it, or appends a new one.
814+
Thread-safe via _index_lock.
815+
"""
816+
self._ensure_initialized()
817+
818+
layout_dir = pathlib.Path(self._layout._oci_layout_path)
819+
index_path = layout_dir / oras.defaults.oci_image_index_file
820+
821+
new_entry = {
822+
"mediaType": desc.get("mediaType", ""),
823+
"digest": desc["digest"],
824+
"size": desc.get("size", 0),
825+
"annotations": {oras.defaults.oci_ref_name_annotation: reference},
826+
}
827+
828+
with self._index_lock:
829+
index_data = read_json(str(index_path))
830+
manifests = index_data.setdefault("manifests", [])
831+
832+
for i, entry in enumerate(manifests):
833+
if (
834+
entry.get("annotations", {}).get(
835+
oras.defaults.oci_ref_name_annotation
836+
)
837+
== reference
838+
):
839+
manifests[i] = new_entry
840+
break
841+
else:
842+
manifests.append(new_entry)
843+
844+
write_json(index_data, str(index_path))
845+
846+
def resolve(self, reference: str) -> Descriptor:
847+
"""Resolve a reference tag to a descriptor via index.json."""
848+
layout_dir = pathlib.Path(self._layout._oci_layout_path)
849+
index_path = layout_dir / oras.defaults.oci_image_index_file
850+
index_data = read_json(str(index_path))
851+
for entry in index_data.get("manifests", []):
852+
annotations = entry.get("annotations", {})
853+
if (
854+
annotations.get(oras.defaults.oci_ref_name_annotation)
855+
== reference
856+
):
857+
return {
858+
"mediaType": entry.get("mediaType", ""),
859+
"digest": entry.get("digest", ""),
860+
"size": entry.get("size", 0),
861+
}
862+
raise FileNotFoundError(
863+
f"Reference not found in layout index: {reference}"
864+
)

0 commit comments

Comments
 (0)