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.
Files changed (25) hide show
  1. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/PKG-INFO +1 -1
  2. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/pyproject.toml +1 -1
  3. acex_devkit-1.2.1/src/acex_devkit/configdiffer/configdiffer.py +158 -0
  4. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/diff.py +4 -2
  5. acex_devkit-1.2.0/src/acex_devkit/configdiffer/configdiffer.py +0 -178
  6. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/README.md +0 -0
  7. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/__init__.py +0 -0
  8. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
  9. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/command.py +0 -0
  10. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  11. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  12. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/__init__.py +0 -0
  13. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/base.py +0 -0
  14. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/drivers/base_driver.py +0 -0
  15. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/exceptions/__init__.py +0 -0
  16. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/__init__.py +0 -0
  17. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/acl_model.py +0 -0
  18. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/attribute_value.py +0 -0
  19. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/composed_configuration.py +0 -0
  20. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/external_value.py +0 -0
  21. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/logging.py +0 -0
  22. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/ned.py +0 -0
  23. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/node_response.py +0 -0
  24. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/models/spanning_tree.py +0 -0
  25. {acex_devkit-1.2.0 → acex_devkit-1.2.1}/src/acex_devkit/types/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acex-devkit
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: ACE-X DevKit - Development kit for building ACE-X drivers and plugins
5
5
  License: AGPL-3.0
6
6
  Keywords: automation,devkit,sdk,drivers,plugins
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "acex-devkit"
3
- version = "1.2.0"
3
+ version = "1.2.1"
4
4
  description = "ACE-X DevKit - Development kit for building ACE-X drivers and plugins"
5
5
  authors = ["Johan Lahti <johan.lahti@acebit.se>"]
6
6
  readme = "README.md"
@@ -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[AttributeValue[Any]] = None
27
- after: Optional[AttributeValue[Any]] = None
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