Skip to content

Core

Base classes for working with the Notion API.

ComposableObjectMeta

Bases: ModelMetaclass

Presents a metaclass that composes objects using simple values.

This is primarily to allow easy definition of data objects without disrupting the BaseModel constructor. e.g. rather than requiring a caller to understand how nested data works in the data objects, they can compose objects from simple values.

Compare the following code for declaring a Paragraph:

# using nested data objects:
text = "hello world"
nested = TextObject._NestedData(content=text)
rtf = text.TextObject(text=nested, plain_text=text)
content = blocks.Paragraph._NestedData(text=[rtf])
para = blocks.Paragraph(paragraph=content)

# using a composable object:
para = blocks.Paragraph["hello world"]

Classes that support composition in this way must define and implement the internal __compose__ method. This method takes an arbitrary number of parameters, based on the needs of the implementation. It is up to the implementing class to ensure that the parameters are specified correctly.

Source code in src/notional/core.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class ComposableObjectMeta(ModelMetaclass):
    """Presents a metaclass that composes objects using simple values.

    This is primarily to allow easy definition of data objects without disrupting the
    `BaseModel` constructor.  e.g. rather than requiring a caller to understand how
    nested data works in the data objects, they can compose objects from simple values.

    Compare the following code for declaring a Paragraph:

    ```python
    # using nested data objects:
    text = "hello world"
    nested = TextObject._NestedData(content=text)
    rtf = text.TextObject(text=nested, plain_text=text)
    content = blocks.Paragraph._NestedData(text=[rtf])
    para = blocks.Paragraph(paragraph=content)

    # using a composable object:
    para = blocks.Paragraph["hello world"]
    ```

    Classes that support composition in this way must define and implement the internal
    `__compose__` method.  This method takes an arbitrary number of parameters, based
    on the needs of the implementation.  It is up to the implementing class to ensure
    that the parameters are specified correctly.
    """

    def __getitem__(self, params):
        """Return the requested class by composing using the given param.

        Types found in `params` will be compared to expected types in the `__compose__`
        method.

        If the requested class does not expose the `__compose__` method, this will raise
        an exception.
        """

        if not hasattr(self, "__compose__"):
            raise NotImplementedError(f"{self} does not support object composition")

        compose_func = self.__compose__

        # __getitem__ only accepts a single parameter...  if the caller provides
        # multiple params, they will be converted and passed as a tuple.  this method
        # also accepts a list for readability when composing from ORM properties

        if params and type(params) in (list, tuple):
            return compose_func(*params)

        return compose_func(params)

__getitem__(params)

Return the requested class by composing using the given param.

Types found in params will be compared to expected types in the __compose__ method.

If the requested class does not expose the __compose__ method, this will raise an exception.

Source code in src/notional/core.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def __getitem__(self, params):
    """Return the requested class by composing using the given param.

    Types found in `params` will be compared to expected types in the `__compose__`
    method.

    If the requested class does not expose the `__compose__` method, this will raise
    an exception.
    """

    if not hasattr(self, "__compose__"):
        raise NotImplementedError(f"{self} does not support object composition")

    compose_func = self.__compose__

    # __getitem__ only accepts a single parameter...  if the caller provides
    # multiple params, they will be converted and passed as a tuple.  this method
    # also accepts a list for readability when composing from ORM properties

    if params and type(params) in (list, tuple):
        return compose_func(*params)

    return compose_func(params)

GenericObject

Bases: BaseModel

The base for all API objects.

As a general convention, data fields in lower case are defined by the Notion API. Properties in Title Case are provided for convenience.

Source code in src/notional/core.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class GenericObject(BaseModel, metaclass=ComposableObjectMeta):
    """The base for all API objects.

    As a general convention, data fields in lower case are defined by the
    Notion API.  Properties in Title Case are provided for convenience.
    """

    def __setattr__(self, name, value):
        """Set the attribute of this object to a given value.

        The implementation of `BaseModel.__setattr__` does not support property setters.

        See https://github.com/samuelcolvin/pydantic/issues/1577
        """
        try:
            super().__setattr__(name, value)
        except ValueError as err:
            setters = inspect.getmembers(
                object=self.__class__,
                predicate=lambda x: isinstance(x, property) and x.fset is not None,
            )
            for setter_name, _ in setters:
                if setter_name == name:
                    object.__setattr__(self, name, value)
                    break
            else:
                raise err

    @classmethod
    def _set_field_default(cls, name, default=None):
        """Modify the `BaseModel` field information for a specific class instance.

        This is necessary in particular for subclasses that change the default values
        of a model when defined.  Notable examples are `TypedObject` and `NotionObject`.

        :param name: the named attribute in the class
        :param default: the new default for the named field
        """

        # set the attribute on the class to the given default
        setattr(cls, name, default)

        # update the model field definition
        field = cls.__fields__.get(name)

        field.default = default
        field.required = default is None

    # https://github.com/samuelcolvin/pydantic/discussions/3139
    def refresh(__notional_self__, **data):
        """Refresh the internal attributes with new data."""

        values, fields, error = validate_model(__notional_self__.__class__, data)

        if error:
            raise error

        for name in fields:
            value = values[name]
            logger.debug("set object data -- %s => %s", name, value)
            setattr(__notional_self__, name, value)

        return __notional_self__

    def dict(self, **kwargs):
        """Convert to a suitable representation for the Notion API."""

        # the API doesn't like "undefined" values...
        kwargs["exclude_none"] = True
        kwargs["by_alias"] = True

        obj = super().dict(**kwargs)

        # TODO read-only fields should not be sent to the API
        # https://github.com/jheddings/notional/issues/9

        return serialize_to_api(obj)

__setattr__(name, value)

Set the attribute of this object to a given value.

The implementation of BaseModel.__setattr__ does not support property setters.

See https://github.com/samuelcolvin/pydantic/issues/1577

Source code in src/notional/core.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __setattr__(self, name, value):
    """Set the attribute of this object to a given value.

    The implementation of `BaseModel.__setattr__` does not support property setters.

    See https://github.com/samuelcolvin/pydantic/issues/1577
    """
    try:
        super().__setattr__(name, value)
    except ValueError as err:
        setters = inspect.getmembers(
            object=self.__class__,
            predicate=lambda x: isinstance(x, property) and x.fset is not None,
        )
        for setter_name, _ in setters:
            if setter_name == name:
                object.__setattr__(self, name, value)
                break
        else:
            raise err

dict(**kwargs)

Convert to a suitable representation for the Notion API.

Source code in src/notional/core.py
158
159
160
161
162
163
164
165
166
167
168
169
170
def dict(self, **kwargs):
    """Convert to a suitable representation for the Notion API."""

    # the API doesn't like "undefined" values...
    kwargs["exclude_none"] = True
    kwargs["by_alias"] = True

    obj = super().dict(**kwargs)

    # TODO read-only fields should not be sent to the API
    # https://github.com/jheddings/notional/issues/9

    return serialize_to_api(obj)

refresh(__notional_self__, **data)

Refresh the internal attributes with new data.

Source code in src/notional/core.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def refresh(__notional_self__, **data):
    """Refresh the internal attributes with new data."""

    values, fields, error = validate_model(__notional_self__.__class__, data)

    if error:
        raise error

    for name in fields:
        value = values[name]
        logger.debug("set object data -- %s => %s", name, value)
        setattr(__notional_self__, name, value)

    return __notional_self__

NotionObject

Bases: GenericObject

A top-level Notion API resource.

Source code in src/notional/core.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class NotionObject(GenericObject):
    """A top-level Notion API resource."""

    object: str
    id: Optional[UUID] = None

    def __init_subclass__(cls, object=None, **kwargs):
        """Update `GenericObject` defaults for the named object."""
        super().__init_subclass__(**kwargs)

        if object is not None:
            cls._set_field_default("object", default=object)

    @validator("object", always=True, pre=False)
    def _verify_object_matches_expected(cls, val):
        """Make sure that the deserialzied object matches the name in this class."""

        if val != cls.object:
            raise ValueError(f"Invalid object for {cls.object} - {val}")

        return val

__init_subclass__(object=None, **kwargs)

Update GenericObject defaults for the named object.

Source code in src/notional/core.py
179
180
181
182
183
184
def __init_subclass__(cls, object=None, **kwargs):
    """Update `GenericObject` defaults for the named object."""
    super().__init_subclass__(**kwargs)

    if object is not None:
        cls._set_field_default("object", default=object)

TypedObject

Bases: GenericObject

A type-referenced object.

Many objects in the Notion API follow a standard pattern with a 'type' property followed by additional data. These objects must specify a type attribute to ensure that the correct object is created.

For example, this contains a nested 'detail' object:

data = {
    type: "detail",
    ...
    detail: {
        ...
    }
}

Calling the object provides direct access to the data stored in {type}.

Source code in src/notional/core.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
class TypedObject(GenericObject):
    """A type-referenced object.

    Many objects in the Notion API follow a standard pattern with a 'type' property
    followed by additional data.  These objects must specify a `type` attribute to
    ensure that the correct object is created.

    For example, this contains a nested 'detail' object:

        data = {
            type: "detail",
            ...
            detail: {
                ...
            }
        }

    Calling the object provides direct access to the data stored in `{type}`.
    """

    type: str

    # modified from the methods described in this discussion:
    # - https://github.com/samuelcolvin/pydantic/discussions/3091

    def __init_subclass__(cls, type=None, **kwargs):
        """Register the subtypes of the TypedObject subclass."""
        super().__init_subclass__(**kwargs)

        type_name = cls.__name__ if type is None else type

        cls._register_type(type_name)

    def __call__(self, field=None):
        """Return the nested data object contained by this `TypedObject`.

        If a field is provided, the contents of that field in the nested data will be
        returned.  Otherwise, the full contents of the NestedData will be returned.
        """

        type = getattr(self, "type", None)

        if type is None:
            raise AttributeError("type not specified")

        nested = getattr(self, type)

        if field is not None:
            nested = getattr(nested, field)

        return nested

    @classmethod
    def __get_validators__(cls):
        """Provide `BaseModel` with the means to convert `TypedObject`'s."""
        yield cls._resolve_type

    @classmethod
    def parse_obj(cls, obj):
        """Parse the structured object data into an instance of `TypedObject`.

        This method overrides `BaseModel.parse_obj()`.
        """
        return cls._resolve_type(obj)

    @classmethod
    def _register_type(cls, name):
        """Register a specific class for the given 'type' name."""

        cls._set_field_default("type", default=name)

        # initialize a __notional_typemap__ map for each direct child of TypedObject

        # this allows different class trees to have the same 'type' name
        # but point to a different object (e.g. the 'date' type may have
        # different implementations depending where it is used in the API)

        if not hasattr(cls, "__notional_typemap__"):
            cls.__notional_typemap__ = {}

        if name in cls.__notional_typemap__:
            raise ValueError(f"Duplicate subtype for class - {name} :: {cls}")

        logger.debug("registered new subtype: %s => %s", name, cls)

        cls.__notional_typemap__[name] = cls

    @classmethod
    def _resolve_type(cls, data):
        """Instantiate the correct object based on the 'type' field."""

        if isinstance(data, cls):
            return data

        if not isinstance(data, dict):
            raise ValueError("Invalid 'data' object")

        if not hasattr(cls, "__notional_typemap__"):
            raise TypeError(f"Missing '__notional_typemap__' in {cls}")

        type_name = data.get("type")

        if type_name is None:
            raise ValueError("Missing 'type' in data")

        sub = cls.__notional_typemap__.get(type_name)

        if sub is None:
            raise TypeError(f"Unsupported sub-type: {type_name}")

        logger.debug(
            "initializing typed object %s :: %s => %s -- %s", cls, type_name, sub, data
        )

        return sub(**data)

__call__(field=None)

Return the nested data object contained by this TypedObject.

If a field is provided, the contents of that field in the nested data will be returned. Otherwise, the full contents of the NestedData will be returned.

Source code in src/notional/core.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def __call__(self, field=None):
    """Return the nested data object contained by this `TypedObject`.

    If a field is provided, the contents of that field in the nested data will be
    returned.  Otherwise, the full contents of the NestedData will be returned.
    """

    type = getattr(self, "type", None)

    if type is None:
        raise AttributeError("type not specified")

    nested = getattr(self, type)

    if field is not None:
        nested = getattr(nested, field)

    return nested

__get_validators__() classmethod

Provide BaseModel with the means to convert TypedObject's.

Source code in src/notional/core.py
248
249
250
251
@classmethod
def __get_validators__(cls):
    """Provide `BaseModel` with the means to convert `TypedObject`'s."""
    yield cls._resolve_type

__init_subclass__(type=None, **kwargs)

Register the subtypes of the TypedObject subclass.

Source code in src/notional/core.py
221
222
223
224
225
226
227
def __init_subclass__(cls, type=None, **kwargs):
    """Register the subtypes of the TypedObject subclass."""
    super().__init_subclass__(**kwargs)

    type_name = cls.__name__ if type is None else type

    cls._register_type(type_name)

parse_obj(obj) classmethod

Parse the structured object data into an instance of TypedObject.

This method overrides BaseModel.parse_obj().

Source code in src/notional/core.py
253
254
255
256
257
258
259
@classmethod
def parse_obj(cls, obj):
    """Parse the structured object data into an instance of `TypedObject`.

    This method overrides `BaseModel.parse_obj()`.
    """
    return cls._resolve_type(obj)

serialize_to_api(data)

Recursively convert the given data to an API-safe form.

This is mostly to handle data types that will not directly serialize to JSON.

Source code in src/notional/core.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def serialize_to_api(data):
    """Recursively convert the given data to an API-safe form.

    This is mostly to handle data types that will not directly serialize to JSON.
    """

    # https://github.com/samuelcolvin/pydantic/issues/1409

    if isinstance(data, (date, datetime)):
        return data.isoformat()

    if isinstance(data, UUID):
        return str(data)

    if isinstance(data, Enum):
        return data.value

    if isinstance(data, (list, tuple)):
        return [serialize_to_api(value) for value in data]

    if isinstance(data, dict):
        return {name: serialize_to_api(value) for name, value in data.items()}

    return data