acex-devkit 1.2.1__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.2.1 → acex_devkit-1.3.1}/PKG-INFO +1 -1
  2. {acex_devkit-1.2.1 → 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.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/acl_model.py +8 -4
  5. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/attribute_value.py +8 -0
  6. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/composed_configuration.py +111 -80
  7. acex_devkit-1.3.1/src/acex_devkit/models/container_entry.py +42 -0
  8. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/logging.py +12 -5
  9. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/spanning_tree.py +12 -6
  10. acex_devkit-1.2.1/src/acex_devkit/configdiffer/configdiffer.py +0 -158
  11. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/README.md +0 -0
  12. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/__init__.py +0 -0
  13. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
  14. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/command.py +0 -0
  15. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/diff.py +0 -0
  16. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  17. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  18. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/__init__.py +0 -0
  19. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base.py +0 -0
  20. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base_driver.py +0 -0
  21. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/exceptions/__init__.py +0 -0
  22. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/__init__.py +0 -0
  23. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/external_value.py +0 -0
  24. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/ned.py +0 -0
  25. {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/node_response.py +0 -0
  26. {acex_devkit-1.2.1 → 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.2.1
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.2.1"
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,20 +52,27 @@ 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 LoggingComponents(BaseModel):
56
+
57
+ class VtyLines(BaseModel):
58
+ lines: Dict[str, VtyLine] = {}
59
+
60
+ class LogFiles(BaseModel):
61
+ files: Dict[str, FileLogging] = {}
62
+
63
+ class LoggingComponents(BaseModel):
56
64
  config: LoggingConfig = LoggingConfig()
57
65
  console: Optional[Console] = None
58
- remote_servers: Optional[Dict[str, RemoteServer]] = None
66
+ remote_servers: Optional[RemoteServers] = RemoteServers()
59
67
  events: Optional[LoggingEvents] = None
60
- vty: Optional[Dict[str, VtyLine]] = None
61
- files: Optional[Dict[str, FileLogging]] = None
68
+ vty: Optional[VtyLines] = VtyLines()
69
+ files: Optional[LogFiles] = LogFiles()
62
70
 
63
71
  class NtpConfig(BaseModel):
64
72
  enabled: AttributeValue[bool] = AttributeValue(value=False)
65
73
 
66
- class NtpServer(BaseModel):
74
+ class NtpServer(ContainerEntry, BaseModel):
75
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
67
76
  address: AttributeValue[str]
68
77
  port: Optional[AttributeValue[int]] = None
69
78
  version: Optional[AttributeValue[int]] = None
@@ -73,7 +82,7 @@ class NtpServer(BaseModel):
73
82
 
74
83
  class Ntp(BaseModel):
75
84
  config: Optional[NtpConfig] = None
76
- servers: Optional[Dict[str, NtpServer]] = None
85
+ servers: Optional[Dict[str, NtpServer]] = {}
77
86
 
78
87
  class SshServer(BaseModel):
79
88
  enable: Optional[AttributeValue[bool]] = None
@@ -94,25 +103,27 @@ class AuthorizedKeyAlgorithms(str, Enum):
94
103
  SSH_RSA = "ssh-rsa"
95
104
  SSH_DSS = "ssh-dss"
96
105
 
97
- class AuthorizedKey(BaseModel):
98
- algorithm: AuthorizedKeyAlgorithms
99
- 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
100
110
 
101
- class Ssh(BaseModel):
111
+ class Ssh(BaseModel):
102
112
  config: Optional[SshServer] = None
103
- host_keys: Optional[Dict[str, AuthorizedKey]] = None
113
+ host_keys: Optional[Dict[str, AuthorizedKey]] = {}
104
114
 
105
115
  class Lldp(BaseModel): ...
106
116
 
107
- class Vlan(BaseModel):
117
+ class Vlan(ContainerEntry, BaseModel):
118
+ identity_fields: ClassVar[tuple[str, ...]] = ("vlan_id",)
108
119
  name: AttributeValue[str]
109
120
  vlan_id: Optional[AttributeValue[int]] = None
110
121
  vlan_name: Optional[AttributeValue[str]] = None
111
122
  network_instance: Optional[AttributeValue[str]] = None
112
- metadata: Optional[Metadata] = Metadata()
113
123
 
114
- class Interface(BaseModel):
124
+ class Interface(ContainerEntry, BaseModel):
115
125
  "Base class for all interfaces"
126
+ identity_fields: ClassVar[tuple[str, ...]] = ("index", "type")
116
127
  index: AttributeValue[int]
117
128
  name: AttributeValue[str]
118
129
 
@@ -120,7 +131,6 @@ class Interface(BaseModel):
120
131
  enabled: Optional[AttributeValue[bool]] = None
121
132
  ipv4: Optional[AttributeValue[str]] = None
122
133
 
123
- metadata: Optional[Metadata] = Metadata()
124
134
  type: Literal[
125
135
  "ethernetCsmacd",
126
136
  "ieee8023adLag",
@@ -190,21 +200,21 @@ class Ieee8023adLagInterface(Interface):
190
200
  class L3IpvlanInterface(Interface):
191
201
  "SVI Interface"
192
202
  type: Literal["l3ipvlan"] = "l3ipvlan"
193
- vlan_id: Optional[int] = None
203
+ vlan_id: Optional[AttributeValue[int]] = None
194
204
 
195
205
  class SoftwareLoopbackInterface(Interface):
196
206
  "Loopback Interface"
197
207
  type: Literal["softwareLoopback"] = "softwareLoopback"
198
208
 
199
209
  # Loopback har varken vlan, duplex eller speed
200
- vlan_id: Optional[int] = None
210
+ vlan_id: Optional[AttributeValue[int]] = None
201
211
  ipv4: Optional[AttributeValue[str]] = None
202
212
 
203
213
  class SubInterface(Interface):
204
214
  "Subinterface"
205
215
  type: Literal["subinterface"] = "subinterface"
206
216
 
207
- vlan_id: Optional[int] = None
217
+ vlan_id: Optional[AttributeValue[int]] = None
208
218
  ipv4: Optional[AttributeValue[str]] = None
209
219
 
210
220
  class ManagementInterface(Interface):
@@ -212,9 +222,10 @@ class ManagementInterface(Interface):
212
222
  type: Literal["managementInterface"] = "managementInterface"
213
223
 
214
224
  # Mgmt har inte vlan
215
- vlan_id: Optional[int] = None
225
+ vlan_id: Optional[AttributeValue[int]] = None
216
226
 
217
- class StaticRouteNextHop(BaseModel):
227
+ class StaticRouteNextHop(ContainerEntry, BaseModel):
228
+ identity_fields: ClassVar[tuple[str, ...]] = ("next_hop",)
218
229
  index: Optional[AttributeValue[int]] = None
219
230
  next_hop: AttributeValue[str] # can be an IP address or an interface. Reference will be handled in config component
220
231
  metric: Optional[AttributeValue[int]] = None
@@ -222,14 +233,15 @@ class StaticRouteNextHop(BaseModel):
222
233
  network_instance: Optional[AttributeValue[str]] = None
223
234
 
224
235
 
225
- class StaticRoute(BaseModel):
236
+ class StaticRoute(ContainerEntry, BaseModel):
237
+ identity_fields: ClassVar[tuple[str, ...]] = ("prefix",)
226
238
  route_name: Optional[AttributeValue[str]] = None
227
239
  prefix: AttributeValue[str]
228
- next_hops: Optional[Dict[str, StaticRouteNextHop]] = None
240
+ next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
229
241
  network_instance: Optional[AttributeValue[str]] = None
230
242
 
231
243
  class Protocols(BaseModel):
232
- static_routes: Optional[Dict[str, StaticRoute]] = None
244
+ static_routes: Optional[Dict[str, StaticRoute]] = {}
233
245
  # OSPF, BGP, etc. can be added here as needed
234
246
 
235
247
  class RouteTarget(BaseModel):
@@ -239,15 +251,17 @@ class ImportExportPolicy(BaseModel):
239
251
  export_route_target: Optional[List[RouteTarget]] = None
240
252
  import_route_target: Optional[List[RouteTarget]] = None
241
253
 
242
- class InterInstancePolicy(BaseModel):
254
+ class InterInstancePolicy(ContainerEntry, BaseModel):
255
+ identity_fields: ClassVar[tuple[str, ...]] = ()
243
256
  import_export_policy: ImportExportPolicy
244
257
 
245
- class NetworkInstance(BaseModel):
258
+ class NetworkInstance(ContainerEntry, BaseModel):
259
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
246
260
  name: AttributeValue[str]
247
261
  description: Optional[AttributeValue[str]] = None
248
- vlans: Optional[Dict[str, Vlan]] = None
262
+ vlans: Optional[Dict[str, Vlan]] = {}
249
263
  interfaces: Optional[Dict[str, Reference]] = {}
250
- inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = None
264
+ inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = {}
251
265
  protocols: Optional[Protocols] = Protocols()
252
266
 
253
267
  class LacpConfig(BaseModel):
@@ -257,7 +271,7 @@ class LacpConfig(BaseModel):
257
271
 
258
272
  class Lacp(BaseModel):
259
273
  config: Optional[LacpConfig] = LacpConfig()
260
- interfaces: Optional[Dict[str, Interface]] = None
274
+ interfaces: Optional[Dict[str, Interface]] = {}
261
275
 
262
276
  # SNMP
263
277
  class SnmpAccess(str, Enum):
@@ -288,14 +302,16 @@ class SnmpPrivProtocol(str, Enum):
288
302
  AES256 = "AES256"
289
303
 
290
304
 
291
- class SnmpConfig(BaseModel):
292
- enabled: AttributeValue[bool] = AttributeValue(value=False)
293
- engine_id: Optional[AttributeValue[str]] = None
294
- location: Optional[AttributeValue[str]] = None
295
- 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
296
311
 
297
312
 
298
- class SnmpCommunity(BaseModel):
313
+ class SnmpCommunity(ContainerEntry, BaseModel):
314
+ identity_fields: ClassVar[tuple[str, ...]] = ("name",)
299
315
  name: AttributeValue[str]
300
316
  community: Optional[AttributeValue[str]] = None # Community string
301
317
  access: Optional[AttributeValue[SnmpAccess]] = AttributeValue(value=SnmpAccess.READ_ONLY)
@@ -306,22 +322,25 @@ class SnmpCommunity(BaseModel):
306
322
  clients: Optional[AttributeValue[List[str]]] = None # Juniper specific
307
323
 
308
324
 
309
- class SnmpUser(BaseModel):
310
- username: AttributeValue[str]
311
- security_level: Optional[AttributeValue[SnmpSecurityLevel]] = AttributeValue(value=SnmpSecurityLevel.NO_AUTH_NO_PRIV)
312
- auth_protocol: Optional[AttributeValue[SnmpAuthProtocol]] = None
313
- auth_password: Optional[AttributeValue[str]] = None
314
- priv_protocol: Optional[AttributeValue[SnmpPrivProtocol]] = None
315
- 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
316
333
 
317
334
 
318
- class SnmpView(BaseModel):
319
- name: AttributeValue[str]
320
- oid: AttributeValue[str]
321
- 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)
322
340
 
323
341
 
324
- class SnmpServer(BaseModel):
342
+ class SnmpServer(ContainerEntry, BaseModel):
343
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
325
344
  name: Optional[AttributeValue[str]] = None
326
345
  address: AttributeValue[str]
327
346
  port: Optional[AttributeValue[int]] = AttributeValue(value=162)
@@ -448,25 +467,28 @@ class TrapEventOptions(str, Enum):
448
467
  BULKSTAT_COLLECTION = "bulkstat_collection"
449
468
  BULKSTAT_TRANSFER = "bulkstat_transfer"
450
469
 
451
- class TrapEvent(BaseModel):
470
+ class TrapEvent(ContainerEntry, BaseModel):
471
+ identity_fields: ClassVar[tuple[str, ...]] = ("event_name",)
452
472
  name: Optional[AttributeValue[str]] = None
453
473
  event_name: Optional[AttributeValue[TrapEventOptions]] = None
454
474
 
455
475
  #class SnmpTrap(BaseModel): ...
456
476
 
457
477
  class Snmp(BaseModel):
458
- config: Optional[Dict[str, SnmpConfig]] = None
459
- communities: Optional[Dict[str, SnmpCommunity]] = None
460
- users: Optional[Dict[str, SnmpUser]] = None
461
- trap_servers: Optional[Dict[str, SnmpServer]] = None
462
- trap_events: Optional[Dict[str, TrapEvent]] = None
463
- views: Optional[Dict[str, SnmpView]] = None
478
+ config: Optional[Dict[str, SnmpConfig]] = {}
479
+ communities: Optional[Dict[str, SnmpCommunity]] = {}
480
+ users: Optional[Dict[str, SnmpUser]] = {}
481
+ trap_servers: Optional[Dict[str, SnmpServer]] = {}
482
+ trap_events: Optional[Dict[str, TrapEvent]] = {}
483
+ views: Optional[Dict[str, SnmpView]] = {}
464
484
 
465
485
  # AAA
466
- class aaaBaseClass(BaseModel):
486
+ class aaaBaseClass(ContainerEntry, BaseModel):
487
+ identity_fields: ClassVar[tuple[str, ...]] = () # key is identity (e.g. "default", "console")
467
488
  name: Optional[AttributeValue[str]] = None
468
489
 
469
- class aaaTacacsAttributes(BaseModel):
490
+ class aaaTacacsAttributes(ContainerEntry, BaseModel):
491
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
470
492
  port: Optional[AttributeValue[int]] = None
471
493
  secret_key: Optional[AttributeValue[str]] = None
472
494
  secret_key_hashed: Optional[AttributeValue[str]] = None
@@ -475,7 +497,8 @@ class aaaTacacsAttributes(BaseModel):
475
497
  source_interface: Optional[Reference] = None
476
498
  server_group: Optional[AttributeValue[str]] = None
477
499
 
478
- class aaaRadiusAttributes(BaseModel):
500
+ class aaaRadiusAttributes(ContainerEntry, BaseModel):
501
+ identity_fields: ClassVar[tuple[str, ...]] = ("address",)
479
502
  auth_port: Optional[AttributeValue[int]] = None
480
503
  acct_port: Optional[AttributeValue[int]] = None
481
504
  secret_key: Optional[AttributeValue[str]] = None
@@ -486,7 +509,8 @@ class aaaRadiusAttributes(BaseModel):
486
509
  retransmit_attempts: Optional[AttributeValue[int]] = None
487
510
  server_group: Optional[AttributeValue[str]] = None
488
511
 
489
- class aaaServerGroupAttributes(BaseModel):
512
+ class aaaServerGroupAttributes(ContainerEntry, BaseModel):
513
+ identity_fields: ClassVar[tuple[str, ...]] = () # key is the group name
490
514
  """
491
515
  Define a AAA server group that can contain multiple TACACS+ and/or RADIUS servers.
492
516
 
@@ -509,8 +533,8 @@ class aaaServerGroupAttributes(BaseModel):
509
533
  """
510
534
  enable: Optional[AttributeValue[bool]] = None
511
535
  type: Optional[AttributeValue[Literal['tacacs','radius']]] = None
512
- tacacs: Optional[Dict[str, aaaTacacsAttributes]] = None
513
- radius: Optional[Dict[str, aaaRadiusAttributes]] = None
536
+ tacacs: Optional[Dict[str, aaaTacacsAttributes]] = {}
537
+ radius: Optional[Dict[str, aaaRadiusAttributes]] = {}
514
538
 
515
539
  # Authentication Models
516
540
  class aaaAuthenticationMethods(aaaBaseClass):
@@ -533,6 +557,7 @@ class aaaAuthenticationMethods(aaaBaseClass):
533
557
  # aaa authentication enable default group TACACS-GROUP-NEW enable
534
558
 
535
559
  class authenticationUser(aaaBaseClass):
560
+ identity_fields: ClassVar[tuple[str, ...]] = ("username",)
536
561
  username: Optional[AttributeValue[str]] = None
537
562
  password: Optional[AttributeValue[str]] = None
538
563
  password_hahsed: Optional[AttributeValue[str]] = None
@@ -540,19 +565,20 @@ class authenticationUser(aaaBaseClass):
540
565
  role: Optional[AttributeValue[str]] = None
541
566
 
542
567
  class aaaAuthenticationUsers(aaaBaseClass):
543
- username: Optional[Dict[str, authenticationUser]] = None
568
+ username: Optional[Dict[str, authenticationUser]] = {}
544
569
 
545
570
  class adminUser(aaaBaseClass):
546
571
  admin_password: Optional[AttributeValue[str]] = None
547
572
  admin_password_hashed: Optional[AttributeValue[str]] = None
548
573
 
549
- class aaaAuthenticationAdminUsers(BaseModel):
550
- config: Optional[Dict[str, adminUser]] = None
574
+ class aaaAuthenticationAdminUsers(ContainerEntry, BaseModel):
575
+ identity_fields: ClassVar[tuple[str, ...]] = ()
576
+ config: Optional[Dict[str, adminUser]] = {}
551
577
 
552
578
  class aaaAuthentication(BaseModel):
553
- config: Optional[Dict[str, aaaAuthenticationMethods]] = None
554
- admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] = None
555
- users: Optional[Dict[str, aaaAuthenticationUsers]] = None
579
+ config: Optional[Dict[str, aaaAuthenticationMethods]] = {}
580
+ admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] = {}
581
+ users: Optional[Dict[str, aaaAuthenticationUsers]] = {}
556
582
 
557
583
  # Authorization Models
558
584
  class aaaAuthorizationMethods(aaaBaseClass):
@@ -582,13 +608,14 @@ class aaaAuthorizationEvents(aaaBaseClass):
582
608
  event: Optional[AttributeValue[str]] = None
583
609
 
584
610
  class aaaAuthorization(BaseModel):
585
- config: Optional[Dict[str, aaaAuthorizationMethods]] = None
586
- events: Optional[Dict[str, aaaAuthorizationEvents]] = None
611
+ config: Optional[Dict[str, aaaAuthorizationMethods]] = {}
612
+ events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
587
613
 
588
614
  # Accounting Models
589
- class aaaAccountingMethods(BaseModel):
615
+ class aaaAccountingMethods(ContainerEntry, BaseModel):
616
+ identity_fields: ClassVar[tuple[str, ...]] = ()
590
617
  """
591
- 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,
592
619
  you can reference it here by its name, but only as a string.
593
620
 
594
621
  Example in config map:
@@ -600,10 +627,11 @@ class aaaAccountingMethods(BaseModel):
600
627
  #method: Optional[List[str]] = None # Ex. ['TACACS_GROUP','LOCAL']
601
628
  method: Optional[AttributeValue[str]] = None
602
629
 
603
- class aaaAccountingEvents(BaseModel):
630
+ class aaaAccountingEvents(ContainerEntry, BaseModel):
631
+ identity_fields: ClassVar[tuple[str, ...]] = ()
604
632
  """
605
633
  Define accounting events.
606
-
634
+
607
635
  Cisco example:
608
636
  aaa accounting send stop-record authentication failure
609
637
  """
@@ -611,15 +639,15 @@ class aaaAccountingEvents(BaseModel):
611
639
  event: Optional[AttributeValue[str]] = None
612
640
 
613
641
  class aaaAccounting(BaseModel):
614
- config: Optional[Dict[str, aaaAccountingMethods]] = None
615
- events: Optional[Dict[str, aaaAccountingEvents]] = None
642
+ config: Optional[Dict[str, aaaAccountingMethods]] = {}
643
+ events: Optional[Dict[str, aaaAccountingEvents]] = {}
616
644
 
617
645
  class aaaGlobalAttributes(BaseModel):
618
646
  enabled: Optional[AttributeValue[bool]] = False # default False
619
647
 
620
648
  class TripleA(BaseModel):
621
649
  config: aaaGlobalAttributes = aaaGlobalAttributes()
622
- server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = None
650
+ server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = {}
623
651
  authentication: aaaAuthentication = aaaAuthentication()
624
652
  authorization: aaaAuthorization = aaaAuthorization()
625
653
  accounting: aaaAccounting = aaaAccounting()
@@ -652,11 +680,14 @@ class ComposedConfiguration(BaseModel):
652
680
  stp: Optional[SpanningTree] = SpanningTree()
653
681
 
654
682
 
655
- """
683
+ """
656
684
  GUIDELINES FOR COMPOSED CONFIGURATION:
657
685
 
658
686
  1. All values must always be typed as AttributeValue.
659
687
  2. Containers must always be defined as hierarchical pydantic types, no dicts as placeholders.
660
- 3. Component collections are always Dict[str, BaseModel] the key identifies the component
661
- 4. Default values for Optional is always None.
688
+ 3. Component collections must use a typed container class (e.g. RemoteServers, VtyLines) with an inner
689
+ Dict[str, BaseModel] field — the key identifies the component. Raw Dict fields are not allowed at
690
+ the container level.
691
+ 4. Default values for Optional containers are always an empty instance of the container class, e.g.
692
+ Optional[RemoteServers] = RemoteServers(). Never default to None or a raw empty dict for collections.
662
693
  """
@@ -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
- transfer: Optional[AttributeValue[str]] = None
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