From a8d3ae95b589a425636a3bfe440024991e7dbfeb Mon Sep 17 00:00:00 2001 From: Darby Johnston Date: Thu, 16 Jul 2026 13:59:37 -0700 Subject: [PATCH 1/3] Restore url_utils Signed-off-by: Darby Johnston --- .../opentimelineio-bindings/otio_bundle.cpp | 11 ++++ .../opentimelineio/__init__.py | 1 + .../opentimelineio/url_utils.py | 64 +++++++++++++++++++ tests/test_url_conversions.py | 60 +++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 src/py-opentimelineio/opentimelineio/url_utils.py create mode 100644 tests/test_url_conversions.py diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp index 8a1784bd38..f031f8826d 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 869ac1f9b9..036907f4f1 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 0000000000..7a65045177 --- /dev/null +++ b/src/py-opentimelineio/opentimelineio/url_utils.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +"""Utilities for conversion between URLs and file paths.""" + +import os +import urllib +from urllib import request +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. + + Raises :class:`ValueError` if the input uses a non-file scheme. + + .. _RFC 3986: https://tools.ietf.org/html/rfc3986#section-2.1 + """ + result = bundle.file_from_url(urlstr) + if result is None: + raise ValueError( + "Cannot convert URL to filepath (non-file scheme): {!r}".format(urlstr) + ) + return result diff --git a/tests/test_url_conversions.py b/tests/test_url_conversions.py new file mode 100644 index 0000000000..ab177c8e00 --- /dev/null +++ b/tests/test_url_conversions.py @@ -0,0 +1,60 @@ +# 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_raises(self): + # The C++ file_from_url returns None for non-file schemes; + # the Python wrapper translates that to ValueError. + with self.assertRaises(ValueError): + otio.url_utils.filepath_from_url("http://example.com/foo") + + +if __name__ == "__main__": + unittest.main() From 6a6fad6315ef42ef0c03a5492f1516d68757cbaf Mon Sep 17 00:00:00 2001 From: Darby Johnston Date: Thu, 16 Jul 2026 14:19:03 -0700 Subject: [PATCH 2/3] Change return behavior Signed-off-by: Darby Johnston --- src/py-opentimelineio/opentimelineio/url_utils.py | 6 ++---- tests/test_url_conversions.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/url_utils.py b/src/py-opentimelineio/opentimelineio/url_utils.py index 7a65045177..c84aff3f2b 100644 --- a/src/py-opentimelineio/opentimelineio/url_utils.py +++ b/src/py-opentimelineio/opentimelineio/url_utils.py @@ -52,13 +52,11 @@ def filepath_from_url(urlstr): (``file://C:/...``), UNC paths (``file://host/share/...``), and the ``file://localhost/...`` variant. - Raises :class:`ValueError` if the input uses a non-file scheme. + 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: - raise ValueError( - "Cannot convert URL to filepath (non-file scheme): {!r}".format(urlstr) - ) + return urlstr return result diff --git a/tests/test_url_conversions.py b/tests/test_url_conversions.py index ab177c8e00..41b92c6f0e 100644 --- a/tests/test_url_conversions.py +++ b/tests/test_url_conversions.py @@ -49,11 +49,15 @@ def test_roundtrip_rel(self): # should have reconstructed it by this point self.assertEqual(os.path.normpath(result), MEDIA_EXAMPLE_PATH_REL) - def test_non_file_scheme_raises(self): + def test_non_file_scheme_passes_through(self): # The C++ file_from_url returns None for non-file schemes; - # the Python wrapper translates that to ValueError. - with self.assertRaises(ValueError): - otio.url_utils.filepath_from_url("http://example.com/foo") + # 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__": From 993dfbc8f26b59fbe4584f876c30b9baaef43d83 Mon Sep 17 00:00:00 2001 From: Darby Johnston Date: Thu, 16 Jul 2026 14:25:13 -0700 Subject: [PATCH 3/3] Remove unused import Signed-off-by: Darby Johnston --- src/py-opentimelineio/opentimelineio/url_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/py-opentimelineio/opentimelineio/url_utils.py b/src/py-opentimelineio/opentimelineio/url_utils.py index c84aff3f2b..995e2f3927 100644 --- a/src/py-opentimelineio/opentimelineio/url_utils.py +++ b/src/py-opentimelineio/opentimelineio/url_utils.py @@ -5,7 +5,6 @@ import os import urllib -from urllib import request import pathlib from ._otio import bundle