diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0bedb40..af15e6f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -44,11 +44,13 @@ updates: # ignore: # # Managed by cisagov/skeleton-python-library # - dependency-name: build + # - dependency-name: click # - dependency-name: coverage # - dependency-name: coveralls # - dependency-name: pre-commit # - dependency-name: pytest-cov # - dependency-name: pytest + # - dependency-name: rich # - dependency-name: setuptools # - dependency-name: twine package-ecosystem: pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 816fa90..4ffee24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -176,8 +176,7 @@ repos: # mypy pre-commit hook additional_dependencies in sync with # the dev section of pyproject.toml to avoid discrepancies in # type checking between environments. - additional_dependencies: - - types-docopt + additional_dependencies: [] - repo: https://github.com/pypa/pip-audit rev: v2.10.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index 705b249..7de996a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,8 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", ] dependencies = [ - "docopt", - "schema", + "click>=8.3.1", + "rich>=14.2.0", ] description = "Example Python library" dynamic = ["readme", "version"] @@ -63,7 +63,6 @@ requires-python = ">=3.10" dev = [ "build", "twine", - "types-docopt", ] test = [ "coverage", @@ -74,7 +73,7 @@ test = [ ] [project.scripts] -example = "example.example:main" +example = "example.example:setup_logging_and_divide" [project.urls] homepage = "https://github.com/cisagov/skeleton-python-library" diff --git a/src/example/__init__.py b/src/example/__init__.py index 556a7d2..571e1d8 100644 --- a/src/example/__init__.py +++ b/src/example/__init__.py @@ -5,6 +5,6 @@ # package_name.__version__, which is used to get version information about this # Python package. from ._version import __version__ # noqa: F401 -from .example import example_div +from .example import divide, setup_logging_and_divide -__all__ = ["example_div"] +__all__ = ["divide", "setup_logging_and_divide"] diff --git a/src/example/__main__.py b/src/example/__main__.py index 11a3238..c7c3338 100644 --- a/src/example/__main__.py +++ b/src/example/__main__.py @@ -1,5 +1,5 @@ """Code to run if this package is used as a Python module.""" -from .example import main +from .example import setup_logging_and_divide -main() +setup_logging_and_divide() diff --git a/src/example/_version.py b/src/example/_version.py index f8d70f6..92bd2ad 100644 --- a/src/example/_version.py +++ b/src/example/_version.py @@ -1,3 +1,3 @@ """This file defines the version of this module.""" -__version__ = "1.0.0" +__version__ = "1.1.0-rc.1" diff --git a/src/example/example.py b/src/example/example.py index 78dd72e..7e473ad 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -7,19 +7,10 @@ This utility exits with one of the following values: 0 Calculation completed successfully. >0 An error occurred. - -Usage: - example [--log-level=LEVEL] - example (-h | --help) - -Options: - -h --help Show this message. - --log-level=LEVEL If specified, then the log level will be set to - the specified value. Valid values are "debug", "info", - "warning", "error", and "critical". [default: info] """ # Standard Python Libraries +from collections.abc import MutableMapping from importlib.resources import files import logging import os @@ -27,19 +18,29 @@ from typing import Any # Third-Party Libraries -import docopt - -# There are no type stubs for the schema library, so mypy requires the type: -# ignore hint. -from schema import And, Schema, SchemaError, Use # type: ignore +import click +from rich.logging import RichHandler from ._version import __version__ DEFAULT_ECHO_MESSAGE: str = "Hello World from the example default!" - - -def example_div(dividend: int, divisor: int) -> float: - """Print some logging messages.""" +LOG_LEVELS: list[str] = [] +if sys.version_info >= (3, 11): + LOG_LEVELS = [*logging.getLevelNamesMapping()] +else: + # The logging.getLevelNamesMapping method was only introduced in + # Python 3.11. + LOG_LEVELS = [ + logging.getLevelName(x) + for x in range(0, 101) + if not logging.getLevelName(x).startswith("Level") + ] +# Context settings for click +CONTEXT_SETTINGS: MutableMapping[str, Any] = {"help_option_names": ["-h", "--help"]} + + +def divide(dividend: int, divisor: int) -> float: + """Perform division, log messages at various levels, and return quotient.""" logging.debug("This is a debug message") logging.info("This is an info message") logging.warning("This is a warning message") @@ -48,47 +49,43 @@ def example_div(dividend: int, divisor: int) -> float: return dividend / divisor -def main() -> None: - """Set up logging and call the example function.""" - args: dict[str, str] = docopt.docopt(__doc__, version=__version__) - # Validate and convert arguments as needed - schema: Schema = Schema( - { - "--log-level": And( - str, - Use(str.lower), - lambda n: n in ("debug", "info", "warning", "error", "critical"), - error="Possible values for --log-level are " - + "debug, info, warning, error, and critical.", - ), - "": Use(int, error=" must be an integer."), - "": And( - Use(int), - lambda n: n != 0, - error=" must be an integer that is not 0.", - ), - str: object, # Don't care about other keys, if any - } - ) - - try: - validated_args: dict[str, Any] = schema.validate(args) - except SchemaError as err: - # Exit because one or more of the arguments were invalid - print(err, file=sys.stderr) - sys.exit(1) - - # Assign validated arguments to variables - dividend: int = validated_args[""] - divisor: int = validated_args[""] - log_level: str = validated_args["--log-level"] - +def divisor_callback(ctx: click.Context, param: click.Parameter, value: int) -> int: + """Verify that the value is nonzero.""" + if value == 0: + raise click.BadParameter("divisor must be nonzero") + + return value + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.argument("dividend", type=click.INT) +@click.argument("divisor", callback=divisor_callback, type=click.INT) +@click.option( + "-l", + "--log-level", + default="info", + help="The logging level.", + type=click.Choice(LOG_LEVELS, case_sensitive=False), +) +@click.version_option(version=__version__, message="%(version)s") +def setup_logging_and_divide( + dividend: int, + divisor: int, + log_level: str = "info", +) -> None: + """Set up logging and call the division function. + + DIVIDEND is the integer dividend. + DIVISOR is the nonzero integer divisor. + """ # Set up logging logging.basicConfig( - format="%(asctime)-15s %(levelname)s %(message)s", level=log_level.upper() + format="%(asctime)-15s %(levelname)s %(message)s", + level=log_level.upper(), + handlers=[RichHandler(rich_tracebacks=True)], ) - logging.info("%d / %d == %f", dividend, divisor, example_div(dividend, divisor)) + logging.info("%d / %d == %f", dividend, divisor, divide(dividend, divisor)) # Access some data from an environment variable message: str = os.getenv("ECHO_MESSAGE", DEFAULT_ECHO_MESSAGE) @@ -102,3 +99,8 @@ def main() -> None: # Stop logging and clean up logging.shutdown() + + +def main() -> None: + """Run the CLI.""" + setup_logging_and_divide() diff --git a/tests/test_example.py b/tests/test_example.py index 96f4560..c017916 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -7,6 +7,7 @@ from unittest.mock import patch # Third-Party Libraries +from click.testing import CliRunner import pytest # cisagov Libraries @@ -32,14 +33,13 @@ PROJECT_VERSION = example.__version__ -def test_stdout_version(capsys): +def test_stdout_version(): """Verify that version string sent to stdout agrees with the module version.""" - with pytest.raises(SystemExit): - with patch.object(sys, "argv", ["bogus", "--version"]): - example.example.main() - captured = capsys.readouterr() + runner = CliRunner() + result = runner.invoke(example.setup_logging_and_divide, ["--version"]) + assert result.exit_code == 0, "should exit cleanly" assert ( - captured.out == f"{PROJECT_VERSION}\n" + result.output == f"{PROJECT_VERSION}\n" ), "standard output by '--version' should agree with module.__version__" @@ -73,41 +73,36 @@ def test_release_version(): @pytest.mark.parametrize("level", log_levels) def test_log_levels(level): """Validate commandline log-level arguments.""" - with patch.object(sys, "argv", ["bogus", f"--log-level={level}", "1", "1"]): - with patch.object(logging.root, "handlers", []): - assert ( - logging.root.hasHandlers() is False - ), "root logger should not have handlers yet" - return_code = None - try: - example.example.main() - except SystemExit as sys_exit: - return_code = sys_exit.code - assert return_code is None, "main() should return success" - assert ( - logging.root.hasHandlers() is True - ), "root logger should now have a handler" - assert ( - logging.getLevelName(logging.root.getEffectiveLevel()) == level.upper() - ), f"root logger level should be set to {level.upper()}" - assert return_code is None, "main() should return success" + runner = CliRunner() + with patch.object(logging.root, "handlers", []): + assert ( + logging.root.hasHandlers() is False + ), "root logger should not have handlers yet" + result = runner.invoke( + example.setup_logging_and_divide, [f"--log-level={level}", "1", "1"] + ) + assert result.exit_code == 0, "should exit cleanly" + assert ( + logging.root.hasHandlers() is True + ), "root logger should now have a handler" + assert ( + logging.getLevelName(logging.root.getEffectiveLevel()) == level.upper() + ), f"root logger level should be set to {level.upper()}" def test_bad_log_level(): """Validate bad log-level argument returns error.""" - with patch.object(sys, "argv", ["bogus", "--log-level=emergency", "1", "1"]): - return_code = None - try: - example.example.main() - except SystemExit as sys_exit: - return_code = sys_exit.code - assert return_code == 1, "main() should exit with error" + runner = CliRunner() + result = runner.invoke( + example.setup_logging_and_divide, ["--log-level=emergency", "1", "1"] + ) + assert result.exit_code == 2, "should exit with return code 2" @pytest.mark.parametrize("dividend, divisor, quotient", div_params) def test_division(dividend, divisor, quotient): """Verify division results.""" - result = example.example_div(dividend, divisor) + result = example.divide(dividend, divisor) assert result == quotient, "result should equal quotient" @@ -121,7 +116,7 @@ def test_slow_division(): # Standard Python Libraries import time - result = example.example_div(256, 16) + result = example.divide(256, 16) time.sleep(4) assert result == 16, "result should equal be 16" @@ -129,15 +124,11 @@ def test_slow_division(): def test_zero_division(): """Verify that division by zero throws the correct exception.""" with pytest.raises(ZeroDivisionError): - example.example_div(1, 0) + example.divide(1, 0) def test_zero_divisor_argument(): """Verify that a divisor of zero is handled as expected.""" - with patch.object(sys, "argv", ["bogus", "1", "0"]): - return_code = None - try: - example.example.main() - except SystemExit as sys_exit: - return_code = sys_exit.code - assert return_code == 1, "main() should exit with error" + runner = CliRunner() + result = runner.invoke(example.setup_logging_and_divide, ["1", "0"]) + assert result.exit_code == 2, "should exit with return code 2"