All functions are now typed and documented

This commit is contained in:
Cian Hughes
2023-11-01 15:11:02 +00:00
parent cba4743035
commit 35fc76054b
11 changed files with 267 additions and 20 deletions

3
.gitignore vendored
View File

@@ -138,6 +138,9 @@ venv.bak/
# Rope project settings
.ropeproject
# VSCode project settings
.vscode/
# mkdocs documentation
/site

47
poetry.lock generated
View File

@@ -599,6 +599,51 @@ beautifulsoup4 = ">=4.7"
lxml = "*"
requests = ">=2.22.0"
[[package]]
name = "mypy"
version = "1.6.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"},
{file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"},
{file = "mypy-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e"},
{file = "mypy-1.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f"},
{file = "mypy-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c"},
{file = "mypy-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5"},
{file = "mypy-1.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245"},
{file = "mypy-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183"},
{file = "mypy-1.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0"},
{file = "mypy-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7"},
{file = "mypy-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f"},
{file = "mypy-1.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660"},
{file = "mypy-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7"},
{file = "mypy-1.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71"},
{file = "mypy-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a"},
{file = "mypy-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169"},
{file = "mypy-1.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143"},
{file = "mypy-1.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46"},
{file = "mypy-1.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85"},
{file = "mypy-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45"},
{file = "mypy-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208"},
{file = "mypy-1.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd"},
{file = "mypy-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332"},
{file = "mypy-1.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f"},
{file = "mypy-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30"},
{file = "mypy-1.6.1-py3-none-any.whl", hash = "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1"},
{file = "mypy-1.6.1.tar.gz", hash = "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
typing-extensions = ">=4.1.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
@@ -1047,4 +1092,4 @@ h11 = ">=0.9.0,<1"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "f4992ac644316ea1b818a9d394644dfa4f16dee7cb6b05fe0853b61b4629c334"
content-hash = "c74d1dcf762e5266d41c81b62bef444f065124fede9b3a34176cd9a6b93d9c2b"

View File

@@ -24,6 +24,7 @@ ruff = "^0.1.1"
black = "^23.10.1"
snoop = "^0.4.3"
pytest = "^7.4.3"
mypy = "^1.6.1"
[tool.poetry.scripts]
node_deployer = "node_deployer.__main__:main"

View File

@@ -1,19 +1,23 @@
#!/usr/bin/env python
def main() -> None:
"""Entry point for the CLI
"""
from . import config
config.update_config("cli")
from .node_deployer import app
app()
def debug():
def debug() -> None:
"""Entry point for the debug CLI
"""
from . import config
config.update_config("debug")
from .node_deployer import app
# Below, we set the default value of the debug flag
# for the base function of each command to True
def unwrap(f):
def unwrap(f): # Not a closure, just here to avoid polluting the namespace
if hasattr(f, "__wrapped__"):
return unwrap(f.__wrapped__)
else:

View File

@@ -5,6 +5,7 @@ import tarfile
import time
from typing import Annotated
import docker
import git
from selenium import webdriver
from selenium.webdriver.common.by import By
@@ -18,7 +19,12 @@ from .debug import debug_guard
from .utils import ensure_build_dir
def create_driver():
def create_driver() -> webdriver.Remote:
"""Creates a selenium webdriver instance
Returns:
webdriver.Remote: The created webdriver instance
"""
driver = webdriver.Remote(
"http://127.0.0.1:4444",
options=webdriver.FirefoxOptions(),
@@ -27,7 +33,20 @@ def create_driver():
return driver
def convert_json_via_fuelignition(container, driver, fuelignition_json, img_path):
def convert_json_via_fuelignition(
container: docker.models.containers.Container,
driver: webdriver.Remote,
fuelignition_json: Path,
img_path: Path,
) -> None:
"""Converts a fuel-ignition json file to an ignition disk image file
Args:
container (docker.models.containers.Container): The selenium container
driver (webdriver.Remote): The selenium webdriver instance
fuelignition_json (Path): The path to the fuel-ignition json file
img_path (Path): The path to the output ignition disk image file
"""
driver.get(config.FUELIGNITION_URL)
# Navigate to "Load Settings from" and upload the json
load_from = driver.find_element(By.NAME, "load_from")
@@ -66,7 +85,12 @@ def convert_json_via_fuelignition(container, driver, fuelignition_json, img_path
f.write(tar.extractfile(tar.getmembers()[0].name).read())
def build_fuelignition():
def build_fuelignition() -> docker.models.images.Image:
"""Builds the fuel-ignition docker image
Returns:
docker.models.images.Image: The built docker image
"""
# Make sure the local fuel-ignition repo is up to date
if (not config.FUELIGNITION_BUILD_DIR.exists()) or (
len(tuple(config.FUELIGNITION_BUILD_DIR.iterdir())) == 0
@@ -149,7 +173,22 @@ def json_to_img(
)
] = False,
) -> None:
"""Takes a fuel-ignition json file and produces an ignition disk image file"""
"""Converts a fuel-ignition json file to an ignition disk image file
Args:
json_path (Annotated[ Path, typer.Option, optional):
The path to the fuel-ignition json file.
Defaults to Path("fuelignition.json").
img_path (Annotated[ Path, typer.Option, optional):
The path to the output ignition disk image file.
Defaults to Path("ignition.img").
debug (Annotated[ bool, typer.Option, optional):
Enable debug mode.
Defaults to False.
Raises:
e: Any exception raised during execution
"""
selenium_container = None
fuelignition_container = None
fuelignition_image = None

View File

@@ -9,10 +9,20 @@ from .utils import Singleton
class SingletonProgress(Progress, metaclass=Singleton):
"""A singleton progress bar"""
pass
def cli_spinner(*spinner_args, **spinner_kwargs) -> Callable:
"""A decorator that adds a spinner to the CLI while the decorated function is running
Args:
*spinner_args: The arguments to pass to the rich spinner object
**spinner_kwargs: The keyword arguments to pass to the rich spinner object
Returns:
Callable: The decorated function
"""
def decorator(f: Callable) -> Callable:
# Indent the spinner to match its nesting level
indent = len(inspect.stack()) - 1

View File

@@ -17,7 +17,17 @@ PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.absolute()
type ConfigLabel = str | list[str]
def get_config(config_label: ConfigLabel = ["default"]) -> dict:
def get_config(config_label: ConfigLabel = "default") -> dict:
"""Gets the specified configuration from config.toml
Args:
config_label (ConfigLabel, optional):
The label of the configuration to get.
Defaults to "default".
Returns:
dict: The specified configuration
"""
if isinstance(config_label, str):
config_label = [config_label]
with open(PROJECT_ROOT / "config.toml", "rb") as f:
@@ -29,6 +39,12 @@ def get_config(config_label: ConfigLabel = ["default"]) -> dict:
def finalise_config(config: dict) -> None:
"""Finalises the configuration by converting paths to Path objects and
appropriately setting secondary parameters such as relative paths
Args:
config (dict): The configuration to finalise
"""
# First, convert base paths to Path objects
for k, v in config.items():
match k:
@@ -57,17 +73,34 @@ def finalise_config(config: dict) -> None:
def apply_config(config: dict) -> None:
"""Applies the specified configuration to this module's globals
Args:
config (dict): The configuration to apply
"""
finalise_config(config)
globals().update(config)
def update_config(config_label: ConfigLabel = "default") -> None:
"""Updates the configuration to the specified configuration
Args:
config_label (ConfigLabel, optional):
The label of the configuration to update to.
Defaults to "default".
"""
apply_config(get_config(config_label))
def init(config) -> None:
globals().update(get_config(config))
def init(config_label: ConfigLabel) -> None:
"""Initialises the configuration module
Args:
config_label (ConfigLabel): The configuration to initialise with
"""
globals().update(get_config(config_label))
update_config()
init(config="default")
init(config_label="default")

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from docker.types import Mount
import typer
from typing import Tuple
from . import config
from .cli import cli_spinner
@@ -16,6 +17,14 @@ type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def filter_validation_response(response: str) -> str:
"""Filters out erroneous warnings from the validation response
Args:
response (str): The response to filter
Returns:
str: The filtered response
"""
return "\n".join(
filter(
# Filter out the warning about unused key human_readable, this always exists in
@@ -27,6 +36,11 @@ def filter_validation_response(response: str) -> str:
def validation_result() -> str:
"""Returns the response resulting from a validation of the ignition image
Returns:
str: The response from the validation
"""
dockerfile = config.DOCKERFILE_DIR / "validate.dockerfile"
image, _ = config.CLIENT.images.build(
path=".",
@@ -50,7 +64,13 @@ def validation_result() -> str:
@cli_spinner(description="Validating ignition image", total=None)
def validate() -> (bool, str):
def validate() -> Tuple[bool, str]:
"""Validates the ignition image
Returns:
Tuple[bool, str]: A tuple containing a boolean indicating whether
the validation was successful and the response from the validation
"""
response = validation_result().decode()
response = filter_validation_response(response)
return (not bool(response), response)
@@ -58,6 +78,11 @@ def validate() -> (bool, str):
@cli_spinner(description="Writing ignition image to disk", total=None)
def write_disk(disk: str) -> None:
"""Writes the ignition image to the specified disk
Args:
disk (str): The disk to write to
"""
config.CLIENT.containers.run(
"alpine",
mounts=[config.CWD_MOUNT, Mount("/ignition_disk", disk, type="bind")],
@@ -141,7 +166,34 @@ def create_ignition_disk(
)
] = False,
) -> None:
"""Writes an ignition image to the specified disk for easy deployment of new nodes to the swarm""" # noqa
"""Creates an ignition image and writes it to the specified disk
Args:
disk (Annotated[ str, typer.Option, optional):
The disk to write to.
Defaults to None.
hostname (Annotated[ str, typer.Option, optional):
The hostname for the new node.
Defaults to "node".
password (Annotated[ str, typer.Option, optional):
The password for the root user on the new node.
Defaults to None.
switch_ip (Annotated[ IPAddress, typer.Option, optional):
The IP address of the switch to connect to.
Defaults to None.
switch_port (Annotated[ int, typer.Option, optional):
The port on the switch to connect to.
Defaults to 4789.
swarm_token (Annotated[ str, typer.Option, optional):
The swarm token for connecting to the swarm.
Defaults to None.
debug (Annotated[ bool, typer.Option, optional):
Enable debug mode.
Defaults to False.
Raises:
typer.Exit: Exit CLI if the ignition image is invalid
"""
create_img(
hostname = hostname,
password = password,

View File

@@ -16,6 +16,11 @@ type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def load_template() -> dict:
"""Loads the default template for the ignition configuration
Returns:
dict: The default ignition configuration
"""
with open(config.SRC_DIR / "templates/fuelignition.json", "r") as f:
out = json.load(f)
return out
@@ -27,6 +32,17 @@ def apply_ignition_settings(
password: str,
swarm_config: str,
) -> dict:
"""Applies the specified ignition settings to the template
Args:
template (dict): The template to apply the settings to
hostname (str): The hostname to set
password (str): The password to set for the root user
swarm_config (str): The swarm configuration to set
Returns:
dict: The template with the settings applied
"""
ignition_config = template.copy()
ignition_config["hostname"] = hostname
ignition_config["login"]["users"][0]["passwd"] = password
@@ -144,7 +160,31 @@ def create_img(
),
] = False,
) -> None:
"""Creates an ignition image for deploying a new node to the swarm"""
"""Creates an ignition image for a node that will automatically join a swarm
Args:
hostname (Annotated[ str, typer.Option, optional):
The hostname to set for the node.
Defaults to "node".
password (Annotated[ str, typer.Option, optional):
The password to set for the root user on the node.
Defaults to None.
switch_ip (Annotated[ IPAddress, typer.Option, optional):
The IP address of the switch to connect to.
Defaults to None.
switch_port (Annotated[ int, typer.Option, optional):
The port on the switch to connect to.
Defaults to 4789.
swarm_token (Annotated[ str, typer.Option, optional):
The swarm token for connecting to the swarm.
Defaults to None.
img_path (Annotated[ Path, typer.Option, optional):
The path to which the ignition image should be written.
Defaults to Path("ignition.img").
debug (Annotated[ bool, typer.Option, optional):
Enable debug mode.
Defaults to False.
"""
# get swarm configuration as JSON
swarm_config = json.dumps(
{

View File

@@ -7,13 +7,18 @@ import typer
from . import config
# def merge(f1: Callable) -> Callable:
# https://docs.python.org/3/library/functools.html#functools.update_wrapper
# wraps, but it combines the signatures of the two functions
# This will allow us to add/remove the `debug` arg depending on config context
def debug_guard(f: Callable) -> Callable:
"""A decorator that contextually enables debug mode for the decorated function
Args:
f (Callable): The function to decorate
Raises:
typer.Exit: Exit CLI if the dev group is not installed
Returns:
Callable: The decorated function
"""
if not config.DEBUG:
return f
try:

View File

@@ -6,6 +6,14 @@ from . import config
def ensure_build_dir(f: Callable) -> Callable:
"""Ensures that the build directory exists before running the decorated function
Args:
f (Callable): The function to decorate
Returns:
Callable: The decorated function
"""
@wraps(f)
def wrapper(*args, **kwargs):
Path(config.BUILD_DIR).mkdir(exist_ok=True, parents=True)
@@ -15,8 +23,15 @@ def ensure_build_dir(f: Callable) -> Callable:
class Singleton(type):
"""A singleton metaclass"""
_instance = None
def __call__(cls, *args, **kwargs):
"""Creates a new instance of the class if one does not already exist
Returns:
cls._instance: The instance of the class
"""
if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance