|
| 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()) |
0 commit comments