From cff81903ca1ab9c25c9f85f3e01561bee0f0ec73 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 12:32:27 +1000 Subject: [PATCH 1/9] Add MCP server + @modelith chat participant to the VS Code surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot Chat integrations, one per mode, sharing one query layer so the two frontends never drift. The spec sketched the MCP server as a Node module compiled against packages/core, but core is Python — Node can't import it, and every other integration here (LSP, canvas) runs by spawning the mdl CLI. So the server IS a Python `mdl mcp` subcommand over stdio, registered from VS Code exactly like the LSP. - packages/mcp (modelith-mcp): FastMCP stdio server with seven tools. Reads (list_entities, get_entity, search_ontology, get_model_context, validate) come from the new shared query layer; writes (create_entity, update_entity) go through mdl_core.commands.apply_command, so they take the same validation + fingerprint path as any edit. Direct-write to the checkout by design (§1). Pinned mcp>=1.2,<2 (v1 FastMCP; editor clients target v1 today). - packages/core/src/mdl_core/query.py: the shared read layer — condensed, chat-context-sized shapes with name-or-ULID entity lookup (the IR has only ULID/domain/code-set name indexes). Backs both AI frontends and `mdl model`. - CLI: `mdl mcp --repo ` starts the server; `mdl model context|entities| entity` print the query layer as JSON (the chat participant's data path). - VS Code: mcpProvider.ts registers the stdio server via McpStdioServerDefinition, resolving mdl through findMdl and passing the workspace dir as --repo; guarded for the 1.99+ API so older hosts keep every other feature. chatParticipant.ts implements @modelith for Ask mode (/list, /explain, name-an-entity, summarise) over the same `mdl model` reads. Verified: real MCP stdio handshake (initialize -> tools/list -> tools/call) on the demo model; create/update entities land on disk and reflect in reads; 593 pytest pass (+13), ruff clean, extension tsc+esbuild+vsce clean. Co-Authored-By: Claude Opus 4.8 --- hatch_build.py | 1 + packages/cli/pyproject.toml | 2 + packages/cli/src/mdl_cli/main.py | 61 ++++ packages/core/src/mdl_core/query.py | 191 +++++++++++ packages/mcp/pyproject.toml | 25 ++ packages/mcp/src/mdl_mcp/__init__.py | 11 + packages/mcp/src/mdl_mcp/server.py | 244 ++++++++++++++ packages/mcp/tests/conftest.py | 15 + packages/mcp/tests/test_query.py | 54 ++++ packages/mcp/tests/test_server.py | 96 ++++++ pyproject.toml | 2 + uv.lock | 458 ++++++++++++++++++++++++++- vscode/CHANGELOG.md | 10 + vscode/README.md | 21 ++ vscode/package.json | 27 +- vscode/src/chatParticipant.ts | 183 +++++++++++ vscode/src/extension.ts | 8 + vscode/src/mcpProvider.ts | 54 ++++ 18 files changed, 1461 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/mdl_core/query.py create mode 100644 packages/mcp/pyproject.toml create mode 100644 packages/mcp/src/mdl_mcp/__init__.py create mode 100644 packages/mcp/src/mdl_mcp/server.py create mode 100644 packages/mcp/tests/conftest.py create mode 100644 packages/mcp/tests/test_query.py create mode 100644 packages/mcp/tests/test_server.py create mode 100644 vscode/src/chatParticipant.ts create mode 100644 vscode/src/mcpProvider.ts diff --git a/hatch_build.py b/hatch_build.py index 0bacf21..a529613 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -39,6 +39,7 @@ "packages/catalog/src/mdl_catalog": "mdl_catalog", "packages/server/src/mdl_server": "mdl_server", "packages/lsp/src/mdl_lsp": "mdl_lsp", + "packages/mcp/src/mdl_mcp": "mdl_mcp", "packages/adapters/collibra/src/mdl_adapter_collibra": "mdl_adapter_collibra", } diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index 72a60ca..bc1ab74 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "modelith-adapter-collibra", "modelith-server", "modelith-lsp", + "modelith-mcp", "typer>=0.12", ] @@ -45,3 +46,4 @@ modelith-catalog = { workspace = true } modelith-adapter-collibra = { workspace = true } modelith-server = { workspace = true } modelith-lsp = { workspace = true } +modelith-mcp = { workspace = true } diff --git a/packages/cli/src/mdl_cli/main.py b/packages/cli/src/mdl_cli/main.py index 7b77c1d..80e8944 100644 --- a/packages/cli/src/mdl_cli/main.py +++ b/packages/cli/src/mdl_cli/main.py @@ -1677,6 +1677,67 @@ def lsp() -> None: lsp_main() +model_app = typer.Typer( + help="Read the model as JSON (the shared query layer behind the AI surfaces)." +) +app.add_typer(model_app, name="model") + + +@model_app.command("context") +def model_context(model_dir: Path = typer.Option(Path("."), "--model-dir", "-m")) -> None: + """Print a condensed model summary (project, counts, subject areas, relationships) + as JSON — sized for a chat context window. Backs `@modelith` in VS Code Ask mode + and the MCP `get_model_context` tool.""" + from mdl_core.query import get_model_context + + typer.echo(json.dumps(get_model_context(_load(model_dir).model), default=str)) + + +@model_app.command("entities") +def model_entities( + model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), + subject_area: str = typer.Option(None, "--subject-area", help="Scope by area name or ULID"), +) -> None: + """List logical entities (name, definition, attribute count, subject area) as JSON.""" + from mdl_core.query import list_entities + + typer.echo(json.dumps(list_entities(_load(model_dir).model, subject_area), default=str)) + + +@model_app.command("entity") +def model_entity( + name: str = typer.Argument(..., help="Entity name or ULID"), + model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), +) -> None: + """Print one entity's full detail (attributes, conceptual layer, relationships) as + JSON. Exits 1 with an error object if no entity matches.""" + from mdl_core.query import get_entity + + hit = get_entity(_load(model_dir).model, name) + if hit is None: + typer.echo(json.dumps({"error": f"no entity named {name!r}"})) + raise typer.Exit(1) + typer.echo(json.dumps(hit, default=str)) + + +@app.command() +def mcp( + repo: Path = typer.Option( + Path("."), "--repo", "-m", help="Model repo directory (contains mdl-project.yaml)" + ), +) -> None: + """Start the Modelith MCP server (stdio) — model + ontology tools for an AI + agent (VS Code Copilot Chat in agent mode, Claude Desktop, Cursor). + + Reads (list_entities, get_entity, search_ontology, get_model_context, validate) + and writes (create_entity, update_entity) share the same core query + command + engine as the CLI and canvas. Writes go straight to this checkout — the + engineer's own trust boundary, not the SME propose-as-PR flow.""" + from mdl_mcp.server import run as mcp_run + + mcp_run(repo) + + @app.command() def serve( model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), diff --git a/packages/core/src/mdl_core/query.py b/packages/core/src/mdl_core/query.py new file mode 100644 index 0000000..5fb0be1 --- /dev/null +++ b/packages/core/src/mdl_core/query.py @@ -0,0 +1,191 @@ +"""The shared read/query layer for the AI surfaces (MCP server + chat participant). + +Both the VS Code MCP server (agent mode) and the `@modelith` chat participant +answer questions from the same functions here, so the two frontends never drift. +The shapes are deliberately CONDENSED — sized for a chat context window — not the +full canvas projection (`mdl_server.projection`), which pre-joins everything the +React graph needs. A grounding tool wants "what exists and how it connects" in a +few hundred tokens, not the whole wire model. + +Everything here is a pure read over an in-memory `Model`; nothing loads or writes. +The CLI/MCP wrappers do the `ModelRepo.load` and hand the model in. +""" + +from __future__ import annotations + +from mdl_core.ir import ConceptualEntity, LogicalEntity, Model, Term + + +def _entity_by_name(model: Model, name: str) -> LogicalEntity | None: + """Resolve a logical entity by name or ULID. There is no name index in the IR + (only ULID-keyed dicts and domain/code-set name lookups), so match by name + case-insensitively, then fall back to a ULID hit.""" + lname = name.lower() + for le in model.logical_entities.values(): + if le.name.lower() == lname: + return le + obj = model.logical_entities.get(name) + return obj + + +def _ontology_summary(obj: ConceptualEntity | Term | LogicalEntity) -> str | None: + """The object's canonical alignment as a single prefixed IRI, or None. Reads the + `primary_ref` accessor (source of truth post-migration).""" + ref = getattr(obj, "primary_ref", None) + if ref is None: + return None + return ref.uri or None + + +def list_entities(model: Model, subject_area: str | None = None) -> list[dict]: + """Grounding: every logical entity as {name, definition, attribute_count, + subject_area}, sorted by name. With `subject_area` (name or ULID), scope to + entities homed in / realising that area.""" + scope: set[str] | None = None + if subject_area: + sa_id = _resolve_subject_area_id(model, subject_area) + # An unknown area name scopes to NOTHING (empty set), not to everything — + # returning the whole model on a typo'd filter would silently mislead. + if sa_id is None: + return [] + sa = model.subject_areas.get(sa_id) + ce_ids = set(sa.members) if sa else set() + ce_ids |= { + ce.id for ce in model.conceptual_entities.values() if ce.subject_area == sa_id + } + scope = {le.id for le in model.logical_entities.values() if le.realises in ce_ids} + + out: list[dict] = [] + for le in sorted(model.logical_entities.values(), key=lambda e: e.name): + if scope is not None and le.id not in scope: + continue + ce = model.conceptual_entities.get(le.realises) if le.realises else None + sa = ( + model.subject_areas.get(ce.subject_area) + if ce and ce.subject_area + else None + ) + out.append( + { + "name": le.name, + "definition": (le.definition or (ce.definition if ce else None)), + "attribute_count": len(le.attributes), + "subject_area": sa.name if sa else None, + } + ) + return out + + +def get_entity(model: Model, name: str) -> dict | None: + """Full detail for one entity by name or ULID: attributes (with types + ontology + alignment), the conceptual layer, and the relationships it participates in. + Returns None when no entity matches.""" + le = _entity_by_name(model, name) + if le is None: + return None + ce = model.conceptual_entities.get(le.realises) if le.realises else None + + attributes = [ + { + "name": a.name, + "definition": a.definition, + "domain": a.domain, + "nullable": a.nullable, + "ontology": _ontology_summary(a), + } + for a in le.attributes + ] + + rels: list[dict] = [] + for r in sorted(model.relationships.values(), key=lambda x: x.name): + from_e = model.logical_entities.get(r.from_.entity) + to_e = model.logical_entities.get(r.to.entity) + if le.id not in (r.from_.entity, r.to.entity): + continue + rels.append( + { + "name": r.name, + "from": from_e.name if from_e else r.from_.entity, + "to": to_e.name if to_e else r.to.entity, + "definition": r.definition, + } + ) + + return { + "name": le.name, + "definition": le.definition or (ce.definition if ce else None), + "pattern": le.pattern, + "unmanaged": bool(le.unmanaged), + "conceptual": ( + { + "name": ce.name, + "definition": ce.definition, + "synonyms": list(ce.synonyms), + "ontology": _ontology_summary(ce), + "ontology_layer": ce.ontology_layer, + } + if ce + else None + ), + "attributes": attributes, + "relationships": rels, + } + + +def get_model_context(model: Model) -> dict: + """A condensed model summary sized for a chat context window: the project name, + counts, subject areas with their entity lists, and every relationship as a + from->to sentence. Enough to ground an answer without shipping the whole model.""" + ce_by_id = model.conceptual_entities + + # subject areas -> the logical entities homed there + areas: list[dict] = [] + for sa in sorted(model.subject_areas.values(), key=lambda s: s.name): + ce_ids = set(sa.members) + ce_ids |= {ce.id for ce in ce_by_id.values() if ce.subject_area == sa.id} + names = sorted( + le.name for le in model.logical_entities.values() if le.realises in ce_ids + ) + areas.append({"name": sa.name, "definition": sa.definition, "entities": names}) + + unhomed = sorted( + le.name + for le in model.logical_entities.values() + if not le.realises or ce_by_id.get(le.realises) is None + ) + + relationships = [] + for r in sorted(model.relationships.values(), key=lambda x: x.name): + from_e = model.logical_entities.get(r.from_.entity) + to_e = model.logical_entities.get(r.to.entity) + relationships.append( + { + "name": r.name, + "from": from_e.name if from_e else r.from_.entity, + "to": to_e.name if to_e else r.to.entity, + } + ) + + return { + "project": model.config.name, + "counts": { + "entities": len(model.logical_entities), + "relationships": len(model.relationships), + "subject_areas": len(model.subject_areas), + "terms": len(model.terms), + }, + "subject_areas": areas, + "unassigned_entities": unhomed, + "relationships": relationships, + } + + +def _resolve_subject_area_id(model: Model, ref: str) -> str | None: + """Accept a subject-area name or ULID; return its ULID, or None if unknown.""" + if ref in model.subject_areas: + return ref + lref = ref.lower() + for sa in model.subject_areas.values(): + if sa.name.lower() == lref: + return sa.id + return None diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml new file mode 100644 index 0000000..a6d8e26 --- /dev/null +++ b/packages/mcp/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "modelith-mcp" +version = "0.1.0" +description = "Modelith MCP server — model + ontology tools for AI agents (Copilot Chat agent mode, Claude, etc.)" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [ + "modelith-core", + "modelith-ontology", + # v1 FastMCP API. mcp 2.x renamed FastMCP -> MCPServer and is still very new; + # editor MCP clients (VS Code, Claude Desktop, Cursor) target the v1 stdio + # server today, so pin below 2 until the ecosystem moves. + "mcp>=1.2,<2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mdl_mcp"] + +[tool.uv.sources] +modelith-core = { workspace = true } +modelith-ontology = { workspace = true } diff --git a/packages/mcp/src/mdl_mcp/__init__.py b/packages/mcp/src/mdl_mcp/__init__.py new file mode 100644 index 0000000..34079a2 --- /dev/null +++ b/packages/mcp/src/mdl_mcp/__init__.py @@ -0,0 +1,11 @@ +"""Modelith MCP server. + +Exposes the model as MCP tools for an AI agent (VS Code Copilot Chat in *agent* +mode, Claude Desktop, Cursor, …). Reads come from `mdl_core.query`; writes go +through `mdl_core.commands.apply_command`, so they take the same validation and +concurrency path as every other Modelith write. Run it with `mdl mcp --repo `. +""" + +from mdl_mcp.server import build_server, run + +__all__ = ["build_server", "run"] diff --git a/packages/mcp/src/mdl_mcp/server.py b/packages/mcp/src/mdl_mcp/server.py new file mode 100644 index 0000000..1e2e929 --- /dev/null +++ b/packages/mcp/src/mdl_mcp/server.py @@ -0,0 +1,244 @@ +"""The Modelith MCP server (stdio). + +Seven tools, mirroring the AI spec. Reads (`list_entities`, `get_entity`, +`search_ontology`, `get_model_context`, `validate`) come from the shared query +layer; writes (`create_entity`, `update_entity`) go through +`mdl_core.commands.apply_command` so they are validated and fingerprint-guarded +exactly like a canvas or CLI edit. + +Direct local write is intentional (spec §1): this runs against the engineer's own +checkout, a different trust boundary than the SME app's propose-as-PR flow. + +The server is bound to ONE model directory, passed at startup (`--repo`). A +programmatically registered MCP server has no implicit repo scope the way a +committed `.vscode/mcp.json` would, so VS Code passes the workspace folder through. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from mdl_core import query +from mdl_core.commands import CommandError, StaleModelError, apply_command +from mdl_core.diagnostics import Severity +from mdl_core.repo import ModelRepo +from mdl_core.validate import validate as run_validate + + +def build_server(repo_dir: Path) -> FastMCP: + """Assemble the MCP server bound to `repo_dir`. Split out from `run()` so tests + can drive the tools without stdio.""" + repo_dir = repo_dir.resolve() + mcp = FastMCP("modelith") + + def _model(): + # Reload per call: an agent's own writes (and a human editing alongside it) + # must be visible to the next read. Cheap on the demo; the LSP/server carry + # their own caches for the hot paths. + return ModelRepo.load(repo_dir).model + + # --- reads ------------------------------------------------------------------ + + @mcp.tool() + def list_entities(subject_area: str | None = None) -> str: + """List the model's logical entities (name, definition, attribute count, + subject area). Grounding: call this first to see what already exists. + Optionally scope to a subject area by name or ULID.""" + return _json(query.list_entities(_model(), subject_area)) + + @mcp.tool() + def get_entity(name: str) -> str: + """Full detail for one entity by name (or ULID): its attributes with types + and ontology alignment, the conceptual layer, and the relationships it takes + part in. Returns an error object if no entity matches.""" + hit = query.get_entity(_model(), name) + if hit is None: + return _json({"error": f"no entity named {name!r}"}) + return _json(hit) + + @mcp.tool() + def get_model_context() -> str: + """A condensed summary of the whole model — project, counts, subject areas + with their entities, and every relationship — sized for a chat context + window. Use it to orient before answering a broad question.""" + return _json(query.get_model_context(_model())) + + @mcp.tool() + def search_ontology(text: str, within: str | None = None, limit: int = 10) -> str: + """Search the model's configured industry/enterprise vocabularies for terms + matching `text` (e.g. to find a FIBO class to align an entity to). `within` + scopes to a single vocabulary id (see the source's ontology list). Returns + ranked hits with their prefixed IRI, label and definition.""" + reg = _registry(repo_dir) + if reg is None: + return _json({"warning": "no ontology sources configured", "results": []}) + results = reg.search(text, within=within, limit=limit) + return _json( + { + "results": [ + { + "prefixed": r.prefixed, + "iri": r.iri, + "label": r.label, + "definition": r.definition, + "source": r.source, + } + for r in results + ] + } + ) + + @mcp.tool() + def validate() -> str: + """Validate the model (schema, references, ontology rules, naming) and return + the diagnostics. Use this before writing, or to check the model's health.""" + diags = run_validate(_model()) + return _json( + { + "ok": not diags.has(Severity.error), + "diagnostics": [ + {"code": d.code, "severity": d.severity.value, "message": d.message} + for d in diags.items + ], + } + ) + + # --- writes (validated + fingerprint-guarded via apply_command) ------------- + + @mcp.tool() + def create_entity( + name: str, + definition: str | None = None, + subject_area: str | None = None, + layer: str | None = None, + ) -> str: + """Create a conceptual + logical entity pair. Writes to the local model on + disk (validated before it lands). `subject_area` is a subject-area ULID; + `layer` is one of industry|core|domain|specialised. Returns the new entity's + ULID or an error object.""" + return _write( + repo_dir, + "create_entity", + { + "name": name, + "definition": definition, + "subject_area": subject_area, + "layer": layer, + }, + ) + + @mcp.tool() + def update_entity(name: str, changes: dict[str, Any]) -> str: + """Update an existing entity by name (or ULID). `changes` may contain: + `definition` (str), `rename` (str, the new name), `add_attribute` + ({name, domain?, definition?, nullable?}), or `subject_area` (ULID). Each is + applied through the validated command engine; the whole update is rejected + (nothing written) if any step is invalid. Returns ok or an error object.""" + le = query._entity_by_name(_model(), name) # noqa: SLF001 - shared helper + if le is None: + return _json({"error": f"no entity named {name!r}"}) + return _apply_updates(repo_dir, le.id, le.realises, changes) + + return mcp + + +# --- helpers ------------------------------------------------------------------- + + +def _apply_updates( + repo_dir: Path, entity_id: str, conceptual_id: str | None, changes: dict[str, Any] +) -> str: + """Map the `update_entity` change bag onto field-scoped commands. Applied in a + fixed order; each goes through apply_command, so validation runs after every + step and a bad change stops the sequence. Definition and subject area live on the + CONCEPTUAL node, so those ops take the conceptual ULID; rename and attributes are + logical.""" + applied: list[str] = [] + try: + if "definition" in changes: + if conceptual_id is None: + return _json({"error": "entity has no conceptual layer to set a definition on"}) + apply_command( + repo_dir, + "set_definition", + {"id": conceptual_id, "definition": changes["definition"]}, + ) + applied.append("definition") + if "rename" in changes: + apply_command(repo_dir, "rename_entity", {"id": entity_id, "name": changes["rename"]}) + applied.append("rename") + if "subject_area" in changes: + if conceptual_id is None: + return _json({"error": "entity has no conceptual layer to home in a subject area"}) + apply_command( + repo_dir, + "set_subject_area", + {"id": conceptual_id, "subject_area": changes["subject_area"]}, + ) + applied.append("subject_area") + if "add_attribute" in changes: + attr = changes["add_attribute"] + if not isinstance(attr, dict) or "name" not in attr: + return _json({"error": "add_attribute must be an object with at least a name"}) + apply_command(repo_dir, "add_attribute", {"entity_id": entity_id, **attr}) + applied.append(f"add_attribute:{attr['name']}") + except StaleModelError as e: + return _json({"error": f"model changed on disk mid-update: {e}", "applied": applied}) + except (CommandError, FileNotFoundError, ValueError) as e: + return _json({"error": str(e), "applied": applied}) + if not applied: + return _json({"error": "no recognised keys in changes", "applied": []}) + return _json({"ok": True, "applied": applied}) + + +def _write(repo_dir: Path, op: str, payload: dict) -> str: + """Run one write command and shape the result (created id + diagnostics), or an + error object the agent can read.""" + try: + result = apply_command(repo_dir, op, {k: v for k, v in payload.items() if v is not None}) + except StaleModelError as e: + return _json({"error": f"model changed on disk: {e}"}) + except (CommandError, FileNotFoundError, ValueError) as e: + return _json({"error": str(e)}) + return _json( + { + "ok": result.ok, + "created_id": result.created_id, + # CommandResult.diagnostics is already a list of {code, severity, message} + # dicts (validated on the reload apply_command does after each write). + "diagnostics": result.diagnostics, + "error": result.error, + } + ) + + +@lru_cache(maxsize=8) +def _registry(repo_dir: Path): + """Build + load the ontology registry once per repo dir. Returns None when the + model has no ontology stack configured (search then returns an empty result).""" + try: + from mdl_ontology import build_registry + + model = ModelRepo.load(repo_dir).model + stack = getattr(model.config, "ontology_stack", None) + if not stack: + return None + reg = build_registry(repo_dir, stack) + reg.load() + return reg + except Exception: # noqa: BLE001 - a bad ontology config must not crash the server + return None + + +def _json(obj) -> str: + return json.dumps(obj, indent=2, default=str) + + +def run(repo_dir: Path) -> None: + """Start the server on stdio (the transport VS Code / Claude Desktop use).""" + build_server(repo_dir).run() diff --git a/packages/mcp/tests/conftest.py b/packages/mcp/tests/conftest.py new file mode 100644 index 0000000..08dec24 --- /dev/null +++ b/packages/mcp/tests/conftest.py @@ -0,0 +1,15 @@ +import sys +from pathlib import Path + +import pytest + +_CORE_TESTS = Path(__file__).resolve().parents[2] / "core" / "tests" +sys.path.insert(0, str(_CORE_TESTS)) + +from model_builders import write_model # noqa: E402 + + +@pytest.fixture +def model_dir(tmp_path): + write_model(tmp_path) + return tmp_path diff --git a/packages/mcp/tests/test_query.py b/packages/mcp/tests/test_query.py new file mode 100644 index 0000000..08e9fdb --- /dev/null +++ b/packages/mcp/tests/test_query.py @@ -0,0 +1,54 @@ +"""The shared query layer (mdl_core.query) that both AI frontends read from.""" + +from __future__ import annotations + +from mdl_core.query import get_entity, get_model_context, list_entities +from mdl_core.repo import ModelRepo + + +def _model(model_dir): + return ModelRepo.load(model_dir).model + + +def test_list_entities_names_and_counts(model_dir): + ents = list_entities(_model(model_dir)) + by_name = {e["name"]: e for e in ents} + assert {"counterparty", "trade"} <= set(by_name) + # counterparty has two attributes in the builder + assert by_name["counterparty"]["attribute_count"] == 2 + assert by_name["counterparty"]["subject_area"] == "Trading" + + +def test_list_entities_scoped_to_subject_area(model_dir): + # Both entities are homed in Trading; an unknown area scopes to nothing. + assert list_entities(_model(model_dir), subject_area="Trading") + assert list_entities(_model(model_dir), subject_area="no-such-area") == [] + + +def test_get_entity_by_name_carries_relationships(model_dir): + e = get_entity(_model(model_dir), "counterparty") + assert e is not None + assert e["name"] == "counterparty" + assert e["conceptual"]["name"] == "Counterparty" + # the trade_has_counterparty relationship touches counterparty + assert any(r["name"] == "trade_has_counterparty" for r in e["relationships"]) + assert {a["name"] for a in e["attributes"]} == {"counterparty_id", "legal_name"} + + +def test_get_entity_unknown_returns_none(model_dir): + assert get_entity(_model(model_dir), "does_not_exist") is None + + +def test_get_entity_accepts_a_ulid(model_dir): + model = _model(model_dir) + le = next(e for e in model.logical_entities.values() if e.name == "trade") + assert get_entity(model, le.id)["name"] == "trade" + + +def test_get_model_context_summary(model_dir): + ctx = get_model_context(_model(model_dir)) + assert ctx["project"] == "testmodel" + assert ctx["counts"]["entities"] == 2 + assert ctx["counts"]["relationships"] == 1 + assert any(a["name"] == "Trading" for a in ctx["subject_areas"]) + assert any(r["name"] == "trade_has_counterparty" for r in ctx["relationships"]) diff --git a/packages/mcp/tests/test_server.py b/packages/mcp/tests/test_server.py new file mode 100644 index 0000000..aeb2557 --- /dev/null +++ b/packages/mcp/tests/test_server.py @@ -0,0 +1,96 @@ +"""The MCP server tools: reads over the query layer, writes through apply_command. + +The FastMCP tool-call API is async; we drive it with a small asyncio.run helper so +the suite needs no async-test plugin (the project has none, and adding one is a +separate decision).""" + +from __future__ import annotations + +import asyncio +import json + +from mdl_mcp.server import build_server + + +def _call(srv, name, args): + """Invoke a FastMCP tool synchronously and return its parsed JSON result.""" + + async def _run(): + res = await srv.call_tool(name, args) + content = res[0] if isinstance(res, tuple) else res + return content[0].text if content and hasattr(content[0], "text") else str(content) + + return json.loads(asyncio.run(_run())) + + +def _tool_names(srv): + return {t.name for t in asyncio.run(srv.list_tools())} + + +def test_tools_are_registered(model_dir): + names = _tool_names(build_server(model_dir)) + assert { + "list_entities", + "get_entity", + "get_model_context", + "search_ontology", + "validate", + "create_entity", + "update_entity", + } <= names + + +def test_get_entity_tool_reads(model_dir): + srv = build_server(model_dir) + e = _call(srv, "get_entity", {"name": "counterparty"}) + assert e["conceptual"]["name"] == "Counterparty" + + +def test_get_entity_tool_unknown_is_error_object(model_dir): + srv = build_server(model_dir) + e = _call(srv, "get_entity", {"name": "nope"}) + assert "error" in e + + +def test_create_entity_writes_to_disk(model_dir): + srv = build_server(model_dir) + r = _call(srv, "create_entity", {"name": "region", "definition": "A geographic region."}) + assert r["ok"] is True + assert r["created_id"] + # visible to the very next read (the server reloads per call) + e = _call(srv, "get_entity", {"name": "region"}) + assert e["name"] == "region" + assert (model_dir / "logical" / "entities" / "region.yaml").exists() + + +def test_update_entity_add_attribute_and_definition(model_dir): + srv = build_server(model_dir) + _call(srv, "create_entity", {"name": "region"}) + r = _call( + srv, + "update_entity", + { + "name": "region", + "changes": { + "definition": "A named geographic area.", + "add_attribute": {"name": "region_code", "domain": "string", "nullable": False}, + }, + }, + ) + assert r["ok"] is True + assert "definition" in r["applied"] + e = _call(srv, "get_entity", {"name": "region"}) + assert e["definition"] == "A named geographic area." + assert any(a["name"] == "region_code" for a in e["attributes"]) + + +def test_update_entity_unknown_is_error(model_dir): + srv = build_server(model_dir) + r = _call(srv, "update_entity", {"name": "ghost", "changes": {"definition": "x"}}) + assert "error" in r + + +def test_validate_tool_reports_ok(model_dir): + srv = build_server(model_dir) + r = _call(srv, "validate", {}) + assert r["ok"] is True diff --git a/pyproject.toml b/pyproject.toml index e3370ff..8c2b13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "python-multipart>=0.0.18", "pygls>=2.0", "sqlglot>=25", + "mcp>=1.2,<2", ] # Optional OpenTelemetry export for the audit log (spec §20). `pip install @@ -70,6 +71,7 @@ dev-mode-dirs = [ "packages/catalog/src", "packages/server/src", "packages/lsp/src", + "packages/mcp/src", "packages/adapters/collibra/src", ] diff --git a/uv.lock b/uv.lock index e03d5a3..634d690 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 1 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", "python_full_version < '3.12'", ] @@ -21,6 +22,7 @@ members = [ "modelith-emit-semantic", "modelith-governance", "modelith-lsp", + "modelith-mcp", "modelith-ontology", "modelith-reverse", "modelith-server", @@ -88,6 +90,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838 }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168 }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805 }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716 }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569 }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907 }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807 }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252 }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214 }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408 }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470 }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096 }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941 }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 }, +] + [[package]] name = "charset-normalizer" version = "3.5.1" @@ -345,6 +445,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350 }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675 }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410 }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378 }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889 }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006 }, +] + [[package]] name = "dbt-artifacts-parser" version = "0.14.0" @@ -431,6 +587,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, +] + [[package]] name = "hypothesis" version = "6.163.0" @@ -538,6 +703,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "lsprotocol" version = "2025.0.0" @@ -637,6 +829,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] +[[package]] +name = "mcp" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -683,6 +900,7 @@ dependencies = [ { name = "modelith-emit-semantic" }, { name = "modelith-governance" }, { name = "modelith-lsp" }, + { name = "modelith-mcp" }, { name = "modelith-ontology" }, { name = "modelith-reverse" }, { name = "modelith-server" }, @@ -701,6 +919,7 @@ requires-dist = [ { name = "modelith-emit-semantic", editable = "packages/emit-semantic" }, { name = "modelith-governance", editable = "packages/governance" }, { name = "modelith-lsp", editable = "packages/lsp" }, + { name = "modelith-mcp", editable = "packages/mcp" }, { name = "modelith-ontology", editable = "packages/ontology" }, { name = "modelith-reverse", editable = "packages/reverse" }, { name = "modelith-server", editable = "packages/server" }, @@ -730,6 +949,7 @@ dependencies = [ { name = "dbt-artifacts-parser" }, { name = "fastapi" }, { name = "jinja2" }, + { name = "mcp" }, { name = "pydantic" }, { name = "pygls" }, { name = "pyshacl" }, @@ -761,6 +981,7 @@ requires-dist = [ { name = "dbt-artifacts-parser", specifier = ">=0.5" }, { name = "fastapi", specifier = ">=0.110" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "mcp", specifier = ">=1.2,<2" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'audit'", specifier = ">=1.20" }, { name = "opentelemetry-sdk", marker = "extra == 'audit'", specifier = ">=1.20" }, { name = "pydantic", specifier = ">=2.6" }, @@ -896,6 +1117,23 @@ requires-dist = [ { name = "pygls", specifier = ">=2.0" }, ] +[[package]] +name = "modelith-mcp" +version = "0.1.0" +source = { editable = "packages/mcp" } +dependencies = [ + { name = "mcp" }, + { name = "modelith-core" }, + { name = "modelith-ontology" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", specifier = ">=1.2,<2" }, + { name = "modelith-core", editable = "packages/core" }, + { name = "modelith-ontology", editable = "packages/ontology" }, +] + [[package]] name = "modelith-ontology" version = "0.1.0" @@ -1106,6 +1344,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1223,6 +1470,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413 }, +] + [[package]] name = "pygls" version = "2.1.1" @@ -1246,6 +1507,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1301,6 +1576,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, ] +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780 }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -1310,6 +1594,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659 }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825 }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875 }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, +] + [[package]] name = "rdflib" version = "7.6.0" @@ -1327,6 +1633,20 @@ html = [ { name = "html5rdf" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1355,6 +1675,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513 }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783 }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316 }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423 }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077 }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315 }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502 }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673 }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964 }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446 }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975 }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453 }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219 }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137 }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791 }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842 }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094 }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662 }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896 }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545 }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059 }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235 }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008 }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118 }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138 }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486 }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -1416,6 +1859,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/9f/3dd2ec8dd84a33e0092f1c815267bc055073f9e833b93e389c59c10f62cc/sqlglot-30.18.0-py3-none-any.whl", hash = "sha256:ee0f9a9f3e2193e763c326e52dfb377b96fdb4292f6305c3ff2d9a220e71c601", size = 748788 }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122 }, +] + [[package]] name = "starlette" version = "1.3.1" diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index c45af60..6de9037 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **AI assistance.** Two Copilot Chat integrations backed by the same `mdl` engine: + a bundled MCP server (`mdl mcp`) exposing model + ontology tools in Agent mode + (`list_entities`, `get_entity`, `search_ontology`, `get_model_context`, `validate`, + `create_entity`, `update_entity`), and an `@modelith` chat participant for Ask mode + (`/list`, `/explain `). Registered automatically; no `mcp.json` to write. +- **Import.** *Modelith: Import to Model* — right-click a `.sql`/`.mmd` file (or the + command palette) to open the Import wizard on the embedded canvas. + ## 0.1.0 First release. diff --git a/vscode/README.md b/vscode/README.md index 7fcbde2..06fbb56 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -43,6 +43,27 @@ embeds the live canvas, so whatever the CLI understands, the editor shows. locations. A standard `uv tool install modelith-dbt` needs no configuration. If nothing matches, run `which mdl` in the integrated terminal and set that as `modelith.mdlPath`. +## AI assistance (Copilot Chat) + +Modelith registers two AI integrations, one per Copilot Chat mode. Both use the same +`mdl` engine already detected for everything else — no `mcp.json` to write, no separate +install. + +- **Agent mode — Modelith MCP tools.** In Copilot Chat's *Agent* mode the model is available + as tools: `list_entities`, `get_entity`, `search_ontology`, `get_model_context`, `validate`, + and the write tools `create_entity` / `update_entity`. The agent can ground itself in what + already exists, search the ontology for an alignment, and write validated entities straight + into your checkout (direct-write — you review the git diff, the same trust boundary as the + CLI). The tools **only run in Agent mode**: tool invocation needs the plan/act/observe loop + that Ask mode does not have. The server is the `mdl mcp` subcommand, registered automatically + on activation and scoped to your workspace model. Requires VS Code 1.99+ (older hosts keep + every other feature; only the tools are absent). +- **Ask mode — `@modelith`.** In *Ask* mode, where MCP tools cannot run, type `@modelith` and + ask about the model: name an entity for its attributes / ontology alignment / relationships, + `@modelith /list` for the entity list, `@modelith /explain ` for one in full, or a + general question for a model summary. It reads the model through the same query layer the MCP + tools use, so the two modes answer consistently. + ## Devcontainers The extension declares `"extensionKind": ["workspace"]`, so in a devcontainer it runs inside diff --git a/vscode/package.json b/vscode/package.json index 3ca6ab9..5f2ba3f 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -42,9 +42,34 @@ "workspace" ], "activationEvents": [ - "workspaceContains:**/mdl-project.yaml" + "workspaceContains:**/mdl-project.yaml", + "onChatParticipant:modelith.chat" ], "contributes": { + "mcpServerDefinitionProviders": [ + { + "id": "modelith.mcp", + "label": "Modelith" + } + ], + "chatParticipants": [ + { + "id": "modelith.chat", + "name": "modelith", + "fullName": "Modelith", + "description": "Ask about entities, relationships, and ontology alignment in this model.", + "commands": [ + { + "name": "explain", + "description": "Explain one entity: its attributes, ontology alignment, and relationships." + }, + { + "name": "list", + "description": "List the model's entities." + } + ] + } + ], "commands": [ { "command": "modelith.validate", diff --git a/vscode/src/chatParticipant.ts b/vscode/src/chatParticipant.ts new file mode 100644 index 0000000..434a06d --- /dev/null +++ b/vscode/src/chatParticipant.ts @@ -0,0 +1,183 @@ +import * as vscode from "vscode"; +import { findMdl, findModelDir, runMdl } from "./mdl"; + +/** The `@modelith` chat participant, for Copilot Chat's ASK mode — where MCP tools + * structurally cannot run (no agentic loop to invoke them from). It answers about + * the model itself: entities, relationships, ontology alignment. + * + * The handler is solely responsible for the response, so it does basic intent + * routing here rather than expecting the model to pick sub-tasks. It reaches the + * model through the SAME query layer the MCP server exposes, via `mdl model …` + * JSON reads — one query layer in `packages/core`, two frontends, no drift. */ + +interface ModelContext { + project: string; + counts: { entities: number; relationships: number; subject_areas: number; terms: number }; + subject_areas: { name: string; definition: string | null; entities: string[] }[]; + unassigned_entities: string[]; + relationships: { name: string; from: string; to: string }[]; +} + +/** Run an `mdl model …` read and parse its JSON stdout. Throws with the CLI's own + * message on a non-zero exit so the handler can surface it. */ +async function readModel(dir: string, args: string[]): Promise { + const bin = await findMdl(dir); + const r = await runMdl(bin, [...args, "-m", dir], dir); + if (r.code !== 0) { + // read commands print an {error} object on stdout, else the message is on stderr + try { + const obj = JSON.parse(r.stdout); + if (obj?.error) throw new Error(obj.error); + } catch { + /* fall through */ + } + throw new Error(r.stderr.trim() || `mdl ${args.join(" ")} failed`); + } + return JSON.parse(r.stdout) as T; +} + +export function registerChatParticipant(ctx: vscode.ExtensionContext): void { + // The chat API is stable from VS Code 1.90+, but guard so activation never + // crashes on an older host that lacks it (as the MCP guard does for 1.99+). + if (!vscode.chat?.createChatParticipant) return; + + const handler: vscode.ChatRequestHandler = async (request, _context, stream, _token) => { + const dir = await findModelDir(); + if (!dir) { + stream.markdown( + "No Modelith model found in this workspace (looked for `mdl-project.yaml`). " + + "Set `modelith.modelDir` if it lives somewhere unusual.", + ); + return; + } + + try { + if (request.command === "explain") { + await explainEntity(dir, request.prompt.trim(), stream); + return; + } + if (request.command === "list") { + await listEntities(dir, stream); + return; + } + // No slash command: infer intent from the prompt. A single entity name that + // matches the model is treated as "explain it"; otherwise summarise + point + // at the commands. Kept deliberately simple — Ask mode has no tool loop. + const named = request.prompt.trim(); + if (named && (await tryExplain(dir, named, stream))) return; + await summarise(dir, request.prompt, stream); + } catch (e) { + stream.markdown(`\n\n⚠️ ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const participant = vscode.chat.createChatParticipant("modelith.chat", handler); + participant.iconPath = undefined; + ctx.subscriptions.push(participant); +} + +async function explainEntity( + dir: string, + name: string, + stream: vscode.ChatResponseStream, +): Promise { + if (!name) { + stream.markdown("Usage: `@modelith /explain `"); + return; + } + if (!(await tryExplain(dir, name, stream))) { + stream.markdown(`No entity named **${name}** in the model. Try \`@modelith /list\`.`); + } +} + +/** Render one entity if it exists; return false if it does not. */ +async function tryExplain( + dir: string, + name: string, + stream: vscode.ChatResponseStream, +): Promise { + interface Entity { + name: string; + definition: string | null; + pattern: string | null; + conceptual: { name: string; ontology: string | null; ontology_layer: string | null } | null; + attributes: { name: string; domain: string | null; nullable: boolean; ontology: string | null }[]; + relationships: { name: string; from: string; to: string }[]; + } + let e: Entity; + try { + e = await readModel(dir, ["model", "entity", name]); + } catch { + return false; // unknown entity → let the caller decide the message + } + stream.markdown(`### ${e.name}\n`); + if (e.definition) stream.markdown(`${e.definition}\n\n`); + if (e.conceptual?.ontology) { + stream.markdown(`**Aligned to** \`${e.conceptual.ontology}\``); + if (e.conceptual.ontology_layer) stream.markdown(` _(${e.conceptual.ontology_layer} layer)_`); + stream.markdown("\n\n"); + } + if (e.attributes.length) { + stream.markdown("**Attributes**\n"); + for (const a of e.attributes) { + const bits = [a.domain ?? "—", a.nullable ? "nullable" : "required"]; + if (a.ontology) bits.push(`→ \`${a.ontology}\``); + stream.markdown(`- \`${a.name}\` (${bits.join(", ")})\n`); + } + stream.markdown("\n"); + } + if (e.relationships.length) { + stream.markdown("**Relationships**\n"); + for (const r of e.relationships) stream.markdown(`- ${r.from} → ${r.to} (${r.name})\n`); + } + return true; +} + +async function listEntities(dir: string, stream: vscode.ChatResponseStream): Promise { + interface Row { + name: string; + definition: string | null; + attribute_count: number; + subject_area: string | null; + } + const rows = await readModel(dir, ["model", "entities"]); + if (!rows.length) { + stream.markdown("The model has no entities yet."); + return; + } + stream.markdown(`The model has **${rows.length}** entities:\n\n`); + for (const r of rows) { + const area = r.subject_area ? ` _(${r.subject_area})_` : ""; + stream.markdown(`- **${r.name}**${area} — ${r.attribute_count} attribute(s)\n`); + } +} + +async function summarise( + dir: string, + prompt: string, + stream: vscode.ChatResponseStream, +): Promise { + const ctx = await readModel(dir, ["model", "context"]); + stream.markdown( + `**${ctx.project}** — ${ctx.counts.entities} entities, ` + + `${ctx.counts.relationships} relationships, ${ctx.counts.subject_areas} subject area(s).\n\n`, + ); + if (ctx.subject_areas.length) { + stream.markdown("**Subject areas**\n"); + for (const sa of ctx.subject_areas) { + stream.markdown(`- **${sa.name}**: ${sa.entities.join(", ") || "(empty)"}\n`); + } + stream.markdown("\n"); + } + if (ctx.unassigned_entities.length) { + stream.markdown(`_Not in any subject area:_ ${ctx.unassigned_entities.join(", ")}\n\n`); + } + stream.markdown( + "Ask about a specific entity by name, or use `@modelith /explain ` and " + + "`@modelith /list`. For editing, switch Copilot Chat to **Agent mode** — the " + + "Modelith tools (create/update entities, ontology search) run there.", + ); + if (prompt.trim()) { + stream.markdown(`\n\n_(I answer from the model directly, so I focused on its structure.)_`); + } +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 8150ddf..29b3244 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -2,7 +2,9 @@ import * as path from "node:path"; import * as vscode from "vscode"; import type { LanguageClient } from "vscode-languageclient/node"; import { CanvasManager } from "./canvasPanel"; +import { registerChatParticipant } from "./chatParticipant"; import { executeLspCommand, startLsp } from "./lspClient"; +import { registerMcpProvider } from "./mcpProvider"; import { findDbtProjectDir, findManifestPath, @@ -29,6 +31,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { canvas = new CanvasManager(out); ctx.subscriptions.push({ dispose: () => canvas.dispose() }); + // AI surfaces: the bundled MCP server (Copilot Chat agent mode) and the + // `@modelith` chat participant (ask mode). Both are internally guarded against + // hosts that lack their API, so a failure here never blocks the LSP or canvas. + registerMcpProvider(ctx); + registerChatParticipant(ctx); + // After a window reload VS Code restores our webview panels, but the `mdl serve` // child died with the old extension host, so the restored iframe points at a // dead port (blank). Re-hydrate the canvas (restart server + rewrite iframe); diff --git a/vscode/src/mcpProvider.ts b/vscode/src/mcpProvider.ts new file mode 100644 index 0000000..df21747 --- /dev/null +++ b/vscode/src/mcpProvider.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; +import { findMdl, findModelDir } from "./mdl"; + +/** Register the bundled Modelith MCP server for Copilot Chat AGENT mode. + * + * Unlike the spec's Node-module sketch, the server IS the Python `mdl mcp` + * subcommand — `packages/core` is Python, so a Node module cannot compile against + * it, and every other integration here (LSP, canvas) already runs by spawning the + * `mdl` CLI. We register an stdio server that runs `mdl mcp --repo `, + * resolving the binary through the same `findMdl` detection the LSP uses. One + * engine, invoked the one way this extension invokes it everywhere. + * + * A programmatically registered server has no implicit repo scope (a committed + * .vscode/mcp.json would), so we pass the workspace model dir explicitly. + * + * `registerMcpServerDefinitionProvider` only exists from VS Code 1.99+. An + * unguarded call has been seen crashing activation on older hosts and taking the + * LSP + canvas down with it — so optional-chain the API and no-op when absent. */ +export function registerMcpProvider(ctx: vscode.ExtensionContext): void { + const lm = vscode.lm as typeof vscode.lm & { + registerMcpServerDefinitionProvider?: ( + id: string, + provider: { + provideMcpServerDefinitions: () => vscode.ProviderResult; + }, + ) => vscode.Disposable; + }; + const Stdio = ( + vscode as unknown as { McpStdioServerDefinition?: new (...a: unknown[]) => unknown } + ).McpStdioServerDefinition; + if (!lm?.registerMcpServerDefinitionProvider || !Stdio) return; + + ctx.subscriptions.push( + lm.registerMcpServerDefinitionProvider("modelith.mcp", { + provideMcpServerDefinitions: async () => { + const dir = (await findModelDir()) ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!dir) return []; + // Resolve the mdl invocation the same way the LSP does (explicit setting → + // .venv → PATH → well-known → `uv run mdl`). `bin.args` is the prefix (e.g. + // ["run","mdl"] for uv); the server command follows. + let cmd: string; + let args: string[]; + try { + const bin = await findMdl(dir); + cmd = bin.cmd; + args = [...bin.args, "mcp", "--repo", dir]; + } catch { + return []; // mdl not found — the LSP path already surfaces this to the user + } + return [new (Stdio as new (...a: unknown[]) => unknown)("modelith", cmd, args)]; + }, + }), + ); +} From 623674e8b40615e2e2d740d4cf2ab0d7508ff776 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 13:18:14 +1000 Subject: [PATCH 2/9] Add Apache LICENSE to the VS Code extension package vsce warned (and prompted) on every package because package.json declares Apache-2.0 but no license file shipped. Copy the repo LICENSE into vscode/ so packaging is non-interactive and the Marketplace shows the license. Co-Authored-By: Claude Opus 4.8 --- vscode/LICENSE | 202 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 vscode/LICENSE diff --git a/vscode/LICENSE b/vscode/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/vscode/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From f69096bdf1b460a719025a177d76cc057774d8ba Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 14:15:40 +1000 Subject: [PATCH 3/9] Give @modelith a clear message when mdl is out of date A stale global `mdl` (predating the `model` command) exits non-zero and prints Typer's usage dump to stdout, which the chat participant surfaced raw ("No such command 'model'"). Detect that signature and tell the user to upgrade mdl and reload, instead of leaking the usage text. Co-Authored-By: Claude Opus 4.8 --- vscode/src/chatParticipant.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/vscode/src/chatParticipant.ts b/vscode/src/chatParticipant.ts index 434a06d..392416a 100644 --- a/vscode/src/chatParticipant.ts +++ b/vscode/src/chatParticipant.ts @@ -29,9 +29,20 @@ async function readModel(dir: string, args: string[]): Promise { const obj = JSON.parse(r.stdout); if (obj?.error) throw new Error(obj.error); } catch { - /* fall through */ + /* not a JSON error object — fall through */ } - throw new Error(r.stderr.trim() || `mdl ${args.join(" ")} failed`); + // A too-old `mdl` (a stale global install predating these commands) doesn't + // know `mdl model …` and prints Typer's usage dump. Turn that into an + // actionable message instead of leaking the raw usage text to chat. + const blob = (r.stdout + r.stderr).toLowerCase(); + if (blob.includes("no such command") || blob.includes("usage: mdl")) { + throw new Error( + "Your installed `mdl` is out of date — it doesn't have the `model` command " + + "these features need. Upgrade it (`uv tool install --force modelith-dbt`, or " + + "reinstall from your working tree), then reload VS Code.", + ); + } + throw new Error(r.stderr.trim() || r.stdout.trim() || `mdl ${args.join(" ")} failed`); } return JSON.parse(r.stdout) as T; } From c4974d246e43cd2ddda80a11f4b233e6a0a023aa Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 14:33:29 +1000 Subject: [PATCH 4/9] Detect a stale mdl CLI on activation and guide the upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension and the mdl CLI install separately (Marketplace .vsix vs a user's own `uv tool`/pipx/pip install), so a stale CLI silently lacks commands the AI surfaces call — as seen when @modelith hit an old global mdl. Add a one-shot capability handshake on activation: probe `mdl model --help` (shipped with `mdl mcp`, so it proxies "new enough"). If it's missing, show ONE dismissible warning with a Reload Window action and an upgrade command inferred from HOW this mdl was resolved (uv tool / pipx / pip / project .venv / workspace uv run) — never assuming a package manager. No hardcoded version to maintain (mdl has no --version anyway); the per-command guards remain the backstop. Co-Authored-By: Claude Opus 4.8 --- vscode/src/extension.ts | 20 ++++++++++++++++++++ vscode/src/mdl.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 29b3244..c447d91 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -10,8 +10,10 @@ import { findManifestPath, findMdl, findModelDir, + hasAiCommands, resetMdlCache, runMdl, + upgradeHint, } from "./mdl"; import { registerSchemas } from "./schemas"; @@ -107,6 +109,24 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.environmentVariableCollection.description = "Adds the Modelith `mdl` CLI to integrated terminals"; } + // Version-skew handshake. The extension and the `mdl` CLI install separately, + // so a stale CLI can lack commands the AI surfaces (chat participant, MCP + // server) call. Probe once; if it's too old, tell the user how to upgrade + // THEIR install (inferred from the resolved path) — once, dismissibly, and + // without blocking anything. The per-call guards remain the backstop. + void hasAiCommands(bin, root).then((ok) => { + if (ok) return; + out.appendLine(`[mdl] ${bin.label} is missing the 'model'/'mcp' commands (out of date)`); + void vscode.window.showWarningMessage( + `Your Modelith CLI (${bin.label}) is out of date — it lacks commands the ` + + `chat and MCP features need. To fix, ${upgradeHint(bin)}, then reload VS Code.`, + "Reload Window", + ).then((pick) => { + if (pick === "Reload Window") { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + }); } catch (e) { out.appendLine(`[mdl] detection failed: ${e}`); } diff --git a/vscode/src/mdl.ts b/vscode/src/mdl.ts index fa0687f..6bae553 100644 --- a/vscode/src/mdl.ts +++ b/vscode/src/mdl.ts @@ -92,6 +92,39 @@ export function runMdl(bin: MdlBin, args: string[], cwd: string): Promise { + const r = await runMdl(bin, ["model", "--help"], cwd); + return r.code === 0; +} + // --- workspace discovery ------------------------------------------------------- /** The model repo dir (contains mdl-project.yaml). Setting wins; else first hit. */ From c7c85413fb9c58fbb62c5201779c64bc63aab669 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 14:39:56 +1000 Subject: [PATCH 5/9] Make @modelith parse intent with the user's Copilot model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler treated the whole prompt after /explain as a literal entity name, so "/explain explain instrument entity, everything about it" failed with "no entity named …". Replace the naive routing with LLM-backed understanding. The chat-participant API hands us request.model — the user's own authenticated Copilot model. For free-text asks we now ground with the real model facts (entity roster + condensed context + detail for any entity the prompt names, all via the same `mdl model` query layer) and let that model answer, pinned to the facts so it can't invent entities. It handles questions ("what connects to instrument?"), buried names, and typos alike. Kept deterministic and fast where it should be: `/list` and an exact-name `/explain ` render templates with no LLM round-trip. And if no model is available (or the call fails), a heuristic name match + structural summary is the fallback — verified it extracts "instrument" from the failing prompt above — so the participant never dead-ends. Co-Authored-By: Claude Opus 4.8 --- vscode/src/chatParticipant.ts | 190 ++++++++++++++++++++++++---------- 1 file changed, 133 insertions(+), 57 deletions(-) diff --git a/vscode/src/chatParticipant.ts b/vscode/src/chatParticipant.ts index 392416a..94cbefc 100644 --- a/vscode/src/chatParticipant.ts +++ b/vscode/src/chatParticipant.ts @@ -5,10 +5,15 @@ import { findMdl, findModelDir, runMdl } from "./mdl"; * structurally cannot run (no agentic loop to invoke them from). It answers about * the model itself: entities, relationships, ontology alignment. * - * The handler is solely responsible for the response, so it does basic intent - * routing here rather than expecting the model to pick sub-tasks. It reaches the - * model through the SAME query layer the MCP server exposes, via `mdl model …` - * JSON reads — one query layer in `packages/core`, two frontends, no drift. */ + * Intent handling is LLM-backed. The chat-participant API hands us `request.model` + * — the user's own already-authenticated Copilot model — so for free-text asks we + * GROUND with the real model facts (fetched through the same `mdl model` query + * layer the MCP server uses) and let that model compose the answer, pinned to the + * facts so it can't invent entities. Exact `/list` and `/explain ` keep a + * fast deterministic template; anything else — a question, a typo'd name, a + * multi-part ask — goes through the model. If no model is available (rare in + * Copilot Chat), a heuristic name match is the fallback, so the participant still + * works. One query layer in `packages/core`, two frontends, no drift. */ interface ModelContext { project: string; @@ -18,13 +23,28 @@ interface ModelContext { relationships: { name: string; from: string; to: string }[]; } +interface EntityRow { + name: string; + definition: string | null; + attribute_count: number; + subject_area: string | null; +} + +interface EntityDetail { + name: string; + definition: string | null; + pattern: string | null; + conceptual: { name: string; ontology: string | null; ontology_layer: string | null } | null; + attributes: { name: string; domain: string | null; nullable: boolean; ontology: string | null }[]; + relationships: { name: string; from: string; to: string }[]; +} + /** Run an `mdl model …` read and parse its JSON stdout. Throws with the CLI's own * message on a non-zero exit so the handler can surface it. */ async function readModel(dir: string, args: string[]): Promise { const bin = await findMdl(dir); const r = await runMdl(bin, [...args, "-m", dir], dir); if (r.code !== 0) { - // read commands print an {error} object on stdout, else the message is on stderr try { const obj = JSON.parse(r.stdout); if (obj?.error) throw new Error(obj.error); @@ -52,7 +72,7 @@ export function registerChatParticipant(ctx: vscode.ExtensionContext): void { // crashes on an older host that lacks it (as the MCP guard does for 1.99+). if (!vscode.chat?.createChatParticipant) return; - const handler: vscode.ChatRequestHandler = async (request, _context, stream, _token) => { + const handler: vscode.ChatRequestHandler = async (request, _context, stream, token) => { const dir = await findModelDir(); if (!dir) { stream.markdown( @@ -63,20 +83,38 @@ export function registerChatParticipant(ctx: vscode.ExtensionContext): void { } try { - if (request.command === "explain") { - await explainEntity(dir, request.prompt.trim(), stream); + if (request.command === "list") { + await renderList(dir, stream); return; } - if (request.command === "list") { - await listEntities(dir, stream); + + const prompt = request.prompt.trim(); + const rows = await readModel(dir, ["model", "entities"]); + const names = rows.map((r) => r.name); + + // Fast path: the prompt (after an optional /explain) IS an exact entity name. + // Render the deterministic card without an LLM round-trip. + const exact = names.find((n) => n.toLowerCase() === prompt.toLowerCase()); + if (exact) { + await renderEntity(dir, exact, stream); return; } - // No slash command: infer intent from the prompt. A single entity name that - // matches the model is treated as "explain it"; otherwise summarise + point - // at the commands. Kept deliberately simple — Ask mode has no tool loop. - const named = request.prompt.trim(); - if (named && (await tryExplain(dir, named, stream))) return; - await summarise(dir, request.prompt, stream); + + // Everything else — a question, a name buried in a sentence, a typo — is + // resolved by the user's Copilot model, grounded in the real model facts. + if (request.model) { + await answerWithModel(dir, request, rows, stream, token); + return; + } + + // No language model available: fall back to a heuristic name match, then a + // structural summary, so the participant still does something useful. + const guessed = guessEntity(prompt, names); + if (guessed) { + await renderEntity(dir, guessed, stream); + return; + } + await renderSummary(dir, stream); } catch (e) { stream.markdown(`\n\n⚠️ ${e instanceof Error ? e.message : String(e)}`); } @@ -87,40 +125,92 @@ export function registerChatParticipant(ctx: vscode.ExtensionContext): void { ctx.subscriptions.push(participant); } -async function explainEntity( +/** Ground the user's Copilot model with the model's facts and stream its answer. + * + * We assemble a compact fact sheet — the entity roster, the condensed model + * context, and (when the question clearly concerns specific entities) their full + * detail — and instruct the model to answer ONLY from it. Deterministic data, + * natural-language reasoning: it handles "what connects to instrument?", "which + * entities have no ontology alignment?", and a fuzzy "explain the instrument + * entity, everything about it" alike. */ +async function answerWithModel( dir: string, - name: string, + request: vscode.ChatRequest, + rows: EntityRow[], stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, ): Promise { - if (!name) { - stream.markdown("Usage: `@modelith /explain `"); - return; + const names = rows.map((r) => r.name); + const context = await readModel(dir, ["model", "context"]); + + // Pull detail for any entity the prompt names, so the model can answer about + // attributes/relationships without guessing. Bounded to keep the prompt small. + const mentioned = names + .filter((n) => request.prompt.toLowerCase().includes(n.toLowerCase())) + .slice(0, 4); + const details: EntityDetail[] = []; + for (const n of mentioned) { + try { + details.push(await readModel(dir, ["model", "entity", n])); + } catch { + /* skip an entity that vanished between the list and the read */ + } } - if (!(await tryExplain(dir, name, stream))) { - stream.markdown(`No entity named **${name}** in the model. Try \`@modelith /list\`.`); + + const facts = JSON.stringify( + { entities: names, context, detail: details }, + null, + 2, + ); + + const system = + "You are Modelith, a data-modeling assistant answering about ONE specific model " + + "inside VS Code. Answer the user's question using ONLY the JSON facts provided — " + + "the model's entities, subject areas, relationships, and any entity detail. Do not " + + "invent entities, attributes, or alignments that are not in the facts; if the answer " + + "is not derivable from them, say so and suggest `@modelith /list`. Be concise, use " + + "Markdown, and prefer the model's own names verbatim. This is read-only: for editing, " + + "point the user to Copilot Agent mode where the Modelith tools run."; + + const messages = [ + vscode.LanguageModelChatMessage.User(`${system}\n\nMODEL FACTS:\n${facts}`), + vscode.LanguageModelChatMessage.User(request.prompt), + ]; + + try { + const res = await request.model.sendRequest(messages, {}, token); + for await (const chunk of res.text) stream.markdown(chunk); + } catch (e) { + // The model call can fail (consent not granted, quota, offline). Fall back to + // the heuristic so the user still gets an answer, not a dead end. + const guessed = guessEntity(request.prompt, names); + if (guessed) { + await renderEntity(dir, guessed, stream); + return; + } + await renderSummary(dir, stream); + stream.markdown( + `\n\n_(Couldn't reach a language model — ${e instanceof Error ? e.message : String(e)} — ` + + "so I answered from the model's structure directly.)_", + ); } } -/** Render one entity if it exists; return false if it does not. */ -async function tryExplain( +/** Heuristic entity pick for the no-LLM fallback: the longest entity name that + * appears as a word-ish substring of the prompt. Deliberately simple. */ +function guessEntity(prompt: string, names: string[]): string | undefined { + const p = prompt.toLowerCase(); + return names + .filter((n) => p.includes(n.toLowerCase())) + .sort((a, b) => b.length - a.length)[0]; +} + +async function renderEntity( dir: string, name: string, stream: vscode.ChatResponseStream, -): Promise { - interface Entity { - name: string; - definition: string | null; - pattern: string | null; - conceptual: { name: string; ontology: string | null; ontology_layer: string | null } | null; - attributes: { name: string; domain: string | null; nullable: boolean; ontology: string | null }[]; - relationships: { name: string; from: string; to: string }[]; - } - let e: Entity; - try { - e = await readModel(dir, ["model", "entity", name]); - } catch { - return false; // unknown entity → let the caller decide the message - } +): Promise { + const e = await readModel(dir, ["model", "entity", name]); stream.markdown(`### ${e.name}\n`); if (e.definition) stream.markdown(`${e.definition}\n\n`); if (e.conceptual?.ontology) { @@ -141,17 +231,10 @@ async function tryExplain( stream.markdown("**Relationships**\n"); for (const r of e.relationships) stream.markdown(`- ${r.from} → ${r.to} (${r.name})\n`); } - return true; } -async function listEntities(dir: string, stream: vscode.ChatResponseStream): Promise { - interface Row { - name: string; - definition: string | null; - attribute_count: number; - subject_area: string | null; - } - const rows = await readModel(dir, ["model", "entities"]); +async function renderList(dir: string, stream: vscode.ChatResponseStream): Promise { + const rows = await readModel(dir, ["model", "entities"]); if (!rows.length) { stream.markdown("The model has no entities yet."); return; @@ -163,11 +246,7 @@ async function listEntities(dir: string, stream: vscode.ChatResponseStream): Pro } } -async function summarise( - dir: string, - prompt: string, - stream: vscode.ChatResponseStream, -): Promise { +async function renderSummary(dir: string, stream: vscode.ChatResponseStream): Promise { const ctx = await readModel(dir, ["model", "context"]); stream.markdown( `**${ctx.project}** — ${ctx.counts.entities} entities, ` + @@ -188,7 +267,4 @@ async function summarise( "`@modelith /list`. For editing, switch Copilot Chat to **Agent mode** — the " + "Modelith tools (create/update entities, ontology search) run there.", ); - if (prompt.trim()) { - stream.markdown(`\n\n_(I answer from the model directly, so I focused on its structure.)_`); - } } From 3e3962826088471f352cfac4ce6feb0a2e53ec4d Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 16:56:02 +1000 Subject: [PATCH 6/9] Ground model-wide @modelith questions with keys and every entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question like "are all entities in BCNF?" got only the entity roster and relationships — no attributes, no keys — so the model (correctly) refused to answer. Two gaps, both fixed in the shared query layer so every surface benefits: - get_entity now returns `keys` (pk / unique / alternate key groups with member columns in order). Normalization and identity questions need this; the MCP get_entity tool and `mdl model entity` gain it too. - New entities_detail() + `mdl model detail`: compact per-entity records (attributes as name:domain?, keys, relationship names) for grounding a model-wide question in ONE call. Terse and capped (default 40, chat passes 120) with total/shown/truncated, so a 150-entity model stays inside the context window instead of blowing it. The chat participant now picks its grounding by scope: a model-wide ask (normalization, which/all/every/each, or no entity named) grounds on `mdl model detail` and appends a truncation notice when the cap bites; a targeted ask grounds on the named entities in full. The deterministic entity card renders keys too. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/mdl_cli/main.py | 17 ++++- packages/core/src/mdl_core/query.py | 78 ++++++++++++++++++++++- packages/mcp/src/mdl_mcp/server.py | 5 +- packages/mcp/tests/test_query.py | 30 ++++++++- vscode/src/chatParticipant.ts | 96 ++++++++++++++++++++++------- 5 files changed, 197 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/mdl_cli/main.py b/packages/cli/src/mdl_cli/main.py index 80e8944..1bda4f3 100644 --- a/packages/cli/src/mdl_cli/main.py +++ b/packages/cli/src/mdl_cli/main.py @@ -1709,8 +1709,8 @@ def model_entity( name: str = typer.Argument(..., help="Entity name or ULID"), model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), ) -> None: - """Print one entity's full detail (attributes, conceptual layer, relationships) as - JSON. Exits 1 with an error object if no entity matches.""" + """Print one entity's full detail (attributes, keys, conceptual layer, + relationships) as JSON. Exits 1 with an error object if no entity matches.""" from mdl_core.query import get_entity hit = get_entity(_load(model_dir).model, name) @@ -1720,6 +1720,19 @@ def model_entity( typer.echo(json.dumps(hit, default=str)) +@model_app.command("detail") +def model_detail( + model_dir: Path = typer.Option(Path("."), "--model-dir", "-m"), + limit: int = typer.Option(40, "--limit", help="Max entities (alphabetical) to include"), +) -> None: + """Print compact detail for EVERY entity (attributes, keys, relationships) as JSON, + for grounding a model-wide question. Terse by design and capped at --limit, with a + `truncated` flag so a large model doesn't overflow a chat context window.""" + from mdl_core.query import entities_detail + + typer.echo(json.dumps(entities_detail(_load(model_dir).model, limit=limit), default=str)) + + @app.command() def mcp( repo: Path = typer.Option( diff --git a/packages/core/src/mdl_core/query.py b/packages/core/src/mdl_core/query.py index 5fb0be1..e1d4a63 100644 --- a/packages/core/src/mdl_core/query.py +++ b/packages/core/src/mdl_core/query.py @@ -76,10 +76,29 @@ def list_entities(model: Model, subject_area: str | None = None) -> list[dict]: return out +def _keys_for(model: Model, le: LogicalEntity) -> list[dict]: + """The entity's key groups (erwin Key Groups) — pk / unique / alternate / index — + with member attribute names in key order. This is what lets a caller reason about + normalization (candidate keys, functional dependencies) and identity.""" + attr_name = {a.id: a.name for a in le.attributes} + keys: list[dict] = [] + for kg in sorted(model.key_groups.values(), key=lambda k: (k.type, k.name)): + if kg.entity != le.id: + continue + keys.append( + { + "name": kg.name, + "type": kg.type, + "columns": [attr_name.get(m, m) for m in kg.members], + } + ) + return keys + + def get_entity(model: Model, name: str) -> dict | None: """Full detail for one entity by name or ULID: attributes (with types + ontology - alignment), the conceptual layer, and the relationships it participates in. - Returns None when no entity matches.""" + alignment), key groups (pk/unique/alternate), the conceptual layer, and the + relationships it participates in. Returns None when no entity matches.""" le = _entity_by_name(model, name) if le is None: return None @@ -95,6 +114,7 @@ def get_entity(model: Model, name: str) -> dict | None: } for a in le.attributes ] + keys = _keys_for(model, le) rels: list[dict] = [] for r in sorted(model.relationships.values(), key=lambda x: x.name): @@ -127,6 +147,7 @@ def get_entity(model: Model, name: str) -> dict | None: if ce else None ), + "keys": keys, "attributes": attributes, "relationships": rels, } @@ -180,6 +201,59 @@ def get_model_context(model: Model) -> dict: } +def entities_detail(model: Model, limit: int = 40) -> dict: + """Compact detail for EVERY entity — attributes (name:domain, nullability), key + groups, and relationship names — for grounding a model-wide question ("are all + entities in BCNF?", "which lack a primary key?"). Deliberately terse: a real + model can have 150+ entities, so each is one small record, not the full nested + `get_entity` shape, keeping the whole thing within a chat context window. + + Capped at `limit` entities (alphabetical). `truncated` is True when the model has + more, with `total` and `shown` so the caller can tell the user the answer covers + only the first N and to narrow the question.""" + all_le = sorted(model.logical_entities.values(), key=lambda e: e.name) + shown = all_le[:limit] + ce_by_id = model.conceptual_entities + + # relationship names per entity, one pass + rels_by_entity: dict[str, list[str]] = {} + for r in model.relationships.values(): + from_e = model.logical_entities.get(r.from_.entity) + to_e = model.logical_entities.get(r.to.entity) + label = f"{from_e.name if from_e else '?'}→{to_e.name if to_e else '?'}" + for end in (r.from_.entity, r.to.entity): + rels_by_entity.setdefault(end, []).append(label) + + records = [] + for le in shown: + ce = ce_by_id.get(le.realises) if le.realises else None + records.append( + { + "name": le.name, + # "col:domain" (+ "?" when nullable) — enough to reason about + # attributes and dependencies without the full attribute objects. + "attributes": [ + f"{a.name}:{a.domain or '?'}{'?' if a.nullable else ''}" + for a in le.attributes + ], + "keys": [ + {"type": kg["type"], "columns": kg["columns"]} + for kg in _keys_for(model, le) + ], + "relationships": sorted(set(rels_by_entity.get(le.id, []))), + "ontology": _ontology_summary(ce) if ce else None, + } + ) + + return { + "project": model.config.name, + "total": len(all_le), + "shown": len(shown), + "truncated": len(all_le) > limit, + "entities": records, + } + + def _resolve_subject_area_id(model: Model, ref: str) -> str | None: """Accept a subject-area name or ULID; return its ULID, or None if unknown.""" if ref in model.subject_areas: diff --git a/packages/mcp/src/mdl_mcp/server.py b/packages/mcp/src/mdl_mcp/server.py index 1e2e929..737783e 100644 --- a/packages/mcp/src/mdl_mcp/server.py +++ b/packages/mcp/src/mdl_mcp/server.py @@ -54,8 +54,9 @@ def list_entities(subject_area: str | None = None) -> str: @mcp.tool() def get_entity(name: str) -> str: """Full detail for one entity by name (or ULID): its attributes with types - and ontology alignment, the conceptual layer, and the relationships it takes - part in. Returns an error object if no entity matches.""" + and ontology alignment, its key groups (pk / unique / alternate), the + conceptual layer, and the relationships it takes part in. Returns an error + object if no entity matches.""" hit = query.get_entity(_model(), name) if hit is None: return _json({"error": f"no entity named {name!r}"}) diff --git a/packages/mcp/tests/test_query.py b/packages/mcp/tests/test_query.py index 08e9fdb..3c53968 100644 --- a/packages/mcp/tests/test_query.py +++ b/packages/mcp/tests/test_query.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mdl_core.query import get_entity, get_model_context, list_entities +from mdl_core.query import entities_detail, get_entity, get_model_context, list_entities from mdl_core.repo import ModelRepo @@ -45,6 +45,34 @@ def test_get_entity_accepts_a_ulid(model_dir): assert get_entity(model, le.id)["name"] == "trade" +def test_get_entity_includes_keys_field(model_dir): + # The builder uses the legacy role:business_key convention (no KeyGroup objects), + # so `keys` is an empty list here — but the field must be present and a list, so + # a caller (and the LLM grounding) can always rely on its shape. + e = get_entity(_model(model_dir), "counterparty") + assert isinstance(e["keys"], list) + + +def test_entities_detail_is_compact_and_complete(model_dir): + d = entities_detail(_model(model_dir)) + assert d["total"] == 2 + assert d["truncated"] is False + names = {e["name"] for e in d["entities"]} + assert {"counterparty", "trade"} == names + cp = next(e for e in d["entities"] if e["name"] == "counterparty") + # attributes are compact "name:domain[?]" strings + assert any(a.startswith("counterparty_id:") for a in cp["attributes"]) + assert isinstance(cp["keys"], list) + + +def test_entities_detail_truncates_at_limit(model_dir): + d = entities_detail(_model(model_dir), limit=1) + assert d["total"] == 2 + assert d["shown"] == 1 + assert d["truncated"] is True + assert len(d["entities"]) == 1 + + def test_get_model_context_summary(model_dir): ctx = get_model_context(_model(model_dir)) assert ctx["project"] == "testmodel" diff --git a/vscode/src/chatParticipant.ts b/vscode/src/chatParticipant.ts index 94cbefc..c102516 100644 --- a/vscode/src/chatParticipant.ts +++ b/vscode/src/chatParticipant.ts @@ -35,10 +35,40 @@ interface EntityDetail { definition: string | null; pattern: string | null; conceptual: { name: string; ontology: string | null; ontology_layer: string | null } | null; + keys: { name: string; type: string; columns: string[] }[]; attributes: { name: string; domain: string | null; nullable: boolean; ontology: string | null }[]; relationships: { name: string; from: string; to: string }[]; } +interface EntitiesDetail { + project: string; + total: number; + shown: number; + truncated: boolean; + entities: { + name: string; + attributes: string[]; + keys: { type: string; columns: string[] }[]; + relationships: string[]; + ontology: string | null; + }[]; +} + +/** A model-WIDE question needs every entity's attributes + keys, not just named + * ones — normalization ("BCNF", "3NF", "normalis"), coverage ("which entities", + * "any entity", "all"/"every"/"each"), or simply a question that names no entity at + * all. Everything else is targeted and grounds on the mentioned entities. */ +function isModelWide(prompt: string, mentioned: string[]): boolean { + const p = prompt.toLowerCase(); + if (/\b(bcnf|[1-6]nf|normal(is|iz)|denormal)/.test(p)) return true; + if (/\b(which|any|all|every|each|no)\b.*\b(entit|model|table|key|attribute)/.test(p)) return true; + return mentioned.length === 0; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** Run an `mdl model …` read and parse its JSON stdout. Throws with the CLI's own * message on a non-zero exit so the handler can surface it. */ async function readModel(dir: string, args: string[]): Promise { @@ -143,34 +173,48 @@ async function answerWithModel( const names = rows.map((r) => r.name); const context = await readModel(dir, ["model", "context"]); - // Pull detail for any entity the prompt names, so the model can answer about - // attributes/relationships without guessing. Bounded to keep the prompt small. - const mentioned = names - .filter((n) => request.prompt.toLowerCase().includes(n.toLowerCase())) - .slice(0, 4); - const details: EntityDetail[] = []; - for (const n of mentioned) { - try { - details.push(await readModel(dir, ["model", "entity", n])); - } catch { - /* skip an entity that vanished between the list and the read */ + // Two grounding strategies, chosen by scope. A model-wide question (normalization, + // "which entities…", "all/every…") needs every entity's attributes + keys; a + // targeted one needs only the named entities in full. We never dump the whole + // model at full fidelity — `mdl model detail` is compact and capped, so a 150- + // entity model stays inside the context window (with a truncation notice). + const mentioned = names.filter((n) => + new RegExp(`\\b${escapeRegExp(n)}\\b`, "i").test(request.prompt), + ); + let facts: string; + let truncationNote = ""; + if (isModelWide(request.prompt, mentioned)) { + const detail = await readModel(dir, ["model", "detail", "--limit", "120"]); + if (detail.truncated) { + truncationNote = + `\n\n_(This model has ${detail.total} entities; I grounded on the first ` + + `${detail.shown}. For a complete answer, ask about a subject area or specific ` + + "entities.)_"; } + facts = JSON.stringify({ context, entities_detail: detail }, null, 2); + } else { + const details: EntityDetail[] = []; + for (const n of mentioned.slice(0, 6)) { + try { + details.push(await readModel(dir, ["model", "entity", n])); + } catch { + /* skip an entity that vanished between the list and the read */ + } + } + facts = JSON.stringify({ entities: names, context, detail: details }, null, 2); } - const facts = JSON.stringify( - { entities: names, context, detail: details }, - null, - 2, - ); - const system = "You are Modelith, a data-modeling assistant answering about ONE specific model " + "inside VS Code. Answer the user's question using ONLY the JSON facts provided — " + - "the model's entities, subject areas, relationships, and any entity detail. Do not " + - "invent entities, attributes, or alignments that are not in the facts; if the answer " + - "is not derivable from them, say so and suggest `@modelith /list`. Be concise, use " + - "Markdown, and prefer the model's own names verbatim. This is read-only: for editing, " + - "point the user to Copilot Agent mode where the Modelith tools run."; + "the model's entities, subject areas, relationships, attributes and keys. Attribute " + + "strings are `name:domain` with a trailing `?` for nullable. Keys list pk / unique / " + + "alternate key groups. Do not invent entities, attributes, keys, or alignments that " + + "are not in the facts. If the facts genuinely lack what the question needs (e.g. no " + + "attributes were provided for an entity), say precisely what is missing rather than " + + "guessing. Be concise, use Markdown, and prefer the model's own names verbatim. This " + + "is read-only: for editing, point the user to Copilot Agent mode where the Modelith " + + "tools run."; const messages = [ vscode.LanguageModelChatMessage.User(`${system}\n\nMODEL FACTS:\n${facts}`), @@ -180,6 +224,7 @@ async function answerWithModel( try { const res = await request.model.sendRequest(messages, {}, token); for await (const chunk of res.text) stream.markdown(chunk); + if (truncationNote) stream.markdown(truncationNote); } catch (e) { // The model call can fail (consent not granted, quota, offline). Fall back to // the heuristic so the user still gets an answer, not a dead end. @@ -218,6 +263,13 @@ async function renderEntity( if (e.conceptual.ontology_layer) stream.markdown(` _(${e.conceptual.ontology_layer} layer)_`); stream.markdown("\n\n"); } + if (e.keys?.length) { + stream.markdown("**Keys**\n"); + for (const k of e.keys) { + stream.markdown(`- ${k.type}: ${k.columns.join(", ") || "—"} (${k.name})\n`); + } + stream.markdown("\n"); + } if (e.attributes.length) { stream.markdown("**Attributes**\n"); for (const a of e.attributes) { From a877f420cd927a88168582d6cc35c5dabfc3a150 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 17:01:14 +1000 Subject: [PATCH 7/9] Infer a primary key from the legacy business_key convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of seven demo entities reported "no keys" because they mark keys with the per-attribute `role: business_key` convention rather than a first-class KeyGroup, and _keys_for only read KeyGroups. The IR documents business_key as still supported (a pk KeyGroup merely "wins over" it), so the model DOES know these keys — the query layer just wasn't surfacing them. _keys_for now synthesises an inferred pk from business_key attributes when no explicit pk KeyGroup exists, flagged `inferred: true`. All entities now expose a primary key, so @modelith can reason about them for normalization; the LLM prompt explains the inferred flag and that a full BCNF/3NF proof still needs functional dependencies the model doesn't yet capture. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/mdl_core/query.py | 13 ++++++++++++- packages/mcp/tests/test_query.py | 12 ++++++++---- vscode/src/chatParticipant.ts | 16 ++++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/core/src/mdl_core/query.py b/packages/core/src/mdl_core/query.py index e1d4a63..6461c9b 100644 --- a/packages/core/src/mdl_core/query.py +++ b/packages/core/src/mdl_core/query.py @@ -79,7 +79,12 @@ def list_entities(model: Model, subject_area: str | None = None) -> list[dict]: def _keys_for(model: Model, le: LogicalEntity) -> list[dict]: """The entity's key groups (erwin Key Groups) — pk / unique / alternate / index — with member attribute names in key order. This is what lets a caller reason about - normalization (candidate keys, functional dependencies) and identity.""" + normalization (candidate keys, functional dependencies) and identity. + + When no explicit `pk` KeyGroup is present, fall back to the legacy per-attribute + `role: business_key` convention (which the IR documents as still supported): the + business-key attributes form an inferred primary key, flagged `inferred: True` so + a caller knows it came from the convention, not a declared KeyGroup.""" attr_name = {a.id: a.name for a in le.attributes} keys: list[dict] = [] for kg in sorted(model.key_groups.values(), key=lambda k: (k.type, k.name)): @@ -92,6 +97,12 @@ def _keys_for(model: Model, le: LogicalEntity) -> list[dict]: "columns": [attr_name.get(m, m) for m in kg.members], } ) + # Legacy fallback: no declared pk KeyGroup, but business_key attributes exist. + if not any(k["type"] == "pk" for k in keys): + bk = [a.name for a in le.attributes if a.role == "business_key"] + if bk: + keys.insert(0, {"name": "pk (from business_key)", "type": "pk", + "columns": bk, "inferred": True}) return keys diff --git a/packages/mcp/tests/test_query.py b/packages/mcp/tests/test_query.py index 3c53968..aff123b 100644 --- a/packages/mcp/tests/test_query.py +++ b/packages/mcp/tests/test_query.py @@ -45,12 +45,16 @@ def test_get_entity_accepts_a_ulid(model_dir): assert get_entity(model, le.id)["name"] == "trade" -def test_get_entity_includes_keys_field(model_dir): - # The builder uses the legacy role:business_key convention (no KeyGroup objects), - # so `keys` is an empty list here — but the field must be present and a list, so - # a caller (and the LLM grounding) can always rely on its shape. +def test_get_entity_infers_pk_from_business_key(model_dir): + # The builder uses the legacy role:business_key convention (no KeyGroup objects). + # _keys_for should synthesise an inferred pk from it, so normalization questions + # can reason about the key even without a declared KeyGroup. e = get_entity(_model(model_dir), "counterparty") assert isinstance(e["keys"], list) + pk = next((k for k in e["keys"] if k["type"] == "pk"), None) + assert pk is not None + assert pk["inferred"] is True + assert pk["columns"] == ["counterparty_id"] def test_entities_detail_is_compact_and_complete(model_dir): diff --git a/vscode/src/chatParticipant.ts b/vscode/src/chatParticipant.ts index c102516..4639b91 100644 --- a/vscode/src/chatParticipant.ts +++ b/vscode/src/chatParticipant.ts @@ -209,12 +209,16 @@ async function answerWithModel( "inside VS Code. Answer the user's question using ONLY the JSON facts provided — " + "the model's entities, subject areas, relationships, attributes and keys. Attribute " + "strings are `name:domain` with a trailing `?` for nullable. Keys list pk / unique / " + - "alternate key groups. Do not invent entities, attributes, keys, or alignments that " + - "are not in the facts. If the facts genuinely lack what the question needs (e.g. no " + - "attributes were provided for an entity), say precisely what is missing rather than " + - "guessing. Be concise, use Markdown, and prefer the model's own names verbatim. This " + - "is read-only: for editing, point the user to Copilot Agent mode where the Modelith " + - "tools run."; + "alternate key groups; a pk marked inferred came from the legacy business_key " + + "convention but is still the entity's primary key. For normalization questions: you " + + "can reason from candidate keys (e.g. an entity whose only key is a single attribute, " + + "with all other attributes dependent on it, is trivially in BCNF for key-determined " + + "dependencies), but the model does NOT capture arbitrary functional dependencies — so " + + "say plainly that a full BCNF/3NF proof needs FDs the model doesn't record, and give " + + "the key-based assessment you can. Do not invent entities, attributes, keys, or " + + "alignments that are not in the facts. Be concise, use Markdown, and prefer the " + + "model's own names verbatim. This is read-only: for editing, point the user to " + + "Copilot Agent mode where the Modelith tools run."; const messages = [ vscode.LanguageModelChatMessage.User(`${system}\n\nMODEL FACTS:\n${facts}`), From a911caab718859efe6f45912f56ccc18347d17b6 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 17:11:22 +1000 Subject: [PATCH 8/9] Prefer a workspace .venv mdl over the global install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findMdl probed `.venv/bin/mdl` only under the MODEL dir, which is typically a demo/ subfolder with no venv — so when developing Modelith itself it fell through to the stale `uv tool` global on PATH, and every core change needed a global reinstall to show up in the extension. Also probe `.venv/bin/mdl` in each workspace folder, ranked above the PATH/global candidates. In the Modelith repo that resolves to the working-tree editable install, so Python edits are live with no reinstall. A normal user (model dir with its own venv, or only a global install) has no workspace .venv and is unaffected. Co-Authored-By: Claude Opus 4.8 --- vscode/src/mdl.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vscode/src/mdl.ts b/vscode/src/mdl.ts index 6bae553..dcb7280 100644 --- a/vscode/src/mdl.ts +++ b/vscode/src/mdl.ts @@ -24,6 +24,17 @@ export async function findMdl(root: string): Promise { if (explicit) candidates.push({ cmd: explicit, args: [], label: explicit }); const venv = path.join(root, ".venv", "bin", "mdl"); if (fs.existsSync(venv)) candidates.push({ cmd: venv, args: [], label: ".venv/bin/mdl" }); + // A `.venv/bin/mdl` in a workspace folder, ranked ABOVE the global PATH install. + // This is what lets someone developing Modelith itself use their working-tree + // (editable) mdl instead of a stale `uv tool` global — the model dir is usually a + // demo/ subfolder with no venv, so the model-dir probe above misses the repo-root + // editable install. For a normal user (no workspace .venv) this adds nothing. + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const wsVenv = path.join(folder.uri.fsPath, ".venv", "bin", "mdl"); + if (wsVenv !== venv && fs.existsSync(wsVenv)) { + candidates.push({ cmd: wsVenv, args: [], label: `${folder.name}/.venv/bin/mdl` }); + } + } candidates.push({ cmd: "mdl", args: [], label: "mdl (PATH)" }); // GUI-launched VS Code on macOS/Linux has a minimal PATH that excludes the // per-user install locations a shell profile would add, so `mdl (PATH)` misses From f6cf379f0c699867453cf06b1aea4143268dc539 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 12 Sep 2026 17:12:22 +1000 Subject: [PATCH 9/9] Fail fast on Node < 18 in the extension build scripts Packaging under the default Node 16 crashed cryptically ("ReferenceError: ReadableStream is not defined") from deep inside vsce/undici. Add a check-node guard (engines.node >=18 declaratively, plus a preflight in build/package/publish) that aborts with a clear message pointing at `nvm use 20` before the toolchain runs. Verified: exits 1 with the message on Node 16, silent no-op on Node 20. Co-Authored-By: Claude Opus 4.8 --- vscode/package.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/vscode/package.json b/vscode/package.json index 5f2ba3f..1064b9f 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -30,7 +30,8 @@ "theme": "dark" }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.85.0", + "node": ">=18" }, "categories": [ "Data Science", @@ -216,12 +217,13 @@ } }, "scripts": { + "check-node": "node -e \"if(+process.versions.node.split('.')[0]<18){console.error('\\n Node '+process.versions.node+' is too old for the build toolchain (vsce/esbuild need Node 18+).\\n Use Node 20: nvm use 20 (or: export NVM_DIR=$HOME/.nvm; . $NVM_DIR/nvm.sh; nvm use 20)\\n');process.exit(1)}\"", "vscode:prepublish": "npm run build -- --minify", - "build": "tsc --noEmit && node esbuild.mjs", + "build": "npm run check-node && tsc --noEmit && node esbuild.mjs", "watch": "node esbuild.mjs --watch", "typecheck": "tsc --noEmit", - "package": "npx @vscode/vsce package --no-dependencies", - "publish": "npx @vscode/vsce publish --no-dependencies" + "package": "npm run check-node && npx @vscode/vsce package --no-dependencies", + "publish": "npm run check-node && npx @vscode/vsce publish --no-dependencies" }, "devDependencies": { "@types/node": "^20.11.0",