• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

scope3data / scope3ai-py / 12933127151

23 Jan 2025 04:01PM UTC coverage: 96.237% (+15.7%) from 80.557%
12933127151

Pull #73

github

aa22d7
kevdevg
fix: fixing pr
Pull Request #73: feat(litellm): Use default tracer and Image generation - TTS - STT

185 of 187 new or added lines in 12 files covered. (98.93%)

49 existing lines in 10 files now uncovered.

2327 of 2418 relevant lines covered (96.24%)

3.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.27
/scope3ai/lib.py
1
import atexit
4✔
2
import importlib.metadata
4✔
3
import importlib.util
4✔
4
import logging
4✔
5
from contextlib import contextmanager
4✔
6
from contextvars import ContextVar
4✔
7
from functools import partial
4✔
8
from os import getenv
4✔
9
from typing import List, Optional
4✔
10
from uuid import uuid4
4✔
11

12
from .api.client import AsyncClient, Client
4✔
13
from .api.defaults import DEFAULT_API_URL
4✔
14
from .api.tracer import Tracer
4✔
15
from .api.types import ImpactResponse, ImpactRow, Scope3AIContext
4✔
16
from .constants import PROVIDERS
4✔
17
from .worker import BackgroundWorker
4✔
18

19
logger = logging.getLogger("scope3ai.lib")
4✔
20

21

22
def init_anthropic_instrumentor() -> None:
4✔
23
    if importlib.util.find_spec("anthropic") is not None:
4✔
24
        from scope3ai.tracers.anthropic.instrument import AnthropicInstrumentor
4✔
25

26
        instrumentor = AnthropicInstrumentor()
4✔
27
        instrumentor.instrument()
4✔
28

29

30
def init_cohere_instrumentor() -> None:
4✔
31
    if importlib.util.find_spec("cohere") is not None:
4✔
32
        from scope3ai.tracers.cohere.instrument import CohereInstrumentor
4✔
33

34
        instrumentor = CohereInstrumentor()
4✔
35
        instrumentor.instrument()
4✔
36

37

38
def init_openai_instrumentor() -> None:
4✔
39
    if importlib.util.find_spec("openai") is not None:
4✔
40
        from scope3ai.tracers.openai.instrument import OpenAIInstrumentor
4✔
41

42
        instrumentor = OpenAIInstrumentor()
4✔
43
        instrumentor.instrument()
4✔
44

45

46
def init_huggingface_hub_instrumentor() -> None:
4✔
47
    if importlib.util.find_spec("huggingface_hub") is not None:
4✔
48
        from scope3ai.tracers.huggingface.instrument import HuggingfaceInstrumentor
4✔
49

50
        instrumentor = HuggingfaceInstrumentor()
4✔
51
        instrumentor.instrument()
4✔
52

53

54
def init_litellm_instrumentor() -> None:
4✔
55
    if importlib.util.find_spec("litellm") is not None:
4✔
56
        from scope3ai.tracers.litellm.instrument import LiteLLMInstrumentor
4✔
57

58
        instrumentor = LiteLLMInstrumentor()
4✔
59
        instrumentor.instrument()
4✔
60

61

62
def init_mistral_v1_instrumentor() -> None:
4✔
63
    if importlib.util.find_spec("mistralai") is not None:
4✔
64
        from scope3ai.tracers.mistralai.instrument import MistralAIInstrumentor
4✔
65

66
        instrumentor = MistralAIInstrumentor()
4✔
67
        instrumentor.instrument()
4✔
68

69

70
def init_response_instrumentor() -> None:
4✔
71
    from scope3ai.response_interceptor.instrument import ResponseInterceptor
4✔
72

73
    instrumentor = ResponseInterceptor()
4✔
74
    instrumentor.instrument()
4✔
75

76

77
_INSTRUMENTS = {
4✔
78
    PROVIDERS.ANTROPIC.value: init_anthropic_instrumentor,
79
    PROVIDERS.COHERE.value: init_cohere_instrumentor,
80
    PROVIDERS.OPENAI.value: init_openai_instrumentor,
81
    PROVIDERS.HUGGINGFACE_HUB.value: init_huggingface_hub_instrumentor,
82
    PROVIDERS.LITELLM.value: init_litellm_instrumentor,
83
    PROVIDERS.MISTRALAI.value: init_mistral_v1_instrumentor,
84
    PROVIDERS.RESPONSE.value: init_response_instrumentor,
85
}
86

87
_RE_INIT_PROVIDERS = [PROVIDERS.RESPONSE.value]
4✔
88

89

90
def generate_id() -> str:
4✔
UNCOV
91
    return uuid4().hex
×
92

93

94
class Scope3AIError(Exception):
4✔
95
    pass
4✔
96

97

98
class Scope3AI:
4✔
99
    """
100
    Scope3AI tracer class
101

102
    This class is a singleton that provides a context manager for tracing
103
    inference metadata and submitting impact requests to the Scope3 AI API.
104
    """
105

106
    _instance: Optional["Scope3AI"] = None
4✔
107
    _tracer: ContextVar[List[Tracer]] = ContextVar("tracer", default=[])
4✔
108
    _worker: Optional[BackgroundWorker] = None
4✔
109
    _providers: List[str] = []
4✔
110
    _keep_tracers: bool = False
4✔
111

112
    def __new__(cls, *args, **kwargs):
4✔
113
        if cls._instance is None:
4✔
114
            cls._instance = super(Scope3AI, cls).__new__(cls)
4✔
115
        return cls._instance
4✔
116

117
    def __init__(self):
4✔
118
        self.api_key: Optional[str] = None
4✔
119
        self.api_url: Optional[str] = None
4✔
120
        self.sync_mode: bool = False
4✔
121
        self._sync_client: Optional[Client] = None
4✔
122
        self._async_client: Optional[AsyncClient] = None
4✔
123

124
    @classmethod
4✔
125
    def init(
4✔
126
        cls,
127
        api_key: Optional[str] = None,
128
        api_url: Optional[str] = None,
129
        sync_mode: bool = False,
130
        enable_debug_logging: bool = False,
131
        providers: Optional[List[str]] = None,
132
    ) -> "Scope3AI":
133
        if cls._instance is not None:
4✔
UNCOV
134
            raise Scope3AIError("Scope3AI is already initialized")
×
135
        cls._instance = self = Scope3AI()
4✔
136
        self.api_key = api_key or getenv("SCOPE3AI_API_KEY")
4✔
137
        self.api_url = api_url or getenv("SCOPE3AI_API_URL") or DEFAULT_API_URL
4✔
138
        self.sync_mode = sync_mode or bool(getenv("SCOPE3AI_SYNC_MODE", False))
4✔
139
        if not self.api_key:
4✔
UNCOV
140
            raise Scope3AIError(
×
141
                "The scope3 api_key option must be set either by "
142
                "passing the API key to the Scope3AI.init(api_key='xxx') "
143
                "or by setting the SCOPE3AI_API_KEY environment variable"
144
            )
145
        if not self.api_url:
4✔
UNCOV
146
            raise Scope3AIError(
×
147
                "The api_url option must be set either by "
148
                "passing the API URL to the Scope3AI.init(api_url='xxx') "
149
                "or by setting the SCOPE3AI_API_URL environment variable"
150
            )
151

152
        if enable_debug_logging:
4✔
153
            self._init_logging()
4✔
154

155
        if providers is None:
4✔
156
            providers = list(_INSTRUMENTS.keys())
4✔
157

158
        http_client_options = {"api_key": self.api_key, "api_url": self.api_url}
4✔
159
        self._sync_client = Client(**http_client_options)
4✔
160
        self._async_client = AsyncClient(**http_client_options)
4✔
161
        self._init_providers(providers)
4✔
162
        self._init_atexit()
4✔
163
        return cls._instance
4✔
164

165
    @classmethod
4✔
166
    def get_instance(cls) -> "Scope3AI":
4✔
167
        """
168
        Return the instance of the Scope3AI singleton.
169

170
        This method provides access to the default global state of the
171
        Scope3AI library. The returned instance can be used to trace
172
        inference metadata and submit impact requests to the Scope3 AI
173
        API from anywhere in the application.
174

175
        Returns:
176
            Scope3AI: The singleton instance of the Scope3AI class.
177
        """
178
        return cls._instance
4✔
179

180
    def submit_impact(
4✔
181
        self,
182
        impact_row: ImpactRow,
183
    ) -> Scope3AIContext:
184
        """
185
        Submit an impact request to the Scope3 AI API.
186

187
        This function sends an impact request represented by the `impact_row`
188
        to the Scope3 AI API and optionally returns the response.
189

190
        Args:
191
            impact_row (ImpactRow): The impact request data
192
                that needs to be submitted to the Scope3 AI API.
193

194
        Returns:
195
            Scope3AIContext: A context object containing the request data and
196
            the response from the API.
197
        """
198

199
        def submit_impact(
4✔
200
            impact_row: ImpactRow,
201
            ctx: Scope3AIContext,
202
        ) -> Optional[ImpactResponse]:
203
            response = self._sync_client.impact(
4✔
204
                rows=[impact_row],
205
                with_response=True,
206
            )
207
            ctx.set_impact(response.rows[0])
4✔
208
            if ctx._tracer:
4✔
209
                ctx._tracer.add_impact(response.rows[0])
4✔
210
                ctx._tracer._unlink_trace(ctx)
4✔
211
            return response
4✔
212

213
        tracer = self.current_tracer
4✔
214
        ctx = Scope3AIContext(request=impact_row)
4✔
215
        ctx._tracer = tracer
4✔
216
        if tracer:
4✔
217
            tracer._link_trace(ctx)
4✔
218

219
        if self.sync_mode:
4✔
220
            submit_impact(impact_row, ctx=ctx)
4✔
221
            return ctx
4✔
222

223
        self._ensure_worker()
4✔
224
        self._worker.submit(partial(submit_impact, impact_row=impact_row, ctx=ctx))
4✔
225
        return ctx
4✔
226

227
    async def asubmit_impact(
4✔
228
        self,
229
        impact_row: ImpactRow,
230
    ) -> Scope3AIContext:
231
        """
232
        Async version of Scope3AI::submit_impact.
233
        """
234

235
        if not self.sync_mode:
4✔
236
            # in non sync-mode, it uses the background worker,
237
            # and the background worker is not async (does not have to be).
238
            # so we just redirect the call to the sync version.
239
            return self.submit_impact(impact_row)
4✔
240
        tracer = self.current_tracer
4✔
241
        ctx = Scope3AIContext(request=impact_row)
4✔
242
        ctx._tracer = tracer
4✔
243
        if tracer:
4✔
244
            tracer._link_trace(ctx)
4✔
245

246
        response = await self._async_client.impact(
4✔
247
            rows=[impact_row],
248
            with_response=True,
249
        )
250
        ctx.set_impact(response.rows[0])
4✔
251
        if tracer:
4✔
252
            tracer.add_impact(response.rows[0])
4✔
253
            tracer._unlink_trace(ctx)
4✔
254

255
        return ctx
4✔
256

257
    @property
4✔
258
    def root_tracer(self):
4✔
259
        """
260
        Return the root tracer.
261

262
        The root tracer is the first tracer in the current execution context
263
        (tracer stack). If no tracers are currently active, it returns None.
264

265
        Returns:
266
            Tracer: The root tracer if available, otherwise None.
267
        """
268
        tracers = self._tracer.get()
×
269
        return tracers[0] if tracers else None
×
270

271
    @property
4✔
272
    def current_tracer(self):
4✔
273
        """
274
        Return the current tracer.
275

276
        The current tracer is the last tracer in the current execution context
277
        (tracer stack). If no tracers are currently active, it returns None.
278

279
        Returns:
280
            Tracer: The current tracer if available, otherwise None.
281
        """
282
        tracers = self._tracer.get()
4✔
283
        return tracers[-1] if tracers else None
4✔
284

285
    @contextmanager
4✔
286
    def trace(self, keep_traces=False):
4✔
287
        tracer = Tracer(keep_traces=keep_traces)
4✔
288
        try:
4✔
289
            self._push_tracer(tracer)
4✔
290
            yield tracer
4✔
291
        finally:
292
            self._pop_tracer(tracer)
4✔
293

294
    def close(self):
4✔
295
        if self._worker:
4✔
296
            self._worker.kill()
4✔
297
        self.__class__._instance = None
4✔
298

299
    #
300
    # Internals
301
    #
302

303
    def _push_tracer(self, tracer: Tracer) -> None:
4✔
304
        tracer._link_parent(self.current_tracer)
4✔
305
        self._tracer.get().append(tracer)
4✔
306

307
    def _pop_tracer(self, tracer: Tracer) -> None:
4✔
308
        self._tracer.get().remove(tracer)
4✔
309
        tracer._unlink_parent(self.current_tracer)
4✔
310

311
    def _init_providers(self, providers: List[str]) -> None:
4✔
312
        """Initialize the specified providers."""
313
        for provider in providers:
4✔
314
            if provider not in _INSTRUMENTS:
4✔
UNCOV
315
                raise Scope3AIError(
×
316
                    f"Could not find tracer for the `{provider}` provider."
317
                )
318
            if provider in self._providers and provider not in _RE_INIT_PROVIDERS:
4✔
319
                # already initialized
320
                continue
4✔
321
            init_func = _INSTRUMENTS[provider]
4✔
322
            init_func()
4✔
323
            self._providers.append(provider)
4✔
324

325
    def _ensure_worker(self) -> None:
4✔
326
        if not self._worker:
4✔
327
            self._worker = BackgroundWorker(-1)
4✔
328

329
    def _init_logging(self) -> None:
4✔
330
        logging.basicConfig(
4✔
331
            level=logging.INFO,
332
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
333
            handlers=[logging.StreamHandler()],
334
        )
335
        logging.getLogger("scope3ai").setLevel(logging.DEBUG)
4✔
336

337
    def _init_atexit(self):
4✔
338
        @atexit.register
4✔
339
        def _shutdown():
4✔
340
            # do not reinstanciate the singleton here if somehow it was deleted
UNCOV
341
            scope3ai = Scope3AI._instance
×
UNCOV
342
            if not scope3ai:
×
UNCOV
343
                return
×
UNCOV
344
            if scope3ai._worker and scope3ai._worker._queue:
×
UNCOV
345
                logging.debug("Waiting background informations to be processed")
×
UNCOV
346
                scope3ai._worker._queue.join()
×
UNCOV
347
                logging.debug("Shutting down Scope3AI")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc