Skip to content
Open
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
41 changes: 38 additions & 3 deletions pyinfra_testing/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,32 @@

from pyinfra.api import Config, Inventory
from pyinfra.api.arguments import all_argument_meta
from pyinfra.api.facts import FactBase
from pyinfra.api.util import get_kwargs_str


def _get_fact_return_type(fact_cls):
"""Return the declared return type of ``fact_cls`` if available.

First checks the ``process`` method's return annotation, then falls back
to the ``FactBase[T]`` generic argument. Returns ``None`` when the type
cannot be determined.
"""
try:
hints = typing.get_type_hints(fact_cls.process)
except Exception:
hints = {}
if "return" in hints:
return hints["return"]

for base in getattr(fact_cls, "__orig_bases__", ()):
if typing.get_origin(base) is FactBase:
args = typing.get_args(base)
if args:
return args[0]
return None


def get_command_string(command):
value = command.get_raw_value()
masked_value = command.get_masked_value()
Expand Down Expand Up @@ -126,7 +149,7 @@ def _coerce_value(value, annotation):
**{key: _coerce_value(val, field_hints.get(key)) for key, val in value.items()}
)

# dict[K, V] / list[V]: coerce the contents.
# dict[K, V] / list[V] / tuple[T, ...]: coerce the contents.
if origin is dict and isinstance(value, dict):
args = typing.get_args(annotation)
value_type = args[1] if len(args) == 2 else None
Expand All @@ -135,6 +158,12 @@ def _coerce_value(value, annotation):
args = typing.get_args(annotation)
value_type = args[0] if args else None
return [_coerce_value(val, value_type) for val in value]
if origin is tuple and isinstance(value, (list, tuple)):
args = typing.get_args(annotation)
if len(args) == 2 and args[1] is Ellipsis:
value_type = args[0]
return tuple(_coerce_value(val, value_type) for val in value)
return tuple(_coerce_value(val, arg) for val, arg in zip(value, args))

return value

Expand Down Expand Up @@ -385,8 +414,14 @@ def get_fact(self, fact_cls, *args, **kwargs):
kwargs_str = get_kwargs_str(kwargs)
if kwargs_str not in fact:
raise KeyError(f"Missing test fact key: {fact_key} -> {kwargs_str}")
return fact.get(kwargs_str)
return fact
value = fact.get(kwargs_str)
else:
value = fact.data

return_type = _get_fact_return_type(fact_cls)
if return_type is not None:
value = _coerce_value(value, return_type)
return value


class FakeFile:
Expand Down
13 changes: 13 additions & 0 deletions tests/test_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,16 @@ def test_extra_positional_args_beyond_signature_pass_through():
# More positional values than named params (e.g. *args) are left as-is.
args, kwargs = coerce_operation_arguments(op_plain, ["n", 2, "extra"], {})
assert args == ["n", 2, "extra"]


# --- _coerce_value: tuples -------------------------------------------------- #
def test_tuple_is_built_from_list():
assert _coerce_value([1, 2, 3], tuple[int, ...]) == (1, 2, 3)


def test_tuple_elements_are_coerced():
assert _coerce_value([{"name": "eth0"}], tuple[Net, ...]) == (Net("eth0"),)


def test_fixed_size_tuple_is_coerced():
assert _coerce_value(["a", 1], tuple[str, int]) == ("a", 1)
76 changes: 76 additions & 0 deletions tests/test_fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
(global-argument stripping)."""

import enum
from dataclasses import dataclass

import pytest
from pyinfra.api.util import get_kwargs_str
from pyinfra.facts.util.packages import PackageInfo, PackageStatus

from pyinfra_testing.util import FakeFact, FakeHost, FakeState, create_host

Expand Down Expand Up @@ -76,6 +78,36 @@ def command(self, name):
return f"echo {name}"


@dataclass
class Widget:
name: str
count: int | None = None


class WidgetFact:
def command(self):
return "echo widget"

def process(self, output) -> Widget:
return Widget(name="".join(output), count=1)


class WidgetListFact:
def command(self):
return "echo widgets"

def process(self, output) -> list[Widget]:
return [Widget(name="a"), Widget(name="b")]


class PackageListFact:
def command(self):
return "echo packages"

def process(self, output) -> list[PackageInfo]:
return []


def _host(facts):
return create_host(FakeState(), facts=facts)

Expand Down Expand Up @@ -108,3 +140,47 @@ def test_get_fact_missing_arg_key_raises():
host = _host({key: {}}) # fact present, but no entry for name=web
with pytest.raises(KeyError):
host.get_fact(ArgFact, "web")


# --- FakeHost.get_fact: dataclass coercion ---------------------------------- #
def test_get_fact_coerces_noarg_fact_to_dataclass():
key = FakeHost._get_fact_key(WidgetFact)
host = _host({key: {"name": "spinner", "count": 3}})
result = host.get_fact(WidgetFact)
assert isinstance(result, Widget)
assert result.name == "spinner"
assert result.count == 3


def test_get_fact_coerces_list_of_dataclasses():
key = FakeHost._get_fact_key(WidgetListFact)
host = _host({key: [{"name": "a", "count": 1}, {"name": "b"}]})
result = host.get_fact(WidgetListFact)
assert isinstance(result, list)
assert all(isinstance(widget, Widget) for widget in result)
assert result[0].name == "a"
assert result[1].count is None


def test_get_fact_coerces_real_package_info():
key = FakeHost._get_fact_key(PackageListFact)
host = _host(
{
key: [
{
"name": "vim",
"installed_versions": ["9.0"],
"available_version": "9.1",
"status": "upgradeable",
},
{"name": "git", "installed_versions": ["2.40"]},
],
},
)
result = host.get_fact(PackageListFact)
assert isinstance(result, list)
assert isinstance(result[0], PackageInfo)
assert result[0].name == "vim"
assert result[0].status is PackageStatus.UPGRADEABLE
assert result[0].installed_versions == ("9.0",)
assert result[1].status is PackageStatus.INSTALLED
Loading