diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 815f5ae53571743970525f988cf057ad498d79ca..1113827480343b7e1dd6064ed92ef6dcfa941833 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,38 +5,18 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/psf/black - rev: 24.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.3 hooks: - - id: black - - repo: https://github.com/pycqa/docformatter - rev: v1.7.5 - hooks: - - id: docformatter - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 + - id: ruff + args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy - args: [ - --install-types, - --non-interactive, - --no-strict-optional, - --ignore-missing-imports, - ] + args: [ --install-types, --non-interactive, --no-strict-optional, --ignore-missing-imports ] exclude: '^.*/data/second_config\.py$' - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 - hooks: - - id: pyupgrade - args: [--py38-plus] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: diff --git a/doc/conf.py b/doc/conf.py index c1eef9ecc82133150218d6c2e2eef46275104faa..8f7734d76a0d72d4cdc98055351156360a61c525 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -import os +import pathlib import time from importlib.metadata import distribution @@ -35,8 +35,9 @@ nitpicky = True nitpick_ignore = [] # Allows the user to override warnings from a separate file -if os.path.exists("nitpick-exceptions.txt"): - for line in open("nitpick-exceptions.txt"): +nitpick_path = pathlib.Path("nitpick-exceptions.txt") +if nitpick_path.exists(): + for line in nitpick_path.open(): if line.strip() == "" or line.startswith("#"): continue dtype, target = line.split(None, 1) @@ -53,8 +54,8 @@ autosummary_generate = True numfig = True # If we are on OSX, the 'dvipng' path maybe different -dvipng_osx = "/Library/TeX/texbin/dvipng" -if os.path.exists(dvipng_osx): +dvipng_osx = pathlib.Path("/Library/TeX/texbin/dvipng") +if dvipng_osx.exists(): pngmath_dvipng = dvipng_osx # Add any paths that contain templates here, relative to this directory. @@ -70,7 +71,7 @@ master_doc = "index" project = "clapper" package = distribution(project) -copyright = "%s, Idiap Research Institute" % time.strftime("%Y") +copyright = "%s, Idiap Research Institute" % time.strftime("%Y") # noqa: A001 # The short X.Y version. version = package.version @@ -118,7 +119,7 @@ auto_intersphinx_packages = [("python", "3"), "click"] auto_intersphinx_catalog = "catalog.json" # Doctest global setup -sphinx_source_dir = os.path.abspath(".") +sphinx_source_dir = pathlib.Path.cwd().resolve() doctest_global_setup = f""" import os data = os.path.join('{sphinx_source_dir}', 'data') diff --git a/doc/example_alias.py b/doc/example_alias.py index b4d45c06133c086fec622e1b2757540c90c5e2be..03396948ee6606253a79bb2ee83caa6acbf62652 100644 --- a/doc/example_alias.py +++ b/doc/example_alias.py @@ -7,23 +7,25 @@ # essential packages needed to start the CLI. Defer all other imports to # within the function implementing the command. -import click - import clapper.click +import click @click.group(cls=clapper.click.AliasedGroup) def main(): + """Declare main command-line application.""" pass @main.command() def push(): + """Push subcommand.""" click.echo("push was called") @main.command() def pop(): + """Pop subcommand.""" click.echo("pop was called") diff --git a/doc/example_cli.py b/doc/example_cli.py index 1db77c7e15915bc36e075c0dd2244e3ceae516cc..85013db65d52b359ff0b22de251f6576d3555d84 100644 --- a/doc/example_cli.py +++ b/doc/example_cli.py @@ -42,7 +42,7 @@ Examples: @click.version_option(package_name="clapper") @click.pass_context def main(ctx, **_): - """Tests our Click interfaces.""" + """Test our Click interfaces.""" # Add imports needed for your code here, and avoid spending time loading! # In this example, we just print the loaded options to demonstrate loading diff --git a/doc/example_options.py b/doc/example_options.py index 812063e43df8d5cf566a385ce15f21bb616e112d..4e48871a99fdf8c5702bc8151694c556dedc9a63 100644 --- a/doc/example_options.py +++ b/doc/example_options.py @@ -5,4 +5,4 @@ integer = 1000 flag = True choice = "blue" -str = "bar" +str = "bar" # noqa: A001 diff --git a/pyproject.toml b/pyproject.toml index 308d69e75ed3bbfe1eff09c5481f171be0ee2f19..9e80ba904814a816018027f92bbae2b049047887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,64 @@ complex-var = "tests.data.complex:cplx" verbose-config = "tests.data.verbose_config" error-config = "tests.data.doesnt_exist" +[tool.ruff] +line-length = 80 +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + "A", # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d + "E", # https://docs.astral.sh/ruff/rules/#error-e + "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f + "I", # https://docs.astral.sh/ruff/rules/#isort-i + "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc + "LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log + "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n + "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q + "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret + "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10 + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "W", # https://docs.astral.sh/ruff/rules/#warning-w + #"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + #"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn +] +ignore = [ + "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ + "D100", # https://docs.astral.sh/ruff/rules/undocumented-public-module/ + "D102", # https://docs.astral.sh/ruff/rules/undocumented-public-method/ + "D104", # https://docs.astral.sh/ruff/rules/undocumented-public-package/ + "D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/ + "D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/ + "D203", # https://docs.astral.sh/ruff/rules/one-blank-line-before-class/ + "D202", # https://docs.astral.sh/ruff/rules/no-blank-line-after-function/ + "D205", # https://docs.astral.sh/ruff/rules/blank-line-after-summary/ + "D212", # https://docs.astral.sh/ruff/rules/multi-line-summary-first-line/ + "D213", # https://docs.astral.sh/ruff/rules/multi-line-summary-second-line/ + "E302", # https://docs.astral.sh/ruff/rules/blank-lines-top-level/ + "E402", # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ + "E501", # https://docs.astral.sh/ruff/rules/line-too-long/ + "ISC001", # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ +] + +[tool.ruff.lint.isort] +# Use a single line between direct and from import. +lines-between-types = 1 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = ["D", "E501"] +"doc/conf.py" = ["D"] + [tool.isort] profile = "black" line_length = 80 diff --git a/src/clapper/click.py b/src/clapper/click.py index 38479ffd4f205660939c35b45a08935f9f9eddc2..67a3003933c29cc3fee137a91de4374062dc039d 100644 --- a/src/clapper/click.py +++ b/src/clapper/click.py @@ -3,11 +3,10 @@ # SPDX-License-Identifier: BSD-3-Clause """Helpers to build command-line interfaces (CLI) via :py:mod:`click`.""" -from __future__ import annotations - import functools import inspect import logging +import pathlib import pprint import shutil import time @@ -75,8 +74,8 @@ def verbosity_option( :py:func:`click.option` - Returns: - + Returns + ------- A callable, that follows the :py:mod:`click`-framework policy for option decorators. Use it accordingly. """ @@ -84,7 +83,7 @@ def verbosity_option( def custom_verbosity_option(f): def callback(ctx, param, value): ctx.meta[name] = value - log_level: int = { + log_level: int = { # type: ignore 0: logging.ERROR, 1: logging.WARNING, 2: logging.INFO, @@ -151,7 +150,7 @@ class ConfigCommand(click.Command): self, name: str, *args: tuple, - help: str | None = None, + help: str | None = None, # noqa: A002 entry_point_group: str | None = None, **kwargs: typing.Any, ) -> None: @@ -164,7 +163,7 @@ files (or names of ``{entry_point_group}`` entry points or module names) as {configs_argument_name} arguments to the command line which contain the parameters listed below as Python variables. The options through the command-line (see below) will override the values of configuration files. You can run this command with ``<COMMAND> -H example_config.py`` to create a template config file.""" - help = (help or "").rstrip() + self.extra_help + help = (help or "").rstrip() + self.extra_help # noqa: A001 super().__init__(name, *args, help=help, **kwargs) # Add the config argument to the command @@ -275,7 +274,8 @@ will override the values of configuration files. You can run this command with class CustomParamType(click.ParamType): """Custom parameter class allowing click to receive complex Python types as - parameters.""" + parameters. + """ name = "custom" @@ -308,9 +308,6 @@ class ResourceOption(click.Option): Using this class without :py:class:`ConfigCommand` and without providing `entry_point_group` does nothing and is not allowed. - - - Attributes: """ entry_point_group: str | None @@ -337,8 +334,8 @@ class ResourceOption(click.Option): multiple=False, count=False, allow_from_autoenv=True, - type=None, - help=None, + type=None, # noqa: A002 + help=None, # noqa: A002 entry_point_group=None, required=False, string_exceptions=None, @@ -355,19 +352,19 @@ class ResourceOption(click.Option): and (count is False) and (is_flag is None) ): - type = CustomParamType() + type = CustomParamType() # noqa: A001 self.entry_point_group = entry_point_group if entry_point_group is not None: name, _, _ = self._parse_decls( param_decls, kwargs.get("expose_value") ) - help = help or "" - help += ( + help = help or "" # noqa: A001 + help += ( # noqa: A001 f" Can be a `{entry_point_group}' entry point, a module name, or " f"a path to a Python file which contains a variable named `{name}'." ) - help = help.format(entry_point_group=entry_point_group, name=name) + help = help.format(entry_point_group=entry_point_group, name=name) # noqa: A001 super().__init__( param_decls=param_decls, @@ -390,21 +387,21 @@ class ResourceOption(click.Option): def consume_value( self, ctx: click.Context, opts: dict ) -> tuple[typing.Any, ParameterSource]: - """Retrieves value for parameter from appropriate context. + """Retrieve value for parameter from appropriate context. This method will retrive the value of its own parameter from the appropriate context, by trying various sources. + Parameters + ---------- + ctx + The click context to retrieve the value from + opts + command-line options, eventually passed by the user - Arguments: - - ctx: The click context to retrieve the value from - - opts: command-line options, eventually passed by the user - - - Returns: + Returns + ------- A tuple containing the parameter value (of any type) and the source it used to retrieve it. """ @@ -461,8 +458,8 @@ class ResourceOption(click.Option): value: The actual value, that needs to be cast - Returns: - + Returns + ------- The cast value """ value = super().type_cast_value(ctx, value) @@ -501,21 +498,22 @@ class AliasedGroup(click.Group): matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] if not matches: return None - elif len(matches) == 1: + + if len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail("Too many matches: %s" % ", ".join(sorted(matches))) + + ctx.fail("Too many matches: %s" % ", ".join(sorted(matches))) # noqa: RET503 def user_defaults_group( logger: logging.Logger, config: UserDefaults, ) -> typing.Callable[..., typing.Any]: - """Decorator to add a command group to read/write RC configuration. + """Add a command group to read/write RC configuration. This decorator adds a whole command group to a user predefined function which is part of the user's CLI. The command group allows the user to get - and set options - through the command-line interface: + and set options through the command-line interface: .. code-block:: python @@ -527,10 +525,11 @@ def user_defaults_group( user_defaults = UserDefaults("~/.myapprc") ... + @user_defaults_group(logger=logger, config=user_defaults) def rc(**kwargs): - '''Use this command to affect the global user configuration.''' - pass + '''Use this command to affect the global user configuration.''' + pass Then use it like this: @@ -542,7 +541,7 @@ def user_defaults_group( """ def group_decorator( - func: typing.Callable[..., typing.Any] + func: typing.Callable[..., typing.Any], ) -> typing.Callable[..., typing.Any]: @click.group( cls=AliasedGroup, @@ -557,20 +556,21 @@ def user_defaults_group( @group_wrapper.command(context_settings=_COMMON_CONTEXT_SETTINGS) @verbosity_option(logger=logger) def show(**_: typing.Any) -> None: - """Shows the user-defaults file contents.""" + """Show the user-defaults file contents.""" click.echo(str(config).strip()) @group_wrapper.command( - no_args_is_help=True, context_settings=_COMMON_CONTEXT_SETTINGS + no_args_is_help=True, + context_settings=_COMMON_CONTEXT_SETTINGS, ) @click.argument("key") @verbosity_option(logger=logger) def get(key: str, **_: typing.Any) -> None: - """Prints a key from the user-defaults file. + """Print a key from the user-defaults file. - Retrieves the value of the requested KEY and displays it. - The KEY may contain dots (``.``) to access values from - subsections in the TOML_ document. + Retrieves the value of the requested KEY and displays it. The KEY + may contain dots (``.``) to access values from subsections in the + TOML_ document. """ try: click.echo(config[key]) @@ -580,26 +580,25 @@ def user_defaults_group( ) @group_wrapper.command( - no_args_is_help=True, context_settings=_COMMON_CONTEXT_SETTINGS + name="set", + no_args_is_help=True, + context_settings=_COMMON_CONTEXT_SETTINGS, ) @click.argument("key") @click.argument("value") @verbosity_option(logger=logger) - def set(key: str, value: str, **_: typing.Any) -> None: - """Sets the value for a key on the user-defaults file. + def set_(key: str, value: str, **_: typing.Any) -> None: + """Set the value for a key on the user-defaults file. - If ``key`` contains dots (``.``), then this sets nested - subsection + If ``key`` contains dots (``.``), then this sets nested subsection variables on the configuration file. Values are parsed and translated following the rules of TOML_. .. warning:: - This command will override the current configuration file - and my - erase any user comments added by hand. To avoid this, - simply - edit your configuration file by hand. + This command will override the current configuration file and my + erase any user comments added by hand. To avoid this, simply + edit your configuration file by hand. """ try: tmp = tomli.loads(f"v = {value}") @@ -625,21 +624,17 @@ def user_defaults_group( @click.argument("key") @verbosity_option(logger=logger) def rm(key: str, **_: typing.Any) -> None: - """Removes the given key from the configuration file. + """Remove the given key from the configuration file. - This command will remove the KEY from the configuration - file. If - the input key corresponds to a section in the configuration - file, + This command will remove the KEY from the configuration file. If + the input key corresponds to a section in the configuration file, then the whole configuration section will be removed. .. warning:: - This command will override the current configuration file - and my - erase any user comments added by hand. To avoid this, - simply - edit your configuration file by hand. + This command will override the current configuration file and my + erase any user comments added by hand. To avoid this, simply + edit your configuration file by hand. """ try: del config[key] @@ -662,8 +657,7 @@ def config_group( logger: logging.Logger, entry_point_group: str, ) -> typing.Callable[..., typing.Any]: - """Decorator to add a command group to list/describe/copy job - configurations. + """Add a command group to list/describe/copy job configurations. This decorator adds a whole command group to a user predefined function which is part of the user's CLI. The command group provdes an interface to @@ -679,10 +673,11 @@ def config_group( logger = logging.getLogger(__name__) ... + @config_group(logger=logger, entry_point_group="mypackage.config") def config(**kwargs): - '''Use this command to list/describe/copy config files.''' - pass + '''Use this command to list/describe/copy config files.''' + pass Then use it like this: @@ -694,7 +689,7 @@ def config_group( """ def group_decorator( - func: typing.Callable[..., typing.Any] + func: typing.Callable[..., typing.Any], ) -> typing.Callable[..., typing.Any]: @click.group( cls=AliasedGroup, context_settings=_COMMON_CONTEXT_SETTINGS @@ -704,22 +699,25 @@ def config_group( def group_wrapper(**kwargs): return func(**kwargs) - @group_wrapper.command(context_settings=_COMMON_CONTEXT_SETTINGS) + @group_wrapper.command( + name="list", + context_settings=_COMMON_CONTEXT_SETTINGS, + ) @click.pass_context @verbosity_option(logger=logger) - def list(ctx, **_: typing.Any): - """Lists installed configuration resources.""" - from .config import _retrieve_entry_points + def list_(ctx, **_: typing.Any): + """List installed configuration resources.""" + from importlib.metadata import entry_points # type: ignore - entry_points: dict[str, EntryPoint] = { - e.name: e for e in _retrieve_entry_points(entry_point_group) + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) } # all modules with configuration resources modules: set[str] = { # note: k.module does not exist on Python < 3.9 k.value.split(":")[0].rsplit(".", 1)[0] - for k in entry_points.values() + for k in entry_points.values() # type: ignore } keep_modules: set[str] = set() for k in sorted(modules): @@ -733,7 +731,7 @@ def config_group( entry_points_by_module: dict[str, dict[str, EntryPoint]] = {} for k in modules: entry_points_by_module[k] = {} - for name, ep in entry_points.items(): + for name, ep in entry_points.items(): # type: ignore # note: ep.module does not exist on Python < 3.9 module = ep.value.split(":", 1)[0] # works on Python 3.8 if module.startswith(k): @@ -752,7 +750,7 @@ def config_group( click.echo(f"module: {config_type}") for name in sorted(entry_points_by_module[config_type]): - ep = entry_points[name] + ep = entry_points[name] # type: ignore if (ctx.parent.params["verbose"] >= 1) or ( ctx.params["verbose"] >= 1 @@ -806,18 +804,18 @@ def config_group( ) @verbosity_option(logger=logger) def describe(ctx, name, **_: typing.Any): - """Describes a specific configuration resource.""" - from .config import _retrieve_entry_points + """Describe a specific configuration resource.""" + from importlib.metadata import entry_points # type: ignore - entry_points: dict[str, EntryPoint] = { - e.name: e for e in _retrieve_entry_points(entry_point_group) + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) } for k in name: - if k not in entry_points: + if k not in entry_points: # type: ignore logger.error(f"Cannot find configuration resource `{k}'") continue - ep = entry_points[k] + ep = entry_points[k] # type: ignore click.echo(f"Configuration: {ep.name}") click.echo(f"Python object: {ep.value}") click.echo("") @@ -829,7 +827,7 @@ def config_group( ): fname = inspect.getfile(mod) click.echo("Contents:") - with open(fname) as f: + with pathlib.Path(fname).open() as f: click.echo(f.read()) else: # only output documentation, if module doc = inspect.getdoc(mod) @@ -852,24 +850,26 @@ def config_group( ) @verbosity_option(logger=logger) def copy(source, destination, **_: typing.Any): - """Copies a specific configuration resource so it can be modified - locally.""" - - from .config import _retrieve_entry_points + """Copy a specific configuration resource so it can be modified + locally. + """ + from importlib.metadata import entry_points # type: ignore - entry_points: dict[str, EntryPoint] = { - e.name: e for e in _retrieve_entry_points(entry_point_group) + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) } - if source not in entry_points: + if source not in entry_points: # type: ignore logger.error(f"Cannot find configuration resource `{source}'") return 1 - ep = entry_points[source] + ep = entry_points[source] # type: ignore mod = ep.load() src_name = inspect.getfile(mod) logger.info(f"cp {src_name} -> {destination}") shutil.copyfile(src_name, destination) + return None + return group_wrapper return group_decorator @@ -878,13 +878,14 @@ def config_group( def log_parameters( logger_handle: logging.Logger, ignore: tuple[str] | None = None ): - """Logs the click parameters with the logging module. - - Arguments: - - logger: The :py:class:`logging.Logger` handle to write debug information into. - - ignore : List of the parameters to ignore when logging. (Tuple) + """Log the click parameters with the logging module. + + Parameters + ---------- + logger + The :py:class:`logging.Logger` handle to write debug information into. + ignore + List of the parameters to ignore when logging. (Tuple) """ ignore = ignore or tuple() ctx = click.get_current_context() diff --git a/src/clapper/config.py b/src/clapper/config.py index 3dd2fe71a658a7f608226d6c16aff3f598cfdcd0..a7ccda791f5ade5dfb978b022a19fb783bc1a83a 100644 --- a/src/clapper/config.py +++ b/src/clapper/config.py @@ -3,13 +3,9 @@ # SPDX-License-Identifier: BSD-3-Clause """Functionality to implement python-based config file parsing and loading.""" -from __future__ import annotations - import logging -import os.path import pathlib import pkgutil -import sys import types import typing @@ -24,7 +20,7 @@ to avoid the garbage collector to collect some already imported modules.""" def _load_context(path: str, mod: types.ModuleType) -> types.ModuleType: - """Loads the Python file as module, returns a resolved context. + """Load the Python file as module, returns a resolved context. This function is implemented in a way that is both Python 2 and Python 3 compatible. It does not directly load the python file, but reads its @@ -48,19 +44,19 @@ def _load_context(path: str, mod: types.ModuleType) -> types.ModuleType: representing the contents of the module to be created. - Returns: - + Returns + ------- A python module with the fully resolved context """ # executes the module code on the context of previously imported modules - with open(path, "rb") as f: + with pathlib.Path(path).open("rb") as f: exec(compile(f.read(), path, "exec"), mod.__dict__) return mod def _get_module_filename(module_name: str) -> str | None: - """Resolves a module name to an actual Python file. + """Resolve a module name to an actual Python file. This function will return the path to the file containing the module named at ``module_name``. Values for this parameter are dot-separated module @@ -72,8 +68,8 @@ def _get_module_filename(module_name: str) -> str | None: module_name: The name of the module to search - Returns: - + Returns + ------- The path that corresponds to file implementing the provided module name """ try: @@ -95,35 +91,12 @@ def _object_name( return r[0], (common_name if len(r) < 2 else r[1]) -def _retrieve_entry_points(group: str) -> typing.Iterable[EntryPoint]: - """Wraps various entry-point retrieval mechanisms. - - For Python 3.9 and 3.10, - :py:func:`importlib.metadata.entry_points()` - returns a dictionary keyed by entry-point group names. From Python - 3.10 - onwards, one may pass the ``group`` keyword to that function to - enable - pre-filtering, or use the ``select()`` method on the returned value, - which - is no longer a dictionary. - - For anything before Python 3.8, you must use the backported library - ``importlib_metadata``. - """ - if sys.version_info[:2] < (3, 10): - all_entry_points = entry_points() - return all_entry_points.get(group, []) # Python 3.9 - - return entry_points(group=group) # Python 3.10 and above - - def _resolve_entry_point_or_modules( paths: list[str | pathlib.Path], entry_point_group: str | None = None, common_name: str | None = None, ) -> tuple[list[str], list[str], list[str]]: - """Resolves a mixture of paths, entry point names, and module names to + """Resolve a mixture of paths, entry point names, and module names to path. This function can resolve actual file system paths, ``setup.py`` @@ -134,21 +107,20 @@ def _resolve_entry_point_or_modules( path, an entry-point described in a ``setup.py`` file, or the name of a python module. + Parameters + ---------- + paths + An iterable strings that either point to actual files, are entry point + names, or are module names. + entry_point_group + The entry point group name to search in entry points. + common_name + It will be used as a default name for object names. See the + ``attribute_name`` parameter from :py:func:`load`. - Arguments: - - paths: An iterable strings that either point to actual files, are entry - point names, or are module names. - - entry_point_group: The entry point group name to search in entry - points. - - common_name: It will be used as a default name for object names. See - the ``attribute_name`` parameter from :py:func:`load`. - - - Returns: + Returns + ------- A tuple containing three lists of strings with: * The resolved paths pointing to existing files @@ -157,15 +129,15 @@ def _resolve_entry_point_or_modules( * The name of objects that are supposed to be picked from paths - Raises: - - ValueError: If one of the paths cannot be resolved to an actual path to - a file. + Raises + ------ + ValueError + If one of the paths cannot be resolved to an actual path to a file. """ if entry_point_group is not None: entry_point_dict: dict[str, EntryPoint] = { - e.name: e for e in _retrieve_entry_points(entry_point_group) + e.name: e for e in entry_points(group=entry_point_group) } else: entry_point_dict = {} @@ -181,7 +153,7 @@ def _resolve_entry_point_or_modules( resolved_path, object_name = _object_name(path, common_name) # if it already points to a file, then do nothing - if os.path.isfile(resolved_path): + if pathlib.Path(resolved_path).is_file(): pass # If it is an entry point name, collect path and module name @@ -191,7 +163,10 @@ def _resolve_entry_point_or_modules( object_name = entry.attr if entry.attr else common_name resolved_path = _get_module_filename(module_name) - if resolved_path is None or not os.path.isfile(resolved_path): + if ( + resolved_path is None + or not pathlib.Path(resolved_path).is_file() + ): raise ValueError( f"The specified entry point `{path}' pointing to module " f"`{module_name}' and resolved to `{resolved_path}' does " @@ -202,7 +177,10 @@ def _resolve_entry_point_or_modules( else: # if we have gotten here so far then path must resolve as a module resolved_path = _get_module_filename(resolved_path) - if resolved_path is None or not os.path.isfile(resolved_path): + if ( + resolved_path is None + or not pathlib.Path(resolved_path).is_file() + ): raise ValueError( f"The specified path `{path}' is not a file, a entry " f"point name, or a known-module name" @@ -221,49 +199,47 @@ def load( entry_point_group: str | None = None, attribute_name: str | None = None, ) -> types.ModuleType | typing.Any: - """Loads a set of configuration files, in sequence. + """Load a set of configuration files, in sequence. This method will load one or more configuration files. Every time a configuration file is loaded, the context (variables) loaded from the previous file is made available, so the new configuration file can override or modify this context. - - Arguments: - - paths: A list or iterable containing paths (relative or absolute) of - configuration files that need to be loaded in sequence. Each - configuration file is loaded by creating/modifying the context - generated after each file readout. - - context: If provided, start the readout of the first configuration file - with the given context. Otherwise, create a new internal context. - - entry_point_group: If provided, it will treat non-existing file paths - as entry point names under the ``entry_point_group`` name. - - attribute_name: If provided, will look for the ``attribute_name`` variable - inside the loaded files. Paths ending with - ``some_path:variable_name`` can override the ``attribute_name``. The - ``entry_point_group`` must provided as well ``attribute_name`` is - not ``None``. - - - Returns: - + Parameters + ---------- + paths + A list or iterable containing paths (relative or absolute) of + configuration files that need to be loaded in sequence. Each + configuration file is loaded by creating/modifying the context + generated after each file readout. + context + If provided, start the readout of the first configuration file with the + given context. Otherwise, create a new internal context. + entry_point_group + If provided, it will treat non-existing file paths as entry point names + under the ``entry_point_group`` name. + attribute_name + If provided, will look for the ``attribute_name`` variable inside the + loaded files. Paths ending with ``some_path:variable_name`` can + override the ``attribute_name``. The ``entry_point_group`` must + provided as well ``attribute_name`` is not ``None``. + + + Returns + ------- A module representing the resolved context, after loading the provided modules and resolving all variables. If ``attribute_name`` is given, the object with the given ``attribute_name`` name (or the name provided by user) is returned instead of the module. - Raises: - - ImportError: If attribute_name is given but the object does not exist - in the paths. - - ValueError: If attribute_name is given but entry_point_group is not - given. + Raises + ------ + ImportError + If attribute_name is given but the object does not exist in the paths. + ValueError + If attribute_name is given but entry_point_group is not given. """ # resolve entry points to paths @@ -314,18 +290,17 @@ def load( def mod_to_context(mod: types.ModuleType) -> dict[str, typing.Any]: - """Converts the loaded module of :py:func:`load` to a dictionary context. + """Convert the loaded module of :py:func:`load` to a dictionary context. This function removes all the variables that start and end with ``__``. + Parameters + ---------- + mod + a Python module, e.g., as returned by :py:func:`load`. - Arguments: - - mod: a Python module, e.g., as returned by :py:func:`load`. - - - Returns: - + Returns + ------- The context that was in ``mod``, as a dictionary mapping strings to objects. """ @@ -341,7 +316,7 @@ def resource_keys( exclude_packages: tuple[str, ...] = tuple(), strip: tuple[str, ...] = ("dummy",), ) -> list[str]: - """Reads and returns all resources that are registered on a entry-point + """Read and returns all resources that are registered on a entry-point group. Entry points from the given ``exclude_packages`` list are ignored. Notice @@ -351,25 +326,25 @@ def resource_keys( resource does not start with any of the strings provided in `exclude_package``. + Parameters + ---------- + entry_point_group + The entry point group name. + exclude_packages + List of packages to exclude when finding resources. + strip + Entrypoint names that start with any value in ``strip`` will be + ignored. - Arguments: - - entry_point_group: The entry point group name. - - exclude_packages: List of packages to exclude when finding resources. - - strip: Entrypoint names that start with any value in ``strip`` will be - ignored. - - - Returns: + Returns + ------- Alphabetically sorted list of resources matching your query """ ret_list = [ k.name - for k in _retrieve_entry_points(entry_point_group) + for k in entry_points(group=entry_point_group) if ( (not k.name.strip().startswith(exclude_packages)) and (not k.name.startswith(strip)) diff --git a/src/clapper/logging.py b/src/clapper/logging.py index eb9ad0289c51480ea8d1f1e7780f9958f492e6dd..75cceddd1826980818bdf68b00b56e9f0ba0c974 100644 --- a/src/clapper/logging.py +++ b/src/clapper/logging.py @@ -11,7 +11,8 @@ import typing # debug and info messages are written to sys.stdout class _InfoFilter(logging.Filter): """Filter-class to delete any log-record with level above - :any:`logging.INFO` **before** reaching the handler.""" + :any:`logging.INFO` **before** reaching the handler. + """ def __init__(self): super().__init__() @@ -22,11 +23,11 @@ class _InfoFilter(logging.Filter): def setup( logger_name: str, - format: str = "[%(levelname)s] %(message)s (%(name)s, %(asctime)s)", + format: str = "[%(levelname)s] %(message)s (%(name)s, %(asctime)s)", # noqa: A002 low_level_stream: typing.TextIO = sys.stdout, high_level_stream: typing.TextIO = sys.stderr, ) -> logging.Logger: - """This function returns a logger object that is ready for console logging. + """Return a logger object that is ready for console logging. Retrieves (as with :py:func:`logging.getLogger()`) the given logger, and then attaches 2 handlers (defined on the module) to it: @@ -44,23 +45,21 @@ def setup( still be controlled from one single place. If output is generated, then it is sent to the right stream. - - Arguments: - - logger_name: The name of the module to generate logs for - - format: The format of the logs, see :py:class:`logging.LogRecord` for - more details. By default, the log contains the logger name, the log - time, the log level and the massage. - - low_level_stream: The stream where to output info messages and below - - high_level_stream: The stream where to output warning messages and - above - - - Returns: - + Parameters + ---------- + logger_name + The name of the module to generate logs for + format + The format of the logs, see :py:class:`logging.LogRecord` for more + details. By default, the log contains the logger name, the log time, + the log level and the massage. + low_level_stream + The stream where to output info messages and below + high_level_stream + The stream where to output warning messages and above + + Returns + ------- The configured logger. The same logger can be retrieved using the :py:func:`logging.getLogger` function. """ diff --git a/src/clapper/rc.py b/src/clapper/rc.py index dfbd7c466103e77ae2deeb657b1097a4bc2f16bc..28eedb3998b691fe3912861bc19ee33ba6e0c6ae 100644 --- a/src/clapper/rc.py +++ b/src/clapper/rc.py @@ -3,8 +3,6 @@ # SPDX-License-Identifier: BSD-3-Clause """Implements a global configuration system setup and readout.""" -from __future__ import annotations - import collections.abc import io import json @@ -31,27 +29,25 @@ class UserDefaults(collections.abc.MutableMapping): section. The ``len()`` method will also return the number of variables set at the ``DEFAULT`` section as well. - - Arguments: - - path: The path, absolute or relative, to the file containing the user - defaults to read. If `path` is a relative path, then it is - considered relative to the directory defined by the environment - variable ``${XDG_CONFIG_HOME}`` (read `XDG defaults`_ for details on - the default location of this directory in the various operating - systems). The tilde (`~`) character may be used to represent the - user home, and is automatically expanded. - - logger: A logger to use for messaging operations. If not set, use this - module's logger. - - - Attributes: - - path: The current path to the user defaults base file. - - - .. _XDG defaults: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + Parameters + ---------- + path + The path, absolute or relative, to the file containing the user + defaults to read. If `path` is a relative path, then it is considered + relative to the directory defined by the environment variable + ``${XDG_CONFIG_HOME}`` (read `XDG defaults + <https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html>`_ + for details on the default location of this directory in the various + operating systems). The tilde (`~`) character may be used to represent + the user home, and is automatically expanded. + logger + A logger to use for messaging operations. If not set, use this + module's logger. + + Attributes + ---------- + path + The current path to the user defaults base file. """ def __init__( @@ -71,7 +67,7 @@ class UserDefaults(collections.abc.MutableMapping): self.read() def read(self) -> None: - """Reads configuration file, replaces any internal values.""" + """Read configuration file, replaces any internal values.""" if self.path.exists(): self.logger.debug( "User configuration file exists, reading contents..." @@ -104,7 +100,7 @@ class UserDefaults(collections.abc.MutableMapping): self.logger.debug("Initializing empty user configuration...") def write(self) -> None: - """Stores any modifications done on the user configuration.""" + """Store any modifications done on the user configuration.""" if self.path.exists(): backup = pathlib.Path(str(self.path) + "~") self.logger.debug(f"Backing-up {str(self.path)} -> {str(backup)}") @@ -124,7 +120,7 @@ class UserDefaults(collections.abc.MutableMapping): if k in self.data: return self.data[k] - elif "." in k: + if "." in k: # search for a key with a matching name after the "." parts = k.split(".") base = self.data @@ -184,7 +180,7 @@ class UserDefaults(collections.abc.MutableMapping): subkey = ".".join(parts[(n + 1) :]) if subkey in base: del base[subkey] - return + return None # otherwise, defaults to the default behaviour return self.data.__delitem__(k) diff --git a/tests/conftest.py b/tests/conftest.py index 91064ebbb65540aec9bf9c6ab6d9d2042109b4fa..f7ce69b050daa131a9915c471d482db46e95d8ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,16 +58,16 @@ class MyCliRunner(CliRunner): return super().invoke(cli, args=args, **params) - def isolation(self, input=None, env=None, color=False): + def isolation(self, input_=None, env=None, color=False): if self._in_pdb: - if input or env or color: + if input_ or env or color: warnings.warn( "CliRunner PDB un-isolation doesn't work if input/env/color are passed" ) else: return self.isolation_pdb() - return super().isolation(input=input, env=env, color=color) + return super().isolation(input=input_, env=env, color=color) @contextlib.contextmanager def isolation_pdb(self): diff --git a/tests/test_click.py b/tests/test_click.py index 3b6f4fc82c08e6e3e62ec7f9d308ae0cab28eb88..1951ca6f2f80f3b6f336e081d3f00cc675e36ca2 100644 --- a/tests/test_click.py +++ b/tests/test_click.py @@ -7,8 +7,6 @@ import logging import click -from click.testing import CliRunner - from clapper.click import ( AliasedGroup, ConfigCommand, @@ -16,6 +14,7 @@ from clapper.click import ( log_parameters, verbosity_option, ) +from click.testing import CliRunner def test_prefix_aliasing(): @@ -115,7 +114,7 @@ def test_commands_with_config_3(): def _assert_config_dump(output, ref, ref_date): - with output.open("rt") as f, open(ref) as f2: + with output.open("rt") as f, ref.open() as f2: diff = difflib.ndiff(f.readlines(), f2.readlines()) important_diffs = [k for k in diff if k.startswith(("+", "-"))] @@ -306,7 +305,7 @@ def test_log_parameter(): def __init__(self): self.accessed = False - def debug(self, str, k, v): + def debug(self, s, k, v): self.accessed = True @click.command() @@ -327,7 +326,7 @@ def test_log_parameter(): def test_log_parameter_with_ignore(): # Fake logger that ensures that the parameter 'a' is ignored class DummyLogger: - def debug(self, str, k, v): + def debug(self, s, k, v): assert "a" not in k @click.command() diff --git a/tests/test_config.py b/tests/test_config.py index 2351c251f99354d4bd058669af667a5a9f690e71..72beac7cb5d22cef1de5fb1d23ff8169c7b2f150 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,11 +7,10 @@ import io import pytest -from click.testing import CliRunner - from clapper.click import config_group from clapper.config import load, mod_to_context from clapper.logging import setup as logger_setup +from click.testing import CliRunner def test_basic(datadir): diff --git a/tests/test_logging.py b/tests/test_logging.py index 9327f74cbc1672cab88768500a03693c2c3947f1..910186bc0b51a5adc6942bcbb93609f29e1cf36c 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -5,13 +5,11 @@ import io import logging -import click - -from click.testing import CliRunner - import clapper.logging +import click from clapper.click import verbosity_option +from click.testing import CliRunner def test_logger_setup(): diff --git a/tests/test_rc.py b/tests/test_rc.py index ef58f9f143bf7bf60363e34e2bb254aaaac0b21c..39d5719d3c3f30886d4a8aeca8e0cecd32a3c220 100644 --- a/tests/test_rc.py +++ b/tests/test_rc.py @@ -5,14 +5,14 @@ import filecmp import logging import os +import pathlib import shutil import pytest -from click.testing import CliRunner - from clapper.click import user_defaults_group from clapper.rc import UserDefaults +from click.testing import CliRunner def _check_userdefaults_ex1_contents(rc): @@ -157,7 +157,7 @@ def test_rc_clear(): rc.clear() assert not rc - assert not os.path.exists("does-not-exist") + assert not pathlib.Path("does-not-exist").exists() def test_rc_reload(tmp_path): @@ -185,7 +185,7 @@ def test_rc_str(tmp_path): rc["section1.an_int"] = 15 rc.write() - assert open(tmp_path / "new-rc").read() == str(rc) + assert (tmp_path / "new-rc").open().read() == str(rc) def test_rc_json_legacy(datadir, tmp_path):