From 5c79195620b3939ce589f1e7eb5a997df9c65134 Mon Sep 17 00:00:00 2001 From: Cristian Le Date: Mon, 20 May 2024 20:16:32 +0200 Subject: [PATCH] Extend resource_files for entry-points Signed-off-by: Cristian Le --- .pre-commit-config.yaml | 2 ++ pyproject.toml | 1 + tmt/utils.py | 67 +++++++++++++++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ef1f50d14..cf170ca3b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,7 @@ repos: # # For now, we simply copy & paste from pyproject.toml :( additional_dependencies: + - "importlib-metadata>=3.6.0; python_version < '3.10'" - "click>=8.0.3,!=8.1.4" # 8.1.3 / 8.1.6 TODO type annotations tmt.cli.Context -> click.core.Context click/issues/2558 - "docutils>=0.16" # 0.16 is the current one available for RHEL9 - "fmf>=1.3.0" @@ -75,6 +76,7 @@ repos: # # For now, we simply copy & paste from pyproject.toml :( additional_dependencies: + - "importlib-metadata>=3.6.0; python_version < '3.10'" - "click>=8.0.3,!=8.1.4" # 8.1.3 / 8.1.6 TODO type annotations tmt.cli.Context -> click.core.Context click/issues/2558 - "docutils>=0.16" # 0.16 is the current one available for RHEL9 - "fmf>=1.3.0" diff --git a/pyproject.toml b/pyproject.toml index e40beae437..4ff33687f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ "Operating System :: POSIX :: Linux", ] dependencies = [ # F39 / PyPI + "importlib-metadata>=3.6.0; python_version < '3.10'", "click>=8.0.3,!=8.1.4", # 8.1.3 / 8.1.6 TODO type annotations tmt.cli.Context -> click.core.Context click/issues/2558 "docutils>=0.16", # 0.16 is the current one available for RHEL9 "fmf>=1.3.0", diff --git a/tmt/utils.py b/tmt/utils.py index 74f63907da..c23da92920 100644 --- a/tmt/utils.py +++ b/tmt/utils.py @@ -27,6 +27,12 @@ from collections import Counter, OrderedDict from collections.abc import Iterable, Iterator, Sequence from contextlib import suppress + +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points +from importlib.readers import MultiplexedPath from re import Match, Pattern from threading import Thread from types import ModuleType @@ -7058,16 +7064,57 @@ def resource_files( Helper function to get path of package file or directory. A thin wrapper for :py:func:`importlib.resources.files`: - ``files()`` returns ``Traversable`` object, though in our use-case - it should always produce a :py:class:`pathlib.PosixPath` object. - Converting it to :py:class:`tmt.utils.Path` instance should be - safe and stick to the "``Path`` only!" rule in tmt's code base. - - :param path: file or directory path to retrieve, relative to the ``package`` root. - :param package: package in which to search for the file/directory. - :returns: an absolute path to the requested file or directory. - """ - return importlib.resources.files(package) / path + ``files()`` returns ``Traversable`` object that can be either a + :py:class:`pathlib.PosixPath` or a :py:class:`importlib.reader.MultiplexedPath`. + + If the final path is a file, go ahead and convert it to a regular ``Path``, otherwise + keep it as a traversable in order to properly support MultiplexedPaths. + + Additional search paths are introduced from entry-point definitions + + :param path: file or directory path to retrieve, relative to the ``package`` + or entry-point's root. + :param package: primary package in which to search for the file/directory. + :returns: a traversable path to the requested file or directory. + """ + def accumulate_path(paths: list[pathlib.Path], + pkg_path: Union[pathlib.Path, + MultiplexedPath]) -> None: + if isinstance(pkg_path, MultiplexedPath): + # The root resources.files can be a MultiplexedPath if it is a namespace + paths.extend(pkg_path._paths) + elif isinstance(pkg_path, pathlib.Path): + # Otherwise it should be a normal Path, just add it as-is + paths.append(pkg_path) + else: + # This should not happen + raise TypeError(f"Unexpected type improtlib.resources for package: {package}") + + # Accumulate the base path of the package and entry-points + base_paths: list[pathlib.Path] = [] + + main_path = importlib.resources.files(package) + accumulate_path(base_paths, main_path) + + # Additional resource files can be imported from entry-point + entry_point_name = 'tmt.resources' + + entry_point_group = entry_points().select(group=entry_point_name) + + for found in entry_point_group: + ep_module = found.load() + with suppress(Exception): + # TODO: Log errors if entry-point was defined that failed to retrieve base path + ep_path = importlib.resources.files(ep_module) + accumulate_path(base_paths, ep_path) + + # Extract the Path if only one was found + if len(base_paths) == 1: + return base_paths[0] / path + # Otherwise construct a MultiplexedPath + assert (len(base_paths) > 1) + search_path = MultiplexedPath(*base_paths) + return search_path / path class Stopwatch(contextlib.AbstractContextManager['Stopwatch']):