Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 163 additions & 114 deletions PYDANTIC_GUIDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ class BuildingPart(
building_id: Annotated[
Id,
Field(description="The building to which this part belongs"),
Reference(Relationship.BELONGS_TO, Building),
Reference(Relationship.COMPOSITION, Building, role="part_of"),
]
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ def describe_field_constraint(
target = constraint.relatee
target_id = TypeIdentity.of(target)
target_str = link_fn(target_id) if link_fn else f"`{target.__name__}`"
if constraint.role:
role_label = constraint.role.replace("_", " ")
return f"References {target_str} ({rel_label}, {role_label})"
return f"References {target_str} ({rel_label})"
if isinstance(constraint, Interval):
desc = _describe_interval(constraint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ class Venue(
]
capacity: Annotated[int, Field(ge=1)] | None = None
resident_ensemble: (
Annotated[Id, Reference(Relationship.BELONGS_TO, Instrument)] | None
Annotated[Id, Reference(Relationship.AGGREGATION, Instrument, role="part_of")]
| None
) = None


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A location where musical performances take place.
| `description` | `string` (optional) | *At least one of `name`, `description` must be set* |
| `geometry` | `geometry` | *Allowed geometry types: Point, Polygon* |
| `capacity` | `int64` (optional) | *`≥ 1`* |
| `resident_ensemble` | `Id` (optional) | A unique identifier<br/><br/>*References `Instrument` (belongs to)* |
| `resident_ensemble` | `Id` (optional) | A unique identifier<br/><br/>*References `Instrument` (aggregation, part of)* |

## Constraints

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,22 +382,22 @@ def test_geometry_type_all_types(self) -> None:
== "Allowed geometry types: LineString, Point, Polygon"
)

def test_reference_belongs_to(self) -> None:
def test_reference_composition(self) -> None:
class Target(Identified):
pass

constraint = Reference(Relationship.BELONGS_TO, Target)
constraint = Reference(Relationship.COMPOSITION, Target)
assert (
describe_field_constraint(constraint) == "References `Target` (belongs to)"
describe_field_constraint(constraint) == "References `Target` (composition)"
)

def test_reference_connects_to(self) -> None:
def test_reference_association(self) -> None:
class Other(Identified):
pass

constraint = Reference(Relationship.CONNECTS_TO, Other)
constraint = Reference(Relationship.ASSOCIATION, Other)
assert (
describe_field_constraint(constraint) == "References `Other` (connects to)"
describe_field_constraint(constraint) == "References `Other` (association)"
)

def test_reference_link_fn_receives_type_identity(self) -> None:
Expand All @@ -412,25 +412,25 @@ def link_fn(tid: TypeIdentity) -> str:
received.append(tid)
return f"[`{tid.name}`](link)"

constraint = Reference(Relationship.BELONGS_TO, Target)
constraint = Reference(Relationship.COMPOSITION, Target)
result = describe_field_constraint(constraint, link_fn=link_fn)

assert len(received) == 1
assert received[0].obj is Target
assert received[0].name == "Target"
assert result == "References [`Target`](link) (belongs to)"
assert result == "References [`Target`](link) (composition)"

def test_reference_link_fn_used_in_output(self) -> None:
"""link_fn return value appears verbatim in the description."""

class Target(Identified):
pass

constraint = Reference(Relationship.CONNECTS_TO, Target)
constraint = Reference(Relationship.ASSOCIATION, Target)
result = describe_field_constraint(
constraint, link_fn=lambda tid: f"[`{tid.name}`](path/to/target)"
)
assert result == "References [`Target`](path/to/target) (connects to)"
assert result == "References [`Target`](path/to/target) (association)"


class TestConstraintDisplayText:
Expand All @@ -442,7 +442,7 @@ def test_link_fn_forwarded_to_reference_constraint(self) -> None:
class Target(Identified):
pass

constraint = Reference(Relationship.BELONGS_TO, Target)
constraint = Reference(Relationship.COMPOSITION, Target)
cs = ConstraintSource(source_ref=None, source_name=None, constraint=constraint)

received: list[TypeIdentity] = []
Expand All @@ -455,4 +455,4 @@ def link_fn(tid: TypeIdentity) -> str:

assert len(received) == 1
assert received[0].obj is Target
assert result == "References [`Target`](link) (belongs to)"
assert result == "References [`Target`](link) (composition)"
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ def test_venue_reference_links_when_context_available(self) -> None:
lines = result.splitlines()
ref_line = next(line for line in lines if "| `resident_ensemble` |" in line)
assert "[`Instrument`](instrument.md)" in ref_line
assert "belongs to" in ref_line
assert "aggregation, part of" in ref_line

def test_venue_reference_unlinked_without_context(self) -> None:
"""Reference constraint renders as plain code when no LinkContext."""
Expand All @@ -629,7 +629,7 @@ def test_venue_reference_unlinked_without_context(self) -> None:
lines = result.splitlines()
ref_line = next(line for line in lines if "| `resident_ensemble` |" in line)
assert "References `Instrument`" in ref_line
assert "belongs to" in ref_line
assert "aggregation, part of" in ref_line


class TestRenderEnumBasic:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class CapitalOfDivisionItem(BaseModel):
division_id: Annotated[
Id,
Field(description="ID of the division whose capital is the current division."),
Reference(Relationship.CAPITAL_OF, Division),
Reference(Relationship.HIERARCHY, Division, role="capital_of"),
]
subtype: DivisionSubtype

Expand All @@ -139,7 +139,7 @@ class HierarchyItem(BaseModel):
the division itself, and any other division that is an ancestor of the division's parent.
""").strip()
),
Reference(Relationship.DESCENDANT_OF, Division),
Reference(Relationship.HIERARCHY, Division, role="descendant_of"),
]
subtype: DivisionSubtype
name: Annotated[
Expand Down Expand Up @@ -276,7 +276,7 @@ class Division(
parent divisions.
""").strip()
),
Reference(Relationship.CHILD_OF, Division),
Reference(Relationship.HIERARCHY, Division, role="child_of"),
] = None
admin_level: AdminLevel | None = None

Expand Down Expand Up @@ -382,7 +382,7 @@ class Division(
list[
Annotated[
Id,
Reference(Relationship.CAPITALLED_BY, Division),
Reference(Relationship.HIERARCHY, Division, role="has_as_capital"),
]
]
| None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ class DivisionArea(
division_id: Annotated[
Id,
Field(
description="Division ID of the division this area belongs to.",
description="Division ID of the parent division of this area.",
),
Reference(Relationship.BELONGS_TO, Division),
Reference(Relationship.HIERARCHY, Division, role="child_of"),
]
country: Annotated[
CountryCodeAlpha2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class DivisionBoundary(
list[
Annotated[
Id,
Reference(Relationship.BOUNDARY_OF, Division),
Reference(Relationship.COMPOSITION, Division, role="boundary_of"),
]
],
Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@
"type": "string"
},
"division_id": {
"description": "Division ID of the division this area belongs to.",
"description": "Division ID of the parent division of this area.",
"minLength": 1,
"pattern": "^\\S+$",
"title": "Division Id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
>>> class Park(Identified):
... pass
>>> class ParkBench(Identified):
... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)]
... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")]
"""

from . import (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class Identified(BaseModel):
... name: str = Field(description = 'Name of the room')
... house_id: Annotated[
... Id,
... Reference(Relationship.BELONGS_TO, House)
... ] = Field(description = "Unique ID of the house the room belongs to.")
... Reference(Relationship.COMPOSITION, House, role="inside_of")
... ] = Field(description = "Unique ID of the house the room is inside of.")

When combining `Identified` with another Pydantic model that has an `id` field, such as a
:class:`~overture.schema.system.feature.Feature`, you must derive from `Identified` first in
Expand Down
102 changes: 65 additions & 37 deletions packages/overture-schema-system/src/overture/schema/system/ref/ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,111 @@
Relationships and references between related entities.
"""

import re
from dataclasses import dataclass
from enum import Enum

from overture.schema.system.doc import DocumentedEnum

from .id import Identified

_SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")


class Relationship(Enum):
class Relationship(str, DocumentedEnum):
"""
Category of relationship between two values, where the first value refers to the second one.
The kind of relationship that exists between two entities.

Relationships represent connections between different features or models. Think of them as links
that connect related pieces of information, like a building part that is structurally part of a
building; or a division area that is administratively nested under a division.

Every kind of relationship between two entities says something about how tightly those entities
are connected, for example: whether one depends on the other to exist (composition); whether
both members are strongly-connected but independently viable (aggregation); whether one of the
two is superior while the other is subordinate (hierarchy); or whether they are simply peers
with a loose affiliation to one another (association).

The kinds of relationship can be thought of as forming a diamond-shaped hierarchy where
composition, the strongest form of relationship, appears at the top; aggregation and hierarchy
as independent relationship types that are weaker than composition but stronger than
association, appears in the middle; and association, the weakest and least well-defined form of
relationship, is at the bottom.

COMPOSITION
/ \\
AGGREGATION HIERARCHY
\\ /
ASSOCIATION

If we call the first value, the one that holds the reference, the relator; and the second value,
value, the one that is referred to, as the relatee; then this value represents the relationship
from the perspective of the relator.
Note that the *kind* of a relationship does not say anything about the *directionality* of the
relationship. The fact that F is in a hierarchy relationship with G does not, without outside
information, tell you whether F is the parent or the child.
"""

def __init__(self, value: str, doc: str) -> None:
self._value_ = value
self.__doc__ = doc

BELONGS_TO = "belongs_to", "The relator belongs to the relatee"
BOUNDARY_OF = "boundary_of", "The relator is a boundary of the relatee"
CAPITAL_OF = "capital_of", "The relator is a capital of the relatee"
CAPITALLED_BY = "capitalled_by", "The relator has the relatee as its capital"
CHILD_OF = "child_of", "The relator is a child of the relatee"
CONNECTS_TO = "connects_to", "The relator connects to the relatee"
DESCENDANT_OF = (
"descendant_of",
"The relator is a hierarchical descendant of the relatee",
COMPOSITION = (
"composition",
"A structural whole-part relationship with lifecycle dependency",
)
AGGREGATION = "aggregation", "A grouping relationship without lifecycle dependency"
HIERARCHY = (
"hierarchy",
"A parent/child relationship within a hierarchy such as an organization or taxonomy",
)
ASSOCIATION = "association", "A peer-level reference without ownership or nesting"


@dataclass(frozen=True, slots=True)
class Reference:
"""
Annotation class describing a relationship between two values where the relatee is referenced
by its unique ID.
Annotation class describing a relationship between two values where the relator refers to the
relatee by the latter's unique ID.

The relator, which is the subject or source of the relationship, is the type annotated with an
instance of this class ("the thing that relates"). The relatee is type that is the object or
target of the relationship ("the thing related to").

Parameters
----------
relationship : Relationship
The kind of relationship between the relator (the type annotated with an instance of this
class that is said to "hold the reference") and the relatee.
The category of the relationship between the relator and the relatee.
relatee : type[Identified]
The type that is the object or target of the relationship ("the thing related to").

Attributes
----------
relationship : Relationship
The kind of relationship between the relator (the type annotated with an instance of this
class that is said to "hold the reference") and the relatee.
relatee : type[Identifier]
The type that is the object or target of the relationship ("the thing related to").
The type that is the target of the relationship.
role : str | None
An optional snake_case descriptor that further describes the relationship from the
perspective of the relator. This field has no effect on schema validation; it is
informational metadata for documentation and tooling.

Examples
--------
A hypothetical ParkBench model holds a foreign key relationship to the model of the park the
bench belongs to.
A hypothetical ParkBench model holds a foreign key relationship to the model of the park that
contains it.

>>> from typing import Annotated
>>> from overture.schema.system.ref import Id, Identified
>>> class Park(Identified):
... pass
>>> class ParkBench(Identified):
... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)]
... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")]
"""

relationship: Relationship
relatee: type[Identified]
role: str | None = None

def __post_init__(self) -> None:
if not isinstance(self.relationship, Relationship):
raise TypeError(
f"`relationship` must be a member of the `Relationship` enumeration, but {self.relationship} is a `{type(self.relationship).__name__}`"
f"`relationship` must be a member of the `Relationship` enumeration, "
f"but {self.relationship} is a `{type(self.relationship).__name__}`"
)
if not isinstance(self.relatee, type) or not issubclass(
self.relatee, Identified
):
raise TypeError(
f"`relatee` must be a type derived from `Identified`, but {self.relatee} is a `{type(self.relatee).__name__}`"
f"`relatee` must be a type derived from `Identified`, "
f"but {self.relatee} is a `{type(self.relatee).__name__}`"
)
if self.role is not None and not _SNAKE_CASE_RE.match(self.role):
raise ValueError(
f"`role` must be a non-empty snake_case string, but got {self.role!r}"
)
Loading
Loading