pos3 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pos3-0.2.1 → pos3-0.2.2}/PKG-INFO +1 -1
- {pos3-0.2.1 → pos3-0.2.2}/pos3/__init__.py +14 -4
- {pos3-0.2.1 → pos3-0.2.2}/pos3.egg-info/PKG-INFO +1 -1
- {pos3-0.2.1 → pos3-0.2.2}/pyproject.toml +1 -1
- {pos3-0.2.1 → pos3-0.2.2}/tests/test_s3.py +67 -0
- {pos3-0.2.1 → pos3-0.2.2}/LICENSE +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/README.md +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/pos3.egg-info/SOURCES.txt +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/pos3.egg-info/dependency_links.txt +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/pos3.egg-info/requires.txt +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/pos3.egg-info/top_level.txt +0 -0
- {pos3-0.2.1 → pos3-0.2.2}/setup.cfg +0 -0
|
@@ -142,6 +142,17 @@ def _s3_paths_conflict(left: str, right: str) -> bool:
|
|
|
142
142
|
return left_norm.startswith(right_norm + "/") or right_norm.startswith(left_norm + "/")
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _make_s3_key(prefix: str, info: FileInfo) -> str:
|
|
146
|
+
"""Build the canonical S3 key from a prefix and FileInfo, including trailing '/' for directories."""
|
|
147
|
+
if info.relative_path:
|
|
148
|
+
key = prefix + "/" + info.relative_path if prefix else info.relative_path
|
|
149
|
+
else:
|
|
150
|
+
key = prefix
|
|
151
|
+
if info.is_dir and not key.endswith("/"):
|
|
152
|
+
key += "/"
|
|
153
|
+
return key
|
|
154
|
+
|
|
155
|
+
|
|
145
156
|
def _process_futures(futures, operation: str) -> None:
|
|
146
157
|
for future in futures:
|
|
147
158
|
try:
|
|
@@ -618,12 +629,12 @@ class _Mirror:
|
|
|
618
629
|
)
|
|
619
630
|
|
|
620
631
|
for info in to_copy:
|
|
621
|
-
s3_key = prefix
|
|
632
|
+
s3_key = _make_s3_key(prefix, info)
|
|
622
633
|
to_put.append((info, local_path, bucket, s3_key, profile))
|
|
623
634
|
total_bytes += info.size
|
|
624
635
|
|
|
625
636
|
for info in to_delete if delete else []:
|
|
626
|
-
s3_key = prefix
|
|
637
|
+
s3_key = _make_s3_key(prefix, info)
|
|
627
638
|
to_remove.append((bucket, s3_key, profile))
|
|
628
639
|
|
|
629
640
|
if to_put:
|
|
@@ -679,7 +690,7 @@ class _Mirror:
|
|
|
679
690
|
total_bytes = 0
|
|
680
691
|
|
|
681
692
|
for info in to_copy:
|
|
682
|
-
s3_key = prefix
|
|
693
|
+
s3_key = _make_s3_key(prefix, info)
|
|
683
694
|
to_put.append((info, bucket, s3_key, local_path))
|
|
684
695
|
total_bytes += info.size
|
|
685
696
|
|
|
@@ -782,7 +793,6 @@ class _Mirror:
|
|
|
782
793
|
try:
|
|
783
794
|
client = self._get_client(profile)
|
|
784
795
|
if info.is_dir:
|
|
785
|
-
key += "/" if not key.endswith("/") else ""
|
|
786
796
|
client.put_object(Bucket=bucket, Key=key, Body=b"")
|
|
787
797
|
else:
|
|
788
798
|
file_path = local_path / info.relative_path if info.relative_path else local_path
|
|
@@ -29,6 +29,43 @@ def _setup_s3_mock(mock_boto_client, paginate_return_value=None):
|
|
|
29
29
|
return mock_s3
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
class TestMakeS3Key:
|
|
33
|
+
def test_file_with_prefix(self):
|
|
34
|
+
info = s3.FileInfo(relative_path="file.txt", size=100, is_dir=False)
|
|
35
|
+
assert s3._make_s3_key("data", info) == "data/file.txt"
|
|
36
|
+
|
|
37
|
+
def test_dir_with_prefix(self):
|
|
38
|
+
info = s3.FileInfo(relative_path="subdir", size=0, is_dir=True)
|
|
39
|
+
assert s3._make_s3_key("data", info) == "data/subdir/"
|
|
40
|
+
|
|
41
|
+
def test_root_dir(self):
|
|
42
|
+
info = s3.FileInfo(relative_path="", size=0, is_dir=True)
|
|
43
|
+
assert s3._make_s3_key("data", info) == "data/"
|
|
44
|
+
|
|
45
|
+
def test_empty_prefix_file(self):
|
|
46
|
+
info = s3.FileInfo(relative_path="file.txt", size=100, is_dir=False)
|
|
47
|
+
assert s3._make_s3_key("", info) == "file.txt"
|
|
48
|
+
|
|
49
|
+
def test_empty_prefix_dir(self):
|
|
50
|
+
info = s3.FileInfo(relative_path="subdir", size=0, is_dir=True)
|
|
51
|
+
assert s3._make_s3_key("", info) == "subdir/"
|
|
52
|
+
|
|
53
|
+
def test_nested_path(self):
|
|
54
|
+
info = s3.FileInfo(relative_path="a/b/c.txt", size=50, is_dir=False)
|
|
55
|
+
assert s3._make_s3_key("prefix", info) == "prefix/a/b/c.txt"
|
|
56
|
+
|
|
57
|
+
def test_nested_dir(self):
|
|
58
|
+
info = s3.FileInfo(relative_path="a/b", size=0, is_dir=True)
|
|
59
|
+
assert s3._make_s3_key("prefix", info) == "prefix/a/b/"
|
|
60
|
+
|
|
61
|
+
def test_dir_already_trailing_slash_prefix(self):
|
|
62
|
+
"""Prefix with trailing slash in relative_path shouldn't double-slash."""
|
|
63
|
+
info = s3.FileInfo(relative_path="sub/", size=0, is_dir=True)
|
|
64
|
+
result = s3._make_s3_key("data", info)
|
|
65
|
+
assert result == "data/sub/"
|
|
66
|
+
assert "//" not in result
|
|
67
|
+
|
|
68
|
+
|
|
32
69
|
class TestS3URLParsing:
|
|
33
70
|
def test_parse_s3_url_valid(self):
|
|
34
71
|
assert s3._parse_s3_url("s3://bucket/path/to/data") == (
|
|
@@ -159,6 +196,36 @@ class TestUpload:
|
|
|
159
196
|
assert mock_s3.upload_file.call_count >= 1
|
|
160
197
|
assert mock_s3.delete_object.call_count == 1
|
|
161
198
|
|
|
199
|
+
@patch(BOTO3_PATCH_TARGET)
|
|
200
|
+
def test_upload_delete_directory_marker_trailing_slash(self, mock_boto_client):
|
|
201
|
+
"""Test that deleting a directory from S3 uses trailing slash to match directory markers."""
|
|
202
|
+
# S3 has a directory marker "output/subdir/" and a file "output/file.txt"
|
|
203
|
+
paginate = [
|
|
204
|
+
{
|
|
205
|
+
"Contents": [
|
|
206
|
+
{"Key": "output/subdir/", "Size": 0}, # Directory marker with trailing slash
|
|
207
|
+
{"Key": "output/file.txt", "Size": 5},
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
mock_s3 = _setup_s3_mock(mock_boto_client, paginate)
|
|
212
|
+
|
|
213
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
214
|
+
output = Path(tmpdir) / "output"
|
|
215
|
+
output.mkdir()
|
|
216
|
+
# Local only has file.txt - subdir was deleted locally
|
|
217
|
+
(output / "file.txt").write_text("content")
|
|
218
|
+
|
|
219
|
+
with s3.mirror(cache_root=tmpdir, show_progress=False):
|
|
220
|
+
s3.upload("s3://bucket/output", local=output, interval=None)
|
|
221
|
+
|
|
222
|
+
# The directory marker should be deleted with trailing slash
|
|
223
|
+
delete_calls = mock_s3.delete_object.call_args_list
|
|
224
|
+
deleted_keys = [call[1]["Key"] for call in delete_calls]
|
|
225
|
+
assert "output/subdir/" in deleted_keys, (
|
|
226
|
+
f"Expected delete of 'output/subdir/' but got: {deleted_keys}"
|
|
227
|
+
)
|
|
228
|
+
|
|
162
229
|
@patch(BOTO3_PATCH_TARGET)
|
|
163
230
|
def test_background_sync_uploads_repeatedly(self, mock_boto_client):
|
|
164
231
|
mock_s3 = _setup_s3_mock(mock_boto_client)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|