jentic-openapi-traverse 1.0.0a22__py3-none-any.whl → 1.0.0a23__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.
- jentic/apitools/openapi/traverse/datamodels/low/__init__.py +14 -0
- jentic/apitools/openapi/traverse/datamodels/low/introspection.py +114 -0
- jentic/apitools/openapi/traverse/datamodels/low/merge.py +111 -0
- jentic/apitools/openapi/traverse/datamodels/low/path.py +145 -0
- jentic/apitools/openapi/traverse/datamodels/low/py.typed +0 -0
- jentic/apitools/openapi/traverse/datamodels/low/traversal.py +235 -0
- jentic_openapi_traverse-1.0.0a23.dist-info/METADATA +491 -0
- jentic_openapi_traverse-1.0.0a23.dist-info/RECORD +14 -0
- jentic_openapi_traverse-1.0.0a22.dist-info/METADATA +0 -209
- jentic_openapi_traverse-1.0.0a22.dist-info/RECORD +0 -8
- {jentic_openapi_traverse-1.0.0a22.dist-info → jentic_openapi_traverse-1.0.0a23.dist-info}/WHEEL +0 -0
- {jentic_openapi_traverse-1.0.0a22.dist-info → jentic_openapi_traverse-1.0.0a23.dist-info}/licenses/LICENSE +0 -0
- {jentic_openapi_traverse-1.0.0a22.dist-info → jentic_openapi_traverse-1.0.0a23.dist-info}/licenses/NOTICE +0 -0
|
@@ -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,145 @@
|
|
|
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: Any | None # Parent datamodel object
|
|
23
|
+
parent_field: str | None # Field name in parent
|
|
24
|
+
parent_key: str | int | None # Key if parent field is list/dict
|
|
25
|
+
ancestors: tuple[Any, ...] # Tuple of ancestor nodes (root first)
|
|
26
|
+
|
|
27
|
+
def create_child(
|
|
28
|
+
self, node: Any, parent_field: str, parent_key: str | int | None
|
|
29
|
+
) -> "NodePath":
|
|
30
|
+
"""
|
|
31
|
+
Create a child NodePath from this path.
|
|
32
|
+
|
|
33
|
+
Helper for creating child paths during traversal.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
node: Child node
|
|
37
|
+
parent_field: Field name in current node
|
|
38
|
+
parent_key: Key if field is list/dict
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
New NodePath for the child
|
|
42
|
+
"""
|
|
43
|
+
return NodePath(
|
|
44
|
+
node=node,
|
|
45
|
+
parent=self.node,
|
|
46
|
+
parent_field=parent_field,
|
|
47
|
+
parent_key=parent_key,
|
|
48
|
+
ancestors=self.ancestors + (self.node,),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def traverse(self, visitor) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Traverse from this node as root (Babel pattern).
|
|
54
|
+
|
|
55
|
+
Allows convenient sub-traversal with a different visitor.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
visitor: Visitor object with visit_* methods
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
class PathItemVisitor:
|
|
62
|
+
def visit_PathItem(self, path):
|
|
63
|
+
# Only traverse GET operations
|
|
64
|
+
if path.node.get:
|
|
65
|
+
operation_visitor = OperationOnlyVisitor()
|
|
66
|
+
get_path = path.create_child(
|
|
67
|
+
node=path.node.get.value,
|
|
68
|
+
parent_field="get",
|
|
69
|
+
parent_key=None
|
|
70
|
+
)
|
|
71
|
+
get_path.traverse(operation_visitor)
|
|
72
|
+
return False # Skip automatic traversal
|
|
73
|
+
"""
|
|
74
|
+
from .traversal import traverse
|
|
75
|
+
|
|
76
|
+
traverse(self.node, visitor)
|
|
77
|
+
|
|
78
|
+
def format_path(
|
|
79
|
+
self, *, path_format: Literal["jsonpointer", "jsonpath"] = "jsonpointer"
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Format path as RFC 6901 JSON Pointer or RFC 9535 Normalized JSONPath.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path_format: Output format - "jsonpointer" (default) or "jsonpath"
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
JSONPointer string like "/paths/~1pets/get/responses/200"
|
|
89
|
+
or Normalized JSONPath like "$['paths']['/pets']['get']['responses']['200']"
|
|
90
|
+
|
|
91
|
+
Examples (jsonpointer):
|
|
92
|
+
"" (root)
|
|
93
|
+
"/info"
|
|
94
|
+
"/paths/~1pets/get"
|
|
95
|
+
"/paths/~1users~1{id}/parameters/0"
|
|
96
|
+
"/components/schemas/User/properties/name"
|
|
97
|
+
|
|
98
|
+
Examples (jsonpath):
|
|
99
|
+
"$" (root)
|
|
100
|
+
"$['info']"
|
|
101
|
+
"$['paths']['/pets']['get']"
|
|
102
|
+
"$['paths']['/users/{id}']['parameters'][0]"
|
|
103
|
+
"$['components']['schemas']['User']['properties']['name']"
|
|
104
|
+
"""
|
|
105
|
+
# Root node
|
|
106
|
+
if not self.ancestors and self.parent_field is None:
|
|
107
|
+
return "$" if path_format == "jsonpath" else ""
|
|
108
|
+
|
|
109
|
+
# Build parts list
|
|
110
|
+
parts: list[str | int] = []
|
|
111
|
+
|
|
112
|
+
# This is a simplified implementation that only captures one level
|
|
113
|
+
# A full implementation would need to walk back through ancestors
|
|
114
|
+
if self.parent_field:
|
|
115
|
+
parts.append(self.parent_field)
|
|
116
|
+
|
|
117
|
+
if self.parent_key is not None:
|
|
118
|
+
# Both int (array index) and str (object key) work with from_parts
|
|
119
|
+
parts.append(self.parent_key)
|
|
120
|
+
|
|
121
|
+
if path_format == "jsonpath":
|
|
122
|
+
# RFC 9535 Normalized JSONPath: $['field'][index]['key']
|
|
123
|
+
segments = ["$"]
|
|
124
|
+
for part in parts:
|
|
125
|
+
if isinstance(part, int):
|
|
126
|
+
# Array index: $[0]
|
|
127
|
+
segments.append(f"[{part}]")
|
|
128
|
+
else:
|
|
129
|
+
# Member name: $['field']
|
|
130
|
+
# Escape single quotes in the string
|
|
131
|
+
escaped = str(part).replace("'", "\\'")
|
|
132
|
+
segments.append(f"['{escaped}']")
|
|
133
|
+
return "".join(segments)
|
|
134
|
+
|
|
135
|
+
# RFC 6901 JSON Pointer
|
|
136
|
+
return JsonPointer.from_parts(parts).path
|
|
137
|
+
|
|
138
|
+
def get_root(self) -> Any:
|
|
139
|
+
"""
|
|
140
|
+
Get the root node of the tree.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Root datamodel object
|
|
144
|
+
"""
|
|
145
|
+
return self.ancestors[0] if self.ancestors else self.node
|
|
File without changes
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Core traversal functionality for low-level OpenAPI datamodels."""
|
|
2
|
+
|
|
3
|
+
from .introspection import get_traversable_fields, is_datamodel_node, unwrap_value
|
|
4
|
+
from .path import NodePath
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ["DataModelLowVisitor", "traverse", "BREAK"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _BreakType: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BREAK = _BreakType()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DataModelLowVisitor:
|
|
17
|
+
"""
|
|
18
|
+
Optional base class for OpenAPI datamodel visitors.
|
|
19
|
+
|
|
20
|
+
You don't need to inherit from this class - just implement visit_* methods.
|
|
21
|
+
Inheritance is optional and provides no functionality - use for organizational purposes only.
|
|
22
|
+
|
|
23
|
+
Visitor Method Signatures:
|
|
24
|
+
Generic hooks (fire for ALL nodes):
|
|
25
|
+
- visit_enter(path: NodePath) -> None | False | BREAK
|
|
26
|
+
- visit_leave(path: NodePath) -> None | False | BREAK
|
|
27
|
+
|
|
28
|
+
Class-specific hooks (fire for matching node types):
|
|
29
|
+
- visit_ClassName(path: NodePath) -> None | False | BREAK
|
|
30
|
+
- visit_enter_ClassName(path: NodePath) -> None | False | BREAK
|
|
31
|
+
- visit_leave_ClassName(path: NodePath) -> None | False | BREAK
|
|
32
|
+
|
|
33
|
+
Dispatch Order:
|
|
34
|
+
1. visit_enter(path) - generic enter
|
|
35
|
+
2. visit_enter_ClassName(path) - specific enter
|
|
36
|
+
3. visit_ClassName(path) - main visitor
|
|
37
|
+
4. [child traversal - automatic unless False returned]
|
|
38
|
+
5. visit_leave_ClassName(path) - specific leave
|
|
39
|
+
6. visit_leave(path) - generic leave
|
|
40
|
+
|
|
41
|
+
Return Values:
|
|
42
|
+
- None: Continue traversal normally (children visited automatically)
|
|
43
|
+
- False: Skip visiting children of this node
|
|
44
|
+
- BREAK: Stop entire traversal immediately
|
|
45
|
+
|
|
46
|
+
Example (with enter/leave hooks):
|
|
47
|
+
class MyVisitor(DataModelLowVisitor): # Optional inheritance
|
|
48
|
+
def visit_enter_Operation(self, path):
|
|
49
|
+
print(f"Entering: {path.format_path()}")
|
|
50
|
+
|
|
51
|
+
def visit_leave_Operation(self, path):
|
|
52
|
+
print(f"Leaving: {path.format_path()}")
|
|
53
|
+
|
|
54
|
+
class SimpleVisitor: # No inheritance (duck typing works too)
|
|
55
|
+
def visit_Operation(self, path):
|
|
56
|
+
print(f"Found: {path.format_path()}")
|
|
57
|
+
# Children automatically visited
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def traverse(root, visitor) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Traverse OpenAPI datamodel tree using visitor pattern.
|
|
66
|
+
|
|
67
|
+
The visitor can be any object with visit_* methods (duck typing).
|
|
68
|
+
Optionally inherit from DataModelLowVisitor for organizational purposes.
|
|
69
|
+
|
|
70
|
+
Children are automatically traversed unless a visitor method returns False.
|
|
71
|
+
Use enter/leave hooks for pre/post traversal logic.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
root: Root datamodel object (OpenAPI30, OpenAPI31, or any datamodel node)
|
|
75
|
+
visitor: Object with visit_* methods
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
# Using enter/leave hooks
|
|
79
|
+
class MyVisitor(DataModelLowVisitor):
|
|
80
|
+
def visit_enter_Operation(self, path):
|
|
81
|
+
print(f"Entering operation: {path.format_path()}")
|
|
82
|
+
|
|
83
|
+
def visit_leave_Operation(self, path):
|
|
84
|
+
print(f"Leaving operation: {path.format_path()}")
|
|
85
|
+
|
|
86
|
+
# Simple visitor (duck typing - no inheritance needed)
|
|
87
|
+
class SimpleVisitor:
|
|
88
|
+
def visit_Operation(self, path):
|
|
89
|
+
print(f"Found operation: {path.format_path()}")
|
|
90
|
+
# Children automatically visited
|
|
91
|
+
|
|
92
|
+
doc = parser.parse(..., return_type=DataModelLow)
|
|
93
|
+
traverse(doc, MyVisitor())
|
|
94
|
+
traverse(doc, SimpleVisitor())
|
|
95
|
+
"""
|
|
96
|
+
# Create initial root path
|
|
97
|
+
initial_path = NodePath(
|
|
98
|
+
node=root,
|
|
99
|
+
parent=None,
|
|
100
|
+
parent_field=None,
|
|
101
|
+
parent_key=None,
|
|
102
|
+
ancestors=(),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Start traversal
|
|
106
|
+
_visit_node(visitor, initial_path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _default_traverse_children(visitor, path: NodePath) -> _BreakType | None:
|
|
110
|
+
"""
|
|
111
|
+
Internal child traversal logic.
|
|
112
|
+
|
|
113
|
+
Iterates through traversable fields and visits datamodel children.
|
|
114
|
+
Called automatically during traversal.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
visitor: Visitor object with visit_* methods
|
|
118
|
+
path: Current node path
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
BREAK to stop traversal, None otherwise
|
|
122
|
+
"""
|
|
123
|
+
# Get all traversable fields
|
|
124
|
+
for field_name, field_value in get_traversable_fields(path.node):
|
|
125
|
+
unwrapped = unwrap_value(field_value)
|
|
126
|
+
|
|
127
|
+
# Handle single datamodel nodes
|
|
128
|
+
if is_datamodel_node(unwrapped):
|
|
129
|
+
child_path = path.create_child(node=unwrapped, parent_field=field_name, parent_key=None)
|
|
130
|
+
result = _visit_node(visitor, child_path)
|
|
131
|
+
if result is BREAK:
|
|
132
|
+
return BREAK
|
|
133
|
+
|
|
134
|
+
# Handle lists
|
|
135
|
+
elif isinstance(unwrapped, list):
|
|
136
|
+
for idx, item in enumerate(unwrapped):
|
|
137
|
+
if is_datamodel_node(item):
|
|
138
|
+
child_path = path.create_child(
|
|
139
|
+
node=item, parent_field=field_name, parent_key=idx
|
|
140
|
+
)
|
|
141
|
+
result = _visit_node(visitor, child_path)
|
|
142
|
+
if result is BREAK:
|
|
143
|
+
return BREAK
|
|
144
|
+
|
|
145
|
+
# Handle dicts
|
|
146
|
+
elif isinstance(unwrapped, dict):
|
|
147
|
+
for key, value in unwrapped.items():
|
|
148
|
+
unwrapped_key = unwrap_value(key)
|
|
149
|
+
unwrapped_value = unwrap_value(value)
|
|
150
|
+
|
|
151
|
+
if is_datamodel_node(unwrapped_value):
|
|
152
|
+
# Dict keys should be str after unwrapping (field names, paths, status codes, etc.)
|
|
153
|
+
assert isinstance(unwrapped_key, (str, int)), (
|
|
154
|
+
f"Expected str or int key, got {type(unwrapped_key)}"
|
|
155
|
+
)
|
|
156
|
+
child_path = path.create_child(
|
|
157
|
+
node=unwrapped_value,
|
|
158
|
+
parent_field=field_name,
|
|
159
|
+
parent_key=unwrapped_key,
|
|
160
|
+
)
|
|
161
|
+
result = _visit_node(visitor, child_path)
|
|
162
|
+
if result is BREAK:
|
|
163
|
+
return BREAK
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _visit_node(visitor, path: NodePath) -> _BreakType | None:
|
|
169
|
+
"""
|
|
170
|
+
Visit a single node with the visitor.
|
|
171
|
+
|
|
172
|
+
Handles enter/main/leave dispatch and control flow.
|
|
173
|
+
Duck typed - works with any object that has visit_* methods.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
visitor: Visitor object with visit_* methods
|
|
177
|
+
path: Current node path
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
BREAK to stop traversal, None otherwise
|
|
181
|
+
"""
|
|
182
|
+
node_class = path.node.__class__.__name__
|
|
183
|
+
|
|
184
|
+
# Generic enter hook: visit_enter (fires for ALL nodes)
|
|
185
|
+
if hasattr(visitor, "visit_enter"):
|
|
186
|
+
result = visitor.visit_enter(path)
|
|
187
|
+
if result is BREAK:
|
|
188
|
+
return BREAK
|
|
189
|
+
if result is False:
|
|
190
|
+
return None # Skip children, but continue traversal
|
|
191
|
+
|
|
192
|
+
# Try enter hook: visit_enter_ClassName
|
|
193
|
+
enter_method = f"visit_enter_{node_class}"
|
|
194
|
+
if hasattr(visitor, enter_method):
|
|
195
|
+
result = getattr(visitor, enter_method)(path)
|
|
196
|
+
if result is BREAK:
|
|
197
|
+
return BREAK
|
|
198
|
+
if result is False:
|
|
199
|
+
return None # Skip children, but continue traversal
|
|
200
|
+
|
|
201
|
+
# Try main visitor: visit_ClassName
|
|
202
|
+
visit_method = f"visit_{node_class}"
|
|
203
|
+
skip_auto_traverse = False
|
|
204
|
+
|
|
205
|
+
if hasattr(visitor, visit_method):
|
|
206
|
+
result = getattr(visitor, visit_method)(path)
|
|
207
|
+
# Only care about BREAK and False:
|
|
208
|
+
# - BREAK: stop entire traversal
|
|
209
|
+
# - False: skip children of this node
|
|
210
|
+
# - Any other value (None, True, etc.): continue normally
|
|
211
|
+
if result is BREAK:
|
|
212
|
+
return BREAK
|
|
213
|
+
if result is False:
|
|
214
|
+
skip_auto_traverse = True
|
|
215
|
+
|
|
216
|
+
# Automatic child traversal (unless explicitly skipped)
|
|
217
|
+
if not skip_auto_traverse:
|
|
218
|
+
result = _default_traverse_children(visitor, path)
|
|
219
|
+
if result is BREAK:
|
|
220
|
+
return BREAK
|
|
221
|
+
|
|
222
|
+
# Try leave hook: visit_leave_ClassName
|
|
223
|
+
leave_method = f"visit_leave_{node_class}"
|
|
224
|
+
if hasattr(visitor, leave_method):
|
|
225
|
+
result = getattr(visitor, leave_method)(path)
|
|
226
|
+
if result is BREAK:
|
|
227
|
+
return BREAK
|
|
228
|
+
|
|
229
|
+
# Generic leave hook: visit_leave (fires for ALL nodes)
|
|
230
|
+
if hasattr(visitor, "visit_leave"):
|
|
231
|
+
result = visitor.visit_leave(path)
|
|
232
|
+
if result is BREAK:
|
|
233
|
+
return BREAK
|
|
234
|
+
|
|
235
|
+
return None
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jentic-openapi-traverse
|
|
3
|
+
Version: 1.0.0a23
|
|
4
|
+
Summary: Jentic OpenAPI Traversal Utilities
|
|
5
|
+
Author: Jentic
|
|
6
|
+
Author-email: Jentic <hello@jentic.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: NOTICE
|
|
10
|
+
Requires-Dist: jentic-openapi-datamodels~=1.0.0a23
|
|
11
|
+
Requires-Dist: jsonpointer~=3.0.0
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Project-URL: Homepage, https://github.com/jentic/jentic-openapi-tools
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# jentic-openapi-traverse
|
|
17
|
+
|
|
18
|
+
A Python library for traversing OpenAPI documents. This package is part of the Jentic OpenAPI Tools ecosystem and provides two types of traversal:
|
|
19
|
+
|
|
20
|
+
1. **Datamodel Traversal** - OpenAPI-aware semantic traversal with visitor pattern
|
|
21
|
+
2. **JSON Traversal** - Generic depth-first traversal of JSON-like structures
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install jentic-openapi-traverse
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Prerequisites:**
|
|
30
|
+
- Python 3.11+
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Datamodel Traversal
|
|
35
|
+
|
|
36
|
+
OpenAPI-aware semantic traversal using the visitor pattern. Works with low-level datamodels from `jentic-openapi-datamodels` package, preserving source location information and providing structured access to OpenAPI nodes.
|
|
37
|
+
|
|
38
|
+
### Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from jentic.apitools.openapi.parser.core import OpenAPIParser
|
|
42
|
+
from jentic.apitools.openapi.traverse.datamodels.low import traverse, DataModelLowVisitor
|
|
43
|
+
|
|
44
|
+
# Parse OpenAPI document
|
|
45
|
+
parser = OpenAPIParser("datamodel-low")
|
|
46
|
+
doc = parser.parse("file:///path/to/openapi.yaml")
|
|
47
|
+
|
|
48
|
+
# Create visitor
|
|
49
|
+
class OperationCollector(DataModelLowVisitor):
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self.operations = []
|
|
52
|
+
|
|
53
|
+
def visit_Operation(self, path):
|
|
54
|
+
self.operations.append({
|
|
55
|
+
"path": path.format_path(path_format="jsonpointer"),
|
|
56
|
+
"operation_id": path.node.operation_id.value if path.node.operation_id else None
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
# Traverse
|
|
60
|
+
visitor = OperationCollector()
|
|
61
|
+
traverse(doc, visitor)
|
|
62
|
+
|
|
63
|
+
print(f"Found {len(visitor.operations)} operations")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Visitor Pattern
|
|
67
|
+
|
|
68
|
+
The datamodel traversal uses a flexible visitor pattern with multiple hook types:
|
|
69
|
+
|
|
70
|
+
#### Hook Methods
|
|
71
|
+
|
|
72
|
+
**Generic hooks** (fire for ALL nodes):
|
|
73
|
+
- `visit_enter(path)` - Called before processing any node
|
|
74
|
+
- `visit_leave(path)` - Called after processing any node and its children
|
|
75
|
+
|
|
76
|
+
**Class-specific hooks** (fire for matching node types):
|
|
77
|
+
- `visit_ClassName(path)` - Main visitor for specific node type
|
|
78
|
+
- `visit_enter_ClassName(path)` - Called before visit_ClassName
|
|
79
|
+
- `visit_leave_ClassName(path)` - Called after children are visited
|
|
80
|
+
|
|
81
|
+
#### Dispatch Order
|
|
82
|
+
|
|
83
|
+
For each node, hooks are called in this order:
|
|
84
|
+
1. `visit_enter(path)` - generic enter
|
|
85
|
+
2. `visit_enter_ClassName(path)` - specific enter
|
|
86
|
+
3. `visit_ClassName(path)` - main visitor
|
|
87
|
+
4. [child traversal - automatic unless skipped]
|
|
88
|
+
5. `visit_leave_ClassName(path)` - specific leave
|
|
89
|
+
6. `visit_leave(path)` - generic leave
|
|
90
|
+
|
|
91
|
+
#### Control Flow
|
|
92
|
+
|
|
93
|
+
Visitor methods control traversal by their return value:
|
|
94
|
+
|
|
95
|
+
- `None` (or no return) - **Continue normally** (children visited automatically)
|
|
96
|
+
- `False` - **Skip children** of this node (but continue to siblings)
|
|
97
|
+
- `BREAK` - **Stop entire traversal** immediately
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from jentic.apitools.openapi.traverse.datamodels.low import traverse, BREAK
|
|
101
|
+
|
|
102
|
+
class ControlFlowExample:
|
|
103
|
+
def visit_PathItem(self, path):
|
|
104
|
+
if path.parent_key == "/internal":
|
|
105
|
+
return False # Skip internal paths and their children
|
|
106
|
+
|
|
107
|
+
def visit_Operation(self, path):
|
|
108
|
+
if some_error_condition:
|
|
109
|
+
return BREAK # Stop entire traversal
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### NodePath Context
|
|
113
|
+
|
|
114
|
+
Every visitor method receives a `NodePath` object with context about the current node:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
class PathInspector:
|
|
118
|
+
def visit_Operation(self, path):
|
|
119
|
+
# Current node
|
|
120
|
+
print(f"Node: {path.node.__class__.__name__}")
|
|
121
|
+
|
|
122
|
+
# Parent information
|
|
123
|
+
print(f"Parent field: {path.parent_field}") # e.g., "get", "post"
|
|
124
|
+
print(f"Parent key: {path.parent_key}") # e.g., "/users" (for path items)
|
|
125
|
+
|
|
126
|
+
# Ancestry
|
|
127
|
+
print(f"Ancestors: {len(path.ancestors)}")
|
|
128
|
+
root = path.get_root()
|
|
129
|
+
|
|
130
|
+
# Path formatting
|
|
131
|
+
print(f"JSONPointer: {path.format_path()}") # /paths/~1users/get
|
|
132
|
+
print(f"JSONPath: {path.format_path(path_format='jsonpath')}") # $['paths']['/users']['get']
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Enter/Leave Hooks
|
|
136
|
+
|
|
137
|
+
Use enter/leave hooks for pre/post processing logic:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
class DepthTracker(DataModelLowVisitor):
|
|
141
|
+
def __init__(self):
|
|
142
|
+
self.current_depth = 0
|
|
143
|
+
self.max_depth = 0
|
|
144
|
+
|
|
145
|
+
def visit_enter(self, path):
|
|
146
|
+
self.current_depth += 1
|
|
147
|
+
self.max_depth = max(self.max_depth, self.current_depth)
|
|
148
|
+
print(" " * self.current_depth + f"Entering {path.node.__class__.__name__}")
|
|
149
|
+
|
|
150
|
+
def visit_leave(self, path):
|
|
151
|
+
print(" " * self.current_depth + f"Leaving {path.node.__class__.__name__}")
|
|
152
|
+
self.current_depth -= 1
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Examples
|
|
156
|
+
|
|
157
|
+
#### Collecting All Schemas
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
class SchemaCollector(DataModelLowVisitor):
|
|
161
|
+
def __init__(self):
|
|
162
|
+
self.schemas = {}
|
|
163
|
+
|
|
164
|
+
def visit_Schema(self, path):
|
|
165
|
+
schema_name = path.parent_key if path.parent_field == "schemas" else None
|
|
166
|
+
if schema_name:
|
|
167
|
+
self.schemas[schema_name] = path.node
|
|
168
|
+
|
|
169
|
+
visitor = SchemaCollector()
|
|
170
|
+
traverse(doc, visitor)
|
|
171
|
+
print(f"Found {len(visitor.schemas)} schemas")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Validating Security Requirements
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
class SecurityValidator(DataModelLowVisitor):
|
|
178
|
+
def __init__(self):
|
|
179
|
+
self.errors = []
|
|
180
|
+
|
|
181
|
+
def visit_Operation(self, path):
|
|
182
|
+
if not path.node.security:
|
|
183
|
+
self.errors.append(f"Missing security at {path.format_path()}")
|
|
184
|
+
|
|
185
|
+
def visit_SecurityRequirement(self, path):
|
|
186
|
+
# Validate security requirement
|
|
187
|
+
if not path.node.schemes:
|
|
188
|
+
self.errors.append(f"Empty security requirement at {path.format_path()}")
|
|
189
|
+
|
|
190
|
+
visitor = SecurityValidator()
|
|
191
|
+
traverse(doc, visitor)
|
|
192
|
+
if visitor.errors:
|
|
193
|
+
for error in visitor.errors:
|
|
194
|
+
print(f"Security error: {error}")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
#### Finding Deprecated Operations
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
class DeprecatedFinder:
|
|
201
|
+
def __init__(self):
|
|
202
|
+
self.deprecated_ops = []
|
|
203
|
+
|
|
204
|
+
def visit_Operation(self, path):
|
|
205
|
+
if path.node.deprecated and path.node.deprecated.value:
|
|
206
|
+
self.deprecated_ops.append({
|
|
207
|
+
"path": path.format_path(),
|
|
208
|
+
"operation_id": path.node.operation_id.value if path.node.operation_id else None,
|
|
209
|
+
"method": path.parent_field
|
|
210
|
+
})
|
|
211
|
+
return False # Skip children (we don't need to go deeper)
|
|
212
|
+
|
|
213
|
+
visitor = DeprecatedFinder()
|
|
214
|
+
traverse(doc, visitor)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Early Exit on Error
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
class ErrorDetector(DataModelLowVisitor):
|
|
221
|
+
def __init__(self):
|
|
222
|
+
self.error_found = False
|
|
223
|
+
self.error_location = None
|
|
224
|
+
|
|
225
|
+
def visit_Operation(self, path):
|
|
226
|
+
if not path.node.responses:
|
|
227
|
+
self.error_found = True
|
|
228
|
+
self.error_location = path.format_path()
|
|
229
|
+
return BREAK # Stop traversal immediately
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Merging Multiple Visitors
|
|
233
|
+
|
|
234
|
+
Run multiple visitors in a single traversal pass (parallel visitation) using `merge_visitors`:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from jentic.apitools.openapi.traverse.datamodels.low import merge_visitors
|
|
238
|
+
|
|
239
|
+
# Create separate visitors
|
|
240
|
+
schema_collector = SchemaCollector()
|
|
241
|
+
security_validator = SecurityValidator()
|
|
242
|
+
deprecated_finder = DeprecatedFinder()
|
|
243
|
+
|
|
244
|
+
# Merge and traverse once
|
|
245
|
+
merged = merge_visitors(schema_collector, security_validator, deprecated_finder)
|
|
246
|
+
traverse(doc, merged)
|
|
247
|
+
|
|
248
|
+
# Each visitor maintains independent state
|
|
249
|
+
print(f"Schemas: {len(schema_collector.schemas)}")
|
|
250
|
+
print(f"Security errors: {len(security_validator.errors)}")
|
|
251
|
+
print(f"Deprecated: {len(deprecated_finder.deprecated_ops)}")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Per-Visitor Control Flow:**
|
|
255
|
+
- Each visitor can independently skip subtrees or break
|
|
256
|
+
- If `visitor1` returns `False`, only `visitor1` skips children
|
|
257
|
+
- Other visitors continue normally
|
|
258
|
+
- This follows ApiDOM's per-visitor semantics
|
|
259
|
+
|
|
260
|
+
### Duck Typing
|
|
261
|
+
|
|
262
|
+
You don't need to inherit from `DataModelLowVisitor` - duck typing works:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
class SimpleCounter: # No inheritance
|
|
266
|
+
def __init__(self):
|
|
267
|
+
self.count = 0
|
|
268
|
+
|
|
269
|
+
def visit_Operation(self, path):
|
|
270
|
+
self.count += 1
|
|
271
|
+
|
|
272
|
+
visitor = SimpleCounter()
|
|
273
|
+
traverse(doc, visitor)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The `DataModelLowVisitor` base class is optional and provides no functionality - it's purely for organizational purposes.
|
|
277
|
+
|
|
278
|
+
### API Reference
|
|
279
|
+
|
|
280
|
+
#### `traverse(root, visitor) -> None`
|
|
281
|
+
|
|
282
|
+
Traverse OpenAPI datamodel tree using visitor pattern.
|
|
283
|
+
|
|
284
|
+
**Parameters:**
|
|
285
|
+
- `root` - Root datamodel object (OpenAPI30, OpenAPI31, or any datamodel node)
|
|
286
|
+
- `visitor` - Object with `visit_*` methods (duck typing)
|
|
287
|
+
|
|
288
|
+
**Returns:**
|
|
289
|
+
- None (traversal is side-effect based)
|
|
290
|
+
|
|
291
|
+
#### `BREAK`
|
|
292
|
+
|
|
293
|
+
Sentinel value to stop traversal immediately. Return this from any visitor method.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from jentic.apitools.openapi.traverse.datamodels.low import BREAK
|
|
297
|
+
|
|
298
|
+
def visit_Operation(self, path):
|
|
299
|
+
if should_stop:
|
|
300
|
+
return BREAK
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
#### `merge_visitors(*visitors) -> object`
|
|
304
|
+
|
|
305
|
+
Merge multiple visitors into one composite visitor.
|
|
306
|
+
|
|
307
|
+
**Parameters:**
|
|
308
|
+
- `*visitors` - Variable number of visitor objects
|
|
309
|
+
|
|
310
|
+
**Returns:**
|
|
311
|
+
- Composite visitor object with per-visitor state tracking
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
## JSON Traversal
|
|
315
|
+
|
|
316
|
+
Generic depth-first traversal of any JSON-like structure (dicts, lists, scalars).
|
|
317
|
+
Works with raw parsed OpenAPI documents or any other JSON data.
|
|
318
|
+
|
|
319
|
+
### Quick Start
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
from jentic.apitools.openapi.traverse.json import traverse
|
|
323
|
+
|
|
324
|
+
# Traverse a nested structure
|
|
325
|
+
data = {
|
|
326
|
+
"openapi": "3.1.0",
|
|
327
|
+
"info": {"title": "My API", "version": "1.0.0"},
|
|
328
|
+
"paths": {
|
|
329
|
+
"/users": {
|
|
330
|
+
"get": {"summary": "List users"}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# Walk all nodes
|
|
336
|
+
for node in traverse(data):
|
|
337
|
+
print(f"{node.format_path()}: {node.value}")
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Output:
|
|
341
|
+
```
|
|
342
|
+
openapi: 3.1.0
|
|
343
|
+
info: {'title': 'My API', 'version': '1.0.0'}
|
|
344
|
+
info.title: My API
|
|
345
|
+
info.version: 1.0.0
|
|
346
|
+
paths: {'/users': {'get': {'summary': 'List users'}}}
|
|
347
|
+
paths./users: {'get': {'summary': 'List users'}}
|
|
348
|
+
paths./users.get: {'summary': 'List users'}
|
|
349
|
+
paths./users.get.summary: List users
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Working with Paths
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
from jentic.apitools.openapi.traverse.json import traverse
|
|
356
|
+
|
|
357
|
+
data = {
|
|
358
|
+
"users": [
|
|
359
|
+
{"name": "Alice", "email": "alice@example.com"},
|
|
360
|
+
{"name": "Bob", "email": "bob@example.com"}
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for node in traverse(data):
|
|
365
|
+
# Access path information
|
|
366
|
+
print(f"Path: {node.path}")
|
|
367
|
+
print(f"Segment: {node.segment}")
|
|
368
|
+
print(f"Full path: {node.full_path}")
|
|
369
|
+
print(f"Formatted: {node.format_path()}")
|
|
370
|
+
print(f"Depth: {len(node.ancestors)}")
|
|
371
|
+
print()
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Custom Path Formatting
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
for node in traverse(data):
|
|
378
|
+
# Default dot separator
|
|
379
|
+
print(node.format_path()) # e.g., "paths./users.get.summary"
|
|
380
|
+
|
|
381
|
+
# Custom separator
|
|
382
|
+
print(node.format_path(separator="/")) # e.g., "paths//users/get/summary"
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Finding Specific Nodes
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
# Find all $ref references in a document
|
|
389
|
+
refs = [
|
|
390
|
+
node.value["$ref"]
|
|
391
|
+
for node in traverse(openapi_doc)
|
|
392
|
+
if isinstance(node.value, dict) and "$ref" in node.value
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
# Find all nodes at a specific path segment
|
|
396
|
+
schemas = [
|
|
397
|
+
node.value
|
|
398
|
+
for node in traverse(openapi_doc)
|
|
399
|
+
if node.segment == "schema"
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
# Find deeply nested values
|
|
403
|
+
response_descriptions = [
|
|
404
|
+
node.value
|
|
405
|
+
for node in traverse(openapi_doc)
|
|
406
|
+
if node.segment == "description" and "responses" in node.path
|
|
407
|
+
]
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### API Reference
|
|
411
|
+
|
|
412
|
+
#### `traverse(root: JSONValue) -> Iterator[TraversalNode]`
|
|
413
|
+
|
|
414
|
+
Performs depth-first traversal of a JSON-like structure.
|
|
415
|
+
|
|
416
|
+
**Parameters:**
|
|
417
|
+
- `root`: The data structure to traverse (dict, list, or scalar)
|
|
418
|
+
|
|
419
|
+
**Returns:**
|
|
420
|
+
- Iterator of `TraversalNode` objects
|
|
421
|
+
|
|
422
|
+
**Yields:**
|
|
423
|
+
- For dicts: one node per key-value pair
|
|
424
|
+
- For lists: one node per index-item pair
|
|
425
|
+
- Scalars at root don't yield nodes (but are accessible via parent nodes)
|
|
426
|
+
|
|
427
|
+
#### `TraversalNode`
|
|
428
|
+
|
|
429
|
+
Immutable dataclass representing a node encountered during traversal.
|
|
430
|
+
|
|
431
|
+
**Attributes:**
|
|
432
|
+
- `path: JSONPath` - Path from root to the parent container (tuple of segments)
|
|
433
|
+
- `parent: JSONContainer` - The parent container (dict or list)
|
|
434
|
+
- `segment: PathSeg` - The key (for dicts) or index (for lists) within parent
|
|
435
|
+
- `value: JSONValue` - The actual value at `parent[segment]`
|
|
436
|
+
- `ancestors: tuple[JSONValue, ...]` - Ordered tuple of values from root down to (but not including) parent
|
|
437
|
+
|
|
438
|
+
**Properties:**
|
|
439
|
+
- `full_path: JSONPath` - Complete path from root to this value (`path + (segment,)`)
|
|
440
|
+
|
|
441
|
+
**Methods:**
|
|
442
|
+
- `format_path(separator: str = ".") -> str` - Format the full path as a human-readable string
|
|
443
|
+
|
|
444
|
+
### Usage Examples
|
|
445
|
+
|
|
446
|
+
#### Collecting All Schemas
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from jentic.apitools.openapi.traverse.json import traverse
|
|
450
|
+
|
|
451
|
+
def collect_schemas(openapi_doc):
|
|
452
|
+
"""Collect all schema objects from an OpenAPI document."""
|
|
453
|
+
schemas = []
|
|
454
|
+
|
|
455
|
+
for node in traverse(openapi_doc):
|
|
456
|
+
if node.segment == "schema" and isinstance(node.value, dict):
|
|
457
|
+
schemas.append({
|
|
458
|
+
"path": node.format_path(),
|
|
459
|
+
"schema": node.value
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
return schemas
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
#### Analyzing Document Structure
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
def analyze_depth(data):
|
|
470
|
+
"""Analyze the depth distribution of a document."""
|
|
471
|
+
max_depth = 0
|
|
472
|
+
depth_counts = {}
|
|
473
|
+
|
|
474
|
+
for node in traverse(data):
|
|
475
|
+
depth = len(node.ancestors)
|
|
476
|
+
max_depth = max(max_depth, depth)
|
|
477
|
+
depth_counts[depth] = depth_counts.get(depth, 0) + 1
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
"max_depth": max_depth,
|
|
481
|
+
"depth_distribution": depth_counts
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Testing
|
|
486
|
+
|
|
487
|
+
The package includes comprehensive test coverage for JSON traversal:
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
uv run --package jentic-openapi-traverse pytest packages/jentic-openapi-traverse/tests -v
|
|
491
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
jentic/apitools/openapi/traverse/datamodels/low/__init__.py,sha256=M0xRiYA2ErksACo8bKSVIu6PVx9aiYTDCHEkcTnmXAA,277
|
|
2
|
+
jentic/apitools/openapi/traverse/datamodels/low/introspection.py,sha256=_QzvTnxrSlyRc_QIajyEkaKEwyng7t1y_qx9dEXxwg8,3216
|
|
3
|
+
jentic/apitools/openapi/traverse/datamodels/low/merge.py,sha256=Z7OpX14y740oC2ML_ylTE0TTh-ZbrEaliMU9s1bDFfM,4411
|
|
4
|
+
jentic/apitools/openapi/traverse/datamodels/low/path.py,sha256=MUqqxzEjOQGzt-TPlhWyh40xXG5B3ABYKvMWxmKgU1Q,4715
|
|
5
|
+
jentic/apitools/openapi/traverse/datamodels/low/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
jentic/apitools/openapi/traverse/datamodels/low/traversal.py,sha256=aAObD_JFRnD2I6EqLgZo3KFL3jkoRxarMcnUqZeMpm0,8090
|
|
7
|
+
jentic/apitools/openapi/traverse/json/__init__.py,sha256=1euUmpZviE_ECtpXYchpO8hZito2BINPjfSHMNqAU8k,326
|
|
8
|
+
jentic/apitools/openapi/traverse/json/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
jentic/apitools/openapi/traverse/json/traversal.py,sha256=1ouszn4S29X0iJaMxxb1neyClbWXqIKwFGhHROcpBSI,3524
|
|
10
|
+
jentic_openapi_traverse-1.0.0a23.dist-info/licenses/LICENSE,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
|
|
11
|
+
jentic_openapi_traverse-1.0.0a23.dist-info/licenses/NOTICE,sha256=pAOGW-rGw9KNc2cuuLWZkfx0GSTV4TicbgBKZSLPMIs,168
|
|
12
|
+
jentic_openapi_traverse-1.0.0a23.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
13
|
+
jentic_openapi_traverse-1.0.0a23.dist-info/METADATA,sha256=mR38IoYfZFJ7_V-sswXIbQyxUbFypNK6V6q4lrge5EI,13572
|
|
14
|
+
jentic_openapi_traverse-1.0.0a23.dist-info/RECORD,,
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: jentic-openapi-traverse
|
|
3
|
-
Version: 1.0.0a22
|
|
4
|
-
Summary: Jentic OpenAPI Traversal Utilities
|
|
5
|
-
Author: Jentic
|
|
6
|
-
Author-email: Jentic <hello@jentic.com>
|
|
7
|
-
License-Expression: Apache-2.0
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
License-File: NOTICE
|
|
10
|
-
Requires-Python: >=3.11
|
|
11
|
-
Project-URL: Homepage, https://github.com/jentic/jentic-openapi-tools
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
|
-
# jentic-openapi-traverse
|
|
15
|
-
|
|
16
|
-
A Python library for traversing OpenAPI documents. This package is part of the Jentic OpenAPI Tools ecosystem and provides two types of traversal:
|
|
17
|
-
|
|
18
|
-
1. **JSON Traversal** (current) - Generic depth-first traversal of JSON-like structures
|
|
19
|
-
2. **Datamodel Traversal** (planned) - OpenAPI-aware semantic traversal with visitor pattern
|
|
20
|
-
|
|
21
|
-
## Installation
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
pip install jentic-openapi-traverse
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
**Prerequisites:**
|
|
28
|
-
- Python 3.11+
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## JSON Traversal (Current Implementation)
|
|
33
|
-
|
|
34
|
-
Generic depth-first traversal of any JSON-like structure (dicts, lists, scalars).
|
|
35
|
-
Works with raw parsed OpenAPI documents or any other JSON data.
|
|
36
|
-
|
|
37
|
-
### Quick Start
|
|
38
|
-
|
|
39
|
-
```python
|
|
40
|
-
from jentic.apitools.openapi.traverse.json import traverse
|
|
41
|
-
|
|
42
|
-
# Traverse a nested structure
|
|
43
|
-
data = {
|
|
44
|
-
"openapi": "3.1.0",
|
|
45
|
-
"info": {"title": "My API", "version": "1.0.0"},
|
|
46
|
-
"paths": {
|
|
47
|
-
"/users": {
|
|
48
|
-
"get": {"summary": "List users"}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
# Walk all nodes
|
|
54
|
-
for node in traverse(data):
|
|
55
|
-
print(f"{node.format_path()}: {node.value}")
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Output:
|
|
59
|
-
```
|
|
60
|
-
openapi: 3.1.0
|
|
61
|
-
info: {'title': 'My API', 'version': '1.0.0'}
|
|
62
|
-
info.title: My API
|
|
63
|
-
info.version: 1.0.0
|
|
64
|
-
paths: {'/users': {'get': {'summary': 'List users'}}}
|
|
65
|
-
paths./users: {'get': {'summary': 'List users'}}
|
|
66
|
-
paths./users.get: {'summary': 'List users'}
|
|
67
|
-
paths./users.get.summary: List users
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
### Working with Paths
|
|
71
|
-
|
|
72
|
-
```python
|
|
73
|
-
from jentic.apitools.openapi.traverse.json import traverse
|
|
74
|
-
|
|
75
|
-
data = {
|
|
76
|
-
"users": [
|
|
77
|
-
{"name": "Alice", "email": "alice@example.com"},
|
|
78
|
-
{"name": "Bob", "email": "bob@example.com"}
|
|
79
|
-
]
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
for node in traverse(data):
|
|
83
|
-
# Access path information
|
|
84
|
-
print(f"Path: {node.path}")
|
|
85
|
-
print(f"Segment: {node.segment}")
|
|
86
|
-
print(f"Full path: {node.full_path}")
|
|
87
|
-
print(f"Formatted: {node.format_path()}")
|
|
88
|
-
print(f"Depth: {len(node.ancestors)}")
|
|
89
|
-
print()
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Custom Path Formatting
|
|
93
|
-
|
|
94
|
-
```python
|
|
95
|
-
for node in traverse(data):
|
|
96
|
-
# Default dot separator
|
|
97
|
-
print(node.format_path()) # e.g., "paths./users.get.summary"
|
|
98
|
-
|
|
99
|
-
# Custom separator
|
|
100
|
-
print(node.format_path(separator="/")) # e.g., "paths//users/get/summary"
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
### Finding Specific Nodes
|
|
104
|
-
|
|
105
|
-
```python
|
|
106
|
-
# Find all $ref references in a document
|
|
107
|
-
refs = [
|
|
108
|
-
node.value["$ref"]
|
|
109
|
-
for node in traverse(openapi_doc)
|
|
110
|
-
if isinstance(node.value, dict) and "$ref" in node.value
|
|
111
|
-
]
|
|
112
|
-
|
|
113
|
-
# Find all nodes at a specific path segment
|
|
114
|
-
schemas = [
|
|
115
|
-
node.value
|
|
116
|
-
for node in traverse(openapi_doc)
|
|
117
|
-
if node.segment == "schema"
|
|
118
|
-
]
|
|
119
|
-
|
|
120
|
-
# Find deeply nested values
|
|
121
|
-
response_descriptions = [
|
|
122
|
-
node.value
|
|
123
|
-
for node in traverse(openapi_doc)
|
|
124
|
-
if node.segment == "description" and "responses" in node.path
|
|
125
|
-
]
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### API Reference
|
|
129
|
-
|
|
130
|
-
#### `traverse(root: JSONValue) -> Iterator[TraversalNode]`
|
|
131
|
-
|
|
132
|
-
Performs depth-first traversal of a JSON-like structure.
|
|
133
|
-
|
|
134
|
-
**Parameters:**
|
|
135
|
-
- `root`: The data structure to traverse (dict, list, or scalar)
|
|
136
|
-
|
|
137
|
-
**Returns:**
|
|
138
|
-
- Iterator of `TraversalNode` objects
|
|
139
|
-
|
|
140
|
-
**Yields:**
|
|
141
|
-
- For dicts: one node per key-value pair
|
|
142
|
-
- For lists: one node per index-item pair
|
|
143
|
-
- Scalars at root don't yield nodes (but are accessible via parent nodes)
|
|
144
|
-
|
|
145
|
-
#### `TraversalNode`
|
|
146
|
-
|
|
147
|
-
Immutable dataclass representing a node encountered during traversal.
|
|
148
|
-
|
|
149
|
-
**Attributes:**
|
|
150
|
-
- `path: JSONPath` - Path from root to the parent container (tuple of segments)
|
|
151
|
-
- `parent: JSONContainer` - The parent container (dict or list)
|
|
152
|
-
- `segment: PathSeg` - The key (for dicts) or index (for lists) within parent
|
|
153
|
-
- `value: JSONValue` - The actual value at `parent[segment]`
|
|
154
|
-
- `ancestors: tuple[JSONValue, ...]` - Ordered tuple of values from root down to (but not including) parent
|
|
155
|
-
|
|
156
|
-
**Properties:**
|
|
157
|
-
- `full_path: JSONPath` - Complete path from root to this value (`path + (segment,)`)
|
|
158
|
-
|
|
159
|
-
**Methods:**
|
|
160
|
-
- `format_path(separator: str = ".") -> str` - Format the full path as a human-readable string
|
|
161
|
-
|
|
162
|
-
### Usage Examples
|
|
163
|
-
|
|
164
|
-
#### Collecting All Schemas
|
|
165
|
-
|
|
166
|
-
```python
|
|
167
|
-
from jentic.apitools.openapi.traverse.json import traverse
|
|
168
|
-
|
|
169
|
-
def collect_schemas(openapi_doc):
|
|
170
|
-
"""Collect all schema objects from an OpenAPI document."""
|
|
171
|
-
schemas = []
|
|
172
|
-
|
|
173
|
-
for node in traverse(openapi_doc):
|
|
174
|
-
if node.segment == "schema" and isinstance(node.value, dict):
|
|
175
|
-
schemas.append({
|
|
176
|
-
"path": node.format_path(),
|
|
177
|
-
"schema": node.value
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
return schemas
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
#### Analyzing Document Structure
|
|
185
|
-
|
|
186
|
-
```python
|
|
187
|
-
def analyze_depth(data):
|
|
188
|
-
"""Analyze the depth distribution of a document."""
|
|
189
|
-
max_depth = 0
|
|
190
|
-
depth_counts = {}
|
|
191
|
-
|
|
192
|
-
for node in traverse(data):
|
|
193
|
-
depth = len(node.ancestors)
|
|
194
|
-
max_depth = max(max_depth, depth)
|
|
195
|
-
depth_counts[depth] = depth_counts.get(depth, 0) + 1
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
"max_depth": max_depth,
|
|
199
|
-
"depth_distribution": depth_counts
|
|
200
|
-
}
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### Testing
|
|
204
|
-
|
|
205
|
-
The package includes comprehensive test coverage for JSON traversal:
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
uv run --package jentic-openapi-traverse pytest packages/jentic-openapi-traverse/tests -v
|
|
209
|
-
```
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
jentic/apitools/openapi/traverse/json/__init__.py,sha256=1euUmpZviE_ECtpXYchpO8hZito2BINPjfSHMNqAU8k,326
|
|
2
|
-
jentic/apitools/openapi/traverse/json/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
jentic/apitools/openapi/traverse/json/traversal.py,sha256=1ouszn4S29X0iJaMxxb1neyClbWXqIKwFGhHROcpBSI,3524
|
|
4
|
-
jentic_openapi_traverse-1.0.0a22.dist-info/licenses/LICENSE,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
|
|
5
|
-
jentic_openapi_traverse-1.0.0a22.dist-info/licenses/NOTICE,sha256=pAOGW-rGw9KNc2cuuLWZkfx0GSTV4TicbgBKZSLPMIs,168
|
|
6
|
-
jentic_openapi_traverse-1.0.0a22.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
7
|
-
jentic_openapi_traverse-1.0.0a22.dist-info/METADATA,sha256=5-XpBl4fy2yjMwJVJM6Qn0qwPzfA9wZHWuKjRkKosAY,5322
|
|
8
|
-
jentic_openapi_traverse-1.0.0a22.dist-info/RECORD,,
|
{jentic_openapi_traverse-1.0.0a22.dist-info → jentic_openapi_traverse-1.0.0a23.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|