From ccc28b31c906302e933ed96eb1ec5c7693bc3750 Mon Sep 17 00:00:00 2001 From: Tomas Coufal Date: Thu, 2 May 2024 13:57:00 +0200 Subject: [PATCH 1/2] feat: simplify how subjects are created from existing manifest Signed-off-by: Tomas Coufal --- docs/getting_started/user-guide.md | 22 ++++++++++++++++++++++ oras/provider.py | 20 ++++++++++++++++++++ oras/tests/test_provider.py | 17 +++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/getting_started/user-guide.md b/docs/getting_started/user-guide.md index cf52fed..3c5d50a 100644 --- a/docs/getting_started/user-guide.md +++ b/docs/getting_started/user-guide.md @@ -489,6 +489,28 @@ def push(uri, root): +
+ +Example of basic artifact attachment + +We are assuming an `derived-artifact.txt` in the present working directory and that there's already a `localhost:5000/dinosaur/artifact:v1` artifact present in the registry. Here is an example of how to [attach](https://oras.land/docs/concepts/reftypes/) a derived artifact to the existing artifact. + +```python +import oras.client +import oras.provider + +client = oras.client.OrasClient(insecure=True) + +manifest = client.remote.get_manifest(f"localhost:5000/dinosaur/artifact:v1") +subject = oras.provider.Subject.from_manifest(manifest) + +client.push(files=["derived-artifact.txt"], target="localhost:5000/dinosaur/artifact:v1-derived", subject=subject) +Successfully pushed localhost:5000/dinosaur/artifact:v1 +Out[4]: +``` + +
+ The above examples are just a start! See our [examples](https://github.com/oras-project/oras-py/tree/main/examples) folder alongside the repository for more code examples and clients. If you would like help for an example, or to contribute an example, [you know what to do](https://github.com/oras-project/oras-py/issues)! diff --git a/oras/provider.py b/oras/provider.py index 17e93f9..5f97dca 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -3,6 +3,8 @@ __license__ = "Apache-2.0" import copy +import hashlib +import json import os import urllib from contextlib import contextmanager, nullcontext @@ -17,6 +19,7 @@ import oras.auth import oras.container import oras.decorator as decorator +import oras.defaults import oras.oci import oras.schemas import oras.utils @@ -41,6 +44,23 @@ class Subject: digest: str size: int + @classmethod + def from_manifest(cls, manifest: dict) -> "Subject": + """ + Create a new Subject from a Manifest + + :param manifest: manifest to convert to subject + """ + manifest_string = json.dumps(manifest).encode("utf-8") + digest = "sha256:" + hashlib.sha256(manifest_string).hexdigest() + size = len(manifest_string) + + return cls( + manifest["mediaType"] or oras.defaults.default_manifest_media_type, + digest, + size, + ) + class Registry: """ diff --git a/oras/tests/test_provider.py b/oras/tests/test_provider.py index d3b014b..9e58b8d 100644 --- a/oras/tests/test_provider.py +++ b/oras/tests/test_provider.py @@ -9,6 +9,7 @@ import oras.client import oras.defaults +import oras.oci import oras.provider import oras.utils @@ -132,3 +133,19 @@ def test_sanitize_path(): str(e.value) == f"Filename {Path(os.path.join(os.getcwd(), '..', '..')).resolve()} is not in {Path('../').resolve()} directory" ) + + +@pytest.mark.with_auth(False) +def test_create_subject_from_manifest(): + """ + Basic tests for oras Subject creation from empty manifest + """ + manifest = oras.oci.NewManifest() + subject = oras.provider.Subject.from_manifest(manifest) + + assert subject.mediaType == oras.defaults.default_manifest_media_type + assert ( + subject.digest + == "sha256:7a6f84d8c73a71bf9417c13f721ed102f74afac9e481f89e5a72d28954e7d0c5" + ) + assert subject.size == 126 From 01732df0b63438975d0eb7857dae28fc0a6e6ea1 Mon Sep 17 00:00:00 2001 From: Tomas Coufal Date: Thu, 2 May 2024 14:53:58 +0200 Subject: [PATCH 2/2] feat: pull recursively to include subject references Signed-off-by: Tomas Coufal --- oras/provider.py | 14 ++++++++++++- oras/tests/conftest.py | 5 +++++ oras/tests/derived-artifact.txt | 1 + oras/tests/test_oras.py | 37 ++++++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 oras/tests/derived-artifact.txt diff --git a/oras/provider.py b/oras/provider.py index 5f97dca..726e9d1 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -862,11 +862,13 @@ def pull(self, *args, **kwargs) -> List[str]: refresh_headers = kwargs.get("refresh_headers") if refresh_headers is None: refresh_headers = True - container = self.get_container(kwargs["target"]) + target: str = kwargs["target"] + container = self.get_container(target) self.load_configs(container, configs=kwargs.get("config_path")) manifest = self.get_manifest(container, allowed_media_type, refresh_headers) outdir = kwargs.get("outdir") or oras.utils.get_tmpdir() overwrite = kwargs.get("overwrite", True) + include_subject = kwargs.get("include_subject", False) files = [] for layer in manifest.get("layers", []): @@ -900,6 +902,16 @@ def pull(self, *args, **kwargs) -> List[str]: self.download_blob(container, layer["digest"], outfile) logger.info(f"Successfully pulled {outfile}.") files.append(outfile) + + if include_subject and manifest.get('subject', False): + separator = "@" if "@" in target else ":" + repo, _tag = target.rsplit(separator, 1) + subject_digest = manifest['subject']['digest'] + new_kwargs = kwargs + new_kwargs['target'] = f'{repo}@{subject_digest}' + + files += self.pull(*args, **kwargs) + return files @decorator.ensure_container diff --git a/oras/tests/conftest.py b/oras/tests/conftest.py index 7b038dc..351f937 100644 --- a/oras/tests/conftest.py +++ b/oras/tests/conftest.py @@ -53,6 +53,11 @@ def target(registry): return f"{registry}/dinosaur/artifact:v1" +@pytest.fixture +def derived_target(registry): + return f"{registry}/dinosaur/artifact:v1-derived" + + @pytest.fixture def target_dir(registry): return f"{registry}/dinosaur/directory:v1" diff --git a/oras/tests/derived-artifact.txt b/oras/tests/derived-artifact.txt new file mode 100644 index 0000000..7876f11 --- /dev/null +++ b/oras/tests/derived-artifact.txt @@ -0,0 +1 @@ +referred artifact is greeting extinct creatures diff --git a/oras/tests/test_oras.py b/oras/tests/test_oras.py index 06df44f..fdd23e5 100644 --- a/oras/tests/test_oras.py +++ b/oras/tests/test_oras.py @@ -8,6 +8,7 @@ import pytest import oras.client +import oras.provider here = os.path.abspath(os.path.dirname(__file__)) @@ -67,6 +68,40 @@ def test_basic_push_pull(tmp_path, registry, credentials, target): assert res.status_code == 201 + +@pytest.mark.with_auth(False) +def test_push_pull_attached_artifacts(tmp_path, registry, credentials, target, derived_target): + """ + Basic tests for oras (without authentication) + """ + client = oras.client.OrasClient(hostname=registry, insecure=True) + + artifact = os.path.join(here, "artifact.txt") + assert os.path.exists(artifact) + + res = client.push(files=[artifact], target=target) + assert res.status_code in [200, 201] + + derived_artifact = os.path.join(here, "derived-artifact.txt") + assert os.path.exists(derived_artifact) + + manifest = client.remote.get_manifest(target) + subject = oras.provider.Subject.from_manifest(manifest) + res = client.push(files=[derived_artifact], target=derived_target, subject=subject) + assert res.status_code in [200, 201] + + # Test pulling elsewhere + files = sorted(client.pull(target=derived_target, outdir=tmp_path, include_subject=True)) + assert len(files) == 2 + assert os.path.basename(files[0]) == "artifact.txt" + assert os.path.basename(files[1]) == "derived-artifact.txt" + assert str(tmp_path) in files[0] + assert str(tmp_path) in files[1] + assert os.path.exists(files[0]) + assert os.path.exists(files[1]) + + + @pytest.mark.with_auth(False) def test_get_delete_tags(tmp_path, registry, credentials, target): """ @@ -87,7 +122,7 @@ def test_get_delete_tags(tmp_path, registry, credentials, target): assert not client.delete_tags(target, "v1-boop-boop") assert "v1" in client.delete_tags(target, "v1") tags = client.get_tags(target) - assert not tags + assert "v1" not in tags def test_get_many_tags():