Skip to content

Commit 9b68e18

Browse files
committed
💅 Introduce a Python CLI app layout
This includes a structure with purpose-based modules and a standard mechanism for adding more subcommands. When adding a new subcommand, one has to wire the `invoke_cli_app()` and `populate_argument_parser()` from their new module into the mappings defined in `_cli_subcommands.py` and `_cli_parsing.py` respectively. This is the only integration point necessary. `populate_argument_parser()` accepts a subparser instance of `argparse.ArgumentParser()` that a new subcommand would need to attach new arguments into. It does not need to return anything. And the `invoke_cli_app()` hook is called with an instance of `argparse.Namespace()` with all the arguments parsed and pre-processed. This function is supposed to have the main check logic and return an instance of `._structs.ReturnCode()` or `int`.
1 parent deeafea commit 9b68e18

File tree

10 files changed

+210
-27
lines changed

10 files changed

+210
-27
lines changed

.pre-commit-hooks.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
name: Terraform docs (overwrite README.md)
3838
description: Overwrite content of README.md with terraform-docs.
3939
require_serial: true
40-
entry: terraform_docs_replace
40+
entry: python -Im pre_commit_terraform replace-docs
4141
language: python
4242
files: (\.tf)$
4343
exclude: \.terraform/.*$

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,3 @@ name = 'Contributors' # FIXME
3131
[project.readme]
3232
file = 'README.md'
3333
content-type = 'text/markdown'
34-
35-
[project.scripts]
36-
terraform_docs_replace = 'pre_commit_terraform.terraform_docs_replace:main'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""A runpy-style CLI entry-point module."""
2+
3+
from sys import argv, exit as exit_with_return_code
4+
5+
from ._cli import invoke_cli_app
6+
7+
8+
if __name__ == '__main__':
9+
return_code = invoke_cli_app(argv[1:])
10+
exit_with_return_code(return_code)

src/pre_commit_terraform/_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Outer CLI layer of the app interface."""
2+
3+
from sys import stderr
4+
5+
from ._cli_subcommands import choose_cli_app
6+
from ._cli_parsing import initialize_argument_parser
7+
from ._errors import (
8+
PreCommitTerraformBaseError,
9+
PreCommitTerraformExit,
10+
PreCommitTerraformRuntimeError,
11+
)
12+
from ._structs import ReturnCode
13+
from ._types import ReturnCodeType
14+
15+
16+
def invoke_cli_app(cli_args: list[str]) -> ReturnCodeType:
17+
"""Run the entry-point of the CLI app.
18+
19+
Includes initializing parsers of all the sub-apps and
20+
choosing what to execute.
21+
"""
22+
root_cli_parser = initialize_argument_parser()
23+
parsed_cli_args = root_cli_parser.parse_args(cli_args)
24+
try:
25+
invoke_chosen_app = choose_cli_app(parsed_cli_args.check_name)
26+
except LookupError as lookup_err:
27+
print(f'Sourcing subcommand failed: {lookup_err !s}', file=stderr)
28+
return ReturnCode.ERROR
29+
30+
try:
31+
return invoke_chosen_app(parsed_cli_args)
32+
except PreCommitTerraformExit as exit_err:
33+
print(f'App exiting: {exit_err !s}', file=stderr)
34+
raise
35+
except PreCommitTerraformRuntimeError as unhandled_exc:
36+
print(
37+
f'App execution took an unexpected turn: {unhandled_exc !s}. '
38+
'Exiting...',
39+
file=stderr,
40+
)
41+
return ReturnCode.ERROR
42+
except PreCommitTerraformBaseError as unhandled_exc:
43+
print(
44+
f'A surprising exception happened: {unhandled_exc !s}. Exiting...',
45+
file=stderr,
46+
)
47+
return ReturnCode.ERROR
48+
except KeyboardInterrupt as ctrl_c_exc:
49+
print(
50+
f'User-initiated interrupt: {ctrl_c_exc !s}. Exiting...',
51+
file=stderr,
52+
)
53+
return ReturnCode.ERROR
54+
55+
56+
__all__ = ('invoke_cli_app',)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Argument parser initialization logic.
2+
3+
This defines helpers for setting up both the root parser and the parsers
4+
of all the sub-commands.
5+
"""
6+
7+
from argparse import ArgumentParser
8+
9+
from .terraform_docs_replace import (
10+
populate_argument_parser as populate_replace_docs_argument_parser,
11+
)
12+
13+
14+
PARSER_MAP = {
15+
'replace-docs': populate_replace_docs_argument_parser,
16+
}
17+
18+
19+
def attach_subcommand_parsers_to(root_cli_parser: ArgumentParser, /) -> None:
20+
"""Connect all sub-command parsers to the given one.
21+
22+
This functions iterates over a mapping of subcommands to their
23+
respective population functions, executing them to augment the
24+
main parser.
25+
"""
26+
subcommand_parsers = root_cli_parser.add_subparsers(
27+
dest='check_name',
28+
help='A check to be performed.',
29+
required=True,
30+
)
31+
for subcommand_name, initialize_subcommand_parser in PARSER_MAP.items():
32+
replace_docs_parser = subcommand_parsers.add_parser(subcommand_name)
33+
initialize_subcommand_parser(replace_docs_parser)
34+
35+
36+
def initialize_argument_parser() -> ArgumentParser:
37+
"""Return the root argument parser with sub-commands."""
38+
root_cli_parser = ArgumentParser(prog=f'python -m {__package__ !s}')
39+
attach_subcommand_parsers_to(root_cli_parser)
40+
return root_cli_parser
41+
42+
43+
__all__ = ('initialize_argument_parser',)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""A CLI sub-commands organization module."""
2+
3+
from argparse import Namespace
4+
from typing import Callable
5+
6+
from ._structs import ReturnCode
7+
from .terraform_docs_replace import (
8+
invoke_cli_app as invoke_replace_docs_cli_app,
9+
)
10+
11+
12+
SUBCOMMAND_MAP = {
13+
'replace-docs': invoke_replace_docs_cli_app,
14+
}
15+
16+
17+
def choose_cli_app(
18+
check_name: str,
19+
/,
20+
) -> Callable[[Namespace], ReturnCode | int]:
21+
"""Return a subcommand callable by CLI argument name."""
22+
try:
23+
return SUBCOMMAND_MAP[check_name]
24+
except KeyError as key_err:
25+
raise LookupError(
26+
f'{key_err !s}: Unable to find a callable for '
27+
f'the `{check_name !s}` subcommand',
28+
) from key_err
29+
30+
31+
__all__ = ('choose_cli_app',)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""App-specific exceptions."""
2+
3+
4+
class PreCommitTerraformBaseError(Exception):
5+
"""Base exception for all the in-app errors."""
6+
7+
8+
class PreCommitTerraformRuntimeError(
9+
PreCommitTerraformBaseError,
10+
RuntimeError,
11+
):
12+
"""An exception representing a runtime error condition."""
13+
14+
15+
class PreCommitTerraformExit(PreCommitTerraformBaseError, SystemExit):
16+
"""An exception for terminating execution from deep app layers."""
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Data structures to be reused across the app."""
2+
3+
from enum import IntEnum
4+
5+
6+
class ReturnCode(IntEnum):
7+
"""POSIX-style return code values.
8+
9+
To be used in check callable implementations.
10+
"""
11+
12+
OK = 0
13+
ERROR = 1
14+
15+
16+
__all__ = ('ReturnCode',)

src/pre_commit_terraform/_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Composite types for annotating in-project code."""
2+
3+
from ._structs import ReturnCode
4+
5+
6+
ReturnCodeType = ReturnCode | int

src/pre_commit_terraform/terraform_docs_replace.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,42 @@
1-
import argparse
21
import os
32
import subprocess
4-
import sys
53
import warnings
4+
from argparse import ArgumentParser, Namespace
65

6+
from ._structs import ReturnCode
7+
from ._types import ReturnCodeType
78

8-
def main(argv=None):
9-
parser = argparse.ArgumentParser(
10-
description="""Run terraform-docs on a set of files. Follows the standard convention of
11-
pulling the documentation from main.tf in order to replace the entire
12-
README.md file each time."""
9+
10+
def populate_argument_parser(subcommand_parser: ArgumentParser) -> None:
11+
subcommand_parser.description = (
12+
'Run terraform-docs on a set of files. Follows the standard '
13+
'convention of pulling the documentation from main.tf in order to '
14+
'replace the entire README.md file each time.'
1315
)
14-
parser.add_argument(
16+
subcommand_parser.add_argument(
1517
'--dest', dest='dest', default='README.md',
1618
)
17-
parser.add_argument(
19+
subcommand_parser.add_argument(
1820
'--sort-inputs-by-required', dest='sort', action='store_true',
1921
help='[deprecated] use --sort-by-required instead',
2022
)
21-
parser.add_argument(
23+
subcommand_parser.add_argument(
2224
'--sort-by-required', dest='sort', action='store_true',
2325
)
24-
parser.add_argument(
25-
'--with-aggregate-type-defaults', dest='aggregate', action='store_true',
26+
subcommand_parser.add_argument(
27+
'--with-aggregate-type-defaults',
28+
dest='aggregate',
29+
action='store_true',
2630
help='[deprecated]',
2731
)
28-
parser.add_argument('filenames', nargs='*', help='Filenames to check.')
29-
args = parser.parse_args(argv)
32+
subcommand_parser.add_argument(
33+
'filenames',
34+
nargs='*',
35+
help='Filenames to check.',
36+
)
37+
3038

39+
def invoke_cli_app(parsed_cli_args: Namespace) -> ReturnCodeType:
3140
warnings.warn(
3241
'`terraform_docs_replace` hook is DEPRECATED.'
3342
'For migration instructions see '
@@ -37,29 +46,28 @@ def main(argv=None):
3746
)
3847

3948
dirs = []
40-
for filename in args.filenames:
49+
for filename in parsed_cli_args.filenames:
4150
if (os.path.realpath(filename) not in dirs and
4251
(filename.endswith(".tf") or filename.endswith(".tfvars"))):
4352
dirs.append(os.path.dirname(filename))
4453

45-
retval = 0
54+
retval = ReturnCode.OK
4655

4756
for dir in dirs:
4857
try:
4958
procArgs = []
5059
procArgs.append('terraform-docs')
51-
if args.sort:
60+
if parsed_cli_args.sort:
5261
procArgs.append('--sort-by-required')
5362
procArgs.append('md')
5463
procArgs.append("./{dir}".format(dir=dir))
5564
procArgs.append('>')
56-
procArgs.append("./{dir}/{dest}".format(dir=dir, dest=args.dest))
65+
procArgs.append(
66+
'./{dir}/{dest}'.
67+
format(dir=dir, dest=parsed_cli_args.dest),
68+
)
5769
subprocess.check_call(" ".join(procArgs), shell=True)
5870
except subprocess.CalledProcessError as e:
5971
print(e)
60-
retval = 1
72+
retval = ReturnCode.ERROR
6173
return retval
62-
63-
64-
if __name__ == '__main__':
65-
sys.exit(main())

0 commit comments

Comments
 (0)