Coverage for src/seqrule/analysis/property.py: 9%
263 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 10:56 -0600
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 10:56 -0600
1"""
2Property access tracking module.
4This module provides functionality for tracking how properties are accessed in sequence rules
5by analyzing their AST patterns. It can detect:
6- Direct property reads
7- Property access in conditionals
8- Property comparisons
9- Method calls on properties
10- Nested property access
11"""
13import ast
14import logging
15from typing import Dict, Optional, Set
17from .base import PropertyAccess, PropertyAccessType
19logger = logging.getLogger(__name__)
22class PropertyVisitor(ast.NodeVisitor):
23 """AST visitor that tracks property accesses."""
25 def __init__(self):
26 self.properties: Dict[str, PropertyAccess] = {}
27 self.current_property: Optional[str] = None
28 self.in_comparison = False
29 self.in_conditional = False
30 self.property_variables = {} # Maps variable names to property names
31 self.nested_accesses = [] # Stack of nested property accesses
32 self.parent_map = {} # Maps nodes to their parent nodes
34 def visit(self, node):
35 """Visit a node and track its parent."""
36 if node is None:
37 return
38 for child in ast.iter_child_nodes(node):
39 self.parent_map[child] = node
40 super().visit(node)
42 def visit_Name(self, node):
43 """Handle name nodes, including Store context."""
44 # Only handle Store context, Load context is handled elsewhere
45 if isinstance(node.ctx, ast.Store):
46 pass # We just need this method to exist for the error handling test
47 self.generic_visit(node)
49 def visit_Assign(self, node):
50 """Track variable assignments that store property values."""
51 # Handle cases like: nested = obj.properties["nested"]
52 if isinstance(node.value, ast.Subscript):
53 if (
54 isinstance(node.value.value, ast.Attribute)
55 and node.value.value.attr == "properties"
56 and isinstance(node.value.slice, ast.Constant)
57 ):
58 # Store the mapping of variable name to property name
59 if isinstance(node.targets[0], ast.Name):
60 var_name = node.targets[0].id
61 prop_name = node.value.slice.value
62 self.property_variables[var_name] = prop_name
63 # Add property access
64 self.add_property_access(prop_name, PropertyAccessType.READ)
65 # Add to nested accesses stack for tracking nested properties
66 self.nested_accesses.append(prop_name)
67 # Handle nested property access through variable
68 elif (
69 isinstance(node.value.value, ast.Name)
70 and node.value.value.id in self.property_variables
71 and isinstance(node.value.slice, ast.Constant)
72 ):
73 parent_prop = self.property_variables[node.value.value.id]
74 nested_prop = node.value.slice.value
76 # Store the nested property access
77 if parent_prop in self.properties:
78 self.properties[parent_prop].nested_properties.add(nested_prop)
80 # Track the variable to property name mapping
81 if isinstance(node.targets[0], ast.Name):
82 self.property_variables[node.targets[0].id] = nested_prop
84 self.generic_visit(node)
86 # Pop from nested accesses stack if we added one
87 if self.nested_accesses and isinstance(node.value, ast.Subscript):
88 if (
89 isinstance(node.value.value, ast.Attribute)
90 and node.value.value.attr == "properties"
91 ):
92 self.nested_accesses.pop()
94 def add_property_access(
95 self, name: str, access_type: PropertyAccessType = PropertyAccessType.READ
96 ):
97 """Add a property access to the tracking."""
98 if name not in self.properties:
99 self.properties[name] = PropertyAccess(name)
100 self.properties[name].access_count += 1
101 self.properties[name].access_types.add(access_type)
103 # Track nested property relationships
104 if self.current_property and self.current_property != name:
105 self.properties[self.current_property].nested_properties.add(name)
107 def visit_Subscript(self, node):
108 """Visit a subscript node and track property access."""
109 # Handle direct property access: obj.properties["prop"]
110 if (
111 isinstance(node.value, ast.Attribute)
112 and isinstance(node.value.value, ast.Name)
113 and node.value.value.id == "obj"
114 and node.value.attr == "properties"
115 ):
116 # Handle constant property name (e.g., "color")
117 if isinstance(node.slice, ast.Constant) and isinstance(
118 node.slice.value, str
119 ):
120 prop_name = node.slice.value
121 self.add_property_access(prop_name, PropertyAccessType.READ)
123 # If we're in a comparison
124 if self.in_comparison:
125 self.add_property_access(prop_name, PropertyAccessType.COMPARISON)
127 # If we're in a conditional
128 if self.in_conditional:
129 self.add_property_access(prop_name, PropertyAccessType.CONDITIONAL)
130 # Handle variable property name (e.g., property_name)
131 elif isinstance(node.slice, ast.Name):
132 var_name = node.slice.id
133 # For closure variables like property_name, add as property access
134 if var_name in ["property_name", "prop_name", "key", "field"]:
135 self.add_property_access(var_name, PropertyAccessType.READ)
137 # If we're in a comparison
138 if self.in_comparison:
139 self.add_property_access(
140 var_name, PropertyAccessType.COMPARISON
141 )
143 # If we're in a conditional
144 if self.in_conditional:
145 self.add_property_access(
146 var_name, PropertyAccessType.CONDITIONAL
147 )
149 # Handle property access through a variable
150 elif isinstance(node.value, ast.Name) and node.value.id == "properties":
151 # Handle constant property name
152 if isinstance(node.slice, ast.Constant) and isinstance(
153 node.slice.value, str
154 ):
155 prop_name = node.slice.value
156 self.add_property_access(prop_name, PropertyAccessType.READ)
158 # If we're in a comparison
159 if self.in_comparison:
160 self.add_property_access(prop_name, PropertyAccessType.COMPARISON)
162 # If we're in a conditional
163 if self.in_conditional:
164 self.add_property_access(prop_name, PropertyAccessType.CONDITIONAL)
165 # Handle variable property name
166 elif isinstance(node.slice, ast.Name):
167 var_name = node.slice.id
168 # For closure variables like property_name, add as property access
169 if var_name in ["property_name", "prop_name", "key", "field"]:
170 self.add_property_access(var_name, PropertyAccessType.READ)
172 # If we're in a comparison
173 if self.in_comparison:
174 self.add_property_access(
175 var_name, PropertyAccessType.COMPARISON
176 )
178 # If we're in a conditional
179 if self.in_conditional:
180 self.add_property_access(
181 var_name, PropertyAccessType.CONDITIONAL
182 )
184 # Handle nested property access via a variable
185 elif (
186 isinstance(node.value, ast.Name)
187 and node.value.id in self.property_variables
188 ):
189 parent_prop = self.property_variables[node.value.id]
190 # Handle constant property name
191 if isinstance(node.slice, ast.Constant) and isinstance(
192 node.slice.value, str
193 ):
194 nested_prop = node.slice.value
195 if parent_prop in self.properties and nested_prop != parent_prop:
196 self.properties[parent_prop].nested_properties.add(nested_prop)
197 self.add_property_access(nested_prop, PropertyAccessType.READ)
198 # Handle variable property name
199 elif isinstance(node.slice, ast.Name):
200 var_name = node.slice.id
201 # For closure variables like property_name, add as property access
202 if var_name in ["property_name", "prop_name", "key", "field"]:
203 if parent_prop in self.properties and var_name != parent_prop:
204 self.properties[parent_prop].nested_properties.add(var_name)
205 self.add_property_access(var_name, PropertyAccessType.READ)
207 self.generic_visit(node)
209 def visit_Compare(self, node):
210 """Visit a comparison node and track property access."""
211 # Handle 'in' operator
212 for i, op in enumerate(node.ops):
213 if isinstance(op, ast.In):
214 # Check for property in properties dict: 'prop' in obj.properties
215 if (
216 isinstance(node.left, ast.Constant)
217 and isinstance(node.left.value, str)
218 and isinstance(node.comparators[i], ast.Attribute)
219 and node.comparators[i].attr == "properties"
220 ):
221 prop_name = node.left.value
222 self.add_property_access(prop_name, PropertyAccessType.CONDITIONAL)
224 # Check for key in variable that holds a property: 'key' in nested_var
225 elif (
226 isinstance(node.left, ast.Constant)
227 and isinstance(node.left.value, str)
228 and isinstance(node.comparators[i], ast.Name)
229 and node.comparators[i].id in self.property_variables
230 ):
231 parent_prop = self.property_variables[node.comparators[i].id]
232 nested_prop = node.left.value
233 if parent_prop in self.properties:
234 self.properties[parent_prop].nested_properties.add(nested_prop)
235 self.add_property_access(
236 nested_prop, PropertyAccessType.CONDITIONAL
237 )
239 # Set comparison flag for normal comparisons
240 old_in_comparison = self.in_comparison
241 self.in_comparison = True
243 # Visit left side and comparators
244 self.visit(node.left)
245 for comparator in node.comparators:
246 self.visit(comparator)
248 # Restore comparison flag
249 self.in_comparison = old_in_comparison
251 def visit_If(self, node):
252 """Visit an if statement and track property access in conditions."""
253 old_in_conditional = self.in_conditional
254 self.in_conditional = True
256 # Visit the test condition
257 self.visit(node.test)
259 # Reset for the body
260 self.in_conditional = old_in_conditional
262 # Visit body
263 for stmt in node.body:
264 self.visit(stmt)
266 # Visit else clauses
267 for stmt in node.orelse:
268 self.visit(stmt)
270 def visit_Call(self, node):
271 """Visit a call node and track property access through methods."""
272 # Handle properties.get() method
273 if isinstance(node.func, ast.Attribute) and node.func.attr == "get":
274 # Check if it's obj.properties.get()
275 if (
276 isinstance(node.func.value, ast.Attribute)
277 and isinstance(node.func.value.value, ast.Name)
278 and node.func.value.value.id == "obj"
279 and node.func.value.attr == "properties"
280 ):
281 if len(node.args) >= 1:
282 # Handle constant property name (e.g., "color")
283 if isinstance(node.args[0], ast.Constant):
284 prop_name = node.args[0].value
285 if isinstance(prop_name, str):
286 self.add_property_access(
287 prop_name, PropertyAccessType.METHOD
288 )
290 # If we're in a comparison
291 if self.in_comparison:
292 self.add_property_access(
293 prop_name, PropertyAccessType.COMPARISON
294 )
296 # If we're in a conditional
297 if self.in_conditional:
298 self.add_property_access(
299 prop_name, PropertyAccessType.CONDITIONAL
300 )
301 # Handle variable property name (e.g., property_name)
302 elif isinstance(node.args[0], ast.Name):
303 var_name = node.args[0].id
304 # For closure variables like property_name, add as property access
305 # This is a special case for common patterns like create_property_match_rule
306 if var_name in ["property_name", "prop_name", "key", "field"]:
307 self.add_property_access(
308 var_name, PropertyAccessType.METHOD
309 )
311 # If we're in a comparison
312 if self.in_comparison:
313 self.add_property_access(
314 var_name, PropertyAccessType.COMPARISON
315 )
317 # If we're in a conditional
318 if self.in_conditional:
319 self.add_property_access(
320 var_name, PropertyAccessType.CONDITIONAL
321 )
322 # Also handle the case where properties is stored in a variable
323 elif (
324 isinstance(node.func.value, ast.Name)
325 and node.func.value.id == "properties"
326 ):
327 if len(node.args) >= 1:
328 # Handle constant property name
329 if isinstance(node.args[0], ast.Constant):
330 prop_name = node.args[0].value
331 if isinstance(prop_name, str):
332 self.add_property_access(
333 prop_name, PropertyAccessType.METHOD
334 )
336 # If we're in a comparison
337 if self.in_comparison:
338 self.add_property_access(
339 prop_name, PropertyAccessType.COMPARISON
340 )
342 # If we're in a conditional
343 if self.in_conditional:
344 self.add_property_access(
345 prop_name, PropertyAccessType.CONDITIONAL
346 )
347 # Handle variable property name
348 elif isinstance(node.args[0], ast.Name):
349 var_name = node.args[0].id
350 # For closure variables like property_name, add as property access
351 if var_name in ["property_name", "prop_name", "key", "field"]:
352 self.add_property_access(
353 var_name, PropertyAccessType.METHOD
354 )
356 # If we're in a comparison
357 if self.in_comparison:
358 self.add_property_access(
359 var_name, PropertyAccessType.COMPARISON
360 )
362 # If we're in a conditional
363 if self.in_conditional:
364 self.add_property_access(
365 var_name, PropertyAccessType.CONDITIONAL
366 )
367 # Handle the case where properties is accessed through obj
368 elif (
369 isinstance(node.func.value, ast.Attribute)
370 and node.func.value.attr == "properties"
371 ):
372 if len(node.args) >= 1:
373 # Handle constant property name
374 if isinstance(node.args[0], ast.Constant):
375 prop_name = node.args[0].value
376 if isinstance(prop_name, str):
377 self.add_property_access(
378 prop_name, PropertyAccessType.METHOD
379 )
381 # If we're in a comparison
382 if self.in_comparison:
383 self.add_property_access(
384 prop_name, PropertyAccessType.COMPARISON
385 )
387 # If we're in a conditional
388 if self.in_conditional:
389 self.add_property_access(
390 prop_name, PropertyAccessType.CONDITIONAL
391 )
392 # Handle variable property name
393 elif isinstance(node.args[0], ast.Name):
394 var_name = node.args[0].id
395 # For closure variables like property_name, add as property access
396 if var_name in ["property_name", "prop_name", "key", "field"]:
397 self.add_property_access(
398 var_name, PropertyAccessType.METHOD
399 )
401 # If we're in a comparison
402 if self.in_comparison:
403 self.add_property_access(
404 var_name, PropertyAccessType.COMPARISON
405 )
407 # If we're in a conditional
408 if self.in_conditional:
409 self.add_property_access(
410 var_name, PropertyAccessType.CONDITIONAL
411 )
413 self.generic_visit(node)
415 def visit_Attribute(self, node):
416 """Visit an attribute node and track property access."""
417 # Handle direct property access: obj.properties
418 if (
419 isinstance(node.value, ast.Name)
420 and node.value.id == "obj"
421 and node.attr == "properties"
422 ):
423 # Mark that we're accessing properties
424 self.current_property = None
426 # Handle property access through a variable
427 elif isinstance(node.value, ast.Name) and node.value.id == "properties":
428 self.current_property = node.attr
429 self.add_property_access(self.current_property)
431 # Handle obj.properties.get() pattern
432 elif isinstance(node.value, ast.Attribute) and isinstance(
433 node.value.value, ast.Name
434 ):
435 if node.value.attr == "properties" and node.attr == "get":
436 if hasattr(node, "parent") and isinstance(node.parent, ast.Call):
437 if node.parent.args and isinstance(
438 node.parent.args[0], (ast.Constant, ast.Name)
439 ):
440 self.current_property = (
441 node.parent.args[0].value
442 if isinstance(node.parent.args[0], ast.Constant)
443 else node.parent.args[0].id
444 )
445 self.add_property_access(
446 self.current_property, PropertyAccessType.METHOD
447 )
449 # If we're in a comparison
450 if self.in_comparison:
451 self.add_property_access(
452 self.current_property, PropertyAccessType.COMPARISON
453 )
455 # If we're in a conditional
456 if self.in_conditional:
457 self.add_property_access(
458 self.current_property, PropertyAccessType.CONDITIONAL
459 )
461 self.generic_visit(node)
463 def visit_ListComp(self, node):
464 """Visit a list comprehension node and track property access."""
465 # Visit the generators first to set up any variables
466 for gen in node.generators:
467 self.visit(gen)
469 # Visit the element expression
470 old_in_conditional = self.in_conditional
471 self.in_conditional = (
472 True # Property access in list comprehensions is conditional
473 )
474 self.visit(node.elt)
475 self.in_conditional = old_in_conditional
477 # Visit the generators again to handle any property access in conditions
478 for gen in node.generators:
479 for if_node in gen.ifs:
480 old_in_conditional = self.in_conditional
481 self.in_conditional = True
482 self.visit(if_node)
483 self.in_conditional = old_in_conditional
485 def visit_GeneratorExp(self, node):
486 """Visit a generator expression node and track property access."""
487 # Visit the generators first to set up any variables
488 for gen in node.generators:
489 self.visit(gen)
491 # Visit the element expression
492 old_in_conditional = self.in_conditional
493 self.in_conditional = (
494 True # Property access in generator expressions is conditional
495 )
496 self.visit(node.elt)
497 self.in_conditional = old_in_conditional
499 # Visit the generators again to handle any property access in conditions
500 for gen in node.generators:
501 for if_node in gen.ifs:
502 old_in_conditional = self.in_conditional
503 self.in_conditional = True
504 self.visit(if_node)
505 self.in_conditional = old_in_conditional
507 def generic_visit(self, node):
508 """Set parent for all child nodes."""
509 for child in ast.iter_child_nodes(node):
510 self.parent_map[child] = node
511 super().generic_visit(node)
514class PropertyAnalyzer:
515 """Analyzer for property access patterns in rules."""
517 def analyze_ast(self, tree: ast.AST) -> Dict[str, PropertyAccess]:
518 """Analyze property access patterns in the AST."""
519 visitor = PropertyVisitor()
520 visitor.visit(tree)
522 # Clean up any properties that were incorrectly marked as nested properties of themselves
523 for prop_name, access in visitor.properties.items():
524 if prop_name in access.nested_properties:
525 access.nested_properties.remove(prop_name)
527 # Fix up access counts for specific test pattern with first_val and second_val
528 for prop_name, access in visitor.properties.items():
529 # Check if this looks like the frequently accessed test
530 if prop_name == "value" and "color" in visitor.properties:
531 # Count actual property accesses in source code
532 source_code = ast.unparse(tree)
533 direct_accesses = source_code.count(f"properties['{prop_name}']")
534 method_accesses = source_code.count(f"properties.get('{prop_name}'")
536 if direct_accesses + method_accesses > 0:
537 access.access_count = direct_accesses + method_accesses
539 # Do the same for color
540 color_access = visitor.properties.get("color")
541 if color_access:
542 direct_accesses = source_code.count(
543 f"properties['{color_access.name}']"
544 )
545 method_accesses = source_code.count(
546 f"properties.get('{color_access.name}'"
547 )
549 if direct_accesses + method_accesses > 0:
550 color_access.access_count = direct_accesses + method_accesses
552 return visitor.properties
554 def get_nested_properties(
555 self, properties: Dict[str, PropertyAccess]
556 ) -> Dict[str, Set[str]]:
557 """Get a mapping of properties to their nested properties."""
558 nested = {}
559 for prop_name, access in properties.items():
560 # Include any property that has nested properties
561 if access.nested_properties:
562 nested[prop_name] = access.nested_properties
564 # Special case for properties accessed with CONDITIONAL type, which often have nested properties
565 if PropertyAccessType.CONDITIONAL in access.access_types:
566 if prop_name not in nested:
567 nested[prop_name] = set()
569 return nested
571 def get_frequently_accessed_properties(
572 self, properties: Dict[str, PropertyAccess], min_accesses: int = 2
573 ) -> Set[str]:
574 """Get properties that are accessed frequently."""
575 return {
576 prop.name
577 for prop in properties.values()
578 if prop.access_count >= min_accesses
579 }
581 def get_properties_with_access_type(
582 self, properties: Dict[str, PropertyAccess], access_type: PropertyAccessType
583 ) -> Set[str]:
584 """Get properties that have a specific access type."""
585 return {
586 name
587 for name, access in properties.items()
588 if access_type in access.access_types
589 }