Skip to content
This repository was archived by the owner on Jan 21, 2023. It is now read-only.

Commit 0a8fad2

Browse files
yt-msMidnighter
authored andcommitted
feat: add FilterView
1 parent 8721296 commit 0a8fad2

File tree

6 files changed

+243
-6
lines changed

6 files changed

+243
-6
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ History
55
Next Release
66
------------
77
* Feat: Add ``DynamicView`` (#77)
8+
* Feat: Add ``FilteredView`` (#81)
89
* Breaking change: View.find_element_view and find_relationship_view parameter changes.
910

1011
0.5.0 (2021-05-03)

src/structurizr/view/filtered_view.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# https://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
14+
"""Provide a filtered view."""
15+
16+
from enum import Enum
17+
from typing import Iterable, List, Optional
18+
19+
from pydantic import Field
20+
21+
from .abstract_view import AbstractView, AbstractViewIO
22+
from .static_view import StaticView
23+
24+
25+
__all__ = ("FilteredView", "FilteredViewIO")
26+
27+
28+
class FilterMode(Enum):
29+
Include = "Include"
30+
Exclude = "Exclude"
31+
32+
33+
class FilteredViewIO(AbstractViewIO):
34+
"""
35+
Represent the FilteredView from the C4 model.
36+
37+
Attributes:
38+
base_view_key: The key of the view on which this filtered view is based.
39+
mode: Whether elements/relationships are being included or excluded based
40+
upon the set of tag
41+
tags: The set of tags to include/exclude elements/relationships when rendering
42+
this filtered view.
43+
"""
44+
45+
base_view_key: str = Field(alias="baseViewKey")
46+
mode: FilterMode
47+
tags: List[str]
48+
49+
50+
class FilteredView(AbstractView):
51+
"""
52+
Represent the filtered view from the C4 model.
53+
54+
A FilteredView is a view that is based on another view, but adding or removing
55+
specific elements specified by tags.
56+
57+
Attributes:
58+
view: the view which this FilteredView is based on
59+
base_view_key: The key of the view on which this filtered view is based.
60+
mode: Whether elements/relationships are being included or excluded based
61+
upon the set of tag
62+
tags: The set of tags to include/exclude elements/relationships when rendering
63+
this filtered view.
64+
"""
65+
66+
def __init__(
67+
self,
68+
mode: FilterMode,
69+
tags: Iterable[str],
70+
view: Optional[StaticView] = None,
71+
base_view_key: Optional[str] = None,
72+
**kwargs
73+
) -> None:
74+
"""Initialize a filtered view."""
75+
super().__init__(**kwargs)
76+
self._base_view_key = base_view_key
77+
self.view = view
78+
self.mode = mode
79+
self.tags = set(tags)
80+
81+
@property
82+
def base_view_key(self) -> str:
83+
"""Return the key of the base view."""
84+
return self.view.key if self.view else self._base_view_key
85+
86+
@classmethod
87+
def hydrate(
88+
cls,
89+
view_io: FilteredViewIO,
90+
) -> "FilteredView":
91+
"""Hydrate a new FilteredView instance from its IO."""
92+
return cls(
93+
**cls.hydrate_arguments(view_io),
94+
base_view_key=view_io.base_view_key,
95+
mode=view_io.mode,
96+
tags=view_io.tags
97+
)

src/structurizr/view/view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
from pydantic import Field
2222

23-
from .abstract_view import AbstractView, AbstractViewIO
2423
from ..model import Element, Model, Relationship, SoftwareSystem
24+
from .abstract_view import AbstractView, AbstractViewIO
2525
from .automatic_layout import AutomaticLayout, AutomaticLayoutIO
2626
from .element_view import ElementView, ElementViewIO
2727
from .paper_size import PaperSize

src/structurizr/view/view_set.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
from ..abstract_base import AbstractBase
2424
from ..base_model import BaseModel
2525
from ..mixin import ModelRefMixin
26+
from .abstract_view import AbstractView
2627
from .component_view import ComponentView, ComponentViewIO
2728
from .configuration import Configuration, ConfigurationIO
2829
from .container_view import ContainerView, ContainerViewIO
2930
from .deployment_view import DeploymentView, DeploymentViewIO
3031
from .dynamic_view import DynamicView, DynamicViewIO
32+
from .filtered_view import FilteredView, FilteredViewIO
3133
from .system_context_view import SystemContextView, SystemContextViewIO
3234
from .system_landscape_view import SystemLandscapeView, SystemLandscapeViewIO
3335
from .view import View
@@ -60,9 +62,7 @@ class ViewSetIO(BaseModel):
6062
default=(), alias="deploymentViews"
6163
)
6264
dynamic_views: List[DynamicViewIO] = Field(default=(), alias="dynamicViews")
63-
64-
# TODO:
65-
# filtered_views: List[FilteredView] = Field(set(), alias="filteredViews")
65+
filtered_views: List[FilteredViewIO] = Field(default=(), alias="filteredViews")
6666

6767

6868
class ViewSet(ModelRefMixin, AbstractBase):
@@ -83,9 +83,10 @@ def __init__(
8383
container_views: Iterable[ContainerView] = (),
8484
component_views: Iterable[ComponentView] = (),
8585
deployment_views: Iterable[DeploymentView] = (),
86+
filtered_views: Iterable[FilteredView] = (),
8687
dynamic_views: Iterable[DynamicView] = (),
8788
configuration: Optional[Configuration] = None,
88-
**kwargs
89+
**kwargs,
8990
) -> None:
9091
"""Initialize a view set."""
9192
super().__init__(**kwargs)
@@ -98,6 +99,7 @@ def __init__(
9899
self.component_views: Set[ComponentView] = set(component_views)
99100
self.deployment_views: Set[DeploymentView] = set(deployment_views)
100101
self.dynamic_views: Set[DynamicView] = set(dynamic_views)
102+
self.filtered_views: Set[FilteredView] = set(filtered_views)
101103
self.configuration = Configuration() if configuration is None else configuration
102104
self.set_model(model)
103105

@@ -150,7 +152,12 @@ def hydrate(cls, views: ViewSetIO, model: "Model") -> "ViewSet":
150152
cls._hydrate_view(view, model=model)
151153
dynamic_views.append(view)
152154

153-
return cls(
155+
filtered_views = []
156+
for view_io in views.filtered_views:
157+
view = FilteredView.hydrate(view_io)
158+
filtered_views.append(view)
159+
160+
result = cls(
154161
model=model,
155162
# TODO:
156163
# enterprise_context_views: Iterable[EnterpriseContextView] = (),
@@ -160,9 +167,17 @@ def hydrate(cls, views: ViewSetIO, model: "Model") -> "ViewSet":
160167
component_views=component_views,
161168
deployment_views=deployment_views,
162169
dynamic_views=dynamic_views,
170+
filtered_views=filtered_views,
163171
configuration=Configuration.hydrate(views.configuration),
164172
)
165173

174+
# Patch up filtered views
175+
for filtered_view in result.filtered_views:
176+
base_view = result[filtered_view.base_view_key]
177+
filtered_view.view = base_view
178+
179+
return result
180+
166181
@classmethod
167182
def _hydrate_view(cls, view: View, model: "Model") -> None:
168183
for element_view in view.element_views:
@@ -287,6 +302,40 @@ def create_dynamic_view(self, **kwargs) -> DynamicView:
287302
self.dynamic_views.add(dynamic_view)
288303
return dynamic_view
289304

305+
def create_filtered_view(self, **kwargs) -> FilteredView:
306+
"""
307+
Add a new FilteredView to the ViewSet.
308+
309+
Args:
310+
**kwargs: Provide keyword arguments for instantiating a `FilteredView`.
311+
"""
312+
# TODO:
313+
# AssertThatTheViewKeyIsUnique(key);
314+
filtered_view = FilteredView(**kwargs)
315+
filtered_view.set_viewset(self)
316+
self.filtered_views.add(filtered_view)
317+
return filtered_view
318+
319+
def get_view(self, key: str) -> Optional[AbstractView]:
320+
"""Return the view with the given key, or None."""
321+
all_views = (
322+
self.system_landscape_views
323+
| self.system_context_views
324+
| self.container_views
325+
| self.component_views
326+
| self.deployment_views
327+
| self.dynamic_views
328+
| self.filtered_views
329+
)
330+
return next((view for view in all_views if view.key == key), None)
331+
332+
def __getitem__(self, key: str) -> AbstractView:
333+
"""Return the view with the given key or raise a KeyError."""
334+
result = self.get_view(key)
335+
if not result:
336+
raise KeyError(f"No view with key '{key}' in ViewSet")
337+
return result
338+
290339
def copy_layout_information_from(self, source: "ViewSet") -> None:
291340
"""Copy all the layout information from a source ViewSet."""
292341
for source_view in source.system_landscape_views:

tests/unit/view/test_filtered_view.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# https://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
14+
"""Ensure the expected behaviour of FilteredView."""
15+
16+
17+
from structurizr.view.container_view import ContainerView
18+
from structurizr.view.filtered_view import FilteredView, FilteredViewIO, FilterMode
19+
20+
21+
def test_uses_view_key_if_view_specified():
22+
"""Test the logic around base_view_key."""
23+
filtered_view = FilteredView(
24+
base_view_key="key1", description="test", mode=FilterMode.Exclude, tags=[]
25+
)
26+
assert filtered_view.base_view_key == "key1"
27+
28+
filtered_view.view = ContainerView(key="static_key", description="container")
29+
assert filtered_view.base_view_key == "static_key"
30+
31+
32+
def test_serialisation():
33+
"""Test serialisation and deserialisation works."""
34+
container_view = ContainerView(key="static_key", description="container")
35+
filtered_view = FilteredView(
36+
key="filter1",
37+
view=container_view,
38+
description="test",
39+
mode=FilterMode.Exclude,
40+
tags=["v1"],
41+
)
42+
io = FilteredViewIO.from_orm(filtered_view)
43+
view2 = FilteredView.hydrate(io)
44+
45+
assert view2.base_view_key == "static_key"
46+
assert view2.key == "filter1"
47+
assert view2.description == "test"
48+
assert view2.mode == FilterMode.Exclude
49+
assert view2.tags == {"v1"}

tests/unit/view/test_view_set.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import pytest
1616

1717
from structurizr.model.model import Model
18+
from structurizr.view.container_view import ContainerView
19+
from structurizr.view.filtered_view import FilterMode
1820
from structurizr.view.paper_size import PaperSize
1921
from structurizr.view.view_set import ViewSet, ViewSetIO
2022

@@ -84,3 +86,42 @@ def test_copying_dynamic_view_layout(empty_viewset):
8486
def test_copying_layout(empty_model):
8587
"""Check copying layout from other view types."""
8688
assert 1 == 0 # TODO
89+
90+
91+
def test_filtered_view_hydrated(empty_viewset):
92+
"""Check dynamic views hydrated properly."""
93+
viewset = empty_viewset
94+
system1 = viewset.model.add_software_system(name="sys1")
95+
container_view = viewset.create_container_view(
96+
key="container1", description="container", software_system=system1
97+
)
98+
viewset.create_filtered_view(
99+
key="filter1",
100+
view=container_view,
101+
description="filtered",
102+
mode=FilterMode.Include,
103+
tags=["v2"],
104+
)
105+
io = ViewSetIO.from_orm(viewset)
106+
107+
new_viewset = ViewSet.hydrate(io, viewset.model)
108+
assert len(new_viewset.filtered_views) == 1
109+
view = list(new_viewset.filtered_views)[0]
110+
assert view.description == "filtered"
111+
assert isinstance(view.view, ContainerView)
112+
assert view.view.key == "container1"
113+
114+
115+
def test_getting_view_by_key(empty_viewset):
116+
"""Check retrieving views by key from the ViewSet."""
117+
viewset = empty_viewset
118+
system1 = viewset.model.add_software_system(name="sys1")
119+
container_view = viewset.create_container_view(
120+
key="container1", description="container", software_system=system1
121+
)
122+
123+
assert viewset.get_view("container1") is container_view
124+
assert viewset.get_view("bogus") is None
125+
assert viewset["container1"] is container_view
126+
with pytest.raises(KeyError, match="No view with key"):
127+
viewset["bogus"]

0 commit comments

Comments
 (0)