raindrop-ai 0.0.19__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: raindrop-ai
3
+ Version: 0.0.19
4
+ Summary: Raindrop AI (Python SDK)
5
+ Home-page: https://raindrop.ai
6
+ Author: Raindrop AI
7
+ Author-email: sdk@raindrop.ai
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.6
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: requires-dist
26
+ Dynamic: summary
27
+
28
+ For questions, email us at sdk@raindrop.ai
@@ -0,0 +1,3 @@
1
+ # Python SDK for dawnai.com
2
+
3
+ to run tests: python3 -m unittest discover tests
File without changes
@@ -0,0 +1,283 @@
1
+ import sys
2
+ import time
3
+ import threading
4
+ from typing import Union, List, Dict, Optional, Literal
5
+ import requests
6
+ from datetime import datetime, timezone
7
+ import logging
8
+ import json
9
+ import uuid
10
+ import atexit
11
+ from raindrop.version import VERSION
12
+
13
+
14
+ # Configure logging
15
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
16
+ logger = logging.getLogger(__name__)
17
+
18
+ write_key = None
19
+ api_url = "https://api.raindrop.ai/v1/"
20
+ max_queue_size = 10000
21
+ upload_size = 10
22
+ upload_interval = 1.0
23
+ buffer = []
24
+ flush_lock = threading.Lock()
25
+ debug_logs = False
26
+ flush_thread = None
27
+ shutdown_event = threading.Event()
28
+ max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
29
+
30
+ def set_debug_logs(value: bool):
31
+ global debug_logs
32
+ debug_logs = value
33
+ if debug_logs:
34
+ logger.setLevel(logging.DEBUG)
35
+ else:
36
+ logger.setLevel(logging.INFO)
37
+
38
+ def start_flush_thread():
39
+ logger.debug("Opening flush thread")
40
+ global flush_thread
41
+ if flush_thread is None:
42
+ flush_thread = threading.Thread(target=flush_loop)
43
+ flush_thread.daemon = True
44
+ flush_thread.start()
45
+
46
+ def flush_loop():
47
+ while not shutdown_event.is_set():
48
+ try:
49
+ flush()
50
+ except Exception as e:
51
+ logger.error(f"Error in flush loop: {e}")
52
+ time.sleep(upload_interval)
53
+
54
+ def flush() -> None:
55
+ global buffer
56
+
57
+ if buffer is None:
58
+ logger.error("No buffer available")
59
+ return
60
+
61
+ logger.debug("Starting flush")
62
+
63
+ with flush_lock:
64
+ current_buffer = buffer
65
+ buffer = []
66
+
67
+ logger.debug(f"Flushing buffer size: {len(current_buffer)}")
68
+
69
+ grouped_events = {}
70
+ for event in current_buffer:
71
+ endpoint = event["type"]
72
+ data = event["data"]
73
+ if endpoint not in grouped_events:
74
+ grouped_events[endpoint] = []
75
+ grouped_events[endpoint].append(data)
76
+
77
+ for endpoint, events_data in grouped_events.items():
78
+ for i in range(0, len(events_data), upload_size):
79
+ batch = events_data[i:i+upload_size]
80
+ logger.debug(f"Sending {len(batch)} events to {endpoint}")
81
+ send_request(endpoint, batch)
82
+
83
+ logger.debug("Flush complete")
84
+
85
+ def send_request(endpoint: str, data_entries: List[Dict[str, Union[str, Dict]]]) -> None:
86
+
87
+ url = f"{api_url}{endpoint}"
88
+ headers = {
89
+ "Content-Type": "application/json",
90
+ "Authorization": f"Bearer {write_key}",
91
+ }
92
+
93
+ max_retries = 3
94
+ for attempt in range(max_retries):
95
+ try:
96
+ response = requests.post(url, json=data_entries, headers=headers)
97
+ response.raise_for_status()
98
+ logger.debug(f"Request successful: {response.status_code}")
99
+ break
100
+ except requests.exceptions.RequestException as e:
101
+ logger.error(f"Error sending request (attempt {attempt + 1}/{max_retries}): {e}")
102
+ if attempt == max_retries - 1:
103
+ logger.error(f"Failed to send request after {max_retries} attempts")
104
+
105
+ def save_to_buffer(event: Dict[str, Union[str, Dict]]) -> None:
106
+ global buffer
107
+
108
+ if len(buffer) >= max_queue_size * 0.8:
109
+ logger.warning(f"Buffer is at {len(buffer) / max_queue_size * 100:.2f}% capacity")
110
+
111
+ if len(buffer) >= max_queue_size:
112
+ logger.error("Buffer is full. Discarding event.")
113
+ return
114
+
115
+ logger.debug(f"Adding event to buffer: {event}")
116
+
117
+ with flush_lock:
118
+ buffer.append(event)
119
+
120
+ start_flush_thread()
121
+
122
+ def identify(user_id: str, traits: Dict[str, Union[str, int, bool, float]]) -> None:
123
+ if not _check_write_key():
124
+ return
125
+ data = {"user_id": user_id, "traits": traits}
126
+ save_to_buffer({"type": "users/identify", "data": data})
127
+
128
+ def track(
129
+ user_id: str,
130
+ event: str,
131
+ properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
132
+ timestamp: Optional[str] = None,
133
+ ) -> None:
134
+ if not _check_write_key():
135
+ return
136
+
137
+ data = {
138
+ "event_id": str(uuid.uuid4()),
139
+ "user_id": user_id,
140
+ "event": event,
141
+ "properties": properties,
142
+ "timestamp": timestamp if timestamp else _get_timestamp(),
143
+ }
144
+ data.setdefault("properties", {})["$context"] = _get_context()
145
+
146
+ save_to_buffer({"type": "events/track", "data": data})
147
+
148
+ def track_ai(
149
+ user_id: str,
150
+ event: str,
151
+ model: Optional[str] = None,
152
+ user_input: Optional[str] = None,
153
+ output: Optional[str] = None,
154
+ convo_id: Optional[str] = None,
155
+ properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
156
+ timestamp: Optional[str] = None,
157
+ ) -> None:
158
+ if not _check_write_key():
159
+ return
160
+
161
+ if not user_input and not output:
162
+ raise ValueError("One of user_input or output must be provided and not empty.")
163
+
164
+ event_id = str(uuid.uuid4())
165
+
166
+ data = {
167
+ "event_id": event_id,
168
+ "user_id": user_id,
169
+ "event": event,
170
+ "properties": properties or {},
171
+ "timestamp": timestamp if timestamp else _get_timestamp(),
172
+ "ai_data": {
173
+ "model": model,
174
+ "input": user_input,
175
+ "output": output,
176
+ "convo_id": convo_id,
177
+ },
178
+ }
179
+ data.setdefault("properties", {})["$context"] = _get_context()
180
+
181
+ size = _get_size(data)
182
+ if size > max_ingest_size_bytes:
183
+ logger.warning(
184
+ f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
185
+ f"an event of size {size / (1024 * 1024):.2f} MB was logged"
186
+ )
187
+ return None # Skip adding oversized events to buffer
188
+
189
+ save_to_buffer({"type": "events/track", "data": data})
190
+ return event_id
191
+
192
+ def shutdown():
193
+ logger.info("Shutting down raindrop analytics")
194
+ shutdown_event.set()
195
+ if flush_thread:
196
+ flush_thread.join(timeout=10)
197
+ flush() # Final flush to ensure all events are sent
198
+
199
+ def _check_write_key():
200
+ if write_key is None:
201
+ logger.warning("write_key is not set. Please set it before using raindrop analytics.")
202
+ return False
203
+ return True
204
+
205
+ def _get_context():
206
+ return {
207
+ "library": {
208
+ "name": "python-sdk",
209
+ "version": VERSION,
210
+ },
211
+ "metadata": {
212
+ "pyVersion": f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
213
+ },
214
+ }
215
+
216
+ def _get_timestamp():
217
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
218
+
219
+
220
+ def _get_size(event: dict[str, any]) -> int:
221
+ try:
222
+ data = json.dumps(event)
223
+ return len(data.encode('utf-8'))
224
+ except (TypeError, OverflowError) as e:
225
+ logger.error(f"Error serializing event for size calculation: {e}")
226
+ return 0
227
+
228
+ # Signal types
229
+ SignalType = Literal["default", "feedback", "edit"]
230
+
231
+ def track_signal(
232
+ event_id: str,
233
+ name: str,
234
+ signal_type: Optional[SignalType] = "default",
235
+ timestamp: Optional[str] = None,
236
+ properties: Optional[Dict[str, any]] = None,
237
+ attachment_id: Optional[str] = None,
238
+ comment: Optional[str] = None,
239
+ after: Optional[str] = None,
240
+ ) -> None:
241
+ """
242
+ Track a signal event.
243
+
244
+ Args:
245
+ event_id: The ID of the event to attach the signal to
246
+ name: Name of the signal (e.g. "thumbs_up", "thumbs_down")
247
+ signal_type: Type of signal ("default", "feedback", or "edit")
248
+ timestamp: Optional timestamp for the signal
249
+ properties: Optional properties for the signal
250
+ attachment_id: Optional ID of an attachment
251
+ comment: Optional comment (only for feedback signals)
252
+ after: Optional after content (only for edit signals)
253
+ """
254
+ if not _check_write_key():
255
+ return
256
+
257
+ # Prepare properties with optional comment and after fields
258
+ signal_properties = properties or {}
259
+ if comment is not None:
260
+ signal_properties["comment"] = comment
261
+ if after is not None:
262
+ signal_properties["after"] = after
263
+
264
+ data = {
265
+ "event_id": event_id,
266
+ "signal_name": name,
267
+ "signal_type": signal_type,
268
+ "timestamp": timestamp if timestamp else _get_timestamp(),
269
+ "properties": signal_properties,
270
+ "attachment_id": attachment_id,
271
+ }
272
+
273
+ size = _get_size(data)
274
+ if size > max_ingest_size_bytes:
275
+ logger.warning(
276
+ f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
277
+ f"an event of size {size / (1024 * 1024):.2f} MB was logged"
278
+ )
279
+ return # Skip adding oversized events to buffer
280
+
281
+ save_to_buffer({"type": "signals/track", "data": data})
282
+
283
+ atexit.register(shutdown)
@@ -0,0 +1 @@
1
+ VERSION = "0.0.19"
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: raindrop-ai
3
+ Version: 0.0.19
4
+ Summary: Raindrop AI (Python SDK)
5
+ Home-page: https://raindrop.ai
6
+ Author: Raindrop AI
7
+ Author-email: sdk@raindrop.ai
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.6
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: requires-dist
26
+ Dynamic: summary
27
+
28
+ For questions, email us at sdk@raindrop.ai
@@ -0,0 +1,11 @@
1
+ README.md
2
+ setup.py
3
+ raindrop/__init__.py
4
+ raindrop/analytics.py
5
+ raindrop/version.py
6
+ raindrop_ai.egg-info/PKG-INFO
7
+ raindrop_ai.egg-info/SOURCES.txt
8
+ raindrop_ai.egg-info/dependency_links.txt
9
+ raindrop_ai.egg-info/requires.txt
10
+ raindrop_ai.egg-info/top_level.txt
11
+ tests/test_analytics.py
@@ -0,0 +1 @@
1
+ requests
@@ -0,0 +1 @@
1
+ raindrop
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ import sys
2
+ import os
3
+
4
+ from setuptools import setup, find_packages
5
+
6
+ # Don't import raindrop-ai module here, since deps may not be installed
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'raindrop'))
8
+ from version import VERSION
9
+
10
+ setup(
11
+ name="raindrop-ai",
12
+ version=VERSION,
13
+ description="Raindrop AI (Python SDK)",
14
+ author="Raindrop AI",
15
+ author_email="sdk@raindrop.ai",
16
+ long_description="For questions, email us at sdk@raindrop.ai",
17
+ long_description_content_type="text/markdown",
18
+ url="https://raindrop.ai",
19
+ packages=find_packages(include=["raindrop", "README.md"]),
20
+ install_requires=[
21
+ "requests",
22
+ ],
23
+ classifiers=[
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "Programming Language :: Python",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.6",
29
+ "Programming Language :: Python :: 3.7",
30
+ "Programming Language :: Python :: 3.8",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ ],
34
+ )
@@ -0,0 +1,254 @@
1
+ import time
2
+ import unittest
3
+ from unittest.mock import patch
4
+ import raindrop.analytics as analytics
5
+ from raindrop.version import VERSION
6
+ import sys
7
+
8
+
9
+ class TestAnalytics(unittest.TestCase):
10
+ def setUp(self):
11
+ # Set up any necessary test data or configurations
12
+ analytics.write_key = "0000"
13
+ analytics.api_url = "http://localhost:3000/"
14
+
15
+ def tearDown(self):
16
+ # Clean up any resources or reset any state after each test
17
+ analytics.flush()
18
+ pass
19
+
20
+ def test_identify(self):
21
+ with patch('requests.post') as mock_post:
22
+ user_id = "user123"
23
+ traits = {"email": "john@example.com", "name": "John"}
24
+
25
+ analytics.identify(user_id, traits)
26
+ analytics.flush() # Force flush to trigger request
27
+
28
+ # Verify the POST request was made
29
+ mock_post.assert_called_once()
30
+
31
+ # Get the data that was sent
32
+ call_args = mock_post.call_args
33
+ url = call_args[0][0]
34
+ data = call_args[1]['json'][0] # First event in the batch
35
+
36
+ # Verify URL and data
37
+ self.assertEqual(url, "http://localhost:3000/users/identify")
38
+ self.assertEqual(data['user_id'], user_id)
39
+ self.assertEqual(data['traits'], traits)
40
+
41
+ @patch('requests.post')
42
+ def test_track(self, mock_post):
43
+ # Test data
44
+ user_id = "user123"
45
+ event = "signed_up"
46
+ properties = {"plan": "Premium"}
47
+
48
+ # Track the event
49
+ analytics.track(user_id, event, properties)
50
+
51
+ # Force a flush to trigger the HTTP request
52
+ analytics.flush()
53
+
54
+ # Verify the POST request was made
55
+ mock_post.assert_called_once()
56
+
57
+ # Get the data that was sent
58
+ call_args = mock_post.call_args
59
+ url = call_args[0][0]
60
+ data = call_args[1]['json'][0] # First event in the batch
61
+
62
+ # Verify URL
63
+ self.assertEqual(url, "http://localhost:3000/events/track")
64
+
65
+ # Verify request structure
66
+ self.assertEqual(data['user_id'], user_id)
67
+ self.assertEqual(data['event'], event)
68
+ self.assertEqual(data['properties']['plan'], "Premium")
69
+
70
+ # Verify context data
71
+ self.assertEqual(data['properties']['$context']['library']['name'], "python-sdk")
72
+ self.assertEqual(data['properties']['$context']['library']['version'], VERSION)
73
+ self.assertEqual(
74
+ data['properties']['$context']['metadata']['pyVersion'],
75
+ f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
76
+ )
77
+
78
+ # Verify other required fields
79
+ self.assertIn('event_id', data)
80
+ self.assertIn('timestamp', data)
81
+
82
+ @patch('requests.post')
83
+ def test_track_ai(self, mock_post):
84
+ # Test data
85
+ user_id = "user123"
86
+ event = "ai_completion"
87
+ model = "gpt-3.5"
88
+ user_input = "Hello"
89
+ output = "Hi there!"
90
+ convo_id = "conv123"
91
+ properties = {"temperature": 0.7}
92
+
93
+ # Track the AI event
94
+ analytics.track_ai(
95
+ user_id=user_id,
96
+ event=event,
97
+ model=model,
98
+ user_input=user_input,
99
+ output=output,
100
+ convo_id=convo_id,
101
+ properties=properties
102
+ )
103
+
104
+ # Force a flush
105
+ analytics.flush()
106
+
107
+ # Verify the POST request was made
108
+ mock_post.assert_called_once()
109
+
110
+ # Get the data that was sent
111
+ call_args = mock_post.call_args
112
+ data = call_args[1]['json'][0] # First event in the batch
113
+
114
+ # Verify AI-specific fields
115
+ self.assertEqual(data['ai_data']['model'], model)
116
+ self.assertEqual(data['ai_data']['input'], user_input)
117
+ self.assertEqual(data['ai_data']['output'], output)
118
+ self.assertEqual(data['ai_data']['convo_id'], convo_id)
119
+
120
+ # Verify common fields
121
+ self.assertEqual(data['user_id'], user_id)
122
+ self.assertEqual(data['event'], event)
123
+ self.assertEqual(data['properties']['temperature'], 0.7)
124
+ self.assertIn('event_id', data)
125
+ self.assertIn('timestamp', data)
126
+
127
+ def test_flush(self):
128
+ with patch('requests.post') as mock_post:
129
+ user_id = "user123"
130
+ event = "ai_chat"
131
+ model = "GPT-3"
132
+ input_text = "Hello"
133
+ output_text = "Hi there!"
134
+
135
+ analytics.track_ai(
136
+ user_id, event, model=model, user_input=input_text, output=output_text
137
+ )
138
+
139
+ analytics.flush() # Force flush
140
+
141
+ # Verify the buffer is empty after flush
142
+ self.assertEqual(len(analytics.buffer), 0)
143
+
144
+ # Verify the POST request was made
145
+ mock_post.assert_called_once()
146
+
147
+ def test_track_ai_with_size_limit(self):
148
+ with patch('requests.post') as mock_post:
149
+ user_id = "user123"
150
+ event = "ai_chat_test"
151
+ model = "GPT-3"
152
+ input_text = "Hello"
153
+ output_text = "Hi there!"
154
+ properties = {
155
+ "key": "v" * 10000,
156
+ "key2": "v" * 10000,
157
+ "key3": "v" * 1048576, # 1 MB of data (1024 * 1024 bytes)
158
+ }
159
+
160
+ # Capture logged output
161
+ with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
162
+ analytics.track_ai(
163
+ user_id, event, model=model, user_input=input_text, output=output_text, properties=properties
164
+ )
165
+ analytics.flush() # Force flush
166
+
167
+ # Check the logged output
168
+ self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
169
+ "Expected size warning is not logged")
170
+
171
+ # Verify no request was made since event was too large
172
+ mock_post.assert_not_called()
173
+
174
+ @patch('requests.post')
175
+ def test_track_signal(self, mock_post):
176
+ # Test basic signal tracking
177
+ event_id = "event123"
178
+ name = "thumbs_up"
179
+ signal_type = "feedback"
180
+ properties = {"rating": 5}
181
+ comment = "Great response!"
182
+ attachment_id = "attach123"
183
+ after = "Updated content"
184
+
185
+ # Track signal with all fields
186
+ analytics.track_signal(
187
+ event_id=event_id,
188
+ name=name,
189
+ signal_type=signal_type,
190
+ properties=properties,
191
+ comment=comment,
192
+ attachment_id=attachment_id,
193
+ after=after
194
+ )
195
+
196
+ # Force a flush
197
+ analytics.flush()
198
+
199
+ # Verify the POST request was made
200
+ mock_post.assert_called_once()
201
+
202
+ # Get the data that was sent
203
+ call_args = mock_post.call_args
204
+ url = call_args[0][0]
205
+ data = call_args[1]['json'][0] # First event in the batch
206
+
207
+ # Verify URL
208
+ self.assertEqual(url, "http://localhost:3000/signals/track")
209
+
210
+ # Verify signal data
211
+ self.assertEqual(data['event_id'], event_id)
212
+ self.assertEqual(data['signal_name'], name)
213
+ self.assertEqual(data['signal_type'], signal_type)
214
+ self.assertEqual(data['attachment_id'], attachment_id)
215
+
216
+ # Verify properties including comment and after
217
+ self.assertEqual(data['properties']['rating'], 5)
218
+ self.assertEqual(data['properties']['comment'], comment)
219
+ self.assertEqual(data['properties']['after'], after)
220
+
221
+ # Test size limit handling
222
+ mock_post.reset_mock()
223
+ large_properties = {
224
+ "key": "v" * 10000,
225
+ "key2": "v" * 10000,
226
+ "key3": "v" * 1048576, # 1 MB of data
227
+ }
228
+
229
+ # Capture logged output for oversized event
230
+ with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
231
+ analytics.track_signal(
232
+ event_id=event_id,
233
+ name=name,
234
+ properties=large_properties
235
+ )
236
+ analytics.flush()
237
+
238
+ # Check the logged output
239
+ self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
240
+ "Expected size warning is not logged")
241
+
242
+ # Test different signal types
243
+ mock_post.reset_mock()
244
+ for signal_type in ["default", "feedback", "edit"]:
245
+ analytics.track_signal(
246
+ event_id=event_id,
247
+ name=name,
248
+ signal_type=signal_type
249
+ )
250
+ analytics.flush()
251
+
252
+ data = mock_post.call_args[1]['json'][0]
253
+ self.assertEqual(data['signal_type'], signal_type)
254
+ mock_post.reset_mock()