chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:34 +08:00
commit 0549b088a4
2405 changed files with 810255 additions and 0 deletions
+32
View File
@@ -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",
]
+257
View File
@@ -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
+169
View File
@@ -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)
+576
View File
@@ -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"
+116
View File
@@ -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
+44
View File
@@ -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
+288
View File
@@ -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."
)
+672
View File
@@ -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())
+99
View File
@@ -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),
]
)
)
+17
View File
@@ -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("-", "")