Skip to content

Commit 8c1f7cc

Browse files
authored
Fix issue 93 Windows blob upload (#94)
* Fix issue 93 Windows blob upload Signed-off-by: Sunny Carter <sunny.carter@metaswitch.com>
1 parent de18e3f commit 8c1f7cc

File tree

7 files changed

+138
-8
lines changed

7 files changed

+138
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
1414
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
1515

1616
## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x)
17+
- patch fix for blob upload Windows, closes issue [93](https://github.com/oras-project/oras-py/issues/93) (0.1.19)
1718
- patch fix for empty manifest config on Windows, closes issue [90](https://github.com/oras-project/oras-py/issues/90) (0.1.18)
1819
- patch fix to correct session url pattern, closes issue [78](https://github.com/oras-project/oras-py/issues/78) (0.1.17)
1920
- add support for tag deletion and retry decorators (0.1.16)

oras/provider.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import oras.schemas
1818
import oras.utils
1919
from oras.logger import logger
20+
from oras.utils.fileio import PathAndOptionalContent
2021

2122
# container type can be string or container
2223
container_type = Union[str, oras.container.Container]
@@ -164,9 +165,9 @@ def _validate_path(self, path: str) -> bool:
164165
"""
165166
return os.getcwd() in os.path.abspath(path)
166167

167-
def _parse_manifest_ref(self, ref: str) -> Union[Tuple[str, str], List[str]]:
168+
def _parse_manifest_ref(self, ref: str) -> Tuple[str, str]:
168169
"""
169-
Parse an optional manifest config, e.g:
170+
Parse an optional manifest config.
170171
171172
Examples
172173
--------
@@ -176,10 +177,13 @@ def _parse_manifest_ref(self, ref: str) -> Union[Tuple[str, str], List[str]]:
176177
177178
:param ref: the manifest reference to parse (examples above)
178179
:type ref: str
180+
:return - A Tuple of the path and the content-type, using the default unknown
181+
config media type if none found in the reference
179182
"""
180-
if ":" not in ref:
181-
return ref, oras.defaults.unknown_config_media_type
182-
return ref.split(":", 1)
183+
path_content: PathAndOptionalContent = oras.utils.split_path_and_content(ref)
184+
if not path_content.content:
185+
path_content.content = oras.defaults.unknown_config_media_type
186+
return path_content.path, path_content.content
183187

184188
def upload_blob(
185189
self,
@@ -637,8 +641,11 @@ def push(self, *args, **kwargs) -> requests.Response:
637641
# Upload files as blobs
638642
for blob in kwargs.get("files", []):
639643
# You can provide a blob + content type
640-
if ":" in str(blob):
641-
blob, media_type = str(blob).split(":", 1)
644+
path_content: PathAndOptionalContent = oras.utils.split_path_and_content(
645+
str(blob)
646+
)
647+
blob = path_content.path
648+
media_type = path_content.content
642649

643650
# Must exist
644651
if not os.path.exists(blob):

oras/tests/test_provider.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
import oras.client
11+
import oras.defaults
1112
import oras.provider
1213
import oras.utils
1314

@@ -80,3 +81,38 @@ def test_annotated_registry_push(tmp_path):
8081
res = client.push(
8182
files=[artifact], target=target, annotation_file=annotation_file
8283
)
84+
85+
86+
def test_parse_manifest():
87+
"""
88+
Test parse manifest function.
89+
90+
Parse manifest function has additional logic for Windows - this isn't included in
91+
these tests as they don't usually run on Windows.
92+
"""
93+
testref = "path/to/config:application/vnd.oci.image.config.v1+json"
94+
remote = oras.provider.Registry(hostname=registry, insecure=True)
95+
ref, content_type = remote._parse_manifest_ref(testref)
96+
assert ref == "path/to/config"
97+
assert content_type == "application/vnd.oci.image.config.v1+json"
98+
99+
testref = "path/to/config:application/vnd.oci.image.config.v1+json:extra"
100+
remote = oras.provider.Registry(hostname=registry, insecure=True)
101+
ref, content_type = remote._parse_manifest_ref(testref)
102+
assert ref == "path/to/config"
103+
assert content_type == "application/vnd.oci.image.config.v1+json:extra"
104+
105+
testref = "/dev/null:application/vnd.oci.image.manifest.v1+json"
106+
ref, content_type = remote._parse_manifest_ref(testref)
107+
assert ref == "/dev/null"
108+
assert content_type == "application/vnd.oci.image.manifest.v1+json"
109+
110+
testref = "/dev/null"
111+
ref, content_type = remote._parse_manifest_ref(testref)
112+
assert ref == "/dev/null"
113+
assert content_type == oras.defaults.unknown_config_media_type
114+
115+
testref = "path/to/config.json"
116+
ref, content_type = remote._parse_manifest_ref(testref)
117+
assert ref == "path/to/config.json"
118+
assert content_type == oras.defaults.unknown_config_media_type

oras/tests/test_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,31 @@ def test_print_json():
102102
print("Testing utils.print_json")
103103
result = utils.print_json({1: 1})
104104
assert result == '{\n "1": 1\n}'
105+
106+
107+
def test_split_path_and_content():
108+
"""
109+
Test split path and content function.
110+
111+
Function has additional logic for Windows - this isn't included in these tests as
112+
they don't usually run on Windows.
113+
"""
114+
testref = "path/to/config:application/vnd.oci.image.config.v1+json"
115+
path_content = utils.split_path_and_content(testref)
116+
assert path_content.path == "path/to/config"
117+
assert path_content.content == "application/vnd.oci.image.config.v1+json"
118+
119+
testref = "/dev/null:application/vnd.oci.image.config.v1+json"
120+
path_content = utils.split_path_and_content(testref)
121+
assert path_content.path == "/dev/null"
122+
assert path_content.content == "application/vnd.oci.image.config.v1+json"
123+
124+
testref = "/dev/null"
125+
path_content = utils.split_path_and_content(testref)
126+
assert path_content.path == "/dev/null"
127+
assert not path_content.content
128+
129+
testref = "path/to/config.json"
130+
path_content = utils.split_path_and_content(testref)
131+
assert path_content.path == "path/to/config.json"
132+
assert not path_content.content

oras/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
readline,
1515
recursive_find,
1616
sanitize_path,
17+
split_path_and_content,
1718
workdir,
1819
write_file,
1920
write_json,

oras/utils/fileio.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
from typing import Generator, Optional, TextIO, Union
1919

2020

21+
class PathAndOptionalContent:
22+
"""Class for holding a path reference and optional content parsed from a string."""
23+
24+
def __init__(self, path: str, content: Optional[str] = None):
25+
self.path = path
26+
self.content = content
27+
28+
2129
def make_targz(source_dir: str, dest_name: Optional[str] = None) -> str:
2230
"""
2331
Make a targz (compressed) archive from a source directory.
@@ -315,3 +323,52 @@ def read_json(filename: str, mode: str = "r") -> dict:
315323
:type mode: str
316324
"""
317325
return json.loads(read_file(filename))
326+
327+
328+
def split_path_and_content(ref: str) -> PathAndOptionalContent:
329+
"""
330+
Parse a string containing a path and an optional content
331+
332+
Examples
333+
--------
334+
<path>:<content-type>
335+
path/to/config:application/vnd.oci.image.config.v1+json
336+
/dev/null:application/vnd.oci.image.config.v1+json
337+
C:\\myconfig:application/vnd.oci.image.config.v1+json
338+
339+
Or,
340+
<path>
341+
/dev/null
342+
C:\\myconfig
343+
344+
:param ref: the manifest reference to parse (examples above)
345+
:type ref: str
346+
: return: A Tuple of the path in the reference, and the content-type if one found,
347+
otherwise None.
348+
"""
349+
if ":" not in ref:
350+
return PathAndOptionalContent(ref, None)
351+
352+
if pathlib.Path(ref).drive:
353+
# Running on Windows and Path has Windows drive letter in it, it definitely has
354+
# one colon and could have two or feasibly more, e.g.
355+
# C:\test.tar
356+
# C:\test.tar:application/vnd.oci.image.layer.v1.tar
357+
# C:\test.tar:application/vnd.oci.image.layer.v1.tar:somethingelse
358+
#
359+
# This regex matches two colons in the string and returns everything before
360+
# the second colon as the "path" group and everything after the second colon
361+
# as the "context" group.
362+
# i.e.
363+
# (C:\test.tar):(application/vnd.oci.image.layer.v1.tar)
364+
# (C:\test.tar):(application/vnd.oci.image.layer.v1.tar:somethingelse)
365+
# But C:\test.tar along will not match and we just return it as is.
366+
path_and_content = re.search(r"(?P<path>.*?:.*?):(?P<content>.*)", ref)
367+
if path_and_content:
368+
return PathAndOptionalContent(
369+
path_and_content.group("path"), path_and_content.group("content")
370+
)
371+
return PathAndOptionalContent(ref, None)
372+
else:
373+
path_content_list = ref.split(":", 1)
374+
return PathAndOptionalContent(path_content_list[0], path_content_list[1])

oras/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
__copyright__ = "Copyright The ORAS Authors."
33
__license__ = "Apache-2.0"
44

5-
__version__ = "0.1.18"
5+
__version__ = "0.1.19"
66
AUTHOR = "Vanessa Sochat"
77
EMAIL = "vsoch@users.noreply.github.com"
88
NAME = "oras"

0 commit comments

Comments
 (0)