jentic-openapi-traverse 1.0.0a22__py3-none-any.whl → 1.0.0a24__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.
@@ -0,0 +1,14 @@
1
+ """Low-level OpenAPI datamodel traversal."""
2
+
3
+ from .merge import merge_visitors
4
+ from .path import NodePath
5
+ from .traversal import BREAK, DataModelLowVisitor, traverse
6
+
7
+
8
+ __all__ = [
9
+ "DataModelLowVisitor",
10
+ "traverse",
11
+ "BREAK",
12
+ "NodePath",
13
+ "merge_visitors",
14
+ ]
@@ -0,0 +1,114 @@
1
+ """Utilities for introspecting datamodel fields."""
2
+
3
+ from dataclasses import is_dataclass
4
+ from typing import Any
5
+
6
+ from jentic.apitools.openapi.datamodels.low.fields import fixed_fields, patterned_fields
7
+ from jentic.apitools.openapi.datamodels.low.sources import (
8
+ FieldSource,
9
+ KeySource,
10
+ ValueSource,
11
+ )
12
+
13
+
14
+ __all__ = [
15
+ "get_traversable_fields",
16
+ "unwrap_value",
17
+ "is_datamodel_node",
18
+ ]
19
+
20
+
21
+ # Cache of field names to check per class type
22
+ # {Info: ["title", "description", "contact", ...], Operation: [...], ...}
23
+ _FIELD_NAMES_CACHE: dict[type, list[str]] = {}
24
+
25
+
26
+ def get_traversable_fields(node):
27
+ """
28
+ Get all fields that should be traversed in a datamodel node.
29
+
30
+ Uses field metadata (fixed_field, patterned_field) to identify OpenAPI
31
+ specification fields. This leverages the explicit field marking system
32
+ from the datamodels package.
33
+
34
+ Caches field names per class type for performance.
35
+
36
+ Args:
37
+ node: Datamodel node (dataclass instance)
38
+
39
+ Returns:
40
+ List of (field_name, field_value) tuples
41
+ """
42
+ if not is_dataclass(node):
43
+ return []
44
+
45
+ node_class = type(node)
46
+
47
+ # Get or compute field names for this class
48
+ if node_class not in _FIELD_NAMES_CACHE:
49
+ # Get all OpenAPI spec fields (fixed + patterned)
50
+ fixed = fixed_fields(node_class)
51
+ patterned = patterned_fields(node_class)
52
+ # Combine and extract field names
53
+ field_names = list(fixed.keys()) + list(patterned.keys())
54
+ _FIELD_NAMES_CACHE[node_class] = field_names
55
+
56
+ # Use cached field names
57
+ result = []
58
+ for field_name in _FIELD_NAMES_CACHE[node_class]:
59
+ value = getattr(node, field_name, None)
60
+
61
+ # Skip None values
62
+ if value is None:
63
+ continue
64
+
65
+ # Check if it's traversable
66
+ unwrapped = unwrap_value(value)
67
+
68
+ # Skip scalar primitives
69
+ if isinstance(unwrapped, (str, int, float, bool, type(None))):
70
+ continue
71
+
72
+ result.append((field_name, value))
73
+
74
+ return result
75
+
76
+
77
+ def unwrap_value(value: Any) -> Any:
78
+ """
79
+ Unwrap FieldSource/ValueSource/KeySource to get actual value.
80
+
81
+ Wrapper types (FieldSource, ValueSource, KeySource) are used to preserve
82
+ source location information for field values. This function extracts the
83
+ actual value from these wrappers.
84
+
85
+ This excludes datamodel nodes like Example which may have .value field
86
+ but are not wrapper types.
87
+
88
+ Args:
89
+ value: Potentially wrapped value
90
+
91
+ Returns:
92
+ Unwrapped value, or original if not wrapped
93
+ """
94
+ # Check if it's a wrapper type
95
+ if isinstance(value, (FieldSource, ValueSource, KeySource)):
96
+ return value.value
97
+ return value
98
+
99
+
100
+ def is_datamodel_node(value):
101
+ """
102
+ Check if value is a low-level datamodel object.
103
+
104
+ Low-level datamodels are distinguished by having a root_node field,
105
+ which contains the YAML source location information. This excludes
106
+ wrapper types (FieldSource, ValueSource, KeySource) and other dataclasses.
107
+
108
+ Args:
109
+ value: Value to check
110
+
111
+ Returns:
112
+ True if it's a low-level datamodel node, False otherwise
113
+ """
114
+ return is_dataclass(value) and hasattr(value, "root_node")
@@ -0,0 +1,111 @@
1
+ """Visitor merging utilities (ApiDOM pattern)."""
2
+
3
+ from .path import NodePath
4
+ from .traversal import BREAK
5
+
6
+
7
+ __all__ = ["merge_visitors"]
8
+
9
+
10
+ def merge_visitors(*visitors) -> object:
11
+ """
12
+ Merge multiple visitors into one composite visitor (ApiDOM semantics).
13
+
14
+ Each visitor maintains independent state:
15
+ - If visitor[i] returns False, only that visitor skips children (resumes when leaving)
16
+ - If visitor[i] returns BREAK, only that visitor stops permanently
17
+ - Other visitors continue normally
18
+
19
+ This matches ApiDOM's per-visitor control model where each visitor can
20
+ independently skip subtrees or stop without affecting other visitors.
21
+
22
+ Args:
23
+ *visitors: Visitor objects (with visit_* methods)
24
+
25
+ Returns:
26
+ A new visitor object that runs all visitors with independent state
27
+
28
+ Example:
29
+ security_check = SecurityCheckVisitor()
30
+ counter = OperationCounterVisitor()
31
+ validator = SchemaValidatorVisitor()
32
+
33
+ # Each visitor can skip/break independently
34
+ merged = merge_visitors(security_check, counter, validator)
35
+ traverse(doc, merged)
36
+
37
+ # If security_check.visit_PathItem returns False:
38
+ # - security_check skips PathItem's children
39
+ # - counter and validator still visit children normally
40
+ """
41
+
42
+ class MergedVisitor:
43
+ """Composite visitor with per-visitor state tracking (ApiDOM pattern)."""
44
+
45
+ def __init__(self, visitors):
46
+ self.visitors = visitors
47
+ # State per visitor: None = active, NodePath = skipping, BREAK = stopped
48
+ self._skipping_state: list[NodePath | object | None] = [None] * len(visitors)
49
+
50
+ def _is_active(self, visitor_idx):
51
+ """Check if visitor is active (not skipping or stopped)."""
52
+ return self._skipping_state[visitor_idx] is None
53
+
54
+ def _is_skipping_node(self, visitor_idx, path):
55
+ """Check if we're leaving the exact node this visitor skipped."""
56
+ state = self._skipping_state[visitor_idx]
57
+ if state is None or state is BREAK:
58
+ return False
59
+ # At this point, state must be a NodePath
60
+ # Compare node identity (not equality)
61
+ assert isinstance(state, NodePath)
62
+ return state.node is path.node
63
+
64
+ def __getattr__(self, name):
65
+ """
66
+ Dynamically handle visit_* method calls.
67
+
68
+ Maintains per-visitor state for skip/break control.
69
+
70
+ Args:
71
+ name: Method name being called
72
+
73
+ Returns:
74
+ Callable that merges visitor results with state tracking
75
+
76
+ Raises:
77
+ AttributeError: If not a visit_* method
78
+ """
79
+ if not name.startswith("visit"):
80
+ raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{name}'")
81
+
82
+ # Determine if this is a leave hook
83
+ is_leave_hook = name.startswith("visit_leave")
84
+
85
+ def merged_visit_method(path: NodePath):
86
+ for i, visitor in enumerate(self.visitors):
87
+ if is_leave_hook:
88
+ # Leave hook: only call if visitor is active
89
+ if self._is_active(i) and hasattr(visitor, name):
90
+ result = getattr(visitor, name)(path)
91
+ if result is BREAK:
92
+ self._skipping_state[i] = BREAK # Stop this visitor
93
+ # Resume visitor if leaving the skipped node (don't call leave hook)
94
+ elif self._is_skipping_node(i, path):
95
+ self._skipping_state[i] = None # Resume for next nodes
96
+ else:
97
+ # Enter/visit hook: only call if visitor is active
98
+ if self._is_active(i) and hasattr(visitor, name):
99
+ result = getattr(visitor, name)(path)
100
+
101
+ if result is BREAK:
102
+ self._skipping_state[i] = BREAK # Stop this visitor
103
+ elif result is False:
104
+ self._skipping_state[i] = path # Skip descendants
105
+
106
+ # Never return BREAK or False - let traversal continue for all visitors
107
+ return None
108
+
109
+ return merged_visit_method
110
+
111
+ return MergedVisitor(visitors)
@@ -0,0 +1,164 @@
1
+ """NodePath context for traversal."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+
6
+ from jsonpointer import JsonPointer
7
+
8
+
9
+ __all__ = ["NodePath"]
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class NodePath:
14
+ """
15
+ Context for a node during traversal.
16
+
17
+ Provides access to node, parent, ancestry, and path formatting.
18
+ Supports Babel-style sub-traversal via traverse() method.
19
+ """
20
+
21
+ node: Any # Current datamodel object
22
+ parent_path: "NodePath | None" # Reference to parent's NodePath (chain)
23
+ parent_field: str | None # Field name in parent
24
+ parent_key: str | int | None # Key if parent field is list/dict
25
+
26
+ @property
27
+ def parent(self) -> Any | None:
28
+ """Get parent node. Computed from parent_path for convenience."""
29
+ return self.parent_path.node if self.parent_path else None
30
+
31
+ @property
32
+ def ancestors(self) -> tuple[Any, ...]:
33
+ """Get ancestor nodes from root to parent. Computed for convenience."""
34
+ result = []
35
+ current = self.parent_path
36
+ while current is not None:
37
+ result.append(current.node)
38
+ current = current.parent_path
39
+ result.reverse() # Root first
40
+ return tuple(result)
41
+
42
+ def create_child(
43
+ self, node: Any, parent_field: str | None, parent_key: str | int | None
44
+ ) -> "NodePath":
45
+ """
46
+ Create a child NodePath from this path.
47
+
48
+ Helper for creating child paths during traversal.
49
+
50
+ Args:
51
+ node: Child node
52
+ parent_field: Field name in current node (None for dict items to avoid duplicates)
53
+ parent_key: Key if field is list/dict
54
+
55
+ Returns:
56
+ New NodePath for the child
57
+ """
58
+ return NodePath(
59
+ node=node,
60
+ parent_path=self,
61
+ parent_field=parent_field,
62
+ parent_key=parent_key,
63
+ )
64
+
65
+ def traverse(self, visitor) -> None:
66
+ """
67
+ Traverse from this node as root (Babel pattern).
68
+
69
+ Allows convenient sub-traversal with a different visitor.
70
+
71
+ Args:
72
+ visitor: Visitor object with visit_* methods
73
+
74
+ Example:
75
+ class PathItemVisitor:
76
+ def visit_PathItem(self, path):
77
+ # Only traverse GET operations
78
+ if path.node.get:
79
+ operation_visitor = OperationOnlyVisitor()
80
+ get_path = path.create_child(
81
+ node=path.node.get.value,
82
+ parent_field="get",
83
+ parent_key=None
84
+ )
85
+ get_path.traverse(operation_visitor)
86
+ return False # Skip automatic traversal
87
+ """
88
+ from .traversal import traverse
89
+
90
+ traverse(self.node, visitor)
91
+
92
+ def format_path(
93
+ self, *, path_format: Literal["jsonpointer", "jsonpath"] = "jsonpointer"
94
+ ) -> str:
95
+ """
96
+ Format path as RFC 6901 JSON Pointer or RFC 9535 Normalized JSONPath.
97
+
98
+ Args:
99
+ path_format: Output format - "jsonpointer" (default) or "jsonpath"
100
+
101
+ Returns:
102
+ JSONPointer string like "/paths/~1pets/get/responses/200"
103
+ or Normalized JSONPath like "$['paths']['/pets']['get']['responses']['200']"
104
+
105
+ Examples (jsonpointer):
106
+ "" (root)
107
+ "/info"
108
+ "/paths/~1pets/get"
109
+ "/paths/~1users~1{id}/parameters/0"
110
+ "/components/schemas/User/properties/name"
111
+
112
+ Examples (jsonpath):
113
+ "$" (root)
114
+ "$['info']"
115
+ "$['paths']['/pets']['get']"
116
+ "$['paths']['/users/{id}']['parameters'][0]"
117
+ "$['components']['schemas']['User']['properties']['name']"
118
+ """
119
+ # Root node
120
+ if self.parent_path is None:
121
+ return "$" if path_format == "jsonpath" else ""
122
+
123
+ # Walk back collecting all segments
124
+ segments: list[str | int] = []
125
+ current = self
126
+ while current.parent_path is not None:
127
+ # Add in reverse order (key first, then field) because we'll reverse the list
128
+ # This ensures field comes before key in the final path
129
+ if current.parent_key is not None:
130
+ segments.append(current.parent_key)
131
+ if current.parent_field:
132
+ segments.append(current.parent_field)
133
+ current = current.parent_path
134
+
135
+ segments.reverse() # Root to leaf order
136
+
137
+ if path_format == "jsonpath":
138
+ # RFC 9535 Normalized JSONPath: $['field'][index]['key']
139
+ result = ["$"]
140
+ for segment in segments:
141
+ if isinstance(segment, int):
142
+ # Array index: $[0]
143
+ result.append(f"[{segment}]")
144
+ else:
145
+ # Member name: $['field']
146
+ # Escape single quotes in the string
147
+ escaped = str(segment).replace("'", "\\'")
148
+ result.append(f"['{escaped}']")
149
+ return "".join(result)
150
+
151
+ # RFC 6901 JSON Pointer
152
+ return JsonPointer.from_parts(segments).path
153
+
154
+ def get_root(self) -> Any:
155
+ """
156
+ Get the root node of the tree.
157
+
158
+ Returns:
159
+ Root datamodel object
160
+ """
161
+ current = self
162
+ while current.parent_path is not None:
163
+ current = current.parent_path
164
+ return current.node
@@ -0,0 +1,245 @@
1
+ """Core traversal functionality for low-level OpenAPI datamodels."""
2
+
3
+ from jentic.apitools.openapi.datamodels.low.fields import patterned_fields
4
+
5
+ from .introspection import get_traversable_fields, is_datamodel_node, unwrap_value
6
+ from .path import NodePath
7
+
8
+
9
+ __all__ = ["DataModelLowVisitor", "traverse", "BREAK"]
10
+
11
+
12
+ class _BreakType: ...
13
+
14
+
15
+ BREAK = _BreakType()
16
+
17
+
18
+ class DataModelLowVisitor:
19
+ """
20
+ Optional base class for OpenAPI datamodel visitors.
21
+
22
+ You don't need to inherit from this class - just implement visit_* methods.
23
+ Inheritance is optional and provides no functionality - use for organizational purposes only.
24
+
25
+ Visitor Method Signatures:
26
+ Generic hooks (fire for ALL nodes):
27
+ - visit_enter(path: NodePath) -> None | False | BREAK
28
+ - visit_leave(path: NodePath) -> None | False | BREAK
29
+
30
+ Class-specific hooks (fire for matching node types):
31
+ - visit_ClassName(path: NodePath) -> None | False | BREAK
32
+ - visit_enter_ClassName(path: NodePath) -> None | False | BREAK
33
+ - visit_leave_ClassName(path: NodePath) -> None | False | BREAK
34
+
35
+ Dispatch Order:
36
+ 1. visit_enter(path) - generic enter
37
+ 2. visit_enter_ClassName(path) - specific enter
38
+ 3. visit_ClassName(path) - main visitor
39
+ 4. [child traversal - automatic unless False returned]
40
+ 5. visit_leave_ClassName(path) - specific leave
41
+ 6. visit_leave(path) - generic leave
42
+
43
+ Return Values:
44
+ - None: Continue traversal normally (children visited automatically)
45
+ - False: Skip visiting children of this node
46
+ - BREAK: Stop entire traversal immediately
47
+
48
+ Example (with enter/leave hooks):
49
+ class MyVisitor(DataModelLowVisitor): # Optional inheritance
50
+ def visit_enter_Operation(self, path):
51
+ print(f"Entering: {path.format_path()}")
52
+
53
+ def visit_leave_Operation(self, path):
54
+ print(f"Leaving: {path.format_path()}")
55
+
56
+ class SimpleVisitor: # No inheritance (duck typing works too)
57
+ def visit_Operation(self, path):
58
+ print(f"Found: {path.format_path()}")
59
+ # Children automatically visited
60
+ """
61
+
62
+ pass
63
+
64
+
65
+ def traverse(root, visitor) -> None:
66
+ """
67
+ Traverse OpenAPI datamodel tree using visitor pattern.
68
+
69
+ The visitor can be any object with visit_* methods (duck typing).
70
+ Optionally inherit from DataModelLowVisitor for organizational purposes.
71
+
72
+ Children are automatically traversed unless a visitor method returns False.
73
+ Use enter/leave hooks for pre/post traversal logic.
74
+
75
+ Args:
76
+ root: Root datamodel object (OpenAPI30, OpenAPI31, or any datamodel node)
77
+ visitor: Object with visit_* methods
78
+
79
+ Example:
80
+ # Using enter/leave hooks
81
+ class MyVisitor(DataModelLowVisitor):
82
+ def visit_enter_Operation(self, path):
83
+ print(f"Entering operation: {path.format_path()}")
84
+
85
+ def visit_leave_Operation(self, path):
86
+ print(f"Leaving operation: {path.format_path()}")
87
+
88
+ # Simple visitor (duck typing - no inheritance needed)
89
+ class SimpleVisitor:
90
+ def visit_Operation(self, path):
91
+ print(f"Found operation: {path.format_path()}")
92
+ # Children automatically visited
93
+
94
+ doc = parser.parse(..., return_type=DataModelLow)
95
+ traverse(doc, MyVisitor())
96
+ traverse(doc, SimpleVisitor())
97
+ """
98
+ # Create initial root path
99
+ initial_path = NodePath(
100
+ node=root,
101
+ parent_path=None,
102
+ parent_field=None,
103
+ parent_key=None,
104
+ )
105
+
106
+ # Start traversal
107
+ _visit_node(visitor, initial_path)
108
+
109
+
110
+ def _default_traverse_children(visitor, path: NodePath) -> _BreakType | None:
111
+ """
112
+ Internal child traversal logic.
113
+
114
+ Iterates through traversable fields and visits datamodel children.
115
+ Called automatically during traversal.
116
+
117
+ Args:
118
+ visitor: Visitor object with visit_* methods
119
+ path: Current node path
120
+
121
+ Returns:
122
+ BREAK to stop traversal, None otherwise
123
+ """
124
+ # Get all traversable fields
125
+ for field_name, field_value in get_traversable_fields(path.node):
126
+ unwrapped = unwrap_value(field_value)
127
+
128
+ # Handle single datamodel nodes
129
+ if is_datamodel_node(unwrapped):
130
+ child_path = path.create_child(node=unwrapped, parent_field=field_name, parent_key=None)
131
+ result = _visit_node(visitor, child_path)
132
+ if result is BREAK:
133
+ return BREAK
134
+
135
+ # Handle lists
136
+ elif isinstance(unwrapped, list):
137
+ for idx, item in enumerate(unwrapped):
138
+ if is_datamodel_node(item):
139
+ child_path = path.create_child(
140
+ node=item, parent_field=field_name, parent_key=idx
141
+ )
142
+ result = _visit_node(visitor, child_path)
143
+ if result is BREAK:
144
+ return BREAK
145
+
146
+ # Handle dicts
147
+ elif isinstance(unwrapped, dict):
148
+ # Check if this field is a patterned field
149
+ # Patterned fields (like Paths.paths, Components.schemas) should not
150
+ # add their field name to the path when iterating dict items
151
+ patterned_field_names = patterned_fields(type(path.node))
152
+ is_patterned = field_name in patterned_field_names
153
+
154
+ for key, value in unwrapped.items():
155
+ unwrapped_key = unwrap_value(key)
156
+ unwrapped_value = unwrap_value(value)
157
+
158
+ if is_datamodel_node(unwrapped_value):
159
+ # Dict keys should be str after unwrapping (field names, paths, status codes, etc.)
160
+ assert isinstance(unwrapped_key, (str, int)), (
161
+ f"Expected str or int key, got {type(unwrapped_key)}"
162
+ )
163
+ # For patterned fields, don't include the field name in the path
164
+ # (e.g., Paths.paths is patterned, so /paths/{path-key} not /paths/paths/{path-key})
165
+ dict_field_name: str | None = None if is_patterned else field_name
166
+ child_path = path.create_child(
167
+ node=unwrapped_value,
168
+ parent_field=dict_field_name,
169
+ parent_key=unwrapped_key,
170
+ )
171
+ result = _visit_node(visitor, child_path)
172
+ if result is BREAK:
173
+ return BREAK
174
+
175
+ return None
176
+
177
+
178
+ def _visit_node(visitor, path: NodePath) -> _BreakType | None:
179
+ """
180
+ Visit a single node with the visitor.
181
+
182
+ Handles enter/main/leave dispatch and control flow.
183
+ Duck typed - works with any object that has visit_* methods.
184
+
185
+ Args:
186
+ visitor: Visitor object with visit_* methods
187
+ path: Current node path
188
+
189
+ Returns:
190
+ BREAK to stop traversal, None otherwise
191
+ """
192
+ node_class = path.node.__class__.__name__
193
+
194
+ # Generic enter hook: visit_enter (fires for ALL nodes)
195
+ if hasattr(visitor, "visit_enter"):
196
+ result = visitor.visit_enter(path)
197
+ if result is BREAK:
198
+ return BREAK
199
+ if result is False:
200
+ return None # Skip children, but continue traversal
201
+
202
+ # Try enter hook: visit_enter_ClassName
203
+ enter_method = f"visit_enter_{node_class}"
204
+ if hasattr(visitor, enter_method):
205
+ result = getattr(visitor, enter_method)(path)
206
+ if result is BREAK:
207
+ return BREAK
208
+ if result is False:
209
+ return None # Skip children, but continue traversal
210
+
211
+ # Try main visitor: visit_ClassName
212
+ visit_method = f"visit_{node_class}"
213
+ skip_auto_traverse = False
214
+
215
+ if hasattr(visitor, visit_method):
216
+ result = getattr(visitor, visit_method)(path)
217
+ # Only care about BREAK and False:
218
+ # - BREAK: stop entire traversal
219
+ # - False: skip children of this node
220
+ # - Any other value (None, True, etc.): continue normally
221
+ if result is BREAK:
222
+ return BREAK
223
+ if result is False:
224
+ skip_auto_traverse = True
225
+
226
+ # Automatic child traversal (unless explicitly skipped)
227
+ if not skip_auto_traverse:
228
+ result = _default_traverse_children(visitor, path)
229
+ if result is BREAK:
230
+ return BREAK
231
+
232
+ # Try leave hook: visit_leave_ClassName
233
+ leave_method = f"visit_leave_{node_class}"
234
+ if hasattr(visitor, leave_method):
235
+ result = getattr(visitor, leave_method)(path)
236
+ if result is BREAK:
237
+ return BREAK
238
+
239
+ # Generic leave hook: visit_leave (fires for ALL nodes)
240
+ if hasattr(visitor, "visit_leave"):
241
+ result = visitor.visit_leave(path)
242
+ if result is BREAK:
243
+ return BREAK
244
+
245
+ return None