From 207fcb322ae427a74b799d3f7866ed2f9593d917 Mon Sep 17 00:00:00 2001 From: leycec Date: Sun, 22 Sep 2024 23:05:10 -0400 Subject: [PATCH] Significant cleanup x 14. This commit is the next in a commit chain quietly resurrecting BETSE for a modern audience and the modern Python ecosystem. Specifically, this commit continues resolving failing GitHub Actions-based continuous integration (CI) workflows by restoring detection of external commands required by optional runtime dependencies. BETSE 1.4.0 is now still days from official release, supposedly. (*Blunt runts!*) --- betse/lib/libs.py | 136 +++++++++++++----------------- betse/util/io/error/errwarning.py | 12 +-- betse/util/py/module/pymodname.py | 49 +++++++++-- 3 files changed, 108 insertions(+), 89 deletions(-) diff --git a/betse/lib/libs.py b/betse/lib/libs.py index 2faf0146..507bb0ad 100644 --- a/betse/lib/libs.py +++ b/betse/lib/libs.py @@ -28,6 +28,7 @@ # from betse.util.io.log import logs # from betse.util.app.meta import appmetaone from betse.util.py.module.pymodname import ( + DEPENDENCY_TO_COMMANDS, DEPENDENCY_TO_MODULE_NAME, import_module, is_module, @@ -236,38 +237,50 @@ def die_unless_runtime_optional(*dependency_names: str) -> None: # dependency_command.name, # dependency_command.basename, # )) -# -# # ....................{ TESTERS }.................... -# @type_check -# def is_command(*dependency_names: str) -> bool: -# ''' -# ``True`` only if all external commands required by all application -# dependencies (of any type, including optional, mandatory, runtime, testing, -# or otherwise) with the passed :mod:`setuptools`-specific project names are -# **installed** (i.e., are executable files in the current ``${PATH}``). -# -# Parameters -# ---------- -# dependency_names : Tuple[str] -# Tuple of the names of all :mod:`setuptools`-specific projects -# corresponding to these dependencies (e.g., ``NetworkX``). -# ''' -# -# # Avoid circular import dependencies. -# from betse.util.os.command import cmdpath -# -# # Return true only if... -# return all( -# # Each external command required by each dependency is in the ${PATH}. -# cmdpath.is_pathable(dependency_command.basename) -# # For the name of each passed dependency... -# for requirement_name in dependency_names -# # For each "RequirementCommand" instance describing an external command -# # required by this dependency... -# for dependency_command in _iter_requirement_commands(requirement_name) -# ) -# -# + +# ....................{ TESTERS }.................... +def is_commands(*dependency_names: str) -> bool: + ''' + ``True`` only if all external commands required by all application + dependencies (of any type, including optional, mandatory, runtime, testing, + or otherwise) with the passed :mod:`setuptools`-specific project names are + **installed** (i.e., are executable files in the current ``${PATH}``). + + Parameters + ---------- + dependency_names : Tuple[str] + Tuple of the names of all :mod:`setuptools`-specific projects + corresponding to these dependencies (e.g., ``NetworkX``). + ''' + + # Avoid circular import dependencies. + from betse.util.os.command.cmdpath import is_pathable + + # Return true only if... + return all( + # Each external command required by each dependency is in the ${PATH}. + is_pathable(dependency_command.basename) + # For the name of each passed dependency... + for dependency_name in dependency_names + # For each "RequirementCommand" instance describing an external command + # required by this dependency... + for dependency_command in DEPENDENCY_TO_COMMANDS.get( + dependency_name, ()) + ) + + #FIXME: Resurrect this when refactoring to "importlib.metadata", please. + # # Return true only if... + # return all( + # # Each external command required by each dependency is in the ${PATH}. + # cmdpath.is_pathable(dependency_command.basename) + # # For the name of each passed dependency... + # for requirement_name in dependency_names + # # For each "RequirementCommand" instance describing an external command + # # required by this dependency... + # for dependency_command in _iter_requirement_commands(requirement_name) + # ) + + def is_runtime_optional(*dependency_names: str) -> bool: ''' ``True`` only if all optional runtime dependencies of this application with @@ -302,11 +315,19 @@ def is_runtime_optional(*dependency_names: str) -> bool: module_name = DEPENDENCY_TO_MODULE_NAME.get( dependency_name, dependency_name) - # If this dependency is unimportable, immediately return false. - if not is_module(module_name): + # If it is *NOT* the case that... + if not ( + # This dependency is importable *AND*... + is_module(module_name) and + # All external commands required by this dependency are installed in + # the current "${PATH}"... + is_commands(dependency_name) + # Then immediately return false. + ): return False - # Else, this dependency is importable. Silently continue to the next. - # Else, all dependencies are importable. + # Else, this dependency is importable *AND* all external commands + # required by this dependency are installed in the current "${PATH}". + # Silently continue to the next dependency. # Return true. return True @@ -495,46 +516,3 @@ def import_runtime_optional(*dependency_names: str) -> ModuleOrSequenceTypes: # # Validate all external commands required by these dependencies. # return setuptool.import_requirements_dict_keys( # requirements_dict, *dependency_names) -# -# # ....................{ PRIVATE ~ iterators }.................... -# @type_check -# def _iter_requirement_commands(requirement_name: str) -> SequenceTypes: -# ''' -# Sequence of zero or more ``RequirementCommand`` instances describing all -# external commands required by the application dependency with the passed -# :mod:`setuptools`-specific project name. -# -# Parameters -# ---------- -# requirement_name : str -# Name of the :mod:`setuptools`-specific project to be inspected. -# -# Returns -# ---------- -# SequenceTypes: -# Sequence of zero or more ``RequirementCommand`` instances describing all -# external commands required by this project. -# ''' -# -# # Application-wide dependency metadata submodule. -# metadeps = appmetaone.get_app_meta().module_metadeps -# -# # Tuple of zero or more "RequirementCommand" instances describing -# # each external command required by this dependency if any *OR* the -# # empty tuple otherwise. -# dependency_commands = metadeps.REQUIREMENT_NAME_TO_COMMANDS.get( -# requirement_name, ()) -# -# #FIXME: Additionally validate that each "namedtuple" instance provides the -# #"name" and "basename" fields. -# # Validate this tuple to contain only "namedtuple" instances. -# # -# # Note that we intentionally do *NOT* validate this tuple to contain only -# # "betse.metadata.RequirementCommand" instances, as downstream consumers -# # (e.g., BETSEE) necessarily duplicate that type into their own codebases -# # (e.g., as "betsee.guimetadata.RequirementCommand"). -# itertest.die_unless_items_instance_of( -# iterable=dependency_commands, cls=tuple) -# -# # Return this tuple. -# return dependency_commands diff --git a/betse/util/io/error/errwarning.py b/betse/util/io/error/errwarning.py index 3d7c885b..4c3e5973 100644 --- a/betse/util/io/error/errwarning.py +++ b/betse/util/io/error/errwarning.py @@ -10,9 +10,12 @@ # ....................{ IMPORTS }.................... import sys, warnings -from beartype.typing import ContextManager +from beartype.typing import ( + ContextManager, + Generator, +) from betse.util.io.log import logs -from betse.util.type.types import type_check, ClassType, GeneratorType +from betse.util.type.types import ClassType from contextlib import contextmanager # ....................{ INITIALIZERS }.................... @@ -79,7 +82,7 @@ def init() -> None: logs.log_debug('Deferring to default warning policy.') # ....................{ MANAGERS }.................... -def ignoring_deprecations() -> GeneratorType: +def ignoring_deprecations() -> ContextManager: ''' Single-shot context manager temporarily ignoring all **deprecation warnings** (i.e., instances of the :class:`DeprecationWarning`, @@ -101,8 +104,7 @@ def ignoring_deprecations() -> GeneratorType: @contextmanager -@type_check -def ignoring_warnings(*warning_clses: ClassType) -> ContextManager: +def ignoring_warnings(*warning_clses: ClassType) -> Generator: ''' Single-shot context manager temporarily ignoring *all* warnings of *all* passed warning types emitted by the :mod:`warnings` module for the duration diff --git a/betse/util/py/module/pymodname.py b/betse/util/py/module/pymodname.py index 4a2e860e..41d20536 100644 --- a/betse/util/py/module/pymodname.py +++ b/betse/util/py/module/pymodname.py @@ -24,16 +24,55 @@ #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! import importlib, sys +from beartype.typing import ( + Collection, + Dict, +) from betse.exceptions import BetseModuleException from betse.util.io.log import logs from betse.util.type.iterable.mapping.mapcls import DefaultDict from betse.util.type.types import ( type_check, MappingOrNoneTypes, ModuleType, StrOrNoneTypes) -from collections import defaultdict +from collections import ( + defaultdict, + namedtuple, +) from importlib import util as importlib_util from importlib.machinery import ModuleSpec +# ....................{ CLASSES }.................... +DependencyCommand = namedtuple('DependencyCommand', ('name', 'basename',)) +DependencyCommand.__doc__ = ''' + Lightweight metadata describing a single external command required by an + application dependency of arbitrary type (including optional, mandatory, + runtime, testing, and otherwise). + + Attributes + ---------- + name : str + Human-readable name associated with this command (e.g., ``Graphviz``). + basename : str + Basename of this command to be searched for in the current ``${PATH}``. + ''' + # ....................{ GLOBALS }.................... +DEPENDENCY_TO_COMMANDS: Dict[str, Collection[DependencyCommand]] = { + # "pydot" requires Graphviz (i.e., "dot") to be externally installed. + 'pydot': (DependencyCommand(name='Graphviz', basename='dot'),), +} +''' +Dictionary mapping each **runtime dependency name** (i.e., human-readable name +of an open-source project hosted by the Python Packaging Index (PyPI), such as +``"PyYAML"``, of a runtime dependency of this project) to a collection of one +or more external commands required by that project. + +See Also +-------- +:download:`/doc/md/INSTALL.md` + Human-readable list of these dependencies. +''' + + DEPENDENCY_TO_MODULE_NAME = DefaultDict( missing_key_value=lambda self, missing_key: missing_key, initial_mapping={ @@ -46,10 +85,10 @@ } ) ''' -Dictionary mapping the human-readable name of an open-source project hosted by -the Python Packaging Index (PyPI) (e.g., ``PyYAML``) to the corresponding -fully-qualified name of the corresponding module or package providing that -project (e.g., ``yaml``). +Dictionary mapping each **runtime dependency name** (i.e., human-readable name +of an open-source project hosted by the Python Packaging Index (PyPI), such as +``"PyYAML"``, of a runtime dependency of this project) to the fully-qualified +name of the module or package providing that project (e.g., ``yaml``). All modules and packages explicitly unmapped by this dictionary default to the identity mapping -- that is, mapping the fully-qualified names of those modules