diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp index 8a1784bd3..f031f8826 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp @@ -71,6 +71,17 @@ Options for reading bundles. "Convert the media reference paths to absolute paths. " "If this is set to true for otioz files, an extract_path must also be set."); + mbundle.def( + "file_from_url", + &file_from_url, + "Convert a file:// URL to a filesystem path.\n\n" + "Handles Windows drive letters in netloc position (file://C:/...), " + "UNC paths (file://host/share/...), percent-encoded characters, " + "and the 'localhost' authority. Returns the input unchanged if it " + "is a bare path with no URL scheme. Returns None if the URL uses a " + "non-file scheme (e.g. http://...).", + py::arg("url")); + mbundle.def( "dry_run", []( diff --git a/src/py-opentimelineio/opentimelineio/__init__.py b/src/py-opentimelineio/opentimelineio/__init__.py index 869ac1f9b..036907f4f 100644 --- a/src/py-opentimelineio/opentimelineio/__init__.py +++ b/src/py-opentimelineio/opentimelineio/__init__.py @@ -22,5 +22,6 @@ adapters, hooks, algorithms, + url_utils, versioning, ) diff --git a/src/py-opentimelineio/opentimelineio/url_utils.py b/src/py-opentimelineio/opentimelineio/url_utils.py new file mode 100644 index 000000000..995e2f392 --- /dev/null +++ b/src/py-opentimelineio/opentimelineio/url_utils.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +"""Utilities for conversion between URLs and file paths.""" + +import os +import urllib +import pathlib + +from ._otio import bundle + + +def url_from_filepath(fpath): + """Convert a filesystem path to a URL in a portable way. + + ensures that `fpath` conforms to the following pattern: + * if it is an absolute path, "file:///path/to/thing" + * if it is a relative path, "path/to/thing" + + In other words, if you pass in: + * "/var/tmp/thing.otio" -> "file:///var/tmp/thing.otio" + * "tmp/thing.otio" -> "tmp/thing.otio" + """ + + try: + # appears to handle absolute windows paths better, which are absolute + # and start with a drive letter. + return urllib.parse.unquote(pathlib.PurePath(fpath).as_uri()) + except ValueError: + # scheme is "file" for absolute paths, else "" + scheme = "file" if os.path.isabs(fpath) else "" + + # handles relative paths + return urllib.parse.urlunparse( + urllib.parse.ParseResult( + scheme=scheme, + path=fpath, + netloc="", + params="", + query="", + fragment="" + ) + ) + + +def filepath_from_url(urlstr): + """ + Take a URL and return a filepath. + + Handles URLs encoded per `RFC 3986`_, Windows drive letters + (``file://C:/...``), UNC paths (``file://host/share/...``), and the + ``file://localhost/...`` variant. + + Returns the input unchanged if it cannot be converted. + + .. _RFC 3986: https://tools.ietf.org/html/rfc3986#section-2.1 + """ + result = bundle.file_from_url(urlstr) + if result is None: + return urlstr + return result diff --git a/tests/test_url_conversions.py b/tests/test_url_conversions.py new file mode 100644 index 000000000..41b92c6f0 --- /dev/null +++ b/tests/test_url_conversions.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +""" Unit tests of functions that convert between file paths and urls. """ + +import unittest +import os + +import opentimelineio as otio + +MEDIA_EXAMPLE_PATH_REL = os.path.relpath( + os.path.join( + os.path.dirname(__file__), + "..", # root + "docs", + "_static", + "OpenTimelineIO@3xDark.png" + ) +) +MEDIA_EXAMPLE_PATH_URL_REL = otio.url_utils.url_from_filepath( + MEDIA_EXAMPLE_PATH_REL +) +MEDIA_EXAMPLE_PATH_ABS = os.path.abspath( + MEDIA_EXAMPLE_PATH_REL.replace( + "3xDark", + "3xLight" + ) +) +MEDIA_EXAMPLE_PATH_URL_ABS = otio.url_utils.url_from_filepath( + MEDIA_EXAMPLE_PATH_ABS +) + + +class TestConversions(unittest.TestCase): + def test_roundtrip_abs(self): + self.assertTrue(MEDIA_EXAMPLE_PATH_URL_ABS.startswith("file://")) + full_path = os.path.abspath( + otio.url_utils.filepath_from_url(MEDIA_EXAMPLE_PATH_URL_ABS) + ) + + # should have reconstructed it by this point + self.assertEqual(full_path, MEDIA_EXAMPLE_PATH_ABS) + + def test_roundtrip_rel(self): + self.assertFalse(MEDIA_EXAMPLE_PATH_URL_REL.startswith("file://")) + + result = otio.url_utils.filepath_from_url(MEDIA_EXAMPLE_PATH_URL_REL) + + # should have reconstructed it by this point + self.assertEqual(os.path.normpath(result), MEDIA_EXAMPLE_PATH_REL) + + def test_non_file_scheme_passes_through(self): + # The C++ file_from_url returns None for non-file schemes; + # the Python wrapper passes the input through unchanged, matching + # the historical Python behavior of not raising on unrecognized + # input. + self.assertEqual( + otio.url_utils.filepath_from_url("http://example.com/foo"), + "http://example.com/foo", + ) + + +if __name__ == "__main__": + unittest.main()