Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 3 additions & 4 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",
"click>=8.3.1",
"rich>=14.2.0",
]
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 All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/example/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
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 setup_logging_and_divide

main()
setup_logging_and_divide()
2 changes: 1 addition & 1 deletion src/example/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""This file defines the version of this module."""

__version__ = "1.0.0"
__version__ = "1.1.0-rc.1"
114 changes: 58 additions & 56 deletions src/example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,40 @@
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]
Comment thread
rm-sbin-sh marked this conversation as resolved.
"""

# Standard Python Libraries
from collections.abc import MutableMapping
from importlib.resources import files
import logging
import os
import sys
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")
Expand All @@ -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.",
),
"<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(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)
Expand All @@ -102,3 +99,8 @@ def main() -> None:

# Stop logging and clean up
logging.shutdown()


def main() -> None:
"""Run the CLI."""
setup_logging_and_divide()
73 changes: 32 additions & 41 deletions tests/test_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import patch

# Third-Party Libraries
from click.testing import CliRunner
import pytest

# cisagov Libraries
Expand All @@ -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__"
Comment thread
jsf9k marked this conversation as resolved.


Expand Down Expand Up @@ -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"


Expand All @@ -121,23 +116,19 @@ 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"


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"
Loading