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.
Files changed (149) hide show
  1. notionary/__init__.py +9 -2
  2. notionary/blocks/__init__.py +5 -0
  3. notionary/blocks/client.py +6 -4
  4. notionary/blocks/enums.py +28 -1
  5. notionary/blocks/rich_text/markdown_rich_text_converter.py +14 -0
  6. notionary/blocks/rich_text/models.py +14 -0
  7. notionary/blocks/rich_text/name_id_resolver/__init__.py +2 -0
  8. notionary/blocks/rich_text/name_id_resolver/data_source.py +32 -0
  9. notionary/blocks/rich_text/rich_text_markdown_converter.py +12 -0
  10. notionary/blocks/rich_text/rich_text_patterns.py +3 -0
  11. notionary/blocks/schemas.py +42 -10
  12. notionary/comments/__init__.py +5 -0
  13. notionary/comments/client.py +7 -10
  14. notionary/comments/factory.py +4 -6
  15. notionary/data_source/http/data_source_instance_client.py +14 -4
  16. notionary/data_source/properties/{models.py → schemas.py} +4 -8
  17. notionary/data_source/query/__init__.py +9 -0
  18. notionary/data_source/query/builder.py +38 -10
  19. notionary/data_source/query/schema.py +13 -10
  20. notionary/data_source/query/validator.py +11 -11
  21. notionary/data_source/schema/registry.py +104 -0
  22. notionary/data_source/schema/service.py +136 -0
  23. notionary/data_source/schemas.py +1 -1
  24. notionary/data_source/service.py +29 -103
  25. notionary/database/service.py +17 -60
  26. notionary/exceptions/__init__.py +5 -1
  27. notionary/exceptions/block_parsing.py +21 -0
  28. notionary/exceptions/search.py +24 -0
  29. notionary/http/client.py +9 -10
  30. notionary/http/models.py +5 -4
  31. notionary/page/content/factory.py +10 -3
  32. notionary/page/content/markdown/builder.py +76 -154
  33. notionary/page/content/markdown/nodes/__init__.py +0 -2
  34. notionary/page/content/markdown/nodes/audio.py +1 -1
  35. notionary/page/content/markdown/nodes/base.py +1 -1
  36. notionary/page/content/markdown/nodes/bookmark.py +1 -1
  37. notionary/page/content/markdown/nodes/breadcrumb.py +1 -1
  38. notionary/page/content/markdown/nodes/bulleted_list.py +31 -8
  39. notionary/page/content/markdown/nodes/callout.py +12 -10
  40. notionary/page/content/markdown/nodes/code.py +3 -5
  41. notionary/page/content/markdown/nodes/columns.py +39 -21
  42. notionary/page/content/markdown/nodes/container.py +64 -0
  43. notionary/page/content/markdown/nodes/divider.py +1 -1
  44. notionary/page/content/markdown/nodes/embed.py +1 -1
  45. notionary/page/content/markdown/nodes/equation.py +1 -1
  46. notionary/page/content/markdown/nodes/file.py +1 -1
  47. notionary/page/content/markdown/nodes/heading.py +26 -6
  48. notionary/page/content/markdown/nodes/image.py +1 -1
  49. notionary/page/content/markdown/nodes/mixins/__init__.py +5 -0
  50. notionary/page/content/markdown/nodes/mixins/caption.py +1 -1
  51. notionary/page/content/markdown/nodes/numbered_list.py +28 -5
  52. notionary/page/content/markdown/nodes/paragraph.py +1 -1
  53. notionary/page/content/markdown/nodes/pdf.py +1 -1
  54. notionary/page/content/markdown/nodes/quote.py +17 -5
  55. notionary/page/content/markdown/nodes/space.py +1 -1
  56. notionary/page/content/markdown/nodes/table.py +1 -1
  57. notionary/page/content/markdown/nodes/table_of_contents.py +1 -1
  58. notionary/page/content/markdown/nodes/todo.py +23 -7
  59. notionary/page/content/markdown/nodes/toggle.py +13 -14
  60. notionary/page/content/markdown/nodes/video.py +1 -1
  61. notionary/page/content/parser/context.py +98 -21
  62. notionary/page/content/parser/factory.py +1 -10
  63. notionary/page/content/parser/parsers/__init__.py +0 -2
  64. notionary/page/content/parser/parsers/audio.py +1 -1
  65. notionary/page/content/parser/parsers/base.py +1 -1
  66. notionary/page/content/parser/parsers/bookmark.py +1 -1
  67. notionary/page/content/parser/parsers/breadcrumb.py +1 -1
  68. notionary/page/content/parser/parsers/bulleted_list.py +52 -8
  69. notionary/page/content/parser/parsers/callout.py +55 -84
  70. notionary/page/content/parser/parsers/caption.py +1 -1
  71. notionary/page/content/parser/parsers/code.py +5 -5
  72. notionary/page/content/parser/parsers/column.py +23 -64
  73. notionary/page/content/parser/parsers/column_list.py +45 -45
  74. notionary/page/content/parser/parsers/divider.py +1 -1
  75. notionary/page/content/parser/parsers/embed.py +1 -1
  76. notionary/page/content/parser/parsers/equation.py +1 -1
  77. notionary/page/content/parser/parsers/file.py +1 -1
  78. notionary/page/content/parser/parsers/heading.py +65 -8
  79. notionary/page/content/parser/parsers/image.py +1 -1
  80. notionary/page/content/parser/parsers/numbered_list.py +52 -8
  81. notionary/page/content/parser/parsers/paragraph.py +3 -2
  82. notionary/page/content/parser/parsers/pdf.py +1 -1
  83. notionary/page/content/parser/parsers/quote.py +75 -15
  84. notionary/page/content/parser/parsers/space.py +14 -8
  85. notionary/page/content/parser/parsers/table.py +1 -1
  86. notionary/page/content/parser/parsers/table_of_contents.py +1 -1
  87. notionary/page/content/parser/parsers/todo.py +57 -19
  88. notionary/page/content/parser/parsers/toggle.py +17 -74
  89. notionary/page/content/parser/parsers/video.py +1 -1
  90. notionary/page/content/parser/post_processing/handlers/rich_text_length.py +6 -4
  91. notionary/page/content/parser/post_processing/handlers/rich_text_length_truncation.py +43 -22
  92. notionary/page/content/parser/pre_processsing/handlers/__init__.py +4 -0
  93. notionary/page/content/parser/pre_processsing/handlers/column_syntax.py +108 -54
  94. notionary/page/content/parser/pre_processsing/handlers/indentation.py +86 -0
  95. notionary/page/content/parser/pre_processsing/handlers/video_syntax.py +66 -0
  96. notionary/page/content/parser/pre_processsing/handlers/whitespace.py +14 -7
  97. notionary/page/content/parser/service.py +9 -0
  98. notionary/page/content/renderer/context.py +5 -2
  99. notionary/page/content/renderer/factory.py +2 -11
  100. notionary/page/content/renderer/post_processing/handlers/__init__.py +2 -2
  101. notionary/page/content/renderer/post_processing/handlers/numbered_list.py +156 -0
  102. notionary/page/content/renderer/renderers/__init__.py +0 -2
  103. notionary/page/content/renderer/renderers/base.py +1 -1
  104. notionary/page/content/renderer/renderers/bulleted_list.py +1 -1
  105. notionary/page/content/renderer/renderers/callout.py +6 -21
  106. notionary/page/content/renderer/renderers/captioned_block.py +1 -1
  107. notionary/page/content/renderer/renderers/column.py +28 -19
  108. notionary/page/content/renderer/renderers/column_list.py +24 -11
  109. notionary/page/content/renderer/renderers/heading.py +53 -27
  110. notionary/page/content/renderer/renderers/numbered_list.py +6 -5
  111. notionary/page/content/renderer/renderers/quote.py +1 -1
  112. notionary/page/content/renderer/renderers/todo.py +1 -1
  113. notionary/page/content/renderer/renderers/toggle.py +6 -7
  114. notionary/page/content/service.py +4 -1
  115. notionary/page/content/syntax/__init__.py +4 -0
  116. notionary/page/content/syntax/grammar.py +10 -0
  117. notionary/page/content/syntax/models.py +0 -2
  118. notionary/page/content/syntax/{service.py → registry.py} +31 -91
  119. notionary/page/properties/client.py +3 -3
  120. notionary/page/properties/models.py +3 -2
  121. notionary/page/properties/service.py +18 -3
  122. notionary/page/service.py +22 -80
  123. notionary/shared/entity/service.py +94 -36
  124. notionary/shared/models/cover.py +1 -1
  125. notionary/shared/typings.py +3 -0
  126. notionary/user/base.py +60 -11
  127. notionary/user/factory.py +0 -0
  128. notionary/utils/decorators.py +122 -0
  129. notionary/utils/fuzzy.py +18 -6
  130. notionary/utils/mixins/logging.py +38 -27
  131. notionary/utils/pagination.py +70 -16
  132. notionary/workspace/__init__.py +2 -1
  133. notionary/workspace/client.py +4 -2
  134. notionary/workspace/query/__init__.py +3 -0
  135. notionary/workspace/query/builder.py +25 -1
  136. notionary/workspace/query/models.py +12 -3
  137. notionary/workspace/query/service.py +57 -32
  138. notionary/workspace/service.py +31 -21
  139. {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/METADATA +35 -105
  140. notionary-0.3.1.dist-info/RECORD +211 -0
  141. notionary/page/content/markdown/nodes/toggleable_heading.py +0 -35
  142. notionary/page/content/parser/parsers/toggleable_heading.py +0 -150
  143. notionary/page/content/renderer/post_processing/handlers/numbered_list_placeholdere.py +0 -62
  144. notionary/page/content/renderer/renderers/toggleable_heading.py +0 -78
  145. notionary/utils/async_retry.py +0 -39
  146. notionary/utils/singleton.py +0 -13
  147. notionary-0.2.28.dist-info/RECORD +0 -200
  148. {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/WHEEL +0 -0
  149. {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 TYPE_CHECKING, Self
4
+ from typing import Self
7
5
 
8
6
  from notionary.shared.entity.entity_metadata_update_client import EntityMetadataUpdateClient
9
- from notionary.user.schemas import PartialUserDto
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
- id: str,
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._emoji_icon = emoji_icon
38
- self._external_icon_url = external_icon_url
39
- self._cover_image_url = cover_image_url
40
- self._in_trash = in_trash
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 created_by(self) -> PartialUserDto:
97
- return self._created_by
119
+ def url(self) -> str:
120
+ return self._url
98
121
 
99
122
  @property
100
- def last_edited_by(self) -> PartialUserDto:
101
- return self._last_edited_by
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 = entity_response.icon.emoji if entity_response.icon else None
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.")
@@ -17,4 +17,4 @@ class NotionCover(BaseModel):
17
17
 
18
18
  @classmethod
19
19
  def from_url(cls, url: str) -> Self:
20
- return cls(icon=ExternalFile(url))
20
+ return cls(external=ExternalFile(url=url))
@@ -0,0 +1,3 @@
1
+ from typing import Any
2
+
3
+ type JsonDict = dict[str, Any]
notionary/user/base.py CHANGED
@@ -1,12 +1,19 @@
1
- from abc import ABC, abstractmethod
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 find_best_match
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(ABC):
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
- user_type = cls._get_expected_user_type().value
46
- raise ValueError(f"No '{user_type}' users found in the workspace")
73
+ raise NoUsersInWorkspace(user_type)
47
74
 
48
- best_match = find_best_match(query=name, items=all_users, text_extractor=cls._get_name_extractor())
49
- if not best_match:
50
- user_type = cls._get_expected_user_type().value
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
- return best_match
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 Any, Generic, TypeVar
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: Any
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 | None = 0.0,
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(None, query.lower().strip(), target.lower().strip()).ratio()
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
- def _setup_logging() -> None:
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
- logging.getLogger("httpx").setLevel(logging.WARNING)
9
+ logger = logging.getLogger("notionary")
10
+ logger.addHandler(logging.NullHandler())
14
11
 
15
12
 
16
- _setup_logging()
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
@@ -10,41 +10,95 @@ class PaginatedResponse(BaseModel):
10
10
  next_cursor: str | None
11
11
 
12
12
 
13
- async def _fetch_pages(
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
- current_kwargs = kwargs.copy()
22
- if next_cursor:
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
- response = await api_call(**current_kwargs)
26
- yield response
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 _fetch_pages(api_call, **kwargs):
38
- if page.results:
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 _fetch_pages(api_call, **kwargs):
48
- if page.results:
49
- for item in page.results:
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
@@ -1,3 +1,4 @@
1
+ from .query import NotionWorkspaceQueryConfigBuilder
1
2
  from .service import NotionWorkspace
2
3
 
3
- __all__ = ["NotionWorkspace"]
4
+ __all__ = ["NotionWorkspace", "NotionWorkspaceQueryConfigBuilder"]