Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type hints for state.py #4294

Merged
merged 5 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .config/pydoclint-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
134 changes: 89 additions & 45 deletions src/molecule/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.

Expand All @@ -60,95 +108,91 @@
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"]

Check warning on line 144 in src/molecule/state.py

View check run for this annotation

Codecov / codecov/patch

src/molecule/state.py#L144

Added line #L144 was not covered by tests

@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"]

Check warning on line 148 in src/molecule/state.py

View check run for this annotation

Codecov / codecov/patch

src/molecule/state.py#L148

Added line #L148 was not covered by tests

@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.

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"
11 changes: 6 additions & 5 deletions src/molecule/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 23 additions & 17 deletions tests/unit/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))

Expand Down