Skip to content

Commit c831dde

Browse files
authored
Merge pull request #9 from ModuleXAI/staging
Release v0.7.0: Google OAuth scope trim (breaking)
2 parents 5c69632 + 9d0adfb commit c831dde

102 files changed

Lines changed: 469 additions & 2940 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

scripts/export_manifests.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/env python3
2+
"""Export every integration manifest to a single ``manifests.json``.
3+
4+
This is the **data contract** consumed by the modulex-docs site generator
5+
(``scripts/generate-integrations.js``) to render one documentation page per
6+
integration. It is a build/CI tool, not part of the shipped runtime package —
7+
it lives under ``scripts/`` and imports the installed ``modulex_integrations``.
8+
9+
Design (why it looks the way it does):
10+
11+
* **Discovery is filesystem-based, not entry-point-based.** Some integrations
12+
exist on disk before their ``modulex.tools`` entry point is registered in
13+
``pyproject.toml``; entry-point discovery would silently drop them from the
14+
docs. We walk ``src/modulex_integrations/tools/*/manifest.py`` instead, and
15+
cross-check against the entry-point group so missing registrations surface as
16+
warnings (the modulex runtime can't see those tools either).
17+
18+
* **Manifests load in isolation.** Each ``manifest.py`` only depends on
19+
``modulex_integrations.schema`` (no SDK/optional deps), so we exec it via
20+
``spec_from_file_location`` *without* triggering the package ``__init__`` —
21+
which would import ``tools.py`` and pull in per-integration optional
22+
dependencies (boto3, snowflake-connector, psycopg, ...) that may not be
23+
installed. This guarantees all integrations export even in a base venv.
24+
25+
* **``output_schema`` is best-effort.** Deriving it requires importing
26+
``tools.py`` (to read each ``@tool``'s pydantic return annotation via
27+
``typing.get_type_hints``, mirroring modulex's ``package_loader``). When that
28+
import fails (missing optional dep) we emit ``null`` for that integration's
29+
action schemas and record a warning, rather than failing the whole export.
30+
31+
Usage::
32+
33+
.venv/bin/python scripts/export_manifests.py # -> dist/manifests.json
34+
.venv/bin/python scripts/export_manifests.py --out - # -> stdout
35+
"""
36+
from __future__ import annotations
37+
38+
import argparse
39+
import importlib
40+
import importlib.util
41+
import inspect
42+
import json
43+
import sys
44+
from pathlib import Path
45+
from typing import Any
46+
47+
SCHEMA_VERSION = "1.0"
48+
49+
50+
def _repo_root() -> Path:
51+
"""Repo root, derived from this file's location (scripts/ is a sibling of src/)."""
52+
return Path(__file__).resolve().parent.parent
53+
54+
55+
def _tools_dir() -> Path:
56+
return _repo_root() / "src" / "modulex_integrations" / "tools"
57+
58+
59+
def _package_version() -> str:
60+
try:
61+
from importlib.metadata import version
62+
63+
return version("modulex-integrations")
64+
except Exception:
65+
return "0.0.0+unknown"
66+
67+
68+
def _registered_entry_point_names() -> set[str] | None:
69+
"""Names in pyproject's ``modulex.tools`` entry-point table (source of truth).
70+
71+
Parsed from ``pyproject.toml`` directly, NOT from installed package metadata:
72+
an editable install's entry-point metadata goes stale the moment a new tool
73+
is added to ``pyproject.toml`` without a reinstall, which would wrongly flag
74+
freshly-added (but correctly registered) tools as missing. Returns ``None``
75+
if the table can't be read, signalling callers to skip the check entirely.
76+
"""
77+
try:
78+
import tomllib
79+
80+
data = tomllib.loads(
81+
(_repo_root() / "pyproject.toml").read_text(encoding="utf-8")
82+
)
83+
return set(data["project"]["entry-points"]["modulex.tools"].keys())
84+
except Exception:
85+
return None
86+
87+
88+
def _load_manifest(manifest_path: Path, name: str) -> Any:
89+
"""Exec a single ``manifest.py`` in isolation and return its ``manifest`` object.
90+
91+
Bypasses the package ``__init__`` (and therefore ``tools.py`` and its
92+
optional deps) by loading the file under a synthetic module name.
93+
"""
94+
spec = importlib.util.spec_from_file_location(f"_mxi_manifest_{name}", manifest_path)
95+
if spec is None or spec.loader is None:
96+
raise ImportError(f"cannot build import spec for {manifest_path}")
97+
module = importlib.util.module_from_spec(spec)
98+
spec.loader.exec_module(module)
99+
return module.manifest
100+
101+
102+
def _output_schema_for(tool_obj: Any) -> dict[str, Any] | None:
103+
"""Derive an action's output JSONSchema from its ``@tool`` return annotation.
104+
105+
Mirrors modulex's ``package_loader``: unwrap the ``serialize_pydantic_return``
106+
wrapper (``functools.wraps`` exposes ``__wrapped__``) so ``get_type_hints``
107+
resolves the stringified PEP 563 annotation against the original module's
108+
globals, then call ``model_json_schema()`` on the pydantic return type.
109+
"""
110+
fn = getattr(tool_obj, "coroutine", None) or getattr(tool_obj, "func", None)
111+
if fn is None:
112+
return None
113+
fn = inspect.unwrap(fn)
114+
try:
115+
from typing import get_type_hints
116+
117+
hints = get_type_hints(fn)
118+
except Exception:
119+
return None
120+
return_type = hints.get("return")
121+
if return_type is None or not hasattr(return_type, "model_json_schema"):
122+
return None
123+
try:
124+
return return_type.model_json_schema()
125+
except Exception:
126+
return None
127+
128+
129+
def _parse_readme(readme_path: Path) -> dict[str, Any]:
130+
"""Split a 5-section README into intro + ``{heading: body}`` sections.
131+
132+
The unique hand-written prose (especially 'Limits & Quotas') is what keeps
133+
each generated docs page from being thin/duplicate content for SEO.
134+
"""
135+
if not readme_path.is_file():
136+
return {}
137+
intro_lines: list[str] = []
138+
sections: dict[str, str] = {}
139+
current: str | None = None
140+
buf: list[str] = []
141+
142+
def _flush() -> None:
143+
if current is not None:
144+
sections[current] = "\n".join(buf).strip()
145+
146+
for raw in readme_path.read_text(encoding="utf-8").splitlines():
147+
if raw.startswith("# "): # h1 title — skip, intro follows
148+
continue
149+
if raw.startswith("## "): # new level-2 section
150+
_flush()
151+
current = raw[3:].strip()
152+
buf = []
153+
continue
154+
if current is None:
155+
intro_lines.append(raw)
156+
else:
157+
buf.append(raw)
158+
_flush()
159+
160+
return {"intro": "\n".join(intro_lines).strip(), "sections": sections}
161+
162+
163+
def _export_one(
164+
name: str, registered: set[str] | None, warnings: list[str]
165+
) -> dict[str, Any] | None:
166+
base = _tools_dir() / name
167+
try:
168+
manifest = _load_manifest(base / "manifest.py", name)
169+
except Exception as exc: # malformed/partial integration — skip, don't abort
170+
warnings.append(f"{name}: manifest load failed ({type(exc).__name__}: {exc})")
171+
return None
172+
173+
data: dict[str, Any] = manifest.model_dump(mode="json")
174+
# ``registered`` is None when pyproject couldn't be read — treat as unknown
175+
# (optimistic True, no warning) rather than flag every tool.
176+
known = registered is not None
177+
data["registered_entry_point"] = (not known) or name in registered
178+
if known and name not in registered:
179+
warnings.append(
180+
f'{name}: on disk but NOT in pyproject [project.entry-points."modulex.tools"]'
181+
)
182+
183+
# Best-effort output schemas (requires importing tools.py + its optional deps).
184+
tools_by_name: dict[str, Any] = {}
185+
try:
186+
pkg = importlib.import_module(f"modulex_integrations.tools.{name}")
187+
tools_by_name = {t.name: t for t in getattr(pkg, "TOOLS", ())}
188+
except Exception as exc:
189+
warnings.append(
190+
f"{name}: tools import failed ({type(exc).__name__}); output_schema omitted"
191+
)
192+
193+
for action in data.get("actions", []):
194+
tool_obj = tools_by_name.get(action["name"])
195+
action["output_schema"] = _output_schema_for(tool_obj) if tool_obj else None
196+
197+
data["readme"] = _parse_readme(base / "README.md")
198+
return data
199+
200+
201+
def build() -> dict[str, Any]:
202+
tools_dir = _tools_dir()
203+
if not tools_dir.is_dir():
204+
raise SystemExit(f"tools dir not found: {tools_dir}")
205+
206+
names = sorted(
207+
p.name
208+
for p in tools_dir.iterdir()
209+
if p.is_dir() and (p / "manifest.py").is_file()
210+
)
211+
registered = _registered_entry_point_names()
212+
warnings: list[str] = []
213+
integrations: list[dict[str, Any]] = []
214+
215+
for name in names:
216+
record = _export_one(name, registered, warnings)
217+
if record is not None:
218+
integrations.append(record)
219+
220+
return {
221+
"schema_version": SCHEMA_VERSION,
222+
"package_version": _package_version(),
223+
"integration_count": len(integrations),
224+
"warnings": warnings,
225+
"integrations": integrations,
226+
}
227+
228+
229+
def main(argv: list[str] | None = None) -> int:
230+
parser = argparse.ArgumentParser(description=__doc__)
231+
parser.add_argument(
232+
"--out",
233+
default="dist/manifests.json",
234+
help="output path, or '-' for stdout (default: dist/manifests.json)",
235+
)
236+
parser.add_argument("--indent", type=int, default=2, help="JSON indent (default: 2)")
237+
args = parser.parse_args(argv)
238+
239+
payload = build()
240+
text = json.dumps(payload, indent=args.indent, ensure_ascii=False)
241+
242+
if args.out == "-":
243+
sys.stdout.write(text + "\n")
244+
else:
245+
out_path = Path(args.out)
246+
if not out_path.is_absolute():
247+
out_path = _repo_root() / out_path
248+
out_path.parent.mkdir(parents=True, exist_ok=True)
249+
out_path.write_text(text + "\n", encoding="utf-8")
250+
print(f"wrote {out_path} ({payload['integration_count']} integrations)", file=sys.stderr)
251+
252+
schema_ok = sum(
253+
1
254+
for it in payload["integrations"]
255+
for a in it.get("actions", [])
256+
if a.get("output_schema") is not None
257+
)
258+
schema_total = sum(len(it.get("actions", [])) for it in payload["integrations"])
259+
print(
260+
f"output_schema coverage: {schema_ok}/{schema_total} actions · "
261+
f"{len(payload['warnings'])} warning(s)",
262+
file=sys.stderr,
263+
)
264+
return 0
265+
266+
267+
if __name__ == "__main__":
268+
raise SystemExit(main())

src/modulex_integrations/tools/ahrefs/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
author="ModuleX",
2424
logo="modulex:ahrefs",
2525
app_url="https://ahrefs.com",
26-
categories=["SEO", "Marketing", "Web Search & Scraping"],
26+
categories=["Marketing & Advertising", "SEO", "Marketing", "Web Search & Scraping"],
2727
actions=[
2828
ActionDefinition(
2929
name="get_backlinks",

src/modulex_integrations/tools/algolia/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
author="ModuleX",
2323
logo="modulex:algolia",
2424
app_url="https://www.algolia.com",
25-
categories=["Search", "Developer Tools & Infrastructure"],
25+
categories=["Developer Tools & Infrastructure", "Search"],
2626
actions=[
2727
ActionDefinition(
2828
name="browse_records",

src/modulex_integrations/tools/amazon_alexa/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
author="ModuleX",
2424
logo="modulex:amazon_alexa",
2525
app_url="https://developer.amazon.com/alexa",
26-
categories=["Voice Assistants", "IoT", "Smart Home"],
26+
categories=["AI & Machine Learning", "Voice Assistants", "IoT", "Smart Home"],
2727
actions=[
2828
ActionDefinition(
2929
name="simulate_skill",

src/modulex_integrations/tools/apollo_io/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _pagination_params(default_per_page: int = 25) -> dict[str, ParameterDef]:
3737
author="ModuleX",
3838
logo="modulex:apolloio",
3939
app_url="https://www.apollo.io",
40-
categories=["CRM & Customer", "sales", "customer_support"],
40+
categories=["Sales", "CRM & Customer", "sales", "customer_support"],
4141
actions=[
4242
# --- Enrichment ---
4343
ActionDefinition(

src/modulex_integrations/tools/bloomerang/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
version="1.0.0",
2323
author="ModuleX",
2424
app_url="https://bloomerang.co",
25-
categories=["Nonprofit", "CRM", "Fundraising"],
25+
categories=["CRM", "Nonprofit", "Fundraising"],
2626
actions=[
2727
ActionDefinition(
2828
name="create_constituent",

src/modulex_integrations/tools/browser_use/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
version="1.0.0",
2323
author="ModuleX",
2424
app_url="https://browser-use.com",
25-
categories=["Automation", "AI", "Developer Tools & Infrastructure"],
25+
categories=["AI & Machine Learning", "Automation", "AI", "Developer Tools & Infrastructure"],
2626
actions=[
2727
ActionDefinition(
2828
name="create_session",

src/modulex_integrations/tools/cal_com/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
author="ModuleX",
2323
logo="modulex:cal_com-themed",
2424
app_url="https://cal.com",
25-
categories=["Productivity & Collaboration", "scheduling", "calendar"],
25+
categories=["Scheduling & Events", "Productivity & Collaboration", "scheduling", "calendar"],
2626
actions=[
2727
ActionDefinition(
2828
name="create_booking",

src/modulex_integrations/tools/calendly/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _pagination_params() -> dict[str, ParameterDef]:
5050
author="ModuleX",
5151
logo="modulex:calendly",
5252
app_url="https://calendly.com",
53-
categories=["Productivity", "calendar", "productivity", "meetings"],
53+
categories=["Scheduling & Events", "Productivity", "calendar", "productivity", "meetings"],
5454
actions=[
5555
ActionDefinition(
5656
name="get_current_user",

src/modulex_integrations/tools/canva/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
author="ModuleX",
2424
logo="modulex:canva",
2525
app_url="https://www.canva.com",
26-
categories=["Design & Creative Tools", "Productivity & Collaboration"],
26+
categories=["Productivity & Collaboration", "Design & Creative Tools"],
2727
actions=[
2828
ActionDefinition(
2929
name="create_design",

0 commit comments

Comments
 (0)