acex-devkit 1.2.0__tar.gz → 1.2.1__tar.gz
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.
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/PKG-INFO +1 -1
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/pyproject.toml +1 -1
- acex_devkit-1.2.1/src/acex_devkit/configdiffer/configdiffer.py +158 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/diff.py +4 -2
- acex_devkit-1.2.0/src/acex_devkit/configdiffer/configdiffer.py +0 -178
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/README.md +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/command.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/base.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/base_driver.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/exceptions/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/acl_model.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/attribute_value.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/composed_configuration.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/external_value.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/logging.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/ned.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/node_response.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/spanning_tree.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/types/__init__.py +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Any, Dict, List, Optional, get_origin, get_args
|
|
3
|
+
|
|
4
|
+
from acex_devkit.models.composed_configuration import ComposedConfiguration
|
|
5
|
+
from acex_devkit.models.attribute_value import AttributeValue
|
|
6
|
+
from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigDiffer:
|
|
10
|
+
|
|
11
|
+
def _is_dict_of_models(self, annotation) -> bool:
|
|
12
|
+
"""Check if a type annotation is Dict[str, SomeBaseModel]."""
|
|
13
|
+
origin = get_origin(annotation)
|
|
14
|
+
if origin is dict:
|
|
15
|
+
args = get_args(annotation)
|
|
16
|
+
if len(args) == 2 and isinstance(args[1], type) and issubclass(args[1], BaseModel):
|
|
17
|
+
return True
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
def _is_base_model(self, annotation) -> bool:
|
|
21
|
+
"""Check if a type annotation is a BaseModel subclass (not Dict, not AttributeValue)."""
|
|
22
|
+
return (
|
|
23
|
+
isinstance(annotation, type)
|
|
24
|
+
and issubclass(annotation, BaseModel)
|
|
25
|
+
and not issubclass(annotation, AttributeValue)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _unwrap_optional(self, annotation):
|
|
29
|
+
"""Unwrap Optional[X] to X. Returns the annotation unchanged if not Optional."""
|
|
30
|
+
origin = get_origin(annotation)
|
|
31
|
+
if origin is type(None):
|
|
32
|
+
return annotation
|
|
33
|
+
args = get_args(annotation)
|
|
34
|
+
if args:
|
|
35
|
+
# Optional[X] is Union[X, None]
|
|
36
|
+
non_none = [a for a in args if a is not type(None)]
|
|
37
|
+
if len(non_none) == 1:
|
|
38
|
+
return non_none[0]
|
|
39
|
+
return annotation
|
|
40
|
+
|
|
41
|
+
def _flatten(self, model: BaseModel, path: tuple = ()) -> Dict[tuple, BaseModel]:
|
|
42
|
+
"""
|
|
43
|
+
Recursively walk a Pydantic model using type annotations to find
|
|
44
|
+
all components. Returns a flat mapping of path → model instance.
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
- Dict[str, BaseModel] → component collection, each entry is a leaf
|
|
48
|
+
- BaseModel (not in Dict) → container, recurse deeper
|
|
49
|
+
- AttributeValue / primitives / None → skip (attributes, not components)
|
|
50
|
+
"""
|
|
51
|
+
result = {}
|
|
52
|
+
|
|
53
|
+
for field_name, field_info in model.model_fields.items():
|
|
54
|
+
annotation = self._unwrap_optional(field_info.annotation)
|
|
55
|
+
value = getattr(model, field_name)
|
|
56
|
+
|
|
57
|
+
if value is None:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if self._is_dict_of_models(annotation):
|
|
61
|
+
# Component collection: each entry is a diffable component
|
|
62
|
+
for key, component in value.items():
|
|
63
|
+
component_path = path + (field_name, key)
|
|
64
|
+
result[component_path] = component
|
|
65
|
+
|
|
66
|
+
elif self._is_base_model(annotation):
|
|
67
|
+
# Recurse deeper — if nothing found, this model itself is a leaf component
|
|
68
|
+
child_path = path + (field_name,)
|
|
69
|
+
deeper = self._flatten(value, child_path)
|
|
70
|
+
if deeper:
|
|
71
|
+
result.update(deeper)
|
|
72
|
+
else:
|
|
73
|
+
result[child_path] = value
|
|
74
|
+
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
def _attribute_changes(self, before: BaseModel, after: BaseModel) -> List[AttributeChange]:
|
|
78
|
+
"""
|
|
79
|
+
Compare two component model instances and return a list of changed attributes.
|
|
80
|
+
"""
|
|
81
|
+
changes = []
|
|
82
|
+
all_fields = set(before.model_fields.keys()) | set(after.model_fields.keys())
|
|
83
|
+
|
|
84
|
+
for field_name in all_fields:
|
|
85
|
+
b = getattr(before, field_name, None)
|
|
86
|
+
a = getattr(after, field_name, None)
|
|
87
|
+
if b != a:
|
|
88
|
+
changes.append(AttributeChange(attribute_name=field_name, before=b, after=a))
|
|
89
|
+
|
|
90
|
+
return changes
|
|
91
|
+
|
|
92
|
+
def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
|
|
93
|
+
"""
|
|
94
|
+
Compare two ComposedConfiguration objects and return a component-based diff.
|
|
95
|
+
|
|
96
|
+
Uses Pydantic model introspection to find all components (entries in
|
|
97
|
+
Dict[str, BaseModel] fields). Components are identified by their full
|
|
98
|
+
path, e.g. ('interfaces', 'GigabitEthernet0/0/1').
|
|
99
|
+
"""
|
|
100
|
+
flat_desired = self._flatten(desired_config)
|
|
101
|
+
flat_observed = self._flatten(observed_config)
|
|
102
|
+
|
|
103
|
+
desired_paths = set(flat_desired.keys())
|
|
104
|
+
observed_paths = set(flat_observed.keys())
|
|
105
|
+
|
|
106
|
+
added = []
|
|
107
|
+
removed = []
|
|
108
|
+
changed = []
|
|
109
|
+
|
|
110
|
+
for path in desired_paths - observed_paths:
|
|
111
|
+
obj = flat_desired[path]
|
|
112
|
+
added.append(ComponentChange(
|
|
113
|
+
op=ComponentDiffOp.ADD,
|
|
114
|
+
path=list(path),
|
|
115
|
+
component_type=type(obj),
|
|
116
|
+
component_name=path[-1],
|
|
117
|
+
before=None,
|
|
118
|
+
after=obj,
|
|
119
|
+
before_dict=None,
|
|
120
|
+
after_dict=obj.model_dump(),
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
for path in observed_paths - desired_paths:
|
|
124
|
+
obj = flat_observed[path]
|
|
125
|
+
removed.append(ComponentChange(
|
|
126
|
+
op=ComponentDiffOp.REMOVE,
|
|
127
|
+
path=list(path),
|
|
128
|
+
component_type=type(obj),
|
|
129
|
+
component_name=path[-1],
|
|
130
|
+
before=obj,
|
|
131
|
+
after=None,
|
|
132
|
+
before_dict=obj.model_dump(),
|
|
133
|
+
after_dict=None,
|
|
134
|
+
))
|
|
135
|
+
|
|
136
|
+
for path in desired_paths & observed_paths:
|
|
137
|
+
desired_obj = flat_desired[path]
|
|
138
|
+
observed_obj = flat_observed[path]
|
|
139
|
+
if desired_obj != observed_obj:
|
|
140
|
+
changed.append(ComponentChange(
|
|
141
|
+
op=ComponentDiffOp.CHANGE,
|
|
142
|
+
path=list(path),
|
|
143
|
+
component_type=type(desired_obj),
|
|
144
|
+
component_name=path[-1],
|
|
145
|
+
before=observed_obj,
|
|
146
|
+
after=desired_obj,
|
|
147
|
+
before_dict=observed_obj.model_dump(),
|
|
148
|
+
after_dict=desired_obj.model_dump(),
|
|
149
|
+
changed_attributes=self._attribute_changes(observed_obj, desired_obj),
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
return Diff(
|
|
153
|
+
added=added,
|
|
154
|
+
removed=removed,
|
|
155
|
+
changed=changed,
|
|
156
|
+
total_desired=len(desired_paths),
|
|
157
|
+
total_observed=len(observed_paths),
|
|
158
|
+
)
|
|
@@ -22,9 +22,11 @@ class ComponentDiffOp(str, Enum):
|
|
|
22
22
|
|
|
23
23
|
class AttributeChange(BaseModel):
|
|
24
24
|
"""Represents a change to a single attribute within a component"""
|
|
25
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
26
|
+
|
|
25
27
|
attribute_name: str
|
|
26
|
-
before: Optional[
|
|
27
|
-
after: Optional[
|
|
28
|
+
before: Optional[Any] = None
|
|
29
|
+
after: Optional[Any] = None
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class ComponentChange(BaseModel):
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
from pydantic import BaseModel
|
|
2
|
-
from typing import Any, Dict, List, Iterator, Tuple
|
|
3
|
-
|
|
4
|
-
from acex_devkit.models.composed_configuration import ComposedConfiguration, Reference, ReferenceTo, ReferenceFrom, Metadata
|
|
5
|
-
from acex_devkit.models.attribute_value import AttributeValue
|
|
6
|
-
from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ConfigDiffer:
|
|
12
|
-
|
|
13
|
-
IGNORED_KEYS = ["metadata", "logging", "ssh", "snmp", "network_instances", "interfaces", "ntp", "domain_name", "location", "contact", "motd_banner"]
|
|
14
|
-
|
|
15
|
-
def _clean_metadata(self, obj: dict) -> dict:
|
|
16
|
-
"""
|
|
17
|
-
Recursively ignored keys.
|
|
18
|
-
"""
|
|
19
|
-
if not isinstance(obj, dict):
|
|
20
|
-
return obj
|
|
21
|
-
cleaned = {}
|
|
22
|
-
for k, v in obj.items():
|
|
23
|
-
if k in self.__class__.IGNORED_KEYS:
|
|
24
|
-
continue
|
|
25
|
-
if isinstance(v, dict):
|
|
26
|
-
cleaned[k] = self._clean_metadata(v)
|
|
27
|
-
elif isinstance(v, list):
|
|
28
|
-
cleaned[k] = [self._clean_metadata(item) if isinstance(item, dict) else item for item in v]
|
|
29
|
-
else:
|
|
30
|
-
cleaned[k] = v
|
|
31
|
-
return cleaned
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _dump_to_dicts(self, config: ComposedConfiguration) -> dict:
|
|
35
|
-
"""
|
|
36
|
-
Dumps to dict, removes unnecessary keys except component_class.
|
|
37
|
-
"""
|
|
38
|
-
# Remove most metadata, but keep component_class for type identification
|
|
39
|
-
config_dict = config.model_dump(exclude_unset=True)
|
|
40
|
-
return self._clean_metadata(config_dict)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _flatten(self, d: dict, path: tuple = ()) -> Dict[tuple, dict]:
|
|
44
|
-
"""
|
|
45
|
-
Recursively walk the tree and collect every component (dict with
|
|
46
|
-
'component_class') into a flat mapping of path → component_dict.
|
|
47
|
-
|
|
48
|
-
Example result:
|
|
49
|
-
{
|
|
50
|
-
('interfaces', 'GigabitEthernet0/0/1'): {...},
|
|
51
|
-
('network_instances', 'default', 'vlans', '100'): {...},
|
|
52
|
-
}
|
|
53
|
-
"""
|
|
54
|
-
result = {}
|
|
55
|
-
for key, value in d.items():
|
|
56
|
-
current_path = path + (key,)
|
|
57
|
-
if not isinstance(value, dict):
|
|
58
|
-
continue
|
|
59
|
-
# Try to find deeper components first
|
|
60
|
-
deeper = self._flatten(value, current_path)
|
|
61
|
-
if deeper:
|
|
62
|
-
# Sub-components found — use those (don't emit current level)
|
|
63
|
-
result.update(deeper)
|
|
64
|
-
elif any(isinstance(v, dict) and 'value' in v for v in value.values()):
|
|
65
|
-
# No sub-components, but this dict has AttributeValue fields → it's a leaf
|
|
66
|
-
result[current_path] = value
|
|
67
|
-
return result
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _get_by_path(self, config: ComposedConfiguration, path: tuple) -> Any:
|
|
72
|
-
"""
|
|
73
|
-
Traverse the ComposedConfiguration object using a path tuple and return
|
|
74
|
-
the actual Pydantic model instance (or primitive) at that location.
|
|
75
|
-
|
|
76
|
-
Example:
|
|
77
|
-
path = ('interfaces', 'GigabitEthernet0/0/1')
|
|
78
|
-
→ returns the actual SoftwareLoopbackInterface / EthernetCsmacdInterface etc.
|
|
79
|
-
"""
|
|
80
|
-
obj = config
|
|
81
|
-
for key in path:
|
|
82
|
-
if isinstance(obj, dict):
|
|
83
|
-
obj = obj[key]
|
|
84
|
-
else:
|
|
85
|
-
obj = getattr(obj, key)
|
|
86
|
-
return obj
|
|
87
|
-
|
|
88
|
-
def _attribute_changes(self, before: dict, after: dict) -> List[AttributeChange]:
|
|
89
|
-
"""
|
|
90
|
-
Compare two component dicts and return a list of changed attributes.
|
|
91
|
-
"""
|
|
92
|
-
changes = []
|
|
93
|
-
for key in set(before.keys()) | set(after.keys()):
|
|
94
|
-
b = before.get(key)
|
|
95
|
-
a = after.get(key)
|
|
96
|
-
if b != a:
|
|
97
|
-
changes.append(AttributeChange(attribute_name=key, before=b, after=a))
|
|
98
|
-
return changes
|
|
99
|
-
|
|
100
|
-
def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
|
|
101
|
-
"""
|
|
102
|
-
Compare two ComposedConfiguration objects and return a component-based diff.
|
|
103
|
-
|
|
104
|
-
Walks both configs using Pydantic model structure (not heuristics) to find
|
|
105
|
-
all named components inside Dict[str, Model] containers. Components are
|
|
106
|
-
identified by their full path, e.g. ('interfaces', 'GigabitEthernet0/0/1').
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
desired_config: The target configuration we want to achieve.
|
|
110
|
-
observed_config: The current configuration as observed on the device.
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
Diff: A structured diff showing added, removed, and changed components.
|
|
114
|
-
"""
|
|
115
|
-
flat_observed = self._flatten(self._dump_to_dicts(observed_config))
|
|
116
|
-
flat_desired = self._flatten(self._dump_to_dicts(desired_config))
|
|
117
|
-
|
|
118
|
-
observed_paths = set(flat_observed.keys())
|
|
119
|
-
desired_paths = set(flat_desired.keys())
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
added = []
|
|
123
|
-
removed = []
|
|
124
|
-
changed = []
|
|
125
|
-
|
|
126
|
-
for path in desired_paths - observed_paths:
|
|
127
|
-
obj = self._get_by_path(desired_config, path)
|
|
128
|
-
added.append(ComponentChange(
|
|
129
|
-
op=ComponentDiffOp.ADD,
|
|
130
|
-
path=list(path),
|
|
131
|
-
component_type=type(obj),
|
|
132
|
-
component_name=path[-1],
|
|
133
|
-
before=None,
|
|
134
|
-
after=obj,
|
|
135
|
-
before_dict=None,
|
|
136
|
-
after_dict=flat_desired[path],
|
|
137
|
-
))
|
|
138
|
-
|
|
139
|
-
for path in observed_paths - desired_paths:
|
|
140
|
-
obj = self._get_by_path(observed_config, path)
|
|
141
|
-
removed.append(ComponentChange(
|
|
142
|
-
op=ComponentDiffOp.REMOVE,
|
|
143
|
-
path=list(path),
|
|
144
|
-
component_type=type(obj),
|
|
145
|
-
component_name=path[-1],
|
|
146
|
-
before=obj,
|
|
147
|
-
after=None,
|
|
148
|
-
before_dict=flat_observed[path],
|
|
149
|
-
after_dict=None,
|
|
150
|
-
))
|
|
151
|
-
|
|
152
|
-
for path in desired_paths & observed_paths:
|
|
153
|
-
desired_comp = flat_desired[path]
|
|
154
|
-
observed_comp = flat_observed[path]
|
|
155
|
-
if desired_comp != observed_comp:
|
|
156
|
-
desired_obj = self._get_by_path(desired_config, path)
|
|
157
|
-
observed_obj = self._get_by_path(observed_config, path)
|
|
158
|
-
changed.append(ComponentChange(
|
|
159
|
-
op=ComponentDiffOp.CHANGE,
|
|
160
|
-
path=list(path),
|
|
161
|
-
component_type=type(desired_obj),
|
|
162
|
-
component_name=path[-1],
|
|
163
|
-
before=observed_obj,
|
|
164
|
-
after=desired_obj,
|
|
165
|
-
before_dict=observed_comp,
|
|
166
|
-
after_dict=desired_comp,
|
|
167
|
-
changed_attributes=self._attribute_changes(observed_comp, desired_comp),
|
|
168
|
-
))
|
|
169
|
-
|
|
170
|
-
return Diff(
|
|
171
|
-
added=added,
|
|
172
|
-
removed=removed,
|
|
173
|
-
changed=changed,
|
|
174
|
-
total_desired=len(desired_paths),
|
|
175
|
-
total_observed=len(observed_paths),
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|