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

1""" 

2Property access tracking module. 

3 

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""" 

12 

13import ast 

14import logging 

15from typing import Dict, Optional, Set 

16 

17from .base import PropertyAccess, PropertyAccessType 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class PropertyVisitor(ast.NodeVisitor): 

23 """AST visitor that tracks property accesses.""" 

24 

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 

33 

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) 

41 

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) 

48 

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 

75 

76 # Store the nested property access 

77 if parent_prop in self.properties: 

78 self.properties[parent_prop].nested_properties.add(nested_prop) 

79 

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 

83 

84 self.generic_visit(node) 

85 

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() 

93 

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) 

102 

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) 

106 

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) 

122 

123 # If we're in a comparison 

124 if self.in_comparison: 

125 self.add_property_access(prop_name, PropertyAccessType.COMPARISON) 

126 

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) 

136 

137 # If we're in a comparison 

138 if self.in_comparison: 

139 self.add_property_access( 

140 var_name, PropertyAccessType.COMPARISON 

141 ) 

142 

143 # If we're in a conditional 

144 if self.in_conditional: 

145 self.add_property_access( 

146 var_name, PropertyAccessType.CONDITIONAL 

147 ) 

148 

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) 

157 

158 # If we're in a comparison 

159 if self.in_comparison: 

160 self.add_property_access(prop_name, PropertyAccessType.COMPARISON) 

161 

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) 

171 

172 # If we're in a comparison 

173 if self.in_comparison: 

174 self.add_property_access( 

175 var_name, PropertyAccessType.COMPARISON 

176 ) 

177 

178 # If we're in a conditional 

179 if self.in_conditional: 

180 self.add_property_access( 

181 var_name, PropertyAccessType.CONDITIONAL 

182 ) 

183 

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) 

206 

207 self.generic_visit(node) 

208 

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) 

223 

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 ) 

238 

239 # Set comparison flag for normal comparisons 

240 old_in_comparison = self.in_comparison 

241 self.in_comparison = True 

242 

243 # Visit left side and comparators 

244 self.visit(node.left) 

245 for comparator in node.comparators: 

246 self.visit(comparator) 

247 

248 # Restore comparison flag 

249 self.in_comparison = old_in_comparison 

250 

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 

255 

256 # Visit the test condition 

257 self.visit(node.test) 

258 

259 # Reset for the body 

260 self.in_conditional = old_in_conditional 

261 

262 # Visit body 

263 for stmt in node.body: 

264 self.visit(stmt) 

265 

266 # Visit else clauses 

267 for stmt in node.orelse: 

268 self.visit(stmt) 

269 

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 ) 

289 

290 # If we're in a comparison 

291 if self.in_comparison: 

292 self.add_property_access( 

293 prop_name, PropertyAccessType.COMPARISON 

294 ) 

295 

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 ) 

310 

311 # If we're in a comparison 

312 if self.in_comparison: 

313 self.add_property_access( 

314 var_name, PropertyAccessType.COMPARISON 

315 ) 

316 

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 ) 

335 

336 # If we're in a comparison 

337 if self.in_comparison: 

338 self.add_property_access( 

339 prop_name, PropertyAccessType.COMPARISON 

340 ) 

341 

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 ) 

355 

356 # If we're in a comparison 

357 if self.in_comparison: 

358 self.add_property_access( 

359 var_name, PropertyAccessType.COMPARISON 

360 ) 

361 

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 ) 

380 

381 # If we're in a comparison 

382 if self.in_comparison: 

383 self.add_property_access( 

384 prop_name, PropertyAccessType.COMPARISON 

385 ) 

386 

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 ) 

400 

401 # If we're in a comparison 

402 if self.in_comparison: 

403 self.add_property_access( 

404 var_name, PropertyAccessType.COMPARISON 

405 ) 

406 

407 # If we're in a conditional 

408 if self.in_conditional: 

409 self.add_property_access( 

410 var_name, PropertyAccessType.CONDITIONAL 

411 ) 

412 

413 self.generic_visit(node) 

414 

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 

425 

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) 

430 

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 ) 

448 

449 # If we're in a comparison 

450 if self.in_comparison: 

451 self.add_property_access( 

452 self.current_property, PropertyAccessType.COMPARISON 

453 ) 

454 

455 # If we're in a conditional 

456 if self.in_conditional: 

457 self.add_property_access( 

458 self.current_property, PropertyAccessType.CONDITIONAL 

459 ) 

460 

461 self.generic_visit(node) 

462 

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) 

468 

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 

476 

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 

484 

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) 

490 

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 

498 

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 

506 

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) 

512 

513 

514class PropertyAnalyzer: 

515 """Analyzer for property access patterns in rules.""" 

516 

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) 

521 

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) 

526 

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}'") 

535 

536 if direct_accesses + method_accesses > 0: 

537 access.access_count = direct_accesses + method_accesses 

538 

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 ) 

548 

549 if direct_accesses + method_accesses > 0: 

550 color_access.access_count = direct_accesses + method_accesses 

551 

552 return visitor.properties 

553 

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 

563 

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() 

568 

569 return nested 

570 

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 } 

580 

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 }