acex-devkit 1.2.0__tar.gz → 1.3.0__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.3.0}/PKG-INFO +1 -1
  2. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/pyproject.toml +1 -1
  3. acex_devkit-1.3.0/src/acex_devkit/configdiffer/configdiffer.py +158 -0
  4. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/diff.py +4 -2
  5. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/composed_configuration.py +44 -32
  6. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/logging.py +1 -1
  7. acex_devkit-1.2.0/src/acex_devkit/configdiffer/configdiffer.py +0 -178
  8. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/README.md +0 -0
  9. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/__init__.py +0 -0
  10. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/__init__.py +0 -0
  11. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/command.py +0 -0
  12. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  13. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  14. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/__init__.py +0 -0
  15. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/base.py +0 -0
  16. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/base_driver.py +0 -0
  17. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/exceptions/__init__.py +0 -0
  18. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/__init__.py +0 -0
  19. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/acl_model.py +0 -0
  20. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/attribute_value.py +0 -0
  21. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/external_value.py +0 -0
  22. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/ned.py +0 -0
  23. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/node_response.py +0 -0
  24. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/spanning_tree.py +0 -0
  25. {acex_devkit-1.2.0 → acex_devkit-1.3.0}/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.3.0
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.3.0"
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):
@@ -52,13 +52,22 @@ class SystemConfig(BaseModel):
52
52
  #class TripleA(BaseModel): ...
53
53
 
54
54
  # Trying to avoid using "Logging" or "logging" as names for anything due to conflicts with standard lib.
55
- class LoggingComponents(BaseModel):
55
+ class RemoteServers(BaseModel):
56
+ servers: Dict[str, RemoteServer] = {}
57
+
58
+ class VtyLines(BaseModel):
59
+ lines: Dict[str, VtyLine] = {}
60
+
61
+ class LogFiles(BaseModel):
62
+ files: Dict[str, FileLogging] = {}
63
+
64
+ class LoggingComponents(BaseModel):
56
65
  config: LoggingConfig = LoggingConfig()
57
66
  console: Optional[Console] = None
58
- remote_servers: Optional[Dict[str, RemoteServer]] = None
67
+ remote_servers: Optional[RemoteServers] = RemoteServers()
59
68
  events: Optional[LoggingEvents] = None
60
- vty: Optional[Dict[str, VtyLine]] = None
61
- files: Optional[Dict[str, FileLogging]] = None
69
+ vty: Optional[VtyLines] = VtyLines()
70
+ files: Optional[LogFiles] = LogFiles()
62
71
 
63
72
  class NtpConfig(BaseModel):
64
73
  enabled: AttributeValue[bool] = AttributeValue(value=False)
@@ -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
@@ -100,7 +109,7 @@ class AuthorizedKey(BaseModel):
100
109
 
101
110
  class Ssh(BaseModel):
102
111
  config: Optional[SshServer] = None
103
- host_keys: Optional[Dict[str, AuthorizedKey]] = None
112
+ host_keys: Optional[Dict[str, AuthorizedKey]] = {}
104
113
 
105
114
  class Lldp(BaseModel): ...
106
115
 
@@ -225,11 +234,11 @@ class StaticRouteNextHop(BaseModel):
225
234
  class StaticRoute(BaseModel):
226
235
  route_name: Optional[AttributeValue[str]] = None
227
236
  prefix: AttributeValue[str]
228
- next_hops: Optional[Dict[str, StaticRouteNextHop]] = None
237
+ next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
229
238
  network_instance: Optional[AttributeValue[str]] = None
230
239
 
231
240
  class Protocols(BaseModel):
232
- static_routes: Optional[Dict[str, StaticRoute]] = None
241
+ static_routes: Optional[Dict[str, StaticRoute]] = {}
233
242
  # OSPF, BGP, etc. can be added here as needed
234
243
 
235
244
  class RouteTarget(BaseModel):
@@ -245,9 +254,9 @@ class InterInstancePolicy(BaseModel):
245
254
  class NetworkInstance(BaseModel):
246
255
  name: AttributeValue[str]
247
256
  description: Optional[AttributeValue[str]] = None
248
- vlans: Optional[Dict[str, Vlan]] = None
257
+ vlans: Optional[Dict[str, Vlan]] = {}
249
258
  interfaces: Optional[Dict[str, Reference]] = {}
250
- inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = None
259
+ inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = {}
251
260
  protocols: Optional[Protocols] = Protocols()
252
261
 
253
262
  class LacpConfig(BaseModel):
@@ -257,7 +266,7 @@ class LacpConfig(BaseModel):
257
266
 
258
267
  class Lacp(BaseModel):
259
268
  config: Optional[LacpConfig] = LacpConfig()
260
- interfaces: Optional[Dict[str, Interface]] = None
269
+ interfaces: Optional[Dict[str, Interface]] = {}
261
270
 
262
271
  # SNMP
263
272
  class SnmpAccess(str, Enum):
@@ -455,12 +464,12 @@ class TrapEvent(BaseModel):
455
464
  #class SnmpTrap(BaseModel): ...
456
465
 
457
466
  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
467
+ config: Optional[Dict[str, SnmpConfig]] = {}
468
+ communities: Optional[Dict[str, SnmpCommunity]] = {}
469
+ users: Optional[Dict[str, SnmpUser]] = {}
470
+ trap_servers: Optional[Dict[str, SnmpServer]] = {}
471
+ trap_events: Optional[Dict[str, TrapEvent]] = {}
472
+ views: Optional[Dict[str, SnmpView]] = {}
464
473
 
465
474
  # AAA
466
475
  class aaaBaseClass(BaseModel):
@@ -509,8 +518,8 @@ class aaaServerGroupAttributes(BaseModel):
509
518
  """
510
519
  enable: Optional[AttributeValue[bool]] = None
511
520
  type: Optional[AttributeValue[Literal['tacacs','radius']]] = None
512
- tacacs: Optional[Dict[str, aaaTacacsAttributes]] = None
513
- radius: Optional[Dict[str, aaaRadiusAttributes]] = None
521
+ tacacs: Optional[Dict[str, aaaTacacsAttributes]] = {}
522
+ radius: Optional[Dict[str, aaaRadiusAttributes]] = {}
514
523
 
515
524
  # Authentication Models
516
525
  class aaaAuthenticationMethods(aaaBaseClass):
@@ -540,19 +549,19 @@ class authenticationUser(aaaBaseClass):
540
549
  role: Optional[AttributeValue[str]] = None
541
550
 
542
551
  class aaaAuthenticationUsers(aaaBaseClass):
543
- username: Optional[Dict[str, authenticationUser]] = None
552
+ username: Optional[Dict[str, authenticationUser]] = {}
544
553
 
545
554
  class adminUser(aaaBaseClass):
546
555
  admin_password: Optional[AttributeValue[str]] = None
547
556
  admin_password_hashed: Optional[AttributeValue[str]] = None
548
557
 
549
558
  class aaaAuthenticationAdminUsers(BaseModel):
550
- config: Optional[Dict[str, adminUser]] = None
559
+ config: Optional[Dict[str, adminUser]] = {}
551
560
 
552
561
  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
562
+ config: Optional[Dict[str, aaaAuthenticationMethods]] = {}
563
+ admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] = {}
564
+ users: Optional[Dict[str, aaaAuthenticationUsers]] = {}
556
565
 
557
566
  # Authorization Models
558
567
  class aaaAuthorizationMethods(aaaBaseClass):
@@ -582,8 +591,8 @@ class aaaAuthorizationEvents(aaaBaseClass):
582
591
  event: Optional[AttributeValue[str]] = None
583
592
 
584
593
  class aaaAuthorization(BaseModel):
585
- config: Optional[Dict[str, aaaAuthorizationMethods]] = None
586
- events: Optional[Dict[str, aaaAuthorizationEvents]] = None
594
+ config: Optional[Dict[str, aaaAuthorizationMethods]] = {}
595
+ events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
587
596
 
588
597
  # Accounting Models
589
598
  class aaaAccountingMethods(BaseModel):
@@ -611,15 +620,15 @@ class aaaAccountingEvents(BaseModel):
611
620
  event: Optional[AttributeValue[str]] = None
612
621
 
613
622
  class aaaAccounting(BaseModel):
614
- config: Optional[Dict[str, aaaAccountingMethods]] = None
615
- events: Optional[Dict[str, aaaAccountingEvents]] = None
623
+ config: Optional[Dict[str, aaaAccountingMethods]] = {}
624
+ events: Optional[Dict[str, aaaAccountingEvents]] = {}
616
625
 
617
626
  class aaaGlobalAttributes(BaseModel):
618
627
  enabled: Optional[AttributeValue[bool]] = False # default False
619
628
 
620
629
  class TripleA(BaseModel):
621
630
  config: aaaGlobalAttributes = aaaGlobalAttributes()
622
- server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = None
631
+ server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = {}
623
632
  authentication: aaaAuthentication = aaaAuthentication()
624
633
  authorization: aaaAuthorization = aaaAuthorization()
625
634
  accounting: aaaAccounting = aaaAccounting()
@@ -652,11 +661,14 @@ class ComposedConfiguration(BaseModel):
652
661
  stp: Optional[SpanningTree] = SpanningTree()
653
662
 
654
663
 
655
- """
664
+ """
656
665
  GUIDELINES FOR COMPOSED CONFIGURATION:
657
666
 
658
667
  1. All values must always be typed as AttributeValue.
659
668
  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.
669
+ 3. Component collections must use a typed container class (e.g. RemoteServers, VtyLines) with an inner
670
+ Dict[str, BaseModel] field — the key identifies the component. Raw Dict fields are not allowed at
671
+ the container level.
672
+ 4. Default values for Optional containers are always an empty instance of the container class, e.g.
673
+ Optional[RemoteServers] = RemoteServers(). Never default to None or a raw empty dict for collections.
662
674
  """
@@ -50,7 +50,7 @@ class RemoteServer(BaseModel):
50
50
  name: Optional[AttributeValue[str]] = None
51
51
  host: Optional[AttributeValue[str]] = None
52
52
  port: Optional[AttributeValue[int]] = None
53
- transfer: Optional[AttributeValue[str]] = None
53
+ transport: Optional[AttributeValue[str]] = None
54
54
  source_address: Optional[AttributeValue[str]] = None # Can be an IP address or an interface reference
55
55
 
56
56
  class VtyLine(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