acex-devkit 1.3.0__tar.gz → 1.3.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 (26) hide show
  1. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/PKG-INFO +1 -1
  2. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/pyproject.toml +1 -1
  3. acex_devkit-1.3.1/src/acex_devkit/configdiffer/configdiffer.py +211 -0
  4. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/acl_model.py +8 -4
  5. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/attribute_value.py +8 -0
  6. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/composed_configuration.py +69 -50
  7. acex_devkit-1.3.1/src/acex_devkit/models/container_entry.py +42 -0
  8. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/logging.py +11 -4
  9. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/spanning_tree.py +12 -6
  10. acex_devkit-1.3.0/src/acex_devkit/configdiffer/configdiffer.py +0 -158
  11. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/README.md +0 -0
  12. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/__init__.py +0 -0
  13. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
  14. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/command.py +0 -0
  15. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/diff.py +0 -0
  16. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  17. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  18. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/__init__.py +0 -0
  19. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base.py +0 -0
  20. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base_driver.py +0 -0
  21. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/exceptions/__init__.py +0 -0
  22. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/__init__.py +0 -0
  23. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/external_value.py +0 -0
  24. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/ned.py +0 -0
  25. {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/node_response.py +0 -0
  26. {acex_devkit-1.3.0 → acex_devkit-1.3.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.3.0
3
+ Version: 1.3.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.3.0"
3
+ version = "1.3.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,211 @@
1
+ from collections import defaultdict
2
+ from pydantic import BaseModel
3
+ from typing import Any, Dict, List, get_args
4
+
5
+ from acex_devkit.models.composed_configuration import ComposedConfiguration
6
+ from acex_devkit.models.attribute_value import AttributeValue
7
+ from acex_devkit.models.container_entry import ContainerEntry
8
+ from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
9
+
10
+
11
+ class ConfigDiffer:
12
+
13
+ def _is_leaf(self, model: BaseModel) -> bool:
14
+ """A model is a leaf if it has at least one direct AttributeValue field."""
15
+ for field_info in model.model_fields.values():
16
+ ann = field_info.annotation
17
+ if isinstance(ann, type) and issubclass(ann, AttributeValue):
18
+ return True
19
+ for arg in get_args(ann):
20
+ if isinstance(arg, type) and issubclass(arg, AttributeValue):
21
+ return True
22
+ return False
23
+
24
+ def _flatten(self, model: BaseModel, path: tuple = ()) -> Dict[tuple, BaseModel]:
25
+ """
26
+ Recursively walk a model tree, collecting leaf nodes.
27
+ A leaf is any BaseModel that has direct AttributeValue fields.
28
+ A model can be both a leaf (stored) and have children (recursed into).
29
+ Returns a flat mapping of path → leaf instance.
30
+ """
31
+ result = {}
32
+
33
+ # Store this model if it has AttributeValue fields (e.g. SystemConfig, NtpServer, NetworkInstance)
34
+ if self._is_leaf(model):
35
+ result[path] = model
36
+
37
+ # Walk each field declared on the model (e.g. System has config, aaa, logging, ntp, ssh, snmp)
38
+ for field_name in model.model_fields:
39
+ value = getattr(model, field_name, None)
40
+ if value is None:
41
+ continue
42
+
43
+ # Skip data fields — AttributeValue holds actual config values (e.g. hostname, enabled)
44
+ if isinstance(value, AttributeValue):
45
+ continue
46
+
47
+ # Single nested model — recurse deeper (e.g. System.config → SystemConfig, System.ssh → Ssh)
48
+ if isinstance(value, BaseModel):
49
+ result.update(self._flatten(value, path + (field_name,)))
50
+
51
+ # Keyed collection — recurse into each entry (e.g. interfaces: Dict[str, Interface])
52
+ elif isinstance(value, dict):
53
+ for key, item in value.items():
54
+ # Each dict entry is a named component (e.g. "Gi0/0/1" → EthernetCsmacdInterface)
55
+ if isinstance(item, BaseModel):
56
+ result.update(self._flatten(item, path + (field_name, key)))
57
+
58
+ return result
59
+
60
+
61
+ def _attribute_changes(self, before: BaseModel, after: BaseModel) -> List[AttributeChange]:
62
+ """Compare two component instances and return a list of changed attributes."""
63
+ changes = []
64
+ all_fields = set(before.model_fields.keys()) | set(after.model_fields.keys())
65
+ for field_name in all_fields:
66
+ b = getattr(before, field_name, None)
67
+ a = getattr(after, field_name, None)
68
+ if b != a:
69
+ changes.append(AttributeChange(attribute_name=field_name, before=b, after=a))
70
+ return changes
71
+
72
+ def _identity_key(self, model: BaseModel) -> tuple:
73
+ """Extract identity values from a ContainerEntry for matching.
74
+ Returns a tuple of raw values (unwrapped from AttributeValue) from identity_fields."""
75
+ fields = getattr(model, "identity_fields", ())
76
+ values = []
77
+ for f in fields:
78
+ v = getattr(model, f, None)
79
+ # Unwrap AttributeValue to its raw value for hashing
80
+ if isinstance(v, AttributeValue):
81
+ v = v.value
82
+ values.append(v)
83
+ return tuple(values)
84
+
85
+ def _make_change(self, op: ComponentDiffOp, path: tuple, before=None, after=None) -> ComponentChange:
86
+ """Build a ComponentChange from an operation, path, and before/after models."""
87
+ obj = after or before
88
+ return ComponentChange(
89
+ op=op,
90
+ path=list(path),
91
+ component_type=type(obj),
92
+ component_name=path[-1] if path else "",
93
+ before=before,
94
+ after=after,
95
+ before_dict=before.model_dump() if before else None,
96
+ after_dict=after.model_dump() if after else None,
97
+ changed_attributes=self._attribute_changes(before, after) if (before and after) else [],
98
+ )
99
+
100
+ def _diff_singletons(self, desired: Dict[tuple, BaseModel], observed: Dict[tuple, BaseModel]) -> List[ComponentChange]:
101
+ """Diff leaves that are not ContainerEntry — matched by exact path."""
102
+ changes = []
103
+ all_paths = set(desired) | set(observed)
104
+ for path in all_paths:
105
+ d = desired.get(path)
106
+ o = observed.get(path)
107
+ if d and o:
108
+ if d != o:
109
+ changes.append(self._make_change(ComponentDiffOp.CHANGE, path, before=o, after=d))
110
+ elif d:
111
+ changes.append(self._make_change(ComponentDiffOp.ADD, path, after=d))
112
+ else:
113
+ changes.append(self._make_change(ComponentDiffOp.REMOVE, path, before=o))
114
+ return changes
115
+
116
+ def _diff_entries(self, desired: Dict[tuple, BaseModel], observed: Dict[tuple, BaseModel]) -> List[ComponentChange]:
117
+ """Diff ContainerEntry leaves — matched by identity_fields within each container scope."""
118
+ changes = []
119
+
120
+ # Group by container path (parent = path[:-1]) so identity matching is scoped
121
+ desired_by_container: Dict[tuple, Dict[str, BaseModel]] = defaultdict(dict)
122
+ observed_by_container: Dict[tuple, Dict[str, BaseModel]] = defaultdict(dict)
123
+
124
+ for path, model in desired.items():
125
+ desired_by_container[path[:-1]][path[-1]] = model
126
+ for path, model in observed.items():
127
+ observed_by_container[path[:-1]][path[-1]] = model
128
+
129
+ all_containers = set(desired_by_container) | set(observed_by_container)
130
+
131
+ for container_path in all_containers:
132
+ d_components = desired_by_container.get(container_path, {})
133
+ o_components = observed_by_container.get(container_path, {})
134
+
135
+ # Build identity → (key, model) lookup for observed
136
+ o_by_identity: Dict[tuple, tuple[str, BaseModel]] = {}
137
+ o_by_key: Dict[str, BaseModel] = dict(o_components)
138
+ for key, model in o_components.items():
139
+ identity = self._identity_key(model)
140
+ if identity: # non-empty identity_fields — match by field values
141
+ o_by_identity[identity] = (key, model)
142
+
143
+ matched_o_keys = set()
144
+
145
+ for d_key, d_model in d_components.items():
146
+ d_identity = self._identity_key(d_model)
147
+ o_key, o_model = None, None
148
+
149
+ if d_identity:
150
+ # Match by identity field values (e.g. same address, same vlan_id)
151
+ match = o_by_identity.get(d_identity)
152
+ if match:
153
+ o_key, o_model = match
154
+ else:
155
+ # identity_fields = () — fall back to dict key matching
156
+ if d_key in o_by_key:
157
+ o_key, o_model = d_key, o_by_key[d_key]
158
+
159
+ if o_model:
160
+ matched_o_keys.add(o_key)
161
+ if d_model != o_model:
162
+ changes.append(self._make_change(
163
+ ComponentDiffOp.CHANGE, container_path + (d_key,),
164
+ before=o_model, after=d_model,
165
+ ))
166
+ else:
167
+ changes.append(self._make_change(
168
+ ComponentDiffOp.ADD, container_path + (d_key,),
169
+ after=d_model,
170
+ ))
171
+
172
+ # Remaining observed entries that weren't matched → removed
173
+ for o_key, o_model in o_components.items():
174
+ if o_key not in matched_o_keys:
175
+ changes.append(self._make_change(
176
+ ComponentDiffOp.REMOVE, container_path + (o_key,),
177
+ before=o_model,
178
+ ))
179
+
180
+ return changes
181
+
182
+ def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
183
+ """
184
+ Compare two ComposedConfiguration objects and return a component-based diff.
185
+
186
+ Singletons (fixed-path leaves like SystemConfig) are matched by path.
187
+ ContainerEntry leaves (dict entries like interfaces) are matched by identity_fields,
188
+ handling cases where keys differ (e.g. "Gi0/0/1" vs "GigabitEthernet0/0/1").
189
+ """
190
+ flat_desired = self._flatten(desired_config)
191
+ flat_observed = self._flatten(observed_config)
192
+
193
+ # Split into singletons (path-matched) and entries (identity-matched)
194
+ singleton_d = {p: m for p, m in flat_desired.items() if not isinstance(m, ContainerEntry)}
195
+ singleton_o = {p: m for p, m in flat_observed.items() if not isinstance(m, ContainerEntry)}
196
+ entry_d = {p: m for p, m in flat_desired.items() if isinstance(m, ContainerEntry)}
197
+ entry_o = {p: m for p, m in flat_observed.items() if isinstance(m, ContainerEntry)}
198
+
199
+ all_changes = self._diff_singletons(singleton_d, singleton_o) + self._diff_entries(entry_d, entry_o)
200
+
201
+ added = [c for c in all_changes if c.op == ComponentDiffOp.ADD]
202
+ removed = [c for c in all_changes if c.op == ComponentDiffOp.REMOVE]
203
+ changed = [c for c in all_changes if c.op == ComponentDiffOp.CHANGE]
204
+
205
+ return Diff(
206
+ added=added,
207
+ removed=removed,
208
+ changed=changed,
209
+ total_desired=len(flat_desired),
210
+ total_observed=len(flat_observed),
211
+ )
@@ -1,8 +1,9 @@
1
1
  from pydantic import BaseModel
2
2
  from ipaddress import IPv4Network, IPv6Network
3
3
  from acex_devkit.models.attribute_value import AttributeValue
4
+ from acex_devkit.models.container_entry import ContainerEntry
4
5
  from enum import Enum
5
- from typing import Optional, Dict, Literal
6
+ from typing import ClassVar, Optional, Dict, Literal
6
7
 
7
8
  class AclProtoclOptions(str, Enum):
8
9
  TCP = 'tcp'
@@ -12,7 +13,8 @@ class AclProtoclOptions(str, Enum):
12
13
  IP = 'ip'
13
14
  ANY = 'any'
14
15
 
15
- class IpAclOptions(BaseModel):
16
+ class IpAclOptions(ContainerEntry, BaseModel):
17
+ identity_fields: ClassVar[tuple[str, ...]] = ("sequence_id",)
16
18
  description: Optional[AttributeValue[str]] = None
17
19
  source_port: Optional[AttributeValue[str]] = None # e.g., "100-200"
18
20
  destination_port: Optional[AttributeValue[str]] = None # e.g., "300-400"
@@ -64,12 +66,14 @@ class Ipv6AclEntryAttributes(IpAclOptions):
64
66
  destination_address: Optional[AttributeValue[IPv6Network | Literal['any']]] = None # Destination IPv6 address prefix.
65
67
  ipv6_acl: Optional[AttributeValue[str]] = None # Reference to the ACL Set this entry belongs to.
66
68
 
67
- class Ipv4AclAttributes(BaseModel):
69
+ class Ipv4AclAttributes(ContainerEntry, BaseModel):
70
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
68
71
  name: AttributeValue[str]
69
72
  type: AttributeValue[str] = "ipv4_acl"
70
73
  acl_entries: Optional[Dict[str, Ipv4AclEntryAttributes]] = None
71
74
 
72
- class Ipv6AclAttributes(BaseModel):
75
+ class Ipv6AclAttributes(ContainerEntry, BaseModel):
76
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
73
77
  name: AttributeValue[str]
74
78
  type: AttributeValue[str] = "ipv6_acl"
75
79
  acl_entries: Optional[Dict[str, Ipv6AclEntryAttributes]] = None
@@ -113,6 +113,14 @@ class AttributeValue(BaseModel, Generic[T]):
113
113
  return str(value)
114
114
  return value
115
115
 
116
+ def __eq__(self, other):
117
+ if not isinstance(other, AttributeValue):
118
+ return NotImplemented
119
+ return self.value == other.value
120
+
121
+ def __hash__(self):
122
+ return hash(self.value)
123
+
116
124
  # Convenience helpers
117
125
  def is_external(self) -> bool:
118
126
  return isinstance(self.value, ExternalValue)
@@ -5,10 +5,11 @@ from enum import Enum
5
5
 
6
6
  from acex_devkit.models.external_value import ExternalValue
7
7
  from acex_devkit.models.attribute_value import AttributeValue
8
+ from acex_devkit.models.container_entry import ContainerEntry
8
9
  from acex_devkit.models.logging import (
9
10
  LoggingConfig,
10
11
  Console,
11
- RemoteServer,
12
+ RemoteServers,
12
13
  VtyLine,
13
14
  FileLogging,
14
15
  LoggingEvents
@@ -25,7 +26,8 @@ class Metadata(BaseModel):
25
26
  type: Optional[str] = "str"
26
27
  value_source: MetadataValueType = MetadataValueType.CONCRETE
27
28
 
28
- class Reference(BaseModel):
29
+ class Reference(ContainerEntry, BaseModel):
30
+ identity_fields: ClassVar[tuple[str, ...]] = ("pointer",)
29
31
  pointer: str
30
32
  metadata: Metadata = Metadata(type="str", value_source="reference")
31
33
 
@@ -50,10 +52,7 @@ class SystemConfig(BaseModel):
50
52
  motd_banner: Optional[AttributeValue[str]] = None
51
53
 
52
54
  #class TripleA(BaseModel): ...
53
-
54
55
  # Trying to avoid using "Logging" or "logging" as names for anything due to conflicts with standard lib.
55
- class RemoteServers(BaseModel):
56
- servers: Dict[str, RemoteServer] = {}
57
56
 
58
57
  class VtyLines(BaseModel):
59
58
  lines: Dict[str, VtyLine] = {}
@@ -72,7 +71,8 @@ class LoggingComponents(BaseModel):
72
71
  class NtpConfig(BaseModel):
73
72
  enabled: AttributeValue[bool] = AttributeValue(value=False)
74
73
 
75
- class NtpServer(BaseModel):
74
+ class NtpServer(ContainerEntry, BaseModel):
75
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
76
76
  address: AttributeValue[str]
77
77
  port: Optional[AttributeValue[int]] = None
78
78
  version: Optional[AttributeValue[int]] = None
@@ -103,25 +103,27 @@ class AuthorizedKeyAlgorithms(str, Enum):
103
103
  SSH_RSA = "ssh-rsa"
104
104
  SSH_DSS = "ssh-dss"
105
105
 
106
- class AuthorizedKey(BaseModel):
107
- algorithm: AuthorizedKeyAlgorithms
108
- public_key: str
106
+ class AuthorizedKey(ContainerEntry, BaseModel):
107
+ identity_fields: ClassVar[tuple[str, ...]] = ("public_key",)
108
+ algorithm: Optional[AttributeValue[AuthorizedKeyAlgorithms]] = None
109
+ public_key: Optional[AttributeValue[str]] = None
109
110
 
110
- class Ssh(BaseModel):
111
+ class Ssh(BaseModel):
111
112
  config: Optional[SshServer] = None
112
113
  host_keys: Optional[Dict[str, AuthorizedKey]] = {}
113
114
 
114
115
  class Lldp(BaseModel): ...
115
116
 
116
- class Vlan(BaseModel):
117
+ class Vlan(ContainerEntry, BaseModel):
118
+ identity_fields: ClassVar[tuple[str, ...]] = ("vlan_id",)
117
119
  name: AttributeValue[str]
118
120
  vlan_id: Optional[AttributeValue[int]] = None
119
121
  vlan_name: Optional[AttributeValue[str]] = None
120
122
  network_instance: Optional[AttributeValue[str]] = None
121
- metadata: Optional[Metadata] = Metadata()
122
123
 
123
- class Interface(BaseModel):
124
+ class Interface(ContainerEntry, BaseModel):
124
125
  "Base class for all interfaces"
126
+ identity_fields: ClassVar[tuple[str, ...]] = ("index", "type")
125
127
  index: AttributeValue[int]
126
128
  name: AttributeValue[str]
127
129
 
@@ -129,7 +131,6 @@ class Interface(BaseModel):
129
131
  enabled: Optional[AttributeValue[bool]] = None
130
132
  ipv4: Optional[AttributeValue[str]] = None
131
133
 
132
- metadata: Optional[Metadata] = Metadata()
133
134
  type: Literal[
134
135
  "ethernetCsmacd",
135
136
  "ieee8023adLag",
@@ -199,21 +200,21 @@ class Ieee8023adLagInterface(Interface):
199
200
  class L3IpvlanInterface(Interface):
200
201
  "SVI Interface"
201
202
  type: Literal["l3ipvlan"] = "l3ipvlan"
202
- vlan_id: Optional[int] = None
203
+ vlan_id: Optional[AttributeValue[int]] = None
203
204
 
204
205
  class SoftwareLoopbackInterface(Interface):
205
206
  "Loopback Interface"
206
207
  type: Literal["softwareLoopback"] = "softwareLoopback"
207
208
 
208
209
  # Loopback har varken vlan, duplex eller speed
209
- vlan_id: Optional[int] = None
210
+ vlan_id: Optional[AttributeValue[int]] = None
210
211
  ipv4: Optional[AttributeValue[str]] = None
211
212
 
212
213
  class SubInterface(Interface):
213
214
  "Subinterface"
214
215
  type: Literal["subinterface"] = "subinterface"
215
216
 
216
- vlan_id: Optional[int] = None
217
+ vlan_id: Optional[AttributeValue[int]] = None
217
218
  ipv4: Optional[AttributeValue[str]] = None
218
219
 
219
220
  class ManagementInterface(Interface):
@@ -221,9 +222,10 @@ class ManagementInterface(Interface):
221
222
  type: Literal["managementInterface"] = "managementInterface"
222
223
 
223
224
  # Mgmt har inte vlan
224
- vlan_id: Optional[int] = None
225
+ vlan_id: Optional[AttributeValue[int]] = None
225
226
 
226
- class StaticRouteNextHop(BaseModel):
227
+ class StaticRouteNextHop(ContainerEntry, BaseModel):
228
+ identity_fields: ClassVar[tuple[str, ...]] = ("next_hop",)
227
229
  index: Optional[AttributeValue[int]] = None
228
230
  next_hop: AttributeValue[str] # can be an IP address or an interface. Reference will be handled in config component
229
231
  metric: Optional[AttributeValue[int]] = None
@@ -231,7 +233,8 @@ class StaticRouteNextHop(BaseModel):
231
233
  network_instance: Optional[AttributeValue[str]] = None
232
234
 
233
235
 
234
- class StaticRoute(BaseModel):
236
+ class StaticRoute(ContainerEntry, BaseModel):
237
+ identity_fields: ClassVar[tuple[str, ...]] = ("prefix",)
235
238
  route_name: Optional[AttributeValue[str]] = None
236
239
  prefix: AttributeValue[str]
237
240
  next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
@@ -248,10 +251,12 @@ class ImportExportPolicy(BaseModel):
248
251
  export_route_target: Optional[List[RouteTarget]] = None
249
252
  import_route_target: Optional[List[RouteTarget]] = None
250
253
 
251
- class InterInstancePolicy(BaseModel):
254
+ class InterInstancePolicy(ContainerEntry, BaseModel):
255
+ identity_fields: ClassVar[tuple[str, ...]] = ()
252
256
  import_export_policy: ImportExportPolicy
253
257
 
254
- class NetworkInstance(BaseModel):
258
+ class NetworkInstance(ContainerEntry, BaseModel):
259
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
255
260
  name: AttributeValue[str]
256
261
  description: Optional[AttributeValue[str]] = None
257
262
  vlans: Optional[Dict[str, Vlan]] = {}
@@ -297,14 +302,16 @@ class SnmpPrivProtocol(str, Enum):
297
302
  AES256 = "AES256"
298
303
 
299
304
 
300
- class SnmpConfig(BaseModel):
301
- enabled: AttributeValue[bool] = AttributeValue(value=False)
302
- engine_id: Optional[AttributeValue[str]] = None
303
- location: Optional[AttributeValue[str]] = None
304
- contact: Optional[AttributeValue[str]] = None
305
+ class SnmpConfig(ContainerEntry, BaseModel):
306
+ identity_fields: ClassVar[tuple[str, ...]] = ()
307
+ enabled: AttributeValue[bool] = AttributeValue(value=False)
308
+ engine_id: Optional[AttributeValue[str]] = None
309
+ location: Optional[AttributeValue[str]] = None
310
+ contact: Optional[AttributeValue[str]] = None
305
311
 
306
312
 
307
- class SnmpCommunity(BaseModel):
313
+ class SnmpCommunity(ContainerEntry, BaseModel):
314
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
308
315
  name: AttributeValue[str]
309
316
  community: Optional[AttributeValue[str]] = None # Community string
310
317
  access: Optional[AttributeValue[SnmpAccess]] = AttributeValue(value=SnmpAccess.READ_ONLY)
@@ -315,22 +322,25 @@ class SnmpCommunity(BaseModel):
315
322
  clients: Optional[AttributeValue[List[str]]] = None # Juniper specific
316
323
 
317
324
 
318
- class SnmpUser(BaseModel):
319
- username: AttributeValue[str]
320
- security_level: Optional[AttributeValue[SnmpSecurityLevel]] = AttributeValue(value=SnmpSecurityLevel.NO_AUTH_NO_PRIV)
321
- auth_protocol: Optional[AttributeValue[SnmpAuthProtocol]] = None
322
- auth_password: Optional[AttributeValue[str]] = None
323
- priv_protocol: Optional[AttributeValue[SnmpPrivProtocol]] = None
324
- priv_password: Optional[AttributeValue[str]] = None
325
+ class SnmpUser(ContainerEntry, BaseModel):
326
+ identity_fields: ClassVar[tuple[str, ...]] = ("username",)
327
+ username: AttributeValue[str]
328
+ security_level: Optional[AttributeValue[SnmpSecurityLevel]] = AttributeValue(value=SnmpSecurityLevel.NO_AUTH_NO_PRIV)
329
+ auth_protocol: Optional[AttributeValue[SnmpAuthProtocol]] = None
330
+ auth_password: Optional[AttributeValue[str]] = None
331
+ priv_protocol: Optional[AttributeValue[SnmpPrivProtocol]] = None
332
+ priv_password: Optional[AttributeValue[str]] = None
325
333
 
326
334
 
327
- class SnmpView(BaseModel):
328
- name: AttributeValue[str]
329
- oid: AttributeValue[str]
330
- included: Optional[AttributeValue[bool]] = AttributeValue(value=True)
335
+ class SnmpView(ContainerEntry, BaseModel):
336
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
337
+ name: AttributeValue[str]
338
+ oid: AttributeValue[str]
339
+ included: Optional[AttributeValue[bool]] = AttributeValue(value=True)
331
340
 
332
341
 
333
- class SnmpServer(BaseModel):
342
+ class SnmpServer(ContainerEntry, BaseModel):
343
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
334
344
  name: Optional[AttributeValue[str]] = None
335
345
  address: AttributeValue[str]
336
346
  port: Optional[AttributeValue[int]] = AttributeValue(value=162)
@@ -457,7 +467,8 @@ class TrapEventOptions(str, Enum):
457
467
  BULKSTAT_COLLECTION = "bulkstat_collection"
458
468
  BULKSTAT_TRANSFER = "bulkstat_transfer"
459
469
 
460
- class TrapEvent(BaseModel):
470
+ class TrapEvent(ContainerEntry, BaseModel):
471
+ identity_fields: ClassVar[tuple[str, ...]] = ("event_name",)
461
472
  name: Optional[AttributeValue[str]] = None
462
473
  event_name: Optional[AttributeValue[TrapEventOptions]] = None
463
474
 
@@ -472,10 +483,12 @@ class Snmp(BaseModel):
472
483
  views: Optional[Dict[str, SnmpView]] = {}
473
484
 
474
485
  # AAA
475
- class aaaBaseClass(BaseModel):
486
+ class aaaBaseClass(ContainerEntry, BaseModel):
487
+ identity_fields: ClassVar[tuple[str, ...]] = () # key is identity (e.g. "default", "console")
476
488
  name: Optional[AttributeValue[str]] = None
477
489
 
478
- class aaaTacacsAttributes(BaseModel):
490
+ class aaaTacacsAttributes(ContainerEntry, BaseModel):
491
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
479
492
  port: Optional[AttributeValue[int]] = None
480
493
  secret_key: Optional[AttributeValue[str]] = None
481
494
  secret_key_hashed: Optional[AttributeValue[str]] = None
@@ -484,7 +497,8 @@ class aaaTacacsAttributes(BaseModel):
484
497
  source_interface: Optional[Reference] = None
485
498
  server_group: Optional[AttributeValue[str]] = None
486
499
 
487
- class aaaRadiusAttributes(BaseModel):
500
+ class aaaRadiusAttributes(ContainerEntry, BaseModel):
501
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
488
502
  auth_port: Optional[AttributeValue[int]] = None
489
503
  acct_port: Optional[AttributeValue[int]] = None
490
504
  secret_key: Optional[AttributeValue[str]] = None
@@ -495,7 +509,8 @@ class aaaRadiusAttributes(BaseModel):
495
509
  retransmit_attempts: Optional[AttributeValue[int]] = None
496
510
  server_group: Optional[AttributeValue[str]] = None
497
511
 
498
- class aaaServerGroupAttributes(BaseModel):
512
+ class aaaServerGroupAttributes(ContainerEntry, BaseModel):
513
+ identity_fields: ClassVar[tuple[str, ...]] = () # key is the group name
499
514
  """
500
515
  Define a AAA server group that can contain multiple TACACS+ and/or RADIUS servers.
501
516
 
@@ -542,6 +557,7 @@ class aaaAuthenticationMethods(aaaBaseClass):
542
557
  # aaa authentication enable default group TACACS-GROUP-NEW enable
543
558
 
544
559
  class authenticationUser(aaaBaseClass):
560
+ identity_fields: ClassVar[tuple[str, ...]] = ("username",)
545
561
  username: Optional[AttributeValue[str]] = None
546
562
  password: Optional[AttributeValue[str]] = None
547
563
  password_hahsed: Optional[AttributeValue[str]] = None
@@ -555,7 +571,8 @@ class adminUser(aaaBaseClass):
555
571
  admin_password: Optional[AttributeValue[str]] = None
556
572
  admin_password_hashed: Optional[AttributeValue[str]] = None
557
573
 
558
- class aaaAuthenticationAdminUsers(BaseModel):
574
+ class aaaAuthenticationAdminUsers(ContainerEntry, BaseModel):
575
+ identity_fields: ClassVar[tuple[str, ...]] = ()
559
576
  config: Optional[Dict[str, adminUser]] = {}
560
577
 
561
578
  class aaaAuthentication(BaseModel):
@@ -595,9 +612,10 @@ class aaaAuthorization(BaseModel):
595
612
  events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
596
613
 
597
614
  # Accounting Models
598
- class aaaAccountingMethods(BaseModel):
615
+ class aaaAccountingMethods(ContainerEntry, BaseModel):
616
+ identity_fields: ClassVar[tuple[str, ...]] = ()
599
617
  """
600
- Define the accounting methods used by AAA. If you define a server group using the "aaaServerGroup" model,
618
+ Define the accounting methods used by AAA. If you define a server group using the "aaaServerGroup" model,
601
619
  you can reference it here by its name, but only as a string.
602
620
 
603
621
  Example in config map:
@@ -609,10 +627,11 @@ class aaaAccountingMethods(BaseModel):
609
627
  #method: Optional[List[str]] = None # Ex. ['TACACS_GROUP','LOCAL']
610
628
  method: Optional[AttributeValue[str]] = None
611
629
 
612
- class aaaAccountingEvents(BaseModel):
630
+ class aaaAccountingEvents(ContainerEntry, BaseModel):
631
+ identity_fields: ClassVar[tuple[str, ...]] = ()
613
632
  """
614
633
  Define accounting events.
615
-
634
+
616
635
  Cisco example:
617
636
  aaa accounting send stop-record authentication failure
618
637
  """
@@ -0,0 +1,42 @@
1
+ from typing import ClassVar
2
+
3
+
4
+ class ContainerEntry:
5
+ """
6
+ Mixin for Pydantic models that are used as values in Dict[str, X] container fields.
7
+
8
+ Every subclass MUST declare `identity_fields` as a ClassVar[tuple[str, ...]]:
9
+ - Non-empty tuple: fields whose values uniquely identify this object.
10
+ The differ will match objects across desired/observed by these field values,
11
+ ignoring the dict key entirely.
12
+ - Empty tuple `()`: the dict key itself is the identity (key-based matching).
13
+ Use this for singleton-like entries (e.g., "global", "default") where no
14
+ field inside the object acts as a natural identifier.
15
+
16
+ Example:
17
+ class NtpServer(ContainerEntry, BaseModel):
18
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
19
+ address: AttributeValue[str]
20
+ ...
21
+
22
+ class SpanningTreeGlobalConfig(ContainerEntry, BaseModel):
23
+ identity_fields: ClassVar[tuple[str, ...]] = () # key is identity
24
+ mode: Optional[AttributeValue[str]] = None
25
+ ...
26
+ """
27
+
28
+ identity_fields: ClassVar[tuple[str, ...]]
29
+
30
+ def __init_subclass__(cls, **kwargs):
31
+ super().__init_subclass__(**kwargs)
32
+ has_identity_fields = any(
33
+ "identity_fields" in c.__dict__
34
+ for c in cls.__mro__
35
+ if c is not ContainerEntry
36
+ )
37
+ if not has_identity_fields:
38
+ raise TypeError(
39
+ f"{cls.__name__} must declare 'identity_fields' as a ClassVar. "
40
+ f"Use identity_fields = ('field_name',) to match by field value, "
41
+ f"or identity_fields = () to match by dict key."
42
+ )
@@ -1,7 +1,8 @@
1
1
  from pydantic import BaseModel
2
2
  from acex_devkit.models.attribute_value import AttributeValue
3
+ from acex_devkit.models.container_entry import ContainerEntry
3
4
  from enum import Enum
4
- from typing import Optional, Dict
5
+ from typing import ClassVar, Optional, Dict
5
6
 
6
7
 
7
8
  class LoggingServerBase(BaseModel): ...
@@ -46,20 +47,26 @@ class Console(BaseModel):
46
47
  line_number: Optional[AttributeValue[int]] = None
47
48
  logging_synchronous: Optional[AttributeValue[bool]] = None
48
49
 
49
- class RemoteServer(BaseModel):
50
+ class RemoteServer(ContainerEntry, BaseModel):
51
+ identity_fields: ClassVar[tuple[str, ...]] = ("host",)
50
52
  name: Optional[AttributeValue[str]] = None
51
53
  host: Optional[AttributeValue[str]] = None
52
54
  port: Optional[AttributeValue[int]] = None
53
55
  transport: Optional[AttributeValue[str]] = None
54
56
  source_address: Optional[AttributeValue[str]] = None # Can be an IP address or an interface reference
55
57
 
56
- class VtyLine(BaseModel):
58
+ class RemoteServers(BaseModel):
59
+ servers: Dict[str, RemoteServer] = {}
60
+
61
+ class VtyLine(ContainerEntry, BaseModel):
62
+ identity_fields: ClassVar[tuple[str, ...]] = ("line_number",)
57
63
  name: Optional[AttributeValue[str]] = None
58
64
  line_number: Optional[AttributeValue[int]] = None
59
65
  logging_synchronous: Optional[AttributeValue[bool]] = None
60
66
  transport_input: Optional[AttributeValue[str]] = None # default is SSH. Mostly used by Cisco.
61
67
 
62
- class FileLogging(BaseModel):
68
+ class FileLogging(ContainerEntry, BaseModel):
69
+ identity_fields: ClassVar[tuple[str, ...]] = ("filename",)
63
70
  name: Optional[AttributeValue[str]] = None # object name
64
71
  filename: Optional[AttributeValue[str]] = None # name of the file
65
72
  rotate: Optional[AttributeValue[int]] = None # How many versions to keep. Juniper specific.
@@ -1,9 +1,11 @@
1
1
  from pydantic import BaseModel
2
2
  from acex_devkit.models.attribute_value import AttributeValue
3
+ from acex_devkit.models.container_entry import ContainerEntry
3
4
  from enum import Enum
4
- from typing import Optional, Dict
5
+ from typing import ClassVar, Optional, Dict
5
6
 
6
- class SpanningTreeGlobalAttributes(BaseModel):
7
+ class SpanningTreeGlobalAttributes(ContainerEntry, BaseModel):
8
+ identity_fields: ClassVar[tuple[str, ...]] = ()
7
9
  mode: Optional[AttributeValue[str]] = None # Needs to be defined by user. Default for Cisco is RAPID-PVST and for Juniper it's just RSTP
8
10
  bpdu_filter: Optional[AttributeValue[bool]] = None # Disabled by default
9
11
  bpdu_guard: Optional[AttributeValue[bool]] = None # Disabled by default
@@ -20,19 +22,22 @@ class SpanningTreeModeConfig(BaseModel):
20
22
  hold_count: Optional[AttributeValue[int]] = None # Range 1..10
21
23
 
22
24
  ## RSTP
23
- class RstpAttributes(SpanningTreeModeConfig): ...
25
+ class RstpAttributes(ContainerEntry, SpanningTreeModeConfig):
26
+ identity_fields: ClassVar[tuple[str, ...]] = ()
24
27
 
25
28
  class RSTPConfig(BaseModel):
26
29
  #config: RstpAttributes = RstpAttributes()
27
30
  config: Optional[Dict[str, RstpAttributes]] = None
28
31
 
29
32
  ### MSTP
30
- class MstpInstanceAttributes(SpanningTreeModeConfig):
33
+ class MstpInstanceAttributes(ContainerEntry, SpanningTreeModeConfig):
34
+ identity_fields: ClassVar[tuple[str, ...]] = ("instance_id",)
31
35
  instance_id: AttributeValue[int] # range: 1..4094
32
36
  name: Optional[AttributeValue[str]] = None
33
37
  vlan: Optional[AttributeValue[list[int]]] = None # List of VLANs mapped to the MST instance
34
38
 
35
- class MstpAttributes(SpanningTreeModeConfig):
39
+ class MstpAttributes(ContainerEntry, SpanningTreeModeConfig):
40
+ identity_fields: ClassVar[tuple[str, ...]] = ()
36
41
  revision: Optional[AttributeValue[int]] = None
37
42
  max_hop: Optional[AttributeValue[int]] = None # Range 1..255
38
43
 
@@ -42,7 +47,8 @@ class MSTPConfig(BaseModel):
42
47
  mst_instances: Optional[Dict[str, MstpInstanceAttributes]] = None
43
48
 
44
49
  ### Rapid PVST
45
- class RapidPVSTAttributes(SpanningTreeModeConfig):
50
+ class RapidPVSTAttributes(ContainerEntry, SpanningTreeModeConfig):
51
+ identity_fields: ClassVar[tuple[str, ...]] = ("vlan",)
46
52
  """
47
53
  Docstring for RapidPVSTAttributes
48
54
  vlan can be a string or list. Depending on how NED is built it will check wether it's a single VLAN or multiple VLANs and then format the data to the correct format
@@ -1,158 +0,0 @@
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
- )
File without changes