From b401fac04fcb658db474cec2c12263a2282a58ef Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Aug 2024 10:12:52 -0500 Subject: [PATCH] feat: add `util.resource_path()` function need that now that we have configurable mako template paths --- pyproject.toml | 1 + src/wuttjamaican/util.py | 43 ++++++++++++++++++++++++++++++++++++++++ tests/test_util.py | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 815cac1..4e985e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ 'importlib-metadata; python_version < "3.10"', + "importlib_resources ; python_version < '3.9'", "progress", "python-configuration", ] diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index d64270d..342868e 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -26,6 +26,7 @@ WuttJamaican - utilities import importlib import logging +import os import shlex from uuid import uuid1 @@ -264,3 +265,45 @@ def progress_loop(func, items, factory, message=None): if progress: progress.finish() + + +def resource_path(path): + """ + Returns the absolute file path for the given resource path. + + A "resource path" is one which designates a python package name, + plus some path under that. For instance: + + .. code-block:: none + + wuttjamaican.email:templates + + Assuming such a path should exist, the question is "where?" + + So this function uses :mod:`python:importlib.resources` to locate + the path, possibly extracting the file(s) from a zipped package, + and returning the final path on disk. + + It only does this if it detects it is needed, based on the given + ``path`` argument. If that is already an absolute path then it + will be returned as-is. + + :param path: Either a package resource specifier as shown above, + or regular file path. + + :returns: Absolute file path to the resource. + """ + if not os.path.isabs(path) and ':' in path: + + try: + # nb. these were added in python 3.9 + from importlib.resources import files, as_file + except ImportError: # python < 3.9 + from importlib_resources import files, as_file + + package, filename = path.split(':') + ref = files(package) / filename + with as_file(ref) as path: + return str(path) + + return path diff --git a/tests/test_util.py b/tests/test_util.py index 2e732ca..3d350cd 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -277,3 +277,43 @@ class TestProgressLoop(TestCase): # without progress mod.progress_loop(act, [1, 2, 3], None, message="whatever") + + +class TestResourcePath(TestCase): + + def test_basic(self): + + # package spec is resolved to path + path = mod.resource_path('wuttjamaican:util.py') + self.assertTrue(path.endswith('wuttjamaican/util.py')) + + # absolute path returned as-is + self.assertEqual(mod.resource_path('/tmp/doesnotexist.txt'), '/tmp/doesnotexist.txt') + + def test_basic_pre_python_3_9(self): + + # the goal here is to get coverage for code which would only + # run on python 3.8 and older, but we only need that coverage + # if we are currently testing python 3.9+ + if sys.version_info.major == 3 and sys.version_info.minor < 9: + pytest.skip("this test is not relevant before python 3.9") + + from importlib.resources import files, as_file + + orig_import = __import__ + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'importlib.resources': + raise ImportError + if name == 'importlib_resources': + return MagicMock(files=files, as_file=as_file) + return orig_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=mock_import): + + # package spec is resolved to path + path = mod.resource_path('wuttjamaican:util.py') + self.assertTrue(path.endswith('wuttjamaican/util.py')) + + # absolute path returned as-is + self.assertEqual(mod.resource_path('/tmp/doesnotexist.txt'), '/tmp/doesnotexist.txt')