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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pos3
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: S3 Simple Sync - Make using S3 as simple as using local files
5
5
  Author-email: Positronic Robotics <hi@positronic.ro>
6
6
  License: Apache-2.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 + ("/" + info.relative_path if info.relative_path else "")
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 + ("/" + info.relative_path if info.relative_path else "")
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 + ("/" + info.relative_path if info.relative_path else "")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pos3
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: S3 Simple Sync - Make using S3 as simple as using local files
5
5
  Author-email: Positronic Robotics <hi@positronic.ro>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pos3"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "S3 Simple Sync - Make using S3 as simple as using local files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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