notionary 0.2.10__py3-none-any.whl → 0.2.11__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 (56) hide show
  1. notionary/__init__.py +6 -0
  2. notionary/database/database_discovery.py +1 -1
  3. notionary/database/notion_database.py +5 -4
  4. notionary/database/notion_database_factory.py +10 -5
  5. notionary/elements/audio_element.py +2 -2
  6. notionary/elements/bookmark_element.py +2 -2
  7. notionary/elements/bulleted_list_element.py +2 -2
  8. notionary/elements/callout_element.py +2 -2
  9. notionary/elements/code_block_element.py +2 -2
  10. notionary/elements/column_element.py +51 -44
  11. notionary/elements/divider_element.py +2 -2
  12. notionary/elements/embed_element.py +2 -2
  13. notionary/elements/heading_element.py +3 -3
  14. notionary/elements/image_element.py +2 -2
  15. notionary/elements/mention_element.py +2 -2
  16. notionary/elements/notion_block_element.py +36 -0
  17. notionary/elements/numbered_list_element.py +2 -2
  18. notionary/elements/paragraph_element.py +2 -2
  19. notionary/elements/qoute_element.py +2 -2
  20. notionary/elements/table_element.py +2 -2
  21. notionary/elements/text_inline_formatter.py +23 -1
  22. notionary/elements/todo_element.py +2 -2
  23. notionary/elements/toggle_element.py +2 -2
  24. notionary/elements/toggleable_heading_element.py +2 -2
  25. notionary/elements/video_element.py +2 -2
  26. notionary/notion_client.py +1 -1
  27. notionary/page/content/notion_page_content_chunker.py +1 -1
  28. notionary/page/content/page_content_retriever.py +1 -1
  29. notionary/page/content/page_content_writer.py +3 -3
  30. notionary/page/{markdown_to_notion_converter.py → formatting/markdown_to_notion_converter.py} +44 -140
  31. notionary/page/formatting/spacer_rules.py +483 -0
  32. notionary/page/metadata/metadata_editor.py +1 -1
  33. notionary/page/metadata/notion_icon_manager.py +1 -1
  34. notionary/page/metadata/notion_page_cover_manager.py +1 -1
  35. notionary/page/notion_page.py +1 -1
  36. notionary/page/notion_page_factory.py +161 -22
  37. notionary/page/properites/database_property_service.py +1 -1
  38. notionary/page/properites/page_property_manager.py +1 -1
  39. notionary/page/properites/property_formatter.py +1 -1
  40. notionary/page/properites/property_value_extractor.py +1 -1
  41. notionary/page/relations/notion_page_relation_manager.py +1 -1
  42. notionary/page/relations/notion_page_title_resolver.py +1 -1
  43. notionary/page/relations/page_database_relation.py +1 -1
  44. notionary/prompting/element_prompt_content.py +1 -0
  45. notionary/telemetry/__init__.py +7 -0
  46. notionary/telemetry/telemetry.py +226 -0
  47. notionary/telemetry/track_usage_decorator.py +76 -0
  48. notionary/util/__init__.py +5 -0
  49. notionary/util/logging_mixin.py +3 -0
  50. notionary/util/singleton.py +18 -0
  51. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/METADATA +2 -1
  52. notionary-0.2.11.dist-info/RECORD +67 -0
  53. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/WHEEL +1 -1
  54. notionary-0.2.10.dist-info/RECORD +0 -61
  55. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/licenses/LICENSE +0 -0
  56. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/top_level.txt +0 -0
@@ -2,10 +2,12 @@ from typing import List, Optional, Dict, Any, Tuple
2
2
  from difflib import SequenceMatcher
3
3
 
4
4
  from notionary import NotionPage, NotionClient
5
- from notionary.util.logging_mixin import LoggingMixin
6
- from notionary.util.page_id_utils import format_uuid, extract_and_validate_page_id
7
-
5
+ from notionary.telemetry import track_usage
6
+ from notionary.util import LoggingMixin
7
+ from notionary.util import format_uuid, extract_and_validate_page_id
8
+ from notionary.util import singleton
8
9
 
10
+ @singleton
9
11
  class NotionPageFactory(LoggingMixin):
10
12
  """
11
13
  Factory class for creating NotionPage instances.
@@ -14,10 +16,14 @@ class NotionPageFactory(LoggingMixin):
14
16
 
15
17
  MATCH_THRESHOLD = 0.6
16
18
  MAX_SUGGESTIONS = 5
19
+ PAGE_SIZE = 100
20
+ EARLY_STOP_THRESHOLD = 0.95
17
21
 
18
22
  @classmethod
23
+ @track_usage('page_factory_method_used', {'method': 'from_page_id'})
19
24
  def from_page_id(cls, page_id: str, token: Optional[str] = None) -> NotionPage:
20
25
  """Create a NotionPage from a page ID."""
26
+
21
27
  try:
22
28
  formatted_id = format_uuid(page_id) or page_id
23
29
  page = NotionPage(page_id=formatted_id, token=token)
@@ -30,6 +36,7 @@ class NotionPageFactory(LoggingMixin):
30
36
  raise
31
37
 
32
38
  @classmethod
39
+ @track_usage('page_factory_method_used', {'method': 'from_url'})
33
40
  def from_url(cls, url: str, token: Optional[str] = None) -> NotionPage:
34
41
  """Create a NotionPage from a Notion URL."""
35
42
 
@@ -49,6 +56,7 @@ class NotionPageFactory(LoggingMixin):
49
56
  raise
50
57
 
51
58
  @classmethod
59
+ @track_usage('page_factory_method_used', {'method': 'from_page_name'})
52
60
  async def from_page_name(
53
61
  cls, page_name: str, token: Optional[str] = None
54
62
  ) -> NotionPage:
@@ -56,20 +64,16 @@ class NotionPageFactory(LoggingMixin):
56
64
  cls.logger.debug("Searching for page with name: %s", page_name)
57
65
 
58
66
  client = NotionClient(token=token)
59
-
67
+
60
68
  try:
61
- # Fetch pages
62
- pages = await cls._search_pages(client)
63
- if not pages:
64
- cls.logger.warning("No pages found matching '%s'", page_name)
65
- raise ValueError(f"No pages found matching '{page_name}'")
66
-
67
- # Find best match
68
- best_match, best_score, suggestions = cls._find_best_match(pages, page_name)
69
+ # Search with pagination and early stopping
70
+ best_match, best_score, all_suggestions = (
71
+ await cls._search_pages_with_matching(client, page_name)
72
+ )
69
73
 
70
74
  # Check if match is good enough
71
75
  if best_score < cls.MATCH_THRESHOLD or not best_match:
72
- suggestion_msg = cls._format_suggestions(suggestions)
76
+ suggestion_msg = cls._format_suggestions(all_suggestions)
73
77
  cls.logger.warning(
74
78
  "No good match found for '%s'. Best score: %.2f",
75
79
  page_name,
@@ -105,22 +109,157 @@ class NotionPageFactory(LoggingMixin):
105
109
  raise
106
110
 
107
111
  @classmethod
108
- async def _search_pages(cls, client: NotionClient) -> List[Dict[str, Any]]:
109
- """Search for pages using the Notion API."""
110
- cls.logger.debug("Using search endpoint to find pages")
112
+ async def _search_pages_with_matching(
113
+ cls, client: NotionClient, query: str
114
+ ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
115
+ """
116
+ Search for pages with pagination and find the best match.
117
+ Includes early stopping for performance optimization.
118
+ """
119
+ cls.logger.debug("Starting paginated search for query: %s", query)
120
+
121
+ best_match = None
122
+ best_score = 0
123
+ all_suggestions = []
124
+ page_count = 0
125
+
126
+ # Track suggestions across all pages
127
+ all_matches = []
128
+
129
+ next_cursor = None
130
+
131
+ while True:
132
+ # Fetch current page batch
133
+ pages_batch = await cls._fetch_pages_batch(client, next_cursor)
134
+
135
+ if not pages_batch:
136
+ cls.logger.debug("No more pages to fetch")
137
+ break
138
+
139
+ pages = pages_batch.get("results", [])
140
+ page_count += len(pages)
141
+ cls.logger.debug(
142
+ "Processing batch of %d pages (total processed: %d)",
143
+ len(pages),
144
+ page_count,
145
+ )
146
+
147
+ # Process current batch
148
+ batch_match, batch_score, batch_suggestions = cls._find_best_match_in_batch(
149
+ pages, query, best_score
150
+ )
151
+
152
+ # Update global best if we found a better match
153
+ if batch_score > best_score:
154
+ best_score = batch_score
155
+ best_match = batch_match
156
+ cls.logger.debug("New best match found with score: %.2f", best_score)
157
+
158
+ # Collect all matches for suggestions
159
+ for page in pages:
160
+ title = cls._extract_title_from_page(page)
161
+ score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
162
+ all_matches.append((title, score))
163
+
164
+ # Early stopping: if we found a very good match, stop searching
165
+ if best_score >= cls.EARLY_STOP_THRESHOLD:
166
+ cls.logger.info(
167
+ "Early stopping: found excellent match with score %.2f", best_score
168
+ )
169
+ break
170
+
171
+ # Check for next page
172
+ next_cursor = pages_batch.get("next_cursor")
173
+ if not next_cursor:
174
+ cls.logger.debug("Reached end of pages")
175
+ break
176
+
177
+ # Generate final suggestions from all matches
178
+ all_matches.sort(key=lambda x: x[1], reverse=True)
179
+ all_suggestions = [title for title, _ in all_matches[: cls.MAX_SUGGESTIONS]]
111
180
 
181
+ cls.logger.info(
182
+ "Search completed. Processed %d pages. Best score: %.2f",
183
+ page_count,
184
+ best_score,
185
+ )
186
+
187
+ return best_match, best_score, all_suggestions
188
+
189
+ @classmethod
190
+ async def _fetch_pages_batch(
191
+ cls, client: NotionClient, next_cursor: Optional[str] = None
192
+ ) -> Dict[str, Any]:
193
+ """Fetch a single batch of pages from the Notion API."""
112
194
  search_payload = {
113
195
  "filter": {"property": "object", "value": "page"},
114
- "page_size": 100,
196
+ "page_size": cls.PAGE_SIZE,
115
197
  }
116
198
 
117
- response = await client.post("search", search_payload)
199
+ if next_cursor:
200
+ search_payload["start_cursor"] = next_cursor
201
+
202
+ try:
203
+ response = await client.post("search", search_payload)
204
+
205
+ if not response:
206
+ cls.logger.error("Empty response from search endpoint")
207
+ return {}
118
208
 
119
- if not response or "results" not in response:
120
- cls.logger.error("Failed to fetch pages using search endpoint")
121
- raise ValueError("Failed to fetch pages using search endpoint")
209
+ return response
122
210
 
123
- return response.get("results", [])
211
+ except Exception as e:
212
+ cls.logger.error("Error fetching pages batch: %s", str(e))
213
+ raise
214
+
215
+ @classmethod
216
+ def _find_best_match_in_batch(
217
+ cls, pages: List[Dict[str, Any]], query: str, current_best_score: float
218
+ ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
219
+ """Find the best matching page in a single batch."""
220
+ batch_best_match = None
221
+ batch_best_score = current_best_score
222
+
223
+ for page in pages:
224
+ title = cls._extract_title_from_page(page)
225
+ score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
226
+
227
+ if score > batch_best_score:
228
+ batch_best_score = score
229
+ batch_best_match = page
230
+
231
+ # Get batch suggestions (not used in the main algorithm but kept for compatibility)
232
+ batch_suggestions = []
233
+
234
+ return batch_best_match, batch_best_score, batch_suggestions
235
+
236
+ @classmethod
237
+ async def _search_pages(cls, client: NotionClient) -> List[Dict[str, Any]]:
238
+ """
239
+ Legacy method - kept for backward compatibility.
240
+ Now uses the paginated approach internally.
241
+ """
242
+ cls.logger.warning(
243
+ "_search_pages is deprecated. Use _search_pages_with_matching instead."
244
+ )
245
+
246
+ all_pages = []
247
+ next_cursor = None
248
+
249
+ while True:
250
+ batch = await cls._fetch_pages_batch(client, next_cursor)
251
+ if not batch:
252
+ break
253
+
254
+ pages = batch.get("results", [])
255
+ all_pages.extend(pages)
256
+
257
+ next_cursor = batch.get("next_cursor")
258
+ if not next_cursor:
259
+ break
260
+
261
+ cls.logger.info("Loaded %d total pages", len(all_pages))
262
+ return all_pages
124
263
 
125
264
  @classmethod
126
265
  def _find_best_match(
@@ -1,6 +1,6 @@
1
1
  from typing import Dict, List, Optional, Any, Tuple
2
2
  from notionary.notion_client import NotionClient
3
- from notionary.util.logging_mixin import LoggingMixin
3
+ from notionary.util import LoggingMixin
4
4
 
5
5
 
6
6
  class DatabasePropertyService(LoggingMixin):
@@ -9,7 +9,7 @@ from notionary.page.relations.page_database_relation import PageDatabaseRelation
9
9
  from notionary.page.properites.property_value_extractor import (
10
10
  PropertyValueExtractor,
11
11
  )
12
- from notionary.util.logging_mixin import LoggingMixin
12
+ from notionary.util import LoggingMixin
13
13
 
14
14
 
15
15
  class PagePropertyManager(LoggingMixin):
@@ -1,6 +1,6 @@
1
1
  from typing import Any, Dict, Optional
2
2
 
3
- from notionary.util.logging_mixin import LoggingMixin
3
+ from notionary.util import LoggingMixin
4
4
 
5
5
 
6
6
  class NotionPropertyFormatter(LoggingMixin):
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  from typing import Any, Awaitable, Callable
3
3
 
4
- from notionary.util.logging_mixin import LoggingMixin
4
+ from notionary.util import LoggingMixin
5
5
 
6
6
 
7
7
  class PropertyValueExtractor(LoggingMixin):
@@ -5,7 +5,7 @@ from notionary.notion_client import NotionClient
5
5
  from notionary.page.relations.notion_page_title_resolver import (
6
6
  NotionPageTitleResolver,
7
7
  )
8
- from notionary.util.logging_mixin import LoggingMixin
8
+ from notionary.util import LoggingMixin
9
9
 
10
10
 
11
11
  class NotionPageRelationManager(LoggingMixin):
@@ -1,6 +1,6 @@
1
1
  from typing import Optional, Dict, Any, List
2
2
  from notionary.notion_client import NotionClient
3
- from notionary.util.logging_mixin import LoggingMixin
3
+ from notionary.util import LoggingMixin
4
4
 
5
5
 
6
6
  class NotionPageTitleResolver(LoggingMixin):
@@ -1,7 +1,7 @@
1
1
  from typing import Dict, Optional, Any
2
2
  from notionary.models.notion_page_response import DatabaseParent, NotionPageResponse
3
3
  from notionary.notion_client import NotionClient
4
- from notionary.util.logging_mixin import LoggingMixin
4
+ from notionary.util import LoggingMixin
5
5
 
6
6
 
7
7
  class PageDatabaseRelation(LoggingMixin):
@@ -40,6 +40,7 @@ class ElementPromptContent:
40
40
  if not self.when_to_use:
41
41
  raise ValueError("Usage guidelines are required")
42
42
 
43
+
43
44
  class ElementPromptBuilder:
44
45
  """
45
46
  Builder class for creating ElementPromptContent with a fluent interface.
@@ -0,0 +1,7 @@
1
+ from .telemetry import NotionaryTelemetry
2
+ from .track_usage_decorator import track_usage
3
+
4
+ __all__ = [
5
+ "NotionaryTelemetry",
6
+ "track_usage",
7
+ ]
@@ -0,0 +1,226 @@
1
+ import os
2
+ import uuid
3
+ import atexit
4
+ import signal
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Dict, Any, Optional
8
+ from posthog import Posthog
9
+ from dotenv import load_dotenv
10
+
11
+ from notionary.util import LoggingMixin
12
+ from notionary.util import singleton
13
+
14
+ load_dotenv()
15
+
16
+ @singleton
17
+ class NotionaryTelemetry(LoggingMixin):
18
+ """
19
+ Anonymous telemetry for Notionary - enabled by default.
20
+ Disable via: ANONYMIZED_TELEMETRY=false
21
+ """
22
+
23
+ USER_ID_PATH = str(Path.home() / ".cache" / "notionary" / "telemetry_user_id")
24
+ PROJECT_API_KEY = (
25
+ "phc_gItKOx21Tc0l07C1taD0QPpqFnbWgWjVfRjF6z24kke" # write-only so no worries
26
+ )
27
+ HOST = "https://eu.i.posthog.com"
28
+
29
+ _logged_init_message = False
30
+
31
+ def __init__(self):
32
+ # Default: enabled, disable via ANONYMIZED_TELEMETRY=false
33
+ telemetry_setting = os.getenv("ANONYMIZED_TELEMETRY", "true").lower()
34
+ self.enabled = telemetry_setting != "false"
35
+
36
+ self._user_id = None
37
+ self._client = None
38
+ self._shutdown_lock = threading.Lock()
39
+ self._is_shutdown = False
40
+ self._shutdown_registered = False
41
+
42
+ if self.enabled:
43
+ self._initialize_client()
44
+ self._register_shutdown_handlers()
45
+
46
+ def _register_shutdown_handlers(self):
47
+ """Register shutdown handlers for clean exit"""
48
+ with self._shutdown_lock:
49
+ if self._shutdown_registered:
50
+ return
51
+
52
+ try:
53
+ # Register atexit handler for normal program termination
54
+ atexit.register(self._atexit_handler)
55
+
56
+ # Register signal handlers for SIGINT (Ctrl+C) and SIGTERM
57
+ signal.signal(signal.SIGINT, self._signal_handler)
58
+ signal.signal(signal.SIGTERM, self._signal_handler)
59
+
60
+ self._shutdown_registered = True
61
+ self.logger.debug("Telemetry shutdown handlers registered")
62
+
63
+ except Exception as e:
64
+ self.logger.debug(f"Failed to register shutdown handlers: {e}")
65
+
66
+ def _signal_handler(self, signum, frame):
67
+ """Handle SIGINT (Ctrl+C) and SIGTERM signals"""
68
+ signal_name = "SIGINT" if signum == signal.SIGINT else f"SIG{signum}"
69
+ self.logger.debug(f"Received {signal_name}, shutting down telemetry...")
70
+
71
+ self.shutdown(timeout=5.0) # Quick shutdown for signals
72
+
73
+ # Let the original signal handler take over (or exit)
74
+ if signum == signal.SIGINT:
75
+ # Restore default handler and re-raise
76
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
77
+ os.kill(os.getpid(), signal.SIGINT)
78
+
79
+ def _atexit_handler(self):
80
+ """Handle normal program exit"""
81
+ self.logger.debug("Normal program exit, shutting down telemetry...")
82
+ self.shutdown(timeout=10.0)
83
+
84
+ @property
85
+ def user_id(self) -> str:
86
+ """Anonymous, persistent user ID"""
87
+ if self._user_id:
88
+ return self._user_id
89
+
90
+ try:
91
+ if not os.path.exists(self.USER_ID_PATH):
92
+ os.makedirs(os.path.dirname(self.USER_ID_PATH), exist_ok=True)
93
+ with open(self.USER_ID_PATH, "w") as f:
94
+ new_user_id = str(uuid.uuid4())
95
+ f.write(new_user_id)
96
+ self._user_id = new_user_id
97
+ else:
98
+ with open(self.USER_ID_PATH, "r") as f:
99
+ self._user_id = f.read().strip()
100
+
101
+ return self._user_id
102
+ except Exception as e:
103
+ self.logger.debug(f"Error getting user ID: {e}")
104
+ return "anonymous_user"
105
+
106
+ def capture(self, event_name: str, properties: Optional[Dict[str, Any]] = None):
107
+ """
108
+ Safe event tracking that never affects library functionality
109
+
110
+ Args:
111
+ event_name: Event name (e.g. 'page_factory_used')
112
+ properties: Event properties as dictionary
113
+ """
114
+ if not self.enabled or not self._client or self._is_shutdown:
115
+ return
116
+
117
+ try:
118
+ # Add base properties
119
+ event_properties = {
120
+ "library": "notionary",
121
+ "library_version": self._get_notionary_version(),
122
+ **(properties or {}),
123
+ }
124
+
125
+ self._client.capture(
126
+ distinct_id=self.user_id, event=event_name, properties=event_properties
127
+ )
128
+
129
+ except Exception:
130
+ pass
131
+
132
+ def flush(self, timeout: float = 5.0):
133
+ """
134
+ Flush events with timeout
135
+
136
+ Args:
137
+ timeout: Maximum time to wait for flush to complete
138
+ """
139
+ if not self.enabled or not self._client or self._is_shutdown:
140
+ return
141
+
142
+ try:
143
+ # PostHog flush doesn't support timeout directly, so we do it in a thread
144
+ flush_thread = threading.Thread(target=self._client.flush)
145
+ flush_thread.daemon = True
146
+ flush_thread.start()
147
+ flush_thread.join(timeout=timeout)
148
+
149
+ if flush_thread.is_alive():
150
+ self.logger.warning(f"Telemetry flush timed out after {timeout}s")
151
+ else:
152
+ self.logger.debug("Telemetry events flushed successfully")
153
+
154
+ except Exception as e:
155
+ self.logger.debug(f"Error during telemetry flush: {e}")
156
+
157
+ def shutdown(self, timeout: float = 10.0):
158
+ """
159
+ Clean shutdown of telemetry with timeout
160
+
161
+ Args:
162
+ timeout: Maximum time to wait for shutdown
163
+ """
164
+ with self._shutdown_lock:
165
+ if self._is_shutdown:
166
+ return
167
+
168
+ self._is_shutdown = True
169
+
170
+ try:
171
+ if self._client:
172
+ # First try to flush remaining events
173
+ self.logger.debug("Flushing telemetry events before shutdown...")
174
+ self.flush(timeout=timeout * 0.7) # Use 70% of timeout for flush
175
+
176
+ # Then shutdown the client
177
+ shutdown_thread = threading.Thread(target=self._client.shutdown)
178
+ shutdown_thread.daemon = True
179
+ shutdown_thread.start()
180
+ shutdown_thread.join(timeout=timeout * 0.3) # Use 30% for shutdown
181
+
182
+ if shutdown_thread.is_alive():
183
+ self.logger.warning(f"Telemetry client shutdown timed out after {timeout}s")
184
+ else:
185
+ self.logger.debug("Telemetry client shut down successfully")
186
+
187
+ except Exception as e:
188
+ self.logger.debug(f"Error during telemetry shutdown: {e}")
189
+ finally:
190
+ self._client = None
191
+
192
+ def _initialize_client(self):
193
+ """Initializes PostHog client and shows startup message"""
194
+ try:
195
+ self._client = Posthog(
196
+ project_api_key=self.PROJECT_API_KEY,
197
+ host=self.HOST,
198
+ disable_geoip=True,
199
+ )
200
+ if not self._logged_init_message:
201
+ self.logger.info(
202
+ "Anonymous telemetry enabled to improve Notionary. "
203
+ "To disable: export ANONYMIZED_TELEMETRY=false"
204
+ )
205
+ self._logged_init_message = True
206
+
207
+ self._track_initialization()
208
+
209
+ except Exception as e:
210
+ self.logger.debug(f"Telemetry initialization failed: {e}")
211
+ self.enabled = False
212
+ self._client = None
213
+
214
+ def _track_initialization(self):
215
+ """Tracks library initialization"""
216
+ self.capture(
217
+ "notionary_initialized",
218
+ {
219
+ "version": self._get_notionary_version(),
220
+ },
221
+ )
222
+
223
+ def _get_notionary_version(self) -> str:
224
+ """Determines the Notionary version"""
225
+ import notionary
226
+ return getattr(notionary, "__version__", "0.2.10")
@@ -0,0 +1,76 @@
1
+ from functools import wraps
2
+ from typing import Any, Callable, Dict, Optional
3
+ from notionary.telemetry import NotionaryTelemetry
4
+
5
+
6
+ def track_usage(event_name: Optional[str] = None, properties: Optional[Dict[str, Any]] = None):
7
+ """
8
+ Simple decorator to track function usage.
9
+
10
+ Args:
11
+ event_name: Custom event name (defaults to function name)
12
+ properties: Additional properties to track
13
+
14
+ Usage:
15
+ @track_usage()
16
+ def my_function():
17
+ pass
18
+
19
+ @track_usage('custom_event_name')
20
+ def my_function():
21
+ pass
22
+
23
+ @track_usage('custom_event', {'feature': 'advanced'})
24
+ def my_function():
25
+ pass
26
+ """
27
+ def decorator(func: Callable) -> Callable:
28
+ @wraps(func)
29
+ def wrapper(*args, **kwargs):
30
+ telemetry = NotionaryTelemetry()
31
+
32
+ # Generate event name and properties
33
+ event = event_name or _generate_event_name(func, args)
34
+ event_properties = _build_properties(func, args, properties)
35
+
36
+ # Track and execute
37
+ telemetry.capture(event, event_properties)
38
+ return func(*args, **kwargs)
39
+
40
+ return wrapper
41
+ return decorator
42
+
43
+
44
+ def _get_class_name(func: Callable, args: tuple) -> Optional[str]:
45
+ """Extract class name from function or arguments."""
46
+ if args and hasattr(args[0], '__class__'):
47
+ return args[0].__class__.__name__
48
+
49
+ if hasattr(func, '__qualname__') and '.' in func.__qualname__:
50
+ return func.__qualname__.split('.')[0]
51
+
52
+ return None
53
+
54
+
55
+ def _generate_event_name(func: Callable, args: tuple) -> str:
56
+ """Generate event name from function and class info."""
57
+ class_name = _get_class_name(func, args)
58
+
59
+ if class_name:
60
+ return f"{class_name.lower()}_{func.__name__}_used"
61
+
62
+ return f"{func.__name__}_used"
63
+
64
+
65
+ def _build_properties(func: Callable, args: tuple, properties: Optional[Dict[str, Any]]) -> Dict[str, Any]:
66
+ """Build event properties with function and class info."""
67
+ event_properties = {
68
+ 'function_name': func.__name__,
69
+ **(properties or {})
70
+ }
71
+
72
+ class_name = _get_class_name(func, args)
73
+ if class_name:
74
+ event_properties['class_name'] = class_name
75
+
76
+ return event_properties
@@ -0,0 +1,5 @@
1
+ from .logging_mixin import LoggingMixin
2
+ from .singleton import singleton
3
+ from .page_id_utils import format_uuid, extract_and_validate_page_id
4
+
5
+ __all__ = ["LoggingMixin", "singleton", "format_uuid", "extract_and_validate_page_id"]
@@ -10,6 +10,9 @@ def setup_logging():
10
10
  )
11
11
 
12
12
 
13
+ setup_logging()
14
+
15
+
13
16
  class LoggingMixin:
14
17
  # Class attribute with proper typing
15
18
  logger: ClassVar[logging.Logger] = None
@@ -0,0 +1,18 @@
1
+ def singleton(cls):
2
+ """
3
+ Simple singleton decorator that ensures only one instance of a class exists.
4
+
5
+ Usage:
6
+ @singleton
7
+ class MyClass:
8
+ pass
9
+ """
10
+ instances = {}
11
+
12
+ def __new__(cls, *args, **kwargs):
13
+ if cls not in instances:
14
+ instances[cls] = super(cls, cls).__new__(cls)
15
+ return instances[cls]
16
+
17
+ cls.__new__ = __new__
18
+ return cls
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notionary
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: A toolkit to convert between Markdown and Notion blocks
5
5
  Home-page: https://github.com/mathisarends/notionary
6
6
  Author: Mathis Arends
@@ -13,6 +13,7 @@ License-File: LICENSE
13
13
  Requires-Dist: httpx>=0.28.0
14
14
  Requires-Dist: python-dotenv>=1.1.0
15
15
  Requires-Dist: pydantic>=2.11.4
16
+ Requires-Dist: posthog>=3.0.0
16
17
  Dynamic: author
17
18
  Dynamic: author-email
18
19
  Dynamic: classifier