ondio 0.2.3__tar.gz → 0.2.4__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.
Files changed (31) hide show
  1. {ondio-0.2.3 → ondio-0.2.4}/PKG-INFO +1 -1
  2. {ondio-0.2.3 → ondio-0.2.4}/ondio/backends/aws.py +3 -0
  3. {ondio-0.2.3 → ondio-0.2.4}/ondio/backends/gcs.py +3 -0
  4. {ondio-0.2.3 → ondio-0.2.4}/ondio/backends/local.py +17 -7
  5. {ondio-0.2.3 → ondio-0.2.4}/ondio/dispatcher.py +5 -1
  6. {ondio-0.2.3 → ondio-0.2.4}/ondio/registry.py +3 -2
  7. {ondio-0.2.3 → ondio-0.2.4}/pyproject.toml +1 -1
  8. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_aws_backend.py +21 -0
  9. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_gcs_backend.py +11 -0
  10. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_local_dispatcher.py +47 -0
  11. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_registry.py +36 -1
  12. {ondio-0.2.3 → ondio-0.2.4}/.github/workflows/publish.yml +0 -0
  13. {ondio-0.2.3 → ondio-0.2.4}/.github/workflows/tests.yml +0 -0
  14. {ondio-0.2.3 → ondio-0.2.4}/.gitignore +0 -0
  15. {ondio-0.2.3 → ondio-0.2.4}/LICENSE.md +0 -0
  16. {ondio-0.2.3 → ondio-0.2.4}/README.md +0 -0
  17. {ondio-0.2.3 → ondio-0.2.4}/docs/architecture.svg +0 -0
  18. {ondio-0.2.3 → ondio-0.2.4}/ondio/__init__.py +0 -0
  19. {ondio-0.2.3 → ondio-0.2.4}/ondio/_log.py +0 -0
  20. {ondio-0.2.3 → ondio-0.2.4}/ondio/backends/http.py +0 -0
  21. {ondio-0.2.3 → ondio-0.2.4}/ondio/backends/protocol.py +0 -0
  22. {ondio-0.2.3 → ondio-0.2.4}/ondio/flac.py +0 -0
  23. {ondio-0.2.3 → ondio-0.2.4}/ondio/parquet.py +0 -0
  24. {ondio-0.2.3 → ondio-0.2.4}/ondio/types.py +0 -0
  25. {ondio-0.2.3 → ondio-0.2.4}/tests/conftest.py +0 -0
  26. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_download_files.py +0 -0
  27. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_flac.py +0 -0
  28. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_http_backend.py +0 -0
  29. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_log.py +0 -0
  30. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_parquet.py +0 -0
  31. {ondio-0.2.3 → ondio-0.2.4}/tests/unit/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ondio
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Uniform IO of audio data and bioacoustic model results across S3, GCS, HTTP, and local filesystems
5
5
  Author-email: Michael Catchen <mdcatchen@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -18,6 +18,9 @@ def _split(uri: str) -> tuple[str, str]:
18
18
  parsed = urlparse(uri)
19
19
  if parsed.scheme != "s3" or not parsed.netloc:
20
20
  raise ValueError(f"not an s3:// URI: {uri!r}")
21
+ # urlparse would drop the text after ? or #, so the key would not be the one given.
22
+ if "?" in uri or "#" in uri:
23
+ raise ValueError(f"s3:// URIs with a query or fragment are not supported: {uri!r}")
21
24
  return parsed.netloc, parsed.path.lstrip("/")
22
25
 
23
26
 
@@ -15,6 +15,9 @@ def _split(uri: str) -> tuple[str, str]:
15
15
  parsed = urlparse(uri)
16
16
  if parsed.scheme != "gs" or not parsed.netloc:
17
17
  raise ValueError(f"not a gs:// URI: {uri!r}")
18
+ # urlparse would drop the text after ? or #, so the key would not be the one given.
19
+ if "?" in uri or "#" in uri:
20
+ raise ValueError(f"gs:// URIs with a query or fragment are not supported: {uri!r}")
18
21
  return parsed.netloc, parsed.path.lstrip("/")
19
22
 
20
23
 
@@ -12,13 +12,23 @@ from urllib.parse import unquote, urlparse
12
12
  from ondio.backends.protocol import _matches_filters
13
13
  from ondio.types import AuthError, ObjectExistsError, ObjectNotFoundError, OndioError
14
14
 
15
+ def _is_file_uri(uri: str) -> bool:
16
+ return urlparse(uri).scheme.lower() == "file"
17
+
18
+
15
19
  def _to_path(uri: str) -> Path:
16
- if uri.startswith("file://"):
17
- parsed = urlparse(uri)
18
- if parsed.netloc not in ("", "localhost"):
19
- raise ValueError(f"file:// URIs with a remote host are not supported: {uri!r}")
20
- return Path(unquote(parsed.path))
21
- return Path(uri)
20
+ if not _is_file_uri(uri):
21
+ return Path(uri)
22
+ # The forms below would be read as a different path: urlparse drops a query or
23
+ # fragment, and without `//` the rest of the URI is a relative path.
24
+ if not uri.lower().startswith("file://"):
25
+ raise ValueError(f"file: URIs must start with file://: {uri!r}")
26
+ if "?" in uri or "#" in uri:
27
+ raise ValueError(f"file:// URIs with a query or fragment are not supported: {uri!r}")
28
+ parsed = urlparse(uri)
29
+ if parsed.netloc not in ("", "localhost"):
30
+ raise ValueError(f"file:// URIs with a remote host are not supported: {uri!r}")
31
+ return Path(unquote(parsed.path))
22
32
 
23
33
  def _wrap_oserror(uri: str, exc: OSError) -> Exception:
24
34
  if isinstance(exc, PermissionError):
@@ -118,7 +128,7 @@ class LocalBackend:
118
128
  required_prefix: str | None = None,
119
129
  required_ext: str | None = None,
120
130
  ) -> list[str]:
121
- as_file_uri = uri_prefix.startswith("file://")
131
+ as_file_uri = _is_file_uri(uri_prefix)
122
132
  matches = (
123
133
  p
124
134
  for p in self._iter_matches(uri_prefix, recursive=recursive)
@@ -3,7 +3,11 @@
3
3
  Every function accepts `**kwargs`, forwarded to the backend constructor (see
4
4
  [`get_backend`][ondio.registry.get_backend]) — not to the individual operation.
5
5
  URIs are fully qualified: `s3://…`, `gs://…`, `http(s)://…`, `file://…`, or a
6
- plain local path.
6
+ plain local path. A `file:`, `s3://` or `gs://` URI with `?` or `#` is refused with
7
+ `ValueError`, since the text after it would be dropped. A local file whose name has
8
+ `?` or `#` is reached with `%3F` and `%23` in a `file://` URI, or with a plain local
9
+ path, which is used as written. S3 and GCS keys are not decoded, so a key with `?`
10
+ or `#` cannot be reached.
7
11
  """
8
12
 
9
13
  from __future__ import annotations
@@ -49,8 +49,9 @@ def locate(uri: str) -> Location:
49
49
  are used as written.
50
50
 
51
51
  Raises:
52
- ValueError: For a relative local path, a `file://` URI with a remote host,
53
- or an `s3://` or `gs://` URI with no bucket.
52
+ ValueError: For a relative local path, a `file:` URI that does not start
53
+ with `file://`, a `file://` URI with a remote host, an `s3://` or `gs://`
54
+ URI with no bucket, or a `file:`, `s3://` or `gs://` URI with `?` or `#`.
54
55
  UnsupportedOperationError: For an HTTP URL, which ondio cannot write.
55
56
  UnknownPlatformError: For an unknown scheme.
56
57
  """
@@ -13,7 +13,7 @@ license = "BSD-3-Clause"
13
13
  license-files = ["LICENSE.md"]
14
14
  authors = [{ name = "Michael Catchen", email = "mdcatchen@gmail.com" }]
15
15
  requires-python = ">= 3.11"
16
- version = "0.2.3"
16
+ version = "0.2.4"
17
17
 
18
18
  [project.optional-dependencies]
19
19
  gcs = ["google-cloud-storage"]
@@ -292,3 +292,24 @@ def test_an_s3_location_is_the_key_ondio_writes(prefix):
292
292
  client = boto3.client("s3", region_name="us-east-1")
293
293
  body = client.get_object(Bucket=store.removeprefix("s3://"), Key=key)["Body"].read()
294
294
  assert body == b"payload"
295
+
296
+
297
+ class TestRefusedUris:
298
+ @pytest.fixture(params=["?b", "#b", "?", "#"])
299
+ def uri(self, prefix, request):
300
+ return f"{prefix}/a{request.param}"
301
+
302
+ def test_read_refuses_a_query_or_fragment(self, prefix, uri):
303
+ ondio.write(f"{prefix}/a", b"payload")
304
+ with pytest.raises(ValueError):
305
+ ondio.read(uri)
306
+
307
+ @pytest.mark.parametrize("operation", [ondio.create, ondio.write])
308
+ def test_writes_refuse_a_query_or_fragment(self, prefix, uri, operation):
309
+ with pytest.raises(ValueError):
310
+ operation(uri, b"payload")
311
+ assert ondio.list_files(f"{prefix}/") == []
312
+
313
+ def test_an_encoded_question_mark_is_part_of_the_key(self, prefix):
314
+ ondio.write(f"{prefix}/a%3Fb", b"payload")
315
+ assert ondio.list_files(f"{prefix}/") == [f"{prefix}/a%3Fb"]
@@ -268,3 +268,14 @@ class TestErrors:
268
268
  def test_create_is_unsupported(backend):
269
269
  with pytest.raises(UnsupportedOperationError):
270
270
  backend.create(f"{PREFIX}/result.json", b"x")
271
+
272
+
273
+ @pytest.mark.parametrize("suffix", ["?b", "#b", "?", "#"])
274
+ def test_a_query_or_fragment_is_refused(backend, store, suffix):
275
+ store["a"] = b"payload"
276
+ uri = f"{PREFIX}/a{suffix}"
277
+ with pytest.raises(ValueError):
278
+ backend.read(uri)
279
+ with pytest.raises(ValueError):
280
+ backend.write(uri, b"other")
281
+ assert store["a"] == b"payload"
@@ -63,6 +63,53 @@ class TestReadWrite:
63
63
  assert ondio.locate(uri) == Location("file://", str(target).removeprefix("/"))
64
64
 
65
65
 
66
+
67
+ class TestRefusedUris:
68
+ @pytest.fixture(params=["?x", "#x", "?", "#"])
69
+ def suffix(self, request):
70
+ return request.param
71
+
72
+ def test_read_refuses_a_query_or_fragment(self, tmp_path, suffix):
73
+ target = tmp_path / "out"
74
+ target.write_bytes(b"payload")
75
+ with pytest.raises(ValueError):
76
+ ondio.read(target.as_uri() + suffix)
77
+
78
+ @pytest.mark.parametrize("operation", [ondio.create, ondio.write])
79
+ def test_writes_refuse_a_query_or_fragment(self, tmp_path, suffix, operation):
80
+ with pytest.raises(ValueError):
81
+ operation((tmp_path / "out").as_uri() + suffix, b"payload")
82
+ assert list(tmp_path.iterdir()) == []
83
+
84
+ @pytest.mark.parametrize("scheme", ["file:", "FILE:"])
85
+ def test_a_file_uri_without_two_slashes_is_refused(self, tmp_path, monkeypatch, scheme):
86
+ monkeypatch.chdir(tmp_path)
87
+ uri = scheme + str(tmp_path / "out")
88
+ for operation in (ondio.read, ondio.locate):
89
+ with pytest.raises(ValueError):
90
+ operation(uri)
91
+ for operation in (ondio.create, ondio.write):
92
+ with pytest.raises(ValueError):
93
+ operation(uri, b"payload")
94
+ assert list(tmp_path.iterdir()) == []
95
+
96
+ def test_a_bare_path_keeps_question_mark_and_hash(self, tmp_path):
97
+ target = tmp_path / "a?b#c"
98
+ ondio.create(str(target), b"payload")
99
+ assert target.read_bytes() == b"payload"
100
+ assert ondio.read(str(target)) == b"payload"
101
+ assert ondio.locate(str(target)) == Location("file://", str(target).removeprefix("/"))
102
+
103
+ def test_an_encoded_question_mark_reaches_the_file(self, tmp_path):
104
+ target = tmp_path / "a?b"
105
+ target.write_bytes(b"payload")
106
+ assert ondio.read(target.as_uri()) == b"payload"
107
+
108
+ def test_an_uppercase_scheme_lists_file_uris(self, tree):
109
+ uri = "FILE://" + tree.as_uri().removeprefix("file://")
110
+ assert ondio.list_files(uri) == ondio.list_files(tree.as_uri())
111
+
112
+
66
113
  class TestReadRange:
67
114
  @pytest.fixture
68
115
  def uri(self, tmp_path):
@@ -75,6 +75,14 @@ def test_create_refuses_unsupported_schemes_before_backend_setup(uri, setup_erro
75
75
  ("file:///data/%6fut", Location("file://", "data/out")),
76
76
  ("file:///data/%2e%2e/other", Location("file://", "data/../other")),
77
77
  ("file:///data/a%2F..%2Fother", Location("file://", "data/a/../other")),
78
+ ("file:///data/a%20b", Location("file://", "data/a b")),
79
+ # A ? or # reaches a local file name only percent-encoded, or in a bare path.
80
+ ("file:///data/a%3Fb%23c", Location("file://", "data/a?b#c")),
81
+ ("FILE:///data/out", Location("file://", "data/out")),
82
+ ("/data/a?b#c", Location("file://", "data/a?b#c")),
83
+ # The same text in a cloud key stays as written.
84
+ ("s3://bucket/a%3Fb", Location("s3://bucket", "a%3Fb")),
85
+ ("gs://bucket/a%3Fb", Location("gs://bucket", "a%3Fb")),
78
86
  ],
79
87
  )
80
88
  def test_locate_names_the_store_and_the_path_ondio_reads(uri, location):
@@ -99,7 +107,9 @@ def test_forms_of_one_local_folder_locate_the_same_place(uri):
99
107
  ("uri", "error"),
100
108
  [
101
109
  ("relative/path", ValueError),
102
- ("file:/data/out", ValueError), # ondio reads this as a relative path
110
+ ("file:/data/out", ValueError),
111
+ ("file:data/out", ValueError),
112
+ ("FILE:/data/out", ValueError),
103
113
  ("file://host/data/out", ValueError),
104
114
  ("s3:///key", ValueError),
105
115
  ("gs:///key", ValueError),
@@ -110,3 +120,28 @@ def test_forms_of_one_local_folder_locate_the_same_place(uri):
110
120
  def test_locate_refuses_a_location_with_no_fixed_place(uri, error):
111
121
  with pytest.raises(error):
112
122
  locate(uri)
123
+
124
+
125
+ @pytest.mark.parametrize(
126
+ "uri",
127
+ [
128
+ "file:///data/out?x",
129
+ "file:///data/out#x",
130
+ "file:///data/out?",
131
+ "file:///data/out#",
132
+ "file://localhost/data/out?x",
133
+ "FILE:///data/out?x",
134
+ "s3://bucket/a?b",
135
+ "s3://bucket/a#b",
136
+ "s3://bucket/a?",
137
+ "s3://bucket/a#",
138
+ "gs://bucket/a?b",
139
+ "gs://bucket/a#b",
140
+ "gs://bucket/a?",
141
+ "gs://bucket/a#",
142
+ ],
143
+ )
144
+ def test_locate_refuses_a_query_or_fragment(uri):
145
+ # urlparse would drop the text after ? or #, so ondio would name another path.
146
+ with pytest.raises(ValueError):
147
+ locate(uri)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes