From 07877c6c0a5d3d646ea9b0994fb6d8dda6b50603 Mon Sep 17 00:00:00 2001 From: my5cents Date: Wed, 3 Apr 2024 18:45:47 +0300 Subject: [PATCH 1/8] Update headers in do_request method, provider.py During the push operation POST and PUT methods loose provided auth headers. It is crucial for remote registry, since further re-auth logic tries to reach registry locally. do_request method is updated to include always 'Authorization' header if it is provided. Signed-off-by: my5cents --- oras/provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oras/provider.py b/oras/provider.py index 0abd2d5..3e63a2a 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -893,6 +893,7 @@ def do_request( :type stream: bool """ headers = headers or {} + headers.update(self.headers) # Make the request and return to calling function, unless requires auth response = self.session.request( From 85db3d44c63af87532567b43933f2524036b5f26 Mon Sep 17 00:00:00 2001 From: my5cents Date: Wed, 3 Apr 2024 23:57:12 +0300 Subject: [PATCH 2/8] Added refresh_headers flag to the push flow Added refresh_headers flag to allow user to keep basic auth in headers during the push flow Signed-off-by: my5cents --- oras/provider.py | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/oras/provider.py b/oras/provider.py index 3e63a2a..7d906cf 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -226,6 +226,7 @@ def upload_blob( container: container_type, layer: dict, do_chunked: bool = False, + refresh_headers: bool = True ) -> requests.Response: """ Prepare and upload a blob. @@ -239,6 +240,8 @@ def upload_blob( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool """ blob = os.path.abspath(blob) container = self.get_container(container) @@ -250,9 +253,9 @@ def upload_blob( # This is currently disabled unless the user asks for it, as # it doesn't seem to work for all registries if not do_chunked: - response = self.put_upload(blob, container, layer) + response = self.put_upload(blob, container, layer, refresh_headers=refresh_headers) else: - response = self.chunked_upload(blob, container, layer) + response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) # If we have an empty layer digest and the registry didn't accept, just return dummy successful response if ( @@ -474,7 +477,7 @@ def download_blob( return outfile def put_upload( - self, blob: str, container: oras.container.Container, layer: dict + self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True ) -> requests.Response: """ Upload to a registry via put. @@ -485,9 +488,15 @@ def put_upload( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool """ # Start an upload session headers = {"Content-Type": "application/octet-stream"} + + if not refresh_headers: + headers.update(self.headers) + upload_url = f"{self.prefix}://{container.upload_blob_url()}" r = self.do_request(upload_url, "POST", headers=headers) @@ -541,7 +550,7 @@ def _get_location( return session_url def chunked_upload( - self, blob: str, container: oras.container.Container, layer: dict + self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True ) -> requests.Response: """ Upload via a chunked upload. @@ -552,9 +561,14 @@ def chunked_upload( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool """ # Start an upload session headers = {"Content-Type": "application/octet-stream", "Content-Length": "0"} + if not refresh_headers: + headers.update(self.headers) + upload_url = f"{self.prefix}://{container.upload_blob_url()}" r = self.do_request(upload_url, "POST", headers=headers) @@ -618,7 +632,7 @@ def _parse_response_errors(self, response: requests.Response): pass def upload_manifest( - self, manifest: dict, container: oras.container.Container + self, manifest: dict, container: oras.container.Container, refresh_headers: bool =True ) -> requests.Response: """ Read a manifest file and upload it. @@ -627,6 +641,8 @@ def upload_manifest( :type manifest: dict :param container: parsed container URI :type container: oras.container.Container or str + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool """ self.reset_basic_auth() jsonschema.validate(manifest, schema=oras.schemas.manifest) @@ -634,6 +650,10 @@ def upload_manifest( "Content-Type": oras.defaults.default_manifest_media_type, "Content-Length": str(len(manifest)), } + + if not refresh_headers: + headers.update(self.headers) + return self.do_request( f"{self.prefix}://{container.manifest_url()}", # noqa "PUT", @@ -659,6 +679,8 @@ def push(self, *args, **kwargs) -> requests.Response: :type manifest_annotations: dict :param target: target location to push to :type target: str + :param refresh_headers: if true or None, headers are refreshed + :type refresh_headers: bool :param subject: optional subject reference :type subject: Subject """ @@ -675,6 +697,10 @@ def push(self, *args, **kwargs) -> requests.Response: annotset = oras.oci.Annotations(kwargs.get("annotation_file")) media_type = None + refresh_headers = kwargs.get("refresh_headers") + if refresh_headers is None: + refresh_headers = True + # Upload files as blobs for blob in kwargs.get("files", []): # You can provide a blob + content type @@ -720,7 +746,7 @@ def push(self, *args, **kwargs) -> requests.Response: logger.debug(f"Preparing layer {layer}") # Upload the blob layer - response = self.upload_blob(blob, container, layer) + response = self.upload_blob(blob, container, layer, refresh_headers=refresh_headers) self._check_200_response(response) # Do we need to cleanup a temporary targz? @@ -762,13 +788,13 @@ def push(self, *args, **kwargs) -> requests.Response: if config_file is None else nullcontext(config_file) ) as config_file: - response = self.upload_blob(config_file, container, conf) + response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) self._check_200_response(response) # Final upload of the manifest manifest["config"] = conf - self._check_200_response(self.upload_manifest(manifest, container)) + self._check_200_response(self.upload_manifest(manifest, container, refresh_headers=refresh_headers)) print(f"Successfully pushed {container}") return response @@ -893,7 +919,6 @@ def do_request( :type stream: bool """ headers = headers or {} - headers.update(self.headers) # Make the request and return to calling function, unless requires auth response = self.session.request( From 01f5e69667d8f823acdbe33cd29d5f11e60eda04 Mon Sep 17 00:00:00 2001 From: my5cents Date: Thu, 4 Apr 2024 13:36:49 +0300 Subject: [PATCH 3/8] fixed pep8 error Signed-off-by: my5cents --- oras/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oras/provider.py b/oras/provider.py index 7d906cf..1416fd7 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -632,7 +632,7 @@ def _parse_response_errors(self, response: requests.Response): pass def upload_manifest( - self, manifest: dict, container: oras.container.Container, refresh_headers: bool =True + self, manifest: dict, container: oras.container.Container, refresh_headers: bool = True ) -> requests.Response: """ Read a manifest file and upload it. From deee70f21ffde7189cfb4b09fe5274cd14cbb130 Mon Sep 17 00:00:00 2001 From: my5cents Date: Thu, 4 Apr 2024 13:39:40 +0300 Subject: [PATCH 4/8] Bumped version in version.py Signed-off-by: my5cents --- oras/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oras/version.py b/oras/version.py index 40aaf2a..5fa3cc4 100644 --- a/oras/version.py +++ b/oras/version.py @@ -2,7 +2,7 @@ __copyright__ = "Copyright The ORAS Authors." __license__ = "Apache-2.0" -__version__ = "0.1.28" +__version__ = "0.1.29" AUTHOR = "Vanessa Sochat" EMAIL = "vsoch@users.noreply.github.com" NAME = "oras" From 7372a1a6d7e3ec2201db8e0d14e4f5f1f452d6fd Mon Sep 17 00:00:00 2001 From: my5cents Date: Thu, 4 Apr 2024 13:46:34 +0300 Subject: [PATCH 5/8] Update CHANGELOG.md Signed-off-by: my5cents --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d90e12a..28936e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are: The versions coincide with releases on pip. Only major versions will be released as tags on Github. ## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x) + - add option to not refresh headers during the pushing flow, useful for push with basic auth (0.1.29) - enable additionalProperties in schema validation (0.1.28) - Introduce the option to not refresh headers when fetching manifests when pulling artifacts (0.1.27) - To make it available for more OCI registries, the value of config used when `manifest_config` is not specified in `client.push()` has been changed from a pure empty string to `{}` (0.1.26) From 598f6b0e379e0e70dc73f23641f4c67ecebea51d Mon Sep 17 00:00:00 2001 From: Yuriy Novostavskiy Date: Fri, 5 Apr 2024 16:28:47 +0000 Subject: [PATCH 6/8] 11reformat with black Signed-off-by: Yuriy Novostavskiy --- build/lib/oras/__init__.py | 1 + build/lib/oras/auth.py | 83 ++ build/lib/oras/client.py | 246 ++++++ build/lib/oras/container.py | 124 +++ build/lib/oras/decorator.py | 83 ++ build/lib/oras/defaults.py | 48 ++ build/lib/oras/logger.py | 306 +++++++ build/lib/oras/main/__init__.py | 0 build/lib/oras/main/login.py | 52 ++ build/lib/oras/oci.py | 153 ++++ build/lib/oras/provider.py | 1074 +++++++++++++++++++++++++ build/lib/oras/schemas.py | 58 ++ build/lib/oras/tests/__init__.py | 0 build/lib/oras/tests/annotations.json | 6 + build/lib/oras/tests/conftest.py | 58 ++ build/lib/oras/tests/run_registry.sh | 4 + build/lib/oras/tests/test_oras.py | 138 ++++ build/lib/oras/tests/test_provider.py | 134 +++ build/lib/oras/tests/test_utils.py | 132 +++ build/lib/oras/utils/__init__.py | 27 + build/lib/oras/utils/fileio.py | 374 +++++++++ build/lib/oras/utils/request.py | 63 ++ build/lib/oras/version.py | 26 + dist/oras-0.1.29-py3.10.egg | Bin 0 -> 80927 bytes oras/provider.py | 102 +-- 25 files changed, 3214 insertions(+), 78 deletions(-) create mode 100644 build/lib/oras/__init__.py create mode 100644 build/lib/oras/auth.py create mode 100644 build/lib/oras/client.py create mode 100644 build/lib/oras/container.py create mode 100644 build/lib/oras/decorator.py create mode 100644 build/lib/oras/defaults.py create mode 100644 build/lib/oras/logger.py create mode 100644 build/lib/oras/main/__init__.py create mode 100644 build/lib/oras/main/login.py create mode 100644 build/lib/oras/oci.py create mode 100644 build/lib/oras/provider.py create mode 100644 build/lib/oras/schemas.py create mode 100644 build/lib/oras/tests/__init__.py create mode 100644 build/lib/oras/tests/annotations.json create mode 100644 build/lib/oras/tests/conftest.py create mode 100755 build/lib/oras/tests/run_registry.sh create mode 100644 build/lib/oras/tests/test_oras.py create mode 100644 build/lib/oras/tests/test_provider.py create mode 100644 build/lib/oras/tests/test_utils.py create mode 100644 build/lib/oras/utils/__init__.py create mode 100644 build/lib/oras/utils/fileio.py create mode 100644 build/lib/oras/utils/request.py create mode 100644 build/lib/oras/version.py create mode 100644 dist/oras-0.1.29-py3.10.egg diff --git a/build/lib/oras/__init__.py b/build/lib/oras/__init__.py new file mode 100644 index 0000000..3003622 --- /dev/null +++ b/build/lib/oras/__init__.py @@ -0,0 +1 @@ +from oras.version import __version__ diff --git a/build/lib/oras/auth.py b/build/lib/oras/auth.py new file mode 100644 index 0000000..6e3ba5d --- /dev/null +++ b/build/lib/oras/auth.py @@ -0,0 +1,83 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import base64 +import os +import re +from typing import List, Optional + +import oras.utils +from oras.logger import logger + + +def load_configs(configs: Optional[List[str]] = None): + """ + Load one or more configs with credentials from the filesystem. + + :param configs: list of configuration paths to load, defaults to None + :type configs: optional list + """ + configs = configs or [] + default_config = oras.utils.find_docker_config() + + # Add the default docker config + if default_config: + configs.append(default_config) + configs = set(configs) # type: ignore + + # Load configs until we find our registry hostname + auths = {} + for config in configs: + if not os.path.exists(config): + logger.warning(f"{config} does not exist.") + continue + cfg = oras.utils.read_json(config) + auths.update(cfg.get("auths", {})) + return auths + + +def get_basic_auth(username: str, password: str): + """ + Prepare basic auth from a username and password. + + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + """ + auth_str = "%s:%s" % (username, password) + return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") + + +class authHeader: + def __init__(self, lookup: dict): + """ + Given a dictionary of values, match them to class attributes + + :param lookup : dictionary of key,value pairs to parse into auth header + :type lookup: dict + """ + self.service: Optional[str] = None + self.realm: Optional[str] = None + self.scope: Optional[str] = None + for key in lookup: + if key in ["realm", "service", "scope"]: + setattr(self, key, lookup[key]) + + +def parse_auth_header(authHeaderRaw: str) -> authHeader: + """ + Parse authentication header into pieces + + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + """ + regex = re.compile('([a-zA-z]+)="(.+?)"') + matches = regex.findall(authHeaderRaw) + lookup = dict() + for match in matches: + lookup[match[0]] = match[1] + return authHeader(lookup) diff --git a/build/lib/oras/client.py b/build/lib/oras/client.py new file mode 100644 index 0000000..b788ef2 --- /dev/null +++ b/build/lib/oras/client.py @@ -0,0 +1,246 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + + +import sys +from typing import List, Optional, Union + +import oras.auth +import oras.container +import oras.main.login as login +import oras.provider +import oras.utils +import oras.version + + +class OrasClient: + """ + Create an OCI Registry as Storage (ORAS) Client. + + This is intended for controlled interactions. The user of oras-py can use + this client, the terminal command line wrappers, or the functions in main + in isolation as an internal Python API. The user can provide a custom + registry as a parameter, if desired. If not provided we default to standard + oras. + """ + + def __init__( + self, + hostname: Optional[str] = None, + registry: Optional[oras.provider.Registry] = None, + insecure: bool = False, + tls_verify: bool = True, + ): + """ + Create an ORAS client. + + The hostname is the remote registry to ping. + + :param hostname: the hostname of the registry to ping + :type hostname: str + :param registry: if provided, use this custom provider instead of default + :type registry: oras.provider.Registry or None + :param insecure: use http instead of https + :type insecure: bool + """ + self.remote = registry or oras.provider.Registry(hostname, insecure, tls_verify) + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + return "[oras-client]" + + def set_token_auth(self, token: str): + """ + Set token authentication. + + :param token: the bearer token + :type token: str + """ + self.remote.set_token_auth(token) + + def set_basic_auth(self, username: str, password: str): + """ + Add basic authentication to the request. + + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + """ + self.remote.set_basic_auth(username, password) + + def version(self, return_items: bool = False) -> Union[dict, str]: + """ + Get the version of the client. + + :param return_items : return the dict of version info instead of string + :type return_items: bool + """ + version = oras.version.__version__ + + python_version = "%s.%s.%s" % ( + sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro, + ) + versions = {"Version": version, "Python version": python_version} + + # If the user wants the dictionary of items returned + if return_items: + return versions + + # Otherwise return a string that can be printed + return "\n".join(["%s: %s" % (k, v) for k, v in versions.items()]) + + def get_tags(self, name: str, N=None) -> List[str]: + """ + Retrieve tags for a package. + + :param name: container URI to parse + :type name: str + :param N: number of tags (None to get all tags) + :type N: int + """ + return self.remote.get_tags(name, N=N) + + def delete_tags(self, name: str, tags=Union[str, list]) -> List[str]: + """ + Delete one or more tags for a unique resource identifier. + + Returns those successfully deleted. + + :param name: container URI to parse + :type name: str + :param tags: single or multiple tags name to delete + :type N: string or list + """ + if isinstance(tags, str): + tags = [tags] + deleted = [] + for tag in tags: + if self.remote.delete_tag(name, tag): + deleted.append(tag) + return deleted + + def push(self, *args, **kwargs): + """ + Push a container to the remote. + """ + return self.remote.push(*args, **kwargs) + + def pull(self, *args, **kwargs): + """ + Pull a container from the remote. + """ + return self.remote.pull(*args, **kwargs) + + def login( + self, + username: str, + password: str, + password_stdin: bool = False, + insecure: bool = False, + tls_verify: bool = True, + hostname: Optional[str] = None, + config_path: Optional[List[str]] = None, + ) -> dict: + """ + Login to a registry. + + :param registry: if provided, use this custom provider instead of default + :type registry: oras.provider.Registry or None + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + :param password_stdin: get the password from standard input + :type password_stdin: bool + :param insecure: use http instead of https + :type insecure: bool + :param tls_verify: verify tls + :type tls_verify: bool + :param hostname: the hostname to login to + :type hostname: str + :param config_path: list of config paths to add + :type config_path: list + """ + login_func = self._login + if hasattr(self.remote, "login"): + login_func = self.remote.login # type: ignore + return login_func( + username=username, + password=password, + password_stdin=password_stdin, + tls_verify=tls_verify, + hostname=hostname, + config_path=config_path, # type: ignore + ) + + def logout(self, hostname: str): + """ + Logout from a registry, meaning removing any auth (if loaded) + + :param hostname: the hostname to login to + :type hostname: str + """ + self.remote.logout(hostname) + + def _login( + self, + username: Optional[str] = None, + password: Optional[str] = None, + password_stdin: bool = False, + tls_verify: bool = True, + hostname: Optional[str] = None, + config_path: Optional[str] = None, + ) -> dict: + """ + Login to an OCI registry. + + The username and password can come from stdin. Most people use username + password to get a token, so we are disabling providing just a token for + now. A tool that wants to provide a token should use set_token_auth. + """ + # Read password from stdin + if password_stdin: + password = oras.utils.readline() + + # No username, try to get from stdin + if not username: + username = input("Username: ") + + # No password provided + if not password: + password = input("Password: ") + if not password: + raise ValueError("password required") + + # Cut out early if we didn't get what we need + if not password or not username: + return {"Login": "Not successful"} + + # Set basic auth for the client + self.set_basic_auth(username, password) + + # Login + # https://docker-py.readthedocs.io/en/stable/client.html?highlight=login#docker.client.DockerClient.login + try: + client = oras.utils.get_docker_client(tls_verify=tls_verify) + return client.login( + username=username, + password=password, + registry=hostname, + dockercfg_path=config_path, + ) + + # Fallback to manual login + except Exception: + return login.DockerClient().login( + username=username, # type: ignore + password=password, # type: ignore + registry=hostname, # type: ignore + dockercfg_path=config_path, + ) diff --git a/build/lib/oras/container.py b/build/lib/oras/container.py new file mode 100644 index 0000000..81f117d --- /dev/null +++ b/build/lib/oras/container.py @@ -0,0 +1,124 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + + +import re +from typing import Optional + +import oras.defaults + +docker_regex = re.compile( + "(?:(?P[^/@]+[.:][^/@]*)/)?" + "(?P(?:[^:@/]+/)+)?" + "(?P[^:@/]+)" + "(?::(?P[^:@]+))?" + "(?:@(?P.+))?" + "$" +) + + +class Container: + def __init__(self, name: str, registry: Optional[str] = None): + """ + Parse a container name and easily get urls for registry interactions. + + :param name: the full name of the container to parse (with any components) + :type name: str + :param registry: a custom registry name, if not provided with URI + :type registry: str + """ + self.registry = registry or oras.defaults.registry.index_name + + # Registry is the name takes precendence + self.parse(name) + + @property + def api_prefix(self): + """ + Return the repository prefix for the v2 API endpoints. + """ + if self.namespace: + return f"{self.namespace}/{self.repository}" + return self.repository + + def get_blob_url(self, digest: str) -> str: + """ + Get the URL to download a blob + + :param digest: the digest to download + :type digest: str + """ + return f"{self.registry}/v2/{self.api_prefix}/blobs/{digest}" + + def upload_blob_url(self) -> str: + return f"{self.registry}/v2/{self.api_prefix}/blobs/uploads/" + + def tags_url(self, N=None) -> str: + if N is None: + return f"{self.registry}/v2/{self.api_prefix}/tags/list" + return f"{self.registry}/v2/{self.api_prefix}/tags/list?n={N}" + + def manifest_url(self, tag: Optional[str] = None) -> str: + """ + Get the manifest url for a specific tag, or the one for this container. + + The tag provided can also correspond to a digest. + + :param tag: an optional tag to provide (if not provided defaults to container) + :type tag: None or str + """ + + # an explicitly defined tag has precedence over everything, + # but from the already defined ones, prefer the digest for consistency. + tag = tag or (self.digest or self.tag) + return f"{self.registry}/v2/{self.api_prefix}/manifests/{tag}" + + def __str__(self) -> str: + return self.uri + + @property + def uri(self) -> str: + """ + Assemble the complete unique resource identifier + """ + if self.namespace: + uri = f"{self.namespace}/{self.repository}" + else: + uri = f"{self.repository}" + if self.registry: + uri = f"{self.registry}/{uri}" + + # Digest takes preference because more specific + if self.digest: + uri = f"{uri}@{self.digest}" + elif self.tag: + uri = f"{uri}:{self.tag}" + return uri + + def parse(self, name: str): + """ + Parse the container name into registry, repository, and tag. + + :param name: the full name of the container to parse (with any components) + :type name: str + """ + match = re.search(docker_regex, name) + if not match: + raise ValueError( + f"{name} does not match a recognized registry unique resource identifier. Try //:" + ) + items = match.groupdict() # type: ignore + self.repository = items["repository"] + self.registry = items["registry"] or self.registry + self.namespace = items["namespace"] + self.tag = items["tag"] or oras.defaults.default_tag + self.digest = items["digest"] + + # Repository is required + if not self.repository: + raise ValueError( + "You are minimally required to include a /" + ) + if self.namespace: + self.namespace = self.namespace.strip("/") diff --git a/build/lib/oras/decorator.py b/build/lib/oras/decorator.py new file mode 100644 index 0000000..885ec52 --- /dev/null +++ b/build/lib/oras/decorator.py @@ -0,0 +1,83 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import time +from functools import partial, update_wrapper + +from oras.logger import logger + + +class Decorator: + """ + Shared parent decorator class + """ + + def __init__(self, func): + update_wrapper(self, func) + self.func = func + + def __get__(self, obj, objtype): + return partial(self.__call__, obj) + + +class ensure_container(Decorator): + """ + Ensure the first argument is a container, and not a string. + """ + + def __call__(self, cls, *args, **kwargs): + if "container" in kwargs: + kwargs["container"] = cls.get_container(kwargs["container"]) + elif args: + container = cls.get_container(args[0]) + args = (container, *args[1:]) + return self.func(cls, *args, **kwargs) + + +class classretry(Decorator): + """ + Retry a function that is part of a class + """ + + def __init__(self, func, attempts=5, timeout=2): + super().__init__(func) + self.attempts = attempts + self.timeout = timeout + + def __call__(self, cls, *args, **kwargs): + attempt = 0 + attempts = self.attempts + timeout = self.timeout + while attempt < attempts: + try: + return self.func(cls, *args, **kwargs) + except Exception as e: + sleep = timeout + 3**attempt + logger.info(f"Retrying in {sleep} seconds - error: {e}") + time.sleep(sleep) + attempt += 1 + return self.func(cls, *args, **kwargs) + + +def retry(attempts, timeout=2): + """ + A simple retry decorator + """ + + def decorator(func): + def inner(*args, **kwargs): + attempt = 0 + while attempt < attempts: + try: + return func(*args, **kwargs) + except Exception as e: + sleep = timeout + 3**attempt + logger.info(f"Retrying in {sleep} seconds - error: {e}") + time.sleep(sleep) + attempt += 1 + return func(*args, **kwargs) + + return inner + + return decorator diff --git a/build/lib/oras/defaults.py b/build/lib/oras/defaults.py new file mode 100644 index 0000000..2992290 --- /dev/null +++ b/build/lib/oras/defaults.py @@ -0,0 +1,48 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors" +__license__ = "Apache-2.0" + + +# Default tag to use +default_tag = "latest" + + +# https://github.com/moby/moby/blob/master/registry/config.go#L29 +class registry: + index_hostname = "index.docker.io" + index_server = "https://index.docker.io/v1/" + index_name = "docker.io" + default_v2_registry = {"scheme": "https", "host": "registry-1.docker.io"} + + +# DefaultBlobDirMediaType specifies the default blob directory media type +default_blob_dir_media_type = "application/vnd.oci.image.layer.v1.tar+gzip" + +# MediaTypeImageLayer is the media type used for layers referenced by the manifest. +default_blob_media_type = "application/vnd.oci.image.layer.v1.tar" +unknown_config_media_type = "application/vnd.unknown.config.v1+json" +default_manifest_media_type = "application/vnd.oci.image.manifest.v1+json" + +# AnnotationDigest is the annotation key for the digest of the uncompressed content +annotation_digest = "io.deis.oras.content.digest" + +# AnnotationTitle is the annotation key for the human-readable title of the image. +annotation_title = "org.opencontainers.image.title" + +# AnnotationUnpack is the annotation key for indication of unpacking +annotation_unpack = "io.deis.oras.content.unpack" + +# OCIImageIndexFile is the file name of the index from the OCI Image Layout Specification +# Reference: https://github.com/opencontainers/image-spec/blob/master/image-layout.md#indexjson-file +oci_image_index_file = "index.json" + +# DefaultBlocksize default size of each slice of bytes read in each write through in gunzipand untar. +default_blocksize = 32768 + +# what you get for a blank digest, so we don't need to save and recalculate +blank_hash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + +# what you get for a blank config digest, so we don't need to save and recalculate +blank_config_hash = ( + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" +) diff --git a/build/lib/oras/logger.py b/build/lib/oras/logger.py new file mode 100644 index 0000000..2b3c992 --- /dev/null +++ b/build/lib/oras/logger.py @@ -0,0 +1,306 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import inspect +import logging as _logging +import os +import platform +import sys +import threading +from pathlib import Path +from typing import Optional, Text, TextIO, Union + + +class ColorizingStreamHandler(_logging.StreamHandler): + BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) + RESET_SEQ = "\033[0m" + COLOR_SEQ = "\033[%dm" + BOLD_SEQ = "\033[1m" + + colors = { + "WARNING": YELLOW, + "INFO": GREEN, + "DEBUG": BLUE, + "CRITICAL": RED, + "ERROR": RED, + } + + def __init__( + self, + nocolor: bool = False, + stream: Union[Text, Path, TextIO] = sys.stderr, + use_threads: bool = False, + ): + """ + Create a new ColorizingStreamHandler + + :param nocolor: do not use color + :type nocolor: bool + :param stream: stream list to this output + :type stream: bool + :param use_threads: use threads! lol + :type use_threads: bool + """ + super().__init__(stream=stream) + self._output_lock = threading.Lock() + self.nocolor = nocolor or not self.can_color_tty() + + def can_color_tty(self) -> bool: + """ + Determine if the tty supports color + """ + if "TERM" in os.environ and os.environ["TERM"] == "dumb": + return False + return self.is_tty and not platform.system() == "Windows" + + @property + def is_tty(self) -> bool: + """ + Determine if we have a tty environment + """ + isatty = getattr(self.stream, "isatty", None) + return isatty and isatty() # type: ignore + + def emit(self, record: _logging.LogRecord): + """ + Emit a log record + + :param record: the record to emit + :type record: logging.LogRecord + """ + with self._output_lock: + try: + self.format(record) # add the message to the record + self.stream.write(self.decorate(record)) + self.stream.write(getattr(self, "terminator", "\n")) + self.flush() + except BrokenPipeError as e: + raise e + except (KeyboardInterrupt, SystemExit): + # ignore any exceptions in these cases as any relevant messages have been printed before + pass + except Exception: + self.handleError(record) + + def decorate(self, record) -> str: + """ + Decorate a log record + + :param record: the record to emit + """ + message = record.message + message = [message] + if not self.nocolor and record.levelname in self.colors: + message.insert(0, self.COLOR_SEQ % (30 + self.colors[record.levelname])) + message.append(self.RESET_SEQ) + return "".join(message) + + +class Logger: + def __init__(self): + """ + Create a new logger + """ + self.logger = _logging.getLogger(__name__) + self.log_handler = [self.text_handler] + self.stream_handler = None + self.printshellcmds = False + self.quiet = False + self.logfile = None + self.last_msg_was_job_info = False + self.logfile_handler = None + + def cleanup(self): + """ + Close open files, etc. for the logger + """ + if self.logfile_handler is not None: + self.logger.removeHandler(self.logfile_handler) + self.logfile_handler.close() + self.log_handler = [self.text_handler] + + def handler(self, msg: dict): + """ + Handle a log message. + + :param msg: the message to handle + :type msg: dict + """ + for handler in self.log_handler: + handler(msg) + + def set_stream_handler(self, stream_handler: _logging.Handler): + """ + Set a stream handler. + + :param stream_handler : the stream handler + :type stream_handler: logging.Handler + """ + if self.stream_handler is not None: + self.logger.removeHandler(self.stream_handler) + self.stream_handler = stream_handler + self.logger.addHandler(stream_handler) + + def set_level(self, level: int): + """ + Set the logging level. + + :param level: the logging level to set + :type level: int + """ + self.logger.setLevel(level) + + def location(self, msg: str): + """ + Debug level message with location info. + + :param msg: the logging message + :type msg: dict + """ + callerframerecord = inspect.stack()[1] + frame = callerframerecord[0] + info = inspect.getframeinfo(frame) + self.debug( + "{}: {info.filename}, {info.function}, {info.lineno}".format(msg, info=info) + ) + + def info(self, msg: str): + """ + Info level message + + :param msg: the informational message + :type msg: str + """ + self.handler({"level": "info", "msg": msg}) + + def warning(self, msg: str): + """ + Warning level message + + :param msg: the warning message + :type msg: str + """ + self.handler({"level": "warning", "msg": msg}) + + def debug(self, msg: str): + """ + Debug level message + + :param msg: the debug message + :type msg: str + """ + self.handler({"level": "debug", "msg": msg}) + + def error(self, msg: str): + """ + Error level message + + :param msg: the error message + :type msg: str + """ + self.handler({"level": "error", "msg": msg}) + + def exit(self, msg: str, return_code: int = 1): + """ + Error level message and exit with error code + + :param msg: the exiting (error) message + :type msg: str + :param return_code: return code to exit on + :type return_code: int + """ + self.handler({"level": "error", "msg": msg}) + sys.exit(return_code) + + def progress(self, done: int, total: int): + """ + Show piece of a progress bar + + :param done: count of total that is complete + :type done: int + :param total: count of total + :type total: int + """ + self.handler({"level": "progress", "done": done, "total": total}) + + def shellcmd(self, msg: Optional[str]): + """ + Shellcmd message + + :param msg: the message + :type msg: str + """ + if msg is not None: + self.handler({"level": "shellcmd", "msg": msg}) + + def text_handler(self, msg: dict): + """ + The default log handler that prints to the console. + + :param msg: the log message dict + :type msg: dict + """ + level = msg["level"] + if level == "info" and not self.quiet: + self.logger.info(msg["msg"]) + if level == "warning": + self.logger.warning(msg["msg"]) + elif level == "error": + self.logger.error(msg["msg"]) + elif level == "debug": + self.logger.debug(msg["msg"]) + elif level == "progress" and not self.quiet: + done = msg["done"] + total = msg["total"] + p = done / total + percent_fmt = ("{:.2%}" if p < 0.01 else "{:.0%}").format(p) + self.logger.info( + "{} of {} steps ({}) done".format(done, total, percent_fmt) + ) + elif level == "shellcmd": + if self.printshellcmds: + self.logger.warning(msg["msg"]) + + +logger = Logger() + + +def setup_logger( + quiet: bool = False, + printshellcmds: bool = False, + nocolor: bool = False, + stdout: bool = False, + debug: bool = False, + use_threads: bool = False, +): + """ + Setup the logger. This should be called from an init or client. + + :param quiet: set logging level to quiet + :type quiet: bool + :param printshellcmds: a special level to print shell commands + :type printshellcmds: bool + :param nocolor: do not use color + :type nocolor: bool + :param stdout: print to standard output for the logger + :type stdout: bool + :param debug: debug level logging + :type debug: bool + :param use_threads: use threads! + :type use_threads: bool + """ + # console output only if no custom logger was specified + stream_handler = ColorizingStreamHandler( + nocolor=nocolor, + stream=sys.stdout if stdout else sys.stderr, + use_threads=use_threads, + ) + level = _logging.INFO + if debug: + level = _logging.DEBUG + + logger.set_stream_handler(stream_handler) + logger.set_level(level) + logger.quiet = quiet + logger.printshellcmds = printshellcmds diff --git a/build/lib/oras/main/__init__.py b/build/lib/oras/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/oras/main/login.py b/build/lib/oras/main/login.py new file mode 100644 index 0000000..9b5e493 --- /dev/null +++ b/build/lib/oras/main/login.py @@ -0,0 +1,52 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import os +from typing import Optional + +import oras.auth +import oras.utils + + +class DockerClient: + """ + If running inside a container (or similar without docker) do a manual login + """ + + def login( + self, + username: str, + password: str, + registry: str, + dockercfg_path: Optional[str] = None, + ) -> dict: + """ + Manual login means loading and checking the config file + + :param registry: if provided, use this custom provider instead of default + :type registry: oras.provider.Registry or None + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + :param dockercfg_str: docker config path + :type dockercfg_str: list + """ + if not dockercfg_path: + dockercfg_path = oras.utils.find_docker_config(exists=False) + if os.path.exists(dockercfg_path): # type: ignore + cfg = oras.utils.read_json(dockercfg_path) # type: ignore + else: + oras.utils.mkdir_p(os.path.dirname(dockercfg_path)) # type: ignore + cfg = {"auths": {}} + if registry in cfg["auths"]: + cfg["auths"][registry]["auth"] = oras.auth.get_basic_auth( + username, password + ) + else: + cfg["auths"][registry] = { + "auth": oras.auth.get_basic_auth(username, password) + } + oras.utils.write_json(cfg, dockercfg_path) # type: ignore + return {"Status": "Login Succeeded"} diff --git a/build/lib/oras/oci.py b/build/lib/oras/oci.py new file mode 100644 index 0000000..4265f7d --- /dev/null +++ b/build/lib/oras/oci.py @@ -0,0 +1,153 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import copy +import os +from typing import Dict, Optional, Tuple + +import jsonschema + +import oras.defaults +import oras.schemas +import oras.utils + +EmptyManifest = { + "schemaVersion": 2, + "mediaType": oras.defaults.default_manifest_media_type, + "config": {}, + "layers": [], + "annotations": {}, +} + + +class Annotations: + """ + Create a new set of annotations + """ + + def __init__(self, filename=None): + self.lookup = {} + self.load(filename) + + def add(self, section, key, value): + """ + Add key/value pairs to a named section. + """ + if section not in self.lookup: + self.lookup[section] = {} + self.lookup[section][key] = value + + def load(self, filename: str): + if filename and os.path.exists(filename): + self.lookup = oras.utils.read_json(filename) + if filename and not os.path.exists(filename): + raise FileNotFoundError(f"Annotation file {filename} does not exist.") + + def get_annotations(self, section: str) -> dict: + """ + Given the name (a relative path or named section) get annotations + """ + for name in section, os.path.abspath(section): + if name in self.lookup: + return self.lookup[name] + return {} + + +class Layer: + def __init__( + self, blob_path: str, media_type: Optional[str] = None, is_dir: bool = False + ): + """ + Create a new Layer + + :param blob_path: the path of the blob for the layer + :type blob_path: str + :param media_type: media type for the blob (optional) + :type media_type: str + :param is_dir: is the blob a directory? + :type is_dir: bool + """ + self.blob_path = blob_path + self.set_media_type(media_type, is_dir) + + def set_media_type(self, media_type: Optional[str] = None, is_dir: bool = False): + """ + Vary the media type to be directory or default layer + + :param media_type: media type for the blob (optional) + :type media_type: str + :param is_dir: is the blob a directory? + :type is_dir: bool + """ + self.media_type = media_type + if is_dir and not media_type: + self.media_type = oras.defaults.default_blob_dir_media_type + elif not is_dir and not media_type: + self.media_type = oras.defaults.default_blob_media_type + + def to_dict(self): + """ + Return a dictionary representation of the layer + """ + layer = { + "mediaType": self.media_type, + "size": oras.utils.get_size(self.blob_path), + "digest": "sha256:" + oras.utils.get_file_hash(self.blob_path), + } + jsonschema.validate(layer, schema=oras.schemas.layer) + return layer + + +def NewLayer( + blob_path: str, media_type: Optional[str] = None, is_dir: bool = False +) -> dict: + """ + Courtesy function to create and retrieve a layer as dict + + :param blob_path: the path of the blob for the layer + :type blob_path: str + :param media_type: media type for the blob (optional) + :type media_type: str + :param is_dir: is the blob a directory? + :type is_dir: bool + """ + return Layer(blob_path=blob_path, media_type=media_type, is_dir=is_dir).to_dict() + + +def ManifestConfig( + path: Optional[str] = None, media_type: Optional[str] = None +) -> Tuple[Dict[str, object], Optional[str]]: + """ + Write an empty config, if one is not provided + + :param path: the path of the manifest config, if exists. + :type path: str + :param media_type: media type for the manifest config (optional) + :type media_type: str + """ + # Create an empty config if we don't have one + if not path or not os.path.exists(path): + path = None + conf = { + "mediaType": media_type or oras.defaults.unknown_config_media_type, + "size": 2, + "digest": oras.defaults.blank_config_hash, + } + + else: + conf = { + "mediaType": media_type or oras.defaults.unknown_config_media_type, + "size": oras.utils.get_size(path), + "digest": "sha256:" + oras.utils.get_file_hash(path), + } + + jsonschema.validate(conf, schema=oras.schemas.layer) + return conf, path + + +def NewManifest() -> dict: + """ + Get an empty manifest config. + """ + return copy.deepcopy(EmptyManifest) diff --git a/build/lib/oras/provider.py b/build/lib/oras/provider.py new file mode 100644 index 0000000..1416fd7 --- /dev/null +++ b/build/lib/oras/provider.py @@ -0,0 +1,1074 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import copy +import os +import urllib +from contextlib import contextmanager, nullcontext +from dataclasses import asdict, dataclass +from http.cookiejar import DefaultCookiePolicy +from tempfile import TemporaryDirectory +from typing import Callable, Generator, List, Optional, Tuple, Union + +import jsonschema +import requests + +import oras.auth +import oras.container +import oras.decorator as decorator +import oras.oci +import oras.schemas +import oras.utils +from oras.logger import logger +from oras.utils.fileio import PathAndOptionalContent + +# container type can be string or container +container_type = Union[str, oras.container.Container] + + +@contextmanager +def temporary_empty_config() -> Generator[str, None, None]: + with TemporaryDirectory() as tmpdir: + config_file = oras.utils.get_tmpfile(tmpdir=tmpdir, suffix=".json") + oras.utils.write_file(config_file, "{}") + yield config_file + + +@dataclass +class Subject: + mediaType: str + digest: str + size: int + + +class Registry: + """ + Direct interactions with an OCI registry. + + This could also be called a "provider" when we add in the "copy" logic + and the registry isn't necessarily the "remote" endpoint. + """ + + def __init__( + self, + hostname: Optional[str] = None, + insecure: bool = False, + tls_verify: bool = True, + ): + """ + Create a new registry provider. + + :param hostname: the registry hostname (optional) + :type hostname: str + :param insecure: use http instead of https + :type insecure: bool + :param tls_verify: verify TLS certificates + :type tls_verify: bool + """ + self.hostname: Optional[str] = hostname + self.headers: dict = {} + self.session: requests.Session = requests.Session() + self.prefix: str = "http" if insecure else "https" + self.token: Optional[str] = None + self._auths: dict = {} + self._basic_auth = None + self._tls_verify = tls_verify + + if not tls_verify: + requests.packages.urllib3.disable_warnings() # type: ignore + + # Ignore all cookies: some registries try to set one + # and take it as a sign they are talking to a browser, + # trying to set further CSRF cookies (Harbor is such a case) + self.session.cookies.set_policy(DefaultCookiePolicy(allowed_domains=[])) + + def logout(self, hostname: str): + """ + If auths are loaded, remove a hostname. + + :param hostname: the registry hostname to remove + :type hostname: str + """ + # Remove any basic auth or token + self._basic_auth = None + self.token = None + + if not self._auths: + logger.info(f"You are not logged in to {hostname}") + return + + for host in oras.utils.iter_localhosts(hostname): + if host in self._auths: + del self._auths[host] + logger.info(f"You have successfully logged out of {hostname}") + return + logger.info(f"You are not logged in to {hostname}") + + @decorator.ensure_container + def load_configs(self, container: container_type, configs: Optional[list] = None): + """ + Load configs to discover credentials for a specific container. + + This is typically just called once. We always add the default Docker + config to the set.s + + :param container: the parsed container URI with components + :type container: oras.container.Container + :param configs: list of configs to read (optional) + :type configs: list + """ + if not self._auths: + self._auths = oras.auth.load_configs(configs) + for registry in oras.utils.iter_localhosts(container.registry): # type: ignore + if self._load_auth(registry): + return + + def _load_auth(self, hostname: str) -> bool: + """ + Look for and load a named authentication token. + + :param hostname: the registry hostname to look for + :type hostname: str + """ + # Note that the hostname can be defined without a token + if hostname in self._auths: + auth = self._auths[hostname].get("auth") + + # Case 1: they use a credsStore we don't know how to read + if not auth and "credsStore" in self._auths[hostname]: + logger.warning( + '"credsStore" found in your ~/.docker/config.json, which is not supported by oras-py. Remove it, docker login, and try again.' + ) + return False + + # Case 2: no auth there (wonky file) + elif not auth: + return False + self._basic_auth = auth + return True + return False + + def set_basic_auth(self, username: str, password: str): + """ + Set basic authentication. + + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + """ + self._basic_auth = oras.auth.get_basic_auth(username, password) + self.set_header("Authorization", "Basic %s" % self._basic_auth) + + def set_token_auth(self, token: str): + """ + Set token authentication. + + :param token: the bearer token + :type token: str + """ + self.token = token + self.set_header("Authorization", "Bearer %s" % token) + + def reset_basic_auth(self): + """ + Given we have basic auth, reset it. + """ + if "Authorization" in self.headers: + del self.headers["Authorization"] + if self._basic_auth: + self.set_header("Authorization", "Basic %s" % self._basic_auth) + + def set_header(self, name: str, value: str): + """ + Courtesy function to set a header + + :param name: header name to set + :type name: str + :param value: header value to set + :type value: str + """ + self.headers.update({name: value}) + + def _validate_path(self, path: str) -> bool: + """ + Ensure a blob path is in the present working directory or below. + + :param path: the path to validate + :type path: str + """ + return os.getcwd() in os.path.abspath(path) + + def _parse_manifest_ref(self, ref: str) -> Tuple[str, str]: + """ + Parse an optional manifest config. + + Examples + -------- + : + path/to/config:application/vnd.oci.image.config.v1+json + /dev/null:application/vnd.oci.image.config.v1+json + + :param ref: the manifest reference to parse (examples above) + :type ref: str + :return - A Tuple of the path and the content-type, using the default unknown + config media type if none found in the reference + """ + path_content: PathAndOptionalContent = oras.utils.split_path_and_content(ref) + if not path_content.content: + path_content.content = oras.defaults.unknown_config_media_type + return path_content.path, path_content.content + + def upload_blob( + self, + blob: str, + container: container_type, + layer: dict, + do_chunked: bool = False, + refresh_headers: bool = True + ) -> requests.Response: + """ + Prepare and upload a blob. + + Sizes > 1024 are uploaded via a chunked approach (post, patch+, put) + and <= 1024 is a single post then put. + + :param blob: path to blob to upload + :type blob: str + :param container: parsed container URI + :type container: oras.container.Container or str + :param layer: dict from oras.oci.NewLayer + :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool + """ + blob = os.path.abspath(blob) + container = self.get_container(container) + + # Always reset headers between uploads + self.reset_basic_auth() + + # Chunked for large, otherwise POST and PUT + # This is currently disabled unless the user asks for it, as + # it doesn't seem to work for all registries + if not do_chunked: + response = self.put_upload(blob, container, layer, refresh_headers=refresh_headers) + else: + response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) + + # If we have an empty layer digest and the registry didn't accept, just return dummy successful response + if ( + response.status_code not in [200, 201, 202] + and layer["digest"] == oras.defaults.blank_hash + ): + response = requests.Response() + response.status_code = 200 + return response + + @decorator.ensure_container + def delete_tag(self, container: container_type, tag: str) -> bool: + """ + Delete a tag for a container. + + :param container: parsed container URI + :type container: oras.container.Container or str + :param tag: name of tag to delete + :type tag: str + """ + logger.debug(f"Deleting tag {tag} for {container}") + + head_url = f"{self.prefix}://{container.manifest_url(tag)}" # type: ignore + + # get digest of manifest to delete + response = self.do_request( + head_url, + "HEAD", + headers={"Accept": "application/vnd.oci.image.manifest.v1+json"}, + ) + if response.status_code == 404: + logger.error(f"Cannot find tag {container}:{tag}") + return False + + digest = response.headers.get("Docker-Content-Digest") + if not digest: + raise RuntimeError("Expected to find Docker-Content-Digest header.") + + delete_url = f"{self.prefix}://{container.manifest_url(digest)}" # type: ignore + response = self.do_request(delete_url, "DELETE") + if response.status_code != 202: + raise RuntimeError("Delete was not successful: {response.json()}") + return True + + @decorator.ensure_container + def get_tags(self, container: container_type, N=None) -> List[str]: + """ + Retrieve tags for a package. + + :param container: parsed container URI + :type container: oras.container.Container or str + :param N: limit number of tags, None for all (default) + :type N: Optional[int] + """ + retrieve_all = N is None + tags_url = f"{self.prefix}://{container.tags_url(N=N)}" # type: ignore + tags: List[str] = [] + + def extract_tags(response: requests.Response): + """ + Determine if we should continue based on new tags and under limit. + """ + json = response.json() + new_tags = json.get("tags") or [] + tags.extend(new_tags) + return len(new_tags) and (retrieve_all or len(tags) < N) + + self._do_paginated_request(tags_url, callable=extract_tags) + + # If we got a longer set than was asked for + if N is not None and len(tags) > N: + tags = tags[:N] + return tags + + def _do_paginated_request( + self, url: str, callable: Callable[[requests.Response], bool] + ): + """ + Paginate a request for a URL. + + We look for the "Link" header to get the next URL to ping. If + the callable returns True, we continue to the next page, otherwise + we stop. + """ + # Save the base url to add parameters to, assuming only the params change + parts = urllib.parse.urlparse(url) + base_url = f"{parts.scheme}://{parts.netloc}" + + # get all results using the pagination + while True: + response = self.do_request(url, "GET", headers=self.headers) + + # Check 200 response, show errors if any + self._check_200_response(response) + + want_more = callable(response) + if not want_more: + break + + link = response.links.get("next", {}).get("url") + + # Get the next link + if not link: + break + + # use link + base url to continue with next page + url = f"{base_url}{link}" + + @decorator.ensure_container + def get_blob( + self, + container: container_type, + digest: str, + stream: bool = False, + head: bool = False, + ) -> requests.Response: + """ + Retrieve a blob for a package. + + :param container: parsed container URI + :type container: oras.container.Container or str + :param digest: sha256 digest of the blob to retrieve + :type digest: str + :param stream: stream the response (or not) + :type stream: bool + :param head: use head to determine if blob exists + :type head: bool + """ + method = "GET" if not head else "HEAD" + blob_url = f"{self.prefix}://{container.get_blob_url(digest)}" # type: ignore + return self.do_request(blob_url, method, headers=self.headers, stream=stream) + + def get_container(self, name: container_type) -> oras.container.Container: + """ + Courtesy function to get a container from a URI. + + :param name: unique resource identifier to parse + :type name: oras.container.Container or str + """ + if isinstance(name, oras.container.Container): + return name + return oras.container.Container(name, registry=self.hostname) + + # Functions to be deprecated in favor of exposed ones + @decorator.ensure_container + def _download_blob( + self, container: container_type, digest: str, outfile: str + ) -> str: + logger.warning( + "This function is deprecated in favor of download_blob and will be removed by 0.1.2" + ) + return self.download_blob(container, digest, outfile) + + def _put_upload( + self, blob: str, container: oras.container.Container, layer: dict + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of put_upload and will be removed by 0.1.2" + ) + return self.put_upload(blob, container, layer) + + def _chunked_upload( + self, blob: str, container: oras.container.Container, layer: dict + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of chunked_upload and will be removed by 0.1.2" + ) + return self.chunked_upload(blob, container, layer) + + def _upload_manifest( + self, manifest: dict, container: oras.container.Container + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of upload_manifest and will be removed by 0.1.2" + ) + return self.upload_manifest(manifest, container) + + def _upload_blob( + self, + blob: str, + container: container_type, + layer: dict, + do_chunked: bool = False, + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of upload_blob and will be removed by 0.1.2" + ) + return self.upload_blob(blob, container, layer, do_chunked) + + @decorator.ensure_container + def download_blob( + self, container: container_type, digest: str, outfile: str + ) -> str: + """ + Stream download a blob into an output file. + + This function is a wrapper around get_blob. + + :param container: parsed container URI + :type container: oras.container.Container or str + """ + try: + # Ensure output directory exists first + outdir = os.path.dirname(outfile) + if outdir and not os.path.exists(outdir): + oras.utils.mkdir_p(outdir) + with self.get_blob(container, digest, stream=True) as r: + r.raise_for_status() + with open(outfile, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + # Allow an empty layer to fail and return /dev/null + except Exception as e: + if digest == oras.defaults.blank_hash: + return os.devnull + raise e + return outfile + + def put_upload( + self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True + ) -> requests.Response: + """ + Upload to a registry via put. + + :param blob: path to blob to upload + :type blob: str + :param container: parsed container URI + :type container: oras.container.Container or str + :param layer: dict from oras.oci.NewLayer + :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool + """ + # Start an upload session + headers = {"Content-Type": "application/octet-stream"} + + if not refresh_headers: + headers.update(self.headers) + + upload_url = f"{self.prefix}://{container.upload_blob_url()}" + r = self.do_request(upload_url, "POST", headers=headers) + + # Location should be in the header + session_url = self._get_location(r, container) + if not session_url: + raise ValueError(f"Issue retrieving session url: {r.json()}") + + # PUT to upload blob url + headers = { + "Content-Length": str(layer["size"]), + "Content-Type": "application/octet-stream", + } + headers.update(self.headers) + blob_url = oras.utils.append_url_params( + session_url, {"digest": layer["digest"]} + ) + with open(blob, "rb") as fd: + response = self.do_request( + blob_url, + method="PUT", + data=fd.read(), + headers=headers, + ) + return response + + def _get_location( + self, r: requests.Response, container: oras.container.Container + ) -> str: + """ + Parse the location header and ensure it includes a hostname. + This currently assumes if there isn't a hostname, we are pushing to + the same registry hostname of the original request. + + :param r: requests response with headers + :type r: requests.Response + :param container: parsed container URI + :type container: oras.container.Container or str + """ + session_url = r.headers.get("location", "") + if not session_url: + return session_url + + # Some registries do not return the full registry hostname. Check that + # the url starts with a protocol scheme, change tracked with: + # https://github.com/oras-project/oras-py/issues/78 + prefix = f"{self.prefix}://{container.registry}" + + if not session_url.startswith("http"): + session_url = f"{prefix}{session_url}" + return session_url + + def chunked_upload( + self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True + ) -> requests.Response: + """ + Upload via a chunked upload. + + :param blob: path to blob to upload + :type blob: str + :param container: parsed container URI + :type container: oras.container.Container or str + :param layer: dict from oras.oci.NewLayer + :type layer: dict + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool + """ + # Start an upload session + headers = {"Content-Type": "application/octet-stream", "Content-Length": "0"} + if not refresh_headers: + headers.update(self.headers) + + upload_url = f"{self.prefix}://{container.upload_blob_url()}" + r = self.do_request(upload_url, "POST", headers=headers) + + # Location should be in the header + session_url = self._get_location(r, container) + if not session_url: + raise ValueError(f"Issue retrieving session url: {r.json()}") + + # Read the blob in chunks, for each do a patch + start = 0 + with open(blob, "rb") as fd: + for chunk in oras.utils.read_in_chunks(fd): + if not chunk: + break + + end = start + len(chunk) - 1 + content_range = "%s-%s" % (start, end) + headers = { + "Content-Range": content_range, + "Content-Length": str(len(chunk)), + "Content-Type": "application/octet-stream", + } + + # Important to update with auth token if acquired + headers.update(self.headers) + start = end + 1 + self._check_200_response( + self.do_request(session_url, "PATCH", data=chunk, headers=headers) + ) + + # Finally, issue a PUT request to close blob + session_url = oras.utils.append_url_params( + session_url, {"digest": layer["digest"]} + ) + return self.do_request(session_url, "PUT", headers=self.headers) + + def _check_200_response(self, response: requests.Response): + """ + Helper function to ensure some flavor of 200 + + :param response: request response to inspect + :type response: requests.Response + """ + if response.status_code not in [200, 201, 202]: + self._parse_response_errors(response) + raise ValueError(f"Issue with {response.request.url}: {response.reason}") + + def _parse_response_errors(self, response: requests.Response): + """ + Given a failed request, look for OCI formatted error messages. + + :param response: request response to inspect + :type response: requests.Response + """ + try: + msg = response.json() + for error in msg.get("errors", []): + if isinstance(error, dict) and "message" in error: + logger.error(error["message"]) + except Exception: + pass + + def upload_manifest( + self, manifest: dict, container: oras.container.Container, refresh_headers: bool = True + ) -> requests.Response: + """ + Read a manifest file and upload it. + + :param manifest: manifest to upload + :type manifest: dict + :param container: parsed container URI + :type container: oras.container.Container or str + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool + """ + self.reset_basic_auth() + jsonschema.validate(manifest, schema=oras.schemas.manifest) + headers = { + "Content-Type": oras.defaults.default_manifest_media_type, + "Content-Length": str(len(manifest)), + } + + if not refresh_headers: + headers.update(self.headers) + + return self.do_request( + f"{self.prefix}://{container.manifest_url()}", # noqa + "PUT", + headers=headers, + json=manifest, + ) + + def push(self, *args, **kwargs) -> requests.Response: + """ + Push a set of files to a target + + :param config_path: path to a config file + :type config_path: str + :param disable_path_validation: ensure paths are relative to the running directory. + :type disable_path_validation: bool + :param files: list of files to push + :type files: list + :param insecure: allow registry to use http + :type insecure: bool + :param annotation_file: manifest annotations file + :type annotation_file: str + :param manifest_annotations: manifest annotations + :type manifest_annotations: dict + :param target: target location to push to + :type target: str + :param refresh_headers: if true or None, headers are refreshed + :type refresh_headers: bool + :param subject: optional subject reference + :type subject: Subject + """ + container = self.get_container(kwargs["target"]) + self.load_configs(container, configs=kwargs.get("config_path")) + + # Hold state of request for http/https + validate_path = not kwargs.get("disable_path_validation", False) + + # Prepare a new manifest + manifest = oras.oci.NewManifest() + + # A lookup of annotations we can add (to blobs or manifest) + annotset = oras.oci.Annotations(kwargs.get("annotation_file")) + media_type = None + + refresh_headers = kwargs.get("refresh_headers") + if refresh_headers is None: + refresh_headers = True + + # Upload files as blobs + for blob in kwargs.get("files", []): + # You can provide a blob + content type + path_content: PathAndOptionalContent = oras.utils.split_path_and_content( + str(blob) + ) + blob = path_content.path + media_type = path_content.content + + # Must exist + if not os.path.exists(blob): + raise FileNotFoundError(f"{blob} does not exist.") + + # Path validation means blob must be relative to PWD. + if validate_path: + if not self._validate_path(blob): + raise ValueError( + f"Blob {blob} is not in the present working directory context." + ) + + # Save directory or blob name before compressing + blob_name = os.path.basename(blob) + + # If it's a directory, we need to compress + cleanup_blob = False + if os.path.isdir(blob): + blob = oras.utils.make_targz(blob) + cleanup_blob = True + + # Create a new layer from the blob + layer = oras.oci.NewLayer(blob, is_dir=cleanup_blob, media_type=media_type) + annotations = annotset.get_annotations(blob) + + # Always strip blob_name of path separator + layer["annotations"] = { + oras.defaults.annotation_title: blob_name.strip(os.sep) + } + if annotations: + layer["annotations"].update(annotations) + + # update the manifest with the new layer + manifest["layers"].append(layer) + logger.debug(f"Preparing layer {layer}") + + # Upload the blob layer + response = self.upload_blob(blob, container, layer, refresh_headers=refresh_headers) + self._check_200_response(response) + + # Do we need to cleanup a temporary targz? + if cleanup_blob and os.path.exists(blob): + os.remove(blob) + + # Add annotations to the manifest, if provided + manifest_annots = annotset.get_annotations("$manifest") or {} + + # Custom manifest annotations from client key=value pairs + # These over-ride any potentially provided from file + custom_annots = kwargs.get("manifest_annotations") + if custom_annots: + manifest_annots.update(custom_annots) + if manifest_annots: + manifest["annotations"] = manifest_annots + + subject = kwargs.get("subject") + if subject: + manifest["subject"] = asdict(subject) + + # Prepare the manifest config (temporary or one provided) + manifest_config = kwargs.get("manifest_config") + config_annots = annotset.get_annotations("$config") + if manifest_config: + ref, media_type = self._parse_manifest_ref(manifest_config) + conf, config_file = oras.oci.ManifestConfig(ref, media_type) + else: + conf, config_file = oras.oci.ManifestConfig() + + # Config annotations? + if config_annots: + conf["annotations"] = config_annots + + # Config is just another layer blob! + logger.debug(f"Preparing config {conf}") + with ( + temporary_empty_config() + if config_file is None + else nullcontext(config_file) + ) as config_file: + response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) + + self._check_200_response(response) + + # Final upload of the manifest + manifest["config"] = conf + self._check_200_response(self.upload_manifest(manifest, container, refresh_headers=refresh_headers)) + print(f"Successfully pushed {container}") + return response + + def pull(self, *args, **kwargs) -> List[str]: + """ + Pull an artifact from a target + + :param config_path: path to a config file + :type config_path: str + :param allowed_media_type: list of allowed media types + :type allowed_media_type: list or None + :param overwrite: if output file exists, overwrite + :type overwrite: bool + :param refresh_headers: if true, headers are refreshed when fetching manifests + :type refresh_headers: bool + :param manifest_config_ref: save manifest config to this file + :type manifest_config_ref: str + :param outdir: output directory path + :type outdir: str + :param target: target location to pull from + :type target: str + """ + allowed_media_type = kwargs.get("allowed_media_type") + refresh_headers = kwargs.get("refresh_headers") + if refresh_headers is None: + refresh_headers = True + container = self.get_container(kwargs["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) + + files = [] + for layer in manifest.get("layers", []): + filename = (layer.get("annotations") or {}).get( + oras.defaults.annotation_title + ) + + # If we don't have a filename, default to digest. Hopefully does not happen + if not filename: + filename = layer["digest"] + + # This raises an error if there is a malicious path + outfile = oras.utils.sanitize_path(outdir, os.path.join(outdir, filename)) + + if not overwrite and os.path.exists(outfile): + logger.warning( + f"{outfile} already exists and --keep-old-files set, will not overwrite." + ) + continue + + # A directory will need to be uncompressed and moved + if layer["mediaType"] == oras.defaults.default_blob_dir_media_type: + targz = oras.utils.get_tmpfile(suffix=".tar.gz") + self.download_blob(container, layer["digest"], targz) + + # The artifact will be extracted to the correct name + oras.utils.extract_targz(targz, os.path.dirname(outfile)) + + # Anything else just extracted directly + else: + self.download_blob(container, layer["digest"], outfile) + logger.info(f"Successfully pulled {outfile}.") + files.append(outfile) + return files + + @decorator.ensure_container + def get_manifest( + self, + container: container_type, + allowed_media_type: Optional[list] = None, + refresh_headers: bool = True, + ) -> dict: + """ + Retrieve a manifest for a package. + + :param container: parsed container URI + :type container: oras.container.Container or str + :param allowed_media_type: one or more allowed media types + :type allowed_media_type: str + :param refresh_headers: if true, headers are refreshed + :type refresh_headers: bool + """ + if not allowed_media_type: + allowed_media_type = [oras.defaults.default_manifest_media_type] + headers = {"Accept": ";".join(allowed_media_type)} + + if not refresh_headers: + headers.update(self.headers) + + get_manifest = f"{self.prefix}://{container.manifest_url()}" # type: ignore + response = self.do_request(get_manifest, "GET", headers=headers) + self._check_200_response(response) + manifest = response.json() + jsonschema.validate(manifest, schema=oras.schemas.manifest) + return manifest + + @decorator.classretry + def do_request( + self, + url: str, + method: str = "GET", + data: Optional[Union[dict, bytes]] = None, + headers: Optional[dict] = None, + json: Optional[dict] = None, + stream: bool = False, + ): + """ + Do a request. This is a wrapper around requests to handle retry auth. + + :param url: the URL to issue the request to + :type url: str + :param method: the method to use (GET, DELETE, POST, PUT, PATCH) + :type method: str + :param data: data for requests + :type data: dict or bytes + :param headers: headers for the request + :type headers: dict + :param json: json data for requests + :type json: dict + :param stream: stream the responses + :type stream: bool + """ + headers = headers or {} + + # Make the request and return to calling function, unless requires auth + response = self.session.request( + method, + url, + data=data, + json=json, + headers=headers, + stream=stream, + verify=self._tls_verify, + ) + + # A 401 response is a request for authentication + if response.status_code not in [401, 404]: + return response + + # Otherwise, authenticate the request and retry + if self.authenticate_request(response): + headers.update(self.headers) + response = self.session.request( + method, + url, + data=data, + json=json, + headers=headers, + stream=stream, + ) + + # Fallback to using Authorization if already required + # This is a catch for EC2. I don't think this is correct + # A basic token should be used for a bearer one. + if response.status_code in [401, 404] and "Authorization" in self.headers: + logger.debug("Trying with provided Basic Authorization...") + headers.update(self.headers) + response = self.session.request( + method, + url, + data=data, + json=json, + headers=headers, + stream=stream, + ) + + return response + + def authenticate_request(self, originalResponse: requests.Response) -> bool: + """ + Authenticate Request + Given a response, look for a Www-Authenticate header to parse. + + We return True/False to indicate if the request should be retried. + + :param originalResponse: original response to get the Www-Authenticate header + :type originalResponse: requests.Response + """ + authHeaderRaw = originalResponse.headers.get("Www-Authenticate") + if not authHeaderRaw: + logger.debug( + "Www-Authenticate not found in original response, cannot authenticate." + ) + return False + + # If we have a token, set auth header (base64 encoded user/pass) + if self.token: + self.set_header("Authorization", "Bearer %s" % self._basic_auth) + return True + + headers = copy.deepcopy(self.headers) + h = oras.auth.parse_auth_header(authHeaderRaw) + + if "Authorization" not in headers: + # First try to request an anonymous token + logger.debug("No Authorization, requesting anonymous token") + if self.request_anonymous_token(h): + logger.debug("Successfully obtained anonymous token!") + return True + + logger.error( + "This endpoint requires a token. Please set " + "oras.provider.Registry.set_basic_auth(username, password) " + "first or use oras-py login to do the same." + ) + return False + + params = {} + + # Prepare request to retry + if h.service: + logger.debug(f"Service: {h.service}") + params["service"] = h.service + headers.update( + { + "Service": h.service, + "Accept": "application/json", + "User-Agent": "oras-py", + } + ) + + # Ensure the realm starts with http + if not h.realm.startswith("http"): # type: ignore + h.realm = f"{self.prefix}://{h.realm}" + + # If the www-authenticate included a scope, honor it! + if h.scope: + logger.debug(f"Scope: {h.scope}") + params["scope"] = h.scope + + authResponse = self.session.get(h.realm, headers=headers, params=params) # type: ignore + if authResponse.status_code != 200: + logger.debug(f"Auth response was not successful: {authResponse.text}") + return False + + # Request the token + info = authResponse.json() + token = info.get("token") or info.get("access_token") + + # Set the token to the original request and retry + self.headers.update({"Authorization": "Bearer %s" % token}) + return True + + def request_anonymous_token(self, h: oras.auth.authHeader) -> bool: + """ + Given no basic auth, fall back to trying to request an anonymous token. + + Returns: boolean if headers have been updated with token. + """ + if not h.realm: + logger.debug("Request anonymous token: no realm provided, exiting early") + return False + + params = {} + if h.service: + params["service"] = h.service + if h.scope: + params["scope"] = h.scope + + logger.debug(f"Final params are {params}") + response = self.session.request("GET", h.realm, params=params) + if response.status_code != 200: + logger.debug(f"Response for anon token failed: {response.text}") + return False + + # From https://docs.docker.com/registry/spec/auth/token/ section + # We can get token OR access_token OR both (when both they are identical) + data = response.json() + token = data.get("token") or data.get("access_token") + + # Update the headers but not self.token (expects Basic) + if token: + self.headers.update({"Authorization": "Bearer %s" % token}) + return True + logger.debug("Warning: no token or access_token present in response.") + return False diff --git a/build/lib/oras/schemas.py b/build/lib/oras/schemas.py new file mode 100644 index 0000000..3a0eb7a --- /dev/null +++ b/build/lib/oras/schemas.py @@ -0,0 +1,58 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +## Manifest and Layer schemas + +schema_url = "http://json-schema.org/draft-07/schema" + +# Layer + +layerProperties = { + "type": "object", + "properties": { + "mediaType": {"type": "string"}, + "size": {"type": "number"}, + "digest": {"type": "string"}, + "annotations": {"type": ["object", "null", "array"]}, + }, +} + +layer = { + "$schema": schema_url, + "title": "Layer Schema", + "required": [ + "mediaType", + "size", + "digest", + ], + "additionalProperties": True, +} + +layer.update(layerProperties) + + +# Manifest + +manifestProperties = { + "schemaVersion": {"type": "number"}, + "subject": {"type": ["null", "object"]}, + "mediaType": {"type": "string"}, + "layers": {"type": "array", "items": layerProperties}, + "config": layerProperties, + "annotations": {"type": ["object", "null", "array"]}, +} + + +manifest = { + "$schema": schema_url, + "title": "Manifest Schema", + "type": "object", + "required": [ + "schemaVersion", + "config", + "layers", + ], + "properties": manifestProperties, + "additionalProperties": True, +} diff --git a/build/lib/oras/tests/__init__.py b/build/lib/oras/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/oras/tests/annotations.json b/build/lib/oras/tests/annotations.json new file mode 100644 index 0000000..d0be02f --- /dev/null +++ b/build/lib/oras/tests/annotations.json @@ -0,0 +1,6 @@ +{ + "$manifest": { + "holiday": "Christmas", + "candy": "candy-corn" + } +} diff --git a/build/lib/oras/tests/conftest.py b/build/lib/oras/tests/conftest.py new file mode 100644 index 0000000..7b038dc --- /dev/null +++ b/build/lib/oras/tests/conftest.py @@ -0,0 +1,58 @@ +import os +from dataclasses import dataclass + +import pytest + + +@dataclass +class TestCredentials: + with_auth: bool + user: str + password: str + + +@pytest.fixture +def registry(): + host = os.environ.get("ORAS_HOST") + port = os.environ.get("ORAS_PORT") + + if not host or not port: + pytest.skip( + "You must export ORAS_HOST and ORAS_PORT" + " for a running registry before running tests." + ) + + return f"{host}:{port}" + + +@pytest.fixture +def credentials(request): + with_auth = os.environ.get("ORAS_AUTH") == "true" + user = os.environ.get("ORAS_USER", "myuser") + pwd = os.environ.get("ORAS_PASS", "mypass") + + if with_auth and not user or not pwd: + pytest.skip("To test auth you need to export ORAS_USER and ORAS_PASS") + + marks = [m.name for m in request.node.iter_markers()] + if request.node.parent: + marks += [m.name for m in request.node.parent.iter_markers()] + + if request.node.get_closest_marker("with_auth"): + if request.node.get_closest_marker("with_auth").args[0] != with_auth: + if with_auth: + pytest.skip("test requires un-authenticated access to registry") + else: + pytest.skip("test requires authenticated access to registry") + + return TestCredentials(with_auth, user, pwd) + + +@pytest.fixture +def target(registry): + return f"{registry}/dinosaur/artifact:v1" + + +@pytest.fixture +def target_dir(registry): + return f"{registry}/dinosaur/directory:v1" diff --git a/build/lib/oras/tests/run_registry.sh b/build/lib/oras/tests/run_registry.sh new file mode 100755 index 0000000..e86145d --- /dev/null +++ b/build/lib/oras/tests/run_registry.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# A helper script to easily run a development, local registry +docker run -it -e REGISTRY_STORAGE_DELETE_ENABLED=true --rm -p 5000:5000 ghcr.io/oras-project/registry:latest diff --git a/build/lib/oras/tests/test_oras.py b/build/lib/oras/tests/test_oras.py new file mode 100644 index 0000000..06df44f --- /dev/null +++ b/build/lib/oras/tests/test_oras.py @@ -0,0 +1,138 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import os +import shutil + +import pytest + +import oras.client + +here = os.path.abspath(os.path.dirname(__file__)) + + +def test_basic_oras(registry): + """ + Basic tests for oras (without authentication) + """ + client = oras.client.OrasClient(hostname=registry, insecure=True) + assert "Python version" in client.version() + + +@pytest.mark.with_auth(True) +def test_login_logout(registry, credentials): + """ + Login and logout are all we can test with basic auth! + """ + client = oras.client.OrasClient(hostname=registry, insecure=True) + res = client.login( + hostname=registry, + username=credentials.user, + password=credentials.password, + insecure=True, + ) + assert res["Status"] == "Login Succeeded" + client.logout(registry) + + +@pytest.mark.with_auth(False) +def test_basic_push_pull(tmp_path, registry, credentials, 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] + + # Test pulling elsewhere + files = client.pull(target=target, outdir=tmp_path) + assert len(files) == 1 + assert os.path.basename(files[0]) == "artifact.txt" + assert str(tmp_path) in files[0] + assert os.path.exists(files[0]) + + # Move artifact outside of context (should not work) + moved_artifact = tmp_path / os.path.basename(artifact) + shutil.copyfile(artifact, moved_artifact) + with pytest.raises(ValueError): + client.push(files=[moved_artifact], target=target) + + # This should work because we aren't checking paths + res = client.push(files=[artifact], target=target, disable_path_validation=True) + assert res.status_code == 201 + + +@pytest.mark.with_auth(False) +def test_get_delete_tags(tmp_path, registry, credentials, target): + """ + Test creationg, getting, and deleting tags. + """ + 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] + + # Test getting tags + tags = client.get_tags(target) + assert "v1" in tags + + # Test deleting not-existence tag + 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 + + +def test_get_many_tags(): + """ + Test getting many tags + """ + client = oras.client.OrasClient(hostname="ghcr.io", insecure=False) + + # Test getting tags with a limit set + tags = client.get_tags( + "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1005 + ) + assert len(tags) == 1005 + + # This should retrieve all tags (defaults to None) + tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp") + assert len(tags) > 1500 + + # Same result if explicitly set + same_tags = client.get_tags( + "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=None + ) + assert not set(tags).difference(set(same_tags)) + + # Small number of tags + tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=10) + assert not set(tags).difference(set(same_tags)) + assert len(tags) == 10 + + +@pytest.mark.with_auth(False) +def test_directory_push_pull(tmp_path, registry, credentials, target_dir): + """ + Test push and pull for directory + """ + client = oras.client.OrasClient(hostname=registry, insecure=True) + + # Test upload of a directory + upload_dir = os.path.join(here, "upload_data") + res = client.push(files=[upload_dir], target=target_dir) + assert res.status_code == 201 + files = client.pull(target=target_dir, outdir=tmp_path) + + assert len(files) == 1 + assert os.path.basename(files[0]) == "upload_data" + assert str(tmp_path) in files[0] + assert os.path.exists(files[0]) + assert "artifact.txt" in os.listdir(files[0]) diff --git a/build/lib/oras/tests/test_provider.py b/build/lib/oras/tests/test_provider.py new file mode 100644 index 0000000..d3b014b --- /dev/null +++ b/build/lib/oras/tests/test_provider.py @@ -0,0 +1,134 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import os +from pathlib import Path + +import pytest + +import oras.client +import oras.defaults +import oras.provider +import oras.utils + +here = os.path.abspath(os.path.dirname(__file__)) + + +@pytest.mark.with_auth(False) +def test_annotated_registry_push(tmp_path, registry, credentials, target): + """ + Basic tests for oras push with annotations + """ + + # Direct access to registry functions + remote = oras.provider.Registry(hostname=registry, insecure=True) + client = oras.client.OrasClient(hostname=registry, insecure=True) + artifact = os.path.join(here, "artifact.txt") + + assert os.path.exists(artifact) + + # Custom manifest annotations + annots = {"holiday": "Halloween", "candy": "chocolate"} + res = client.push(files=[artifact], target=target, manifest_annotations=annots) + assert res.status_code in [200, 201] + + # Get the manifest + manifest = remote.get_manifest(target) + assert "annotations" in manifest + for k, v in annots.items(): + assert k in manifest["annotations"] + assert manifest["annotations"][k] == v + + # Annotations from file with $manifest + annotation_file = os.path.join(here, "annotations.json") + file_annots = oras.utils.read_json(annotation_file) + assert "$manifest" in file_annots + res = client.push(files=[artifact], target=target, annotation_file=annotation_file) + assert res.status_code in [200, 201] + manifest = remote.get_manifest(target) + + assert "annotations" in manifest + for k, v in file_annots["$manifest"].items(): + assert k in manifest["annotations"] + assert manifest["annotations"][k] == v + + # File that doesn't exist + annotation_file = os.path.join(here, "annotations-nope.json") + with pytest.raises(FileNotFoundError): + res = client.push( + files=[artifact], target=target, annotation_file=annotation_file + ) + + +def test_parse_manifest(registry): + """ + Test parse manifest function. + + Parse manifest function has additional logic for Windows - this isn't included in + these tests as they don't usually run on Windows. + """ + testref = "path/to/config:application/vnd.oci.image.config.v1+json" + remote = oras.provider.Registry(hostname=registry, insecure=True) + ref, content_type = remote._parse_manifest_ref(testref) + assert ref == "path/to/config" + assert content_type == "application/vnd.oci.image.config.v1+json" + + testref = "path/to/config:application/vnd.oci.image.config.v1+json:extra" + remote = oras.provider.Registry(hostname=registry, insecure=True) + ref, content_type = remote._parse_manifest_ref(testref) + assert ref == "path/to/config" + assert content_type == "application/vnd.oci.image.config.v1+json:extra" + + testref = "/dev/null:application/vnd.oci.image.manifest.v1+json" + ref, content_type = remote._parse_manifest_ref(testref) + assert ref == "/dev/null" + assert content_type == "application/vnd.oci.image.manifest.v1+json" + + testref = "/dev/null" + ref, content_type = remote._parse_manifest_ref(testref) + assert ref == "/dev/null" + assert content_type == oras.defaults.unknown_config_media_type + + testref = "path/to/config.json" + ref, content_type = remote._parse_manifest_ref(testref) + assert ref == "path/to/config.json" + assert content_type == oras.defaults.unknown_config_media_type + + +def test_sanitize_path(): + HOME_DIR = str(Path.home()) + assert str(oras.utils.sanitize_path(HOME_DIR, HOME_DIR)) == f"{HOME_DIR}" + assert ( + str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "username"))) + == f"{HOME_DIR}/username" + ) + assert ( + str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, ".", "username"))) + == f"{HOME_DIR}/username" + ) + + with pytest.raises(Exception) as e: + assert oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "..")) + assert ( + str(e.value) + == f"Filename {Path(os.path.join(HOME_DIR, '..')).resolve()} is not in {HOME_DIR} directory" + ) + + assert oras.utils.sanitize_path("", "") == str(Path(".").resolve()) + assert oras.utils.sanitize_path("/opt", os.path.join("/opt", "image_name")) == str( + Path("/opt/image_name").resolve() + ) + assert oras.utils.sanitize_path("/../../", "/") == str(Path("/").resolve()) + assert oras.utils.sanitize_path( + Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..") + ) == str(Path("..").resolve()) + + with pytest.raises(Exception) as e: + assert oras.utils.sanitize_path( + Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..", "..") + ) != str(Path("../..").resolve()) + assert ( + str(e.value) + == f"Filename {Path(os.path.join(os.getcwd(), '..', '..')).resolve()} is not in {Path('../').resolve()} directory" + ) diff --git a/build/lib/oras/tests/test_utils.py b/build/lib/oras/tests/test_utils.py new file mode 100644 index 0000000..440d381 --- /dev/null +++ b/build/lib/oras/tests/test_utils.py @@ -0,0 +1,132 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import json +import os +import pathlib +import shutil + +import pytest + +import oras.utils as utils + + +def test_write_read_files(tmp_path): + print("Testing utils.write_file...") + + tmpfile = str(tmp_path / "written_file.txt") + assert not os.path.exists(tmpfile) + utils.write_file(tmpfile, "hello!") + assert os.path.exists(tmpfile) + + print("Testing utils.read_file...") + + content = utils.read_file(tmpfile) + assert content == "hello!" + + +def test_workdir(tmp_path): + print("Testing utils.workdir") + noodle_base = os.path.join(tmp_path, "noodles") + os.makedirs(noodle_base) + pathlib.Path(os.path.join(noodle_base, "pasta.txt")).touch() + assert "pasta.txt" not in os.listdir() + with utils.workdir(noodle_base): + assert "pasta.txt" in os.listdir() + + +def test_write_bad_json(tmp_path): + bad_json = {"Wakkawakkawakka'}": [{True}, "2", 3]} + tmpfile = str(tmp_path / "json_file.txt") + assert not os.path.exists(tmpfile) + with pytest.raises(TypeError): + utils.write_json(bad_json, tmpfile) + + +def test_write_json(tmp_path): + good_json = {"Wakkawakkawakka": [True, "2", 3]} + tmpfile = str(tmp_path / "good_json_file.txt") + + assert not os.path.exists(tmpfile) + utils.write_json(good_json, tmpfile) + with open(tmpfile, "r") as f: + content = json.loads(f.read()) + assert isinstance(content, dict) + assert "Wakkawakkawakka" in content + content = utils.read_json(tmpfile) + assert "Wakkawakkawakka" in content + + +def test_copyfile(tmp_path): + print("Testing utils.copyfile") + + original = str(tmp_path / "location1.txt") + dest = str(tmp_path / "location2.txt") + print(original) + print(dest) + utils.write_file(original, "CONTENT IN FILE") + utils.copyfile(original, dest) + assert os.path.exists(original) + assert os.path.exists(dest) + + +def test_get_tmpdir_tmpfile(): + print("Testing utils.get_tmpdir, get_tmpfile") + + tmpdir = utils.get_tmpdir() + assert os.path.exists(tmpdir) + assert os.path.basename(tmpdir).startswith("oras") + shutil.rmtree(tmpdir) + tmpdir = utils.get_tmpdir(prefix="name") + assert os.path.basename(tmpdir).startswith("name") + shutil.rmtree(tmpdir) + tmpfile = utils.get_tmpfile() + assert "oras" in tmpfile + os.remove(tmpfile) + tmpfile = utils.get_tmpfile(prefix="pancakes") + assert "pancakes" in tmpfile + os.remove(tmpfile) + + +def test_mkdir_p(tmp_path): + print("Testing utils.mkdir_p") + + dirname = str(tmp_path / "input") + result = os.path.join(dirname, "level1", "level2", "level3") + utils.mkdir_p(result) + assert os.path.exists(result) + + +def test_print_json(): + print("Testing utils.print_json") + result = utils.print_json({1: 1}) + assert result == '{\n "1": 1\n}' + + +def test_split_path_and_content(): + """ + Test split path and content function. + + Function has additional logic for Windows - this isn't included in these tests as + they don't usually run on Windows. + """ + testref = "path/to/config:application/vnd.oci.image.config.v1+json" + path_content = utils.split_path_and_content(testref) + assert path_content.path == "path/to/config" + assert path_content.content == "application/vnd.oci.image.config.v1+json" + + testref = "/dev/null:application/vnd.oci.image.config.v1+json" + path_content = utils.split_path_and_content(testref) + assert path_content.path == "/dev/null" + assert path_content.content == "application/vnd.oci.image.config.v1+json" + + testref = "/dev/null" + path_content = utils.split_path_and_content(testref) + assert path_content.path == "/dev/null" + assert not path_content.content + + testref = "path/to/config.json" + path_content = utils.split_path_and_content(testref) + assert path_content.path == "path/to/config.json" + assert not path_content.content diff --git a/build/lib/oras/utils/__init__.py b/build/lib/oras/utils/__init__.py new file mode 100644 index 0000000..a64749c --- /dev/null +++ b/build/lib/oras/utils/__init__.py @@ -0,0 +1,27 @@ +from .fileio import ( + copyfile, + extract_targz, + get_file_hash, + get_size, + get_tmpdir, + get_tmpfile, + make_targz, + mkdir_p, + print_json, + read_file, + read_in_chunks, + read_json, + readline, + recursive_find, + sanitize_path, + split_path_and_content, + workdir, + write_file, + write_json, +) +from .request import ( + append_url_params, + find_docker_config, + get_docker_client, + iter_localhosts, +) diff --git a/build/lib/oras/utils/fileio.py b/build/lib/oras/utils/fileio.py new file mode 100644 index 0000000..db30820 --- /dev/null +++ b/build/lib/oras/utils/fileio.py @@ -0,0 +1,374 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import errno +import hashlib +import io +import json +import os +import pathlib +import re +import shutil +import stat +import sys +import tarfile +import tempfile +from contextlib import contextmanager +from typing import Generator, Optional, TextIO, Union + + +class PathAndOptionalContent: + """Class for holding a path reference and optional content parsed from a string.""" + + def __init__(self, path: str, content: Optional[str] = None): + self.path = path + self.content = content + + +def make_targz(source_dir: str, dest_name: Optional[str] = None) -> str: + """ + Make a targz (compressed) archive from a source directory. + """ + dest_name = dest_name or get_tmpfile(suffix=".tar.gz") + with tarfile.open(dest_name, "w:gz") as tar: + tar.add(source_dir, arcname=os.path.basename(source_dir)) + return dest_name + + +def sanitize_path(expected_dir, path): + """ + Ensure a path resolves to be in the expected parent directory. + + It can be directly there or a child, but not outside it. + We raise an error if it does not - this should not happen + """ + base_dir = pathlib.Path(expected_dir).expanduser().resolve() + path = pathlib.Path(path).expanduser().resolve() # path = base_dir + file_name + if not ((base_dir in path.parents) or (str(base_dir) == str(path))): + raise Exception(f"Filename {path} is not in {base_dir} directory") + return str(path) + + +@contextmanager +def workdir(dirname): + """ + Provide context for a working directory, e.g., + + with workdir(name): + # do stuff + """ + here = os.getcwd() + os.chdir(dirname) + try: + yield + finally: + os.chdir(here) + + +def readline() -> str: + """ + Read lines from stdin + """ + content = sys.stdin.readlines() + return content[0].strip() + + +def extract_targz(targz: str, outdir: str, numeric_owner: bool = False): + """ + Extract a .tar.gz to an output directory. + """ + with tarfile.open(targz, "r:gz") as tar: + for member in tar.getmembers(): + member_path = os.path.join(outdir, member.name) + if not is_within_directory(outdir, member_path): + raise Exception("Attempted Path Traversal in Tar File") + tar.extractall(outdir, members=None, numeric_owner=numeric_owner) + + +def is_within_directory(directory: str, target: str) -> bool: + """ + Determine whether a file is within a directory + """ + abs_directory = os.path.abspath(directory) + abs_target = os.path.abspath(target) + prefix = os.path.commonprefix([abs_directory, abs_target]) + return prefix == abs_directory + + +def get_size(path: str) -> int: + """ + Get the size of a blob + + :param path : the path to get the size for + :type path: str + """ + return pathlib.Path(path).stat().st_size + + +def get_file_hash(path: str, algorithm: str = "sha256") -> str: + """ + Return an sha256 hash of the file based on an algorithm + Raises AttributeError if incorrect algorithm supplied. + + :param path: the path to get the size for + :type path: str + :param algorithm: the algorithm to use + :type algorithm: str + """ + hasher = getattr(hashlib, algorithm)() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def mkdir_p(path: str): + """ + Make a directory path if it does not exist, akin to mkdir -p + + :param path : the path to create + :type path: str + """ + try: + os.makedirs(path) + except OSError as e: + if e.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise ValueError(f"Error creating path {path}.") + + +def get_tmpfile( + tmpdir: Optional[str] = None, prefix: str = "", suffix: str = "" +) -> str: + """ + Get a temporary file with an optional prefix. + + :param tmpdir : an optional temporary directory + :type tmpdir: str + :param prefix: an optional prefix for the temporary path + :type prefix: str + :param suffix: an optional suffix (extension) + :type suffix: str + """ + # First priority for the base goes to the user requested. + tmpdir = get_tmpdir(tmpdir) + + # If tmpdir is set, add to prefix + if tmpdir: + prefix = os.path.join(tmpdir, os.path.basename(prefix)) + + fd, tmp_file = tempfile.mkstemp(prefix=prefix, suffix=suffix) + os.close(fd) + + return tmp_file + + +def get_tmpdir( + tmpdir: Optional[str] = None, prefix: Optional[str] = "", create: bool = True +) -> str: + """ + Get a temporary directory for an operation. + + :param tmpdir: an optional temporary directory + :type tmpdir: str + :param prefix: an optional prefix for the temporary path + :type prefix: str + :param create: create the directory + :type create: bool + """ + tmpdir = tmpdir or tempfile.gettempdir() + prefix = prefix or "oras-tmp" + prefix = "%s.%s" % (prefix, next(tempfile._get_candidate_names())) # type: ignore + tmpdir = os.path.join(tmpdir, prefix) + + if not os.path.exists(tmpdir) and create is True: + os.mkdir(tmpdir) + + return tmpdir + + +def recursive_find(base: str, pattern: Optional[str] = None) -> Generator: + """ + Find filenames that match a particular pattern, and yield them. + + :param base : the root to search + :type base: str + :param pattern: an optional file pattern to use with fnmatch + :type pattern: str + """ + # We can identify modules by finding module.lua + for root, folders, files in os.walk(base): + for file in files: + fullpath = os.path.abspath(os.path.join(root, file)) + + if pattern and not re.search(pattern, fullpath): + continue + + yield fullpath + + +def copyfile(source: str, destination: str, force: bool = True) -> str: + """ + Copy a file from a source to its destination. + + :param source: the source to copy from + :type source: str + :param destination: the destination to copy to + :type destination: str + :param force: force copy if destination already exists + :type force: bool + """ + # Case 1: It's already there, we aren't replacing it :) + if source == destination and force is False: + return destination + + # Case 2: It's already there, we ARE replacing it :) + if os.path.exists(destination) and force is True: + os.remove(destination) + + shutil.copyfile(source, destination) + return destination + + +def write_file( + filename: str, content: str, mode: str = "w", make_exec: bool = False +) -> str: + """ + Write content to a filename + + :param filename: filname to write + :type filename: str + :param content: content to write + :type content: str + :param mode: mode to write + :type mode: str + :param make_exec: make executable + :type make_exec: bool + """ + with open(filename, mode) as filey: + filey.writelines(content) + if make_exec: + st = os.stat(filename) + + # Execute / search permissions for the user and others + os.chmod(filename, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return filename + + +def read_in_chunks(image: Union[TextIO, io.BufferedReader], chunk_size: int = 1024): + """ + Helper function to read file in chunks, with default size 1k. + + :param image: file descriptor + :type image: TextIO or io.BufferedReader + :param chunk_size: size of the chunk + :type chunk_size: int + """ + while True: + data = image.read(chunk_size) + if not data: + break + yield data + + +def write_json(json_obj: dict, filename: str, mode: str = "w") -> str: + """ + Write json to a filename + + :param json_obj: json object to write + :type json_obj: dict + :param filename: filename to write + :type filename: str + :param mode: mode to write + :type mode: str + """ + with open(filename, mode) as filey: + filey.writelines(print_json(json_obj)) + return filename + + +def print_json(json_obj: dict) -> str: + """ + Pretty print json. + + :param json_obj: json object to print + :type json_obj: dict + """ + return json.dumps(json_obj, indent=4, separators=(",", ": ")) + + +def read_file(filename: str, mode: str = "r") -> str: + """ + Read a file. + + :param filename: filename to read + :type filename: str + :param mode: mode to read + :type mode: str + """ + with open(filename, mode) as filey: + content = filey.read() + return content + + +def read_json(filename: str, mode: str = "r") -> dict: + """ + Read a json file to a dictionary. + + :param filename: filename to read + :type filename: str + :param mode: mode to read + :type mode: str + """ + return json.loads(read_file(filename)) + + +def split_path_and_content(ref: str) -> PathAndOptionalContent: + """ + Parse a string containing a path and an optional content + + Examples + -------- + : + path/to/config:application/vnd.oci.image.config.v1+json + /dev/null:application/vnd.oci.image.config.v1+json + C:\\myconfig:application/vnd.oci.image.config.v1+json + + Or, + + /dev/null + C:\\myconfig + + :param ref: the manifest reference to parse (examples above) + :type ref: str + : return: A Tuple of the path in the reference, and the content-type if one found, + otherwise None. + """ + if ":" not in ref: + return PathAndOptionalContent(ref, None) + + if pathlib.Path(ref).drive: + # Running on Windows and Path has Windows drive letter in it, it definitely has + # one colon and could have two or feasibly more, e.g. + # C:\test.tar + # C:\test.tar:application/vnd.oci.image.layer.v1.tar + # C:\test.tar:application/vnd.oci.image.layer.v1.tar:somethingelse + # + # This regex matches two colons in the string and returns everything before + # the second colon as the "path" group and everything after the second colon + # as the "context" group. + # i.e. + # (C:\test.tar):(application/vnd.oci.image.layer.v1.tar) + # (C:\test.tar):(application/vnd.oci.image.layer.v1.tar:somethingelse) + # But C:\test.tar along will not match and we just return it as is. + path_and_content = re.search(r"(?P.*?:.*?):(?P.*)", ref) + if path_and_content: + return PathAndOptionalContent( + path_and_content.group("path"), path_and_content.group("content") + ) + return PathAndOptionalContent(ref, None) + else: + path_content_list = ref.split(":", 1) + return PathAndOptionalContent(path_content_list[0], path_content_list[1]) diff --git a/build/lib/oras/utils/request.py b/build/lib/oras/utils/request.py new file mode 100644 index 0000000..7fa48ae --- /dev/null +++ b/build/lib/oras/utils/request.py @@ -0,0 +1,63 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import os +import urllib.parse as urlparse +from urllib.parse import urlencode + + +def iter_localhosts(name: str): + """ + Given a url with localhost, always resolve to 127.0.0.1. + + :param name : the name of the original host string + :type name: str + """ + names = [name] + if "localhost" in name: + names.append(name.replace("localhost", "127.0.0.1")) + elif "127.0.0.1" in name: + names.append(name.replace("127.0.0.1", "localhost")) + for name in names: + yield name + + +def find_docker_config(exists: bool = True): + """ + Return the docker default config path. + """ + path = os.path.expanduser("~/.docker/config.json") + + # Allow the caller to request the path regardless of existing + if os.path.exists(path) or not exists: + return path + + +def append_url_params(url: str, params: dict) -> str: + """ + Given a dictionary of params and a url, parse the url and add extra params. + + :param url: the url string to parse + :type url: str + :param params: parameters to add + :type params: dict + """ + parts = urlparse.urlparse(url) + query = dict(urlparse.parse_qsl(parts.query)) + query.update(params) + updated = list(parts) + updated[4] = urlencode(query) + return urlparse.urlunparse(updated) + + +def get_docker_client(tls_verify: bool = True, **kwargs): + """ + Get a docker client. + + :param tls_verify : enable tls + :type tls_verify: bool + """ + import docker + + return docker.DockerClient(tls=tls_verify, **kwargs) diff --git a/build/lib/oras/version.py b/build/lib/oras/version.py new file mode 100644 index 0000000..5fa3cc4 --- /dev/null +++ b/build/lib/oras/version.py @@ -0,0 +1,26 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +__version__ = "0.1.29" +AUTHOR = "Vanessa Sochat" +EMAIL = "vsoch@users.noreply.github.com" +NAME = "oras" +PACKAGE_URL = "https://github.com/oras-project/oras-py" +KEYWORDS = "oci, registry, storage" +DESCRIPTION = "OCI Registry as Storage Python SDK" +LICENSE = "LICENSE" + +################################################################################ +# Global requirements + +INSTALL_REQUIRES = ( + ("jsonschema", {"min_version": None}), + ("requests", {"min_version": None}), +) + +TESTS_REQUIRES = (("pytest", {"min_version": "4.6.2"}),) + +DOCKER_REQUIRES = (("docker", {"exact_version": "5.0.1"}),) + +INSTALL_REQUIRES_ALL = INSTALL_REQUIRES + TESTS_REQUIRES + DOCKER_REQUIRES diff --git a/dist/oras-0.1.29-py3.10.egg b/dist/oras-0.1.29-py3.10.egg new file mode 100644 index 0000000000000000000000000000000000000000..582f46e7df0b815a41a5ba8608b588a4a3ebe837 GIT binary patch literal 80927 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

M)>#{3as4&vdRIn8}5I-tqcH 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 Tuple[str, str]: return path_content.path, path_content.content def upload_blob( - self, - blob: str, - container: container_type, - layer: dict, - do_chunked: bool = False, - refresh_headers: bool = True + self, blob: str, container: container_type, layer: dict, do_chunked: bool = False, refresh_headers: bool = True ) -> requests.Response: """ Prepare and upload a blob. @@ -258,10 +253,7 @@ def upload_blob( response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) # If we have an empty layer digest and the registry didn't accept, just return dummy successful response - if ( - response.status_code not in [200, 201, 202] - and layer["digest"] == oras.defaults.blank_hash - ): + if response.status_code not in [200, 201, 202] and layer["digest"] == oras.defaults.blank_hash: response = requests.Response() response.status_code = 200 return response @@ -330,9 +322,7 @@ def extract_tags(response: requests.Response): tags = tags[:N] return tags - def _do_paginated_request( - self, url: str, callable: Callable[[requests.Response], bool] - ): + def _do_paginated_request(self, url: str, callable: Callable[[requests.Response], bool]): """ Paginate a request for a URL. @@ -401,36 +391,20 @@ def get_container(self, name: container_type) -> oras.container.Container: # Functions to be deprecated in favor of exposed ones @decorator.ensure_container - def _download_blob( - self, container: container_type, digest: str, outfile: str - ) -> str: - logger.warning( - "This function is deprecated in favor of download_blob and will be removed by 0.1.2" - ) + def _download_blob(self, container: container_type, digest: str, outfile: str) -> str: + logger.warning("This function is deprecated in favor of download_blob and will be removed by 0.1.2") return self.download_blob(container, digest, outfile) - def _put_upload( - self, blob: str, container: oras.container.Container, layer: dict - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of put_upload and will be removed by 0.1.2" - ) + def _put_upload(self, blob: str, container: oras.container.Container, layer: dict) -> requests.Response: + logger.warning("This function is deprecated in favor of put_upload and will be removed by 0.1.2") return self.put_upload(blob, container, layer) - def _chunked_upload( - self, blob: str, container: oras.container.Container, layer: dict - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of chunked_upload and will be removed by 0.1.2" - ) + def _chunked_upload(self, blob: str, container: oras.container.Container, layer: dict) -> requests.Response: + logger.warning("This function is deprecated in favor of chunked_upload and will be removed by 0.1.2") return self.chunked_upload(blob, container, layer) - def _upload_manifest( - self, manifest: dict, container: oras.container.Container - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of upload_manifest and will be removed by 0.1.2" - ) + def _upload_manifest(self, manifest: dict, container: oras.container.Container) -> requests.Response: + logger.warning("This function is deprecated in favor of upload_manifest and will be removed by 0.1.2") return self.upload_manifest(manifest, container) def _upload_blob( @@ -440,15 +414,11 @@ def _upload_blob( layer: dict, do_chunked: bool = False, ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of upload_blob and will be removed by 0.1.2" - ) + logger.warning("This function is deprecated in favor of upload_blob and will be removed by 0.1.2") return self.upload_blob(blob, container, layer, do_chunked) @decorator.ensure_container - def download_blob( - self, container: container_type, digest: str, outfile: str - ) -> str: + def download_blob(self, container: container_type, digest: str, outfile: str) -> str: """ Stream download a blob into an output file. @@ -511,9 +481,7 @@ def put_upload( "Content-Type": "application/octet-stream", } headers.update(self.headers) - blob_url = oras.utils.append_url_params( - session_url, {"digest": layer["digest"]} - ) + blob_url = oras.utils.append_url_params(session_url, {"digest": layer["digest"]}) with open(blob, "rb") as fd: response = self.do_request( blob_url, @@ -523,9 +491,7 @@ def put_upload( ) return response - def _get_location( - self, r: requests.Response, container: oras.container.Container - ) -> str: + def _get_location(self, r: requests.Response, container: oras.container.Container) -> str: """ Parse the location header and ensure it includes a hostname. This currently assumes if there isn't a hostname, we are pushing to @@ -595,14 +561,10 @@ def chunked_upload( # Important to update with auth token if acquired headers.update(self.headers) start = end + 1 - self._check_200_response( - self.do_request(session_url, "PATCH", data=chunk, headers=headers) - ) + self._check_200_response(self.do_request(session_url, "PATCH", data=chunk, headers=headers)) # Finally, issue a PUT request to close blob - session_url = oras.utils.append_url_params( - session_url, {"digest": layer["digest"]} - ) + session_url = oras.utils.append_url_params(session_url, {"digest": layer["digest"]}) return self.do_request(session_url, "PUT", headers=self.headers) def _check_200_response(self, response: requests.Response): @@ -704,9 +666,7 @@ def push(self, *args, **kwargs) -> requests.Response: # Upload files as blobs for blob in kwargs.get("files", []): # You can provide a blob + content type - path_content: PathAndOptionalContent = oras.utils.split_path_and_content( - str(blob) - ) + path_content: PathAndOptionalContent = oras.utils.split_path_and_content(str(blob)) blob = path_content.path media_type = path_content.content @@ -717,9 +677,7 @@ def push(self, *args, **kwargs) -> requests.Response: # Path validation means blob must be relative to PWD. if validate_path: if not self._validate_path(blob): - raise ValueError( - f"Blob {blob} is not in the present working directory context." - ) + raise ValueError(f"Blob {blob} is not in the present working directory context.") # Save directory or blob name before compressing blob_name = os.path.basename(blob) @@ -735,9 +693,7 @@ def push(self, *args, **kwargs) -> requests.Response: annotations = annotset.get_annotations(blob) # Always strip blob_name of path separator - layer["annotations"] = { - oras.defaults.annotation_title: blob_name.strip(os.sep) - } + layer["annotations"] = {oras.defaults.annotation_title: blob_name.strip(os.sep)} if annotations: layer["annotations"].update(annotations) @@ -783,11 +739,7 @@ def push(self, *args, **kwargs) -> requests.Response: # Config is just another layer blob! logger.debug(f"Preparing config {conf}") - with ( - temporary_empty_config() - if config_file is None - else nullcontext(config_file) - ) as config_file: + with temporary_empty_config() if config_file is None else nullcontext(config_file) as config_file: response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) self._check_200_response(response) @@ -829,9 +781,7 @@ def pull(self, *args, **kwargs) -> List[str]: files = [] for layer in manifest.get("layers", []): - filename = (layer.get("annotations") or {}).get( - oras.defaults.annotation_title - ) + filename = (layer.get("annotations") or {}).get(oras.defaults.annotation_title) # If we don't have a filename, default to digest. Hopefully does not happen if not filename: @@ -841,9 +791,7 @@ def pull(self, *args, **kwargs) -> List[str]: outfile = oras.utils.sanitize_path(outdir, os.path.join(outdir, filename)) if not overwrite and os.path.exists(outfile): - logger.warning( - f"{outfile} already exists and --keep-old-files set, will not overwrite." - ) + logger.warning(f"{outfile} already exists and --keep-old-files set, will not overwrite.") continue # A directory will need to be uncompressed and moved @@ -976,9 +924,7 @@ def authenticate_request(self, originalResponse: requests.Response) -> bool: """ authHeaderRaw = originalResponse.headers.get("Www-Authenticate") if not authHeaderRaw: - logger.debug( - "Www-Authenticate not found in original response, cannot authenticate." - ) + logger.debug("Www-Authenticate not found in original response, cannot authenticate.") return False # If we have a token, set auth header (base64 encoded user/pass) From fdb66a1fe7d5ccdbc9a5288d348f92c406702fba Mon Sep 17 00:00:00 2001 From: Yuriy Novostavskiy Date: Fri, 5 Apr 2024 16:40:11 +0000 Subject: [PATCH 7/8] Revert "11reformat with black" This reverts commit 598f6b0e379e0e70dc73f23641f4c67ecebea51d. Signed-off-by: Yuriy Novostavskiy --- build/lib/oras/__init__.py | 1 - build/lib/oras/auth.py | 83 -- build/lib/oras/client.py | 246 ------ build/lib/oras/container.py | 124 --- build/lib/oras/decorator.py | 83 -- build/lib/oras/defaults.py | 48 -- build/lib/oras/logger.py | 306 ------- build/lib/oras/main/__init__.py | 0 build/lib/oras/main/login.py | 52 -- build/lib/oras/oci.py | 153 ---- build/lib/oras/provider.py | 1074 ------------------------- build/lib/oras/schemas.py | 58 -- build/lib/oras/tests/__init__.py | 0 build/lib/oras/tests/annotations.json | 6 - build/lib/oras/tests/conftest.py | 58 -- build/lib/oras/tests/run_registry.sh | 4 - build/lib/oras/tests/test_oras.py | 138 ---- build/lib/oras/tests/test_provider.py | 134 --- build/lib/oras/tests/test_utils.py | 132 --- build/lib/oras/utils/__init__.py | 27 - build/lib/oras/utils/fileio.py | 374 --------- build/lib/oras/utils/request.py | 63 -- build/lib/oras/version.py | 26 - dist/oras-0.1.29-py3.10.egg | Bin 80927 -> 0 bytes oras/provider.py | 102 ++- 25 files changed, 78 insertions(+), 3214 deletions(-) delete mode 100644 build/lib/oras/__init__.py delete mode 100644 build/lib/oras/auth.py delete mode 100644 build/lib/oras/client.py delete mode 100644 build/lib/oras/container.py delete mode 100644 build/lib/oras/decorator.py delete mode 100644 build/lib/oras/defaults.py delete mode 100644 build/lib/oras/logger.py delete mode 100644 build/lib/oras/main/__init__.py delete mode 100644 build/lib/oras/main/login.py delete mode 100644 build/lib/oras/oci.py delete mode 100644 build/lib/oras/provider.py delete mode 100644 build/lib/oras/schemas.py delete mode 100644 build/lib/oras/tests/__init__.py delete mode 100644 build/lib/oras/tests/annotations.json delete mode 100644 build/lib/oras/tests/conftest.py delete mode 100755 build/lib/oras/tests/run_registry.sh delete mode 100644 build/lib/oras/tests/test_oras.py delete mode 100644 build/lib/oras/tests/test_provider.py delete mode 100644 build/lib/oras/tests/test_utils.py delete mode 100644 build/lib/oras/utils/__init__.py delete mode 100644 build/lib/oras/utils/fileio.py delete mode 100644 build/lib/oras/utils/request.py delete mode 100644 build/lib/oras/version.py delete mode 100644 dist/oras-0.1.29-py3.10.egg diff --git a/build/lib/oras/__init__.py b/build/lib/oras/__init__.py deleted file mode 100644 index 3003622..0000000 --- a/build/lib/oras/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from oras.version import __version__ diff --git a/build/lib/oras/auth.py b/build/lib/oras/auth.py deleted file mode 100644 index 6e3ba5d..0000000 --- a/build/lib/oras/auth.py +++ /dev/null @@ -1,83 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import base64 -import os -import re -from typing import List, Optional - -import oras.utils -from oras.logger import logger - - -def load_configs(configs: Optional[List[str]] = None): - """ - Load one or more configs with credentials from the filesystem. - - :param configs: list of configuration paths to load, defaults to None - :type configs: optional list - """ - configs = configs or [] - default_config = oras.utils.find_docker_config() - - # Add the default docker config - if default_config: - configs.append(default_config) - configs = set(configs) # type: ignore - - # Load configs until we find our registry hostname - auths = {} - for config in configs: - if not os.path.exists(config): - logger.warning(f"{config} does not exist.") - continue - cfg = oras.utils.read_json(config) - auths.update(cfg.get("auths", {})) - return auths - - -def get_basic_auth(username: str, password: str): - """ - Prepare basic auth from a username and password. - - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - """ - auth_str = "%s:%s" % (username, password) - return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") - - -class authHeader: - def __init__(self, lookup: dict): - """ - Given a dictionary of values, match them to class attributes - - :param lookup : dictionary of key,value pairs to parse into auth header - :type lookup: dict - """ - self.service: Optional[str] = None - self.realm: Optional[str] = None - self.scope: Optional[str] = None - for key in lookup: - if key in ["realm", "service", "scope"]: - setattr(self, key, lookup[key]) - - -def parse_auth_header(authHeaderRaw: str) -> authHeader: - """ - Parse authentication header into pieces - - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - """ - regex = re.compile('([a-zA-z]+)="(.+?)"') - matches = regex.findall(authHeaderRaw) - lookup = dict() - for match in matches: - lookup[match[0]] = match[1] - return authHeader(lookup) diff --git a/build/lib/oras/client.py b/build/lib/oras/client.py deleted file mode 100644 index b788ef2..0000000 --- a/build/lib/oras/client.py +++ /dev/null @@ -1,246 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - - -import sys -from typing import List, Optional, Union - -import oras.auth -import oras.container -import oras.main.login as login -import oras.provider -import oras.utils -import oras.version - - -class OrasClient: - """ - Create an OCI Registry as Storage (ORAS) Client. - - This is intended for controlled interactions. The user of oras-py can use - this client, the terminal command line wrappers, or the functions in main - in isolation as an internal Python API. The user can provide a custom - registry as a parameter, if desired. If not provided we default to standard - oras. - """ - - def __init__( - self, - hostname: Optional[str] = None, - registry: Optional[oras.provider.Registry] = None, - insecure: bool = False, - tls_verify: bool = True, - ): - """ - Create an ORAS client. - - The hostname is the remote registry to ping. - - :param hostname: the hostname of the registry to ping - :type hostname: str - :param registry: if provided, use this custom provider instead of default - :type registry: oras.provider.Registry or None - :param insecure: use http instead of https - :type insecure: bool - """ - self.remote = registry or oras.provider.Registry(hostname, insecure, tls_verify) - - def __repr__(self) -> str: - return str(self) - - def __str__(self) -> str: - return "[oras-client]" - - def set_token_auth(self, token: str): - """ - Set token authentication. - - :param token: the bearer token - :type token: str - """ - self.remote.set_token_auth(token) - - def set_basic_auth(self, username: str, password: str): - """ - Add basic authentication to the request. - - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - """ - self.remote.set_basic_auth(username, password) - - def version(self, return_items: bool = False) -> Union[dict, str]: - """ - Get the version of the client. - - :param return_items : return the dict of version info instead of string - :type return_items: bool - """ - version = oras.version.__version__ - - python_version = "%s.%s.%s" % ( - sys.version_info.major, - sys.version_info.minor, - sys.version_info.micro, - ) - versions = {"Version": version, "Python version": python_version} - - # If the user wants the dictionary of items returned - if return_items: - return versions - - # Otherwise return a string that can be printed - return "\n".join(["%s: %s" % (k, v) for k, v in versions.items()]) - - def get_tags(self, name: str, N=None) -> List[str]: - """ - Retrieve tags for a package. - - :param name: container URI to parse - :type name: str - :param N: number of tags (None to get all tags) - :type N: int - """ - return self.remote.get_tags(name, N=N) - - def delete_tags(self, name: str, tags=Union[str, list]) -> List[str]: - """ - Delete one or more tags for a unique resource identifier. - - Returns those successfully deleted. - - :param name: container URI to parse - :type name: str - :param tags: single or multiple tags name to delete - :type N: string or list - """ - if isinstance(tags, str): - tags = [tags] - deleted = [] - for tag in tags: - if self.remote.delete_tag(name, tag): - deleted.append(tag) - return deleted - - def push(self, *args, **kwargs): - """ - Push a container to the remote. - """ - return self.remote.push(*args, **kwargs) - - def pull(self, *args, **kwargs): - """ - Pull a container from the remote. - """ - return self.remote.pull(*args, **kwargs) - - def login( - self, - username: str, - password: str, - password_stdin: bool = False, - insecure: bool = False, - tls_verify: bool = True, - hostname: Optional[str] = None, - config_path: Optional[List[str]] = None, - ) -> dict: - """ - Login to a registry. - - :param registry: if provided, use this custom provider instead of default - :type registry: oras.provider.Registry or None - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - :param password_stdin: get the password from standard input - :type password_stdin: bool - :param insecure: use http instead of https - :type insecure: bool - :param tls_verify: verify tls - :type tls_verify: bool - :param hostname: the hostname to login to - :type hostname: str - :param config_path: list of config paths to add - :type config_path: list - """ - login_func = self._login - if hasattr(self.remote, "login"): - login_func = self.remote.login # type: ignore - return login_func( - username=username, - password=password, - password_stdin=password_stdin, - tls_verify=tls_verify, - hostname=hostname, - config_path=config_path, # type: ignore - ) - - def logout(self, hostname: str): - """ - Logout from a registry, meaning removing any auth (if loaded) - - :param hostname: the hostname to login to - :type hostname: str - """ - self.remote.logout(hostname) - - def _login( - self, - username: Optional[str] = None, - password: Optional[str] = None, - password_stdin: bool = False, - tls_verify: bool = True, - hostname: Optional[str] = None, - config_path: Optional[str] = None, - ) -> dict: - """ - Login to an OCI registry. - - The username and password can come from stdin. Most people use username - password to get a token, so we are disabling providing just a token for - now. A tool that wants to provide a token should use set_token_auth. - """ - # Read password from stdin - if password_stdin: - password = oras.utils.readline() - - # No username, try to get from stdin - if not username: - username = input("Username: ") - - # No password provided - if not password: - password = input("Password: ") - if not password: - raise ValueError("password required") - - # Cut out early if we didn't get what we need - if not password or not username: - return {"Login": "Not successful"} - - # Set basic auth for the client - self.set_basic_auth(username, password) - - # Login - # https://docker-py.readthedocs.io/en/stable/client.html?highlight=login#docker.client.DockerClient.login - try: - client = oras.utils.get_docker_client(tls_verify=tls_verify) - return client.login( - username=username, - password=password, - registry=hostname, - dockercfg_path=config_path, - ) - - # Fallback to manual login - except Exception: - return login.DockerClient().login( - username=username, # type: ignore - password=password, # type: ignore - registry=hostname, # type: ignore - dockercfg_path=config_path, - ) diff --git a/build/lib/oras/container.py b/build/lib/oras/container.py deleted file mode 100644 index 81f117d..0000000 --- a/build/lib/oras/container.py +++ /dev/null @@ -1,124 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - - -import re -from typing import Optional - -import oras.defaults - -docker_regex = re.compile( - "(?:(?P[^/@]+[.:][^/@]*)/)?" - "(?P(?:[^:@/]+/)+)?" - "(?P[^:@/]+)" - "(?::(?P[^:@]+))?" - "(?:@(?P.+))?" - "$" -) - - -class Container: - def __init__(self, name: str, registry: Optional[str] = None): - """ - Parse a container name and easily get urls for registry interactions. - - :param name: the full name of the container to parse (with any components) - :type name: str - :param registry: a custom registry name, if not provided with URI - :type registry: str - """ - self.registry = registry or oras.defaults.registry.index_name - - # Registry is the name takes precendence - self.parse(name) - - @property - def api_prefix(self): - """ - Return the repository prefix for the v2 API endpoints. - """ - if self.namespace: - return f"{self.namespace}/{self.repository}" - return self.repository - - def get_blob_url(self, digest: str) -> str: - """ - Get the URL to download a blob - - :param digest: the digest to download - :type digest: str - """ - return f"{self.registry}/v2/{self.api_prefix}/blobs/{digest}" - - def upload_blob_url(self) -> str: - return f"{self.registry}/v2/{self.api_prefix}/blobs/uploads/" - - def tags_url(self, N=None) -> str: - if N is None: - return f"{self.registry}/v2/{self.api_prefix}/tags/list" - return f"{self.registry}/v2/{self.api_prefix}/tags/list?n={N}" - - def manifest_url(self, tag: Optional[str] = None) -> str: - """ - Get the manifest url for a specific tag, or the one for this container. - - The tag provided can also correspond to a digest. - - :param tag: an optional tag to provide (if not provided defaults to container) - :type tag: None or str - """ - - # an explicitly defined tag has precedence over everything, - # but from the already defined ones, prefer the digest for consistency. - tag = tag or (self.digest or self.tag) - return f"{self.registry}/v2/{self.api_prefix}/manifests/{tag}" - - def __str__(self) -> str: - return self.uri - - @property - def uri(self) -> str: - """ - Assemble the complete unique resource identifier - """ - if self.namespace: - uri = f"{self.namespace}/{self.repository}" - else: - uri = f"{self.repository}" - if self.registry: - uri = f"{self.registry}/{uri}" - - # Digest takes preference because more specific - if self.digest: - uri = f"{uri}@{self.digest}" - elif self.tag: - uri = f"{uri}:{self.tag}" - return uri - - def parse(self, name: str): - """ - Parse the container name into registry, repository, and tag. - - :param name: the full name of the container to parse (with any components) - :type name: str - """ - match = re.search(docker_regex, name) - if not match: - raise ValueError( - f"{name} does not match a recognized registry unique resource identifier. Try //:" - ) - items = match.groupdict() # type: ignore - self.repository = items["repository"] - self.registry = items["registry"] or self.registry - self.namespace = items["namespace"] - self.tag = items["tag"] or oras.defaults.default_tag - self.digest = items["digest"] - - # Repository is required - if not self.repository: - raise ValueError( - "You are minimally required to include a /" - ) - if self.namespace: - self.namespace = self.namespace.strip("/") diff --git a/build/lib/oras/decorator.py b/build/lib/oras/decorator.py deleted file mode 100644 index 885ec52..0000000 --- a/build/lib/oras/decorator.py +++ /dev/null @@ -1,83 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import time -from functools import partial, update_wrapper - -from oras.logger import logger - - -class Decorator: - """ - Shared parent decorator class - """ - - def __init__(self, func): - update_wrapper(self, func) - self.func = func - - def __get__(self, obj, objtype): - return partial(self.__call__, obj) - - -class ensure_container(Decorator): - """ - Ensure the first argument is a container, and not a string. - """ - - def __call__(self, cls, *args, **kwargs): - if "container" in kwargs: - kwargs["container"] = cls.get_container(kwargs["container"]) - elif args: - container = cls.get_container(args[0]) - args = (container, *args[1:]) - return self.func(cls, *args, **kwargs) - - -class classretry(Decorator): - """ - Retry a function that is part of a class - """ - - def __init__(self, func, attempts=5, timeout=2): - super().__init__(func) - self.attempts = attempts - self.timeout = timeout - - def __call__(self, cls, *args, **kwargs): - attempt = 0 - attempts = self.attempts - timeout = self.timeout - while attempt < attempts: - try: - return self.func(cls, *args, **kwargs) - except Exception as e: - sleep = timeout + 3**attempt - logger.info(f"Retrying in {sleep} seconds - error: {e}") - time.sleep(sleep) - attempt += 1 - return self.func(cls, *args, **kwargs) - - -def retry(attempts, timeout=2): - """ - A simple retry decorator - """ - - def decorator(func): - def inner(*args, **kwargs): - attempt = 0 - while attempt < attempts: - try: - return func(*args, **kwargs) - except Exception as e: - sleep = timeout + 3**attempt - logger.info(f"Retrying in {sleep} seconds - error: {e}") - time.sleep(sleep) - attempt += 1 - return func(*args, **kwargs) - - return inner - - return decorator diff --git a/build/lib/oras/defaults.py b/build/lib/oras/defaults.py deleted file mode 100644 index 2992290..0000000 --- a/build/lib/oras/defaults.py +++ /dev/null @@ -1,48 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors" -__license__ = "Apache-2.0" - - -# Default tag to use -default_tag = "latest" - - -# https://github.com/moby/moby/blob/master/registry/config.go#L29 -class registry: - index_hostname = "index.docker.io" - index_server = "https://index.docker.io/v1/" - index_name = "docker.io" - default_v2_registry = {"scheme": "https", "host": "registry-1.docker.io"} - - -# DefaultBlobDirMediaType specifies the default blob directory media type -default_blob_dir_media_type = "application/vnd.oci.image.layer.v1.tar+gzip" - -# MediaTypeImageLayer is the media type used for layers referenced by the manifest. -default_blob_media_type = "application/vnd.oci.image.layer.v1.tar" -unknown_config_media_type = "application/vnd.unknown.config.v1+json" -default_manifest_media_type = "application/vnd.oci.image.manifest.v1+json" - -# AnnotationDigest is the annotation key for the digest of the uncompressed content -annotation_digest = "io.deis.oras.content.digest" - -# AnnotationTitle is the annotation key for the human-readable title of the image. -annotation_title = "org.opencontainers.image.title" - -# AnnotationUnpack is the annotation key for indication of unpacking -annotation_unpack = "io.deis.oras.content.unpack" - -# OCIImageIndexFile is the file name of the index from the OCI Image Layout Specification -# Reference: https://github.com/opencontainers/image-spec/blob/master/image-layout.md#indexjson-file -oci_image_index_file = "index.json" - -# DefaultBlocksize default size of each slice of bytes read in each write through in gunzipand untar. -default_blocksize = 32768 - -# what you get for a blank digest, so we don't need to save and recalculate -blank_hash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - -# what you get for a blank config digest, so we don't need to save and recalculate -blank_config_hash = ( - "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" -) diff --git a/build/lib/oras/logger.py b/build/lib/oras/logger.py deleted file mode 100644 index 2b3c992..0000000 --- a/build/lib/oras/logger.py +++ /dev/null @@ -1,306 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import inspect -import logging as _logging -import os -import platform -import sys -import threading -from pathlib import Path -from typing import Optional, Text, TextIO, Union - - -class ColorizingStreamHandler(_logging.StreamHandler): - BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) - RESET_SEQ = "\033[0m" - COLOR_SEQ = "\033[%dm" - BOLD_SEQ = "\033[1m" - - colors = { - "WARNING": YELLOW, - "INFO": GREEN, - "DEBUG": BLUE, - "CRITICAL": RED, - "ERROR": RED, - } - - def __init__( - self, - nocolor: bool = False, - stream: Union[Text, Path, TextIO] = sys.stderr, - use_threads: bool = False, - ): - """ - Create a new ColorizingStreamHandler - - :param nocolor: do not use color - :type nocolor: bool - :param stream: stream list to this output - :type stream: bool - :param use_threads: use threads! lol - :type use_threads: bool - """ - super().__init__(stream=stream) - self._output_lock = threading.Lock() - self.nocolor = nocolor or not self.can_color_tty() - - def can_color_tty(self) -> bool: - """ - Determine if the tty supports color - """ - if "TERM" in os.environ and os.environ["TERM"] == "dumb": - return False - return self.is_tty and not platform.system() == "Windows" - - @property - def is_tty(self) -> bool: - """ - Determine if we have a tty environment - """ - isatty = getattr(self.stream, "isatty", None) - return isatty and isatty() # type: ignore - - def emit(self, record: _logging.LogRecord): - """ - Emit a log record - - :param record: the record to emit - :type record: logging.LogRecord - """ - with self._output_lock: - try: - self.format(record) # add the message to the record - self.stream.write(self.decorate(record)) - self.stream.write(getattr(self, "terminator", "\n")) - self.flush() - except BrokenPipeError as e: - raise e - except (KeyboardInterrupt, SystemExit): - # ignore any exceptions in these cases as any relevant messages have been printed before - pass - except Exception: - self.handleError(record) - - def decorate(self, record) -> str: - """ - Decorate a log record - - :param record: the record to emit - """ - message = record.message - message = [message] - if not self.nocolor and record.levelname in self.colors: - message.insert(0, self.COLOR_SEQ % (30 + self.colors[record.levelname])) - message.append(self.RESET_SEQ) - return "".join(message) - - -class Logger: - def __init__(self): - """ - Create a new logger - """ - self.logger = _logging.getLogger(__name__) - self.log_handler = [self.text_handler] - self.stream_handler = None - self.printshellcmds = False - self.quiet = False - self.logfile = None - self.last_msg_was_job_info = False - self.logfile_handler = None - - def cleanup(self): - """ - Close open files, etc. for the logger - """ - if self.logfile_handler is not None: - self.logger.removeHandler(self.logfile_handler) - self.logfile_handler.close() - self.log_handler = [self.text_handler] - - def handler(self, msg: dict): - """ - Handle a log message. - - :param msg: the message to handle - :type msg: dict - """ - for handler in self.log_handler: - handler(msg) - - def set_stream_handler(self, stream_handler: _logging.Handler): - """ - Set a stream handler. - - :param stream_handler : the stream handler - :type stream_handler: logging.Handler - """ - if self.stream_handler is not None: - self.logger.removeHandler(self.stream_handler) - self.stream_handler = stream_handler - self.logger.addHandler(stream_handler) - - def set_level(self, level: int): - """ - Set the logging level. - - :param level: the logging level to set - :type level: int - """ - self.logger.setLevel(level) - - def location(self, msg: str): - """ - Debug level message with location info. - - :param msg: the logging message - :type msg: dict - """ - callerframerecord = inspect.stack()[1] - frame = callerframerecord[0] - info = inspect.getframeinfo(frame) - self.debug( - "{}: {info.filename}, {info.function}, {info.lineno}".format(msg, info=info) - ) - - def info(self, msg: str): - """ - Info level message - - :param msg: the informational message - :type msg: str - """ - self.handler({"level": "info", "msg": msg}) - - def warning(self, msg: str): - """ - Warning level message - - :param msg: the warning message - :type msg: str - """ - self.handler({"level": "warning", "msg": msg}) - - def debug(self, msg: str): - """ - Debug level message - - :param msg: the debug message - :type msg: str - """ - self.handler({"level": "debug", "msg": msg}) - - def error(self, msg: str): - """ - Error level message - - :param msg: the error message - :type msg: str - """ - self.handler({"level": "error", "msg": msg}) - - def exit(self, msg: str, return_code: int = 1): - """ - Error level message and exit with error code - - :param msg: the exiting (error) message - :type msg: str - :param return_code: return code to exit on - :type return_code: int - """ - self.handler({"level": "error", "msg": msg}) - sys.exit(return_code) - - def progress(self, done: int, total: int): - """ - Show piece of a progress bar - - :param done: count of total that is complete - :type done: int - :param total: count of total - :type total: int - """ - self.handler({"level": "progress", "done": done, "total": total}) - - def shellcmd(self, msg: Optional[str]): - """ - Shellcmd message - - :param msg: the message - :type msg: str - """ - if msg is not None: - self.handler({"level": "shellcmd", "msg": msg}) - - def text_handler(self, msg: dict): - """ - The default log handler that prints to the console. - - :param msg: the log message dict - :type msg: dict - """ - level = msg["level"] - if level == "info" and not self.quiet: - self.logger.info(msg["msg"]) - if level == "warning": - self.logger.warning(msg["msg"]) - elif level == "error": - self.logger.error(msg["msg"]) - elif level == "debug": - self.logger.debug(msg["msg"]) - elif level == "progress" and not self.quiet: - done = msg["done"] - total = msg["total"] - p = done / total - percent_fmt = ("{:.2%}" if p < 0.01 else "{:.0%}").format(p) - self.logger.info( - "{} of {} steps ({}) done".format(done, total, percent_fmt) - ) - elif level == "shellcmd": - if self.printshellcmds: - self.logger.warning(msg["msg"]) - - -logger = Logger() - - -def setup_logger( - quiet: bool = False, - printshellcmds: bool = False, - nocolor: bool = False, - stdout: bool = False, - debug: bool = False, - use_threads: bool = False, -): - """ - Setup the logger. This should be called from an init or client. - - :param quiet: set logging level to quiet - :type quiet: bool - :param printshellcmds: a special level to print shell commands - :type printshellcmds: bool - :param nocolor: do not use color - :type nocolor: bool - :param stdout: print to standard output for the logger - :type stdout: bool - :param debug: debug level logging - :type debug: bool - :param use_threads: use threads! - :type use_threads: bool - """ - # console output only if no custom logger was specified - stream_handler = ColorizingStreamHandler( - nocolor=nocolor, - stream=sys.stdout if stdout else sys.stderr, - use_threads=use_threads, - ) - level = _logging.INFO - if debug: - level = _logging.DEBUG - - logger.set_stream_handler(stream_handler) - logger.set_level(level) - logger.quiet = quiet - logger.printshellcmds = printshellcmds diff --git a/build/lib/oras/main/__init__.py b/build/lib/oras/main/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/build/lib/oras/main/login.py b/build/lib/oras/main/login.py deleted file mode 100644 index 9b5e493..0000000 --- a/build/lib/oras/main/login.py +++ /dev/null @@ -1,52 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import os -from typing import Optional - -import oras.auth -import oras.utils - - -class DockerClient: - """ - If running inside a container (or similar without docker) do a manual login - """ - - def login( - self, - username: str, - password: str, - registry: str, - dockercfg_path: Optional[str] = None, - ) -> dict: - """ - Manual login means loading and checking the config file - - :param registry: if provided, use this custom provider instead of default - :type registry: oras.provider.Registry or None - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - :param dockercfg_str: docker config path - :type dockercfg_str: list - """ - if not dockercfg_path: - dockercfg_path = oras.utils.find_docker_config(exists=False) - if os.path.exists(dockercfg_path): # type: ignore - cfg = oras.utils.read_json(dockercfg_path) # type: ignore - else: - oras.utils.mkdir_p(os.path.dirname(dockercfg_path)) # type: ignore - cfg = {"auths": {}} - if registry in cfg["auths"]: - cfg["auths"][registry]["auth"] = oras.auth.get_basic_auth( - username, password - ) - else: - cfg["auths"][registry] = { - "auth": oras.auth.get_basic_auth(username, password) - } - oras.utils.write_json(cfg, dockercfg_path) # type: ignore - return {"Status": "Login Succeeded"} diff --git a/build/lib/oras/oci.py b/build/lib/oras/oci.py deleted file mode 100644 index 4265f7d..0000000 --- a/build/lib/oras/oci.py +++ /dev/null @@ -1,153 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import copy -import os -from typing import Dict, Optional, Tuple - -import jsonschema - -import oras.defaults -import oras.schemas -import oras.utils - -EmptyManifest = { - "schemaVersion": 2, - "mediaType": oras.defaults.default_manifest_media_type, - "config": {}, - "layers": [], - "annotations": {}, -} - - -class Annotations: - """ - Create a new set of annotations - """ - - def __init__(self, filename=None): - self.lookup = {} - self.load(filename) - - def add(self, section, key, value): - """ - Add key/value pairs to a named section. - """ - if section not in self.lookup: - self.lookup[section] = {} - self.lookup[section][key] = value - - def load(self, filename: str): - if filename and os.path.exists(filename): - self.lookup = oras.utils.read_json(filename) - if filename and not os.path.exists(filename): - raise FileNotFoundError(f"Annotation file {filename} does not exist.") - - def get_annotations(self, section: str) -> dict: - """ - Given the name (a relative path or named section) get annotations - """ - for name in section, os.path.abspath(section): - if name in self.lookup: - return self.lookup[name] - return {} - - -class Layer: - def __init__( - self, blob_path: str, media_type: Optional[str] = None, is_dir: bool = False - ): - """ - Create a new Layer - - :param blob_path: the path of the blob for the layer - :type blob_path: str - :param media_type: media type for the blob (optional) - :type media_type: str - :param is_dir: is the blob a directory? - :type is_dir: bool - """ - self.blob_path = blob_path - self.set_media_type(media_type, is_dir) - - def set_media_type(self, media_type: Optional[str] = None, is_dir: bool = False): - """ - Vary the media type to be directory or default layer - - :param media_type: media type for the blob (optional) - :type media_type: str - :param is_dir: is the blob a directory? - :type is_dir: bool - """ - self.media_type = media_type - if is_dir and not media_type: - self.media_type = oras.defaults.default_blob_dir_media_type - elif not is_dir and not media_type: - self.media_type = oras.defaults.default_blob_media_type - - def to_dict(self): - """ - Return a dictionary representation of the layer - """ - layer = { - "mediaType": self.media_type, - "size": oras.utils.get_size(self.blob_path), - "digest": "sha256:" + oras.utils.get_file_hash(self.blob_path), - } - jsonschema.validate(layer, schema=oras.schemas.layer) - return layer - - -def NewLayer( - blob_path: str, media_type: Optional[str] = None, is_dir: bool = False -) -> dict: - """ - Courtesy function to create and retrieve a layer as dict - - :param blob_path: the path of the blob for the layer - :type blob_path: str - :param media_type: media type for the blob (optional) - :type media_type: str - :param is_dir: is the blob a directory? - :type is_dir: bool - """ - return Layer(blob_path=blob_path, media_type=media_type, is_dir=is_dir).to_dict() - - -def ManifestConfig( - path: Optional[str] = None, media_type: Optional[str] = None -) -> Tuple[Dict[str, object], Optional[str]]: - """ - Write an empty config, if one is not provided - - :param path: the path of the manifest config, if exists. - :type path: str - :param media_type: media type for the manifest config (optional) - :type media_type: str - """ - # Create an empty config if we don't have one - if not path or not os.path.exists(path): - path = None - conf = { - "mediaType": media_type or oras.defaults.unknown_config_media_type, - "size": 2, - "digest": oras.defaults.blank_config_hash, - } - - else: - conf = { - "mediaType": media_type or oras.defaults.unknown_config_media_type, - "size": oras.utils.get_size(path), - "digest": "sha256:" + oras.utils.get_file_hash(path), - } - - jsonschema.validate(conf, schema=oras.schemas.layer) - return conf, path - - -def NewManifest() -> dict: - """ - Get an empty manifest config. - """ - return copy.deepcopy(EmptyManifest) diff --git a/build/lib/oras/provider.py b/build/lib/oras/provider.py deleted file mode 100644 index 1416fd7..0000000 --- a/build/lib/oras/provider.py +++ /dev/null @@ -1,1074 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import copy -import os -import urllib -from contextlib import contextmanager, nullcontext -from dataclasses import asdict, dataclass -from http.cookiejar import DefaultCookiePolicy -from tempfile import TemporaryDirectory -from typing import Callable, Generator, List, Optional, Tuple, Union - -import jsonschema -import requests - -import oras.auth -import oras.container -import oras.decorator as decorator -import oras.oci -import oras.schemas -import oras.utils -from oras.logger import logger -from oras.utils.fileio import PathAndOptionalContent - -# container type can be string or container -container_type = Union[str, oras.container.Container] - - -@contextmanager -def temporary_empty_config() -> Generator[str, None, None]: - with TemporaryDirectory() as tmpdir: - config_file = oras.utils.get_tmpfile(tmpdir=tmpdir, suffix=".json") - oras.utils.write_file(config_file, "{}") - yield config_file - - -@dataclass -class Subject: - mediaType: str - digest: str - size: int - - -class Registry: - """ - Direct interactions with an OCI registry. - - This could also be called a "provider" when we add in the "copy" logic - and the registry isn't necessarily the "remote" endpoint. - """ - - def __init__( - self, - hostname: Optional[str] = None, - insecure: bool = False, - tls_verify: bool = True, - ): - """ - Create a new registry provider. - - :param hostname: the registry hostname (optional) - :type hostname: str - :param insecure: use http instead of https - :type insecure: bool - :param tls_verify: verify TLS certificates - :type tls_verify: bool - """ - self.hostname: Optional[str] = hostname - self.headers: dict = {} - self.session: requests.Session = requests.Session() - self.prefix: str = "http" if insecure else "https" - self.token: Optional[str] = None - self._auths: dict = {} - self._basic_auth = None - self._tls_verify = tls_verify - - if not tls_verify: - requests.packages.urllib3.disable_warnings() # type: ignore - - # Ignore all cookies: some registries try to set one - # and take it as a sign they are talking to a browser, - # trying to set further CSRF cookies (Harbor is such a case) - self.session.cookies.set_policy(DefaultCookiePolicy(allowed_domains=[])) - - def logout(self, hostname: str): - """ - If auths are loaded, remove a hostname. - - :param hostname: the registry hostname to remove - :type hostname: str - """ - # Remove any basic auth or token - self._basic_auth = None - self.token = None - - if not self._auths: - logger.info(f"You are not logged in to {hostname}") - return - - for host in oras.utils.iter_localhosts(hostname): - if host in self._auths: - del self._auths[host] - logger.info(f"You have successfully logged out of {hostname}") - return - logger.info(f"You are not logged in to {hostname}") - - @decorator.ensure_container - def load_configs(self, container: container_type, configs: Optional[list] = None): - """ - Load configs to discover credentials for a specific container. - - This is typically just called once. We always add the default Docker - config to the set.s - - :param container: the parsed container URI with components - :type container: oras.container.Container - :param configs: list of configs to read (optional) - :type configs: list - """ - if not self._auths: - self._auths = oras.auth.load_configs(configs) - for registry in oras.utils.iter_localhosts(container.registry): # type: ignore - if self._load_auth(registry): - return - - def _load_auth(self, hostname: str) -> bool: - """ - Look for and load a named authentication token. - - :param hostname: the registry hostname to look for - :type hostname: str - """ - # Note that the hostname can be defined without a token - if hostname in self._auths: - auth = self._auths[hostname].get("auth") - - # Case 1: they use a credsStore we don't know how to read - if not auth and "credsStore" in self._auths[hostname]: - logger.warning( - '"credsStore" found in your ~/.docker/config.json, which is not supported by oras-py. Remove it, docker login, and try again.' - ) - return False - - # Case 2: no auth there (wonky file) - elif not auth: - return False - self._basic_auth = auth - return True - return False - - def set_basic_auth(self, username: str, password: str): - """ - Set basic authentication. - - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - """ - self._basic_auth = oras.auth.get_basic_auth(username, password) - self.set_header("Authorization", "Basic %s" % self._basic_auth) - - def set_token_auth(self, token: str): - """ - Set token authentication. - - :param token: the bearer token - :type token: str - """ - self.token = token - self.set_header("Authorization", "Bearer %s" % token) - - def reset_basic_auth(self): - """ - Given we have basic auth, reset it. - """ - if "Authorization" in self.headers: - del self.headers["Authorization"] - if self._basic_auth: - self.set_header("Authorization", "Basic %s" % self._basic_auth) - - def set_header(self, name: str, value: str): - """ - Courtesy function to set a header - - :param name: header name to set - :type name: str - :param value: header value to set - :type value: str - """ - self.headers.update({name: value}) - - def _validate_path(self, path: str) -> bool: - """ - Ensure a blob path is in the present working directory or below. - - :param path: the path to validate - :type path: str - """ - return os.getcwd() in os.path.abspath(path) - - def _parse_manifest_ref(self, ref: str) -> Tuple[str, str]: - """ - Parse an optional manifest config. - - Examples - -------- - : - path/to/config:application/vnd.oci.image.config.v1+json - /dev/null:application/vnd.oci.image.config.v1+json - - :param ref: the manifest reference to parse (examples above) - :type ref: str - :return - A Tuple of the path and the content-type, using the default unknown - config media type if none found in the reference - """ - path_content: PathAndOptionalContent = oras.utils.split_path_and_content(ref) - if not path_content.content: - path_content.content = oras.defaults.unknown_config_media_type - return path_content.path, path_content.content - - def upload_blob( - self, - blob: str, - container: container_type, - layer: dict, - do_chunked: bool = False, - refresh_headers: bool = True - ) -> requests.Response: - """ - Prepare and upload a blob. - - Sizes > 1024 are uploaded via a chunked approach (post, patch+, put) - and <= 1024 is a single post then put. - - :param blob: path to blob to upload - :type blob: str - :param container: parsed container URI - :type container: oras.container.Container or str - :param layer: dict from oras.oci.NewLayer - :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool - """ - blob = os.path.abspath(blob) - container = self.get_container(container) - - # Always reset headers between uploads - self.reset_basic_auth() - - # Chunked for large, otherwise POST and PUT - # This is currently disabled unless the user asks for it, as - # it doesn't seem to work for all registries - if not do_chunked: - response = self.put_upload(blob, container, layer, refresh_headers=refresh_headers) - else: - response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) - - # If we have an empty layer digest and the registry didn't accept, just return dummy successful response - if ( - response.status_code not in [200, 201, 202] - and layer["digest"] == oras.defaults.blank_hash - ): - response = requests.Response() - response.status_code = 200 - return response - - @decorator.ensure_container - def delete_tag(self, container: container_type, tag: str) -> bool: - """ - Delete a tag for a container. - - :param container: parsed container URI - :type container: oras.container.Container or str - :param tag: name of tag to delete - :type tag: str - """ - logger.debug(f"Deleting tag {tag} for {container}") - - head_url = f"{self.prefix}://{container.manifest_url(tag)}" # type: ignore - - # get digest of manifest to delete - response = self.do_request( - head_url, - "HEAD", - headers={"Accept": "application/vnd.oci.image.manifest.v1+json"}, - ) - if response.status_code == 404: - logger.error(f"Cannot find tag {container}:{tag}") - return False - - digest = response.headers.get("Docker-Content-Digest") - if not digest: - raise RuntimeError("Expected to find Docker-Content-Digest header.") - - delete_url = f"{self.prefix}://{container.manifest_url(digest)}" # type: ignore - response = self.do_request(delete_url, "DELETE") - if response.status_code != 202: - raise RuntimeError("Delete was not successful: {response.json()}") - return True - - @decorator.ensure_container - def get_tags(self, container: container_type, N=None) -> List[str]: - """ - Retrieve tags for a package. - - :param container: parsed container URI - :type container: oras.container.Container or str - :param N: limit number of tags, None for all (default) - :type N: Optional[int] - """ - retrieve_all = N is None - tags_url = f"{self.prefix}://{container.tags_url(N=N)}" # type: ignore - tags: List[str] = [] - - def extract_tags(response: requests.Response): - """ - Determine if we should continue based on new tags and under limit. - """ - json = response.json() - new_tags = json.get("tags") or [] - tags.extend(new_tags) - return len(new_tags) and (retrieve_all or len(tags) < N) - - self._do_paginated_request(tags_url, callable=extract_tags) - - # If we got a longer set than was asked for - if N is not None and len(tags) > N: - tags = tags[:N] - return tags - - def _do_paginated_request( - self, url: str, callable: Callable[[requests.Response], bool] - ): - """ - Paginate a request for a URL. - - We look for the "Link" header to get the next URL to ping. If - the callable returns True, we continue to the next page, otherwise - we stop. - """ - # Save the base url to add parameters to, assuming only the params change - parts = urllib.parse.urlparse(url) - base_url = f"{parts.scheme}://{parts.netloc}" - - # get all results using the pagination - while True: - response = self.do_request(url, "GET", headers=self.headers) - - # Check 200 response, show errors if any - self._check_200_response(response) - - want_more = callable(response) - if not want_more: - break - - link = response.links.get("next", {}).get("url") - - # Get the next link - if not link: - break - - # use link + base url to continue with next page - url = f"{base_url}{link}" - - @decorator.ensure_container - def get_blob( - self, - container: container_type, - digest: str, - stream: bool = False, - head: bool = False, - ) -> requests.Response: - """ - Retrieve a blob for a package. - - :param container: parsed container URI - :type container: oras.container.Container or str - :param digest: sha256 digest of the blob to retrieve - :type digest: str - :param stream: stream the response (or not) - :type stream: bool - :param head: use head to determine if blob exists - :type head: bool - """ - method = "GET" if not head else "HEAD" - blob_url = f"{self.prefix}://{container.get_blob_url(digest)}" # type: ignore - return self.do_request(blob_url, method, headers=self.headers, stream=stream) - - def get_container(self, name: container_type) -> oras.container.Container: - """ - Courtesy function to get a container from a URI. - - :param name: unique resource identifier to parse - :type name: oras.container.Container or str - """ - if isinstance(name, oras.container.Container): - return name - return oras.container.Container(name, registry=self.hostname) - - # Functions to be deprecated in favor of exposed ones - @decorator.ensure_container - def _download_blob( - self, container: container_type, digest: str, outfile: str - ) -> str: - logger.warning( - "This function is deprecated in favor of download_blob and will be removed by 0.1.2" - ) - return self.download_blob(container, digest, outfile) - - def _put_upload( - self, blob: str, container: oras.container.Container, layer: dict - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of put_upload and will be removed by 0.1.2" - ) - return self.put_upload(blob, container, layer) - - def _chunked_upload( - self, blob: str, container: oras.container.Container, layer: dict - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of chunked_upload and will be removed by 0.1.2" - ) - return self.chunked_upload(blob, container, layer) - - def _upload_manifest( - self, manifest: dict, container: oras.container.Container - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of upload_manifest and will be removed by 0.1.2" - ) - return self.upload_manifest(manifest, container) - - def _upload_blob( - self, - blob: str, - container: container_type, - layer: dict, - do_chunked: bool = False, - ) -> requests.Response: - logger.warning( - "This function is deprecated in favor of upload_blob and will be removed by 0.1.2" - ) - return self.upload_blob(blob, container, layer, do_chunked) - - @decorator.ensure_container - def download_blob( - self, container: container_type, digest: str, outfile: str - ) -> str: - """ - Stream download a blob into an output file. - - This function is a wrapper around get_blob. - - :param container: parsed container URI - :type container: oras.container.Container or str - """ - try: - # Ensure output directory exists first - outdir = os.path.dirname(outfile) - if outdir and not os.path.exists(outdir): - oras.utils.mkdir_p(outdir) - with self.get_blob(container, digest, stream=True) as r: - r.raise_for_status() - with open(outfile, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - # Allow an empty layer to fail and return /dev/null - except Exception as e: - if digest == oras.defaults.blank_hash: - return os.devnull - raise e - return outfile - - def put_upload( - self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True - ) -> requests.Response: - """ - Upload to a registry via put. - - :param blob: path to blob to upload - :type blob: str - :param container: parsed container URI - :type container: oras.container.Container or str - :param layer: dict from oras.oci.NewLayer - :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool - """ - # Start an upload session - headers = {"Content-Type": "application/octet-stream"} - - if not refresh_headers: - headers.update(self.headers) - - upload_url = f"{self.prefix}://{container.upload_blob_url()}" - r = self.do_request(upload_url, "POST", headers=headers) - - # Location should be in the header - session_url = self._get_location(r, container) - if not session_url: - raise ValueError(f"Issue retrieving session url: {r.json()}") - - # PUT to upload blob url - headers = { - "Content-Length": str(layer["size"]), - "Content-Type": "application/octet-stream", - } - headers.update(self.headers) - blob_url = oras.utils.append_url_params( - session_url, {"digest": layer["digest"]} - ) - with open(blob, "rb") as fd: - response = self.do_request( - blob_url, - method="PUT", - data=fd.read(), - headers=headers, - ) - return response - - def _get_location( - self, r: requests.Response, container: oras.container.Container - ) -> str: - """ - Parse the location header and ensure it includes a hostname. - This currently assumes if there isn't a hostname, we are pushing to - the same registry hostname of the original request. - - :param r: requests response with headers - :type r: requests.Response - :param container: parsed container URI - :type container: oras.container.Container or str - """ - session_url = r.headers.get("location", "") - if not session_url: - return session_url - - # Some registries do not return the full registry hostname. Check that - # the url starts with a protocol scheme, change tracked with: - # https://github.com/oras-project/oras-py/issues/78 - prefix = f"{self.prefix}://{container.registry}" - - if not session_url.startswith("http"): - session_url = f"{prefix}{session_url}" - return session_url - - def chunked_upload( - self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True - ) -> requests.Response: - """ - Upload via a chunked upload. - - :param blob: path to blob to upload - :type blob: str - :param container: parsed container URI - :type container: oras.container.Container or str - :param layer: dict from oras.oci.NewLayer - :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool - """ - # Start an upload session - headers = {"Content-Type": "application/octet-stream", "Content-Length": "0"} - if not refresh_headers: - headers.update(self.headers) - - upload_url = f"{self.prefix}://{container.upload_blob_url()}" - r = self.do_request(upload_url, "POST", headers=headers) - - # Location should be in the header - session_url = self._get_location(r, container) - if not session_url: - raise ValueError(f"Issue retrieving session url: {r.json()}") - - # Read the blob in chunks, for each do a patch - start = 0 - with open(blob, "rb") as fd: - for chunk in oras.utils.read_in_chunks(fd): - if not chunk: - break - - end = start + len(chunk) - 1 - content_range = "%s-%s" % (start, end) - headers = { - "Content-Range": content_range, - "Content-Length": str(len(chunk)), - "Content-Type": "application/octet-stream", - } - - # Important to update with auth token if acquired - headers.update(self.headers) - start = end + 1 - self._check_200_response( - self.do_request(session_url, "PATCH", data=chunk, headers=headers) - ) - - # Finally, issue a PUT request to close blob - session_url = oras.utils.append_url_params( - session_url, {"digest": layer["digest"]} - ) - return self.do_request(session_url, "PUT", headers=self.headers) - - def _check_200_response(self, response: requests.Response): - """ - Helper function to ensure some flavor of 200 - - :param response: request response to inspect - :type response: requests.Response - """ - if response.status_code not in [200, 201, 202]: - self._parse_response_errors(response) - raise ValueError(f"Issue with {response.request.url}: {response.reason}") - - def _parse_response_errors(self, response: requests.Response): - """ - Given a failed request, look for OCI formatted error messages. - - :param response: request response to inspect - :type response: requests.Response - """ - try: - msg = response.json() - for error in msg.get("errors", []): - if isinstance(error, dict) and "message" in error: - logger.error(error["message"]) - except Exception: - pass - - def upload_manifest( - self, manifest: dict, container: oras.container.Container, refresh_headers: bool = True - ) -> requests.Response: - """ - Read a manifest file and upload it. - - :param manifest: manifest to upload - :type manifest: dict - :param container: parsed container URI - :type container: oras.container.Container or str - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool - """ - self.reset_basic_auth() - jsonschema.validate(manifest, schema=oras.schemas.manifest) - headers = { - "Content-Type": oras.defaults.default_manifest_media_type, - "Content-Length": str(len(manifest)), - } - - if not refresh_headers: - headers.update(self.headers) - - return self.do_request( - f"{self.prefix}://{container.manifest_url()}", # noqa - "PUT", - headers=headers, - json=manifest, - ) - - def push(self, *args, **kwargs) -> requests.Response: - """ - Push a set of files to a target - - :param config_path: path to a config file - :type config_path: str - :param disable_path_validation: ensure paths are relative to the running directory. - :type disable_path_validation: bool - :param files: list of files to push - :type files: list - :param insecure: allow registry to use http - :type insecure: bool - :param annotation_file: manifest annotations file - :type annotation_file: str - :param manifest_annotations: manifest annotations - :type manifest_annotations: dict - :param target: target location to push to - :type target: str - :param refresh_headers: if true or None, headers are refreshed - :type refresh_headers: bool - :param subject: optional subject reference - :type subject: Subject - """ - container = self.get_container(kwargs["target"]) - self.load_configs(container, configs=kwargs.get("config_path")) - - # Hold state of request for http/https - validate_path = not kwargs.get("disable_path_validation", False) - - # Prepare a new manifest - manifest = oras.oci.NewManifest() - - # A lookup of annotations we can add (to blobs or manifest) - annotset = oras.oci.Annotations(kwargs.get("annotation_file")) - media_type = None - - refresh_headers = kwargs.get("refresh_headers") - if refresh_headers is None: - refresh_headers = True - - # Upload files as blobs - for blob in kwargs.get("files", []): - # You can provide a blob + content type - path_content: PathAndOptionalContent = oras.utils.split_path_and_content( - str(blob) - ) - blob = path_content.path - media_type = path_content.content - - # Must exist - if not os.path.exists(blob): - raise FileNotFoundError(f"{blob} does not exist.") - - # Path validation means blob must be relative to PWD. - if validate_path: - if not self._validate_path(blob): - raise ValueError( - f"Blob {blob} is not in the present working directory context." - ) - - # Save directory or blob name before compressing - blob_name = os.path.basename(blob) - - # If it's a directory, we need to compress - cleanup_blob = False - if os.path.isdir(blob): - blob = oras.utils.make_targz(blob) - cleanup_blob = True - - # Create a new layer from the blob - layer = oras.oci.NewLayer(blob, is_dir=cleanup_blob, media_type=media_type) - annotations = annotset.get_annotations(blob) - - # Always strip blob_name of path separator - layer["annotations"] = { - oras.defaults.annotation_title: blob_name.strip(os.sep) - } - if annotations: - layer["annotations"].update(annotations) - - # update the manifest with the new layer - manifest["layers"].append(layer) - logger.debug(f"Preparing layer {layer}") - - # Upload the blob layer - response = self.upload_blob(blob, container, layer, refresh_headers=refresh_headers) - self._check_200_response(response) - - # Do we need to cleanup a temporary targz? - if cleanup_blob and os.path.exists(blob): - os.remove(blob) - - # Add annotations to the manifest, if provided - manifest_annots = annotset.get_annotations("$manifest") or {} - - # Custom manifest annotations from client key=value pairs - # These over-ride any potentially provided from file - custom_annots = kwargs.get("manifest_annotations") - if custom_annots: - manifest_annots.update(custom_annots) - if manifest_annots: - manifest["annotations"] = manifest_annots - - subject = kwargs.get("subject") - if subject: - manifest["subject"] = asdict(subject) - - # Prepare the manifest config (temporary or one provided) - manifest_config = kwargs.get("manifest_config") - config_annots = annotset.get_annotations("$config") - if manifest_config: - ref, media_type = self._parse_manifest_ref(manifest_config) - conf, config_file = oras.oci.ManifestConfig(ref, media_type) - else: - conf, config_file = oras.oci.ManifestConfig() - - # Config annotations? - if config_annots: - conf["annotations"] = config_annots - - # Config is just another layer blob! - logger.debug(f"Preparing config {conf}") - with ( - temporary_empty_config() - if config_file is None - else nullcontext(config_file) - ) as config_file: - response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) - - self._check_200_response(response) - - # Final upload of the manifest - manifest["config"] = conf - self._check_200_response(self.upload_manifest(manifest, container, refresh_headers=refresh_headers)) - print(f"Successfully pushed {container}") - return response - - def pull(self, *args, **kwargs) -> List[str]: - """ - Pull an artifact from a target - - :param config_path: path to a config file - :type config_path: str - :param allowed_media_type: list of allowed media types - :type allowed_media_type: list or None - :param overwrite: if output file exists, overwrite - :type overwrite: bool - :param refresh_headers: if true, headers are refreshed when fetching manifests - :type refresh_headers: bool - :param manifest_config_ref: save manifest config to this file - :type manifest_config_ref: str - :param outdir: output directory path - :type outdir: str - :param target: target location to pull from - :type target: str - """ - allowed_media_type = kwargs.get("allowed_media_type") - refresh_headers = kwargs.get("refresh_headers") - if refresh_headers is None: - refresh_headers = True - container = self.get_container(kwargs["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) - - files = [] - for layer in manifest.get("layers", []): - filename = (layer.get("annotations") or {}).get( - oras.defaults.annotation_title - ) - - # If we don't have a filename, default to digest. Hopefully does not happen - if not filename: - filename = layer["digest"] - - # This raises an error if there is a malicious path - outfile = oras.utils.sanitize_path(outdir, os.path.join(outdir, filename)) - - if not overwrite and os.path.exists(outfile): - logger.warning( - f"{outfile} already exists and --keep-old-files set, will not overwrite." - ) - continue - - # A directory will need to be uncompressed and moved - if layer["mediaType"] == oras.defaults.default_blob_dir_media_type: - targz = oras.utils.get_tmpfile(suffix=".tar.gz") - self.download_blob(container, layer["digest"], targz) - - # The artifact will be extracted to the correct name - oras.utils.extract_targz(targz, os.path.dirname(outfile)) - - # Anything else just extracted directly - else: - self.download_blob(container, layer["digest"], outfile) - logger.info(f"Successfully pulled {outfile}.") - files.append(outfile) - return files - - @decorator.ensure_container - def get_manifest( - self, - container: container_type, - allowed_media_type: Optional[list] = None, - refresh_headers: bool = True, - ) -> dict: - """ - Retrieve a manifest for a package. - - :param container: parsed container URI - :type container: oras.container.Container or str - :param allowed_media_type: one or more allowed media types - :type allowed_media_type: str - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool - """ - if not allowed_media_type: - allowed_media_type = [oras.defaults.default_manifest_media_type] - headers = {"Accept": ";".join(allowed_media_type)} - - if not refresh_headers: - headers.update(self.headers) - - get_manifest = f"{self.prefix}://{container.manifest_url()}" # type: ignore - response = self.do_request(get_manifest, "GET", headers=headers) - self._check_200_response(response) - manifest = response.json() - jsonschema.validate(manifest, schema=oras.schemas.manifest) - return manifest - - @decorator.classretry - def do_request( - self, - url: str, - method: str = "GET", - data: Optional[Union[dict, bytes]] = None, - headers: Optional[dict] = None, - json: Optional[dict] = None, - stream: bool = False, - ): - """ - Do a request. This is a wrapper around requests to handle retry auth. - - :param url: the URL to issue the request to - :type url: str - :param method: the method to use (GET, DELETE, POST, PUT, PATCH) - :type method: str - :param data: data for requests - :type data: dict or bytes - :param headers: headers for the request - :type headers: dict - :param json: json data for requests - :type json: dict - :param stream: stream the responses - :type stream: bool - """ - headers = headers or {} - - # Make the request and return to calling function, unless requires auth - response = self.session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - verify=self._tls_verify, - ) - - # A 401 response is a request for authentication - if response.status_code not in [401, 404]: - return response - - # Otherwise, authenticate the request and retry - if self.authenticate_request(response): - headers.update(self.headers) - response = self.session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - ) - - # Fallback to using Authorization if already required - # This is a catch for EC2. I don't think this is correct - # A basic token should be used for a bearer one. - if response.status_code in [401, 404] and "Authorization" in self.headers: - logger.debug("Trying with provided Basic Authorization...") - headers.update(self.headers) - response = self.session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - ) - - return response - - def authenticate_request(self, originalResponse: requests.Response) -> bool: - """ - Authenticate Request - Given a response, look for a Www-Authenticate header to parse. - - We return True/False to indicate if the request should be retried. - - :param originalResponse: original response to get the Www-Authenticate header - :type originalResponse: requests.Response - """ - authHeaderRaw = originalResponse.headers.get("Www-Authenticate") - if not authHeaderRaw: - logger.debug( - "Www-Authenticate not found in original response, cannot authenticate." - ) - return False - - # If we have a token, set auth header (base64 encoded user/pass) - if self.token: - self.set_header("Authorization", "Bearer %s" % self._basic_auth) - return True - - headers = copy.deepcopy(self.headers) - h = oras.auth.parse_auth_header(authHeaderRaw) - - if "Authorization" not in headers: - # First try to request an anonymous token - logger.debug("No Authorization, requesting anonymous token") - if self.request_anonymous_token(h): - logger.debug("Successfully obtained anonymous token!") - return True - - logger.error( - "This endpoint requires a token. Please set " - "oras.provider.Registry.set_basic_auth(username, password) " - "first or use oras-py login to do the same." - ) - return False - - params = {} - - # Prepare request to retry - if h.service: - logger.debug(f"Service: {h.service}") - params["service"] = h.service - headers.update( - { - "Service": h.service, - "Accept": "application/json", - "User-Agent": "oras-py", - } - ) - - # Ensure the realm starts with http - if not h.realm.startswith("http"): # type: ignore - h.realm = f"{self.prefix}://{h.realm}" - - # If the www-authenticate included a scope, honor it! - if h.scope: - logger.debug(f"Scope: {h.scope}") - params["scope"] = h.scope - - authResponse = self.session.get(h.realm, headers=headers, params=params) # type: ignore - if authResponse.status_code != 200: - logger.debug(f"Auth response was not successful: {authResponse.text}") - return False - - # Request the token - info = authResponse.json() - token = info.get("token") or info.get("access_token") - - # Set the token to the original request and retry - self.headers.update({"Authorization": "Bearer %s" % token}) - return True - - def request_anonymous_token(self, h: oras.auth.authHeader) -> bool: - """ - Given no basic auth, fall back to trying to request an anonymous token. - - Returns: boolean if headers have been updated with token. - """ - if not h.realm: - logger.debug("Request anonymous token: no realm provided, exiting early") - return False - - params = {} - if h.service: - params["service"] = h.service - if h.scope: - params["scope"] = h.scope - - logger.debug(f"Final params are {params}") - response = self.session.request("GET", h.realm, params=params) - if response.status_code != 200: - logger.debug(f"Response for anon token failed: {response.text}") - return False - - # From https://docs.docker.com/registry/spec/auth/token/ section - # We can get token OR access_token OR both (when both they are identical) - data = response.json() - token = data.get("token") or data.get("access_token") - - # Update the headers but not self.token (expects Basic) - if token: - self.headers.update({"Authorization": "Bearer %s" % token}) - return True - logger.debug("Warning: no token or access_token present in response.") - return False diff --git a/build/lib/oras/schemas.py b/build/lib/oras/schemas.py deleted file mode 100644 index 3a0eb7a..0000000 --- a/build/lib/oras/schemas.py +++ /dev/null @@ -1,58 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -## Manifest and Layer schemas - -schema_url = "http://json-schema.org/draft-07/schema" - -# Layer - -layerProperties = { - "type": "object", - "properties": { - "mediaType": {"type": "string"}, - "size": {"type": "number"}, - "digest": {"type": "string"}, - "annotations": {"type": ["object", "null", "array"]}, - }, -} - -layer = { - "$schema": schema_url, - "title": "Layer Schema", - "required": [ - "mediaType", - "size", - "digest", - ], - "additionalProperties": True, -} - -layer.update(layerProperties) - - -# Manifest - -manifestProperties = { - "schemaVersion": {"type": "number"}, - "subject": {"type": ["null", "object"]}, - "mediaType": {"type": "string"}, - "layers": {"type": "array", "items": layerProperties}, - "config": layerProperties, - "annotations": {"type": ["object", "null", "array"]}, -} - - -manifest = { - "$schema": schema_url, - "title": "Manifest Schema", - "type": "object", - "required": [ - "schemaVersion", - "config", - "layers", - ], - "properties": manifestProperties, - "additionalProperties": True, -} diff --git a/build/lib/oras/tests/__init__.py b/build/lib/oras/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/build/lib/oras/tests/annotations.json b/build/lib/oras/tests/annotations.json deleted file mode 100644 index d0be02f..0000000 --- a/build/lib/oras/tests/annotations.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$manifest": { - "holiday": "Christmas", - "candy": "candy-corn" - } -} diff --git a/build/lib/oras/tests/conftest.py b/build/lib/oras/tests/conftest.py deleted file mode 100644 index 7b038dc..0000000 --- a/build/lib/oras/tests/conftest.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -from dataclasses import dataclass - -import pytest - - -@dataclass -class TestCredentials: - with_auth: bool - user: str - password: str - - -@pytest.fixture -def registry(): - host = os.environ.get("ORAS_HOST") - port = os.environ.get("ORAS_PORT") - - if not host or not port: - pytest.skip( - "You must export ORAS_HOST and ORAS_PORT" - " for a running registry before running tests." - ) - - return f"{host}:{port}" - - -@pytest.fixture -def credentials(request): - with_auth = os.environ.get("ORAS_AUTH") == "true" - user = os.environ.get("ORAS_USER", "myuser") - pwd = os.environ.get("ORAS_PASS", "mypass") - - if with_auth and not user or not pwd: - pytest.skip("To test auth you need to export ORAS_USER and ORAS_PASS") - - marks = [m.name for m in request.node.iter_markers()] - if request.node.parent: - marks += [m.name for m in request.node.parent.iter_markers()] - - if request.node.get_closest_marker("with_auth"): - if request.node.get_closest_marker("with_auth").args[0] != with_auth: - if with_auth: - pytest.skip("test requires un-authenticated access to registry") - else: - pytest.skip("test requires authenticated access to registry") - - return TestCredentials(with_auth, user, pwd) - - -@pytest.fixture -def target(registry): - return f"{registry}/dinosaur/artifact:v1" - - -@pytest.fixture -def target_dir(registry): - return f"{registry}/dinosaur/directory:v1" diff --git a/build/lib/oras/tests/run_registry.sh b/build/lib/oras/tests/run_registry.sh deleted file mode 100755 index e86145d..0000000 --- a/build/lib/oras/tests/run_registry.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -# A helper script to easily run a development, local registry -docker run -it -e REGISTRY_STORAGE_DELETE_ENABLED=true --rm -p 5000:5000 ghcr.io/oras-project/registry:latest diff --git a/build/lib/oras/tests/test_oras.py b/build/lib/oras/tests/test_oras.py deleted file mode 100644 index 06df44f..0000000 --- a/build/lib/oras/tests/test_oras.py +++ /dev/null @@ -1,138 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import os -import shutil - -import pytest - -import oras.client - -here = os.path.abspath(os.path.dirname(__file__)) - - -def test_basic_oras(registry): - """ - Basic tests for oras (without authentication) - """ - client = oras.client.OrasClient(hostname=registry, insecure=True) - assert "Python version" in client.version() - - -@pytest.mark.with_auth(True) -def test_login_logout(registry, credentials): - """ - Login and logout are all we can test with basic auth! - """ - client = oras.client.OrasClient(hostname=registry, insecure=True) - res = client.login( - hostname=registry, - username=credentials.user, - password=credentials.password, - insecure=True, - ) - assert res["Status"] == "Login Succeeded" - client.logout(registry) - - -@pytest.mark.with_auth(False) -def test_basic_push_pull(tmp_path, registry, credentials, 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] - - # Test pulling elsewhere - files = client.pull(target=target, outdir=tmp_path) - assert len(files) == 1 - assert os.path.basename(files[0]) == "artifact.txt" - assert str(tmp_path) in files[0] - assert os.path.exists(files[0]) - - # Move artifact outside of context (should not work) - moved_artifact = tmp_path / os.path.basename(artifact) - shutil.copyfile(artifact, moved_artifact) - with pytest.raises(ValueError): - client.push(files=[moved_artifact], target=target) - - # This should work because we aren't checking paths - res = client.push(files=[artifact], target=target, disable_path_validation=True) - assert res.status_code == 201 - - -@pytest.mark.with_auth(False) -def test_get_delete_tags(tmp_path, registry, credentials, target): - """ - Test creationg, getting, and deleting tags. - """ - 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] - - # Test getting tags - tags = client.get_tags(target) - assert "v1" in tags - - # Test deleting not-existence tag - 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 - - -def test_get_many_tags(): - """ - Test getting many tags - """ - client = oras.client.OrasClient(hostname="ghcr.io", insecure=False) - - # Test getting tags with a limit set - tags = client.get_tags( - "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1005 - ) - assert len(tags) == 1005 - - # This should retrieve all tags (defaults to None) - tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp") - assert len(tags) > 1500 - - # Same result if explicitly set - same_tags = client.get_tags( - "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=None - ) - assert not set(tags).difference(set(same_tags)) - - # Small number of tags - tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=10) - assert not set(tags).difference(set(same_tags)) - assert len(tags) == 10 - - -@pytest.mark.with_auth(False) -def test_directory_push_pull(tmp_path, registry, credentials, target_dir): - """ - Test push and pull for directory - """ - client = oras.client.OrasClient(hostname=registry, insecure=True) - - # Test upload of a directory - upload_dir = os.path.join(here, "upload_data") - res = client.push(files=[upload_dir], target=target_dir) - assert res.status_code == 201 - files = client.pull(target=target_dir, outdir=tmp_path) - - assert len(files) == 1 - assert os.path.basename(files[0]) == "upload_data" - assert str(tmp_path) in files[0] - assert os.path.exists(files[0]) - assert "artifact.txt" in os.listdir(files[0]) diff --git a/build/lib/oras/tests/test_provider.py b/build/lib/oras/tests/test_provider.py deleted file mode 100644 index d3b014b..0000000 --- a/build/lib/oras/tests/test_provider.py +++ /dev/null @@ -1,134 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import os -from pathlib import Path - -import pytest - -import oras.client -import oras.defaults -import oras.provider -import oras.utils - -here = os.path.abspath(os.path.dirname(__file__)) - - -@pytest.mark.with_auth(False) -def test_annotated_registry_push(tmp_path, registry, credentials, target): - """ - Basic tests for oras push with annotations - """ - - # Direct access to registry functions - remote = oras.provider.Registry(hostname=registry, insecure=True) - client = oras.client.OrasClient(hostname=registry, insecure=True) - artifact = os.path.join(here, "artifact.txt") - - assert os.path.exists(artifact) - - # Custom manifest annotations - annots = {"holiday": "Halloween", "candy": "chocolate"} - res = client.push(files=[artifact], target=target, manifest_annotations=annots) - assert res.status_code in [200, 201] - - # Get the manifest - manifest = remote.get_manifest(target) - assert "annotations" in manifest - for k, v in annots.items(): - assert k in manifest["annotations"] - assert manifest["annotations"][k] == v - - # Annotations from file with $manifest - annotation_file = os.path.join(here, "annotations.json") - file_annots = oras.utils.read_json(annotation_file) - assert "$manifest" in file_annots - res = client.push(files=[artifact], target=target, annotation_file=annotation_file) - assert res.status_code in [200, 201] - manifest = remote.get_manifest(target) - - assert "annotations" in manifest - for k, v in file_annots["$manifest"].items(): - assert k in manifest["annotations"] - assert manifest["annotations"][k] == v - - # File that doesn't exist - annotation_file = os.path.join(here, "annotations-nope.json") - with pytest.raises(FileNotFoundError): - res = client.push( - files=[artifact], target=target, annotation_file=annotation_file - ) - - -def test_parse_manifest(registry): - """ - Test parse manifest function. - - Parse manifest function has additional logic for Windows - this isn't included in - these tests as they don't usually run on Windows. - """ - testref = "path/to/config:application/vnd.oci.image.config.v1+json" - remote = oras.provider.Registry(hostname=registry, insecure=True) - ref, content_type = remote._parse_manifest_ref(testref) - assert ref == "path/to/config" - assert content_type == "application/vnd.oci.image.config.v1+json" - - testref = "path/to/config:application/vnd.oci.image.config.v1+json:extra" - remote = oras.provider.Registry(hostname=registry, insecure=True) - ref, content_type = remote._parse_manifest_ref(testref) - assert ref == "path/to/config" - assert content_type == "application/vnd.oci.image.config.v1+json:extra" - - testref = "/dev/null:application/vnd.oci.image.manifest.v1+json" - ref, content_type = remote._parse_manifest_ref(testref) - assert ref == "/dev/null" - assert content_type == "application/vnd.oci.image.manifest.v1+json" - - testref = "/dev/null" - ref, content_type = remote._parse_manifest_ref(testref) - assert ref == "/dev/null" - assert content_type == oras.defaults.unknown_config_media_type - - testref = "path/to/config.json" - ref, content_type = remote._parse_manifest_ref(testref) - assert ref == "path/to/config.json" - assert content_type == oras.defaults.unknown_config_media_type - - -def test_sanitize_path(): - HOME_DIR = str(Path.home()) - assert str(oras.utils.sanitize_path(HOME_DIR, HOME_DIR)) == f"{HOME_DIR}" - assert ( - str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "username"))) - == f"{HOME_DIR}/username" - ) - assert ( - str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, ".", "username"))) - == f"{HOME_DIR}/username" - ) - - with pytest.raises(Exception) as e: - assert oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "..")) - assert ( - str(e.value) - == f"Filename {Path(os.path.join(HOME_DIR, '..')).resolve()} is not in {HOME_DIR} directory" - ) - - assert oras.utils.sanitize_path("", "") == str(Path(".").resolve()) - assert oras.utils.sanitize_path("/opt", os.path.join("/opt", "image_name")) == str( - Path("/opt/image_name").resolve() - ) - assert oras.utils.sanitize_path("/../../", "/") == str(Path("/").resolve()) - assert oras.utils.sanitize_path( - Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..") - ) == str(Path("..").resolve()) - - with pytest.raises(Exception) as e: - assert oras.utils.sanitize_path( - Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..", "..") - ) != str(Path("../..").resolve()) - assert ( - str(e.value) - == f"Filename {Path(os.path.join(os.getcwd(), '..', '..')).resolve()} is not in {Path('../').resolve()} directory" - ) diff --git a/build/lib/oras/tests/test_utils.py b/build/lib/oras/tests/test_utils.py deleted file mode 100644 index 440d381..0000000 --- a/build/lib/oras/tests/test_utils.py +++ /dev/null @@ -1,132 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import json -import os -import pathlib -import shutil - -import pytest - -import oras.utils as utils - - -def test_write_read_files(tmp_path): - print("Testing utils.write_file...") - - tmpfile = str(tmp_path / "written_file.txt") - assert not os.path.exists(tmpfile) - utils.write_file(tmpfile, "hello!") - assert os.path.exists(tmpfile) - - print("Testing utils.read_file...") - - content = utils.read_file(tmpfile) - assert content == "hello!" - - -def test_workdir(tmp_path): - print("Testing utils.workdir") - noodle_base = os.path.join(tmp_path, "noodles") - os.makedirs(noodle_base) - pathlib.Path(os.path.join(noodle_base, "pasta.txt")).touch() - assert "pasta.txt" not in os.listdir() - with utils.workdir(noodle_base): - assert "pasta.txt" in os.listdir() - - -def test_write_bad_json(tmp_path): - bad_json = {"Wakkawakkawakka'}": [{True}, "2", 3]} - tmpfile = str(tmp_path / "json_file.txt") - assert not os.path.exists(tmpfile) - with pytest.raises(TypeError): - utils.write_json(bad_json, tmpfile) - - -def test_write_json(tmp_path): - good_json = {"Wakkawakkawakka": [True, "2", 3]} - tmpfile = str(tmp_path / "good_json_file.txt") - - assert not os.path.exists(tmpfile) - utils.write_json(good_json, tmpfile) - with open(tmpfile, "r") as f: - content = json.loads(f.read()) - assert isinstance(content, dict) - assert "Wakkawakkawakka" in content - content = utils.read_json(tmpfile) - assert "Wakkawakkawakka" in content - - -def test_copyfile(tmp_path): - print("Testing utils.copyfile") - - original = str(tmp_path / "location1.txt") - dest = str(tmp_path / "location2.txt") - print(original) - print(dest) - utils.write_file(original, "CONTENT IN FILE") - utils.copyfile(original, dest) - assert os.path.exists(original) - assert os.path.exists(dest) - - -def test_get_tmpdir_tmpfile(): - print("Testing utils.get_tmpdir, get_tmpfile") - - tmpdir = utils.get_tmpdir() - assert os.path.exists(tmpdir) - assert os.path.basename(tmpdir).startswith("oras") - shutil.rmtree(tmpdir) - tmpdir = utils.get_tmpdir(prefix="name") - assert os.path.basename(tmpdir).startswith("name") - shutil.rmtree(tmpdir) - tmpfile = utils.get_tmpfile() - assert "oras" in tmpfile - os.remove(tmpfile) - tmpfile = utils.get_tmpfile(prefix="pancakes") - assert "pancakes" in tmpfile - os.remove(tmpfile) - - -def test_mkdir_p(tmp_path): - print("Testing utils.mkdir_p") - - dirname = str(tmp_path / "input") - result = os.path.join(dirname, "level1", "level2", "level3") - utils.mkdir_p(result) - assert os.path.exists(result) - - -def test_print_json(): - print("Testing utils.print_json") - result = utils.print_json({1: 1}) - assert result == '{\n "1": 1\n}' - - -def test_split_path_and_content(): - """ - Test split path and content function. - - Function has additional logic for Windows - this isn't included in these tests as - they don't usually run on Windows. - """ - testref = "path/to/config:application/vnd.oci.image.config.v1+json" - path_content = utils.split_path_and_content(testref) - assert path_content.path == "path/to/config" - assert path_content.content == "application/vnd.oci.image.config.v1+json" - - testref = "/dev/null:application/vnd.oci.image.config.v1+json" - path_content = utils.split_path_and_content(testref) - assert path_content.path == "/dev/null" - assert path_content.content == "application/vnd.oci.image.config.v1+json" - - testref = "/dev/null" - path_content = utils.split_path_and_content(testref) - assert path_content.path == "/dev/null" - assert not path_content.content - - testref = "path/to/config.json" - path_content = utils.split_path_and_content(testref) - assert path_content.path == "path/to/config.json" - assert not path_content.content diff --git a/build/lib/oras/utils/__init__.py b/build/lib/oras/utils/__init__.py deleted file mode 100644 index a64749c..0000000 --- a/build/lib/oras/utils/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from .fileio import ( - copyfile, - extract_targz, - get_file_hash, - get_size, - get_tmpdir, - get_tmpfile, - make_targz, - mkdir_p, - print_json, - read_file, - read_in_chunks, - read_json, - readline, - recursive_find, - sanitize_path, - split_path_and_content, - workdir, - write_file, - write_json, -) -from .request import ( - append_url_params, - find_docker_config, - get_docker_client, - iter_localhosts, -) diff --git a/build/lib/oras/utils/fileio.py b/build/lib/oras/utils/fileio.py deleted file mode 100644 index db30820..0000000 --- a/build/lib/oras/utils/fileio.py +++ /dev/null @@ -1,374 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import errno -import hashlib -import io -import json -import os -import pathlib -import re -import shutil -import stat -import sys -import tarfile -import tempfile -from contextlib import contextmanager -from typing import Generator, Optional, TextIO, Union - - -class PathAndOptionalContent: - """Class for holding a path reference and optional content parsed from a string.""" - - def __init__(self, path: str, content: Optional[str] = None): - self.path = path - self.content = content - - -def make_targz(source_dir: str, dest_name: Optional[str] = None) -> str: - """ - Make a targz (compressed) archive from a source directory. - """ - dest_name = dest_name or get_tmpfile(suffix=".tar.gz") - with tarfile.open(dest_name, "w:gz") as tar: - tar.add(source_dir, arcname=os.path.basename(source_dir)) - return dest_name - - -def sanitize_path(expected_dir, path): - """ - Ensure a path resolves to be in the expected parent directory. - - It can be directly there or a child, but not outside it. - We raise an error if it does not - this should not happen - """ - base_dir = pathlib.Path(expected_dir).expanduser().resolve() - path = pathlib.Path(path).expanduser().resolve() # path = base_dir + file_name - if not ((base_dir in path.parents) or (str(base_dir) == str(path))): - raise Exception(f"Filename {path} is not in {base_dir} directory") - return str(path) - - -@contextmanager -def workdir(dirname): - """ - Provide context for a working directory, e.g., - - with workdir(name): - # do stuff - """ - here = os.getcwd() - os.chdir(dirname) - try: - yield - finally: - os.chdir(here) - - -def readline() -> str: - """ - Read lines from stdin - """ - content = sys.stdin.readlines() - return content[0].strip() - - -def extract_targz(targz: str, outdir: str, numeric_owner: bool = False): - """ - Extract a .tar.gz to an output directory. - """ - with tarfile.open(targz, "r:gz") as tar: - for member in tar.getmembers(): - member_path = os.path.join(outdir, member.name) - if not is_within_directory(outdir, member_path): - raise Exception("Attempted Path Traversal in Tar File") - tar.extractall(outdir, members=None, numeric_owner=numeric_owner) - - -def is_within_directory(directory: str, target: str) -> bool: - """ - Determine whether a file is within a directory - """ - abs_directory = os.path.abspath(directory) - abs_target = os.path.abspath(target) - prefix = os.path.commonprefix([abs_directory, abs_target]) - return prefix == abs_directory - - -def get_size(path: str) -> int: - """ - Get the size of a blob - - :param path : the path to get the size for - :type path: str - """ - return pathlib.Path(path).stat().st_size - - -def get_file_hash(path: str, algorithm: str = "sha256") -> str: - """ - Return an sha256 hash of the file based on an algorithm - Raises AttributeError if incorrect algorithm supplied. - - :param path: the path to get the size for - :type path: str - :param algorithm: the algorithm to use - :type algorithm: str - """ - hasher = getattr(hashlib, algorithm)() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - hasher.update(chunk) - return hasher.hexdigest() - - -def mkdir_p(path: str): - """ - Make a directory path if it does not exist, akin to mkdir -p - - :param path : the path to create - :type path: str - """ - try: - os.makedirs(path) - except OSError as e: - if e.errno == errno.EEXIST and os.path.isdir(path): - pass - else: - raise ValueError(f"Error creating path {path}.") - - -def get_tmpfile( - tmpdir: Optional[str] = None, prefix: str = "", suffix: str = "" -) -> str: - """ - Get a temporary file with an optional prefix. - - :param tmpdir : an optional temporary directory - :type tmpdir: str - :param prefix: an optional prefix for the temporary path - :type prefix: str - :param suffix: an optional suffix (extension) - :type suffix: str - """ - # First priority for the base goes to the user requested. - tmpdir = get_tmpdir(tmpdir) - - # If tmpdir is set, add to prefix - if tmpdir: - prefix = os.path.join(tmpdir, os.path.basename(prefix)) - - fd, tmp_file = tempfile.mkstemp(prefix=prefix, suffix=suffix) - os.close(fd) - - return tmp_file - - -def get_tmpdir( - tmpdir: Optional[str] = None, prefix: Optional[str] = "", create: bool = True -) -> str: - """ - Get a temporary directory for an operation. - - :param tmpdir: an optional temporary directory - :type tmpdir: str - :param prefix: an optional prefix for the temporary path - :type prefix: str - :param create: create the directory - :type create: bool - """ - tmpdir = tmpdir or tempfile.gettempdir() - prefix = prefix or "oras-tmp" - prefix = "%s.%s" % (prefix, next(tempfile._get_candidate_names())) # type: ignore - tmpdir = os.path.join(tmpdir, prefix) - - if not os.path.exists(tmpdir) and create is True: - os.mkdir(tmpdir) - - return tmpdir - - -def recursive_find(base: str, pattern: Optional[str] = None) -> Generator: - """ - Find filenames that match a particular pattern, and yield them. - - :param base : the root to search - :type base: str - :param pattern: an optional file pattern to use with fnmatch - :type pattern: str - """ - # We can identify modules by finding module.lua - for root, folders, files in os.walk(base): - for file in files: - fullpath = os.path.abspath(os.path.join(root, file)) - - if pattern and not re.search(pattern, fullpath): - continue - - yield fullpath - - -def copyfile(source: str, destination: str, force: bool = True) -> str: - """ - Copy a file from a source to its destination. - - :param source: the source to copy from - :type source: str - :param destination: the destination to copy to - :type destination: str - :param force: force copy if destination already exists - :type force: bool - """ - # Case 1: It's already there, we aren't replacing it :) - if source == destination and force is False: - return destination - - # Case 2: It's already there, we ARE replacing it :) - if os.path.exists(destination) and force is True: - os.remove(destination) - - shutil.copyfile(source, destination) - return destination - - -def write_file( - filename: str, content: str, mode: str = "w", make_exec: bool = False -) -> str: - """ - Write content to a filename - - :param filename: filname to write - :type filename: str - :param content: content to write - :type content: str - :param mode: mode to write - :type mode: str - :param make_exec: make executable - :type make_exec: bool - """ - with open(filename, mode) as filey: - filey.writelines(content) - if make_exec: - st = os.stat(filename) - - # Execute / search permissions for the user and others - os.chmod(filename, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - return filename - - -def read_in_chunks(image: Union[TextIO, io.BufferedReader], chunk_size: int = 1024): - """ - Helper function to read file in chunks, with default size 1k. - - :param image: file descriptor - :type image: TextIO or io.BufferedReader - :param chunk_size: size of the chunk - :type chunk_size: int - """ - while True: - data = image.read(chunk_size) - if not data: - break - yield data - - -def write_json(json_obj: dict, filename: str, mode: str = "w") -> str: - """ - Write json to a filename - - :param json_obj: json object to write - :type json_obj: dict - :param filename: filename to write - :type filename: str - :param mode: mode to write - :type mode: str - """ - with open(filename, mode) as filey: - filey.writelines(print_json(json_obj)) - return filename - - -def print_json(json_obj: dict) -> str: - """ - Pretty print json. - - :param json_obj: json object to print - :type json_obj: dict - """ - return json.dumps(json_obj, indent=4, separators=(",", ": ")) - - -def read_file(filename: str, mode: str = "r") -> str: - """ - Read a file. - - :param filename: filename to read - :type filename: str - :param mode: mode to read - :type mode: str - """ - with open(filename, mode) as filey: - content = filey.read() - return content - - -def read_json(filename: str, mode: str = "r") -> dict: - """ - Read a json file to a dictionary. - - :param filename: filename to read - :type filename: str - :param mode: mode to read - :type mode: str - """ - return json.loads(read_file(filename)) - - -def split_path_and_content(ref: str) -> PathAndOptionalContent: - """ - Parse a string containing a path and an optional content - - Examples - -------- - : - path/to/config:application/vnd.oci.image.config.v1+json - /dev/null:application/vnd.oci.image.config.v1+json - C:\\myconfig:application/vnd.oci.image.config.v1+json - - Or, - - /dev/null - C:\\myconfig - - :param ref: the manifest reference to parse (examples above) - :type ref: str - : return: A Tuple of the path in the reference, and the content-type if one found, - otherwise None. - """ - if ":" not in ref: - return PathAndOptionalContent(ref, None) - - if pathlib.Path(ref).drive: - # Running on Windows and Path has Windows drive letter in it, it definitely has - # one colon and could have two or feasibly more, e.g. - # C:\test.tar - # C:\test.tar:application/vnd.oci.image.layer.v1.tar - # C:\test.tar:application/vnd.oci.image.layer.v1.tar:somethingelse - # - # This regex matches two colons in the string and returns everything before - # the second colon as the "path" group and everything after the second colon - # as the "context" group. - # i.e. - # (C:\test.tar):(application/vnd.oci.image.layer.v1.tar) - # (C:\test.tar):(application/vnd.oci.image.layer.v1.tar:somethingelse) - # But C:\test.tar along will not match and we just return it as is. - path_and_content = re.search(r"(?P.*?:.*?):(?P.*)", ref) - if path_and_content: - return PathAndOptionalContent( - path_and_content.group("path"), path_and_content.group("content") - ) - return PathAndOptionalContent(ref, None) - else: - path_content_list = ref.split(":", 1) - return PathAndOptionalContent(path_content_list[0], path_content_list[1]) diff --git a/build/lib/oras/utils/request.py b/build/lib/oras/utils/request.py deleted file mode 100644 index 7fa48ae..0000000 --- a/build/lib/oras/utils/request.py +++ /dev/null @@ -1,63 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -import os -import urllib.parse as urlparse -from urllib.parse import urlencode - - -def iter_localhosts(name: str): - """ - Given a url with localhost, always resolve to 127.0.0.1. - - :param name : the name of the original host string - :type name: str - """ - names = [name] - if "localhost" in name: - names.append(name.replace("localhost", "127.0.0.1")) - elif "127.0.0.1" in name: - names.append(name.replace("127.0.0.1", "localhost")) - for name in names: - yield name - - -def find_docker_config(exists: bool = True): - """ - Return the docker default config path. - """ - path = os.path.expanduser("~/.docker/config.json") - - # Allow the caller to request the path regardless of existing - if os.path.exists(path) or not exists: - return path - - -def append_url_params(url: str, params: dict) -> str: - """ - Given a dictionary of params and a url, parse the url and add extra params. - - :param url: the url string to parse - :type url: str - :param params: parameters to add - :type params: dict - """ - parts = urlparse.urlparse(url) - query = dict(urlparse.parse_qsl(parts.query)) - query.update(params) - updated = list(parts) - updated[4] = urlencode(query) - return urlparse.urlunparse(updated) - - -def get_docker_client(tls_verify: bool = True, **kwargs): - """ - Get a docker client. - - :param tls_verify : enable tls - :type tls_verify: bool - """ - import docker - - return docker.DockerClient(tls=tls_verify, **kwargs) diff --git a/build/lib/oras/version.py b/build/lib/oras/version.py deleted file mode 100644 index 5fa3cc4..0000000 --- a/build/lib/oras/version.py +++ /dev/null @@ -1,26 +0,0 @@ -__author__ = "Vanessa Sochat" -__copyright__ = "Copyright The ORAS Authors." -__license__ = "Apache-2.0" - -__version__ = "0.1.29" -AUTHOR = "Vanessa Sochat" -EMAIL = "vsoch@users.noreply.github.com" -NAME = "oras" -PACKAGE_URL = "https://github.com/oras-project/oras-py" -KEYWORDS = "oci, registry, storage" -DESCRIPTION = "OCI Registry as Storage Python SDK" -LICENSE = "LICENSE" - -################################################################################ -# Global requirements - -INSTALL_REQUIRES = ( - ("jsonschema", {"min_version": None}), - ("requests", {"min_version": None}), -) - -TESTS_REQUIRES = (("pytest", {"min_version": "4.6.2"}),) - -DOCKER_REQUIRES = (("docker", {"exact_version": "5.0.1"}),) - -INSTALL_REQUIRES_ALL = INSTALL_REQUIRES + TESTS_REQUIRES + DOCKER_REQUIRES diff --git a/dist/oras-0.1.29-py3.10.egg b/dist/oras-0.1.29-py3.10.egg deleted file mode 100644 index 582f46e7df0b815a41a5ba8608b588a4a3ebe837..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80927 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

M)>#{3as4&vdRIn8}5I-tqcH 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 Tuple[str, str]: return path_content.path, path_content.content def upload_blob( - self, blob: str, container: container_type, layer: dict, do_chunked: bool = False, refresh_headers: bool = True + self, + blob: str, + container: container_type, + layer: dict, + do_chunked: bool = False, + refresh_headers: bool = True ) -> requests.Response: """ Prepare and upload a blob. @@ -253,7 +258,10 @@ def upload_blob( response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) # If we have an empty layer digest and the registry didn't accept, just return dummy successful response - if response.status_code not in [200, 201, 202] and layer["digest"] == oras.defaults.blank_hash: + if ( + response.status_code not in [200, 201, 202] + and layer["digest"] == oras.defaults.blank_hash + ): response = requests.Response() response.status_code = 200 return response @@ -322,7 +330,9 @@ def extract_tags(response: requests.Response): tags = tags[:N] return tags - def _do_paginated_request(self, url: str, callable: Callable[[requests.Response], bool]): + def _do_paginated_request( + self, url: str, callable: Callable[[requests.Response], bool] + ): """ Paginate a request for a URL. @@ -391,20 +401,36 @@ def get_container(self, name: container_type) -> oras.container.Container: # Functions to be deprecated in favor of exposed ones @decorator.ensure_container - def _download_blob(self, container: container_type, digest: str, outfile: str) -> str: - logger.warning("This function is deprecated in favor of download_blob and will be removed by 0.1.2") + def _download_blob( + self, container: container_type, digest: str, outfile: str + ) -> str: + logger.warning( + "This function is deprecated in favor of download_blob and will be removed by 0.1.2" + ) return self.download_blob(container, digest, outfile) - def _put_upload(self, blob: str, container: oras.container.Container, layer: dict) -> requests.Response: - logger.warning("This function is deprecated in favor of put_upload and will be removed by 0.1.2") + def _put_upload( + self, blob: str, container: oras.container.Container, layer: dict + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of put_upload and will be removed by 0.1.2" + ) return self.put_upload(blob, container, layer) - def _chunked_upload(self, blob: str, container: oras.container.Container, layer: dict) -> requests.Response: - logger.warning("This function is deprecated in favor of chunked_upload and will be removed by 0.1.2") + def _chunked_upload( + self, blob: str, container: oras.container.Container, layer: dict + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of chunked_upload and will be removed by 0.1.2" + ) return self.chunked_upload(blob, container, layer) - def _upload_manifest(self, manifest: dict, container: oras.container.Container) -> requests.Response: - logger.warning("This function is deprecated in favor of upload_manifest and will be removed by 0.1.2") + def _upload_manifest( + self, manifest: dict, container: oras.container.Container + ) -> requests.Response: + logger.warning( + "This function is deprecated in favor of upload_manifest and will be removed by 0.1.2" + ) return self.upload_manifest(manifest, container) def _upload_blob( @@ -414,11 +440,15 @@ def _upload_blob( layer: dict, do_chunked: bool = False, ) -> requests.Response: - logger.warning("This function is deprecated in favor of upload_blob and will be removed by 0.1.2") + logger.warning( + "This function is deprecated in favor of upload_blob and will be removed by 0.1.2" + ) return self.upload_blob(blob, container, layer, do_chunked) @decorator.ensure_container - def download_blob(self, container: container_type, digest: str, outfile: str) -> str: + def download_blob( + self, container: container_type, digest: str, outfile: str + ) -> str: """ Stream download a blob into an output file. @@ -481,7 +511,9 @@ def put_upload( "Content-Type": "application/octet-stream", } headers.update(self.headers) - blob_url = oras.utils.append_url_params(session_url, {"digest": layer["digest"]}) + blob_url = oras.utils.append_url_params( + session_url, {"digest": layer["digest"]} + ) with open(blob, "rb") as fd: response = self.do_request( blob_url, @@ -491,7 +523,9 @@ def put_upload( ) return response - def _get_location(self, r: requests.Response, container: oras.container.Container) -> str: + def _get_location( + self, r: requests.Response, container: oras.container.Container + ) -> str: """ Parse the location header and ensure it includes a hostname. This currently assumes if there isn't a hostname, we are pushing to @@ -561,10 +595,14 @@ def chunked_upload( # Important to update with auth token if acquired headers.update(self.headers) start = end + 1 - self._check_200_response(self.do_request(session_url, "PATCH", data=chunk, headers=headers)) + self._check_200_response( + self.do_request(session_url, "PATCH", data=chunk, headers=headers) + ) # Finally, issue a PUT request to close blob - session_url = oras.utils.append_url_params(session_url, {"digest": layer["digest"]}) + session_url = oras.utils.append_url_params( + session_url, {"digest": layer["digest"]} + ) return self.do_request(session_url, "PUT", headers=self.headers) def _check_200_response(self, response: requests.Response): @@ -666,7 +704,9 @@ def push(self, *args, **kwargs) -> requests.Response: # Upload files as blobs for blob in kwargs.get("files", []): # You can provide a blob + content type - path_content: PathAndOptionalContent = oras.utils.split_path_and_content(str(blob)) + path_content: PathAndOptionalContent = oras.utils.split_path_and_content( + str(blob) + ) blob = path_content.path media_type = path_content.content @@ -677,7 +717,9 @@ def push(self, *args, **kwargs) -> requests.Response: # Path validation means blob must be relative to PWD. if validate_path: if not self._validate_path(blob): - raise ValueError(f"Blob {blob} is not in the present working directory context.") + raise ValueError( + f"Blob {blob} is not in the present working directory context." + ) # Save directory or blob name before compressing blob_name = os.path.basename(blob) @@ -693,7 +735,9 @@ def push(self, *args, **kwargs) -> requests.Response: annotations = annotset.get_annotations(blob) # Always strip blob_name of path separator - layer["annotations"] = {oras.defaults.annotation_title: blob_name.strip(os.sep)} + layer["annotations"] = { + oras.defaults.annotation_title: blob_name.strip(os.sep) + } if annotations: layer["annotations"].update(annotations) @@ -739,7 +783,11 @@ def push(self, *args, **kwargs) -> requests.Response: # Config is just another layer blob! logger.debug(f"Preparing config {conf}") - with temporary_empty_config() if config_file is None else nullcontext(config_file) as config_file: + with ( + temporary_empty_config() + if config_file is None + else nullcontext(config_file) + ) as config_file: response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) self._check_200_response(response) @@ -781,7 +829,9 @@ def pull(self, *args, **kwargs) -> List[str]: files = [] for layer in manifest.get("layers", []): - filename = (layer.get("annotations") or {}).get(oras.defaults.annotation_title) + filename = (layer.get("annotations") or {}).get( + oras.defaults.annotation_title + ) # If we don't have a filename, default to digest. Hopefully does not happen if not filename: @@ -791,7 +841,9 @@ def pull(self, *args, **kwargs) -> List[str]: outfile = oras.utils.sanitize_path(outdir, os.path.join(outdir, filename)) if not overwrite and os.path.exists(outfile): - logger.warning(f"{outfile} already exists and --keep-old-files set, will not overwrite.") + logger.warning( + f"{outfile} already exists and --keep-old-files set, will not overwrite." + ) continue # A directory will need to be uncompressed and moved @@ -924,7 +976,9 @@ def authenticate_request(self, originalResponse: requests.Response) -> bool: """ authHeaderRaw = originalResponse.headers.get("Www-Authenticate") if not authHeaderRaw: - logger.debug("Www-Authenticate not found in original response, cannot authenticate.") + logger.debug( + "Www-Authenticate not found in original response, cannot authenticate." + ) return False # If we have a token, set auth header (base64 encoded user/pass) From 51b43772327bc8ac632f31ff6c6103d91e79227b Mon Sep 17 00:00:00 2001 From: Yuriy Novostavskiy Date: Fri, 5 Apr 2024 17:00:52 +0000 Subject: [PATCH 8/8] reformatted with black Signed-off-by: Yuriy Novostavskiy --- .gitignore | 1 + oras/provider.py | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 0569f39..2eb46af 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ oras.egg-info/ .env env __pycache__ +.python-version diff --git a/oras/provider.py b/oras/provider.py index 1416fd7..17e93f9 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -226,7 +226,7 @@ def upload_blob( container: container_type, layer: dict, do_chunked: bool = False, - refresh_headers: bool = True + refresh_headers: bool = True, ) -> requests.Response: """ Prepare and upload a blob. @@ -253,9 +253,13 @@ def upload_blob( # This is currently disabled unless the user asks for it, as # it doesn't seem to work for all registries if not do_chunked: - response = self.put_upload(blob, container, layer, refresh_headers=refresh_headers) + response = self.put_upload( + blob, container, layer, refresh_headers=refresh_headers + ) else: - response = self.chunked_upload(blob, container, layer, refresh_headers=refresh_headers) + response = self.chunked_upload( + blob, container, layer, refresh_headers=refresh_headers + ) # If we have an empty layer digest and the registry didn't accept, just return dummy successful response if ( @@ -477,7 +481,11 @@ def download_blob( return outfile def put_upload( - self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True + self, + blob: str, + container: oras.container.Container, + layer: dict, + refresh_headers: bool = True, ) -> requests.Response: """ Upload to a registry via put. @@ -550,7 +558,11 @@ def _get_location( return session_url def chunked_upload( - self, blob: str, container: oras.container.Container, layer: dict, refresh_headers: bool = True + self, + blob: str, + container: oras.container.Container, + layer: dict, + refresh_headers: bool = True, ) -> requests.Response: """ Upload via a chunked upload. @@ -632,7 +644,10 @@ def _parse_response_errors(self, response: requests.Response): pass def upload_manifest( - self, manifest: dict, container: oras.container.Container, refresh_headers: bool = True + self, + manifest: dict, + container: oras.container.Container, + refresh_headers: bool = True, ) -> requests.Response: """ Read a manifest file and upload it. @@ -746,7 +761,9 @@ def push(self, *args, **kwargs) -> requests.Response: logger.debug(f"Preparing layer {layer}") # Upload the blob layer - response = self.upload_blob(blob, container, layer, refresh_headers=refresh_headers) + response = self.upload_blob( + blob, container, layer, refresh_headers=refresh_headers + ) self._check_200_response(response) # Do we need to cleanup a temporary targz? @@ -788,13 +805,17 @@ def push(self, *args, **kwargs) -> requests.Response: if config_file is None else nullcontext(config_file) ) as config_file: - response = self.upload_blob(config_file, container, conf, refresh_headers=refresh_headers) + response = self.upload_blob( + config_file, container, conf, refresh_headers=refresh_headers + ) self._check_200_response(response) # Final upload of the manifest manifest["config"] = conf - self._check_200_response(self.upload_manifest(manifest, container, refresh_headers=refresh_headers)) + self._check_200_response( + self.upload_manifest(manifest, container, refresh_headers=refresh_headers) + ) print(f"Successfully pushed {container}") return response