diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 589599fe..460b5236 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -171,8 +171,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.9.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index afb395ca..66132b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,8 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", ] dependencies = [ - "docopt", - "schema", + "rich>=14.2.0", + "typer>=0.21.1", ] description = "Example Python library" dynamic = ["readme", "version"] @@ -63,7 +63,6 @@ requires-python = ">=3.10" dev = [ "build", "twine", - "types-docopt", ] test = [ "coverage", diff --git a/src/example/__main__.py b/src/example/__main__.py index 11a3238f..9a8152e5 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 app -main() +app(prog_name="example") diff --git a/src/example/example.py b/src/example/example.py index 4ec8d8fb..675bdecc 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -7,16 +7,6 @@ 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 @@ -24,18 +14,29 @@ import logging import os import sys -from typing import Any +from typing import Annotated # 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 +from rich import print +from rich.logging import RichHandler +import typer from ._version import __version__ DEFAULT_ECHO_MESSAGE: str = "Hello World from the example default!" +LOG_LEVELS: list[str] = list() +if sys.version_info.minor > 10: + 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") + ] + +app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}) def example_div(dividend: int, divisor: int) -> float: @@ -48,44 +49,61 @@ 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(value: int) -> int: + """Verify that the divisor is not zero.""" + if value == 0: + raise typer.BadParameter("divisor must not be zero") + return value + + +def log_level_callback(value: str) -> str: + """Verify that the value is a valid logging level name.""" + if value.upper() not in LOG_LEVELS: + raise typer.BadParameter( + f"log_level must one of the following: {', '.join(LOG_LEVELS)}" + ) + return value + + +def version_callback(ctx: typer.Context, value: bool) -> None: + """If value is True then print the version and exit early.""" + # Doing this doesn't break shell completion when you print text to + # the screen from a callback. For more information see: + # https://typer.tiangolo.com/tutorial/options/callback-and-context/#fix-completion-using-the-context + if ctx.resilient_parsing: + return + + if value: + print(__version__) + raise typer.Exit() + + +@app.command() +def example( + dividend: Annotated[int, typer.Argument(help="The dividend")], + divisor: Annotated[ + int, typer.Argument(callback=divisor_callback, help="The nonzero divisor") + ], + log_level: Annotated[ + str, + typer.Option( + callback=log_level_callback, + help=f"The logging level. Valid values are: {', '.join(LOG_LEVELS)}.", + ), + ] = "info", + version: Annotated[ + bool | None, + typer.Option( + "--version", callback=version_callback, help="Show version", is_eager=True + ), + ] = None, +) -> None: + """Set up logging and call the division function.""" # 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)) @@ -102,3 +120,8 @@ def main() -> None: # Stop logging and clean up logging.shutdown() + + +def main() -> None: + """Run the CLI.""" + app() diff --git a/tests/test_example.py b/tests/test_example.py index 96f45605..c7d871b6 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -83,14 +83,14 @@ def test_log_levels(level): example.example.main() except SystemExit as sys_exit: return_code = sys_exit.code - assert return_code is None, "main() should return success" + assert return_code == 0, "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" + assert return_code == 0, "main() should return success" def test_bad_log_level(): @@ -101,7 +101,7 @@ def test_bad_log_level(): example.example.main() except SystemExit as sys_exit: return_code = sys_exit.code - assert return_code == 1, "main() should exit with error" + assert return_code == 2, "main() should exit with error" @pytest.mark.parametrize("dividend, divisor, quotient", div_params) @@ -140,4 +140,4 @@ def test_zero_divisor_argument(): example.example.main() except SystemExit as sys_exit: return_code = sys_exit.code - assert return_code == 1, "main() should exit with error" + assert return_code == 2, "main() should exit with error"