notionary 0.2.13__py3-none-any.whl → 0.2.15__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 (83) hide show
  1. notionary/__init__.py +3 -16
  2. notionary/{notion_client.py → base_notion_client.py} +92 -98
  3. notionary/blocks/__init__.py +61 -0
  4. notionary/{elements → blocks}/audio_element.py +6 -3
  5. notionary/{elements → blocks}/bookmark_element.py +3 -5
  6. notionary/{elements → blocks}/bulleted_list_element.py +5 -6
  7. notionary/{elements → blocks}/callout_element.py +4 -6
  8. notionary/{elements → blocks}/code_block_element.py +4 -5
  9. notionary/{elements → blocks}/column_element.py +3 -5
  10. notionary/{elements → blocks}/divider_element.py +3 -5
  11. notionary/{elements → blocks}/embed_element.py +4 -5
  12. notionary/{elements → blocks}/heading_element.py +4 -7
  13. notionary/{elements → blocks}/image_element.py +4 -5
  14. notionary/{elements → blocks}/mention_element.py +3 -6
  15. notionary/blocks/notion_block_client.py +26 -0
  16. notionary/{elements → blocks}/notion_block_element.py +2 -3
  17. notionary/{elements → blocks}/numbered_list_element.py +4 -6
  18. notionary/{elements → blocks}/paragraph_element.py +4 -6
  19. notionary/{prompting/element_prompt_content.py → blocks/prompts/element_prompt_builder.py} +1 -40
  20. notionary/blocks/prompts/element_prompt_content.py +41 -0
  21. notionary/{elements → blocks}/qoute_element.py +4 -5
  22. notionary/{elements → blocks}/registry/block_registry.py +4 -26
  23. notionary/{elements → blocks}/registry/block_registry_builder.py +26 -25
  24. notionary/{elements → blocks}/table_element.py +5 -6
  25. notionary/{elements → blocks}/text_inline_formatter.py +1 -4
  26. notionary/{elements → blocks}/todo_element.py +5 -6
  27. notionary/{elements → blocks}/toggle_element.py +3 -5
  28. notionary/{elements → blocks}/toggleable_heading_element.py +4 -6
  29. notionary/{elements → blocks}/video_element.py +4 -5
  30. notionary/database/__init__.py +0 -0
  31. notionary/database/client.py +132 -0
  32. notionary/database/database_exceptions.py +13 -0
  33. notionary/database/factory.py +0 -0
  34. notionary/database/filter_builder.py +175 -0
  35. notionary/database/notion_database.py +340 -127
  36. notionary/database/notion_database_provider.py +230 -0
  37. notionary/elements/__init__.py +0 -0
  38. notionary/models/notion_database_response.py +294 -13
  39. notionary/models/notion_page_response.py +9 -31
  40. notionary/models/search_response.py +0 -0
  41. notionary/page/__init__.py +0 -0
  42. notionary/page/client.py +110 -0
  43. notionary/page/content/page_content_retriever.py +5 -20
  44. notionary/page/content/page_content_writer.py +3 -4
  45. notionary/page/formatting/markdown_to_notion_converter.py +1 -3
  46. notionary/{prompting → page}/markdown_syntax_prompt_generator.py +1 -2
  47. notionary/page/notion_page.py +354 -317
  48. notionary/page/notion_to_markdown_converter.py +1 -4
  49. notionary/page/properites/property_value_extractor.py +0 -64
  50. notionary/page/{properites/property_formatter.py → property_formatter.py} +7 -4
  51. notionary/page/search_filter_builder.py +131 -0
  52. notionary/page/utils.py +60 -0
  53. notionary/util/__init__.py +12 -3
  54. notionary/util/factory_decorator.py +33 -0
  55. notionary/util/fuzzy_matcher.py +82 -0
  56. notionary/util/page_id_utils.py +0 -21
  57. notionary/util/singleton_metaclass.py +22 -0
  58. notionary/workspace.py +69 -0
  59. notionary-0.2.15.dist-info/METADATA +223 -0
  60. notionary-0.2.15.dist-info/RECORD +68 -0
  61. {notionary-0.2.13.dist-info → notionary-0.2.15.dist-info}/WHEEL +1 -2
  62. notionary/cli/main.py +0 -347
  63. notionary/cli/onboarding.py +0 -116
  64. notionary/database/database_discovery.py +0 -142
  65. notionary/database/notion_database_factory.py +0 -190
  66. notionary/exceptions/database_exceptions.py +0 -76
  67. notionary/exceptions/page_creation_exception.py +0 -9
  68. notionary/page/metadata/metadata_editor.py +0 -150
  69. notionary/page/metadata/notion_icon_manager.py +0 -77
  70. notionary/page/metadata/notion_page_cover_manager.py +0 -56
  71. notionary/page/notion_page_factory.py +0 -328
  72. notionary/page/properites/database_property_service.py +0 -302
  73. notionary/page/properites/page_property_manager.py +0 -152
  74. notionary/page/relations/notion_page_relation_manager.py +0 -350
  75. notionary/page/relations/notion_page_title_resolver.py +0 -104
  76. notionary/page/relations/page_database_relation.py +0 -68
  77. notionary/util/warn_direct_constructor_usage.py +0 -54
  78. notionary-0.2.13.dist-info/METADATA +0 -273
  79. notionary-0.2.13.dist-info/RECORD +0 -67
  80. notionary-0.2.13.dist-info/entry_points.txt +0 -2
  81. notionary-0.2.13.dist-info/top_level.txt +0 -1
  82. /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
  83. {notionary-0.2.13.dist-info/licenses → notionary-0.2.15.dist-info}/LICENSE +0 -0
@@ -1,77 +0,0 @@
1
- import json
2
- from typing import Optional
3
-
4
- from notionary.models.notion_page_response import EmojiIcon, ExternalIcon, FileIcon
5
- from notionary.notion_client import NotionClient
6
- from notionary.util import LoggingMixin
7
-
8
-
9
- class NotionPageIconManager(LoggingMixin):
10
- def __init__(self, page_id: str, client: NotionClient):
11
- self.page_id = page_id
12
- self._client = client
13
-
14
- async def set_emoji_icon(self, emoji: str) -> Optional[str]:
15
- """
16
- Sets the page icon to an emoji.
17
-
18
- Args:
19
- emoji (str): The emoji character to set as the icon.
20
-
21
- Returns:
22
- Optional[str]: The emoji that was set as the icon, or None if the operation failed.
23
- """
24
- icon = {"type": "emoji", "emoji": emoji}
25
- page_response = await self._client.patch_page(
26
- page_id=self.page_id, data={"icon": icon}
27
- )
28
-
29
- if page_response and page_response.icon and page_response.icon.type == "emoji":
30
- return page_response.icon.emoji
31
- return None
32
-
33
- async def set_external_icon(self, external_icon_url: str) -> Optional[str]:
34
- """
35
- Sets the page icon to an external image.
36
-
37
- Args:
38
- url (str): The URL of the external image to set as the icon.
39
-
40
- Returns:
41
- Optional[str]: The URL of the external image that was set as the icon,
42
- or None if the operation failed.
43
- """
44
- icon = {"type": "external", "external": {"url": external_icon_url}}
45
- page_response = await self._client.patch_page(
46
- page_id=self.page_id, data={"icon": icon}
47
- )
48
-
49
- if (
50
- page_response
51
- and page_response.icon
52
- and page_response.icon.type == "external"
53
- ):
54
- return page_response.icon.external.url
55
- return None
56
-
57
- async def get_icon(self) -> Optional[str]:
58
- """
59
- Retrieves the page icon - either emoji or external URL.
60
-
61
- Returns:
62
- Optional[str]: Emoji character or URL if set, None if no icon.
63
- """
64
- page_response = await self._client.get_page(self.page_id)
65
- if not page_response or not page_response.icon:
66
- return None
67
-
68
- icon = page_response.icon
69
-
70
- if isinstance(icon, EmojiIcon):
71
- return icon.emoji
72
- if isinstance(icon, ExternalIcon):
73
- return icon.external.url
74
- if isinstance(icon, FileIcon):
75
- return icon.file.url
76
-
77
- return None
@@ -1,56 +0,0 @@
1
- import random
2
- from typing import Any, Dict, Optional
3
- from notionary.notion_client import NotionClient
4
- from notionary.util import LoggingMixin
5
-
6
-
7
- class NotionPageCoverManager(LoggingMixin):
8
- def __init__(self, page_id: str, client: NotionClient):
9
- self.page_id = page_id
10
- self._client = client
11
-
12
- async def set_cover(self, external_url: str) -> Optional[str]:
13
- """
14
- Sets a cover image from an external URL and returns the new URL if successful.
15
-
16
- Args:
17
- external_url: The URL to be set as the external cover image.
18
-
19
- Returns:
20
- The URL of the new cover image, or None if the request failed.
21
- """
22
- data = {"cover": {"type": "external", "external": {"url": external_url}}}
23
-
24
- try:
25
- updated_page = await self._client.patch_page(self.page_id, data=data)
26
- return updated_page.cover.external.url
27
- except Exception as e:
28
- self.logger.error("Failed to set cover image: %s", str(e))
29
- return None
30
-
31
- async def set_random_gradient_cover(self) -> Optional[Dict[str, Any]]:
32
- """Sets a random gradient cover from Notion's default gradient covers."""
33
- default_notion_covers = [
34
- "https://www.notion.so/images/page-cover/gradients_8.png",
35
- "https://www.notion.so/images/page-cover/gradients_2.png",
36
- "https://www.notion.so/images/page-cover/gradients_11.jpg",
37
- "https://www.notion.so/images/page-cover/gradients_10.jpg",
38
- "https://www.notion.so/images/page-cover/gradients_5.png",
39
- "https://www.notion.so/images/page-cover/gradients_3.png",
40
- ]
41
-
42
- random_cover_url = random.choice(default_notion_covers)
43
-
44
- return await self.set_cover(random_cover_url)
45
-
46
- async def get_cover_url(self) -> Optional[str]:
47
- """Retrieves the current cover image URL of the page."""
48
- page_data = await self._client.get_page(self.page_id)
49
-
50
- if not page_data or not page_data.cover:
51
- return None
52
-
53
- if page_data.cover.type == "external":
54
- return page_data.cover.external.url
55
-
56
- return None
@@ -1,328 +0,0 @@
1
- from typing import List, Optional, Dict, Any, Tuple
2
- from difflib import SequenceMatcher
3
-
4
- from notionary import NotionPage, NotionClient
5
- from notionary.util import LoggingMixin
6
- from notionary.util import format_uuid, extract_and_validate_page_id
7
- from notionary.util import singleton
8
-
9
- @singleton
10
- class NotionPageFactory(LoggingMixin):
11
- """
12
- Factory class for creating NotionPage instances.
13
- Provides methods for creating page instances by page ID, URL, or name.
14
- """
15
-
16
- MATCH_THRESHOLD = 0.6
17
- MAX_SUGGESTIONS = 5
18
- PAGE_SIZE = 100
19
- EARLY_STOP_THRESHOLD = 0.95
20
-
21
- @classmethod
22
- def from_page_id(cls, page_id: str, token: Optional[str] = None) -> NotionPage:
23
- """Create a NotionPage from a page ID."""
24
-
25
- try:
26
- formatted_id = format_uuid(page_id) or page_id
27
- page = NotionPage(page_id=formatted_id, token=token)
28
- cls.logger.info(
29
- "Successfully created page instance for ID: %s", formatted_id
30
- )
31
- return page
32
- except Exception as e:
33
- cls.logger.error("Error connecting to page %s: %s", page_id, str(e))
34
- raise
35
-
36
- @classmethod
37
- def from_url(cls, url: str, token: Optional[str] = None) -> NotionPage:
38
- """Create a NotionPage from a Notion URL."""
39
-
40
- try:
41
- page_id = extract_and_validate_page_id(url=url)
42
- if not page_id:
43
- cls.logger.error("Could not extract valid page ID from URL: %s", url)
44
- raise ValueError(f"Invalid URL: {url}")
45
-
46
- page = NotionPage(page_id=page_id, url=url, token=token)
47
- cls.logger.info(
48
- "Successfully created page instance from URL for ID: %s", page_id
49
- )
50
- return page
51
- except Exception as e:
52
- cls.logger.error("Error connecting to page with URL %s: %s", url, str(e))
53
- raise
54
-
55
- @classmethod
56
- async def from_page_name(
57
- cls, page_name: str, token: Optional[str] = None
58
- ) -> NotionPage:
59
- """Create a NotionPage by finding a page with a matching name using fuzzy matching."""
60
- cls.logger.debug("Searching for page with name: %s", page_name)
61
-
62
- client = NotionClient(token=token)
63
-
64
- try:
65
- # Search with pagination and early stopping
66
- best_match, best_score, all_suggestions = (
67
- await cls._search_pages_with_matching(client, page_name)
68
- )
69
-
70
- # Check if match is good enough
71
- if best_score < cls.MATCH_THRESHOLD or not best_match:
72
- suggestion_msg = cls._format_suggestions(all_suggestions)
73
- cls.logger.warning(
74
- "No good match found for '%s'. Best score: %.2f",
75
- page_name,
76
- best_score,
77
- )
78
- raise ValueError(
79
- f"No good match found for '{page_name}'. {suggestion_msg}"
80
- )
81
-
82
- # Create page from best match
83
- page_id = best_match.get("id")
84
- if not page_id:
85
- cls.logger.error("Best match page has no ID")
86
- raise ValueError("Best match page has no ID")
87
-
88
- matched_name = cls._extract_title_from_page(best_match)
89
- cls.logger.info(
90
- "Found matching page: '%s' (ID: %s) with score: %.2f",
91
- matched_name,
92
- page_id,
93
- best_score,
94
- )
95
-
96
- page = NotionPage.from_page_id(page_id=page_id, token=token)
97
- cls.logger.info("Successfully created page instance for '%s'", matched_name)
98
-
99
- await client.close()
100
- return page
101
-
102
- except Exception as e:
103
- cls.logger.error("Error finding page by name: %s", str(e))
104
- await client.close()
105
- raise
106
-
107
- @classmethod
108
- async def _search_pages_with_matching(
109
- cls, client: NotionClient, query: str
110
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
111
- """
112
- Search for pages with pagination and find the best match.
113
- Includes early stopping for performance optimization.
114
- """
115
- cls.logger.debug("Starting paginated search for query: %s", query)
116
-
117
- best_match = None
118
- best_score = 0
119
- all_suggestions = []
120
- page_count = 0
121
-
122
- # Track suggestions across all pages
123
- all_matches = []
124
-
125
- next_cursor = None
126
-
127
- while True:
128
- # Fetch current page batch
129
- pages_batch = await cls._fetch_pages_batch(client, next_cursor)
130
-
131
- if not pages_batch:
132
- cls.logger.debug("No more pages to fetch")
133
- break
134
-
135
- pages = pages_batch.get("results", [])
136
- page_count += len(pages)
137
- cls.logger.debug(
138
- "Processing batch of %d pages (total processed: %d)",
139
- len(pages),
140
- page_count,
141
- )
142
-
143
- # Process current batch
144
- batch_match, batch_score, batch_suggestions = cls._find_best_match_in_batch(
145
- pages, query, best_score
146
- )
147
-
148
- # Update global best if we found a better match
149
- if batch_score > best_score:
150
- best_score = batch_score
151
- best_match = batch_match
152
- cls.logger.debug("New best match found with score: %.2f", best_score)
153
-
154
- # Collect all matches for suggestions
155
- for page in pages:
156
- title = cls._extract_title_from_page(page)
157
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
158
- all_matches.append((title, score))
159
-
160
- # Early stopping: if we found a very good match, stop searching
161
- if best_score >= cls.EARLY_STOP_THRESHOLD:
162
- cls.logger.info(
163
- "Early stopping: found excellent match with score %.2f", best_score
164
- )
165
- break
166
-
167
- # Check for next page
168
- next_cursor = pages_batch.get("next_cursor")
169
- if not next_cursor:
170
- cls.logger.debug("Reached end of pages")
171
- break
172
-
173
- # Generate final suggestions from all matches
174
- all_matches.sort(key=lambda x: x[1], reverse=True)
175
- all_suggestions = [title for title, _ in all_matches[: cls.MAX_SUGGESTIONS]]
176
-
177
- cls.logger.info(
178
- "Search completed. Processed %d pages. Best score: %.2f",
179
- page_count,
180
- best_score,
181
- )
182
-
183
- return best_match, best_score, all_suggestions
184
-
185
- @classmethod
186
- async def _fetch_pages_batch(
187
- cls, client: NotionClient, next_cursor: Optional[str] = None
188
- ) -> Dict[str, Any]:
189
- """Fetch a single batch of pages from the Notion API."""
190
- search_payload = {
191
- "filter": {"property": "object", "value": "page"},
192
- "page_size": cls.PAGE_SIZE,
193
- }
194
-
195
- if next_cursor:
196
- search_payload["start_cursor"] = next_cursor
197
-
198
- try:
199
- response = await client.post("search", search_payload)
200
-
201
- if not response:
202
- cls.logger.error("Empty response from search endpoint")
203
- return {}
204
-
205
- return response
206
-
207
- except Exception as e:
208
- cls.logger.error("Error fetching pages batch: %s", str(e))
209
- raise
210
-
211
- @classmethod
212
- def _find_best_match_in_batch(
213
- cls, pages: List[Dict[str, Any]], query: str, current_best_score: float
214
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
215
- """Find the best matching page in a single batch."""
216
- batch_best_match = None
217
- batch_best_score = current_best_score
218
-
219
- for page in pages:
220
- title = cls._extract_title_from_page(page)
221
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
222
-
223
- if score > batch_best_score:
224
- batch_best_score = score
225
- batch_best_match = page
226
-
227
- # Get batch suggestions (not used in the main algorithm but kept for compatibility)
228
- batch_suggestions = []
229
-
230
- return batch_best_match, batch_best_score, batch_suggestions
231
-
232
- @classmethod
233
- async def _search_pages(cls, client: NotionClient) -> List[Dict[str, Any]]:
234
- """
235
- Legacy method - kept for backward compatibility.
236
- Now uses the paginated approach internally.
237
- """
238
- cls.logger.warning(
239
- "_search_pages is deprecated. Use _search_pages_with_matching instead."
240
- )
241
-
242
- all_pages = []
243
- next_cursor = None
244
-
245
- while True:
246
- batch = await cls._fetch_pages_batch(client, next_cursor)
247
- if not batch:
248
- break
249
-
250
- pages = batch.get("results", [])
251
- all_pages.extend(pages)
252
-
253
- next_cursor = batch.get("next_cursor")
254
- if not next_cursor:
255
- break
256
-
257
- cls.logger.info("Loaded %d total pages", len(all_pages))
258
- return all_pages
259
-
260
- @classmethod
261
- def _find_best_match(
262
- cls, pages: List[Dict[str, Any]], query: str
263
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
264
- """Find the best matching page for the given query."""
265
- cls.logger.debug("Found %d pages, searching for best match", len(pages))
266
-
267
- matches = []
268
- best_match = None
269
- best_score = 0
270
-
271
- for page in pages:
272
- title = cls._extract_title_from_page(page)
273
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
274
- matches.append((page, title, score))
275
-
276
- if score > best_score:
277
- best_score = score
278
- best_match = page
279
-
280
- # Get top suggestions
281
- matches.sort(key=lambda x: x[2], reverse=True)
282
- suggestions = [title for _, title, _ in matches[: cls.MAX_SUGGESTIONS]]
283
-
284
- return best_match, best_score, suggestions
285
-
286
- @classmethod
287
- def _format_suggestions(cls, suggestions: List[str]) -> str:
288
- """Format suggestions as a readable string."""
289
- if not suggestions:
290
- return ""
291
-
292
- msg = "Did you mean one of these?\n"
293
- msg += "\n".join(f"- {suggestion}" for suggestion in suggestions)
294
- return msg
295
-
296
- @classmethod
297
- def _extract_title_from_page(cls, page: Dict[str, Any]) -> str:
298
- """Extract the title from a page object."""
299
- try:
300
- if "properties" in page:
301
- for prop_value in page["properties"].values():
302
- if prop_value.get("type") != "title":
303
- continue
304
- title_array = prop_value.get("title", [])
305
- if title_array:
306
- return cls._extract_text_from_rich_text(title_array)
307
-
308
- # Fall back to child_page
309
- if "child_page" in page:
310
- return page.get("child_page", {}).get("title", "Untitled")
311
-
312
- return "Untitled"
313
-
314
- except Exception as e:
315
- cls.logger.warning("Error extracting page title: %s", str(e))
316
- return "Untitled"
317
-
318
- @classmethod
319
- def _extract_text_from_rich_text(cls, rich_text: List[Dict[str, Any]]) -> str:
320
- """Extract plain text from a rich text array."""
321
- if not rich_text:
322
- return ""
323
-
324
- text_parts = [
325
- text_obj["plain_text"] for text_obj in rich_text if "plain_text" in text_obj
326
- ]
327
-
328
- return "".join(text_parts)