raindrop-ai 0.0.27__tar.gz → 0.0.29__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,62 @@
1
+ Metadata-Version: 2.1
2
+ Name: raindrop-ai
3
+ Version: 0.0.29
4
+ Summary: Raindrop AI (Python SDK)
5
+ License: MIT
6
+ Author: Raindrop AI
7
+ Author-email: sdk@raindrop.ai
8
+ Requires-Python: >=3.10, !=2.7.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*, !=3.8.*, !=3.9.*
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: pydantic (>=2.09,<3)
15
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
16
+ Requires-Dist: traceloop-sdk (==0.45.6)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Raindrop Python SDK
20
+
21
+ ## Installation dependencies
22
+
23
+
24
+ ```bash
25
+ pip install poetry
26
+ ```
27
+
28
+ ```bash
29
+ poetry install
30
+ ```
31
+
32
+
33
+ ## Run tests
34
+
35
+ ### Using pytest (recommended)
36
+ ```bash
37
+ # Run all tests
38
+ poetry run pytest
39
+
40
+ # Run tests with verbose output
41
+ poetry run pytest -v
42
+
43
+ # Run specific test file
44
+ poetry run pytest tests/test_trace_attributes.py
45
+
46
+ # Run specific test
47
+ poetry run pytest tests/test_trace_attributes.py::TestTraceAttributes::test_user_id_attribute_direct
48
+
49
+ # Run tests with coverage
50
+ poetry run pytest --cov=raindrop
51
+ ```
52
+
53
+ ### Using green (legacy)
54
+ ```bash
55
+ poetry run green -vv
56
+ ```
57
+
58
+
59
+
60
+
61
+
62
+
@@ -0,0 +1,43 @@
1
+ # Raindrop Python SDK
2
+
3
+ ## Installation dependencies
4
+
5
+
6
+ ```bash
7
+ pip install poetry
8
+ ```
9
+
10
+ ```bash
11
+ poetry install
12
+ ```
13
+
14
+
15
+ ## Run tests
16
+
17
+ ### Using pytest (recommended)
18
+ ```bash
19
+ # Run all tests
20
+ poetry run pytest
21
+
22
+ # Run tests with verbose output
23
+ poetry run pytest -v
24
+
25
+ # Run specific test file
26
+ poetry run pytest tests/test_trace_attributes.py
27
+
28
+ # Run specific test
29
+ poetry run pytest tests/test_trace_attributes.py::TestTraceAttributes::test_user_id_attribute_direct
30
+
31
+ # Run tests with coverage
32
+ poetry run pytest --cov=raindrop
33
+ ```
34
+
35
+ ### Using green (legacy)
36
+ ```bash
37
+ poetry run green -vv
38
+ ```
39
+
40
+
41
+
42
+
43
+
@@ -0,0 +1,36 @@
1
+ [tool.poetry]
2
+ name = "raindrop-ai"
3
+ version = "0.0.29"
4
+ description = "Raindrop AI (Python SDK)"
5
+ authors = ["Raindrop AI <sdk@raindrop.ai>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ packages = [{include = "raindrop"}]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10,<3.12.1 || >3.12.1,<4.0"
12
+ pydantic = ">=2.09,<3"
13
+ requests = "^2.32.3"
14
+ traceloop-sdk = "0.45.6"
15
+
16
+
17
+ [tool.poetry.group.dev.dependencies]
18
+ green = "^4.0.2"
19
+ pytest = "^8.4.1"
20
+ pytest-cov = "^6.2.1"
21
+ openai = "^1.100.1"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ python_files = ["test_*.py"]
26
+ python_classes = ["Test*"]
27
+ python_functions = ["test_*"]
28
+ addopts = ["-v", "--tb=short"]
29
+ filterwarnings = [
30
+ "ignore::DeprecationWarning",
31
+ "ignore::PendingDeprecationWarning",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["poetry-core"]
36
+ build-backend = "poetry.core.masonry.api"
@@ -1,7 +1,9 @@
1
1
  import sys
2
2
  import time
3
3
  import threading
4
- from typing import Union, List, Dict, Optional, Literal, Any
4
+ import os
5
+ from contextlib import contextmanager
6
+ from typing import Callable, Union, List, Dict, Optional, Literal, Any
5
7
  import requests
6
8
  from datetime import datetime, timezone
7
9
  import logging
@@ -23,6 +25,18 @@ from raindrop.models import (
23
25
  )
24
26
  from raindrop.interaction import Interaction
25
27
  from raindrop.redact import perform_pii_redaction
28
+ import weakref
29
+ import urllib.parse
30
+
31
+ from traceloop.sdk import Traceloop
32
+ from traceloop.sdk.tracing.tracing import TracerWrapper
33
+ from opentelemetry.trace import get_current_span
34
+ from traceloop.sdk.decorators import (
35
+ task as tlp_task,
36
+ workflow as tlp_workflow,
37
+ TraceloopSpanKindValues,
38
+ F,
39
+ )
26
40
 
27
41
 
28
42
  # Configure logging
@@ -40,6 +54,7 @@ buffer = []
40
54
  flush_lock = threading.Lock()
41
55
  debug_logs = False
42
56
  redact_pii = False
57
+ _tracing_enabled = False
43
58
  flush_thread = None
44
59
  shutdown_event = threading.Event()
45
60
  max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
@@ -236,6 +251,11 @@ def shutdown():
236
251
  if flush_thread:
237
252
  flush_thread.join(timeout=10)
238
253
  flush() # Final flush to ensure all events are sent
254
+ if _tracing_enabled:
255
+ try:
256
+ TracerWrapper().flush()
257
+ except Exception as e:
258
+ logger.debug(f"Could not flush TracerWrapper during shutdown: {e}")
239
259
 
240
260
 
241
261
  def _check_write_key():
@@ -365,6 +385,14 @@ def track_signal(
365
385
  save_to_buffer({"type": "signals/track", "data": data})
366
386
 
367
387
 
388
+ INTERACTION_TRACE_ID_REGISTRY: weakref.WeakValueDictionary[int, Interaction] = (
389
+ weakref.WeakValueDictionary()
390
+ )
391
+ INTERACTION_EVENT_ID_REGISTRY: weakref.WeakValueDictionary[str, Interaction] = (
392
+ weakref.WeakValueDictionary()
393
+ )
394
+
395
+
368
396
  def begin(
369
397
  user_id: str,
370
398
  event: str,
@@ -387,6 +415,10 @@ def begin(
387
415
  # Combine properties with initial_fields, giving precedence to initial_fields if keys clash
388
416
  final_properties = (properties or {}).copy()
389
417
 
418
+ current_trace_id = _safe_current_trace_id()
419
+ if current_trace_id is not None:
420
+ final_properties["trace_id"] = f"{current_trace_id:032x}"
421
+
390
422
  partial_event = PartialTrackAIEvent(
391
423
  event_id=eid,
392
424
  user_id=user_id,
@@ -397,13 +429,145 @@ def begin(
397
429
  attachments=attachments,
398
430
  )
399
431
 
432
+ span_attributes = {
433
+ "user_id": user_id,
434
+ "convo_id": convo_id,
435
+ "event": event,
436
+ "event_id": eid,
437
+ }
438
+ if _tracing_enabled:
439
+ Traceloop.set_association_properties(
440
+ {k: v for k, v in span_attributes.items() if v is not None}
441
+ )
442
+
443
+ interaction = Interaction(eid)
444
+ INTERACTION_EVENT_ID_REGISTRY[eid] = interaction
445
+ if current_trace_id is not None and current_trace_id != 0:
446
+ INTERACTION_TRACE_ID_REGISTRY[current_trace_id] = interaction
447
+
400
448
  _track_ai_partial(partial_event)
401
- return Interaction(eid)
449
+ return interaction
450
+
451
+
452
+ @contextmanager
453
+ def _temp_env(key: str, value: str):
454
+ """Temporarily sets an environment variable. Hacky helper to deal with traceloop's BS"""
455
+ orig = os.environ.get(key)
456
+ os.environ[key] = value
457
+ try:
458
+ yield
459
+ finally:
460
+ if orig is None:
461
+ del os.environ[key]
462
+ else:
463
+ os.environ[key] = orig
464
+
465
+
466
+ def init(
467
+ api_key: str,
468
+ tracing_enabled: bool = False,
469
+ **traceloop_kwargs,
470
+ ):
471
+ """Initialize Raindrop with Traceloop integration."""
472
+ global write_key
473
+ write_key = api_key
474
+
475
+ global _tracing_enabled
476
+ _tracing_enabled = tracing_enabled
477
+
478
+ if not _tracing_enabled:
479
+ return
480
+
481
+ parsed_url = urllib.parse.urlparse(api_url)
482
+ api_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}"
483
+
484
+ with _temp_env("TRACELOOP_METRICS_ENABLED", "false"):
485
+ Traceloop.init(
486
+ api_endpoint=api_endpoint,
487
+ api_key=api_key,
488
+ telemetry_enabled=False,
489
+ **traceloop_kwargs,
490
+ )
491
+
492
+
493
+ def _safe_current_trace_id() -> int | None:
494
+ """Return current trace id or None if unavailable."""
495
+ try:
496
+ trace_id = get_current_span().get_span_context().trace_id
497
+ except Exception:
498
+ return None
499
+ return trace_id if trace_id else None
500
+
501
+
502
+ def interaction(
503
+ name: Optional[str] = None,
504
+ version: Optional[int] = None,
505
+ method_name: Optional[str] = None,
506
+ ) -> Callable[[F], F]:
507
+ return tlp_workflow(
508
+ name=name,
509
+ version=version,
510
+ method_name=method_name,
511
+ tlp_span_kind=TraceloopSpanKindValues.WORKFLOW,
512
+ )
513
+
514
+
515
+ def task(
516
+ name: Optional[str] = None,
517
+ version: Optional[int] = None,
518
+ method_name: Optional[str] = None,
519
+ tlp_span_kind: Optional[TraceloopSpanKindValues] = TraceloopSpanKindValues.TASK,
520
+ ) -> Callable[[F], F]:
521
+ return tlp_task(
522
+ name=name,
523
+ version=version,
524
+ method_name=method_name,
525
+ tlp_span_kind=tlp_span_kind,
526
+ )
527
+
528
+
529
+ def tool(
530
+ name: Optional[str] = None,
531
+ version: Optional[int] = None,
532
+ method_name: Optional[str] = None,
533
+ ) -> Callable[[F], F]:
534
+ return tlp_task(
535
+ name=name,
536
+ version=version,
537
+ method_name=method_name,
538
+ tlp_span_kind=TraceloopSpanKindValues.TOOL,
539
+ )
540
+
541
+
542
+ def set_span_properties(properties: Dict[str, Any]) -> None:
543
+ """
544
+ Set association properties on the current span for tracing.
545
+
546
+ Args:
547
+ properties: Dictionary of properties to associate with the current span
548
+ """
549
+ if not _tracing_enabled:
550
+ return
551
+
552
+ Traceloop.set_association_properties(properties)
553
+
554
+
555
+ def resume_interaction(event_id: str | None = None) -> Interaction:
556
+ """Return an Interaction associated with the current trace or given event_id."""
557
+
558
+ if event_id is not None:
559
+ if (interaction := INTERACTION_EVENT_ID_REGISTRY.get(event_id)) is not None:
560
+ return interaction
561
+ return Interaction(event_id)
402
562
 
563
+ if (trace_id := _safe_current_trace_id()) is not None:
564
+ if (interaction := INTERACTION_TRACE_ID_REGISTRY.get(trace_id)) is not None:
565
+ return interaction
403
566
 
404
- def resume_interaction(event_id: str) -> Interaction:
405
- """Return an Interaction wrapper for an existing eventId."""
406
- return Interaction(event_id)
567
+ # Fallback: create a fresh Interaction when no identifiers are available
568
+ # TODO: Return No-Op interaction if event_id is None
569
+ logger.debug("No interaction found, creating a new one")
570
+ return Interaction()
407
571
 
408
572
 
409
573
  def _track_ai_partial(event: PartialTrackAIEvent) -> None:
@@ -1,9 +1,18 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Dict, List, Optional, Callable
2
+ from typing import (
3
+ Any,
4
+ Dict,
5
+ List,
6
+ Optional,
7
+ Union,
8
+ Iterator,
9
+ )
3
10
  from uuid import uuid4
11
+ from dataclasses import dataclass
4
12
 
5
13
  from .models import Attachment, PartialTrackAIEvent
6
14
  from . import analytics as _core
15
+ from opentelemetry import context as context_api
7
16
 
8
17
 
9
18
  class Interaction:
@@ -11,12 +20,19 @@ class Interaction:
11
20
  Thin helper returned by analytics.begin().
12
21
  Each mutator just relays a partial update back to Analytics.
13
22
  """
14
- __slots__ = ("_event_id", "_analytics")
15
23
 
16
- def __init__(self, event_id: Optional[str] = None):
24
+ __slots__ = (
25
+ "_event_id",
26
+ "_analytics",
27
+ "__weakref__",
28
+ )
29
+
30
+ def __init__(
31
+ self,
32
+ event_id: Optional[str] = None,
33
+ ):
17
34
  self._event_id = event_id or str(uuid4())
18
35
  self._analytics = _core
19
-
20
36
 
21
37
  # -- mutators ----------------------------------------------------------- #
22
38
  def set_input(self, text: str) -> None:
@@ -34,7 +50,11 @@ class Interaction:
34
50
  PartialTrackAIEvent(event_id=self._event_id, properties=props)
35
51
  )
36
52
 
53
+ def set_property(self, key: str, value: Any) -> None:
54
+ self.set_properties({key: value})
55
+
37
56
  def finish(self, *, output: str | None = None, **extra) -> None:
57
+
38
58
  payload = PartialTrackAIEvent(
39
59
  event_id=self._event_id,
40
60
  ai_data={"output": output} if output is not None else None,
@@ -46,4 +66,4 @@ class Interaction:
46
66
  # convenience
47
67
  @property
48
68
  def id(self) -> str:
49
- return self._event_id
69
+ return self._event_id
@@ -1,6 +1,8 @@
1
1
  from pydantic import BaseModel, Field, ValidationError, model_validator, field_validator
2
2
  from typing import Any, Optional, Dict, Literal, List, Union
3
3
  from datetime import datetime, timezone
4
+ from dataclasses import dataclass, field
5
+
4
6
 
5
7
  class _Base(BaseModel):
6
8
  model_config = dict(extra="forbid", validate_default=True)
@@ -8,10 +10,10 @@ class _Base(BaseModel):
8
10
 
9
11
  class Attachment(BaseModel):
10
12
  type: Literal["code", "text", "image", "iframe"]
11
- value: str # URL, raw code, etc.
12
- name: Optional[str] = None # e.g. "Generated SQL"
13
+ value: str # URL, raw code, etc.
14
+ name: Optional[str] = None # e.g. "Generated SQL"
13
15
  role: Optional[Literal["input", "output", "context"]] = None
14
- language: Optional[str] = None # for code snippets
16
+ language: Optional[str] = None # for code snippets
15
17
 
16
18
  @model_validator(mode="after")
17
19
  def _require_value(cls, values):
@@ -19,6 +21,7 @@ class Attachment(BaseModel):
19
21
  raise ValueError("value must be non-empty.")
20
22
  return values
21
23
 
24
+
22
25
  class AIData(_Base):
23
26
  model: Optional[str]
24
27
  input: Optional[str]
@@ -30,7 +33,8 @@ class AIData(_Base):
30
33
  if not (values.input or values.output):
31
34
  raise ValueError("Either 'input' or 'output' must be non-empty.")
32
35
  return values
33
-
36
+
37
+
34
38
  class TrackAIEvent(_Base):
35
39
  event_id: Optional[str] = None
36
40
  user_id: str
@@ -38,7 +42,9 @@ class TrackAIEvent(_Base):
38
42
  ai_data: AIData
39
43
  properties: Dict[str, Any] = Field(default_factory=dict)
40
44
  timestamp: datetime = Field(
41
- default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0).isoformat()
45
+ default_factory=lambda: datetime.now(timezone.utc)
46
+ .replace(microsecond=0)
47
+ .isoformat()
42
48
  )
43
49
  attachments: Optional[List[Attachment]] = None
44
50
 
@@ -55,8 +61,10 @@ class TrackAIEvent(_Base):
55
61
 
56
62
  # --- Signal Tracking Models --- #
57
63
 
64
+
58
65
  class BaseSignal(_Base):
59
66
  """Base model for signal events, containing common fields."""
67
+
60
68
  event_id: str
61
69
  signal_name: str
62
70
  timestamp: datetime = Field(
@@ -73,41 +81,52 @@ class BaseSignal(_Base):
73
81
  raise ValueError(f"'{info.field_name}' must be a non-empty string.")
74
82
  return v
75
83
 
84
+
76
85
  class DefaultSignal(BaseSignal):
77
86
  """Model for default signal events."""
87
+
78
88
  signal_type: Literal["default"] = "default"
79
89
 
90
+
80
91
  class FeedbackSignal(BaseSignal):
81
92
  """Model for feedback signal events, requiring a comment."""
93
+
82
94
  signal_type: Literal["feedback"]
83
95
 
84
96
  @model_validator(mode="after")
85
97
  def _check_comment_in_properties(cls, values):
86
98
  # Check properties safely after potential initialization
87
99
  # Use getattr to safely access properties, returning None if not present
88
- props = getattr(values, 'properties', None)
100
+ props = getattr(values, "properties", None)
89
101
  if not isinstance(props, dict):
90
- raise ValueError("'properties' must be a dictionary for feedback signals.")
102
+ raise ValueError("'properties' must be a dictionary for feedback signals.")
91
103
  comment = props.get("comment")
92
104
  if not comment or not isinstance(comment, str) or not comment.strip():
93
- raise ValueError("'properties' must contain a non-empty string 'comment' for feedback signals.")
105
+ raise ValueError(
106
+ "'properties' must contain a non-empty string 'comment' for feedback signals."
107
+ )
94
108
  return values
95
109
 
110
+
96
111
  class EditSignal(BaseSignal):
97
112
  """Model for edit signal events, requiring after content."""
113
+
98
114
  signal_type: Literal["edit"]
99
115
 
100
116
  @model_validator(mode="after")
101
117
  def _check_after_in_properties(cls, values):
102
118
  # Check properties safely after potential initialization
103
- props = getattr(values, 'properties', None)
119
+ props = getattr(values, "properties", None)
104
120
  if not isinstance(props, dict):
105
- raise ValueError("'properties' must be a dictionary for edit signals.")
121
+ raise ValueError("'properties' must be a dictionary for edit signals.")
106
122
  after = props.get("after")
107
123
  if not after or not isinstance(after, str) or not after.strip():
108
- raise ValueError("'properties' must contain a non-empty string 'after' for edit signals.")
124
+ raise ValueError(
125
+ "'properties' must contain a non-empty string 'after' for edit signals."
126
+ )
109
127
  return values
110
128
 
129
+
111
130
  # Discriminated Union for Signal Events
112
131
  # Pydantic will automatically use the 'signal_type' field to determine which model to use.
113
132
  SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
@@ -117,18 +136,21 @@ SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
117
136
 
118
137
  class PartialAIData(_Base):
119
138
  """Looser version for incremental updates."""
139
+
120
140
  model: Optional[str] = None
121
141
  input: Optional[str] = None
122
142
  output: Optional[str] = None
123
- convo_id: Optional[str] = None
143
+ convo_id: Optional[str] = None
144
+
124
145
 
125
146
  class PartialTrackAIEvent(_Base):
126
147
  """Accepts *any subset* of TrackAIEvent fields."""
127
- event_id: str # always required for merge-key
148
+
149
+ event_id: str # always required for merge-key
128
150
  user_id: Optional[str] = None
129
151
  event: Optional[str] = None
130
152
  ai_data: Optional[PartialAIData] = None
131
153
  timestamp: Optional[datetime] = None
132
154
  properties: Optional[Dict[str, Any]] = None
133
155
  attachments: Optional[List[Attachment]] = None
134
- is_pending: Optional[bool] = True
156
+ is_pending: Optional[bool] = True
@@ -0,0 +1 @@
1
+ VERSION = "0.0.29"
@@ -1,41 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: raindrop-ai
3
- Version: 0.0.27
4
- Summary: Raindrop AI (Python SDK)
5
- License: MIT
6
- Author: Raindrop AI
7
- Author-email: sdk@raindrop.ai
8
- Requires-Python: >=3.11, !=2.7.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*, !=3.8.*, !=3.9.*, !=3.10.*
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
- Classifier: Programming Language :: Python :: 3.12
13
- Requires-Dist: pydantic (>=2.09,<3)
14
- Requires-Dist: requests (>=2.32.3,<3.0.0)
15
- Description-Content-Type: text/markdown
16
-
17
- # Raindrop Python SDK
18
-
19
- ## Installation dependencies
20
-
21
-
22
- ```bash
23
- pip install poetry
24
- ```
25
-
26
- ```bash
27
- poetry install
28
- ```
29
-
30
-
31
- ## Run tests
32
-
33
- ```bash
34
- poetry run green -vv
35
- ```
36
-
37
-
38
-
39
-
40
-
41
-
@@ -1,24 +0,0 @@
1
- # Raindrop Python SDK
2
-
3
- ## Installation dependencies
4
-
5
-
6
- ```bash
7
- pip install poetry
8
- ```
9
-
10
- ```bash
11
- poetry install
12
- ```
13
-
14
-
15
- ## Run tests
16
-
17
- ```bash
18
- poetry run green -vv
19
- ```
20
-
21
-
22
-
23
-
24
-
@@ -1,21 +0,0 @@
1
- [tool.poetry]
2
- name = "raindrop-ai"
3
- version = "0.0.27"
4
- description = "Raindrop AI (Python SDK)"
5
- authors = ["Raindrop AI <sdk@raindrop.ai>"]
6
- license = "MIT"
7
- readme = "README.md"
8
- packages = [{include = "raindrop"}]
9
-
10
- [tool.poetry.dependencies]
11
- python = ">=3.11,<3.12.1 || >3.12.1,<4.0"
12
- pydantic = ">=2.09,<3"
13
- requests = "^2.32.3"
14
-
15
-
16
- [tool.poetry.group.dev.dependencies]
17
- green = "^4.0.2"
18
-
19
- [build-system]
20
- requires = ["poetry-core"]
21
- build-backend = "poetry.core.masonry.api"
@@ -1 +0,0 @@
1
- VERSION = "0.0.27"