diff --git a/src/sas/qtgui/MainWindow/PackageGatherer.py b/src/sas/qtgui/MainWindow/PackageGatherer.py index 38da8127b9..89655026b0 100644 --- a/src/sas/qtgui/MainWindow/PackageGatherer.py +++ b/src/sas/qtgui/MainWindow/PackageGatherer.py @@ -1,8 +1,8 @@ +import importlib.metadata import logging -import pathlib +import pkgutil import sys - -import pkg_resources +import sysconfig import sas import sas.system.version @@ -31,14 +31,14 @@ def log_installed_modules(self): # Get python modules installed locally - installed_packages = pkg_resources.working_set - + pkgdetails = [] + for dist in importlib.metadata.distributions(): + pkgdetails.append(f"{dist.metadata['Name']}: {dist.version}") python_str = f'python:{sys.version}\n' - print_str = "\n".join(f"{package.key}: {package.version}" for package in installed_packages) - msg = f"Installed packages:\n{python_str+print_str}" + print_str = "\n".join(sorted(pkgdetails)) + msg = f"Installed packages:\n{python_str}{print_str}" logger.info(msg) - def log_imported_packages(self): """ Log version number of python packages imported in this instance of SasView. @@ -50,16 +50,16 @@ def log_imported_packages(self): """ imported_packages_dict = self.get_imported_packages() - res_str = "\n".join(f"{module}: {version_num}" for module, version_num - in imported_packages_dict["results"].items()) - no_res_str = "\n".join(f"{module}: {version_num}" for module, version_num - in imported_packages_dict["no_results"].items()) - errs_res_str = "\n".join(f"{module}: {version_num}" for module, version_num - in imported_packages_dict["errors"].items()) + def fmt(section): + s = [] + for module in sorted(imported_packages_dict[section]): + s.append(f"{module}: {imported_packages_dict[section][module]}") + return "\n".join(s) - msg = f"Imported modules:\n {res_str}\n {no_res_str}\n {errs_res_str}" - logger.info(msg) + sections = ("results", "no_results", "errors") + msg = f"Imported modules:\n{'\n\n'.join(fmt(s) for s in sections)}" + logger.info(msg) def get_imported_packages(self): """ Get a dictionary of imported package version numbers @@ -76,8 +76,8 @@ def get_imported_packages(self): no_version_list = [] # Generate a list of standard modules by looking at the local python library try: - standard_lib = [path.stem.split('.')[0] for path in pathlib.Path(pathlib.__file__) - .parent.absolute().glob('*')] + stdlib_path = sysconfig.get_paths()["stdlib"] + standard_lib = [name for _, name, _ in pkgutil.iter_modules([stdlib_path])] except Exception: standard_lib = ['abc', 'aifc', 'antigravity', 'argparse', 'ast', 'asynchat', 'asyncio', 'asyncore', 'base64', 'bdb', 'binhex', 'bisect', 'bz2', 'calendar', 'cgi', 'cgitb', 'chunk', 'cmd', @@ -109,10 +109,18 @@ def get_imported_packages(self): standard_lib.extend(sys.builtin_module_names) standard_lib.append("sas") - for module_name in sys.modules.keys(): + # extract all module distributions already known + dists = importlib.metadata.packages_distributions() + + for module_name in list(sys.modules): package_name = module_name.split('.')[0] + # skip modules that start with _ as they are internal implementation details + # of the real modules and not interesting. + if package_name.startswith("_"): + continue + # A built in python module or a local file, which have no version, only the python/SasView version if package_name in standard_lib or package_name in package_versions_dict: continue @@ -136,12 +144,13 @@ def get_imported_packages(self): f"version using .__version__" pass - # Retrieving the modules version using the pkg_resources package + # Retrieving the modules version from importlib.metadata # Unreliable, so second option try: - package_versions_dict[package_name] = pkg_resources.get_distribution(package_name).version + dist_name = dists[package_name][0] + package_versions_dict[package_name] = importlib.metadata.distribution(dist_name).version except Exception: - # Modules that cannot be found by pkg_resources + # Modules that cannot be found by importlib.metadata pass else: continue @@ -171,7 +180,6 @@ def get_imported_packages(self): return {"results": package_versions_dict, "no_results": no_version_dict, "errors": err_version_dict} - def remove_duplicate_modules(self, modules_dict): """ Strip duplicate instances of each module @@ -191,7 +199,7 @@ def remove_duplicate_modules(self, modules_dict): """ output_dict = dict() - for module_name in modules_dict.keys(): + for module_name in modules_dict: parent_module = module_name.split('.')[0] # Save one instance of each module if parent_module not in output_dict: @@ -204,7 +212,6 @@ def remove_duplicate_modules(self, modules_dict): return output_dict - def format_no_version_list(self, modules_dict, no_version_list): """ Format module names in the no_version_list list @@ -229,7 +236,7 @@ def format_no_version_list(self, modules_dict, no_version_list): for module_name in no_version_list: parent_module = module_name.split('.')[0] # Version number exists for this module - if parent_module in modules_dict.keys(): + if parent_module in modules_dict: pass # Module is already in output_list elif parent_module in output_dict: diff --git a/src/sas/qtgui/Utilities/Reports/reports.py b/src/sas/qtgui/Utilities/Reports/reports.py index 4f95415c90..1887563373 100644 --- a/src/sas/qtgui/Utilities/Reports/reports.py +++ b/src/sas/qtgui/Utilities/Reports/reports.py @@ -1,6 +1,5 @@ import base64 import datetime -import importlib.resources as pkg_resources import logging import os import sys @@ -20,6 +19,7 @@ from sas.qtgui.Plotting.PlotterBase import Data1D from sas.qtgui.Utilities import GuiUtils from sas.qtgui.Utilities.Reports.reportdata import ReportData +from sas.system import SAS_RESOURCES logger = logging.getLogger(__name__) @@ -100,8 +100,8 @@ def __init__(self, tags.link(rel="stylesheet", href=style_link) else: - style_data = pkg_resources.read_text("sas.qtgui.Utilities.Reports", "report_style.css") - tags.style(style_data) + with SAS_RESOURCES.resource("qtgui/Utilities/Reports/report_style.css") as res_path: + tags.style(res_path.read_text()) with self._html_doc.body: with tags.div(id="main"): diff --git a/src/sas/system/resources.py b/src/sas/system/resources.py index 7a0354f573..646a376208 100644 --- a/src/sas/system/resources.py +++ b/src/sas/system/resources.py @@ -35,6 +35,7 @@ # glob matching; until we're using that, we have some messier regular # expressions in the code below. +import contextlib import enum import functools import importlib.metadata @@ -42,6 +43,8 @@ import itertools import logging import re +import tempfile +from collections.abc import Generator from pathlib import Path, PurePath logger = logging.getLogger(__name__) @@ -106,6 +109,32 @@ def extract_resource_tree(self, src: str | PurePath, dest: Path | str) -> bool: raise NotADirectoryError(f"Resource tree {src} not found in module {self.module}") + @contextlib.contextmanager + def resource(self, src: str | PurePath) -> Generator[Path, None, None]: + """Provide a filesystem path to a file resource, in a temporary directory if needed + + If the resource is already available on the filesystem, then provide + the path to it directly; if it is available as an extractable resource, + then provide it in a temporary directory that will get cleaned up + when the context manager is completed. + + If the resource can't be found by any means, then a FileNotFoundError + is raised. + """ + # step 1: look for the resource already on disk + try: + path = self.path_to_resource(src) + yield path + return + except FileNotFoundError: + pass + + # step 2: if not already on disk then it's time for a temp dir + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) / Path(src).name + self.extract_resource(src, tmp_path) + yield tmp_path + def path_to_resource(self, src: str | PurePath) -> Path: """Provide the filesystem path to a file resource diff --git a/test/system/utest_resources.py b/test/system/utest_resources.py index bc4a1b735b..fb07700249 100644 --- a/test/system/utest_resources.py +++ b/test/system/utest_resources.py @@ -67,7 +67,19 @@ def test_path_to_resource_directory(self, input_type): assert Path(str(pth)).is_dir() assert len(list(pth.iterdir())) > 10 - def test_resource(self, tmp_path, input_type): + def test_resource(self, input_type): + """Test extracting single resource recorded by installed module""" + resources = sas.system.resources.ModuleResources("sas") + + source = Path("docs/index.html") + src = input_type(source) + + with resources.resource(src) as dest: + assert dest.exists() + assert dest.as_posix().endswith(source.name) + assert dest.stat().st_size > 1000 + + def test_extract_resource(self, tmp_path, input_type): """Test extracting single resource recorded by installed module""" resources = sas.system.resources.ModuleResources("sas") @@ -83,7 +95,7 @@ def test_resource(self, tmp_path, input_type): assert resources.extract_resource(src, dest) assert dest.stat().st_size > 1000 - def test_resource_tree(self, tmp_path, input_type): + def test_extract_resource_tree(self, tmp_path, input_type): """Test extracting resource tree recorded by installed module""" resources = sas.system.resources.ModuleResources("sas") @@ -133,7 +145,19 @@ def test_path_to_resource_directory(self, input_type): assert Path(str(pth)).is_dir() assert len(list(pth.iterdir())) > 10 - def test_resource(self, tmp_path, input_type): + def test_resource(self, input_type): + """Test extracting single resource recorded by installed module""" + resources = sas.system.resources.ModuleResources("sas") + + source = Path("qtgui/images/ball.ico") + src = input_type(source) + + with resources.resource(src) as dest: + assert dest.exists() + assert dest.as_posix().endswith(source.name) + assert dest.stat().st_size > 1000 + + def test_extract_resource(self, tmp_path, input_type): """Test extracting single resource adjacent to code in module""" resources = sas.system.resources.ModuleResources("sas") @@ -149,7 +173,7 @@ def test_resource(self, tmp_path, input_type): assert resources.extract_resource(src, dest) assert dest.stat().st_size > 1000 - def test_resource_tree(self, tmp_path, input_type): + def test_extract_resource_tree(self, tmp_path, input_type): """Test extracting resource tree adjacent to code in module""" resources = sas.system.resources.ModuleResources("sas") @@ -183,7 +207,7 @@ def test_nonexisting_path_to_resource(self, input_type): for pth in self.check_paths: with pytest.raises(FileNotFoundError): src = input_type(pth) - assert resources.path_to_resource(src) + resources.path_to_resource(src) def test_nonexisting_extract_resource(self, tmp_path, input_type): """Test exception raised for extracting non-existent resource""" @@ -193,7 +217,17 @@ def test_nonexisting_extract_resource(self, tmp_path, input_type): for pth in self.check_paths: with pytest.raises(FileNotFoundError): src = input_type(pth) - assert resources.extract_resource(src, dest) + resources.extract_resource(src, dest) + + def test_nonexisting_resource(self, input_type): + """Test exception raised for extracting non-existent resource""" + resources = sas.system.resources.ModuleResources("sas") + + for pth in self.check_paths: + with pytest.raises(FileNotFoundError): + src = input_type(pth) + with resources.resource(src): + pass def test_nonexisting_extract_resource_tree(self, tmp_path, input_type): """Test exception raised for extracting non-existent resource tree""" @@ -203,4 +237,4 @@ def test_nonexisting_extract_resource_tree(self, tmp_path, input_type): for pth in self.check_paths + ["sas/docs/"]: with pytest.raises(NotADirectoryError): src = input_type(pth) - assert resources.extract_resource_tree(src, dest) + resources.extract_resource_tree(src, dest)