pyinfra-tasmota 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ layout uv
2
+ source_env_if_exists .envrc.local
@@ -0,0 +1,43 @@
1
+ name: Style and unit tests
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ run-tests:
11
+ name: Setup and run tests
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.13", "3.14"]
17
+
18
+ steps:
19
+ - name: Check out the source
20
+ uses: actions/checkout@v6
21
+ with:
22
+ fetch-depth: 0
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v7
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ enable-cache: true
29
+
30
+ - name: Install dependencies
31
+ run: uv sync --locked
32
+
33
+ - name: Style and formatting (ruff)
34
+ run: uv run ruff check .
35
+
36
+ - name: Unit and integration tests (pytest)
37
+ run: uv run pytest
38
+
39
+ - name: Mypy Check
40
+ run: uv run mypy .
41
+
42
+ - name: Bandit check
43
+ run: uv run bandit -c pyproject.toml -r .
@@ -0,0 +1,73 @@
1
+ name: Build package, publish to PyPi, and make a release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch: # allows manual triggering
7
+
8
+ jobs:
9
+ build:
10
+ name: "Build package"
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Check out the source
15
+ uses: actions/checkout@v6
16
+ with:
17
+ fetch-depth: 0
18
+ persist-credentials: false
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v7
22
+
23
+ - name: Build package
24
+ run: uv build
25
+
26
+ - name: Upload distributions
27
+ uses: actions/upload-artifact@v6
28
+ with:
29
+ name: python-package-distributions
30
+ path: dist/
31
+
32
+ publish-to-pypi:
33
+ name: Upload to PyPi
34
+ runs-on: ubuntu-latest
35
+ if: startsWith(github.ref, 'refs/tags/v')
36
+ needs: build
37
+
38
+ environment: pypi
39
+ permissions:
40
+ id-token: write
41
+ contents: read
42
+
43
+ steps:
44
+ - name: Download distributions
45
+ uses: actions/download-artifact@v7
46
+ with:
47
+ name: python-package-distributions
48
+ path: dist/
49
+
50
+ - name: Push to PyPI
51
+ uses: pypa/gh-action-pypi-publish@release/v1
52
+
53
+ github-release:
54
+ name: Create GitHub Release
55
+ runs-on: ubuntu-latest
56
+ if: startsWith(github.ref, 'refs/tags/v')
57
+ needs: build
58
+
59
+ permissions:
60
+ contents: write
61
+
62
+ steps:
63
+ - name: Download all the dists
64
+ uses: actions/download-artifact@v7
65
+ with:
66
+ name: python-package-distributions
67
+ path: dist/
68
+
69
+ - name: Create GitHub Release
70
+ uses: softprops/action-gh-release@v2
71
+ with:
72
+ files: dist/*.whl dist/*.tar.gz
73
+ generate_release_notes: true
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ /.venv/
4
+ .coverage
5
+ .*_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ /.envrc.local
@@ -0,0 +1,146 @@
1
+ # `pyinfra-tasmota`
2
+
3
+ Guidance for coding agents working in this repository. Read this together with
4
+ the user-level `AGENTS.md`, which takes precedence where the two overlap.
5
+
6
+ ## Product
7
+
8
+ A Pyinfra connector to handle Tasmota devices via their HTTP API.
9
+
10
+ ## Stack
11
+
12
+ - Python, [`decode-config`](https://github.com/tasmota/decode-config)
13
+ - `uv` for dependency management
14
+
15
+ ## Project Structure
16
+
17
+ ```raw
18
+ ./
19
+ ├── src/pyinfra_tasmota/ # package
20
+ │ ├── connector.py # Implementation of the connector
21
+ │ └── __init__.py # Export of public API
22
+ ├── tests/ # pytest test suite
23
+ ├── pyproject.toml # project metadata & tool config
24
+ ├── .editorconfig # common editor configuration
25
+ ├── .gitignore # Git ignore file
26
+ └── .pre-commit-config.yaml # `pre-commit` configuration
27
+ ```
28
+
29
+ ## Setup & Build
30
+
31
+ - `uv add <dependency>` to add a dependency (never `uv pip`).
32
+ - `uv add --dev <dependency>` to add a development dependency.
33
+ - `uv sync` to create the environment and install the package.
34
+ - `uv build` for production wheels.
35
+ - `uv run <tool>` to run any tool (`pytest`, `mypy`, `ruff`, `bandit`).
36
+
37
+ ## Quality Gates
38
+
39
+ | Gate | Tool |
40
+ | -------- | ------------------------------- |
41
+ | Format | `ruff format --diff` |
42
+ | Lint | `ruff check --show-fixes` |
43
+ | Types | `mypy .` |
44
+ | Security | `bandit -c pyproject.toml -r .` |
45
+ | Tests | `pytest` |
46
+
47
+ All gates run via pre-commit hooks; only `pytest` needs a manual run (with
48
+ `uv run`).
49
+
50
+ The pre-commit config lives in `.pre-commit-config.yaml`.
51
+
52
+ A task is done only when docstrings, other comments, README, and manpage are
53
+ updated, and `uv run pytest` passes with 100% coverage.
54
+
55
+ ### Mypy configuration
56
+
57
+ Set `warn_unreachable = true`.
58
+
59
+ Set `ignore_missing_imports = true` for module `ipdb`.
60
+
61
+ The Mypy pre-commit hook runs `uv run mypy .`, so it sees the project
62
+ environment and its dependencies; there is no separate dependency list to
63
+ maintain.
64
+
65
+ ### Coverage
66
+
67
+ ```toml
68
+ exclude_also = [
69
+ 'def __repr__',
70
+ 'if TYPE_CHECKING:',
71
+ 'class .*\bProtocol\):',
72
+ '@overload',
73
+ '@(abc\.)?abstractmethod',
74
+ 'raise NotImplementedError',
75
+ 'raise AssertionError',
76
+ ]
77
+ ```
78
+
79
+ ### Bandit
80
+
81
+ Skip `assert_used` for test files.
82
+
83
+ ## Git Workflow
84
+
85
+ Each task goes on a **separate feature branch** created from `main`:
86
+
87
+ ```sh
88
+ git checkout main
89
+ git checkout -b feature/<short-name>
90
+ ```
91
+
92
+ Never commit to `main`.
93
+
94
+ Always bundle commits which update the tests, change the code, and reflect the
95
+ new behaviour in all documentation, docstrings, and comments. Do not split a
96
+ change's tests, code, or documentation across separate commits.
97
+
98
+ Scope-prefixed messages (e.g. `docs:`, `ci:`, `collector:`) commit messages, not
99
+ Conventional Commits.
100
+
101
+ While a feature branch has not been merged into `main`, related follow-up work
102
+ (bugfixes, tweaks) should be appended to the appropriate feature branch. If it
103
+ is not clear to you what branch to use, let me choose from a list of
104
+ possibilities.
105
+
106
+ ## Coding Conventions
107
+
108
+ - Google-style docstrings on every public function.
109
+ - Strict formatting according to Ruff;
110
+ - Prefer test D211 over D203.
111
+ - Prefer test D213 over D212.
112
+ - Type hints everywhere (use `| None` / `list[X]` / `dict[K, V]`).
113
+ - Relative imports within the package (`from .foo import bar`).
114
+ - Module-level imports preferred; inline imports only for tests, otherwise with
115
+ approval; Select Ruff test "PLC0415" except for tests.
116
+ - EAFP: catch exceptions instead of pre-checking.
117
+ - `pathlib` over `os.path`; f-strings over `%` / `.format()`.
118
+ - Use comprehensions, generators, decorators where they fit.
119
+
120
+ ## Test Conventions
121
+
122
+ - 100% line + branch coverage goal. `# pragma: no cover` only on defensive
123
+ branches that cannot be triggered (document why).
124
+ - Red / green: write the test, make it pass, keep it green.
125
+ - Prefer behaviour-based assertions over introspection of private attributes.
126
+ - Deterministic fixtures (factories / `tmp_path` / `CliRunner`).
127
+ - `pytest.mark.xfail` instead of commenting out a failing test.
128
+ - Run affected tests before the full suite.
129
+
130
+ ## Text encoding
131
+
132
+ - Write typographic characters directly as UTF-8 (for example ’ “ ” – — é © …),
133
+ not as HTML/XML entities or numeric references such as `&#8217;`, `&#x2019;`,
134
+ or `&rsquo;`.
135
+ - Use escaping only where the target syntax requires it, such as `&`, `<`, and
136
+ `>` inside HTML or XML markup, or where a format explicitly needs ASCII.
137
+ - Save all text files as UTF-8.
138
+
139
+ ## Ignore
140
+
141
+ Treat everything in `.gitignore` as off-limits to read or edit. On top of that,
142
+ never open:
143
+
144
+ - Secrets and `.env` files
145
+ - Large data files unrelated to the current task
146
+ - Vendored or generated code
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyinfra-tasmota
3
+ Version: 0.1.0
4
+ Summary: A Pyinfra connector to manage Tasmota devices via their HTTP API
5
+ Author: Martin F. Krafft
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pyinfra>=3.10
9
+ Requires-Dist: requests>=2.31
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pyinfra-tasmota
13
+
14
+ A [Pyinfra](https://pyinfra.com) connector for Tasmota devices.
15
+
16
+ The connector talks to a Tasmota device over its HTTP API. It reads the
17
+ device settings once at connect time, compares operation intent against
18
+ that cache, and sends only the commands that are needed as batched
19
+ `Backlog0` requests.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ uv add pyinfra-tasmota
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Inventory:
30
+
31
+ ```py
32
+ hosts = ["@tasmota/tasmota.example.org"]
33
+ ```
34
+
35
+ Deploy:
36
+
37
+ ```py
38
+ from pyinfra_tasmota.operations import apply, cmnd
39
+
40
+ cmnd(SetOption114=1, SetOption120=0, Power="toggle")
41
+ apply()
42
+ ```
43
+
44
+ `cmnd` takes any number of commands as keyword arguments. It compares each
45
+ desired value with the cache. It reads the current value from the device if
46
+ the setting is not cached, and records a pending change only if the value
47
+ differs. Every command is sent by `apply`, or on disconnect as a safety net.
48
+
49
+ Command names are matched case-insensitively and resolve the `SO` alias of
50
+ `SetOption`, so `SetOption114`, `setoption114`, and `so114` are the same
51
+ command. Boolean values are folded, so a desired `0` matches a device value
52
+ of `OFF`, and `1` matches `ON`.
53
+
54
+ Non-rebooting commands are flushed as `Backlog0` batches, split to stay
55
+ within Tasmota's limit of 30 commands and decode-config's limit of 320
56
+ characters per command line. Reboot-triggering commands are sent on their own.
57
+
58
+ ## Reboots
59
+
60
+ Some commands reboot the device, for example `Restart`, `Module`, and
61
+ `Template`. The connector sends these commands on their own, after all other
62
+ commands, and waits for the device to answer again before continuing. A
63
+ device is considered back once it was seen to drop and is answering, or once
64
+ its `BootCount` has advanced.
65
+
66
+ If the device does not return within the timeout, `apply` reports an error.
67
+
68
+ ## Verbosity
69
+
70
+ The connector follows pyinfra's `-v` flags:
71
+
72
+ - `-v` reports every command sent to the device.
73
+ - `-vv` also reports the observed state of each command, and any command
74
+ skipped because the device already matches the desired value.
75
+ - `-vvv` also reports internal detail, such as the size of the status cache
76
+ and how commands are grouped into `Backlog0` batches.
77
+
78
+ ## Versioning
79
+
80
+ The package version comes from Git via `uv-dynamic-versioning`. Tag a release
81
+ as `vX.Y.Z`; commits after a tag get a `.postN.dev0+<hash>` suffix. A checkout
82
+ with no tags reports `0.0.0`.
83
+
84
+ ## Connector data
85
+
86
+ Set these on a host in the inventory:
87
+
88
+ - `tasmota_friendly_name`: an optional human-readable name, used in logs.
89
+ - `tasmota_reboot_timeout`: seconds to wait for a rebooted device to answer
90
+ again (default `60`).
91
+
92
+ The connector registers itself with Pyinfra under the name `tasmota`, so use
93
+ `@tasmota/<host>` anywhere a connector is expected.
@@ -0,0 +1,82 @@
1
+ # pyinfra-tasmota
2
+
3
+ A [Pyinfra](https://pyinfra.com) connector for Tasmota devices.
4
+
5
+ The connector talks to a Tasmota device over its HTTP API. It reads the
6
+ device settings once at connect time, compares operation intent against
7
+ that cache, and sends only the commands that are needed as batched
8
+ `Backlog0` requests.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ uv add pyinfra-tasmota
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Inventory:
19
+
20
+ ```py
21
+ hosts = ["@tasmota/tasmota.example.org"]
22
+ ```
23
+
24
+ Deploy:
25
+
26
+ ```py
27
+ from pyinfra_tasmota.operations import apply, cmnd
28
+
29
+ cmnd(SetOption114=1, SetOption120=0, Power="toggle")
30
+ apply()
31
+ ```
32
+
33
+ `cmnd` takes any number of commands as keyword arguments. It compares each
34
+ desired value with the cache. It reads the current value from the device if
35
+ the setting is not cached, and records a pending change only if the value
36
+ differs. Every command is sent by `apply`, or on disconnect as a safety net.
37
+
38
+ Command names are matched case-insensitively and resolve the `SO` alias of
39
+ `SetOption`, so `SetOption114`, `setoption114`, and `so114` are the same
40
+ command. Boolean values are folded, so a desired `0` matches a device value
41
+ of `OFF`, and `1` matches `ON`.
42
+
43
+ Non-rebooting commands are flushed as `Backlog0` batches, split to stay
44
+ within Tasmota's limit of 30 commands and decode-config's limit of 320
45
+ characters per command line. Reboot-triggering commands are sent on their own.
46
+
47
+ ## Reboots
48
+
49
+ Some commands reboot the device, for example `Restart`, `Module`, and
50
+ `Template`. The connector sends these commands on their own, after all other
51
+ commands, and waits for the device to answer again before continuing. A
52
+ device is considered back once it was seen to drop and is answering, or once
53
+ its `BootCount` has advanced.
54
+
55
+ If the device does not return within the timeout, `apply` reports an error.
56
+
57
+ ## Verbosity
58
+
59
+ The connector follows pyinfra's `-v` flags:
60
+
61
+ - `-v` reports every command sent to the device.
62
+ - `-vv` also reports the observed state of each command, and any command
63
+ skipped because the device already matches the desired value.
64
+ - `-vvv` also reports internal detail, such as the size of the status cache
65
+ and how commands are grouped into `Backlog0` batches.
66
+
67
+ ## Versioning
68
+
69
+ The package version comes from Git via `uv-dynamic-versioning`. Tag a release
70
+ as `vX.Y.Z`; commits after a tag get a `.postN.dev0+<hash>` suffix. A checkout
71
+ with no tags reports `0.0.0`.
72
+
73
+ ## Connector data
74
+
75
+ Set these on a host in the inventory:
76
+
77
+ - `tasmota_friendly_name`: an optional human-readable name, used in logs.
78
+ - `tasmota_reboot_timeout`: seconds to wait for a rebooted device to answer
79
+ again (default `60`).
80
+
81
+ The connector registers itself with Pyinfra under the name `tasmota`, so use
82
+ `@tasmota/<host>` anywhere a connector is expected.
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "pyinfra-tasmota"
3
+ dynamic = ["version"]
4
+ description = "A Pyinfra connector to manage Tasmota devices via their HTTP API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Martin F. Krafft" }]
9
+ dependencies = ["pyinfra>=3.10", "requests>=2.31"]
10
+
11
+ [project.entry-points."pyinfra.connectors"]
12
+ tasmota = "pyinfra_tasmota.connector:TasmotaConnector"
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "bandit>=1.7",
17
+ "mypy>=1.11",
18
+ "pytest>=8.0",
19
+ "pytest-cov>=5.0",
20
+ "ruff>=0.6",
21
+ "types-requests>=2.32",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["hatchling", "uv-dynamic-versioning"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.version]
29
+ source = "uv-dynamic-versioning"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/pyinfra_tasmota"]
33
+
34
+ [tool.ruff]
35
+ line-length = 100
36
+ target-version = "py310"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "PT", "RET", "SIM", "PL", "RUF", "D"]
40
+ ignore = ["D203", "D212", "PLR0913"]
41
+
42
+ [tool.ruff.lint.pydocstyle]
43
+ convention = "google"
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ "tests/*" = ["D101", "D102", "D103", "D104", "D107", "PLC0415", "PLR2004"]
47
+
48
+ [tool.mypy]
49
+ python_version = "3.10"
50
+ strict = true
51
+ warn_unreachable = true
52
+ ignore_missing_imports = true
53
+
54
+ [[tool.mypy.overrides]]
55
+ module = "tests.*"
56
+ disallow_untyped_defs = false
57
+ disallow_untyped_calls = false
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ addopts = "--cov=pyinfra_tasmota --cov-report=term-missing --cov-fail-under=100"
62
+
63
+ [tool.coverage.run]
64
+ branch = true
65
+
66
+ [tool.coverage.report]
67
+ exclude_also = [
68
+ 'def __repr__',
69
+ 'if TYPE_CHECKING:',
70
+ 'class .*\bProtocol\):',
71
+ '@overload',
72
+ '@(abc\.)?abstractmethod',
73
+ 'raise NotImplementedError',
74
+ 'raise AssertionError',
75
+ ]
76
+
77
+ [tool.bandit]
78
+ skips = ["B101"]
79
+ exclude_dirs = [".venv", "tests"]
@@ -0,0 +1,5 @@
1
+ """A Pyinfra connector to manage Tasmota devices via their HTTP API."""
2
+
3
+ from .connector import TasmotaConnector
4
+
5
+ __all__ = ["TasmotaConnector"]