franklinwh-modbus-async 0.0.1__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,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+
10
+ # Tooling caches
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .mypy_cache/
14
+ .coverage
15
+ htmlcov/
16
+ site/
17
+
18
+ # Local-only: never commit
19
+ .env
20
+ .pii-denylist
21
+ captures/raw/
22
+ tests/results/
23
+ .DS_Store
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+
9
+ - **Phase 0 scaffold:**
10
+ - Packaging: Python >= 3.12, pinned `modbus-connection[tmodbus]~=4.12.4`.
11
+ - Code checks: ruff, mypy `--strict`, pytest-asyncio.
12
+ - CI on Python 3.12 and 3.13.
13
+ - **PII guard** (`tools/pii_check.py`). It runs in pre-commit and CI and blocks:
14
+ - home-directory paths
15
+ - aGate serial numbers
16
+ - MAC addresses
17
+ - non-documentation IP addresses
18
+ - email addresses
19
+ - terms from a local `.pii-denylist` file
20
+ - **Documentation:**
21
+ - `docs/why.md`: the library's purpose, basis and rationale, with a comparison table.
22
+ - `docs/research/modbus-connection.md`: findings on the transport.
23
+ - `PLAN.md`
24
+ - **Release workflow** (`.github/workflows/release.yml`):
25
+ - Publishes to PyPI with trusted publishing (OIDC) when a `v*` tag is pushed.
26
+ - The PII guard runs before the build.
27
+ - **sdist contents:** the source distribution includes only `src/`, `tests/`, the PII tool, README, LICENSE and CHANGELOG.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 david2069
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: franklinwh-modbus-async
3
+ Version: 0.0.1
4
+ Summary: Async FranklinWH aGate Modbus TCP library (SunSpec + extension registers) built on modbus-connection
5
+ Project-URL: Homepage, https://github.com/david2069/franklinwh-modbus-async
6
+ Project-URL: Bug Tracker, https://github.com/david2069/franklinwh-modbus-async/issues
7
+ Project-URL: Changelog, https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md
8
+ Author-email: david2069 <david2069@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agate,asyncio,battery,energy,franklinwh,modbus,sunspec
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Home Automation
19
+ Classifier: Topic :: System :: Hardware
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: modbus-connection[tmodbus]~=4.12.4
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.11; extra == 'dev'
25
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
31
+ Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
32
+ Provides-Extra: pymodbus
33
+ Requires-Dist: modbus-connection[pymodbus]~=4.12.4; extra == 'pymodbus'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # franklinwh-modbus-async
37
+
38
+ An **async** Python library for the FranklinWH **aGate**. It talks Modbus TCP and covers the SunSpec models and the vendor extension registers. It is built on [`modbus-connection`](https://github.com/home-assistant-libs/modbus-connection).
39
+
40
+ This is the asyncio successor to [`franklinwh-modbus`](https://github.com/david2069/franklinwh-modbus), which is synchronous. It aims to match that library's features and to fix its known connection and threading defects. To find out why it exists and how it compares, read **[docs/why.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md)**.
41
+
42
+ > **Status: pre-alpha (Phase 0 of 7).** The tooling and docs are in place, but there is no device API yet. See [PLAN.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) for the roadmap.
43
+
44
+ > ## ⚠️ Unofficial software
45
+ >
46
+ > **About this software**
47
+ >
48
+ > - Not affiliated with, endorsed or supported by FranklinWH. FranklinWH and aGate are trademarks of their owners.
49
+ > - Provided **as is**, without warranty, for educational and informational use.
50
+ >
51
+ > **Write commands carry real risk**
52
+ >
53
+ > - This library can send **write commands to grid-connected battery hardware**.
54
+ > - Used incorrectly, it can discharge your battery when you need it, import power you did not intend to, or leave the system in an unexpected state.
55
+ > - You use it at your own risk.
56
+ >
57
+ > **Getting help**
58
+ >
59
+ > - Do not contact FranklinWH support about this library. Report problems [here](https://github.com/david2069/franklinwh-modbus-async/issues).
60
+
61
+ ## Requirements
62
+
63
+ - An aGate with **Modbus TCP enabled** (ask your installer), reachable on TCP port 502. A fixed IP and a wired LAN are recommended.
64
+ - **Python 3.12 or later.**
65
+
66
+ ## Install
67
+
68
+ The library is not on PyPI yet. To work on it from source:
69
+
70
+ ```bash
71
+ git clone git@github.com:david2069/franklinwh-modbus-async.git
72
+ cd franklinwh-modbus-async
73
+ python3.12 -m venv .venv && source .venv/bin/activate
74
+ pip install -e ".[dev]"
75
+ pre-commit install
76
+ pytest
77
+ ```
78
+
79
+ ## Roadmap
80
+
81
+ | Phase | Scope | State |
82
+ |---|---|---|
83
+ | 0 | Scaffold, CI, PII guard, rationale docs | ✅ |
84
+ | 1 | Connection, SunSpec discovery, generated models, `FakeAGate` | — |
85
+ | 2 | Typed reads and snapshot | — |
86
+ | 3 | Control: M704 dispatch, watchdog, verify, native modes, safety | — |
87
+ | 4 | Virtual modes, TOU schedules, sequencer, discovery | — |
88
+ | 5 | CLI | — |
89
+ | 6 | Emulator and hardware verification | — |
90
+ | 7 | 1.0 release, migration guide | — |
91
+
92
+ ## Documentation
93
+
94
+ | Page | |
95
+ |---|---|
96
+ | [Why this library](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md) | Purpose, basis, why async, why modbus-connection, and how it compares with the alternatives |
97
+ | [modbus-connection findings](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/research/modbus-connection.md) | Notes from reviewing the transport library |
98
+ | [Plan](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) | Design decisions and phases |
99
+ | [Changelog](https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md) | |
100
+
101
+ ## Contributing
102
+
103
+ PRs are welcome. CI runs ruff, mypy `--strict`, pytest and a **PII guard** (`tools/pii_check.py`).
104
+
105
+ - **Placeholders only.** In code, docs, examples and captured output, use:
106
+ - `192.0.2.10` for hosts
107
+ - `FWH-SN-REDACTED` for serials
108
+ - `00:00:5E:00:53:00` for MAC addresses
109
+ - **Private terms.** Put anything you want blocked in a local `.pii-denylist`. git ignores this file.
110
+
111
+ ## License
112
+
113
+ MIT. See [LICENSE](https://github.com/david2069/franklinwh-modbus-async/blob/main/LICENSE).
@@ -0,0 +1,78 @@
1
+ # franklinwh-modbus-async
2
+
3
+ An **async** Python library for the FranklinWH **aGate**. It talks Modbus TCP and covers the SunSpec models and the vendor extension registers. It is built on [`modbus-connection`](https://github.com/home-assistant-libs/modbus-connection).
4
+
5
+ This is the asyncio successor to [`franklinwh-modbus`](https://github.com/david2069/franklinwh-modbus), which is synchronous. It aims to match that library's features and to fix its known connection and threading defects. To find out why it exists and how it compares, read **[docs/why.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md)**.
6
+
7
+ > **Status: pre-alpha (Phase 0 of 7).** The tooling and docs are in place, but there is no device API yet. See [PLAN.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) for the roadmap.
8
+
9
+ > ## ⚠️ Unofficial software
10
+ >
11
+ > **About this software**
12
+ >
13
+ > - Not affiliated with, endorsed or supported by FranklinWH. FranklinWH and aGate are trademarks of their owners.
14
+ > - Provided **as is**, without warranty, for educational and informational use.
15
+ >
16
+ > **Write commands carry real risk**
17
+ >
18
+ > - This library can send **write commands to grid-connected battery hardware**.
19
+ > - Used incorrectly, it can discharge your battery when you need it, import power you did not intend to, or leave the system in an unexpected state.
20
+ > - You use it at your own risk.
21
+ >
22
+ > **Getting help**
23
+ >
24
+ > - Do not contact FranklinWH support about this library. Report problems [here](https://github.com/david2069/franklinwh-modbus-async/issues).
25
+
26
+ ## Requirements
27
+
28
+ - An aGate with **Modbus TCP enabled** (ask your installer), reachable on TCP port 502. A fixed IP and a wired LAN are recommended.
29
+ - **Python 3.12 or later.**
30
+
31
+ ## Install
32
+
33
+ The library is not on PyPI yet. To work on it from source:
34
+
35
+ ```bash
36
+ git clone git@github.com:david2069/franklinwh-modbus-async.git
37
+ cd franklinwh-modbus-async
38
+ python3.12 -m venv .venv && source .venv/bin/activate
39
+ pip install -e ".[dev]"
40
+ pre-commit install
41
+ pytest
42
+ ```
43
+
44
+ ## Roadmap
45
+
46
+ | Phase | Scope | State |
47
+ |---|---|---|
48
+ | 0 | Scaffold, CI, PII guard, rationale docs | ✅ |
49
+ | 1 | Connection, SunSpec discovery, generated models, `FakeAGate` | — |
50
+ | 2 | Typed reads and snapshot | — |
51
+ | 3 | Control: M704 dispatch, watchdog, verify, native modes, safety | — |
52
+ | 4 | Virtual modes, TOU schedules, sequencer, discovery | — |
53
+ | 5 | CLI | — |
54
+ | 6 | Emulator and hardware verification | — |
55
+ | 7 | 1.0 release, migration guide | — |
56
+
57
+ ## Documentation
58
+
59
+ | Page | |
60
+ |---|---|
61
+ | [Why this library](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md) | Purpose, basis, why async, why modbus-connection, and how it compares with the alternatives |
62
+ | [modbus-connection findings](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/research/modbus-connection.md) | Notes from reviewing the transport library |
63
+ | [Plan](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) | Design decisions and phases |
64
+ | [Changelog](https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md) | |
65
+
66
+ ## Contributing
67
+
68
+ PRs are welcome. CI runs ruff, mypy `--strict`, pytest and a **PII guard** (`tools/pii_check.py`).
69
+
70
+ - **Placeholders only.** In code, docs, examples and captured output, use:
71
+ - `192.0.2.10` for hosts
72
+ - `FWH-SN-REDACTED` for serials
73
+ - `00:00:5E:00:53:00` for MAC addresses
74
+ - **Private terms.** Put anything you want blocked in a local `.pii-denylist`. git ignores this file.
75
+
76
+ ## License
77
+
78
+ MIT. See [LICENSE](https://github.com/david2069/franklinwh-modbus-async/blob/main/LICENSE).
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "franklinwh-modbus-async"
7
+ dynamic = ["version"]
8
+ description = "Async FranklinWH aGate Modbus TCP library (SunSpec + extension registers) built on modbus-connection"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "david2069", email = "david2069@users.noreply.github.com" }]
13
+ requires-python = ">=3.12"
14
+ dependencies = [
15
+ # Pinned tightly: modbus-connection releases roughly weekly and changes
16
+ # behaviour within minors. Bump deliberately, after the test suite passes.
17
+ "modbus-connection[tmodbus]~=4.12.4",
18
+ ]
19
+ keywords = ["franklinwh", "agate", "battery", "modbus", "sunspec", "asyncio", "energy"]
20
+ classifiers = [
21
+ "Development Status :: 2 - Pre-Alpha",
22
+ "Framework :: AsyncIO",
23
+ "Intended Audience :: Developers",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Home Automation",
28
+ "Topic :: System :: Hardware",
29
+ "Typing :: Typed",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ pymodbus = ["modbus-connection[pymodbus]~=4.12.4"]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.24",
37
+ "ruff>=0.6",
38
+ "mypy>=1.11",
39
+ "pre-commit>=3.7",
40
+ ]
41
+ docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5"]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/david2069/franklinwh-modbus-async"
45
+ "Bug Tracker" = "https://github.com/david2069/franklinwh-modbus-async/issues"
46
+ Changelog = "https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md"
47
+
48
+ [tool.hatch.version]
49
+ path = "src/franklinwh_modbus_async/__init__.py"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/franklinwh_modbus_async"]
53
+
54
+ [tool.pytest.ini_options]
55
+ asyncio_mode = "auto"
56
+ asyncio_default_fixture_loop_scope = "function"
57
+ testpaths = ["tests"]
58
+ addopts = "--strict-markers"
59
+ markers = [
60
+ "hardware: needs a real aGate (deselect with -m 'not hardware')",
61
+ "destructive: writes to a real aGate; also needs --destructive-enabled",
62
+ "emulator: needs the gateway emulator",
63
+ ]
64
+
65
+ [tool.ruff]
66
+ target-version = "py312"
67
+ line-length = 100
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "ASYNC", "RUF"]
71
+
72
+ [tool.mypy]
73
+ python_version = "3.12"
74
+ strict = true
75
+ files = ["src", "tools"]
76
+
77
+ [tool.hatch.build.targets.sdist]
78
+ # PyPI artifacts are public even while the repo is private: ship only what users need.
79
+ include = ["src/", "tests/", "tools/pii_check.py", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
@@ -0,0 +1,8 @@
1
+ """Async FranklinWH aGate Modbus TCP library, built on modbus-connection.
2
+
3
+ Unofficial; not affiliated with or endorsed by FranklinWH. See README.md.
4
+ """
5
+
6
+ __version__ = "0.0.1"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,13 @@
1
+ import re
2
+
3
+ import franklinwh_modbus_async
4
+
5
+
6
+ def test_version_is_pep440() -> None:
7
+ pattern = r"\d+\.\d+\.\d+(\.dev\d+|a\d+|b\d+|rc\d+)?"
8
+ assert re.fullmatch(pattern, franklinwh_modbus_async.__version__)
9
+
10
+
11
+ def test_transport_importable() -> None:
12
+ # The pinned transport and its default (tmodbus) backend must be installed.
13
+ from modbus_connection.tmodbus import ModbusConnection # noqa: F401
@@ -0,0 +1,67 @@
1
+ """Tests for tools/pii_check.py.
2
+
3
+ Bad samples are assembled at runtime so this file does not trip the guard itself.
4
+ """
5
+
6
+ import importlib.util
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ _spec = importlib.util.spec_from_file_location(
13
+ "pii_check", Path(__file__).parents[1] / "tools" / "pii_check.py"
14
+ )
15
+ assert _spec and _spec.loader
16
+ pii_check = importlib.util.module_from_spec(_spec)
17
+ sys.modules["pii_check"] = pii_check
18
+ _spec.loader.exec_module(pii_check)
19
+
20
+
21
+ def kinds(text: str, denylist: tuple[str, ...] = ()) -> list[str]:
22
+ return [f.kind for f in pii_check.scan_text("x", text, denylist)]
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ ("text", "kind"),
27
+ [
28
+ ("/Us" + "ers/alice/dev/repo", "home path"),
29
+ ("/ho" + "me/bob/src", "home path"),
30
+ ("C:" + "\\Users\\carol\\x", "home path"),
31
+ ("SN " + "12345678" + "A02" + "B12345678", "serial number"),
32
+ ("mac " + "AA:BB:CC" + ":12:34:56", "MAC address"),
33
+ ("host " + "192.168" + ".0.50", "IP address"),
34
+ ("mail " + "someone" + "@gmail.com", "email"),
35
+ ],
36
+ )
37
+ def test_flags_pii(text: str, kind: str) -> None:
38
+ assert kinds(text) == [kind]
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ "text",
43
+ [
44
+ "host 192.0.2.10 port 502",
45
+ "mac 00:00:5E:00:53:00",
46
+ "loopback 127.0.0.1 and 0.0.0.0",
47
+ "david2069@users.noreply.github.com",
48
+ "serial FWH-SN-REDACTED",
49
+ "modbus-connection~=4.12.4 and version 0.0.1",
50
+ "the `/Users/` and `/home/` prefixes are blocked",
51
+ "x " + "192.168" + ".0.50 # pii: allow",
52
+ ],
53
+ )
54
+ def test_allows_placeholders(text: str) -> None:
55
+ assert kinds(text) == []
56
+
57
+
58
+ def test_denylist_is_case_insensitive_and_redacted() -> None:
59
+ findings = list(pii_check.scan_text("x", "Written by JANE Example", ["jane example"]))
60
+ assert [f.kind for f in findings] == ["denylisted term"]
61
+ assert findings[0].text == "<redacted>"
62
+
63
+
64
+ def test_denylist_from_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
65
+ (tmp_path / ".pii-denylist").write_text("# comment\nalpha\n\n", encoding="utf-8")
66
+ monkeypatch.setenv("PII_DENYLIST", "beta, gamma")
67
+ assert pii_check.load_denylist(tmp_path) == ["alpha", "beta", "gamma"]
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Fail if tracked files contain personal or site-identifying data.
3
+
4
+ Checks every file git would commit (tracked plus untracked-but-not-ignored) for:
5
+
6
+ - home-directory paths (macOS, Linux, Windows)
7
+ - FranklinWH aGate serial numbers (20 chars: 8 digits, letter, 2 digits, letter, 8 digits)
8
+ - MAC addresses other than the RFC 7042 documentation range
9
+ - IPv4 addresses other than loopback, unspecified and RFC 5737 documentation ranges
10
+ - email addresses other than noreply and example domains
11
+ - any term in the local, gitignored ``.pii-denylist`` (one per line, case-insensitive),
12
+ plus terms in the ``PII_DENYLIST`` environment variable (comma-separated, for CI secrets)
13
+
14
+ The denylist lives outside git so the things it blocks (for example a real name) are never
15
+ committed. A line can opt out with the marker ``pii: allow``; use it sparingly and only for
16
+ placeholders the patterns misjudge.
17
+
18
+ Exit status: 0 clean, 1 findings.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ipaddress
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from collections.abc import Iterable, Iterator
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ ALLOW_MARKER = "pii: allow"
33
+ DENYLIST_FILE = ".pii-denylist"
34
+
35
+ HOME_PATH = re.compile(r"(?:/Users/|/home/|[A-Za-z]:\\Users\\)[A-Za-z0-9._-]+")
36
+ SERIAL = re.compile(r"\b\d{8}[A-Z]\d{2}[A-Z]\d{8}\b")
37
+ MAC = re.compile(r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b")
38
+ IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
39
+ EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
40
+
41
+ DOC_NETWORKS = tuple(
42
+ ipaddress.ip_network(n)
43
+ for n in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "127.0.0.0/8", "0.0.0.0/32")
44
+ )
45
+ DOC_MAC_PREFIX = "00:00:5e:00:53"
46
+ ALLOWED_EMAIL_SUFFIXES = (
47
+ "@users.noreply.github.com",
48
+ "@noreply.anthropic.com",
49
+ "@example.com",
50
+ "@example.org",
51
+ "@example.net",
52
+ "@github.com",
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Finding:
58
+ path: str
59
+ line: int
60
+ kind: str
61
+ text: str
62
+
63
+ def __str__(self) -> str:
64
+ return f"{self.path}:{self.line}: {self.kind}: {self.text}"
65
+
66
+
67
+ def _ip_allowed(text: str) -> bool:
68
+ try:
69
+ ip = ipaddress.ip_address(text)
70
+ except ValueError:
71
+ return True # not an address (e.g. 999.1.1.1): not our concern
72
+ return any(ip in net for net in DOC_NETWORKS)
73
+
74
+
75
+ def scan_text(path: str, text: str, denylist: Iterable[str] = ()) -> Iterator[Finding]:
76
+ terms = [t.lower() for t in denylist if t]
77
+ for lineno, line in enumerate(text.splitlines(), start=1):
78
+ if ALLOW_MARKER in line:
79
+ continue
80
+ for m in HOME_PATH.finditer(line):
81
+ yield Finding(path, lineno, "home path", m.group())
82
+ for m in SERIAL.finditer(line):
83
+ yield Finding(path, lineno, "serial number", m.group())
84
+ for m in MAC.finditer(line):
85
+ if not m.group().lower().replace("-", ":").startswith(DOC_MAC_PREFIX):
86
+ yield Finding(path, lineno, "MAC address", m.group())
87
+ for m in IPV4.finditer(line):
88
+ if not _ip_allowed(m.group()):
89
+ yield Finding(path, lineno, "IP address", m.group())
90
+ for m in EMAIL.finditer(line):
91
+ if not m.group().lower().endswith(ALLOWED_EMAIL_SUFFIXES):
92
+ yield Finding(path, lineno, "email", m.group())
93
+ lowered = line.lower()
94
+ for term in terms:
95
+ if term in lowered:
96
+ yield Finding(path, lineno, "denylisted term", "<redacted>")
97
+
98
+
99
+ def load_denylist(root: Path) -> list[str]:
100
+ terms: list[str] = []
101
+ f = root / DENYLIST_FILE
102
+ if f.is_file():
103
+ for raw in f.read_text(encoding="utf-8").splitlines():
104
+ term = raw.strip()
105
+ if term and not term.startswith("#"):
106
+ terms.append(term)
107
+ terms += [t.strip() for t in os.environ.get("PII_DENYLIST", "").split(",") if t.strip()]
108
+ return terms
109
+
110
+
111
+ def candidate_files(root: Path) -> list[Path]:
112
+ out = subprocess.run(
113
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
114
+ cwd=root,
115
+ check=True,
116
+ capture_output=True,
117
+ ).stdout
118
+ return [root / p for p in out.decode().split("\0") if p]
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ root = Path(argv[0]) if argv else Path.cwd()
123
+ denylist = load_denylist(root)
124
+ findings: list[Finding] = []
125
+ for file in candidate_files(root):
126
+ if not file.is_file():
127
+ continue # deleted in the working tree
128
+ try:
129
+ text = file.read_text(encoding="utf-8")
130
+ except UnicodeDecodeError:
131
+ continue # binary; images need a manual check
132
+ findings.extend(scan_text(str(file.relative_to(root)), text, denylist))
133
+ for f in findings:
134
+ print(f)
135
+ if findings:
136
+ print(
137
+ f"\npii_check: {len(findings)} finding(s). Replace with placeholders "
138
+ "(192.0.2.10, FWH-SN-REDACTED, 00:00:5E:00:53:00).",
139
+ file=sys.stderr,
140
+ )
141
+ return 1
142
+ print(f"pii_check: clean ({'with' if denylist else 'no'} denylist)")
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main(sys.argv[1:]))