notionary 0.2.16__py3-none-any.whl → 0.2.17__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 (35) hide show
  1. notionary/__init__.py +9 -5
  2. notionary/base_notion_client.py +18 -7
  3. notionary/blocks/__init__.py +2 -0
  4. notionary/blocks/document_element.py +194 -0
  5. notionary/database/__init__.py +4 -0
  6. notionary/database/database.py +481 -0
  7. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  8. notionary/database/{notion_database_provider.py → database_provider.py} +4 -4
  9. notionary/database/notion_database.py +45 -18
  10. notionary/file_upload/__init__.py +7 -0
  11. notionary/file_upload/client.py +254 -0
  12. notionary/file_upload/models.py +60 -0
  13. notionary/file_upload/notion_file_upload.py +387 -0
  14. notionary/page/notion_page.py +4 -3
  15. notionary/telemetry/views.py +15 -6
  16. notionary/user/__init__.py +11 -0
  17. notionary/user/base_notion_user.py +52 -0
  18. notionary/user/client.py +129 -0
  19. notionary/user/models.py +83 -0
  20. notionary/user/notion_bot_user.py +227 -0
  21. notionary/user/notion_user.py +256 -0
  22. notionary/user/notion_user_manager.py +173 -0
  23. notionary/user/notion_user_provider.py +1 -0
  24. notionary/util/__init__.py +3 -5
  25. notionary/util/{factory_decorator.py → factory_only.py} +9 -5
  26. notionary/util/fuzzy.py +74 -0
  27. notionary/util/logging_mixin.py +12 -12
  28. notionary/workspace.py +38 -2
  29. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/METADATA +2 -1
  30. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/RECORD +34 -20
  31. notionary/util/fuzzy_matcher.py +0 -82
  32. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  33. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  34. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
  35. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/WHEEL +0 -0
@@ -0,0 +1,83 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, Optional
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class PersonUser(BaseModel):
7
+ """Person user details"""
8
+
9
+ email: Optional[str] = None
10
+
11
+
12
+ class BotOwner(BaseModel):
13
+ """Bot owner information - simplified structure"""
14
+
15
+ type: Literal["workspace", "user"]
16
+ workspace: Optional[bool] = None
17
+
18
+
19
+ class WorkspaceLimits(BaseModel):
20
+ """Workspace limits for bot users"""
21
+
22
+ max_file_upload_size_in_bytes: int
23
+
24
+
25
+ class BotUser(BaseModel):
26
+ """Bot user details"""
27
+
28
+ owner: Optional[BotOwner] = None
29
+ workspace_name: Optional[str] = None
30
+ workspace_limits: Optional[WorkspaceLimits] = None
31
+
32
+
33
+ class NotionUserResponse(BaseModel):
34
+ """
35
+ Represents a Notion user object as returned by the Users API.
36
+ Can represent both person and bot users.
37
+ """
38
+
39
+ object: Literal["user"]
40
+ id: str
41
+ type: Optional[Literal["person", "bot"]] = None
42
+ name: Optional[str] = None
43
+ avatar_url: Optional[str] = None
44
+
45
+ # Person-specific fields
46
+ person: Optional[PersonUser] = None
47
+
48
+ # Bot-specific fields
49
+ bot: Optional[BotUser] = None
50
+
51
+
52
+ class NotionBotUserResponse(NotionUserResponse):
53
+ """
54
+ Specialized response for bot user (from /users/me endpoint)
55
+ """
56
+
57
+ # Bot users should have these fields, but they can still be None
58
+ type: Literal["bot"]
59
+ bot: Optional[BotUser] = None
60
+
61
+
62
+ class NotionUsersListResponse(BaseModel):
63
+ """
64
+ Response model for paginated users list from /v1/users endpoint.
65
+ Follows Notion's standard pagination pattern.
66
+ """
67
+
68
+ object: Literal["list"]
69
+ results: list[NotionUserResponse]
70
+ next_cursor: Optional[str] = None
71
+ has_more: bool
72
+ type: Literal["user"]
73
+ user: dict = {}
74
+
75
+
76
+ @dataclass
77
+ class WorkspaceInfo:
78
+ """Dataclass to hold workspace information for bot users."""
79
+
80
+ name: Optional[str] = None
81
+ limits: Optional[WorkspaceLimits] = None
82
+ owner_type: Optional[str] = None
83
+ is_workspace_owned: bool = False
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, List
3
+ from notionary.user.base_notion_user import BaseNotionUser
4
+ from notionary.user.client import NotionUserClient
5
+ from notionary.user.models import (
6
+ NotionBotUserResponse,
7
+ WorkspaceLimits,
8
+ )
9
+ from notionary.util import factory_only
10
+ from notionary.util.fuzzy import find_best_match
11
+
12
+
13
+ class NotionBotUser(BaseNotionUser):
14
+ """
15
+ Manager for Notion bot users.
16
+ Handles bot-specific operations and workspace information.
17
+ """
18
+
19
+ NO_USERS_FOUND_MSG = "No users found in workspace"
20
+ NO_BOT_USERS_FOUND_MSG = "No bot users found in workspace"
21
+
22
+ @factory_only("from_current_integration", "from_bot_response", "from_name")
23
+ def __init__(
24
+ self,
25
+ user_id: str,
26
+ name: Optional[str] = None,
27
+ avatar_url: Optional[str] = None,
28
+ workspace_name: Optional[str] = None,
29
+ workspace_limits: Optional[WorkspaceLimits] = None,
30
+ owner_type: Optional[str] = None,
31
+ token: Optional[str] = None,
32
+ ):
33
+ """Initialize bot user with bot-specific properties."""
34
+ super().__init__(user_id, name, avatar_url, token)
35
+ self._workspace_name = workspace_name
36
+ self._workspace_limits = workspace_limits
37
+ self._owner_type = owner_type
38
+
39
+ @classmethod
40
+ async def from_current_integration(
41
+ cls, token: Optional[str] = None
42
+ ) -> Optional[NotionBotUser]:
43
+ """
44
+ Get the current bot user (from the API token).
45
+
46
+ Args:
47
+ token: Optional Notion API token
48
+
49
+ Returns:
50
+ Optional[NotionBotUser]: Bot user instance or None if failed
51
+ """
52
+ client = NotionUserClient(token=token)
53
+ bot_response = await client.get_bot_user()
54
+
55
+ if bot_response is None:
56
+ cls.logger.error("Failed to load bot user data")
57
+ return None
58
+
59
+ return cls._create_from_response(bot_response, token)
60
+
61
+ @classmethod
62
+ async def from_name(
63
+ cls, name: str, token: Optional[str] = None, min_similarity: float = 0.6
64
+ ) -> Optional[NotionBotUser]:
65
+ """
66
+ Create a NotionBotUser by finding a bot user with fuzzy matching on the name.
67
+ Uses Notion's list users API and fuzzy matching to find the best result.
68
+ """
69
+ client = NotionUserClient(token=token)
70
+
71
+ try:
72
+ # Get all users from workspace
73
+ all_users_response = await client.get_all_users()
74
+
75
+ if not all_users_response:
76
+ cls.logger.warning(cls.NO_USERS_FOUND_MSG)
77
+ raise ValueError(cls.NO_USERS_FOUND_MSG)
78
+
79
+ # Filter to only bot users
80
+ bot_users = [
81
+ user for user in all_users_response if user.type == "bot" and user.name
82
+ ]
83
+
84
+ if not bot_users:
85
+ cls.logger.warning(cls.NO_BOT_USERS_FOUND_MSG)
86
+ raise ValueError(cls.NO_BOT_USERS_FOUND_MSG)
87
+
88
+ cls.logger.debug(
89
+ "Found %d bot users for fuzzy matching: %s",
90
+ len(bot_users),
91
+ [user.name for user in bot_users[:5]], # Log first 5 names
92
+ )
93
+
94
+ # Use fuzzy matching to find best match
95
+ best_match = find_best_match(
96
+ query=name,
97
+ items=bot_users,
98
+ text_extractor=lambda user: user.name or "",
99
+ min_similarity=min_similarity,
100
+ )
101
+
102
+ if not best_match:
103
+ available_names = [user.name for user in bot_users[:5]]
104
+ cls.logger.warning(
105
+ "No sufficiently similar bot user found for '%s' (min: %.3f). Available: %s",
106
+ name,
107
+ min_similarity,
108
+ available_names,
109
+ )
110
+ raise ValueError(f"No sufficiently similar bot user found for '{name}'")
111
+
112
+ cls.logger.info(
113
+ "Found best match: '%s' with similarity %.3f for query '%s'",
114
+ best_match.matched_text,
115
+ best_match.similarity,
116
+ name,
117
+ )
118
+
119
+ # Create NotionBotUser from the matched user response
120
+ return cls._create_from_response(best_match.item, token)
121
+
122
+ except Exception as e:
123
+ cls.logger.error("Error finding bot user by name '%s': %s", name, str(e))
124
+ raise
125
+
126
+ @classmethod
127
+ def from_bot_response(
128
+ cls, bot_response: NotionBotUserResponse, token: Optional[str] = None
129
+ ) -> NotionBotUser:
130
+ """
131
+ Create a NotionBotUser from an existing bot API response.
132
+
133
+ Args:
134
+ bot_response: Bot user response from Notion API
135
+ token: Optional Notion API token
136
+
137
+ Returns:
138
+ NotionBotUser: Bot user instance
139
+ """
140
+ return cls._create_from_response(bot_response, token)
141
+
142
+ @property
143
+ def workspace_name(self) -> Optional[str]:
144
+ """Get the workspace name."""
145
+ return self._workspace_name
146
+
147
+ @property
148
+ def workspace_limits(self) -> Optional[WorkspaceLimits]:
149
+ """Get the workspace limits."""
150
+ return self._workspace_limits
151
+
152
+ @property
153
+ def owner_type(self) -> Optional[str]:
154
+ """Get the owner type ('workspace' or 'user')."""
155
+ return self._owner_type
156
+
157
+ @property
158
+ def user_type(self) -> str:
159
+ """Get the user type."""
160
+ return "bot"
161
+
162
+ @property
163
+ def is_person(self) -> bool:
164
+ """Check if this is a person user."""
165
+ return False
166
+
167
+ @property
168
+ def is_bot(self) -> bool:
169
+ """Check if this is a bot user."""
170
+ return True
171
+
172
+ @property
173
+ def is_workspace_integration(self) -> bool:
174
+ """Check if this is a workspace-owned integration."""
175
+ return self._owner_type == "workspace"
176
+
177
+ @property
178
+ def is_user_integration(self) -> bool:
179
+ """Check if this is a user-owned integration."""
180
+ return self._owner_type == "user"
181
+
182
+ @property
183
+ def max_file_upload_size(self) -> Optional[int]:
184
+ """The maximum file upload size in bytes."""
185
+ return (
186
+ self._workspace_limits.max_file_upload_size_in_bytes
187
+ if self._workspace_limits
188
+ else None
189
+ )
190
+
191
+ def __str__(self) -> str:
192
+ """String representation of the bot user."""
193
+ workspace = self._workspace_name or "Unknown Workspace"
194
+ return f"NotionBotUser(name='{self.get_display_name()}', workspace='{workspace}', id='{self._user_id[:8]}...')"
195
+
196
+ @classmethod
197
+ def _create_from_response(
198
+ cls, bot_response: NotionBotUserResponse, token: Optional[str]
199
+ ) -> NotionBotUser:
200
+ """Create NotionBotUser instance from API response."""
201
+ workspace_name = None
202
+ workspace_limits = None
203
+ owner_type = None
204
+
205
+ if bot_response.bot:
206
+ workspace_name = bot_response.bot.workspace_name
207
+ workspace_limits = bot_response.bot.workspace_limits
208
+ owner_type = bot_response.bot.owner.type if bot_response.bot.owner else None
209
+
210
+ instance = cls(
211
+ user_id=bot_response.id,
212
+ name=bot_response.name,
213
+ avatar_url=bot_response.avatar_url,
214
+ workspace_name=workspace_name,
215
+ workspace_limits=workspace_limits,
216
+ owner_type=owner_type,
217
+ token=token,
218
+ )
219
+
220
+ cls.logger.info(
221
+ "Created bot user: '%s' (ID: %s, Workspace: %s)",
222
+ bot_response.name or "Unknown Bot",
223
+ bot_response.id,
224
+ workspace_name or "Unknown",
225
+ )
226
+
227
+ return instance
@@ -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