Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -63,7 +63,6 @@ requires-python = ">=3.10"
dev = [
"build",
"twine",
"types-docopt",
]
test = [
"coverage",
Expand Down
4 changes: 2 additions & 2 deletions src/example/__main__.py
Original file line number Diff line number Diff line change
@@ -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")
127 changes: 75 additions & 52 deletions src/example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,36 @@
This utility exits with one of the following values:
0 Calculation completed successfully.
>0 An error occurred.

Usage:
example [--log-level=LEVEL] <dividend> <divisor>
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] = list()
if sys.version_info.minor > 10:
LOG_LEVELS = [*logging.getLevelNamesMapping()]
Comment thread
dav3r marked this conversation as resolved.
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:
Expand All @@ -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.",
),
"<dividend>": Use(int, error="<dividend> must be an integer."),
"<divisor>": And(
Use(int),
lambda n: n != 0,
error="<divisor> 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["<dividend>"]
divisor: int = validated_args["<divisor>"]
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))
Expand All @@ -102,3 +120,8 @@ def main() -> None:

# Stop logging and clean up
logging.shutdown()


def main() -> None:
"""Run the CLI."""
app()
8 changes: 4 additions & 4 deletions tests/test_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Loading