diff --git a/.config/pydoclint-baseline.txt b/.config/pydoclint-baseline.txt index ea7d5bdb9..cba971778 100644 --- a/.config/pydoclint-baseline.txt +++ b/.config/pydoclint-baseline.txt @@ -435,14 +435,6 @@ src/molecule/shell.py DOC107: Function `main`: The option `--arg-type-hints-in-signature` is `True` but not all args in the signature have type hints DOC103: Function `main`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [base_config: , ctx: , debug: , env_file: , verbose: ]. -------------------- -src/molecule/state.py - DOC106: Method `State.__init__`: The option `--arg-type-hints-in-signature` is `True` but there are no argument type hints in the signature - DOC107: Method `State.__init__`: The option `--arg-type-hints-in-signature` is `True` but not all args in the signature have type hints - DOC106: Method `State.change_state`: The option `--arg-type-hints-in-signature` is `True` but there are no argument type hints in the signature - DOC107: Method `State.change_state`: The option `--arg-type-hints-in-signature` is `True` but not all args in the signature have type hints - DOC501: Method `State.change_state` has "raise" statements, but the docstring does not have a "Raises" section - DOC503: Method `State.change_state` exceptions in the "Raises" section in the docstring do not match those in the function body Raises values in the docstring: []. Raised exceptions in the body: ['InvalidState']. --------------------- src/molecule/status.py DOC601: Class `Status`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) DOC603: Class `Status`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [converged: str, created: str, driver_name: str, instance_name: str, provisioner_name: str, scenario_name: str]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) diff --git a/src/molecule/state.py b/src/molecule/state.py index f153d55a0..473dd2ef6 100644 --- a/src/molecule/state.py +++ b/src/molecule/state.py @@ -21,11 +21,19 @@ from __future__ import annotations import logging -import os + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict, TypeVar, cast from molecule import util +if TYPE_CHECKING: + + from molecule.config import Config + + LOG = logging.getLogger(__name__) VALID_KEYS = [ "created", @@ -36,12 +44,52 @@ "is_parallel", "molecule_yml_date_modified", ] +F = TypeVar("F", bound=Callable[..., None]) class InvalidState(Exception): # noqa: N818 """Exception class raised when an error occurs in :class:`.State`.""" +class StateData(TypedDict): + """Valid state values and types. + + Attributes: + converged: Has scenario converged. + created: Has scenario been created: + driver: Driver for scenario. + prepared: Has scenario prepare run. + molecule_yml_date_modified: Modified date of molecule.yml file. + run_uuid: UUID of active run. + is_parallel: Is this run parallel. + """ + + converged: bool + created: bool + driver: str | None + prepared: bool + molecule_yml_date_modified: float | None + run_uuid: str + is_parallel: bool + + +def marshal(func: F) -> F: + """Decorator to immediately write state to file after call finishes. + + Args: + func: Function to decorate. + + Returns: + Decorated function. + """ + + def wrapper(self: State, *args: object, **kwargs: object) -> None: + func(self, *args, **kwargs) + self._write_state_file() + + return cast(F, wrapper) + + class State: """A class which manages the state file. @@ -60,62 +108,55 @@ class State: Molecule. """ - def __init__(self, config) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + def __init__(self, config: Config) -> None: """Initialize a new state class and returns None. Args: config: An instance of a Molecule config. """ self._config = config - self._state_file = self._get_state_file() # type: ignore[no-untyped-call] - self._data = self._get_data() # type: ignore[no-untyped-call] - self._write_state_file() # type: ignore[no-untyped-call] - - def marshal(func): # type: ignore[no-untyped-def] # noqa: ANN201, N805, D102 - def wrapper(self, *args, **kwargs): # type: ignore[no-untyped-def] # noqa: ANN001, ANN002, ANN003, ANN202 - func(self, *args, **kwargs) # type: ignore[operator] # pylint: disable=not-callable - self._write_state_file() - - return wrapper + self._state_file = self._get_state_file() + self._data = self._get_data() + self._write_state_file() @property - def state_file(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._state_file + def state_file(self) -> str: # noqa: D102 + return str(self._state_file) @property - def converged(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("converged") + def converged(self) -> bool: # noqa: D102 + return self._data["converged"] @property - def created(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("created") + def created(self) -> bool: # noqa: D102 + return self._data["created"] @property - def driver(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("driver") + def driver(self) -> str | None: # noqa: D102 + return self._data["driver"] @property - def prepared(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("prepared") + def prepared(self) -> bool: # noqa: D102 + return self._data["prepared"] @property - def run_uuid(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("run_uuid") + def run_uuid(self) -> str: # noqa: D102 + return self._data["run_uuid"] @property - def is_parallel(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("is_parallel") + def is_parallel(self) -> bool: # noqa: D102 + return self._data["is_parallel"] @property - def molecule_yml_date_modified(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - return self._data.get("molecule_yml_date_modified") + def molecule_yml_date_modified(self) -> float | None: # noqa: D102 + return self._data["molecule_yml_date_modified"] - @marshal # type: ignore[arg-type] - def reset(self): # type: ignore[no-untyped-def] # noqa: ANN201, D102 - self._data = self._default_data() # type: ignore[no-untyped-call] + @marshal + def reset(self) -> None: # noqa: D102 + self._data = self._default_data() - @marshal # type: ignore[arg-type] - def change_state(self, key, value): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201 + @marshal + def change_state(self, key: str, value: object) -> None: """Change the state of the instance data with the given `key` and the provided ``value`. Wrapping with a decorator is probably not necessary. @@ -123,32 +164,35 @@ def change_state(self, key, value): # type: ignore[no-untyped-def] # noqa: ANN Args: key: A ``str`` containing the key to update value: A value to change the ``key`` to + + Raises: + InvalidState: if an invalid key is requested. """ if key not in VALID_KEYS: raise InvalidState - self._data[key] = value + self._data[key] = value # type: ignore[literal-required] - def _get_data(self): # type: ignore[no-untyped-def] # noqa: ANN202 - if os.path.isfile(self.state_file): # noqa: PTH113 - return self._load_file() # type: ignore[no-untyped-call] - return self._default_data() # type: ignore[no-untyped-call] + def _get_data(self) -> StateData: + if self._state_file.is_file(): + return self._load_file() + return self._default_data() - def _default_data(self): # type: ignore[no-untyped-def] # noqa: ANN202 + def _default_data(self) -> StateData: return { "converged": False, "created": False, "driver": None, - "prepared": None, + "prepared": False, "molecule_yml_date_modified": None, "run_uuid": self._config._run_uuid, # noqa: SLF001 "is_parallel": self._config.is_parallel, } - def _load_file(self): # type: ignore[no-untyped-def] # noqa: ANN202 - return util.safe_load_file(self.state_file) + def _load_file(self) -> StateData: + return cast(StateData, util.safe_load_file(self._state_file)) - def _write_state_file(self): # type: ignore[no-untyped-def] # noqa: ANN202 + def _write_state_file(self) -> None: util.write_file(self.state_file, util.safe_dump(self._data)) - def _get_state_file(self): # type: ignore[no-untyped-def] # noqa: ANN202 - return os.path.join(self._config.scenario.ephemeral_directory, "state.yml") # noqa: PTH118 + def _get_state_file(self) -> Path: + return Path(self._config.scenario.ephemeral_directory) / "state.yml" diff --git a/src/molecule/util.py b/src/molecule/util.py index 75b9b992f..6cd3a7f1c 100644 --- a/src/molecule/util.py +++ b/src/molecule/util.py @@ -246,19 +246,20 @@ def render_template(template: str, **kwargs: str) -> str: return t.from_string(template).render(kwargs) -def write_file(filename: str, content: str, header: str | None = None) -> None: - """Write a file with the given filename and content and returns None. +def write_file(filename: str | Path, content: str, header: str | None = None) -> None: + """Write a file with the given filename and content. Args: - filename: A string containing the target filename. + filename: The target file. content: A string containing the data to be written. header: A header, if None it will use default header. """ if header is None: content = MOLECULE_HEADER + "\n\n" + content - with open(filename, "w") as f: # noqa: PTH123 - f.write(content) + if isinstance(filename, str): + filename = Path(filename) + filename.write_text(content) def molecule_prepender(content: str) -> str: diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 48467f3cd..596b4568e 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -21,39 +21,45 @@ import os +from typing import TYPE_CHECKING + import pytest from molecule import config, state, util +if TYPE_CHECKING: + from typing import Any + + @pytest.fixture() -def _instance(config_instance: config.Config): # type: ignore[no-untyped-def] # noqa: ANN202 +def _instance(config_instance: config.Config) -> state.State: return state.State(config_instance) -def test_state_file_property(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_state_file_property(_instance: state.State) -> None: # noqa: PT019, D103 x = os.path.join(_instance._config.scenario.ephemeral_directory, "state.yml") # noqa: PTH118 assert x == _instance.state_file -def test_converged(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_converged(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.converged -def test_created(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_created(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.created -def test_state_driver(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_state_driver(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.driver -def test_prepared(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_prepared(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.prepared -def test_reset(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_reset(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.converged _instance.change_state("converged", True) # noqa: FBT003 @@ -63,7 +69,7 @@ def test_reset(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN2 assert not _instance.converged -def test_reset_persists(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_reset_persists(_instance: state.State) -> None: # noqa: PT019, D103 assert not _instance.converged _instance.change_state("converged", True) # noqa: FBT003 @@ -76,40 +82,40 @@ def test_reset_persists(_instance): # type: ignore[no-untyped-def] # noqa: ANN assert not d.get("converged") -def test_change_state_converged(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_change_state_converged(_instance: state.State) -> None: # noqa: PT019, D103 _instance.change_state("converged", True) # noqa: FBT003 assert _instance.converged -def test_change_state_created(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_change_state_created(_instance: state.State) -> None: # noqa: PT019, D103 _instance.change_state("created", True) # noqa: FBT003 assert _instance.created -def test_change_state_driver(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_change_state_driver(_instance: state.State) -> None: # noqa: PT019, D103 _instance.change_state("driver", "foo") assert _instance.driver == "foo" -def test_change_state_prepared(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_change_state_prepared(_instance: state.State) -> None: # noqa: PT019, D103 _instance.change_state("prepared", True) # noqa: FBT003 assert _instance.prepared -def test_change_state_raises(_instance): # type: ignore[no-untyped-def] # noqa: ANN001, ANN201, PT019, D103 +def test_change_state_raises(_instance: state.State) -> None: # noqa: PT019, D103 with pytest.raises(state.InvalidState): _instance.change_state("invalid-state", True) # noqa: FBT003 -def test_get_data_loads_existing_state_file( # type: ignore[no-untyped-def] # noqa: ANN201, D103 - _instance, # noqa: ANN001, PT019 - molecule_data, # noqa: ANN001, ARG001 +def test_get_data_loads_existing_state_file( # noqa: D103 + _instance: state.State, # noqa: PT019 + molecule_data: dict[str, Any], # noqa: ARG001 config_instance: config.Config, -): +) -> None: data = {"converged": False, "created": True, "driver": None, "prepared": None} util.write_file(_instance._state_file, util.safe_dump(data))