notionary 0.2.15__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 (38) hide show
  1. notionary/__init__.py +9 -5
  2. notionary/base_notion_client.py +19 -8
  3. notionary/blocks/__init__.py +2 -0
  4. notionary/blocks/document_element.py +194 -0
  5. notionary/blocks/registry/block_registry.py +27 -3
  6. notionary/database/__init__.py +4 -0
  7. notionary/database/database.py +481 -0
  8. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  9. notionary/database/{notion_database_provider.py → database_provider.py} +6 -10
  10. notionary/database/notion_database.py +73 -32
  11. notionary/file_upload/__init__.py +7 -0
  12. notionary/file_upload/client.py +254 -0
  13. notionary/file_upload/models.py +60 -0
  14. notionary/file_upload/notion_file_upload.py +387 -0
  15. notionary/page/notion_page.py +5 -6
  16. notionary/telemetry/__init__.py +19 -0
  17. notionary/telemetry/service.py +136 -0
  18. notionary/telemetry/views.py +73 -0
  19. notionary/user/__init__.py +11 -0
  20. notionary/user/base_notion_user.py +52 -0
  21. notionary/user/client.py +129 -0
  22. notionary/user/models.py +83 -0
  23. notionary/user/notion_bot_user.py +227 -0
  24. notionary/user/notion_user.py +256 -0
  25. notionary/user/notion_user_manager.py +173 -0
  26. notionary/user/notion_user_provider.py +1 -0
  27. notionary/util/__init__.py +5 -5
  28. notionary/util/{factory_decorator.py → factory_only.py} +9 -5
  29. notionary/util/fuzzy.py +74 -0
  30. notionary/util/logging_mixin.py +12 -12
  31. notionary/workspace.py +38 -2
  32. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/METADATA +3 -1
  33. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/RECORD +37 -20
  34. notionary/util/fuzzy_matcher.py +0 -82
  35. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  36. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  37. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
  38. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/WHEEL +0 -0
notionary/__init__.py CHANGED
@@ -1,13 +1,17 @@
1
- __version__ = "0.2.14"
2
-
3
- from .database.notion_database import NotionDatabase
4
-
1
+ from .database import NotionDatabase, DatabaseFilterBuilder
5
2
  from .page.notion_page import NotionPage
6
3
  from .workspace import NotionWorkspace
7
-
4
+ from .user import NotionUser, NotionUserManager, NotionBotUser
5
+ from .file_upload import NotionFileUpload, NotionFileUploadClient
8
6
 
9
7
  __all__ = [
10
8
  "NotionDatabase",
9
+ "DatabaseFilterBuilder",
11
10
  "NotionPage",
12
11
  "NotionWorkspace",
12
+ "NotionUser",
13
+ "NotionUserManager",
14
+ "NotionBotUser",
15
+ "NotionFileUpload",
16
+ "NotionFileUploadClient",
13
17
  ]
@@ -93,14 +93,17 @@ class BaseNotionClient(LoggingMixin, ABC):
93
93
  self._is_initialized = False
94
94
  self.logger.debug("NotionClient closed")
95
95
 
96
- async def get(self, endpoint: str) -> Optional[Dict[str, Any]]:
96
+ async def get(
97
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None
98
+ ) -> Optional[Dict[str, Any]]:
97
99
  """
98
100
  Sends a GET request to the specified Notion API endpoint.
99
101
 
100
102
  Args:
101
103
  endpoint: The API endpoint (without base URL)
104
+ params: Query parameters to include in the request
102
105
  """
103
- return await self._make_request(HttpMethod.GET, endpoint)
106
+ return await self._make_request(HttpMethod.GET, endpoint, params=params)
104
107
 
105
108
  async def post(
106
109
  self, endpoint: str, data: Optional[Dict[str, Any]] = None
@@ -141,6 +144,7 @@ class BaseNotionClient(LoggingMixin, ABC):
141
144
  method: Union[HttpMethod, str],
142
145
  endpoint: str,
143
146
  data: Optional[Dict[str, Any]] = None,
147
+ params: Optional[Dict[str, Any]] = None,
144
148
  ) -> Optional[Dict[str, Any]]:
145
149
  """
146
150
  Executes an HTTP request and returns the data or None on error.
@@ -149,6 +153,7 @@ class BaseNotionClient(LoggingMixin, ABC):
149
153
  method: HTTP method to use
150
154
  endpoint: API endpoint
151
155
  data: Request body data (for POST/PATCH)
156
+ params: Query parameters (for GET requests)
152
157
  """
153
158
  await self.ensure_initialized()
154
159
 
@@ -160,15 +165,21 @@ class BaseNotionClient(LoggingMixin, ABC):
160
165
  try:
161
166
  self.logger.debug("Sending %s request to %s", method_str.upper(), url)
162
167
 
168
+ request_kwargs = {}
169
+
170
+ # Add query parameters for GET requests
171
+ if params:
172
+ request_kwargs["params"] = params
173
+
163
174
  if (
164
175
  method_str in [HttpMethod.POST.value, HttpMethod.PATCH.value]
165
176
  and data is not None
166
177
  ):
167
- response: httpx.Response = await getattr(self.client, method_str)(
168
- url, json=data
169
- )
170
- else:
171
- response: httpx.Response = await getattr(self.client, method_str)(url)
178
+ request_kwargs["json"] = data
179
+
180
+ response: httpx.Response = await getattr(self.client, method_str)(
181
+ url, **request_kwargs
182
+ )
172
183
 
173
184
  response.raise_for_status()
174
185
  result_data = response.json()
@@ -194,7 +205,7 @@ class BaseNotionClient(LoggingMixin, ABC):
194
205
  token = next(
195
206
  (
196
207
  os.getenv(var)
197
- for var in ("NOTION_SECRET", "NOTION_API_KEY", "NOTION_TOKEN")
208
+ for var in ("NOTION_SECRET", "NOTION_INTEGRATION_KEY", "NOTION_TOKEN")
198
209
  if os.getenv(var)
199
210
  ),
200
211
  None,
@@ -26,6 +26,7 @@ from .divider_element import DividerElement
26
26
  from .heading_element import HeadingElement
27
27
  from .mention_element import MentionElement
28
28
  from .qoute_element import QuoteElement
29
+ from .document_element import DocumentElement
29
30
 
30
31
  from .registry.block_registry import BlockRegistry
31
32
  from .registry.block_registry_builder import BlockRegistryBuilder
@@ -55,6 +56,7 @@ __all__ = [
55
56
  "BookmarkElement",
56
57
  "MentionElement",
57
58
  "QuoteElement",
59
+ "DocumentElement",
58
60
  "BlockRegistry",
59
61
  "BlockRegistryBuilder",
60
62
  "NotionBlockClient",
@@ -0,0 +1,194 @@
1
+ import re
2
+ from typing import Dict, Any, Optional, List
3
+
4
+ from notionary.blocks import NotionBlockElement
5
+ from notionary.blocks import ElementPromptContent, ElementPromptBuilder
6
+
7
+
8
+ class DocumentElement(NotionBlockElement):
9
+ """
10
+ Handles conversion between Markdown document embeds and Notion file blocks.
11
+
12
+ Markdown document syntax (custom format):
13
+ - %[Caption](https://example.com/document.pdf) - Basic document with caption
14
+ - %[](https://example.com/document.pdf) - Document without caption
15
+ - %[Meeting Notes](https://drive.google.com/file/d/123/view) - Google Drive document
16
+ - %[Report](https://company.sharepoint.com/document.docx) - SharePoint document
17
+
18
+ Supports various document URLs including PDFs, Word docs, Excel files, PowerPoint,
19
+ Google Drive files, and other document formats that Notion can display.
20
+ """
21
+
22
+ PATTERN = re.compile(
23
+ r"^%\[(.*?)\]" # %[Caption] part
24
+ + r'\((https?://[^\s"]+)' # (URL part
25
+ + r"\)$" # closing parenthesis
26
+ )
27
+
28
+ DOCUMENT_EXTENSIONS = [
29
+ ".pdf",
30
+ ".doc",
31
+ ".docx",
32
+ ".xls",
33
+ ".xlsx",
34
+ ".ppt",
35
+ ".pptx",
36
+ ".txt",
37
+ ".rtf",
38
+ ".odt",
39
+ ".ods",
40
+ ".odp",
41
+ ".pages",
42
+ ".numbers",
43
+ ".key",
44
+ ".epub",
45
+ ".mobi",
46
+ ]
47
+
48
+ @classmethod
49
+ def match_markdown(cls, text: str) -> bool:
50
+ """Check if text is a markdown document embed."""
51
+ text = text.strip()
52
+ return text.startswith("%[") and bool(cls.PATTERN.match(text))
53
+
54
+ @classmethod
55
+ def match_notion(cls, block: Dict[str, Any]) -> bool:
56
+ """Check if block is a Notion file (document)."""
57
+ return block.get("type") == "file"
58
+
59
+ @classmethod
60
+ def is_document_url(cls, url: str) -> bool:
61
+ """Check if URL points to a document file."""
62
+ url_lower = url.lower()
63
+
64
+ # Check for common document file extensions
65
+ if any(url_lower.endswith(ext) for ext in cls.DOCUMENT_EXTENSIONS):
66
+ return True
67
+
68
+ # Check for common document hosting services
69
+ document_services = [
70
+ "drive.google.com",
71
+ "docs.google.com",
72
+ "sheets.google.com",
73
+ "slides.google.com",
74
+ "sharepoint.com",
75
+ "onedrive.com",
76
+ "dropbox.com",
77
+ "box.com",
78
+ "scribd.com",
79
+ "slideshare.net",
80
+ ]
81
+
82
+ return any(service in url_lower for service in document_services)
83
+
84
+ @classmethod
85
+ def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
86
+ """Convert markdown document embed to Notion file block."""
87
+ doc_match = cls.PATTERN.match(text.strip())
88
+ if not doc_match:
89
+ return None
90
+
91
+ caption = doc_match.group(1)
92
+ url = doc_match.group(2)
93
+
94
+ if not url:
95
+ return None
96
+
97
+ # Verify this looks like a document URL
98
+ if not cls.is_document_url(url):
99
+ # Still proceed - user might know better than our detection
100
+ pass
101
+
102
+ # Prepare the file block
103
+ file_block = {
104
+ "type": "file",
105
+ "file": {"type": "external", "external": {"url": url}},
106
+ }
107
+
108
+ # Add caption if provided
109
+ if caption:
110
+ file_block["file"]["caption"] = [
111
+ {"type": "text", "text": {"content": caption}}
112
+ ]
113
+
114
+ return file_block
115
+
116
+ @classmethod
117
+ def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
118
+ """Convert Notion file block to markdown document embed."""
119
+ if block.get("type") != "file":
120
+ return None
121
+
122
+ file_data = block.get("file", {})
123
+
124
+ # Handle both external and file (uploaded) documents
125
+ if file_data.get("type") == "external":
126
+ url = file_data.get("external", {}).get("url", "")
127
+ elif file_data.get("type") == "file":
128
+ url = file_data.get("file", {}).get("url", "")
129
+ elif file_data.get("type") == "file_upload":
130
+ # Handle file uploads with special notion:// syntax
131
+ file_upload_id = file_data.get("file_upload", {}).get("id", "")
132
+ if file_upload_id:
133
+ url = f"notion://file_upload/{file_upload_id}"
134
+ else:
135
+ return None
136
+ else:
137
+ return None
138
+
139
+ if not url:
140
+ return None
141
+
142
+ # Extract caption if available
143
+ caption = ""
144
+ caption_rich_text = file_data.get("caption", [])
145
+ if caption_rich_text:
146
+ caption = cls._extract_text_content(caption_rich_text)
147
+
148
+ return f"%[{caption}]({url})"
149
+
150
+ @classmethod
151
+ def is_multiline(cls) -> bool:
152
+ """Document embeds are single-line elements."""
153
+ return False
154
+
155
+ @classmethod
156
+ def _extract_text_content(cls, rich_text: List[Dict[str, Any]]) -> str:
157
+ """Extract plain text content from Notion rich_text elements."""
158
+ result = ""
159
+ for text_obj in rich_text:
160
+ if text_obj.get("type") == "text":
161
+ result += text_obj.get("text", {}).get("content", "")
162
+ elif "plain_text" in text_obj:
163
+ result += text_obj.get("plain_text", "")
164
+ return result
165
+
166
+ @classmethod
167
+ def get_llm_prompt_content(cls) -> ElementPromptContent:
168
+ """Returns information for LLM prompts about this element."""
169
+ return (
170
+ ElementPromptBuilder()
171
+ .with_description(
172
+ "Embeds document files from external sources like PDFs, Word docs, Excel files, or cloud storage services."
173
+ )
174
+ .with_usage_guidelines(
175
+ "Use document embeds when you want to include reference materials, reports, presentations, or any "
176
+ "file-based content directly in your document. Documents can be viewed inline or downloaded by users. "
177
+ "Perfect for sharing contracts, reports, manuals, or any important files."
178
+ )
179
+ .with_syntax("%[Caption](https://example.com/document.pdf)")
180
+ .with_examples(
181
+ [
182
+ "%[Project Proposal](https://drive.google.com/file/d/1a2b3c4d5e/view)",
183
+ "%[Q4 Financial Report](https://company.sharepoint.com/reports/q4-2024.xlsx)",
184
+ "%[User Manual](https://cdn.company.com/docs/manual-v2.1.pdf)",
185
+ "%[Meeting Minutes](https://docs.google.com/document/d/1x2y3z4/edit)",
186
+ "%[](https://example.com/contract.pdf)",
187
+ ]
188
+ )
189
+ .with_avoidance_guidelines(
190
+ "Only use for actual document files. For web pages or articles, use bookmark or embed elements instead. "
191
+ "Ensure document URLs are accessible to your intended audience."
192
+ )
193
+ .build()
194
+ )
@@ -8,6 +8,12 @@ from notionary.page.markdown_syntax_prompt_generator import (
8
8
  from notionary.blocks.text_inline_formatter import TextInlineFormatter
9
9
 
10
10
  from notionary.blocks import NotionBlockElement
11
+ from notionary.telemetry import (
12
+ ProductTelemetry,
13
+ NotionMarkdownSyntaxPromptEvent,
14
+ MarkdownToNotionConversionEvent,
15
+ NotionToMarkdownConversionEvent,
16
+ )
11
17
 
12
18
 
13
19
  class BlockRegistry:
@@ -28,6 +34,8 @@ class BlockRegistry:
28
34
  for element in elements:
29
35
  self.register(element)
30
36
 
37
+ self.telemetry = ProductTelemetry()
38
+
31
39
  def register(self, element_class: Type[NotionBlockElement]) -> bool:
32
40
  """
33
41
  Register an element class.
@@ -57,13 +65,13 @@ class BlockRegistry:
57
65
 
58
66
  def contains(self, element_class: Type[NotionBlockElement]) -> bool:
59
67
  """
60
- Prüft, ob ein bestimmtes Element im Registry enthalten ist.
68
+ Checks if a specific element is contained in the registry.
61
69
 
62
70
  Args:
63
- element_class: Die zu prüfende Element-Klasse
71
+ element_class: The element class to check.
64
72
 
65
73
  Returns:
66
- bool: True, wenn das Element enthalten ist, sonst False
74
+ bool: True if the element is contained, otherwise False.
67
75
  """
68
76
  return element_class in self._elements
69
77
 
@@ -77,14 +85,28 @@ class BlockRegistry:
77
85
  def markdown_to_notion(self, text: str) -> Optional[Dict[str, Any]]:
78
86
  """Convert markdown to Notion block using registered elements."""
79
87
  handler = self.find_markdown_handler(text)
88
+
80
89
  if handler:
90
+ self.telemetry.capture(
91
+ MarkdownToNotionConversionEvent(
92
+ handler_element_name=handler.__name__,
93
+ )
94
+ )
95
+
81
96
  return handler.markdown_to_notion(text)
82
97
  return None
83
98
 
84
99
  def notion_to_markdown(self, block: Dict[str, Any]) -> Optional[str]:
85
100
  """Convert Notion block to markdown using registered elements."""
86
101
  handler = self._find_notion_handler(block)
102
+
87
103
  if handler:
104
+ self.telemetry.capture(
105
+ NotionToMarkdownConversionEvent(
106
+ handler_element_name=handler.__name__,
107
+ )
108
+ )
109
+
88
110
  return handler.notion_to_markdown(block)
89
111
  return None
90
112
 
@@ -106,6 +128,8 @@ class BlockRegistry:
106
128
  if "TextInlineFormatter" not in formatter_names:
107
129
  element_classes = element_classes + [TextInlineFormatter]
108
130
 
131
+ self.telemetry.capture(NotionMarkdownSyntaxPromptEvent())
132
+
109
133
  return MarkdownSyntaxPromptGenerator.generate_system_prompt(element_classes)
110
134
 
111
135
  def _find_notion_handler(
@@ -0,0 +1,4 @@
1
+ from .database import NotionDatabase
2
+ from .database_filter_builder import DatabaseFilterBuilder
3
+
4
+ __all__ = ["NotionDatabase", "DatabaseFilterBuilder"]