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 +16 -0
- haconf/__main__.py +9 -0
- haconf/catalog/v0.1/integrations.json +165 -0
- haconf/catalog/v0.1/meta.json +12 -0
- haconf/catalog/v0.1/rules.json +19 -0
- haconf/catalog.py +76 -0
- haconf/cli.py +212 -0
- haconf/entities.py +235 -0
- haconf/errors.py +105 -0
- haconf/includes.py +186 -0
- haconf/loader.py +183 -0
- haconf/rules.py +210 -0
- haconf/structure.py +229 -0
- haconf/suppress.py +101 -0
- haconf/templates.py +388 -0
- haconf/walker.py +109 -0
- haconf-0.1.0.dist-info/METADATA +105 -0
- haconf-0.1.0.dist-info/RECORD +21 -0
- haconf-0.1.0.dist-info/WHEEL +4 -0
- haconf-0.1.0.dist-info/entry_points.txt +2 -0
- haconf-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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
|