notionary 0.2.16__py3-none-any.whl → 0.2.18__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 (137) hide show
  1. notionary/__init__.py +10 -5
  2. notionary/base_notion_client.py +18 -7
  3. notionary/blocks/__init__.py +55 -24
  4. notionary/blocks/audio/__init__.py +7 -0
  5. notionary/blocks/audio/audio_element.py +152 -0
  6. notionary/blocks/audio/audio_markdown_node.py +29 -0
  7. notionary/blocks/audio/audio_models.py +59 -0
  8. notionary/blocks/bookmark/__init__.py +7 -0
  9. notionary/blocks/{bookmark_element.py → bookmark/bookmark_element.py} +20 -65
  10. notionary/blocks/bookmark/bookmark_markdown_node.py +43 -0
  11. notionary/blocks/bulleted_list/__init__.py +7 -0
  12. notionary/blocks/{bulleted_list_element.py → bulleted_list/bulleted_list_element.py} +7 -3
  13. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +33 -0
  14. notionary/blocks/bulleted_list/bulleted_list_models.py +0 -0
  15. notionary/blocks/callout/__init__.py +7 -0
  16. notionary/blocks/callout/callout_element.py +132 -0
  17. notionary/blocks/callout/callout_markdown_node.py +31 -0
  18. notionary/blocks/callout/callout_models.py +0 -0
  19. notionary/blocks/code/__init__.py +7 -0
  20. notionary/blocks/{code_block_element.py → code/code_element.py} +72 -40
  21. notionary/blocks/code/code_markdown_node.py +43 -0
  22. notionary/blocks/code/code_models.py +0 -0
  23. notionary/blocks/column/__init__.py +5 -0
  24. notionary/blocks/{column_element.py → column/column_element.py} +24 -55
  25. notionary/blocks/column/column_models.py +0 -0
  26. notionary/blocks/divider/__init__.py +7 -0
  27. notionary/blocks/{divider_element.py → divider/divider_element.py} +11 -3
  28. notionary/blocks/divider/divider_markdown_node.py +24 -0
  29. notionary/blocks/divider/divider_models.py +0 -0
  30. notionary/blocks/document/__init__.py +7 -0
  31. notionary/blocks/document/document_element.py +102 -0
  32. notionary/blocks/document/document_markdown_node.py +31 -0
  33. notionary/blocks/document/document_models.py +0 -0
  34. notionary/blocks/embed/__init__.py +7 -0
  35. notionary/blocks/{embed_element.py → embed/embed_element.py} +50 -32
  36. notionary/blocks/embed/embed_markdown_node.py +30 -0
  37. notionary/blocks/embed/embed_models.py +0 -0
  38. notionary/blocks/heading/__init__.py +7 -0
  39. notionary/blocks/{heading_element.py → heading/heading_element.py} +25 -17
  40. notionary/blocks/heading/heading_markdown_node.py +29 -0
  41. notionary/blocks/heading/heading_models.py +0 -0
  42. notionary/blocks/image/__init__.py +7 -0
  43. notionary/blocks/{image_element.py → image/image_element.py} +62 -42
  44. notionary/blocks/image/image_markdown_node.py +33 -0
  45. notionary/blocks/image/image_models.py +0 -0
  46. notionary/blocks/markdown_builder.py +356 -0
  47. notionary/blocks/markdown_node.py +29 -0
  48. notionary/blocks/mention/__init__.py +7 -0
  49. notionary/blocks/{mention_element.py → mention/mention_element.py} +6 -2
  50. notionary/blocks/mention/mention_markdown_node.py +38 -0
  51. notionary/blocks/mention/mention_models.py +0 -0
  52. notionary/blocks/numbered_list/__init__.py +7 -0
  53. notionary/blocks/{numbered_list_element.py → numbered_list/numbered_list_element.py} +10 -6
  54. notionary/blocks/numbered_list/numbered_list_markdown_node.py +29 -0
  55. notionary/blocks/numbered_list/numbered_list_models.py +0 -0
  56. notionary/blocks/paragraph/__init__.py +7 -0
  57. notionary/blocks/{paragraph_element.py → paragraph/paragraph_element.py} +7 -3
  58. notionary/blocks/paragraph/paragraph_markdown_node.py +25 -0
  59. notionary/blocks/paragraph/paragraph_models.py +0 -0
  60. notionary/blocks/quote/__init__.py +7 -0
  61. notionary/blocks/quote/quote_element.py +92 -0
  62. notionary/blocks/quote/quote_markdown_node.py +23 -0
  63. notionary/blocks/quote/quote_models.py +0 -0
  64. notionary/blocks/registry/block_registry.py +17 -3
  65. notionary/blocks/registry/block_registry_builder.py +90 -178
  66. notionary/blocks/shared/__init__.py +0 -0
  67. notionary/blocks/shared/block_client.py +256 -0
  68. notionary/blocks/shared/models.py +710 -0
  69. notionary/blocks/{notion_block_element.py → shared/notion_block_element.py} +8 -5
  70. notionary/blocks/{text_inline_formatter.py → shared/text_inline_formatter.py} +14 -14
  71. notionary/blocks/shared/text_inline_formatter_new.py +139 -0
  72. notionary/blocks/table/__init__.py +7 -0
  73. notionary/blocks/{table_element.py → table/table_element.py} +23 -11
  74. notionary/blocks/table/table_markdown_node.py +40 -0
  75. notionary/blocks/table/table_models.py +0 -0
  76. notionary/blocks/todo/__init__.py +7 -0
  77. notionary/blocks/{todo_element.py → todo/todo_element.py} +8 -4
  78. notionary/blocks/todo/todo_markdown_node.py +31 -0
  79. notionary/blocks/todo/todo_models.py +0 -0
  80. notionary/blocks/toggle/__init__.py +4 -0
  81. notionary/blocks/{toggle_element.py → toggle/toggle_element.py} +7 -3
  82. notionary/blocks/toggle/toggle_markdown_node.py +35 -0
  83. notionary/blocks/toggle/toggle_models.py +0 -0
  84. notionary/blocks/toggleable_heading/__init__.py +9 -0
  85. notionary/blocks/{toggleable_heading_element.py → toggleable_heading/toggleable_heading_element.py} +8 -4
  86. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +43 -0
  87. notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
  88. notionary/blocks/video/__init__.py +7 -0
  89. notionary/blocks/{video_element.py → video/video_element.py} +82 -57
  90. notionary/blocks/video/video_markdown_node.py +30 -0
  91. notionary/database/__init__.py +4 -0
  92. notionary/database/database.py +481 -0
  93. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  94. notionary/database/{notion_database_provider.py → database_provider.py} +4 -4
  95. notionary/database/notion_database.py +45 -18
  96. notionary/file_upload/__init__.py +7 -0
  97. notionary/file_upload/client.py +254 -0
  98. notionary/file_upload/models.py +60 -0
  99. notionary/file_upload/notion_file_upload.py +387 -0
  100. notionary/page/content/markdown_whitespace_processor.py +80 -0
  101. notionary/page/content/notion_text_length_utils.py +87 -0
  102. notionary/page/content/page_content_retriever.py +2 -2
  103. notionary/page/content/page_content_writer.py +97 -148
  104. notionary/page/formatting/line_processor.py +153 -0
  105. notionary/page/formatting/markdown_to_notion_converter.py +103 -424
  106. notionary/page/notion_page.py +13 -14
  107. notionary/page/notion_to_markdown_converter.py +9 -13
  108. notionary/telemetry/views.py +15 -6
  109. notionary/user/__init__.py +11 -0
  110. notionary/user/base_notion_user.py +52 -0
  111. notionary/user/client.py +129 -0
  112. notionary/user/models.py +83 -0
  113. notionary/user/notion_bot_user.py +227 -0
  114. notionary/user/notion_user.py +256 -0
  115. notionary/user/notion_user_manager.py +173 -0
  116. notionary/user/notion_user_provider.py +1 -0
  117. notionary/util/__init__.py +3 -5
  118. notionary/util/factory_decorator.py +0 -33
  119. notionary/util/factory_only.py +37 -0
  120. notionary/util/fuzzy.py +74 -0
  121. notionary/util/logging_mixin.py +12 -12
  122. notionary/workspace.py +38 -3
  123. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/METADATA +2 -1
  124. notionary-0.2.18.dist-info/RECORD +149 -0
  125. notionary/blocks/audio_element.py +0 -144
  126. notionary/blocks/callout_element.py +0 -122
  127. notionary/blocks/notion_block_client.py +0 -26
  128. notionary/blocks/qoute_element.py +0 -169
  129. notionary/page/content/notion_page_content_chunker.py +0 -84
  130. notionary/page/formatting/spacer_rules.py +0 -483
  131. notionary/util/fuzzy_matcher.py +0 -82
  132. notionary-0.2.16.dist-info/RECORD +0 -71
  133. /notionary/{elements/__init__.py → blocks/bookmark/bookmark_models.py} +0 -0
  134. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  135. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  136. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/LICENSE +0 -0
  137. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/WHEEL +0 -0
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, List
4
+ from notionary.user.base_notion_user import BaseNotionUser
5
+ from notionary.user.client import NotionUserClient
6
+ from notionary.user.models import (
7
+ NotionUserResponse,
8
+ )
9
+ from notionary.util import factory_only
10
+ from notionary.util.fuzzy import find_best_matches
11
+
12
+
13
+ class NotionUser(BaseNotionUser):
14
+ """
15
+ Manager for Notion person users.
16
+ Handles person-specific operations and information.
17
+ """
18
+
19
+ NO_USERS_FOUND_MSG = "No users found in workspace"
20
+ NO_PERSON_USERS_FOUND_MSG = "No person users found in workspace"
21
+
22
+ @factory_only("from_user_id", "from_user_response", "from_name")
23
+ def __init__(
24
+ self,
25
+ user_id: str,
26
+ name: Optional[str] = None,
27
+ avatar_url: Optional[str] = None,
28
+ email: Optional[str] = None,
29
+ token: Optional[str] = None,
30
+ ):
31
+ """Initialize person user with person-specific properties."""
32
+ super().__init__(user_id, name, avatar_url, token)
33
+ self._email = email
34
+
35
+ @classmethod
36
+ async def from_user_id(
37
+ cls, user_id: str, token: Optional[str] = None
38
+ ) -> Optional[NotionUser]:
39
+ """
40
+ Create a NotionUser from a user ID.
41
+ """
42
+ client = NotionUserClient(token=token)
43
+ user_response = await client.get_user(user_id)
44
+
45
+ if user_response is None:
46
+ cls.logger.error("Failed to load user data for ID: %s", user_id)
47
+ return None
48
+
49
+ # Ensure this is actually a person user
50
+ if user_response.type != "person":
51
+ cls.logger.error(
52
+ "User %s is not a person user (type: %s)", user_id, user_response.type
53
+ )
54
+ return None
55
+
56
+ return cls._create_from_response(user_response, token)
57
+
58
+ @classmethod
59
+ async def from_name(
60
+ cls, name: str, token: Optional[str] = None, min_similarity: float = 0.6
61
+ ) -> Optional[NotionUser]:
62
+ """
63
+ Create a NotionUser by finding a person user with fuzzy matching on the name.
64
+ """
65
+ from notionary.util import find_best_match
66
+
67
+ client = NotionUserClient(token=token)
68
+
69
+ try:
70
+ # Get all users from workspace
71
+ all_users_response = await client.get_all_users()
72
+
73
+ if not all_users_response:
74
+ cls.logger.warning(cls.NO_USERS_FOUND_MSG)
75
+ raise ValueError(cls.NO_USERS_FOUND_MSG)
76
+
77
+ person_users = [
78
+ user
79
+ for user in all_users_response
80
+ if user.type == "person" and user.name
81
+ ]
82
+
83
+ if not person_users:
84
+ cls.logger.warning(cls.NO_PERSON_USERS_FOUND_MSG)
85
+ raise ValueError(cls.NO_PERSON_USERS_FOUND_MSG)
86
+
87
+ cls.logger.debug(
88
+ "Found %d person users for fuzzy matching: %s",
89
+ len(person_users),
90
+ [user.name for user in person_users[:5]],
91
+ )
92
+
93
+ # Use fuzzy matching to find best match
94
+ best_match = find_best_match(
95
+ query=name,
96
+ items=person_users,
97
+ text_extractor=lambda user: user.name or "",
98
+ min_similarity=min_similarity,
99
+ )
100
+
101
+ if not best_match:
102
+ available_names = [user.name for user in person_users[:5]]
103
+ cls.logger.warning(
104
+ "No sufficiently similar person user found for '%s' (min: %.3f). Available: %s",
105
+ name,
106
+ min_similarity,
107
+ available_names,
108
+ )
109
+ raise ValueError(
110
+ f"No sufficiently similar person user found for '{name}'"
111
+ )
112
+
113
+ cls.logger.info(
114
+ "Found best match: '%s' with similarity %.3f for query '%s'",
115
+ best_match.matched_text,
116
+ best_match.similarity,
117
+ name,
118
+ )
119
+
120
+ # Create NotionUser from the matched user response
121
+ return cls._create_from_response(best_match.item, token)
122
+
123
+ except Exception as e:
124
+ cls.logger.error("Error finding user by name '%s': %s", name, str(e))
125
+ raise
126
+
127
+ @classmethod
128
+ def from_user_response(
129
+ cls, user_response: NotionUserResponse, token: Optional[str] = None
130
+ ) -> NotionUser:
131
+ """
132
+ Create a NotionUser from an existing API response.
133
+ """
134
+ if user_response.type != "person":
135
+ raise ValueError(f"Cannot create NotionUser from {user_response.type} user")
136
+
137
+ return cls._create_from_response(user_response, token)
138
+
139
+ @classmethod
140
+ async def search_users_by_name(
141
+ cls,
142
+ name: str,
143
+ token: Optional[str] = None,
144
+ min_similarity: float = 0.3,
145
+ limit: Optional[int] = 5,
146
+ ) -> List[NotionUser]:
147
+ """
148
+ Search for multiple person users by name using fuzzy matching.
149
+
150
+ Args:
151
+ name: The name to search for
152
+ token: Optional Notion API token
153
+ min_similarity: Minimum similarity threshold (0.0 to 1.0), default 0.3
154
+ limit: Maximum number of results to return, default 5
155
+
156
+ Returns:
157
+ List[NotionUser]: List of matching users sorted by similarity (best first)
158
+ """
159
+ client = NotionUserClient(token=token)
160
+
161
+ try:
162
+ # Get all users from workspace
163
+ all_users_response = await client.get_all_users()
164
+
165
+ if not all_users_response:
166
+ cls.logger.warning(cls.NO_USERS_FOUND_MSG)
167
+ return []
168
+
169
+ # Filter to only person users (not bots)
170
+ person_users = [
171
+ user
172
+ for user in all_users_response
173
+ if user.type == "person" and user.name
174
+ ]
175
+
176
+ if not person_users:
177
+ cls.logger.warning(cls.NO_PERSON_USERS_FOUND_MSG)
178
+ return []
179
+
180
+ # Use fuzzy matching to find all matches
181
+ matches = find_best_matches(
182
+ query=name,
183
+ items=person_users,
184
+ text_extractor=lambda user: user.name or "",
185
+ min_similarity=min_similarity,
186
+ limit=limit,
187
+ )
188
+
189
+ cls.logger.info(
190
+ "Found %d matching users for query '%s'", len(matches), name
191
+ )
192
+
193
+ # Convert to NotionUser instances
194
+ result_users = []
195
+ for match in matches:
196
+ try:
197
+ user = cls._create_from_response(match.item, token)
198
+ result_users.append(user)
199
+ except Exception as e:
200
+ cls.logger.warning(
201
+ "Failed to create user from match '%s': %s",
202
+ match.matched_text,
203
+ str(e),
204
+ )
205
+ continue
206
+
207
+ return result_users
208
+
209
+ except Exception as e:
210
+ cls.logger.error("Error searching users by name '%s': %s", name, str(e))
211
+ return []
212
+
213
+ @property
214
+ def email(self) -> Optional[str]:
215
+ """
216
+ Get the user email (requires proper integration capabilities).
217
+ """
218
+ return self._email
219
+
220
+ @property
221
+ def user_type(self) -> str:
222
+ """Get the user type."""
223
+ return "person"
224
+
225
+ @property
226
+ def is_person(self) -> bool:
227
+ """Check if this is a person user."""
228
+ return True
229
+
230
+ @property
231
+ def is_bot(self) -> bool:
232
+ """Check if this is a bot user."""
233
+ return False
234
+
235
+ @classmethod
236
+ def _create_from_response(
237
+ cls, user_response: NotionUserResponse, token: Optional[str]
238
+ ) -> NotionUser:
239
+ """Create NotionUser instance from API response."""
240
+ email = user_response.person.email if user_response.person else None
241
+
242
+ instance = cls(
243
+ user_id=user_response.id,
244
+ name=user_response.name,
245
+ avatar_url=user_response.avatar_url,
246
+ email=email,
247
+ token=token,
248
+ )
249
+
250
+ cls.logger.info(
251
+ "Created person user: '%s' (ID: %s)",
252
+ user_response.name or "Unknown",
253
+ user_response.id,
254
+ )
255
+
256
+ return instance
@@ -0,0 +1,173 @@
1
+ from typing import Any, Dict, Optional, List
2
+ from notionary.user.client import NotionUserClient
3
+ from notionary.user.notion_user import NotionUser
4
+ from notionary.user.models import NotionUsersListResponse
5
+ from notionary.util import LoggingMixin
6
+
7
+
8
+ class NotionUserManager(LoggingMixin):
9
+ """
10
+ Manager for user operations within API limitations.
11
+
12
+ Note: The Notion API provides endpoints to list workspace users (excluding guests).
13
+ This manager provides utility functions for working with individual users and user lists.
14
+ """
15
+
16
+ def __init__(self, token: Optional[str] = None):
17
+ """Initialize the user manager."""
18
+ self.client = NotionUserClient(token=token)
19
+
20
+ async def get_current_bot_user(self) -> Optional[NotionUser]:
21
+ """
22
+ Get the current bot user from the API token.
23
+ """
24
+ return await NotionUser.current_bot_user(token=self.client.token)
25
+
26
+ async def get_user_by_id(self, user_id: str) -> Optional[NotionUser]:
27
+ """
28
+ Get a specific user by their ID.
29
+ """
30
+ return await NotionUser.from_user_id(user_id, token=self.client.token)
31
+
32
+ async def list_users(
33
+ self, page_size: int = 100, start_cursor: Optional[str] = None
34
+ ) -> Optional[NotionUsersListResponse]:
35
+ """
36
+ List users in the workspace (paginated).
37
+
38
+ Note: Guests are not included in the response.
39
+ """
40
+ try:
41
+ response = await self.client.list_users(page_size, start_cursor)
42
+ if response is None:
43
+ self.logger.error("Failed to list users")
44
+ return None
45
+
46
+ self.logger.info(
47
+ "Retrieved %d users (has_more: %s)",
48
+ len(response.results),
49
+ response.has_more,
50
+ )
51
+ return response
52
+
53
+ except Exception as e:
54
+ self.logger.error("Error listing users: %s", str(e))
55
+ return None
56
+
57
+ async def get_all_users(self) -> List[NotionUser]:
58
+ """
59
+ Get all users in the workspace as NotionUser objects.
60
+ Automatically handles pagination and converts responses to NotionUser instances.
61
+ """
62
+ try:
63
+ # Get raw user responses
64
+ user_responses = await self.client.get_all_users()
65
+
66
+ # Convert to NotionUser objects
67
+ notion_users = []
68
+ for user_response in user_responses:
69
+ try:
70
+ # Use the internal creation method to convert response to NotionUser
71
+ notion_user = NotionUser.from_notion_user_response(
72
+ user_response, self.client.token
73
+ )
74
+ notion_users.append(notion_user)
75
+ except Exception as e:
76
+ self.logger.warning(
77
+ "Failed to convert user %s to NotionUser: %s",
78
+ user_response.id,
79
+ str(e),
80
+ )
81
+ continue
82
+
83
+ self.logger.info(
84
+ "Successfully converted %d users to NotionUser objects",
85
+ len(notion_users),
86
+ )
87
+ return notion_users
88
+
89
+ except Exception as e:
90
+ self.logger.error("Error getting all users: %s", str(e))
91
+ return []
92
+
93
+ async def get_users_by_type(self, user_type: str = "person") -> List[NotionUser]:
94
+ """
95
+ Get all users of a specific type (person or bot).
96
+ """
97
+ try:
98
+ all_users = await self.get_all_users()
99
+ filtered_users = [user for user in all_users if user.user_type == user_type]
100
+
101
+ self.logger.info(
102
+ "Found %d users of type '%s' out of %d total users",
103
+ len(filtered_users),
104
+ user_type,
105
+ len(all_users),
106
+ )
107
+ return filtered_users
108
+
109
+ except Exception as e:
110
+ self.logger.error("Error filtering users by type: %s", str(e))
111
+ return []
112
+
113
+ # TODO: Type this
114
+ async def get_workspace_info(self) -> Optional[Dict[str, Any]]:
115
+ """
116
+ Get available workspace information from the bot user.
117
+ """
118
+ bot_user = await self.get_current_bot_user()
119
+ if bot_user is None:
120
+ self.logger.error("Failed to get bot user for workspace info")
121
+ return None
122
+
123
+ workspace_info = {
124
+ "workspace_name": bot_user.workspace_name,
125
+ "bot_user_id": bot_user.id,
126
+ "bot_user_name": bot_user.name,
127
+ "bot_user_type": bot_user.user_type,
128
+ }
129
+
130
+ # Add workspace limits if available
131
+ if bot_user.is_bot:
132
+ limits = await bot_user.get_workspace_limits()
133
+ if limits:
134
+ workspace_info["workspace_limits"] = limits
135
+
136
+ # Add user count statistics
137
+ try:
138
+ all_users = await self.get_all_users()
139
+ workspace_info["total_users"] = len(all_users)
140
+ workspace_info["person_users"] = len([u for u in all_users if u.is_person])
141
+ workspace_info["bot_users"] = len([u for u in all_users if u.is_bot])
142
+ except Exception as e:
143
+ self.logger.warning("Could not get user statistics: %s", str(e))
144
+
145
+ return workspace_info
146
+
147
+ async def find_users_by_name(self, name_pattern: str) -> List[NotionUser]:
148
+ """
149
+ Find users by name pattern (case-insensitive partial match).
150
+
151
+ Note: The API doesn't support server-side filtering, so this fetches all users
152
+ and filters client-side.
153
+ """
154
+ try:
155
+ all_users = await self.get_all_users()
156
+ pattern_lower = name_pattern.lower()
157
+
158
+ matching_users = [
159
+ user
160
+ for user in all_users
161
+ if user.name and pattern_lower in user.name.lower()
162
+ ]
163
+
164
+ self.logger.info(
165
+ "Found %d users matching pattern '%s'",
166
+ len(matching_users),
167
+ name_pattern,
168
+ )
169
+ return matching_users
170
+
171
+ except Exception as e:
172
+ self.logger.error("Error finding users by name: %s", str(e))
173
+ return []
@@ -0,0 +1 @@
1
+ # for caching shit
@@ -1,15 +1,13 @@
1
1
  from .logging_mixin import LoggingMixin
2
- from .singleton_decorator import singleton
2
+ from .singleton import singleton
3
3
  from .page_id_utils import format_uuid
4
- from .fuzzy_matcher import FuzzyMatcher
5
- from .factory_decorator import factory_only
4
+ from .factory_only import factory_only
6
5
  from .singleton_metaclass import SingletonMetaClass
7
6
 
8
7
  __all__ = [
9
8
  "LoggingMixin",
10
- "singleton_decorator",
9
+ "singleton",
11
10
  "format_uuid",
12
- "FuzzyMatcher",
13
11
  "factory_only",
14
12
  "singleton",
15
13
  "SingletonMetaClass",
@@ -1,33 +0,0 @@
1
- import functools
2
- import inspect
3
-
4
-
5
- def factory_only(*allowed_factories):
6
- """
7
- Decorator that ensures __init__ is only called from allowed factory methods.
8
-
9
- Args:
10
- *allowed_factories: Names of allowed factory methods (e.g. 'from_database_id')
11
- """
12
-
13
- def decorator(init_method):
14
- @functools.wraps(init_method)
15
- def wrapper(self, *args, **kwargs):
16
- frame = inspect.currentframe()
17
- try:
18
- caller_frame = frame.f_back.f_back
19
- if not caller_frame:
20
- return init_method(self, *args, **kwargs)
21
- caller_name = caller_frame.f_code.co_name
22
- if caller_name in allowed_factories or caller_name.startswith("_"):
23
- return init_method(self, *args, **kwargs)
24
- else:
25
- raise RuntimeError(
26
- f"Direct instantiation not allowed! Use one of: {', '.join(allowed_factories)}"
27
- )
28
- finally:
29
- del frame
30
-
31
- return wrapper
32
-
33
- return decorator
@@ -0,0 +1,37 @@
1
+ import functools
2
+ import inspect
3
+ import warnings
4
+
5
+
6
+ def factory_only(*allowed_factories):
7
+ """
8
+ Decorator that warns when __init__ is not called from allowed factory methods.
9
+
10
+ Args:
11
+ *allowed_factories: Names of allowed factory methods (e.g. 'from_database_id')
12
+ """
13
+
14
+ def decorator(init_method):
15
+ @functools.wraps(init_method)
16
+ def wrapper(self, *args, **kwargs):
17
+ frame = inspect.currentframe()
18
+ try:
19
+ caller_frame = frame.f_back.f_back
20
+ if not caller_frame:
21
+ return init_method(self, *args, **kwargs)
22
+ caller_name = caller_frame.f_code.co_name
23
+ if caller_name in allowed_factories or caller_name.startswith("_"):
24
+ return init_method(self, *args, **kwargs)
25
+
26
+ warnings.warn(
27
+ f"Direct instantiation not recommended! Consider using one of: {', '.join(allowed_factories)}",
28
+ UserWarning,
29
+ stacklevel=3,
30
+ )
31
+ return init_method(self, *args, **kwargs)
32
+ finally:
33
+ del frame
34
+
35
+ return wrapper
36
+
37
+ return decorator
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+ import difflib
3
+ from typing import List, Any, TypeVar, Callable, Optional
4
+ from dataclasses import dataclass
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ @dataclass
10
+ class MatchResult:
11
+ """Result of a fuzzy match operation."""
12
+
13
+ item: Any
14
+ similarity: float
15
+ matched_text: str
16
+
17
+
18
+ def calculate_similarity(query: str, target: str) -> float:
19
+ """Calculate similarity between two strings using difflib."""
20
+ return difflib.SequenceMatcher(
21
+ None, query.lower().strip(), target.lower().strip()
22
+ ).ratio()
23
+
24
+
25
+ def find_best_matches(
26
+ query: str,
27
+ items: List[T],
28
+ text_extractor: Callable[[T], str],
29
+ min_similarity: float = 0.0,
30
+ limit: Optional[int] = None,
31
+ ) -> List[MatchResult[T]]:
32
+ """
33
+ Find best fuzzy matches from a list of items.
34
+
35
+ Args:
36
+ query: The search query
37
+ items: List of items to search through
38
+ text_extractor: Function to extract text from each item
39
+ min_similarity: Minimum similarity threshold (0.0 to 1.0)
40
+ limit: Maximum number of results to return
41
+
42
+ Returns:
43
+ List of MatchResult objects sorted by similarity (highest first)
44
+ """
45
+ results = []
46
+
47
+ for item in items:
48
+ text = text_extractor(item)
49
+ similarity = calculate_similarity(query, text)
50
+
51
+ if similarity >= min_similarity:
52
+ results.append(
53
+ MatchResult(item=item, similarity=similarity, matched_text=text)
54
+ )
55
+
56
+ # Sort by similarity (highest first)
57
+ results.sort(key=lambda x: x.similarity, reverse=True)
58
+
59
+ # Apply limit if specified
60
+ if limit:
61
+ results = results[:limit]
62
+
63
+ return results
64
+
65
+
66
+ def find_best_match(
67
+ query: str,
68
+ items: List[T],
69
+ text_extractor: Callable[[T], str],
70
+ min_similarity: float = 0.0,
71
+ ) -> Optional[MatchResult[T]]:
72
+ """Find the single best fuzzy match."""
73
+ matches = find_best_matches(query, items, text_extractor, min_similarity, limit=1)
74
+ return matches[0] if matches else None
@@ -1,14 +1,24 @@
1
1
  import logging
2
- import inspect
3
2
  from typing import Optional, ClassVar
4
3
 
4
+ import os
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
5
9
 
6
10
  def setup_logging():
11
+ """
12
+ Sets up logging configuration for the application.
13
+ """
14
+ log_level = os.getenv("LOG_LEVEL", "WARNING").upper()
7
15
  logging.basicConfig(
8
- level=logging.INFO,
16
+ level=getattr(logging, log_level),
9
17
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
10
18
  )
11
19
 
20
+ logging.getLogger("httpx").setLevel(logging.WARNING)
21
+
12
22
 
13
23
  setup_logging()
14
24
 
@@ -32,16 +42,6 @@ class LoggingMixin:
32
42
  self._logger = logging.getLogger(self.__class__.__name__)
33
43
  return self._logger
34
44
 
35
- @staticmethod
36
- def static_logger() -> logging.Logger:
37
- """Static logger - for static methods"""
38
- stack = inspect.stack()
39
- for frame_info in stack[1:]:
40
- class_name = LoggingMixin._get_class_name_from_frame(frame_info.frame)
41
- if class_name:
42
- return logging.getLogger(class_name)
43
- return logging.getLogger("UnknownStaticContext")
44
-
45
45
  @staticmethod
46
46
  def _get_class_name_from_frame(frame) -> Optional[str]:
47
47
  local_vars = frame.f_locals