chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
from .uuid import generate_short_id, generate_uuid
|
||||
|
||||
|
||||
class DeprecationError(Exception):
|
||||
"""Raised when deprecating some older functions. This is strictly for use
|
||||
while developing and will be removed from the codebase later."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def deprecate(reason: str = "This function is deprecated"):
|
||||
"""Deprecation decorator. Provide `reason` to show why you're deprecating something.
|
||||
NOTE: Decorating something with this will ensure that the function _will not run._
|
||||
"""
|
||||
import functools
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
raise DeprecationError(f"{func.__name__} is deprecated: `{reason}`")
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DeprecationError",
|
||||
"deprecate",
|
||||
"generate_short_id",
|
||||
"generate_uuid",
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Inline internal JSON Schema ``$ref`` pointers.
|
||||
|
||||
Python counterpart of the TypeScript SDK's ``dereferenceJsonSchema``
|
||||
(``ts/packages/core/src/utils/jsonSchema.ts``). Hand-rolled schema walkers in
|
||||
the SDK (e.g. the file-upload/download substitution in
|
||||
:mod:`composio.core.models._files`) recurse through ``properties``/``anyOf``/
|
||||
``oneOf``/``allOf``/``items`` but do not dereference ``$ref``. The Composio API
|
||||
emits tool parameter schemas with ``$ref``/``$defs`` indirection (``GMAIL_GET_
|
||||
ATTACHMENT`` is the canonical shape), so a ``file_uploadable``/``file_
|
||||
downloadable`` flag reachable only through a reference is silently missed.
|
||||
|
||||
Resolving references *once* at the boundary — rather than teaching every walker
|
||||
to dereference — keeps the walkers reference-agnostic and gives all of them
|
||||
``$ref`` support for free, including keywords they never special-cased
|
||||
(``additionalProperties``, ``patternProperties``, ``prefixItems``, ``not``, …),
|
||||
because :func:`dereference_json_schema` walks containers reflectively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio.exceptions import JSONSchemaRefResolutionError
|
||||
from composio.utils.logging import get as get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# A ``$ref`` chain longer than this, or object nesting deeper than this, is
|
||||
# treated as pathological (cyclic data, a billion-laughs-style schema) and
|
||||
# raises rather than exhausting the interpreter's recursion limit. The caps
|
||||
# fire in both strict and lenient modes — leniency applies to *unresolvable*
|
||||
# refs, not to runaway expansion.
|
||||
MAX_REF_CHAIN_DEPTH = 100
|
||||
MAX_NODE_DEPTH = 512
|
||||
|
||||
# In-band hint attached to the sentinel when lenient mode stands in for an
|
||||
# unresolvable ``$ref``. Makes the degradation visible to an LLM reading the
|
||||
# schema instead of leaving it a bare permissive object. Overridden by a
|
||||
# caller-provided ``description`` sibling (Draft 2020-12 sibling-keyword merge).
|
||||
UNRESOLVED_REF_DESCRIPTION = (
|
||||
"Schema shape unresolved at the source — validate loosely. "
|
||||
"See https://github.com/ComposioHQ/composio/issues/3307."
|
||||
)
|
||||
|
||||
# Strategy when an internal ``$ref`` cannot be resolved.
|
||||
# - ``"throw"`` (default): raise ``JSONSchemaRefResolutionError``. Right for
|
||||
# first-party / custom-tool schemas where a dangling ``$ref`` is a bug.
|
||||
# - ``"sentinel"``: replace the node with a permissive object. Right for
|
||||
# schemas from an upstream service the caller cannot edit (the Composio API
|
||||
# ships some ``output_parameters`` with a ``$ref`` into ``#/$defs/...`` but no
|
||||
# ``$defs`` block — see https://github.com/ComposioHQ/composio/issues/3307).
|
||||
UnresolvedRefStrategy = t.Literal["throw", "sentinel"]
|
||||
UnresolvedRefReason = t.Literal["missing-target", "malformed-pointer"]
|
||||
|
||||
# Callback invoked once per replaced node in ``"sentinel"`` mode.
|
||||
OnReplace = t.Callable[[str, "UnresolvedRefReason"], None]
|
||||
|
||||
|
||||
def _cycle_break_sentinel() -> t.Dict[str, t.Any]:
|
||||
"""A fresh permissive object used to break cycles / stand in for danglers."""
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
|
||||
|
||||
def _is_mapping(value: t.Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def _decode_pointer_segment(segment: str) -> str:
|
||||
# Order matters: ``~01`` must decode to ``~1`` (literal), not ``/``.
|
||||
return segment.replace("~1", "/").replace("~0", "~")
|
||||
|
||||
|
||||
class _Resolution(t.NamedTuple):
|
||||
ok: bool
|
||||
value: t.Any = None
|
||||
reason: t.Optional[UnresolvedRefReason] = None
|
||||
failed_at: t.Optional[str] = None
|
||||
|
||||
|
||||
def _try_resolve_pointer(root: t.Dict[str, t.Any], pointer: str) -> _Resolution:
|
||||
"""Walk a local JSON Pointer (``#/a/b/0``) against ``root``.
|
||||
|
||||
Returns a tagged result so the caller can distinguish a malformed pointer
|
||||
from a structurally valid pointer whose target is absent.
|
||||
"""
|
||||
if pointer in ("#", ""):
|
||||
return _Resolution(ok=True, value=root)
|
||||
if not pointer.startswith("#/"):
|
||||
return _Resolution(ok=False, reason="malformed-pointer")
|
||||
|
||||
cursor: t.Any = root
|
||||
for raw_segment in pointer[2:].split("/"):
|
||||
segment = _decode_pointer_segment(raw_segment)
|
||||
if isinstance(cursor, list):
|
||||
try:
|
||||
index = int(segment)
|
||||
except ValueError:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
if index < 0 or index >= len(cursor):
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
cursor = cursor[index]
|
||||
elif isinstance(cursor, dict):
|
||||
if segment not in cursor:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
cursor = cursor[segment]
|
||||
else:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
return _Resolution(ok=True, value=cursor)
|
||||
|
||||
|
||||
def _raise_resolution_error(pointer: str, resolution: _Resolution) -> t.NoReturn:
|
||||
if resolution.reason == "malformed-pointer":
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"Unsupported $ref pointer: {pointer}",
|
||||
meta={"ref": pointer},
|
||||
)
|
||||
meta: t.Dict[str, t.Any] = {"ref": pointer}
|
||||
if resolution.failed_at is not None:
|
||||
meta["failed_at"] = resolution.failed_at
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"Cannot resolve $ref {pointer}",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
def dereference_json_schema(
|
||||
schema: t.Any,
|
||||
*,
|
||||
on_unresolved: UnresolvedRefStrategy = "throw",
|
||||
on_replace: t.Optional[OnReplace] = None,
|
||||
) -> t.Any:
|
||||
"""Inline internal ``$ref`` pointers (``#/$defs/...`` and legacy
|
||||
``#/definitions/...``), returning a new schema; the input is never mutated.
|
||||
|
||||
External refs (``http://``, ``https://``, …) are left untouched (and logged
|
||||
once for audit, since a downstream resolver fetching them could enable SSRF
|
||||
or local-file disclosure). Cycles — both ``$ref`` cycles and JS-object-style
|
||||
identity cycles — are broken with a permissive ``{"type": "object",
|
||||
"additionalProperties": True}`` sentinel. ``$defs``/``definitions`` are
|
||||
stripped from the returned root once everything is inlined.
|
||||
|
||||
:param on_unresolved: see :data:`UnresolvedRefStrategy`.
|
||||
:param on_replace: invoked once per replaced node in ``"sentinel"`` mode,
|
||||
with the original pointer and the reason it could not be resolved.
|
||||
:raises JSONSchemaRefResolutionError: on malformed pointers or missing
|
||||
targets (strict mode only), or chains/nesting past the safety caps
|
||||
(both modes).
|
||||
"""
|
||||
if not _is_mapping(schema):
|
||||
return schema
|
||||
|
||||
root = t.cast(t.Dict[str, t.Any], schema)
|
||||
# Identity-based cycle guard for the *current* walk path. Python doesn't
|
||||
# suffer JS-style prototype pollution, so unlike the TS port there's no
|
||||
# need to filter ``__proto__``/``constructor`` keys while cloning.
|
||||
visiting: t.Set[int] = set()
|
||||
|
||||
# ``walk`` uses explicit loops (not comprehensions) so each level of schema
|
||||
# nesting costs exactly one Python stack frame. That keeps ``MAX_NODE_DEPTH``
|
||||
# the binding limit under the interpreter's default recursion ceiling, so a
|
||||
# pathologically deep schema fails with our typed error rather than a bare
|
||||
# ``RecursionError`` (which is also caught at the call site as a backstop).
|
||||
def walk(
|
||||
node: t.Any,
|
||||
visited_refs: t.FrozenSet[str],
|
||||
chain_depth: int,
|
||||
node_depth: int,
|
||||
) -> t.Any:
|
||||
if node_depth >= MAX_NODE_DEPTH:
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"JSON Schema node depth exceeded cap ({MAX_NODE_DEPTH})"
|
||||
)
|
||||
|
||||
if isinstance(node, list):
|
||||
node_id = id(node)
|
||||
if node_id in visiting:
|
||||
return _cycle_break_sentinel()
|
||||
visiting.add(node_id)
|
||||
try:
|
||||
cloned_list: t.List[t.Any] = []
|
||||
for item in node:
|
||||
cloned_list.append(
|
||||
walk(item, visited_refs, chain_depth, node_depth + 1)
|
||||
)
|
||||
return cloned_list
|
||||
finally:
|
||||
visiting.discard(node_id)
|
||||
|
||||
if not _is_mapping(node):
|
||||
return node
|
||||
|
||||
node_id = id(node)
|
||||
if node_id in visiting:
|
||||
return _cycle_break_sentinel()
|
||||
visiting.add(node_id)
|
||||
try:
|
||||
ref = node.get("$ref")
|
||||
ref = ref if isinstance(ref, str) else None
|
||||
|
||||
# External refs and non-``$ref`` nodes both pass through the same
|
||||
# reflective clone path.
|
||||
if ref is None or not ref.startswith("#"):
|
||||
if ref is not None:
|
||||
logger.warning("Leaving external $ref untouched: %s", ref)
|
||||
cloned: t.Dict[str, t.Any] = {}
|
||||
for key, value in node.items():
|
||||
cloned[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
||||
return cloned
|
||||
|
||||
if chain_depth >= MAX_REF_CHAIN_DEPTH:
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"JSON Schema $ref chain exceeded depth cap "
|
||||
f"({MAX_REF_CHAIN_DEPTH}): {ref}",
|
||||
meta={"ref": ref},
|
||||
)
|
||||
if ref in visited_refs:
|
||||
return _cycle_break_sentinel()
|
||||
|
||||
resolution = _try_resolve_pointer(root, ref)
|
||||
if resolution.ok:
|
||||
target: t.Any = resolution.value
|
||||
elif on_unresolved == "sentinel":
|
||||
if on_replace is not None and resolution.reason is not None:
|
||||
on_replace(ref, resolution.reason)
|
||||
target = {
|
||||
**_cycle_break_sentinel(),
|
||||
"description": UNRESOLVED_REF_DESCRIPTION,
|
||||
}
|
||||
else:
|
||||
_raise_resolution_error(ref, resolution)
|
||||
|
||||
next_refs = visited_refs | {ref}
|
||||
resolved = walk(target, next_refs, chain_depth + 1, node_depth + 1)
|
||||
|
||||
# Shallow-merge sibling keywords next to ``$ref`` (Draft 2020-12:
|
||||
# siblings win on collision). Draft 7 ignores them, but the Composio
|
||||
# tool surface admits both, so we honor siblings for safety.
|
||||
siblings = {key: value for key, value in node.items() if key != "$ref"}
|
||||
if not siblings or not _is_mapping(resolved):
|
||||
return resolved
|
||||
merged = dict(resolved)
|
||||
for key, value in siblings.items():
|
||||
merged[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
||||
return merged
|
||||
finally:
|
||||
visiting.discard(node_id)
|
||||
|
||||
try:
|
||||
out = walk(root, frozenset(), 0, 0)
|
||||
except RecursionError as exc: # pragma: no cover - depth cap normally fires first
|
||||
raise JSONSchemaRefResolutionError(
|
||||
"JSON Schema nesting too deep to dereference"
|
||||
) from exc
|
||||
if _is_mapping(out):
|
||||
out.pop("$defs", None)
|
||||
out.pop("definitions", None)
|
||||
return out
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Logging utilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import typing as t
|
||||
from enum import Enum
|
||||
|
||||
ENV_COMPOSIO_LOGGING_LEVEL = "COMPOSIO_LOGGING_LEVEL"
|
||||
|
||||
_DEFAULT_FORMAT = "[%(asctime)s][%(levelname)s] %(message)s"
|
||||
_DEFAULT_LOGGER_NAME = "composio"
|
||||
|
||||
_LOG_VERBOSITY = int(os.environ.get("COMPOSIO_LOG_VERBOSITY", 0))
|
||||
_LOG_LINE_SIZE_BY_VERBOSITY = {
|
||||
0: 256,
|
||||
1: 512,
|
||||
2: 1024,
|
||||
3: -1,
|
||||
}
|
||||
|
||||
_logger: t.Optional[logging.Logger] = None
|
||||
"""Global logger object for composio."""
|
||||
|
||||
_logger_wrapper: t.Optional["_VerbosityWrapper"] = None
|
||||
"""Global logger object wrapped with verbosity setting for composio."""
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Logging level."""
|
||||
|
||||
CRITICAL = "critical"
|
||||
FATAL = "fatal"
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
WARN = "warn"
|
||||
INFO = "info"
|
||||
DEBUG = "debug"
|
||||
NOTSET = "notset"
|
||||
|
||||
|
||||
_LEVELS: t.Dict[LogLevel, int] = {
|
||||
LogLevel.CRITICAL: logging.CRITICAL,
|
||||
LogLevel.FATAL: logging.FATAL,
|
||||
LogLevel.ERROR: logging.ERROR,
|
||||
LogLevel.WARNING: logging.WARNING,
|
||||
LogLevel.WARN: logging.WARN,
|
||||
LogLevel.INFO: logging.INFO,
|
||||
LogLevel.DEBUG: logging.DEBUG,
|
||||
LogLevel.NOTSET: logging.NOTSET,
|
||||
}
|
||||
|
||||
|
||||
class _VerbosityWrapper:
|
||||
def __init__(
|
||||
self,
|
||||
logger: logging.Logger,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> None:
|
||||
self.logger = logger
|
||||
self.level = logger.level
|
||||
self.setup(verbosity_level=verbosity_level)
|
||||
self.verbosity = min(verbosity_level or _LOG_VERBOSITY, 3)
|
||||
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
|
||||
|
||||
def setup(self, verbosity_level: t.Optional[int] = None) -> None:
|
||||
if verbosity_level is None:
|
||||
return
|
||||
self.verbosity = verbosity_level
|
||||
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
|
||||
|
||||
def _trim(self, msg) -> str:
|
||||
msg = str(msg)
|
||||
if self.size == -1:
|
||||
return msg
|
||||
|
||||
if len(msg) < self.size:
|
||||
return msg
|
||||
|
||||
return msg[: self.size] + "..."
|
||||
|
||||
def info(self, msg, *args, **kwargs):
|
||||
self.logger.info(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def debug(self, msg, *args, **kwargs):
|
||||
self.logger.debug(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def warning(self, msg, *args, **kwargs):
|
||||
self.logger.warning(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def error(self, msg, *args, **kwargs):
|
||||
self.logger.error(msg, *args, **kwargs)
|
||||
|
||||
def isEnabledFor(self, level: int):
|
||||
return self.logger.isEnabledFor(level=level)
|
||||
|
||||
|
||||
def _parse_log_level_from_env(default: int) -> int:
|
||||
"""Parse log level from environment."""
|
||||
level = os.environ.get(ENV_COMPOSIO_LOGGING_LEVEL)
|
||||
if level is None:
|
||||
return default
|
||||
|
||||
try:
|
||||
return _LEVELS[LogLevel(level.lower())]
|
||||
except (ValueError, KeyError):
|
||||
return default
|
||||
|
||||
|
||||
def setup(level: LogLevel = LogLevel.INFO, log_format: str = _DEFAULT_FORMAT) -> None:
|
||||
"""Setup logging config."""
|
||||
global _logger_wrapper
|
||||
if _logger_wrapper is None:
|
||||
_logger_wrapper = get(name=_DEFAULT_LOGGER_NAME)
|
||||
|
||||
logging.basicConfig(format=log_format)
|
||||
_logger_wrapper.logger.setLevel(_LEVELS[level])
|
||||
|
||||
|
||||
def get(
|
||||
name: t.Optional[str] = None,
|
||||
level: LogLevel = LogLevel.INFO,
|
||||
log_format: str = _DEFAULT_FORMAT,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> _VerbosityWrapper:
|
||||
"""Set up the logger."""
|
||||
global _logger, _logger_wrapper
|
||||
if _logger_wrapper is not None:
|
||||
return t.cast(_VerbosityWrapper, _logger_wrapper)
|
||||
|
||||
# Setup logging format.
|
||||
logging.basicConfig(format=log_format)
|
||||
|
||||
# Create logger
|
||||
_logger = logging.getLogger(name or _DEFAULT_LOGGER_NAME)
|
||||
_logger.setLevel(_parse_log_level_from_env(default=_LEVELS[level]))
|
||||
_logger_wrapper = _VerbosityWrapper(_logger, verbosity_level=verbosity_level)
|
||||
return _logger_wrapper
|
||||
|
||||
|
||||
class WithLogger:
|
||||
"""Interface to endow subclasses with a logger."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger: t.Optional[logging.Logger] = None,
|
||||
logger_name: str = _DEFAULT_LOGGER_NAME,
|
||||
logging_level: LogLevel = LogLevel.INFO,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the logger.
|
||||
|
||||
:param logger: the logger object.
|
||||
:param logger_name: the default logger name, if a logger is not provided.
|
||||
"""
|
||||
self._logger = (
|
||||
_VerbosityWrapper(logger, verbosity_level=verbosity_level)
|
||||
if logger is not None
|
||||
else get(name=logger_name, level=logging_level)
|
||||
)
|
||||
self._logger.setup(verbosity_level=verbosity_level)
|
||||
self._logging_level = logging._levelToName[self._logger.level]
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Get the component logger."""
|
||||
return t.cast(logging.Logger, self._logger)
|
||||
@@ -0,0 +1,576 @@
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
_default = "application/octet-stream"
|
||||
_types = {
|
||||
".3dm": "x-world/x-3dmf",
|
||||
".3dmf": "x-world/x-3dmf",
|
||||
".7z": "application/x-7z-compressed",
|
||||
".a": "application/octet-stream",
|
||||
".aab": "application/x-authorware-bin",
|
||||
".aam": "application/x-authorware-map",
|
||||
".aas": "application/x-authorware-seg",
|
||||
".abc": "text/vnd.abc",
|
||||
".acgi": "text/html",
|
||||
".afl": "video/animaflex",
|
||||
".ai": "application/postscript",
|
||||
".aif": "audio/x-aiff",
|
||||
".aifc": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".aim": "application/x-aim",
|
||||
".aip": "text/x-audiosoft-intra",
|
||||
".ani": "application/x-navi-animation",
|
||||
".aos": "application/x-nokia-9000-communicator-add-on-software",
|
||||
".aps": "application/mime",
|
||||
".arc": "application/octet-stream",
|
||||
".arj": "application/octet-stream",
|
||||
".art": "image/x-jg",
|
||||
".asf": "video/x-ms-asf",
|
||||
".asm": "text/x-asm",
|
||||
".asp": "text/asp",
|
||||
".asx": "video/x-ms-asf-plugin",
|
||||
".au": "audio/basic",
|
||||
".avi": "video/x-msvideo",
|
||||
".avs": "video/avs-video",
|
||||
".bcpio": "application/x-bcpio",
|
||||
".bin": "application/octet-stream",
|
||||
".bm": "image/bmp",
|
||||
".bmp": "image/x-ms-bmp",
|
||||
".boo": "application/book",
|
||||
".book": "application/book",
|
||||
".boz": "application/x-bzip2",
|
||||
".bsh": "application/x-bsh",
|
||||
".bz": "application/x-bzip",
|
||||
".bz2": "application/x-bzip2",
|
||||
".c": "text/plain",
|
||||
".c+": "text/plain",
|
||||
".cat": "application/vnd.ms-pki.seccat",
|
||||
".cc": "text/x-c",
|
||||
".ccad": "application/clariscad",
|
||||
".cco": "application/x-cocoa",
|
||||
".cdf": "application/x-netcdf",
|
||||
".cer": "application/x-x509-ca-cert",
|
||||
".cha": "application/x-chat",
|
||||
".chat": "application/x-chat",
|
||||
".class": "application/x-java-class",
|
||||
".com": "text/plain",
|
||||
".conf": "text/plain",
|
||||
".cpio": "application/x-cpio",
|
||||
".cpp": "text/x-c",
|
||||
".cpt": "application/x-cpt",
|
||||
".crl": "application/pkix-crl",
|
||||
".crt": "application/x-x509-user-cert",
|
||||
".csh": "application/x-csh",
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".cxx": "text/plain",
|
||||
".dcr": "application/x-director",
|
||||
".deepv": "application/x-deepv",
|
||||
".def": "text/plain",
|
||||
".der": "application/x-x509-ca-cert",
|
||||
".dif": "video/x-dv",
|
||||
".dir": "application/x-director",
|
||||
".dl": "video/x-dl",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".dot": "application/msword",
|
||||
".dp": "application/commonground",
|
||||
".drw": "application/drafting",
|
||||
".dump": "application/octet-stream",
|
||||
".dv": "video/x-dv",
|
||||
".dvi": "application/x-dvi",
|
||||
".dwf": "model/vnd.dwf",
|
||||
".dwg": "image/x-dwg",
|
||||
".dxf": "image/x-dwg",
|
||||
".dxr": "application/x-director",
|
||||
".el": "text/x-script.elisp",
|
||||
".elc": "application/x-elc",
|
||||
".env": "application/x-envoy",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".eps": "application/postscript",
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/x-envoy",
|
||||
".exe": "application/octet-stream",
|
||||
".f": "text/x-fortran",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
".fdf": "application/vnd.fdf",
|
||||
".fif": "image/fif",
|
||||
".flac": "audio/flac",
|
||||
".fli": "video/x-fli",
|
||||
".flo": "image/florian",
|
||||
".flx": "text/vnd.fmi.flexstor",
|
||||
".fmf": "video/x-atomic3d-feature",
|
||||
".for": "text/x-fortran",
|
||||
".fpx": "image/vnd.net-fpx",
|
||||
".frl": "application/freeloader",
|
||||
".funk": "audio/make",
|
||||
".g": "text/plain",
|
||||
".g3": "image/g3fax",
|
||||
".gif": "image/gif",
|
||||
".gl": "video/x-gl",
|
||||
".gsd": "audio/x-gsm",
|
||||
".gsm": "audio/x-gsm",
|
||||
".gsp": "application/x-gsp",
|
||||
".gss": "application/x-gss",
|
||||
".gtar": "application/x-gtar",
|
||||
".gz": "application/x-gzip",
|
||||
".gzip": "multipart/x-gzip",
|
||||
".h": "text/plain",
|
||||
".hdf": "application/x-hdf",
|
||||
".help": "application/x-helpfile",
|
||||
".hgl": "application/vnd.hp-hpgl",
|
||||
".hh": "text/x-h",
|
||||
".hlb": "text/x-script",
|
||||
".hlp": "application/x-winhelp",
|
||||
".hpg": "application/vnd.hp-hpgl",
|
||||
".hpgl": "application/vnd.hp-hpgl",
|
||||
".hqx": "application/x-mac-binhex40",
|
||||
".hta": "application/hta",
|
||||
".htc": "text/x-component",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".htmls": "text/html",
|
||||
".htt": "text/webviewhtml",
|
||||
".htx": "text/html",
|
||||
".ice": "x-conference/x-cooltalk",
|
||||
".ico": "image/vnd.microsoft.icon",
|
||||
".ics": "text/calendar",
|
||||
".idc": "text/plain",
|
||||
".ief": "image/ief",
|
||||
".iefs": "image/ief",
|
||||
".iges": "model/iges",
|
||||
".igs": "model/iges",
|
||||
".ima": "application/x-ima",
|
||||
".imap": "application/x-httpd-imap",
|
||||
".inf": "application/inf",
|
||||
".ins": "application/x-internett-signup",
|
||||
".ip": "application/x-ip2",
|
||||
".isu": "video/x-isvideo",
|
||||
".it": "audio/it",
|
||||
".iv": "application/x-inventor",
|
||||
".ivr": "i-world/i-vrml",
|
||||
".ivy": "application/x-livescreen",
|
||||
".jam": "audio/x-jam",
|
||||
".jav": "text/x-java-source",
|
||||
".java": "text/x-java-source",
|
||||
".jcm": "application/x-java-commerce",
|
||||
".jfif": "image/pjpeg",
|
||||
".jfif-bnl": "image/jpeg",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpg",
|
||||
".jps": "image/x-jps",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".jut": "image/jutvision",
|
||||
".kar": "music/x-karaoke",
|
||||
".ksh": "text/plain",
|
||||
".la": "audio/x-nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/x-lha",
|
||||
".lhx": "application/octet-stream",
|
||||
".list": "text/plain",
|
||||
".lma": "audio/x-nspaudio",
|
||||
".log": "text/plain",
|
||||
".lsp": "text/x-script.lisp",
|
||||
".lst": "text/plain",
|
||||
".lsx": "text/x-la-asf",
|
||||
".ltx": "application/x-latex",
|
||||
".lzh": "application/x-lzh",
|
||||
".lzx": "application/x-lzx",
|
||||
".m": "text/x-m",
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "application/vnd.apple.mpegurl",
|
||||
".man": "application/x-troff-man",
|
||||
".map": "application/x-navimap",
|
||||
".mar": "text/plain",
|
||||
".mbd": "application/mbedlet",
|
||||
".mc": "application/x-magic-cap-package-1.0",
|
||||
".mcd": "application/x-mathcad",
|
||||
".mcf": "text/mcf",
|
||||
".mcp": "application/netmc",
|
||||
".me": "application/x-troff-me",
|
||||
".mht": "message/rfc822",
|
||||
".mhtml": "message/rfc822",
|
||||
".mid": "audio/midi",
|
||||
".midi": "audio/midi",
|
||||
".mif": "application/x-mif",
|
||||
".mime": "www/mime",
|
||||
".mjf": "audio/x-vnd.audioexplosion.mjuicemediafile",
|
||||
".mjpg": "video/x-motion-jpeg",
|
||||
".mka": "audio/x-matroska",
|
||||
".mkv": "video/x-matroska",
|
||||
".mm": "application/x-meme",
|
||||
".mme": "application/base64",
|
||||
".mod": "audio/x-mod",
|
||||
".moov": "video/quicktime",
|
||||
".mov": "video/quicktime",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mpa": "video/mpeg",
|
||||
".mpc": "application/x-project",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpga": "audio/mpeg",
|
||||
".mpp": "application/vnd.ms-project",
|
||||
".mpt": "application/x-project",
|
||||
".mpv": "application/x-project",
|
||||
".mpx": "application/x-project",
|
||||
".mrc": "application/marc",
|
||||
".ms": "application/x-troff-ms",
|
||||
".mv": "video/x-sgi-movie",
|
||||
".my": "audio/make",
|
||||
".mzz": "application/x-vnd.audioexplosion.mzz",
|
||||
".nap": "image/naplps",
|
||||
".naplps": "image/naplps",
|
||||
".nc": "application/x-netcdf",
|
||||
".ncm": "application/vnd.nokia.configuration-message",
|
||||
".nif": "image/x-niff",
|
||||
".niff": "image/x-niff",
|
||||
".nix": "application/x-mix-transfer",
|
||||
".nsc": "application/x-conference",
|
||||
".nvd": "application/x-navidoc",
|
||||
".o": "application/octet-stream",
|
||||
".oda": "application/oda",
|
||||
".ogg": "video/ogg",
|
||||
".omc": "application/x-omc",
|
||||
".omcd": "application/x-omcdatamaker",
|
||||
".omcr": "application/x-omcregerator",
|
||||
".otf": "font/otf",
|
||||
".p": "text/x-pascal",
|
||||
".p10": "application/x-pkcs10",
|
||||
".p12": "application/x-pkcs12",
|
||||
".p7a": "application/x-pkcs7-signature",
|
||||
".p7c": "application/pkcs7-mime",
|
||||
".p7m": "application/x-pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/pkcs7-signature",
|
||||
".part": "application/pro_eng",
|
||||
".pas": "text/pascal",
|
||||
".pbm": "image/x-portable-bitmap",
|
||||
".pcl": "application/x-pcl",
|
||||
".pct": "image/pict",
|
||||
".pcx": "image/x-pcx",
|
||||
".pdb": "chemical/x-pdb",
|
||||
".pdf": "application/pdf",
|
||||
".pfunk": "audio/make.my.funk",
|
||||
".pgm": "image/x-portable-graymap",
|
||||
".pic": "image/pict",
|
||||
".pict": "image/pict",
|
||||
".pkg": "application/x-newton-compatible-pkg",
|
||||
".pko": "application/vnd.ms-pki.pko",
|
||||
".pl": "text/plain",
|
||||
".plx": "application/x-pixclscript",
|
||||
".pm": "text/x-script.perl-module",
|
||||
".pm4": "application/x-pagemaker",
|
||||
".pm5": "application/x-pagemaker",
|
||||
".png": "image/png",
|
||||
".pnm": "image/x-portable-anymap",
|
||||
".pot": "application/vnd.ms-powerpoint",
|
||||
".pov": "model/x-pov",
|
||||
".ppa": "application/vnd.ms-powerpoint",
|
||||
".ppm": "image/x-portable-pixmap",
|
||||
".pps": "application/vnd.ms-powerpoint",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".ppz": "application/mspowerpoint",
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "application/octet-stream",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vnd.ms-powerpoint",
|
||||
".py": "text/x-python",
|
||||
".pyc": "application/x-python-code",
|
||||
".qcp": "audio/vnd.qcelp",
|
||||
".qd3": "x-world/x-3dmf",
|
||||
".qd3d": "x-world/x-3dmf",
|
||||
".qif": "image/x-quicktime",
|
||||
".qt": "video/quicktime",
|
||||
".qtc": "video/x-qtc",
|
||||
".qti": "image/x-quicktime",
|
||||
".qtif": "image/x-quicktime",
|
||||
".ra": "audio/x-pn-realaudio",
|
||||
".ram": "application/x-pn-realaudio",
|
||||
".ras": "image/x-cmu-raster",
|
||||
".rast": "image/cmu-raster",
|
||||
".rar": "application/vnd.rar",
|
||||
".rexx": "text/x-script.rexx",
|
||||
".rf": "image/vnd.rn-realflash",
|
||||
".rgb": "image/x-rgb",
|
||||
".rm": "audio/x-pn-realaudio",
|
||||
".rmi": "audio/mid",
|
||||
".rmm": "audio/x-pn-realaudio",
|
||||
".rmp": "audio/x-pn-realaudio-plugin",
|
||||
".rng": "application/vnd.nokia.ringing-tone",
|
||||
".rnx": "application/vnd.rn-realplayer",
|
||||
".roff": "application/x-troff",
|
||||
".rp": "image/vnd.rn-realpix",
|
||||
".rpm": "audio/x-pn-realaudio-plugin",
|
||||
".rt": "text/vnd.rn-realtext",
|
||||
".rtf": "application/rtf",
|
||||
".rtx": "text/richtext",
|
||||
".rv": "video/vnd.rn-realvideo",
|
||||
".s": "text/x-asm",
|
||||
".s3m": "audio/s3m",
|
||||
".saveme": "application/octet-stream",
|
||||
".sbk": "application/x-tbook",
|
||||
".scm": "video/x-scm",
|
||||
".sdml": "text/plain",
|
||||
".sdp": "application/x-sdp",
|
||||
".sdr": "application/sounder",
|
||||
".sea": "application/x-sea",
|
||||
".set": "application/set",
|
||||
".sgm": "text/x-sgml",
|
||||
".sgml": "text/x-sgml",
|
||||
".sh": "application/x-sh",
|
||||
".shar": "application/x-shar",
|
||||
".shtml": "text/x-server-parsed-html",
|
||||
".sid": "audio/x-psid",
|
||||
".sit": "application/x-stuffit",
|
||||
".skd": "application/x-koan",
|
||||
".skm": "application/x-koan",
|
||||
".skp": "application/x-koan",
|
||||
".skt": "application/x-koan",
|
||||
".sl": "application/x-seelogo",
|
||||
".smi": "application/smil",
|
||||
".smil": "application/smil",
|
||||
".snd": "audio/basic",
|
||||
".sol": "application/solids",
|
||||
".spc": "text/x-speech",
|
||||
".spl": "application/futuresplash",
|
||||
".spr": "application/x-sprite",
|
||||
".sprite": "application/x-sprite",
|
||||
".src": "application/x-wais-source",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vnd.ms-pki.certstore",
|
||||
".step": "application/step",
|
||||
".stl": "application/x-navistyle",
|
||||
".stp": "application/step",
|
||||
".sv4cpio": "application/x-sv4cpio",
|
||||
".sv4crc": "application/x-sv4crc",
|
||||
".svf": "image/x-dwg",
|
||||
".svg": "image/svg+xml",
|
||||
".svr": "x-world/x-svr",
|
||||
".swf": "application/x-shockwave-flash",
|
||||
".t": "application/x-troff",
|
||||
".talk": "text/x-speech",
|
||||
".tar": "application/x-tar",
|
||||
".tbk": "application/x-tbook",
|
||||
".tcl": "application/x-tcl",
|
||||
".tcsh": "text/x-script.tcsh",
|
||||
".tex": "application/x-tex",
|
||||
".texi": "application/x-texinfo",
|
||||
".texinfo": "application/x-texinfo",
|
||||
".text": "text/plain",
|
||||
".tgz": "application/x-compressed",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".tr": "application/x-troff",
|
||||
".ts": "video/mp2t",
|
||||
".tsi": "audio/tsp-audio",
|
||||
".tsp": "audio/tsplayer",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".turbot": "image/florian",
|
||||
".txt": "text/plain",
|
||||
".uil": "text/x-uil",
|
||||
".uni": "text/uri-list",
|
||||
".unis": "text/uri-list",
|
||||
".unv": "application/i-deas",
|
||||
".uri": "text/uri-list",
|
||||
".uris": "text/uri-list",
|
||||
".ustar": "application/x-ustar",
|
||||
".uu": "text/x-uuencode",
|
||||
".uue": "text/x-uuencode",
|
||||
".vcd": "application/x-cdlink",
|
||||
".vcs": "text/x-vcalendar",
|
||||
".vda": "application/vda",
|
||||
".vdo": "video/vdo",
|
||||
".vew": "application/groupwise",
|
||||
".viv": "video/vnd.vivo",
|
||||
".vivo": "video/vnd.vivo",
|
||||
".vmd": "application/vocaltec-media-desc",
|
||||
".vmf": "application/vocaltec-media-file",
|
||||
".voc": "audio/x-voc",
|
||||
".vos": "video/vosaic",
|
||||
".vox": "audio/voxware",
|
||||
".vqe": "audio/x-twinvq-plugin",
|
||||
".vqf": "audio/x-twinvq",
|
||||
".vql": "audio/x-twinvq-plugin",
|
||||
".vrml": "x-world/x-vrml",
|
||||
".vrt": "x-world/x-vrt",
|
||||
".vsd": "application/x-visio",
|
||||
".vst": "application/x-visio",
|
||||
".vsw": "application/x-visio",
|
||||
".w60": "application/wordperfect6.0",
|
||||
".w61": "application/wordperfect6.1",
|
||||
".w6w": "application/msword",
|
||||
".wav": "audio/x-wav",
|
||||
".wb1": "application/x-qpro",
|
||||
".wbmp": "image/vnd.wap.wbmp",
|
||||
".web": "application/vnd.xara",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wiz": "application/msword",
|
||||
".wk1": "application/x-123",
|
||||
".wmf": "windows/metafile",
|
||||
".wml": "text/vnd.wap.wml",
|
||||
".wmlc": "application/vnd.wap.wmlc",
|
||||
".wmls": "text/vnd.wap.wmlscript",
|
||||
".wmlsc": "application/vnd.wap.wmlscriptc",
|
||||
".word": "application/msword",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wp": "application/wordperfect",
|
||||
".wp5": "application/wordperfect6.0",
|
||||
".wp6": "application/wordperfect",
|
||||
".wpd": "application/x-wpwin",
|
||||
".wq1": "application/x-lotus",
|
||||
".wri": "application/x-wri",
|
||||
".wrl": "x-world/x-vrml",
|
||||
".wrz": "x-world/x-vrml",
|
||||
".wsc": "text/scriplet",
|
||||
".wsrc": "application/x-wais-source",
|
||||
".wtk": "application/x-wintalk",
|
||||
".xbm": "image/x-xbitmap",
|
||||
".xdr": "video/x-amt-demorun",
|
||||
".xgz": "xgl/drawing",
|
||||
".xif": "image/vnd.xiff",
|
||||
".xl": "application/excel",
|
||||
".xla": "application/x-msexcel",
|
||||
".xlb": "application/vnd.ms-excel",
|
||||
".xlc": "application/x-excel",
|
||||
".xld": "application/x-excel",
|
||||
".xlk": "application/x-excel",
|
||||
".xll": "application/x-excel",
|
||||
".xlm": "application/x-excel",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlt": "application/x-excel",
|
||||
".xlv": "application/x-excel",
|
||||
".xlw": "application/x-msexcel",
|
||||
".xm": "audio/xm",
|
||||
".xml": "text/xml",
|
||||
".xmz": "xgl/movie",
|
||||
".xpix": "application/x-vnd.ls-xpix",
|
||||
".xpm": "image/x-xpixmap",
|
||||
".x-ng": "image/png",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xsr": "video/x-amt-showrun",
|
||||
".xwd": "image/x-xwindowdump",
|
||||
".xyz": "chemical/x-pdb",
|
||||
".yaml": "application/x-yaml",
|
||||
".yml": "application/x-yaml",
|
||||
".z": "application/x-compressed",
|
||||
".zip": "application/zip",
|
||||
".zoo": "application/octet-stream",
|
||||
".zsh": "text/x-script.zsh",
|
||||
".mjs": "application/javascript",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".dll": "application/octet-stream",
|
||||
".obj": "application/octet-stream",
|
||||
".so": "application/octet-stream",
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".wasm": "application/wasm",
|
||||
".h5": "application/x-hdf5",
|
||||
".pfx": "application/x-pkcs12",
|
||||
".pyo": "application/x-python-code",
|
||||
".xsl": "application/xml",
|
||||
".rdf": "application/xml",
|
||||
".wsdl": "application/xml",
|
||||
".xpdl": "application/xml",
|
||||
".3gp": "audio/3gpp",
|
||||
".3gpp": "audio/3gpp",
|
||||
".3g2": "audio/3gpp2",
|
||||
".3gpp2": "audio/3gpp2",
|
||||
".aac": "audio/aac",
|
||||
".adts": "audio/aac",
|
||||
".loas": "audio/aac",
|
||||
".ass": "audio/aac",
|
||||
".opus": "audio/opus",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".eml": "message/rfc822",
|
||||
".nws": "message/rfc822",
|
||||
".bat": "text/plain",
|
||||
".vcf": "text/x-vcard",
|
||||
".xul": "text/xul",
|
||||
}
|
||||
|
||||
|
||||
def guess(file: t.Union[str, Path]) -> str:
|
||||
return _types.get(Path(file).suffix, _default)
|
||||
|
||||
|
||||
# MIME type -> extension (no leading dot). Used when deriving filenames from
|
||||
# content-type headers (e.g. for URLs without path segments).
|
||||
_MIME_TO_EXT: t.Dict[str, str] = {
|
||||
"text/plain": "txt",
|
||||
"text/html": "html",
|
||||
"text/css": "css",
|
||||
"text/javascript": "js",
|
||||
"application/json": "json",
|
||||
"application/xml": "xml",
|
||||
"application/pdf": "pdf",
|
||||
"application/zip": "zip",
|
||||
"application/x-zip-compressed": "zip",
|
||||
"application/gzip": "gz",
|
||||
"application/octet-stream": "bin",
|
||||
"application/x-tar": "tar",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
"image/tiff": "tiff",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"video/mp4": "mp4",
|
||||
"video/mpeg": "mpeg",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-msvideo": "avi",
|
||||
"video/webm": "webm",
|
||||
}
|
||||
|
||||
|
||||
def get_extension_from_mime_type(mime_type: str) -> str:
|
||||
"""Map MIME type to file extension (no leading dot).
|
||||
|
||||
Used when deriving filenames from content-type headers
|
||||
(e.g. for URLs without path segments).
|
||||
"""
|
||||
clean = mime_type.split(";")[0].lower().strip()
|
||||
if clean in _MIME_TO_EXT:
|
||||
return _MIME_TO_EXT[clean]
|
||||
parts = clean.split("/")
|
||||
if len(parts) == 2:
|
||||
subtype = parts[1].lower()
|
||||
if "+" in subtype:
|
||||
plus_parts = subtype.split("+")
|
||||
suffix = plus_parts[-1]
|
||||
known_prefixes = ("svg", "atom", "rss")
|
||||
if plus_parts[0] in known_prefixes:
|
||||
return plus_parts[0]
|
||||
structured_suffixes = ("json", "xml", "yaml", "zip", "gzip")
|
||||
if suffix in structured_suffixes:
|
||||
return suffix
|
||||
return suffix
|
||||
return subtype or "txt"
|
||||
return "bin"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""OpenAPI helpers."""
|
||||
|
||||
import inspect
|
||||
import typing as t
|
||||
|
||||
from composio.exceptions import InvalidSchemaError
|
||||
|
||||
OPENAPI_TO_PYTHON = {
|
||||
"null": None,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"string": str,
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_object_type(schema: t.Dict) -> t.Type:
|
||||
# Nested objects are not supported ATM
|
||||
return t.Dict[str, t.Any]
|
||||
|
||||
|
||||
def _handle_array_type(schema: t.Dict) -> t.Any:
|
||||
# This discards the nested objects
|
||||
items_type = schema.get("items", {}).get("type")
|
||||
if items_type is None:
|
||||
return t.List[t.Any]
|
||||
|
||||
SubT = _type_to_parameter(schema=schema.get("items", {}))
|
||||
return t.List[SubT] # type: ignore
|
||||
|
||||
|
||||
def _handle_enum_type(schema: t.Dict) -> t.Any:
|
||||
return t.Literal[tuple(schema["enum"])]
|
||||
|
||||
|
||||
def _type_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
if "enum" in schema:
|
||||
return _handle_enum_type(schema=schema)
|
||||
|
||||
p_type = schema.get("type")
|
||||
if p_type in OPENAPI_TO_PYTHON:
|
||||
return OPENAPI_TO_PYTHON[p_type]
|
||||
|
||||
if p_type == "object":
|
||||
return _handle_object_type(schema=schema)
|
||||
|
||||
if p_type == "array":
|
||||
return _handle_array_type(schema=schema)
|
||||
|
||||
if p_type is None:
|
||||
# No type specified (e.g. a combiner option that is description-only or
|
||||
# an explicit Any), mirroring the top-level fallback below.
|
||||
return t.Any
|
||||
|
||||
raise InvalidSchemaError(f"Invalid property type {p_type}: {schema!r}")
|
||||
|
||||
|
||||
def _handle_composite_type(schemas: t.List[t.Dict]) -> t.Any:
|
||||
if not schemas:
|
||||
# An empty oneOf/anyOf has no options to union; fall back to Any.
|
||||
return t.Any
|
||||
return t.Union[tuple(map(_type_to_parameter, schemas))]
|
||||
|
||||
|
||||
def _one_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
return _handle_composite_type(schemas=schema["oneOf"])
|
||||
|
||||
|
||||
def _any_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
return _handle_composite_type(schemas=schema["anyOf"])
|
||||
|
||||
|
||||
def _all_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Type:
|
||||
composite = {}
|
||||
for subschema in schema["allOf"]:
|
||||
composite.update(subschema)
|
||||
return _type_to_parameter(schema=composite)
|
||||
|
||||
|
||||
def function_signature_from_jsonschema(
|
||||
schema: t.Dict[str, t.Any],
|
||||
skip_default: bool = False,
|
||||
) -> t.List[inspect.Parameter]:
|
||||
"""Convert json schema to a list of parameters (`inspect.Parameter`)."""
|
||||
parameters = []
|
||||
required = set(schema.get("required", []))
|
||||
for p_name, p_schema in schema.get("properties", {}).items():
|
||||
if "oneOf" in p_schema:
|
||||
p_type = _one_of_to_parameter(schema=p_schema)
|
||||
elif "anyOf" in p_schema:
|
||||
p_type = _any_of_to_parameter(schema=p_schema)
|
||||
elif "allOf" in p_schema:
|
||||
p_type = _all_of_to_parameter(schema=p_schema)
|
||||
elif "type" in p_schema:
|
||||
p_type = _type_to_parameter(schema=p_schema)
|
||||
else:
|
||||
# Handle cases where no type is specified (e.g., Pydantic's Any type)
|
||||
# This typically happens when using typing.Any in Pydantic models,
|
||||
# which intentionally omits the 'type' field in JSON schema
|
||||
p_type = t.Any
|
||||
|
||||
p_val = p_schema.get("default", None)
|
||||
if p_name in required or p_schema.get("required", False) or skip_default:
|
||||
p_val = inspect.Parameter.empty
|
||||
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
name=p_name,
|
||||
annotation=p_type,
|
||||
default=p_val,
|
||||
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
)
|
||||
|
||||
return parameters
|
||||
@@ -0,0 +1,44 @@
|
||||
import typing as t
|
||||
|
||||
import pydantic
|
||||
from composio_client import omit
|
||||
|
||||
|
||||
def none_to_omit(value: t.Optional[t.Any]) -> t.Any:
|
||||
"""Convert None to omit for composio_client API calls.
|
||||
|
||||
This utility function helps convert Python's None values to composio_client's
|
||||
omit sentinel value, which tells the API to exclude the parameter entirely.
|
||||
|
||||
Args:
|
||||
value: Any value that might be None
|
||||
|
||||
Returns:
|
||||
The original value if not None, otherwise omit
|
||||
|
||||
Example:
|
||||
>>> none_to_omit("hello")
|
||||
"hello"
|
||||
>>> none_to_omit(None)
|
||||
<omit>
|
||||
>>> none_to_omit(42)
|
||||
42
|
||||
"""
|
||||
return omit if value is None else value
|
||||
|
||||
|
||||
def parse_pydantic_error(e: pydantic.ValidationError) -> str:
|
||||
"""Parse pydantic validation error."""
|
||||
message = "Invalid request data provided"
|
||||
missing = []
|
||||
others = [""]
|
||||
for error in e.errors():
|
||||
param = ".".join(map(str, error["loc"]))
|
||||
if error["type"] == "missing":
|
||||
missing.append(param)
|
||||
continue
|
||||
others.append(error["msg"] + f" on parameter `{param}`")
|
||||
if len(missing) > 0:
|
||||
message += f"\n- Following fields are missing: {set(missing)}"
|
||||
message += "\n- ".join(others)
|
||||
return message
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
JSON Schema to Pydantic type conversion using json-schema-to-pydantic library.
|
||||
|
||||
This module provides a wrapper around json-schema-to-pydantic that maintains
|
||||
backwards compatibility with the existing json_schema_to_pydantic_type() API.
|
||||
|
||||
The library handles most JSON Schema features correctly, but doesn't support
|
||||
boolean schemas (true/false values that are valid in JSON Schema draft-06+).
|
||||
We handle those by pre-filtering them before passing to the library.
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from functools import reduce
|
||||
|
||||
from json_schema_to_pydantic import (
|
||||
CombinerError,
|
||||
SchemaError,
|
||||
create_model as create_model_from_schema,
|
||||
)
|
||||
|
||||
from composio.utils.logging import get as get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Type mapping for simple cases where we don't need full model creation
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"number": float,
|
||||
"boolean": bool,
|
||||
"array": t.List,
|
||||
"object": t.Dict,
|
||||
"null": t.Optional[t.Any],
|
||||
}
|
||||
|
||||
CONTAINER_TYPE = ("array", "object")
|
||||
|
||||
# Should be deprecated,
|
||||
# required values will always be provided by users
|
||||
# Non-required values are nullable(None) if default value not provided.
|
||||
FALLBACK_VALUES = {
|
||||
"string": "",
|
||||
"number": 0.0,
|
||||
"integer": 0,
|
||||
"boolean": False,
|
||||
"object": {},
|
||||
"array": [],
|
||||
"null": None,
|
||||
}
|
||||
|
||||
|
||||
def _filter_boolean_schemas(
|
||||
schema: t.Union[t.Dict[str, t.Any], bool, t.List],
|
||||
) -> t.Union[t.Dict[str, t.Any], t.Any, None]:
|
||||
"""
|
||||
Pre-filter boolean schemas from anyOf/allOf/oneOf arrays.
|
||||
|
||||
JSON Schema draft-06+ allows `true` and `false` as valid schemas:
|
||||
- `true` means "accept any value" (equivalent to {})
|
||||
- `false` means "reject all values" (equivalent to {"not": {}})
|
||||
|
||||
The json-schema-to-pydantic library doesn't handle these, so we:
|
||||
- Replace `true` with {} (empty schema, accepts anything)
|
||||
- Filter out `false` (rejects everything, so no point including it)
|
||||
|
||||
Returns None if the schema is a standalone `false` boolean.
|
||||
"""
|
||||
if isinstance(schema, bool):
|
||||
if schema:
|
||||
# true -> empty schema (accepts any value)
|
||||
return {}
|
||||
else:
|
||||
# false -> reject all, return None to filter out
|
||||
return None
|
||||
|
||||
if isinstance(schema, list):
|
||||
# Filter list items (e.g., for anyOf arrays)
|
||||
filtered = []
|
||||
for item in schema:
|
||||
result = _filter_boolean_schemas(item)
|
||||
if result is not None:
|
||||
filtered.append(result)
|
||||
return filtered if filtered else None
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Make a copy to avoid mutating the original
|
||||
result = {}
|
||||
for key, value in schema.items():
|
||||
if key in ("anyOf", "allOf", "oneOf"):
|
||||
# Filter boolean schemas from combiner arrays
|
||||
filtered_value = _filter_boolean_schemas(value)
|
||||
if filtered_value is None or (
|
||||
isinstance(filtered_value, list) and len(filtered_value) == 0
|
||||
):
|
||||
# All schemas were false, skip this combiner
|
||||
continue
|
||||
result[key] = filtered_value
|
||||
elif key == "items" and isinstance(value, (dict, bool)):
|
||||
# Handle array items schema
|
||||
filtered_items = _filter_boolean_schemas(value)
|
||||
if filtered_items is not None:
|
||||
result[key] = filtered_items
|
||||
elif key == "properties" and isinstance(value, dict):
|
||||
# Recursively filter property schemas
|
||||
filtered_props = {}
|
||||
for prop_name, prop_schema in value.items():
|
||||
filtered_prop = _filter_boolean_schemas(prop_schema)
|
||||
if filtered_prop is not None:
|
||||
filtered_props[prop_name] = filtered_prop
|
||||
result[key] = filtered_props
|
||||
elif key in ("$defs", "definitions") and isinstance(value, dict):
|
||||
# Recursively filter definitions
|
||||
filtered_defs = {}
|
||||
for def_name, def_schema in value.items():
|
||||
filtered_def = _filter_boolean_schemas(def_schema)
|
||||
if filtered_def is not None:
|
||||
filtered_defs[def_name] = filtered_def
|
||||
result[key] = filtered_defs
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def json_schema_to_pydantic_type(
|
||||
json_schema: t.Union[t.Dict[str, t.Any], bool],
|
||||
) -> t.Union[t.Type, t.Optional[t.Any]]:
|
||||
"""
|
||||
Converts a JSON schema type to a Pydantic type.
|
||||
|
||||
Uses json-schema-to-pydantic for complex schemas (anyOf, allOf, oneOf),
|
||||
falls back to simple type mapping for primitive types.
|
||||
|
||||
:param json_schema: The JSON schema to convert (can be dict or boolean).
|
||||
:return: A Pydantic type.
|
||||
"""
|
||||
# Handle boolean schemas (JSON Schema draft-06+)
|
||||
if isinstance(json_schema, bool):
|
||||
if json_schema:
|
||||
return t.Any # true schema accepts any value
|
||||
else:
|
||||
return None # false schema - will be filtered out in union processing
|
||||
|
||||
# Pre-filter boolean schemas from combiners
|
||||
filtered_schema = _filter_boolean_schemas(json_schema)
|
||||
if filtered_schema is None:
|
||||
return str # Fallback if all schemas were false
|
||||
|
||||
# Handle simple primitive types without complex combiners
|
||||
if _is_simple_primitive(filtered_schema):
|
||||
return _convert_simple_type(filtered_schema)
|
||||
|
||||
# Use library for complex schemas (anyOf, allOf, oneOf, nested objects)
|
||||
return _convert_with_library(filtered_schema)
|
||||
|
||||
|
||||
def _is_simple_primitive(schema: t.Dict[str, t.Any]) -> bool:
|
||||
"""Check if schema is a simple primitive without combiners."""
|
||||
has_combiners = any(k in schema for k in ("anyOf", "allOf", "oneOf"))
|
||||
has_properties = "properties" in schema
|
||||
schema_type = schema.get("type")
|
||||
|
||||
return (
|
||||
not has_combiners
|
||||
and not has_properties
|
||||
and schema_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
|
||||
and schema_type not in CONTAINER_TYPE
|
||||
)
|
||||
|
||||
|
||||
def _convert_simple_type(schema: t.Dict[str, t.Any]) -> t.Type[t.Any]:
|
||||
"""Convert simple primitive types directly."""
|
||||
type_ = schema.get("type", "string")
|
||||
return t.cast(t.Type[t.Any], PYDANTIC_TYPE_TO_PYTHON_TYPE.get(type_, str))
|
||||
|
||||
|
||||
def _convert_with_library(
|
||||
schema: t.Dict[str, t.Any],
|
||||
) -> t.Union[t.Type, t.Any]:
|
||||
"""Use json-schema-to-pydantic for complex schema conversion."""
|
||||
try:
|
||||
# Handle top-level combiner without type (e.g., {"anyOf": [...]})
|
||||
if (
|
||||
any(k in schema for k in ("anyOf", "allOf", "oneOf"))
|
||||
and "type" not in schema
|
||||
):
|
||||
return _handle_toplevel_combiner(schema)
|
||||
|
||||
# For object schemas, create model directly
|
||||
if schema.get("type") == "object":
|
||||
if "title" not in schema:
|
||||
schema = {**schema, "title": "GeneratedModel"}
|
||||
return create_model_from_schema(
|
||||
schema,
|
||||
allow_undefined_array_items=True,
|
||||
allow_undefined_type=True,
|
||||
)
|
||||
|
||||
# For array schemas
|
||||
if schema.get("type") == "array":
|
||||
items = schema.get("items")
|
||||
if items:
|
||||
item_type = json_schema_to_pydantic_type(items)
|
||||
return t.List[t.cast(t.Type, item_type)] # type: ignore
|
||||
return t.List
|
||||
|
||||
# Fallback to simple type
|
||||
return _convert_simple_type(schema)
|
||||
|
||||
except (SchemaError, CombinerError) as e:
|
||||
logger.debug(f"Library schema conversion failed: {e}, falling back to string")
|
||||
return str
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Unexpected error in schema conversion: {e}, falling back to string"
|
||||
)
|
||||
return str
|
||||
|
||||
|
||||
def _handle_toplevel_combiner(
|
||||
schema: t.Dict[str, t.Any],
|
||||
) -> t.Union[t.Type, t.Any]:
|
||||
"""
|
||||
Handle top-level combiner schemas (anyOf, allOf, oneOf without "type").
|
||||
|
||||
The library can handle these directly - it returns the appropriate type.
|
||||
"""
|
||||
try:
|
||||
# Try direct conversion - library handles anyOf/oneOf/allOf at top level
|
||||
result = create_model_from_schema(
|
||||
schema,
|
||||
allow_undefined_array_items=True,
|
||||
allow_undefined_type=True,
|
||||
)
|
||||
if result is type(None):
|
||||
return t.Optional[t.Any]
|
||||
# If result is a type (like a Union or Optional), return it directly
|
||||
# If result is a model class, return it
|
||||
return result
|
||||
except (SchemaError, CombinerError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: manually build union type for anyOf/oneOf
|
||||
if "anyOf" in schema or "oneOf" in schema:
|
||||
options = schema.get("anyOf", schema.get("oneOf", []))
|
||||
return _build_union_from_options(options)
|
||||
|
||||
# Fallback: use first option for allOf
|
||||
if "allOf" in schema and schema["allOf"]:
|
||||
return json_schema_to_pydantic_type(schema["allOf"][0])
|
||||
|
||||
return t.Any
|
||||
|
||||
|
||||
def _build_union_from_options(options: t.List[t.Dict[str, t.Any]]) -> t.Type:
|
||||
"""Build a Union type from a list of schema options."""
|
||||
pydantic_types = []
|
||||
null_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get("null")
|
||||
has_null = False
|
||||
|
||||
for option in options:
|
||||
ptype = json_schema_to_pydantic_type(option)
|
||||
if ptype is None:
|
||||
continue
|
||||
if ptype == null_type or ptype is type(None):
|
||||
has_null = True
|
||||
continue
|
||||
pydantic_types.append(ptype)
|
||||
|
||||
if len(pydantic_types) == 0:
|
||||
return t.Optional[t.Any] if has_null else str # type: ignore
|
||||
|
||||
if len(pydantic_types) == 1:
|
||||
base_type = pydantic_types[0]
|
||||
if has_null:
|
||||
return t.Optional[t.cast(t.Type, base_type)] # type: ignore
|
||||
return base_type
|
||||
|
||||
# Build union
|
||||
cast_types = [t.cast(t.Type, ptype) for ptype in pydantic_types]
|
||||
union_type = reduce(lambda a, b: t.Union[a, b], cast_types) # type: ignore
|
||||
if has_null:
|
||||
return t.Optional[union_type] # type: ignore
|
||||
return union_type
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Block accidental upload of local files from well-known secret/credential locations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
# Path components that indicate a sensitive directory anywhere in a resolved local path.
|
||||
BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS: t.Tuple[str, ...] = (
|
||||
".ssh",
|
||||
".aws",
|
||||
".azure",
|
||||
".gnupg",
|
||||
".kube",
|
||||
".docker",
|
||||
".claude", # may contain API keys and project context read by assistants
|
||||
".password-store",
|
||||
"keychains",
|
||||
)
|
||||
|
||||
_SECRET_LIKE_BASENAME = re.compile(r"^(\.env(\.|$)|\.netrc$|\.pgpass$)", re.IGNORECASE)
|
||||
_DEFAULT_PRIVATE_KEY_BASENAME = re.compile(
|
||||
r"^id_(rsa|ed25519|ecdsa|dsa|ecdsa_sk)(\.old)?$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path_segments(file_path: t.Union[str, Path]) -> t.List[str]:
|
||||
p = Path(file_path).expanduser()
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
resolved = p
|
||||
parts = [part for part in resolved.as_posix().split("/") if part]
|
||||
if parts and parts[0].endswith(":"):
|
||||
# Windows path: "C:/Users/..." -> drop drive letter segment
|
||||
parts = parts[1:]
|
||||
return parts
|
||||
|
||||
|
||||
def _get_block_reason(
|
||||
file_path: t.Union[str, Path],
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> t.Optional[str]:
|
||||
extra = [s.strip() for s in (additional_deny_segments or []) if s and s.strip()]
|
||||
deny: t.Set[str] = {s.lower() for s in BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS}
|
||||
deny.update(s.lower() for s in extra)
|
||||
|
||||
segments = _normalize_path_segments(file_path)
|
||||
# Case-insensitive segment match: lower each path component once per check.
|
||||
for seg, key in zip(segments, (s.lower() for s in segments), strict=True):
|
||||
if key in deny:
|
||||
return f'path segment "{seg}" is in the sensitive file upload denylist'
|
||||
|
||||
if not segments:
|
||||
return None
|
||||
basename = segments[-1]
|
||||
if _SECRET_LIKE_BASENAME.search(basename) or _DEFAULT_PRIVATE_KEY_BASENAME.match(
|
||||
basename
|
||||
):
|
||||
return (
|
||||
f'file name "{basename}" looks like a credential, env, or private key file'
|
||||
)
|
||||
if basename.lower() == "credentials":
|
||||
return 'file name "credentials" is often used for cloud/API credential stores'
|
||||
return None
|
||||
|
||||
|
||||
def is_blocked_sensitive_file_upload_path(
|
||||
file_path: t.Union[str, Path],
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> bool:
|
||||
return _get_block_reason(file_path, additional_deny_segments) is not None
|
||||
|
||||
|
||||
def assert_safe_local_file_upload_path(
|
||||
file_path: t.Union[str, Path],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Raise SensitiveFilePathBlockedError if *file_path* matches the denylist."""
|
||||
if not enabled:
|
||||
return
|
||||
reason = _get_block_reason(file_path, additional_deny_segments)
|
||||
if reason:
|
||||
from composio.exceptions import SensitiveFilePathBlockedError
|
||||
|
||||
raise SensitiveFilePathBlockedError(
|
||||
f"Refusing to upload: {reason}. "
|
||||
"Set sensitive_file_upload_protection=False on Composio if you must "
|
||||
"(not recommended), or use a copy outside sensitive locations."
|
||||
)
|
||||
@@ -0,0 +1,672 @@
|
||||
"""
|
||||
Shared utils.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import keyword
|
||||
import typing as t
|
||||
import uuid
|
||||
from functools import reduce
|
||||
from inspect import Parameter
|
||||
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from composio.exceptions import InvalidParams, InvalidSchemaError
|
||||
from composio.utils.json_schema import dereference_json_schema
|
||||
from composio.utils.logging import get as get_logger
|
||||
from composio.utils.schema_converter import (
|
||||
CONTAINER_TYPE,
|
||||
FALLBACK_VALUES,
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE,
|
||||
json_schema_to_pydantic_type,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
"json_schema_to_pydantic_type",
|
||||
"PYDANTIC_TYPE_TO_PYTHON_TYPE",
|
||||
"CONTAINER_TYPE",
|
||||
"FALLBACK_VALUES",
|
||||
"json_schema_to_pydantic_field",
|
||||
"json_schema_to_fields_dict",
|
||||
"json_schema_to_model",
|
||||
"pydantic_model_from_param_schema",
|
||||
"get_signature_format_from_schema_params",
|
||||
"get_pydantic_signature_format_from_schema_params",
|
||||
"generate_request_id",
|
||||
"ToolSchemaAliases",
|
||||
"alias_tool_input_schema",
|
||||
"restore_tool_arguments",
|
||||
"substitute_reserved_python_keywords",
|
||||
"reinstate_reserved_python_keywords",
|
||||
"normalize_tool_arguments",
|
||||
]
|
||||
|
||||
reserved_names = ["validate"]
|
||||
|
||||
_OBJ_MARKER = "-_object_-"
|
||||
_ARR_MARKER = "-_array_-"
|
||||
_MAX_PROVIDER_ALIAS_LENGTH = 64
|
||||
|
||||
|
||||
def normalize_tool_arguments(arguments: t.Any) -> t.Dict[str, t.Any]:
|
||||
"""Coerce model-supplied tool arguments into a dict.
|
||||
|
||||
Models (and some MCP transports) occasionally emit tool-call arguments as a
|
||||
JSON string instead of a dict, which breaks execution with errors such as
|
||||
``tool_use.input: Input should be a valid dictionary``. This is the single
|
||||
coercion every provider routes through so behaviour is identical everywhere.
|
||||
|
||||
See https://github.com/ComposioHQ/composio/issues/2406.
|
||||
|
||||
- ``None`` becomes ``{}`` (some models send no arguments for no-arg tools).
|
||||
- A dict is returned unchanged.
|
||||
- A string is JSON-parsed; an empty / whitespace-only string becomes ``{}``.
|
||||
- Anything that does not resolve to a dict (lists, primitives, unparseable
|
||||
strings, JSON that parses to a non-object) raises :class:`InvalidParams`.
|
||||
|
||||
:param arguments: Raw arguments as received from the model / framework.
|
||||
:return: The normalized arguments as a dict.
|
||||
:raises InvalidParams: If the arguments cannot be resolved to a dict.
|
||||
"""
|
||||
if arguments is None:
|
||||
return {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise InvalidParams(
|
||||
f"Tool arguments were provided as a string that is not valid JSON: {e}"
|
||||
) from e
|
||||
return _as_dict(parsed)
|
||||
|
||||
return _as_dict(arguments)
|
||||
|
||||
|
||||
def _as_dict(value: t.Any) -> t.Dict[str, t.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
raise InvalidParams(
|
||||
f"Tool arguments must resolve to an object, received {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _make_safe_name(name: str) -> str:
|
||||
"""Append ``_rs`` to a Python keyword so it can be used as a parameter name."""
|
||||
return f"{name}_rs"
|
||||
|
||||
|
||||
def _make_python_identifier(name: str) -> str:
|
||||
if keyword.iskeyword(name):
|
||||
return _make_safe_name(name)
|
||||
|
||||
safe = "".join(
|
||||
char if (char.isascii() and (char.isalnum() or char == "_")) else "_"
|
||||
for char in name
|
||||
)
|
||||
if not safe:
|
||||
safe = "param"
|
||||
if safe[0].isdigit() or safe[0] == "_":
|
||||
safe = f"param_{safe.lstrip('_') or 'value'}"
|
||||
if keyword.iskeyword(safe):
|
||||
safe = _make_safe_name(safe)
|
||||
if len(safe) > _MAX_PROVIDER_ALIAS_LENGTH:
|
||||
hash_suffix = hashlib.sha256(name.encode()).hexdigest()[:8]
|
||||
safe = (
|
||||
f"{safe[: _MAX_PROVIDER_ALIAS_LENGTH - len(hash_suffix) - 1]}_{hash_suffix}"
|
||||
)
|
||||
return safe
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ToolSchemaAliases:
|
||||
"""Provider-visible schema plus mapping back to backend argument names."""
|
||||
|
||||
schema: t.Dict[str, t.Any]
|
||||
aliases: t.Dict[str, t.Any]
|
||||
|
||||
def restore_arguments(self, arguments: dict) -> dict:
|
||||
return restore_tool_arguments(arguments, self.aliases)
|
||||
|
||||
|
||||
def alias_tool_input_schema(schema: t.Dict) -> ToolSchemaAliases:
|
||||
"""Alias tool input schema keys so they are valid Python parameter names.
|
||||
|
||||
Returns a :class:`ToolSchemaAliases` object containing a deep-copied schema
|
||||
for provider/framework exposure and a reverse alias map for restoring model
|
||||
arguments before calling the backend executor.
|
||||
|
||||
Python keywords keep the historical ``_rs`` suffix (``from`` becomes
|
||||
``from_rs``). Other names that cannot be used as Python identifiers are
|
||||
converted to Pydantic-safe identifiers and long aliases are capped at 64
|
||||
characters so the same provider-facing schema is accepted by
|
||||
Anthropic-style tool schema validators. Internal JSON Schema references are
|
||||
inlined before aliasing so referenced object properties are exposed through
|
||||
the same safe names. If two properties would expose the same alias, an
|
||||
:class:`InvalidSchemaError` is raised instead of guessing.
|
||||
"""
|
||||
aliased_schema = t.cast(
|
||||
t.Dict[str, t.Any],
|
||||
dereference_json_schema(
|
||||
copy.deepcopy(schema),
|
||||
on_unresolved="sentinel",
|
||||
),
|
||||
)
|
||||
schema_params, aliases = _alias_schema_properties(aliased_schema)
|
||||
return ToolSchemaAliases(schema=schema_params, aliases=aliases)
|
||||
|
||||
|
||||
def _alias_schema_properties(schema: t.Dict[str, t.Any]) -> t.Tuple[dict, dict]:
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return schema, {}
|
||||
|
||||
aliases: t.Dict[str, t.Any] = {}
|
||||
aliased_properties: t.Dict[str, t.Any] = {}
|
||||
|
||||
for original_name, property_schema in properties.items():
|
||||
safe_name = _make_python_identifier(original_name)
|
||||
if safe_name in aliased_properties:
|
||||
raise InvalidSchemaError(
|
||||
"Tool input schema property names produce a duplicate Python "
|
||||
f"parameter alias {safe_name!r}"
|
||||
)
|
||||
|
||||
nested_object_aliases: t.Dict[str, t.Any] = {}
|
||||
nested_array_aliases: t.Dict[str, t.Any] = {}
|
||||
if isinstance(property_schema, dict):
|
||||
property_schema, nested_object_aliases = _alias_nested_object_schema(
|
||||
property_schema
|
||||
)
|
||||
property_schema, nested_array_aliases = _alias_nested_array_schema(
|
||||
property_schema
|
||||
)
|
||||
|
||||
aliased_properties[safe_name] = property_schema
|
||||
if safe_name != original_name or nested_object_aliases or nested_array_aliases:
|
||||
aliases[safe_name] = original_name
|
||||
if nested_object_aliases:
|
||||
aliases[f"{safe_name}{_OBJ_MARKER}"] = nested_object_aliases
|
||||
if nested_array_aliases:
|
||||
aliases[f"{safe_name}{_ARR_MARKER}"] = nested_array_aliases
|
||||
|
||||
schema["properties"] = aliased_properties
|
||||
if aliases and "required" in schema:
|
||||
reverse = {
|
||||
original: safe
|
||||
for safe, original in aliases.items()
|
||||
if not safe.endswith(_OBJ_MARKER) and not safe.endswith(_ARR_MARKER)
|
||||
}
|
||||
schema["required"] = [reverse.get(r, r) for r in schema["required"]]
|
||||
|
||||
return schema, aliases
|
||||
|
||||
|
||||
def _alias_nested_object_schema(
|
||||
property_schema: t.Dict[str, t.Any],
|
||||
) -> t.Tuple[dict, dict]:
|
||||
if not isinstance(property_schema.get("properties"), dict):
|
||||
return property_schema, {}
|
||||
return _alias_schema_properties(property_schema)
|
||||
|
||||
|
||||
def _alias_nested_array_schema(
|
||||
property_schema: t.Dict[str, t.Any],
|
||||
) -> t.Tuple[dict, dict]:
|
||||
items_schema = property_schema.get("items")
|
||||
if not isinstance(items_schema, dict):
|
||||
return property_schema, {}
|
||||
aliased_items, aliases = _alias_schema_properties(items_schema)
|
||||
if aliases:
|
||||
property_schema["items"] = aliased_items
|
||||
return property_schema, aliases
|
||||
|
||||
|
||||
def restore_tool_arguments(request: dict, aliases: dict) -> dict:
|
||||
"""Restore provider-visible argument aliases back to backend schema names.
|
||||
|
||||
Modifies *request* in-place and returns it.
|
||||
"""
|
||||
alias_keys = [
|
||||
key
|
||||
for key in aliases
|
||||
if not key.endswith(_OBJ_MARKER) and not key.endswith(_ARR_MARKER)
|
||||
]
|
||||
for clean_key in sorted(alias_keys, reverse=True):
|
||||
if clean_key not in request:
|
||||
continue
|
||||
|
||||
original_value = request.pop(clean_key)
|
||||
object_aliases = aliases.get(f"{clean_key}{_OBJ_MARKER}", {})
|
||||
array_aliases = aliases.get(f"{clean_key}{_ARR_MARKER}", {})
|
||||
if object_aliases and isinstance(original_value, dict):
|
||||
original_value = restore_tool_arguments(
|
||||
request=original_value,
|
||||
aliases=object_aliases,
|
||||
)
|
||||
if array_aliases and isinstance(original_value, list):
|
||||
original_value = [
|
||||
restore_tool_arguments(item, array_aliases)
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in original_value
|
||||
]
|
||||
request[aliases.get(clean_key, clean_key)] = original_value
|
||||
return request
|
||||
|
||||
|
||||
def substitute_reserved_python_keywords(
|
||||
schema: t.Dict,
|
||||
) -> t.Tuple[dict, dict]:
|
||||
"""Replace unsafe JSON schema property names with Python parameter aliases.
|
||||
|
||||
Backward-compatible wrapper around :func:`alias_tool_input_schema`.
|
||||
"""
|
||||
aliased = alias_tool_input_schema(schema=schema)
|
||||
return aliased.schema, aliased.aliases
|
||||
|
||||
|
||||
def reinstate_reserved_python_keywords(
|
||||
request: dict,
|
||||
keywords: dict,
|
||||
) -> dict:
|
||||
"""Reverse the substitution performed by :func:`substitute_reserved_python_keywords`.
|
||||
|
||||
Modifies *request* **in-place** and returns it.
|
||||
"""
|
||||
return restore_tool_arguments(request=request, aliases=keywords)
|
||||
|
||||
|
||||
def _coerce_default_value(
|
||||
default: t.Any,
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
) -> t.Any:
|
||||
"""
|
||||
Coerce a default value to match the expected type from JSON schema.
|
||||
|
||||
Handles common mismatches where string defaults should be boolean/int/float.
|
||||
This fixes issues where API returns stringified defaults like "true" instead of true.
|
||||
|
||||
Coercion precedence: boolean > integer > float. This means values like "1" and "0"
|
||||
become booleans when both bool and int are expected types.
|
||||
|
||||
:param default: The default value from the JSON schema.
|
||||
:param json_schema: The JSON schema property definition.
|
||||
:return: The coerced default value, or original if no coercion possible.
|
||||
"""
|
||||
if default is None or not isinstance(default, str):
|
||||
return default
|
||||
|
||||
# Collect expected types from schema
|
||||
expected_types: t.Set[t.Any] = set()
|
||||
|
||||
if "type" in json_schema:
|
||||
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(json_schema["type"])
|
||||
if py_type is not None:
|
||||
expected_types.add(py_type)
|
||||
|
||||
for combiner in ("anyOf", "oneOf", "allOf"):
|
||||
for option in json_schema.get(combiner, []):
|
||||
if isinstance(option, dict):
|
||||
option_type = option.get("type")
|
||||
if isinstance(option_type, str):
|
||||
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(option_type)
|
||||
if py_type is not None:
|
||||
expected_types.add(py_type)
|
||||
|
||||
# If string is expected, no coercion needed
|
||||
if str in expected_types:
|
||||
return default
|
||||
|
||||
# Boolean coercion (takes precedence over int for "1"/"0")
|
||||
if bool in expected_types:
|
||||
lower_default = default.lower()
|
||||
if lower_default in ("true", "yes", "1"):
|
||||
return True
|
||||
if lower_default in ("false", "no", "0"):
|
||||
return False
|
||||
|
||||
# Integer coercion
|
||||
if int in expected_types:
|
||||
try:
|
||||
return int(default)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Float coercion
|
||||
if float in expected_types:
|
||||
try:
|
||||
return float(default)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def json_schema_to_pydantic_field(
|
||||
name: str,
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
required: t.List[str],
|
||||
skip_default: bool = False,
|
||||
) -> t.Tuple[str, t.Type, FieldInfo]:
|
||||
"""
|
||||
Converts a JSON schema property to a Pydantic field definition.
|
||||
|
||||
:param name: The field name.
|
||||
:param json_schema: The JSON schema property.
|
||||
:param required: List of required properties.
|
||||
:return: A Pydantic field definition.
|
||||
"""
|
||||
description = json_schema.get("description")
|
||||
if "oneOf" in json_schema:
|
||||
description = " | ".join(
|
||||
[option.get("description", "") for option in json_schema["oneOf"]]
|
||||
)
|
||||
description = f"Any of the following options(separated by |): {description}"
|
||||
|
||||
examples = json_schema.get("examples", [])
|
||||
default = json_schema.get("default")
|
||||
|
||||
# Coerce default value to match expected type from schema
|
||||
if default is not None:
|
||||
default = _coerce_default_value(default, json_schema)
|
||||
|
||||
# Check if the field name is a reserved Pydantic name
|
||||
original_name = name
|
||||
if name in reserved_names:
|
||||
name = f"{name}_"
|
||||
alias = original_name
|
||||
else:
|
||||
alias = None
|
||||
|
||||
field = {
|
||||
"description": description,
|
||||
"examples": examples,
|
||||
"alias": alias,
|
||||
}
|
||||
if not skip_default:
|
||||
field["default"] = ... if original_name in required else default
|
||||
|
||||
return (
|
||||
name,
|
||||
t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(
|
||||
json_schema=json_schema,
|
||||
),
|
||||
),
|
||||
Field(**field), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def json_schema_to_fields_dict(json_schema: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
|
||||
"""
|
||||
Converts a JSON schema to a dictionary of param name, and a tuple of type & Field.
|
||||
|
||||
:param json_schema: The JSON schema to convert.
|
||||
:return: dict<str, tuple<<class 'type'>, Field>>
|
||||
|
||||
Example Output:
|
||||
```python
|
||||
{
|
||||
'owner': (<class 'str'>, FieldInfo(default=Ellipsis, description='The account owner of the repository.', extra={'examples': ([],)})),
|
||||
'repo': (<class 'str'>, FieldInfo(default=Ellipsis, description='The name of the repository without the `.git` extension.', extra={'examples': ([],)}))}
|
||||
}
|
||||
```
|
||||
|
||||
"""
|
||||
field_definitions = {}
|
||||
for name, prop in json_schema.get("properties", {}).items():
|
||||
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
|
||||
name, prop, json_schema.get("required", [])
|
||||
)
|
||||
field_definitions[updated_name] = (pydantic_type, pydantic_field)
|
||||
return field_definitions # type: ignore
|
||||
|
||||
|
||||
def json_schema_to_model(
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
skip_default: bool = False,
|
||||
) -> t.Type[BaseModel]:
|
||||
"""
|
||||
Converts a JSON schema to a Pydantic BaseModel class.
|
||||
|
||||
:param json_schema: The JSON schema to convert.
|
||||
:param skip_default: Skip the default values when building field object
|
||||
:return: Pydantic `BaseModel` type
|
||||
"""
|
||||
model_name = json_schema.get("title")
|
||||
field_definitions = {}
|
||||
for name, prop in json_schema.get("properties", {}).items():
|
||||
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
|
||||
name,
|
||||
prop,
|
||||
json_schema.get("required", []),
|
||||
skip_default=skip_default,
|
||||
)
|
||||
field_definitions[updated_name] = (pydantic_type, pydantic_field)
|
||||
return create_model(model_name, **field_definitions) # type: ignore
|
||||
|
||||
|
||||
def pydantic_model_from_param_schema(param_schema: t.Dict) -> t.Type:
|
||||
"""
|
||||
Dynamically creates a Pydantic model from a schema dictionary.
|
||||
|
||||
:param param_schema: Schema with 'title', 'properties', and optionally 'required' keys.
|
||||
:return: A Pydantic model class for the defined schema.
|
||||
|
||||
:raised ValueError: Invalid 'type' for property or recursive model creation.
|
||||
|
||||
Note: Requires global `schema_type_python_type_dict` for type mapping and
|
||||
`fallback_values` for default values.
|
||||
"""
|
||||
required_fields = {}
|
||||
optional_fields = {}
|
||||
if "title" not in param_schema:
|
||||
raise ValueError(f"Missing 'title' in param_schema: {param_schema}")
|
||||
|
||||
param_title = str(param_schema["title"]).replace(" ", "")
|
||||
required_props = param_schema.get("required", [])
|
||||
|
||||
if param_schema.get("type") == "array":
|
||||
# print("param_schema inside array - ", param_schema)
|
||||
item_schema = param_schema.get("items")
|
||||
if item_schema:
|
||||
ItemType = t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(
|
||||
json_schema=item_schema,
|
||||
),
|
||||
)
|
||||
return t.List[ItemType] # type: ignore
|
||||
return t.List
|
||||
|
||||
for prop_name, prop_info in param_schema.get("properties", {}).items():
|
||||
prop_type = prop_info.get("type")
|
||||
prop_title = prop_info.get("title", prop_name).replace(" ", "")
|
||||
prop_default = prop_info.get("default", FALLBACK_VALUES.get(prop_type))
|
||||
if (
|
||||
prop_type is not None
|
||||
and prop_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
|
||||
and prop_type not in CONTAINER_TYPE
|
||||
):
|
||||
signature_prop_type = PYDANTIC_TYPE_TO_PYTHON_TYPE[prop_type]
|
||||
elif prop_type is None:
|
||||
# Schema uses anyOf/allOf/oneOf/$ref instead of a top-level "type" key.
|
||||
# Delegate to json_schema_to_pydantic_type which handles all combiners.
|
||||
signature_prop_type = t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(json_schema=prop_info),
|
||||
)
|
||||
else:
|
||||
signature_prop_type = pydantic_model_from_param_schema(prop_info)
|
||||
|
||||
field_kwargs = {
|
||||
"description": prop_info.get(
|
||||
"description", prop_info.get("desc", prop_title)
|
||||
),
|
||||
}
|
||||
|
||||
# Add alias if the field name is a reserved Pydantic name
|
||||
if prop_name in reserved_names:
|
||||
field_kwargs["alias"] = prop_name
|
||||
field_kwargs["title"] = f"{prop_name}_"
|
||||
else:
|
||||
field_kwargs["title"] = prop_title
|
||||
|
||||
if prop_name in required_props:
|
||||
required_fields[prop_name] = (
|
||||
signature_prop_type,
|
||||
Field(..., **field_kwargs),
|
||||
)
|
||||
else:
|
||||
optional_fields[prop_name] = (
|
||||
signature_prop_type,
|
||||
Field(default=prop_default, **field_kwargs),
|
||||
)
|
||||
|
||||
if not required_fields and not optional_fields:
|
||||
return t.Dict
|
||||
|
||||
return create_model( # type: ignore
|
||||
param_title,
|
||||
**required_fields,
|
||||
**optional_fields,
|
||||
)
|
||||
|
||||
|
||||
def get_signature_format_from_schema_params(
|
||||
schema_params: t.Dict,
|
||||
skip_default: bool = False,
|
||||
) -> t.List[Parameter]:
|
||||
"""
|
||||
Get function parameters signature(with pydantic field definition as default values)
|
||||
from schema parameters. Works like:
|
||||
|
||||
def demo_function(
|
||||
owner: str,
|
||||
repo: str),
|
||||
)
|
||||
|
||||
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
|
||||
:return: List of required and optional parameters
|
||||
|
||||
Output Format:
|
||||
[
|
||||
<Parameter "owner: str">,
|
||||
<Parameter "repo: str">
|
||||
]
|
||||
"""
|
||||
default_parameters = []
|
||||
none_default_parameters = []
|
||||
|
||||
required_params = schema_params.get("required", [])
|
||||
schema_params_object = schema_params.get("properties", {})
|
||||
for param_name, param_schema in schema_params_object.items():
|
||||
param_type = param_schema.get("type", None)
|
||||
param_oneOf = param_schema.get("oneOf", None)
|
||||
param_anyOf = param_schema.get("anyOf", None)
|
||||
param_allOf = param_schema.get("allOf", None)
|
||||
if param_allOf is not None and len(param_allOf) == 1:
|
||||
param_type = param_allOf[0].get("type", None)
|
||||
if param_oneOf is not None or param_anyOf is not None:
|
||||
param_types = [ptype.get("type") for ptype in (param_oneOf or param_anyOf)]
|
||||
# Map each option to a Python type, falling back to t.Any for options
|
||||
# that are missing a "type" key or use an unrecognized type, then build
|
||||
# a Union for any count of members (no 1/2/3-member cap).
|
||||
mapped_types: t.List[t.Any] = [
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE.get(ptype, t.Any) for ptype in param_types
|
||||
]
|
||||
if len(mapped_types) == 1:
|
||||
annotation = mapped_types[0]
|
||||
else:
|
||||
annotation = reduce(lambda a, b: t.Union[a, b], mapped_types)
|
||||
param_default = param_schema.get("default", "")
|
||||
elif param_type in PYDANTIC_TYPE_TO_PYTHON_TYPE:
|
||||
annotation = PYDANTIC_TYPE_TO_PYTHON_TYPE[param_type]
|
||||
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
|
||||
else:
|
||||
annotation = pydantic_model_from_param_schema(param_schema)
|
||||
if param_type is None or param_type == "null":
|
||||
param_default = None
|
||||
else:
|
||||
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
|
||||
|
||||
default = param_default
|
||||
required = param_schema.get("required", False) or param_name in required_params
|
||||
if required:
|
||||
default = Parameter.empty
|
||||
|
||||
if skip_default:
|
||||
default = Parameter.empty
|
||||
|
||||
parameter = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=annotation,
|
||||
default=default,
|
||||
)
|
||||
if required:
|
||||
default_parameters.append(parameter)
|
||||
continue
|
||||
none_default_parameters.append(parameter)
|
||||
return default_parameters + none_default_parameters
|
||||
|
||||
|
||||
def get_pydantic_signature_format_from_schema_params(
|
||||
schema_params: t.Dict,
|
||||
skip_default: bool = False,
|
||||
) -> t.List[Parameter]:
|
||||
"""
|
||||
Get function parameters signature(with pydantic field definition as default values)
|
||||
from schema parameters. Works like:
|
||||
|
||||
def demo_function(
|
||||
owner: str=Field(..., description='The account owner of the repository.'),
|
||||
repo: str=Field(..., description='The name of the repository without the `.git` extension.'),
|
||||
)
|
||||
|
||||
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
|
||||
:return: List of required and optional parameters
|
||||
|
||||
Example Output Format:
|
||||
```python
|
||||
[
|
||||
<Parameter "owner: str = FieldInfo(
|
||||
default=Ellipsis,
|
||||
description='The account owner of the repository.',
|
||||
extra={'examples': ([],)})">,
|
||||
<Parameter "repo: str = FieldInfo(
|
||||
default=Ellipsis,
|
||||
description='The name of the repository without the `.git` extension.',
|
||||
extra={'examples': ([],)})">
|
||||
]
|
||||
```
|
||||
"""
|
||||
all_parameters = []
|
||||
field_definitions = json_schema_to_fields_dict(schema_params)
|
||||
for param_name, (param_dtype, parame_field) in field_definitions.items():
|
||||
param = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=param_dtype,
|
||||
default=Parameter.empty if skip_default else parame_field.default,
|
||||
)
|
||||
all_parameters.append(param)
|
||||
|
||||
return all_parameters
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""Generate a unique request ID."""
|
||||
return str(uuid.uuid4())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Utilities for handling toolkit versions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
from composio.core.types import ToolkitVersion, ToolkitVersionParam, ToolkitVersions
|
||||
|
||||
|
||||
def normalize_toolkit_slug(toolkit_slug: str) -> str:
|
||||
"""
|
||||
Canonicalizes a toolkit slug into the form used as a version-map key.
|
||||
|
||||
Toolkit slugs are matched case-insensitively. This is the single source of
|
||||
truth for that rule: every write into a version map (env vars, user-supplied
|
||||
dicts) and every read out of one MUST go through this helper so the two sides
|
||||
can never drift apart and silently miss a configured pin.
|
||||
|
||||
Kept intentionally equivalent to the TypeScript SDK's ``normalizeToolkitSlug``
|
||||
(see ts/packages/core/src/utils/toolkitVersion.ts).
|
||||
|
||||
:param toolkit_slug: The slug/name of the toolkit, in any casing
|
||||
:return: The normalized (lowercase) slug used as a version-map key
|
||||
"""
|
||||
return toolkit_slug.lower()
|
||||
|
||||
|
||||
def get_toolkit_version(
|
||||
toolkit_slug: str, toolkit_versions: t.Optional[ToolkitVersionParam] = None
|
||||
) -> ToolkitVersion:
|
||||
"""
|
||||
Gets the version for a specific toolkit based on the provided toolkit versions configuration.
|
||||
|
||||
:param toolkit_slug: The slug/name of the toolkit to get the version for
|
||||
:param toolkit_versions: Optional toolkit versions configuration (string for global version
|
||||
or dict mapping toolkit slugs to versions)
|
||||
:return: The toolkit version to use - either the specific version from config, or 'latest' as fallback
|
||||
"""
|
||||
# If toolkit_versions is a string, use it as a global version for all toolkits
|
||||
if isinstance(toolkit_versions, str):
|
||||
return toolkit_versions
|
||||
|
||||
# If toolkit_versions is a dict mapping, look up the specific toolkit version.
|
||||
# The map is keyed by normalized slugs, so normalize the lookup too
|
||||
# (see normalize_toolkit_slug for why).
|
||||
if isinstance(toolkit_versions, dict) and len(toolkit_versions) > 0:
|
||||
return toolkit_versions.get(normalize_toolkit_slug(toolkit_slug), "latest")
|
||||
|
||||
# Else use 'latest'
|
||||
return "latest"
|
||||
|
||||
|
||||
def get_toolkit_versions(
|
||||
default_versions: t.Optional[ToolkitVersionParam] = None,
|
||||
) -> ToolkitVersionParam:
|
||||
"""
|
||||
Gets toolkit versions configuration by merging environment variables, user-provided defaults, and fallbacks.
|
||||
|
||||
Priority order:
|
||||
1. If default_versions is a string, use it as a global version for all toolkits
|
||||
2. User-provided toolkit version mappings (default_versions dict)
|
||||
3. Environment variables (COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_NAME>)
|
||||
4. Fallback to 'latest' if no versions are configured
|
||||
|
||||
:param default_versions: Optional default versions configuration (string for global version or dict mapping toolkit names to versions)
|
||||
:return: Toolkit versions configuration - either a string for global version or dict mapping toolkit names to versions
|
||||
"""
|
||||
# If already set by user as a string, use it as global version for all toolkits
|
||||
if isinstance(default_versions, str):
|
||||
return default_versions
|
||||
|
||||
# Check if there are envs similar to COMPOSIO_TOOLKIT_VERSION_GITHUB then extract the toolkit name
|
||||
toolkit_versions_from_env: ToolkitVersions = {}
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("COMPOSIO_TOOLKIT_VERSION_"):
|
||||
toolkit_name = key.replace("COMPOSIO_TOOLKIT_VERSION_", "")
|
||||
toolkit_versions_from_env[normalize_toolkit_slug(toolkit_name)] = value
|
||||
|
||||
# Normalize keys via normalize_toolkit_slug (the same helper the lookup uses);
|
||||
# user-provided values override env.
|
||||
user_provided_toolkit_versions: ToolkitVersions = {}
|
||||
if default_versions and isinstance(default_versions, dict):
|
||||
user_provided_toolkit_versions = {
|
||||
normalize_toolkit_slug(key): value
|
||||
for key, value in default_versions.items()
|
||||
}
|
||||
|
||||
# Final toolkit versions
|
||||
toolkit_versions = {
|
||||
**toolkit_versions_from_env,
|
||||
**user_provided_toolkit_versions,
|
||||
}
|
||||
|
||||
# If the toolkit_versions are empty, use 'latest'
|
||||
if len(toolkit_versions) == 0:
|
||||
return "latest"
|
||||
|
||||
return toolkit_versions
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Allowlist enforcement for automatic file upload during tool execution.
|
||||
|
||||
This is only consulted when
|
||||
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
|
||||
not subject to the allowlist (parity with the TypeScript SDK).
|
||||
|
||||
- A local path is accepted iff its symlink-resolved absolute path is inside one
|
||||
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
|
||||
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
|
||||
- URLs never hit this check.
|
||||
- User-provided ``file_upload_dirs`` fully REPLACES the default
|
||||
``[~/.composio/temp]``.
|
||||
- On Windows, path comparisons are case-insensitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from composio.exceptions import (
|
||||
FileUploadPathNotAllowedError,
|
||||
SDKFileNotFoundError,
|
||||
)
|
||||
|
||||
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
|
||||
|
||||
|
||||
def get_default_upload_dir() -> t.Optional[Path]:
|
||||
"""Absolute path of the default upload staging directory
|
||||
(``<home>/.composio/temp``), or None when a home directory cannot be
|
||||
determined."""
|
||||
try:
|
||||
home = Path.home()
|
||||
except (RuntimeError, OSError):
|
||||
return None
|
||||
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
|
||||
|
||||
|
||||
def ensure_dir_exists(p: Path) -> None:
|
||||
"""Best-effort directory creation. Allowlist enforcement still works if
|
||||
creation fails (e.g. read-only filesystem)."""
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
|
||||
"""User-facing type for ``file_upload_dirs``.
|
||||
|
||||
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
|
||||
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
|
||||
work).
|
||||
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
|
||||
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
|
||||
"""
|
||||
|
||||
|
||||
def resolve_effective_upload_allowlist(
|
||||
user_dirs: FileUploadDirs,
|
||||
) -> t.List[Path]:
|
||||
"""Resolve the effective allowlist.
|
||||
|
||||
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
|
||||
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
|
||||
in-memory bytes still work because they aren't path-checked.
|
||||
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
|
||||
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
|
||||
equivalent to ``False``.
|
||||
- Empty / blank / non-string entries are skipped.
|
||||
- Duplicates are removed (case-insensitive on Windows).
|
||||
"""
|
||||
if user_dirs is None:
|
||||
default = get_default_upload_dir()
|
||||
return [default] if default is not None else []
|
||||
if user_dirs is False:
|
||||
return []
|
||||
|
||||
seen: t.Set[str] = set()
|
||||
out: t.List[Path] = []
|
||||
for raw in user_dirs:
|
||||
if not isinstance(raw, (str, os.PathLike)):
|
||||
continue
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
abs_path = Path(s).expanduser().resolve()
|
||||
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(abs_path)
|
||||
return out
|
||||
|
||||
|
||||
def _is_inside_dir(child: Path, parent: Path) -> bool:
|
||||
"""True iff ``child`` equals ``parent`` or is nested inside on a component
|
||||
boundary. Assumes both are absolute and normalized."""
|
||||
try:
|
||||
child_str = str(child)
|
||||
parent_str = str(parent)
|
||||
if sys.platform == "win32":
|
||||
child_str = child_str.lower()
|
||||
parent_str = parent_str.lower()
|
||||
if child_str == parent_str:
|
||||
return True
|
||||
sep = os.sep
|
||||
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
|
||||
return child_str.startswith(parent_with_sep)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
|
||||
if not dirs:
|
||||
return " (none configured — all local paths are blocked)"
|
||||
return "\n".join(f" - {d}" for d in dirs)
|
||||
|
||||
|
||||
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"",
|
||||
"Allowed upload directories (file_upload_dirs):",
|
||||
_format_allowlist(allowlist),
|
||||
"",
|
||||
"Fix one of the following:",
|
||||
"",
|
||||
" 1. Recommended — move the file under an allowed directory, or add your",
|
||||
" directory to the allowlist:",
|
||||
" Composio(",
|
||||
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
|
||||
" )",
|
||||
" Note: user-provided `file_upload_dirs` REPLACES the default",
|
||||
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
|
||||
" to keep working.",
|
||||
"",
|
||||
" 2. Pass the file by URL (http://... / https://...) instead of a",
|
||||
" filesystem path. URLs are never path-checked.",
|
||||
"",
|
||||
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
|
||||
" file upload/download from any readable path:",
|
||||
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
|
||||
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
|
||||
" file the process can access. Only enable it in trusted, sandboxed",
|
||||
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
|
||||
" .env, private SSH keys, etc.) still applies unless you also set",
|
||||
" `sensitive_file_upload_protection=False`.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def assert_path_inside_upload_dirs(
|
||||
file_path: t.Union[str, Path],
|
||||
allowlist: t.Sequence[Path],
|
||||
) -> None:
|
||||
"""Raise an elaborate error if the path is missing or outside the allowlist.
|
||||
|
||||
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
|
||||
:raises FileUploadPathNotAllowedError: when resolved path is outside every
|
||||
entry of ``allowlist`` (including when allowlist is empty).
|
||||
"""
|
||||
attempted = str(file_path)
|
||||
abs_path = Path(attempted).expanduser()
|
||||
try:
|
||||
abs_path = abs_path.resolve(strict=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not abs_path.exists():
|
||||
cwd = Path.cwd()
|
||||
parent = abs_path.parent
|
||||
parent_exists = parent.exists()
|
||||
raise SDKFileNotFoundError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {abs_path}",
|
||||
f"Process cwd: {cwd}",
|
||||
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
|
||||
"",
|
||||
"Common causes:",
|
||||
" - Typo in the filename passed to the tool.",
|
||||
" - Relative path resolved against the wrong working directory",
|
||||
" (relative paths use os.getcwd() at the moment of upload).",
|
||||
" - File was deleted between the tool being called and the upload starting.",
|
||||
"",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
real_path = abs_path.resolve(strict=True)
|
||||
except OSError:
|
||||
real_path = abs_path
|
||||
|
||||
if not allowlist:
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
"",
|
||||
"Automatic file upload during tool execution is locked down by default",
|
||||
"to prevent a prompt-injected tool from exfiltrating server files",
|
||||
"(source code, .env, SSH keys, etc.).",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
for dir_entry in allowlist:
|
||||
try:
|
||||
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
|
||||
except OSError:
|
||||
real_dir = Path(dir_entry)
|
||||
if _is_inside_dir(real_path, real_dir):
|
||||
return
|
||||
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
|
||||
"directory in the configured `file_upload_dirs` allowlist.",
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""UUID utility functions for Composio SDK."""
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a random UUID v4 string."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def generate_short_id() -> str:
|
||||
"""
|
||||
Generate a short ID (8 characters) from a UUID.
|
||||
|
||||
Returns the first 8 characters of a UUID with dashes removed.
|
||||
"""
|
||||
return generate_uuid()[:8].replace("-", "")
|
||||
Reference in New Issue
Block a user