{"id":51371884,"url":"https://github.com/pyinfra-dev/pyinfra-testing","last_synced_at":"2026-07-03T07:34:01.283Z","repository":{"id":364140896,"uuid":"1048626014","full_name":"pyinfra-dev/pyinfra-testing","owner":"pyinfra-dev","description":"Generate Python unit tests from JSON and YAML files.","archived":false,"fork":false,"pushed_at":"2026-07-01T20:22:09.000Z","size":108,"stargazers_count":0,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-01T22:19:27.674Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://pypi.org/project/pyinfra-testgen","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pyinfra-dev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-09-01T18:48:11.000Z","updated_at":"2026-07-01T20:22:15.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pyinfra-dev/pyinfra-testing","commit_stats":null,"previous_names":["pyinfra-dev/testgen","pyinfra-dev/pyinfra-testing"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/pyinfra-dev/pyinfra-testing","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pyinfra-dev%2Fpyinfra-testing","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pyinfra-dev%2Fpyinfra-testing/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pyinfra-dev%2Fpyinfra-testing/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pyinfra-dev%2Fpyinfra-testing/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pyinfra-dev","download_url":"https://codeload.github.com/pyinfra-dev/pyinfra-testing/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pyinfra-dev%2Fpyinfra-testing/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35077510,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-03T02:00:05.635Z","response_time":110,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-03T07:33:56.611Z","updated_at":"2026-07-03T07:34:01.261Z","avatar_url":"https://github.com/pyinfra-dev.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# pyinfra testing utils\n\nGenerate `unittest` tests for pyinfra facts and operations from data files\n(JSON/YAML) instead of hand-writing assertions.\n\nYou point a generator at a fact or operation and a folder of test-case files.\nThe harness creates a `TestCase` subclass with **one test method per file** in\nthat folder, so adding a case is just dropping in another `.json`/`.yaml`.\n\n## Install\n\nFrom PyPI as a development dependency, using `uv`:\n\n```bash\nuv add --dev pyinfra-testing\n```\n\nThis adds it to your `dev` dependency group:\n\n```toml\n# pyproject.toml\n[dependency-groups]\ndev = [\"pyinfra-testing\u003e=0.2.0\"]\n```\n\nThen `uv sync` to install. The generated classes are plain\n`unittest.TestCase`s, so `pytest` (or `python -m unittest`) discovers them with\nno extra config.\n\n## Layout\n\nThe conventional layout uses one directory per fact/operation, named after its\ndotted import path, with the case files inside:\n\n```\ntests/\n  test_facts.py                 # discovers tests/facts/*/\n  facts/\n    proxmox.pve.PVEContainers/  # dir name == \"\u003cmodule\u003e.\u003cFactClass\u003e\"\n      two_running.json\n      empty.json\n  test_operations.py            # discovers tests/operations/*/\n  operations/\n    proxmox.pve.container/      # dir name == \"\u003cmodule\u003e.\u003coperation\u003e\"\n      create_minimal.json\n      destroy.json\n```\n\n`test_facts.py` just iterates those directories and hands each one to the\ngenerator (the directory name *is* the dotted path argument):\n\n```python\n# tests/test_facts.py\nfrom pathlib import Path\nfrom pyinfra_testing.facts import make_fact_tests\n\nBASE_IMPORT_PATH = \"facts\"          # your facts package root\nTESTS_BASE = Path(__file__).parent / \"facts\"\n\nfor fact_path in sorted(d.name for d in TESTS_BASE.iterdir() if d.is_dir()):\n    locals()[fact_path] = make_fact_tests(BASE_IMPORT_PATH, fact_path, TESTS_BASE / fact_path)\n```\n\n```python\n# tests/test_operations.py\nfrom pathlib import Path\nfrom pyinfra_testing.operations import make_operation_tests\n\nBASE_IMPORT_PATH = \"operations\"\nTESTS_BASE = Path(__file__).parent / \"operations\"\n\nfor op_path in sorted(d.name for d in TESTS_BASE.iterdir() if d.is_dir()):\n    locals()[op_path] = make_operation_tests(BASE_IMPORT_PATH, op_path, TESTS_BASE / op_path)\n```\n\n`make_*_tests(base_import_path, dotted_path, folder)` imports\n`base_import_path + \".\" + dotted_path` and resolves the trailing attribute as\nthe fact class / operation. Both **flat** (`pyinfra.facts` + `server.LinuxName`)\nand **nested** (`facts` + `proxmox.pve.PVEContainers`) namespaces work — the\nfinal `.`-segment is the attribute, everything before it is the module.\n\nA test directory must contain **only** case files: every `.json`/`.yaml`/`.yml`\nin it becomes one test.\n\n## Testing facts\n\nA fact case file describes the command output and the expected parsed result:\n\n```yaml\n# facts/proxmox.pve.PVEContainers/two_running.yaml\n# `output`: the raw lines the command would print (a str is split on newlines).\noutput: |\n  VMID       Status     Lock         Name\n  100        running                 postgres\n  102        running    backup       web-server\n# `fact`: the expected result of fact.process(output), JSON-normalised.\nfact:\n  \"100\": { vmid: 100, status: running, lock: null, name: postgres }\n  \"102\": { vmid: 102, status: running, lock: backup, name: web-server }\n# Optional — asserted when present, otherwise a warning is emitted:\n# command: pct list\n# requires_command: pveum\n# arg: [100]          # args passed when instantiating the fact class\n# facts: { ... }      # other facts the fact's process() reads via the host\n```\n\n`process()` runs under a frozen clock (`2025-01-01`) so facts that use the\ncurrent date are deterministic.\n\n### Supported fact return types\n\nBefore comparison the result of `process()` is run through\n`json.dumps(..., default=...)` and back. A fact may return anything that\nsurvives that round-trip:\n\n| Returned value | Serialised as | Notes |\n|---|---|---|\n| `dict`, `list`, `str`, `int`, `float`, `bool`, `None` | themselves | the JSON-native types |\n| **dataclass instance** | object via `dataclasses.asdict` | nested dataclasses, and dataclasses inside `dict`/`list`, are handled recursively |\n| `enum.StrEnum` / `enum.IntEnum` (or any `Enum` subclassing `str`/`int`) | its underlying value | a **plain `Enum`** is *not* serialisable — make it a `StrEnum`/`IntEnum` |\n| `datetime` | ISO-8601 string | |\n| `pathlib.Path` | `str` | |\n| `set` | sorted `list` | |\n| `bytes` | decoded `str` | |\n| object exposing `to_json()` | whatever `to_json()` returns | pyinfra's own extension hook; also used at runtime by the CLI |\n\nAnything else raises `TypeError: Cannot serialize: ...`. If you need a custom\nshape (omit `None` fields, rename keys, serialise a plain `Enum`), give the\nreturned type a `to_json()` method — it takes precedence and is the same hook\npyinfra uses to serialise facts at runtime.\n\nTwo consequences worth noting when writing the expected `fact`:\n\n- **Dict keys become strings.** JSON object keys are always strings, so a fact\n  returning `dict[int, ...]` (e.g. keyed by VMID) must use string keys in the\n  expected `fact` — `\"100\":` not `100:`. A fact keyed by a **tuple** is encoded\n  with its JSON-array form as the key — `dict[tuple, ...]` keyed by\n  `(\"/\", \"user\", \"alice@pve\")` becomes the key `\"[\\\"/\\\", \\\"user\\\", \\\"alice@pve\\\"]\"`.\n- **`asdict` emits every field**, including those left at their default/`None`.\n  The expected `fact` must list them too (or give the dataclass a `to_json()`\n  that drops them).\n\n## Testing operations\n\nAn operation case file describes the inputs and the exact commands the\noperation should yield:\n\n```yaml\n# operations/proxmox.pve.container/create_minimal.yaml\nargs: [100, \"ubuntu-22.04-standard_22.04-1_amd64.tar.zst\"]\nkwargs: { present: true }\n# `facts`: data returned by host.get_fact(...) inside the operation, keyed by\n# \"\u003cmodule\u003e.\u003cFactClass\u003e\". Global/executor kwargs the operation passes to\n# get_fact (_sudo, _su_user, ...) are ignored when matching; real fact args\n# (positional, or kwargs like _id) are used to build the lookup key.\nfacts:\n  pve.PVEContainers: {}        # no existing containers\n# `commands`: the exact list the operation should yield.\ncommands:\n  - pct create 100 ubuntu-22.04-standard_22.04-1_amd64.tar.zst\n# Optional:\n# noop_description: \"Container '175' already exists. Use force=True to recreate.\"\n# exception: { name: ValueError, message: \"...\" }   # or names: [A, B]\n# require_platform: [Linux]                          # skip on other platforms\n# local_files: { files: {...}, dirs: {...}, links: {...} }\n```\n\nOperations are invoked via `op._inner(*args, **kwargs)`. If a case yields no\ncommands, set `noop_description` to assert the operation called `host.noop(...)`.\n\nInjected `facts` values are returned to the operation as attribute-accessible\ndicts, so an operation that reads a fact value as an object\n(`info.some_field`) works against plain JSON data. Lookup keys are matched\nJSON-style — an operation doing `fact.get(100)` matches the `\"100\"` key, and\n`fact.get((\"/\", \"user\", \"alice@pve\"))` matches the tuple's JSON-array key\n`\"[\\\"/\\\", \\\"user\\\", \\\"alice@pve\\\"]\"` (the same canonical form a `dict[tuple, ...]`\nfact result is encoded with). So facts keyed by ints, enums, or tuples can all\nbe supplied; key the JSON `facts` data with that canonical string.\n\nYielded commands may be plain strings, `StringCommand`, `FunctionCommand`,\n`FileUploadCommand`/`FileDownloadCommand` (compared as `[\"upload\", data, dest]`\n/ `[\"download\", src, dest]`), or a connector-argument dict.\n\n### Enum and dataclass arguments\n\n`args`/`kwargs` are coerced to the operation's annotated parameter types using\nthe operation's own signature, so you write plain JSON and the types are\nreconstructed for you:\n\n- a parameter annotated as an `Enum` (e.g. `arch: PVEContainerArch | None`)\n  receives the member built from the JSON value — `\"arch\": \"amd64\"` becomes\n  `PVEContainerArch.AMD64`;\n- a parameter annotated as a dataclass receives an instance built from a JSON\n  object — `\"features\": {\"nesting\": true}` becomes `Features(nesting=True)`;\n- `dict[..., T]` / `list[T]` containers of those are coerced element-wise —\n  `\"networks\": {\"0\": {\"name\": \"eth0\"}}` becomes `{\"0\": NetworkInterface(...)}`.\n\nCoercion only applies where the annotation is an enum, a dataclass, or a\ncontainer of them. Plain parameters, unannotated parameters, and union members\nthe value already satisfies (e.g. the `str` in `Protocol | str`) are left\nuntouched, and an operation whose hints can't be resolved is skipped entirely.\n\n### Typed values in args/kwargs\n\nThe coercion above is driven by parameter types. For values whose target type\nis *not* visible in the signature, JSON/YAML strings are also expanded by\nprefix:\n\n- `\"datetime:2025-01-01T00:00:00\"` → `datetime`\n- `\"path:/etc/hosts\"` → `pathlib.Path`\n- `[\"set:\", \"a\", \"b\"]` → `set`\n\n## Running\n\n```bash\nuv run pytest                      # all generated (+ any hand-written) tests\nuv run pytest tests/test_facts.py  # just the fact suites\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpyinfra-dev%2Fpyinfra-testing","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpyinfra-dev%2Fpyinfra-testing","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpyinfra-dev%2Fpyinfra-testing/lists"}