notionary 0.2.14__py3-none-any.whl → 0.2.16__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.
@@ -194,7 +194,7 @@ class BaseNotionClient(LoggingMixin, ABC):
194
194
  token = next(
195
195
  (
196
196
  os.getenv(var)
197
- for var in ("NOTION_SECRET", "NOTION_API_KEY", "NOTION_TOKEN")
197
+ for var in ("NOTION_SECRET", "NOTION_INTEGRATION_KEY", "NOTION_TOKEN")
198
198
  if os.getenv(var)
199
199
  ),
200
200
  None,
@@ -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(
@@ -13,6 +13,11 @@ from notionary.page.notion_page import NotionPage
13
13
  from notionary.database.notion_database_provider import NotionDatabaseProvider
14
14
 
15
15
  from notionary.database.filter_builder import FilterBuilder
16
+ from notionary.telemetry import (
17
+ ProductTelemetry,
18
+ DatabaseFactoryUsedEvent,
19
+ QueryOperationEvent,
20
+ )
16
21
  from notionary.util import factory_only, LoggingMixin
17
22
 
18
23
 
@@ -22,11 +27,13 @@ class NotionDatabase(LoggingMixin):
22
27
  Focused exclusively on creating basic pages and retrieving page managers
23
28
  for further page operations.
24
29
  """
30
+
31
+ telemetry = ProductTelemetry()
25
32
 
26
33
  @factory_only("from_database_id", "from_database_name")
27
34
  def __init__(
28
35
  self,
29
- database_id: str,
36
+ id: str,
30
37
  title: str,
31
38
  url: str,
32
39
  emoji_icon: Optional[str] = None,
@@ -36,7 +43,7 @@ class NotionDatabase(LoggingMixin):
36
43
  """
37
44
  Initialize the minimal database manager.
38
45
  """
39
- self._database_id = database_id
46
+ self._id = id
40
47
  self._title = title
41
48
  self._url = url
42
49
  self._emoji_icon = emoji_icon
@@ -46,13 +53,17 @@ class NotionDatabase(LoggingMixin):
46
53
 
47
54
  @classmethod
48
55
  async def from_database_id(
49
- cls, database_id: str, token: Optional[str] = None
56
+ cls, id: str, token: Optional[str] = None
50
57
  ) -> NotionDatabase:
51
58
  """
52
59
  Create a NotionDatabase from a database ID using NotionDatabaseProvider.
53
60
  """
54
61
  provider = cls.get_database_provider()
55
- return await provider.get_database_by_id(database_id, token)
62
+ cls.telemetry.capture(
63
+ DatabaseFactoryUsedEvent(factory_method="from_database_id")
64
+ )
65
+
66
+ return await provider.get_database_by_id(id, token)
56
67
 
57
68
  @classmethod
58
69
  async def from_database_name(
@@ -65,12 +76,15 @@ class NotionDatabase(LoggingMixin):
65
76
  Create a NotionDatabase by finding a database with fuzzy matching on the title using NotionDatabaseProvider.
66
77
  """
67
78
  provider = cls.get_database_provider()
79
+ cls.telemetry.capture(
80
+ DatabaseFactoryUsedEvent(factory_method="from_database_name")
81
+ )
68
82
  return await provider.get_database_by_name(database_name, token, min_similarity)
69
83
 
70
84
  @property
71
- def database_id(self) -> str:
85
+ def id(self) -> str:
72
86
  """Get the database ID (readonly)."""
73
- return self._database_id
87
+ return self._id
74
88
 
75
89
  @property
76
90
  def title(self) -> str:
@@ -109,7 +123,7 @@ class NotionDatabase(LoggingMixin):
109
123
  """
110
124
  try:
111
125
  create_page_response: NotionPageResponse = await self.client.create_page(
112
- parent_database_id=self.database_id
126
+ parent_database_id=self.id
113
127
  )
114
128
 
115
129
  return await NotionPage.from_page_id(page_id=create_page_response.id)
@@ -124,14 +138,12 @@ class NotionDatabase(LoggingMixin):
124
138
  """
125
139
  try:
126
140
  result = await self.client.update_database_title(
127
- database_id=self.database_id, title=new_title
141
+ database_id=self.id, title=new_title
128
142
  )
129
143
 
130
144
  self._title = result.title[0].plain_text
131
145
  self.logger.info(f"Successfully updated database title to: {new_title}")
132
- self.database_provider.invalidate_database_cache(
133
- database_id=self.database_id
134
- )
146
+ self.database_provider.invalidate_database_cache(database_id=self.id)
135
147
  return True
136
148
 
137
149
  except Exception as e:
@@ -144,14 +156,12 @@ class NotionDatabase(LoggingMixin):
144
156
  """
145
157
  try:
146
158
  result = await self.client.update_database_emoji(
147
- database_id=self.database_id, emoji=new_emoji
159
+ database_id=self.id, emoji=new_emoji
148
160
  )
149
161
 
150
162
  self._emoji_icon = result.icon.emoji if result.icon else None
151
163
  self.logger.info(f"Successfully updated database emoji to: {new_emoji}")
152
- self.database_provider.invalidate_database_cache(
153
- database_id=self.database_id
154
- )
164
+ self.database_provider.invalidate_database_cache(database_id=self.id)
155
165
  return True
156
166
 
157
167
  except Exception as e:
@@ -164,13 +174,11 @@ class NotionDatabase(LoggingMixin):
164
174
  """
165
175
  try:
166
176
  result = await self.client.update_database_cover_image(
167
- database_id=self.database_id, image_url=image_url
177
+ database_id=self.id, image_url=image_url
168
178
  )
169
179
 
170
180
  if result.cover and result.cover.external:
171
- self.database_provider.invalidate_database_cache(
172
- database_id=self.database_id
173
- )
181
+ self.database_provider.invalidate_database_cache(database_id=self.id)
174
182
  return result.cover.external.url
175
183
  return None
176
184
 
@@ -193,13 +201,11 @@ class NotionDatabase(LoggingMixin):
193
201
  """
194
202
  try:
195
203
  result = await self.client.update_database_external_icon(
196
- database_id=self.database_id, icon_url=external_icon_url
204
+ database_id=self.id, icon_url=external_icon_url
197
205
  )
198
206
 
199
207
  if result.icon and result.icon.external:
200
- self.database_provider.invalidate_database_cache(
201
- database_id=self.database_id
202
- )
208
+ self.database_provider.invalidate_database_cache(database_id=self.id)
203
209
  return result.icon.external.url
204
210
  return None
205
211
 
@@ -244,16 +250,22 @@ class NotionDatabase(LoggingMixin):
244
250
  """
245
251
  search_results: NotionQueryDatabaseResponse = (
246
252
  await self.client.query_database_by_title(
247
- database_id=self.database_id, page_title=page_title
253
+ database_id=self.id, page_title=page_title
248
254
  )
249
255
  )
250
256
 
251
257
  page_results: List[NotionPage] = []
252
258
 
253
259
  for page in search_results.results:
254
- page = NotionPage.from_page_id(page_id=page.id, token=self.client.token)
260
+ page = await NotionPage.from_page_id(
261
+ page_id=page.id, token=self.client.token
262
+ )
255
263
  page_results.append(page)
256
264
 
265
+ self.telemetry.capture(
266
+ QueryOperationEvent(query_type="query_database_by_title")
267
+ )
268
+
257
269
  return page_results
258
270
 
259
271
  async def iter_pages_updated_within(
@@ -289,14 +301,14 @@ class NotionDatabase(LoggingMixin):
289
301
  ISO 8601 timestamp string of the last database edit, or None if request fails.
290
302
  """
291
303
  try:
292
- db = await self.client.get_database(self.database_id)
304
+ db = await self.client.get_database(self.id)
293
305
 
294
306
  return db.last_edited_time
295
307
 
296
308
  except Exception as e:
297
309
  self.logger.error(
298
310
  "Error fetching last_edited_time for database %s: %s",
299
- self.database_id,
311
+ self.id,
300
312
  str(e),
301
313
  )
302
314
  return None
@@ -345,14 +357,16 @@ class NotionDatabase(LoggingMixin):
345
357
  current_body["start_cursor"] = start_cursor
346
358
 
347
359
  result = await self.client.query_database(
348
- database_id=self.database_id, query_data=current_body
360
+ database_id=self.id, query_data=current_body
349
361
  )
350
362
 
351
363
  if not result or not result.results:
352
364
  return
353
365
 
354
366
  for page in result.results:
355
- yield await NotionPage.from_page_id(page_id=page.id, token=self.client.token)
367
+ yield await NotionPage.from_page_id(
368
+ page_id=page.id, token=self.client.token
369
+ )
356
370
 
357
371
  has_more = result.has_more
358
372
  start_cursor = result.next_cursor if has_more else None
@@ -368,7 +382,7 @@ class NotionDatabase(LoggingMixin):
368
382
  emoji_icon = cls._extract_emoji_icon(db_response)
369
383
 
370
384
  instance = cls(
371
- database_id=db_response.id,
385
+ id=db_response.id,
372
386
  title=title,
373
387
  url=db_response.url,
374
388
  emoji_icon=emoji_icon,
@@ -55,20 +55,16 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
55
55
  database_name, token, min_similarity
56
56
  )
57
57
 
58
- id_cache_key = self._create_id_cache_key(database.database_id)
58
+ id_cache_key = self._create_id_cache_key(database.id)
59
59
  if not force_refresh and id_cache_key in self._database_cache:
60
- self.logger.debug(
61
- f"Found existing cached database by ID: {database.database_id}"
62
- )
60
+ self.logger.debug(f"Found existing cached database by ID: {database.id}")
63
61
  existing_database = self._database_cache[id_cache_key]
64
62
 
65
63
  self._database_cache[name_cache_key] = existing_database
66
64
  return existing_database
67
65
 
68
66
  self._cache_database(database, token, database_name)
69
- self.logger.debug(
70
- f"Cached database: {database.title} (ID: {database.database_id})"
71
- )
67
+ self.logger.debug(f"Cached database: {database.title} (ID: {database.id})")
72
68
 
73
69
  return database
74
70
 
@@ -96,7 +92,7 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
96
92
  name_keys_to_remove = [
97
93
  cache_key
98
94
  for cache_key, cached_db in self._database_cache.items()
99
- if (cache_key.startswith("name:") and cached_db.database_id == database_id)
95
+ if (cache_key.startswith("name:") and cached_db.id == database_id)
100
96
  ]
101
97
 
102
98
  for name_key in name_keys_to_remove:
@@ -173,7 +169,7 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
173
169
  ) -> None:
174
170
  """Cache a database by both ID and name (if provided)."""
175
171
  # Always cache by ID
176
- id_cache_key = self._create_id_cache_key(database.database_id)
172
+ id_cache_key = self._create_id_cache_key(database.id)
177
173
  self._database_cache[id_cache_key] = database
178
174
 
179
175
  if original_name:
@@ -199,7 +195,7 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
199
195
  emoji_icon = self._extract_emoji_icon(db_response)
200
196
 
201
197
  instance = NotionDatabase(
202
- database_id=db_response.id,
198
+ id=db_response.id,
203
199
  title=title,
204
200
  url=db_response.url,
205
201
  emoji_icon=emoji_icon,
@@ -500,9 +500,7 @@ class NotionPage(LoggingMixin):
500
500
  parent_database_id = cls._extract_parent_database_id(page_response)
501
501
 
502
502
  parent_database = (
503
- await NotionDatabase.from_database_id(
504
- database_id=parent_database_id, token=token
505
- )
503
+ await NotionDatabase.from_database_id(id=parent_database_id, token=token)
506
504
  if parent_database_id
507
505
  else None
508
506
  )
@@ -0,0 +1,19 @@
1
+ from .service import ProductTelemetry
2
+ from .views import (
3
+ BaseTelemetryEvent,
4
+ DatabaseFactoryUsedEvent,
5
+ QueryOperationEvent,
6
+ NotionMarkdownSyntaxPromptEvent,
7
+ MarkdownToNotionConversionEvent,
8
+ NotionToMarkdownConversionEvent,
9
+ )
10
+
11
+ __all__ = [
12
+ "ProductTelemetry",
13
+ "BaseTelemetryEvent",
14
+ "DatabaseFactoryUsedEvent",
15
+ "QueryOperationEvent",
16
+ "NotionMarkdownSyntaxPromptEvent",
17
+ "MarkdownToNotionConversionEvent",
18
+ "NotionToMarkdownConversionEvent",
19
+ ]
@@ -0,0 +1,136 @@
1
+ import os
2
+ import uuid
3
+ from pathlib import Path
4
+ from typing import Dict, Any, Optional
5
+ from posthog import Posthog
6
+ from dotenv import load_dotenv
7
+
8
+ from notionary.telemetry.views import BaseTelemetryEvent
9
+ from notionary.util import SingletonMetaClass, LoggingMixin
10
+
11
+ load_dotenv()
12
+
13
+ POSTHOG_EVENT_SETTINGS = {
14
+ "process_person_profile": True,
15
+ }
16
+
17
+
18
+ class ProductTelemetry(LoggingMixin, metaclass=SingletonMetaClass):
19
+ """
20
+ Anonymous telemetry for Notionary - enabled by default.
21
+ Disable via: ANONYMIZED_NOTIONARY_TELEMETRY=false
22
+ """
23
+
24
+ USER_ID_PATH = str(Path.home() / ".cache" / "notionary" / "telemetry_user_id")
25
+ PROJECT_API_KEY = "phc_gItKOx21Tc0l07C1taD0QPpqFnbWgWjVfRjF6z24kke"
26
+ HOST = "https://eu.i.posthog.com"
27
+ UNKNOWN_USER_ID = "UNKNOWN"
28
+
29
+ _logged_init_message = False
30
+ _curr_user_id = None
31
+
32
+ def __init__(self):
33
+ # Default: enabled, disable via environment variable
34
+ telemetry_setting = os.getenv("ANONYMIZED_NOTIONARY_TELEMETRY", "true").lower()
35
+ telemetry_disabled = telemetry_setting == "false"
36
+ self.debug_logging = os.getenv("NOTIONARY_DEBUG", "false").lower() == "true"
37
+
38
+ if telemetry_disabled:
39
+ self._posthog_client = None
40
+ else:
41
+ if not self._logged_init_message:
42
+ self.logger.info(
43
+ "Anonymous telemetry enabled to improve Notionary. "
44
+ "To disable: export ANONYMIZED_NOTIONARY_TELEMETRY=false"
45
+ )
46
+ self._logged_init_message = True
47
+
48
+ self._posthog_client = Posthog(
49
+ project_api_key=self.PROJECT_API_KEY,
50
+ host=self.HOST,
51
+ disable_geoip=True,
52
+ enable_exception_autocapture=True,
53
+ )
54
+
55
+ # Silence posthog's logging unless debug mode
56
+ if not self.debug_logging:
57
+ import logging
58
+
59
+ posthog_logger = logging.getLogger("posthog")
60
+ posthog_logger.disabled = True
61
+
62
+ if self._posthog_client is None:
63
+ self.logger.debug("Telemetry disabled")
64
+
65
+ def capture(self, event: BaseTelemetryEvent) -> None:
66
+ """
67
+ Safe event tracking that never affects library functionality
68
+
69
+ Args:
70
+ event: BaseTelemetryEvent instance to capture
71
+ """
72
+ if self._posthog_client is None:
73
+ return
74
+
75
+ self._direct_capture(event)
76
+
77
+ def _direct_capture(self, event: BaseTelemetryEvent) -> None:
78
+ """
79
+ Direct capture method - PostHog handles threading internally
80
+ Should not be thread blocking because posthog magically handles it
81
+ """
82
+ if self._posthog_client is None:
83
+ return
84
+
85
+ try:
86
+ self._posthog_client.capture(
87
+ distinct_id=self.user_id,
88
+ event=event.name,
89
+ properties={
90
+ "library": "notionary",
91
+ **event.properties,
92
+ **POSTHOG_EVENT_SETTINGS,
93
+ },
94
+ )
95
+
96
+ except Exception as e:
97
+ self.logger.error(f"Failed to send telemetry event {event.name}: {e}")
98
+
99
+ def flush(self) -> None:
100
+ """
101
+ Flush pending events - simplified without threading complexity
102
+ """
103
+ if not self._posthog_client:
104
+ self.logger.debug("PostHog client not available, skipping flush.")
105
+ return
106
+
107
+ try:
108
+ self._posthog_client.flush()
109
+ self.logger.debug("PostHog client telemetry queue flushed.")
110
+ except Exception as e:
111
+ self.logger.error(f"Failed to flush PostHog client: {e}")
112
+
113
+ @property
114
+ def user_id(self) -> str:
115
+ """Anonymous, persistent user ID"""
116
+ if self._curr_user_id:
117
+ return self._curr_user_id
118
+
119
+ # File access may fail due to permissions or other reasons.
120
+ # We don't want to crash so we catch all exceptions.
121
+ try:
122
+ if not os.path.exists(self.USER_ID_PATH):
123
+ os.makedirs(os.path.dirname(self.USER_ID_PATH), exist_ok=True)
124
+ with open(self.USER_ID_PATH, "w") as f:
125
+ new_user_id = str(uuid.uuid4())
126
+ f.write(new_user_id)
127
+ self._curr_user_id = new_user_id
128
+ else:
129
+ with open(self.USER_ID_PATH, "r") as f:
130
+ self._curr_user_id = f.read().strip()
131
+
132
+ return self._curr_user_id
133
+ except Exception as e:
134
+ self.logger.debug(f"Error getting user ID: {e}")
135
+ self._curr_user_id = self.UNKNOWN_USER_ID
136
+ return self._curr_user_id
@@ -0,0 +1,64 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import asdict, dataclass
3
+ from typing import Any, Optional
4
+
5
+
6
+ @dataclass
7
+ class BaseTelemetryEvent(ABC):
8
+ @property
9
+ @abstractmethod
10
+ def name(self) -> str:
11
+ pass
12
+
13
+ @property
14
+ def properties(self) -> dict[str, Any]:
15
+ return {k: v for k, v in asdict(self).items() if k != "name"}
16
+
17
+
18
+ @dataclass
19
+ class DatabaseFactoryUsedEvent(BaseTelemetryEvent):
20
+ """Event fired when a database factory method is used"""
21
+
22
+ factory_method: str
23
+
24
+ @property
25
+ def name(self) -> str:
26
+ return "database_factory_used"
27
+
28
+ @dataclass
29
+ class QueryOperationEvent(BaseTelemetryEvent):
30
+ """Event fired when a query operation is performed"""
31
+
32
+ query_type: str
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "query_operation"
37
+
38
+ @dataclass
39
+ class NotionMarkdownSyntaxPromptEvent(BaseTelemetryEvent):
40
+ """Event fired when Notion Markdown syntax is used"""
41
+
42
+ @property
43
+ def name(self) -> str:
44
+ return "notion_markdown_syntax_used"
45
+
46
+ # Tracks markdown conversion
47
+ @dataclass
48
+ class MarkdownToNotionConversionEvent(BaseTelemetryEvent):
49
+ """Event fired when markdown is converted to Notion blocks"""
50
+ handler_element_name: Optional[str] = None # e.g. "HeadingElement", "ParagraphElement"
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ return "markdown_to_notion_conversion"
55
+
56
+
57
+ @dataclass
58
+ class NotionToMarkdownConversionEvent(BaseTelemetryEvent):
59
+ """Event fired when Notion blocks are converted to markdown"""
60
+ handler_element_name: Optional[str] = None # e.g. "HeadingElement", "ParagraphElement"
61
+
62
+ @property
63
+ def name(self) -> str:
64
+ return "notion_to_markdown_conversion"
@@ -3,6 +3,7 @@ from .singleton_decorator import singleton
3
3
  from .page_id_utils import format_uuid
4
4
  from .fuzzy_matcher import FuzzyMatcher
5
5
  from .factory_decorator import factory_only
6
+ from .singleton_metaclass import SingletonMetaClass
6
7
 
7
8
  __all__ = [
8
9
  "LoggingMixin",
@@ -11,4 +12,5 @@ __all__ = [
11
12
  "FuzzyMatcher",
12
13
  "factory_only",
13
14
  "singleton",
15
+ "SingletonMetaClass",
14
16
  ]