Skip to content

Commit ee108b6

Browse files
committed
Simplify model interface
1 parent 2967c2e commit ee108b6

File tree

12 files changed

+138
-290
lines changed

12 files changed

+138
-290
lines changed

diffsync/__init__.py

Lines changed: 33 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,22 @@
1414
See the License for the specific language governing permissions and
1515
limitations under the License.
1616
"""
17+
from functools import cached_property
1718
from inspect import isclass
18-
from typing import Callable, ClassVar, Dict, List, Mapping, Optional, Text, Tuple, Type, Union
19+
from typing import Callable, ClassVar, Dict, List, Mapping, Optional, Text, Tuple, Type, Union, get_type_hints, get_args
1920

2021
from pydantic import BaseModel, PrivateAttr
2122
import structlog # type: ignore
2223

2324
from diffsync.diff import Diff
24-
from diffsync.enum import DiffSyncModelFlags, DiffSyncFlags, DiffSyncStatus
25-
from diffsync.exceptions import DiffClassMismatch, ObjectAlreadyExists, ObjectStoreWrongType, ObjectNotFound
25+
from diffsync.enum import DiffSyncModelFlags, DiffSyncFlags, DiffSyncStatus, DiffSyncFieldType
26+
from diffsync.exceptions import (
27+
DiffClassMismatch,
28+
ObjectAlreadyExists,
29+
ObjectStoreWrongType,
30+
ObjectNotFound,
31+
ModelChildrenException,
32+
)
2633
from diffsync.helpers import DiffSyncDiffer, DiffSyncSyncer
2734
from diffsync.store import BaseStore
2835
from diffsync.store.local import LocalStore
@@ -37,9 +44,6 @@ class DiffSyncModel(BaseModel):
3744
as model attributes and we want to avoid any ambiguity or collisions.
3845
3946
This class has several underscore-prefixed class variables that subclasses should set as desired; see below.
40-
41-
NOTE: The groupings _identifiers, _attributes, and _children are mutually exclusive; any given field name can
42-
be included in **at most** one of these three tuples.
4347
"""
4448

4549
_modelname: ClassVar[str] = "diffsyncmodel"
@@ -48,38 +52,13 @@ class DiffSyncModel(BaseModel):
4852
Lowercase by convention; typically corresponds to the class name, but that is not enforced.
4953
"""
5054

51-
_identifiers: ClassVar[Tuple[str, ...]] = ()
52-
"""List of model fields which together uniquely identify an instance of this model.
53-
54-
This identifier MUST be globally unique among all instances of this class.
55-
"""
56-
5755
_shortname: ClassVar[Tuple[str, ...]] = ()
5856
"""Optional: list of model fields that together form a shorter identifier of an instance.
5957
6058
This MUST be locally unique (e.g., interface shortnames MUST be unique among all interfaces on a given device),
6159
but does not need to be guaranteed to be globally unique among all instances.
6260
"""
6361

64-
_attributes: ClassVar[Tuple[str, ...]] = ()
65-
"""Optional: list of additional model fields (beyond those in `_identifiers`) that are relevant to this model.
66-
67-
Only the fields in `_attributes` (as well as any `_children` fields, see below) will be considered
68-
for the purposes of Diff calculation.
69-
A model may define additional fields (not included in `_attributes`) for its internal use;
70-
a common example would be a locally significant database primary key or id value.
71-
72-
Note: inclusion in `_attributes` is mutually exclusive from inclusion in `_identifiers`; a field cannot be in both!
73-
"""
74-
75-
_children: ClassVar[Mapping[str, str]] = {}
76-
"""Optional: dict of `{_modelname: field_name}` entries describing how to store "child" models in this model.
77-
78-
When calculating a Diff or performing a sync, DiffSync will automatically recurse into these child models.
79-
80-
Note: inclusion in `_children` is mutually exclusive from inclusion in `_identifiers` or `_attributes`.
81-
"""
82-
8362
model_flags: DiffSyncModelFlags = DiffSyncModelFlags.NONE
8463
"""Optional: any non-default behavioral flags for this DiffSyncModel.
8564
@@ -106,31 +85,32 @@ def __init_subclass__(cls):
10685
10786
Called automatically on subclass declaration.
10887
"""
109-
variables = cls.__fields__.keys()
88+
type_hints = get_type_hints(cls, include_extras=True)
89+
field_name_type_mapping = {field_type.value: [] for field_type in DiffSyncFieldType}
90+
children = {}
91+
for key, value in type_hints.items():
92+
try:
93+
field_type = value.__metadata__[0]
94+
except AttributeError:
95+
# In this case we aren't dealing with an actual payload field
96+
continue
97+
98+
if field_type in [DiffSyncFieldType.IDENTIFIER, DiffSyncFieldType.ATTRIBUTE]:
99+
field_name_type_mapping[field_type.value].append(key)
100+
elif field_type == DiffSyncFieldType.CHILDREN:
101+
actual_type, _, child_modelname = get_args(value)
102+
children[child_modelname] = key
103+
104+
cls._identifiers = field_name_type_mapping[DiffSyncFieldType.IDENTIFIER.value]
105+
cls._attributes = field_name_type_mapping[DiffSyncFieldType.ATTRIBUTE.value]
106+
cls._children = children
107+
108+
all_field_names = cls._identifiers + cls._attributes + list(cls._children.keys())
109+
110110
# Make sure that any field referenced by name actually exists on the model
111-
for attr in cls._identifiers:
112-
if attr not in variables and not hasattr(cls, attr):
113-
raise AttributeError(f"_identifiers {cls._identifiers} references missing or un-annotated attr {attr}")
114111
for attr in cls._shortname:
115-
if attr not in variables:
112+
if attr not in all_field_names:
116113
raise AttributeError(f"_shortname {cls._shortname} references missing or un-annotated attr {attr}")
117-
for attr in cls._attributes:
118-
if attr not in variables:
119-
raise AttributeError(f"_attributes {cls._attributes} references missing or un-annotated attr {attr}")
120-
for attr in cls._children.values():
121-
if attr not in variables:
122-
raise AttributeError(f"_children {cls._children} references missing or un-annotated attr {attr}")
123-
124-
# Any given field can only be in one of (_identifiers, _attributes, _children)
125-
id_attr_overlap = set(cls._identifiers).intersection(cls._attributes)
126-
if id_attr_overlap:
127-
raise AttributeError(f"Fields {id_attr_overlap} are included in both _identifiers and _attributes.")
128-
id_child_overlap = set(cls._identifiers).intersection(cls._children.values())
129-
if id_child_overlap:
130-
raise AttributeError(f"Fields {id_child_overlap} are included in both _identifiers and _children.")
131-
attr_child_overlap = set(cls._attributes).intersection(cls._children.values())
132-
if attr_child_overlap:
133-
raise AttributeError(f"Fields {attr_child_overlap} are included in both _attributes and _children.")
134114

135115
def __repr__(self):
136116
return f'{self.get_type()} "{self.get_unique_id()}"'

diffsync/enum.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,9 @@ class DiffSyncActions: # pylint: disable=too-few-public-methods
104104
DELETE = "delete"
105105
SKIP = "skip"
106106
NO_CHANGE = None
107+
108+
109+
class DiffSyncFieldType(enum.Enum):
110+
ATTRIBUTE = "attribute"
111+
IDENTIFIER = "identifier"
112+
CHILDREN = "children"

diffsync/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,7 @@ class DiffException(Exception):
5959

6060
class DiffClassMismatch(DiffException):
6161
"""Exception raised when a diff object is not the same as the expected diff_class."""
62+
63+
64+
class ModelChildrenException(Exception):
65+
"""Exception raised when a model is defined for which a children's type doesn't have a _modelname field."""

examples/01-multiple-data-sources/models.py

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,46 +14,38 @@
1414
See the License for the specific language governing permissions and
1515
limitations under the License.
1616
"""
17-
from typing import List, Optional
18-
from diffsync import DiffSyncModel
17+
from typing import List, Optional, Annotated
18+
from diffsync import DiffSyncModel, DiffSyncFieldType
1919

2020

2121
class Site(DiffSyncModel):
2222
"""Example model of a geographic Site."""
2323

2424
_modelname = "site"
25-
_identifiers = ("name",)
2625
_shortname = ()
27-
_attributes = ()
28-
_children = {"device": "devices"}
2926

30-
name: str
31-
devices: List = []
27+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
28+
devices: Annotated[List, DiffSyncFieldType.CHILDREN, "device"] = []
3229

3330

3431
class Device(DiffSyncModel):
3532
"""Example model of a network Device."""
3633

3734
_modelname = "device"
38-
_identifiers = ("name",)
39-
_attributes = ()
40-
_children = {"interface": "interfaces"}
4135

42-
name: str
43-
site_name: Optional[str] # note that this attribute is NOT included in _attributes
44-
role: Optional[str] # note that this attribute is NOT included in _attributes
45-
interfaces: List = []
36+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
37+
site_name: Optional[str] # note that this attribute is NOT annotated
38+
role: Optional[str] # note that this attribute is NOT annotated
39+
interfaces: Annotated[List, DiffSyncFieldType.CHILDREN, "interface"] = []
4640

4741

4842
class Interface(DiffSyncModel):
4943
"""Example model of a network Interface."""
5044

5145
_modelname = "interface"
52-
_identifiers = ("device_name", "name")
5346
_shortname = ("name",)
54-
_attributes = ("description",)
5547

56-
name: str
57-
device_name: str
48+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
49+
device_name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
5850

59-
description: Optional[str]
51+
description: Annotated[Optional[str], DiffSyncFieldType.ATTRIBUTE]

examples/02-callback-function/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@
1616
limitations under the License.
1717
"""
1818
import random
19+
from typing import Annotated
1920

20-
from diffsync import DiffSync, DiffSyncModel
21+
from diffsync import DiffSync, DiffSyncModel, DiffSyncFieldType
2122
from diffsync.logging import enable_console_logging
2223

2324

2425
class Number(DiffSyncModel):
2526
"""Simple model that consists only of a number."""
2627

2728
_modelname = "number"
28-
_identifiers = ("number",)
2929

30-
number: int
30+
number: Annotated[int, DiffSyncFieldType.IDENTIFIER]
3131

3232

3333
class DiffSync1(DiffSync):
Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
"""Main DiffSync models for example3."""
2-
from typing import List, Optional
3-
from diffsync import DiffSyncModel
2+
from typing import List, Optional, Annotated
3+
from diffsync import DiffSyncModel, DiffSyncFieldType
44

55

66
class Region(DiffSyncModel):
77
"""Example model of a geographic region."""
88

99
_modelname = "region"
10-
_identifiers = ("slug",)
11-
_attributes = ("name",)
1210

13-
# By listing country as a child to Region
14-
# DiffSync will be able to recursively compare all regions including all their children
15-
_children = {"country": "countries"}
11+
slug: Annotated[str, DiffSyncFieldType.IDENTIFIER]
12+
name: Annotated[str, DiffSyncFieldType.ATTRIBUTE]
1613

17-
slug: str
18-
name: str
19-
countries: List[str] = []
14+
# By annotating country as a child to Region
15+
# DiffSync will be able to recursively compare all regions including all their children
16+
countries: Annotated[List[str], DiffSyncFieldType.CHILDREN, "country"] = []
2017

2118

2219
class Country(DiffSyncModel):
@@ -26,10 +23,8 @@ class Country(DiffSyncModel):
2623
"""
2724

2825
_modelname = "country"
29-
_identifiers = ("slug",)
30-
_attributes = ("name", "region", "population")
3126

32-
slug: str
33-
name: str
34-
region: str
35-
population: Optional[int]
27+
slug: Annotated[str, DiffSyncFieldType.IDENTIFIER]
28+
name: Annotated[str, DiffSyncFieldType.ATTRIBUTE]
29+
region: Annotated[str, DiffSyncFieldType.ATTRIBUTE]
30+
population: Annotated[Optional[int], DiffSyncFieldType.ATTRIBUTE]

examples/04-get-update-instantiate/models.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,45 +14,38 @@
1414
See the License for the specific language governing permissions and
1515
limitations under the License.
1616
"""
17-
from typing import List, Optional
18-
from diffsync import DiffSyncModel
17+
from typing import List, Optional, Annotated
18+
from diffsync import DiffSyncModel, DiffSyncFieldType
1919

2020

2121
class Site(DiffSyncModel):
2222
"""Example model of a geographic Site."""
2323

2424
_modelname = "site"
25-
_identifiers = ("name",)
2625
_shortname = ()
27-
_attributes = ()
2826

29-
name: str
27+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
3028

3129

3230
class Device(DiffSyncModel):
3331
"""Example model of a network Device."""
3432

3533
_modelname = "device"
36-
_identifiers = ("name",)
37-
_attributes = ()
38-
_children = {"interface": "interfaces", "site": "sites"}
3934

40-
name: str
41-
site_name: Optional[str] # note that this attribute is NOT included in _attributes
42-
role: Optional[str] # note that this attribute is NOT included in _attributes
43-
interfaces: List = []
44-
sites: List = []
35+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
36+
site_name: Optional[str] # note that this attribute is NOT annotated
37+
role: Optional[str] # note that this attribute is NOT annotated
38+
interfaces: Annotated[List[str], DiffSyncFieldType.CHILDREN, "interface"] = []
39+
sites: Annotated[List[str], DiffSyncFieldType.CHILDREN, "site"] = []
4540

4641

4742
class Interface(DiffSyncModel):
4843
"""Example model of a network Interface."""
4944

5045
_modelname = "interface"
51-
_identifiers = ("device_name", "name")
5246
_shortname = ("name",)
53-
_attributes = ("description",)
5447

55-
name: str
56-
device_name: str
48+
name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
49+
device_name: Annotated[str, DiffSyncFieldType.IDENTIFIER]
5750

58-
description: Optional[str]
51+
description: Annotated[Optional[str], DiffSyncFieldType.ATTRIBUTE]

0 commit comments

Comments
 (0)