Skip to content

Commit d73b774

Browse files
authored
Merge pull request #98 from lsst/tickets/DM-46776
DM-46776: Propagate fragments with ResourcePath.join
2 parents 8ddb459 + 1e556d8 commit d73b774

File tree

7 files changed

+57
-11
lines changed

7 files changed

+57
-11
lines changed

doc/changes/DM-46776.feature.rst

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
* Modified ``ResourcePath.join()`` to propagate fragments from the given path to the joined path.
2+
This now means that if the ``ResourcePath`` constructor finds a fragment that fragment will be used.
3+
Previously the fragment was dropped if a ``ResourcePath`` was given that had a fragment, or the fragment was treated as part of the filename if a plain string was given.
4+
This change means that filenames can no longer include ``#`` characters.
5+
* Added new ``ResourcePath.unquoted_fragment`` property to get the unquoted fragment.

python/lsst/resources/_resourcePath.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,14 @@ def is_root(self) -> bool:
393393

394394
@property
395395
def fragment(self) -> str:
396-
"""Return the fragment component of the URI."""
396+
"""Return the fragment component of the URI. May be quoted."""
397397
return self._uri.fragment
398398

399+
@property
400+
def unquoted_fragment(self) -> str:
401+
"""Return unquoted fragment."""
402+
return urllib.parse.unquote(self.fragment)
403+
399404
@property
400405
def params(self) -> str:
401406
"""Return any parameters included in the URI."""
@@ -693,7 +698,7 @@ def join(
693698
quoted depending on the associated URI scheme. If the path looks
694699
like a URI referring to an absolute location, it will be returned
695700
directly (matching the behavior of `os.path.join`). It can
696-
also be a `ResourcePath`.
701+
also be a `ResourcePath`. Fragments are propagated.
697702
isTemporary : `bool`, optional
698703
Indicate that the resulting URI represents a temporary resource.
699704
Default is ``self.isTemporary``.
@@ -723,6 +728,9 @@ def join(
723728
refer to a file. Use `updatedFile` if the file is to be
724729
replaced.
725730
731+
If an unquoted ``#`` is included in the path it is assumed to be
732+
referring to a fragment and not part of the file name.
733+
726734
Raises
727735
------
728736
ValueError
@@ -757,10 +765,10 @@ def join(
757765
# Absolute URI so return it directly.
758766
return path_uri
759767

760-
# If this was originally a ResourcePath extract the unquoted path from
761-
# it. Otherwise we use the string we were given to allow "#" to appear
762-
# in the filename if given as a plain string.
763-
if not isinstance(path, str):
768+
# We want to propagate fragments to the joined path and we rely on
769+
# the ResourcePath parser to find these fragments for us even in plain
770+
# strings. Must assume there are no `#` characters in filenames.
771+
if not isinstance(path, str) or path_uri.fragment:
764772
path = path_uri.unquoted_path
765773

766774
# Might need to quote the path.
@@ -780,6 +788,7 @@ def join(
780788
path=newpath,
781789
forceDirectory=forceDirectory,
782790
isTemporary=isTemporary,
791+
fragment=path_uri.fragment,
783792
)
784793

785794
def relative_to(self, other: ResourcePath) -> str | None:

python/lsst/resources/http.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ def _sign_with_macaroon(self, activity: ActivityCaveat, expiration_time_seconds:
13901390
try:
13911391
response_body = json.loads(resp.text)
13921392
if "macaroon" in response_body:
1393-
return f"{self}?authz={response_body['macaroon']}"
1393+
return str(self.replace(query=f"authz={response_body['macaroon']}"))
13941394
else:
13951395
raise ValueError(f"could not retrieve macaroon for URL {self}")
13961396
except json.JSONDecodeError:

python/lsst/resources/s3.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -582,8 +582,12 @@ def generate_presigned_put_url(self, *, expiration_time_seconds: int) -> str:
582582
return self._generate_presigned_url("put_object", expiration_time_seconds)
583583

584584
def _generate_presigned_url(self, method: str, expiration_time_seconds: int) -> str:
585-
return self.client.generate_presigned_url(
585+
url = self.client.generate_presigned_url(
586586
method,
587587
Params={"Bucket": self._bucket, "Key": self.relativeToPathRoot},
588588
ExpiresIn=expiration_time_seconds,
589589
)
590+
if self.fragment:
591+
resource = ResourcePath(url)
592+
url = str(resource.replace(fragment=self.fragment))
593+
return url

python/lsst/resources/tests.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,12 @@ def test_escapes(self) -> None:
444444
uri = ResourcePath(hash_path)
445445
self.assertEqual(uri.ospath, hash_path[:hpos])
446446
self.assertEqual(uri.fragment, hash_path[hpos + 1 :])
447+
self.assertEqual(uri.unquoted_fragment, uri.fragment)
448+
449+
# Fragments can be quoted, although this is not enforced anywhere.
450+
with_frag = ResourcePath(self._make_uri("a/b.txt#" + urllib.parse.quote("zip-path=ingést")))
451+
self.assertEqual(with_frag.fragment, "zip-path%3Ding%C3%A9st")
452+
self.assertEqual(with_frag.unquoted_fragment, "zip-path=ingést")
447453

448454
def test_hash(self) -> None:
449455
"""Test that we can store URIs in sets and as keys."""
@@ -474,9 +480,22 @@ def test_join(self) -> None:
474480
self.assertFalse(up_relative.isdir())
475481
self.assertEqual(up_relative.geturl(), f"{root_str}b/c.txt")
476482

483+
# Check that fragment is passed through join (simple unquoted case).
484+
fnew3 = root.join("a/b.txt#fragment")
485+
self.assertEqual(fnew3.fragment, "fragment")
486+
self.assertEqual(fnew3.basename(), "b.txt", msg=f"Got: {fnew3._uri}")
487+
488+
# Join a resource path.
489+
subpath = ResourcePath("a/b.txt#fragment2", forceAbsolute=False, forceDirectory=False)
490+
fnew3 = root.join(subpath)
491+
self.assertEqual(fnew3.fragment, "fragment2")
492+
self.assertEqual(fnew3.basename(), "b.txt", msg=f"Got: {fnew3._uri}")
493+
494+
# Quoted string with fragment.
477495
quote_example = "hsc/payload/b&c.t@x#t"
478496
needs_quote = root.join(quote_example)
479-
self.assertEqual(needs_quote.unquoted_path, "/" + quote_example)
497+
self.assertEqual(needs_quote.unquoted_path, "/" + quote_example[:-2])
498+
self.assertEqual(needs_quote.fragment, "t")
480499

481500
other = ResourcePath(f"{self.root}test.txt")
482501
self.assertEqual(root.join(other), other)

tests/test_http.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -469,9 +469,9 @@ def test_plain_http_url_signing(self):
469469

470470
# Plain HTTP URLs are already readable without authentication, so
471471
# generating a pre-signed URL is a no-op.
472-
path = ResourcePath("http://nonwebdav.test/file")
472+
path = ResourcePath("http://nonwebdav.test/file#frag")
473473
self.assertEqual(
474-
path.generate_presigned_get_url(expiration_time_seconds=300), "http://nonwebdav.test/file"
474+
path.generate_presigned_get_url(expiration_time_seconds=300), "http://nonwebdav.test/file#frag"
475475
)
476476

477477
# Writing to an arbitrary plain HTTP URL is unlikely to work, so we

tests/test_s3.py

+9
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ def _test_url_signing_case(self, filename: str, test_data: bytes):
169169
get_url = s3_path.generate_presigned_get_url(expiration_time_seconds=3600)
170170
self._check_presigned_url(get_url, 3600)
171171

172+
# Check that fragments are retained.
173+
s3_path = s3_path.replace(fragment="zip-path=X")
174+
put_url = s3_path.generate_presigned_put_url(expiration_time_seconds=1800)
175+
self.assertEqual(ResourcePath(put_url).fragment, "zip-path=X")
176+
self._check_presigned_url(put_url, 1800)
177+
get_url = s3_path.generate_presigned_get_url(expiration_time_seconds=3600)
178+
self.assertEqual(ResourcePath(get_url).fragment, "zip-path=X")
179+
self._check_presigned_url(get_url, 3600)
180+
172181
# Moto monkeypatches the 'requests' library to mock access to presigned
173182
# URLs, so we are able to use HttpResourcePath to access the URLs in
174183
# this test.

0 commit comments

Comments
 (0)