Skip to content

Commit b969615

Browse files
committed
fix(deprecations): scope literal lookup to function bodies
The deprecation checker's segment-confirmation step searched for wildcard segment values as string literals across the entire file. That produced false positives whenever two orthogonal getProperty calls in different functions shared a token. Concrete case after #770: ventilation.operating.programs.* was flagged as in-use because getVentilationPrograms iterates ['basic', 'intensive', 'reduced', ...] (none in the DB), but getVentilationQuickmodes in the same file iterates ['comfort', 'eco', 'forcedLevelFour', 'holiday', 'silent'] (all in the DB) — for the unrelated ventilation.quickmodes.* path. The file-wide literal search joined them, marking the deprecated programs features as in-code-use even though no code actually queries them. Switch find_code_usage to extract each function body via ast and scope the segment lookup to the same function that contains the getProperty call. Top-level/class-body code is scanned as a single residual scope so nothing is missed. All 729 tests stay green; the script now exits 0 against current master (5 spurious in-code warnings removed).
1 parent 277fc78 commit b969615

1 file changed

Lines changed: 63 additions & 29 deletions

File tree

check_deprecations.py

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import argparse
17+
import ast
1718
import glob
1819
import json
1920
import re
@@ -116,58 +117,91 @@ def add_feature(feat, info, source_label):
116117

117118

118119
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
120+
"""Find all feature paths referenced via getProperty() in PyViCare code.
121+
122+
Returns:
123+
usage: dict mapping feature pattern -> list of (filename, function_source) tuples.
124+
The function source is the body text of the enclosing function (or the full
125+
file text for top-level calls) and is used by feature_matches_code to
126+
confirm wildcard segments via local string literals — file-wide literal
127+
search produced false positives across orthogonal functions sharing a
128+
token (e.g. "comfort" appearing both in a `ventilation.quickmodes.*` loop
129+
and in a `ventilation.operating.programs.*` lookup).
130+
"""
131+
usage = {} # feature pattern -> list of (filename, function_source)
132+
pattern_re = re.compile(r'getProperty\(\s*f?"([^"]+)"\s*\)')
122133

123134
for filepath in sorted(glob.glob(f"{PYVICARE_DIR}/**/*.py", recursive=True)):
124135
filename = Path(filepath).name
125136
with open(filepath) as f:
126137
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
136138

139+
try:
140+
tree = ast.parse(content)
141+
except SyntaxError:
142+
continue
137143

138-
def feature_matches_code(feature, code_usage, sources):
144+
scopes = [] # list of source-text scopes to scan independently
145+
seen_lines = set()
146+
for node in ast.walk(tree):
147+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
148+
segment = ast.get_source_segment(content, node)
149+
if segment is not None:
150+
scopes.append(segment)
151+
for line in range(node.lineno, getattr(node, "end_lineno", node.lineno) + 1):
152+
seen_lines.add(line)
153+
154+
# Cover code outside any function (top-level / class body statements) by
155+
# scanning the remaining lines as one extra scope.
156+
remaining = "\n".join(
157+
line for i, line in enumerate(content.splitlines(), start=1)
158+
if i not in seen_lines
159+
)
160+
if remaining:
161+
scopes.append(remaining)
162+
163+
for scope in scopes:
164+
for match in pattern_re.findall(scope):
165+
normalized = re.sub(r"\{[^}]+\}", "*", match)
166+
usage.setdefault(normalized, []).append((filename, scope))
167+
168+
return usage
169+
170+
171+
def feature_matches_code(feature, code_usage):
139172
"""Check if a deprecated feature is used in code.
140173
141174
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.
175+
verifies that the specific segment value appears as a string literal in the
176+
same function scope as the getProperty call — file-wide literal search
177+
produced false positives across orthogonal functions sharing a token.
144178
"""
145179
matching_files = []
146-
for pattern, files in code_usage.items():
180+
for pattern, occurrences in code_usage.items():
147181
if "*" in pattern:
148182
regex = re.escape(pattern).replace(r"\*", r"([^.]+)")
149183
m = re.fullmatch(regex, feature)
150184
if m:
151185
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, ""):
186+
for filename, scope in occurrences:
187+
confirmed = True
188+
for seg in segments:
189+
if seg.isdigit():
190+
continue
191+
if f"'{seg}'" not in scope and f'"{seg}"' not in scope:
192+
confirmed = False
158193
break
159-
else:
160-
all_confirmed = False
161-
if all_confirmed:
162-
matching_files.extend(files)
194+
if confirmed:
195+
matching_files.append(filename)
163196
elif pattern == feature:
164-
matching_files.extend(files)
197+
for filename, _scope in occurrences:
198+
matching_files.append(filename)
165199
return list(set(matching_files))
166200

167201

168202
def report(db):
169203
"""Print deprecation report and return exit code."""
170-
code_usage, sources = find_code_usage()
204+
code_usage = find_code_usage()
171205
today = date.today()
172206

173207
used_in_code = []
@@ -179,7 +213,7 @@ def report(db):
179213
removal_str = info.get("removalDate", "")
180214
replacement = info.get("info", "")
181215
feature_sources = info.get("sources", [])
182-
code_files = feature_matches_code(feature, code_usage, sources)
216+
code_files = feature_matches_code(feature, code_usage)
183217

184218
try:
185219
removal_date = datetime.strptime(removal_str, "%Y-%m-%d").date()

0 commit comments

Comments
 (0)