Skip to content

Commit be8a13e

Browse files
lackasCFenner
andauthored
chore: add deprecation database to prevent use of deprecated API paths (#707)
* Add deprecation database and checker tool Maintain a persistent database (tests/deprecated_features.json) of known deprecated Viessmann API features, merged from test response data and fresh device dumps. This prevents us from accidentally adding new code that uses deprecated API paths. - check_deprecations.py: reports deprecated features used in code, keeps the database up to date, and ingests fresh device dumps - test_deprecatedProperties now also checks the database, catching deprecations not marked in test data (e.g. heating.cop.green) - Each entry records firstSeenIn/firstSeenOn for provenance tracking * Add CI validation for deprecation database Add a job to the format workflow that runs check_deprecations.py --update and verifies the database stays in sync when test data changes. * Update step name in format workflow * Simplify git diff command in workflow * Only save deprecation database when features actually change --------- Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
1 parent 0dc8252 commit be8a13e

4 files changed

Lines changed: 1380 additions & 7 deletions

File tree

.github/workflows/format.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ jobs:
2828
-exec sh -c 'mv $1 $1.tmp; jq ".data|=sort_by(.feature)" --sort-keys $1.tmp > $1; rm $1.tmp' shell {} ";"
2929
- name: 🔍 Verify
3030
run: git diff --name-only --exit-code
31+
32+
deprecations:
33+
name: validate deprecation database
34+
runs-on: ubuntu-latest
35+
steps:
36+
- name: ⤵️ Check out code from GitHub
37+
uses: actions/checkout@v6.0.2
38+
- name: Set up Python
39+
uses: actions/setup-python@v6.2.0
40+
with:
41+
python-version: ${{ env.DEFAULT_PYTHON }}
42+
- name: Update deprecation database
43+
run: python check_deprecations.py --update
44+
- name: Verify no changes
45+
run: git diff --exit-code
46+
if: always()

check_deprecations.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
#!/usr/bin/env python3
2+
"""Check for deprecated Viessmann API features and maintain the deprecation database.
3+
4+
Usage:
5+
check_deprecations.py Report using the database
6+
check_deprecations.py --update Rescan test data, update database
7+
check_deprecations.py --update f.json Also ingest a fresh device dump
8+
9+
The database (tests/deprecated_features.json) accumulates deprecation info from
10+
test response files and fresh device dumps. Features deprecated in one API response
11+
are deprecated everywhere, so we merge and propagate across all sources.
12+
13+
Exit code 1 if deprecated features are used in code (outside the ignore list).
14+
"""
15+
16+
import argparse
17+
import glob
18+
import json
19+
import re
20+
import sys
21+
from collections import OrderedDict
22+
from datetime import date, datetime
23+
from pathlib import Path
24+
25+
ROOT = Path(__file__).parent
26+
DB_PATH = ROOT / "tests" / "deprecated_features.json"
27+
RESPONSE_DIR = ROOT / "tests" / "response"
28+
PYVICARE_DIR = ROOT / "PyViCare"
29+
30+
31+
def load_database():
32+
"""Load the deprecation database, or return empty if it doesn't exist."""
33+
if DB_PATH.exists():
34+
with open(DB_PATH) as f:
35+
data = json.load(f)
36+
return data.get("features", {})
37+
return {}
38+
39+
40+
def save_database(features):
41+
"""Save the deprecation database."""
42+
output = {
43+
"_meta": {
44+
"description": "Known deprecated Viessmann API features, merged from test data and device dumps.",
45+
"updated": str(date.today()),
46+
"feature_count": len(features),
47+
},
48+
"features": OrderedDict(sorted(features.items())),
49+
}
50+
with open(DB_PATH, "w") as f:
51+
json.dump(output, f, indent=2)
52+
f.write("\n")
53+
54+
55+
def scan_json_file(filepath, source_label):
56+
"""Extract deprecated features from a JSON file. Returns dict of feature -> info."""
57+
found = {}
58+
with open(filepath) as f:
59+
data = json.load(f)
60+
61+
items = data.get("data", data) if isinstance(data, dict) else data
62+
if not isinstance(items, list):
63+
return found
64+
65+
for item in items:
66+
dep = item.get("deprecated")
67+
if dep:
68+
feat = item["feature"]
69+
found[feat] = {
70+
"removalDate": dep.get("removalDate", ""),
71+
"info": dep.get("info", ""),
72+
"source": source_label,
73+
}
74+
return found
75+
76+
77+
def update_database(extra_files=None):
78+
"""Rescan test data and optional extra files, merge into database. Returns new features."""
79+
db = load_database()
80+
db_snapshot = json.dumps(db, sort_keys=True)
81+
new_features = {}
82+
today = str(date.today())
83+
84+
def add_feature(feat, info, source_label):
85+
if feat not in db:
86+
db[feat] = {
87+
"removalDate": info["removalDate"],
88+
"info": info["info"],
89+
"firstSeenIn": source_label,
90+
"firstSeenOn": today,
91+
"sources": [],
92+
}
93+
new_features[feat] = {**info, "source": source_label}
94+
if source_label not in db[feat]["sources"]:
95+
db[feat]["sources"].append(source_label)
96+
97+
# Scan test response files
98+
for filepath in sorted(glob.glob(f"{RESPONSE_DIR}/*.json")):
99+
filename = Path(filepath).name
100+
for feat, info in scan_json_file(filepath, filename).items():
101+
add_feature(feat, info, filename)
102+
103+
# Scan extra dump files
104+
for filepath in extra_files or []:
105+
path = Path(filepath)
106+
if not path.exists():
107+
print(f"Warning: {filepath} not found, skipping", file=sys.stderr)
108+
continue
109+
label = f"dump:{path.name}"
110+
for feat, info in scan_json_file(filepath, label).items():
111+
add_feature(feat, info, label)
112+
113+
if json.dumps(db, sort_keys=True) != db_snapshot:
114+
save_database(db)
115+
return new_features
116+
117+
118+
def find_code_usage():
119+
"""Find all feature paths referenced via getProperty() in PyViCare code."""
120+
usage = {} # feature pattern -> [files]
121+
sources = {} # filename -> full source content
122+
123+
for filepath in sorted(glob.glob(f"{PYVICARE_DIR}/**/*.py", recursive=True)):
124+
filename = Path(filepath).name
125+
with open(filepath) as f:
126+
content = f.read()
127+
sources[filename] = content
128+
129+
for match in re.findall(r'getProperty\(\s*f?"([^"]+)"\s*\)', content):
130+
normalized = re.sub(r"\{[^}]+\}", "*", match)
131+
if normalized not in usage:
132+
usage[normalized] = []
133+
usage[normalized].append(filename)
134+
135+
return usage, sources
136+
137+
138+
def feature_matches_code(feature, code_usage, sources):
139+
"""Check if a deprecated feature is used in code.
140+
141+
For wildcard matches (e.g., heating.circuits.*.operating.programs.*),
142+
verifies that the specific segment value appears as a string literal
143+
in the source file to avoid false positives from dynamic iteration.
144+
"""
145+
matching_files = []
146+
for pattern, files in code_usage.items():
147+
if "*" in pattern:
148+
regex = re.escape(pattern).replace(r"\*", r"([^.]+)")
149+
m = re.fullmatch(regex, feature)
150+
if m:
151+
segments = m.groups()
152+
all_confirmed = True
153+
for seg in segments:
154+
if seg.isdigit():
155+
continue
156+
for f in files:
157+
if f"'{seg}'" in sources.get(f, "") or f'"{seg}"' in sources.get(f, ""):
158+
break
159+
else:
160+
all_confirmed = False
161+
if all_confirmed:
162+
matching_files.extend(files)
163+
elif pattern == feature:
164+
matching_files.extend(files)
165+
return list(set(matching_files))
166+
167+
168+
def report(db):
169+
"""Print deprecation report and return exit code."""
170+
code_usage, sources = find_code_usage()
171+
today = date.today()
172+
173+
used_in_code = []
174+
past_due = []
175+
upcoming = []
176+
177+
for feature in sorted(db):
178+
info = db[feature]
179+
removal_str = info.get("removalDate", "")
180+
replacement = info.get("info", "")
181+
feature_sources = info.get("sources", [])
182+
code_files = feature_matches_code(feature, code_usage, sources)
183+
184+
try:
185+
removal_date = datetime.strptime(removal_str, "%Y-%m-%d").date()
186+
is_past_due = removal_date <= today
187+
days = (removal_date - today).days
188+
date_label = f"{removal_str} ({'PAST DUE' if is_past_due else f'{days} days left'})"
189+
except (ValueError, TypeError):
190+
removal_date = None
191+
is_past_due = False
192+
date_label = removal_str or "unknown"
193+
194+
entry = {
195+
"feature": feature,
196+
"date_label": date_label,
197+
"replacement": replacement,
198+
"sources": feature_sources,
199+
"code_files": code_files,
200+
"is_past_due": is_past_due,
201+
}
202+
203+
if code_files:
204+
used_in_code.append(entry)
205+
elif is_past_due:
206+
past_due.append(entry)
207+
else:
208+
upcoming.append(entry)
209+
210+
def print_entry(entry):
211+
print(f" {entry['feature']}")
212+
print(f" Removal: {entry['date_label']}")
213+
if entry["replacement"] and entry["replacement"] != "none":
214+
print(f" Replaced by: {entry['replacement']}")
215+
if entry.get("code_files"):
216+
print(f" Used in code: {', '.join(entry['code_files'])}")
217+
dump_sources = [s for s in entry.get("sources", []) if s.startswith("dump:")]
218+
if dump_sources:
219+
print(f" From dump: {', '.join(s.removeprefix('dump:') for s in dump_sources)}")
220+
print()
221+
222+
def collapse_entries(entries):
223+
"""Group entries that only differ by numeric indices (e.g. rooms.0, rooms.1)."""
224+
groups = {} # pattern -> {entry (first), count}
225+
for entry in entries:
226+
pattern = re.sub(r"\b\d+\b", "*", entry["feature"])
227+
if pattern in groups:
228+
groups[pattern]["count"] += 1
229+
else:
230+
collapsed = dict(entry)
231+
collapsed["feature"] = pattern
232+
groups[pattern] = {"entry": collapsed, "count": 1}
233+
result = []
234+
for pattern, group in groups.items():
235+
entry = group["entry"]
236+
if group["count"] > 1:
237+
entry["feature"] = f"{pattern} ({group['count']}x)"
238+
result.append(entry)
239+
return result
240+
241+
if used_in_code:
242+
print("=== WARNING: Deprecated features used in code ===\n")
243+
for entry in collapse_entries(used_in_code):
244+
print_entry(entry)
245+
246+
if past_due:
247+
print(f"=== Past removal date (not used in code): {len(past_due)} features ===\n")
248+
for entry in collapse_entries(past_due):
249+
print_entry(entry)
250+
251+
if upcoming:
252+
print("=== Upcoming deprecations ===\n")
253+
for entry in collapse_entries(upcoming):
254+
print_entry(entry)
255+
256+
total = len(db)
257+
in_code = len(used_in_code)
258+
print("=== Summary ===")
259+
print(f"{total} deprecated features in database")
260+
print(f"{in_code} used in code {'(ACTION NEEDED)' if in_code else '(clean)'}")
261+
print(f"{len(past_due)} past removal date")
262+
print(f"{len(upcoming)} upcoming")
263+
264+
return 1 if in_code else 0
265+
266+
267+
def main():
268+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
269+
parser.add_argument("--update", action="store_true", help="Rescan test data and update the database")
270+
parser.add_argument("dumps", nargs="*", help="Additional dump files to ingest (implies --update)")
271+
args = parser.parse_args()
272+
273+
if args.dumps:
274+
args.update = True
275+
276+
if args.update:
277+
new = update_database(args.dumps)
278+
if new:
279+
print(f"=== {len(new)} NEW deprecated features found ===\n")
280+
for feat, info in sorted(new.items()):
281+
print(f" {feat}")
282+
print(f" Removal: {info['removalDate']}")
283+
if info["info"] and info["info"] != "none":
284+
print(f" Replaced by: {info['info']}")
285+
print(f" Discovered in: {info['source']}")
286+
print()
287+
else:
288+
print("Database up to date, no new deprecated features found.\n")
289+
290+
db = load_database()
291+
if not db:
292+
print("No deprecation database found. Run with --update first.", file=sys.stderr)
293+
sys.exit(1)
294+
295+
sys.exit(report(db))
296+
297+
298+
if __name__ == "__main__":
299+
main()

0 commit comments

Comments
 (0)