From 61bb56c1a159c1af959440d11f482f529d8e3b1a Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Thu, 28 May 2026 09:58:05 +0000 Subject: [PATCH 1/5] auto-integrate: postgrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR integrates the **postgrid** tool into `modulex-integrations`. **What it does:** Provides 3 LangChain `@tool` async actions for programmatic direct mail delivery via the PostGrid Print & Mail API: - `create_contact` — Create a new contact in PostGrid - `create_letter` — Create a new letter in PostGrid - `create_postcard` — Create a new postcard in PostGrid **Auth:** `api_key` (single env var: `POSTGRID_API_KEY`, injected via `x-api-key` header). Credential validation via `GET /print-mail/v1/contacts` (free, read-only). **Consumer-side audit patches applied (1):** 1. **Check 8.9 (manifest.py)** — Added missing `logo="modulex:postgrid-themed"` field. Strategy: mechanical. **Additional merger fixes:** - Removed unused `Field` import from `outputs.py` (ruff F401). - Added ruff E501 per-file-ignores for `manifest.py` and `tools.py`. **Gates:** ruff PASS, mypy --strict PASS, pytest 7/7 PASS. **No new runtime dependencies.** Pure HTTP via `httpx`. Provider: primary Run: 26567233851 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 4 + pyproject.toml | 5 + .../tools/postgrid/README.md | 31 ++ .../tools/postgrid/__init__.py | 21 ++ .../tools/postgrid/dependencies.toml | 3 + .../tools/postgrid/manifest.py | 243 +++++++++++++ .../tools/postgrid/outputs.py | 89 +++++ .../tools/postgrid/tests/__init__.py | 1 + .../tools/postgrid/tests/test_postgrid.py | 166 +++++++++ .../tools/postgrid/tools.py | 343 ++++++++++++++++++ 10 files changed, 906 insertions(+) create mode 100644 src/modulex_integrations/tools/postgrid/README.md create mode 100644 src/modulex_integrations/tools/postgrid/__init__.py create mode 100644 src/modulex_integrations/tools/postgrid/dependencies.toml create mode 100644 src/modulex_integrations/tools/postgrid/manifest.py create mode 100644 src/modulex_integrations/tools/postgrid/outputs.py create mode 100644 src/modulex_integrations/tools/postgrid/tests/__init__.py create mode 100644 src/modulex_integrations/tools/postgrid/tests/test_postgrid.py create mode 100644 src/modulex_integrations/tools/postgrid/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca90d9..2c12748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `postgrid` integration — 3 actions, auth: api_key. Programmatic direct + mail delivery via the PostGrid Print & Mail API (create_contact, + create_letter, create_postcard). Producer-staged by integration-drafts; + consumer-side audit applied 1 patch before merge. - `canvas` integration — 5 actions, auth: custom. Learning management system for course, assignment, and user management via the Canvas REST API (list_accounts, list_assignments, list_courses, diff --git a/pyproject.toml b/pyproject.toml index 7a79893..7264006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,7 @@ microsoft_teams = "modulex_integrations.tools.microsoft_teams" mintlify = "modulex_integrations.tools.mintlify" mixpanel = "modulex_integrations.tools.mixpanel" monday = "modulex_integrations.tools.monday" +postgrid = "modulex_integrations.tools.postgrid" posthog = "modulex_integrations.tools.posthog" postman = "modulex_integrations.tools.postman" product_hunt = "modulex_integrations.tools.product_hunt" @@ -576,6 +577,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/canvas/manifest.py" = ["E501"] "src/modulex_integrations/tools/canvas/tools.py" = ["E501"] +# postgrid manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/postgrid/manifest.py" = ["E501"] +"src/modulex_integrations/tools/postgrid/tools.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/postgrid/README.md b/src/modulex_integrations/tools/postgrid/README.md new file mode 100644 index 0000000..c9d35b0 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/README.md @@ -0,0 +1,31 @@ +# PostGrid + +Programmatic direct mail delivery via the PostGrid Print & Mail API (`api.postgrid.com/print-mail/v1`). + +## Authentication + +### API Key Authentication + +- Sign in at [app.postgrid.com](https://app.postgrid.com), navigate to Settings > API Keys. +- Copy your live or test API key. +- Required env var: `POSTGRID_API_KEY` (format: `live_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_contact` | Create a new contact in PostGrid | `first_name`, `address_line1` | +| `create_letter` | Create a new letter in PostGrid | `to`, `from_contact`, `html` | +| `create_postcard` | Create a new postcard in PostGrid | `to`, `from_contact`, `front_html`, `back_html`, `size` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- No publicly documented rate limits. PostGrid applies per-account request limits based on plan tier. +- Sending physical mail incurs per-item costs based on the PostGrid pricing plan. +- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/postgrid/__init__.py b/src/modulex_integrations/tools/postgrid/__init__.py new file mode 100644 index 0000000..533565b --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/__init__.py @@ -0,0 +1,21 @@ +"""PostGrid integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.postgrid.manifest import manifest +from modulex_integrations.tools.postgrid.tools import ( + create_contact, + create_letter, + create_postcard, +) + +TOOLS = ( + create_contact, + create_letter, + create_postcard, +) + +__all__ = [ + "TOOLS", + "create_contact", + "create_letter", + "create_postcard", + "manifest", +] diff --git a/src/modulex_integrations/tools/postgrid/dependencies.toml b/src/modulex_integrations/tools/postgrid/dependencies.toml new file mode 100644 index 0000000..58e3828 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the postgrid integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/postgrid/manifest.py b/src/modulex_integrations/tools/postgrid/manifest.py new file mode 100644 index 0000000..4461876 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/manifest.py @@ -0,0 +1,243 @@ +"""PostGrid integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="postgrid", + display_name="PostGrid", + description="Programmatic direct mail delivery via the PostGrid Print & Mail API", + logo="modulex:postgrid-themed", + version="1.0.0", + author="ModuleX", + app_url="https://www.postgrid.com", + categories=["Marketing", "Business Services"], + actions=[ + ActionDefinition( + name="create_contact", + description="Create a new contact in PostGrid", + parameters={ + "first_name": ParameterDef( + type="string", + description="The first name of the contact", + required=True, + ), + "last_name": ParameterDef( + type="string", + description="The last name of the contact", + ), + "company_name": ParameterDef( + type="string", + description="The contact's company name", + ), + "address_line1": ParameterDef( + type="string", + description="The contact's first address line", + required=True, + ), + "address_line2": ParameterDef( + type="string", + description="The contact's second address line", + ), + "city": ParameterDef( + type="string", + description="The contact's city", + ), + "province_or_state": ParameterDef( + type="string", + description="The province or state of the contact", + ), + "email": ParameterDef( + type="string", + description="The contact's email address", + ), + "phone_number": ParameterDef( + type="string", + description="The contact's phone number", + ), + "job_title": ParameterDef( + type="string", + description="The contact's job title", + ), + "postal_or_zip": ParameterDef( + type="string", + description="The postal code or ZIP code of the contact", + ), + "country_code": ParameterDef( + type="string", + description="ISO 3166-1 country code of the contact's address. Defaults to CA", + default="CA", + ), + "description": ParameterDef( + type="string", + description="A description for the contact", + ), + "skip_verification": ParameterDef( + type="boolean", + description="If true, skip address verification and mark the address as failed", + ), + }, + ), + ActionDefinition( + name="create_letter", + description="Create a new letter in PostGrid", + parameters={ + "to": ParameterDef( + type="string", + description="The ID or contact object of the receiver", + required=True, + ), + "from_contact": ParameterDef( + type="string", + description="The ID or contact object of the sender", + required=True, + ), + "html": ParameterDef( + type="string", + description="The HTML content of the letter", + required=True, + ), + "address_placement": ParameterDef( + type="string", + description="Location where the address will be placed. One of: top_first_page, insert_blank_page", + ), + "double_sided": ParameterDef( + type="boolean", + description="Whether the letter is double sided", + ), + "color": ParameterDef( + type="boolean", + description="Whether the letter will be printed in color", + ), + "perforated_page": ParameterDef( + type="integer", + description="Page number to be perforated", + ), + "extra_service": ParameterDef( + type="string", + description="Extra services for the letter. One of: certified, certified_return_receipt, registered", + ), + "envelope_type": ParameterDef( + type="string", + description="Envelope type. One of: standard_double_window, flat", + ), + "return_envelope": ParameterDef( + type="string", + description="The ID of the return envelope to be used", + ), + "send_date": ParameterDef( + type="string", + description="Desired date for the letter to be sent out (ISO 8601 format)", + ), + "description": ParameterDef( + type="string", + description="A description for the letter", + ), + "express": ParameterDef( + type="boolean", + description="Whether to use express shipping", + ), + "mailing_class": ParameterDef( + type="string", + description="Mailing class. One of: standard_class, first_class. Defaults to first_class", + ), + "size": ParameterDef( + type="string", + description="Letter size. One of: us_letter, us_legal, a4", + ), + }, + ), + ActionDefinition( + name="create_postcard", + description="Create a new postcard in PostGrid", + parameters={ + "to": ParameterDef( + type="string", + description="The ID or contact object of the receiver", + required=True, + ), + "from_contact": ParameterDef( + type="string", + description="The ID or contact object of the sender", + required=True, + ), + "front_html": ParameterDef( + type="string", + description="The HTML content for the front of the postcard", + required=True, + ), + "back_html": ParameterDef( + type="string", + description="The HTML content for the back of the postcard", + required=True, + ), + "size": ParameterDef( + type="string", + description="Postcard size. One of: 6x4, 9x6, 11x6", + required=True, + ), + "send_date": ParameterDef( + type="string", + description="Desired date for the postcard to be sent out (ISO 8601 format)", + ), + "express": ParameterDef( + type="boolean", + description="Whether to use express shipping", + ), + "description": ParameterDef( + type="string", + description="A description for the postcard", + ), + "mailing_class": ParameterDef( + type="string", + description="Mailing class. One of: standard_class, first_class. Defaults to first_class", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your PostGrid API key", + setup_instructions=[ + "Sign in at https://app.postgrid.com", + "Navigate to 'Settings' > 'API Keys'", + "Copy your live or test API key", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="POSTGRID_API_KEY", + display_name="PostGrid API Key", + description="Your PostGrid API key from app.postgrid.com", + required=True, + sensitive=True, + sample_format="live_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://app.postgrid.com", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.postgrid.com/print-mail/v1/contacts", + method="GET", + headers={"x-api-key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates the API key by listing contacts", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/postgrid/outputs.py b/src/modulex_integrations/tools/postgrid/outputs.py new file mode 100644 index 0000000..0f04aa5 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/outputs.py @@ -0,0 +1,89 @@ +"""Pydantic response models for the postgrid integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = [ + "ContactResource", + "CreateContactOutput", + "CreateLetterOutput", + "CreatePostcardOutput", + "LetterResource", + "PostcardResource", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class ContactResource(_Base): + """A contact object returned by the PostGrid API.""" + + id: str | None = None + object: str | None = None + live: bool | None = None + first_name: str | None = None + last_name: str | None = None + company_name: str | None = None + address_line1: str | None = None + address_line2: str | None = None + city: str | None = None + province_or_state: str | None = None + postal_or_zip: str | None = None + country: str | None = None + country_code: str | None = None + email: str | None = None + phone_number: str | None = None + job_title: str | None = None + description: str | None = None + address_status: str | None = None + + +class LetterResource(_Base): + """A letter object returned by the PostGrid API.""" + + id: str | None = None + object: str | None = None + live: bool | None = None + send_date: str | None = None + status: str | None = None + url: str | None = None + + +class PostcardResource(_Base): + """A postcard object returned by the PostGrid API.""" + + id: str | None = None + object: str | None = None + live: bool | None = None + send_date: str | None = None + status: str | None = None + size: str | None = None + url: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class CreateContactOutput(_Base): + success: bool + error: str | None = None + contact: ContactResource | None = None + + +class CreateLetterOutput(_Base): + success: bool + error: str | None = None + letter: LetterResource | None = None + + +class CreatePostcardOutput(_Base): + success: bool + error: str | None = None + postcard: PostcardResource | None = None diff --git a/src/modulex_integrations/tools/postgrid/tests/__init__.py b/src/modulex_integrations/tools/postgrid/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/postgrid/tests/test_postgrid.py b/src/modulex_integrations/tools/postgrid/tests/test_postgrid.py new file mode 100644 index 0000000..7fb95ed --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/tests/test_postgrid.py @@ -0,0 +1,166 @@ +"""Happy-path tests for every postgrid @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.postgrid import ( + TOOLS, + create_contact, + create_letter, + create_postcard, + manifest, +) +from modulex_integrations.tools.postgrid.outputs import ( + CreateContactOutput, + CreateLetterOutput, + CreatePostcardOutput, +) + +API = "https://api.postgrid.com/print-mail/v1" + +_API_KEY = "fake-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_3_actions(self) -> None: + assert len(manifest.actions) == 3 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_contact(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/contacts", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "contact_abc123", + "object": "contact", + "live": True, + "firstName": "John", + "lastName": "Doe", + "companyName": None, + "addressLine1": "123 Main St", + "addressLine2": None, + "city": "Toronto", + "provinceOrState": "ON", + "postalOrZip": "M5V 1A1", + "country": "Canada", + "countryCode": "CA", + "email": "john@example.com", + "phoneNumber": None, + "jobTitle": None, + "description": None, + "addressStatus": "verified", + }, + ) + + result_dict = await create_contact.ainvoke( + _args(first_name="John", address_line1="123 Main St", last_name="Doe", city="Toronto") + ) + + assert isinstance(result_dict, dict) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is True + assert result.contact is not None + assert result.contact.id == "contact_abc123" + assert result.contact.first_name == "John" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-api-key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_create_letter(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/letters", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "letter_abc123", + "object": "letter", + "live": True, + "sendDate": "2023-02-16T15:40:35.873Z", + "status": "ready", + "url": "https://pg-prod-bucket.s3.amazonaws.com/letters/letter_abc123.pdf", + }, + ) + + result_dict = await create_letter.ainvoke( + _args(to="contact_receiver", from_contact="contact_sender", html="

Hello

") + ) + + assert isinstance(result_dict, dict) + result = CreateLetterOutput.model_validate(result_dict) + assert result.success is True + assert result.letter is not None + assert result.letter.id == "letter_abc123" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-api-key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_create_postcard(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/postcards", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "postcard_abc123", + "object": "postcard", + "live": True, + "sendDate": "2023-02-16T15:40:35.873Z", + "status": "ready", + "size": "6x4", + "url": "https://pg-prod-bucket.s3.amazonaws.com/postcards/postcard_abc123.pdf", + }, + ) + + result_dict = await create_postcard.ainvoke( + _args( + to="contact_receiver", + from_contact="contact_sender", + front_html="

Front

", + back_html="

Back

", + size="6x4", + ) + ) + + assert isinstance(result_dict, dict) + result = CreatePostcardOutput.model_validate(result_dict) + assert result.success is True + assert result.postcard is not None + assert result.postcard.id == "postcard_abc123" + assert result.postcard.size == "6x4" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-api-key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_create_contact_validates_empty_api_key() -> None: + result_dict = await create_contact.ainvoke( + {"first_name": "Test", "address_line1": "123 St", "api_key": ""} + ) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "API key" in result.error diff --git a/src/modulex_integrations/tools/postgrid/tools.py b/src/modulex_integrations/tools/postgrid/tools.py new file mode 100644 index 0000000..f027071 --- /dev/null +++ b/src/modulex_integrations/tools/postgrid/tools.py @@ -0,0 +1,343 @@ +"""PostGrid LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.postgrid.outputs import ( + ContactResource, + CreateContactOutput, + CreateLetterOutput, + CreatePostcardOutput, + LetterResource, + PostcardResource, +) + +__all__ = [ + "create_contact", + "create_letter", + "create_postcard", +] + +_BASE_URL = "https://api.postgrid.com/print-mail/v1" + + +def _headers(api_key: str) -> dict[str, str]: + return { + "x-api-key": api_key, + "Content-Type": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateContactInput(BaseModel): + first_name: str = Field(description="The first name of the contact") + address_line1: str = Field(description="The contact's first address line") + api_key: str = Field(description="PostGrid API key") + last_name: str | None = Field(default=None, description="The last name of the contact") + company_name: str | None = Field(default=None, description="The contact's company name") + address_line2: str | None = Field(default=None, description="The contact's second address line") + city: str | None = Field(default=None, description="The contact's city") + province_or_state: str | None = Field(default=None, description="The province or state of the contact") + email: str | None = Field(default=None, description="The contact's email address") + phone_number: str | None = Field(default=None, description="The contact's phone number") + job_title: str | None = Field(default=None, description="The contact's job title") + postal_or_zip: str | None = Field(default=None, description="The postal code or ZIP code of the contact") + country_code: str = Field(default="CA", description="ISO 3166-1 country code. Defaults to CA") + description: str | None = Field(default=None, description="A description for the contact") + skip_verification: bool | None = Field(default=None, description="If true, skip address verification") + + +class CreateLetterInput(BaseModel): + to: str = Field(description="The ID or contact object of the receiver") + from_contact: str = Field(description="The ID or contact object of the sender") + html: str = Field(description="The HTML content of the letter") + api_key: str = Field(description="PostGrid API key") + address_placement: str | None = Field(default=None, description="Address placement. One of: top_first_page, insert_blank_page") + double_sided: bool | None = Field(default=None, description="Whether the letter is double sided") + color: bool | None = Field(default=None, description="Whether the letter will be printed in color") + perforated_page: int | None = Field(default=None, description="Page number to be perforated") + extra_service: str | None = Field(default=None, description="Extra services. One of: certified, certified_return_receipt, registered") + envelope_type: str | None = Field(default=None, description="Envelope type. One of: standard_double_window, flat") + return_envelope: str | None = Field(default=None, description="The ID of the return envelope") + send_date: str | None = Field(default=None, description="Desired send date in ISO 8601 format") + description: str | None = Field(default=None, description="A description for the letter") + express: bool | None = Field(default=None, description="Whether to use express shipping") + mailing_class: str | None = Field(default=None, description="Mailing class. One of: standard_class, first_class") + size: str | None = Field(default=None, description="Letter size. One of: us_letter, us_legal, a4") + + +class CreatePostcardInput(BaseModel): + to: str = Field(description="The ID or contact object of the receiver") + from_contact: str = Field(description="The ID or contact object of the sender") + front_html: str = Field(description="The HTML content for the front of the postcard") + back_html: str = Field(description="The HTML content for the back of the postcard") + size: str = Field(description="Postcard size. One of: 6x4, 9x6, 11x6") + api_key: str = Field(description="PostGrid API key") + send_date: str | None = Field(default=None, description="Desired send date in ISO 8601 format") + express: bool | None = Field(default=None, description="Whether to use express shipping") + description: str | None = Field(default=None, description="A description for the postcard") + mailing_class: str | None = Field(default=None, description="Mailing class. One of: standard_class, first_class") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateContactInput) +@serialize_pydantic_return +async def create_contact( + first_name: str, + address_line1: str, + api_key: str, + last_name: str | None = None, + company_name: str | None = None, + address_line2: str | None = None, + city: str | None = None, + province_or_state: str | None = None, + email: str | None = None, + phone_number: str | None = None, + job_title: str | None = None, + postal_or_zip: str | None = None, + country_code: str = "CA", + description: str | None = None, + skip_verification: bool | None = None, +) -> CreateContactOutput: + """Create a new contact in PostGrid.""" + if not api_key or not api_key.strip(): + return CreateContactOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = { + "firstName": first_name, + "addressLine1": address_line1, + } + if last_name is not None: + body["lastName"] = last_name + if company_name is not None: + body["companyName"] = company_name + if address_line2 is not None: + body["addressLine2"] = address_line2 + if city is not None: + body["city"] = city + if province_or_state is not None: + body["provinceOrState"] = province_or_state + if email is not None: + body["email"] = email + if phone_number is not None: + body["phoneNumber"] = phone_number + if job_title is not None: + body["jobTitle"] = job_title + if postal_or_zip is not None: + body["postalOrZip"] = postal_or_zip + if country_code != "CA": + body["countryCode"] = country_code + else: + body["countryCode"] = country_code + if description is not None: + body["description"] = description + if skip_verification is not None: + body["skipVerification"] = skip_verification + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/contacts", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreateContactOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateContactOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateContactOutput(success=False, error=f"Call failed: {exc}") + + return CreateContactOutput( + success=True, + contact=ContactResource( + id=data.get("id"), + object=data.get("object"), + live=data.get("live"), + first_name=data.get("firstName"), + last_name=data.get("lastName"), + company_name=data.get("companyName"), + address_line1=data.get("addressLine1"), + address_line2=data.get("addressLine2"), + city=data.get("city"), + province_or_state=data.get("provinceOrState"), + postal_or_zip=data.get("postalOrZip"), + country=data.get("country"), + country_code=data.get("countryCode"), + email=data.get("email"), + phone_number=data.get("phoneNumber"), + job_title=data.get("jobTitle"), + description=data.get("description"), + address_status=data.get("addressStatus"), + ), + ) + + +@tool(args_schema=CreateLetterInput) +@serialize_pydantic_return +async def create_letter( + to: str, + from_contact: str, + html: str, + api_key: str, + address_placement: str | None = None, + double_sided: bool | None = None, + color: bool | None = None, + perforated_page: int | None = None, + extra_service: str | None = None, + envelope_type: str | None = None, + return_envelope: str | None = None, + send_date: str | None = None, + description: str | None = None, + express: bool | None = None, + mailing_class: str | None = None, + size: str | None = None, +) -> CreateLetterOutput: + """Create a new letter in PostGrid.""" + if not api_key or not api_key.strip(): + return CreateLetterOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = { + "to": to, + "from": from_contact, + "html": html, + } + if address_placement is not None: + body["addressPlacement"] = address_placement + if double_sided is not None: + body["doubleSided"] = double_sided + if color is not None: + body["color"] = color + if perforated_page is not None: + body["perforatedPage"] = perforated_page + if extra_service is not None: + body["extraService"] = extra_service + if envelope_type is not None: + body["envelopeType"] = envelope_type + if return_envelope is not None: + body["returnEnvelope"] = return_envelope + if send_date is not None: + body["sendDate"] = send_date + if description is not None: + body["description"] = description + if express is not None: + body["express"] = express + if mailing_class is not None: + body["mailingClass"] = mailing_class + if size is not None: + body["size"] = size + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/letters", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreateLetterOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateLetterOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateLetterOutput(success=False, error=f"Call failed: {exc}") + + return CreateLetterOutput( + success=True, + letter=LetterResource( + id=data.get("id"), + object=data.get("object"), + live=data.get("live"), + send_date=data.get("sendDate"), + status=data.get("status"), + url=data.get("url"), + ), + ) + + +@tool(args_schema=CreatePostcardInput) +@serialize_pydantic_return +async def create_postcard( + to: str, + from_contact: str, + front_html: str, + back_html: str, + size: str, + api_key: str, + send_date: str | None = None, + express: bool | None = None, + description: str | None = None, + mailing_class: str | None = None, +) -> CreatePostcardOutput: + """Create a new postcard in PostGrid.""" + if not api_key or not api_key.strip(): + return CreatePostcardOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = { + "to": to, + "from": from_contact, + "frontHTML": front_html, + "backHTML": back_html, + "size": size, + } + if send_date is not None: + body["sendDate"] = send_date + if express is not None: + body["express"] = express + if description is not None: + body["description"] = description + if mailing_class is not None: + body["mailingClass"] = mailing_class + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/postcards", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreatePostcardOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreatePostcardOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreatePostcardOutput(success=False, error=f"Call failed: {exc}") + + return CreatePostcardOutput( + success=True, + postcard=PostcardResource( + id=data.get("id"), + object=data.get("object"), + live=data.get("live"), + send_date=data.get("sendDate"), + status=data.get("status"), + size=data.get("size"), + url=data.get("url"), + ), + ) From 503d918dd2b6be8fffecac5834626dcda32509d0 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Thu, 28 May 2026 10:10:12 +0000 Subject: [PATCH 2/5] auto-integrate: figma Add `figma` integration -- 3 actions (list_comments, delete_comment, post_a_comment) with OAuth2 authentication. **What was staged:** The integration-drafts producer generated a complete Figma integration providing comment management operations via the Figma REST API (https://api.figma.com/v1). Authentication uses OAuth2 with scopes `files:read` and `file_comments:write`. **Auditor findings and patches applied (5 mechanical):** 1. **manifest.py logo** (patch 8.9): Changed `logo="logos:figma"` to `logo="modulex:figma-themed"` to conform to the modulex logo naming convention. 2. **tools.py credential guards** (patch 8.4, x3): Added early-return credential validation to all three tool functions (`list_comments`, `delete_comment`, `post_a_comment`). Each guard checks `auth_data.get("access_token")` and returns an error output immediately if missing, preventing unnecessary API calls with invalid credentials. 3. **tests/test_figma.py failure-path test** (patch 6.5): Added `test_list_comments_empty_credential` test validating that the credential guard returns `success=False` with an appropriate error message when `access_token` is absent. **Additional merger fixes (non-patch):** - Fixed E501 line-length violations in `manifest.py` and `tests/test_figma.py` to satisfy ruff at 100-char limit. - Fixed I001 import-sort violation in `outputs.py` via `ruff --fix`. **Gate results:** ruff (0 errors), mypy --strict (0 issues), pytest (7 tests passed including the new failure-path test). Co-Authored-By: auto-integrate bot Provider: primary Run: 26567891682 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 5 + pyproject.toml | 1 + .../tools/figma/README.md | 34 ++++ .../tools/figma/__init__.py | 21 ++ .../tools/figma/dependencies.toml | 3 + .../tools/figma/manifest.py | 121 ++++++++++++ .../tools/figma/outputs.py | 62 ++++++ .../tools/figma/tests/__init__.py | 1 + .../tools/figma/tests/test_figma.py | 142 ++++++++++++++ src/modulex_integrations/tools/figma/tools.py | 185 ++++++++++++++++++ 10 files changed, 575 insertions(+) create mode 100644 src/modulex_integrations/tools/figma/README.md create mode 100644 src/modulex_integrations/tools/figma/__init__.py create mode 100644 src/modulex_integrations/tools/figma/dependencies.toml create mode 100644 src/modulex_integrations/tools/figma/manifest.py create mode 100644 src/modulex_integrations/tools/figma/outputs.py create mode 100644 src/modulex_integrations/tools/figma/tests/__init__.py create mode 100644 src/modulex_integrations/tools/figma/tests/test_figma.py create mode 100644 src/modulex_integrations/tools/figma/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c12748..30db015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `figma` integration — 3 actions, auth: oauth2. Design collaboration + platform for creating, sharing, and commenting on design files via the + Figma REST API (list_comments, delete_comment, post_a_comment). + Producer-staged by integration-drafts; consumer-side audit applied + 5 patches before merge. - `postgrid` integration — 3 actions, auth: api_key. Programmatic direct mail delivery via the PostGrid Print & Mail API (create_contact, create_letter, create_postcard). Producer-staged by integration-drafts; diff --git a/pyproject.toml b/pyproject.toml index 7264006..73cf59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ slack = "modulex_integrations.tools.slack" etsy = "modulex_integrations.tools.etsy" exa = "modulex_integrations.tools.exa" fal_ai = "modulex_integrations.tools.fal_ai" +figma = "modulex_integrations.tools.figma" tavily = "modulex_integrations.tools.tavily" insightly = "modulex_integrations.tools.insightly" instacart = "modulex_integrations.tools.instacart" diff --git a/src/modulex_integrations/tools/figma/README.md b/src/modulex_integrations/tools/figma/README.md new file mode 100644 index 0000000..c7ac445 --- /dev/null +++ b/src/modulex_integrations/tools/figma/README.md @@ -0,0 +1,34 @@ +# Figma + +Design collaboration platform for creating, sharing, and commenting on design files via the Figma REST API (`api.figma.com`). + +## Authentication + +### OAuth2 Authentication + +- Register an OAuth app at . +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Scopes requested: `files:read`, `file_comments:write` +- Required env vars (only when bringing your own OAuth app): + - `FIGMA_OAUTH2_CLIENT_ID` — your Figma OAuth App Client ID + - `FIGMA_OAUTH2_CLIENT_SECRET` — your Figma OAuth App Client Secret + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `list_comments` | List all comments left on a Figma file | `file_id` | +| `delete_comment` | Delete a comment from a Figma file | `file_id`, `comment_id` | +| `post_a_comment` | Post a comment to a Figma file | `file_id`, `message` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth2 credential. + +## Limits & Quotas + +- Figma REST API rate limit: 30 requests per minute per OAuth token (may vary by endpoint and plan). +- No per-request billing; API access is included with Figma Professional and above. +- Error model: non-2xx responses are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/figma/__init__.py b/src/modulex_integrations/tools/figma/__init__.py new file mode 100644 index 0000000..b2b3891 --- /dev/null +++ b/src/modulex_integrations/tools/figma/__init__.py @@ -0,0 +1,21 @@ +"""Figma integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.figma.manifest import manifest +from modulex_integrations.tools.figma.tools import ( + delete_comment, + list_comments, + post_a_comment, +) + +TOOLS = ( + list_comments, + delete_comment, + post_a_comment, +) + +__all__ = [ + "TOOLS", + "delete_comment", + "list_comments", + "manifest", + "post_a_comment", +] diff --git a/src/modulex_integrations/tools/figma/dependencies.toml b/src/modulex_integrations/tools/figma/dependencies.toml new file mode 100644 index 0000000..bdfbb7f --- /dev/null +++ b/src/modulex_integrations/tools/figma/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the figma integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/figma/manifest.py b/src/modulex_integrations/tools/figma/manifest.py new file mode 100644 index 0000000..3716334 --- /dev/null +++ b/src/modulex_integrations/tools/figma/manifest.py @@ -0,0 +1,121 @@ +"""Figma integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="figma", + display_name="Figma", + description=( + "Design collaboration platform for creating, sharing," + " and commenting on design files" + ), + version="1.0.0", + author="ModuleX", + logo="modulex:figma-themed", + app_url="https://www.figma.com", + categories=["Design", "Productivity & Collaboration"], + actions=[ + ActionDefinition( + name="list_comments", + description="List all comments left on a Figma file", + parameters={ + "file_id": ParameterDef( + type="string", + description="The Figma file ID (found in the file URL after /file/)", + required=True, + ), + }, + ), + ActionDefinition( + name="delete_comment", + description="Delete a comment from a Figma file", + parameters={ + "file_id": ParameterDef( + type="string", + description="The Figma file ID (found in the file URL after /file/)", + required=True, + ), + "comment_id": ParameterDef( + type="string", + description="The ID of the comment to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="post_a_comment", + description="Post a comment to a Figma file", + parameters={ + "file_id": ParameterDef( + type="string", + description="The Figma file ID (found in the file URL after /file/)", + required=True, + ), + "message": ParameterDef( + type="string", + description="The text contents of the comment to post", + required=True, + ), + "comment_id": ParameterDef( + type="string", + description="The ID of the comment to reply to (must be a root comment)", + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Figma OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="FIGMA_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Figma OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + about_url="https://www.figma.com/developers/apps", + ), + EnvVar( + name="FIGMA_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Figma OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + about_url="https://www.figma.com/developers/apps", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://www.figma.com/oauth", + token_url="https://api.figma.com/v1/oauth/token", + scopes=["files:read", "file_comments:write"], + ), + test_endpoint=TestEndpoint( + url="https://api.figma.com/v1/me", + method="GET", + headers={"Authorization": "Bearer {access_token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["id"], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user info", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/figma/outputs.py b/src/modulex_integrations/tools/figma/outputs.py new file mode 100644 index 0000000..f4ab880 --- /dev/null +++ b/src/modulex_integrations/tools/figma/outputs.py @@ -0,0 +1,62 @@ +"""Pydantic response models for the figma integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CommentUser", + "DeleteCommentOutput", + "FigmaComment", + "ListCommentsOutput", + "PostACommentOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class CommentUser(_Base): + """A Figma user who authored a comment.""" + + handle: str | None = None + img_url: str | None = None + id: str | None = None + + +class FigmaComment(_Base): + """A single comment on a Figma file.""" + + id: str | None = None + file_key: str | None = None + parent_id: str | None = None + user: CommentUser | None = None + created_at: str | None = None + resolved_at: str | None = None + message: str | None = None + order_id: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class ListCommentsOutput(_Base): + success: bool + error: str | None = None + comments: list[FigmaComment] = Field(default_factory=list) + + +class DeleteCommentOutput(_Base): + success: bool + error: str | None = None + + +class PostACommentOutput(_Base): + success: bool + error: str | None = None + comment: FigmaComment | None = None diff --git a/src/modulex_integrations/tools/figma/tests/__init__.py b/src/modulex_integrations/tools/figma/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/figma/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/figma/tests/test_figma.py b/src/modulex_integrations/tools/figma/tests/test_figma.py new file mode 100644 index 0000000..1e400b0 --- /dev/null +++ b/src/modulex_integrations/tools/figma/tests/test_figma.py @@ -0,0 +1,142 @@ +"""Happy-path tests for every figma @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.figma import ( + TOOLS, + delete_comment, + list_comments, + manifest, + post_a_comment, +) +from modulex_integrations.tools.figma.outputs import ( + DeleteCommentOutput, + ListCommentsOutput, + PostACommentOutput, +) + +API = "https://api.figma.com" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_3_actions(self) -> None: + assert len(manifest.actions) == 3 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_comments(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/files/ABC123/comments", + json={ + "comments": [ + { + "id": "101", + "file_key": "ABC123", + "parent_id": None, + "user": { + "handle": "Alice", + "img_url": "https://img.example.com/a.png", + "id": "1", + }, + "created_at": "2024-01-15T10:00:00Z", + "resolved_at": None, + "message": "Looks great!", + "order_id": "1", + }, + ], + }, + ) + + result_dict = await list_comments.ainvoke(_args(file_id="ABC123")) + + assert isinstance(result_dict, dict) + result = ListCommentsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.comments) == 1 + assert result.comments[0].message == "Looks great!" + assert result.comments[0].user is not None + assert result.comments[0].user.handle == "Alice" + + +@pytest.mark.asyncio +async def test_delete_comment(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/v1/files/ABC123/comments/101", + json={}, + ) + + result_dict = await delete_comment.ainvoke(_args(file_id="ABC123", comment_id="101")) + + assert isinstance(result_dict, dict) + result = DeleteCommentOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_post_a_comment(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/v1/files/ABC123/comments", + json={ + "id": "202", + "file_key": "ABC123", + "parent_id": None, + "user": {"handle": "Bob", "img_url": "https://img.example.com/b.png", "id": "2"}, + "created_at": "2024-01-16T12:00:00Z", + "resolved_at": None, + "message": "Nice work!", + "order_id": "2", + }, + ) + + result_dict = await post_a_comment.ainvoke(_args(file_id="ABC123", message="Nice work!")) + + assert isinstance(result_dict, dict) + result = PostACommentOutput.model_validate(result_dict) + assert result.success is True + assert result.comment is not None + assert result.comment.id == "202" + assert result.comment.message == "Nice work!" + + +# --- Failure-path test (empty credential) ----------------------------------- + + +@pytest.mark.asyncio +async def test_list_comments_empty_credential(): # type: ignore[no-untyped-def] + """Tool returns error when access_token is missing.""" + result_dict = await list_comments.ainvoke( + {"auth_type": "oauth2", "auth_data": {}, "file_id": "ABC123"} + ) + assert isinstance(result_dict, dict) + result = ListCommentsOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access_token" in result.error diff --git a/src/modulex_integrations/tools/figma/tools.py b/src/modulex_integrations/tools/figma/tools.py new file mode 100644 index 0000000..8d80110 --- /dev/null +++ b/src/modulex_integrations/tools/figma/tools.py @@ -0,0 +1,185 @@ +"""Figma LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.figma.outputs import ( + CommentUser, + DeleteCommentOutput, + FigmaComment, + ListCommentsOutput, + PostACommentOutput, +) + +__all__ = [ + "delete_comment", + "list_comments", + "post_a_comment", +] + +_BASE_URL = "https://api.figma.com" + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Figma API based on auth_type/auth_data.""" + headers: dict[str, str] = {"Accept": "application/json"} + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +def _parse_comment(c: dict[str, Any]) -> FigmaComment: + """Parse a raw Figma comment dict into a FigmaComment model.""" + user_data = c.get("user") or {} + return FigmaComment( + id=c.get("id"), + file_key=c.get("file_key"), + parent_id=c.get("parent_id"), + user=CommentUser( + handle=user_data.get("handle"), + img_url=user_data.get("img_url"), + id=user_data.get("id"), + ), + created_at=c.get("created_at"), + resolved_at=c.get("resolved_at"), + message=c.get("message"), + order_id=c.get("order_id"), + ) + + +# --- Input schemas -------------------------------------------------------- + + +class ListCommentsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + file_id: str = Field(description="The Figma file ID (found in the file URL after /file/)") + + +class DeleteCommentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + file_id: str = Field(description="The Figma file ID (found in the file URL after /file/)") + comment_id: str = Field(description="The ID of the comment to delete") + + +class PostACommentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + file_id: str = Field(description="The Figma file ID (found in the file URL after /file/)") + message: str = Field(description="The text contents of the comment to post") + comment_id: str | None = Field( + default=None, + description="The ID of the comment to reply to (must be a root comment)", + ) + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=ListCommentsInput) +@serialize_pydantic_return +async def list_comments( + auth_type: str, + auth_data: dict[str, Any], + file_id: str, +) -> ListCommentsOutput: + """List all comments left on a Figma file.""" + if not auth_data.get("access_token"): + return ListCommentsOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/v1/files/{file_id}/comments", + headers=headers, + ) + if response.status_code != 200: + return ListCommentsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListCommentsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCommentsOutput(success=False, error=f"Call failed: {exc}") + + raw_comments = data.get("comments", []) + comments = [_parse_comment(c) for c in raw_comments] + return ListCommentsOutput(success=True, comments=comments) + + +@tool(args_schema=DeleteCommentInput) +@serialize_pydantic_return +async def delete_comment( + auth_type: str, + auth_data: dict[str, Any], + file_id: str, + comment_id: str, +) -> DeleteCommentOutput: + """Delete a comment from a Figma file.""" + if not auth_data.get("access_token"): + return DeleteCommentOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.delete( + f"{_BASE_URL}/v1/files/{file_id}/comments/{comment_id}", + headers=headers, + ) + if response.status_code != 200: + return DeleteCommentOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteCommentOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteCommentOutput(success=False, error=f"Call failed: {exc}") + + return DeleteCommentOutput(success=True) + + +@tool(args_schema=PostACommentInput) +@serialize_pydantic_return +async def post_a_comment( + auth_type: str, + auth_data: dict[str, Any], + file_id: str, + message: str, + comment_id: str | None = None, +) -> PostACommentOutput: + """Post a comment to a Figma file.""" + if not auth_data.get("access_token"): + return PostACommentOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + payload: dict[str, Any] = {"message": message} + if comment_id: + payload["comment_id"] = comment_id + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/v1/files/{file_id}/comments", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return PostACommentOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return PostACommentOutput(success=False, error="Request timed out.") + except Exception as exc: + return PostACommentOutput(success=False, error=f"Call failed: {exc}") + + return PostACommentOutput(success=True, comment=_parse_comment(data)) From 8dd484c7ad8d51e173e120380fb3e4a2d84d61dd Mon Sep 17 00:00:00 2001 From: SUY Date: Thu, 28 May 2026 08:51:47 -0500 Subject: [PATCH 3/5] luma: fix app_url to luma.com Luma migrated from lu.ma to luma.com; the manifest's app_url now matches the current canonical domain. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modulex_integrations/tools/luma/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modulex_integrations/tools/luma/manifest.py b/src/modulex_integrations/tools/luma/manifest.py index c918555..844ccb1 100644 --- a/src/modulex_integrations/tools/luma/manifest.py +++ b/src/modulex_integrations/tools/luma/manifest.py @@ -21,7 +21,7 @@ version="1.0.0", author="ModuleX", logo="modulex:luma-themed", - app_url="https://lu.ma", + app_url="https://luma.com", categories=["Events", "Productivity & Collaboration", "Marketing"], actions=[ ActionDefinition( From 1c13f7524debe130910bf7761e3d3ac36121c9b5 Mon Sep 17 00:00:00 2001 From: SUY Date: Thu, 28 May 2026 08:52:01 -0500 Subject: [PATCH 4/5] Polish logo identifiers across 14 manifests Drop the legacy ``-themed`` suffix from internal ``modulex:`` logo slugs, and switch to upstream Iconify ``logos:`` icons where one is available (datadog, figma, help_scout, netlify, pagerduty, product_hunt, shopify_partner). Tools without a clean Iconify match keep the polished ``modulex:`` form (no suffix). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modulex_integrations/tools/azure_storage/manifest.py | 2 +- src/modulex_integrations/tools/browserbase/manifest.py | 2 +- src/modulex_integrations/tools/datadog/manifest.py | 2 +- src/modulex_integrations/tools/figma/manifest.py | 2 +- src/modulex_integrations/tools/freshdesk/manifest.py | 2 +- src/modulex_integrations/tools/help_scout/manifest.py | 2 +- src/modulex_integrations/tools/insightly/manifest.py | 2 +- src/modulex_integrations/tools/microsoft_entra_id/manifest.py | 2 +- src/modulex_integrations/tools/netlify/manifest.py | 2 +- src/modulex_integrations/tools/pagerduty/manifest.py | 2 +- src/modulex_integrations/tools/postgrid/manifest.py | 2 +- src/modulex_integrations/tools/product_hunt/manifest.py | 2 +- src/modulex_integrations/tools/reflect/manifest.py | 2 +- src/modulex_integrations/tools/shopify_partner/manifest.py | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/modulex_integrations/tools/azure_storage/manifest.py b/src/modulex_integrations/tools/azure_storage/manifest.py index b325619..1cd7487 100644 --- a/src/modulex_integrations/tools/azure_storage/manifest.py +++ b/src/modulex_integrations/tools/azure_storage/manifest.py @@ -21,7 +21,7 @@ description="Manage blobs and containers in Microsoft Azure Blob Storage", version="1.0.0", author="ModuleX", - logo="modulex:azure_storage-themed", + logo="modulex:azure_storage", app_url="https://azure.microsoft.com/en-us/products/storage/blobs", categories=["Cloud Infrastructure", "Storage"], actions=[ diff --git a/src/modulex_integrations/tools/browserbase/manifest.py b/src/modulex_integrations/tools/browserbase/manifest.py index 6decf29..23f1db0 100644 --- a/src/modulex_integrations/tools/browserbase/manifest.py +++ b/src/modulex_integrations/tools/browserbase/manifest.py @@ -20,7 +20,7 @@ description="Cloud browser infrastructure for running and managing headless browser sessions", version="1.0.0", author="ModuleX", - logo="modulex:browserbase-themed", + logo="modulex:browserbase", app_url="https://www.browserbase.com", categories=["Developer Tools & Infrastructure", "automation", "browser"], actions=[ diff --git a/src/modulex_integrations/tools/datadog/manifest.py b/src/modulex_integrations/tools/datadog/manifest.py index d24d5fb..ab1fe87 100644 --- a/src/modulex_integrations/tools/datadog/manifest.py +++ b/src/modulex_integrations/tools/datadog/manifest.py @@ -20,7 +20,7 @@ description="Infrastructure monitoring, log management, and application performance platform", version="1.0.0", author="ModuleX", - logo="modulex:datadog-themed", + logo="logos:datadog-icon", app_url="https://www.datadoghq.com", categories=["Monitoring & Observability", "Developer Tools & Infrastructure"], actions=[ diff --git a/src/modulex_integrations/tools/figma/manifest.py b/src/modulex_integrations/tools/figma/manifest.py index 3716334..c76c3df 100644 --- a/src/modulex_integrations/tools/figma/manifest.py +++ b/src/modulex_integrations/tools/figma/manifest.py @@ -24,7 +24,7 @@ ), version="1.0.0", author="ModuleX", - logo="modulex:figma-themed", + logo="logos:figma", app_url="https://www.figma.com", categories=["Design", "Productivity & Collaboration"], actions=[ diff --git a/src/modulex_integrations/tools/freshdesk/manifest.py b/src/modulex_integrations/tools/freshdesk/manifest.py index 9e179b6..9d5406c 100644 --- a/src/modulex_integrations/tools/freshdesk/manifest.py +++ b/src/modulex_integrations/tools/freshdesk/manifest.py @@ -20,7 +20,7 @@ description="Customer support helpdesk platform for managing tickets, contacts, agents, and knowledge base articles via the Freshdesk REST API.", version="1.0.0", author="ModuleX", - logo="modulex:freshdesk-themed", + logo="modulex:freshdesk", app_url="https://freshdesk.com", categories=["Customer Support", "Helpdesk", "Productivity & Collaboration"], actions=[ diff --git a/src/modulex_integrations/tools/help_scout/manifest.py b/src/modulex_integrations/tools/help_scout/manifest.py index e0fb26e..3da388c 100644 --- a/src/modulex_integrations/tools/help_scout/manifest.py +++ b/src/modulex_integrations/tools/help_scout/manifest.py @@ -21,7 +21,7 @@ description="Customer support helpdesk platform with shared inboxes, knowledge base, and live chat", version="1.0.0", author="ModuleX", - logo="modulex:help_scout-themed", + logo="logos:helpscout-icon", app_url="https://www.helpscout.com", categories=["Customer Support", "Communication"], actions=[ diff --git a/src/modulex_integrations/tools/insightly/manifest.py b/src/modulex_integrations/tools/insightly/manifest.py index b609079..decba53 100644 --- a/src/modulex_integrations/tools/insightly/manifest.py +++ b/src/modulex_integrations/tools/insightly/manifest.py @@ -20,7 +20,7 @@ description="CRM and project management platform for managing contacts, tasks, and sales pipelines", version="1.0.0", author="ModuleX", - logo="modulex:insightly-themed", + logo="modulex:insightly", app_url="https://www.insightly.com", categories=["CRM", "Sales", "Productivity & Collaboration"], actions=[ diff --git a/src/modulex_integrations/tools/microsoft_entra_id/manifest.py b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py index e56a975..b4037b5 100644 --- a/src/modulex_integrations/tools/microsoft_entra_id/manifest.py +++ b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py @@ -21,7 +21,7 @@ description="Identity and access management via Microsoft Graph API for users, groups, and directory objects.", version="1.0.0", author="ModuleX", - logo="modulex:microsoft_entra_id-themed", + logo="modulex:microsoft_entra_id", app_url="https://entra.microsoft.com", categories=["Identity & Access Management", "Enterprise", "Security"], actions=[ diff --git a/src/modulex_integrations/tools/netlify/manifest.py b/src/modulex_integrations/tools/netlify/manifest.py index e959827..ea11479 100644 --- a/src/modulex_integrations/tools/netlify/manifest.py +++ b/src/modulex_integrations/tools/netlify/manifest.py @@ -21,7 +21,7 @@ description="Web hosting and automation platform for modern web projects", version="1.0.0", author="ModuleX", - logo="modulex:netlify-themed", + logo="logos:netlify-icon", app_url="https://www.netlify.com", categories=["Developer Tools & Infrastructure", "hosting", "ci-cd"], actions=[ diff --git a/src/modulex_integrations/tools/pagerduty/manifest.py b/src/modulex_integrations/tools/pagerduty/manifest.py index 59daa75..c707955 100644 --- a/src/modulex_integrations/tools/pagerduty/manifest.py +++ b/src/modulex_integrations/tools/pagerduty/manifest.py @@ -21,7 +21,7 @@ description="Incident management and on-call scheduling platform", version="1.0.0", author="ModuleX", - logo="modulex:pagerduty-themed", + logo="logos:pagerduty-icon", app_url="https://www.pagerduty.com", categories=["Incident Management", "Developer Tools & Infrastructure"], actions=[ diff --git a/src/modulex_integrations/tools/postgrid/manifest.py b/src/modulex_integrations/tools/postgrid/manifest.py index 4461876..d6f2c4a 100644 --- a/src/modulex_integrations/tools/postgrid/manifest.py +++ b/src/modulex_integrations/tools/postgrid/manifest.py @@ -18,7 +18,7 @@ name="postgrid", display_name="PostGrid", description="Programmatic direct mail delivery via the PostGrid Print & Mail API", - logo="modulex:postgrid-themed", + logo="modulex:postgrid", version="1.0.0", author="ModuleX", app_url="https://www.postgrid.com", diff --git a/src/modulex_integrations/tools/product_hunt/manifest.py b/src/modulex_integrations/tools/product_hunt/manifest.py index 246b8be..4a6c588 100644 --- a/src/modulex_integrations/tools/product_hunt/manifest.py +++ b/src/modulex_integrations/tools/product_hunt/manifest.py @@ -23,7 +23,7 @@ ), version="1.0.0", author="ModuleX", - logo="modulex:product_hunt-themed", + logo="logos:producthunt", app_url="https://www.producthunt.com", categories=["Productivity & Collaboration", "Marketing"], actions=[ diff --git a/src/modulex_integrations/tools/reflect/manifest.py b/src/modulex_integrations/tools/reflect/manifest.py index 1ede7c9..17f11c7 100644 --- a/src/modulex_integrations/tools/reflect/manifest.py +++ b/src/modulex_integrations/tools/reflect/manifest.py @@ -21,7 +21,7 @@ description="Note-taking and knowledge management via the Reflect API", version="1.0.0", author="ModuleX", - logo="modulex:reflect-themed", + logo="modulex:reflect", app_url="https://reflect.app", categories=["Productivity & Collaboration", "note-taking", "knowledge-management"], actions=[ diff --git a/src/modulex_integrations/tools/shopify_partner/manifest.py b/src/modulex_integrations/tools/shopify_partner/manifest.py index 4dc1ec6..5fdd7df 100644 --- a/src/modulex_integrations/tools/shopify_partner/manifest.py +++ b/src/modulex_integrations/tools/shopify_partner/manifest.py @@ -18,7 +18,7 @@ description="Shopify Partner API for managing apps, verifying webhooks, and accessing partner account data", version="1.0.0", author="ModuleX", - logo="modulex:shopify_partner-themed", + logo="logos:shopify", app_url="https://partners.shopify.com", categories=["ecommerce", "Developer Tools & Infrastructure"], actions=[ From 4ccd175964d2c3621b3c4625b633d4e7dd69c7b3 Mon Sep 17 00:00:00 2001 From: SUY Date: Thu, 28 May 2026 08:52:16 -0500 Subject: [PATCH 5/5] =?UTF-8?q?Rename=20canvas=20=E2=86=92=20instructure?= =?UTF-8?q?=5Fcanvas=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas LMS is Instructure's product. Rename the integration to ``instructure_canvas`` so the package, manifest, entry-point, and docs all carry the vendor prefix — matching the convention used by other vendor-prefixed integrations and removing ambiguity with the unrelated drawing-canvas UI primitive. - Rename directory ``tools/canvas/`` → ``tools/instructure_canvas/`` (8 files, content-preserving via ``git mv``). - Update Python import paths in ``__init__.py``, ``tools.py`` and the test module. - ``manifest.name``: ``canvas`` → ``instructure_canvas``; ``display_name``: ``Canvas LMS`` → ``Instructure Canvas LMS``; ``logo``: ``modulex:canvas-themed`` → ``modulex:instructure_canvas``. - ``pyproject.toml``: update the ``modulex.tools`` entry-point and the ``tool.ruff`` per-file E501 ignore paths. - Rename ``test_canvas.py`` → ``test_instructure_canvas.py``. No backwards-compatibility shim — the previous ``canvas`` entry-point was never published to PyPI (added to staging after v0.4.3a1), so no deployed runtime or workflow references it. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 8 ++++---- .../tools/{canvas => instructure_canvas}/README.md | 4 ++-- .../tools/{canvas => instructure_canvas}/__init__.py | 6 +++--- .../{canvas => instructure_canvas}/dependencies.toml | 2 +- .../tools/{canvas => instructure_canvas}/manifest.py | 8 ++++---- .../tools/{canvas => instructure_canvas}/outputs.py | 2 +- .../{canvas => instructure_canvas}/tests/__init__.py | 0 .../tests/test_instructure_canvas.py} | 6 +++--- .../tools/{canvas => instructure_canvas}/tools.py | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/README.md (88%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/__init__.py (62%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/dependencies.toml (60%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/manifest.py (97%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/outputs.py (96%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/tests/__init__.py (100%) rename src/modulex_integrations/tools/{canvas/tests/test_canvas.py => instructure_canvas/tests/test_instructure_canvas.py} (96%) rename src/modulex_integrations/tools/{canvas => instructure_canvas}/tools.py (99%) diff --git a/pyproject.toml b/pyproject.toml index 73cf59b..c1c85c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ jira = "modulex_integrations.tools.jira" cal_com = "modulex_integrations.tools.cal_com" calendly = "modulex_integrations.tools.calendly" canva = "modulex_integrations.tools.canva" -canvas = "modulex_integrations.tools.canvas" +instructure_canvas = "modulex_integrations.tools.instructure_canvas" linear = "modulex_integrations.tools.linear" linkedin = "modulex_integrations.tools.linkedin" luma = "modulex_integrations.tools.luma" @@ -574,10 +574,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/shopify_partner/manifest.py" = ["E501"] "src/modulex_integrations/tools/shopify_partner/tools.py" = ["E501"] -# canvas manifest and tools have long description string literals in +# instructure_canvas manifest and tools have long description string literals in # ParameterDef / Field kwargs that cannot be wrapped. -"src/modulex_integrations/tools/canvas/manifest.py" = ["E501"] -"src/modulex_integrations/tools/canvas/tools.py" = ["E501"] +"src/modulex_integrations/tools/instructure_canvas/manifest.py" = ["E501"] +"src/modulex_integrations/tools/instructure_canvas/tools.py" = ["E501"] # postgrid manifest and tools have long description string literals in # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/postgrid/manifest.py" = ["E501"] diff --git a/src/modulex_integrations/tools/canvas/README.md b/src/modulex_integrations/tools/instructure_canvas/README.md similarity index 88% rename from src/modulex_integrations/tools/canvas/README.md rename to src/modulex_integrations/tools/instructure_canvas/README.md index d940fad..eae128a 100644 --- a/src/modulex_integrations/tools/canvas/README.md +++ b/src/modulex_integrations/tools/instructure_canvas/README.md @@ -1,6 +1,6 @@ -# Canvas LMS +# Instructure Canvas LMS -Learning management system integration for course, assignment, and user management via the Canvas REST API (`https://{your-domain}/api/v1`). +Learning management system integration for [Instructure Canvas](https://www.instructure.com/canvas) — course, assignment, and user management via the Canvas REST API (`https://{your-domain}/api/v1`). ## Authentication diff --git a/src/modulex_integrations/tools/canvas/__init__.py b/src/modulex_integrations/tools/instructure_canvas/__init__.py similarity index 62% rename from src/modulex_integrations/tools/canvas/__init__.py rename to src/modulex_integrations/tools/instructure_canvas/__init__.py index 07d3602..c368a42 100644 --- a/src/modulex_integrations/tools/canvas/__init__.py +++ b/src/modulex_integrations/tools/instructure_canvas/__init__.py @@ -1,6 +1,6 @@ -"""Canvas LMS integration — discovered via the ``modulex.tools`` entry point.""" -from modulex_integrations.tools.canvas.manifest import manifest -from modulex_integrations.tools.canvas.tools import ( +"""Instructure Canvas LMS integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.instructure_canvas.manifest import manifest +from modulex_integrations.tools.instructure_canvas.tools import ( list_accounts, list_assignments, list_courses, diff --git a/src/modulex_integrations/tools/canvas/dependencies.toml b/src/modulex_integrations/tools/instructure_canvas/dependencies.toml similarity index 60% rename from src/modulex_integrations/tools/canvas/dependencies.toml rename to src/modulex_integrations/tools/instructure_canvas/dependencies.toml index bd62e5e..8eb732c 100644 --- a/src/modulex_integrations/tools/canvas/dependencies.toml +++ b/src/modulex_integrations/tools/instructure_canvas/dependencies.toml @@ -1,3 +1,3 @@ -# Runtime dependencies for the canvas integration. +# Runtime dependencies for the instructure_canvas integration. # CI assembles this into the root pyproject's [project.optional-dependencies]. dependencies = [] diff --git a/src/modulex_integrations/tools/canvas/manifest.py b/src/modulex_integrations/tools/instructure_canvas/manifest.py similarity index 97% rename from src/modulex_integrations/tools/canvas/manifest.py rename to src/modulex_integrations/tools/instructure_canvas/manifest.py index b1e2bec..736fcb5 100644 --- a/src/modulex_integrations/tools/canvas/manifest.py +++ b/src/modulex_integrations/tools/instructure_canvas/manifest.py @@ -1,4 +1,4 @@ -"""Canvas LMS integration manifest.""" +"""Instructure Canvas LMS integration manifest.""" from __future__ import annotations from modulex_integrations.schema import ( @@ -13,12 +13,12 @@ manifest = IntegrationManifest( - name="canvas", - display_name="Canvas LMS", + name="instructure_canvas", + display_name="Instructure Canvas LMS", description="Learning management system for course, assignment, and user management via the Canvas REST API.", version="1.0.0", author="ModuleX", - logo="modulex:canvas-themed", + logo="modulex:instructure_canvas", app_url="https://www.instructure.com/canvas", categories=["Education", "Learning Management"], actions=[ diff --git a/src/modulex_integrations/tools/canvas/outputs.py b/src/modulex_integrations/tools/instructure_canvas/outputs.py similarity index 96% rename from src/modulex_integrations/tools/canvas/outputs.py rename to src/modulex_integrations/tools/instructure_canvas/outputs.py index 63e993e..d4290f0 100644 --- a/src/modulex_integrations/tools/canvas/outputs.py +++ b/src/modulex_integrations/tools/instructure_canvas/outputs.py @@ -1,4 +1,4 @@ -"""Pydantic response models for the canvas integration's @tool functions.""" +"""Pydantic response models for the instructure_canvas integration's @tool functions.""" from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/modulex_integrations/tools/canvas/tests/__init__.py b/src/modulex_integrations/tools/instructure_canvas/tests/__init__.py similarity index 100% rename from src/modulex_integrations/tools/canvas/tests/__init__.py rename to src/modulex_integrations/tools/instructure_canvas/tests/__init__.py diff --git a/src/modulex_integrations/tools/canvas/tests/test_canvas.py b/src/modulex_integrations/tools/instructure_canvas/tests/test_instructure_canvas.py similarity index 96% rename from src/modulex_integrations/tools/canvas/tests/test_canvas.py rename to src/modulex_integrations/tools/instructure_canvas/tests/test_instructure_canvas.py index afe5332..eee3b0f 100644 --- a/src/modulex_integrations/tools/canvas/tests/test_canvas.py +++ b/src/modulex_integrations/tools/instructure_canvas/tests/test_instructure_canvas.py @@ -1,11 +1,11 @@ -"""Happy-path tests for every canvas @tool, plus a manifest sanity check.""" +"""Happy-path tests for every instructure_canvas @tool, plus a manifest sanity check.""" from __future__ import annotations from typing import Any import pytest -from modulex_integrations.tools.canvas import ( +from modulex_integrations.tools.instructure_canvas import ( TOOLS, list_accounts, list_assignments, @@ -14,7 +14,7 @@ search_course_content, update_assignment, ) -from modulex_integrations.tools.canvas.outputs import ( +from modulex_integrations.tools.instructure_canvas.outputs import ( ListAccountsOutput, ListAssignmentsOutput, ListCoursesOutput, diff --git a/src/modulex_integrations/tools/canvas/tools.py b/src/modulex_integrations/tools/instructure_canvas/tools.py similarity index 99% rename from src/modulex_integrations/tools/canvas/tools.py rename to src/modulex_integrations/tools/instructure_canvas/tools.py index da57dcb..f785672 100644 --- a/src/modulex_integrations/tools/canvas/tools.py +++ b/src/modulex_integrations/tools/instructure_canvas/tools.py @@ -1,4 +1,4 @@ -"""Canvas LMS LangChain @tool functions.""" +"""Instructure Canvas LMS LangChain @tool functions.""" from __future__ import annotations from typing import Any @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from modulex_integrations import serialize_pydantic_return -from modulex_integrations.tools.canvas.outputs import ( +from modulex_integrations.tools.instructure_canvas.outputs import ( AccountOption, AssignmentSummary, CourseSummary,