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 # Rope project settings
.ropeproject .ropeproject
# VSCode project settings
.vscode/
# mkdocs documentation # mkdocs documentation
/site /site

47
poetry.lock generated
View File

@@ -599,6 +599,51 @@ beautifulsoup4 = ">=4.7"
lxml = "*" lxml = "*"
requests = ">=2.22.0" 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]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "1.0.0" version = "1.0.0"
@@ -1047,4 +1092,4 @@ h11 = ">=0.9.0,<1"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "f4992ac644316ea1b818a9d394644dfa4f16dee7cb6b05fe0853b61b4629c334" content-hash = "c74d1dcf762e5266d41c81b62bef444f065124fede9b3a34176cd9a6b93d9c2b"

View File

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

View File

@@ -1,19 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
def main() -> None: def main() -> None:
"""Entry point for the CLI
"""
from . import config from . import config
config.update_config("cli") config.update_config("cli")
from .node_deployer import app from .node_deployer import app
app() app()
def debug(): def debug() -> None:
"""Entry point for the debug CLI
"""
from . import config from . import config
config.update_config("debug") config.update_config("debug")
from .node_deployer import app from .node_deployer import app
# Below, we set the default value of the debug flag # Below, we set the default value of the debug flag
# for the base function of each command to True # 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__"): if hasattr(f, "__wrapped__"):
return unwrap(f.__wrapped__) return unwrap(f.__wrapped__)
else: else:

View File

@@ -5,6 +5,7 @@ import tarfile
import time import time
from typing import Annotated from typing import Annotated
import docker
import git import git
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -18,7 +19,12 @@ from .debug import debug_guard
from .utils import ensure_build_dir 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( driver = webdriver.Remote(
"http://127.0.0.1:4444", "http://127.0.0.1:4444",
options=webdriver.FirefoxOptions(), options=webdriver.FirefoxOptions(),
@@ -27,7 +33,20 @@ def create_driver():
return 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) driver.get(config.FUELIGNITION_URL)
# Navigate to "Load Settings from" and upload the json # Navigate to "Load Settings from" and upload the json
load_from = driver.find_element(By.NAME, "load_from") 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()) 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 # Make sure the local fuel-ignition repo is up to date
if (not config.FUELIGNITION_BUILD_DIR.exists()) or ( if (not config.FUELIGNITION_BUILD_DIR.exists()) or (
len(tuple(config.FUELIGNITION_BUILD_DIR.iterdir())) == 0 len(tuple(config.FUELIGNITION_BUILD_DIR.iterdir())) == 0
@@ -149,7 +173,22 @@ def json_to_img(
) )
] = False, ] = False,
) -> None: ) -> 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 selenium_container = None
fuelignition_container = None fuelignition_container = None
fuelignition_image = None fuelignition_image = None

View File

@@ -9,10 +9,20 @@ from .utils import Singleton
class SingletonProgress(Progress, metaclass=Singleton): class SingletonProgress(Progress, metaclass=Singleton):
"""A singleton progress bar"""
pass pass
def cli_spinner(*spinner_args, **spinner_kwargs) -> Callable: 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: def decorator(f: Callable) -> Callable:
# Indent the spinner to match its nesting level # Indent the spinner to match its nesting level
indent = len(inspect.stack()) - 1 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] 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): if isinstance(config_label, str):
config_label = [config_label] config_label = [config_label]
with open(PROJECT_ROOT / "config.toml", "rb") as f: 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: 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 # First, convert base paths to Path objects
for k, v in config.items(): for k, v in config.items():
match k: match k:
@@ -57,17 +73,34 @@ def finalise_config(config: dict) -> None:
def apply_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) finalise_config(config)
globals().update(config) globals().update(config)
def update_config(config_label: ConfigLabel = "default") -> None: 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)) apply_config(get_config(config_label))
def init(config) -> None: def init(config_label: ConfigLabel) -> None:
globals().update(get_config(config)) """Initialises the configuration module
Args:
config_label (ConfigLabel): The configuration to initialise with
"""
globals().update(get_config(config_label))
update_config() update_config()
init(config="default") init(config_label="default")

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from docker.types import Mount from docker.types import Mount
import typer import typer
from typing import Tuple
from . import config from . import config
from .cli import cli_spinner from .cli import cli_spinner
@@ -16,6 +17,14 @@ type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def filter_validation_response(response: str) -> str: 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( return "\n".join(
filter( filter(
# Filter out the warning about unused key human_readable, this always exists in # 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: 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" dockerfile = config.DOCKERFILE_DIR / "validate.dockerfile"
image, _ = config.CLIENT.images.build( image, _ = config.CLIENT.images.build(
path=".", path=".",
@@ -50,7 +64,13 @@ def validation_result() -> str:
@cli_spinner(description="Validating ignition image", total=None) @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 = validation_result().decode()
response = filter_validation_response(response) response = filter_validation_response(response)
return (not bool(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) @cli_spinner(description="Writing ignition image to disk", total=None)
def write_disk(disk: str) -> 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( config.CLIENT.containers.run(
"alpine", "alpine",
mounts=[config.CWD_MOUNT, Mount("/ignition_disk", disk, type="bind")], mounts=[config.CWD_MOUNT, Mount("/ignition_disk", disk, type="bind")],
@@ -141,7 +166,34 @@ def create_ignition_disk(
) )
] = False, ] = False,
) -> None: ) -> 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( create_img(
hostname = hostname, hostname = hostname,
password = password, password = password,

View File

@@ -16,6 +16,11 @@ type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def load_template() -> dict: 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: with open(config.SRC_DIR / "templates/fuelignition.json", "r") as f:
out = json.load(f) out = json.load(f)
return out return out
@@ -27,6 +32,17 @@ def apply_ignition_settings(
password: str, password: str,
swarm_config: str, swarm_config: str,
) -> dict: ) -> 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 = template.copy()
ignition_config["hostname"] = hostname ignition_config["hostname"] = hostname
ignition_config["login"]["users"][0]["passwd"] = password ignition_config["login"]["users"][0]["passwd"] = password
@@ -144,7 +160,31 @@ def create_img(
), ),
] = False, ] = False,
) -> None: ) -> 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 # get swarm configuration as JSON
swarm_config = json.dumps( swarm_config = json.dumps(
{ {

View File

@@ -7,13 +7,18 @@ import typer
from . import config 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: 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: if not config.DEBUG:
return f return f
try: try:

View File

@@ -6,6 +6,14 @@ from . import config
def ensure_build_dir(f: Callable) -> Callable: 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) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
Path(config.BUILD_DIR).mkdir(exist_ok=True, parents=True) Path(config.BUILD_DIR).mkdir(exist_ok=True, parents=True)
@@ -15,8 +23,15 @@ def ensure_build_dir(f: Callable) -> Callable:
class Singleton(type): class Singleton(type):
"""A singleton metaclass"""
_instance = None _instance = None
def __call__(cls, *args, **kwargs): 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: if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs) cls._instance = super().__call__(*args, **kwargs)
return cls._instance return cls._instance