followthemoney.property

  1from banal import is_mapping, as_bool
  2from typing import TYPE_CHECKING, Any, List, Optional, TypedDict
  3
  4from followthemoney.exc import InvalidModel
  5from followthemoney.types import registry
  6from followthemoney.util import gettext, get_entity_id
  7
  8if TYPE_CHECKING:
  9    from followthemoney.schema import Schema
 10    from followthemoney.model import Model
 11
 12
 13class ReverseSpec(TypedDict, total=False):
 14    name: str
 15    label: Optional[str]
 16    hidden: Optional[bool]
 17
 18
 19class PropertyDict(TypedDict, total=False):
 20    label: Optional[str]
 21    description: Optional[str]
 22    type: Optional[str]
 23    hidden: Optional[bool]
 24    matchable: Optional[bool]
 25    deprecated: Optional[bool]
 26    maxLength: Optional[int]
 27    # stub: Optional[bool]
 28    range: Optional[str]
 29    format: Optional[str]
 30
 31
 32class PropertySpec(PropertyDict):
 33    reverse: ReverseSpec
 34
 35
 36class PropertyToDict(PropertyDict, total=False):
 37    name: str
 38    qname: str
 39    reverse: Optional[str]
 40    stub: Optional[bool]
 41
 42
 43class Property:
 44    """A definition of a value-holding field on a schema. Properties define
 45    the field type and other possible constraints. They also serve as entity
 46    to entity references."""
 47
 48    __slots__ = (
 49        "model",
 50        "schema",
 51        "name",
 52        "qname",
 53        "_label",
 54        "_hash",
 55        "_description",
 56        "hidden",
 57        "type",
 58        "matchable",
 59        "deprecated",
 60        "max_length",
 61        "_range",
 62        "format",
 63        "range",
 64        "stub",
 65        "_reverse",
 66        "reverse",
 67    )
 68
 69    #: Invalid property names.
 70    RESERVED = ["id", "caption", "schema", "schemata"]
 71
 72    def __init__(self, schema: "Schema", name: str, data: PropertySpec) -> None:
 73        #: The schema which the property is defined for. This is always the
 74        #: most abstract schema that has this property, not the possible
 75        #: child schemata that inherit it.
 76        self.schema = schema
 77
 78        #: Machine-readable name for this property.
 79        self.name = name
 80
 81        #: Qualified property name, which also includes the schema name.
 82        self.qname = "%s:%s" % (schema.name, self.name)
 83        if self.name in self.RESERVED:
 84            raise InvalidModel("Reserved name: %s" % self.name)
 85
 86        self._hash = hash("<Property(%r)>" % self.qname)
 87
 88        self._label = data.get("label", name)
 89        self._description = data.get("description")
 90
 91        #: This property is deprecated and should not be used.
 92        self.deprecated = as_bool(data.get("deprecated", False))
 93
 94        #: This property should not be shown or mentioned in the user interface.
 95        self.hidden = as_bool(data.get("hidden"))
 96
 97        type_ = data.get("type") or "string"
 98        #: The data type for this property.
 99        self.type = registry.get(type_)
100        if self.type is None:
101            raise InvalidModel("Invalid type: %s" % type_)
102
103        #: Whether this property should be used for matching and cross-referencing.
104        _matchable = data.get("matchable")
105        if _matchable is not None:
106            self.matchable = as_bool(data.get("matchable"))
107        else:
108            self.matchable = self.type.matchable
109
110        #: The maximum length of the property value.
111        self.max_length = int(data.get("maxLength") or self.type.max_length)
112
113        #: If the property is of type ``entity``, the set of valid schema to be added
114        #: in this property can be constrained. For example, an asset can be owned,
115        #: but a person cannot be owned.
116        self._range = data.get("range")
117        self.range: Optional["Schema"] = None
118
119        #: If the property is of type ``identifier``, a more narrow definition of the
120        #: identifier format can be provided. For example, LEI, INN or IBAN codes
121        #: can be automatically validated.
122        self.format: Optional[str] = data.get("format")
123
124        #: When a property points to another schema, a reverse property is added for
125        #: various administrative reasons. These properties are, however, not real
126        #: and cannot be written to. That's why they are marked as stubs and adding
127        #: values to them will raise an exception.
128        self.stub: Optional[bool] = False
129
130        #: When a property points to another schema, a stub reverse property is
131        #: added as a place to store metadata to help display the link in inverted
132        #: views.
133        self._reverse = data.get("reverse")
134        self.reverse: Optional["Property"] = None
135
136    def generate(self, model: "Model") -> None:
137        """Setup method used when loading the model in order to build out the reverse
138        links of the property."""
139        model.properties.add(self)
140
141        if self.type == registry.entity:
142            if self.range is None and self._range is not None:
143                self.range = model.get(self._range)
144
145            if self.reverse is None and self.range and self._reverse:
146                if not is_mapping(self._reverse):
147                    raise InvalidModel("Invalid reverse: %s" % self)
148                self.reverse = self.range._add_reverse(model, self._reverse, self)
149
150    @property
151    def label(self) -> str:
152        """User-facing title for this property."""
153        return gettext(self._label)
154
155    @property
156    def description(self) -> str:
157        """A longer description of the semantics of this property."""
158        return gettext(self._description)
159
160    def specificity(self, value: str) -> float:
161        """Return a measure of how precise the given value is."""
162        if not self.matchable:
163            return 0.0
164        return self.type.specificity(value)
165
166    def caption(self, value: str) -> str:
167        """Return a user-friendly caption for the given value."""
168        return self.type.caption(value, format=self.format)
169
170    def validate(self, data: List[Any]) -> Optional[str]:
171        """Validate that the data should be stored.
172
173        Since the types system doesn't really have validation, this currently
174        tries to normalize the value to see if it passes strict parsing.
175        """
176        values = []
177        for val in data:
178            if self.stub:
179                return gettext("Property cannot be written")
180            val = get_entity_id(val)
181            if val is None:
182                continue
183            if not self.type.validate(val):
184                return gettext("Invalid value")
185            if val is not None:
186                values.append(val)
187        return None
188
189    def __eq__(self, other: Any) -> bool:
190        return self._hash == hash(other)
191
192    def __hash__(self) -> int:
193        return self._hash
194
195    def to_dict(self) -> PropertyToDict:
196        """Return property metadata in a serializable form."""
197        data: PropertyToDict = {
198            "name": self.name,
199            "qname": self.qname,
200            "label": self.label,
201            "type": self.type.name,
202            "maxLength": self.max_length,
203        }
204        if self.description:
205            data["description"] = self.description
206        if self.stub:
207            data["stub"] = True
208        if self.matchable:
209            data["matchable"] = True
210        if self.hidden:
211            data["hidden"] = True
212        if self.deprecated:
213            data["deprecated"] = True
214        if self.range is not None:
215            data["range"] = self.range.name
216        if self.reverse is not None:
217            data["reverse"] = self.reverse.name
218        if self.format is not None:
219            data["format"] = self.format
220        return data
221
222    def __repr__(self) -> str:
223        return "<Property(%r)>" % self.qname
224
225    def __str__(self) -> str:
226        return self.qname
class ReverseSpec(typing.TypedDict):
14class ReverseSpec(TypedDict, total=False):
15    name: str
16    label: Optional[str]
17    hidden: Optional[bool]
name: str
label: Optional[str]
hidden: Optional[bool]
class PropertyDict(typing.TypedDict):
20class PropertyDict(TypedDict, total=False):
21    label: Optional[str]
22    description: Optional[str]
23    type: Optional[str]
24    hidden: Optional[bool]
25    matchable: Optional[bool]
26    deprecated: Optional[bool]
27    maxLength: Optional[int]
28    # stub: Optional[bool]
29    range: Optional[str]
30    format: Optional[str]
label: Optional[str]
description: Optional[str]
type: Optional[str]
hidden: Optional[bool]
matchable: Optional[bool]
deprecated: Optional[bool]
maxLength: Optional[int]
range: Optional[str]
format: Optional[str]
class PropertySpec(PropertyDict):
33class PropertySpec(PropertyDict):
34    reverse: ReverseSpec
reverse: ReverseSpec
class PropertyToDict(PropertyDict):
37class PropertyToDict(PropertyDict, total=False):
38    name: str
39    qname: str
40    reverse: Optional[str]
41    stub: Optional[bool]
name: str
qname: str
reverse: Optional[str]
stub: Optional[bool]
class Property:
 44class Property:
 45    """A definition of a value-holding field on a schema. Properties define
 46    the field type and other possible constraints. They also serve as entity
 47    to entity references."""
 48
 49    __slots__ = (
 50        "model",
 51        "schema",
 52        "name",
 53        "qname",
 54        "_label",
 55        "_hash",
 56        "_description",
 57        "hidden",
 58        "type",
 59        "matchable",
 60        "deprecated",
 61        "max_length",
 62        "_range",
 63        "format",
 64        "range",
 65        "stub",
 66        "_reverse",
 67        "reverse",
 68    )
 69
 70    #: Invalid property names.
 71    RESERVED = ["id", "caption", "schema", "schemata"]
 72
 73    def __init__(self, schema: "Schema", name: str, data: PropertySpec) -> None:
 74        #: The schema which the property is defined for. This is always the
 75        #: most abstract schema that has this property, not the possible
 76        #: child schemata that inherit it.
 77        self.schema = schema
 78
 79        #: Machine-readable name for this property.
 80        self.name = name
 81
 82        #: Qualified property name, which also includes the schema name.
 83        self.qname = "%s:%s" % (schema.name, self.name)
 84        if self.name in self.RESERVED:
 85            raise InvalidModel("Reserved name: %s" % self.name)
 86
 87        self._hash = hash("<Property(%r)>" % self.qname)
 88
 89        self._label = data.get("label", name)
 90        self._description = data.get("description")
 91
 92        #: This property is deprecated and should not be used.
 93        self.deprecated = as_bool(data.get("deprecated", False))
 94
 95        #: This property should not be shown or mentioned in the user interface.
 96        self.hidden = as_bool(data.get("hidden"))
 97
 98        type_ = data.get("type") or "string"
 99        #: The data type for this property.
100        self.type = registry.get(type_)
101        if self.type is None:
102            raise InvalidModel("Invalid type: %s" % type_)
103
104        #: Whether this property should be used for matching and cross-referencing.
105        _matchable = data.get("matchable")
106        if _matchable is not None:
107            self.matchable = as_bool(data.get("matchable"))
108        else:
109            self.matchable = self.type.matchable
110
111        #: The maximum length of the property value.
112        self.max_length = int(data.get("maxLength") or self.type.max_length)
113
114        #: If the property is of type ``entity``, the set of valid schema to be added
115        #: in this property can be constrained. For example, an asset can be owned,
116        #: but a person cannot be owned.
117        self._range = data.get("range")
118        self.range: Optional["Schema"] = None
119
120        #: If the property is of type ``identifier``, a more narrow definition of the
121        #: identifier format can be provided. For example, LEI, INN or IBAN codes
122        #: can be automatically validated.
123        self.format: Optional[str] = data.get("format")
124
125        #: When a property points to another schema, a reverse property is added for
126        #: various administrative reasons. These properties are, however, not real
127        #: and cannot be written to. That's why they are marked as stubs and adding
128        #: values to them will raise an exception.
129        self.stub: Optional[bool] = False
130
131        #: When a property points to another schema, a stub reverse property is
132        #: added as a place to store metadata to help display the link in inverted
133        #: views.
134        self._reverse = data.get("reverse")
135        self.reverse: Optional["Property"] = None
136
137    def generate(self, model: "Model") -> None:
138        """Setup method used when loading the model in order to build out the reverse
139        links of the property."""
140        model.properties.add(self)
141
142        if self.type == registry.entity:
143            if self.range is None and self._range is not None:
144                self.range = model.get(self._range)
145
146            if self.reverse is None and self.range and self._reverse:
147                if not is_mapping(self._reverse):
148                    raise InvalidModel("Invalid reverse: %s" % self)
149                self.reverse = self.range._add_reverse(model, self._reverse, self)
150
151    @property
152    def label(self) -> str:
153        """User-facing title for this property."""
154        return gettext(self._label)
155
156    @property
157    def description(self) -> str:
158        """A longer description of the semantics of this property."""
159        return gettext(self._description)
160
161    def specificity(self, value: str) -> float:
162        """Return a measure of how precise the given value is."""
163        if not self.matchable:
164            return 0.0
165        return self.type.specificity(value)
166
167    def caption(self, value: str) -> str:
168        """Return a user-friendly caption for the given value."""
169        return self.type.caption(value, format=self.format)
170
171    def validate(self, data: List[Any]) -> Optional[str]:
172        """Validate that the data should be stored.
173
174        Since the types system doesn't really have validation, this currently
175        tries to normalize the value to see if it passes strict parsing.
176        """
177        values = []
178        for val in data:
179            if self.stub:
180                return gettext("Property cannot be written")
181            val = get_entity_id(val)
182            if val is None:
183                continue
184            if not self.type.validate(val):
185                return gettext("Invalid value")
186            if val is not None:
187                values.append(val)
188        return None
189
190    def __eq__(self, other: Any) -> bool:
191        return self._hash == hash(other)
192
193    def __hash__(self) -> int:
194        return self._hash
195
196    def to_dict(self) -> PropertyToDict:
197        """Return property metadata in a serializable form."""
198        data: PropertyToDict = {
199            "name": self.name,
200            "qname": self.qname,
201            "label": self.label,
202            "type": self.type.name,
203            "maxLength": self.max_length,
204        }
205        if self.description:
206            data["description"] = self.description
207        if self.stub:
208            data["stub"] = True
209        if self.matchable:
210            data["matchable"] = True
211        if self.hidden:
212            data["hidden"] = True
213        if self.deprecated:
214            data["deprecated"] = True
215        if self.range is not None:
216            data["range"] = self.range.name
217        if self.reverse is not None:
218            data["reverse"] = self.reverse.name
219        if self.format is not None:
220            data["format"] = self.format
221        return data
222
223    def __repr__(self) -> str:
224        return "<Property(%r)>" % self.qname
225
226    def __str__(self) -> str:
227        return self.qname

A definition of a value-holding field on a schema. Properties define the field type and other possible constraints. They also serve as entity to entity references.

Property( schema: followthemoney.schema.Schema, name: str, data: PropertySpec)
 73    def __init__(self, schema: "Schema", name: str, data: PropertySpec) -> None:
 74        #: The schema which the property is defined for. This is always the
 75        #: most abstract schema that has this property, not the possible
 76        #: child schemata that inherit it.
 77        self.schema = schema
 78
 79        #: Machine-readable name for this property.
 80        self.name = name
 81
 82        #: Qualified property name, which also includes the schema name.
 83        self.qname = "%s:%s" % (schema.name, self.name)
 84        if self.name in self.RESERVED:
 85            raise InvalidModel("Reserved name: %s" % self.name)
 86
 87        self._hash = hash("<Property(%r)>" % self.qname)
 88
 89        self._label = data.get("label", name)
 90        self._description = data.get("description")
 91
 92        #: This property is deprecated and should not be used.
 93        self.deprecated = as_bool(data.get("deprecated", False))
 94
 95        #: This property should not be shown or mentioned in the user interface.
 96        self.hidden = as_bool(data.get("hidden"))
 97
 98        type_ = data.get("type") or "string"
 99        #: The data type for this property.
100        self.type = registry.get(type_)
101        if self.type is None:
102            raise InvalidModel("Invalid type: %s" % type_)
103
104        #: Whether this property should be used for matching and cross-referencing.
105        _matchable = data.get("matchable")
106        if _matchable is not None:
107            self.matchable = as_bool(data.get("matchable"))
108        else:
109            self.matchable = self.type.matchable
110
111        #: The maximum length of the property value.
112        self.max_length = int(data.get("maxLength") or self.type.max_length)
113
114        #: If the property is of type ``entity``, the set of valid schema to be added
115        #: in this property can be constrained. For example, an asset can be owned,
116        #: but a person cannot be owned.
117        self._range = data.get("range")
118        self.range: Optional["Schema"] = None
119
120        #: If the property is of type ``identifier``, a more narrow definition of the
121        #: identifier format can be provided. For example, LEI, INN or IBAN codes
122        #: can be automatically validated.
123        self.format: Optional[str] = data.get("format")
124
125        #: When a property points to another schema, a reverse property is added for
126        #: various administrative reasons. These properties are, however, not real
127        #: and cannot be written to. That's why they are marked as stubs and adding
128        #: values to them will raise an exception.
129        self.stub: Optional[bool] = False
130
131        #: When a property points to another schema, a stub reverse property is
132        #: added as a place to store metadata to help display the link in inverted
133        #: views.
134        self._reverse = data.get("reverse")
135        self.reverse: Optional["Property"] = None
RESERVED = ['id', 'caption', 'schema', 'schemata']
schema
name
qname
deprecated
hidden
type
max_length
range: Optional[followthemoney.schema.Schema]
format: Optional[str]
stub: Optional[bool]
reverse: Optional[Property]
def generate(self, model: followthemoney.model.Model) -> None:
137    def generate(self, model: "Model") -> None:
138        """Setup method used when loading the model in order to build out the reverse
139        links of the property."""
140        model.properties.add(self)
141
142        if self.type == registry.entity:
143            if self.range is None and self._range is not None:
144                self.range = model.get(self._range)
145
146            if self.reverse is None and self.range and self._reverse:
147                if not is_mapping(self._reverse):
148                    raise InvalidModel("Invalid reverse: %s" % self)
149                self.reverse = self.range._add_reverse(model, self._reverse, self)

Setup method used when loading the model in order to build out the reverse links of the property.

label: str
151    @property
152    def label(self) -> str:
153        """User-facing title for this property."""
154        return gettext(self._label)

User-facing title for this property.

description: str
156    @property
157    def description(self) -> str:
158        """A longer description of the semantics of this property."""
159        return gettext(self._description)

A longer description of the semantics of this property.

def specificity(self, value: str) -> float:
161    def specificity(self, value: str) -> float:
162        """Return a measure of how precise the given value is."""
163        if not self.matchable:
164            return 0.0
165        return self.type.specificity(value)

Return a measure of how precise the given value is.

def caption(self, value: str) -> str:
167    def caption(self, value: str) -> str:
168        """Return a user-friendly caption for the given value."""
169        return self.type.caption(value, format=self.format)

Return a user-friendly caption for the given value.

def validate(self, data: List[Any]) -> Optional[str]:
171    def validate(self, data: List[Any]) -> Optional[str]:
172        """Validate that the data should be stored.
173
174        Since the types system doesn't really have validation, this currently
175        tries to normalize the value to see if it passes strict parsing.
176        """
177        values = []
178        for val in data:
179            if self.stub:
180                return gettext("Property cannot be written")
181            val = get_entity_id(val)
182            if val is None:
183                continue
184            if not self.type.validate(val):
185                return gettext("Invalid value")
186            if val is not None:
187                values.append(val)
188        return None

Validate that the data should be stored.

Since the types system doesn't really have validation, this currently tries to normalize the value to see if it passes strict parsing.

def to_dict(self) -> PropertyToDict:
196    def to_dict(self) -> PropertyToDict:
197        """Return property metadata in a serializable form."""
198        data: PropertyToDict = {
199            "name": self.name,
200            "qname": self.qname,
201            "label": self.label,
202            "type": self.type.name,
203            "maxLength": self.max_length,
204        }
205        if self.description:
206            data["description"] = self.description
207        if self.stub:
208            data["stub"] = True
209        if self.matchable:
210            data["matchable"] = True
211        if self.hidden:
212            data["hidden"] = True
213        if self.deprecated:
214            data["deprecated"] = True
215        if self.range is not None:
216            data["range"] = self.range.name
217        if self.reverse is not None:
218            data["reverse"] = self.reverse.name
219        if self.format is not None:
220            data["format"] = self.format
221        return data

Return property metadata in a serializable form.

matchable
model