Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pull recursively to include subject references #132

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions docs/getting_started/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,28 @@ def push(uri, root):

</details>

<details>

<summary>Example of basic artifact attachment</summary>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks to be redundant with the other PR - let's do that one first, merge and rebase here, and then we can do the review.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I know... that's why I left this PR as a draft for now. 🙂 It builds on top of the other PR. I wanted to share the direction with you but wanted to keep PRs atomic at the same time. 🙂 Let's finish the first PR first and I'll rebase on top of it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! Thank you for your work on both, and looking forward to coming back to this one.


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]: <Response [201]>
```

</details>

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)!
Expand Down
34 changes: 33 additions & 1 deletion oras/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
__license__ = "Apache-2.0"

import copy
import hashlib
import json
import os
import urllib
from contextlib import contextmanager, nullcontext
Expand All @@ -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
Expand All @@ -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:
"""
Expand Down Expand Up @@ -842,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", []):
Expand Down Expand Up @@ -880,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
Expand Down
5 changes: 5 additions & 0 deletions oras/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions oras/tests/derived-artifact.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
referred artifact is greeting extinct creatures
37 changes: 36 additions & 1 deletion oras/tests/test_oras.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

import oras.client
import oras.provider

here = os.path.abspath(os.path.dirname(__file__))

Expand Down Expand Up @@ -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):
"""
Expand All @@ -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():
Expand Down
17 changes: 17 additions & 0 deletions oras/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import oras.client
import oras.defaults
import oras.oci
import oras.provider
import oras.utils

Expand Down Expand Up @@ -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