From 045cd43ff667379d2712803ca290ed09f2637983 Mon Sep 17 00:00:00 2001 From: SUY Date: Fri, 5 Jun 2026 19:47:52 -0500 Subject: [PATCH 1/2] Add export_manifests.py script and normalize integration categories --- scripts/export_manifests.py | 268 ++++++++++++++++++ .../tools/ahrefs/manifest.py | 2 +- .../tools/algolia/manifest.py | 2 +- .../tools/amazon_alexa/manifest.py | 2 +- .../tools/apollo_io/manifest.py | 2 +- .../tools/bloomerang/manifest.py | 2 +- .../tools/browser_use/manifest.py | 2 +- .../tools/cal_com/manifest.py | 2 +- .../tools/calendly/manifest.py | 2 +- .../tools/canva/manifest.py | 2 +- .../tools/clickup/manifest.py | 7 +- .../tools/coinbase/manifest.py | 2 +- .../tools/coinmarketcap/manifest.py | 2 +- .../tools/convertapi/manifest.py | 2 +- .../tools/crunchbase/manifest.py | 2 +- .../tools/customerio/manifest.py | 2 +- .../tools/databricks/manifest.py | 2 +- .../tools/dropbox/manifest.py | 7 +- .../tools/figma/manifest.py | 2 +- .../tools/gmail/manifest.py | 2 +- .../tools/godaddy/manifest.py | 2 +- .../tools/google_ad_manager/manifest.py | 2 +- .../tools/google_analytics/manifest.py | 1 + .../tools/google_calendar/manifest.py | 2 +- .../tools/google_drive/manifest.py | 2 +- .../tools/google_maps_platform/manifest.py | 2 +- .../tools/google_my_business/manifest.py | 2 +- .../tools/google_search_console/manifest.py | 2 +- .../tools/google_tag_manager/manifest.py | 2 +- .../tools/hackernews/manifest.py | 2 +- .../tools/heygen/manifest.py | 2 +- .../tools/hootsuite/manifest.py | 2 +- .../tools/hunter/manifest.py | 2 +- .../tools/instacart/manifest.py | 2 +- .../tools/intercom/manifest.py | 7 +- .../tools/jira/manifest.py | 7 +- .../tools/klaviyo/manifest.py | 8 +- .../tools/lemon_squeezy/manifest.py | 1 + .../tools/livestorm/manifest.py | 2 +- .../tools/luma/manifest.py | 2 +- .../tools/mailchimp/manifest.py | 2 +- .../tools/medium/manifest.py | 2 +- .../tools/microsoft_bookings/manifest.py | 7 +- .../tools/microsoft_entra_id/manifest.py | 7 +- .../tools/microsoft_onedrive/manifest.py | 2 +- .../tools/microsoft_outlook/manifest.py | 2 +- .../tools/microsoft_power_bi/manifest.py | 2 +- .../tools/mixpanel/manifest.py | 2 +- .../tools/monday/manifest.py | 2 +- .../tools/motion/manifest.py | 2 +- .../tools/mysql/manifest.py | 2 +- .../tools/nasdaq/manifest.py | 2 +- .../tools/notion/manifest.py | 7 +- .../tools/okta/manifest.py | 6 +- .../tools/pagerduty/manifest.py | 6 +- .../tools/pinterest/manifest.py | 1 + .../tools/postgresql/manifest.py | 2 +- .../tools/postgrid/manifest.py | 2 +- .../tools/posthog/manifest.py | 2 +- .../tools/product_hunt/manifest.py | 2 +- .../tools/segment/manifest.py | 2 +- .../tools/semrush/manifest.py | 8 +- .../tools/sendgrid/manifest.py | 2 +- .../tools/sentry/manifest.py | 7 +- .../tools/shopify_partner/manifest.py | 2 +- .../tools/short_io/manifest.py | 2 +- .../tools/slack/manifest.py | 2 +- .../tools/snowflake/manifest.py | 2 +- .../tools/square/manifest.py | 2 +- .../tools/supabase/manifest.py | 2 +- .../tools/telegram/manifest.py | 1 + .../tools/tinyurl/manifest.py | 2 +- .../tools/yelp/manifest.py | 2 +- 73 files changed, 395 insertions(+), 73 deletions(-) create mode 100644 scripts/export_manifests.py diff --git a/scripts/export_manifests.py b/scripts/export_manifests.py new file mode 100644 index 0000000..9b1ee6e --- /dev/null +++ b/scripts/export_manifests.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Export every integration manifest to a single ``manifests.json``. + +This is the **data contract** consumed by the modulex-docs site generator +(``scripts/generate-integrations.js``) to render one documentation page per +integration. It is a build/CI tool, not part of the shipped runtime package — +it lives under ``scripts/`` and imports the installed ``modulex_integrations``. + +Design (why it looks the way it does): + +* **Discovery is filesystem-based, not entry-point-based.** Some integrations + exist on disk before their ``modulex.tools`` entry point is registered in + ``pyproject.toml``; entry-point discovery would silently drop them from the + docs. We walk ``src/modulex_integrations/tools/*/manifest.py`` instead, and + cross-check against the entry-point group so missing registrations surface as + warnings (the modulex runtime can't see those tools either). + +* **Manifests load in isolation.** Each ``manifest.py`` only depends on + ``modulex_integrations.schema`` (no SDK/optional deps), so we exec it via + ``spec_from_file_location`` *without* triggering the package ``__init__`` — + which would import ``tools.py`` and pull in per-integration optional + dependencies (boto3, snowflake-connector, psycopg, ...) that may not be + installed. This guarantees all integrations export even in a base venv. + +* **``output_schema`` is best-effort.** Deriving it requires importing + ``tools.py`` (to read each ``@tool``'s pydantic return annotation via + ``typing.get_type_hints``, mirroring modulex's ``package_loader``). When that + import fails (missing optional dep) we emit ``null`` for that integration's + action schemas and record a warning, rather than failing the whole export. + +Usage:: + + .venv/bin/python scripts/export_manifests.py # -> dist/manifests.json + .venv/bin/python scripts/export_manifests.py --out - # -> stdout +""" +from __future__ import annotations + +import argparse +import importlib +import importlib.util +import inspect +import json +import sys +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = "1.0" + + +def _repo_root() -> Path: + """Repo root, derived from this file's location (scripts/ is a sibling of src/).""" + return Path(__file__).resolve().parent.parent + + +def _tools_dir() -> Path: + return _repo_root() / "src" / "modulex_integrations" / "tools" + + +def _package_version() -> str: + try: + from importlib.metadata import version + + return version("modulex-integrations") + except Exception: + return "0.0.0+unknown" + + +def _registered_entry_point_names() -> set[str] | None: + """Names in pyproject's ``modulex.tools`` entry-point table (source of truth). + + Parsed from ``pyproject.toml`` directly, NOT from installed package metadata: + an editable install's entry-point metadata goes stale the moment a new tool + is added to ``pyproject.toml`` without a reinstall, which would wrongly flag + freshly-added (but correctly registered) tools as missing. Returns ``None`` + if the table can't be read, signalling callers to skip the check entirely. + """ + try: + import tomllib + + data = tomllib.loads( + (_repo_root() / "pyproject.toml").read_text(encoding="utf-8") + ) + return set(data["project"]["entry-points"]["modulex.tools"].keys()) + except Exception: + return None + + +def _load_manifest(manifest_path: Path, name: str) -> Any: + """Exec a single ``manifest.py`` in isolation and return its ``manifest`` object. + + Bypasses the package ``__init__`` (and therefore ``tools.py`` and its + optional deps) by loading the file under a synthetic module name. + """ + spec = importlib.util.spec_from_file_location(f"_mxi_manifest_{name}", manifest_path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot build import spec for {manifest_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.manifest + + +def _output_schema_for(tool_obj: Any) -> dict[str, Any] | None: + """Derive an action's output JSONSchema from its ``@tool`` return annotation. + + Mirrors modulex's ``package_loader``: unwrap the ``serialize_pydantic_return`` + wrapper (``functools.wraps`` exposes ``__wrapped__``) so ``get_type_hints`` + resolves the stringified PEP 563 annotation against the original module's + globals, then call ``model_json_schema()`` on the pydantic return type. + """ + fn = getattr(tool_obj, "coroutine", None) or getattr(tool_obj, "func", None) + if fn is None: + return None + fn = inspect.unwrap(fn) + try: + from typing import get_type_hints + + hints = get_type_hints(fn) + except Exception: + return None + return_type = hints.get("return") + if return_type is None or not hasattr(return_type, "model_json_schema"): + return None + try: + return return_type.model_json_schema() + except Exception: + return None + + +def _parse_readme(readme_path: Path) -> dict[str, Any]: + """Split a 5-section README into intro + ``{heading: body}`` sections. + + The unique hand-written prose (especially 'Limits & Quotas') is what keeps + each generated docs page from being thin/duplicate content for SEO. + """ + if not readme_path.is_file(): + return {} + intro_lines: list[str] = [] + sections: dict[str, str] = {} + current: str | None = None + buf: list[str] = [] + + def _flush() -> None: + if current is not None: + sections[current] = "\n".join(buf).strip() + + for raw in readme_path.read_text(encoding="utf-8").splitlines(): + if raw.startswith("# "): # h1 title — skip, intro follows + continue + if raw.startswith("## "): # new level-2 section + _flush() + current = raw[3:].strip() + buf = [] + continue + if current is None: + intro_lines.append(raw) + else: + buf.append(raw) + _flush() + + return {"intro": "\n".join(intro_lines).strip(), "sections": sections} + + +def _export_one( + name: str, registered: set[str] | None, warnings: list[str] +) -> dict[str, Any] | None: + base = _tools_dir() / name + try: + manifest = _load_manifest(base / "manifest.py", name) + except Exception as exc: # malformed/partial integration — skip, don't abort + warnings.append(f"{name}: manifest load failed ({type(exc).__name__}: {exc})") + return None + + data: dict[str, Any] = manifest.model_dump(mode="json") + # ``registered`` is None when pyproject couldn't be read — treat as unknown + # (optimistic True, no warning) rather than flag every tool. + known = registered is not None + data["registered_entry_point"] = (not known) or name in registered + if known and name not in registered: + warnings.append( + f'{name}: on disk but NOT in pyproject [project.entry-points."modulex.tools"]' + ) + + # Best-effort output schemas (requires importing tools.py + its optional deps). + tools_by_name: dict[str, Any] = {} + try: + pkg = importlib.import_module(f"modulex_integrations.tools.{name}") + tools_by_name = {t.name: t for t in getattr(pkg, "TOOLS", ())} + except Exception as exc: + warnings.append( + f"{name}: tools import failed ({type(exc).__name__}); output_schema omitted" + ) + + for action in data.get("actions", []): + tool_obj = tools_by_name.get(action["name"]) + action["output_schema"] = _output_schema_for(tool_obj) if tool_obj else None + + data["readme"] = _parse_readme(base / "README.md") + return data + + +def build() -> dict[str, Any]: + tools_dir = _tools_dir() + if not tools_dir.is_dir(): + raise SystemExit(f"tools dir not found: {tools_dir}") + + names = sorted( + p.name + for p in tools_dir.iterdir() + if p.is_dir() and (p / "manifest.py").is_file() + ) + registered = _registered_entry_point_names() + warnings: list[str] = [] + integrations: list[dict[str, Any]] = [] + + for name in names: + record = _export_one(name, registered, warnings) + if record is not None: + integrations.append(record) + + return { + "schema_version": SCHEMA_VERSION, + "package_version": _package_version(), + "integration_count": len(integrations), + "warnings": warnings, + "integrations": integrations, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out", + default="dist/manifests.json", + help="output path, or '-' for stdout (default: dist/manifests.json)", + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indent (default: 2)") + args = parser.parse_args(argv) + + payload = build() + text = json.dumps(payload, indent=args.indent, ensure_ascii=False) + + if args.out == "-": + sys.stdout.write(text + "\n") + else: + out_path = Path(args.out) + if not out_path.is_absolute(): + out_path = _repo_root() / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(text + "\n", encoding="utf-8") + print(f"wrote {out_path} ({payload['integration_count']} integrations)", file=sys.stderr) + + schema_ok = sum( + 1 + for it in payload["integrations"] + for a in it.get("actions", []) + if a.get("output_schema") is not None + ) + schema_total = sum(len(it.get("actions", [])) for it in payload["integrations"]) + print( + f"output_schema coverage: {schema_ok}/{schema_total} actions · " + f"{len(payload['warnings'])} warning(s)", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/modulex_integrations/tools/ahrefs/manifest.py b/src/modulex_integrations/tools/ahrefs/manifest.py index 25d78b5..8e18354 100644 --- a/src/modulex_integrations/tools/ahrefs/manifest.py +++ b/src/modulex_integrations/tools/ahrefs/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:ahrefs", app_url="https://ahrefs.com", - categories=["SEO", "Marketing", "Web Search & Scraping"], + categories=["Marketing & Advertising", "SEO", "Marketing", "Web Search & Scraping"], actions=[ ActionDefinition( name="get_backlinks", diff --git a/src/modulex_integrations/tools/algolia/manifest.py b/src/modulex_integrations/tools/algolia/manifest.py index 39067c4..ac1b5d2 100644 --- a/src/modulex_integrations/tools/algolia/manifest.py +++ b/src/modulex_integrations/tools/algolia/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:algolia", app_url="https://www.algolia.com", - categories=["Search", "Developer Tools & Infrastructure"], + categories=["Developer Tools & Infrastructure", "Search"], actions=[ ActionDefinition( name="browse_records", diff --git a/src/modulex_integrations/tools/amazon_alexa/manifest.py b/src/modulex_integrations/tools/amazon_alexa/manifest.py index cdeca0d..e3cbc57 100644 --- a/src/modulex_integrations/tools/amazon_alexa/manifest.py +++ b/src/modulex_integrations/tools/amazon_alexa/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:amazon_alexa", app_url="https://developer.amazon.com/alexa", - categories=["Voice Assistants", "IoT", "Smart Home"], + categories=["AI & Machine Learning", "Voice Assistants", "IoT", "Smart Home"], actions=[ ActionDefinition( name="simulate_skill", diff --git a/src/modulex_integrations/tools/apollo_io/manifest.py b/src/modulex_integrations/tools/apollo_io/manifest.py index a88443a..721d8d7 100644 --- a/src/modulex_integrations/tools/apollo_io/manifest.py +++ b/src/modulex_integrations/tools/apollo_io/manifest.py @@ -37,7 +37,7 @@ def _pagination_params(default_per_page: int = 25) -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:apolloio", app_url="https://www.apollo.io", - categories=["CRM & Customer", "sales", "customer_support"], + categories=["Sales", "CRM & Customer", "sales", "customer_support"], actions=[ # --- Enrichment --- ActionDefinition( diff --git a/src/modulex_integrations/tools/bloomerang/manifest.py b/src/modulex_integrations/tools/bloomerang/manifest.py index 6aac32c..652b9b7 100644 --- a/src/modulex_integrations/tools/bloomerang/manifest.py +++ b/src/modulex_integrations/tools/bloomerang/manifest.py @@ -22,7 +22,7 @@ version="1.0.0", author="ModuleX", app_url="https://bloomerang.co", - categories=["Nonprofit", "CRM", "Fundraising"], + categories=["CRM", "Nonprofit", "Fundraising"], actions=[ ActionDefinition( name="create_constituent", diff --git a/src/modulex_integrations/tools/browser_use/manifest.py b/src/modulex_integrations/tools/browser_use/manifest.py index b40bca8..682069e 100644 --- a/src/modulex_integrations/tools/browser_use/manifest.py +++ b/src/modulex_integrations/tools/browser_use/manifest.py @@ -22,7 +22,7 @@ version="1.0.0", author="ModuleX", app_url="https://browser-use.com", - categories=["Automation", "AI", "Developer Tools & Infrastructure"], + categories=["AI & Machine Learning", "Automation", "AI", "Developer Tools & Infrastructure"], actions=[ ActionDefinition( name="create_session", diff --git a/src/modulex_integrations/tools/cal_com/manifest.py b/src/modulex_integrations/tools/cal_com/manifest.py index 4051e83..c6825be 100644 --- a/src/modulex_integrations/tools/cal_com/manifest.py +++ b/src/modulex_integrations/tools/cal_com/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:cal_com-themed", app_url="https://cal.com", - categories=["Productivity & Collaboration", "scheduling", "calendar"], + categories=["Scheduling & Events", "Productivity & Collaboration", "scheduling", "calendar"], actions=[ ActionDefinition( name="create_booking", diff --git a/src/modulex_integrations/tools/calendly/manifest.py b/src/modulex_integrations/tools/calendly/manifest.py index 38d6d7c..140dbc8 100644 --- a/src/modulex_integrations/tools/calendly/manifest.py +++ b/src/modulex_integrations/tools/calendly/manifest.py @@ -50,7 +50,7 @@ def _pagination_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:calendly", app_url="https://calendly.com", - categories=["Productivity", "calendar", "productivity", "meetings"], + categories=["Scheduling & Events", "Productivity", "calendar", "productivity", "meetings"], actions=[ ActionDefinition( name="get_current_user", diff --git a/src/modulex_integrations/tools/canva/manifest.py b/src/modulex_integrations/tools/canva/manifest.py index ad81595..ce57d6d 100644 --- a/src/modulex_integrations/tools/canva/manifest.py +++ b/src/modulex_integrations/tools/canva/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:canva", app_url="https://www.canva.com", - categories=["Design & Creative Tools", "Productivity & Collaboration"], + categories=["Productivity & Collaboration", "Design & Creative Tools"], actions=[ ActionDefinition( name="create_design", diff --git a/src/modulex_integrations/tools/clickup/manifest.py b/src/modulex_integrations/tools/clickup/manifest.py index a0eae3a..3d356af 100644 --- a/src/modulex_integrations/tools/clickup/manifest.py +++ b/src/modulex_integrations/tools/clickup/manifest.py @@ -61,7 +61,12 @@ def _custom_task_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:clickup", app_url="https://clickup.com", - categories=["Project Management", "Productivity", "Communication & Collaboration"], + categories=[ + "Project & Task Management", + "Project Management", + "Productivity", + "Communication & Collaboration", + ], actions=[ # --- Workspace / team ---------------------------------------------- ActionDefinition( diff --git a/src/modulex_integrations/tools/coinbase/manifest.py b/src/modulex_integrations/tools/coinbase/manifest.py index e307c18..0933fd5 100644 --- a/src/modulex_integrations/tools/coinbase/manifest.py +++ b/src/modulex_integrations/tools/coinbase/manifest.py @@ -57,7 +57,7 @@ def _amount_currency_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:coinbase", app_url="https://www.coinbase.com", - categories=["Business Services", "finance", "trading", "market"], + categories=["Finance & Payments", "Business Services", "finance", "trading", "market"], actions=[ ActionDefinition( name="get_accounts", diff --git a/src/modulex_integrations/tools/coinmarketcap/manifest.py b/src/modulex_integrations/tools/coinmarketcap/manifest.py index 7492524..5bd4885 100644 --- a/src/modulex_integrations/tools/coinmarketcap/manifest.py +++ b/src/modulex_integrations/tools/coinmarketcap/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:coinmarketcap-themed", app_url="https://coinmarketcap.com", - categories=["Finance", "Cryptocurrency", "Market Data"], + categories=["Finance & Payments", "Finance", "Cryptocurrency", "Market Data"], actions=[ ActionDefinition( name="get_cryptocurrency_metadata", diff --git a/src/modulex_integrations/tools/convertapi/manifest.py b/src/modulex_integrations/tools/convertapi/manifest.py index 14669bf..dc35c8c 100644 --- a/src/modulex_integrations/tools/convertapi/manifest.py +++ b/src/modulex_integrations/tools/convertapi/manifest.py @@ -26,7 +26,7 @@ author="ModuleX", logo="modulex:convertapi", app_url="https://www.convertapi.com", - categories=["Utilities", "convert", "documents"], + categories=["Productivity & Collaboration", "Utilities", "convert", "documents"], actions=[ ActionDefinition( name="convert_file", diff --git a/src/modulex_integrations/tools/crunchbase/manifest.py b/src/modulex_integrations/tools/crunchbase/manifest.py index f71b281..d6d6584 100644 --- a/src/modulex_integrations/tools/crunchbase/manifest.py +++ b/src/modulex_integrations/tools/crunchbase/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:crunchbase", app_url="https://www.crunchbase.com", - categories=["Business Intelligence", "Data", "Research"], + categories=["Sales", "Business Intelligence", "Data", "Research"], actions=[ ActionDefinition( name="get_organization", diff --git a/src/modulex_integrations/tools/customerio/manifest.py b/src/modulex_integrations/tools/customerio/manifest.py index 9332f99..c78e719 100644 --- a/src/modulex_integrations/tools/customerio/manifest.py +++ b/src/modulex_integrations/tools/customerio/manifest.py @@ -41,7 +41,7 @@ author="ModuleX", logo="logos:customerio-icon", app_url="https://customer.io", - categories=["Marketing & Email", "social_media", "customer_support"], + categories=["Marketing & Advertising", "Marketing & Email", "social_media", "customer_support"], actions=[ ActionDefinition( name="create_or_update_customer", diff --git a/src/modulex_integrations/tools/databricks/manifest.py b/src/modulex_integrations/tools/databricks/manifest.py index 41257f4..08fa226 100644 --- a/src/modulex_integrations/tools/databricks/manifest.py +++ b/src/modulex_integrations/tools/databricks/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:databricks", app_url="https://www.databricks.com", - categories=["Data Engineering", "Analytics", "Machine Learning"], + categories=["Analytics & Data", "Data Engineering", "Analytics", "Machine Learning"], actions=[ ActionDefinition( name="cancel_all_runs", diff --git a/src/modulex_integrations/tools/dropbox/manifest.py b/src/modulex_integrations/tools/dropbox/manifest.py index 990f13d..3dcea7b 100644 --- a/src/modulex_integrations/tools/dropbox/manifest.py +++ b/src/modulex_integrations/tools/dropbox/manifest.py @@ -23,7 +23,12 @@ author="ModuleX", logo="logos:dropbox", app_url="https://www.dropbox.com", - categories=["Productivity & Collaboration", "file-storage", "cloud-storage"], + categories=[ + "Cloud Infrastructure", + "Productivity & Collaboration", + "file-storage", + "cloud-storage", + ], actions=[ ActionDefinition( name="create_folder", diff --git a/src/modulex_integrations/tools/figma/manifest.py b/src/modulex_integrations/tools/figma/manifest.py index c76c3df..3adeadb 100644 --- a/src/modulex_integrations/tools/figma/manifest.py +++ b/src/modulex_integrations/tools/figma/manifest.py @@ -26,7 +26,7 @@ author="ModuleX", logo="logos:figma", app_url="https://www.figma.com", - categories=["Design", "Productivity & Collaboration"], + categories=["Productivity & Collaboration", "Design"], actions=[ ActionDefinition( name="list_comments", diff --git a/src/modulex_integrations/tools/gmail/manifest.py b/src/modulex_integrations/tools/gmail/manifest.py index 46e82d1..57e5a46 100644 --- a/src/modulex_integrations/tools/gmail/manifest.py +++ b/src/modulex_integrations/tools/gmail/manifest.py @@ -71,7 +71,7 @@ def _email_compose_params() -> dict[str, ParameterDef]: author="ModuleX", logo="logos:google-gmail", app_url="https://mail.google.com", - categories=["Communication & Collaboration", "email", "productivity"], + categories=["Communication", "Communication & Collaboration", "email", "productivity"], actions=[ ActionDefinition( name="send_message", diff --git a/src/modulex_integrations/tools/godaddy/manifest.py b/src/modulex_integrations/tools/godaddy/manifest.py index eefeea6..247c154 100644 --- a/src/modulex_integrations/tools/godaddy/manifest.py +++ b/src/modulex_integrations/tools/godaddy/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:godaddy-themed", app_url="https://www.godaddy.com", - categories=["domains", "infrastructure", "web-hosting"], + categories=["Cloud Infrastructure", "domains", "infrastructure", "web-hosting"], actions=[ ActionDefinition( name="check_domain_availability", diff --git a/src/modulex_integrations/tools/google_ad_manager/manifest.py b/src/modulex_integrations/tools/google_ad_manager/manifest.py index f4a391c..7eaa4db 100644 --- a/src/modulex_integrations/tools/google_ad_manager/manifest.py +++ b/src/modulex_integrations/tools/google_ad_manager/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:google_ad_manager-themed", app_url="https://admanager.google.com", - categories=["Advertising", "Marketing"], + categories=["Marketing & Advertising", "Advertising", "Marketing"], actions=[ ActionDefinition( name="create_report", diff --git a/src/modulex_integrations/tools/google_analytics/manifest.py b/src/modulex_integrations/tools/google_analytics/manifest.py index fb74238..97cf436 100644 --- a/src/modulex_integrations/tools/google_analytics/manifest.py +++ b/src/modulex_integrations/tools/google_analytics/manifest.py @@ -28,6 +28,7 @@ logo="logos:google-analytics", app_url="https://analytics.google.com", categories=[ + "Analytics & Data", "Analytics & Reporting", "Marketing", "Productivity & Collaboration", diff --git a/src/modulex_integrations/tools/google_calendar/manifest.py b/src/modulex_integrations/tools/google_calendar/manifest.py index f1dd899..ca56430 100644 --- a/src/modulex_integrations/tools/google_calendar/manifest.py +++ b/src/modulex_integrations/tools/google_calendar/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:google-calendar", app_url="https://calendar.google.com", - categories=["Productivity & Collaboration", "Scheduling"], + categories=["Scheduling & Events", "Productivity & Collaboration", "Scheduling"], actions=[ ActionDefinition( name="add_attendees_to_event", diff --git a/src/modulex_integrations/tools/google_drive/manifest.py b/src/modulex_integrations/tools/google_drive/manifest.py index b6b99ba..5d66df1 100644 --- a/src/modulex_integrations/tools/google_drive/manifest.py +++ b/src/modulex_integrations/tools/google_drive/manifest.py @@ -71,7 +71,7 @@ def _name_param(item: str) -> ParameterDef: author="ModuleX", logo="logos:google-drive", app_url="https://drive.google.com", - categories=["File Storage", "Document Management", "Productivity"], + categories=["Cloud Infrastructure", "File Storage", "Document Management", "Productivity"], actions=[ # --- Drive --------------------------------------------------------- ActionDefinition( diff --git a/src/modulex_integrations/tools/google_maps_platform/manifest.py b/src/modulex_integrations/tools/google_maps_platform/manifest.py index c2e9526..991ac87 100644 --- a/src/modulex_integrations/tools/google_maps_platform/manifest.py +++ b/src/modulex_integrations/tools/google_maps_platform/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="logos:google-maps", app_url="https://developers.google.com/maps", - categories=["Geolocation", "Maps & Places"], + categories=["Developer Tools & Infrastructure", "Geolocation", "Maps & Places"], actions=[ ActionDefinition( name="search_places", diff --git a/src/modulex_integrations/tools/google_my_business/manifest.py b/src/modulex_integrations/tools/google_my_business/manifest.py index f9adae0..88f2101 100644 --- a/src/modulex_integrations/tools/google_my_business/manifest.py +++ b/src/modulex_integrations/tools/google_my_business/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:google_my_business", app_url="https://business.google.com", - categories=["Marketing", "Local Business", "Reviews"], + categories=["Marketing & Advertising", "Marketing", "Local Business", "Reviews"], actions=[ ActionDefinition( name="create_post", diff --git a/src/modulex_integrations/tools/google_search_console/manifest.py b/src/modulex_integrations/tools/google_search_console/manifest.py index 3dc9287..8a0831f 100644 --- a/src/modulex_integrations/tools/google_search_console/manifest.py +++ b/src/modulex_integrations/tools/google_search_console/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:google-search-console", app_url="https://search.google.com/search-console", - categories=["SEO", "Marketing", "Developer Tools & Infrastructure"], + categories=["Marketing & Advertising", "SEO", "Marketing", "Developer Tools & Infrastructure"], actions=[ ActionDefinition( name="retrieve_site_performance_data", diff --git a/src/modulex_integrations/tools/google_tag_manager/manifest.py b/src/modulex_integrations/tools/google_tag_manager/manifest.py index 6c2cf7b..8f7caec 100644 --- a/src/modulex_integrations/tools/google_tag_manager/manifest.py +++ b/src/modulex_integrations/tools/google_tag_manager/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:google-tag-manager", app_url="https://tagmanager.google.com", - categories=["Analytics", "Marketing"], + categories=["Analytics & Data", "Analytics", "Marketing"], actions=[ ActionDefinition( name="create_tag", diff --git a/src/modulex_integrations/tools/hackernews/manifest.py b/src/modulex_integrations/tools/hackernews/manifest.py index 94c4b4b..358e239 100644 --- a/src/modulex_integrations/tools/hackernews/manifest.py +++ b/src/modulex_integrations/tools/hackernews/manifest.py @@ -40,7 +40,7 @@ def _stories_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:hackernews", app_url="https://news.ycombinator.com", - categories=["news", "social", "technology", "community"], + categories=["Social Media", "news", "social", "technology", "community"], actions=[ ActionDefinition( name="search_stories", diff --git a/src/modulex_integrations/tools/heygen/manifest.py b/src/modulex_integrations/tools/heygen/manifest.py index c8e7a76..9b3c649 100644 --- a/src/modulex_integrations/tools/heygen/manifest.py +++ b/src/modulex_integrations/tools/heygen/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:heygen", app_url="https://www.heygen.com", - categories=["AI", "Video", "Content Creation"], + categories=["AI & Machine Learning", "AI", "Video", "Content Creation"], actions=[ ActionDefinition( name="create_talking_photo", diff --git a/src/modulex_integrations/tools/hootsuite/manifest.py b/src/modulex_integrations/tools/hootsuite/manifest.py index 45bb756..d74aeff 100644 --- a/src/modulex_integrations/tools/hootsuite/manifest.py +++ b/src/modulex_integrations/tools/hootsuite/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:hootsuite-icon", app_url="https://hootsuite.com", - categories=["Marketing", "Social Media"], + categories=["Social Media", "Marketing"], actions=[ ActionDefinition( name="create_media_upload_job", diff --git a/src/modulex_integrations/tools/hunter/manifest.py b/src/modulex_integrations/tools/hunter/manifest.py index 6b5653f..ff847eb 100644 --- a/src/modulex_integrations/tools/hunter/manifest.py +++ b/src/modulex_integrations/tools/hunter/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:hunter", app_url="https://hunter.io", - categories=["Marketing & Sales", "Lead Generation", "Email"], + categories=["Sales", "Marketing & Sales", "Lead Generation", "Email"], actions=[ ActionDefinition( name="account_information", diff --git a/src/modulex_integrations/tools/instacart/manifest.py b/src/modulex_integrations/tools/instacart/manifest.py index 2b0f23f..b3695ee 100644 --- a/src/modulex_integrations/tools/instacart/manifest.py +++ b/src/modulex_integrations/tools/instacart/manifest.py @@ -24,7 +24,7 @@ author="ModuleX", logo="modulex:instacart", app_url="https://www.instacart.com", - categories=["Business Services", "grocery", "retail", "shopping"], + categories=["E-Commerce", "Business Services", "grocery", "retail", "shopping"], actions=[ ActionDefinition( name="create_recipe_page", diff --git a/src/modulex_integrations/tools/intercom/manifest.py b/src/modulex_integrations/tools/intercom/manifest.py index 265c0a0..9fe29e2 100644 --- a/src/modulex_integrations/tools/intercom/manifest.py +++ b/src/modulex_integrations/tools/intercom/manifest.py @@ -53,12 +53,7 @@ def _me_test_endpoint( author="ModuleX", logo="modulex:intercom-themed", app_url="https://www.intercom.com", - categories=[ - "CRM & Customer", - "communication", - "automation", - "development", - ], + categories=["Customer Support", "CRM & Customer", "communication", "automation", "development"], actions=[ ActionDefinition( name="get_contact", diff --git a/src/modulex_integrations/tools/jira/manifest.py b/src/modulex_integrations/tools/jira/manifest.py index d6fbd26..a6b66cf 100644 --- a/src/modulex_integrations/tools/jira/manifest.py +++ b/src/modulex_integrations/tools/jira/manifest.py @@ -23,7 +23,12 @@ author="ModuleX", logo="logos:jira", app_url="https://www.atlassian.com/software/jira", - categories=["Project Management", "Developer Tools & Infrastructure", "Productivity & Collaboration"], + categories=[ + "Project & Task Management", + "Project Management", + "Developer Tools & Infrastructure", + "Productivity & Collaboration", + ], actions=[ ActionDefinition( name="add_attachment_to_issue", diff --git a/src/modulex_integrations/tools/klaviyo/manifest.py b/src/modulex_integrations/tools/klaviyo/manifest.py index f0174fc..c780987 100644 --- a/src/modulex_integrations/tools/klaviyo/manifest.py +++ b/src/modulex_integrations/tools/klaviyo/manifest.py @@ -26,7 +26,13 @@ author="ModuleX", logo="modulex:klaviyo-themed", app_url="https://www.klaviyo.com", - categories=["Marketing & Email", "email", "automation", "development"], + categories=[ + "Marketing & Advertising", + "Marketing & Email", + "email", + "automation", + "development", + ], actions=[ ActionDefinition( name="get_lists", diff --git a/src/modulex_integrations/tools/lemon_squeezy/manifest.py b/src/modulex_integrations/tools/lemon_squeezy/manifest.py index f5d9572..0557662 100644 --- a/src/modulex_integrations/tools/lemon_squeezy/manifest.py +++ b/src/modulex_integrations/tools/lemon_squeezy/manifest.py @@ -41,6 +41,7 @@ def _pagination_params() -> dict[str, ParameterDef]: logo="modulex:lemon-squeezy", app_url="https://lemonsqueezy.com", categories=[ + "Finance & Payments", "Business Services", "payments", "subscriptions", diff --git a/src/modulex_integrations/tools/livestorm/manifest.py b/src/modulex_integrations/tools/livestorm/manifest.py index ed04dc2..f09e9d5 100644 --- a/src/modulex_integrations/tools/livestorm/manifest.py +++ b/src/modulex_integrations/tools/livestorm/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:livestorm-themed", app_url="https://livestorm.co", - categories=["Marketing", "Webinars & Events"], + categories=["Scheduling & Events", "Marketing", "Webinars & Events"], actions=[ ActionDefinition( name="create_event", diff --git a/src/modulex_integrations/tools/luma/manifest.py b/src/modulex_integrations/tools/luma/manifest.py index 844ccb1..a76b8a3 100644 --- a/src/modulex_integrations/tools/luma/manifest.py +++ b/src/modulex_integrations/tools/luma/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:luma-themed", app_url="https://luma.com", - categories=["Events", "Productivity & Collaboration", "Marketing"], + categories=["Scheduling & Events", "Events", "Productivity & Collaboration", "Marketing"], actions=[ ActionDefinition( name="create_event", diff --git a/src/modulex_integrations/tools/mailchimp/manifest.py b/src/modulex_integrations/tools/mailchimp/manifest.py index 8e6ae1f..b008691 100644 --- a/src/modulex_integrations/tools/mailchimp/manifest.py +++ b/src/modulex_integrations/tools/mailchimp/manifest.py @@ -55,7 +55,7 @@ def _count_offset_params(default_count: int = 10) -> dict[str, ParameterDef]: author="ModuleX", logo="logos:mailgun-icon", app_url="https://mailchimp.com", - categories=["Marketing & Email", "Email Marketing", "CRM"], + categories=["Marketing & Advertising", "Marketing & Email", "Email Marketing", "CRM"], actions=[ # --- Lists --------------------------------------------------------- ActionDefinition( diff --git a/src/modulex_integrations/tools/medium/manifest.py b/src/modulex_integrations/tools/medium/manifest.py index e03572d..7bf9140 100644 --- a/src/modulex_integrations/tools/medium/manifest.py +++ b/src/modulex_integrations/tools/medium/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:medium-themed", app_url="https://medium.com", - categories=["Content & Publishing", "Blogging"], + categories=["Social Media", "Content & Publishing", "Blogging"], actions=[ ActionDefinition( name="create_post", diff --git a/src/modulex_integrations/tools/microsoft_bookings/manifest.py b/src/modulex_integrations/tools/microsoft_bookings/manifest.py index 6fe33d2..01f2108 100644 --- a/src/modulex_integrations/tools/microsoft_bookings/manifest.py +++ b/src/modulex_integrations/tools/microsoft_bookings/manifest.py @@ -26,7 +26,12 @@ author="ModuleX", logo="modulex:microsoft_bookings", app_url="https://www.microsoft.com/en-us/microsoft-365/business/scheduling-and-booking-app", - categories=["Productivity & Collaboration", "Scheduling", "Business Services"], + categories=[ + "Scheduling & Events", + "Productivity & Collaboration", + "Scheduling", + "Business Services", + ], actions=[ ActionDefinition( name="cancel_appointment", diff --git a/src/modulex_integrations/tools/microsoft_entra_id/manifest.py b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py index b4037b5..274262a 100644 --- a/src/modulex_integrations/tools/microsoft_entra_id/manifest.py +++ b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py @@ -23,7 +23,12 @@ author="ModuleX", logo="modulex:microsoft_entra_id", app_url="https://entra.microsoft.com", - categories=["Identity & Access Management", "Enterprise", "Security"], + categories=[ + "Developer Tools & Infrastructure", + "Identity & Access Management", + "Enterprise", + "Security", + ], actions=[ ActionDefinition( name="add_member_to_group", diff --git a/src/modulex_integrations/tools/microsoft_onedrive/manifest.py b/src/modulex_integrations/tools/microsoft_onedrive/manifest.py index 4ebf832..d8961b2 100644 --- a/src/modulex_integrations/tools/microsoft_onedrive/manifest.py +++ b/src/modulex_integrations/tools/microsoft_onedrive/manifest.py @@ -27,7 +27,7 @@ author="ModuleX", logo="logos:microsoft-onedrive", app_url="https://onedrive.live.com", - categories=["file-storage", "productivity"], + categories=["Cloud Infrastructure", "file-storage", "productivity"], actions=[ ActionDefinition( name="create_folder", diff --git a/src/modulex_integrations/tools/microsoft_outlook/manifest.py b/src/modulex_integrations/tools/microsoft_outlook/manifest.py index a1d4861..7e981df 100644 --- a/src/modulex_integrations/tools/microsoft_outlook/manifest.py +++ b/src/modulex_integrations/tools/microsoft_outlook/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="modulex:microsoft_outlook", app_url="https://outlook.live.com", - categories=["email", "productivity", "communication", "microsoft"], + categories=["Communication", "email", "productivity", "communication", "microsoft"], actions=[ ActionDefinition( name="add_label_to_email", diff --git a/src/modulex_integrations/tools/microsoft_power_bi/manifest.py b/src/modulex_integrations/tools/microsoft_power_bi/manifest.py index 7eb4ce8..651f12d 100644 --- a/src/modulex_integrations/tools/microsoft_power_bi/manifest.py +++ b/src/modulex_integrations/tools/microsoft_power_bi/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:microsoft-power-bi", app_url="https://powerbi.microsoft.com", - categories=["Business Intelligence", "Analytics", "Productivity"], + categories=["Analytics & Data", "Business Intelligence", "Analytics", "Productivity"], actions=[ ActionDefinition( name="add_rows_to_push_dataset", diff --git a/src/modulex_integrations/tools/mixpanel/manifest.py b/src/modulex_integrations/tools/mixpanel/manifest.py index bab7fa1..5a5b10e 100644 --- a/src/modulex_integrations/tools/mixpanel/manifest.py +++ b/src/modulex_integrations/tools/mixpanel/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:mixpanel", app_url="https://mixpanel.com", - categories=["Analytics", "Product Analytics"], + categories=["Analytics & Data", "Analytics", "Product Analytics"], actions=[ ActionDefinition( name="emit_event_to", diff --git a/src/modulex_integrations/tools/monday/manifest.py b/src/modulex_integrations/tools/monday/manifest.py index a2d4fe2..8b1b6f3 100644 --- a/src/modulex_integrations/tools/monday/manifest.py +++ b/src/modulex_integrations/tools/monday/manifest.py @@ -24,7 +24,7 @@ author="ModuleX", logo="logos:monday-icon", app_url="https://monday.com", - categories=["Productivity & Collaboration", "Project Management"], + categories=["Project & Task Management", "Productivity & Collaboration", "Project Management"], actions=[ ActionDefinition( name="create_board", diff --git a/src/modulex_integrations/tools/motion/manifest.py b/src/modulex_integrations/tools/motion/manifest.py index 1503522..4b95847 100644 --- a/src/modulex_integrations/tools/motion/manifest.py +++ b/src/modulex_integrations/tools/motion/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:motion-themed", app_url="https://www.usemotion.com", - categories=["Productivity & Collaboration", "project-management"], + categories=["Project & Task Management", "Productivity & Collaboration", "project-management"], actions=[ ActionDefinition( name="create_task", diff --git a/src/modulex_integrations/tools/mysql/manifest.py b/src/modulex_integrations/tools/mysql/manifest.py index cbcb86a..ece5a28 100644 --- a/src/modulex_integrations/tools/mysql/manifest.py +++ b/src/modulex_integrations/tools/mysql/manifest.py @@ -46,7 +46,7 @@ def _values_param() -> ParameterDef: author="ModuleX", logo="logos:mysql-icon", app_url="https://www.mysql.com/", - categories=["Database", "Data Management", "storage"], + categories=["Databases", "Database", "Data Management", "storage"], actions=[ ActionDefinition( name="execute_raw_query", diff --git a/src/modulex_integrations/tools/nasdaq/manifest.py b/src/modulex_integrations/tools/nasdaq/manifest.py index ed9d9be..e7ec816 100644 --- a/src/modulex_integrations/tools/nasdaq/manifest.py +++ b/src/modulex_integrations/tools/nasdaq/manifest.py @@ -55,7 +55,7 @@ def _periodic_query_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:nasdaq", app_url="https://data.nasdaq.com", - categories=["finance", "data", "research", "analytics"], + categories=["Finance & Payments", "finance", "data", "research", "analytics"], actions=[ ActionDefinition( name="get_balance_sheet", diff --git a/src/modulex_integrations/tools/notion/manifest.py b/src/modulex_integrations/tools/notion/manifest.py index 6ca9ea4..876cb7d 100644 --- a/src/modulex_integrations/tools/notion/manifest.py +++ b/src/modulex_integrations/tools/notion/manifest.py @@ -51,7 +51,12 @@ def _test_endpoint(placeholder: str) -> TestEndpoint: author="ModuleX", logo="logos:notion-icon", app_url="https://www.notion.so", - categories=["Productivity", "Knowledge Management", "Documentation"], + categories=[ + "Productivity & Collaboration", + "Productivity", + "Knowledge Management", + "Documentation", + ], actions=[ ActionDefinition( name="search", diff --git a/src/modulex_integrations/tools/okta/manifest.py b/src/modulex_integrations/tools/okta/manifest.py index 2141109..21ede41 100644 --- a/src/modulex_integrations/tools/okta/manifest.py +++ b/src/modulex_integrations/tools/okta/manifest.py @@ -25,7 +25,11 @@ author="ModuleX", logo="modulex:okta-themed", app_url="https://www.okta.com", - categories=["Identity & Access Management", "Productivity & Collaboration"], + categories=[ + "Developer Tools & Infrastructure", + "Identity & Access Management", + "Productivity & Collaboration", + ], actions=[ ActionDefinition( name="create_user", diff --git a/src/modulex_integrations/tools/pagerduty/manifest.py b/src/modulex_integrations/tools/pagerduty/manifest.py index c707955..a7527a4 100644 --- a/src/modulex_integrations/tools/pagerduty/manifest.py +++ b/src/modulex_integrations/tools/pagerduty/manifest.py @@ -23,7 +23,11 @@ author="ModuleX", logo="logos:pagerduty-icon", app_url="https://www.pagerduty.com", - categories=["Incident Management", "Developer Tools & Infrastructure"], + categories=[ + "Monitoring & Observability", + "Incident Management", + "Developer Tools & Infrastructure", + ], actions=[ ActionDefinition( name="trigger_incident", diff --git a/src/modulex_integrations/tools/pinterest/manifest.py b/src/modulex_integrations/tools/pinterest/manifest.py index c3f9d78..0aebff7 100644 --- a/src/modulex_integrations/tools/pinterest/manifest.py +++ b/src/modulex_integrations/tools/pinterest/manifest.py @@ -41,6 +41,7 @@ logo="logos:pinterest", app_url="https://www.pinterest.com", categories=[ + "Social Media", "Marketing & Email", "social_media", "automation", diff --git a/src/modulex_integrations/tools/postgresql/manifest.py b/src/modulex_integrations/tools/postgresql/manifest.py index 086f33b..8cbb843 100644 --- a/src/modulex_integrations/tools/postgresql/manifest.py +++ b/src/modulex_integrations/tools/postgresql/manifest.py @@ -61,7 +61,7 @@ def _values_param() -> ParameterDef: author="ModuleX", logo="logos:postgresql", app_url="https://www.postgresql.org/", - categories=["Database", "Data Management", "storage"], + categories=["Databases", "Database", "Data Management", "storage"], actions=[ ActionDefinition( name="execute_raw_query", diff --git a/src/modulex_integrations/tools/postgrid/manifest.py b/src/modulex_integrations/tools/postgrid/manifest.py index d6f2c4a..24faa06 100644 --- a/src/modulex_integrations/tools/postgrid/manifest.py +++ b/src/modulex_integrations/tools/postgrid/manifest.py @@ -22,7 +22,7 @@ version="1.0.0", author="ModuleX", app_url="https://www.postgrid.com", - categories=["Marketing", "Business Services"], + categories=["Marketing & Advertising", "Marketing", "Business Services"], actions=[ ActionDefinition( name="create_contact", diff --git a/src/modulex_integrations/tools/posthog/manifest.py b/src/modulex_integrations/tools/posthog/manifest.py index 1bcd6f9..ce48b27 100644 --- a/src/modulex_integrations/tools/posthog/manifest.py +++ b/src/modulex_integrations/tools/posthog/manifest.py @@ -147,7 +147,7 @@ def _crud_delete( author="ModuleX", logo="logos:posthog-icon", app_url="https://posthog.com", - categories=["Data & Analytics", "data", "development"], + categories=["Analytics & Data", "Data & Analytics", "data", "development"], actions=[ # --- Dashboards ---------------------------------------------------- _crud_get_all( diff --git a/src/modulex_integrations/tools/product_hunt/manifest.py b/src/modulex_integrations/tools/product_hunt/manifest.py index 4a6c588..5f03ff3 100644 --- a/src/modulex_integrations/tools/product_hunt/manifest.py +++ b/src/modulex_integrations/tools/product_hunt/manifest.py @@ -25,7 +25,7 @@ author="ModuleX", logo="logos:producthunt", app_url="https://www.producthunt.com", - categories=["Productivity & Collaboration", "Marketing"], + categories=["Social Media", "Productivity & Collaboration", "Marketing"], actions=[ ActionDefinition( name="list_topic_options", diff --git a/src/modulex_integrations/tools/segment/manifest.py b/src/modulex_integrations/tools/segment/manifest.py index a18e617..98e4860 100644 --- a/src/modulex_integrations/tools/segment/manifest.py +++ b/src/modulex_integrations/tools/segment/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:segment-icon", app_url="https://segment.com", - categories=["Analytics", "Customer Data Platform"], + categories=["Analytics & Data", "Analytics", "Customer Data Platform"], actions=[ ActionDefinition( name="alias", diff --git a/src/modulex_integrations/tools/semrush/manifest.py b/src/modulex_integrations/tools/semrush/manifest.py index 192a4af..0adf301 100644 --- a/src/modulex_integrations/tools/semrush/manifest.py +++ b/src/modulex_integrations/tools/semrush/manifest.py @@ -66,7 +66,13 @@ def _keyword_with_db(required_db: bool = True, default_limit: int = 10) -> dict[ author="ModuleX", logo="modulex:semrush", app_url="https://www.semrush.com", - categories=["Data & Analytics", "analytics", "marketing", "research"], + categories=[ + "Marketing & Advertising", + "Data & Analytics", + "analytics", + "marketing", + "research", + ], actions=[ ActionDefinition( name="domain_overview", diff --git a/src/modulex_integrations/tools/sendgrid/manifest.py b/src/modulex_integrations/tools/sendgrid/manifest.py index abf6751..09b3ddf 100644 --- a/src/modulex_integrations/tools/sendgrid/manifest.py +++ b/src/modulex_integrations/tools/sendgrid/manifest.py @@ -76,7 +76,7 @@ def _bulk_delete_params(item: str) -> dict[str, ParameterDef]: author="ModuleX", logo="logos:sendgrid-icon", app_url="https://sendgrid.com", - categories=["Marketing & Email", "email", "automation"], + categories=["Marketing & Advertising", "Marketing & Email", "email", "automation"], actions=[ ActionDefinition( name="send_email", diff --git a/src/modulex_integrations/tools/sentry/manifest.py b/src/modulex_integrations/tools/sentry/manifest.py index f94389f..1861d5e 100644 --- a/src/modulex_integrations/tools/sentry/manifest.py +++ b/src/modulex_integrations/tools/sentry/manifest.py @@ -22,7 +22,12 @@ author="ModuleX", logo="modulex:sentry-themed", app_url="https://sentry.io", - categories=["Developer Tools & Infrastructure", "monitoring", "error-tracking"], + categories=[ + "Monitoring & Observability", + "Developer Tools & Infrastructure", + "monitoring", + "error-tracking", + ], actions=[ ActionDefinition( name="list_issue_events", diff --git a/src/modulex_integrations/tools/shopify_partner/manifest.py b/src/modulex_integrations/tools/shopify_partner/manifest.py index 5fdd7df..9283b8f 100644 --- a/src/modulex_integrations/tools/shopify_partner/manifest.py +++ b/src/modulex_integrations/tools/shopify_partner/manifest.py @@ -20,7 +20,7 @@ author="ModuleX", logo="logos:shopify", app_url="https://partners.shopify.com", - categories=["ecommerce", "Developer Tools & Infrastructure"], + categories=["E-Commerce", "ecommerce", "Developer Tools & Infrastructure"], actions=[ ActionDefinition( name="verify_webhook", diff --git a/src/modulex_integrations/tools/short_io/manifest.py b/src/modulex_integrations/tools/short_io/manifest.py index 3b2d6af..a040405 100644 --- a/src/modulex_integrations/tools/short_io/manifest.py +++ b/src/modulex_integrations/tools/short_io/manifest.py @@ -35,7 +35,7 @@ def _utm_params() -> dict[str, ParameterDef]: author="ModuleX", logo="modulex:shortio-themed", app_url="https://short.io", - categories=["Utilities", "link_management", "analytics"], + categories=["Marketing & Advertising", "Utilities", "link_management", "analytics"], actions=[ ActionDefinition( name="create_link", diff --git a/src/modulex_integrations/tools/slack/manifest.py b/src/modulex_integrations/tools/slack/manifest.py index db11c6e..9d7cf58 100644 --- a/src/modulex_integrations/tools/slack/manifest.py +++ b/src/modulex_integrations/tools/slack/manifest.py @@ -37,7 +37,7 @@ author="ModuleX", logo="logos:slack-icon", app_url="https://slack.com", - categories=["Communication & Collaboration", "collaboration", "messaging"], + categories=["Communication", "Communication & Collaboration", "collaboration", "messaging"], actions=[ ActionDefinition( name="list_channels", diff --git a/src/modulex_integrations/tools/snowflake/manifest.py b/src/modulex_integrations/tools/snowflake/manifest.py index 5f54367..e5eb64d 100644 --- a/src/modulex_integrations/tools/snowflake/manifest.py +++ b/src/modulex_integrations/tools/snowflake/manifest.py @@ -37,7 +37,7 @@ def _table_name_param() -> ParameterDef: author="ModuleX", logo="logos:snowflake-icon", app_url="https://www.snowflake.com/", - categories=["Database", "Data Warehouse", "analytics"], + categories=["Databases", "Database", "Data Warehouse", "analytics"], actions=[ ActionDefinition( name="execute_sql_query", diff --git a/src/modulex_integrations/tools/square/manifest.py b/src/modulex_integrations/tools/square/manifest.py index d5b6190..0b8ba05 100644 --- a/src/modulex_integrations/tools/square/manifest.py +++ b/src/modulex_integrations/tools/square/manifest.py @@ -23,7 +23,7 @@ author="ModuleX", logo="logos:square", app_url="https://squareup.com", - categories=["payments", "commerce", "finance"], + categories=["Finance & Payments", "payments", "commerce", "finance"], actions=[ ActionDefinition( name="create_customer", diff --git a/src/modulex_integrations/tools/supabase/manifest.py b/src/modulex_integrations/tools/supabase/manifest.py index f9f4df2..f397e3a 100644 --- a/src/modulex_integrations/tools/supabase/manifest.py +++ b/src/modulex_integrations/tools/supabase/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="logos:supabase-icon", app_url="https://supabase.com", - categories=["Database", "Backend", "Developer Tools & Infrastructure"], + categories=["Databases", "Database", "Backend", "Developer Tools & Infrastructure"], actions=[ ActionDefinition( name="select_row", diff --git a/src/modulex_integrations/tools/telegram/manifest.py b/src/modulex_integrations/tools/telegram/manifest.py index 9ca08f1..75bbc84 100644 --- a/src/modulex_integrations/tools/telegram/manifest.py +++ b/src/modulex_integrations/tools/telegram/manifest.py @@ -53,6 +53,7 @@ def _send_common_params() -> dict[str, ParameterDef]: logo="logos:telegram", app_url="https://core.telegram.org/bots/api", categories=[ + "Communication", "Communication & Collaboration", "automation", "development", diff --git a/src/modulex_integrations/tools/tinyurl/manifest.py b/src/modulex_integrations/tools/tinyurl/manifest.py index 33835fa..9bc6c95 100644 --- a/src/modulex_integrations/tools/tinyurl/manifest.py +++ b/src/modulex_integrations/tools/tinyurl/manifest.py @@ -25,7 +25,7 @@ author="ModuleX", logo="modulex:tinyurl-themed", app_url="https://tinyurl.com", - categories=["Utilities", "link", "miscellaneous"], + categories=["Marketing & Advertising", "Utilities", "link", "miscellaneous"], actions=[ ActionDefinition( name="create_shortened_link", diff --git a/src/modulex_integrations/tools/yelp/manifest.py b/src/modulex_integrations/tools/yelp/manifest.py index 2b43ddd..b979342 100644 --- a/src/modulex_integrations/tools/yelp/manifest.py +++ b/src/modulex_integrations/tools/yelp/manifest.py @@ -22,7 +22,7 @@ author="ModuleX", logo="modulex:yelp", app_url="https://www.yelp.com", - categories=["Local Services", "Reviews", "Business Data"], + categories=["Marketing & Advertising", "Local Services", "Reviews", "Business Data"], actions=[ ActionDefinition( name="search_businesses", From 9d0adfbe8e0f76ea042e2bdf6994ad3ef256c400 Mon Sep 17 00:00:00 2001 From: SUY Date: Fri, 5 Jun 2026 21:17:59 -0500 Subject: [PATCH 2/2] Trim Google OAuth scopes: drop 5 restricted/unused scopes + dependent actions Shrinks the Google OAuth client footprint to avoid Google's CASA restricted-scope security assessment. Drops 4 restricted scopes (gmail.readonly, gmail.modify, drive, drive.readonly) + 1 sensitive-unused (tagmanager.manage.accounts); the distinct googleapis scope union goes 29 -> 24 with ZERO restricted scopes left. BREAKING: removes ~23 agent-callable actions across 5 integrations. - gmail: drop readonly+modify; remove 11 read/modify actions (keep send_message, list_labels); credential test endpoint /users/me/profile -> /users/me/labels - google_drive: drop full drive; remove 8 broad-access file ops (search/list/read/ delete/rename/move/copy/metadata); keep 16 create+edit actions under drive.file + documents/spreadsheets/presentations - google_docs: drop drive (keep documents); remove find_document + create_document_from_template; strip create_document's Drive folder-move branch - google_sheets: drop drive.readonly (keep spreadsheets, drive.file); remove list_spreadsheets - google_slides: drop drive (keep presentations); remove merge_data + refresh_chart; strip create_presentation's Drive-copy branch; test endpoint drive/about -> slides presentations/1 [200,404] - google_tag_manager: drop unused tagmanager.manage.accounts (no action change) Full 7-file cascade per removed action (manifest, tools, outputs, __init__, tests, README). Verified: ruff + mypy clean (pre-existing pymssql stub error unrelated), 1924 passed, 8 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tools/gmail/README.md | 36 +- .../tools/gmail/__init__.py | 33 -- .../tools/gmail/manifest.py | 134 +---- .../tools/gmail/outputs.py | 96 ---- .../tools/gmail/tests/test_gmail.py | 288 +--------- src/modulex_integrations/tools/gmail/tools.py | 536 +----------------- .../tools/google_docs/README.md | 9 +- .../tools/google_docs/__init__.py | 6 - .../tools/google_docs/manifest.py | 44 -- .../tools/google_docs/outputs.py | 25 - .../google_docs/tests/test_google_docs.py | 57 +- .../tools/google_docs/tools.py | 170 ------ .../tools/google_drive/README.md | 15 +- .../tools/google_drive/__init__.py | 24 - .../tools/google_drive/manifest.py | 95 ---- .../tools/google_drive/outputs.py | 72 --- .../google_drive/tests/test_google_drive.py | 201 +------ .../tools/google_drive/tools.py | 446 +-------------- .../tools/google_sheets/README.md | 11 +- .../tools/google_sheets/__init__.py | 3 - .../tools/google_sheets/manifest.py | 25 +- .../tools/google_sheets/outputs.py | 17 - .../google_sheets/tests/test_google_sheets.py | 48 +- .../tools/google_sheets/tools.py | 66 --- .../tools/google_slides/README.md | 15 +- .../tools/google_slides/__init__.py | 6 - .../tools/google_slides/manifest.py | 94 +-- .../tools/google_slides/outputs.py | 23 - .../google_slides/tests/test_google_slides.py | 105 +--- .../tools/google_slides/tools.py | 239 +------- .../tools/google_tag_manager/README.md | 1 - .../tools/google_tag_manager/manifest.py | 1 - 32 files changed, 74 insertions(+), 2867 deletions(-) diff --git a/src/modulex_integrations/tools/gmail/README.md b/src/modulex_integrations/tools/gmail/README.md index e15c755..609d89a 100644 --- a/src/modulex_integrations/tools/gmail/README.md +++ b/src/modulex_integrations/tools/gmail/README.md @@ -1,9 +1,8 @@ # Gmail Gmail integration via the Gmail REST v1 API -(`www.googleapis.com/gmail/v1`). Send/read/search/list/draft + label -management + archive/trash. Pure HTTP — does **not** depend on the -`google-api-python-client` SDK. +(`www.googleapis.com/gmail/v1`). Send email + list labels. Pure +HTTP — does **not** depend on the `google-api-python-client` SDK. ## Authentication @@ -15,9 +14,9 @@ management + archive/trash. Pure HTTP — does **not** depend on the - OAuth env vars: `GMAIL_OAUTH2_CLIENT_ID`, `GMAIL_OAUTH2_CLIENT_SECRET` (both `only_for_custom=True`). - Bearer env var: `GMAIL_ACCESS_TOKEN`. -- OAuth flow uses Google's standard endpoints with the four Gmail - scopes (`send`, `readonly`, `modify`, `labels`). -- Both `test_endpoint`s GET `/users/me/profile`. +- OAuth flow uses Google's standard endpoints with two Gmail + scopes (`send`, `labels`). +- Both `test_endpoint`s GET `/users/me/labels`. ## Runtime convention @@ -28,34 +27,15 @@ Token-based: every `@tool` accepts `(auth_type, auth_data, ...)`. | name | description | required params | | --- | --- | --- | | `send_message` | Send a new email | `to`, `subject`, `body` | -| `read_message` | Read a message by ID | `message_id` | -| `search_messages` | Search via Gmail query syntax | `query` | -| `list_messages` | List messages from labels/folders | — | -| `create_draft` | Create an email draft | `to`, `subject`, `body` | -| `mark_as_read` | Remove UNREAD label | `message_id` | -| `mark_as_unread` | Add UNREAD label | `message_id` | -| `archive_message` | Remove INBOX label | `message_id` | -| `unarchive_message` | Add INBOX label | `message_id` | -| `delete_message` | Move to Trash | `message_id` | -| `add_label` / `remove_label` | Add/remove labels | `message_id`, `label_ids` | | `list_labels` | All Gmail labels (system + user-created) | — | -## Multi-call workflows - -`search_messages` and `list_messages` both do an **N+1 metadata -fetch**: GET `/messages?q=…` returns IDs only, then for each ID GET -`/messages/{id}?format=metadata&metadataHeaders=Subject,From,Date` to -pull the displayable header values. Preserved from legacy. The -alternative (`history.list`) needs a different OAuth scope. - ## Limits & Quotas -- `send_message` and `create_draft` build base64url-encoded MIME - messages locally (no SDK dep). -- `max_results` is clamped at 500 (Gmail's max per request). +- `send_message` builds a base64url-encoded MIME message locally + (no SDK dep). - Sending is limited to 500 messages/day (consumer accounts) or 2000/day (Google Workspace). -- 60s timeout for send/draft, 30s for everything else. +- 60s timeout for send, 30s for label listing. ## Maintainer diff --git a/src/modulex_integrations/tools/gmail/__init__.py b/src/modulex_integrations/tools/gmail/__init__.py index c13af54..be1aaef 100644 --- a/src/modulex_integrations/tools/gmail/__init__.py +++ b/src/modulex_integrations/tools/gmail/__init__.py @@ -1,51 +1,18 @@ """Gmail integration.""" from modulex_integrations.tools.gmail.manifest import manifest from modulex_integrations.tools.gmail.tools import ( - add_label, - archive_message, - create_draft, - delete_message, list_labels, - list_messages, - mark_as_read, - mark_as_unread, - read_message, - remove_label, - search_messages, send_message, - unarchive_message, ) TOOLS = ( send_message, - read_message, - search_messages, - list_messages, - create_draft, - mark_as_read, - mark_as_unread, - archive_message, - unarchive_message, - delete_message, - add_label, - remove_label, list_labels, ) __all__ = [ "TOOLS", - "add_label", - "archive_message", - "create_draft", - "delete_message", "list_labels", - "list_messages", "manifest", - "mark_as_read", - "mark_as_unread", - "read_message", - "remove_label", - "search_messages", "send_message", - "unarchive_message", ] diff --git a/src/modulex_integrations/tools/gmail/manifest.py b/src/modulex_integrations/tools/gmail/manifest.py index 57e5a46..df5075c 100644 --- a/src/modulex_integrations/tools/gmail/manifest.py +++ b/src/modulex_integrations/tools/gmail/manifest.py @@ -16,25 +16,19 @@ __all__ = ["manifest"] -def _profile_test_endpoint(placeholder: str, description: str) -> TestEndpoint: +def _labels_test_endpoint(placeholder: str, description: str) -> TestEndpoint: return TestEndpoint( - url="https://www.googleapis.com/gmail/v1/users/me/profile", + url="https://www.googleapis.com/gmail/v1/users/me/labels", method="GET", headers={"Authorization": f"Bearer {{{placeholder}}}"}, success_indicators=SuccessIndicators( - status_codes=[200], response_fields=["emailAddress"] + status_codes=[200], response_fields=["labels"] ), cost_level="free", description=description, ) -def _message_id_param() -> ParameterDef: - return ParameterDef( - type="string", description="The ID of the message", required=True - ) - - def _email_compose_params() -> dict[str, ParameterDef]: return { "to": ParameterDef( @@ -78,118 +72,6 @@ def _email_compose_params() -> dict[str, ParameterDef]: description="Send a new email via Gmail", parameters=_email_compose_params(), ), - ActionDefinition( - name="read_message", - description="Read a specific email message by ID", - parameters={ - "message_id": _message_id_param(), - "format": ParameterDef( - type="string", - description="Message format ('minimal', 'full', 'raw', 'metadata')", - default="full", - ), - }, - ), - ActionDefinition( - name="search_messages", - description="Search emails using Gmail query syntax", - parameters={ - "query": ParameterDef( - type="string", - description="Gmail search query (e.g. 'from:user@x.io', 'is:unread')", - required=True, - ), - "max_results": ParameterDef( - type="integer", - description="Maximum messages to return (max 500)", - default=10, - ), - "page_token": ParameterDef( - type="string", description="Token for pagination" - ), - "label_ids": ParameterDef( - type="array", description="Filter by label IDs" - ), - }, - ), - ActionDefinition( - name="list_messages", - description="List emails from specified folders/labels", - parameters={ - "label_ids": ParameterDef( - type="array", - description="Label IDs to list (e.g. ['INBOX'], ['SENT'])", - default=["INBOX"], - ), - "query": ParameterDef( - type="string", - description="Gmail search query for filtering", - ), - "max_results": ParameterDef( - type="integer", - description="Maximum messages to return", - default=20, - ), - "page_token": ParameterDef( - type="string", description="Token for pagination" - ), - "include_spam_trash": ParameterDef( - type="boolean", - description="Include messages from SPAM and TRASH", - default=False, - ), - }, - ), - ActionDefinition( - name="create_draft", - description="Create an email draft", - parameters=_email_compose_params(), - ), - ActionDefinition( - name="mark_as_read", - description="Mark an email as read", - parameters={"message_id": _message_id_param()}, - ), - ActionDefinition( - name="mark_as_unread", - description="Mark an email as unread", - parameters={"message_id": _message_id_param()}, - ), - ActionDefinition( - name="archive_message", - description="Archive an email (remove from inbox)", - parameters={"message_id": _message_id_param()}, - ), - ActionDefinition( - name="unarchive_message", - description="Move an archived email back to inbox", - parameters={"message_id": _message_id_param()}, - ), - ActionDefinition( - name="delete_message", - description="Move an email to trash", - parameters={"message_id": _message_id_param()}, - ), - ActionDefinition( - name="add_label", - description="Add labels to an email", - parameters={ - "message_id": _message_id_param(), - "label_ids": ParameterDef( - type="array", description="Label IDs to add", required=True - ), - }, - ), - ActionDefinition( - name="remove_label", - description="Remove labels from an email", - parameters={ - "message_id": _message_id_param(), - "label_ids": ParameterDef( - type="array", description="Label IDs to remove", required=True - ), - }, - ), ActionDefinition( name="list_labels", description="List all available Gmail labels", @@ -228,15 +110,13 @@ def _email_compose_params() -> dict[str, ParameterDef]: token_url="https://oauth2.googleapis.com/token", scopes=[ "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.labels", ], token_auth_method="body", ), - test_endpoint=_profile_test_endpoint( + test_endpoint=_labels_test_endpoint( "access_token", - "Validates OAuth token by fetching Gmail profile information", + "Validates OAuth token by listing Gmail labels", ), ), BearerTokenAuthSchema( @@ -254,9 +134,9 @@ def _email_compose_params() -> dict[str, ParameterDef]: sensitive=True, ), ], - test_endpoint=_profile_test_endpoint( + test_endpoint=_labels_test_endpoint( "bearer_token", - "Validates token by fetching Gmail profile information", + "Validates token by listing Gmail labels", ), ), ], diff --git a/src/modulex_integrations/tools/gmail/outputs.py b/src/modulex_integrations/tools/gmail/outputs.py index cb97ffd..b0b3ed5 100644 --- a/src/modulex_integrations/tools/gmail/outputs.py +++ b/src/modulex_integrations/tools/gmail/outputs.py @@ -6,20 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field __all__ = [ - "AddLabelOutput", - "ArchiveMessageOutput", - "CreateDraftOutput", - "DeleteMessageOutput", - "GmailMessageSummary", "ListLabelsOutput", - "ListMessagesOutput", - "MarkAsReadOutput", - "MarkAsUnreadOutput", - "ReadMessageOutput", - "RemoveLabelOutput", - "SearchMessagesOutput", "SendMessageOutput", - "UnarchiveMessageOutput", ] @@ -29,96 +17,12 @@ class _Base(BaseModel): error: str | None = None -class _MessageStub(_Base): - """Common shape for mark/archive/label-modify endpoints.""" - - id: str | None = None - thread_id: str | None = None - label_ids: list[str] = Field(default_factory=list) - message: str | None = None - - class SendMessageOutput(_Base): id: str | None = None thread_id: str | None = None label_ids: list[str] = Field(default_factory=list) -class ReadMessageOutput(_Base): - id: str | None = None - thread_id: str | None = None - label_ids: list[str] = Field(default_factory=list) - snippet: str | None = None - subject: str | None = None - from_address: str | None = None - to: str | None = None - cc: str | None = None - date: str | None = None - body: str | None = None - internal_date: str | None = None - size_estimate: int | None = None - - -class GmailMessageSummary(BaseModel): - model_config = ConfigDict(extra="forbid") - id: str | None = None - thread_id: str | None = None - snippet: str | None = None - subject: str | None = None - from_address: str | None = None - date: str | None = None - label_ids: list[str] = Field(default_factory=list) - - -class SearchMessagesOutput(_Base): - messages: list[GmailMessageSummary] = Field(default_factory=list) - total: int = 0 - result_size_estimate: int | None = None - next_page_token: str | None = None - - -class ListMessagesOutput(_Base): - messages: list[GmailMessageSummary] = Field(default_factory=list) - total: int = 0 - next_page_token: str | None = None - - -class CreateDraftOutput(_Base): - draft_id: str | None = None - message_id: str | None = None - thread_id: str | None = None - - -class MarkAsReadOutput(_MessageStub): - pass - - -class MarkAsUnreadOutput(_MessageStub): - pass - - -class ArchiveMessageOutput(_MessageStub): - pass - - -class UnarchiveMessageOutput(_MessageStub): - pass - - -class DeleteMessageOutput(_Base): - id: str | None = None - thread_id: str | None = None - message: str | None = None - - -class AddLabelOutput(_MessageStub): - added_labels: list[str] = Field(default_factory=list) - - -class RemoveLabelOutput(_MessageStub): - removed_labels: list[str] = Field(default_factory=list) - - class ListLabelsOutput(_Base): labels: list[dict[str, Any]] = Field(default_factory=list) total: int = 0 diff --git a/src/modulex_integrations/tools/gmail/tests/test_gmail.py b/src/modulex_integrations/tools/gmail/tests/test_gmail.py index 01e516e..bf035c3 100644 --- a/src/modulex_integrations/tools/gmail/tests/test_gmail.py +++ b/src/modulex_integrations/tools/gmail/tests/test_gmail.py @@ -8,35 +8,13 @@ from modulex_integrations.tools.gmail import ( TOOLS, - add_label, - archive_message, - create_draft, - delete_message, list_labels, - list_messages, manifest, - mark_as_read, - mark_as_unread, - read_message, - remove_label, - search_messages, send_message, - unarchive_message, ) from modulex_integrations.tools.gmail.outputs import ( - AddLabelOutput, - ArchiveMessageOutput, - CreateDraftOutput, - DeleteMessageOutput, ListLabelsOutput, - ListMessagesOutput, - MarkAsReadOutput, - MarkAsUnreadOutput, - ReadMessageOutput, - RemoveLabelOutput, - SearchMessagesOutput, SendMessageOutput, - UnarchiveMessageOutput, ) API = "https://www.googleapis.com/gmail/v1" @@ -55,13 +33,9 @@ def _args(auth: dict[str, Any], **extra: Any) -> dict[str, Any]: return dict(auth, **extra) -def _b64url(text: str) -> str: - return base64.urlsafe_b64encode(text.encode("utf-8")).decode("utf-8") - - class TestManifest: - def test_manifest_exposes_thirteen_actions(self) -> None: - assert len(manifest.actions) == 13 + def test_manifest_exposes_two_actions(self) -> None: + assert len(manifest.actions) == 2 def test_manifest_actions_match_tools_tuple(self) -> None: assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} @@ -70,10 +44,11 @@ def test_manifest_has_oauth2_and_bearer_token_auth(self) -> None: types = {a.auth_type for a in manifest.auth_schemas} assert types == {"oauth2", "bearer_token"} - def test_oauth_config_carries_four_gmail_scopes(self) -> None: + def test_oauth_config_carries_two_gmail_scopes(self) -> None: oauth = next(a for a in manifest.auth_schemas if a.auth_type == "oauth2") - assert len(oauth.oauth_config.scopes) == 4 + assert len(oauth.oauth_config.scopes) == 2 assert "https://www.googleapis.com/auth/gmail.send" in oauth.oauth_config.scopes + assert "https://www.googleapis.com/auth/gmail.labels" in oauth.oauth_config.scopes @pytest.mark.asyncio @@ -133,259 +108,6 @@ async def test_send_message_validates_missing_token() -> None: assert result.error is not None and "access token" in result.error -@pytest.mark.asyncio -async def test_read_message_parses_payload(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/users/me/messages/M1?format=full", - json={ - "id": "M1", - "threadId": "T1", - "labelIds": ["INBOX"], - "snippet": "Hello", - "internalDate": "1700000000000", - "sizeEstimate": 100, - "payload": { - "headers": [ - {"name": "Subject", "value": "Greetings"}, - {"name": "From", "value": "a@x.io"}, - {"name": "To", "value": "me@x.io"}, - {"name": "Date", "value": "Fri, 16 May 2026 00:00:00 +0000"}, - ], - "body": {"data": _b64url("Hello, world.")}, - }, - }, - ) - result = ReadMessageOutput.model_validate( - await read_message.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert result.success is True - assert result.subject == "Greetings" - assert result.body == "Hello, world." - - -@pytest.mark.asyncio -async def test_read_message_multipart(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/users/me/messages/M1?format=full", - json={ - "id": "M1", - "threadId": "T1", - "payload": { - "headers": [{"name": "Subject", "value": "Multipart test"}], - "parts": [ - { - "mimeType": "text/plain", - "body": {"data": _b64url("plain text version")}, - }, - { - "mimeType": "text/html", - "body": {"data": _b64url("

html version

")}, - }, - ], - }, - }, - ) - result = ReadMessageOutput.model_validate( - await read_message.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert result.success is True - # Plain-text preferred when both are present. - assert result.body == "plain text version" - - -@pytest.mark.asyncio -async def test_search_messages_fetches_metadata_per_id(httpx_mock: Any) -> None: - # First call: list IDs. - httpx_mock.add_response( - method="GET", - url=f"{API}/users/me/messages?q=is:unread&maxResults=10", - json={ - "messages": [ - {"id": "M1", "threadId": "T1"}, - {"id": "M2", "threadId": "T2"}, - ], - "resultSizeEstimate": 2, - }, - ) - # Two follow-up calls for metadata. pytest_httpx is tolerant of - # repeated identical query-string keys; we register a single - # callback by URL pattern instead. - import re - - def _metadata(request: Any) -> Any: - from httpx import Response - msg_id = str(request.url).rsplit("/", 1)[-1].split("?", 1)[0] - return Response( - 200, - json={ - "id": msg_id, - "threadId": f"T{msg_id[-1]}", - "snippet": f"snip {msg_id}", - "payload": { - "headers": [ - {"name": "Subject", "value": f"sub {msg_id}"}, - {"name": "From", "value": "x@y.io"}, - {"name": "Date", "value": "today"}, - ] - }, - }, - ) - - httpx_mock.add_callback( - _metadata, - method="GET", - url=re.compile(rf"{API}/users/me/messages/M\d.*"), - is_reusable=True, - ) - result = SearchMessagesOutput.model_validate( - await search_messages.ainvoke(_args(_OAUTH_AUTH, query="is:unread")) - ) - assert result.success is True - assert result.total == 2 - assert {m.id for m in result.messages} == {"M1", "M2"} - - -@pytest.mark.asyncio -async def test_list_messages(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/users/me/messages?labelIds=INBOX&maxResults=20&includeSpamTrash=false", - json={"messages": [{"id": "M1", "threadId": "T1"}]}, - ) - httpx_mock.add_response( - method="GET", - url=( - f"{API}/users/me/messages/M1" - "?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date" - ), - json={ - "id": "M1", - "threadId": "T1", - "snippet": "Hi", - "labelIds": ["INBOX"], - "payload": {"headers": [{"name": "Subject", "value": "Hi"}]}, - }, - ) - result = ListMessagesOutput.model_validate( - await list_messages.ainvoke(_args(_OAUTH_AUTH)) - ) - assert result.success is True - assert result.total == 1 - assert result.messages[0].subject == "Hi" - - -@pytest.mark.asyncio -async def test_create_draft(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/drafts", - status_code=201, - json={"id": "D1", "message": {"id": "M1", "threadId": "T1"}}, - ) - result = CreateDraftOutput.model_validate( - await create_draft.ainvoke( - _args(_OAUTH_AUTH, to="x@y.io", subject="Draft", body="hi") - ) - ) - assert result.success is True - assert result.draft_id == "D1" - - -@pytest.mark.asyncio -async def test_mark_as_read(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": ["INBOX"]}, - ) - result = MarkAsReadOutput.model_validate( - await mark_as_read.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert result.success is True - assert "UNREAD" not in result.label_ids - - -@pytest.mark.asyncio -async def test_mark_as_unread(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": ["INBOX", "UNREAD"]}, - ) - result = MarkAsUnreadOutput.model_validate( - await mark_as_unread.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_archive_and_unarchive(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": []}, - ) - arch = ArchiveMessageOutput.model_validate( - await archive_message.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert arch.success is True - - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": ["INBOX"]}, - ) - unarch = UnarchiveMessageOutput.model_validate( - await unarchive_message.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert unarch.success is True - assert "INBOX" in unarch.label_ids - - -@pytest.mark.asyncio -async def test_delete_message(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/trash", - json={"id": "M1", "threadId": "T1"}, - ) - result = DeleteMessageOutput.model_validate( - await delete_message.ainvoke(_args(_OAUTH_AUTH, message_id="M1")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_add_label_and_remove_label(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": ["INBOX", "STARRED"]}, - ) - add = AddLabelOutput.model_validate( - await add_label.ainvoke( - _args(_OAUTH_AUTH, message_id="M1", label_ids=["STARRED"]) - ) - ) - assert add.success is True - assert add.added_labels == ["STARRED"] - - httpx_mock.add_response( - method="POST", - url=f"{API}/users/me/messages/M1/modify", - json={"id": "M1", "threadId": "T1", "labelIds": ["INBOX"]}, - ) - rem = RemoveLabelOutput.model_validate( - await remove_label.ainvoke( - _args(_OAUTH_AUTH, message_id="M1", label_ids=["STARRED"]) - ) - ) - assert rem.success is True - assert rem.removed_labels == ["STARRED"] - - @pytest.mark.asyncio async def test_list_labels(httpx_mock: Any) -> None: httpx_mock.add_response( diff --git a/src/modulex_integrations/tools/gmail/tools.py b/src/modulex_integrations/tools/gmail/tools.py index c68d837..3332a75 100644 --- a/src/modulex_integrations/tools/gmail/tools.py +++ b/src/modulex_integrations/tools/gmail/tools.py @@ -5,11 +5,10 @@ verbatim to keep the dependency surface tiny). Token-based runtime convention with paired oauth2 + bearer_token auth schemas. -Two list-style actions (``search_messages``, ``list_messages``) use an -N+1 metadata-fetch pattern: list message IDs first, then GET each -message in ``metadata`` format to pick up the subject/from/date headers. -Preserved from legacy — the alternative (``history.list``) needs a -different OAuth scope. +Scope-trimmed to send + label management only: ``send_message`` builds +a base64url MIME message locally, ``list_labels`` reads the account's +labels. Read/search/modify actions were removed alongside the +``gmail.readonly`` and ``gmail.modify`` OAuth scopes. """ from __future__ import annotations @@ -24,36 +23,13 @@ from modulex_integrations import serialize_pydantic_return from modulex_integrations.tools.gmail.outputs import ( - AddLabelOutput, - ArchiveMessageOutput, - CreateDraftOutput, - DeleteMessageOutput, - GmailMessageSummary, ListLabelsOutput, - ListMessagesOutput, - MarkAsReadOutput, - MarkAsUnreadOutput, - ReadMessageOutput, - RemoveLabelOutput, - SearchMessagesOutput, SendMessageOutput, - UnarchiveMessageOutput, ) __all__ = [ - "add_label", - "archive_message", - "create_draft", - "delete_message", "list_labels", - "list_messages", - "mark_as_read", - "mark_as_unread", - "read_message", - "remove_label", - "search_messages", "send_message", - "unarchive_message", ] _API_BASE = "https://www.googleapis.com/gmail/v1" @@ -110,32 +86,6 @@ def _build_raw_message( return base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8") -def _parse_payload(payload: dict[str, Any]) -> tuple[dict[str, str], str]: - """Pull headers + plaintext body out of a Gmail ``payload`` block.""" - headers = { - str(h.get("name", "")).lower(): str(h.get("value", "")) - for h in payload.get("headers") or [] - } - - body = "" - parts = payload.get("parts") - if isinstance(parts, list) and parts: - for part in parts: - mime = part.get("mimeType") - data = (part.get("body") or {}).get("data") or "" - if mime == "text/plain" and data: - body = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace") - break - if mime == "text/html" and data and not body: - body = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace") - else: - data = (payload.get("body") or {}).get("data") or "" - if data: - body = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace") - - return headers, body - - def _api_err(action: str, response: httpx.Response) -> str: return f"{action} failed: {response.status_code} - {response.text}" @@ -157,49 +107,6 @@ class SendMessageInput(_AuthFields): is_html: bool = Field(default=False, description="Whether body is HTML") -class ReadMessageInput(_AuthFields): - message_id: str = Field(description="The ID of the message to read") - format: str = Field(default="full", description="'minimal', 'full', 'raw', or 'metadata'") - - -class SearchMessagesInput(_AuthFields): - query: str = Field(description="Gmail search query") - max_results: int = Field(default=10, description="Maximum messages (max 500)") - page_token: str | None = Field(default=None, description="Pagination token") - label_ids: list[str] | None = Field(default=None, description="Filter by label IDs") - - -class ListMessagesInput(_AuthFields): - label_ids: list[str] = Field(default=["INBOX"], description="Label IDs to list from") - query: str | None = Field(default=None, description="Optional Gmail query filter") - max_results: int = Field(default=20, description="Maximum messages to return") - page_token: str | None = Field(default=None, description="Pagination token") - include_spam_trash: bool = Field(default=False, description="Include SPAM/TRASH") - - -class CreateDraftInput(_AuthFields): - to: str = Field(description="Recipient email address") - subject: str = Field(description="Email subject line") - body: str = Field(description="Email body content") - cc: str | None = Field(default=None, description="CC recipients") - bcc: str | None = Field(default=None, description="BCC recipients") - is_html: bool = Field(default=False, description="Whether body is HTML") - - -class _MessageIdOnly(_AuthFields): - message_id: str = Field(description="The ID of the message") - - -class AddLabelInput(_AuthFields): - message_id: str = Field(description="The ID of the message") - label_ids: list[str] = Field(description="Label IDs to add") - - -class RemoveLabelInput(_AuthFields): - message_id: str = Field(description="The ID of the message") - label_ids: list[str] = Field(description="Label IDs to remove") - - class ListLabelsInput(_AuthFields): pass @@ -248,441 +155,6 @@ async def send_message( ) -@tool(args_schema=ReadMessageInput) -@serialize_pydantic_return -async def read_message( - auth_type: str, - auth_data: dict[str, Any], - message_id: str, - format: str = "full", -) -> ReadMessageOutput: - """Read a specific email by ID.""" - err = _validate(auth_data, "read_message") - if err: - return ReadMessageOutput(success=False, error=err) - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.get( - f"{_API_BASE}/users/me/messages/{message_id}", - headers=_headers(auth_type, auth_data), - params={"format": format}, - ) - if response.status_code != 200: - return ReadMessageOutput( - success=False, error=_api_err("read_message", response) - ) - data = response.json() or {} - except Exception as exc: - return ReadMessageOutput(success=False, error=f"read_message failed: {exc}") - - payload = data.get("payload") or {} - headers, body = _parse_payload(payload) - return ReadMessageOutput( - success=True, - id=data.get("id"), - thread_id=data.get("threadId"), - label_ids=data.get("labelIds") or [], - snippet=data.get("snippet"), - subject=headers.get("subject"), - from_address=headers.get("from"), - to=headers.get("to"), - cc=headers.get("cc"), - date=headers.get("date"), - body=body, - internal_date=data.get("internalDate"), - size_estimate=data.get("sizeEstimate"), - ) - - -async def _fetch_summary( - client: httpx.AsyncClient, - msg: dict[str, Any], - headers: dict[str, str], -) -> GmailMessageSummary: - """Fetch metadata for one message and turn it into a summary row.""" - msg_id = msg.get("id") - if not msg_id: - return GmailMessageSummary() - try: - response = await client.get( - f"{_API_BASE}/users/me/messages/{msg_id}", - headers=headers, - params={ - "format": "metadata", - "metadataHeaders": ["Subject", "From", "Date"], - }, - ) - if response.status_code != 200: - return GmailMessageSummary(id=msg_id, thread_id=msg.get("threadId")) - body = response.json() or {} - except Exception: - return GmailMessageSummary(id=msg_id, thread_id=msg.get("threadId")) - - msg_headers = { - str(h.get("name", "")).lower(): str(h.get("value", "")) - for h in (body.get("payload") or {}).get("headers") or [] - } - return GmailMessageSummary( - id=msg_id, - thread_id=msg.get("threadId"), - snippet=body.get("snippet"), - subject=msg_headers.get("subject"), - from_address=msg_headers.get("from"), - date=msg_headers.get("date"), - label_ids=body.get("labelIds") or [], - ) - - -@tool(args_schema=SearchMessagesInput) -@serialize_pydantic_return -async def search_messages( - auth_type: str, - auth_data: dict[str, Any], - query: str, - max_results: int = 10, - page_token: str | None = None, - label_ids: list[str] | None = None, -) -> SearchMessagesOutput: - """Search Gmail messages using Gmail's query syntax (N+1 metadata fetch).""" - err = _validate(auth_data, "search_messages") - if err: - return SearchMessagesOutput(success=False, error=err) - - params: dict[str, Any] = {"q": query, "maxResults": min(max_results, 500)} - if page_token: - params["pageToken"] = page_token - if label_ids: - params["labelIds"] = label_ids - - headers = _headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.get( - f"{_API_BASE}/users/me/messages", headers=headers, params=params - ) - if response.status_code != 200: - return SearchMessagesOutput( - success=False, error=_api_err("search_messages", response) - ) - data = response.json() or {} - raw_messages = data.get("messages") or [] - summaries = [ - await _fetch_summary(client, m, headers) - for m in raw_messages[:max_results] - ] - except Exception as exc: - return SearchMessagesOutput( - success=False, error=f"search_messages failed: {exc}" - ) - - return SearchMessagesOutput( - success=True, - messages=summaries, - total=len(summaries), - result_size_estimate=data.get("resultSizeEstimate"), - next_page_token=data.get("nextPageToken"), - ) - - -@tool(args_schema=ListMessagesInput) -@serialize_pydantic_return -async def list_messages( - auth_type: str, - auth_data: dict[str, Any], - label_ids: list[str] | None = None, - query: str | None = None, - max_results: int = 20, - page_token: str | None = None, - include_spam_trash: bool = False, -) -> ListMessagesOutput: - """List messages from one or more labels (N+1 metadata fetch).""" - err = _validate(auth_data, "list_messages") - if err: - return ListMessagesOutput(success=False, error=err) - - params: dict[str, Any] = { - "labelIds": label_ids or ["INBOX"], - "maxResults": min(max_results, 500), - "includeSpamTrash": include_spam_trash, - } - if query: - params["q"] = query - if page_token: - params["pageToken"] = page_token - - headers = _headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.get( - f"{_API_BASE}/users/me/messages", headers=headers, params=params - ) - if response.status_code != 200: - return ListMessagesOutput( - success=False, error=_api_err("list_messages", response) - ) - data = response.json() or {} - summaries = [ - await _fetch_summary(client, m, headers) - for m in data.get("messages") or [] - ] - except Exception as exc: - return ListMessagesOutput( - success=False, error=f"list_messages failed: {exc}" - ) - - return ListMessagesOutput( - success=True, - messages=summaries, - total=len(summaries), - next_page_token=data.get("nextPageToken"), - ) - - -@tool(args_schema=CreateDraftInput) -@serialize_pydantic_return -async def create_draft( - auth_type: str, - auth_data: dict[str, Any], - to: str, - subject: str, - body: str, - cc: str | None = None, - bcc: str | None = None, - is_html: bool = False, -) -> CreateDraftOutput: - """Create an email draft in Gmail.""" - err = _validate(auth_data, "create_draft") - if err: - return CreateDraftOutput(success=False, error=err) - - raw = _build_raw_message(to, subject, body, cc, bcc, is_html) - try: - async with httpx.AsyncClient(timeout=_SEND_TIMEOUT) as client: - response = await client.post( - f"{_API_BASE}/users/me/drafts", - headers=_headers(auth_type, auth_data), - json={"message": {"raw": raw}}, - ) - if response.status_code not in (200, 201): - return CreateDraftOutput( - success=False, error=_api_err("create_draft", response) - ) - data = response.json() or {} - except Exception as exc: - return CreateDraftOutput(success=False, error=f"create_draft failed: {exc}") - - message = data.get("message") or {} - return CreateDraftOutput( - success=True, - draft_id=data.get("id"), - message_id=message.get("id"), - thread_id=message.get("threadId"), - ) - - -async def _modify_message( - action: str, - auth_type: str, - auth_data: dict[str, Any], - message_id: str, - add: list[str] | None = None, - remove: list[str] | None = None, -) -> tuple[bool, str | None, dict[str, Any]]: - payload: dict[str, Any] = {} - if add: - payload["addLabelIds"] = add - if remove: - payload["removeLabelIds"] = remove - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.post( - f"{_API_BASE}/users/me/messages/{message_id}/modify", - headers=_headers(auth_type, auth_data), - json=payload, - ) - if response.status_code != 200: - return False, _api_err(action, response), {} - body = response.json() or {} - except Exception as exc: - return False, f"{action} failed: {exc}", {} - return True, None, body - - -@tool(args_schema=_MessageIdOnly) -@serialize_pydantic_return -async def mark_as_read( - auth_type: str, auth_data: dict[str, Any], message_id: str -) -> MarkAsReadOutput: - """Mark a Gmail message as read.""" - err = _validate(auth_data, "mark_as_read") - if err: - return MarkAsReadOutput(success=False, error=err) - ok, e, body = await _modify_message( - "mark_as_read", auth_type, auth_data, message_id, remove=["UNREAD"] - ) - if not ok: - return MarkAsReadOutput(success=False, error=e) - return MarkAsReadOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - ) - - -@tool(args_schema=_MessageIdOnly) -@serialize_pydantic_return -async def mark_as_unread( - auth_type: str, auth_data: dict[str, Any], message_id: str -) -> MarkAsUnreadOutput: - """Mark a Gmail message as unread.""" - err = _validate(auth_data, "mark_as_unread") - if err: - return MarkAsUnreadOutput(success=False, error=err) - ok, e, body = await _modify_message( - "mark_as_unread", auth_type, auth_data, message_id, add=["UNREAD"] - ) - if not ok: - return MarkAsUnreadOutput(success=False, error=e) - return MarkAsUnreadOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - ) - - -@tool(args_schema=_MessageIdOnly) -@serialize_pydantic_return -async def archive_message( - auth_type: str, auth_data: dict[str, Any], message_id: str -) -> ArchiveMessageOutput: - """Archive a Gmail message (remove from INBOX).""" - err = _validate(auth_data, "archive_message") - if err: - return ArchiveMessageOutput(success=False, error=err) - ok, e, body = await _modify_message( - "archive_message", auth_type, auth_data, message_id, remove=["INBOX"] - ) - if not ok: - return ArchiveMessageOutput(success=False, error=e) - return ArchiveMessageOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - message="Message archived successfully", - ) - - -@tool(args_schema=_MessageIdOnly) -@serialize_pydantic_return -async def unarchive_message( - auth_type: str, auth_data: dict[str, Any], message_id: str -) -> UnarchiveMessageOutput: - """Move an archived Gmail message back to INBOX.""" - err = _validate(auth_data, "unarchive_message") - if err: - return UnarchiveMessageOutput(success=False, error=err) - ok, e, body = await _modify_message( - "unarchive_message", auth_type, auth_data, message_id, add=["INBOX"] - ) - if not ok: - return UnarchiveMessageOutput(success=False, error=e) - return UnarchiveMessageOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - message="Message moved to inbox", - ) - - -@tool(args_schema=_MessageIdOnly) -@serialize_pydantic_return -async def delete_message( - auth_type: str, auth_data: dict[str, Any], message_id: str -) -> DeleteMessageOutput: - """Move a Gmail message to Trash.""" - err = _validate(auth_data, "delete_message") - if err: - return DeleteMessageOutput(success=False, error=err) - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.post( - f"{_API_BASE}/users/me/messages/{message_id}/trash", - headers=_headers(auth_type, auth_data), - ) - if response.status_code != 200: - return DeleteMessageOutput( - success=False, error=_api_err("delete_message", response) - ) - data = response.json() or {} - except Exception as exc: - return DeleteMessageOutput(success=False, error=f"delete_message failed: {exc}") - - return DeleteMessageOutput( - success=True, - id=data.get("id"), - thread_id=data.get("threadId"), - message="Message moved to trash", - ) - - -@tool(args_schema=AddLabelInput) -@serialize_pydantic_return -async def add_label( - auth_type: str, - auth_data: dict[str, Any], - message_id: str, - label_ids: list[str], -) -> AddLabelOutput: - """Add one or more labels to a Gmail message.""" - err = _validate(auth_data, "add_label") - if err: - return AddLabelOutput(success=False, error=err) - ok, e, body = await _modify_message( - "add_label", auth_type, auth_data, message_id, add=label_ids - ) - if not ok: - return AddLabelOutput(success=False, error=e) - return AddLabelOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - added_labels=label_ids, - ) - - -@tool(args_schema=RemoveLabelInput) -@serialize_pydantic_return -async def remove_label( - auth_type: str, - auth_data: dict[str, Any], - message_id: str, - label_ids: list[str], -) -> RemoveLabelOutput: - """Remove one or more labels from a Gmail message.""" - err = _validate(auth_data, "remove_label") - if err: - return RemoveLabelOutput(success=False, error=err) - ok, e, body = await _modify_message( - "remove_label", auth_type, auth_data, message_id, remove=label_ids - ) - if not ok: - return RemoveLabelOutput(success=False, error=e) - return RemoveLabelOutput( - success=True, - id=body.get("id"), - thread_id=body.get("threadId"), - label_ids=body.get("labelIds") or [], - removed_labels=label_ids, - ) - - @tool(args_schema=ListLabelsInput) @serialize_pydantic_return async def list_labels( diff --git a/src/modulex_integrations/tools/google_docs/README.md b/src/modulex_integrations/tools/google_docs/README.md index 496c614..88262dd 100644 --- a/src/modulex_integrations/tools/google_docs/README.md +++ b/src/modulex_integrations/tools/google_docs/README.md @@ -1,15 +1,15 @@ # Google Docs -Create, read, and edit Google Docs documents via the Google Docs API (`docs.googleapis.com/v1`) and Google Drive API (`www.googleapis.com/drive/v3`). +Create, read, and edit Google Docs documents via the Google Docs API (`docs.googleapis.com/v1`). ## Authentication ### OAuth2 Authentication - Create OAuth credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials). -- Enable the Google Docs API and Google Drive API in your project. +- Enable the Google Docs API in your project. - Required env vars: `GOOGLE_DOCS_OAUTH2_CLIENT_ID` and `GOOGLE_DOCS_OAUTH2_CLIENT_SECRET` (only when using your own OAuth app). -- Scopes requested: `https://www.googleapis.com/auth/documents`, `https://www.googleapis.com/auth/drive`. +- Scopes requested: `https://www.googleapis.com/auth/documents`. - Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback`. ## Tools @@ -19,8 +19,6 @@ Create, read, and edit Google Docs documents via the Google Docs API (`docs.goog | `append_image` | Append an image to the end of a Google Docs document | `doc_id`, `image_uri` | | `append_text` | Append text to an existing Google Docs document | `doc_id`, `text` | | `create_document` | Create a new Google Docs document with optional text content | `title` | -| `create_document_from_template` | Create a new Google Docs document from a template with placeholder replacement | `template_id`, `name`, `replace_values` | -| `find_document` | Search for Google Docs documents by name or query using Google Drive search | | | `get_document` | Get the contents of a Google Docs document | `doc_id` | | `get_tab_content` | Get the content of specific tabs in a Google Docs document | `doc_id`, `tab_ids` | | `insert_page_break` | Insert a page break into a Google Docs document at a specified index | `doc_id` | @@ -34,7 +32,6 @@ Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fil ## Limits & Quotas - Google Docs API: 300 read requests per minute per user, 60 write requests per minute per user. -- Google Drive API: 12,000 queries per day, 1,200 queries per 100 seconds per user. - Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. ## Maintainer diff --git a/src/modulex_integrations/tools/google_docs/__init__.py b/src/modulex_integrations/tools/google_docs/__init__.py index bec793b..ba7ea46 100644 --- a/src/modulex_integrations/tools/google_docs/__init__.py +++ b/src/modulex_integrations/tools/google_docs/__init__.py @@ -4,8 +4,6 @@ append_image, append_text, create_document, - create_document_from_template, - find_document, get_document, get_tab_content, insert_page_break, @@ -19,8 +17,6 @@ append_image, append_text, create_document, - create_document_from_template, - find_document, get_document, get_tab_content, insert_page_break, @@ -35,8 +31,6 @@ "append_image", "append_text", "create_document", - "create_document_from_template", - "find_document", "get_document", "get_tab_content", "insert_page_break", diff --git a/src/modulex_integrations/tools/google_docs/manifest.py b/src/modulex_integrations/tools/google_docs/manifest.py index f6ab383..63a2aaf 100644 --- a/src/modulex_integrations/tools/google_docs/manifest.py +++ b/src/modulex_integrations/tools/google_docs/manifest.py @@ -80,49 +80,6 @@ type="string", description="Text content to insert into the document", ), - "folder_id": ParameterDef( - type="string", - description="ID of the Google Drive folder to place the document in", - ), - }, - ), - ActionDefinition( - name="create_document_from_template", - description="Create a new Google Docs document from a template with placeholder replacement", - parameters={ - "template_id": ParameterDef( - type="string", - description="The ID of the template document containing {{placeholder}} variables", - required=True, - ), - "name": ParameterDef( - type="string", - description="Name for the new document", - required=True, - ), - "replace_values": ParameterDef( - type="object", - description="Key-value pairs to replace placeholders in the template (keys without curly braces)", - required=True, - ), - "folder_id": ParameterDef( - type="string", - description="ID of the Google Drive folder for the new document", - ), - }, - ), - ActionDefinition( - name="find_document", - description="Search for Google Docs documents by name or query using Google Drive search", - parameters={ - "name_search_term": ParameterDef( - type="string", - description="Search for a document by name (equivalent to 'name contains' query)", - ), - "search_query": ParameterDef( - type="string", - description="Custom Google Drive search query. If specified, name_search_term is ignored", - ), }, ), ActionDefinition( @@ -311,7 +268,6 @@ token_url="https://oauth2.googleapis.com/token", scopes=[ "https://www.googleapis.com/auth/documents", - "https://www.googleapis.com/auth/drive", ], ), test_endpoint=TestEndpoint( diff --git a/src/modulex_integrations/tools/google_docs/outputs.py b/src/modulex_integrations/tools/google_docs/outputs.py index b1910ac..923670b 100644 --- a/src/modulex_integrations/tools/google_docs/outputs.py +++ b/src/modulex_integrations/tools/google_docs/outputs.py @@ -8,10 +8,7 @@ __all__ = [ "AppendImageOutput", "AppendTextOutput", - "CreateDocumentFromTemplateOutput", "CreateDocumentOutput", - "DocumentFile", - "FindDocumentOutput", "GetDocumentOutput", "GetTabContentOutput", "InsertPageBreakOutput", @@ -29,14 +26,6 @@ class _Base(BaseModel): model_config = ConfigDict(extra="forbid") -class DocumentFile(_Base): - """A Google Drive file reference returned by find_document.""" - - id: str | None = None - name: str | None = None - mime_type: str | None = None - - class TabContent(_Base): """Tab content returned by get_tab_content.""" @@ -66,20 +55,6 @@ class CreateDocumentOutput(_Base): title: str | None = None -class CreateDocumentFromTemplateOutput(_Base): - success: bool - error: str | None = None - google_doc_id: str | None = None - pdf_id: str | None = None - name: str | None = None - - -class FindDocumentOutput(_Base): - success: bool - error: str | None = None - files: list[DocumentFile] = Field(default_factory=list) - - class GetDocumentOutput(_Base): success: bool error: str | None = None diff --git a/src/modulex_integrations/tools/google_docs/tests/test_google_docs.py b/src/modulex_integrations/tools/google_docs/tests/test_google_docs.py index fad956a..f3e368c 100644 --- a/src/modulex_integrations/tools/google_docs/tests/test_google_docs.py +++ b/src/modulex_integrations/tools/google_docs/tests/test_google_docs.py @@ -11,8 +11,6 @@ append_image, append_text, create_document, - create_document_from_template, - find_document, get_document, get_tab_content, insert_page_break, @@ -25,9 +23,7 @@ from modulex_integrations.tools.google_docs.outputs import ( AppendImageOutput, AppendTextOutput, - CreateDocumentFromTemplateOutput, CreateDocumentOutput, - FindDocumentOutput, GetDocumentOutput, GetTabContentOutput, InsertPageBreakOutput, @@ -38,7 +34,6 @@ ) DOCS_API = "https://docs.googleapis.com/v1" -DRIVE_API = "https://www.googleapis.com/drive/v3" _AUTH: dict[str, Any] = { "auth_type": "oauth2", @@ -55,8 +50,8 @@ def _args(**extra: Any) -> dict[str, Any]: class TestManifest: - def test_manifest_exposes_12_actions(self) -> None: - assert len(manifest.actions) == 12 + def test_manifest_exposes_10_actions(self) -> None: + assert len(manifest.actions) == 10 def test_manifest_actions_match_tools_tuple(self) -> None: assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} @@ -139,54 +134,6 @@ async def test_create_document(httpx_mock) -> None: # type: ignore[no-untyped-d assert result.title == "New Doc" -@pytest.mark.asyncio -async def test_create_document_from_template(httpx_mock) -> None: # type: ignore[no-untyped-def] - httpx_mock.add_response( - method="POST", - url=f"{DRIVE_API}/files/tmpl123/copy", - json={"id": "copied_doc_id", "name": "My Doc"}, - ) - httpx_mock.add_response( - method="POST", - url=f"{DOCS_API}/documents/copied_doc_id:batchUpdate", - json={"replies": []}, - ) - - result_dict = await create_document_from_template.ainvoke( - _args( - template_id="tmpl123", - name="My Doc", - replace_values={"greeting": "Hello"}, - ) - ) - - assert isinstance(result_dict, dict) - result = CreateDocumentFromTemplateOutput.model_validate(result_dict) - assert result.success is True - assert result.google_doc_id == "copied_doc_id" - - -@pytest.mark.asyncio -async def test_find_document(httpx_mock) -> None: # type: ignore[no-untyped-def] - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{re.escape(DRIVE_API)}/files\??.*"), - json={ - "files": [ - {"id": "doc1", "name": "My Doc", "mimeType": "application/vnd.google-apps.document"} - ] - }, - ) - - result_dict = await find_document.ainvoke(_args(name_search_term="My Doc")) - - assert isinstance(result_dict, dict) - result = FindDocumentOutput.model_validate(result_dict) - assert result.success is True - assert len(result.files) == 1 - assert result.files[0].id == "doc1" - - @pytest.mark.asyncio async def test_get_document(httpx_mock) -> None: # type: ignore[no-untyped-def] httpx_mock.add_response( diff --git a/src/modulex_integrations/tools/google_docs/tools.py b/src/modulex_integrations/tools/google_docs/tools.py index 6bcd325..7f89317 100644 --- a/src/modulex_integrations/tools/google_docs/tools.py +++ b/src/modulex_integrations/tools/google_docs/tools.py @@ -11,10 +11,7 @@ from modulex_integrations.tools.google_docs.outputs import ( AppendImageOutput, AppendTextOutput, - CreateDocumentFromTemplateOutput, CreateDocumentOutput, - DocumentFile, - FindDocumentOutput, GetDocumentOutput, GetTabContentOutput, InsertPageBreakOutput, @@ -29,8 +26,6 @@ "append_image", "append_text", "create_document", - "create_document_from_template", - "find_document", "get_document", "get_tab_content", "insert_page_break", @@ -41,7 +36,6 @@ ] _DOCS_BASE_URL = "https://docs.googleapis.com/v1" -_DRIVE_BASE_URL = "https://www.googleapis.com/drive/v3" def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: @@ -78,23 +72,6 @@ class CreateDocumentInput(BaseModel): auth_data: dict[str, Any] = Field(description="Authentication data") title: str = Field(description="Title of the new document") text: str | None = Field(default=None, description="Text content to insert into the document") - folder_id: str | None = Field(default=None, description="ID of the Google Drive folder to place the document in") - - -class CreateDocumentFromTemplateInput(BaseModel): - auth_type: str = Field(description="Authentication type") - auth_data: dict[str, Any] = Field(description="Authentication data") - template_id: str = Field(description="The ID of the template document containing {{placeholder}} variables") - name: str = Field(description="Name for the new document") - replace_values: dict[str, str] = Field(description="Key-value pairs to replace placeholders in the template (keys without curly braces)") - folder_id: str | None = Field(default=None, description="ID of the Google Drive folder for the new document") - - -class FindDocumentInput(BaseModel): - auth_type: str = Field(description="Authentication type") - auth_data: dict[str, Any] = Field(description="Authentication data") - name_search_term: str | None = Field(default=None, description="Search for a document by name (equivalent to 'name contains' query)") - search_query: str | None = Field(default=None, description="Custom Google Drive search query. If specified, name_search_term is ignored") class GetDocumentInput(BaseModel): @@ -293,7 +270,6 @@ async def create_document( auth_data: dict[str, Any], title: str, text: str | None = None, - folder_id: str | None = None, ) -> CreateDocumentOutput: """Create a new Google Docs document with optional text content.""" if not auth_data.get("access_token"): @@ -328,24 +304,6 @@ async def create_document( headers={**headers, "Content-Type": "application/json"}, json={"requests": insert_requests}, ) - - if folder_id and document_id: - get_file_resp = await client.get( - f"{_DRIVE_BASE_URL}/files/{document_id}", - headers=headers, - params={"fields": "parents"}, - ) - if get_file_resp.status_code == 200: - parents = get_file_resp.json().get("parents", []) - remove_parents = ",".join(parents) if parents else "" - await client.patch( - f"{_DRIVE_BASE_URL}/files/{document_id}", - headers=headers, - params={ - "addParents": folder_id, - "removeParents": remove_parents, - }, - ) except httpx.TimeoutException: return CreateDocumentOutput(success=False, error="Request timed out.") except Exception as exc: @@ -358,134 +316,6 @@ async def create_document( ) -@tool(args_schema=CreateDocumentFromTemplateInput) -@serialize_pydantic_return -async def create_document_from_template( - auth_type: str, - auth_data: dict[str, Any], - template_id: str, - name: str, - replace_values: dict[str, str], - folder_id: str | None = None, -) -> CreateDocumentFromTemplateOutput: - """Create a new Google Docs document from a template with placeholder replacement.""" - if not auth_data.get("access_token"): - return CreateDocumentFromTemplateOutput(success=False, error="Missing OAuth access token.") - headers = _get_auth_headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=60.0) as client: - copy_resp = await client.post( - f"{_DRIVE_BASE_URL}/files/{template_id}/copy", - headers={**headers, "Content-Type": "application/json"}, - json={"name": name}, - ) - if copy_resp.status_code != 200: - return CreateDocumentFromTemplateOutput( - success=False, - error=f"Failed to copy template ({copy_resp.status_code}): {copy_resp.text}", - ) - copy_data = copy_resp.json() - new_doc_id = copy_data.get("id") - - if replace_values and new_doc_id: - replace_requests: list[dict[str, Any]] = [ - { - "replaceAllText": { - "containsText": { - "text": "{{" + key + "}}", - "matchCase": True, - }, - "replaceText": value, - } - } - for key, value in replace_values.items() - ] - await client.post( - f"{_DOCS_BASE_URL}/documents/{new_doc_id}:batchUpdate", - headers={**headers, "Content-Type": "application/json"}, - json={"requests": replace_requests}, - ) - - if folder_id and new_doc_id: - get_file_resp = await client.get( - f"{_DRIVE_BASE_URL}/files/{new_doc_id}", - headers=headers, - params={"fields": "parents"}, - ) - if get_file_resp.status_code == 200: - parents = get_file_resp.json().get("parents", []) - remove_parents = ",".join(parents) if parents else "" - await client.patch( - f"{_DRIVE_BASE_URL}/files/{new_doc_id}", - headers=headers, - params={ - "addParents": folder_id, - "removeParents": remove_parents, - }, - ) - except httpx.TimeoutException: - return CreateDocumentFromTemplateOutput(success=False, error="Request timed out.") - except Exception as exc: - return CreateDocumentFromTemplateOutput(success=False, error=f"Call failed: {exc}") - - return CreateDocumentFromTemplateOutput( - success=True, - google_doc_id=new_doc_id, - name=name, - ) - - -@tool(args_schema=FindDocumentInput) -@serialize_pydantic_return -async def find_document( - auth_type: str, - auth_data: dict[str, Any], - name_search_term: str | None = None, - search_query: str | None = None, -) -> FindDocumentOutput: - """Search for Google Docs documents by name or query using Google Drive search.""" - if not auth_data.get("access_token"): - return FindDocumentOutput(success=False, error="Missing OAuth access token.") - headers = _get_auth_headers(auth_type, auth_data) - try: - if search_query: - q = search_query - elif name_search_term: - q = f"name contains '{name_search_term}' and mimeType = 'application/vnd.google-apps.document'" - else: - q = "mimeType = 'application/vnd.google-apps.document'" - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get( - f"{_DRIVE_BASE_URL}/files", - headers=headers, - params={ - "q": q, - "fields": "files(id,name,mimeType)", - }, - ) - if response.status_code != 200: - return FindDocumentOutput( - success=False, - error=f"API error ({response.status_code}): {response.text}", - ) - data = response.json() - except httpx.TimeoutException: - return FindDocumentOutput(success=False, error="Request timed out.") - except Exception as exc: - return FindDocumentOutput(success=False, error=f"Call failed: {exc}") - - files = [ - DocumentFile( - id=f.get("id"), - name=f.get("name"), - mime_type=f.get("mimeType"), - ) - for f in data.get("files", []) - ] - return FindDocumentOutput(success=True, files=files) - - @tool(args_schema=GetDocumentInput) @serialize_pydantic_return async def get_document( diff --git a/src/modulex_integrations/tools/google_drive/README.md b/src/modulex_integrations/tools/google_drive/README.md index 77af723..6da7f03 100644 --- a/src/modulex_integrations/tools/google_drive/README.md +++ b/src/modulex_integrations/tools/google_drive/README.md @@ -1,12 +1,15 @@ # Google Drive (+ Docs / Sheets / Slides) Google Workspace integration via the v3 (Drive) / v1 (Docs, Slides) / -v4 (Sheets) REST APIs. Pure HTTP, no SDK dep. 24 actions. +v4 (Sheets) REST APIs. Pure HTTP, no SDK dep. 16 actions. ## Authentication -- **Paired `oauth2 + bearer_token` schemas.** OAuth requests five - scopes covering Drive + Docs + Sheets + Slides. +- **Paired `oauth2 + bearer_token` schemas.** OAuth requests four + scopes covering Drive (app-created files) + Docs + Sheets + Slides: + `drive.file`, `documents`, `spreadsheets`, `presentations`. The + broad `drive` scope was dropped — access is now limited to files + the app creates or that the user explicitly opens. - OAuth env vars: `GOOGLE_DRIVE_OAUTH2_CLIENT_ID`, `GOOGLE_DRIVE_OAUTH2_CLIENT_SECRET` (both `only_for_custom`). - Bearer env var: `GOOGLE_ACCESS_TOKEN`. @@ -20,8 +23,8 @@ Token-based: every `@tool` accepts `(auth_type, auth_data, ...)`. | group | tools | | --- | --- | -| Drive — files | `search_files`, `list_folder`, `read_file`, `create_text_file`, `update_text_file`, `copy_file`, `get_file_metadata` | -| Drive — items | `create_folder`, `delete_item`, `rename_item`, `move_item` | +| Drive — files | `create_text_file`, `update_text_file` | +| Drive — items | `create_folder` | | Docs | `create_google_doc`, `read_google_doc`, `update_google_doc`, `append_to_google_doc` | | Sheets | `create_google_sheet`, `read_google_sheet`, `update_google_sheet`, `format_sheet_cells`, `format_sheet_text` | | Slides | `create_google_slides`, `read_google_slides`, `add_slide`, `update_slide_content` | @@ -40,8 +43,6 @@ Token-based: every `@tool` accepts `(auth_type, auth_data, ...)`. - **`read_google_sheet` / `update_google_sheet`** — first GET the spreadsheet to resolve localized sheet names (e.g. `Sayfa1` in Turkish), then call the values endpoint. -- **`move_item`** — GET parents first, then PATCH with - `addParents` / `removeParents` query params. - **`format_sheet_cells` / `format_sheet_text`** — convert A1 notation to `GridRange` for the Sheets batchUpdate API. diff --git a/src/modulex_integrations/tools/google_drive/__init__.py b/src/modulex_integrations/tools/google_drive/__init__.py index ca292aa..18f5358 100644 --- a/src/modulex_integrations/tools/google_drive/__init__.py +++ b/src/modulex_integrations/tools/google_drive/__init__.py @@ -3,24 +3,16 @@ from modulex_integrations.tools.google_drive.tools import ( add_slide, append_to_google_doc, - copy_file, create_folder, create_google_doc, create_google_sheet, create_google_slides, create_text_file, - delete_item, format_sheet_cells, format_sheet_text, - get_file_metadata, - list_folder, - move_item, - read_file, read_google_doc, read_google_sheet, read_google_slides, - rename_item, - search_files, update_google_doc, update_google_sheet, update_slide_content, @@ -28,17 +20,9 @@ ) TOOLS = ( - search_files, - list_folder, - read_file, create_text_file, update_text_file, create_folder, - delete_item, - rename_item, - move_item, - copy_file, - get_file_metadata, create_google_doc, read_google_doc, update_google_doc, @@ -58,25 +42,17 @@ "TOOLS", "add_slide", "append_to_google_doc", - "copy_file", "create_folder", "create_google_doc", "create_google_sheet", "create_google_slides", "create_text_file", - "delete_item", "format_sheet_cells", "format_sheet_text", - "get_file_metadata", - "list_folder", "manifest", - "move_item", - "read_file", "read_google_doc", "read_google_sheet", "read_google_slides", - "rename_item", - "search_files", "update_google_doc", "update_google_sheet", "update_slide_content", diff --git a/src/modulex_integrations/tools/google_drive/manifest.py b/src/modulex_integrations/tools/google_drive/manifest.py index 5d66df1..5e78e75 100644 --- a/src/modulex_integrations/tools/google_drive/manifest.py +++ b/src/modulex_integrations/tools/google_drive/manifest.py @@ -74,46 +74,6 @@ def _name_param(item: str) -> ParameterDef: categories=["Cloud Infrastructure", "File Storage", "Document Management", "Productivity"], actions=[ # --- Drive --------------------------------------------------------- - ActionDefinition( - name="search_files", - description="Search files in Google Drive by name substring", - parameters={ - "query": ParameterDef( - type="string", - description="Substring to match in file name", - required=True, - ), - "page_size": ParameterDef( - type="integer", description="Results (max 100)", default=10 - ), - "page_token": ParameterDef( - type="string", description="Pagination token" - ), - }, - ), - ActionDefinition( - name="list_folder", - description="List files + subfolders in a folder (use 'root' for root)", - parameters={ - "folder_id": ParameterDef( - type="string", description="Folder ID", default="root" - ), - "page_size": ParameterDef( - type="integer", description="Results (max 100)", default=20 - ), - "page_token": ParameterDef( - type="string", description="Pagination token" - ), - }, - ), - ActionDefinition( - name="read_file", - description=( - "Read a file's content — exports Google Docs as plain text, " - "downloads text/* directly, otherwise returns the web link" - ), - parameters={"file_id": _file_id_param()}, - ), ActionDefinition( name="create_text_file", description=( @@ -158,60 +118,6 @@ def _name_param(item: str) -> ParameterDef: ), }, ), - ActionDefinition( - name="delete_item", - description="Permanently delete a file or folder", - parameters={ - "item_id": ParameterDef( - type="string", description="File / folder ID", required=True - ), - }, - ), - ActionDefinition( - name="rename_item", - description="Rename a file or folder", - parameters={ - "item_id": ParameterDef( - type="string", description="File / folder ID", required=True - ), - "new_name": ParameterDef( - type="string", description="New name", required=True - ), - }, - ), - ActionDefinition( - name="move_item", - description=( - "Move a file/folder to a destination (N+1: read parents, " - "then PATCH with addParents/removeParents)" - ), - parameters={ - "item_id": ParameterDef( - type="string", description="File / folder ID", required=True - ), - "destination_folder_id": ParameterDef( - type="string", - description="Destination folder ID", - default="root", - ), - }, - ), - ActionDefinition( - name="copy_file", - description="Copy a file (optionally rename / change folder)", - parameters={ - "file_id": _file_id_param(), - "new_name": ParameterDef(type="string", description="Copy name"), - "destination_folder_id": ParameterDef( - type="string", description="Destination folder" - ), - }, - ), - ActionDefinition( - name="get_file_metadata", - description="Get full metadata for a file/folder", - parameters={"file_id": _file_id_param()}, - ), # --- Docs ---------------------------------------------------------- ActionDefinition( name="create_google_doc", @@ -433,7 +339,6 @@ def _name_param(item: str) -> ParameterDef: auth_url="https://accounts.google.com/o/oauth2/v2/auth", token_url="https://oauth2.googleapis.com/token", scopes=[ - "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/spreadsheets", diff --git a/src/modulex_integrations/tools/google_drive/outputs.py b/src/modulex_integrations/tools/google_drive/outputs.py index 483b3e6..188577d 100644 --- a/src/modulex_integrations/tools/google_drive/outputs.py +++ b/src/modulex_integrations/tools/google_drive/outputs.py @@ -8,24 +8,16 @@ __all__ = [ "AddSlideOutput", "AppendToGoogleDocOutput", - "CopyFileOutput", "CreateFolderOutput", "CreateGoogleDocOutput", "CreateGoogleSheetOutput", "CreateGoogleSlidesOutput", "CreateTextFileOutput", - "DeleteItemOutput", "FormatSheetCellsOutput", "FormatSheetTextOutput", - "GetFileMetadataOutput", - "ListFolderOutput", - "MoveItemOutput", - "ReadFileOutput", "ReadGoogleDocOutput", "ReadGoogleSheetOutput", "ReadGoogleSlidesOutput", - "RenameItemOutput", - "SearchFilesOutput", "UpdateGoogleDocOutput", "UpdateGoogleSheetOutput", "UpdateSlideContentOutput", @@ -39,30 +31,6 @@ class _Base(BaseModel): error: str | None = None -class SearchFilesOutput(_Base): - files: list[dict[str, Any]] = Field(default_factory=list) - total: int = 0 - next_page_token: str | None = None - - -class ListFolderOutput(_Base): - folder_id: str | None = None - folders: list[dict[str, Any]] = Field(default_factory=list) - files: list[dict[str, Any]] = Field(default_factory=list) - total: int = 0 - next_page_token: str | None = None - - -class ReadFileOutput(_Base): - id: str | None = None - name: str | None = None - mime_type: str | None = None - content: str | None = None - size: str | None = None - web_view_link: str | None = None - message: str | None = None - - class CreateTextFileOutput(_Base): id: str | None = None name: str | None = None @@ -84,46 +52,6 @@ class CreateFolderOutput(_Base): web_view_link: str | None = None -class DeleteItemOutput(_Base): - deleted_id: str | None = None - message: str | None = None - - -class RenameItemOutput(_Base): - id: str | None = None - name: str | None = None - mime_type: str | None = None - modified_time: str | None = None - - -class MoveItemOutput(_Base): - id: str | None = None - name: str | None = None - new_parent: str | None = None - web_view_link: str | None = None - - -class CopyFileOutput(_Base): - id: str | None = None - name: str | None = None - mime_type: str | None = None - web_view_link: str | None = None - - -class GetFileMetadataOutput(_Base): - id: str | None = None - name: str | None = None - mime_type: str | None = None - created_time: str | None = None - modified_time: str | None = None - size: str | None = None - web_view_link: str | None = None - web_content_link: str | None = None - parents: list[str] | None = None - shared: bool | None = None - owners: list[dict[str, Any]] | None = None - - class CreateGoogleDocOutput(_Base): id: str | None = None name: str | None = None diff --git a/src/modulex_integrations/tools/google_drive/tests/test_google_drive.py b/src/modulex_integrations/tools/google_drive/tests/test_google_drive.py index 5500dc0..ddaf856 100644 --- a/src/modulex_integrations/tools/google_drive/tests/test_google_drive.py +++ b/src/modulex_integrations/tools/google_drive/tests/test_google_drive.py @@ -1,6 +1,6 @@ """Tests for the Google Drive / Docs / Sheets / Slides integration. -24 actions all share the same shape, so coverage is shape- +16 actions all share the same shape, so coverage is shape- representative rather than exhaustive: one happy-path per API surface plus the manifest sanity trio, an auth-validation test, and a few multi-call workflow tests for the trickier shapes. @@ -16,25 +16,17 @@ TOOLS, add_slide, append_to_google_doc, - copy_file, create_folder, create_google_doc, create_google_sheet, create_google_slides, create_text_file, - delete_item, format_sheet_cells, format_sheet_text, - get_file_metadata, - list_folder, manifest, - move_item, - read_file, read_google_doc, read_google_sheet, read_google_slides, - rename_item, - search_files, update_google_doc, update_google_sheet, update_slide_content, @@ -43,24 +35,16 @@ from modulex_integrations.tools.google_drive.outputs import ( AddSlideOutput, AppendToGoogleDocOutput, - CopyFileOutput, CreateFolderOutput, CreateGoogleDocOutput, CreateGoogleSheetOutput, CreateGoogleSlidesOutput, CreateTextFileOutput, - DeleteItemOutput, FormatSheetCellsOutput, FormatSheetTextOutput, - GetFileMetadataOutput, - ListFolderOutput, - MoveItemOutput, - ReadFileOutput, ReadGoogleDocOutput, ReadGoogleSheetOutput, ReadGoogleSlidesOutput, - RenameItemOutput, - SearchFilesOutput, UpdateGoogleDocOutput, UpdateGoogleSheetOutput, UpdateSlideContentOutput, @@ -85,8 +69,8 @@ def _args(**extra: Any) -> dict[str, Any]: class TestManifest: - def test_manifest_exposes_24_actions(self) -> None: - assert len(manifest.actions) == 24 + def test_manifest_exposes_16_actions(self) -> None: + assert len(manifest.actions) == 16 def test_manifest_actions_match_tools_tuple(self) -> None: assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} @@ -114,110 +98,15 @@ def test_a1_to_grid() -> None: @pytest.mark.asyncio -async def test_search_files_missing_token() -> None: +async def test_create_folder_missing_token() -> None: bad = {"auth_type": "oauth2", "auth_data": {}} - result = SearchFilesOutput.model_validate( - await search_files.ainvoke(dict(bad, query="foo")) + result = CreateFolderOutput.model_validate( + await create_folder.ainvoke(dict(bad, name="X")) ) assert result.success is False assert result.error is not None and "token" in result.error -@pytest.mark.asyncio -async def test_search_files(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{DRIVE}/files\?.*"), - json={"files": [{"id": "f1", "name": "doc.txt", "mimeType": "text/plain"}]}, - ) - result = SearchFilesOutput.model_validate( - await search_files.ainvoke(_args(query="doc")) - ) - assert result.success is True - assert result.total == 1 - - -@pytest.mark.asyncio -async def test_list_folder_splits_folders_and_files(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{DRIVE}/files\?.*"), - json={ - "files": [ - {"id": "F1", "mimeType": "application/vnd.google-apps.folder"}, - {"id": "f1", "mimeType": "text/plain"}, - ] - }, - ) - result = ListFolderOutput.model_validate( - await list_folder.ainvoke(_args()) - ) - assert result.success is True - assert len(result.folders) == 1 - assert len(result.files) == 1 - - -@pytest.mark.asyncio -async def test_read_file_text(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/f1?fields=id%2Cname%2CmimeType%2Csize%2CwebViewLink", - json={"id": "f1", "name": "doc.txt", "mimeType": "text/plain"}, - ) - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/f1?alt=media", - text="hello world", - ) - result = ReadFileOutput.model_validate( - await read_file.ainvoke(_args(file_id="f1")) - ) - assert result.success is True - assert result.content == "hello world" - - -@pytest.mark.asyncio -async def test_read_file_google_doc_exports_text(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/d1?fields=id%2Cname%2CmimeType%2Csize%2CwebViewLink", - json={ - "id": "d1", - "name": "My Doc", - "mimeType": "application/vnd.google-apps.document", - }, - ) - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/d1/export?mimeType=text%2Fplain", - text="document body", - ) - result = ReadFileOutput.model_validate( - await read_file.ainvoke(_args(file_id="d1")) - ) - assert result.success is True - assert result.content == "document body" - - -@pytest.mark.asyncio -async def test_read_file_binary_returns_link(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/b1?fields=id%2Cname%2CmimeType%2Csize%2CwebViewLink", - json={ - "id": "b1", - "name": "image.png", - "mimeType": "image/png", - "webViewLink": "https://drive.google.com/file/d/b1/view", - }, - ) - result = ReadFileOutput.model_validate( - await read_file.ainvoke(_args(file_id="b1")) - ) - assert result.success is True - assert result.message is not None and "web_view_link" in result.message - - @pytest.mark.asyncio async def test_create_text_file_rejects_bad_extension() -> None: result = CreateTextFileOutput.model_validate( @@ -296,84 +185,6 @@ async def test_create_folder(httpx_mock: Any) -> None: assert result.success is True -@pytest.mark.asyncio -async def test_delete_item(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="DELETE", - url=re.compile(rf"{DRIVE}/files/F1\?.*"), - status_code=204, - ) - result = DeleteItemOutput.model_validate( - await delete_item.ainvoke(_args(item_id="F1")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_rename_item(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="PATCH", - url=re.compile(rf"{DRIVE}/files/F1\?.*"), - json={"id": "F1", "name": "Renamed"}, - ) - result = RenameItemOutput.model_validate( - await rename_item.ainvoke(_args(item_id="F1", new_name="Renamed")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_move_item_reads_parents_first(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{DRIVE}/files/F1?fields=parents", - json={"parents": ["P1", "P2"]}, - ) - httpx_mock.add_response( - method="PATCH", - url=re.compile(rf"{DRIVE}/files/F1\?.*"), - json={"id": "F1", "name": "X"}, - ) - result = MoveItemOutput.model_validate( - await move_item.ainvoke(_args(item_id="F1", destination_folder_id="Dest")) - ) - assert result.success is True - assert result.new_parent == "Dest" - - -@pytest.mark.asyncio -async def test_copy_file(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=re.compile(rf"{DRIVE}/files/F1/copy\?.*"), - status_code=201, - json={"id": "F_copy", "name": "Copy"}, - ) - result = CopyFileOutput.model_validate( - await copy_file.ainvoke(_args(file_id="F1", new_name="Copy")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_get_file_metadata(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{DRIVE}/files/F1\?.*"), - json={ - "id": "F1", - "name": "X", - "mimeType": "application/pdf", - "size": "1024", - }, - ) - result = GetFileMetadataOutput.model_validate( - await get_file_metadata.ainvoke(_args(file_id="F1")) - ) - assert result.success is True - assert result.size == "1024" - - @pytest.mark.asyncio async def test_create_google_doc_with_content(httpx_mock: Any) -> None: httpx_mock.add_response( diff --git a/src/modulex_integrations/tools/google_drive/tools.py b/src/modulex_integrations/tools/google_drive/tools.py index e3abbe6..f36acdb 100644 --- a/src/modulex_integrations/tools/google_drive/tools.py +++ b/src/modulex_integrations/tools/google_drive/tools.py @@ -4,7 +4,7 @@ Slides v1). Token-based runtime convention with paired ``oauth2 + bearer_token``. -24 actions. Key shapes preserved verbatim from legacy: +16 actions. Key shapes preserved verbatim from legacy: - **`create_text_file`** uses a manual multipart/related upload (Google Drive's media-upload pattern). Pre-formats the JSON @@ -16,8 +16,6 @@ - **`read_google_sheet` / `update_google_sheet`** resolve localized sheet names (e.g. `Sayfa1` for Turkish) via `_get_first_sheet_name` before composing the A1 range. -- **`move_item`** reads current parents first, then PATCHes with - `addParents`/`removeParents` query params (Drive API quirk). - **`format_sheet_*`** convert A1 notation to `GridRange` for the Sheets batchUpdate API. @@ -37,24 +35,16 @@ from modulex_integrations.tools.google_drive.outputs import ( AddSlideOutput, AppendToGoogleDocOutput, - CopyFileOutput, CreateFolderOutput, CreateGoogleDocOutput, CreateGoogleSheetOutput, CreateGoogleSlidesOutput, CreateTextFileOutput, - DeleteItemOutput, FormatSheetCellsOutput, FormatSheetTextOutput, - GetFileMetadataOutput, - ListFolderOutput, - MoveItemOutput, - ReadFileOutput, ReadGoogleDocOutput, ReadGoogleSheetOutput, ReadGoogleSlidesOutput, - RenameItemOutput, - SearchFilesOutput, UpdateGoogleDocOutput, UpdateGoogleSheetOutput, UpdateSlideContentOutput, @@ -64,24 +54,16 @@ __all__ = [ "add_slide", "append_to_google_doc", - "copy_file", "create_folder", "create_google_doc", "create_google_sheet", "create_google_slides", "create_text_file", - "delete_item", "format_sheet_cells", "format_sheet_text", - "get_file_metadata", - "list_folder", - "move_item", - "read_file", "read_google_doc", "read_google_sheet", "read_google_slides", - "rename_item", - "search_files", "update_google_doc", "update_google_sheet", "update_slide_content", @@ -167,22 +149,6 @@ class _AuthFields(BaseModel): auth_data: dict[str, Any] = Field(description="Auth data with access_token") -class SearchFilesInput(_AuthFields): - query: str - page_size: int = 10 - page_token: str | None = None - - -class ListFolderInput(_AuthFields): - folder_id: str = "root" - page_size: int = 20 - page_token: str | None = None - - -class ReadFileInput(_AuthFields): - file_id: str - - class CreateTextFileInput(_AuthFields): name: str content: str @@ -200,30 +166,6 @@ class CreateFolderInput(_AuthFields): parent_folder_id: str | None = None -class DeleteItemInput(_AuthFields): - item_id: str - - -class RenameItemInput(_AuthFields): - item_id: str - new_name: str - - -class MoveItemInput(_AuthFields): - item_id: str - destination_folder_id: str = "root" - - -class CopyFileInput(_AuthFields): - file_id: str - new_name: str | None = None - destination_folder_id: str | None = None - - -class GetFileMetadataInput(_AuthFields): - file_id: str - - class CreateGoogleDocInput(_AuthFields): name: str content: str = "" @@ -300,207 +242,9 @@ class UpdateSlideContentInput(_AuthFields): text: str -# --- Drive helpers -------------------------------------------------------- - - -def _file_summary(f: dict[str, Any]) -> dict[str, Any]: - return { - "id": f.get("id"), - "name": f.get("name"), - "mime_type": f.get("mimeType"), - "created_time": f.get("createdTime"), - "modified_time": f.get("modifiedTime"), - "size": f.get("size"), - "web_view_link": f.get("webViewLink"), - "parents": f.get("parents"), - } - - # --- Drive tools ----------------------------------------------------------- -@tool(args_schema=SearchFilesInput) -@serialize_pydantic_return -async def search_files( - auth_type: str, - auth_data: dict[str, Any], - query: str, - page_size: int = 10, - page_token: str | None = None, -) -> SearchFilesOutput: - """Search Drive files by name substring.""" - err = _validate(auth_data, "search_files") - if err: - return SearchFilesOutput(success=False, error=err) - params: dict[str, Any] = { - "q": f"name contains '{query}' and trashed = false", - "pageSize": min(page_size, 100), - "fields": ( - "files(id,name,mimeType,createdTime,modifiedTime,size," - "webViewLink,parents),nextPageToken" - ), - "spaces": "drive", - "includeItemsFromAllDrives": "true", - "supportsAllDrives": "true", - } - if page_token: - params["pageToken"] = page_token - ok, e, data = await _call( - "GET", f"{_DRIVE}/files", auth_type, auth_data, params=params - ) - if not ok: - return SearchFilesOutput(success=False, error=e) - files = [_file_summary(f) for f in data.get("files") or []] - return SearchFilesOutput( - success=True, - files=files, - total=len(files), - next_page_token=data.get("nextPageToken"), - ) - - -@tool(args_schema=ListFolderInput) -@serialize_pydantic_return -async def list_folder( - auth_type: str, - auth_data: dict[str, Any], - folder_id: str = "root", - page_size: int = 20, - page_token: str | None = None, -) -> ListFolderOutput: - """List a folder's contents.""" - err = _validate(auth_data, "list_folder") - if err: - return ListFolderOutput(success=False, error=err) - params: dict[str, Any] = { - "q": f"'{folder_id}' in parents and trashed = false", - "pageSize": min(page_size, 100), - "fields": "files(id,name,mimeType,createdTime,modifiedTime,size,webViewLink),nextPageToken", - "spaces": "drive", - "includeItemsFromAllDrives": "true", - "supportsAllDrives": "true", - } - if page_token: - params["pageToken"] = page_token - ok, e, data = await _call( - "GET", f"{_DRIVE}/files", auth_type, auth_data, params=params - ) - if not ok: - return ListFolderOutput(success=False, error=e) - files = data.get("files") or [] - folders_out = [ - { - "id": f.get("id"), - "name": f.get("name"), - "modified_time": f.get("modifiedTime"), - } - for f in files - if f.get("mimeType") == _FOLDER_MIME - ] - files_out = [ - { - "id": f.get("id"), - "name": f.get("name"), - "mime_type": f.get("mimeType"), - "modified_time": f.get("modifiedTime"), - "size": f.get("size"), - "web_view_link": f.get("webViewLink"), - } - for f in files - if f.get("mimeType") != _FOLDER_MIME - ] - return ListFolderOutput( - success=True, - folder_id=folder_id, - folders=folders_out, - files=files_out, - total=len(files), - next_page_token=data.get("nextPageToken"), - ) - - -async def _read_text_response( - client: httpx.AsyncClient, url: str, headers: dict[str, str], params: dict[str, Any] -) -> tuple[bool, str | None, str]: - response = await client.get(url, headers=headers, params=params, timeout=_LONG_TIMEOUT) - if response.status_code != 200: - return False, _api_err("Failed to read", response), "" - return True, None, response.text - - -@tool(args_schema=ReadFileInput) -@serialize_pydantic_return -async def read_file( - auth_type: str, auth_data: dict[str, Any], file_id: str -) -> ReadFileOutput: - """Read file content (exports Docs as plain text).""" - err = _validate(auth_data, "read_file") - if err: - return ReadFileOutput(success=False, error=err) - headers = _headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - meta = await client.get( - f"{_DRIVE}/files/{file_id}", - headers=headers, - params={"fields": "id,name,mimeType,size,webViewLink"}, - ) - if meta.status_code != 200: - return ReadFileOutput( - success=False, - error=f"Failed to get file metadata: {meta.status_code}", - ) - metadata = meta.json() or {} - mime_type = metadata.get("mimeType", "") - if mime_type == _GDOC_MIME: - ok, e, text = await _read_text_response( - client, - f"{_DRIVE}/files/{file_id}/export", - headers, - {"mimeType": "text/plain"}, - ) - if not ok: - return ReadFileOutput( - success=False, error=f"Failed to export document: {e}" - ) - return ReadFileOutput( - success=True, - id=file_id, - name=metadata.get("name"), - mime_type=mime_type, - content=text, - ) - if mime_type.startswith("text/"): - ok, e, text = await _read_text_response( - client, - f"{_DRIVE}/files/{file_id}", - headers, - {"alt": "media"}, - ) - if not ok: - return ReadFileOutput( - success=False, error=f"Failed to download file: {e}" - ) - return ReadFileOutput( - success=True, - id=file_id, - name=metadata.get("name"), - mime_type=mime_type, - content=text, - ) - return ReadFileOutput( - success=True, - id=file_id, - name=metadata.get("name"), - mime_type=mime_type, - size=metadata.get("size"), - web_view_link=metadata.get("webViewLink"), - message="Binary file - use web_view_link to access", - ) - except Exception as exc: - return ReadFileOutput(success=False, error=str(exc)) - - @tool(args_schema=CreateTextFileInput) @serialize_pydantic_return async def create_text_file( @@ -667,194 +411,6 @@ async def create_folder( ) -@tool(args_schema=DeleteItemInput) -@serialize_pydantic_return -async def delete_item( - auth_type: str, auth_data: dict[str, Any], item_id: str -) -> DeleteItemOutput: - """Permanently delete a file or folder.""" - err = _validate(auth_data, "delete_item") - if err: - return DeleteItemOutput(success=False, error=err) - ok, e, _ = await _call( - "DELETE", - f"{_DRIVE}/files/{item_id}", - auth_type, - auth_data, - params={"supportsAllDrives": "true"}, - success_codes=(204,), - ) - if not ok: - return DeleteItemOutput(success=False, error=e) - return DeleteItemOutput( - success=True, deleted_id=item_id, message="Item deleted successfully" - ) - - -@tool(args_schema=RenameItemInput) -@serialize_pydantic_return -async def rename_item( - auth_type: str, - auth_data: dict[str, Any], - item_id: str, - new_name: str, -) -> RenameItemOutput: - """Rename a file or folder.""" - err = _validate(auth_data, "rename_item") - if err: - return RenameItemOutput(success=False, error=err) - ok, e, data = await _call( - "PATCH", - f"{_DRIVE}/files/{item_id}", - auth_type, - auth_data, - json_body={"name": new_name}, - params={ - "fields": "id,name,mimeType,webViewLink,modifiedTime", - "supportsAllDrives": "true", - }, - ) - if not ok: - return RenameItemOutput(success=False, error=e) - return RenameItemOutput( - success=True, - id=data.get("id"), - name=data.get("name"), - mime_type=data.get("mimeType"), - modified_time=data.get("modifiedTime"), - ) - - -@tool(args_schema=MoveItemInput) -@serialize_pydantic_return -async def move_item( - auth_type: str, - auth_data: dict[str, Any], - item_id: str, - destination_folder_id: str = "root", -) -> MoveItemOutput: - """Move a file/folder to a destination (reads current parents first).""" - err = _validate(auth_data, "move_item") - if err: - return MoveItemOutput(success=False, error=err) - ok, e, meta = await _call( - "GET", - f"{_DRIVE}/files/{item_id}", - auth_type, - auth_data, - params={"fields": "parents"}, - ) - if not ok: - return MoveItemOutput( - success=False, error=f"Failed to get item parents: {e}" - ) - current_parents = meta.get("parents") or [] - remove_parents = ",".join(current_parents) - ok, e, data = await _call( - "PATCH", - f"{_DRIVE}/files/{item_id}", - auth_type, - auth_data, - params={ - "addParents": destination_folder_id, - "removeParents": remove_parents, - "fields": "id,name,mimeType,parents,webViewLink", - "supportsAllDrives": "true", - }, - ) - if not ok: - return MoveItemOutput(success=False, error=e) - return MoveItemOutput( - success=True, - id=data.get("id"), - name=data.get("name"), - new_parent=destination_folder_id, - web_view_link=data.get("webViewLink"), - ) - - -@tool(args_schema=CopyFileInput) -@serialize_pydantic_return -async def copy_file( - auth_type: str, - auth_data: dict[str, Any], - file_id: str, - new_name: str | None = None, - destination_folder_id: str | None = None, -) -> CopyFileOutput: - """Copy a file.""" - err = _validate(auth_data, "copy_file") - if err: - return CopyFileOutput(success=False, error=err) - body: dict[str, Any] = {} - if new_name: - body["name"] = new_name - if destination_folder_id: - body["parents"] = [destination_folder_id] - ok, e, data = await _call( - "POST", - f"{_DRIVE}/files/{file_id}/copy", - auth_type, - auth_data, - json_body=body if body else None, - params={ - "fields": "id,name,mimeType,webViewLink", - "supportsAllDrives": "true", - }, - success_codes=(200, 201), - timeout=_LONG_TIMEOUT, - ) - if not ok: - return CopyFileOutput(success=False, error=e) - return CopyFileOutput( - success=True, - id=data.get("id"), - name=data.get("name"), - mime_type=data.get("mimeType"), - web_view_link=data.get("webViewLink"), - ) - - -@tool(args_schema=GetFileMetadataInput) -@serialize_pydantic_return -async def get_file_metadata( - auth_type: str, auth_data: dict[str, Any], file_id: str -) -> GetFileMetadataOutput: - """Get full metadata for a file/folder.""" - err = _validate(auth_data, "get_file_metadata") - if err: - return GetFileMetadataOutput(success=False, error=err) - ok, e, data = await _call( - "GET", - f"{_DRIVE}/files/{file_id}", - auth_type, - auth_data, - params={ - "fields": ( - "id,name,mimeType,createdTime,modifiedTime,size," - "webViewLink,webContentLink,parents,shared,owners,permissions" - ), - "supportsAllDrives": "true", - }, - ) - if not ok: - return GetFileMetadataOutput(success=False, error=e) - return GetFileMetadataOutput( - success=True, - id=data.get("id"), - name=data.get("name"), - mime_type=data.get("mimeType"), - created_time=data.get("createdTime"), - modified_time=data.get("modifiedTime"), - size=data.get("size"), - web_view_link=data.get("webViewLink"), - web_content_link=data.get("webContentLink"), - parents=data.get("parents"), - shared=data.get("shared"), - owners=data.get("owners"), - ) - - # --- Docs tools ----------------------------------------------------------- diff --git a/src/modulex_integrations/tools/google_sheets/README.md b/src/modulex_integrations/tools/google_sheets/README.md index 3e2362b..19654ab 100644 --- a/src/modulex_integrations/tools/google_sheets/README.md +++ b/src/modulex_integrations/tools/google_sheets/README.md @@ -1,8 +1,7 @@ # Google Sheets Read, write, and manage Google Sheets spreadsheets and worksheets via the Google -Sheets API v4 (`https://sheets.googleapis.com/v4`) and the Drive API v3 -(`https://www.googleapis.com/drive/v3`) for spreadsheet listing. +Sheets API v4 (`https://sheets.googleapis.com/v4`). ## Authentication @@ -11,21 +10,20 @@ Sheets API v4 (`https://sheets.googleapis.com/v4`) and the Drive API v3 - Create an OAuth client in the [Google Cloud Console — Credentials](https://console.cloud.google.com/apis/credentials). - Add the redirect URI `https://api.modulex.dev/credentials/oauth2/callback` to the OAuth client's authorized URIs. -- Enable both the **Google Sheets API** and the **Google Drive API** for the - project (the Drive scope is required for `list_spreadsheets`). +- Enable the **Google Sheets API** for the project. The **Google Drive API** + must also be enabled so the OAuth token can be validated against the Drive + `about` endpoint with the `drive.file` scope. - Required env vars: - `GOOGLE_SHEETS_OAUTH2_CLIENT_ID` (format: `.apps.googleusercontent.com`) - `GOOGLE_SHEETS_OAUTH2_CLIENT_SECRET` (format: `GOCSPX-...`) - Scopes requested: - `https://www.googleapis.com/auth/spreadsheets` - `https://www.googleapis.com/auth/drive.file` - - `https://www.googleapis.com/auth/drive.readonly` ## Tools | name | description | required params | | --- | --- | --- | -| `list_spreadsheets` | List Google Spreadsheets accessible to the authenticated user (search by name optional). | _none_ | | `new_spreadsheet` | Create a new spreadsheet, optionally with a first-worksheet name and header row. | `title` | | `get_spreadsheet_info` | Inspect a spreadsheet — worksheet names, sheet IDs, row counts, and headers. | `spreadsheet_id` | | `list_worksheets` | List all worksheets (tabs) in a spreadsheet. | `spreadsheet_id` | @@ -47,7 +45,6 @@ fills in from the user's stored OAuth credential. - Google Sheets API default quota: 300 read requests / minute / project and 300 write requests / minute / project (with a 60 / minute / user cap for both). -- Google Drive API default quota: 12,000 queries / minute / user. - Cell-value writes use `valueInputOption=USER_ENTERED`, so values are parsed as if a user typed them in the UI (numbers/dates/booleans get coerced). - `add_rows` uses `insertDataOption=INSERT_ROWS`, so it never overwrites diff --git a/src/modulex_integrations/tools/google_sheets/__init__.py b/src/modulex_integrations/tools/google_sheets/__init__.py index 247de03..5da0d27 100644 --- a/src/modulex_integrations/tools/google_sheets/__init__.py +++ b/src/modulex_integrations/tools/google_sheets/__init__.py @@ -9,7 +9,6 @@ find_rows, get_spreadsheet_info, get_values_in_range, - list_spreadsheets, list_worksheets, new_spreadsheet, read_rows, @@ -18,7 +17,6 @@ ) TOOLS = ( - list_spreadsheets, new_spreadsheet, get_spreadsheet_info, list_worksheets, @@ -44,7 +42,6 @@ "find_rows", "get_spreadsheet_info", "get_values_in_range", - "list_spreadsheets", "list_worksheets", "manifest", "new_spreadsheet", diff --git a/src/modulex_integrations/tools/google_sheets/manifest.py b/src/modulex_integrations/tools/google_sheets/manifest.py index 24f7ca7..77c5ac2 100644 --- a/src/modulex_integrations/tools/google_sheets/manifest.py +++ b/src/modulex_integrations/tools/google_sheets/manifest.py @@ -25,27 +25,6 @@ app_url="https://www.google.com/sheets/about/", categories=["Productivity & Collaboration", "Data & Analytics"], actions=[ - ActionDefinition( - name="list_spreadsheets", - description=( - "List Google Spreadsheets accessible to the authenticated user. " - "Optionally search by name. Returns spreadsheet IDs that can be " - "used with all other tools." - ), - parameters={ - "query": ParameterDef( - type="string", - description="Search spreadsheets by name. Leave empty to list all.", - required=False, - ), - "limit": ParameterDef( - type="integer", - description="Maximum number of spreadsheets to return.", - default=20, - required=False, - ), - }, - ), ActionDefinition( name="new_spreadsheet", description=( @@ -452,7 +431,6 @@ scopes=[ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", ], token_auth_method="body", ), @@ -467,8 +445,7 @@ cost_level="free", description=( "Validates the OAuth token via the Drive about endpoint " - "(Sheets are stored in Drive, so a Sheets-scoped token " - "with drive.readonly can read this)." + "(reachable with the drive.file scope)." ), ), ), diff --git a/src/modulex_integrations/tools/google_sheets/outputs.py b/src/modulex_integrations/tools/google_sheets/outputs.py index 11730db..102ca61 100644 --- a/src/modulex_integrations/tools/google_sheets/outputs.py +++ b/src/modulex_integrations/tools/google_sheets/outputs.py @@ -14,11 +14,9 @@ "FindRowsOutput", "GetSpreadsheetInfoOutput", "GetValuesInRangeOutput", - "ListSpreadsheetsOutput", "ListWorksheetsOutput", "NewSpreadsheetOutput", "ReadRowsOutput", - "SpreadsheetSummary", "UpdateCellOutput", "UpdateRowOutput", "WorksheetInfo", @@ -35,14 +33,6 @@ class _Base(BaseModel): # --- Nested resource models ------------------------------------------------- -class SpreadsheetSummary(_Base): - """A lightweight summary of a Google Sheets spreadsheet.""" - - spreadsheet_id: str | None = None - name: str | None = None - url: str | None = None - - class WorksheetSummary(_Base): """A worksheet (tab) summary as returned by list/info actions.""" @@ -66,13 +56,6 @@ class WorksheetInfo(_Base): # --- Per-action output models ---------------------------------------------- -class ListSpreadsheetsOutput(_Base): - success: bool - error: str | None = None - spreadsheets: list[SpreadsheetSummary] | None = None - count: int | None = None - - class NewSpreadsheetOutput(_Base): success: bool error: str | None = None diff --git a/src/modulex_integrations/tools/google_sheets/tests/test_google_sheets.py b/src/modulex_integrations/tools/google_sheets/tests/test_google_sheets.py index 6cf9b32..26eb3d1 100644 --- a/src/modulex_integrations/tools/google_sheets/tests/test_google_sheets.py +++ b/src/modulex_integrations/tools/google_sheets/tests/test_google_sheets.py @@ -15,7 +15,6 @@ find_rows, get_spreadsheet_info, get_values_in_range, - list_spreadsheets, list_worksheets, manifest, new_spreadsheet, @@ -32,7 +31,6 @@ FindRowsOutput, GetSpreadsheetInfoOutput, GetValuesInRangeOutput, - ListSpreadsheetsOutput, ListWorksheetsOutput, NewSpreadsheetOutput, ReadRowsOutput, @@ -41,7 +39,6 @@ ) SHEETS_API = "https://sheets.googleapis.com/v4" -DRIVE_API = "https://www.googleapis.com/drive/v3" _AUTH: dict[str, Any] = { "auth_type": "oauth2", @@ -57,8 +54,8 @@ def _args(**extra: Any) -> dict[str, Any]: class TestManifest: - def test_manifest_exposes_14_actions(self) -> None: - assert len(manifest.actions) == 14 + def test_manifest_exposes_13_actions(self) -> None: + assert len(manifest.actions) == 13 def test_manifest_actions_match_tools_tuple(self) -> None: assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} @@ -70,39 +67,6 @@ def test_manifest_has_oauth2_auth(self) -> None: # --- Per-action happy-path tests ------------------------------------------- -@pytest.mark.asyncio -async def test_list_spreadsheets(httpx_mock): # type: ignore[no-untyped-def] - # TODO: replace with a real Drive Files response from - # https://developers.google.com/drive/api/reference/rest/v3/files/list - httpx_mock.add_response( - method="GET", - url=( - f"{DRIVE_API}/files" - "?q=mimeType%3D%27application%2Fvnd.google-apps.spreadsheet%27" - "&pageSize=20" - "&fields=files%28id%2Cname%29" - ), - json={ - "files": [ - {"id": "spreadsheet-id-1", "name": "Budget"}, - {"id": "spreadsheet-id-2", "name": "Customers"}, - ], - }, - ) - - result_dict = await list_spreadsheets.ainvoke(_args()) - - assert isinstance(result_dict, dict) - result = ListSpreadsheetsOutput.model_validate(result_dict) - assert result.success is True - assert result.count == 2 - assert result.spreadsheets is not None - assert result.spreadsheets[0].name == "Budget" - assert result.spreadsheets[0].url == ( - "https://docs.google.com/spreadsheets/d/spreadsheet-id-1/edit" - ) - - @pytest.mark.asyncio async def test_new_spreadsheet(httpx_mock): # type: ignore[no-untyped-def] # TODO: replace with a real Sheets create response from @@ -478,12 +442,12 @@ async def test_delete_rows(httpx_mock): # type: ignore[no-untyped-def] @pytest.mark.asyncio -async def test_list_spreadsheets_empty_credentials(httpx_mock): # type: ignore[no-untyped-def] +async def test_new_spreadsheet_empty_credentials(httpx_mock): # type: ignore[no-untyped-def] """Verify that empty credentials return a clear error without hitting the wire.""" - result_dict = await list_spreadsheets.ainvoke( - _args(**{"auth_type": "oauth2", "auth_data": {}}), + result_dict = await new_spreadsheet.ainvoke( + _args(**{"auth_type": "oauth2", "auth_data": {}, "title": "My Sheet"}), ) assert isinstance(result_dict, dict) - result = ListSpreadsheetsOutput.model_validate(result_dict) + result = NewSpreadsheetOutput.model_validate(result_dict) assert result.success is False assert result.error is not None diff --git a/src/modulex_integrations/tools/google_sheets/tools.py b/src/modulex_integrations/tools/google_sheets/tools.py index 7ce3d23..cf38810 100644 --- a/src/modulex_integrations/tools/google_sheets/tools.py +++ b/src/modulex_integrations/tools/google_sheets/tools.py @@ -19,11 +19,9 @@ FindRowsOutput, GetSpreadsheetInfoOutput, GetValuesInRangeOutput, - ListSpreadsheetsOutput, ListWorksheetsOutput, NewSpreadsheetOutput, ReadRowsOutput, - SpreadsheetSummary, UpdateCellOutput, UpdateRowOutput, WorksheetInfo, @@ -39,7 +37,6 @@ "find_rows", "get_spreadsheet_info", "get_values_in_range", - "list_spreadsheets", "list_worksheets", "new_spreadsheet", "read_rows", @@ -48,7 +45,6 @@ ] _SHEETS_BASE = "https://sheets.googleapis.com/v4" -_DRIVE_BASE = "https://www.googleapis.com/drive/v3" _TIMEOUT = 30.0 @@ -118,16 +114,6 @@ def _rows_to_objects(headers: list[Any], data_rows: list[list[Any]]) -> list[dic # --- Input schemas --------------------------------------------------------- -class ListSpreadsheetsInput(BaseModel): - auth_type: str = Field(description="Authentication type (oauth2)") - auth_data: dict[str, Any] = Field(description="Authentication data containing tokens") - query: str | None = Field( - default=None, - description="Search spreadsheets by name. Leave empty to list all.", - ) - limit: int = Field(default=20, description="Maximum number of spreadsheets to return.") - - class NewSpreadsheetInput(BaseModel): auth_type: str = Field(description="Authentication type (oauth2)") auth_data: dict[str, Any] = Field(description="Authentication data containing tokens") @@ -273,58 +259,6 @@ class DeleteRowsInput(BaseModel): # --- @tool functions ------------------------------------------------------ -@tool(args_schema=ListSpreadsheetsInput) -@serialize_pydantic_return -async def list_spreadsheets( - auth_type: str, - auth_data: dict[str, Any], - query: str | None = None, - limit: int = 20, -) -> ListSpreadsheetsOutput: - """List Google Spreadsheets accessible to the authenticated user.""" - if not auth_data.get("access_token"): - return ListSpreadsheetsOutput(success=False, error="Missing or empty OAuth access token.") - headers = _get_auth_headers(auth_type, auth_data) - q_parts = ["mimeType='application/vnd.google-apps.spreadsheet'"] - if query: - escaped = query.replace("'", "\\'") - q_parts.append(f"name contains '{escaped}'") - params: dict[str, Any] = { - "q": " and ".join(q_parts), - "pageSize": max(1, min(int(limit or 20), 1000)), - "fields": "files(id,name)", - } - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.get( - f"{_DRIVE_BASE}/files", - headers=headers, - params=params, - ) - if response.status_code != 200: - return ListSpreadsheetsOutput(success=False, error=_format_http_error(response)) - data = response.json() - except httpx.TimeoutException: - return ListSpreadsheetsOutput(success=False, error="Request timed out.") - except Exception as exc: - return ListSpreadsheetsOutput(success=False, error=f"Call failed: {exc}") - - files = data.get("files", []) or [] - summaries = [ - SpreadsheetSummary( - spreadsheet_id=f.get("id"), - name=f.get("name"), - url=f"https://docs.google.com/spreadsheets/d/{f.get('id')}/edit", - ) - for f in files - ] - return ListSpreadsheetsOutput( - success=True, - spreadsheets=summaries, - count=len(summaries), - ) - - @tool(args_schema=NewSpreadsheetInput) @serialize_pydantic_return async def new_spreadsheet( diff --git a/src/modulex_integrations/tools/google_slides/README.md b/src/modulex_integrations/tools/google_slides/README.md index b13fd21..76702bd 100644 --- a/src/modulex_integrations/tools/google_slides/README.md +++ b/src/modulex_integrations/tools/google_slides/README.md @@ -1,20 +1,19 @@ # Google Slides -Create and edit Google Slides presentations from agents — manage slides, shapes, images, tables, and text via the Google Slides REST API (`slides.googleapis.com/v1`), with presentation duplication and discovery powered by the Google Drive REST API (`www.googleapis.com/drive/v3`). +Create and edit Google Slides presentations from agents — manage slides, shapes, images, tables, and text via the Google Slides REST API (`slides.googleapis.com/v1`). ## Authentication -This integration supports a single auth method. Token validation hits Drive `GET /about?fields=user`. +This integration supports a single auth method. Token validation hits Slides `GET /v1/presentations/1` (a 200 or 404 both confirm the token is accepted). ### OAuth2 Authentication - Create an OAuth 2.0 Client ID in the [Google Cloud Console Credentials page](https://console.cloud.google.com/apis/credentials). - Register `https://api.modulex.dev/credentials/oauth2/callback` as an authorized redirect URI on the OAuth client. -- Enable the Google Slides API and Google Drive API on the same Google Cloud project (APIs & Services -> Library). +- Enable the Google Slides API on the Google Cloud project (APIs & Services -> Library). - Required env vars: `GOOGLE_SLIDES_OAUTH2_CLIENT_ID`, `GOOGLE_SLIDES_OAUTH2_CLIENT_SECRET`. - Scopes requested: - `https://www.googleapis.com/auth/presentations` (read/write Slides) - - `https://www.googleapis.com/auth/drive` (copy template files, look up file metadata) ## Tools @@ -22,7 +21,7 @@ This integration supports a single auth method. Token validation hits Drive `GET | --- | --- | --- | | `create_image` | Insert an image (by URL) onto a slide in a presentation. | `presentation_id`, `slide_id`, `url`, `height`, `width` | | `create_page_element` | Insert a new shape page element (text box, rectangle, ellipse, arrow, etc.) onto a slide. | `presentation_id`, `slide_id`, `type`, `height`, `width` | -| `create_presentation` | Create a blank Google Slides presentation, or duplicate an existing one when `source_presentation_id` is supplied. | `title` | +| `create_presentation` | Create a blank Google Slides presentation. | `title` | | `create_slide` | Create a new slide in a presentation, optionally based on a specific layout. | `presentation_id`, `layout_id` | | `create_table` | Create a new table on a slide with the given rows and columns. | `presentation_id`, `slide_id`, `rows`, `columns`, `height`, `width` | | `delete_page_element` | Delete a page element (shape, image, table, etc.) from a slide. | `presentation_id`, `page_element_id` | @@ -34,16 +33,14 @@ This integration supports a single auth method. Token validation hits Drive `GET | `insert_table_rows` | Insert new rows into an existing table on a slide (max 20 per request). | `presentation_id`, `table_id` | | `insert_text` | Insert text into a shape (typically a `TEXT_BOX`) on a slide. | `presentation_id`, `shape_id`, `text` | | `insert_text_into_table` | Insert text into a specific cell of a table on a slide. | `presentation_id`, `table_id`, `text` | -| `merge_data` | Duplicate a template presentation and merge data into it by replacing placeholders with text and/or images. | `source_presentation_id`, `title`, `placeholders_and_texts` | -| `refresh_chart` | Refresh every embedded Sheets chart in a presentation. | `presentation_id` | | `replace_all_text` | Replace every occurrence of a given text snippet inside a presentation, optionally restricted to specific slide pages. | `presentation_id`, `text`, `replace_text` | Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. ## Limits & Quotas -- **Per-project quotas**: Google Slides API and Google Drive API default to a few hundred read or write requests per 100 seconds per project; per-user limits also apply (see the [Slides API quotas page](https://developers.google.com/workspace/slides/api/limits)). Quotas are managed in the Google Cloud Console (APIs & Services -> Quotas). -- **batchUpdate body**: Slides `batchUpdate` requests should stay under ~10 MB; for very large merges, batch the work into multiple calls. +- **Per-project quotas**: Google Slides API defaults to a few hundred read or write requests per 100 seconds per project; per-user limits also apply (see the [Slides API quotas page](https://developers.google.com/workspace/slides/api/limits)). Quotas are managed in the Google Cloud Console (APIs & Services -> Quotas). +- **batchUpdate body**: Slides `batchUpdate` requests should stay under ~10 MB; for very large updates, batch the work into multiple calls. - **Insert table rows/columns**: capped at 20 per single request, per Google's documented `InsertTableRowsRequest` / `InsertTableColumnsRequest` limits. - **Error model**: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. Plan for retries on the agent side based on the error string. OAuth tokens that have expired surface as HTTP 401 in the `error` field. diff --git a/src/modulex_integrations/tools/google_slides/__init__.py b/src/modulex_integrations/tools/google_slides/__init__.py index b3d4979..4ca7c84 100644 --- a/src/modulex_integrations/tools/google_slides/__init__.py +++ b/src/modulex_integrations/tools/google_slides/__init__.py @@ -15,8 +15,6 @@ insert_table_rows, insert_text, insert_text_into_table, - merge_data, - refresh_chart, replace_all_text, ) @@ -35,8 +33,6 @@ insert_table_rows, insert_text, insert_text_into_table, - merge_data, - refresh_chart, replace_all_text, ) @@ -57,7 +53,5 @@ "insert_text", "insert_text_into_table", "manifest", - "merge_data", - "refresh_chart", "replace_all_text", ] diff --git a/src/modulex_integrations/tools/google_slides/manifest.py b/src/modulex_integrations/tools/google_slides/manifest.py index 62984db..f99be26 100644 --- a/src/modulex_integrations/tools/google_slides/manifest.py +++ b/src/modulex_integrations/tools/google_slides/manifest.py @@ -20,8 +20,7 @@ display_name="Google Slides", description=( "Create and edit Google Slides presentations — manage slides, shapes, " - "images, tables, and text via the Google Slides REST API; copy or " - "discover presentations via the Google Drive REST API." + "images, tables, and text via the Google Slides REST API." ), version="1.0.0", author="ModuleX", @@ -183,10 +182,7 @@ ActionDefinition( name="create_presentation", description=( - "Create a blank Google Slides presentation, or duplicate an " - "existing one when source_presentation_id is supplied. " - "Blank creation uses the Slides API; duplication uses Drive " - "files.copy." + "Create a blank Google Slides presentation via the Slides API." ), parameters={ "title": ParameterDef( @@ -194,15 +190,6 @@ description="Title of the new presentation.", required=True, ), - "source_presentation_id": ParameterDef( - type="string", - description=( - "Optional ID of an existing presentation to copy. " - "When omitted, a blank presentation is created." - ), - required=False, - default=None, - ), }, ), ActionDefinition( @@ -584,72 +571,6 @@ ), }, ), - ActionDefinition( - name="merge_data", - description=( - "Duplicate a template presentation and merge data into it by " - "replacing placeholders ('{{key}}') with text and/or images. " - "Uses Drive files.copy + Slides batchUpdate with " - "replaceAllText and replaceAllShapesWithImage requests." - ), - parameters={ - "source_presentation_id": ParameterDef( - type="string", - description=( - "ID of the source template presentation that will be " - "copied before placeholders are replaced." - ), - required=True, - ), - "title": ParameterDef( - type="string", - description=( - "Title of the new (copied) presentation that " - "receives the merged data." - ), - required=True, - ), - "placeholders_and_texts": ParameterDef( - type="object", - description=( - "Mapping of placeholder text -> replacement string " - "(e.g. {'{{name}}': 'John Doe'}). Each pair becomes " - "a replaceAllText request." - ), - required=True, - ), - "placeholders_and_image_urls": ParameterDef( - type="object", - description=( - "Optional mapping of placeholder text -> image URL " - "(e.g. {'{{image}}': 'https://...'}). Each pair " - "becomes a replaceAllShapesWithImage request with " - "CENTER_INSIDE." - ), - required=False, - default=None, - ), - }, - ), - ActionDefinition( - name="refresh_chart", - description=( - "Refresh every embedded Sheets chart in a presentation by " - "scanning all slides for sheetsChart page elements and " - "issuing a Slides batchUpdate RefreshSheetsChartRequest per " - "chart." - ), - parameters={ - "presentation_id": ParameterDef( - type="string", - description=( - "ID of the target Google Slides presentation whose " - "Sheets charts should be refreshed." - ), - required=True, - ), - }, - ), ActionDefinition( name="replace_all_text", description=( @@ -730,22 +651,21 @@ token_url="https://oauth2.googleapis.com/token", scopes=[ "https://www.googleapis.com/auth/presentations", - "https://www.googleapis.com/auth/drive", ], token_auth_method="body", ), test_endpoint=TestEndpoint( - url="https://www.googleapis.com/drive/v3/about?fields=user", + url="https://slides.googleapis.com/v1/presentations/1", method="GET", headers={"Authorization": "Bearer {access_token}"}, success_indicators=SuccessIndicators( - status_codes=[200], - response_fields=["user"], + status_codes=[200, 404], + response_fields=None, ), cost_level="free", description=( - "Validates the OAuth token by fetching the Drive 'about' " - "user profile." + "Validates the OAuth token against the Slides API " + "(200 or 404 both confirm the token is accepted)." ), ), ), diff --git a/src/modulex_integrations/tools/google_slides/outputs.py b/src/modulex_integrations/tools/google_slides/outputs.py index 125d77f..4b4dc30 100644 --- a/src/modulex_integrations/tools/google_slides/outputs.py +++ b/src/modulex_integrations/tools/google_slides/outputs.py @@ -20,8 +20,6 @@ "InsertTableRowsOutput", "InsertTextIntoTableOutput", "InsertTextOutput", - "MergeDataOutput", - "RefreshChartOutput", "ReplaceAllTextOutput", ] @@ -59,9 +57,6 @@ class CreatePresentationOutput(_Base): presentation_id: str | None = None title: str | None = None revision_id: str | None = None - copied_from: str | None = None - file_id: str | None = None - web_view_link: str | None = None raw: dict[str, Any] | None = None @@ -164,24 +159,6 @@ class InsertTextOutput(_Base): write_control: dict[str, Any] | None = None -class MergeDataOutput(_Base): - success: bool - error: str | None = None - presentation_id: str | None = None - copied_from_id: str | None = None - new_presentation_title: str | None = None - replies: list[dict[str, Any]] = Field(default_factory=list) - write_control: dict[str, Any] | None = None - - -class RefreshChartOutput(_Base): - success: bool - error: str | None = None - presentation_id: str | None = None - refreshed_chart_count: int = 0 - refreshed_chart_ids: list[str] = Field(default_factory=list) - - class ReplaceAllTextOutput(_Base): success: bool error: str | None = None diff --git a/src/modulex_integrations/tools/google_slides/tests/test_google_slides.py b/src/modulex_integrations/tools/google_slides/tests/test_google_slides.py index 543ceab..1f9187c 100644 --- a/src/modulex_integrations/tools/google_slides/tests/test_google_slides.py +++ b/src/modulex_integrations/tools/google_slides/tests/test_google_slides.py @@ -22,8 +22,6 @@ insert_text, insert_text_into_table, manifest, - merge_data, - refresh_chart, replace_all_text, ) from modulex_integrations.tools.google_slides.outputs import ( @@ -41,13 +39,10 @@ InsertTableRowsOutput, InsertTextIntoTableOutput, InsertTextOutput, - MergeDataOutput, - RefreshChartOutput, ReplaceAllTextOutput, ) SLIDES_API = "https://slides.googleapis.com/v1" -DRIVE_API = "https://www.googleapis.com/drive/v3" _AUTH: dict[str, Any] = { "auth_type": "oauth2", @@ -64,8 +59,8 @@ def _args(**extra: Any) -> dict[str, Any]: class TestManifest: - def test_manifest_exposes_17_actions(self) -> None: - assert len(manifest.actions) == 17 + def test_manifest_exposes_15_actions(self) -> None: + assert len(manifest.actions) == 15 def test_manifest_actions_match_tools_tuple(self) -> None: assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} @@ -153,29 +148,6 @@ async def test_create_presentation_blank(httpx_mock): # type: ignore[no-untyped assert result.presentation_id == "NEW_PRESID" -@pytest.mark.asyncio -async def test_create_presentation_copy(httpx_mock): # type: ignore[no-untyped-def] - httpx_mock.add_response( - method="POST", - url=f"{DRIVE_API}/files/SRC_ID/copy?supportsAllDrives=true&fields=*", - json={ - # TODO: fill in a representative Drive files.copy response - "id": "COPIED_ID", - "name": "My Deck Copy", - "webViewLink": "https://docs.google.com/presentation/d/COPIED_ID/edit", - }, - ) - - result_dict = await create_presentation.ainvoke( - _args(title="My Deck Copy", source_presentation_id="SRC_ID") - ) - - result = CreatePresentationOutput.model_validate(result_dict) - assert result.success is True - assert result.presentation_id == "COPIED_ID" - assert result.copied_from == "SRC_ID" - - @pytest.mark.asyncio async def test_create_slide(httpx_mock): # type: ignore[no-untyped-def] httpx_mock.add_response( @@ -418,79 +390,6 @@ async def test_insert_text_into_table(httpx_mock): # type: ignore[no-untyped-de assert result.success is True -@pytest.mark.asyncio -async def test_merge_data(httpx_mock): # type: ignore[no-untyped-def] - httpx_mock.add_response( - method="POST", - url=f"{DRIVE_API}/files/SRC_ID/copy?supportsAllDrives=true&fields=*", - json={ - # TODO: fill in a representative Drive files.copy response - "id": "COPIED_ID", - "name": "Merged Deck", - }, - ) - httpx_mock.add_response( - method="POST", - url=f"{SLIDES_API}/presentations/COPIED_ID:batchUpdate", - json={ - # TODO: fill in a representative Slides batchUpdate response - "presentationId": "COPIED_ID", - "replies": [{}], - }, - ) - - result_dict = await merge_data.ainvoke( - _args( - source_presentation_id="SRC_ID", - title="Merged Deck", - placeholders_and_texts={"{{name}}": "Alice"}, - ) - ) - - result = MergeDataOutput.model_validate(result_dict) - assert result.success is True - assert result.presentation_id == "COPIED_ID" - - -@pytest.mark.asyncio -async def test_refresh_chart(httpx_mock): # type: ignore[no-untyped-def] - httpx_mock.add_response( - method="GET", - url=f"{SLIDES_API}/presentations/PRESID", - json={ - # TODO: fill in a representative Slides presentations.get response - "presentationId": "PRESID", - "slides": [ - { - "objectId": "S1", - "pageElements": [ - { - "objectId": "CHART_1", - "sheetsChart": {"spreadsheetId": "SHEET_1"}, - } - ], - } - ], - }, - ) - httpx_mock.add_response( - method="POST", - url=f"{SLIDES_API}/presentations/PRESID:batchUpdate", - json={ - # TODO: fill in a representative Slides batchUpdate response - "presentationId": "PRESID", - "replies": [{}], - }, - ) - - result_dict = await refresh_chart.ainvoke(_args(presentation_id="PRESID")) - - result = RefreshChartOutput.model_validate(result_dict) - assert result.success is True - assert result.refreshed_chart_count == 1 - assert result.refreshed_chart_ids == ["CHART_1"] - - @pytest.mark.asyncio async def test_replace_all_text(httpx_mock): # type: ignore[no-untyped-def] httpx_mock.add_response( diff --git a/src/modulex_integrations/tools/google_slides/tools.py b/src/modulex_integrations/tools/google_slides/tools.py index d34f66c..d5a0c4d 100644 --- a/src/modulex_integrations/tools/google_slides/tools.py +++ b/src/modulex_integrations/tools/google_slides/tools.py @@ -23,8 +23,6 @@ InsertTableRowsOutput, InsertTextIntoTableOutput, InsertTextOutput, - MergeDataOutput, - RefreshChartOutput, ReplaceAllTextOutput, ) @@ -43,13 +41,10 @@ "insert_table_rows", "insert_text", "insert_text_into_table", - "merge_data", - "refresh_chart", "replace_all_text", ] _SLIDES_BASE_URL = "https://slides.googleapis.com/v1" -_DRIVE_BASE_URL = "https://www.googleapis.com/drive/v3" _TIMEOUT = 30.0 @@ -118,13 +113,6 @@ class CreatePresentationInput(BaseModel): auth_type: str = Field(description="Authentication type") auth_data: dict[str, Any] = Field(description="Authentication data") title: str = Field(description="Title of the new presentation.") - source_presentation_id: str | None = Field( - default=None, - description=( - "Optional ID of an existing presentation to copy. When omitted, a " - "blank presentation is created." - ), - ) class CreateSlideInput(BaseModel): @@ -253,28 +241,6 @@ class InsertTextIntoTableInput(BaseModel): ) -class MergeDataInput(BaseModel): - auth_type: str = Field(description="Authentication type") - auth_data: dict[str, Any] = Field(description="Authentication data") - source_presentation_id: str = Field( - description="ID of the template presentation to copy." - ) - title: str = Field(description="Title of the new (copied) presentation.") - placeholders_and_texts: dict[str, str] = Field( - description="Mapping of placeholder text -> replacement string." - ) - placeholders_and_image_urls: dict[str, str] | None = Field( - default=None, - description="Optional mapping of placeholder text -> image URL.", - ) - - -class RefreshChartInput(BaseModel): - auth_type: str = Field(description="Authentication type") - auth_data: dict[str, Any] = Field(description="Authentication data") - presentation_id: str = Field(description="ID of the target presentation.") - - class ReplaceAllTextInput(BaseModel): auth_type: str = Field(description="Authentication type") auth_data: dict[str, Any] = Field(description="Authentication data") @@ -435,27 +401,16 @@ async def create_presentation( auth_type: str, auth_data: dict[str, Any], title: str, - source_presentation_id: str | None = None, ) -> CreatePresentationOutput: - """Create a blank presentation or duplicate an existing one.""" + """Create a blank presentation.""" if not auth_data.get("access_token"): return CreatePresentationOutput(success=False, error="Missing access token in auth_data.") headers = _get_auth_headers(auth_type, auth_data) try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - if source_presentation_id: - # Copy via Drive API. - url = f"{_DRIVE_BASE_URL}/files/{source_presentation_id}/copy" - response = await client.post( - url, - headers=headers, - params={"supportsAllDrives": "true", "fields": "*"}, - json={"name": title}, - ) - else: - # Create blank via Slides API. - url = f"{_SLIDES_BASE_URL}/presentations" - response = await client.post(url, headers=headers, json={"title": title}) + # Create blank via Slides API. + url = f"{_SLIDES_BASE_URL}/presentations" + response = await client.post(url, headers=headers, json={"title": title}) except httpx.TimeoutException: return CreatePresentationOutput(success=False, error="Request timed out.") except Exception as exc: @@ -472,17 +427,6 @@ async def create_presentation( except Exception: data = {} - if source_presentation_id: - # Drive copy response. - return CreatePresentationOutput( - success=True, - presentation_id=data.get("id"), - title=data.get("name"), - copied_from=source_presentation_id, - file_id=data.get("id"), - web_view_link=data.get("webViewLink"), - raw=data, - ) return CreatePresentationOutput( success=True, presentation_id=data.get("presentationId"), @@ -996,181 +940,6 @@ async def insert_text_into_table( ) -@tool(args_schema=MergeDataInput) -@serialize_pydantic_return -async def merge_data( - auth_type: str, - auth_data: dict[str, Any], - source_presentation_id: str, - title: str, - placeholders_and_texts: dict[str, str], - placeholders_and_image_urls: dict[str, str] | None = None, -) -> MergeDataOutput: - """Duplicate a template presentation and merge placeholder data into it.""" - if not auth_data.get("access_token"): - return MergeDataOutput(success=False, error="Missing access token in auth_data.") - headers = _get_auth_headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - # Step 1: copy source via Drive. - copy_response = await client.post( - f"{_DRIVE_BASE_URL}/files/{source_presentation_id}/copy", - headers=headers, - params={"supportsAllDrives": "true", "fields": "*"}, - json={"name": title}, - ) - if copy_response.status_code not in (200, 201): - return MergeDataOutput( - success=False, - error=( - f"Drive copy error ({copy_response.status_code}): " - f"{copy_response.text}" - ), - ) - copy_data = copy_response.json() - new_presentation_id = copy_data.get("id") - if not new_presentation_id: - return MergeDataOutput( - success=False, - error="Drive copy did not return a new file id.", - ) - - # Step 2: build batchUpdate requests. - text_requests: list[dict[str, Any]] = [ - { - "replaceAllText": { - "containsText": {"text": k, "matchCase": True}, - "replaceText": v, - } - } - for k, v in (placeholders_and_texts or {}).items() - ] - image_requests: list[dict[str, Any]] = [ - { - "replaceAllShapesWithImage": { - "imageUrl": v, - "replaceMethod": "CENTER_INSIDE", - "containsText": {"text": k, "matchCase": True}, - } - } - for k, v in (placeholders_and_image_urls or {}).items() - ] - all_requests = text_requests + image_requests - - # Step 3: batchUpdate the new copy. - bu_response = await client.post( - f"{_SLIDES_BASE_URL}/presentations/{new_presentation_id}:batchUpdate", - headers=headers, - json={"requests": all_requests}, - ) - except httpx.TimeoutException: - return MergeDataOutput(success=False, error="Request timed out.") - except Exception as exc: - return MergeDataOutput(success=False, error=f"Call failed: {exc}") - - if bu_response.status_code != 200: - return MergeDataOutput( - success=False, - error=( - f"batchUpdate error ({bu_response.status_code}): " - f"{bu_response.text}" - ), - ) - - try: - bu_data = bu_response.json() - except Exception: - bu_data = {} - - return MergeDataOutput( - success=True, - presentation_id=bu_data.get("presentationId") or new_presentation_id, - copied_from_id=source_presentation_id, - new_presentation_title=title, - replies=bu_data.get("replies") or [], - write_control=bu_data.get("writeControl"), - ) - - -@tool(args_schema=RefreshChartInput) -@serialize_pydantic_return -async def refresh_chart( - auth_type: str, - auth_data: dict[str, Any], - presentation_id: str, -) -> RefreshChartOutput: - """Refresh every Sheets chart embedded in a presentation.""" - if not auth_data.get("access_token"): - return RefreshChartOutput(success=False, error="Missing access token in auth_data.") - headers = _get_auth_headers(auth_type, auth_data) - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - # Step 1: fetch presentation to discover sheetsChart elements. - get_response = await client.get( - f"{_SLIDES_BASE_URL}/presentations/{presentation_id}", - headers=headers, - ) - if get_response.status_code != 200: - return RefreshChartOutput( - success=False, - error=( - f"Slides get error ({get_response.status_code}): " - f"{get_response.text}" - ), - ) - presentation = get_response.json() - chart_ids: list[str] = [] - for slide in presentation.get("slides") or []: - for element in slide.get("pageElements") or []: - sheets_chart = element.get("sheetsChart") - if sheets_chart and sheets_chart.get("spreadsheetId"): - obj_id = element.get("objectId") - if obj_id: - chart_ids.append(obj_id) - - if not chart_ids: - return RefreshChartOutput( - success=True, - presentation_id=presentation_id, - refreshed_chart_count=0, - refreshed_chart_ids=[], - ) - - # Step 2: issue one batchUpdate per chart (mirrors pipedream loop). - for chart_id in chart_ids: - bu_response = await client.post( - f"{_SLIDES_BASE_URL}/presentations/{presentation_id}:batchUpdate", - headers=headers, - json={ - "requests": [ - {"refreshSheetsChart": {"objectId": chart_id}} - ] - }, - ) - if bu_response.status_code != 200: - return RefreshChartOutput( - success=False, - presentation_id=presentation_id, - error=( - f"refreshSheetsChart error for {chart_id} " - f"({bu_response.status_code}): {bu_response.text}" - ), - refreshed_chart_count=0, - refreshed_chart_ids=[], - ) - except httpx.TimeoutException: - return RefreshChartOutput(success=False, error="Request timed out.") - except Exception as exc: - return RefreshChartOutput(success=False, error=f"Call failed: {exc}") - - return RefreshChartOutput( - success=True, - presentation_id=presentation_id, - refreshed_chart_count=len(chart_ids), - refreshed_chart_ids=chart_ids, - ) - - @tool(args_schema=ReplaceAllTextInput) @serialize_pydantic_return async def replace_all_text( diff --git a/src/modulex_integrations/tools/google_tag_manager/README.md b/src/modulex_integrations/tools/google_tag_manager/README.md index 905e0aa..c700097 100644 --- a/src/modulex_integrations/tools/google_tag_manager/README.md +++ b/src/modulex_integrations/tools/google_tag_manager/README.md @@ -13,7 +13,6 @@ Tag Manager API v2 (`www.googleapis.com/tagmanager/v2`). - Scopes requested: - `https://www.googleapis.com/auth/tagmanager.edit.containers` - `https://www.googleapis.com/auth/tagmanager.readonly` - - `https://www.googleapis.com/auth/tagmanager.manage.accounts` - Required env vars (custom OAuth app only): - `GOOGLE_TAG_MANAGER_OAUTH2_CLIENT_ID` - `GOOGLE_TAG_MANAGER_OAUTH2_CLIENT_SECRET` diff --git a/src/modulex_integrations/tools/google_tag_manager/manifest.py b/src/modulex_integrations/tools/google_tag_manager/manifest.py index 8f7caec..14de0dc 100644 --- a/src/modulex_integrations/tools/google_tag_manager/manifest.py +++ b/src/modulex_integrations/tools/google_tag_manager/manifest.py @@ -261,7 +261,6 @@ scopes=[ "https://www.googleapis.com/auth/tagmanager.edit.containers", "https://www.googleapis.com/auth/tagmanager.readonly", - "https://www.googleapis.com/auth/tagmanager.manage.accounts", ], ), test_endpoint=TestEndpoint(