arcade-core 2.3.0__py3-none-any.whl → 2.5.0rc1__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.
arcade_core/executor.py CHANGED
@@ -6,11 +6,9 @@ from typing import Any
6
6
  from pydantic import BaseModel, ValidationError
7
7
 
8
8
  from arcade_core.errors import (
9
- RetryableToolError,
10
9
  ToolInputError,
11
10
  ToolOutputError,
12
11
  ToolRuntimeError,
13
- ToolSerializationError,
14
12
  )
15
13
  from arcade_core.output import output_factory
16
14
  from arcade_core.schema import (
@@ -69,31 +67,26 @@ class ToolExecutor:
69
67
  # return the output
70
68
  return output_factory.success(data=output, logs=tool_call_logs)
71
69
 
72
- except RetryableToolError as e:
73
- return output_factory.fail_retry(
74
- message=e.message,
75
- developer_message=e.developer_message,
76
- additional_prompt_content=e.additional_prompt_content,
77
- retry_after_ms=e.retry_after_ms,
78
- )
79
-
80
- except ToolSerializationError as e:
81
- return output_factory.fail(message=e.message, developer_message=e.developer_message)
82
-
83
- # should catch all tool exceptions due to the try/except in the tool decorator
84
70
  except ToolRuntimeError as e:
71
+ e.with_context(func.__name__)
85
72
  return output_factory.fail(
86
73
  message=e.message,
87
74
  developer_message=e.developer_message,
88
- traceback_info=e.traceback_info(),
75
+ stacktrace=e.stacktrace(),
76
+ additional_prompt_content=getattr(e, "additional_prompt_content", None),
77
+ retry_after_ms=getattr(e, "retry_after_ms", None),
78
+ kind=e.kind,
79
+ can_retry=e.can_retry,
80
+ status_code=e.status_code,
81
+ extra=e.extra,
89
82
  )
90
83
 
91
84
  # if we get here we're in trouble
92
85
  except Exception as e:
93
86
  return output_factory.fail(
94
- message="Error in execution",
87
+ message=f"Error in execution of '{func.__name__}'",
95
88
  developer_message=str(e),
96
- traceback_info=traceback.format_exc(),
89
+ stacktrace=traceback.format_exc(),
97
90
  )
98
91
 
99
92
  @staticmethod
arcade_core/output.py CHANGED
@@ -1,7 +1,8 @@
1
- from typing import TypeVar
1
+ from typing import Any, TypeVar
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
+ from arcade_core.errors import ErrorKind
5
6
  from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput
6
7
  from arcade_core.utils import coerce_empty_list_to_none
7
8
 
@@ -25,14 +26,27 @@ class ToolOutputFactory:
25
26
 
26
27
  The executor guarantees that `data` is either a string, a dict, or None.
27
28
  """
28
- value: str | int | float | bool | dict | list[str] | None
29
+ value: str | int | float | bool | dict | list | None
29
30
  if data is None:
30
31
  value = ""
31
32
  elif hasattr(data, "result"):
32
- value = getattr(data, "result", "")
33
+ result = getattr(data, "result", "")
34
+ # Handle None result the same way as None data
35
+ if result is None:
36
+ value = ""
37
+ # If the result is a BaseModel (e.g., from TypedDict conversion), convert to dict
38
+ elif isinstance(result, BaseModel):
39
+ value = result.model_dump()
40
+ # If the result is a list, check if it contains BaseModel objects
41
+ elif isinstance(result, list):
42
+ value = [
43
+ item.model_dump() if isinstance(item, BaseModel) else item for item in result
44
+ ]
45
+ else:
46
+ value = result
33
47
  elif isinstance(data, BaseModel):
34
48
  value = data.model_dump()
35
- elif isinstance(data, (str, int, float, bool, list)):
49
+ elif isinstance(data, (str, int, float, bool, list, dict)):
36
50
  value = data
37
51
  else:
38
52
  raise ValueError(f"Unsupported data output type: {type(data)}")
@@ -48,15 +62,26 @@ class ToolOutputFactory:
48
62
  *,
49
63
  message: str,
50
64
  developer_message: str | None = None,
51
- traceback_info: str | None = None,
65
+ stacktrace: str | None = None,
52
66
  logs: list[ToolCallLog] | None = None,
67
+ additional_prompt_content: str | None = None,
68
+ retry_after_ms: int | None = None,
69
+ kind: ErrorKind = ErrorKind.UNKNOWN,
70
+ can_retry: bool = False,
71
+ status_code: int | None = None,
72
+ extra: dict[str, Any] | None = None,
53
73
  ) -> ToolCallOutput:
54
74
  return ToolCallOutput(
55
75
  error=ToolCallError(
56
76
  message=message,
57
77
  developer_message=developer_message,
58
- can_retry=False,
59
- traceback_info=traceback_info,
78
+ can_retry=can_retry,
79
+ additional_prompt_content=additional_prompt_content,
80
+ retry_after_ms=retry_after_ms,
81
+ stacktrace=stacktrace,
82
+ kind=kind,
83
+ status_code=status_code,
84
+ extra=extra,
60
85
  ),
61
86
  logs=coerce_empty_list_to_none(logs),
62
87
  )
@@ -68,9 +93,17 @@ class ToolOutputFactory:
68
93
  developer_message: str | None = None,
69
94
  additional_prompt_content: str | None = None,
70
95
  retry_after_ms: int | None = None,
71
- traceback_info: str | None = None,
96
+ stacktrace: str | None = None,
72
97
  logs: list[ToolCallLog] | None = None,
98
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_RETRY,
99
+ status_code: int = 500,
100
+ extra: dict[str, Any] | None = None,
73
101
  ) -> ToolCallOutput:
102
+ """
103
+ DEPRECATED: Use ToolOutputFactory.fail instead.
104
+ This method will be removed in version 3.0.0
105
+ """
106
+
74
107
  return ToolCallOutput(
75
108
  error=ToolCallError(
76
109
  message=message,
@@ -78,7 +111,10 @@ class ToolOutputFactory:
78
111
  can_retry=True,
79
112
  additional_prompt_content=additional_prompt_content,
80
113
  retry_after_ms=retry_after_ms,
81
- traceback_info=traceback_info,
114
+ stacktrace=stacktrace,
115
+ kind=kind,
116
+ status_code=status_code,
117
+ extra=extra,
82
118
  ),
83
119
  logs=coerce_empty_list_to_none(logs),
84
120
  )
arcade_core/parse.py CHANGED
@@ -36,6 +36,18 @@ def get_function_name_if_decorated(
36
36
  and isinstance(decorator.func, ast.Name)
37
37
  and decorator.func.id in decorator_ids
38
38
  )
39
+ # Support MCPApp tools. e.g., @app.tool or @app.tool(...)
40
+ or (
41
+ isinstance(decorator, ast.Attribute)
42
+ and decorator.attr == "tool"
43
+ and isinstance(decorator.value, ast.Name)
44
+ )
45
+ or (
46
+ isinstance(decorator, ast.Call)
47
+ and isinstance(decorator.func, ast.Attribute)
48
+ and decorator.func.attr == "tool"
49
+ and isinstance(decorator.func.value, ast.Name)
50
+ )
39
51
  ):
40
52
  return node.name
41
53
  return None
arcade_core/schema.py CHANGED
@@ -1,3 +1,21 @@
1
+ """
2
+ Arcade Core Schema
3
+
4
+ Defines transport-agnostic tool schemas and runtime context protocols used
5
+ across Arcade libraries. This includes:
6
+
7
+ - Tool and toolkit specifications (parameters, outputs, requirements)
8
+ - Transport-agnostic ToolContext carrying authorization, secrets, metadata
9
+ - Runtime ModelContext Protocol and its namespaced sub-protocols for logs,
10
+ progress, resources, tools, prompts, sampling, UI, and notifications
11
+
12
+ Note: ToolContext does not embed runtime capabilities; those are provided by
13
+ implementations of ModelContext (e.g., in arcade-mcp-server) that subclasses ToolContext
14
+ to expose the namespaced APIs to tools without changing function signatures.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
1
19
  import os
2
20
  from dataclasses import dataclass
3
21
  from enum import Enum
@@ -5,6 +23,8 @@ from typing import Any, Literal
5
23
 
6
24
  from pydantic import BaseModel, Field
7
25
 
26
+ from arcade_core.errors import ErrorKind
27
+
8
28
  # allow for custom tool name separator
9
29
  TOOL_NAME_SEPARATOR = os.getenv("ARCADE_TOOL_NAME_SEPARATOR", ".")
10
30
 
@@ -21,10 +41,10 @@ class ValueSchema(BaseModel):
21
41
  enum: list[str] | None = None
22
42
  """The list of possible values for the value, if it is a closed list."""
23
43
 
24
- properties: dict[str, "ValueSchema"] | None = None
44
+ properties: dict[str, ValueSchema] | None = None
25
45
  """For object types (json), the schema of nested properties."""
26
46
 
27
- inner_properties: dict[str, "ValueSchema"] | None = None
47
+ inner_properties: dict[str, ValueSchema] | None = None
28
48
  """For array types with json items, the schema of properties for each array item."""
29
49
 
30
50
  description: str | None = None
@@ -98,7 +118,7 @@ class ToolAuthRequirement(BaseModel):
98
118
  # or
99
119
  # client.auth.authorize(provider=AuthProvider.google, scopes=["profile", "email"])
100
120
  #
101
- # The Arcade SDK translates these into the appropriate provider ID (Google) and type (OAuth2).
121
+ # The Arcade TDK translates these into the appropriate provider ID (Google) and type (OAuth2).
102
122
  # The only time the developer will set these is if they are using a custom auth provider.
103
123
  provider_id: str | None = None
104
124
  """The provider ID configured in Arcade that acts as an alias to well-known configuration."""
@@ -198,7 +218,7 @@ class FullyQualifiedName:
198
218
  (self.toolkit_version or "").lower(),
199
219
  ))
200
220
 
201
- def equals_ignoring_version(self, other: "FullyQualifiedName") -> bool:
221
+ def equals_ignoring_version(self, other: FullyQualifiedName) -> bool:
202
222
  """Check if two fully-qualified tool names are equal, ignoring the version."""
203
223
  return (
204
224
  self.name.lower() == other.name.lower()
@@ -206,7 +226,7 @@ class FullyQualifiedName:
206
226
  )
207
227
 
208
228
  @staticmethod
209
- def from_toolkit(tool_name: str, toolkit: ToolkitDefinition) -> "FullyQualifiedName":
229
+ def from_toolkit(tool_name: str, toolkit: ToolkitDefinition) -> FullyQualifiedName:
210
230
  """Creates a fully-qualified tool name from a tool name and a ToolkitDefinition."""
211
231
  return FullyQualifiedName(tool_name, toolkit.name, toolkit.version)
212
232
 
@@ -296,7 +316,16 @@ class ToolMetadataItem(BaseModel):
296
316
 
297
317
 
298
318
  class ToolContext(BaseModel):
299
- """The context for a tool invocation."""
319
+ """The context for a tool invocation.
320
+
321
+ This type is transport-agnostic and contains only authorization,
322
+ secret, and metadata information needed by the tool. Runtime-specific
323
+ capabilities (logging, resources, etc.) are provided by a separate
324
+ runtime context that wraps this object.
325
+
326
+ Recommendation: For new tools, annotate the parameter as
327
+ `arcade_mcp_server.Context` to access namespaced runtime APIs directly.
328
+ """
300
329
 
301
330
  authorization: ToolAuthorizationContext | None = None
302
331
  """The authorization context for the tool invocation that requires authorization."""
@@ -310,16 +339,35 @@ class ToolContext(BaseModel):
310
339
  user_id: str | None = None
311
340
  """The user ID for the tool invocation (if any)."""
312
341
 
342
+ model_config = {"arbitrary_types_allowed": True}
343
+
344
+ def set_secret(self, key: str, value: str) -> None:
345
+ """Add or update a secret to the tool context."""
346
+ if self.secrets is None:
347
+ self.secrets = []
348
+ # Update existing or add new
349
+ for secret in self.secrets:
350
+ if secret.key == key:
351
+ secret.value = value
352
+ return
353
+ self.secrets.append(ToolSecretItem(key=key, value=value))
354
+
313
355
  def get_auth_token_or_empty(self) -> str:
314
356
  """Retrieve the authorization token, or return an empty string if not available."""
315
357
  return self.authorization.token if self.authorization and self.authorization.token else ""
316
358
 
317
359
  def get_secret(self, key: str) -> str:
318
- """Retrieve the secret for the tool invocation."""
360
+ """Retrieve the secret for the tool invocation.
361
+
362
+ Raises a ValueError if the secret is not found.
363
+ """
319
364
  return self._get_item(key, self.secrets, "secret")
320
365
 
321
366
  def get_metadata(self, key: str) -> str:
322
- """Retrieve the metadata for the tool invocation."""
367
+ """Retrieve the metadata for the tool invocation.
368
+
369
+ Raises a ValueError if the metadata is not found.
370
+ """
323
371
  return self._get_item(key, self.metadata, "metadata")
324
372
 
325
373
  def _get_item(
@@ -333,21 +381,14 @@ class ToolContext(BaseModel):
333
381
  f"{item_name.capitalize()} key passed to get_{item_name} cannot be empty."
334
382
  )
335
383
  if not items:
336
- raise ValueError(f"{item_name.capitalize()}s not found in context.")
384
+ raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.")
337
385
 
338
386
  normalized_key = key.lower()
339
387
  for item in items:
340
388
  if item.key.lower() == normalized_key:
341
389
  return item.value
342
390
 
343
- raise ValueError(f"{item_name.capitalize()} {key} not found in context.")
344
-
345
- def set_secret(self, key: str, value: str) -> None:
346
- """Set a secret for the tool invocation."""
347
- if not self.secrets:
348
- self.secrets = []
349
- secret = ToolSecretItem(key=str(key), value=str(value))
350
- self.secrets.append(secret)
391
+ raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.")
351
392
 
352
393
 
353
394
  class ToolCallRequest(BaseModel):
@@ -390,6 +431,8 @@ class ToolCallError(BaseModel):
390
431
 
391
432
  message: str
392
433
  """The user-facing error message."""
434
+ kind: ErrorKind
435
+ """The error kind that uniquely identifies the kind of error."""
393
436
  developer_message: str | None = None
394
437
  """The developer-facing error details."""
395
438
  can_retry: bool = False
@@ -398,8 +441,27 @@ class ToolCallError(BaseModel):
398
441
  """Additional content to be included in the retry prompt."""
399
442
  retry_after_ms: int | None = None
400
443
  """The number of milliseconds (if any) to wait before retrying the tool call."""
401
- traceback_info: str | None = None
402
- """The traceback information for the tool call."""
444
+ stacktrace: str | None = None
445
+ """The stacktrace information for the tool call."""
446
+ status_code: int | None = None
447
+ """The HTTP status code of the error."""
448
+ extra: dict[str, Any] | None = None
449
+ """Additional information about the error."""
450
+
451
+ @property
452
+ def is_toolkit_error(self) -> bool:
453
+ """Check if this error originated from loading a toolkit."""
454
+ return self.kind.name.startswith("TOOLKIT_")
455
+
456
+ @property
457
+ def is_tool_error(self) -> bool:
458
+ """Check if this error originated from a tool."""
459
+ return self.kind.name.startswith("TOOL_")
460
+
461
+ @property
462
+ def is_upstream_error(self) -> bool:
463
+ """Check if this error originated from an upstream service."""
464
+ return self.kind.name.startswith("UPSTREAM_")
403
465
 
404
466
 
405
467
  class ToolCallRequiresAuthorization(BaseModel):
@@ -418,7 +480,7 @@ class ToolCallRequiresAuthorization(BaseModel):
418
480
  class ToolCallOutput(BaseModel):
419
481
  """The output of a tool invocation."""
420
482
 
421
- value: str | int | float | bool | dict | list[str] | None = None
483
+ value: str | int | float | bool | dict | list | None = None
422
484
  """The value returned by the tool."""
423
485
  logs: list[ToolCallLog] | None = None
424
486
  """The logs that occurred during the tool invocation."""
arcade_core/toolkit.py CHANGED
@@ -6,6 +6,7 @@ import types
6
6
  from collections import defaultdict
7
7
  from pathlib import Path, PurePosixPath, PureWindowsPath
8
8
 
9
+ import toml
9
10
  from pydantic import BaseModel, ConfigDict, field_validator
10
11
 
11
12
  from arcade_core.errors import ToolkitLoadError
@@ -59,6 +60,71 @@ class Toolkit(BaseModel):
59
60
  """
60
61
  return cls.from_package(module.__name__)
61
62
 
63
+ @classmethod
64
+ def from_directory(cls, directory: Path) -> "Toolkit":
65
+ """
66
+ Load a Toolkit from a directory.
67
+ """
68
+ pyproject_path = directory / "pyproject.toml"
69
+ if not pyproject_path.exists():
70
+ raise ToolkitLoadError(f"pyproject.toml not found in {directory}")
71
+
72
+ try:
73
+ with open(pyproject_path) as f:
74
+ pyproject_data = toml.load(f)
75
+
76
+ project_data = pyproject_data.get("project", {})
77
+ name = project_data.get("name")
78
+ if not name:
79
+
80
+ def _missing_name_error() -> ToolkitLoadError:
81
+ return ToolkitLoadError("name not found in pyproject.toml")
82
+
83
+ raise _missing_name_error() # noqa: TRY301
84
+
85
+ package_name = name
86
+ version = project_data.get("version", "0.0.0")
87
+ description = project_data.get("description", "")
88
+ authors = project_data.get("authors", [])
89
+ author_names = [author.get("name", "") for author in authors]
90
+
91
+ # For homepage and repository, you might need to look under project.urls
92
+ urls = project_data.get("urls", {})
93
+ homepage = urls.get("Homepage")
94
+ repo = urls.get("Repository")
95
+
96
+ except Exception as e:
97
+ raise ToolkitLoadError(f"Failed to load metadata from {pyproject_path}: {e}")
98
+
99
+ # Determine the actual package directory (supports src/ layout and flat layout)
100
+ package_dir = directory
101
+ try:
102
+ src_candidate = directory / "src" / package_name
103
+ flat_candidate = directory / package_name
104
+ if src_candidate.is_dir():
105
+ package_dir = src_candidate
106
+ elif flat_candidate.is_dir():
107
+ package_dir = flat_candidate
108
+ else:
109
+ # Fallback to the provided directory; tools_from_directory will de-duplicate prefixes
110
+ package_dir = directory
111
+ except Exception:
112
+ package_dir = directory
113
+
114
+ toolkit = cls(
115
+ name=name,
116
+ package_name=package_name,
117
+ version=version,
118
+ description=description,
119
+ author=author_names,
120
+ homepage=homepage,
121
+ repository=repo,
122
+ )
123
+
124
+ toolkit.tools = cls.tools_from_directory(package_dir, package_name)
125
+
126
+ return toolkit
127
+
62
128
  @classmethod
63
129
  def from_package(cls, package: str) -> "Toolkit":
64
130
  """
@@ -232,9 +298,14 @@ class Toolkit(BaseModel):
232
298
  for module_path in modules:
233
299
  relative_path = module_path.relative_to(package_dir)
234
300
  cls.validate_file(module_path)
235
- import_path = ".".join(relative_path.with_suffix("").parts)
236
- import_path = f"{package_name}.{import_path}"
237
- tools[import_path] = get_tools_from_file(str(module_path))
301
+ # Build import path and avoid duplicating the package prefix if it already exists
302
+ relative_parts = relative_path.with_suffix("").parts
303
+ import_path = ".".join(relative_parts)
304
+ if relative_parts and relative_parts[0] == package_name:
305
+ full_import_path = import_path
306
+ else:
307
+ full_import_path = f"{package_name}.{import_path}" if import_path else package_name
308
+ tools[full_import_path] = get_tools_from_file(str(module_path))
238
309
 
239
310
  if not tools:
240
311
  raise ToolkitLoadError(f"No tools found in package {package_name}")
arcade_core/utils.py CHANGED
@@ -4,6 +4,7 @@ import ast
4
4
  import inspect
5
5
  import re
6
6
  from collections.abc import Callable, Iterable
7
+ from textwrap import dedent
7
8
  from types import UnionType
8
9
  from typing import Any, Literal, TypeVar, Union, get_args, get_origin
9
10
 
@@ -75,7 +76,9 @@ def does_function_return_value(func: Callable) -> bool:
75
76
  if source is None:
76
77
  raise ValueError("Source code not found")
77
78
 
78
- tree = ast.parse(source)
79
+ # dedent in case the function is an inner function
80
+ dedented_source = dedent(source)
81
+ tree = ast.parse(dedented_source)
79
82
 
80
83
  class ReturnVisitor(ast.NodeVisitor):
81
84
  def __init__(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade-core
3
- Version: 2.3.0
3
+ Version: 2.5.0rc1
4
4
  Summary: Arcade Core - Core library for Arcade platform
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: MIT
@@ -14,9 +14,6 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Requires-Dist: loguru>=0.7.0
17
- Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.28.2
18
- Requires-Dist: opentelemetry-exporter-otlp-proto-http==1.28.2
19
- Requires-Dist: opentelemetry-instrumentation-fastapi==0.49b2
20
17
  Requires-Dist: packaging>=24.1
21
18
  Requires-Dist: pydantic>=2.7.0
22
19
  Requires-Dist: pyjwt>=2.8.0
@@ -0,0 +1,21 @@
1
+ arcade_core/__init__.py,sha256=1heu3AROAjpistehPzY2H-2nkj_IjQEh-vVlVOCRF1E,88
2
+ arcade_core/annotations.py,sha256=Nst6aejLWXlpTu7GwzWETu1gQCG1XVAUR_qcFbNvyRc,198
3
+ arcade_core/auth.py,sha256=On9sJPOxvHjKBxgKC1yqp7oijF6KYBsG6fG8KUw-9OY,5882
4
+ arcade_core/catalog.py,sha256=QD7PLaKBW8eBaelj3_ax7s_pgKNnOYWEIsMLtqmbtjI,41819
5
+ arcade_core/config.py,sha256=e98XQAkYySGW9T_yrJg54BB8Wuq06GPVHp7xqe2d1vU,572
6
+ arcade_core/config_model.py,sha256=GYO37yKi7ih6EYKPpX1Kl-K1XwM2JyEJguyaJ7j9TY8,4260
7
+ arcade_core/context.py,sha256=J2MgbVznhJC2qarHq3dTL72W4NGYOM1pjXdI_YwgkA4,3316
8
+ arcade_core/discovery.py,sha256=PluKGhNtJ7RYjJuPDMB8LCNinQLKzlqoAtc3dwKb6IA,8397
9
+ arcade_core/errors.py,sha256=fsi7m6TQQSsdSNHl4rBoSN_YH3ZV910gjvBFqB207f4,13326
10
+ arcade_core/executor.py,sha256=aFRqB4OdC4b8JJN3zekx0hOWYmihWHAZqVWVlSFXzE4,4308
11
+ arcade_core/output.py,sha256=CMY1pHlQIR27Beiz2I-Yg1aO-P-pbsEbhBZ1RdYuflc,4040
12
+ arcade_core/parse.py,sha256=arKGKL9C6g__tRfZ4re6IM_wAqr1v3LrOzTOBEDLhDc,2366
13
+ arcade_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ arcade_core/schema.py,sha256=l1eE4__sliPIMOp70YxQMlEIpiYQWlBUWEUvr9K1wxA,16927
15
+ arcade_core/toolkit.py,sha256=UcZ151pC8zfIFzVMYxaq31H7M0f-2qprU0PkVAzfRtI,13815
16
+ arcade_core/utils.py,sha256=RxVIzURTtZ4nAWYB3FYGngqMMPmBBxf330Ez9eEoXaw,3109
17
+ arcade_core/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
18
+ arcade_core/converters/openai.py,sha256=4efdgTkvdwT44VGStBhdUmzCnoP5dysceIqPVVPG-vk,7408
19
+ arcade_core-2.5.0rc1.dist-info/METADATA,sha256=bGOwNQd_gIRNUKIe6vheQ6bJIYW51EsjvaPVUybWM6o,2373
20
+ arcade_core-2.5.0rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ arcade_core-2.5.0rc1.dist-info/RECORD,,
arcade_core/telemetry.py DELETED
@@ -1,130 +0,0 @@
1
- import logging
2
- import os
3
- import urllib.parse
4
- from typing import Optional
5
-
6
- from fastapi import FastAPI
7
- from opentelemetry import _logs, trace
8
- from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
9
- from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
10
- from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
11
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
12
- from opentelemetry.metrics import Meter, get_meter_provider, set_meter_provider
13
- from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
14
- from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
15
- from opentelemetry.sdk.metrics import MeterProvider
16
- from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
17
- from opentelemetry.sdk.resources import SERVICE_NAME, Resource
18
- from opentelemetry.sdk.trace import TracerProvider
19
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
20
-
21
-
22
- class ShutdownError(Exception):
23
- pass
24
-
25
-
26
- class OTELHandler:
27
- def __init__(self, enable: bool = True, log_level: int = logging.INFO):
28
- self.enable = enable
29
- self.log_level = log_level
30
- self._tracer_provider: Optional[TracerProvider] = None
31
- self._tracer_span_exporter: Optional[OTLPSpanExporter] = None
32
- self._meter_provider: Optional[MeterProvider] = None
33
- self._meter_reader: Optional[PeriodicExportingMetricReader] = None
34
- self._otlp_metric_exporter: Optional[OTLPMetricExporter] = None
35
- self._logger_provider: Optional[LoggerProvider] = None
36
- self._log_processor: Optional[BatchLogRecordProcessor] = None
37
- self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
38
-
39
- def instrument_app(self, app: FastAPI) -> None:
40
- if self.enable:
41
- logging.info(
42
- "🔎 Initializing OpenTelemetry. Use environment variables to configure the connection"
43
- )
44
- self.resource = Resource(
45
- attributes={SERVICE_NAME: "arcade-worker", "environment": self.environment}
46
- )
47
-
48
- self._init_tracer()
49
- self._init_metrics()
50
- self._init_logging(self.log_level)
51
- FastAPIInstrumentor().instrument_app(app)
52
-
53
- def _init_tracer(self) -> None:
54
- self._tracer_provider = TracerProvider(resource=self.resource)
55
- trace.set_tracer_provider(self._tracer_provider)
56
-
57
- # Create an OTLP exporter
58
- self._tracer_span_exporter = OTLPSpanExporter()
59
-
60
- try:
61
- self._tracer_span_exporter.export([trace.get_tracer(__name__).start_span("ping")])
62
- except Exception as e:
63
- raise ConnectionError(
64
- f"Could not connect to OpenTelemetry Tracer endpoint. Check OpenTelemetry configuration or disable: {e}"
65
- )
66
-
67
- # Create a batch span processor and add the exporter
68
- span_processor = BatchSpanProcessor(self._tracer_span_exporter)
69
- self._tracer_provider.add_span_processor(span_processor)
70
-
71
- def _init_metrics(self) -> None:
72
- self._otlp_metric_exporter = OTLPMetricExporter()
73
-
74
- self._meter_reader = PeriodicExportingMetricReader(self._otlp_metric_exporter)
75
-
76
- self._meter_provider = MeterProvider(
77
- metric_readers=[self._meter_reader], resource=self.resource
78
- )
79
-
80
- set_meter_provider(self._meter_provider)
81
-
82
- def get_meter(self) -> Meter:
83
- return get_meter_provider().get_meter(__name__)
84
-
85
- def _init_logging(self, log_level: int) -> None:
86
- otlp_log_exporter = OTLPLogExporter()
87
-
88
- self._logger_provider = LoggerProvider(resource=self.resource)
89
- _logs.set_logger_provider(self._logger_provider)
90
-
91
- # Create a batch span processor and add the exporter
92
- self._log_processor = BatchLogRecordProcessor(otlp_log_exporter)
93
- self._logger_provider.add_log_record_processor(self._log_processor)
94
-
95
- handler = LoggingHandler(level=log_level, logger_provider=self._logger_provider)
96
- logging.getLogger().addHandler(handler)
97
-
98
- # Create a filter for urllib3 connection logs related to OpenTelemetry
99
- class OTELConnectionFilter(logging.Filter):
100
- def filter(self, record: logging.LogRecord) -> bool:
101
- # Filter out connection logs to OpenTelemetry endpoints
102
- parsed_url = urllib.parse.urlparse(
103
- os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
104
- )
105
- domain = parsed_url.netloc.split(":")[0]
106
- return not (domain and domain in str(getattr(record, "args", ())))
107
-
108
- # Apply the filter to the urllib3 logger
109
- urllib3_logger = logging.getLogger("urllib3.connectionpool")
110
- urllib3_logger.addFilter(OTELConnectionFilter())
111
-
112
- def _shutdown_tracer(self) -> None:
113
- if self._tracer_span_exporter is None:
114
- raise ShutdownError("Tracer provider not initialized. Failed to shutdown")
115
- self._tracer_span_exporter.shutdown()
116
-
117
- def _shutdown_metrics(self) -> None:
118
- if self._otlp_metric_exporter is None:
119
- raise ShutdownError("Meter provider not initialized. Failed to shutdown")
120
- self._otlp_metric_exporter.shutdown()
121
-
122
- def _shutdown_logging(self) -> None:
123
- if self._logger_provider is None:
124
- raise ShutdownError("Log provider not initialized. Failed to shutdown")
125
- self._logger_provider.shutdown()
126
-
127
- def shutdown(self) -> None:
128
- self._shutdown_tracer()
129
- self._shutdown_metrics()
130
- self._shutdown_logging()