haconf 0.1.0__py3-none-any.whl

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.
haconf/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """haconf — check Home Assistant YAML and Jinja before reload; no instance needed.
2
+
3
+ Exit-code contract (deviation D-HC-1, logged in the I1 commit and the README):
4
+
5
+ 0 clean
6
+ 1 findings
7
+ 64 usage errors — a deliberate extension of the contract's 0/1, same
8
+ rationale as RestVerify's D-I1: a usage failure is not a lint outcome
9
+ and must not collide with 1.
10
+ """
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ EXIT_CLEAN = 0
15
+ EXIT_FINDINGS = 1
16
+ EXIT_USAGE = 64
haconf/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """``python -m haconf`` entry point."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1,165 @@
1
+ {
2
+ "integrations": {
3
+ "automation": {
4
+ "shape": "list_of_maps",
5
+ "keys": ["alias", "id", "description", "mode", "max", "max_exceeded", "initial_state", "trace", "variables", "trigger", "triggers", "condition", "conditions", "action", "actions", "timestamp", "last_triggered", "use_blueprint"]
6
+ },
7
+ "script": {
8
+ "shape": "entity_map",
9
+ "keys": ["alias", "id", "description", "icon", "mode", "max", "max_exceeded", "fields", "variables", "sequence", "trace", "continue_on_error", "use_blueprint"]
10
+ },
11
+ "scene": {
12
+ "shape": "list_of_maps",
13
+ "keys": ["id", "name", "icon", "entities", "description"]
14
+ },
15
+ "command_line": {
16
+ "shape": "list_of_maps",
17
+ "keys": ["switch", "sensor", "binary_sensor", "cover"]
18
+ },
19
+ "rest": {
20
+ "shape": "list_of_maps",
21
+ "keys": ["resource", "method", "headers", "params", "payload", "verify_ssl", "timeout", "scan_interval", "authentication", "username", "password", "sensor", "binary_sensor"]
22
+ },
23
+ "zone": {
24
+ "shape": "list_of_maps",
25
+ "keys": ["name", "latitude", "longitude", "radius", "icon", "passive", "track_ios"]
26
+ },
27
+ "person": {
28
+ "shape": "list_of_maps",
29
+ "keys": ["name", "id", "user_id", "device_trackers", "picture"]
30
+ },
31
+ "sensor": {
32
+ "shape": "platform_list",
33
+ "keys": ["platform", "name", "unique_id", "state", "unit_of_measurement", "device_class", "state_class", "icon", "availability", "scan_interval", "value_template", "json_attributes", "json_attributes_topic", "json_attributes_path", "last_reset"]
34
+ },
35
+ "binary_sensor": {
36
+ "shape": "platform_list",
37
+ "keys": ["platform", "name", "unique_id", "state", "device_class", "icon", "delay_on", "delay_off", "availability", "value_template", "scan_interval", "json_attributes"]
38
+ },
39
+ "switch": {
40
+ "shape": "platform_list",
41
+ "keys": ["platform", "name", "unique_id", "icon", "device_class", "optimistic", "state", "command_on", "command_off", "state_topic", "command_topic", "value_template", "availability", "scan_interval"]
42
+ },
43
+ "light": {
44
+ "shape": "platform_list",
45
+ "keys": ["platform", "name", "unique_id", "icon", "optimistic", "state", "command_on", "command_off", "state_topic", "command_topic", "brightness", "color_temp", "value_template", "availability", "scan_interval"]
46
+ },
47
+ "cover": {
48
+ "shape": "platform_list",
49
+ "keys": ["platform", "name", "unique_id", "icon", "device_class", "optimistic", "state", "command_open", "command_close", "command_stop", "position_template", "state_template", "value_template", "availability", "scan_interval"]
50
+ },
51
+ "lock": {
52
+ "shape": "platform_list",
53
+ "keys": ["platform", "name", "unique_id", "icon", "optimistic", "state", "command", "value_template", "availability", "scan_interval"]
54
+ },
55
+ "fan": {
56
+ "shape": "platform_list",
57
+ "keys": ["platform", "name", "unique_id", "icon", "optimistic", "state", "command_on", "command_off", "state_topic", "command_topic", "percentage", "value_template", "availability", "scan_interval"]
58
+ },
59
+ "number": {
60
+ "shape": "platform_list",
61
+ "keys": ["platform", "name", "unique_id", "icon", "min", "max", "step", "mode", "unit_of_measurement", "value_template", "command_template", "set_value", "optimistic", "availability", "scan_interval"]
62
+ },
63
+ "select": {
64
+ "shape": "platform_list",
65
+ "keys": ["platform", "name", "unique_id", "icon", "options", "value_template", "command_template", "optimistic", "availability", "scan_interval"]
66
+ },
67
+ "button": {
68
+ "shape": "platform_list",
69
+ "keys": ["platform", "name", "unique_id", "icon", "command", "availability", "scan_interval"]
70
+ },
71
+ "update": {
72
+ "shape": "platform_list",
73
+ "keys": ["platform", "name", "unique_id", "icon", "state", "latest_version", "installed_version", "device_class", "value_template", "availability", "scan_interval"]
74
+ },
75
+ "camera": {
76
+ "shape": "platform_list",
77
+ "keys": ["platform", "name", "unique_id", "icon", "still_image_url", "stream_source", "verify_ssl", "availability", "scan_interval"]
78
+ },
79
+ "input_boolean": {
80
+ "shape": "entity_map",
81
+ "keys": ["name", "icon", "initial", "unique_id"]
82
+ },
83
+ "input_number": {
84
+ "shape": "entity_map",
85
+ "keys": ["name", "icon", "initial", "min", "max", "step", "mode", "unit_of_measurement", "unique_id"]
86
+ },
87
+ "input_select": {
88
+ "shape": "entity_map",
89
+ "keys": ["name", "icon", "options", "initial", "unique_id"]
90
+ },
91
+ "input_text": {
92
+ "shape": "entity_map",
93
+ "keys": ["name", "icon", "initial", "min", "max", "mode", "pattern", "unique_id"]
94
+ },
95
+ "input_datetime": {
96
+ "shape": "entity_map",
97
+ "keys": ["name", "icon", "has_date", "has_time", "initial", "unique_id"]
98
+ },
99
+ "input_button": {
100
+ "shape": "entity_map",
101
+ "keys": ["name", "icon", "unique_id"]
102
+ },
103
+ "counter": {
104
+ "shape": "entity_map",
105
+ "keys": ["name", "icon", "initial", "step", "minimum", "maximum", "restore", "unique_id"]
106
+ },
107
+ "timer": {
108
+ "shape": "entity_map",
109
+ "keys": ["name", "icon", "duration", "restore", "unique_id"]
110
+ },
111
+ "utility_meter": {
112
+ "shape": "entity_map",
113
+ "keys": ["name", "icon", "source", "cycle", "offset", "delta_values", "net_consumption", "tariffs", "periodically_reset_factor", "always_available", "unique_id"]
114
+ },
115
+ "group": {
116
+ "shape": "entity_map",
117
+ "keys": ["name", "icon", "entities", "all", "unique_id"]
118
+ },
119
+ "homeassistant": {
120
+ "shape": "options_map",
121
+ "keys": ["name", "latitude", "longitude", "elevation", "unit_system", "time_zone", "currency", "country", "language", "packages", "customize", "customize_domain", "customize_glob", "allowlist_external_dirs", "allowlist_external_urls", "media_dirs", "legacy_templates", "internal_url", "external_url"]
122
+ },
123
+ "recorder": {
124
+ "shape": "options_map",
125
+ "keys": ["db_url", "purge_keep_days", "auto_purge", "auto_repack", "commit_interval", "include", "exclude", "include_entities", "exclude_entities", "include_domains", "exclude_domains", "include_entity_globs", "exclude_entity_globs", "db_max_retries", "db_retry_wait"]
126
+ },
127
+ "logger": {
128
+ "shape": "options_map",
129
+ "keys": ["default", "logs", "filters"]
130
+ },
131
+ "http": {
132
+ "shape": "options_map",
133
+ "keys": ["server_host", "server_port", "ssl_certificate", "ssl_key", "cors_allowed_origins", "use_x_forwarded_for", "trusted_proxies", "ip_ban_enabled", "login_attempts_threshold"]
134
+ },
135
+ "systemmonitor": {
136
+ "shape": "options_map",
137
+ "keys": ["resources"]
138
+ },
139
+ "template": {"keys": null},
140
+ "mqtt": {"keys": null},
141
+ "shell_command": {"keys": null},
142
+ "climate": {"keys": null},
143
+ "media_player": {"keys": null},
144
+ "vacuum": {"keys": null},
145
+ "tts": {"keys": null},
146
+ "notify": {"keys": null},
147
+ "sun": {"keys": null},
148
+ "met": {"keys": null},
149
+ "google_translate": {"keys": null},
150
+ "radio_browser": {"keys": null},
151
+ "default_config": {"keys": null},
152
+ "frontend": {"keys": null},
153
+ "mobile_app": {"keys": null},
154
+ "esphome": {"keys": null},
155
+ "zha": {"keys": null},
156
+ "shelly": {"keys": null},
157
+ "hue": {"keys": null},
158
+ "homekit": {"keys": null},
159
+ "samsungtv": {"keys": null},
160
+ "sonos": {"keys": null},
161
+ "matter": {"keys": null},
162
+ "bluetooth": {"keys": null},
163
+ "cast": {"keys": null}
164
+ }
165
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "haconf default catalog",
3
+ "version": "v0.1",
4
+ "ha_min_version": "2026.9",
5
+ "generated_from": "Home Assistant Analytics 2026-09-25 (688,333 active installations; top-60 by usage) plus the Home Assistant documentation's integration pages; hand-curated for the v0.1 starter set. Sources: docs/FINDINGS.md (row 18) and docs/PLAN.md D2.",
6
+ "coverage": "59 integrations ship in v0.1; 34 of them carry curated key lists, the rest are name-checked only (keys: null) until curation lands in later catalog versions.",
7
+ "false_positive_mode": {
8
+ "unknown_key": "key lists are a curated subset of each integration's documented options; an unknown key is an error per R32, so a false positive means the catalog is missing a key - treat it as a catalog gap and report it.",
9
+ "unknown_integration": "informational only (N13): an integration we do not know is never an error.",
10
+ "integration_case": "the canonical lowercase form is named in the finding."
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "rules": {
3
+ "unknown_key": {
4
+ "tier": "error",
5
+ "message": "unknown key {key!r} under {integration!r}",
6
+ "hint": "check the integration's documented options; if this key is valid, the catalog is missing it"
7
+ },
8
+ "unknown_integration": {
9
+ "tier": "info",
10
+ "message": "{key!r} is not in catalog {version} - informational only (unknown integrations are never errors)",
11
+ "hint": "informational only - unknown integrations are never errors"
12
+ },
13
+ "integration_case": {
14
+ "tier": "error",
15
+ "message": "integration names are lowercase: {name!r} should be {canonical!r}",
16
+ "hint": "write integration names in lowercase"
17
+ }
18
+ }
19
+ }
haconf/catalog.py ADDED
@@ -0,0 +1,76 @@
1
+ """R32/R33/R42 — the versioned, bundled, network-free integration catalog.
2
+
3
+ Layout (per docs/PLAN.md §2.0.5): ``catalog/<version>/`` holds ``meta.json``,
4
+ ``integrations.json`` and ``rules.json``. The catalog ships as package data and
5
+ is read straight from the package directory — there is no fetch path and no
6
+ network import anywhere in this module (R42). ``--catalog`` selects a version;
7
+ an unknown version is a usage error that lists the shipped ones.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .errors import UsageError
16
+
17
+ _CATALOG_DIR = Path(__file__).with_name("catalog")
18
+ DEFAULT_VERSION = "v0.1"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Catalog:
23
+ version: str
24
+ meta: dict
25
+ rules: dict
26
+ integrations: dict
27
+
28
+ def entry(self, name: str) -> dict | None:
29
+ return self.integrations.get(name)
30
+
31
+ def canonical(self, name: str) -> str | None:
32
+ """The name itself when it is a known integration, else None."""
33
+ return name if name in self.integrations else None
34
+
35
+ def case_fix(self, name: str) -> str | None:
36
+ """The canonical spelling when the name differs only by case."""
37
+ low = name.lower()
38
+ for candidate in self.integrations:
39
+ if candidate.lower() == low and candidate != name:
40
+ return candidate
41
+ return None
42
+
43
+ def keys_for(self, name: str) -> frozenset | None:
44
+ entry = self.integrations.get(name) or {}
45
+ keys = entry.get("keys")
46
+ return frozenset(keys) if keys else None
47
+
48
+ def shape_for(self, name: str) -> str | None:
49
+ return (self.integrations.get(name) or {}).get("shape")
50
+
51
+ def rule(self, rule_id: str) -> dict:
52
+ return self.rules.get(rule_id) or {}
53
+
54
+
55
+ def available_versions() -> list[str]:
56
+ if not _CATALOG_DIR.is_dir():
57
+ return []
58
+ return sorted(
59
+ path.name for path in _CATALOG_DIR.iterdir()
60
+ if path.is_dir() and (path / "integrations.json").is_file()
61
+ )
62
+
63
+
64
+ def load_catalog(version: str | None = None) -> Catalog:
65
+ chosen = version or DEFAULT_VERSION
66
+ root = _CATALOG_DIR / chosen
67
+ if not root.is_dir() or not (root / "integrations.json").is_file():
68
+ shipped = available_versions()
69
+ raise UsageError(
70
+ f"unknown catalog version: {chosen!r}",
71
+ hint="shipped catalogs: " + (", ".join(shipped) if shipped else "(none)"),
72
+ )
73
+ meta = json.loads((root / "meta.json").read_text(encoding="utf-8"))
74
+ rules = json.loads((root / "rules.json").read_text(encoding="utf-8")).get("rules", {})
75
+ integrations = json.loads((root / "integrations.json").read_text(encoding="utf-8"))
76
+ return Catalog(chosen, meta, rules, dict(integrations.get("integrations") or {}))
haconf/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ """Command-line surface for haconf.
2
+
3
+ Checks built so far (I1–I5): walker, YAML loader, catalog rules, Jinja
4
+ templates, includes/secrets/packages, automation/script structure, entity
5
+ integrity — with severity tiers (error / warning / info), plain-language hints,
6
+ visible suppression (``.haconfignore`` + inline) and ``--json``.
7
+
8
+ Exit codes: 0 clean (or info/warning only; ``--strict`` escalates warnings to a
9
+ failure), 1 findings with errors, 64 usage (deviation D-HC-1, same rationale as
10
+ RestVerify's D-I1).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import EXIT_CLEAN, EXIT_FINDINGS, EXIT_USAGE, __version__
20
+ from . import entities as entitiesmod
21
+ from . import includes as includesmod
22
+ from . import loader as loadermod
23
+ from . import rules as rulesmod
24
+ from . import structure as structuremod
25
+ from . import suppress as suppressmod
26
+ from . import templates as templatesmod
27
+ from . import walker as walkermod
28
+ from .catalog import available_versions, load_catalog
29
+ from .errors import HacheckError, UsageError
30
+
31
+ _EPILOG = """\
32
+ examples:
33
+ haconf ~/.homeassistant/
34
+ haconf /config --json
35
+ haconf ~/.homeassistant/ --strict
36
+ haconf ~/.homeassistant/ --suppress-help
37
+
38
+ what haconf checks (I1-I8): the directory walk, YAML parsing (duplicate keys,
39
+ multi-document files, quoting pitfalls, tabs/indentation), catalog rules
40
+ (unknown key, integration-name case, unknown integration as info only, and
41
+ every homeassistant: packages: body), Jinja templates including
42
+ custom_templates/*.jinja (the real error text, e.g. "No filter named
43
+ 'strftime'"), !include/!secret integrity, automation/script/script-map
44
+ structure at every nesting depth, and entity integrity - with severity tiers,
45
+ suppression and --json.
46
+
47
+ exit codes: 0 clean (or info/warning only; --strict escalates warnings) ·
48
+ 1 findings with errors · 64 usage (extension, deviation D-HC-1)
49
+
50
+ release work (I5c/I6/I7): packaging, security pass, real-config validation -
51
+ tracked in docs/PLAN.md.
52
+ """
53
+
54
+
55
+ class _Parser(argparse.ArgumentParser):
56
+ """argparse with usage errors raised as UsageError (mapped to exit 64, D-HC-1)."""
57
+
58
+ def error(self, message):
59
+ raise UsageError(message)
60
+
61
+
62
+ def build_parser() -> _Parser:
63
+ parser = _Parser(
64
+ prog="haconf",
65
+ description="Check Home Assistant YAML and Jinja before you reload — "
66
+ "no instance needed.",
67
+ epilog=_EPILOG,
68
+ formatter_class=argparse.RawDescriptionHelpFormatter,
69
+ )
70
+ parser.add_argument(
71
+ "config_dir",
72
+ nargs="?",
73
+ metavar="CONFIG-DIR",
74
+ help="path to the Home Assistant configuration directory "
75
+ "(any directory is accepted; the install shape is not detected)",
76
+ )
77
+ shipped = ", ".join(available_versions()) or "(none)"
78
+ parser.add_argument(
79
+ "--catalog",
80
+ metavar="VERSION",
81
+ default=None,
82
+ help=f"catalog version to use (shipped: {shipped})",
83
+ )
84
+ parser.add_argument(
85
+ "--json",
86
+ action="store_true",
87
+ help="machine-readable findings on stdout (one JSON object)",
88
+ )
89
+ parser.add_argument(
90
+ "--strict",
91
+ action="store_true",
92
+ help="treat warnings as failures (errors already fail)",
93
+ )
94
+ parser.add_argument(
95
+ "--suppress-help",
96
+ action="store_true",
97
+ help="show the two suppression forms and exit",
98
+ )
99
+ parser.add_argument("--version", action="version", version=f"haconf {__version__}")
100
+ return parser
101
+
102
+
103
+ def _json_finding(finding) -> dict:
104
+ return {
105
+ "path": finding.path,
106
+ "line": finding.line,
107
+ "code": finding.code,
108
+ "severity": finding.severity,
109
+ "message": finding.message,
110
+ "hint": finding.resolved_hint,
111
+ }
112
+
113
+
114
+ def main(argv=None) -> int:
115
+ parser = build_parser()
116
+ try:
117
+ args = parser.parse_args(argv)
118
+ except UsageError as exc:
119
+ parser.print_usage(sys.stderr)
120
+ print(f"{parser.prog}: error: {exc.what}", file=sys.stderr)
121
+ return EXIT_USAGE
122
+ if args.suppress_help:
123
+ print(suppressmod.help_text())
124
+ return EXIT_CLEAN
125
+ if not args.config_dir:
126
+ parser.print_usage(sys.stderr)
127
+ print(f"{parser.prog}: error: the config directory is required "
128
+ "(e.g. `haconf ~/.homeassistant/`)", file=sys.stderr)
129
+ return EXIT_USAGE
130
+
131
+ try:
132
+ catalog = load_catalog(args.catalog)
133
+ except HacheckError as exc:
134
+ print(exc.render(), file=sys.stderr)
135
+ return exc.exit_code
136
+
137
+ root = Path(args.config_dir)
138
+ secrets_present, secrets_keys = includesmod.load_secrets(root)
139
+ registry_ids = entitiesmod.load_registry(root)
140
+
141
+ try:
142
+ files, findings = walkermod.walk(args.config_dir)
143
+ except HacheckError as exc:
144
+ print(exc.render(), file=sys.stderr)
145
+ return exc.exit_code
146
+
147
+ custom_templates = templatesmod.custom_template_files(root)
148
+ if not files and not custom_templates:
149
+ print(f"no YAML files found under {args.config_dir}")
150
+ return EXIT_CLEAN
151
+
152
+ declarations: list = []
153
+ references: list = []
154
+ for path in files:
155
+ findings.extend(loadermod.load_file(path, root))
156
+ findings.extend(rulesmod.check_file(path, root, catalog))
157
+ findings.extend(templatesmod.check_file(path, root))
158
+ findings.extend(includesmod.check_file(path, root, secrets_present, secrets_keys))
159
+ findings.extend(structuremod.check_file(path, root))
160
+ decls, refs = entitiesmod.collect_file(path, root)
161
+ declarations.extend(decls)
162
+ references.extend(refs)
163
+
164
+ findings.extend(templatesmod.check_custom_templates(root))
165
+
166
+ declared_ids = frozenset(f"{d.domain}.{d.object_id}" for d in declarations)
167
+ findings.extend(entitiesmod.check_duplicates(declarations))
168
+ findings.extend(entitiesmod.check_references(references, declared_ids, registry_ids))
169
+ findings.sort(key=lambda f: (f.path, f.line, f.code))
170
+
171
+ findings, suppressed = suppressmod.split(findings, root)
172
+
173
+ errors = [f for f in findings if f.severity == "error"]
174
+ warnings = [f for f in findings if f.severity == "warning"]
175
+ infos = [f for f in findings if f.severity == "info"]
176
+ failing = bool(errors) or (args.strict and bool(warnings))
177
+ exit_code = EXIT_FINDINGS if failing else EXIT_CLEAN
178
+
179
+ if args.json:
180
+ payload = {
181
+ "tool": "haconf",
182
+ "version": __version__,
183
+ "catalog": catalog.version,
184
+ "status": "findings" if findings else "clean",
185
+ "exit_code": exit_code,
186
+ "summary": {
187
+ "errors": len(errors),
188
+ "warnings": len(warnings),
189
+ "infos": len(infos),
190
+ "suppressed": len(suppressed),
191
+ },
192
+ "findings": [_json_finding(f) for f in findings],
193
+ "suppressed": [
194
+ {**_json_finding(s.finding), "reason": s.reason, "source": s.source}
195
+ for s in suppressed
196
+ ],
197
+ }
198
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
199
+ return exit_code
200
+
201
+ for finding in findings:
202
+ print(finding.render())
203
+
204
+ if findings or suppressed:
205
+ summary = (f"summary: {len(errors)} error(s), {len(warnings)} warning(s), "
206
+ f"{len(infos)} info(s)")
207
+ if suppressed:
208
+ summary += (f"; {len(suppressed)} suppressed "
209
+ "(--suppress-help for the two forms)")
210
+ print(summary)
211
+
212
+ return exit_code