• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ultimate-notion / ultimate-notion / 15740234214

18 Jun 2025 06:04PM CUT coverage: 89.532% (+0.1%) from 89.392%
15740234214

Pull #79

github

web-flow
Merge cfb4f1aab into 949e4df22
Pull Request #79: Change the attributes of database property types.

66 of 71 new or added lines in 4 files covered. (92.96%)

39 existing lines in 3 files now uncovered.

5568 of 6219 relevant lines covered (89.53%)

5.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.35
/src/ultimate_notion/obj_api/core.py
1
"""Base classes for working with the Notion API."""
2

3
from __future__ import annotations
6✔
4

5
import logging
6✔
6
import re
6✔
7
from datetime import datetime
6✔
8
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
6✔
9
from uuid import UUID
6✔
10

11
from pydantic import (
6✔
12
    BaseModel,
13
    ConfigDict,
14
    Field,
15
    SerializeAsAny,
16
    ValidatorFunctionWrapHandler,
17
    field_validator,
18
    model_validator,
19
)
20
from typing_extensions import Self
6✔
21

22
from ultimate_notion.utils import is_stable_release
6✔
23

24
if TYPE_CHECKING:
25
    from ultimate_notion.obj_api.objects import ParentRef, UserRef
26

27

28
_logger = logging.getLogger(__name__)
6✔
29

30
BASE_URL_PATTERN = r'https://(www.)?notion.so/'
6✔
31
UUID_PATTERN = r'[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}'
6✔
32

33
UUID_RE = re.compile(rf'^(?P<id>{UUID_PATTERN})$')
6✔
34

35
PAGE_URL_SHORT_RE = re.compile(
6✔
36
    rf"""^
37
      {BASE_URL_PATTERN}
38
      (?P<page_id>{UUID_PATTERN})
39
    $""",
40
    flags=re.IGNORECASE | re.VERBOSE,
41
)
42

43
PAGE_URL_LONG_RE = re.compile(
6✔
44
    rf"""^
45
      {BASE_URL_PATTERN}
46
      (?P<title>.*)-
47
      (?P<page_id>{UUID_PATTERN})
48
    $""",
49
    flags=re.IGNORECASE | re.VERBOSE,
50
)
51

52
BLOCK_URL_LONG_RE = re.compile(
6✔
53
    rf"""^
54
      {BASE_URL_PATTERN}
55
      (?P<username>.*)/
56
      (?P<title>.*)-
57
      (?P<page_id>{UUID_PATTERN})
58
      \#(?P<block_id>{UUID_PATTERN})
59
    $""",
60
    flags=re.IGNORECASE | re.VERBOSE,
61
)
62

63

64
def extract_id(text: str) -> str | None:
6✔
65
    """Examine the given text to find a valid Notion object ID."""
66

67
    m = UUID_RE.match(text)
6✔
68
    if m is not None:
6✔
69
        return m.group('id')
6✔
70

71
    m = PAGE_URL_LONG_RE.match(text)
6✔
72
    if m is not None:
6✔
73
        return m.group('page_id')
6✔
74

75
    m = PAGE_URL_SHORT_RE.match(text)
6✔
76
    if m is not None:
6✔
77
        return m.group('page_id')
6✔
78

79
    m = BLOCK_URL_LONG_RE.match(text)
6✔
80
    if m is not None:
6✔
81
        return m.group('block_id')
6✔
82

83
    return None
×
84

85

86
class GenericObject(BaseModel):
6✔
87
    """The base for all API objects."""
88

89
    model_config = ConfigDict(extra='ignore' if is_stable_release() else 'forbid')
6✔
90

91
    @classmethod
6✔
92
    def _set_field_default(cls, name: str, default: str) -> None:
6✔
93
        """Modify the `BaseModel` field information for a specific class instance.
94

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

98
        Args:
99
            name: the named attribute in the class
100
            default: the new default value for the named field
101
        """
102
        # Rebuild model to avoid UserWarning about shadowing an attribute in parent.
103
        # More details here: https://github.com/pydantic/pydantic/issues/6966
104
        field = cls.model_fields.get(name)
6✔
105
        if field is None:
6✔
106
            msg = f'No field of name {name} in {cls.__name__} found!'
×
107
            raise ValueError(msg)
×
108
        field.default = default
6✔
109
        field.validate_default = False
6✔
110
        cls.model_rebuild(force=True)
6✔
111

112
    # https://github.com/pydantic/pydantic/discussions/3139
113
    def update(self, **data: Any) -> Self:
6✔
114
        """Update the internal attributes with new data in place."""
115

116
        new_obj_dct = self.model_dump()
6✔
117
        new_obj_dct.update(data)
6✔
118
        new_obj = self.model_validate(new_obj_dct)
6✔
119

120
        for k, v in new_obj.model_dump(exclude_unset=True).items():
6✔
121
            # exclude_unset avoids overwriting for instance known children that need to be retrieved separately
122
            _logger.debug('updating object data: %s => %s', k, v)
6✔
123
            setattr(self, k, getattr(new_obj, k))
6✔
124

125
        return self
6✔
126

127
    def serialize_for_api(self) -> dict[str, Any]:
6✔
128
        """Serialize the object for sending it to the Notion API."""
129
        # Notion API doesn't like "null" values
130
        return self.model_dump(mode='json', exclude_none=True, by_alias=True)
6✔
131

132
    @classmethod
6✔
133
    def build(cls, *args, **kwargs):
6✔
134
        """Use the standard constructur to build the instance. Will be overwritten for more complex types."""
135
        return cls(*args, **kwargs)
6✔
136

137

138
class UniqueObject(GenericObject):
6✔
139
    """A Notion object that has a unique ID.
140

141
    This is the base class for all Notion objects that have a unique identifier, i.e. `id`.
142
    """
143

144
    id: UUID | str = Field(union_mode='left_to_right', default=None)  # type: ignore
6✔
145
    """`id` is an `UUID` if possible or a string (possibly not unique) depending on the object"""
6✔
146

147
    def __hash__(self) -> int:
6✔
148
        """Return a hash of the object based on its ID."""
149
        return hash(self.id)
×
150

151
    def __eq__(self, value: Any) -> bool:
6✔
152
        """Check if the given value is equal to this object."""
153
        match value:
×
154
            case UniqueObject():
×
155
                return self.id == value.id
×
156
            case BaseModel():
×
157
                return super().__eq__(value)
×
158
            case _:
×
159
                return False
×
160

161

162
class NotionObject(UniqueObject):
6✔
163
    """A top-level Notion API resource.
164

165
    Many objects in the Notion API follow a standard pattern with a `object` property, which
166
    defines the general object type, e.g. `page`, `database`, `user`, `block`, ...
167
    """
168

169
    object: str = Field(default=None)  # type: ignore # avoids mypy plugin errors as this is set in __init_subclass__
6✔
170
    """`object` is a string that identifies the general object type, e.g. `page`, `database`, `user`, `block`, ..."""
6✔
171

172
    request_id: UUID | None = None
6✔
173
    """`request_id` is a UUID that is used to track requests in the Notion API"""
6✔
174

175
    def __init_subclass__(cls, *, object=None, **kwargs):  # noqa: A002
6✔
176
        super().__init_subclass__(**kwargs)
6✔
177

178
    @classmethod
6✔
179
    def __pydantic_init_subclass__(cls, *, object=None, **kwargs):  # noqa: A002, PLW3201
6✔
180
        """Update `GenericObject` defaults for the named object.
181

182
        Needed since `model_fields` are not available during __init_subclass__
183
        See: https://github.com/pydantic/pydantic/issues/5369
184
        """
185
        super().__pydantic_init_subclass__(object=object, **kwargs)
6✔
186

187
        if object is not None:  # if None we inherit 'object' from the base class
6✔
188
            cls._set_field_default('object', default=object)
6✔
189

190
    @field_validator('object', mode='after')
6✔
191
    @classmethod
6✔
192
    def _verify_object_matches_expected(cls, val):
6✔
193
        """Make sure that the deserialized object matches the name in this class."""
194

195
        obj_attr = cls.model_fields.get('object').default
6✔
196
        if val != obj_attr:
6✔
197
            msg = f'Invalid object for {obj_attr} - {val}'
6✔
198
            raise ValueError(msg)
6✔
199

200
        return val
6✔
201

202

203
class NotionEntity(NotionObject):
6✔
204
    """A materialized entity, which was created by a user."""
205

206
    id: UUID = None  # type: ignore
6✔
207
    parent: SerializeAsAny[ParentRef] = None  # type: ignore
6✔
208

209
    created_time: datetime = None  # type: ignore
6✔
210
    created_by: UserRef = None  # type: ignore
6✔
211

212
    last_edited_time: datetime = None  # type: ignore
6✔
213

214

215
T = TypeVar('T')  # ToDo: use new syntax in Python 3.12 and consider using default = in Python 3.13+
6✔
216

217

218
class TypedObject(GenericObject, Generic[T]):
6✔
219
    """A type-referenced object.
220

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

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

227
        data = {
228
            type: "detail",
229
            ...
230
            detail: {
231
                ...
232
            }
233
        }
234
    """
235

236
    type: str = Field(default=None)  # type: ignore  # avoids mypy plugin errors as this is set in __init_subclass__
6✔
237
    """`type` is a string that identifies the specific object type, e.g. `heading_1`, `paragraph`, `equation`, ..."""
6✔
238
    _polymorphic_base: ClassVar[bool] = False
6✔
239

240
    def __init_subclass__(cls, *, type: str | None = None, polymorphic_base: bool = False, **kwargs):  # noqa: A002
6✔
241
        super().__init_subclass__(**kwargs)
6✔
242
        cls._polymorphic_base = polymorphic_base
6✔
243

244
    @classmethod
6✔
245
    def __pydantic_init_subclass__(cls, *, type: str | None = None, **kwargs):  # noqa: A002, PLW3201
6✔
246
        """Register the subtypes of the TypedObject subclass.
247

248
        This is needed since `model_fields` is not available during __init_subclass__.
249
        See: https://github.com/pydantic/pydantic/issues/5369
250
        """
251
        super().__pydantic_init_subclass__(type=type, **kwargs)
6✔
252
        type_name = cls.__name__ if type is None else type
6✔
253
        cls._register_type(type_name)
6✔
254

255
    @classmethod
6✔
256
    def _register_type(cls, name):
6✔
257
        """Register a specific class for the given 'type' name."""
258

259
        cls._set_field_default('type', default=name)
6✔
260

261
        # initialize a _typemap map for each direct child of TypedObject
262

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

267
        if not hasattr(cls, '_typemap'):
6✔
268
            cls._typemap = {}
6✔
269

270
        if name in cls._typemap:
6✔
271
            msg = f'Duplicate subtype for class - {name} :: {cls}'
×
272
            raise ValueError(msg)
×
273

274
        _logger.debug('registered new subtype: %s => %s', name, cls)
6✔
275
        cls._typemap[name] = cls
6✔
276

277
    @model_validator(mode='wrap')
6✔
278
    @classmethod
6✔
279
    def _resolve_type(cls, value: Any, handler: ValidatorFunctionWrapHandler):
6✔
280
        """Instantiate the correct object based on the `type` field.
281

282
        Following this approach: https://github.com/pydantic/pydantic/discussions/7008
283
        Also the reason for `polymorphic_base` is explained there.
284
        """
285

286
        if isinstance(value, cls):
6✔
287
            return handler(value)
×
288

289
        if not cls._polymorphic_base:  # breaks the recursion
6✔
290
            return handler(value)
6✔
291

292
        if not isinstance(value, dict):
6✔
293
            msg = "Invalid 'data' object"
×
294
            raise ValueError(msg)
×
295

296
        if not hasattr(cls, '_typemap'):
6✔
297
            msg = f"Missing '_typemap' in {cls}"
×
298
            raise ValueError(msg)
×
299

300
        type_name = value.get('type')
6✔
301

302
        if type_name is None:
6✔
303
            _logger.warning(f'Missing type in data {value}. Most likely a User object without type')
×
304
            msg = f"Missing 'type' in data {value}"
×
305
            if value['object'] == 'user':
×
306
                type_name = 'unknown'  # for the unofficial type objects.UnknownUser
×
307
            else:
308
                raise ValueError(msg)
×
309

310
        sub_cls = cls._typemap.get(type_name)
6✔
311

312
        if sub_cls is None:
6✔
313
            msg = f'Unsupported sub-type: {type_name}'
6✔
314
            raise ValueError(msg)
6✔
315

316
        return sub_cls(**value)
6✔
317

318
    @property
6✔
319
    def value(self) -> T:
6✔
320
        """Return the nested object."""
321
        return getattr(self, self.type)
6✔
322

323
    @value.setter
6✔
324
    def value(self, val: T) -> None:
6✔
325
        """Set the nested object."""
326
        setattr(self, self.type, val)
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc