Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ History
Next Release
------------
* Feat: Add ``DynamicView`` (#77)
* Feat: Add ``FilteredView`` (#81)
* Breaking change: View.find_element_view and find_relationship_view parameter changes.

0.5.0 (2021-05-03)
Expand Down
73 changes: 73 additions & 0 deletions src/structurizr/view/abstract_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""Provide a superclass for all views."""


from abc import ABC
from typing import Dict

from ..abstract_base import AbstractBase
from ..base_model import BaseModel
from ..mixin import ViewSetRefMixin


__all__ = ("AbstractView", "AbstractViewIO")


class AbstractViewIO(BaseModel, ABC):
"""
Define an abstract base class for all views.

Views include static views, dynamic views, deployment views and filtered views.
"""

key: str
description: str = ""
title: str = ""


class AbstractView(ViewSetRefMixin, AbstractBase, ABC):
"""
Define an abstract base class for all views.

Views include static views, dynamic views, deployment views and filtered views.

"""

def __init__(
self,
*,
key: str = None,
description: str,
title: str = "",
**kwargs,
):
"""Initialize a view with a 'private' view set."""
super().__init__(**kwargs)
self.key = key
self.description = description
self.title = title

def __repr__(self) -> str:
"""Return repr(self)."""
return f"{type(self).__name__}(key={self.key})"

@classmethod
def hydrate_arguments(cls, view_io: AbstractViewIO) -> Dict:
"""Hydrate an AbstractViewIO into the constructor args for AbstractView."""
return {
"key": view_io.key,
"description": view_io.description,
"title": view_io.title,
}
Comment on lines +69 to +73
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not very happy with how we construct these arguments but that's neither here nor there.

110 changes: 110 additions & 0 deletions src/structurizr/view/filtered_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""Provide a filtered view."""

from enum import Enum
from typing import Iterable, List, Optional, Union

from ordered_set import OrderedSet
from pydantic import Field, validator

from .abstract_view import AbstractView, AbstractViewIO
from .static_view import StaticView


__all__ = ("FilteredView", "FilteredViewIO")


class FilterMode(Enum):
"""indicates whether elements/relationships are being included or excluded."""

Include = "Include"
Exclude = "Exclude"


class FilteredViewIO(AbstractViewIO):
"""
Represent the FilteredView from the C4 model.

Attributes:
base_view_key: The key of the view on which this filtered view is based.
mode: Whether elements/relationships are being included or excluded based
upon the set of tag
tags: The set of tags to include/exclude elements/relationships when rendering
this filtered view.

Note that unlike Model Items, when filtered view tags are serialised to JSON then
they are serialised as an array rather than comma-separated.
"""

base_view_key: str = Field(alias="baseViewKey")
mode: FilterMode
tags: List[str]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at ModelItemIO. We introduced two methods there to convert the tags to and from string which is what the structurizr API expects.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the API is inconsistent, and for FilteredView the tags are serialised in JSON as an array. I've added the validator anyway for safety.

https://editor.swagger.io/?url=https://raw.githubusercontent.com/structurizr/json/master/structurizr.yaml

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try this against an actual Structurizr instance to make sure the Swagger is correct though...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I built a filtered view in Structurizr and exported the JSON, and it does indeed serialise the tags as an array rather than comma-separated.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, thanks for digging into that. @simonbrowndotje is that an oversight or fully intentional?

Copy link

@simonbrowndotje simonbrowndotje Jun 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was intentional at one point in time, but not any more ... when I first started writing the Java client library (5+ years ago), I had some issues with the order of array elements being preserved. The order for tags defining a filtered view isn't important, but the ordering of element/relationship tags is. So the former is an array, whereas the latter is a comma-separated list.


@validator("tags", pre=True)
def split_tags(cls, tags: Union[str, Iterable[str]]) -> List[str]:
"""Convert comma-separated tag list into list if needed."""
if isinstance(tags, str):
return tags.split(",")
return list(tags)


class FilteredView(AbstractView):
"""
Represent the filtered view from the C4 model.

A FilteredView is a view that is based on another view, but including or excluding
elements and relationships from the base view according to their tags.

Attributes:
view: the view which this FilteredView is based on
base_view_key: The key of the view on which this filtered view is based.
mode: Whether elements/relationships are being included or excluded based
upon the set of tag
tags: The set of tags to include/exclude elements/relationships when rendering
this filtered view.
"""

def __init__(
self,
mode: FilterMode,
tags: Iterable[str],
view: Optional[StaticView] = None,
base_view_key: Optional[str] = None,
**kwargs
) -> None:
"""Initialize a filtered view."""
super().__init__(**kwargs)
self._base_view_key = base_view_key
self.view = view
self.mode = mode
self.tags = OrderedSet(tags)

@property
def base_view_key(self) -> str:
"""Return the key of the base view."""
return self.view.key if self.view else self._base_view_key

@classmethod
def hydrate(
cls,
view_io: FilteredViewIO,
) -> "FilteredView":
"""Hydrate a new FilteredView instance from its IO."""
return cls(
**cls.hydrate_arguments(view_io),
base_view_key=view_io.base_view_key,
mode=view_io.mode,
tags=view_io.tags
)
30 changes: 6 additions & 24 deletions src/structurizr/view/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,12 @@
"""Provide a superclass for all views."""


from abc import ABC
from typing import Any, Dict, Iterable, List, Optional, Set

from pydantic import Field

from ..abstract_base import AbstractBase
from ..base_model import BaseModel
from ..mixin import ViewSetRefMixin
from ..model import Element, Model, Relationship, SoftwareSystem
from .abstract_view import AbstractView, AbstractViewIO
from .automatic_layout import AutomaticLayout, AutomaticLayoutIO
from .element_view import ElementView, ElementViewIO
from .paper_size import PaperSize
Expand All @@ -34,21 +31,18 @@
__all__ = ("View", "ViewIO")


class ViewIO(BaseModel, ABC):
class ViewIO(AbstractViewIO):
"""
Define an abstract base class for all views.
Define a base class for non-filtered views.

Views include static views, dynamic views and deployment views.
"""

key: str
description: str = ""
software_system_id: Optional[str] = Field(default=None, alias="softwareSystemId")
paper_size: Optional[PaperSize] = Field(default=None, alias="paperSize")
automatic_layout: Optional[AutomaticLayoutIO] = Field(
default=None, alias="automaticLayout"
)
title: str = ""

element_views: List[ElementViewIO] = Field(default=(), alias="elements")
relationship_views: List[RelationshipViewIO] = Field(
Expand All @@ -61,9 +55,9 @@ class ViewIO(BaseModel, ABC):
# )


class View(ViewSetRefMixin, AbstractBase, ABC):
class View(AbstractView):
"""
Define an abstract base class for all views.
Define a base class for non-filtered views.

Views include static views, dynamic views and deployment views.

Expand All @@ -73,11 +67,8 @@ def __init__(
self,
*,
software_system: Optional[SoftwareSystem] = None,
key: str = None,
description: str,
paper_size: Optional[PaperSize] = None,
automatic_layout: Optional[AutomaticLayout] = None,
title: str = "",
element_views: Optional[Iterable[ElementView]] = (),
relationship_views: Optional[Iterable[RelationshipView]] = (),
layout_merge_strategy: Optional[Any] = None,
Expand All @@ -87,33 +78,24 @@ def __init__(
super().__init__(**kwargs)
self.software_system = software_system
self.software_system_id = software_system.id if software_system else None
self.key = key
self.description = description
self.paper_size = paper_size
self.automatic_layout = automatic_layout
self.title = title
self.element_views: Set[ElementView] = set(element_views)
self._relationship_views: Set[RelationshipView] = set(relationship_views)

# TODO
self.layout_merge_strategy = layout_merge_strategy

def __repr__(self) -> str:
"""Return repr(self)."""
return f"{type(self).__name__}(key={self.key})"

@classmethod
def hydrate_arguments(cls, view_io: ViewIO) -> Dict:
"""Hydrate a ViewIO into the constructor arguments for View."""
return {
**super().hydrate_arguments(view_io),
# TODO: should we add this here? probably not: "software_system"
"key": view_io.key,
"description": view_io.description,
"paper_size": view_io.paper_size,
"automatic_layout": AutomaticLayout.hydrate(view_io.automatic_layout)
if view_io.automatic_layout
else None,
"title": view_io.title,
"element_views": map(ElementView.hydrate, view_io.element_views),
"relationship_views": map(
RelationshipView.hydrate, view_io.relationship_views
Expand Down
Loading