diff --git a/.gitignore b/.gitignore index 57b2cea..c4dcd0a 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,9 @@ venv.bak/ # Rope project settings .ropeproject +# VSCode project settings +.vscode/ + # mkdocs documentation /site diff --git a/poetry.lock b/poetry.lock index ecc37a1..cb5ce69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 34a5763..4a21428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/node_deployer/__main__.py b/src/node_deployer/__main__.py index c8a4b98..6aa97e1 100644 --- a/src/node_deployer/__main__.py +++ b/src/node_deployer/__main__.py @@ -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: diff --git a/src/node_deployer/autoignition.py b/src/node_deployer/autoignition.py index 847ee08..07d8c6d 100644 --- a/src/node_deployer/autoignition.py +++ b/src/node_deployer/autoignition.py @@ -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 diff --git a/src/node_deployer/cli.py b/src/node_deployer/cli.py index 1a64fb0..d1f69f6 100644 --- a/src/node_deployer/cli.py +++ b/src/node_deployer/cli.py @@ -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 diff --git a/src/node_deployer/config.py b/src/node_deployer/config.py index 7fbf1e5..bc6565d 100644 --- a/src/node_deployer/config.py +++ b/src/node_deployer/config.py @@ -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") \ No newline at end of file +init(config_label="default") \ No newline at end of file diff --git a/src/node_deployer/create_disk.py b/src/node_deployer/create_disk.py index e7f1268..2a7b571 100644 --- a/src/node_deployer/create_disk.py +++ b/src/node_deployer/create_disk.py @@ -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, diff --git a/src/node_deployer/create_img.py b/src/node_deployer/create_img.py index 5eaa1d8..035baa8 100644 --- a/src/node_deployer/create_img.py +++ b/src/node_deployer/create_img.py @@ -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( { diff --git a/src/node_deployer/debug.py b/src/node_deployer/debug.py index 8322ebc..e910904 100644 --- a/src/node_deployer/debug.py +++ b/src/node_deployer/debug.py @@ -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: diff --git a/src/node_deployer/utils.py b/src/node_deployer/utils.py index 0b4f503..b334302 100644 --- a/src/node_deployer/utils.py +++ b/src/node_deployer/utils.py @@ -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