notionary 0.2.28__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notionary/__init__.py +9 -2
- notionary/blocks/__init__.py +5 -0
- notionary/blocks/client.py +6 -4
- notionary/blocks/enums.py +28 -1
- notionary/blocks/rich_text/markdown_rich_text_converter.py +14 -0
- notionary/blocks/rich_text/models.py +14 -0
- notionary/blocks/rich_text/name_id_resolver/__init__.py +2 -0
- notionary/blocks/rich_text/name_id_resolver/data_source.py +32 -0
- notionary/blocks/rich_text/rich_text_markdown_converter.py +12 -0
- notionary/blocks/rich_text/rich_text_patterns.py +3 -0
- notionary/blocks/schemas.py +42 -10
- notionary/comments/__init__.py +5 -0
- notionary/comments/client.py +7 -10
- notionary/comments/factory.py +4 -6
- notionary/data_source/http/data_source_instance_client.py +14 -4
- notionary/data_source/properties/{models.py → schemas.py} +4 -8
- notionary/data_source/query/__init__.py +9 -0
- notionary/data_source/query/builder.py +38 -10
- notionary/data_source/query/schema.py +13 -10
- notionary/data_source/query/validator.py +11 -11
- notionary/data_source/schema/registry.py +104 -0
- notionary/data_source/schema/service.py +136 -0
- notionary/data_source/schemas.py +1 -1
- notionary/data_source/service.py +29 -103
- notionary/database/service.py +17 -60
- notionary/exceptions/__init__.py +5 -1
- notionary/exceptions/block_parsing.py +21 -0
- notionary/exceptions/search.py +24 -0
- notionary/http/client.py +9 -10
- notionary/http/models.py +5 -4
- notionary/page/content/factory.py +10 -3
- notionary/page/content/markdown/builder.py +76 -154
- notionary/page/content/markdown/nodes/__init__.py +0 -2
- notionary/page/content/markdown/nodes/audio.py +1 -1
- notionary/page/content/markdown/nodes/base.py +1 -1
- notionary/page/content/markdown/nodes/bookmark.py +1 -1
- notionary/page/content/markdown/nodes/breadcrumb.py +1 -1
- notionary/page/content/markdown/nodes/bulleted_list.py +31 -8
- notionary/page/content/markdown/nodes/callout.py +12 -10
- notionary/page/content/markdown/nodes/code.py +3 -5
- notionary/page/content/markdown/nodes/columns.py +39 -21
- notionary/page/content/markdown/nodes/container.py +64 -0
- notionary/page/content/markdown/nodes/divider.py +1 -1
- notionary/page/content/markdown/nodes/embed.py +1 -1
- notionary/page/content/markdown/nodes/equation.py +1 -1
- notionary/page/content/markdown/nodes/file.py +1 -1
- notionary/page/content/markdown/nodes/heading.py +26 -6
- notionary/page/content/markdown/nodes/image.py +1 -1
- notionary/page/content/markdown/nodes/mixins/__init__.py +5 -0
- notionary/page/content/markdown/nodes/mixins/caption.py +1 -1
- notionary/page/content/markdown/nodes/numbered_list.py +28 -5
- notionary/page/content/markdown/nodes/paragraph.py +1 -1
- notionary/page/content/markdown/nodes/pdf.py +1 -1
- notionary/page/content/markdown/nodes/quote.py +17 -5
- notionary/page/content/markdown/nodes/space.py +1 -1
- notionary/page/content/markdown/nodes/table.py +1 -1
- notionary/page/content/markdown/nodes/table_of_contents.py +1 -1
- notionary/page/content/markdown/nodes/todo.py +23 -7
- notionary/page/content/markdown/nodes/toggle.py +13 -14
- notionary/page/content/markdown/nodes/video.py +1 -1
- notionary/page/content/parser/context.py +98 -21
- notionary/page/content/parser/factory.py +1 -10
- notionary/page/content/parser/parsers/__init__.py +0 -2
- notionary/page/content/parser/parsers/audio.py +1 -1
- notionary/page/content/parser/parsers/base.py +1 -1
- notionary/page/content/parser/parsers/bookmark.py +1 -1
- notionary/page/content/parser/parsers/breadcrumb.py +1 -1
- notionary/page/content/parser/parsers/bulleted_list.py +52 -8
- notionary/page/content/parser/parsers/callout.py +55 -84
- notionary/page/content/parser/parsers/caption.py +1 -1
- notionary/page/content/parser/parsers/code.py +5 -5
- notionary/page/content/parser/parsers/column.py +23 -64
- notionary/page/content/parser/parsers/column_list.py +45 -45
- notionary/page/content/parser/parsers/divider.py +1 -1
- notionary/page/content/parser/parsers/embed.py +1 -1
- notionary/page/content/parser/parsers/equation.py +1 -1
- notionary/page/content/parser/parsers/file.py +1 -1
- notionary/page/content/parser/parsers/heading.py +65 -8
- notionary/page/content/parser/parsers/image.py +1 -1
- notionary/page/content/parser/parsers/numbered_list.py +52 -8
- notionary/page/content/parser/parsers/paragraph.py +3 -2
- notionary/page/content/parser/parsers/pdf.py +1 -1
- notionary/page/content/parser/parsers/quote.py +75 -15
- notionary/page/content/parser/parsers/space.py +14 -8
- notionary/page/content/parser/parsers/table.py +1 -1
- notionary/page/content/parser/parsers/table_of_contents.py +1 -1
- notionary/page/content/parser/parsers/todo.py +57 -19
- notionary/page/content/parser/parsers/toggle.py +17 -74
- notionary/page/content/parser/parsers/video.py +1 -1
- notionary/page/content/parser/post_processing/handlers/rich_text_length.py +6 -4
- notionary/page/content/parser/post_processing/handlers/rich_text_length_truncation.py +43 -22
- notionary/page/content/parser/pre_processsing/handlers/__init__.py +4 -0
- notionary/page/content/parser/pre_processsing/handlers/column_syntax.py +108 -54
- notionary/page/content/parser/pre_processsing/handlers/indentation.py +86 -0
- notionary/page/content/parser/pre_processsing/handlers/video_syntax.py +66 -0
- notionary/page/content/parser/pre_processsing/handlers/whitespace.py +14 -7
- notionary/page/content/parser/service.py +9 -0
- notionary/page/content/renderer/context.py +5 -2
- notionary/page/content/renderer/factory.py +2 -11
- notionary/page/content/renderer/post_processing/handlers/__init__.py +2 -2
- notionary/page/content/renderer/post_processing/handlers/numbered_list.py +156 -0
- notionary/page/content/renderer/renderers/__init__.py +0 -2
- notionary/page/content/renderer/renderers/base.py +1 -1
- notionary/page/content/renderer/renderers/bulleted_list.py +1 -1
- notionary/page/content/renderer/renderers/callout.py +6 -21
- notionary/page/content/renderer/renderers/captioned_block.py +1 -1
- notionary/page/content/renderer/renderers/column.py +28 -19
- notionary/page/content/renderer/renderers/column_list.py +24 -11
- notionary/page/content/renderer/renderers/heading.py +53 -27
- notionary/page/content/renderer/renderers/numbered_list.py +6 -5
- notionary/page/content/renderer/renderers/quote.py +1 -1
- notionary/page/content/renderer/renderers/todo.py +1 -1
- notionary/page/content/renderer/renderers/toggle.py +6 -7
- notionary/page/content/service.py +4 -1
- notionary/page/content/syntax/__init__.py +4 -0
- notionary/page/content/syntax/grammar.py +10 -0
- notionary/page/content/syntax/models.py +0 -2
- notionary/page/content/syntax/{service.py → registry.py} +31 -91
- notionary/page/properties/client.py +3 -3
- notionary/page/properties/models.py +3 -2
- notionary/page/properties/service.py +18 -3
- notionary/page/service.py +22 -80
- notionary/shared/entity/service.py +94 -36
- notionary/shared/models/cover.py +1 -1
- notionary/shared/typings.py +3 -0
- notionary/user/base.py +60 -11
- notionary/user/factory.py +0 -0
- notionary/utils/decorators.py +122 -0
- notionary/utils/fuzzy.py +18 -6
- notionary/utils/mixins/logging.py +38 -27
- notionary/utils/pagination.py +70 -16
- notionary/workspace/__init__.py +2 -1
- notionary/workspace/client.py +4 -2
- notionary/workspace/query/__init__.py +3 -0
- notionary/workspace/query/builder.py +25 -1
- notionary/workspace/query/models.py +12 -3
- notionary/workspace/query/service.py +57 -32
- notionary/workspace/service.py +31 -21
- {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/METADATA +35 -105
- notionary-0.3.1.dist-info/RECORD +211 -0
- notionary/page/content/markdown/nodes/toggleable_heading.py +0 -35
- notionary/page/content/parser/parsers/toggleable_heading.py +0 -150
- notionary/page/content/renderer/post_processing/handlers/numbered_list_placeholdere.py +0 -62
- notionary/page/content/renderer/renderers/toggleable_heading.py +0 -78
- notionary/utils/async_retry.py +0 -39
- notionary/utils/singleton.py +0 -13
- notionary-0.2.28.dist-info/RECORD +0 -200
- {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/WHEEL +0 -0
- {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,45 +1,68 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import random
|
|
4
2
|
from abc import ABC, abstractmethod
|
|
5
3
|
from collections.abc import Sequence
|
|
6
|
-
from typing import
|
|
4
|
+
from typing import Self
|
|
7
5
|
|
|
8
6
|
from notionary.shared.entity.entity_metadata_update_client import EntityMetadataUpdateClient
|
|
9
|
-
from notionary.
|
|
7
|
+
from notionary.shared.entity.schemas import EntityResponseDto
|
|
8
|
+
from notionary.shared.models.cover import CoverType
|
|
9
|
+
from notionary.shared.models.icon import IconType
|
|
10
|
+
from notionary.shared.models.parent import ParentType
|
|
11
|
+
from notionary.user.base import BaseUser
|
|
10
12
|
from notionary.user.service import UserService
|
|
11
13
|
from notionary.utils.mixins.logging import LoggingMixin
|
|
12
14
|
from notionary.utils.uuid_utils import extract_uuid
|
|
13
15
|
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from notionary.user.base import BaseUser
|
|
16
|
-
|
|
17
16
|
|
|
18
17
|
class Entity(LoggingMixin, ABC):
|
|
19
18
|
def __init__(
|
|
20
19
|
self,
|
|
21
|
-
|
|
22
|
-
created_time: str,
|
|
23
|
-
created_by: PartialUserDto,
|
|
24
|
-
last_edited_time: str,
|
|
25
|
-
last_edited_by: PartialUserDto,
|
|
26
|
-
in_trash: bool,
|
|
27
|
-
emoji_icon: str | None = None,
|
|
28
|
-
external_icon_url: str | None = None,
|
|
29
|
-
cover_image_url: str | None = None,
|
|
20
|
+
dto: EntityResponseDto,
|
|
30
21
|
user_service: UserService | None = None,
|
|
31
22
|
) -> None:
|
|
32
|
-
self._id = id
|
|
33
|
-
self._created_time = created_time
|
|
34
|
-
self._created_by = created_by
|
|
35
|
-
self._last_edited_time = last_edited_time
|
|
36
|
-
self._last_edited_by = last_edited_by
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
39
|
-
self.
|
|
40
|
-
self.
|
|
23
|
+
self._id = dto.id
|
|
24
|
+
self._created_time = dto.created_time
|
|
25
|
+
self._created_by = dto.created_by
|
|
26
|
+
self._last_edited_time = dto.last_edited_time
|
|
27
|
+
self._last_edited_by = dto.last_edited_by
|
|
28
|
+
self._in_trash = dto.in_trash
|
|
29
|
+
self._parent = dto.parent
|
|
30
|
+
self._url = dto.url
|
|
31
|
+
self._public_url = dto.public_url
|
|
32
|
+
|
|
33
|
+
self._emoji_icon = self._extract_emoji_icon(dto)
|
|
34
|
+
self._external_icon_url = self._extract_external_icon_url(dto)
|
|
35
|
+
self._cover_image_url = self._extract_cover_image_url(dto)
|
|
36
|
+
|
|
41
37
|
self._user_service = user_service or UserService()
|
|
42
38
|
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _extract_emoji_icon(dto: EntityResponseDto) -> str | None:
|
|
41
|
+
if dto.icon is None:
|
|
42
|
+
return None
|
|
43
|
+
if dto.icon.type is not IconType.EMOJI:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
return dto.icon.emoji
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _extract_external_icon_url(dto: EntityResponseDto) -> str | None:
|
|
50
|
+
if dto.icon is None:
|
|
51
|
+
return None
|
|
52
|
+
if dto.icon.type is not IconType.EXTERNAL:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return dto.icon.external.url
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _extract_cover_image_url(dto: EntityResponseDto) -> str | None:
|
|
59
|
+
if dto.cover is None:
|
|
60
|
+
return None
|
|
61
|
+
if dto.cover.type is not CoverType.EXTERNAL:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return dto.cover.external.url
|
|
65
|
+
|
|
43
66
|
@classmethod
|
|
44
67
|
@abstractmethod
|
|
45
68
|
async def from_id(cls, id: str) -> Self:
|
|
@@ -93,12 +116,43 @@ class Entity(LoggingMixin, ABC):
|
|
|
93
116
|
return self._cover_image_url
|
|
94
117
|
|
|
95
118
|
@property
|
|
96
|
-
def
|
|
97
|
-
return self.
|
|
119
|
+
def url(self) -> str:
|
|
120
|
+
return self._url
|
|
98
121
|
|
|
99
122
|
@property
|
|
100
|
-
def
|
|
101
|
-
return self.
|
|
123
|
+
def public_url(self) -> str | None:
|
|
124
|
+
return self._public_url
|
|
125
|
+
|
|
126
|
+
# =========================================================================
|
|
127
|
+
# Parent ID Getters
|
|
128
|
+
# =========================================================================
|
|
129
|
+
|
|
130
|
+
def get_parent_database_id_if_present(self) -> str | None:
|
|
131
|
+
if self._parent.type == ParentType.DATABASE_ID:
|
|
132
|
+
return self._parent.database_id
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def get_parent_data_source_id_if_present(self) -> str | None:
|
|
136
|
+
if self._parent.type == ParentType.DATA_SOURCE_ID:
|
|
137
|
+
return self._parent.data_source_id
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def get_parent_page_id_if_present(self) -> str | None:
|
|
141
|
+
if self._parent.type == ParentType.PAGE_ID:
|
|
142
|
+
return self._parent.page_id
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def get_parent_block_id_if_present(self) -> str | None:
|
|
146
|
+
if self._parent.type == ParentType.BLOCK_ID:
|
|
147
|
+
return self._parent.block_id
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def is_workspace_parent(self) -> bool:
|
|
151
|
+
return self._parent.type == ParentType.WORKSPACE
|
|
152
|
+
|
|
153
|
+
# =========================================================================
|
|
154
|
+
# User Methods
|
|
155
|
+
# =========================================================================
|
|
102
156
|
|
|
103
157
|
async def get_created_by_user(self) -> BaseUser | None:
|
|
104
158
|
return await self._user_service.get_user_by_id(self._created_by.id)
|
|
@@ -106,17 +160,19 @@ class Entity(LoggingMixin, ABC):
|
|
|
106
160
|
async def get_last_edited_by_user(self) -> BaseUser | None:
|
|
107
161
|
return await self._user_service.get_user_by_id(self._last_edited_by.id)
|
|
108
162
|
|
|
163
|
+
# =========================================================================
|
|
164
|
+
# Icon & Cover Methods
|
|
165
|
+
# =========================================================================
|
|
166
|
+
|
|
109
167
|
async def set_emoji_icon(self, emoji: str) -> None:
|
|
110
168
|
entity_response = await self._entity_metadata_update_client.patch_emoji_icon(emoji)
|
|
111
|
-
self._emoji_icon =
|
|
169
|
+
self._emoji_icon = self._extract_emoji_icon(entity_response)
|
|
112
170
|
self._external_icon_url = None
|
|
113
171
|
|
|
114
172
|
async def set_external_icon(self, icon_url: str) -> None:
|
|
115
173
|
entity_response = await self._entity_metadata_update_client.patch_external_icon(icon_url)
|
|
116
174
|
self._emoji_icon = None
|
|
117
|
-
self._external_icon_url = (
|
|
118
|
-
entity_response.icon.external.url if entity_response.icon and entity_response.icon.external else None
|
|
119
|
-
)
|
|
175
|
+
self._external_icon_url = self._extract_external_icon_url(entity_response)
|
|
120
176
|
|
|
121
177
|
async def remove_icon(self) -> None:
|
|
122
178
|
await self._entity_metadata_update_client.remove_icon()
|
|
@@ -125,9 +181,7 @@ class Entity(LoggingMixin, ABC):
|
|
|
125
181
|
|
|
126
182
|
async def set_cover_image_by_url(self, image_url: str) -> None:
|
|
127
183
|
entity_response = await self._entity_metadata_update_client.patch_external_cover(image_url)
|
|
128
|
-
self._cover_image_url = (
|
|
129
|
-
entity_response.cover.external.url if entity_response.cover and entity_response.cover.external else None
|
|
130
|
-
)
|
|
184
|
+
self._cover_image_url = self._extract_cover_image_url(entity_response)
|
|
131
185
|
|
|
132
186
|
async def set_random_gradient_cover(self) -> None:
|
|
133
187
|
random_cover_url = self._get_random_gradient_cover()
|
|
@@ -137,6 +191,10 @@ class Entity(LoggingMixin, ABC):
|
|
|
137
191
|
await self._entity_metadata_update_client.remove_cover()
|
|
138
192
|
self._cover_image_url = None
|
|
139
193
|
|
|
194
|
+
# =========================================================================
|
|
195
|
+
# Trash Methods
|
|
196
|
+
# =========================================================================
|
|
197
|
+
|
|
140
198
|
async def move_to_trash(self) -> None:
|
|
141
199
|
if self._in_trash:
|
|
142
200
|
self.logger.warning("Entity is already in trash.")
|
notionary/shared/models/cover.py
CHANGED
notionary/user/base.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
from
|
|
2
|
-
from typing import Self
|
|
1
|
+
from __future__ import annotations
|
|
3
2
|
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Self
|
|
5
|
+
|
|
6
|
+
from notionary.exceptions.search import NoUsersInWorkspace, UserNotFound
|
|
4
7
|
from notionary.user.client import UserHttpClient
|
|
5
8
|
from notionary.user.schemas import UserResponseDto, UserType
|
|
6
|
-
from notionary.utils.fuzzy import
|
|
9
|
+
from notionary.utils.fuzzy import find_all_matches
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from notionary.user.bot import BotUser
|
|
13
|
+
from notionary.user.person import PersonUser
|
|
7
14
|
|
|
8
15
|
|
|
9
|
-
class BaseUser
|
|
16
|
+
class BaseUser:
|
|
10
17
|
def __init__(
|
|
11
18
|
self,
|
|
12
19
|
id: str,
|
|
@@ -17,6 +24,25 @@ class BaseUser(ABC):
|
|
|
17
24
|
self._name = name
|
|
18
25
|
self._avatar_url = avatar_url
|
|
19
26
|
|
|
27
|
+
@classmethod
|
|
28
|
+
async def from_id_auto(
|
|
29
|
+
cls,
|
|
30
|
+
user_id: str,
|
|
31
|
+
http_client: UserHttpClient | None = None,
|
|
32
|
+
) -> BotUser | PersonUser:
|
|
33
|
+
from notionary.user.bot import BotUser
|
|
34
|
+
from notionary.user.person import PersonUser
|
|
35
|
+
|
|
36
|
+
client = http_client or UserHttpClient()
|
|
37
|
+
user_dto = await client.get_user_by_id(user_id)
|
|
38
|
+
|
|
39
|
+
if user_dto.type == UserType.BOT:
|
|
40
|
+
return BotUser.from_dto(user_dto)
|
|
41
|
+
elif user_dto.type == UserType.PERSON:
|
|
42
|
+
return PersonUser.from_dto(user_dto)
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError(f"Unknown user type: {user_dto.type}")
|
|
45
|
+
|
|
20
46
|
@classmethod
|
|
21
47
|
async def from_id(
|
|
22
48
|
cls,
|
|
@@ -41,16 +67,39 @@ class BaseUser(ABC):
|
|
|
41
67
|
client = http_client or UserHttpClient()
|
|
42
68
|
all_users = await cls._get_all_users_of_type(client)
|
|
43
69
|
|
|
70
|
+
user_type = cls._get_expected_user_type().value
|
|
71
|
+
|
|
44
72
|
if not all_users:
|
|
45
|
-
|
|
46
|
-
raise ValueError(f"No '{user_type}' users found in the workspace")
|
|
73
|
+
raise NoUsersInWorkspace(user_type)
|
|
47
74
|
|
|
48
|
-
|
|
49
|
-
if
|
|
50
|
-
|
|
51
|
-
raise ValueError(f"No '{user_type}' user found with name similar to '{name}'")
|
|
75
|
+
exact_match = cls._find_exact_match(all_users, name)
|
|
76
|
+
if exact_match:
|
|
77
|
+
return exact_match
|
|
52
78
|
|
|
53
|
-
|
|
79
|
+
suggestions = cls._get_fuzzy_suggestions(all_users, name)
|
|
80
|
+
raise UserNotFound(user_type, name, suggestions)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def _find_exact_match(cls, users: list[Self], query: str) -> Self | None:
|
|
84
|
+
query_lower = query.lower()
|
|
85
|
+
for user in users:
|
|
86
|
+
if user.name and user.name.lower() == query_lower:
|
|
87
|
+
return user
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def _get_fuzzy_suggestions(cls, users: list[Self], query: str) -> list[str]:
|
|
92
|
+
sorted_by_similarity = find_all_matches(
|
|
93
|
+
query=query,
|
|
94
|
+
items=users,
|
|
95
|
+
text_extractor=cls._get_name_extractor(),
|
|
96
|
+
min_similarity=0.6,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if sorted_by_similarity:
|
|
100
|
+
return [user.name for user in sorted_by_similarity[:5] if user.name]
|
|
101
|
+
|
|
102
|
+
return [user.name for user in users[:5] if user.name]
|
|
54
103
|
|
|
55
104
|
@classmethod
|
|
56
105
|
async def _get_all_users_of_type(cls, http_client: UserHttpClient) -> list[Self]:
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any, ParamSpec, TypeVar
|
|
7
|
+
|
|
8
|
+
P = ParamSpec("P")
|
|
9
|
+
R = TypeVar("R")
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
type SyncFunc = Callable[P, R]
|
|
13
|
+
type AsyncFunc = Callable[P, Coroutine[Any, Any, R]]
|
|
14
|
+
type SyncDecorator = Callable[[SyncFunc], SyncFunc]
|
|
15
|
+
type AsyncDecorator = Callable[[AsyncFunc], AsyncFunc]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def singleton(cls):
|
|
19
|
+
instance = [None]
|
|
20
|
+
|
|
21
|
+
def wrapper(*args, **kwargs):
|
|
22
|
+
if instance[0] is None:
|
|
23
|
+
instance[0] = cls(*args, **kwargs)
|
|
24
|
+
return instance[0]
|
|
25
|
+
|
|
26
|
+
return wrapper
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def time_execution_sync(additional_text: str = "", min_duration_to_log: float = 0.25) -> SyncDecorator:
|
|
30
|
+
def decorator(func: SyncFunc) -> SyncFunc:
|
|
31
|
+
@functools.wraps(func)
|
|
32
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
33
|
+
start_time = time.perf_counter()
|
|
34
|
+
result = func(*args, **kwargs)
|
|
35
|
+
execution_time = time.perf_counter() - start_time
|
|
36
|
+
|
|
37
|
+
if execution_time > min_duration_to_log:
|
|
38
|
+
logger = _get_logger_from_context(args, func)
|
|
39
|
+
function_name = additional_text.strip("-") or func.__name__
|
|
40
|
+
logger.debug(f"⏳ {function_name}() took {execution_time:.2f}s")
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
return wrapper
|
|
45
|
+
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def time_execution_async(
|
|
50
|
+
additional_text: str = "",
|
|
51
|
+
min_duration_to_log: float = 0.25,
|
|
52
|
+
) -> AsyncDecorator:
|
|
53
|
+
def decorator(func: AsyncFunc) -> AsyncFunc:
|
|
54
|
+
@functools.wraps(func)
|
|
55
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
56
|
+
start_time = time.perf_counter()
|
|
57
|
+
result = await func(*args, **kwargs)
|
|
58
|
+
execution_time = time.perf_counter() - start_time
|
|
59
|
+
|
|
60
|
+
if execution_time > min_duration_to_log:
|
|
61
|
+
logger = _get_logger_from_context(args, func)
|
|
62
|
+
function_name = additional_text.strip("-") or func.__name__
|
|
63
|
+
logger.debug(f"⏳ {function_name}() took {execution_time:.2f}s")
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
return wrapper
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_logger_from_context(args: tuple, func: Callable) -> logging.Logger:
|
|
73
|
+
if _has_instance_logger(args):
|
|
74
|
+
return _extract_instance_logger(args)
|
|
75
|
+
|
|
76
|
+
return _get_module_logger(func)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_instance_logger(args: tuple) -> bool:
|
|
80
|
+
return bool(args) and hasattr(args[0], "logger")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_instance_logger(args: tuple) -> logging.Logger:
|
|
84
|
+
return args[0].logger
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_module_logger(func: Callable) -> logging.Logger:
|
|
88
|
+
return logging.getLogger(func.__module__)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def async_retry(
|
|
92
|
+
max_retries: int = 3,
|
|
93
|
+
initial_delay: float = 1.0,
|
|
94
|
+
backoff_factor: float = 2.0,
|
|
95
|
+
retry_on_exceptions: tuple[type[Exception], ...] | None = None,
|
|
96
|
+
):
|
|
97
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
98
|
+
@functools.wraps(func)
|
|
99
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
100
|
+
delay = initial_delay
|
|
101
|
+
last_exception = None
|
|
102
|
+
|
|
103
|
+
for attempt in range(max_retries + 1):
|
|
104
|
+
try:
|
|
105
|
+
return await func(*args, **kwargs)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
last_exception = e
|
|
108
|
+
|
|
109
|
+
if retry_on_exceptions is not None and not isinstance(e, retry_on_exceptions):
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
if attempt == max_retries:
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
await asyncio.sleep(delay)
|
|
116
|
+
delay *= backoff_factor
|
|
117
|
+
|
|
118
|
+
raise last_exception
|
|
119
|
+
|
|
120
|
+
return wrapper
|
|
121
|
+
|
|
122
|
+
return decorator
|
notionary/utils/fuzzy.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import difflib
|
|
2
2
|
from collections.abc import Callable
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
5
|
|
|
6
6
|
T = TypeVar("T")
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
10
10
|
class _MatchResult(Generic[T]):
|
|
11
|
-
item:
|
|
11
|
+
item: T
|
|
12
12
|
similarity: float
|
|
13
13
|
|
|
14
14
|
|
|
@@ -16,14 +16,22 @@ def find_best_match(
|
|
|
16
16
|
query: str,
|
|
17
17
|
items: list[T],
|
|
18
18
|
text_extractor: Callable[[T], str],
|
|
19
|
-
min_similarity: float
|
|
19
|
+
min_similarity: float,
|
|
20
20
|
) -> T | None:
|
|
21
|
-
min_similarity = 0.0 if min_similarity is None else min_similarity
|
|
22
|
-
|
|
23
21
|
matches = _find_best_matches(query, items, text_extractor, min_similarity, limit=1)
|
|
24
22
|
return matches[0].item if matches else None
|
|
25
23
|
|
|
26
24
|
|
|
25
|
+
def find_all_matches(
|
|
26
|
+
query: str,
|
|
27
|
+
items: list[T],
|
|
28
|
+
text_extractor: Callable[[T], str],
|
|
29
|
+
min_similarity: float,
|
|
30
|
+
) -> list[T]:
|
|
31
|
+
matches = _find_best_matches(query, items, text_extractor, min_similarity, limit=None)
|
|
32
|
+
return [match.item for match in matches]
|
|
33
|
+
|
|
34
|
+
|
|
27
35
|
def _find_best_matches(
|
|
28
36
|
query: str,
|
|
29
37
|
items: list[T],
|
|
@@ -53,4 +61,8 @@ def _sort_by_highest_similarity_first(results: list[_MatchResult]) -> list[_Matc
|
|
|
53
61
|
|
|
54
62
|
|
|
55
63
|
def _calculate_similarity(query: str, target: str) -> float:
|
|
56
|
-
return difflib.SequenceMatcher(
|
|
64
|
+
return difflib.SequenceMatcher(
|
|
65
|
+
isjunk=None,
|
|
66
|
+
a=query.lower().strip(),
|
|
67
|
+
b=target.lower().strip(),
|
|
68
|
+
).ratio()
|
|
@@ -2,18 +2,46 @@ import logging
|
|
|
2
2
|
import os
|
|
3
3
|
from typing import ClassVar
|
|
4
4
|
|
|
5
|
+
from dotenv import load_dotenv
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
log_level = os.getenv("LOG_LEVEL", "WARNING").upper()
|
|
8
|
-
logging.basicConfig(
|
|
9
|
-
level=getattr(logging, log_level),
|
|
10
|
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
11
|
-
)
|
|
7
|
+
load_dotenv(override=True)
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
logger = logging.getLogger("notionary")
|
|
10
|
+
logger.addHandler(logging.NullHandler())
|
|
14
11
|
|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
def configure_library_logging(level: str = "WARNING") -> None:
|
|
14
|
+
log_level = getattr(logging, level.upper(), logging.WARNING)
|
|
15
|
+
|
|
16
|
+
library_logger = logging.getLogger("notionary")
|
|
17
|
+
|
|
18
|
+
if library_logger.handlers:
|
|
19
|
+
library_logger.handlers.clear()
|
|
20
|
+
|
|
21
|
+
handler = logging.StreamHandler()
|
|
22
|
+
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
|
23
|
+
|
|
24
|
+
library_logger.setLevel(log_level)
|
|
25
|
+
library_logger.addHandler(handler)
|
|
26
|
+
|
|
27
|
+
_suppress_noisy_third_party_loggers()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _suppress_noisy_third_party_loggers() -> None:
|
|
31
|
+
noisy_loggers = ["httpx", "httpcore", "httpcore.connection", "httpcore.http11"]
|
|
32
|
+
|
|
33
|
+
for logger_name in noisy_loggers:
|
|
34
|
+
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _auto_configure_from_environment() -> None:
|
|
38
|
+
env_log_level = os.getenv("NOTIONARY_LOG_LEVEL")
|
|
39
|
+
|
|
40
|
+
if env_log_level:
|
|
41
|
+
configure_library_logging(env_log_level)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_auto_configure_from_environment()
|
|
17
45
|
|
|
18
46
|
|
|
19
47
|
class LoggingMixin:
|
|
@@ -21,27 +49,10 @@ class LoggingMixin:
|
|
|
21
49
|
|
|
22
50
|
def __init_subclass__(cls, **kwargs):
|
|
23
51
|
super().__init_subclass__(**kwargs)
|
|
24
|
-
cls.logger = logging.getLogger(cls.__name__)
|
|
52
|
+
cls.logger = logging.getLogger(f"notionary.{cls.__name__}")
|
|
25
53
|
|
|
26
54
|
@property
|
|
27
55
|
def instance_logger(self) -> logging.Logger:
|
|
28
|
-
"""Instance logger - for instance methods"""
|
|
29
56
|
if not hasattr(self, "_logger"):
|
|
30
|
-
self._logger = logging.getLogger(self.__class__.__name__)
|
|
57
|
+
self._logger = logging.getLogger(f"notionary.{self.__class__.__name__}")
|
|
31
58
|
return self._logger
|
|
32
|
-
|
|
33
|
-
@staticmethod
|
|
34
|
-
def _get_class_name_from_frame(frame) -> str | None:
|
|
35
|
-
local_vars = frame.f_locals
|
|
36
|
-
if "self" in local_vars:
|
|
37
|
-
return local_vars["self"].__class__.__name__
|
|
38
|
-
|
|
39
|
-
if "cls" in local_vars:
|
|
40
|
-
return local_vars["cls"].__name__
|
|
41
|
-
|
|
42
|
-
if "__qualname__" in frame.f_code.co_names:
|
|
43
|
-
qualname = frame.f_code.co_qualname
|
|
44
|
-
if "." in qualname:
|
|
45
|
-
return qualname.split(".")[0]
|
|
46
|
-
|
|
47
|
-
return None
|
notionary/utils/pagination.py
CHANGED
|
@@ -10,41 +10,95 @@ class PaginatedResponse(BaseModel):
|
|
|
10
10
|
next_cursor: str | None
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
async def
|
|
13
|
+
async def _fetch_data(
|
|
14
14
|
api_call: Callable[..., Coroutine[Any, Any, PaginatedResponse]],
|
|
15
|
+
total_results_limit: int | None = None,
|
|
15
16
|
**kwargs,
|
|
16
17
|
) -> AsyncGenerator[PaginatedResponse]:
|
|
17
|
-
next_cursor = None
|
|
18
|
-
has_more = True
|
|
18
|
+
next_cursor: str | None = None
|
|
19
|
+
has_more: bool = True
|
|
20
|
+
total_fetched: int = 0
|
|
21
|
+
api_page_size: int = kwargs.get("page_size", 100)
|
|
19
22
|
|
|
20
|
-
while has_more:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
current_kwargs["start_cursor"] = next_cursor
|
|
23
|
+
while has_more and _should_continue_fetching(total_results_limit, total_fetched):
|
|
24
|
+
request_params = _build_request_params(kwargs, next_cursor)
|
|
25
|
+
response = await api_call(**request_params)
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
limited_results = _apply_result_limit(response.results, total_results_limit, total_fetched)
|
|
28
|
+
total_fetched += len(limited_results)
|
|
29
|
+
|
|
30
|
+
yield _create_limited_response(response, limited_results, api_page_size)
|
|
31
|
+
|
|
32
|
+
if _has_reached_limit(total_results_limit, total_fetched):
|
|
33
|
+
break
|
|
27
34
|
|
|
28
35
|
has_more = response.has_more
|
|
29
36
|
next_cursor = response.next_cursor
|
|
30
37
|
|
|
31
38
|
|
|
39
|
+
def _should_continue_fetching(total_limit: int | None, total_fetched: int) -> bool:
|
|
40
|
+
if total_limit is None:
|
|
41
|
+
return True
|
|
42
|
+
return total_fetched < total_limit
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_request_params(
|
|
46
|
+
base_kwargs: dict[str, Any],
|
|
47
|
+
cursor: str | None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
params = base_kwargs.copy()
|
|
50
|
+
if cursor:
|
|
51
|
+
params["start_cursor"] = cursor
|
|
52
|
+
return params
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _apply_result_limit(results: list[Any], total_limit: int | None, total_fetched: int) -> list[Any]:
|
|
56
|
+
if total_limit is None:
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
remaining_space = total_limit - total_fetched
|
|
60
|
+
return results[:remaining_space]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has_reached_limit(total_limit: int | None, total_fetched: int) -> bool:
|
|
64
|
+
if total_limit is None:
|
|
65
|
+
return False
|
|
66
|
+
return total_fetched >= total_limit
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _create_limited_response(
|
|
70
|
+
original: PaginatedResponse,
|
|
71
|
+
limited_results: list[Any],
|
|
72
|
+
api_page_size: int,
|
|
73
|
+
) -> PaginatedResponse:
|
|
74
|
+
results_were_limited_by_client = len(limited_results) < len(original.results)
|
|
75
|
+
api_returned_full_page = len(original.results) == api_page_size
|
|
76
|
+
|
|
77
|
+
has_more_after_limit = original.has_more and not results_were_limited_by_client and api_returned_full_page
|
|
78
|
+
|
|
79
|
+
return PaginatedResponse(
|
|
80
|
+
results=limited_results,
|
|
81
|
+
has_more=has_more_after_limit,
|
|
82
|
+
next_cursor=original.next_cursor if has_more_after_limit else None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
32
86
|
async def paginate_notion_api(
|
|
33
87
|
api_call: Callable[..., Coroutine[Any, Any, PaginatedResponse]],
|
|
88
|
+
total_results_limit: int | None = None,
|
|
34
89
|
**kwargs,
|
|
35
90
|
) -> list[Any]:
|
|
36
91
|
all_results = []
|
|
37
|
-
async for page in
|
|
38
|
-
|
|
39
|
-
all_results.extend(page.results)
|
|
92
|
+
async for page in _fetch_data(api_call, total_results_limit=total_results_limit, **kwargs):
|
|
93
|
+
all_results.extend(page.results)
|
|
40
94
|
return all_results
|
|
41
95
|
|
|
42
96
|
|
|
43
97
|
async def paginate_notion_api_generator(
|
|
44
98
|
api_call: Callable[..., Coroutine[Any, Any, PaginatedResponse]],
|
|
99
|
+
total_results_limit: int | None = None,
|
|
45
100
|
**kwargs,
|
|
46
101
|
) -> AsyncGenerator[Any]:
|
|
47
|
-
async for page in
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
yield item
|
|
102
|
+
async for page in _fetch_data(api_call, total_results_limit, **kwargs):
|
|
103
|
+
for item in page.results:
|
|
104
|
+
yield item
|
notionary/workspace/__init__.py
CHANGED