followthemoney.property

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

User-facing title for this property.

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

A longer description of the semantics of this property.

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

Return a measure of how precise the given value is.

def validate(self, data: List[Any]) -> Optional[str]:
169    def validate(self, data: List[Any]) -> Optional[str]:
170        """Validate that the data should be stored.
171
172        Since the types system doesn't really have validation, this currently
173        tries to normalize the value to see if it passes strict parsing.
174        """
175        values = []
176        for val in data:
177            if self.stub:
178                return gettext("Property cannot be written")
179            val = get_entity_id(val)
180            if val is None:
181                continue
182            if not self.type.validate(val):
183                return gettext("Invalid value")
184            if val is not None:
185                values.append(val)
186        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:
194    def to_dict(self) -> PropertyToDict:
195        """Return property metadata in a serializable form."""
196        data: PropertyToDict = {
197            "name": self.name,
198            "qname": self.qname,
199            "label": self.label,
200            "type": self.type.name,
201        }
202        if self.description:
203            data["description"] = self.description
204        if self.stub:
205            data["stub"] = True
206        if self.matchable:
207            data["matchable"] = True
208        if self.hidden:
209            data["hidden"] = True
210        if self.deprecated:
211            data["deprecated"] = True
212        if self.range is not None:
213            data["range"] = self.range.name
214        if self.reverse is not None:
215            data["reverse"] = self.reverse.name
216        if self.format is not None:
217            data["format"] = self.format
218        return data

Return property metadata in a serializable form.

matchable
model