Skip to content

Commit c03e3fe

Browse files
committed
Merge remote-tracking branch 'webknjaz/maintenance/python-cli-structure' into rewrite_hooks_on_python
2 parents db3fefd + 52cf580 commit c03e3fe

File tree

11 files changed

+319
-59
lines changed

11 files changed

+319
-59
lines changed

.pre-commit-hooks.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
name: Terraform docs (overwrite README.md)
4747
description: Overwrite content of README.md with terraform-docs.
4848
require_serial: true
49-
entry: terraform_docs_replace
49+
entry: python -Im pre_commit_terraform replace-docs
5050
language: python
5151
files: (\.tf)$
5252
exclude: \.terraform/.*$

pyproject.toml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,3 @@ email = 'yz@yz.kiev.ua'
4040
[project.readme]
4141
file = 'README.md'
4242
content-type = 'text/markdown'
43-
44-
[project.scripts]
45-
terraform_docs_replace = 'pre_commit_terraform.terraform_docs_replace:main'
46-
terraform_fmt = 'pre_commit_terraform.terraform_fmt:main'
47-
terraform_checkov = 'pre_commit_terraform.terraform_checkov:main'

src/pre_commit_terraform/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Maintainer's manual
2+
3+
## Structure
4+
5+
This folder is what's called an [importable package]. It's a top-level folder
6+
that ends up being installed into `site-packages/` of virtualenvs.
7+
8+
When the Git repository is `pip install`ed, this [import package] becomes
9+
available for use within respective Python interpreter instance. It can be
10+
imported and sub-modules can be imported through the dot-syntax. Additionally,
11+
the modules within can import the neighboring ones using relative imports that
12+
have a leading dot in them.
13+
14+
It additionally implements a [runpy interface], meaning that its name can
15+
be passed to `python -m` to invoke the CLI. This is the primary method of
16+
integration with the [`pre-commit` framework] and local development/testing.
17+
18+
The layout allows for having several Python modules wrapping third-party tools,
19+
each having an argument parser and being a subcommand for the main CLI
20+
interface.
21+
22+
## Control flow
23+
24+
When `python -m pre_commit_terraform` is executed, it imports `__main__.py`.
25+
Which in turn, performs the initialization of the main argument parser and the
26+
parsers of subcommands, followed by executing the logic defined in dedicated
27+
subcommand modules.
28+
29+
## Integrating a new subcommand
30+
31+
1. Create a new module called `subcommand_x.py`.
32+
2. Within that module, define two functions —
33+
`invoke_cli_app(parsed_cli_args: Namespace) -> ReturnCodeType | int` and
34+
`populate_argument_parser(subcommand_parser: ArgumentParser) -> None`.
35+
Additionally, define a module-level constant
36+
`CLI_SUBCOMMAND_NAME: Final[str] = 'subcommand-x'`.
37+
3. Edit [`_cli_subcommands.py`], importing `subcommand_x` as a relative module
38+
and add it into the `SUBCOMMAND_MODULES` list.
39+
4. Edit [`.pre-commit-hooks.yaml`], adding a new hook that invokes
40+
`python -m pre_commit_terraform subcommand-x`.
41+
42+
## Manual testing
43+
44+
Usually, having a development virtualenv where you `pip install -e .` is enough
45+
to make it possible to invoke the CLI app. Do so first. Most source code
46+
updates do not require running it again. But sometimes, it's needed.
47+
48+
Once done, you can run `python -m pre_commit_terraform` and/or
49+
`python -m pre_commit_terraform subcommand-x` to see how it behaves. There's
50+
`--help` and all other typical conventions one would usually expect from a
51+
POSIX-inspired CLI app.
52+
53+
## DX/UX considerations
54+
55+
Since it's an app that can be executed outside the [`pre-commit` framework],
56+
it is useful to check out and follow these [CLI guidelines][clig].
57+
58+
## Subcommand development
59+
60+
`populate_argument_parser()` accepts a regular instance of
61+
[`argparse.ArgumentParser`]. Call its methods to extend the CLI arguments that
62+
would be specific for the subcommand you are creating. Those arguments will be
63+
available later, as an argument to the `invoke_cli_app()` function — through an
64+
instance of [`argparse.Namespace`]. For the `CLI_SUBCOMMAND_NAME` constant,
65+
choose `kebab-space-sub-command-style`, it does not need to be `snake_case`.
66+
67+
Make sure to return a `ReturnCode` instance or an integer from
68+
`invoke_cli_app()`. Returning a non-zero value will result in the CLI app
69+
exiting with a return code typically interpreted as an error while zero means
70+
success. You can `import errno` to use typical POSIX error codes through their
71+
human-readable identifiers.
72+
73+
Another way to interrupt the CLI app control flow is by raising an instance of
74+
one of the in-app errors. `raise PreCommitTerraformExit` for a successful exit,
75+
but it can be turned into an error outcome via
76+
`raise PreCommitTerraformExit(1)`.
77+
`raise PreCommitTerraformRuntimeError('The world is broken')` to indicate
78+
problems within the runtime. The framework will intercept any exceptions
79+
inheriting `PreCommitTerraformBaseError`, so they won't be presented to the
80+
end-users.
81+
82+
[`.pre-commit-hooks.yaml`]: ../../.pre-commit-hooks.yaml
83+
[`_cli_parsing.py`]: ./_cli_parsing.py
84+
[`_cli_subcommands.py`]: ./_cli_subcommands.py
85+
[`argparse.ArgumentParser`]:
86+
https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser
87+
[`argparse.Namespace`]:
88+
https://docs.python.org/3/library/argparse.html#argparse.Namespace
89+
[clig]: https://clig.dev
90+
[importable package]: https://docs.python.org/3/tutorial/modules.html#packages
91+
[import package]: https://packaging.python.org/en/latest/glossary/#term-Import-Package
92+
[`pre-commit` framework]: https://pre-commit.com
93+
[runpy interface]: https://docs.python.org/3/library/__main__.html
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
return_code = invoke_cli_app(argv[1:])
9+
exit_with_return_code(return_code)

src/pre_commit_terraform/_cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Outer CLI layer of the app interface."""
2+
3+
from sys import stderr
4+
5+
from ._cli_parsing import initialize_argument_parser
6+
from ._errors import (
7+
PreCommitTerraformBaseError,
8+
PreCommitTerraformExit,
9+
PreCommitTerraformRuntimeError,
10+
)
11+
from ._structs import ReturnCode
12+
from ._types import ReturnCodeType
13+
14+
15+
def invoke_cli_app(cli_args: list[str]) -> ReturnCodeType:
16+
"""Run the entry-point of the CLI app.
17+
18+
Includes initializing parsers of all the sub-apps and
19+
choosing what to execute.
20+
"""
21+
root_cli_parser = initialize_argument_parser()
22+
parsed_cli_args = root_cli_parser.parse_args(cli_args)
23+
24+
try:
25+
return parsed_cli_args.invoke_cli_app(parsed_cli_args)
26+
except PreCommitTerraformExit as exit_err:
27+
print(f'App exiting: {exit_err !s}', file=stderr)
28+
raise
29+
except PreCommitTerraformRuntimeError as unhandled_exc:
30+
print(
31+
f'App execution took an unexpected turn: {unhandled_exc !s}. '
32+
'Exiting...',
33+
file=stderr,
34+
)
35+
return ReturnCode.ERROR
36+
except PreCommitTerraformBaseError as unhandled_exc:
37+
print(
38+
f'A surprising exception happened: {unhandled_exc !s}. Exiting...',
39+
file=stderr,
40+
)
41+
return ReturnCode.ERROR
42+
except KeyboardInterrupt as ctrl_c_exc:
43+
print(
44+
f'User-initiated interrupt: {ctrl_c_exc !s}. Exiting...',
45+
file=stderr,
46+
)
47+
return ReturnCode.ERROR
48+
49+
50+
__all__ = ('invoke_cli_app',)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 ._cli_subcommands import SUBCOMMAND_MODULES
10+
11+
12+
def attach_subcommand_parsers_to(root_cli_parser: ArgumentParser, /) -> None:
13+
"""Connect all sub-command parsers to the given one.
14+
15+
This functions iterates over a mapping of subcommands to their
16+
respective population functions, executing them to augment the
17+
main parser.
18+
"""
19+
subcommand_parsers = root_cli_parser.add_subparsers(
20+
dest='check_name',
21+
help='A check to be performed.',
22+
required=True,
23+
)
24+
for subcommand_module in SUBCOMMAND_MODULES:
25+
replace_docs_parser = subcommand_parsers.add_parser(subcommand_module.CLI_SUBCOMMAND_NAME)
26+
replace_docs_parser.set_defaults(
27+
invoke_cli_app=subcommand_module.invoke_cli_app,
28+
)
29+
subcommand_module.populate_argument_parser(replace_docs_parser)
30+
31+
32+
def initialize_argument_parser() -> ArgumentParser:
33+
"""Return the root argument parser with sub-commands."""
34+
root_cli_parser = ArgumentParser(prog=f'python -m {__package__ !s}')
35+
attach_subcommand_parsers_to(root_cli_parser)
36+
return root_cli_parser
37+
38+
39+
__all__ = ('initialize_argument_parser',)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""A CLI sub-commands organization module."""
2+
3+
from . import terraform_docs_replace
4+
from ._types import CLISubcommandModuleProtocol
5+
6+
7+
SUBCOMMAND_MODULES: list[CLISubcommandModuleProtocol] = [
8+
terraform_docs_replace,
9+
]
10+
11+
12+
__all__ = ('SUBCOMMAND_MODULES',)
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Composite types for annotating in-project code."""
2+
3+
from argparse import ArgumentParser, Namespace
4+
from typing import Final, Protocol
5+
6+
from ._structs import ReturnCode
7+
8+
9+
ReturnCodeType = ReturnCode | int
10+
11+
12+
class CLISubcommandModuleProtocol(Protocol):
13+
"""A protocol for the subcommand-implementing module shape."""
14+
15+
CLI_SUBCOMMAND_NAME: Final[str]
16+
"""This constant contains a CLI."""
17+
18+
def populate_argument_parser(
19+
self, subcommand_parser: ArgumentParser,
20+
) -> None:
21+
"""Run a module hook for populating the subcommand parser."""
22+
23+
def invoke_cli_app(
24+
self, parsed_cli_args: Namespace,
25+
) -> ReturnCodeType | int:
26+
"""Run a module hook implementing the subcommand logic."""
27+
... # pylint: disable=unnecessary-ellipsis
28+
29+
30+
__all__ = ('CLISubcommandModuleProtocol', 'ReturnCodeType')

0 commit comments

Comments
 (0)