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

scope3data / scope3ai-py / 12640049828

06 Jan 2025 08:44PM UTC coverage: 94.023% (+13.5%) from 80.557%
12640049828

Pull #49

github

14e6be
web-flow
Merge 32499f0cf into 3f7a316e4
Pull Request #49: fix: use local model cost for tests

1463 of 1556 relevant lines covered (94.02%)

3.76 hits per line

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

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

12
from .api.client import Client, AsyncClient
4✔
13
from .api.tracer import Tracer
4✔
14
from .api.types import ImpactRow, ImpactResponse, Scope3AIContext
4✔
15
from .api.defaults import DEFAULT_API_URL
4✔
16

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.mistrarlai_v1.instrument import MistralAIInstrumentor
4✔
65

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

69

70
_INSTRUMENTS = {
4✔
71
    "anthropic": init_anthropic_instrumentor,
72
    "cohere": init_cohere_instrumentor,
73
    "openai": init_openai_instrumentor,
74
    "huggingface_hub": init_huggingface_hub_instrumentor,
75
    "litellm": init_litellm_instrumentor,
76
    "mistralai": init_mistral_v1_instrumentor,
77
}
78

79

80
def generate_id() -> str:
4✔
81
    return uuid4().hex
×
82

83

84
class Scope3AIError(Exception):
4✔
85
    pass
4✔
86

87

88
class Scope3AI:
4✔
89
    """
90
    Scope3AI tracer class
91

92
    This class is a singleton that provides a context manager for tracing
93
    inference metadata and submitting impact requests to the Scope3 AI API.
94
    """
95

96
    _instance: Optional["Scope3AI"] = None
4✔
97
    _tracer: ContextVar[List[Tracer]] = ContextVar("tracer", default=[])
4✔
98
    _worker: Optional[BackgroundWorker] = None
4✔
99
    _providers: List[str] = []
4✔
100

101
    def __new__(cls, *args, **kwargs):
4✔
102
        if cls._instance is None:
4✔
103
            cls._instance = super(Scope3AI, cls).__new__(cls)
4✔
104
        return cls._instance
4✔
105

106
    @classmethod
4✔
107
    def init(
4✔
108
        cls,
109
        api_key: str = None,
110
        api_url: str = None,
111
        sync_mode: bool = False,
112
        enable_debug_logging: bool = False,
113
        providers: Optional[List[str]] = None,
114
    ) -> None:
115
        if cls._instance is not None:
4✔
116
            raise Scope3AIError("Scope3AI is already initialized")
×
117
        cls._instance = self = Scope3AI()
4✔
118
        self.api_key = api_key or getenv("SCOPE3AI_API_KEY")
4✔
119
        self.api_url = api_url or getenv("SCOPE3AI_API_URL") or DEFAULT_API_URL
4✔
120
        self.sync_mode = sync_mode or bool(getenv("SCOPE3AI_SYNC_MODE", False))
4✔
121
        if not self.api_key:
4✔
122
            raise Scope3AIError(
×
123
                "The scope3 api_key option must be set either by "
124
                "passing the API key to the Scope3AI.init(api_key='xxx') "
125
                "or by setting the SCOPE3AI_API_KEY environment variable"
126
            )
127
        if not self.api_url:
4✔
128
            raise Scope3AIError(
×
129
                "The api_url option must be set either by "
130
                "passing the API URL to the Scope3AI.init(api_url='xxx') "
131
                "or by setting the SCOPE3AI_API_URL environment variable"
132
            )
133

134
        if enable_debug_logging:
4✔
135
            self._init_logging()
4✔
136

137
        if providers is None:
4✔
138
            providers = list(_INSTRUMENTS.keys())
4✔
139

140
        http_client_options = {"api_key": self.api_key, "api_url": self.api_url}
4✔
141
        self._sync_client = Client(**http_client_options)
4✔
142
        self._async_client = AsyncClient(**http_client_options)
4✔
143
        self._init_providers(providers)
4✔
144
        self._init_atexit()
4✔
145
        return cls._instance
4✔
146

147
    @classmethod
4✔
148
    def get_instance(cls) -> "Scope3AI":
4✔
149
        """
150
        Return the instance of the Scope3AI singleton.
151

152
        This method provides access to the default global state of the
153
        Scope3AI library. The returned instance can be used to trace
154
        inference metadata and submit impact requests to the Scope3 AI
155
        API from anywhere in the application.
156

157
        Returns:
158
            Scope3AI: The singleton instance of the Scope3AI class.
159
        """
160
        return cls._instance
4✔
161

162
    def submit_impact(
4✔
163
        self,
164
        impact_row: ImpactRow,
165
    ) -> Scope3AIContext:
166
        """
167
        Submit an impact request to the Scope3 AI API.
168

169
        This function sends an impact request represented by the `impact_row`
170
        to the Scope3 AI API and optionally returns the response.
171

172
        Args:
173
            impact_row (ImpactRow): The impact request data
174
                that needs to be submitted to the Scope3 AI API.
175

176
        Returns:
177
            Scope3AIContext: A context object containing the request data and
178
            the response from the API.
179
        """
180

181
        def submit_impact(
4✔
182
            impact_row: ImpactRow,
183
            ctx: Scope3AIContext,
184
        ) -> Optional[ImpactResponse]:
185
            response = self._sync_client.impact(
4✔
186
                rows=[impact_row],
187
                with_response=True,
188
            )
189
            ctx.set_impact(response.rows[0])
4✔
190
            if ctx._tracer:
4✔
191
                ctx._tracer.add_impact(response.rows[0])
4✔
192
                ctx._tracer._unlink_trace(ctx)
4✔
193
            return response
4✔
194

195
        tracer = self.current_tracer
4✔
196
        ctx = Scope3AIContext(request=impact_row)
4✔
197
        ctx._tracer = tracer
4✔
198
        if tracer:
4✔
199
            tracer._link_trace(ctx)
4✔
200

201
        if self.sync_mode:
4✔
202
            submit_impact(impact_row, ctx=ctx)
4✔
203
            return ctx
4✔
204

205
        self._ensure_worker()
4✔
206
        self._worker.submit(partial(submit_impact, impact_row=impact_row, ctx=ctx))
4✔
207
        return ctx
4✔
208

209
    async def asubmit_impact(
4✔
210
        self,
211
        impact_row: ImpactRow,
212
    ) -> Scope3AIContext:
213
        """
214
        Async version of Scope3AI::submit_impact.
215
        """
216

217
        if not self.sync_mode:
4✔
218
            # in non sync-mode, it uses the background worker,
219
            # and the background worker is not async (does not have to be).
220
            # so we just redirect the call to the sync version.
221
            return self.submit_impact(impact_row)
4✔
222

223
        tracer = self.current_tracer
4✔
224
        ctx = Scope3AIContext(request=impact_row)
4✔
225
        ctx._tracer = tracer
4✔
226
        if tracer:
4✔
227
            tracer._link_trace(ctx)
×
228

229
        response = await self._async_client.impact(
4✔
230
            rows=[impact_row],
231
            with_response=True,
232
        )
233
        ctx.set_impact(response.rows[0])
4✔
234
        if tracer:
4✔
235
            tracer.add_impact(response.rows[0])
×
236
            tracer._unlink_trace(ctx)
×
237

238
        return ctx
4✔
239

240
    @property
4✔
241
    def root_tracer(self):
4✔
242
        """
243
        Return the root tracer.
244

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

248
        Returns:
249
            Tracer: The root tracer if available, otherwise None.
250
        """
251
        tracers = self._tracer.get()
×
252
        return tracers[0] if tracers else None
×
253

254
    @property
4✔
255
    def current_tracer(self):
4✔
256
        """
257
        Return the current tracer.
258

259
        The current tracer is the last tracer in the current execution context
260
        (tracer stack). If no tracers are currently active, it returns None.
261

262
        Returns:
263
            Tracer: The current tracer if available, otherwise None.
264
        """
265
        tracers = self._tracer.get()
4✔
266
        return tracers[-1] if tracers else None
4✔
267

268
    @contextmanager
4✔
269
    def trace(self):
4✔
270
        tracer = Tracer()
4✔
271
        try:
4✔
272
            self._push_tracer(tracer)
4✔
273
            yield tracer
4✔
274
        finally:
275
            self._pop_tracer(tracer)
4✔
276

277
    def close(self):
4✔
278
        if self._worker:
4✔
279
            self._worker.kill()
4✔
280
        self.__class__._instance = None
4✔
281

282
    #
283
    # Internals
284
    #
285

286
    def _push_tracer(self, tracer: Tracer) -> None:
4✔
287
        tracer._link_parent(self.current_tracer)
4✔
288
        self._tracer.get().append(tracer)
4✔
289

290
    def _pop_tracer(self, tracer: Tracer) -> None:
4✔
291
        self._tracer.get().remove(tracer)
4✔
292
        tracer._unlink_parent(self.current_tracer)
4✔
293

294
    def _init_providers(self, providers: List[str]) -> None:
4✔
295
        """Initialize the specified providers."""
296
        for provider in providers:
4✔
297
            if provider not in _INSTRUMENTS:
4✔
298
                raise Scope3AIError(
×
299
                    f"Could not find tracer for the `{provider}` provider."
300
                )
301
            if provider in self._providers:
4✔
302
                # already initialized
303
                continue
4✔
304
            init_func = _INSTRUMENTS[provider]
4✔
305
            init_func()
4✔
306
            self._providers.append(provider)
4✔
307

308
    def _ensure_worker(self) -> None:
4✔
309
        if not self._worker:
4✔
310
            self._worker = BackgroundWorker(-1)
4✔
311

312
    def _init_logging(self) -> None:
4✔
313
        logging.basicConfig(
4✔
314
            level=logging.INFO,
315
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
316
            handlers=[logging.StreamHandler()],
317
        )
318
        logging.getLogger("scope3ai").setLevel(logging.DEBUG)
4✔
319

320
    def _init_atexit(self):
4✔
321
        @atexit.register
4✔
322
        def _shutdown():
4✔
323
            # do not reinstanciate the singleton here if somehow it was deleted
324
            scope3ai = Scope3AI._instance
×
325
            if not scope3ai:
×
326
                return
×
327
            if scope3ai._worker and scope3ai._worker._queue:
×
328
                logging.debug("Waiting background informations to be processed")
×
329
                scope3ai._worker._queue.join()
×
330
                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