api-foundry-query-engine 0.8.39__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.
Files changed (37) hide show
  1. api_foundry_query_engine/.pre-commit-config.yaml +22 -0
  2. api_foundry_query_engine/__init__.py +1 -0
  3. api_foundry_query_engine/adapters/adapter.py +73 -0
  4. api_foundry_query_engine/adapters/case_change_adapter.py +79 -0
  5. api_foundry_query_engine/adapters/gateway_adapter.py +191 -0
  6. api_foundry_query_engine/adapters/security_adapter.py +106 -0
  7. api_foundry_query_engine/connectors/connection.py +32 -0
  8. api_foundry_query_engine/connectors/connection_factory.py +115 -0
  9. api_foundry_query_engine/connectors/oracle_connector.py +29 -0
  10. api_foundry_query_engine/connectors/postgres_connection.py +142 -0
  11. api_foundry_query_engine/dao/batch_operation_handler.py +295 -0
  12. api_foundry_query_engine/dao/dao.py +23 -0
  13. api_foundry_query_engine/dao/operation_dao.py +186 -0
  14. api_foundry_query_engine/dao/sql_custom_query_handler.py +68 -0
  15. api_foundry_query_engine/dao/sql_delete_query_handler.py +146 -0
  16. api_foundry_query_engine/dao/sql_insert_query_handler.py +187 -0
  17. api_foundry_query_engine/dao/sql_query_handler.py +713 -0
  18. api_foundry_query_engine/dao/sql_restore_query_handler.py +195 -0
  19. api_foundry_query_engine/dao/sql_select_query_handler.py +433 -0
  20. api_foundry_query_engine/dao/sql_subselect_query_handler.py +66 -0
  21. api_foundry_query_engine/dao/sql_update_query_handler.py +198 -0
  22. api_foundry_query_engine/lambda_handler.py +63 -0
  23. api_foundry_query_engine/operation.py +104 -0
  24. api_foundry_query_engine/services/service.py +75 -0
  25. api_foundry_query_engine/services/transactional_service.py +52 -0
  26. api_foundry_query_engine/utils/api_model.py +380 -0
  27. api_foundry_query_engine/utils/app_exception.py +22 -0
  28. api_foundry_query_engine/utils/claims_check.py +471 -0
  29. api_foundry_query_engine/utils/dependency_resolver.py +157 -0
  30. api_foundry_query_engine/utils/gateway_operation.py +279 -0
  31. api_foundry_query_engine/utils/logger.py +60 -0
  32. api_foundry_query_engine/utils/reference_resolver.py +222 -0
  33. api_foundry_query_engine/utils/token_decoder.py +624 -0
  34. api_foundry_query_engine-0.8.39.dist-info/METADATA +21 -0
  35. api_foundry_query_engine-0.8.39.dist-info/RECORD +37 -0
  36. api_foundry_query_engine-0.8.39.dist-info/WHEEL +4 -0
  37. api_foundry_query_engine-0.8.39.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,471 @@
1
+ """
2
+ Claims Check Decorator for Lambda Functions
3
+
4
+ This decorator validates JWT scopes and permissions after token decoding.
5
+ It works in conjunction with the @token_decoder() decorator to ensure
6
+ that the authenticated user has the required permissions for the requested
7
+ operation.
8
+ """
9
+
10
+ import functools
11
+ import logging
12
+ import os
13
+ import re
14
+ from typing import Optional, Dict, Any, Callable
15
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
16
+
17
+ logging.basicConfig(level=logging.INFO)
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def claims_check(
22
+ require_authentication: Optional[bool] = None,
23
+ validate_scope_format: bool = True,
24
+ validate_path_scope: bool = True,
25
+ min_scope_level: Optional[str] = None,
26
+ required_scopes: Optional[list] = None,
27
+ required_permissions: Optional[list] = None,
28
+ operation_type: Optional[str] = None,
29
+ entity_name: Optional[str] = None,
30
+ ):
31
+ """
32
+ Decorator for API Foundry Query Engine authentication validation.
33
+
34
+ This decorator validates that the user has valid authentication for the
35
+ Query Engine. The Query Engine acts as a SQL translation engine that
36
+ handles all operations, so granular permissions are enforced at the
37
+ SQL level by the existing permissions system.
38
+
39
+ Args:
40
+ require_authentication: Require valid JWT token claims (default: True)
41
+ validate_scope_format: Validate that scopes follow expected format
42
+ validate_path_scope: Auto-validate scope matches request path/method
43
+ (default: True). E.g. GET /employee needs
44
+ read:employee scope
45
+ min_scope_level: Minimum scope level required
46
+ ('read', 'write', 'delete')
47
+ If None, any valid scope is accepted
48
+ required_scopes: List of specific scopes required (optional)
49
+ required_permissions: List of specific permissions required (optional)
50
+ operation_type: Override operation type detection (optional)
51
+ entity_name: Override entity name detection (optional)
52
+
53
+ Returns:
54
+ Decorated function that validates authentication before executing
55
+
56
+ Raises:
57
+ ApplicationException: If authentication validation fails
58
+
59
+ Example:
60
+ @token_decoder()
61
+ @claims_check() # Auto-validate scope matches path
62
+ def handler(event, context):
63
+ # GET /employee needs read:employee scope OR employee.read perm
64
+ # Additional SQL-level permissions enforced by query engine
65
+ return query_engine.process(event)
66
+
67
+ @claims_check(validate_path_scope=False) # Skip path validation
68
+ def handler_no_scope_check(event, context):
69
+ # Only SQL-level permissions will be enforced
70
+ return query_engine.process(event)
71
+
72
+ @claims_check(min_scope_level="write") # Path + minimum write level
73
+ def admin_handler(event, context):
74
+ return query_engine.process(event)
75
+ """
76
+
77
+ def decorator(func: Callable) -> Callable:
78
+ @functools.wraps(func)
79
+ def wrapper(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
80
+ try:
81
+ log.debug("Claims check decorator starting")
82
+
83
+ config_require_authentication = require_authentication or os.getenv(
84
+ "REQUIRE_AUTHENTICATION", ""
85
+ ).lower() in ("true", "1", "yes")
86
+ log.debug(
87
+ "config_require_authentication: %s", config_require_authentication
88
+ )
89
+
90
+ # Extract claims from the event (set by token_decoder)
91
+ claims = _extract_claims(event)
92
+ if not claims:
93
+ if config_require_authentication:
94
+ raise ApplicationException(
95
+ status_code=401, message="No authentication claims found"
96
+ )
97
+ else:
98
+ log.debug("Found claims: %s", list(claims.keys()))
99
+
100
+ # Validate scope format if requested
101
+ if validate_scope_format:
102
+ _validate_scope_format(claims)
103
+
104
+ # Check minimum scope level if specified
105
+ if min_scope_level:
106
+ _validate_min_scope_level(claims, min_scope_level)
107
+
108
+ # Auto-validate path-based scope if enabled
109
+ if validate_path_scope:
110
+ _validate_path_scope(claims, event, operation_type, entity_name)
111
+
112
+ # Check specific scopes if required
113
+ if required_scopes:
114
+ _validate_required_scopes(
115
+ claims, required_scopes, event, operation_type, entity_name
116
+ )
117
+
118
+ # Check specific permissions if required
119
+ if required_permissions:
120
+ _validate_required_permissions(claims, required_permissions)
121
+
122
+ log.debug("Authentication validation passed")
123
+
124
+ # Execute the original function
125
+ return func(event, context)
126
+
127
+ except ApplicationException:
128
+ raise
129
+ except Exception as e:
130
+ log.error("Claims check error: %s", str(e))
131
+ raise ApplicationException(
132
+ status_code=500,
133
+ message="Internal server error during claims validation",
134
+ )
135
+
136
+ return wrapper
137
+
138
+ return decorator
139
+
140
+
141
+ def _extract_claims(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
142
+ """Extract JWT claims from the event context.
143
+
144
+ Checks multiple locations where claims might be stored:
145
+ - requestContext.authorizer (API Gateway TOKEN authorizer)
146
+ - requestContext.authorizer.claims (nested claims)
147
+ - requestContext.authorizer.iam (IAM authorizer context)
148
+ - requestContext.authorizer.lambda (Lambda authorizer context)
149
+ """
150
+ try:
151
+ request_context = event.get("requestContext", {})
152
+ authorizer = request_context.get("authorizer")
153
+
154
+ if authorizer is None:
155
+ log.debug("No authorizer found in requestContext")
156
+ return None
157
+
158
+ # Check direct authorizer context (most common for TOKEN authorizer)
159
+ claim_keys = ["sub", "scope", "permissions", "roles"]
160
+ if isinstance(authorizer, dict) and any(
161
+ key in authorizer for key in claim_keys
162
+ ):
163
+ log.debug("Found claims in requestContext.authorizer")
164
+ return authorizer
165
+
166
+ # Check nested claims object
167
+ claims = authorizer.get("claims")
168
+ if claims and isinstance(claims, dict):
169
+ log.debug("Found claims in requestContext.authorizer.claims")
170
+ return claims
171
+
172
+ # Check IAM authorizer context
173
+ iam_context = authorizer.get("iam")
174
+ if iam_context and isinstance(iam_context, dict):
175
+ log.debug("Found claims in requestContext.authorizer.iam")
176
+ return iam_context
177
+
178
+ # Check Lambda authorizer context
179
+ lambda_context = authorizer.get("lambda")
180
+ if lambda_context and isinstance(lambda_context, dict):
181
+ log.debug("Found claims in requestContext.authorizer.lambda")
182
+ return lambda_context
183
+
184
+ # If authorizer exists but no recognized claims format, return it
185
+ # This handles empty authorizers or custom claim structures
186
+ log.debug("Authorizer exists but no standard claims structure found")
187
+ return authorizer
188
+
189
+ except Exception as e:
190
+ log.warning("Failed to extract claims: %s", str(e))
191
+ return None
192
+
193
+
194
+ # Operation types and resources are handled by the SQL Query Engine
195
+
196
+
197
+ def _validate_scope_format(claims: Dict[str, Any]) -> None:
198
+ """Validate that scopes follow expected format."""
199
+ scopes = claims.get("scope", "")
200
+ if isinstance(scopes, str):
201
+ scopes = scopes.split()
202
+ elif not isinstance(scopes, list):
203
+ scopes = []
204
+
205
+ # Allow empty scopes if permissions exist
206
+ permissions = claims.get("permissions", [])
207
+ if not scopes and not permissions:
208
+ raise ApplicationException(
209
+ status_code=403, message="No scopes or permissions found in token"
210
+ )
211
+
212
+ # Validate scope format: should be operation:resource or operation:*
213
+ for scope in scopes:
214
+ if not re.match(r"^[a-zA-Z]+:[a-zA-Z*_-]+$", scope):
215
+ log.warning("Invalid scope format: %s", scope)
216
+ # Don't fail on invalid format, just log warning
217
+ # The SQL layer will handle actual permission enforcement
218
+
219
+
220
+ def _validate_min_scope_level(claims: Dict[str, Any], min_level: str) -> None:
221
+ """Validate that user has at least the minimum scope level."""
222
+ scopes = claims.get("scope", "")
223
+ if isinstance(scopes, str):
224
+ scopes = scopes.split()
225
+ elif not isinstance(scopes, list):
226
+ scopes = []
227
+
228
+ # Define scope hierarchy
229
+ scope_hierarchy = {"read": 1, "write": 2, "delete": 3, "admin": 4}
230
+
231
+ min_level_value = scope_hierarchy.get(min_level, 0)
232
+ user_max_level = 0
233
+
234
+ for scope in scopes:
235
+ if ":" in scope:
236
+ operation = scope.split(":")[0].lower()
237
+ scope_value = scope_hierarchy.get(operation, 0)
238
+ user_max_level = max(user_max_level, scope_value)
239
+
240
+ if user_max_level < min_level_value:
241
+ raise ApplicationException(
242
+ status_code=403, message=f"Insufficient scope level. Required: {min_level}"
243
+ )
244
+
245
+
246
+ def _validate_path_scope(
247
+ claims: Dict[str, Any],
248
+ event: Dict[str, Any],
249
+ operation_type: Optional[str],
250
+ entity_name: Optional[str],
251
+ ) -> None:
252
+ """Validate that user has scope matching the request path and method."""
253
+ user_scopes = claims.get("scope", "")
254
+ if isinstance(user_scopes, str):
255
+ user_scopes = user_scopes.split()
256
+ elif not isinstance(user_scopes, list):
257
+ user_scopes = []
258
+
259
+ # Get user permissions as well
260
+ user_permissions = claims.get("permissions", [])
261
+ if isinstance(user_permissions, str):
262
+ try:
263
+ import json
264
+
265
+ user_permissions = json.loads(user_permissions)
266
+ except (json.JSONDecodeError, ValueError):
267
+ user_permissions = []
268
+ elif not isinstance(user_permissions, list):
269
+ user_permissions = []
270
+
271
+ # Skip path scope validation if user has no scopes or permissions
272
+ if not user_scopes and not user_permissions:
273
+ log.debug("No scopes or permissions found, skipping path validation")
274
+ return
275
+
276
+ # Determine operation and entity from request
277
+ operation = operation_type or _extract_operation_type(event)
278
+ entity = entity_name or _extract_entity_from_path(event)
279
+
280
+ if not entity:
281
+ # Can't validate without entity - skip validation
282
+ log.debug("No entity found in path, skipping path scope validation")
283
+ return
284
+
285
+ # Construct required scope for this request
286
+ required_scope = f"{operation}:{entity}"
287
+
288
+ # Check if user has the required scope OR equivalent permission
289
+ has_scope = _scope_matches(user_scopes, required_scope, operation, entity)
290
+ required_permission = f"{entity}.{operation}"
291
+ has_permission = _permission_matches(user_permissions, required_permission)
292
+
293
+ if not has_scope and not has_permission:
294
+ raise ApplicationException(
295
+ status_code=403,
296
+ message=f"Access denied. Required scope: {required_scope} "
297
+ f"or permission: {required_permission}",
298
+ )
299
+
300
+ log.debug("Path scope validation passed for %s", required_scope)
301
+
302
+
303
+ def _validate_required_scopes(
304
+ claims: Dict[str, Any],
305
+ required_scopes: list,
306
+ event: Dict[str, Any],
307
+ operation_type: Optional[str],
308
+ entity_name: Optional[str],
309
+ ) -> None:
310
+ """Validate that user has required scopes."""
311
+ user_scopes = claims.get("scope", "")
312
+ if isinstance(user_scopes, str):
313
+ user_scopes = user_scopes.split()
314
+ elif not isinstance(user_scopes, list):
315
+ user_scopes = []
316
+
317
+ # Determine operation and entity
318
+ operation = operation_type or _extract_operation_type(event)
319
+ entity = entity_name or _extract_entity_from_path(event)
320
+
321
+ # Check each required scope
322
+ for required_scope in required_scopes:
323
+ if not _scope_matches(user_scopes, required_scope, operation, entity):
324
+ raise ApplicationException(
325
+ status_code=403, message=f"Required scope not found: {required_scope}"
326
+ )
327
+
328
+
329
+ def _validate_required_permissions(
330
+ claims: Dict[str, Any], required_permissions: list
331
+ ) -> None:
332
+ """Validate that user has required permissions."""
333
+ user_permissions = claims.get("permissions", [])
334
+ if isinstance(user_permissions, str):
335
+ # Handle JSON string format
336
+ try:
337
+ import json
338
+
339
+ user_permissions = json.loads(user_permissions)
340
+ except (json.JSONDecodeError, ValueError):
341
+ user_permissions = []
342
+ elif not isinstance(user_permissions, list):
343
+ user_permissions = []
344
+
345
+ # Check each required permission
346
+ for required_permission in required_permissions:
347
+ if not _permission_matches(user_permissions, required_permission):
348
+ raise ApplicationException(
349
+ status_code=403,
350
+ message=f"Required permission not found: {required_permission}",
351
+ )
352
+
353
+
354
+ # The Query Engine handles granular permissions at the SQL level
355
+ # This decorator only validates basic authentication and scope levels
356
+
357
+
358
+ # Convenience decorators for Query Engine access levels
359
+ def requires_authentication():
360
+ """Require valid authentication but allow any scopes."""
361
+ return claims_check(require_authentication=True)
362
+
363
+
364
+ def requires_read_access():
365
+ """Require at least read-level access."""
366
+ return claims_check(min_scope_level="read")
367
+
368
+
369
+ def requires_write_access():
370
+ """Require at least write-level access."""
371
+ return claims_check(min_scope_level="write")
372
+
373
+
374
+ def requires_delete_access():
375
+ """Require at least delete-level access."""
376
+ return claims_check(min_scope_level="delete")
377
+
378
+
379
+ def requires_admin_access():
380
+ """Require admin-level access."""
381
+ return claims_check(min_scope_level="admin")
382
+
383
+
384
+ # Additional utility functions for testing and convenience
385
+ def requires_read_scope(entity: Optional[str] = None, extract_from_path: bool = False):
386
+ """Convenience decorator for read operations."""
387
+ # Parameters kept for compatibility but not used in simple implementation
388
+ _ = entity, extract_from_path # Silence unused warnings
389
+ return claims_check(min_scope_level="read")
390
+
391
+
392
+ def requires_write_scope(entity: Optional[str] = None, extract_from_path: bool = False):
393
+ """Convenience decorator for write operations."""
394
+ # Parameters kept for compatibility but not used in simple implementation
395
+ _ = entity, extract_from_path # Silence unused warnings
396
+ return claims_check(min_scope_level="write")
397
+
398
+
399
+ def _extract_operation_type(event: Dict[str, Any]) -> str:
400
+ """Extract operation type from HTTP method."""
401
+ method = event.get("httpMethod", "GET").upper()
402
+ if method == "GET":
403
+ return "read"
404
+ elif method in ["POST", "PUT", "PATCH"]:
405
+ return "write"
406
+ elif method == "DELETE":
407
+ return "delete"
408
+ else:
409
+ return "read" # default
410
+
411
+
412
+ def _extract_entity_from_path(event: Dict[str, Any]) -> Optional[str]:
413
+ """Extract entity name from request path."""
414
+ path = event.get("path") or event.get("resource", "")
415
+ if not path:
416
+ return None
417
+
418
+ # Remove leading slash and split by slash
419
+ path_parts = path.lstrip("/").split("/")
420
+
421
+ # Skip API prefixes and return the last non-parameter part
422
+ # For paths like "/chinook-api/album" or "/api/v1/customer/123"
423
+ for part in reversed(path_parts):
424
+ if part and not part.startswith("{") and not part.isdigit():
425
+ return part
426
+
427
+ # If no suitable part found, return the last non-parameter part
428
+ for part in reversed(path_parts):
429
+ if part and not part.startswith("{"):
430
+ return part
431
+
432
+ return None
433
+
434
+
435
+ def _scope_matches(
436
+ user_scopes: list, required_scope: str, operation: str, entity: str
437
+ ) -> bool:
438
+ """Check if user scopes match the required scope."""
439
+ _ = entity # Silence unused warning
440
+ # Check for exact match
441
+ if required_scope in user_scopes:
442
+ return True
443
+
444
+ # Check for wildcard matches
445
+ wildcard_patterns = [
446
+ f"{operation}:*", # operation wildcard
447
+ "*:*", # global wildcard
448
+ "*", # simple wildcard
449
+ ]
450
+
451
+ for pattern in wildcard_patterns:
452
+ if pattern in user_scopes:
453
+ return True
454
+
455
+ return False
456
+
457
+
458
+ def _permission_matches(user_permissions: list, required_permission: str) -> bool:
459
+ """Check if user permissions match the required permission."""
460
+ # Direct match
461
+ if required_permission in user_permissions:
462
+ return True
463
+
464
+ # Wildcard match (e.g., "customer.*" matches "customer.read")
465
+ for perm in user_permissions:
466
+ if perm.endswith(".*"):
467
+ prefix = perm[:-2] # Remove ".*"
468
+ if required_permission.startswith(prefix + "."):
469
+ return True
470
+
471
+ return False
@@ -0,0 +1,157 @@
1
+ """
2
+ Dependency Resolver for Batch Operations
3
+
4
+ Performs topological sorting of operations based on their dependency relationships.
5
+ Detects circular dependencies and provides clear error messages.
6
+ """
7
+
8
+ from typing import List, Dict, Set
9
+ from api_foundry_query_engine.utils.app_exception import ApplicationException
10
+ from api_foundry_query_engine.utils.logger import logger
11
+
12
+ log = logger(__name__)
13
+
14
+
15
+ class DependencyResolver:
16
+ """Resolves operation dependencies and determines execution order."""
17
+
18
+ def __init__(self, operations: List[Dict]):
19
+ """
20
+ Initialize the dependency resolver.
21
+
22
+ Args:
23
+ operations: List of operation dictionaries with 'id' and optional 'depends_on'
24
+ """
25
+ self.operations = operations
26
+ self.op_map = {op["id"]: op for op in operations}
27
+ self._validate_operations()
28
+
29
+ def _validate_operations(self):
30
+ """Validate that operations have unique IDs and valid dependencies."""
31
+ # Check for unique IDs
32
+ ids = [op["id"] for op in self.operations]
33
+ if len(ids) != len(set(ids)):
34
+ duplicates = [id for id in ids if ids.count(id) > 1]
35
+ raise ApplicationException(
36
+ 400, f"Duplicate operation IDs found: {set(duplicates)}"
37
+ )
38
+
39
+ # Check that all dependencies reference valid operation IDs
40
+ for op in self.operations:
41
+ depends_on = op.get("depends_on", [])
42
+ for dep_id in depends_on:
43
+ if dep_id not in self.op_map:
44
+ raise ApplicationException(
45
+ 400,
46
+ f"Operation '{op['id']}' depends on unknown operation '{dep_id}'",
47
+ )
48
+
49
+ def get_execution_order(self) -> List[str]:
50
+ """
51
+ Determine execution order using topological sort (Kahn's algorithm).
52
+
53
+ Returns:
54
+ List of operation IDs in execution order
55
+
56
+ Raises:
57
+ ApplicationException: If circular dependencies detected
58
+ """
59
+ # Build adjacency list and in-degree map
60
+ # graph maps: operation_id -> list of operations it depends on
61
+ graph = {op["id"]: op.get("depends_on", []) for op in self.operations}
62
+ in_degree = {op_id: 0 for op_id in graph}
63
+
64
+ # Calculate in-degrees - how many dependencies each operation has
65
+ for op_id, dependencies in graph.items():
66
+ in_degree[op_id] = len(dependencies)
67
+
68
+ # Find all nodes with no dependencies (in-degree = 0)
69
+ queue = [op_id for op_id, degree in in_degree.items() if degree == 0]
70
+ result = []
71
+
72
+ while queue:
73
+ # Remove node from queue
74
+ node = queue.pop(0)
75
+ result.append(node)
76
+
77
+ # For each operation that depends on this node, reduce in-degree
78
+ for op_id, dependencies in graph.items():
79
+ if node in dependencies:
80
+ in_degree[op_id] -= 1
81
+ if in_degree[op_id] == 0:
82
+ queue.append(op_id)
83
+
84
+ # If result doesn't contain all nodes, there's a cycle
85
+ if len(result) != len(graph):
86
+ cycle = self._find_cycle(graph)
87
+ raise ApplicationException(
88
+ 400, f"Circular dependency detected: {' -> '.join(cycle)}"
89
+ )
90
+
91
+ log.info(f"Execution order determined: {result}")
92
+ return result
93
+
94
+ def _find_cycle(self, graph: Dict[str, List[str]]) -> List[str]:
95
+ """
96
+ Find and return a cycle in the dependency graph for error reporting.
97
+
98
+ Args:
99
+ graph: Adjacency list representation of dependencies
100
+
101
+ Returns:
102
+ List of operation IDs forming a cycle
103
+ """
104
+ visited = set()
105
+ rec_stack = set()
106
+
107
+ def dfs(node: str, path: List[str]) -> List[str]:
108
+ visited.add(node)
109
+ rec_stack.add(node)
110
+ path.append(node)
111
+
112
+ # Check all dependencies
113
+ for dependency in graph.get(node, []):
114
+ if dependency not in visited:
115
+ cycle = dfs(dependency, path[:])
116
+ if cycle:
117
+ return cycle
118
+ elif dependency in rec_stack:
119
+ # Found cycle - return the cycle path
120
+ cycle_start = path.index(dependency)
121
+ return path[cycle_start:] + [dependency]
122
+
123
+ rec_stack.remove(node)
124
+ return []
125
+
126
+ for node in graph:
127
+ if node not in visited:
128
+ cycle = dfs(node, [])
129
+ if cycle:
130
+ return cycle
131
+
132
+ return []
133
+
134
+ def get_independent_operations(self) -> Set[str]:
135
+ """
136
+ Get operations that have no dependencies (can execute immediately).
137
+
138
+ Returns:
139
+ Set of operation IDs with no dependencies
140
+ """
141
+ return {op["id"] for op in self.operations if not op.get("depends_on", [])}
142
+
143
+ def get_dependents(self, op_id: str) -> List[str]:
144
+ """
145
+ Get all operations that depend on the given operation.
146
+
147
+ Args:
148
+ op_id: Operation ID to check
149
+
150
+ Returns:
151
+ List of operation IDs that depend on op_id
152
+ """
153
+ dependents = []
154
+ for op in self.operations:
155
+ if op_id in op.get("depends_on", []):
156
+ dependents.append(op["id"])
157
+ return dependents