Skip to content

Commit decd0f7

Browse files
authored
Merge pull request #34 from cisagov/improvement/schema
Add schema validation for docopt arguments.
2 parents 3356ecf + 454553e commit decd0f7

File tree

5 files changed

+71
-25
lines changed

5 files changed

+71
-25
lines changed

.github/workflows/build.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- uses: actions/checkout@v1
1818
- uses: actions/setup-python@v1
1919
with:
20-
python-version: 3.7
20+
python-version: 3.8
2121
- name: Cache pip test requirements
2222
uses: actions/cache@v1
2323
with:
@@ -45,7 +45,7 @@ jobs:
4545
- uses: actions/checkout@v1
4646
- uses: actions/setup-python@v1
4747
with:
48-
python-version: 3.7
48+
python-version: 3.8
4949
- name: Cache pip test requirements
5050
uses: actions/cache@v1
5151
with:
@@ -75,7 +75,7 @@ jobs:
7575
- uses: actions/checkout@v1
7676
- uses: actions/setup-python@v1
7777
with:
78-
python-version: 3.7
78+
python-version: 3.8
7979
- name: Cache pip build requirements
8080
uses: actions/cache@v1
8181
with:

.isort.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ import_heading_thirdparty=Third-Party Libraries
77
import_heading_firstparty=cisagov Libraries
88

99
# Should be auto-populated by seed-isort-config hook
10-
known_third_party=docopt,pkg_resources,pytest,setuptools
10+
known_third_party=docopt,pkg_resources,pytest,schema,setuptools
1111
# These must be manually set to correctly separate them from third party libraries
1212
known_first_party=example

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def package_vars(version_file):
6161
"Programming Language :: Python :: 3",
6262
"Programming Language :: Python :: 3.6",
6363
"Programming Language :: Python :: 3.7",
64+
"Programming Language :: Python :: 3.8",
6465
],
6566
python_requires=">=3.6",
6667
# What does your project relate to?
@@ -70,7 +71,7 @@ def package_vars(version_file):
7071
package_data={"example": ["data/*.txt"]},
7172
py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")],
7273
include_package_data=True,
73-
install_requires=["docopt", "setuptools >= 24.2.0"],
74+
install_requires=["docopt", "setuptools >= 24.2.0", "schema"],
7475
extras_require={
7576
"test": [
7677
"pre-commit",

src/example/example.py

+50-17
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
22

33
"""example is an example Python library and tool.
44
5+
Divide one integer by another and log the result. Also log some information
6+
from an environment variable and a package resource.
7+
8+
EXIT STATUS
9+
This utility exits with one of the following values:
10+
0 Calculation completed successfully.
11+
>0 An error occurred.
12+
513
Usage:
6-
example [--log-level=LEVEL]
14+
example [--log-level=LEVEL] <dividend> <divisor>
715
example (-h | --help)
816
917
Options:
1018
-h --help Show this message.
1119
--log-level=LEVEL If specified, then the log level will be set to
1220
the specified value. Valid values are "debug", "info",
13-
"warning", "error", and "critical". [default: warning]
21+
"warning", "error", and "critical". [default: info]
1422
"""
1523

1624
# Standard Python Libraries
@@ -21,51 +29,76 @@
2129
# Third-Party Libraries
2230
import docopt
2331
import pkg_resources
32+
from schema import And, Schema, SchemaError, Use
2433

2534
from ._version import __version__
2635

2736
DEFAULT_ECHO_MESSAGE = "Hello World from the example default!"
2837

2938

30-
def example_div(x, y):
39+
def example_div(dividend, divisor):
3140
"""Print some logging messages."""
3241
logging.debug("This is a debug message")
3342
logging.info("This is an info message")
3443
logging.warning("This is a warning message")
3544
logging.error("This is an error message")
3645
logging.critical("This is a critical message")
37-
return x / y
46+
return dividend / divisor
3847

3948

4049
def main():
4150
"""Set up logging and call the example function."""
4251
args = docopt.docopt(__doc__, version=__version__)
43-
# Set up logging
44-
log_level = args["--log-level"]
52+
# Validate and convert arguments as needed
53+
schema = Schema(
54+
{
55+
"--log-level": And(
56+
str,
57+
Use(str.lower),
58+
lambda n: n in ("debug", "info", "warning", "error", "critical"),
59+
error="Possible values for --log-level are "
60+
+ "debug, info, warning, error, and critical.",
61+
),
62+
"<dividend>": Use(int, error="<dividend> must be an integer."),
63+
"<divisor>": And(
64+
Use(int),
65+
lambda n: n != 0,
66+
error="<divisor> must be an integer that is not 0.",
67+
),
68+
str: object, # Don't care about other keys, if any
69+
}
70+
)
71+
4572
try:
46-
logging.basicConfig(
47-
format="%(asctime)-15s %(levelname)s %(message)s", level=log_level.upper()
48-
)
49-
except ValueError:
50-
logging.critical(
51-
f'"{log_level}" is not a valid logging level. Possible values '
52-
"are debug, info, warning, and error."
53-
)
73+
args = schema.validate(args)
74+
except SchemaError as err:
75+
# Exit because one or more of the arguments were invalid
76+
print(err, file=sys.stderr)
5477
return 1
5578

56-
print(f"8 / 2 == {example_div(8, 2)}")
79+
# Assign validated arguments to variables
80+
dividend = args["<dividend>"]
81+
divisor = args["<divisor>"]
82+
log_level = args["--log-level"]
83+
84+
# Set up logging
85+
logging.basicConfig(
86+
format="%(asctime)-15s %(levelname)s %(message)s", level=log_level.upper()
87+
)
88+
89+
logging.info(f"{dividend} / {divisor} == {example_div(dividend, divisor)}")
5790

5891
# Access some data from an environment variable
5992
message = os.getenv("ECHO_MESSAGE", DEFAULT_ECHO_MESSAGE)
60-
print(f'ECHO_MESSAGE="{message}"')
93+
logging.info(f'ECHO_MESSAGE="{message}"')
6194

6295
# Access some data from our package data (see the setup.py)
6396
secret_message = (
6497
pkg_resources.resource_string("example", "data/secret.txt")
6598
.decode("utf-8")
6699
.strip()
67100
)
68-
print(f'Secret="{secret_message}"')
101+
logging.info(f'Secret="{secret_message}"')
69102

70103
# Stop logging and clean up
71104
logging.shutdown()

tests/test_example.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
(2, 2, 1),
1919
(0, 1, 0),
2020
(8, 2, 4),
21-
pytest.param(0, 0, 0, marks=pytest.mark.xfail(raises=ZeroDivisionError)),
2221
]
2322

2423
log_levels = (
@@ -27,7 +26,6 @@
2726
"warning",
2827
"error",
2928
"critical",
30-
pytest.param("critical2", marks=pytest.mark.xfail),
3129
)
3230

3331
# define sources of version strings
@@ -59,7 +57,7 @@ def test_release_version():
5957
@pytest.mark.parametrize("level", log_levels)
6058
def test_log_levels(level):
6159
"""Validate commandline log-level arguments."""
62-
with patch.object(sys, "argv", ["bogus", f"--log-level={level}"]):
60+
with patch.object(sys, "argv", ["bogus", f"--log-level={level}", "1", "1"]):
6361
with patch.object(logging.root, "handlers", []):
6462
assert (
6563
logging.root.hasHandlers() is False
@@ -71,6 +69,13 @@ def test_log_levels(level):
7169
assert return_code == 0, "main() should return success (0)"
7270

7371

72+
def test_bad_log_level():
73+
"""Validate bad log-level argument returns error."""
74+
with patch.object(sys, "argv", ["bogus", "--log-level=emergency", "1", "1"]):
75+
return_code = example.example.main()
76+
assert return_code == 1, "main() should return failure"
77+
78+
7479
@pytest.mark.parametrize("dividend, divisor, quotient", div_params)
7580
def test_division(dividend, divisor, quotient):
7681
"""Verify division results."""
@@ -96,3 +101,10 @@ def test_zero_division():
96101
"""Verify that division by zero throws the correct exception."""
97102
with pytest.raises(ZeroDivisionError):
98103
example.example_div(1, 0)
104+
105+
106+
def test_zero_divisor_argument():
107+
"""Verify that a divisor of zero is handled as expected."""
108+
with patch.object(sys, "argv", ["bogus", "1", "0"]):
109+
return_code = example.example.main()
110+
assert return_code == 1, "main() should exit with error"

0 commit comments

Comments
 (0)