From 582cd1b5eaa3d52b67a646ae7174b44711c50b14 Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Tue, 6 Jan 2026 16:42:38 -0500 Subject: [PATCH 1/6] Drop docopt in favor of typer docopt hasn't been updated in years and is still built using setup.py. typer is a more modern CLI library that gives shell completion for free. For more information see: - https://typer.tiangolo.com/ - https://github.com/fastapi/typer --- pyproject.toml | 4 +- src/example/__main__.py | 4 +- src/example/example.py | 118 ++++++++++++++++++++++------------------ 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index afb395ca..9375be04 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"] 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..f7e93579 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -7,35 +7,25 @@ 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 importlib.resources import files 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] = [*logging.getLevelNamesMapping()] + +app = typer.Typer() def example_div(dividend: int, divisor: int) -> float: @@ -48,44 +38,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 +109,8 @@ def main() -> None: # Stop logging and clean up logging.shutdown() + + +def main() -> None: + """Run the CLI.""" + app() From 8014b35ba6f0e1aa2b77d8df977269a9e67afe6c Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Tue, 6 Jan 2026 17:36:17 -0500 Subject: [PATCH 2/6] Update return values in tests to match what typer uses --- tests/test_example.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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" From ce0641eb6a8e0dd2e212478e0480071bfd90e0b5 Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Tue, 6 Jan 2026 17:37:03 -0500 Subject: [PATCH 3/6] Allow short option (-h) for help --- src/example/example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/example/example.py b/src/example/example.py index f7e93579..154ff88e 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -25,7 +25,7 @@ DEFAULT_ECHO_MESSAGE: str = "Hello World from the example default!" LOG_LEVELS: list[str] = [*logging.getLevelNamesMapping()] -app = typer.Typer() +app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}) def example_div(dividend: int, divisor: int) -> float: From 52c5c78ed051fd969515e30e83685ffa1b6617c9 Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Tue, 6 Jan 2026 22:43:32 -0500 Subject: [PATCH 4/6] Define LOG_LEVELS differently for Python 3.10 The logging.getLevelNamesMapping() method was only introduced in Python 3.11. --- src/example/example.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/example/example.py b/src/example/example.py index 154ff88e..f6a5446c 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -13,6 +13,7 @@ from importlib.resources import files import logging import os +import sys from typing import Annotated # Third-Party Libraries @@ -23,7 +24,15 @@ from ._version import __version__ DEFAULT_ECHO_MESSAGE: str = "Hello World from the example default!" -LOG_LEVELS: list[str] = [*logging.getLevelNamesMapping()] +LOG_LEVELS: list[str] = list() +if sys.version_info.minor > 10: + LOG_LEVELS = [*logging.getLevelNamesMapping()] +else: + 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"]}) From 3b705e509a5b95b63209aeb11fec84cdd8bd7017 Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Tue, 6 Jan 2026 22:59:28 -0500 Subject: [PATCH 5/6] Remove docopt type hint library This is no longer needed since we're now using typer for our CLI argument-parsing needs. --- .pre-commit-config.yaml | 3 +-- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) 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 9375be04..66132b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ requires-python = ">=3.10" dev = [ "build", "twine", - "types-docopt", ] test = [ "coverage", From ed03f5bc807a8a6cf0cc4e0e430d1f6270d65ab0 Mon Sep 17 00:00:00 2001 From: Jeremy Frasier Date: Wed, 7 Jan 2026 13:45:04 -0500 Subject: [PATCH 6/6] Explain why log levels must be extracted differently on Python 10 Co-authored-by: dav3r --- src/example/example.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/example/example.py b/src/example/example.py index f6a5446c..675bdecc 100644 --- a/src/example/example.py +++ b/src/example/example.py @@ -28,6 +28,8 @@ 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)