From 0c6e66668dfa582ecc6910bd3048d38ec44f180d Mon Sep 17 00:00:00 2001 From: Carlos Schrupp Date: Tue, 28 Apr 2026 14:08:26 -0400 Subject: [PATCH 1/5] Add experimental MCP rollout and demo notebook --- .github/workflows/release.yml | 14 + CHANGELOG.md | 40 + README.md | 56 +- docs/mcp-plan.md | 115 +++ docs/release-process.md | 29 +- docs/roadmap.md | 34 +- docs/site/guides/examples.md | 9 + docs/site/index.md | 6 +- docs/site/installation.md | 47 +- docs/site/reference/index.md | 4 + docs/site/reference/mcp-api.md | 251 +++++ docs/site/workflows/mcp-workflow.md | 142 +++ docs/site/workflows/release-publishing.md | 27 +- examples/mcp_workflow_demo.py | 363 +++++++ examples/notebooks/developer/README.md | 4 + .../developer/mcp_workflow_demo.ipynb | 215 ++++ examples/notebooks/user/cbl_log_example.ipynb | 2 +- .../cbl_log_example_numpy_computed.ipynb | 2 +- ...e16b_porosity_example_numpy_computed.ipynb | 2 +- .../cbl_log_example_pandas_computed.ipynb | 2 +- ...16b_porosity_example_pandas_computed.ipynb | 2 +- .../user/forge16b_porosity_example.ipynb | 2 +- mkdocs.yml | 2 + pyproject.toml | 9 + scripts/generate_example_notebooks.py | 110 +- scripts/smoke_installed_wheel.py | 66 ++ src/wellplot/_version.py | 2 +- src/wellplot/api/render.py | 31 +- src/wellplot/errors.py | 4 + src/wellplot/logfile.py | 130 ++- src/wellplot/mcp/__init__.py | 39 + src/wellplot/mcp/assets/__init__.py | 20 + .../production/cbl_log_example/README.md | 80 ++ .../cbl_log_example/base.template.yaml | 226 +++++ .../production/cbl_log_example/data-notes.md | 56 ++ .../full_reconstruction.log.yaml | 630 ++++++++++++ .../forge16b_porosity_example/README.md | 90 ++ .../base.template.yaml | 238 +++++ .../forge16b_porosity_example/data-notes.md | 72 ++ .../full_reconstruction.log.yaml | 601 +++++++++++ src/wellplot/mcp/server.py | 304 ++++++ src/wellplot/mcp/service.py | 939 ++++++++++++++++++ src/wellplot/pipeline.py | 93 +- tests/test_mcp_server.py | 236 +++++ tests/test_mcp_service.py | 371 +++++++ uv.lock | 393 +++++++- 46 files changed, 6044 insertions(+), 66 deletions(-) create mode 100644 docs/mcp-plan.md create mode 100644 docs/site/reference/mcp-api.md create mode 100644 docs/site/workflows/mcp-workflow.md create mode 100644 examples/mcp_workflow_demo.py create mode 100644 examples/notebooks/developer/mcp_workflow_demo.ipynb create mode 100644 src/wellplot/mcp/__init__.py create mode 100644 src/wellplot/mcp/assets/__init__.py create mode 100644 src/wellplot/mcp/assets/production/cbl_log_example/README.md create mode 100644 src/wellplot/mcp/assets/production/cbl_log_example/base.template.yaml create mode 100644 src/wellplot/mcp/assets/production/cbl_log_example/data-notes.md create mode 100644 src/wellplot/mcp/assets/production/cbl_log_example/full_reconstruction.log.yaml create mode 100644 src/wellplot/mcp/assets/production/forge16b_porosity_example/README.md create mode 100644 src/wellplot/mcp/assets/production/forge16b_porosity_example/base.template.yaml create mode 100644 src/wellplot/mcp/assets/production/forge16b_porosity_example/data-notes.md create mode 100644 src/wellplot/mcp/assets/production/forge16b_porosity_example/full_reconstruction.log.yaml create mode 100644 src/wellplot/mcp/server.py create mode 100644 src/wellplot/mcp/service.py create mode 100644 tests/test_mcp_server.py create mode 100644 tests/test_mcp_service.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7a763a..cecaf06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,20 @@ jobs: MPLBACKEND: Agg run: ./.release-venv/bin/python scripts/smoke_installed_wheel.py + - name: Create clean MCP release-test environment + run: python -m venv .release-mcp-venv + + - name: Install built wheel with MCP dependency into clean environment + run: | + ./.release-mcp-venv/bin/pip install --upgrade pip + ./.release-mcp-venv/bin/pip install dist/*.whl + ./.release-mcp-venv/bin/pip install "mcp>=1,<2" + + - name: Run installed-wheel smoke test with MCP support + env: + MPLBACKEND: Agg + run: ./.release-mcp-venv/bin/python scripts/smoke_installed_wheel.py + - name: Upload release artifacts uses: actions/upload-artifact@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c9691e5..ea0381b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,46 @@ This changelog tracks public release notes for the `wellplot` distribution. The entries below track public PyPI releases of the `wellplot` distribution. +## [0.3.0] - 2026-04-28 + +Experimental MCP support is now part of the public `wellplot` distribution. +This release folds the planned scoped-preview and writable-authoring MCP slices +into one publishable version. + +### Added + +- Optional `wellplot[mcp]` install extra and `wellplot-mcp` stdio entry point + for local MCP clients. +- MCP tools for logfile validation, inspection, generic preview rendering, + explicit section/track/window PNG previews, explicit file rendering, example + bundle export, and canonical logfile YAML validation/format/save flows. +- Packaged MCP resources for the logfile schema and curated production example + bundles, plus guided prompts for logfile review, preview, and example-based + starts. +- Public MCP user documentation covering installation, client registration, + server-root policy, workflow usage, and API reference. + +### Changed + +- Release verification now smoke-tests both the base installed wheel and a + clean environment with the optional MCP dependency enabled. +- Maintainer release guidance now includes MCP-specific preflight checks and a + TestPyPI rehearsal recommendation for releases that change the MCP surface or + verification path. + +### Fixed + +- Scoped section and track previews now preserve implicit bindings for + single-section savefiles, preventing filtered render failures during MCP + preview calls. + +### Notes + +- MCP remains experimental in this release. +- `format_logfile_text(...)` and `save_logfile_text(...)` normalize the logfile + through the canonical serializer path; comments, anchors, and template + indirection are not preserved in the returned YAML text. + ## [0.1.0] - 2026-04-22 Initial public release of `wellplot` as an open-source Python toolkit for diff --git a/README.md b/README.md index fff9980..8b04f4b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ This repository currently contains the current MVP baseline: - a physical page layout engine - optional `matplotlib` and `plotly` renderer backends - optional LAS and DLIS ingestion adapters +- optional experimental MCP server support for local logfile validation, + previews, starter example export, and canonical YAML save workflows - DLIS VDL/WF1-style array support with derived micro-time sample axes - printable VDL density and waveform array rendering - scale-aware curve fills, including crossover, limit, and baseline modes @@ -93,9 +95,10 @@ The intended workflow is: - render a full report or a partial view - optionally serialize the layout back to YAML -The next development phase adds two public API surfaces on top of those layers: -- dataset ingestion for computed numpy/pandas results -- programmatic composition/rendering so users can build logs without hand-authoring YAML +The public interaction layers now sit on top of that same render pipeline: +- YAML templates and savefiles for declarative jobs +- the Python surfaces exposed through `wellplot` and `wellplot.api` +- the experimental stdio MCP server exposed through `wellplot[mcp]` Track types are explicit: `reference`, `normal`, `array`, and `annotation` (with compatibility aliases `depth`, `curve`, `image`). @@ -128,6 +131,7 @@ python -m pip install "wellplot[dlis]" python -m pip install "wellplot[pandas]" python -m pip install "wellplot[interactive]" python -m pip install "wellplot[notebook]" +python -m pip install "wellplot[mcp]" ``` Common example-workflow installs: @@ -138,6 +142,44 @@ python -m pip install "wellplot[dlis,notebook]" python -m pip install "wellplot[las,dlis,pandas,notebook,interactive,units]" ``` +## Experimental MCP Server + +The optional MCP surface is experimental and is exposed only through the +optional dependency extra and the stdio entry point. + +Install it with: + +```bash +python -m pip install "wellplot[mcp]" +``` + +The server root is fixed to the current working directory when `wellplot-mcp` +starts. Logfile paths, referenced source data, exported example bundles, saved +YAML files, and explicit render outputs must all resolve inside that root. + +Typical client registration: + +```json +{ + "mcpServers": { + "wellplot": { + "command": "wellplot-mcp", + "cwd": "/absolute/path/to/job-root" + } + } +} +``` + +The MCP tool surface currently supports: +- `validate_logfile` and `inspect_logfile` +- `preview_logfile_png` plus explicit section/track/window preview tools +- `render_logfile_to_file` +- `export_example_bundle` +- `validate_logfile_text`, `format_logfile_text`, and `save_logfile_text` + +Packaged example resources and guided prompts are also exposed for MCP-aware +clients. + ## Contributor Development Workflow This project uses `uv` for environment management and dependency resolution. @@ -172,6 +214,7 @@ Run tests: ```bash uv run python -m unittest discover -s tests -v +uv run --with mcp pytest tests/test_mcp_service.py tests/test_mcp_server.py ``` Build and smoke-test the wheel: @@ -183,6 +226,11 @@ python -m venv .smoke-venv ./.smoke-venv/bin/pip install dist/*.whl ./.smoke-venv/bin/wellplot --help MPLBACKEND=Agg ./.smoke-venv/bin/python scripts/smoke_installed_wheel.py + +python -m venv .smoke-venv-mcp +./.smoke-venv-mcp/bin/pip install --upgrade pip +./.smoke-venv-mcp/bin/pip install dist/*.whl "mcp>=1,<2" +MPLBACKEND=Agg ./.smoke-venv-mcp/bin/python scripts/smoke_installed_wheel.py ``` Format and lint: @@ -250,6 +298,8 @@ Current examples: - [examples/api_partial_render_demo.py](examples/api_partial_render_demo.py) - [examples/api_notebook_bytes_demo.py](examples/api_notebook_bytes_demo.py) - [examples/api_serialize_demo.py](examples/api_serialize_demo.py) +- [examples/mcp_workflow_demo.py](examples/mcp_workflow_demo.py) +- [examples/notebooks/developer/mcp_workflow_demo.ipynb](examples/notebooks/developer/mcp_workflow_demo.ipynb) Important current boundary: - `document_*` helpers round-trip the normalized `LogDocument` template shape diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md new file mode 100644 index 0000000..e489d47 --- /dev/null +++ b/docs/mcp-plan.md @@ -0,0 +1,115 @@ +# MCP Rollout Status + +Last updated: 2026-04-28 + +## Purpose + +This document is the live repo-local status document for the experimental +`wellplot` MCP surface. + +The original extracted v1 plan has now been implemented and extended. This file +tracks what is actually in the repository, what release it is being prepared +for, and what still requires maintainer action outside the repo. + +## Release Status + +- Repo target version: `0.3.0` +- Public status: experimental +- Transport: stdio-first through `wellplot-mcp` +- Packaging: + - optional extra: `wellplot[mcp]` + - console entry point: `wellplot-mcp` +- Release note boundary: + - the originally planned scoped-preview slice and writable-authoring slice + landed together in the current tree and are being released together as + `0.3.0` + +## Implemented Surface + +### Tools + +- `validate_logfile(logfile_path)` +- `inspect_logfile(logfile_path)` +- `preview_logfile_png(...)` +- `preview_section_png(logfile_path, section_id, page_index=0, dpi=144)` +- `preview_track_png(logfile_path, section_id, track_ids, page_index=0, dpi=144, depth_range=None, depth_range_unit=None)` +- `preview_window_png(logfile_path, depth_range, depth_range_unit=None, page_index=0, dpi=144, section_ids=None)` +- `render_logfile_to_file(logfile_path, output_path, overwrite=False)` +- `export_example_bundle(example_id, output_dir, overwrite=False)` +- `validate_logfile_text(yaml_text, base_dir=None)` +- `format_logfile_text(yaml_text, base_dir=None)` +- `save_logfile_text(yaml_text, output_path, overwrite=False, base_dir=None)` + +### Resources + +- `wellplot://schema/logfile.json` +- `wellplot://examples/production/index.json` +- `wellplot://examples/production/{example_id}/README.md` +- `wellplot://examples/production/{example_id}/base.template.yaml` +- `wellplot://examples/production/{example_id}/full_reconstruction.log.yaml` +- `wellplot://examples/production/{example_id}/data-notes.md` + +Packaged example ids: + +- `cbl_log_example` +- `forge16b_porosity_example` + +### Prompts + +- `review_logfile(logfile_path)` +- `preview_logfile(logfile_path, focus=None)` +- `start_from_example(example_id, goal)` + +## Behavior and Safety Notes + +- The server root is the current working directory when `wellplot-mcp` starts. +- File-based tools may only read from and write to paths that resolve inside + that root. +- Preview tools are read-only and always return in-memory PNG content rendered + through Matplotlib. +- Explicit writes only happen through: + - `render_logfile_to_file(...)` + - `export_example_bundle(...)` + - `save_logfile_text(...)` +- `validate_logfile_text(...)`, `format_logfile_text(...)`, and + `save_logfile_text(...)` accept unsaved full logfile YAML text. When + `base_dir` is provided, relative template and data references are resolved + from that directory under the server root. +- `format_logfile_text(...)` and `save_logfile_text(...)` normalize the logfile + through the canonical serializer path. They do not preserve comments, + anchors, original formatting, or template indirection. + +## Implementation Notes + +- `wellplot` and `wellplot.api` import surfaces remain unchanged. +- The implementation lives in: + - `src/wellplot/mcp/server.py` + - `src/wellplot/mcp/service.py` + - packaged example assets under `src/wellplot/mcp/assets/` +- Release verification now includes: + - base installed-wheel smoke coverage + - a second clean-environment smoke path with the optional MCP dependency + enabled +- Scoped preview filtering now preserves implicit bindings for single-section + savefiles, which was necessary for reliable section/track MCP previews. + +## Verification In Repo + +Primary coverage now includes: + +- service-layer tests in `tests/test_mcp_service.py` +- stdio integration and registration tests in `tests/test_mcp_server.py` +- installed-wheel smoke coverage in `scripts/smoke_installed_wheel.py` +- release workflow MCP verification in `.github/workflows/release.yml` +- public documentation pages under `docs/site/` + +## Remaining Maintainer Actions + +The following actions are intentionally outside this repo-local implementation +document and still need to happen through the normal release flow: + +1. Run the GitHub `Release` workflow with `publish_target=verify-only`. +2. Run a TestPyPI rehearsal for `0.3.0`. +3. Publish `0.3.0` to PyPI. +4. Let the merged documentation changes publish through the normal docs path to + Read the Docs and the GitHub Pages mirror. diff --git a/docs/release-process.md b/docs/release-process.md index a5f9714..22d726f 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -23,7 +23,7 @@ The manual workflow accepts: - `testpypi` - `pypi` 2. `expected_version` - - optional version guard such as `0.1.0` + - optional version guard such as `0.3.0` - if provided, the workflow checks it against `src/wellplot/_version.py` ## What The Workflow Does @@ -34,11 +34,14 @@ The `build` job always runs first: 2. sets up Python and `uv` 3. optionally verifies the requested version 4. runs `uv build` -5. creates a clean virtual environment +5. creates a clean virtual environment for the base wheel 6. installs the built wheel into that clean environment 7. verifies the `wellplot` console entry point 8. runs `scripts/smoke_installed_wheel.py` -9. uploads the built artifacts for later publish jobs +9. creates a second clean virtual environment for MCP verification +10. installs the built wheel plus `mcp>=1,<2` +11. reruns `scripts/smoke_installed_wheel.py` with MCP support enabled +12. uploads the built artifacts for later publish jobs Publishing jobs only run when selected: @@ -76,12 +79,17 @@ Run the local validation path first: ```bash uv run ruff check . -uv run python -m unittest discover -s tests -v +uv run pytest tests/test_mcp_service.py tests/test_mcp_server.py tests/test_pipeline.py tests/test_cli.py tests/test_public_api.py +uv run --with mcp pytest tests/test_mcp_server.py +uv run --group docs mkdocs build --strict uv build uv venv /tmp/wellplot-release-check uv pip install --python /tmp/wellplot-release-check/bin/python dist/*.whl /tmp/wellplot-release-check/bin/wellplot --help MPLBACKEND=Agg /tmp/wellplot-release-check/bin/python scripts/smoke_installed_wheel.py +uv venv /tmp/wellplot-release-check-mcp +uv pip install --python /tmp/wellplot-release-check-mcp/bin/python dist/*.whl "mcp>=1,<2" +MPLBACKEND=Agg /tmp/wellplot-release-check-mcp/bin/python scripts/smoke_installed_wheel.py ``` ## Maintenance Release Sequence @@ -92,7 +100,8 @@ MPLBACKEND=Agg /tmp/wellplot-release-check/bin/python scripts/smoke_installed_wh 4. Trigger the `Release` workflow with: - `publish_target=verify-only` - `expected_version=` -5. Optionally rehearse on TestPyPI if the workflow, metadata, or dependencies changed in a risky way. +5. Rehearse on TestPyPI when the workflow, metadata, optional dependencies, or + MCP verification path changed in a risky way. 6. Trigger the `Release` workflow with: - `publish_target=pypi` - `expected_version=` @@ -106,6 +115,14 @@ python -m venv /tmp/wellplot-pypi-check /tmp/wellplot-pypi-check/bin/wellplot --help ``` +Experimental MCP install verification: + +```bash +python -m venv /tmp/wellplot-pypi-check-mcp +/tmp/wellplot-pypi-check-mcp/bin/pip install "wellplot[mcp]" +/tmp/wellplot-pypi-check-mcp/bin/python scripts/smoke_installed_wheel.py +``` + Optional TestPyPI install verification: ```bash @@ -124,3 +141,5 @@ The first public PyPI release has been completed. Future work is maintenance: - keep trusted publishing claims aligned with repository and environment names - update the changelog before each release - verify the public PyPI install after each release +- keep the optional MCP smoke path working whenever the experimental MCP + surface changes diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e7671f..30a8455 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # wellplot Roadmap -Last updated: 2026-04-22 +Last updated: 2026-04-28 ## Scope Summary @@ -72,6 +72,12 @@ Decision history is tracked in `docs/decision-log.md`. - shared heading/tail report blocks - fixed-row `open_hole` and `cased_hole` detail tables - first-page `remarks` sections +- Experimental MCP support is now available through: + - optional install extra `wellplot[mcp]` + - stdio entry point `wellplot-mcp` + - logfile validate/inspect/preview/render tools + - example-bundle export and normalized logfile YAML authoring tools + - packaged example/schema resources and guided prompts - User documentation foundation is now in place: - MkDocs + Material site scaffold under `docs/site` - Read the Docs primary build configuration under `.readthedocs.yaml` @@ -95,6 +101,29 @@ Decision history is tracked in `docs/decision-log.md`. - `layout.log_sections[*].data.source_path` - `layout.log_sections[*].data.source_format` +## Completed Slice: Experimental MCP Server (2026-04-28) + +This slice is now part of the project baseline. + +Delivered goals: + +- local MCP clients can validate, inspect, preview, and render existing + logfiles under a fixed server root +- local MCP clients can export starter example bundles and safely validate, + normalize, and save full logfile YAML text +- package and release verification now explicitly cover the optional MCP extra + instead of relying only on the base wheel + +Delivered public surface: + +- optional extra `wellplot[mcp]` +- stdio entry point `wellplot-mcp` +- tools, resources, and prompts documented in `docs/site/reference/mcp-api.md` + +Detailed checklist and release status: + +- [docs/mcp-plan.md](mcp-plan.md) + ## MVP Architecture Gaps (layout/bindings model, 2026-03-10) Needed next to complete the intended workflow: @@ -204,6 +233,9 @@ Current status: - first public PyPI release completed for `wellplot 0.1.0` - generated example notebooks and production example docs now prefer installed-package usage over repo-local `src/` bootstrapping + - release workflow now verifies both: + - the base installed wheel + - a clean environment with the optional MCP dependency enabled - next: - keep pruning stale comments and filling public API docstring gaps where they still exist - keep expanding user docs depth while the public library surface stabilizes diff --git a/docs/site/guides/examples.md b/docs/site/guides/examples.md index ba54088..66eb6f0 100644 --- a/docs/site/guides/examples.md +++ b/docs/site/guides/examples.md @@ -72,6 +72,15 @@ The examples are grouped by workflow rather than by isolated feature. - `examples/api_end_to_end_demo.py` - ingest, compute, align, merge, render, serialize +## Experimental MCP examples + +- `examples/mcp_workflow_demo.py` + - launch the local stdio MCP server, inspect its tools/resources/prompts, + preview a production logfile, and exercise export/format/save/render calls +- `examples/notebooks/developer/mcp_workflow_demo.ipynb` + - notebook companion to the same MCP walkthrough, generated into the + developer notebook set + ## YAML/report examples - `examples/cbl_job_demo.log.yaml` diff --git a/docs/site/index.md b/docs/site/index.md index fb8df02..52e03a3 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -2,10 +2,11 @@ `wellplot` is a Python library for building printable and interactive well-log layouts from LAS, DLIS, and in-memory scientific data. -It is designed for two equally important workflows: +It is designed for three complementary workflows: - declarative log authoring with YAML templates/savefiles - programmatic log authoring from Python, including notebooks and research pipelines +- experimental MCP-driven log review, preview, and YAML authoring ## Installation @@ -80,6 +81,7 @@ flowchart LR - add computed channels from `numpy` and `pandas` - align, sort, convert, and merge channels before rendering - build layouts with YAML or with the Python API +- validate, inspect, preview, and safely save logfile workflows through MCP - render full reports, sections, tracks, and bounded windows - generate PDF reports and notebook-friendly PNG/SVG outputs - serialize layout/report definitions back to YAML @@ -89,4 +91,4 @@ flowchart LR - Read [Getting Started](getting-started.md) - Install the package from [Installation](installation.md) - Learn the core objects in [Concepts](concepts.md) -- Choose a [YAML Workflow](workflows/yaml-workflow.md) or a [Python API Workflow](workflows/python-api.md) +- Choose a [YAML Workflow](workflows/yaml-workflow.md), a [Python API Workflow](workflows/python-api.md), or the experimental [MCP Workflow](workflows/mcp-workflow.md) diff --git a/docs/site/installation.md b/docs/site/installation.md index 2321471..93cfe78 100644 --- a/docs/site/installation.md +++ b/docs/site/installation.md @@ -23,6 +23,7 @@ python -m pip install "wellplot[pandas]" python -m pip install "wellplot[interactive]" python -m pip install "wellplot[notebook]" python -m pip install "wellplot[units]" +python -m pip install "wellplot[mcp]" ``` Common combinations: @@ -39,6 +40,47 @@ If you want every optional dependency exposed by the package metadata: python -m pip install "wellplot[all]" ``` +## Experimental MCP Install + +Install the MCP surface only when you need agent-driven logfile workflows: + +```bash +python -m pip install "wellplot[mcp]" +``` + +Verify that the stdio entry point was installed: + +```bash +python - <<'PY' +import shutil + +path = shutil.which("wellplot-mcp") +print(path) +if path is None: + raise SystemExit("wellplot-mcp entry point was not installed") +PY +``` + +Launch behavior: + +- `wellplot-mcp` starts a stdio server and stays attached to the client session +- the server root is the current working directory when the process starts +- logfile paths, referenced source data, saved YAML files, export directories, + and render outputs must all resolve inside that root + +Typical client registration: + +```json +{ + "mcpServers": { + "wellplot": { + "command": "wellplot-mcp", + "cwd": "/absolute/path/to/job-root" + } + } +} +``` + ## Verify The Install ```bash @@ -70,14 +112,15 @@ This is not required for normal installed-package usage. ```bash uv sync uv run ruff check . -uv run python -m unittest discover -s tests -v +uv run pytest tests/test_mcp_service.py tests/test_mcp_server.py tests/test_pipeline.py tests/test_cli.py tests/test_public_api.py +uv run --with mcp pytest tests/test_mcp_server.py ``` Build the local documentation: ```bash uv sync --group docs -uv run mkdocs build --strict +uv run --group docs mkdocs build --strict ``` Build and smoke-test a local wheel: diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index a8cbd49..25c64cd 100644 --- a/docs/site/reference/index.md +++ b/docs/site/reference/index.md @@ -11,6 +11,10 @@ surface, YAML shape, or report-page structure. - public package imports - `wellplot.api` module surfaces - dataset, builder, render, and serialization entry points +- [MCP API](mcp-api.md) + - experimental stdio server surface + - tools, resources, and prompts + - write semantics and server-root policy - [YAML Logfile](logfile-yaml.md) - top-level logfile structure - section-first layout and binding model diff --git a/docs/site/reference/mcp-api.md b/docs/site/reference/mcp-api.md new file mode 100644 index 0000000..5ae3458 --- /dev/null +++ b/docs/site/reference/mcp-api.md @@ -0,0 +1,251 @@ +# MCP API + +This page documents the experimental MCP server surface exposed by +`wellplot[mcp]`. + +## Status And Boundary + +- status: experimental +- transport: stdio +- entry point: `wellplot-mcp` +- import boundary: + - no new public `wellplot` or `wellplot.api` imports were added for this + feature + - the public surface is the MCP server contract itself + +## Root Policy + +The server root is the current working directory when `wellplot-mcp` starts. + +These inputs must resolve inside that root: + +- logfile paths +- template references +- data source paths +- `base_dir` +- output directories +- output file paths + +Preview tools are always read-only. Write-capable tools require explicit output +paths or directories. + +## Tools + +### `validate_logfile(logfile_path)` + +Purpose: validate one saved logfile path and its referenced inputs. + +Returns: + +- `valid` +- `message` +- `name` +- `render_backend` +- `section_ids` + +### `inspect_logfile(logfile_path)` + +Purpose: inspect one saved logfile path and discover valid preview ids. + +Returns: + +- `name` +- `render_backend` +- `configured_output_path` +- `page_settings` +- `depth_settings` +- `has_heading` +- `has_remarks` +- `has_tail` +- `section_ids` +- `sections` + +Each section record contains: + +- `id` +- `title` +- `source_path` +- `source_format` +- `depth_range` +- `track_ids` +- `track_kinds` + +### `preview_logfile_png(logfile_path, page_index=0, dpi=144, section_id=None, track_ids=None, depth_range=None, depth_range_unit=None, include_report_pages=True)` + +Purpose: generic superset preview tool for whole-report or filtered PNG output. + +Behavior: + +- returns one MCP image payload +- forces the Matplotlib preview path +- supports report pages, scoped sections, scoped tracks, and depth windows + +### `preview_section_png(logfile_path, section_id, page_index=0, dpi=144)` + +Purpose: preview one section without using the generic preview contract. + +Behavior: + +- returns one MCP image payload +- validates that `section_id` exists + +### `preview_track_png(logfile_path, section_id, track_ids, page_index=0, dpi=144, depth_range=None, depth_range_unit=None)` + +Purpose: preview one or more tracks inside one section. + +Behavior: + +- returns one MCP image payload +- validates `section_id` +- validates that each requested track id exists inside that section +- rejects an empty `track_ids` selection + +### `preview_window_png(logfile_path, depth_range, depth_range_unit=None, page_index=0, dpi=144, section_ids=None)` + +Purpose: preview a bounded depth window, optionally filtered to selected +sections. + +Behavior: + +- returns one MCP image payload +- validates that the depth range has positive height +- validates requested section ids when provided + +### `render_logfile_to_file(logfile_path, output_path, overwrite=False)` + +Purpose: render the full report to an explicit file path. + +Returns: + +- `backend` +- `page_count` +- `output_path` + +Behavior: + +- uses the configured render backend +- writes only when `output_path` is explicit +- rejects existing targets unless `overwrite=True` + +### `export_example_bundle(example_id, output_dir, overwrite=False)` + +Purpose: export one packaged production example into a writable directory under +the server root. + +Returns: + +- `example_id` +- `output_dir` +- `written_files` + +Exported files: + +- `README.md` +- `base.template.yaml` +- `full_reconstruction.log.yaml` +- `data-notes.md` + +### `validate_logfile_text(yaml_text, base_dir=None)` + +Purpose: validate unsaved full logfile YAML text. + +Returns the same structured fields as `validate_logfile(...)`: + +- `valid` +- `message` +- `name` +- `render_backend` +- `section_ids` + +Notes: + +- `base_dir` is optional +- when provided, relative template and data paths are resolved from that + directory under the server root + +### `format_logfile_text(yaml_text, base_dir=None)` + +Purpose: validate and normalize full logfile YAML text through the canonical +serializer path. + +Returns: + +- `name` +- `render_backend` +- `section_ids` +- `yaml_text` + +Normalization boundary: + +- comments are not preserved +- YAML anchors are not preserved +- original formatting is not preserved +- template indirection is flattened into canonical YAML + +### `save_logfile_text(yaml_text, output_path, overwrite=False, base_dir=None)` + +Purpose: validate, normalize, and write full logfile YAML text to an explicit +path. + +Returns: + +- `name` +- `render_backend` +- `section_ids` +- `output_path` + +Behavior: + +- rejects existing targets unless `overwrite=True` +- rebases relative template and data paths so the saved normalized YAML still + resolves correctly from the output location + +## Resources + +Static resources: + +- `wellplot://schema/logfile.json` +- `wellplot://examples/production/index.json` + +Resource templates: + +- `wellplot://examples/production/{example_id}/README.md` +- `wellplot://examples/production/{example_id}/base.template.yaml` +- `wellplot://examples/production/{example_id}/full_reconstruction.log.yaml` +- `wellplot://examples/production/{example_id}/data-notes.md` + +Supported packaged example ids: + +- `cbl_log_example` +- `forge16b_porosity_example` + +## Prompts + +Prompt names: + +- `review_logfile(logfile_path)` +- `preview_logfile(logfile_path, focus=None)` +- `start_from_example(example_id, goal)` + +Intent: + +- `review_logfile` guides the validate -> inspect -> summarize path +- `preview_logfile` guides the validate -> inspect -> preview path +- `start_from_example` embeds example resources and asks the client to adapt + them to a stated goal + +## Suggested Client Order + +For existing files: + +1. `validate_logfile(...)` +2. `inspect_logfile(...)` +3. a narrow preview tool +4. `render_logfile_to_file(...)` only when you want an artifact on disk + +For new YAML authoring: + +1. `export_example_bundle(...)` when you want a starter packet +2. `validate_logfile_text(...)` +3. `format_logfile_text(...)` +4. `save_logfile_text(...)` diff --git a/docs/site/workflows/mcp-workflow.md b/docs/site/workflows/mcp-workflow.md new file mode 100644 index 0000000..3e56ad4 --- /dev/null +++ b/docs/site/workflows/mcp-workflow.md @@ -0,0 +1,142 @@ +# MCP Workflow + +`wellplot` includes an experimental stdio MCP server for local logfile +workflows. + +Use it when you want an MCP-aware client to validate, inspect, preview, and +author normalized logfile YAML without adding new Python import surfaces to your +own code. + +## Install + +```bash +python -m pip install "wellplot[mcp]" +``` + +## Launch Model + +`wellplot-mcp` is a stdio server. In normal use, an MCP client launches it for +you and keeps the process attached to the session. + +Typical client registration: + +```json +{ + "mcpServers": { + "wellplot": { + "command": "wellplot-mcp", + "cwd": "/absolute/path/to/job-root" + } + } +} +``` + +## Server-Root Policy + +The server root is the current working directory when `wellplot-mcp` starts. + +That policy applies to: + +- `logfile_path` +- referenced template paths +- referenced section or top-level data sources +- `output_path` +- `output_dir` +- `base_dir` + +If any of those resolve outside the server root, the call fails with a clear +path-access error. + +## Recommended Review Flow + +Use the narrow tools in this order: + +1. `validate_logfile(logfile_path)` +2. `inspect_logfile(logfile_path)` +3. one of: + - `preview_section_png(...)` + - `preview_track_png(...)` + - `preview_window_png(...)` + - `preview_logfile_png(...)` when you really need the generic superset + +Why this order: + +- validation confirms the logfile and its referenced data are renderable +- inspection gives you valid section ids, track ids, and resolved source paths +- the explicit preview tools are easier for MCP clients than the generic + preview contract + +## Preview Tools + +Preferred read-only preview tools: + +- `preview_section_png(logfile_path, section_id, page_index=0, dpi=144)` +- `preview_track_png(logfile_path, section_id, track_ids, page_index=0, dpi=144, depth_range=None, depth_range_unit=None)` +- `preview_window_png(logfile_path, depth_range, depth_range_unit=None, page_index=0, dpi=144, section_ids=None)` + +All preview tools: + +- return in-memory PNG content only +- force the Matplotlib preview path even if the logfile config uses another + backend +- never write files + +Use `render_logfile_to_file(...)` only when you want an explicit on-disk +artifact such as a PDF. + +## Authoring Flow + +The experimental authoring flow is intentionally conservative: + +1. start from a packaged example bundle when useful +2. generate or edit full logfile YAML text +3. validate the text before writing +4. normalize it through the canonical serializer path +5. save only to an explicit output path + +Main tools: + +- `export_example_bundle(example_id, output_dir, overwrite=False)` +- `validate_logfile_text(yaml_text, base_dir=None)` +- `format_logfile_text(yaml_text, base_dir=None)` +- `save_logfile_text(yaml_text, output_path, overwrite=False, base_dir=None)` + +## `base_dir` Semantics + +`base_dir` is used only for unsaved text workflows. + +When provided, `validate_logfile_text(...)`, `format_logfile_text(...)`, and +`save_logfile_text(...)` resolve relative paths from that directory under the +server root. This matters for: + +- `template.path` +- top-level `data.source_path` +- section-level `data.source_path` + +When `save_logfile_text(...)` writes the normalized YAML, relative paths are +rebased so they still resolve correctly from the saved file location. + +## Normalization Boundary + +`format_logfile_text(...)` and `save_logfile_text(...)` operate on the +normalized report representation. + +That means: + +- comments are not preserved +- YAML anchors are not preserved +- original formatting is not preserved +- template indirection is materialized into canonical YAML + +Use the authoring tools when you want a clean, validated logfile artifact, not +when you need a comment-preserving editor. + +## Packaged Resources And Prompts + +The server also exposes: + +- schema and example resources for discovery and example adaptation +- prompts for logfile review, preview, and example-driven starts + +See [MCP API](../reference/mcp-api.md) for the exact tool, resource, and prompt +names. diff --git a/docs/site/workflows/release-publishing.md b/docs/site/workflows/release-publishing.md index 3533e9f..52a2d08 100644 --- a/docs/site/workflows/release-publishing.md +++ b/docs/site/workflows/release-publishing.md @@ -40,7 +40,7 @@ The GitHub Actions workflow accepts two important inputs: - `testpypi` - `pypi` 2. `expected_version` - - optional version guard such as `0.1.0` + - optional version guard such as `0.3.0` - checked against `src/wellplot/_version.py` ## Build Job @@ -54,11 +54,14 @@ It performs these steps: 2. install Python and `uv` 3. optionally verify the requested version 4. run `uv build` -5. create a clean virtual environment +5. create a clean virtual environment for the base wheel 6. install the newly built wheel into that clean environment 7. verify the `wellplot` console entry point 8. run `scripts/smoke_installed_wheel.py` -9. upload artifacts for the later publish job +9. create a second clean virtual environment for MCP verification +10. install the built wheel plus `mcp>=1,<2` +11. rerun `scripts/smoke_installed_wheel.py` with MCP support enabled +12. upload artifacts for the later publish job ## Publishing Targets @@ -106,12 +109,17 @@ Run the local checks first: ```bash uv run ruff check . -uv run python -m unittest discover -s tests -v +uv run pytest tests/test_mcp_service.py tests/test_mcp_server.py tests/test_pipeline.py tests/test_cli.py tests/test_public_api.py +uv run --with mcp pytest tests/test_mcp_server.py +uv run --group docs mkdocs build --strict uv build uv venv /tmp/wellplot-release-check uv pip install --python /tmp/wellplot-release-check/bin/python dist/*.whl /tmp/wellplot-release-check/bin/wellplot --help MPLBACKEND=Agg /tmp/wellplot-release-check/bin/python scripts/smoke_installed_wheel.py +uv venv /tmp/wellplot-release-check-mcp +uv pip install --python /tmp/wellplot-release-check-mcp/bin/python dist/*.whl "mcp>=1,<2" +MPLBACKEND=Agg /tmp/wellplot-release-check-mcp/bin/python scripts/smoke_installed_wheel.py ``` ## Maintenance Release Sequence @@ -120,7 +128,8 @@ MPLBACKEND=Agg /tmp/wellplot-release-check/bin/python scripts/smoke_installed_wh 2. update `CHANGELOG.md` for the release 3. run the local preflight checks 4. trigger the GitHub Actions workflow with `publish_target=verify-only` -5. use `publish_target=testpypi` if you want an index-level rehearsal +5. use `publish_target=testpypi` when the workflow, metadata, optional + dependencies, or MCP verification path changed in a risky way 6. trigger the workflow with `publish_target=pypi` for the production release 7. verify a fresh PyPI install @@ -132,6 +141,14 @@ python -m venv /tmp/wellplot-pypi-check /tmp/wellplot-pypi-check/bin/wellplot --help ``` +Experimental MCP install verification: + +```bash +python -m venv /tmp/wellplot-pypi-check-mcp +/tmp/wellplot-pypi-check-mcp/bin/pip install "wellplot[mcp]" +/tmp/wellplot-pypi-check-mcp/bin/python scripts/smoke_installed_wheel.py +``` + Optional TestPyPI install verification: ```bash diff --git a/examples/mcp_workflow_demo.py b/examples/mcp_workflow_demo.py new file mode 100644 index 0000000..eaa5bd2 --- /dev/null +++ b/examples/mcp_workflow_demo.py @@ -0,0 +1,363 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Demonstrate the experimental wellplot MCP workflow against a production example.""" + +from __future__ import annotations + +import base64 +import json +import shutil +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +DEFAULT_EXAMPLE_ID = "cbl_log_example" +DEFAULT_LOGFILE = Path("examples/production/cbl_log_example/full_reconstruction.log.yaml") +DEFAULT_BASE_DIR = DEFAULT_LOGFILE.parent +DEFAULT_OUTPUT_ROOT = Path("workspace/mcp_demo") +DEFAULT_RENDER_LOGFILE = Path("examples/cbl_main.log.yaml") + + +def repo_root() -> Path: + """Return the repository root that contains the example checkout.""" + return Path(__file__).resolve().parents[1] + + +def default_export_dir( + *, + output_root: str | Path = DEFAULT_OUTPUT_ROOT, + example_id: str = DEFAULT_EXAMPLE_ID, +) -> Path: + """Return the export target used by the writable MCP example calls.""" + return Path(output_root) / f"exported_{example_id}" + + +def default_render_output( + *, + output_root: str | Path = DEFAULT_OUTPUT_ROOT, + logfile_path: str | Path = DEFAULT_RENDER_LOGFILE, +) -> Path: + """Return the explicit render target used by the demo.""" + stem = Path(logfile_path).name.removesuffix(".log.yaml") + return Path(output_root) / f"{stem}_mcp.pdf" + + +def default_saved_logfile( + *, + output_root: str | Path = DEFAULT_OUTPUT_ROOT, + example_id: str = DEFAULT_EXAMPLE_ID, +) -> Path: + """Return the normalized logfile path written by the demo.""" + return Path(output_root) / f"{example_id}_saved.log.yaml" + + +def relative_repo_path(path: str | Path) -> str: + """Return one path relative to the repository root.""" + raw = Path(path) + if not raw.is_absolute(): + return raw.as_posix() + return raw.resolve().relative_to(repo_root()).as_posix() + + +def logfile_text(logfile_path: str | Path = DEFAULT_LOGFILE) -> str: + """Return the source logfile text used for text-authoring demo calls.""" + return (repo_root() / Path(relative_repo_path(logfile_path))).read_text(encoding="utf-8") + + +def decode_image_data(image_data: str | bytes) -> bytes: + """Return raw PNG bytes from one MCP image payload.""" + if isinstance(image_data, bytes): + return image_data + return base64.b64decode(image_data) + + +def _load_mcp_runtime() -> tuple[type[Any], type[Any], Any]: + try: + from mcp.client.session import ClientSession + from mcp.client.stdio import StdioServerParameters, stdio_client + except ModuleNotFoundError as exc: + raise RuntimeError( + "Install the published 'wellplot[mcp,notebook]' package in the active " + "environment before running this example." + ) from exc + return ClientSession, StdioServerParameters, stdio_client + + +def _server_command() -> tuple[str, list[str]]: + entry_point = shutil.which("wellplot-mcp") + if entry_point is not None: + return entry_point, [] + return sys.executable, ["-m", "wellplot.mcp.server"] + + +def _tool_image_bytes(result: object) -> bytes: + content = getattr(result, "content", None) + if not content: + raise RuntimeError("Expected image content from the MCP tool call.") + image_data = getattr(content[0], "data", None) + if image_data is None: + raise RuntimeError("Expected image data in the MCP tool response.") + return decode_image_data(image_data) + + +def _first_resource_text(result: object) -> str: + contents = getattr(result, "contents", None) + if not contents: + raise RuntimeError("Expected text content from the MCP resource response.") + text = getattr(contents[0], "text", None) + if not isinstance(text, str): + raise RuntimeError("Expected text content from the MCP resource response.") + return text + + +def _first_prompt_text(result: object) -> str: + messages = getattr(result, "messages", None) + if not messages: + raise RuntimeError("Expected prompt messages from the MCP prompt response.") + content = getattr(messages[0], "content", None) + text = getattr(content, "text", None) + if not isinstance(text, str): + raise RuntimeError("Expected text content from the MCP prompt response.") + return text + + +@asynccontextmanager +async def open_mcp_session(root: str | Path | None = None) -> AsyncIterator[Any]: + """Start one stdio MCP session rooted at the repository checkout.""" + ClientSession, StdioServerParameters, stdio_client = _load_mcp_runtime() + server_root = repo_root() if root is None else Path(root).resolve() + command, args = _server_command() + server = StdioServerParameters( + command=command, + args=args, + cwd=str(server_root), + ) + async with stdio_client(server) as streams, ClientSession(*streams) as session: + await session.initialize() + yield session + + +async def collect_server_contract( + logfile_path: str | Path = DEFAULT_LOGFILE, + *, + root: str | Path | None = None, +) -> dict[str, Any]: + """Return the current public MCP surface in a notebook-friendly structure.""" + relative_logfile = relative_repo_path(logfile_path) + async with open_mcp_session(root) as session: + tools = await session.list_tools() + resources = await session.list_resources() + templates = await session.list_resource_templates() + prompts = await session.list_prompts() + manifest = await session.read_resource("wellplot://examples/production/index.json") + review_prompt = await session.get_prompt( + "review_logfile", + {"logfile_path": relative_logfile}, + ) + + return { + "tools": [tool.name for tool in tools.tools], + "resources": [str(resource.uri) for resource in resources.resources], + "resource_templates": [template.uriTemplate for template in templates.resourceTemplates], + "prompts": [prompt.name for prompt in prompts.prompts], + "example_manifest": json.loads(_first_resource_text(manifest)), + "review_prompt": _first_prompt_text(review_prompt), + } + + +async def run_review_flow( + logfile_path: str | Path = DEFAULT_LOGFILE, + *, + dpi: int = 96, + root: str | Path | None = None, +) -> dict[str, Any]: + """Validate, inspect, and preview one production logfile through MCP.""" + relative_logfile = relative_repo_path(logfile_path) + async with open_mcp_session(root) as session: + validation = await session.call_tool( + "validate_logfile", + {"logfile_path": relative_logfile}, + ) + inspection = await session.call_tool( + "inspect_logfile", + {"logfile_path": relative_logfile}, + ) + inspection_data = dict(inspection.structuredContent) + first_section = inspection_data["sections"][0] + section_id = first_section["id"] + candidate_track_ids = [ + track_id for track_id in first_section["track_ids"] if track_id != "depth" + ] + selected_track_ids = ( + candidate_track_ids[:2] if candidate_track_ids else first_section["track_ids"][:1] + ) + section_depth_range = first_section.get("depth_range") or [100.0, 200.0] + window_top = float(section_depth_range[0]) + window_bottom = min(window_top + 100.0, float(section_depth_range[1])) + if window_bottom <= window_top: + window_bottom = window_top + 1.0 + + section_preview = await session.call_tool( + "preview_section_png", + { + "logfile_path": relative_logfile, + "section_id": section_id, + "dpi": dpi, + }, + ) + track_preview = await session.call_tool( + "preview_track_png", + { + "logfile_path": relative_logfile, + "section_id": section_id, + "track_ids": selected_track_ids, + "dpi": dpi, + }, + ) + window_preview = await session.call_tool( + "preview_window_png", + { + "logfile_path": relative_logfile, + "depth_range": [window_top, window_bottom], + "section_ids": [section_id], + "dpi": dpi, + }, + ) + + return { + "validation": dict(validation.structuredContent), + "inspection": inspection_data, + "selected_section_id": section_id, + "selected_track_ids": selected_track_ids, + "window_depth_range": [window_top, window_bottom], + "section_preview_png": _tool_image_bytes(section_preview), + "track_preview_png": _tool_image_bytes(track_preview), + "window_preview_png": _tool_image_bytes(window_preview), + } + + +async def run_authoring_flow( + logfile_path: str | Path = DEFAULT_LOGFILE, + *, + example_id: str = DEFAULT_EXAMPLE_ID, + output_root: str | Path = DEFAULT_OUTPUT_ROOT, + render_logfile_path: str | Path = DEFAULT_RENDER_LOGFILE, + root: str | Path | None = None, +) -> dict[str, Any]: + """Exercise the writable MCP tools against the same production example.""" + relative_base_dir = relative_repo_path(Path(logfile_path).parent) + relative_output_root = relative_repo_path(output_root) + relative_export_dir = relative_repo_path( + default_export_dir(output_root=output_root, example_id=example_id) + ) + relative_render_output = relative_repo_path( + default_render_output(output_root=output_root, logfile_path=render_logfile_path) + ) + relative_saved_logfile = relative_repo_path( + default_saved_logfile(output_root=output_root, example_id=example_id) + ) + relative_render_logfile = relative_repo_path(render_logfile_path) + yaml_text = logfile_text(logfile_path) + + async with open_mcp_session(root) as session: + exported = await session.call_tool( + "export_example_bundle", + { + "example_id": example_id, + "output_dir": relative_export_dir, + "overwrite": True, + }, + ) + text_validation = await session.call_tool( + "validate_logfile_text", + { + "yaml_text": yaml_text, + "base_dir": relative_base_dir, + }, + ) + formatting = await session.call_tool( + "format_logfile_text", + { + "yaml_text": yaml_text, + "base_dir": relative_base_dir, + }, + ) + saved = await session.call_tool( + "save_logfile_text", + { + "yaml_text": yaml_text, + "output_path": relative_saved_logfile, + "overwrite": True, + "base_dir": relative_base_dir, + }, + ) + rendered = await session.call_tool( + "render_logfile_to_file", + { + "logfile_path": relative_render_logfile, + "output_path": relative_render_output, + "overwrite": True, + }, + ) + + return { + "output_root": relative_output_root, + "rendered_logfile": relative_render_logfile, + "export": dict(exported.structuredContent), + "text_validation": dict(text_validation.structuredContent), + "format": dict(formatting.structuredContent), + "save": dict(saved.structuredContent), + "render": dict(rendered.structuredContent), + "formatted_yaml": formatting.structuredContent["yaml_text"], + } + + +async def _run_demo() -> None: + contract = await collect_server_contract() + review = await run_review_flow() + authoring = await run_authoring_flow() + summary = { + "tools": contract["tools"], + "resources": contract["resources"], + "prompts": contract["prompts"], + "validated": review["validation"]["name"], + "selected_section_id": review["selected_section_id"], + "selected_track_ids": review["selected_track_ids"], + "rendered_logfile": authoring["rendered_logfile"], + "rendered_output": authoring["render"]["output_path"], + "saved_logfile": authoring["save"]["output_path"], + "exported_file_count": len(authoring["export"]["written_files"]), + } + print(json.dumps(summary, indent=2)) + + +def main() -> None: + """Run the MCP workflow demo as a normal Python script.""" + import anyio + + anyio.run(_run_demo) + + +if __name__ == "__main__": + main() diff --git a/examples/notebooks/developer/README.md b/examples/notebooks/developer/README.md index d217227..b23da3b 100644 --- a/examples/notebooks/developer/README.md +++ b/examples/notebooks/developer/README.md @@ -39,6 +39,10 @@ Runtime note: - `synthetic_demo.ipynb` - `cbl_vdl_array_mvp_demo.ipynb` +## MCP walkthroughs + +- `mcp_workflow_demo.ipynb` + ## YAML and legacy walkthroughs - `annotation_track_objects_showcase.ipynb` diff --git a/examples/notebooks/developer/mcp_workflow_demo.ipynb b/examples/notebooks/developer/mcp_workflow_demo.ipynb new file mode 100644 index 0000000..120b645 --- /dev/null +++ b/examples/notebooks/developer/mcp_workflow_demo.ipynb @@ -0,0 +1,215 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b1e9d23f", + "metadata": {}, + "source": [ + "# Experimental MCP Workflow Walkthrough\n", + "\n", + "This generated notebook is the recipe companion for\n", + "`examples/mcp_workflow_demo.py`.\n", + "\n", + "Launch the local stdio MCP server, inspect its public contract, preview a production logfile, and exercise the writable authoring tools without dropping down to the direct Python or YAML pipeline.\n", + "\n", + "What you will practice in this walkthrough:\n", + "\n", + "- Start the experimental `wellplot-mcp` surface from a notebook-friendly Python workflow.\n", + "- Inspect the registered tools, resources, resource templates, and prompts before using them.\n", + "- Use MCP tool calls to validate, inspect, and preview a production logfile at full-report, section, track, and window scopes.\n", + "- Round-trip a full logfile through MCP text validation, formatting, export, save, and explicit render-to-file calls.\n", + "\n", + "Prerequisites:\n", + "\n", + "- `pip install \"wellplot[mcp,notebook]\"`\n", + "- run the notebook from a checkout of this repository so the `examples/` files and sample data are available\n", + "\n", + "Runtime model:\n", + "\n", + "- import `wellplot` from the active installed environment\n", + "- use the repository checkout for the example files, helper modules, and sample data\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fbac5ccf", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "from pathlib import Path\n", + "\n", + "try:\n", + " import wellplot\n", + "except ImportError as exc:\n", + " raise RuntimeError(\n", + " \"Install the published 'wellplot' package in the active \"\n", + " \"environment before running this notebook.\"\n", + " ) from exc\n", + "\n", + "# Walk upward from the current working directory until we find the\n", + "# repository checkout that holds the example sources and sample data.\n", + "cwd = Path.cwd().resolve()\n", + "REPO_ROOT = next((path for path in (cwd, *cwd.parents) if (path / \"examples\").exists()), None)\n", + "if REPO_ROOT is None:\n", + " raise RuntimeError(\n", + " \"Run this notebook from a checkout of the wellplot repository \"\n", + " \"so the example files and sample data are available.\"\n", + " )\n", + "\n", + "EXAMPLES_DIR = REPO_ROOT / \"examples\"\n", + "WORKSPACE_DIR = REPO_ROOT / \"workspace\"\n", + "WORKSPACE_RENDERS = WORKSPACE_DIR / \"renders\"\n", + "WORKSPACE_RENDERS.mkdir(parents=True, exist_ok=True)\n", + "\n", + "examples_path = str(EXAMPLES_DIR)\n", + "if examples_path not in sys.path:\n", + " sys.path.insert(0, examples_path)\n", + "\n", + "print(\"wellplot version:\", wellplot.__version__)\n", + "print(\"Examples root:\", EXAMPLES_DIR)\n", + "print(\"Render output:\", WORKSPACE_RENDERS)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5496e00d", + "metadata": {}, + "outputs": [], + "source": [ + "# Display the source directly in the notebook so the recipe is easy to\n", + "# read and copy from without opening another file.\n", + "from IPython.display import Code, display\n", + "\n", + "source_path = EXAMPLES_DIR / \"mcp_workflow_demo.py\"\n", + "display(Code(source_path.read_text(), language=\"python\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df89346c", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the MCP helper module and inspect the default example paths it\n", + "# uses for the walkthrough.\n", + "import mcp_workflow_demo as demo\n", + "\n", + "print(\"Demo logfile:\", demo.DEFAULT_LOGFILE)\n", + "print(\"Demo base dir:\", demo.DEFAULT_BASE_DIR)\n", + "print(\"Demo output root:\", demo.DEFAULT_OUTPUT_ROOT)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eddbae0f", + "metadata": {}, + "outputs": [], + "source": [ + "# Ask the MCP server what it exposes before calling the workflow tools.\n", + "contract = await demo.collect_server_contract()\n", + "\n", + "print(\"Tools:\")\n", + "for name in contract[\"tools\"]:\n", + " print(\" -\", name)\n", + "\n", + "print(\"\\nResources:\")\n", + "for uri in contract[\"resources\"]:\n", + " print(\" -\", uri)\n", + "\n", + "print(\"\\nResource templates:\")\n", + "for uri_template in contract[\"resource_templates\"]:\n", + " print(\" -\", uri_template)\n", + "\n", + "print(\"\\nPrompts:\")\n", + "for name in contract[\"prompts\"]:\n", + " print(\" -\", name)\n", + "\n", + "print(\"\\nPackaged production examples:\")\n", + "for example in contract[\"example_manifest\"][\"examples\"]:\n", + " print(f\" - {example['id']}: {example['title']}\")\n", + "\n", + "print(\"\\nreview_logfile prompt preview:\\n\")\n", + "print(contract[\"review_prompt\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9eb3caac", + "metadata": {}, + "outputs": [], + "source": [ + "# Validate, inspect, and preview the production logfile through MCP only.\n", + "from IPython.display import Image, display\n", + "\n", + "review = await demo.run_review_flow()\n", + "\n", + "print(\"Validation:\")\n", + "print(review[\"validation\"])\n", + "print(\"\\nSelected section:\", review[\"selected_section_id\"])\n", + "print(\"Selected tracks:\", review[\"selected_track_ids\"])\n", + "print(\"Window depth range:\", review[\"window_depth_range\"])\n", + "print(\"\\nResolved section ids:\", review[\"inspection\"][\"section_ids\"])\n", + "\n", + "display(Image(data=review[\"section_preview_png\"]))\n", + "display(Image(data=review[\"track_preview_png\"]))\n", + "display(Image(data=review[\"window_preview_png\"]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78a698b2", + "metadata": {}, + "outputs": [], + "source": [ + "# Exercise the writable MCP tools against the same production example.\n", + "from IPython.display import Code, display\n", + "\n", + "authoring = await demo.run_authoring_flow()\n", + "\n", + "print(\"Text validation:\")\n", + "print(authoring[\"text_validation\"])\n", + "print(\"\\nExported files:\")\n", + "for path in authoring[\"export\"][\"written_files\"]:\n", + " print(\" -\", path)\n", + "print(\"\\nSaved logfile:\", authoring[\"save\"][\"output_path\"])\n", + "print(\"Rendered logfile:\", authoring[\"rendered_logfile\"])\n", + "print(\"Rendered PDF:\", authoring[\"render\"][\"output_path\"])\n", + "\n", + "preview_yaml = \"\\n\".join(authoring[\"formatted_yaml\"].splitlines()[:80])\n", + "display(Code(preview_yaml, language=\"yaml\"))" + ] + }, + { + "cell_type": "markdown", + "id": "04d8e4ea", + "metadata": {}, + "source": [ + "## Adapt Experimental MCP Workflow Walkthrough Safely\n", + "\n", + "- Keep the server root pointed at a narrow working directory when you move from a repo demo to a real job directory.\n", + "- Use `inspect_logfile(...)` first and then drive the narrow preview tools with the returned section and track ids.\n", + "- Treat `format_logfile_text(...)` and `save_logfile_text(...)` as normalization steps, not as comment-preserving editors.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/user/cbl_log_example.ipynb b/examples/notebooks/user/cbl_log_example.ipynb index 74ded06..5610ff6 100644 --- a/examples/notebooks/user/cbl_log_example.ipynb +++ b/examples/notebooks/user/cbl_log_example.ipynb @@ -49,7 +49,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/cbl_log_example/full_reconstruction.log.yaml\n", "Tutorial workspace: workspace/tutorials/cbl_log_example\n", "Render output folder: workspace/tutorials/cbl_log_example/renders\n" diff --git a/examples/notebooks/user/computed_numpy/cbl_log_example_numpy_computed.ipynb b/examples/notebooks/user/computed_numpy/cbl_log_example_numpy_computed.ipynb index 91cd525..f25b8c5 100644 --- a/examples/notebooks/user/computed_numpy/cbl_log_example_numpy_computed.ipynb +++ b/examples/notebooks/user/computed_numpy/cbl_log_example_numpy_computed.ipynb @@ -30,7 +30,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/cbl_log_example/full_reconstruction.log.yaml\n", "Source section: main_pass\n", "Tutorial workspace: workspace/tutorials/computed_numpy/cbl_log_example\n" diff --git a/examples/notebooks/user/computed_numpy/forge16b_porosity_example_numpy_computed.ipynb b/examples/notebooks/user/computed_numpy/forge16b_porosity_example_numpy_computed.ipynb index 492306b..d7ec659 100644 --- a/examples/notebooks/user/computed_numpy/forge16b_porosity_example_numpy_computed.ipynb +++ b/examples/notebooks/user/computed_numpy/forge16b_porosity_example_numpy_computed.ipynb @@ -30,7 +30,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/forge16b_porosity_example/full_reconstruction.log.yaml\n", "Source section: upper_review\n", "Tutorial workspace: workspace/tutorials/computed_numpy/forge16b_porosity_example\n" diff --git a/examples/notebooks/user/computed_pandas/cbl_log_example_pandas_computed.ipynb b/examples/notebooks/user/computed_pandas/cbl_log_example_pandas_computed.ipynb index 65dfc02..0013659 100644 --- a/examples/notebooks/user/computed_pandas/cbl_log_example_pandas_computed.ipynb +++ b/examples/notebooks/user/computed_pandas/cbl_log_example_pandas_computed.ipynb @@ -30,7 +30,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/cbl_log_example/full_reconstruction.log.yaml\n", "Source section: main_pass\n", "Tutorial workspace: workspace/tutorials/computed_pandas/cbl_log_example\n" diff --git a/examples/notebooks/user/computed_pandas/forge16b_porosity_example_pandas_computed.ipynb b/examples/notebooks/user/computed_pandas/forge16b_porosity_example_pandas_computed.ipynb index 083827d..c096561 100644 --- a/examples/notebooks/user/computed_pandas/forge16b_porosity_example_pandas_computed.ipynb +++ b/examples/notebooks/user/computed_pandas/forge16b_porosity_example_pandas_computed.ipynb @@ -30,7 +30,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/forge16b_porosity_example/full_reconstruction.log.yaml\n", "Source section: upper_review\n", "Tutorial workspace: workspace/tutorials/computed_pandas/forge16b_porosity_example\n" diff --git a/examples/notebooks/user/forge16b_porosity_example.ipynb b/examples/notebooks/user/forge16b_porosity_example.ipynb index 2c9ddf8..5e81f8b 100644 --- a/examples/notebooks/user/forge16b_porosity_example.ipynb +++ b/examples/notebooks/user/forge16b_porosity_example.ipynb @@ -49,7 +49,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "wellplot version: 0.1.0\n", + "wellplot version: 0.3.0\n", "Production example: examples/production/forge16b_porosity_example/full_reconstruction.log.yaml\n", "Tutorial workspace: workspace/tutorials/forge16b_porosity_example\n", "Render output folder: workspace/tutorials/forge16b_porosity_example/renders\n" diff --git a/mkdocs.yml b/mkdocs.yml index f30944a..d21f86f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,7 @@ nav: - Workflows: - YAML Workflow: workflows/yaml-workflow.md - Python API Workflow: workflows/python-api.md + - MCP Workflow: workflows/mcp-workflow.md - Rendering: workflows/rendering.md - Report Pages: workflows/report-pages.md - Release And Publishing: workflows/release-publishing.md @@ -63,5 +64,6 @@ nav: - Reference: - Overview: reference/index.md - Python API: reference/python-api.md + - MCP API: reference/mcp-api.md - YAML Logfile: reference/logfile-yaml.md - Report Pages: reference/report-pages.md diff --git a/pyproject.toml b/pyproject.toml index 61fb1ae..3649e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ pdf = [ interactive = [ "plotly>=5.18", ] +mcp = [ + "mcp>=1,<2", +] notebook = [ "ipykernel>=6.29", ] @@ -70,6 +73,7 @@ all = [ "dlisio>=1.0", "matplotlib>=3.8", "plotly>=5.18", + "mcp>=1,<2", "ipykernel>=6.29", "pandas>=2.2", "pint>=0.24", @@ -77,6 +81,7 @@ all = [ [project.scripts] wellplot = "wellplot.cli:main" +wellplot-mcp = "wellplot.mcp.server:main" [dependency-groups] dev = [ @@ -101,6 +106,10 @@ version = {attr = "wellplot._version.__version__"} [tool.setuptools.package-data] "wellplot.renderers" = ["*.yaml"] +"wellplot.mcp.assets" = [ + "production/*/*.md", + "production/*/*.yaml", +] [tool.uv] default-groups = ["dev"] diff --git a/scripts/generate_example_notebooks.py b/scripts/generate_example_notebooks.py index d2fa60c..b88632c 100644 --- a/scripts/generate_example_notebooks.py +++ b/scripts/generate_example_notebooks.py @@ -578,6 +578,108 @@ class UserNotebookTutorial: "Inspect a short text slice in the notebook before diffing the files in your editor.", ), ), + "mcp_workflow_demo.py": PythonRecipe( + source="mcp_workflow_demo.py", + title="Experimental MCP Workflow Walkthrough", + summary=( + "Launch the local stdio MCP server, inspect its public contract, preview a " + "production logfile, and exercise the writable authoring tools without " + "dropping down to the direct Python or YAML pipeline." + ), + learning_goals=( + "Start the experimental `wellplot-mcp` surface from a notebook-friendly Python workflow.", + "Inspect the registered tools, resources, resource templates, and prompts before using them.", + "Use MCP tool calls to validate, inspect, and preview a production logfile at full-report, section, track, and window scopes.", + "Round-trip a full logfile through MCP text validation, formatting, export, save, and explicit render-to-file calls.", + ), + prerequisites=("mcp",), + code_cells=( + dedent( + """ + # Import the MCP helper module and inspect the default example paths it + # uses for the walkthrough. + import mcp_workflow_demo as demo + + print("Demo logfile:", demo.DEFAULT_LOGFILE) + print("Demo base dir:", demo.DEFAULT_BASE_DIR) + print("Demo output root:", demo.DEFAULT_OUTPUT_ROOT) + """ + ).strip(), + dedent( + """ + # Ask the MCP server what it exposes before calling the workflow tools. + contract = await demo.collect_server_contract() + + print("Tools:") + for name in contract["tools"]: + print(" -", name) + + print("\\nResources:") + for uri in contract["resources"]: + print(" -", uri) + + print("\\nResource templates:") + for uri_template in contract["resource_templates"]: + print(" -", uri_template) + + print("\\nPrompts:") + for name in contract["prompts"]: + print(" -", name) + + print("\\nPackaged production examples:") + for example in contract["example_manifest"]["examples"]: + print(f" - {example['id']}: {example['title']}") + + print("\\nreview_logfile prompt preview:\\n") + print(contract["review_prompt"]) + """ + ).strip(), + dedent( + """ + # Validate, inspect, and preview the production logfile through MCP only. + from IPython.display import Image, display + + review = await demo.run_review_flow() + + print("Validation:") + print(review["validation"]) + print("\\nSelected section:", review["selected_section_id"]) + print("Selected tracks:", review["selected_track_ids"]) + print("Window depth range:", review["window_depth_range"]) + print("\\nResolved section ids:", review["inspection"]["section_ids"]) + + display(Image(data=review["section_preview_png"])) + display(Image(data=review["track_preview_png"])) + display(Image(data=review["window_preview_png"])) + """ + ).strip(), + dedent( + """ + # Exercise the writable MCP tools against the same production example. + from IPython.display import Code, display + + authoring = await demo.run_authoring_flow() + + print("Text validation:") + print(authoring["text_validation"]) + print("\\nExported files:") + for path in authoring["export"]["written_files"]: + print(" -", path) + print("\\nSaved logfile:", authoring["save"]["output_path"]) + print("Rendered logfile:", authoring["rendered_logfile"]) + print("Rendered PDF:", authoring["render"]["output_path"]) + + preview_yaml = "\\n".join(authoring["formatted_yaml"].splitlines()[:80]) + display(Code(preview_yaml, language="yaml")) + """ + ).strip(), + ), + adaptation_tips=( + "Keep the server root pointed at a narrow working directory when you move from a repo demo to a real job directory.", + "Use `inspect_logfile(...)` first and then drive the narrow preview tools with the returned section and track ids.", + "Treat `format_logfile_text(...)` and `save_logfile_text(...)` as normalization steps, not as comment-preserving editors.", + ), + ), "real_data_demo.py": PythonRecipe( source="real_data_demo.py", title="Real-Data CLI Wrapper Walkthrough", @@ -4763,6 +4865,7 @@ def _developer_grouped_notebook_list() -> dict[str, list[str]]: grouped = { "Production package walkthroughs": [], "Programmatic API walkthroughs": [], + "MCP walkthroughs": [], "YAML and legacy walkthroughs": [], } for package_dir in _production_packages(): @@ -4770,7 +4873,12 @@ def _developer_grouped_notebook_list() -> dict[str, list[str]]: _production_notebook_path(package_dir.name).name ) for recipe in PYTHON_RECIPES.values(): - grouped["Programmatic API walkthroughs"].append(_relative_notebook_path(recipe.source).name) + section = ( + "MCP walkthroughs" + if recipe.source.startswith("mcp_") + else "Programmatic API walkthroughs" + ) + grouped[section].append(_relative_notebook_path(recipe.source).name) for path in _yaml_example_paths(): if ( path.name == "triple_combo.yaml" diff --git a/scripts/smoke_installed_wheel.py b/scripts/smoke_installed_wheel.py index fce5e3d..fd9caf7 100644 --- a/scripts/smoke_installed_wheel.py +++ b/scripts/smoke_installed_wheel.py @@ -21,7 +21,9 @@ from __future__ import annotations +import importlib.util import os +import sys from io import StringIO from pathlib import Path from tempfile import TemporaryDirectory @@ -132,6 +134,70 @@ def main() -> int: if not png_bytes: raise RuntimeError("Installed wheel smoke render did not produce PNG bytes.") + if importlib.util.find_spec("mcp") is not None: + import anyio + from mcp.client.session import ClientSession + from mcp.client.stdio import StdioServerParameters, stdio_client + + from wellplot.mcp import create_mcp_server + from wellplot.mcp.service import production_example_manifest, schema_resource + + server = create_mcp_server() + if not hasattr(server, "run"): + raise RuntimeError("wellplot MCP server did not expose a runnable server instance.") + + schema_payload = schema_resource().text + if "\"$schema\"" not in schema_payload: + raise RuntimeError("wellplot MCP schema resource did not contain JSON schema text.") + + example_ids = [item["id"] for item in production_example_manifest()["examples"]] + if example_ids != ["cbl_log_example", "forge16b_porosity_example"]: + raise RuntimeError( + "wellplot MCP example manifest did not expose the expected examples." + ) + + server_command = Path(sys.executable).resolve().with_name("wellplot-mcp") + if not server_command.exists(): + candidate = server_command.with_suffix(".exe") + if candidate.exists(): + server_command = candidate + else: + raise RuntimeError("Installed wheel smoke test could not locate wellplot-mcp.") + + async def _exercise_stdio_server() -> None: + server_params = StdioServerParameters( + command=str(server_command), + cwd=str(Path.cwd()), + ) + async with stdio_client(server_params) as streams, ClientSession(*streams) as session: + await session.initialize() + tools = await session.list_tools() + tool_names = [tool.name for tool in tools.tools] + required_tools = { + "validate_logfile", + "preview_section_png", + "preview_track_png", + "preview_window_png", + "export_example_bundle", + "validate_logfile_text", + "format_logfile_text", + "save_logfile_text", + } + if not required_tools.issubset(tool_names): + raise RuntimeError( + "Installed wheel MCP server did not expose the expected tool set." + ) + validation = await session.call_tool( + "validate_logfile", + {"logfile_path": "examples/cbl_main.log.yaml"}, + ) + if validation.structuredContent.get("valid") is not True: + raise RuntimeError( + "Installed wheel MCP validate_logfile call did not succeed." + ) + + anyio.run(_exercise_stdio_server) + print(f"Installed wheel smoke test passed for wellplot {__version__}.") return 0 diff --git a/src/wellplot/_version.py b/src/wellplot/_version.py index 81da25d..06c0693 100644 --- a/src/wellplot/_version.py +++ b/src/wellplot/_version.py @@ -19,4 +19,4 @@ """Package version metadata.""" -__version__ = "0.1.0" +__version__ = "0.3.0" diff --git a/src/wellplot/api/render.py b/src/wellplot/api/render.py index 3bc1e11..cdbff39 100644 --- a/src/wellplot/api/render.py +++ b/src/wellplot/api/render.py @@ -123,6 +123,8 @@ def _suppress_report_pages(filtered_mapping: dict[str, Any]) -> None: def _filter_track_layout( filtered_mapping: dict[str, Any], track_ids_by_section: dict[str, tuple[str, ...]], + *, + implicit_section_id: str | None = None, ) -> None: layout = filtered_mapping["document"]["layout"] sections = list(layout.get("log_sections", [])) @@ -147,9 +149,14 @@ def _filter_track_layout( binding for binding in bindings.get("channels", []) if ( - selected_by_section.get(str(binding.get("section", ""))) is None + selected_by_section.get( + _binding_section_id(binding, implicit_section_id=implicit_section_id) + ) + is None or str(binding.get("track_id", "")) - in selected_by_section[str(binding.get("section", ""))] + in selected_by_section[ + _binding_section_id(binding, implicit_section_id=implicit_section_id) + ] ) ] @@ -161,6 +168,17 @@ def _apply_depth_window( filtered_mapping["document"]["depth_range"] = [float(depth_range[0]), float(depth_range[1])] +def _binding_section_id( + binding: dict[str, Any], + *, + implicit_section_id: str | None = None, +) -> str: + section_id = str(binding.get("section", "")).strip() + if section_id: + return section_id + return "" if implicit_section_id is None else implicit_section_id + + def _filtered_report( report: ProgrammaticLogSpec, section_ids: tuple[str, ...] | None, @@ -185,6 +203,7 @@ def _filtered_report( requested = set(track_ids_by_section) else: requested = set(available_section_ids) + implicit_section_id = available_section_ids[0] if len(available_section_ids) == 1 else None filtered_mapping = report.to_mapping() layout = filtered_mapping["document"]["layout"] @@ -197,10 +216,14 @@ def _filtered_report( bindings["channels"] = [ binding for binding in bindings.get("channels", []) - if str(binding.get("section", "")) in requested + if _binding_section_id(binding, implicit_section_id=implicit_section_id) in requested ] if track_ids_by_section: - _filter_track_layout(filtered_mapping, track_ids_by_section) + _filter_track_layout( + filtered_mapping, + track_ids_by_section, + implicit_section_id=implicit_section_id, + ) if depth_range is not None: _apply_depth_window(filtered_mapping, depth_range) if not include_report_pages: diff --git a/src/wellplot/errors.py b/src/wellplot/errors.py index 4a2fbee..69f2ee8 100644 --- a/src/wellplot/errors.py +++ b/src/wellplot/errors.py @@ -42,3 +42,7 @@ class LayoutError(WellLogOSError): class DatasetValidationError(WellLogOSError): """Raised when a dataset or channel violates the normalized data contract.""" + + +class PathAccessError(WellLogOSError): + """Raised when a requested path is inaccessible or outside an allowed root.""" diff --git a/src/wellplot/logfile.py b/src/wellplot/logfile.py index d186e5b..00b56d4 100644 --- a/src/wellplot/logfile.py +++ b/src/wellplot/logfile.py @@ -29,7 +29,7 @@ import numpy as np import yaml -from .errors import TemplateValidationError +from .errors import PathAccessError, TemplateValidationError from .io import load_dlis, load_las from .logfile_schema import validate_logfile_mapping from .model import LogDocument, RasterChannel, ScalarChannel, WellDataset @@ -98,10 +98,20 @@ def _load_yaml_mapping(path: Path, *, context: str) -> dict[str, Any]: return payload +def _ensure_path_within_root(path: Path, *, allowed_root: Path, context: str) -> None: + try: + path.relative_to(allowed_root) + except ValueError as exc: + raise PathAccessError( + f"{context} must resolve inside the allowed root {allowed_root}." + ) from exc + + def _resolve_template_inheritance( payload: dict[str, Any], *, base_dir: Path, + allowed_root: Path | None = None, visited_templates: set[Path] | None = None, ) -> dict[str, Any]: template_ref = payload.get("template") @@ -118,6 +128,12 @@ def _resolve_template_inheritance( template_path = (base_dir / template_path).resolve() else: template_path = template_path.resolve() + if allowed_root is not None: + _ensure_path_within_root( + template_path, + allowed_root=allowed_root, + context="template.path", + ) visited = set() if visited_templates is None else set(visited_templates) if template_path in visited: @@ -128,6 +144,7 @@ def _resolve_template_inheritance( resolved_template = _resolve_template_inheritance( template_payload, base_dir=template_path.parent, + allowed_root=allowed_root, visited_templates=visited, ) @@ -1110,11 +1127,40 @@ def logfile_from_mapping(data: dict[str, Any]) -> LogFileSpec: ) -def load_logfile(path: str | Path) -> LogFileSpec: +def load_logfile(path: str | Path, *, allowed_root: Path | None = None) -> LogFileSpec: """Load a logfile YAML file, resolve templates, and return a typed spec.""" file_path = Path(path).resolve() + if allowed_root is not None: + _ensure_path_within_root(file_path, allowed_root=allowed_root, context="logfile_path") payload = _load_yaml_mapping(file_path, context="Log file") - resolved_payload = _resolve_template_inheritance(payload, base_dir=file_path.parent) + resolved_payload = _resolve_template_inheritance( + payload, + base_dir=file_path.parent, + allowed_root=allowed_root, + ) + return logfile_from_mapping(resolved_payload) + + +def load_logfile_text( + yaml_text: str, + *, + base_dir: str | Path | None = None, + allowed_root: Path | None = None, +) -> LogFileSpec: + """Load logfile YAML text, resolve templates, and return a typed spec.""" + resolved_base_dir = Path.cwd().resolve() + if base_dir is not None: + resolved_base_dir = Path(base_dir).expanduser().resolve() + if allowed_root is not None: + _ensure_path_within_root(resolved_base_dir, allowed_root=allowed_root, context="base_dir") + payload = yaml.safe_load(yaml_text) or {} + if not isinstance(payload, dict): + raise TemplateValidationError("Logfile text root must be a mapping.") + resolved_payload = _resolve_template_inheritance( + dict(payload), + base_dir=resolved_base_dir, + allowed_root=allowed_root, + ) return logfile_from_mapping(resolved_payload) @@ -1249,11 +1295,23 @@ def _format_report_cell(item: object) -> object: ] -def _resolve_data_source_path(source_path: str, *, base_dir: Path | None = None) -> Path: +def _resolve_data_source_path( + source_path: str, + *, + base_dir: Path | None = None, + allowed_root: Path | None = None, +) -> Path: resolved_path = Path(source_path) if not resolved_path.is_absolute(): resolved_path = (base_dir or Path.cwd()) / resolved_path - return resolved_path.resolve() + resolved_path = resolved_path.resolve() + if allowed_root is not None: + _ensure_path_within_root( + resolved_path, + allowed_root=allowed_root, + context="data.source_path", + ) + return resolved_path def _load_dataset_from_source( @@ -1261,8 +1319,13 @@ def _load_dataset_from_source( source_format: str, *, base_dir: Path | None = None, + allowed_root: Path | None = None, ) -> tuple[WellDataset, Path]: - resolved_path = _resolve_data_source_path(source_path, base_dir=base_dir) + resolved_path = _resolve_data_source_path( + source_path, + base_dir=base_dir, + allowed_root=allowed_root, + ) resolved_format = _normalized_source_format(source_format, context="source_format") if resolved_format == "auto": resolved_format = resolved_path.suffix.lower().lstrip(".") @@ -1313,31 +1376,55 @@ def _section_data_sources_for_logfile( return section_sources -def load_datasets_for_logfile( +def resolve_section_data_sources_for_logfile( spec: LogFileSpec, *, base_dir: Path | None = None, -) -> tuple[dict[str, WellDataset], dict[str, Path]]: - """Load and cache datasets for every layout section in a logfile spec.""" + allowed_root: Path | None = None, +) -> dict[str, tuple[Path, str]]: + """Resolve each logfile section data source to an absolute path and format.""" section_sources = _section_data_sources_for_logfile(spec) - cache: dict[tuple[Path, str], tuple[WellDataset, Path]] = {} - datasets_by_section: dict[str, WellDataset] = {} - source_paths_by_section: dict[str, Path] = {} - + resolved_sources: dict[str, tuple[Path, str]] = {} for section_id, (source_path, source_format) in section_sources.items(): - resolved_path = _resolve_data_source_path(source_path, base_dir=base_dir) + resolved_path = _resolve_data_source_path( + source_path, + base_dir=base_dir, + allowed_root=allowed_root, + ) resolved_format = _normalized_source_format( source_format, context=f"document.layout.log_sections[{section_id}].data.source_format", ) if resolved_format == "auto": resolved_format = resolved_path.suffix.lower().lstrip(".") + resolved_sources[section_id] = (resolved_path, resolved_format) + return resolved_sources + + +def load_datasets_for_logfile( + spec: LogFileSpec, + *, + base_dir: Path | None = None, + allowed_root: Path | None = None, +) -> tuple[dict[str, WellDataset], dict[str, Path]]: + """Load and cache datasets for every layout section in a logfile spec.""" + section_sources = resolve_section_data_sources_for_logfile( + spec, + base_dir=base_dir, + allowed_root=allowed_root, + ) + cache: dict[tuple[Path, str], tuple[WellDataset, Path]] = {} + datasets_by_section: dict[str, WellDataset] = {} + source_paths_by_section: dict[str, Path] = {} + + for section_id, (resolved_path, resolved_format) in section_sources.items(): cache_key = (resolved_path, resolved_format) if cache_key not in cache: dataset, loaded_path = _load_dataset_from_source( str(resolved_path), resolved_format, base_dir=base_dir, + allowed_root=allowed_root, ) cache[cache_key] = (dataset, loaded_path) dataset, loaded_path = cache[cache_key] @@ -1348,7 +1435,10 @@ def load_datasets_for_logfile( def load_dataset_for_logfile( - spec: LogFileSpec, *, base_dir: Path | None = None + spec: LogFileSpec, + *, + base_dir: Path | None = None, + allowed_root: Path | None = None, ) -> tuple[WellDataset, Path]: """Load a single dataset when the logfile resolves to one unique source.""" if spec.data_source_path is not None: @@ -1356,9 +1446,14 @@ def load_dataset_for_logfile( spec.data_source_path, spec.data_source_format, base_dir=base_dir, + allowed_root=allowed_root, ) - section_sources = _section_data_sources_for_logfile(spec) + section_sources = resolve_section_data_sources_for_logfile( + spec, + base_dir=base_dir, + allowed_root=allowed_root, + ) unique_sources = set(section_sources.values()) if len(unique_sources) != 1: raise TemplateValidationError( @@ -1367,9 +1462,10 @@ def load_dataset_for_logfile( ) source_path, source_format = next(iter(unique_sources)) return _load_dataset_from_source( - source_path, + str(source_path), source_format, base_dir=base_dir, + allowed_root=allowed_root, ) diff --git a/src/wellplot/mcp/__init__.py b/src/wellplot/mcp/__init__.py new file mode 100644 index 0000000..5903199 --- /dev/null +++ b/src/wellplot/mcp/__init__.py @@ -0,0 +1,39 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Optional MCP server support for wellplot.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from mcp.server.fastmcp import FastMCP + + +def create_mcp_server(root: str | Path | None = None) -> FastMCP: + """Create and configure the wellplot MCP server.""" + from .server import create_mcp_server as _create_mcp_server + + return _create_mcp_server(root) + + +__all__ = ["create_mcp_server"] diff --git a/src/wellplot/mcp/assets/__init__.py b/src/wellplot/mcp/assets/__init__.py new file mode 100644 index 0000000..5ca1d80 --- /dev/null +++ b/src/wellplot/mcp/assets/__init__.py @@ -0,0 +1,20 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Packaged MCP assets for schema and example resources.""" diff --git a/src/wellplot/mcp/assets/production/cbl_log_example/README.md b/src/wellplot/mcp/assets/production/cbl_log_example/README.md new file mode 100644 index 0000000..cc040be --- /dev/null +++ b/src/wellplot/mcp/assets/production/cbl_log_example/README.md @@ -0,0 +1,80 @@ +# CBL/VDL Reconstruction Example + +This package is production example #1 for `wellplot`. + +It reconstructs the supported portion of a real CBL/VDL packet using the DLIS +files already stored in the repository and a reference PDF for visual +comparison. + +## Source files + +Primary data inputs: + +- `workspace/data/CBL_Main.dlis` +- `workspace/data/CBL_Repeat.dlis` + +Visual reference: + +- `workspace/renders/CBL_log_example.Pdf` + +## Intended scope + +This package is the packet-level baseline for the production examples. The +supported subset is: + +- heading page +- first-page remarks +- main-pass log section +- repeat-pass log section +- tail page +- realistic scalar headers, reference overlays, dual-scale CBL, and VDL raster + rendering + +The package intentionally omits content that does not map cleanly to the +current `wellplot` feature set, including calibration reports, vendor parameter +tables, borehole sketches, proprietary disclaimer pages, and unsupported table +or graphics layouts. + +## Files + +- `base.template.yaml` + - shared page geometry, report styling, heading content, and tail defaults +- `full_reconstruction.log.yaml` + - the full supported packet with remarks, sections, data sources, and + bindings +- `data-notes.md` + - source-file notes, dependencies, and rendered-channel coverage + +## Validate and render + +From the repository root, with the published package installed in the active +environment: + +```bash +pip install "wellplot[dlis]" + +wellplot validate \ + examples/production/cbl_log_example/full_reconstruction.log.yaml + +wellplot render \ + examples/production/cbl_log_example/full_reconstruction.log.yaml +``` + +Expected render target: + +- `workspace/renders/CBL_log_example_full_reconstruction.pdf` + +## Public Data and IP Note + +This example uses publicly available or repository-provided demonstration data. +The generated packet is a new `wellplot` rendering that preserves factual well +and service context without claiming to be the original vendor-authored +deliverable. + +Keep that boundary intact when you adapt this package: + +- retain factual attribution where it is part of the source metadata +- avoid copying proprietary disclaimer pages, tables, or artwork that are not + reproduced by the supported feature set +- confirm provenance and redistribution rights before sharing rendered outputs + outside this repository diff --git a/src/wellplot/mcp/assets/production/cbl_log_example/base.template.yaml b/src/wellplot/mcp/assets/production/cbl_log_example/base.template.yaml new file mode 100644 index 0000000..bf200c3 --- /dev/null +++ b/src/wellplot/mcp/assets/production/cbl_log_example/base.template.yaml @@ -0,0 +1,226 @@ +render: + backend: matplotlib + output_path: ../../../workspace/renders/cbl_log_example_placeholder.pdf + dpi: 180 + continuous_strip_page_height_mm: 297 + matplotlib: + style: + report: + summary_label_fontsize: 8.0 + summary_value_fontsize: 10.5 + provider_fontsize: 16.0 + service_fontsize: 7.5 + tail_service_fontsize: 7.5 + field_label_fontsize: 7.0 + field_value_fontsize: 7.8 + detail_header_fontsize: 9.0 + detail_label_fontsize: 6.4 + detail_value_fontsize: 6.4 + tail_frame_y: 0.74 + tail_frame_height: 0.22 + section_title: + background_color: "#f7f7f7" + border_mode: bottom_rule + border_color: "#2b2b2b" + border_linewidth: 0.9 + height_mm: 11.0 + padding_left: 0.03 + padding_right: 0.03 + title_align: center + subtitle_align: center + title_fontsize: 11.5 + subtitle_fontsize: 6.8 + title_y: 0.68 + subtitle_y: 0.22 + track_header: + background_color: "#e8e8e8" + separator_color: "#5a5a5a" + separator_linewidth: 0.4 + track: + x_tick_labelsize: 6.0 + grid: + depth_major_linewidth: 0.65 +document: + depth_range: + - 100 + - 8489 + page: + size: A4 + orientation: portrait + continuous: false + bottom_track_header_enabled: true + margin_left_mm: 0 + margin_right_mm: 8 + margin_top_mm: 0 + margin_bottom_mm: 0 + track_gap_mm: 0 + header_height_mm: 0 + track_header_height_mm: 30 + footer_height_mm: 0 + depth: + unit: ft + scale: 240 + major_step: 10 + minor_step: 2 + layout: + heading: + enabled: true + provider_name: Company + general_fields: + - key: company + label: Company + value: University of Utah + - key: well + label: Well + value: Forge 78B-32 + - key: field + label: Field + value: None + - key: county + label: County + value: Beaver + - key: country + label: Country + value: USA + - key: location + label: Location + value: "Lat: 38.500171 Long: -112.88221" + - key: logging_date + label: Logging Date + value: 06-Oct-2021 + - key: run_number + label: Run Number + value: ONE + - key: scale + label: Scale + value: ft 1:240 + - key: top_log_interval + label: Top Log Interval + value: 100.00 ft + - key: bottom_log_interval + label: Bottom Log Interval + value: 8489.00 ft + - key: fluid_type + label: Casing Fluid Type + value: Water + service_titles: + - value: Cement Bond Log + font_size: 16 + auto_adjust: true + bold: true + alignment: left + - value: Variable Density Log + font_size: 15 + auto_adjust: true + italic: true + alignment: center + - value: Gamma Ray - CCL + font_size: 14 + auto_adjust: true + alignment: right + detail: + kind: cased_hole + title: Cased Hole + rows: + - label: Date + values: + - 06-Oct-2021 + - "" + - label_cells: + - Run + - Direction + columns: + - cells: + - ONE + - cells: + - Up / Down + - label: Service Order + values: + - NRC 42-00090-03 + - "" + - label: Driller Depth + columns: + - cells: + - 8540.00 + - ft + - cells: [""] + - label: Logged Depth + columns: + - cells: + - 8540.00 + - ft + - cells: [""] + - label: First Reading (Bottom) + columns: + - cells: + - 8489.00 + - ft + - cells: [""] + - label: Last Reading + columns: + - cells: + - 100.00 + - ft + - cells: [""] + - label: Start Time + columns: + - cells: + - "12:32" + - HS + - cells: [""] + - label: End Time + columns: + - cells: + - "16:49" + - HS + - cells: [""] + - label: Drill Time + columns: + - cells: + - "4:16" + - HS + - cells: [""] + - label: Fluid Density + values: + - 8.4 lbm/gal + - "" + - label: Fluid Level + columns: + - cells: + - 8.00 + - ft + - cells: [""] + - label: Maximum Deviation + values: + - 0 deg + - "" + - label_cells: + - Equipment No. + - Base + columns: + - cells: + - OSLC-HA 2801 + - Fort Morgan + - cells: [""] + - label: Logged By + values: + - Erik Leslie + - "" + - label: Witnessed By + values: + - Garth Larsen + - "" + remarks: + - title: Public Data and IP Notice + lines: + - This example uses publicly available or repository-provided demonstration data intended for educational use. + - Rendered layouts are independent reproductions generated by wellplot, not vendor-authored originals or official service-company deliverables. + - Original trademarks and service names remain the property of their respective owners. + - Confirm data provenance and redistribution rights before reusing outputs outside this repository. + alignment: left + log_sections: [] + tail: + enabled: true + bindings: + on_missing: skip + channels: [] diff --git a/src/wellplot/mcp/assets/production/cbl_log_example/data-notes.md b/src/wellplot/mcp/assets/production/cbl_log_example/data-notes.md new file mode 100644 index 0000000..5fcc90f --- /dev/null +++ b/src/wellplot/mcp/assets/production/cbl_log_example/data-notes.md @@ -0,0 +1,56 @@ +# Data Notes + +## Source files + +This package depends on the real DLIS files already present in the repository: + +- `workspace/data/CBL_Main.dlis` +- `workspace/data/CBL_Repeat.dlis` + +Reference packet for structural comparison: + +- `workspace/renders/CBL_log_example.Pdf` + +## Required dependency + +To render these files from an installed environment: + +```bash +pip install "wellplot[dlis]" +wellplot render \ + examples/production/cbl_log_example/full_reconstruction.log.yaml +``` + +## Rendered channels + +The production packet is built around the DLIS-backed channels used in the CBL +sections: + +- `ECGR_STGC` +- `TT` +- `TENS` +- `MTEM` +- `CBL` +- `VDL` +- `STIT` +- `TDSP` +- `VSEC` + +## Rendering notes + +- `full_reconstruction.log.yaml` validates successfully through the CLI. +- Full DLIS-backed rendering is heavier than the LAS-based examples because the + packet loads two source files and renders VDL raster output. +- When reviewing the result, compare packet structure, headings, remarks, + section layout, and track content against the reference packet rather than the + omitted vendor-only pages. + +## Public Data and IP Note + +- This example uses publicly available or repository-provided demonstration + data. +- Rendered pages are independent `wellplot` outputs, not original vendor PDFs + or official service-company deliverables. +- Trademarks and service names remain the property of their respective owners. +- Confirm provenance and redistribution rights before reusing outputs outside + this repository. diff --git a/src/wellplot/mcp/assets/production/cbl_log_example/full_reconstruction.log.yaml b/src/wellplot/mcp/assets/production/cbl_log_example/full_reconstruction.log.yaml new file mode 100644 index 0000000..7f482d2 --- /dev/null +++ b/src/wellplot/mcp/assets/production/cbl_log_example/full_reconstruction.log.yaml @@ -0,0 +1,630 @@ +template: + path: ./base.template.yaml +version: 1 +name: CBL Log Example Full Reconstruction +render: + output_path: ../../../workspace/renders/CBL_log_example_full_reconstruction.pdf +document: + layout: + remarks: + - title: Supported Reconstruction Scope + lines: + - This production example reconstructs the supported pages of the original CBL/VDL packet. + - Calibration reports, parameter listings, borehole sketches, and vendor disclaimers are intentionally omitted. + - The main and repeat sections use the real DLIS files in the repository and preserve the source packet scale convention. + alignment: left + - title: Data Sources + lines: + - "Main pass source: workspace/data/CBL_Main.dlis" + - "Repeat pass source: workspace/data/CBL_Repeat.dlis" + - "Shared index convention: measured depth in feet at 1:240 scale." + alignment: left + - title: Public Data and IP Notice + lines: + - This example uses publicly available or repository-provided demonstration data intended for educational use. + - Rendered layouts are independent reproductions generated by wellplot, not vendor-authored originals or official service-company deliverables. + - Original trademarks and service names remain the property of their respective owners. + - Confirm data provenance and redistribution rights before reusing outputs outside this repository. + alignment: left + log_sections: + - id: main_pass + title: Main Pass + subtitle: CBL_Main.dlis + data: + source_path: ../../../workspace/data/CBL_Main.dlis + source_format: dlis + tracks: + - id: combo + title: "" + kind: normal + width_mm: 50 + position: 1 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: depth + title: "" + kind: reference + width_mm: 36 + position: 2 + reference: + axis: depth + define_layout: true + unit: ft + scale_ratio: 240 + major_step: 10 + secondary_grid: + display: true + line_count: 5 + header: + display_unit: true + display_scale: true + display_annotations: false + number_format: + format: automatic + precision: 0 + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: cbl + title: "" + kind: normal + width_mm: 44 + position: 3 + grid: + vertical: + main: + scale: linear + line_count: 2 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: vdl + title: "" + kind: array + width_mm: 48 + position: 4 + x_scale: + kind: linear + min: 200 + max: 1200 + grid: + vertical: + main: + visible: false + secondary: + visible: false + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: repeat_pass + title: Repeat Pass + subtitle: CBL_Repeat.dlis + depth_range: + - 8230 + - 8490 + data: + source_path: ../../../workspace/data/CBL_Repeat.dlis + source_format: dlis + tracks: + - id: combo + title: "" + kind: normal + width_mm: 50 + position: 1 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: depth + title: "" + kind: reference + width_mm: 36 + position: 2 + reference: + axis: depth + define_layout: true + unit: ft + scale_ratio: 240 + major_step: 10 + secondary_grid: + display: true + line_count: 5 + header: + display_unit: true + display_scale: true + display_annotations: false + number_format: + format: automatic + precision: 0 + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: cbl + title: "" + kind: normal + width_mm: 44 + position: 3 + grid: + vertical: + main: + scale: linear + line_count: 2 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: vdl + title: "" + kind: array + width_mm: 48 + position: 4 + x_scale: + kind: linear + min: 200 + max: 1200 + grid: + vertical: + main: + visible: false + secondary: + visible: false + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + bindings: + channels: + - section: main_pass + channel: ECGR_STGC + track_id: combo + kind: curve + label: Gamma Ray (ECGR_STGC) QTGC-B + header_display: + wrap_name: true + style: + color: "#16a34a" + line_width: 0.8 + scale: + kind: linear + min: 0 + max: 150 + - section: main_pass + channel: TT + track_id: combo + kind: curve + label: Transit Time for CBL (TT) QSLT-B + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.75 + scale: + kind: linear + min: 200 + max: 400 + reverse: true + - section: main_pass + channel: TENS + track_id: combo + kind: curve + label: Cable Tension (TENS) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 5000 + max: 0 + - section: main_pass + channel: MTEM + track_id: combo + kind: curve + label: Mud Temperature (MTEM) LEH-MT + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.9 + scale: + kind: linear + min: 100 + max: 500 + - section: main_pass + channel: CBL + track_id: cbl + kind: curve + id: cbl_0_100_main + label: CBL Amplitude (CBL) QSLT-B + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: linear + min: 0 + max: 100 + - section: main_pass + channel: CBL + track_id: cbl + kind: curve + id: cbl_0_10_main + label: CBL Amplitude (CBL) QSLT-B + header_display: + wrap_name: true + style: + color: "#2563eb" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 0 + max: 10 + - section: main_pass + channel: VDL + track_id: vdl + kind: raster + label: VDL VariableDensity (VDL) QSLT-B + profile: vdl + normalization: auto + waveform_normalization: trace_maxabs + style: + colormap: gray_r + show_raster: true + raster_alpha: 1.0 + colorbar: + enabled: true + label: Amplitude + position: header + sample_axis: + enabled: true + unit: us + min: 200 + max: 1200 + source_origin: 40 + source_step: 10 + ticks: 7 + - section: main_pass + channel: STIT + track_id: depth + kind: curve + label: Stuck Tool Indicator, Total (STIT) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.65 + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.06 + lane_end: 0.20 + - section: main_pass + channel: TDSP + track_id: depth + kind: curve + label: Cable Drag + header_display: + wrap_name: true + style: + color: "#92400e" + line_width: 0.65 + line_style: ":" + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.24 + lane_end: 0.38 + - section: main_pass + channel: VSEC + track_id: depth + kind: curve + label: Tool_Tot. Drag + header_display: + wrap_name: true + style: + color: "#1d4ed8" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.80 + lane_end: 0.94 + - section: repeat_pass + channel: ECGR_STGC + track_id: combo + kind: curve + label: Gamma Ray (ECGR_STGC) QTGC-B + header_display: + wrap_name: true + style: + color: "#16a34a" + line_width: 0.8 + scale: + kind: linear + min: 0 + max: 150 + - section: repeat_pass + channel: TT + track_id: combo + kind: curve + label: Transit Time for CBL (TT) QSLT-B + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.75 + scale: + kind: linear + min: 200 + max: 400 + reverse: true + - section: repeat_pass + channel: TENS + track_id: combo + kind: curve + label: Cable Tension (TENS) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 5000 + max: 0 + - section: repeat_pass + channel: MTEM + track_id: combo + kind: curve + label: Mud Temperature (MTEM) LEH-MT + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.9 + scale: + kind: linear + min: 100 + max: 500 + - section: repeat_pass + channel: CBL + track_id: cbl + kind: curve + id: cbl_0_100_repeat + label: CBL Amplitude (CBL) QSLT-B + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: linear + min: 0 + max: 100 + - section: repeat_pass + channel: CBL + track_id: cbl + kind: curve + id: cbl_0_10_repeat + label: CBL Amplitude (CBL) QSLT-B + header_display: + wrap_name: true + style: + color: "#2563eb" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 0 + max: 10 + - section: repeat_pass + channel: VDL + track_id: vdl + kind: raster + label: VDL VariableDensity (VDL) QSLT-B + profile: vdl + normalization: auto + waveform_normalization: trace_maxabs + style: + colormap: gray_r + show_raster: true + raster_alpha: 1.0 + colorbar: + enabled: true + label: Amplitude + position: header + sample_axis: + enabled: true + unit: us + min: 200 + max: 1200 + source_origin: 40 + source_step: 10 + ticks: 7 + - section: repeat_pass + channel: STIT + track_id: depth + kind: curve + label: Stuck Tool Indicator, Total (STIT) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.65 + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.06 + lane_end: 0.20 + - section: repeat_pass + channel: TDSP + track_id: depth + kind: curve + label: Cable Drag + header_display: + wrap_name: true + style: + color: "#92400e" + line_width: 0.65 + line_style: ":" + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.24 + lane_end: 0.38 + - section: repeat_pass + channel: VSEC + track_id: depth + kind: curve + label: Tool_Tot. Drag + header_display: + wrap_name: true + style: + color: "#1d4ed8" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: 0 + max: 50 + reference_overlay: + mode: indicator + lane_start: 0.80 + lane_end: 0.94 diff --git a/src/wellplot/mcp/assets/production/forge16b_porosity_example/README.md b/src/wellplot/mcp/assets/production/forge16b_porosity_example/README.md new file mode 100644 index 0000000..e666aae --- /dev/null +++ b/src/wellplot/mcp/assets/production/forge16b_porosity_example/README.md @@ -0,0 +1,90 @@ +# 30/23a-3 Porosity Example + +This package is production example #2 for `wellplot`. + +It keeps the existing production report template and now uses the replacement +public LAS source: + +- `workspace/data/30-23a-3 8117_d.las` + +Unlike the CBL reconstruction package, this example is not trying to mirror a +separate reference PDF packet page by page. Its role is to show how to retain a +production-ready open-hole report template while swapping in a public LAS file +and LAS-derived heading metadata. + +## Intended scope + +This example retains the packet structure used by the earlier production +template and rebuilds it around the replacement LAS source. The curated subset +is: + +- heading +- remarks +- upper interval open-hole review +- lower interval open-hole review +- tail + +## Data-backed header note + +The heading now pulls well metadata directly from the LAS well header, including +company, well, field, location, country, province, UWI, logging date, datum, +and elevation values. + +The plotted channels focus on the curves that are present in the replacement +LAS and fit the retained porosity-oriented layout: + +- `GR` +- `SP` +- `ILD` +- `ILM` +- `MSFL` +- `NPHI` +- `RHOB` +- `PEF` +- `DRHO` + +The two strip sections reuse the same LAS over different depth windows so the +example stays compact while still showing the deeper resistivity interval. + +## Files + +- `base.template.yaml` + - shared page, report, and style defaults with LAS-backed heading metadata +- `full_reconstruction.log.yaml` + - example #2 packet built from the replacement LAS source +- `data-notes.md` + - source file notes, metadata summary, and curve inventory + +## Validate and render + +From the repository root, with the published package installed in the active +environment: + +```bash +pip install wellplot + +wellplot validate \ + examples/production/forge16b_porosity_example/full_reconstruction.log.yaml + +wellplot render \ + examples/production/forge16b_porosity_example/full_reconstruction.log.yaml +``` + +Expected render target: + +- `workspace/renders/30-23a-3_8117_porosity_reconstruction.pdf` + +## Public Data and IP Note + +This example uses publicly available or repository-provided demonstration data. +The generated packet is a new `wellplot` rendering that preserves source-backed +well metadata and curve relationships without claiming to be an original vendor +deliverable. + +Keep that boundary intact when you adapt this package: + +- retain factual attribution where it is part of the LAS metadata +- avoid copying proprietary artwork, tables, or vendor-only disclaimer pages + that are outside the supported feature set +- confirm provenance and redistribution rights before sharing rendered outputs + outside this repository diff --git a/src/wellplot/mcp/assets/production/forge16b_porosity_example/base.template.yaml b/src/wellplot/mcp/assets/production/forge16b_porosity_example/base.template.yaml new file mode 100644 index 0000000..3894798 --- /dev/null +++ b/src/wellplot/mcp/assets/production/forge16b_porosity_example/base.template.yaml @@ -0,0 +1,238 @@ +render: + backend: matplotlib + output_path: ../../../workspace/renders/30-23a-3_8117_template_placeholder.pdf + dpi: 180 + continuous_strip_page_height_mm: 297 + matplotlib: + style: + report: + summary_label_fontsize: 8.0 + summary_value_fontsize: 10.5 + provider_fontsize: 16.0 + service_fontsize: 8.0 + tail_service_fontsize: 8.0 + field_label_fontsize: 7.0 + field_value_fontsize: 7.8 + detail_header_fontsize: 9.0 + detail_label_fontsize: 6.4 + detail_value_fontsize: 6.4 + tail_frame_y: 0.74 + tail_frame_height: 0.22 + section_title: + background_color: "#0d3fb3" + border_mode: box + border_color: "#0d3fb3" + border_linewidth: 0.8 + padding_left: 0.03 + padding_right: 0.03 + title_align: center + subtitle_align: center + title_fontsize: 11.0 + subtitle_fontsize: 6.6 + title_color: "#ffffff" + subtitle_color: "#eaf0ff" + title_y: 0.68 + subtitle_y: 0.22 + track_header: + background_color: "#eef2f8" + track: + x_tick_labelsize: 6.0 + grid: + depth_major_linewidth: 0.65 +document: + depth_range: + - 8400 + - 10200 + page: + size: A4 + orientation: portrait + continuous: false + bottom_track_header_enabled: true + margin_left_mm: 0 + margin_right_mm: 8 + margin_top_mm: 0 + margin_bottom_mm: 0 + track_gap_mm: 0 + header_height_mm: 0 + track_header_height_mm: 30 + footer_height_mm: 0 + depth: + unit: ft + scale: 240 + major_step: 10 + minor_step: 2 + layout: + heading: + enabled: true + provider_name: Company + general_fields: + - key: company + label: Company + source_key: COMP + - key: well + label: Well + source_key: WELL + - key: field + label: Field + source_key: FLD + - key: location + label: Location + source_key: LOC + - key: country + label: Country + source_key: CTRY + - key: province + label: Province + source_key: PROV + - key: uwi + label: UWI + source_key: UWI + - key: logging_date + label: Logging Date + source_key: DATE + - key: scale + label: Scale + value: ft 1:240 + - key: logging_measured_from + label: Logging Measured From + source_key: LMF + - key: permanent_datum + label: Permanent Datum + source_key: PDAT + - key: service_company + label: Service Company + source_key: SRVC + service_titles: + - value: Open Hole Density + font_size: 15 + auto_adjust: true + bold: true + alignment: left + - value: Neutron Porosity + font_size: 14 + auto_adjust: true + alignment: center + - value: Resistivity Review + font_size: 13 + auto_adjust: true + alignment: right + detail: + kind: open_hole + title: Open Hole Metadata + rows: + - label: Date + values: + - source_key: DATE + - "" + - label: Project + values: + - source_key: PROJ + - "" + - label: Service Company + values: + - source_key: SRVC + - "" + - label: Well UWI + values: + - source_key: UWI + - "" + - label: Total Depth (Driller) + columns: + - cells: + - source_key: TDD + - ft + - cells: [""] + - label: Total Depth (Logger) + columns: + - cells: + - source_key: TDL + - ft + - cells: [""] + - label: First Reading (Bottom) + columns: + - cells: + - source_key: STOP + - ft + - cells: [""] + - label: Last Reading + columns: + - cells: + - source_key: STRT + - ft + - cells: [""] + - label: Logging Measured From + values: + - source_key: LMF + - "" + - label: Permanent Datum + values: + - source_key: PDAT + - "" + - label: Elev. Kelly Bushing + columns: + - cells: + - source_key: EKB + - ft + - cells: [""] + - label: Elev. Drill Floor + columns: + - cells: + - source_key: EDF + - ft + - cells: [""] + - label: Elev. Ground Level + columns: + - cells: + - source_key: EGL + - ft + - cells: [""] + - label: Elev. Perm Datum + columns: + - cells: + - source_key: EPD + - ft + - cells: [""] + - label: Above Perm Datum + columns: + - cells: + - source_key: APD + - ft + - cells: [""] + - label: Log Set + values: + - source_key: SET + - "" + - label: Location + values: + - source_key: LOC + - "" + - label_cells: + - Country + - Province + columns: + - cells: + - source_key: CTRY + - cells: + - source_key: PROV + - label_cells: + - Latitude + - Longitude + columns: + - cells: + - source_key: LATI + - cells: + - source_key: LONG + remarks: + - title: Public Data and IP Notice + lines: + - This example uses publicly available or repository-provided demonstration data intended for educational use. + - Rendered layouts are independent reproductions generated by wellplot, not vendor-authored originals or official service-company deliverables. + - Original trademarks and service names remain the property of their respective owners. + - Confirm data provenance and redistribution rights before reusing outputs outside this repository. + alignment: left + log_sections: [] + tail: + enabled: true + bindings: + on_missing: skip + channels: [] diff --git a/src/wellplot/mcp/assets/production/forge16b_porosity_example/data-notes.md b/src/wellplot/mcp/assets/production/forge16b_porosity_example/data-notes.md new file mode 100644 index 0000000..e523866 --- /dev/null +++ b/src/wellplot/mcp/assets/production/forge16b_porosity_example/data-notes.md @@ -0,0 +1,72 @@ +# Data Notes + +This example depends on: + +- `workspace/data/30-23a-3 8117_d.las` + +No optional dependency extra is required beyond the published `wellplot` +installation because this package is LAS-backed: + +```bash +pip install wellplot +``` + +## Packet facts from the LAS header + +- well: `30/23a-3` +- company: `SHELL` +- field: `WILDCAT` +- location: `NORTH SEA` +- country: `UK` +- province: `UKCS` +- UWI: `8117` +- permanent datum: `MSL` +- logging measured from: `RKB` +- logging date: `SEP-OCT-1985` +- service company: `SCHLUMBERGER` + +## Curves available in the replacement LAS + +Relevant scalar channels include: + +- `CAL` +- `CALI` +- `DRHO` +- `DT` +- `GR` +- `ILD` +- `ILM` +- `MSFL` +- `NPHI` +- `PEF` +- `RHOB` +- `SP` + +## Supported reconstruction choice + +The retained production template now focuses on a data-backed open-hole subset: + +- gamma ray and spontaneous potential overview +- deep, medium, and flushed-zone resistivity review +- density-neutron porosity review with `RHOB`, `NPHI`, `PEF`, and `DRHO` +- upper and lower depth windows from the same LAS source + +## Rendering notes + +- `full_reconstruction.log.yaml` validates successfully through the CLI. +- The rendered packet reuses one LAS source across two depth windows instead of + stitching together multiple files. +- The porosity track highlights density-neutron crossover with a dedicated + crossover fill rather than a density-baseline fill. +- The packet is meant to demonstrate a reproducible open-hole layout, not to + stand in for a certified vendor-issued log packet. + +## Public Data and IP Note + +- This example uses publicly available or repository-provided demonstration + data. +- Rendered pages are independent `wellplot` outputs, not original vendor PDFs + or official service-company deliverables. +- Trademarks and service names remain the property of their respective owners. +- Confirm provenance and redistribution rights before reusing outputs outside + this repository. diff --git a/src/wellplot/mcp/assets/production/forge16b_porosity_example/full_reconstruction.log.yaml b/src/wellplot/mcp/assets/production/forge16b_porosity_example/full_reconstruction.log.yaml new file mode 100644 index 0000000..4c72384 --- /dev/null +++ b/src/wellplot/mcp/assets/production/forge16b_porosity_example/full_reconstruction.log.yaml @@ -0,0 +1,601 @@ +template: + path: ./base.template.yaml +version: 1 +name: 30-23a-3 Density-Neutron Reconstruction +render: + output_path: ../../../workspace/renders/30-23a-3_8117_porosity_reconstruction.pdf +document: + layout: + remarks: + - title: Supported Reconstruction Scope + lines: + - This production example builds a two-window open-hole density-neutron review from the public LAS source. + - Heading and report metadata are resolved directly from the LAS well header for repeatable packet generation. + - Only curves present in the source file are plotted; vendor-only packet pages are intentionally omitted. + alignment: left + - title: Data Source and Coverage + lines: + - "Source file: workspace/data/30-23a-3 8117_d.las" + - "Displayed intervals: 8400 to 9300 ft and 9300 to 10200 ft at 1:240 scale." + - "Curves shown: GR, SP, ILD, ILM, MSFL, NPHI, RHOB, PEF, and DRHO." + alignment: left + - title: Interpretation Notes + lines: + - Resistivity is shown as deep, medium, and flushed-zone overlays for quick invasion review. + - The porosity track pairs RHOB and NPHI with gas-crossover fill support plus PEF and DRHO QC curves. + - Use this packet as a reproducible demonstration layout, not as a certified vendor log replacement. + alignment: left + - title: Public Data and IP Notice + lines: + - This example uses publicly available or repository-provided demonstration data intended for educational use. + - Rendered layouts are independent reproductions generated by wellplot, not vendor-authored originals or official service-company deliverables. + - Original trademarks and service names remain the property of their respective owners. + - Confirm data provenance and redistribution rights before reusing outputs outside this repository. + alignment: left + log_sections: + - id: upper_review + title: Upper Open-Hole Review + subtitle: 30-23a-3 8117_d.las + depth_range: + - 8400 + - 9300 + data: + source_path: "../../../workspace/data/30-23a-3 8117_d.las" + source_format: las + tracks: + - id: gr_sp + title: "" + kind: normal + width_mm: 38 + position: 1 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: depth + title: "" + kind: reference + width_mm: 20 + position: 2 + reference: + axis: depth + define_layout: true + unit: ft + scale_ratio: 240 + major_step: 10 + secondary_grid: + display: true + line_count: 5 + header: + display_unit: true + display_scale: true + display_annotations: false + number_format: + format: automatic + precision: 0 + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: resistivity + title: "" + kind: normal + width_mm: 40 + position: 3 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: porosity + title: "" + kind: normal + width_mm: 48 + position: 4 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 5 + - kind: divisions + enabled: false + reserve_space: false + - id: lower_review + title: Lower Open-Hole Review + subtitle: 30-23a-3 8117_d.las + depth_range: + - 9300 + - 10200 + data: + source_path: "../../../workspace/data/30-23a-3 8117_d.las" + source_format: las + tracks: + - id: gr_sp + title: "" + kind: normal + width_mm: 38 + position: 1 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: depth + title: "" + kind: reference + width_mm: 20 + position: 2 + reference: + axis: depth + define_layout: true + unit: ft + scale_ratio: 240 + major_step: 10 + secondary_grid: + display: true + line_count: 5 + header: + display_unit: true + display_scale: true + display_annotations: false + number_format: + format: automatic + precision: 0 + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 2 + - kind: divisions + enabled: false + reserve_space: false + - id: resistivity + title: "" + kind: normal + width_mm: 40 + position: 3 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 3 + - kind: divisions + enabled: false + reserve_space: false + - id: porosity + title: "" + kind: normal + width_mm: 48 + position: 4 + grid: + vertical: + main: + scale: linear + line_count: 4 + spacing_mode: count + secondary: + scale: linear + line_count: 5 + spacing_mode: count + track_header: + objects: + - kind: title + enabled: false + reserve_space: false + - kind: scale + enabled: true + line_units: 1 + - kind: legend + enabled: true + line_units: 5 + - kind: divisions + enabled: false + reserve_space: false + bindings: + channels: + - section: upper_review + channel: GR + track_id: gr_sp + kind: curve + label: Gamma Ray (GR) + header_display: + wrap_name: true + style: + color: "#16a34a" + line_width: 0.8 + scale: + kind: linear + min: 0 + max: 150 + fill: + kind: to_lower_limit + label: Gamma Fill + color: "#8fd19e" + alpha: 0.22 + - section: upper_review + channel: SP + track_id: gr_sp + kind: curve + label: Spontaneous Potential (SP) + header_display: + wrap_name: true + style: + color: "#8b5e3c" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: -60 + max: 80 + - section: upper_review + channel: ILD + track_id: resistivity + kind: curve + label: Deep Induction (ILD) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: log + min: 0.2 + max: 2000 + - section: upper_review + channel: ILM + track_id: resistivity + kind: curve + label: Medium Induction (ILM) + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.70 + line_style: "--" + scale: + kind: log + min: 0.2 + max: 2000 + - section: upper_review + channel: MSFL + track_id: resistivity + kind: curve + label: Microspherically Focused (MSFL) + header_display: + wrap_name: true + style: + color: "#c62828" + line_width: 0.70 + line_style: "-." + scale: + kind: log + min: 0.2 + max: 2000 + - section: upper_review + channel: NPHI + track_id: porosity + kind: curve + id: nphi_overlay + label: Neutron Porosity (NPHI) + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.75 + scale: + kind: linear + min: -5 + max: 45 + reverse: true + fill: + kind: between_instances + other_element_id: rhob_overlay + label: Gas Crossover + color: "#d1d5db" + alpha: 0.18 + crossover: + enabled: true + left_color: "#bfdbfe" + right_color: "#fbbf24" + alpha: 0.28 + - section: upper_review + channel: RHOB + track_id: porosity + kind: curve + id: rhob_overlay + label: Bulk Density (RHOB) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: linear + min: 1.95 + max: 2.95 + - section: upper_review + channel: PEF + track_id: porosity + kind: curve + label: Photoelectric Factor (PEF) + header_display: + wrap_name: true + style: + color: "#d97706" + line_width: 0.70 + line_style: "--" + scale: + kind: linear + min: 0 + max: 10 + - section: upper_review + channel: DRHO + track_id: porosity + kind: curve + label: Density Correction (DRHO) + header_display: + wrap_name: true + style: + color: "#6b7280" + line_width: 0.65 + line_style: ":" + scale: + kind: linear + min: -0.10 + max: 0.25 + - section: lower_review + channel: GR + track_id: gr_sp + kind: curve + label: Gamma Ray (GR) + header_display: + wrap_name: true + style: + color: "#16a34a" + line_width: 0.8 + scale: + kind: linear + min: 0 + max: 150 + fill: + kind: to_lower_limit + label: Gamma Fill + color: "#8fd19e" + alpha: 0.22 + - section: lower_review + channel: SP + track_id: gr_sp + kind: curve + label: Spontaneous Potential (SP) + header_display: + wrap_name: true + style: + color: "#8b5e3c" + line_width: 0.65 + line_style: "--" + scale: + kind: linear + min: -60 + max: 80 + - section: lower_review + channel: ILD + track_id: resistivity + kind: curve + label: Deep Induction (ILD) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: log + min: 0.2 + max: 2000 + - section: lower_review + channel: ILM + track_id: resistivity + kind: curve + label: Medium Induction (ILM) + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.70 + line_style: "--" + scale: + kind: log + min: 0.2 + max: 2000 + - section: lower_review + channel: MSFL + track_id: resistivity + kind: curve + label: Microspherically Focused (MSFL) + header_display: + wrap_name: true + style: + color: "#c62828" + line_width: 0.70 + line_style: "-." + scale: + kind: log + min: 0.2 + max: 2000 + - section: lower_review + channel: NPHI + track_id: porosity + kind: curve + id: nphi_overlay + label: Neutron Porosity (NPHI) + header_display: + wrap_name: true + style: + color: "#2142ff" + line_width: 0.75 + scale: + kind: linear + min: -5 + max: 45 + reverse: true + fill: + kind: between_instances + other_element_id: rhob_overlay + label: Gas Crossover + color: "#d1d5db" + alpha: 0.18 + crossover: + enabled: true + left_color: "#bfdbfe" + right_color: "#fbbf24" + alpha: 0.28 + - section: lower_review + channel: RHOB + track_id: porosity + kind: curve + id: rhob_overlay + label: Bulk Density (RHOB) + header_display: + wrap_name: true + style: + color: "#111111" + line_width: 0.75 + scale: + kind: linear + min: 1.95 + max: 2.95 + - section: lower_review + channel: PEF + track_id: porosity + kind: curve + label: Photoelectric Factor (PEF) + header_display: + wrap_name: true + style: + color: "#d97706" + line_width: 0.70 + line_style: "--" + scale: + kind: linear + min: 0 + max: 10 + - section: lower_review + channel: DRHO + track_id: porosity + kind: curve + label: Density Correction (DRHO) + header_display: + wrap_name: true + style: + color: "#6b7280" + line_width: 0.65 + line_style: ":" + scale: + kind: linear + min: -0.10 + max: 0.25 diff --git a/src/wellplot/mcp/server.py b/src/wellplot/mcp/server.py new file mode 100644 index 0000000..8a8b23b --- /dev/null +++ b/src/wellplot/mcp/server.py @@ -0,0 +1,304 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""FastMCP server registration for the optional wellplot MCP surface.""" + +from __future__ import annotations + +import sys +from dataclasses import asdict +from pathlib import Path +from typing import TYPE_CHECKING + +from ..errors import DependencyUnavailableError +from . import service + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP, Image + + +def _load_mcp_runtime() -> tuple[type[FastMCP], type[Image]]: + try: + from mcp.server.fastmcp import FastMCP, Image + except ModuleNotFoundError as exc: + raise DependencyUnavailableError( + "wellplot MCP support requires the optional `mcp` dependency. " + "Install `wellplot[mcp]` to use `wellplot-mcp`." + ) from exc + return FastMCP, Image + + +def create_mcp_server(root: str | Path | None = None) -> FastMCP: + """Create and configure the wellplot FastMCP server.""" + FastMCP, Image = _load_mcp_runtime() + server_root = service.resolve_server_root(root) + mcp = FastMCP( + "wellplot", + instructions=( + "wellplot MCP server for validating, inspecting, previewing, and rendering " + f"logfiles under the fixed server root {server_root}." + ), + ) + + @mcp.tool() + def validate_logfile(logfile_path: str) -> dict[str, object]: + """Validate a logfile path and return structured status.""" + return asdict(service.validate_logfile(logfile_path, root=server_root)) + + @mcp.tool() + def inspect_logfile(logfile_path: str) -> dict[str, object]: + """Inspect a logfile path and return structured report metadata.""" + return asdict(service.inspect_logfile(logfile_path, root=server_root)) + + @mcp.tool() + def preview_logfile_png( + logfile_path: str, + page_index: int = 0, + dpi: int = 144, + section_id: str | None = None, + track_ids: list[str] | None = None, + depth_range: tuple[float, float] | None = None, + depth_range_unit: str | None = None, + include_report_pages: bool = True, + ) -> object: + """Render one logfile preview as an MCP image payload.""" + png_bytes = service.preview_logfile_png( + logfile_path, + page_index=page_index, + dpi=dpi, + section_id=section_id, + track_ids=track_ids, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + include_report_pages=include_report_pages, + root=server_root, + ) + return Image(data=png_bytes, format="png") + + @mcp.tool() + def preview_section_png( + logfile_path: str, + section_id: str, + page_index: int = 0, + dpi: int = 144, + ) -> object: + """Render one logfile section preview as an MCP image payload.""" + png_bytes = service.preview_section_png( + logfile_path, + section_id=section_id, + page_index=page_index, + dpi=dpi, + root=server_root, + ) + return Image(data=png_bytes, format="png") + + @mcp.tool() + def preview_track_png( + logfile_path: str, + section_id: str, + track_ids: list[str], + page_index: int = 0, + dpi: int = 144, + depth_range: tuple[float, float] | None = None, + depth_range_unit: str | None = None, + ) -> object: + """Render one logfile track selection preview as an MCP image payload.""" + png_bytes = service.preview_track_png( + logfile_path, + section_id=section_id, + track_ids=track_ids, + page_index=page_index, + dpi=dpi, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + root=server_root, + ) + return Image(data=png_bytes, format="png") + + @mcp.tool() + def preview_window_png( + logfile_path: str, + depth_range: tuple[float, float], + depth_range_unit: str | None = None, + page_index: int = 0, + dpi: int = 144, + section_ids: list[str] | None = None, + ) -> object: + """Render one logfile depth-window preview as an MCP image payload.""" + png_bytes = service.preview_window_png( + logfile_path, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + page_index=page_index, + dpi=dpi, + section_ids=section_ids, + root=server_root, + ) + return Image(data=png_bytes, format="png") + + @mcp.tool() + def render_logfile_to_file( + logfile_path: str, + output_path: str, + overwrite: bool = False, + ) -> dict[str, object]: + """Render one logfile to an explicit output path.""" + return asdict( + service.render_logfile_to_file( + logfile_path, + output_path, + overwrite=overwrite, + root=server_root, + ) + ) + + @mcp.tool() + def export_example_bundle( + example_id: str, + output_dir: str, + overwrite: bool = False, + ) -> dict[str, object]: + """Export one packaged example bundle under the server root.""" + return asdict( + service.export_example_bundle( + example_id, + output_dir, + overwrite=overwrite, + root=server_root, + ) + ) + + @mcp.tool() + def validate_logfile_text( + yaml_text: str, + base_dir: str | None = None, + ) -> dict[str, object]: + """Validate unsaved logfile YAML text under the server root.""" + return asdict( + service.validate_logfile_text( + yaml_text, + base_dir=base_dir, + root=server_root, + ) + ) + + @mcp.tool() + def format_logfile_text( + yaml_text: str, + base_dir: str | None = None, + ) -> dict[str, object]: + """Normalize valid logfile YAML text through the canonical serializer.""" + return asdict( + service.format_logfile_text( + yaml_text, + base_dir=base_dir, + root=server_root, + ) + ) + + @mcp.tool() + def save_logfile_text( + yaml_text: str, + output_path: str, + overwrite: bool = False, + base_dir: str | None = None, + ) -> dict[str, object]: + """Validate, normalize, and save logfile YAML text under the server root.""" + return asdict( + service.save_logfile_text( + yaml_text, + output_path, + overwrite=overwrite, + base_dir=base_dir, + root=server_root, + ) + ) + + @mcp.resource("wellplot://schema/logfile.json", mime_type="application/json") + def logfile_schema_resource() -> str: + """Return the wellplot logfile JSON schema resource.""" + return service.schema_resource().text + + @mcp.resource("wellplot://examples/production/index.json", mime_type="application/json") + def production_examples_manifest_resource() -> str: + """Return the curated production example manifest resource.""" + return service.production_example_manifest_resource().text + + @mcp.resource( + "wellplot://examples/production/{example_id}/README.md", + mime_type="text/markdown", + ) + def production_example_readme_resource(example_id: str) -> str: + """Return the packaged production example README resource.""" + return service.production_example_resource(example_id, "README.md").text + + @mcp.resource( + "wellplot://examples/production/{example_id}/base.template.yaml", + mime_type="text/yaml", + ) + def production_example_template_resource(example_id: str) -> str: + """Return the packaged production example base template resource.""" + return service.production_example_resource(example_id, "base.template.yaml").text + + @mcp.resource( + "wellplot://examples/production/{example_id}/full_reconstruction.log.yaml", + mime_type="text/yaml", + ) + def production_example_logfile_resource(example_id: str) -> str: + """Return the packaged production example logfile resource.""" + return service.production_example_resource(example_id, "full_reconstruction.log.yaml").text + + @mcp.resource( + "wellplot://examples/production/{example_id}/data-notes.md", + mime_type="text/markdown", + ) + def production_example_notes_resource(example_id: str) -> str: + """Return the packaged production example notes resource.""" + return service.production_example_resource(example_id, "data-notes.md").text + + @mcp.prompt() + def review_logfile(logfile_path: str) -> str: + """Guide a model through the logfile review workflow.""" + return service.review_logfile_prompt(logfile_path) + + @mcp.prompt() + def preview_logfile(logfile_path: str, focus: str | None = None) -> str: + """Guide a model through the logfile preview workflow.""" + return service.preview_logfile_prompt(logfile_path, focus) + + @mcp.prompt() + def start_from_example(example_id: str, goal: str) -> str: + """Guide a model through adapting one packaged example to a new goal.""" + return service.start_from_example_prompt(example_id, goal) + + return mcp + + +def main() -> int: + """Run the wellplot MCP server over stdio.""" + try: + create_mcp_server().run() + except DependencyUnavailableError as exc: + print(exc, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/wellplot/mcp/service.py b/src/wellplot/mcp/service.py new file mode 100644 index 0000000..c82964b --- /dev/null +++ b/src/wellplot/mcp/service.py @@ -0,0 +1,939 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Pure-Python service helpers backing the optional wellplot MCP server.""" + +from __future__ import annotations + +import json +import os +from copy import deepcopy +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path + +import yaml + +from ..api.builder import ProgrammaticLogSpec +from ..api.render import ( + render_png_bytes, + render_section_png, + render_track_png, + render_window_png, +) +from ..api.serialize import report_to_dict, report_to_yaml +from ..errors import PathAccessError, TemplateValidationError +from ..logfile import ( + LogFileSpec, + build_documents_for_logfile, + load_datasets_for_logfile, + load_logfile, + load_logfile_text, + logfile_from_mapping, + resolve_section_data_sources_for_logfile, +) +from ..logfile_schema import get_logfile_json_schema +from ..pipeline import prepare_logfile_render, render_prepared_logfile + +ASSET_PACKAGE = "wellplot.mcp.assets" +PRODUCTION_EXAMPLE_IDS = ("cbl_log_example", "forge16b_porosity_example") +PRODUCTION_EXAMPLE_FILES = ( + "README.md", + "base.template.yaml", + "full_reconstruction.log.yaml", + "data-notes.md", +) +RESOURCE_MIME_TYPES = { + "README.md": "text/markdown", + "base.template.yaml": "text/yaml", + "full_reconstruction.log.yaml": "text/yaml", + "data-notes.md": "text/markdown", +} + + +@dataclass(slots=True) +class LogfileValidationResult: + """Structured logfile validation result for MCP clients.""" + + valid: bool + message: str + name: str + render_backend: str + section_ids: list[str] + + +@dataclass(slots=True) +class LogfileSectionSummary: + """Structured per-section report metadata for MCP inspection.""" + + id: str + title: str + source_path: str + source_format: str + depth_range: list[float] | None + track_ids: list[str] + track_kinds: list[str] + + +@dataclass(slots=True) +class LogfileInspectionResult: + """Structured logfile inspection payload for MCP clients.""" + + name: str + render_backend: str + configured_output_path: str + page_settings: dict[str, object] + depth_settings: dict[str, object] + has_heading: bool + has_remarks: bool + has_tail: bool + section_ids: list[str] + sections: list[LogfileSectionSummary] + + +@dataclass(slots=True) +class RenderToFileResult: + """Structured file-render result for MCP clients.""" + + backend: str + page_count: int + output_path: str + + +@dataclass(slots=True) +class ResourceContent: + """Text resource payload with its declared MIME type.""" + + text: str + mime_type: str + + +@dataclass(slots=True) +class ExampleBundleExportResult: + """Structured result for exporting one packaged example bundle.""" + + example_id: str + output_dir: str + written_files: list[str] + + +@dataclass(slots=True) +class FormattedLogfileTextResult: + """Structured result for normalized logfile YAML text output.""" + + name: str + render_backend: str + section_ids: list[str] + yaml_text: str + + +@dataclass(slots=True) +class SavedLogfileTextResult: + """Structured result for saving normalized logfile YAML text.""" + + name: str + render_backend: str + section_ids: list[str] + output_path: str + + +def resolve_server_root(root: str | Path | None = None) -> Path: + """Resolve the effective MCP server root.""" + return Path.cwd().resolve() if root is None else Path(root).expanduser().resolve() + + +def _resolve_user_path(path_value: str | Path, *, root: Path, context: str) -> Path: + path = Path(path_value).expanduser() + path = (root / path).resolve() if not path.is_absolute() else path.resolve() + try: + path.relative_to(root) + except ValueError as exc: + raise PathAccessError(f"{context} must resolve inside the server root {root}.") from exc + return path + + +def _section_ids_from_spec(spec: LogFileSpec) -> list[str]: + layout = dict(spec.document.get("layout", {})) + sections = list(layout.get("log_sections", [])) + return [str(section.get("id", "")) for section in sections] + + +def _section_map_from_spec(spec: LogFileSpec) -> dict[str, dict[str, object]]: + layout = dict(spec.document.get("layout", {})) + sections = list(layout.get("log_sections", [])) + return {str(section.get("id", "")): dict(section) for section in sections} + + +def _resolve_base_dir( + base_dir: str | Path | None, + *, + root: Path, + fallback: Path | None = None, +) -> Path: + if base_dir is None: + return root if fallback is None else fallback.resolve() + return _resolve_user_path(base_dir, root=root, context="base_dir") + + +def _document_default_depth_range(spec: LogFileSpec) -> list[float] | None: + depth_range = spec.document.get("depth_range") + if not isinstance(depth_range, list) or len(depth_range) != 2: + return None + return [float(depth_range[0]), float(depth_range[1])] + + +def _section_depth_range( + section: dict[str, object], + *, + default_depth_range: list[float] | None, +) -> list[float] | None: + depth_range = section.get("depth_range") + if isinstance(depth_range, list) and len(depth_range) == 2: + return [float(depth_range[0]), float(depth_range[1])] + return deepcopy(default_depth_range) + + +def _report_mapping_from_spec( + spec: LogFileSpec, + *, + backend_override: str | None = None, +) -> dict[str, object]: + render_mapping: dict[str, object] = { + "backend": backend_override or spec.render_backend, + "output_path": spec.render_output_path, + "dpi": spec.render_dpi, + } + if spec.render_continuous_strip_page_height_mm is not None: + render_mapping["continuous_strip_page_height_mm"] = ( + spec.render_continuous_strip_page_height_mm + ) + if spec.render_matplotlib: + render_mapping["matplotlib"] = deepcopy(spec.render_matplotlib) + + mapping: dict[str, object] = { + "version": 1, + "name": spec.name, + "render": render_mapping, + "document": deepcopy(spec.document), + } + if spec.data_source_path is not None: + mapping["data"] = { + "source_path": spec.data_source_path, + "source_format": spec.data_source_format, + } + return mapping + + +def _preview_report(prepared: object) -> ProgrammaticLogSpec: + mapping = _report_mapping_from_spec(prepared.spec, backend_override="matplotlib") + preview_spec = logfile_from_mapping(mapping) + return ProgrammaticLogSpec( + spec=preview_spec, + mapping=mapping, + datasets_by_section=prepared.datasets_by_section, + source_paths_by_section=prepared.source_paths_by_section, + ) + + +def _validate_preview_parameters( + *, + page_index: int, + dpi: int, + section_id: str | None, + track_ids: list[str] | None, + depth_range: tuple[float, float] | None, +) -> None: + if page_index < 0: + raise ValueError("page_index must be zero or greater.") + if dpi <= 0: + raise ValueError("dpi must be positive.") + if track_ids is not None and not track_ids: + raise ValueError("track_ids must contain at least one track identifier.") + if track_ids is not None and section_id is None: + raise ValueError("track_ids filtering requires a section_id.") + if depth_range is not None and float(depth_range[0]) == float(depth_range[1]): + raise ValueError("depth_range values must differ.") + + +def _validate_logfile_spec_renderable( + spec: LogFileSpec, + *, + base_dir: Path, + allowed_root: Path, +) -> None: + datasets_by_section, source_paths_by_section = load_datasets_for_logfile( + spec, + base_dir=base_dir, + allowed_root=allowed_root, + ) + if not datasets_by_section: + raise TemplateValidationError("No datasets were resolved for the configured log sections.") + _ = build_documents_for_logfile( + spec, + datasets_by_section, + source_path=source_paths_by_section, + ) + + +def _load_validated_logfile_text_spec( + yaml_text: str, + *, + base_dir: Path, + root: Path, +) -> LogFileSpec: + spec = load_logfile_text( + yaml_text, + base_dir=base_dir, + allowed_root=root, + ) + _validate_logfile_spec_renderable(spec, base_dir=base_dir, allowed_root=root) + return spec + + +def _ensure_known_section(spec: LogFileSpec, section_id: str) -> None: + available = _section_ids_from_spec(spec) + if section_id not in available: + raise TemplateValidationError( + f"Unknown section_id {section_id!r}. Available sections: {available}." + ) + + +def _ensure_known_section_ids(spec: LogFileSpec, section_ids: list[str]) -> None: + if not section_ids: + raise ValueError("section_ids must contain at least one section identifier.") + available = set(_section_ids_from_spec(spec)) + missing = [section_id for section_id in section_ids if section_id not in available] + if missing: + raise TemplateValidationError( + f"Unknown section_ids {missing}. Available sections: {sorted(available)}." + ) + + +def _ensure_known_track_ids(spec: LogFileSpec, section_id: str, track_ids: list[str]) -> None: + _ensure_known_section(spec, section_id) + section = _section_map_from_spec(spec)[section_id] + tracks = list(section.get("tracks", [])) + available = { + str(track.get("id", "")) + for track in tracks + if isinstance(track, dict) + } + missing = [track_id for track_id in track_ids if track_id not in available] + if missing: + raise TemplateValidationError( + f"Unknown track_ids {missing} for section {section_id!r}. " + f"Available tracks: {sorted(available)}." + ) + + +def _force_agg_preview_backend() -> None: + """Force a non-GUI matplotlib backend for MCP PNG previews.""" + os.environ.setdefault("MPLBACKEND", "Agg") + try: + import matplotlib + + matplotlib.use("Agg", force=True) + except ImportError: + return + + +def _production_asset_path(example_id: str, filename: str) -> object: + if example_id not in PRODUCTION_EXAMPLE_IDS: + raise TemplateValidationError(f"Unknown production example {example_id!r}.") + if filename not in RESOURCE_MIME_TYPES: + raise TemplateValidationError(f"Unsupported production example resource {filename!r}.") + return files(ASSET_PACKAGE).joinpath("production", example_id, filename) + + +def _production_readme_title(example_id: str) -> str: + readme = read_production_example_text(example_id, "README.md") + for line in readme.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return example_id + + +def read_production_example_text(example_id: str, filename: str) -> str: + """Return one packaged production example resource as UTF-8 text.""" + asset_path = _production_asset_path(example_id, filename) + return asset_path.read_text(encoding="utf-8") + + +def schema_resource() -> ResourceContent: + """Return the JSON schema resource payload.""" + payload = json.dumps(get_logfile_json_schema(), indent=2, sort_keys=True) + return ResourceContent(text=payload, mime_type="application/json") + + +def production_example_manifest() -> dict[str, object]: + """Return the curated production example manifest.""" + examples: list[dict[str, object]] = [] + for example_id in PRODUCTION_EXAMPLE_IDS: + examples.append( + { + "id": example_id, + "title": _production_readme_title(example_id), + "files": list(PRODUCTION_EXAMPLE_FILES), + } + ) + return {"examples": examples} + + +def production_example_manifest_resource() -> ResourceContent: + """Return the curated production example manifest as JSON text.""" + payload = json.dumps(production_example_manifest(), indent=2, sort_keys=True) + return ResourceContent(text=payload, mime_type="application/json") + + +def production_example_resource(example_id: str, filename: str) -> ResourceContent: + """Return one packaged production example resource and its MIME type.""" + return ResourceContent( + text=read_production_example_text(example_id, filename), + mime_type=RESOURCE_MIME_TYPES[filename], + ) + + +def validate_logfile( + logfile_path: str, + *, + root: str | Path | None = None, +) -> LogfileValidationResult: + """Validate one logfile path under the configured server root.""" + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + try: + spec = load_logfile(resolved_logfile, allowed_root=server_root) + except (TemplateValidationError, yaml.YAMLError) as exc: + return LogfileValidationResult( + valid=False, + message=str(exc), + name="", + render_backend="", + section_ids=[], + ) + section_ids = _section_ids_from_spec(spec) + try: + prepare_logfile_render(resolved_logfile, allowed_root=server_root) + except (TemplateValidationError, yaml.YAMLError) as exc: + return LogfileValidationResult( + valid=False, + message=str(exc), + name=spec.name, + render_backend=spec.render_backend, + section_ids=section_ids, + ) + return LogfileValidationResult( + valid=True, + message="Valid logfile.", + name=spec.name, + render_backend=spec.render_backend, + section_ids=section_ids, + ) + + +def validate_logfile_text( + yaml_text: str, + *, + base_dir: str | Path | None = None, + root: str | Path | None = None, +) -> LogfileValidationResult: + """Validate unsaved logfile YAML text under the configured server root.""" + server_root = resolve_server_root(root) + resolved_base_dir = _resolve_base_dir(base_dir, root=server_root) + try: + spec = load_logfile_text( + yaml_text, + base_dir=resolved_base_dir, + allowed_root=server_root, + ) + except (TemplateValidationError, yaml.YAMLError) as exc: + return LogfileValidationResult( + valid=False, + message=str(exc), + name="", + render_backend="", + section_ids=[], + ) + section_ids = _section_ids_from_spec(spec) + try: + _validate_logfile_spec_renderable( + spec, + base_dir=resolved_base_dir, + allowed_root=server_root, + ) + except (TemplateValidationError, yaml.YAMLError) as exc: + return LogfileValidationResult( + valid=False, + message=str(exc), + name=spec.name, + render_backend=spec.render_backend, + section_ids=section_ids, + ) + return LogfileValidationResult( + valid=True, + message="Valid logfile.", + name=spec.name, + render_backend=spec.render_backend, + section_ids=section_ids, + ) + + +def inspect_logfile( + logfile_path: str, + *, + root: str | Path | None = None, +) -> LogfileInspectionResult: + """Inspect one logfile path and return structured report metadata.""" + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + spec = prepared.spec + resolved_sources = resolve_section_data_sources_for_logfile( + spec, + base_dir=resolved_logfile.parent, + allowed_root=server_root, + ) + layout = dict(spec.document.get("layout", {})) + sections = list(layout.get("log_sections", [])) + default_depth_range = _document_default_depth_range(spec) + section_summaries: list[LogfileSectionSummary] = [] + for section in sections: + section_id = str(section.get("id", "")) + tracks = list(section.get("tracks", [])) + source_path, source_format = resolved_sources[section_id] + section_summaries.append( + LogfileSectionSummary( + id=section_id, + title=str(section.get("title", "")), + source_path=str(source_path), + source_format=source_format, + depth_range=_section_depth_range( + section, + default_depth_range=default_depth_range, + ), + track_ids=[str(track.get("id", "")) for track in tracks], + track_kinds=[str(track.get("kind", "")) for track in tracks], + ) + ) + + return LogfileInspectionResult( + name=spec.name, + render_backend=spec.render_backend, + configured_output_path=spec.render_output_path, + page_settings=deepcopy(dict(spec.document.get("page", {}))), + depth_settings=deepcopy(dict(spec.document.get("depth", {}))), + has_heading=bool(layout.get("heading")), + has_remarks=bool(layout.get("remarks")), + has_tail=bool(layout.get("tail")), + section_ids=[summary.id for summary in section_summaries], + sections=section_summaries, + ) + + +def preview_logfile_png( + logfile_path: str, + *, + page_index: int = 0, + dpi: int = 144, + section_id: str | None = None, + track_ids: list[str] | None = None, + depth_range: tuple[float, float] | None = None, + depth_range_unit: str | None = None, + include_report_pages: bool = True, + root: str | Path | None = None, +) -> bytes: + """Render one logfile preview as in-memory PNG bytes.""" + _validate_preview_parameters( + page_index=page_index, + dpi=dpi, + section_id=section_id, + track_ids=track_ids, + depth_range=depth_range, + ) + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + if section_id is not None: + _ensure_known_section(prepared.spec, section_id) + if track_ids is not None: + _ensure_known_track_ids(prepared.spec, section_id, track_ids) + report = _preview_report(prepared) + _force_agg_preview_backend() + section_ids = None if section_id is None else [section_id] + track_ids_by_section = None + if track_ids is not None: + track_ids_by_section = {section_id: track_ids} + return render_png_bytes( + report, + page_index=page_index, + dpi=dpi, + section_ids=section_ids, + track_ids_by_section=track_ids_by_section, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + include_report_pages=include_report_pages, + ) + + +def preview_section_png( + logfile_path: str, + *, + section_id: str, + page_index: int = 0, + dpi: int = 144, + root: str | Path | None = None, +) -> bytes: + """Render one section preview as in-memory PNG bytes.""" + _validate_preview_parameters( + page_index=page_index, + dpi=dpi, + section_id=section_id, + track_ids=None, + depth_range=None, + ) + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + _ensure_known_section(prepared.spec, section_id) + report = _preview_report(prepared) + _force_agg_preview_backend() + return render_section_png( + report, + section_id=section_id, + page_index=page_index, + dpi=dpi, + ) + + +def preview_track_png( + logfile_path: str, + *, + section_id: str, + track_ids: list[str], + page_index: int = 0, + dpi: int = 144, + depth_range: tuple[float, float] | None = None, + depth_range_unit: str | None = None, + root: str | Path | None = None, +) -> bytes: + """Render selected tracks from one section as in-memory PNG bytes.""" + _validate_preview_parameters( + page_index=page_index, + dpi=dpi, + section_id=section_id, + track_ids=track_ids, + depth_range=depth_range, + ) + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + _ensure_known_track_ids(prepared.spec, section_id, track_ids) + report = _preview_report(prepared) + _force_agg_preview_backend() + return render_track_png( + report, + section_id=section_id, + track_ids=track_ids, + page_index=page_index, + dpi=dpi, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + ) + + +def preview_window_png( + logfile_path: str, + *, + depth_range: tuple[float, float], + depth_range_unit: str | None = None, + page_index: int = 0, + dpi: int = 144, + section_ids: list[str] | None = None, + root: str | Path | None = None, +) -> bytes: + """Render a depth-windowed preview as in-memory PNG bytes.""" + _validate_preview_parameters( + page_index=page_index, + dpi=dpi, + section_id=None, + track_ids=None, + depth_range=depth_range, + ) + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + if section_ids is not None: + _ensure_known_section_ids(prepared.spec, section_ids) + report = _preview_report(prepared) + _force_agg_preview_backend() + return render_window_png( + report, + depth_range=depth_range, + depth_range_unit=depth_range_unit, + page_index=page_index, + dpi=dpi, + section_ids=section_ids, + ) + + +def render_logfile_to_file( + logfile_path: str, + output_path: str, + *, + overwrite: bool = False, + root: str | Path | None = None, +) -> RenderToFileResult: + """Render one logfile to an explicit output path under the server root.""" + server_root = resolve_server_root(root) + resolved_logfile = _resolve_user_path(logfile_path, root=server_root, context="logfile_path") + resolved_output = _resolve_user_path(output_path, root=server_root, context="output_path") + if resolved_output.exists() and not overwrite: + raise FileExistsError(f"Output path already exists: {resolved_output}") + prepared = prepare_logfile_render(resolved_logfile, allowed_root=server_root) + result = render_prepared_logfile(prepared, output_path=resolved_output) + output_text = "" if result.output_path is None else str(result.output_path) + return RenderToFileResult( + backend=result.backend, + page_count=result.page_count, + output_path=output_text, + ) + + +def _rebase_relative_path( + path_value: str, + *, + from_base_dir: Path, + to_base_dir: Path, +) -> str: + original_path = Path(path_value).expanduser() + if original_path.is_absolute(): + return str(original_path.resolve()) + resolved_path = (from_base_dir / original_path).resolve() + return Path(os.path.relpath(resolved_path, start=to_base_dir)).as_posix() + + +def _rebase_report_paths( + mapping: dict[str, object], + *, + from_base_dir: Path, + to_base_dir: Path, +) -> dict[str, object]: + render = mapping.get("render") + if isinstance(render, dict): + output_path = render.get("output_path") + if isinstance(output_path, str) and output_path.strip(): + render["output_path"] = _rebase_relative_path( + output_path, + from_base_dir=from_base_dir, + to_base_dir=to_base_dir, + ) + + data = mapping.get("data") + if isinstance(data, dict): + source_path = data.get("source_path") + if isinstance(source_path, str) and source_path.strip(): + data["source_path"] = _rebase_relative_path( + source_path, + from_base_dir=from_base_dir, + to_base_dir=to_base_dir, + ) + + document = mapping.get("document") + if not isinstance(document, dict): + return mapping + layout = document.get("layout") + if not isinstance(layout, dict): + return mapping + sections = layout.get("log_sections") + if not isinstance(sections, list): + return mapping + for section in sections: + if not isinstance(section, dict): + continue + section_data = section.get("data") + if not isinstance(section_data, dict): + continue + source_path = section_data.get("source_path") + if isinstance(source_path, str) and source_path.strip(): + section_data["source_path"] = _rebase_relative_path( + source_path, + from_base_dir=from_base_dir, + to_base_dir=to_base_dir, + ) + return mapping + + +def export_example_bundle( + example_id: str, + output_dir: str, + *, + overwrite: bool = False, + root: str | Path | None = None, +) -> ExampleBundleExportResult: + """Export one packaged example bundle into a writable server-root directory.""" + server_root = resolve_server_root(root) + resolved_output_dir = _resolve_user_path(output_dir, root=server_root, context="output_dir") + if resolved_output_dir.exists() and not resolved_output_dir.is_dir(): + raise NotADirectoryError(f"Output directory path is not a directory: {resolved_output_dir}") + + targets = [resolved_output_dir / filename for filename in PRODUCTION_EXAMPLE_FILES] + if not overwrite: + for target in targets: + if target.exists(): + raise FileExistsError(f"Example bundle target already exists: {target}") + + resolved_output_dir.mkdir(parents=True, exist_ok=True) + written_files: list[str] = [] + for filename in PRODUCTION_EXAMPLE_FILES: + target = resolved_output_dir / filename + target.write_text( + read_production_example_text(example_id, filename), + encoding="utf-8", + ) + written_files.append(str(target)) + return ExampleBundleExportResult( + example_id=example_id, + output_dir=str(resolved_output_dir), + written_files=written_files, + ) + + +def format_logfile_text( + yaml_text: str, + *, + base_dir: str | Path | None = None, + root: str | Path | None = None, +) -> FormattedLogfileTextResult: + """Normalize valid logfile YAML text through the canonical serializer path.""" + server_root = resolve_server_root(root) + resolved_base_dir = _resolve_base_dir(base_dir, root=server_root) + spec = _load_validated_logfile_text_spec( + yaml_text, + base_dir=resolved_base_dir, + root=server_root, + ) + normalized_yaml = report_to_yaml(spec) + if not isinstance(normalized_yaml, str): + raise RuntimeError("Expected canonical YAML text from report_to_yaml().") + return FormattedLogfileTextResult( + name=spec.name, + render_backend=spec.render_backend, + section_ids=_section_ids_from_spec(spec), + yaml_text=normalized_yaml, + ) + + +def save_logfile_text( + yaml_text: str, + output_path: str, + *, + overwrite: bool = False, + base_dir: str | Path | None = None, + root: str | Path | None = None, +) -> SavedLogfileTextResult: + """Validate, normalize, and save logfile YAML text under the server root.""" + server_root = resolve_server_root(root) + resolved_output_path = _resolve_user_path(output_path, root=server_root, context="output_path") + if resolved_output_path.exists() and not overwrite: + raise FileExistsError(f"Output path already exists: {resolved_output_path}") + + resolved_base_dir = _resolve_base_dir( + base_dir, + root=server_root, + fallback=resolved_output_path.parent, + ) + spec = _load_validated_logfile_text_spec( + yaml_text, + base_dir=resolved_base_dir, + root=server_root, + ) + normalized_mapping = report_to_dict(spec) + rebased_mapping = _rebase_report_paths( + normalized_mapping, + from_base_dir=resolved_base_dir, + to_base_dir=resolved_output_path.parent, + ) + normalized_yaml = report_to_yaml(rebased_mapping) + if not isinstance(normalized_yaml, str): + raise RuntimeError("Expected canonical YAML text from report_to_yaml().") + resolved_output_path.parent.mkdir(parents=True, exist_ok=True) + resolved_output_path.write_text(normalized_yaml, encoding="utf-8") + return SavedLogfileTextResult( + name=spec.name, + render_backend=spec.render_backend, + section_ids=_section_ids_from_spec(spec), + output_path=str(resolved_output_path), + ) + + +def review_logfile_prompt(logfile_path: str) -> str: + """Return the guided prompt text for logfile review workflows.""" + return ( + "Review this wellplot logfile under the current MCP server root.\n\n" + f"Logfile path: {logfile_path}\n\n" + "Workflow:\n" + "1. Call validate_logfile(logfile_path) first.\n" + "2. If valid is true, call inspect_logfile(logfile_path).\n" + "3. Summarize any issues, notable section/source layout facts, and the next sensible " + "render or preview step.\n" + "4. If validation fails, explain the returned message before proposing fixes.\n" + ) + + +def preview_logfile_prompt(logfile_path: str, focus: str | None = None) -> str: + """Return the guided prompt text for logfile preview workflows.""" + focus_text = "Preview focus: full report." if focus is None else f"Preview focus: {focus}" + return ( + "Preview this wellplot logfile under the current MCP server root.\n\n" + f"Logfile path: {logfile_path}\n" + f"{focus_text}\n\n" + "Workflow:\n" + "1. Call validate_logfile(logfile_path).\n" + "2. Call inspect_logfile(logfile_path) to identify relevant sections or tracks.\n" + "3. Call preview_logfile_png(logfile_path, ...) with the most relevant section, track, " + "page, or depth window for the stated focus.\n" + "4. Briefly explain what the preview shows and any next refinement you recommend.\n" + ) + + +def start_from_example_prompt(example_id: str, goal: str) -> str: + """Return the guided prompt text for adapting a packaged production example.""" + readme = read_production_example_text(example_id, "README.md") + template_yaml = read_production_example_text(example_id, "base.template.yaml") + logfile_yaml = read_production_example_text(example_id, "full_reconstruction.log.yaml") + return ( + f"Adapt the packaged wellplot production example `{example_id}` for this goal:\n" + f"{goal}\n\n" + "Use the bundled example resources below as the starting point.\n\n" + "README.md\n" + "```markdown\n" + f"{readme}\n" + "```\n\n" + "base.template.yaml\n" + "```yaml\n" + f"{template_yaml}\n" + "```\n\n" + "full_reconstruction.log.yaml\n" + "```yaml\n" + f"{logfile_yaml}\n" + "```\n\n" + "Preserve wellplot schema compatibility, explain the main edits you make, and prefer " + "changing only the sections that are necessary for the stated goal.\n" + ) diff --git a/src/wellplot/pipeline.py b/src/wellplot/pipeline.py index 50cf846..71873e7 100644 --- a/src/wellplot/pipeline.py +++ b/src/wellplot/pipeline.py @@ -21,11 +21,17 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path from .errors import TemplateValidationError -from .logfile import build_documents_for_logfile, load_datasets_for_logfile, load_logfile -from .model import LogDocument +from .logfile import ( + LogFileSpec, + build_documents_for_logfile, + load_datasets_for_logfile, + load_logfile, +) +from .model import LogDocument, WellDataset from .renderers import MatplotlibRenderer, PlotlyRenderer from .renderers.base import RenderResult @@ -56,17 +62,30 @@ def _document_section_id(document: LogDocument) -> str: return "" -def render_from_logfile( +@dataclass(slots=True) +class PreparedLogfileRender: + """Resolved logfile data, documents, and dataset mapping for one render request.""" + + logfile_path: Path + spec: LogFileSpec + datasets_by_section: dict[str, WellDataset] + source_paths_by_section: dict[str, Path] + documents: tuple[LogDocument, ...] + document_datasets: tuple[WellDataset, ...] + + +def prepare_logfile_render( logfile_path: str | Path, *, - output_path: str | Path | None = None, -) -> RenderResult: - """Load a logfile spec, resolve datasets, and render its configured output.""" + allowed_root: Path | None = None, +) -> PreparedLogfileRender: + """Load a logfile, its datasets, and render-ready documents.""" resolved_logfile = Path(logfile_path).resolve() - spec = load_logfile(resolved_logfile) + spec = load_logfile(resolved_logfile, allowed_root=allowed_root) datasets_by_section, source_paths_by_section = load_datasets_for_logfile( spec, base_dir=resolved_logfile.parent, + allowed_root=allowed_root, ) documents = build_documents_for_logfile( spec, @@ -80,28 +99,66 @@ def render_from_logfile( datasets_by_section.get(_document_section_id(document), default_dataset) for document in documents ) - resolved_output = _resolve_output_path(resolved_logfile, spec.render_output_path, output_path) + return PreparedLogfileRender( + logfile_path=resolved_logfile, + spec=spec, + datasets_by_section=datasets_by_section, + source_paths_by_section=source_paths_by_section, + documents=documents, + document_datasets=document_datasets, + ) + + +def render_prepared_logfile( + prepared: PreparedLogfileRender, + *, + output_path: str | Path | None = None, +) -> RenderResult: + """Render a previously prepared logfile payload.""" + resolved_output = _resolve_output_path( + prepared.logfile_path, + prepared.spec.render_output_path, + output_path, + ) - if spec.render_backend == "matplotlib": - renderer_kwargs = {"dpi": spec.render_dpi} - if spec.render_continuous_strip_page_height_mm is not None: + if prepared.spec.render_backend == "matplotlib": + renderer_kwargs = {"dpi": prepared.spec.render_dpi} + if prepared.spec.render_continuous_strip_page_height_mm is not None: renderer_kwargs["continuous_strip_page_height_mm"] = ( - spec.render_continuous_strip_page_height_mm + prepared.spec.render_continuous_strip_page_height_mm ) - matplotlib_style = spec.render_matplotlib.get("style") + matplotlib_style = prepared.spec.render_matplotlib.get("style") if matplotlib_style is not None: renderer_kwargs["style"] = matplotlib_style renderer = MatplotlibRenderer(**renderer_kwargs) - return renderer.render_documents(documents, document_datasets, output_path=resolved_output) - if spec.render_backend == "plotly": - if len(documents) > 1: + return renderer.render_documents( + prepared.documents, + prepared.document_datasets, + output_path=resolved_output, + ) + if prepared.spec.render_backend == "plotly": + if len(prepared.documents) > 1: raise TemplateValidationError( "Plotly backend currently supports a single log section. " "Use matplotlib for multisection rendering." ) renderer = PlotlyRenderer() - return renderer.render(documents[0], document_datasets[0], output_path=resolved_output) + return renderer.render( + prepared.documents[0], + prepared.document_datasets[0], + output_path=resolved_output, + ) raise TemplateValidationError( - f"Unsupported render backend {spec.render_backend!r}. " + f"Unsupported render backend {prepared.spec.render_backend!r}. " "Supported backends: matplotlib, plotly." ) + + +def render_from_logfile( + logfile_path: str | Path, + *, + output_path: str | Path | None = None, +) -> RenderResult: + """Load a logfile spec, resolve datasets, and render its configured output.""" + prepared = prepare_logfile_render(logfile_path) + return render_prepared_logfile(prepared, output_path=output_path) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..97286f6 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,236 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Server-entry tests for the optional wellplot MCP support.""" + +from __future__ import annotations + +import importlib.util +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +from wellplot.errors import DependencyUnavailableError +from wellplot.mcp.server import create_mcp_server, main + +MCP_AVAILABLE = importlib.util.find_spec("mcp") is not None +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") +CBL_MAIN_LOGFILE = "examples/cbl_main.log.yaml" +PRODUCTION_LOGFILE = "examples/production/cbl_log_example/full_reconstruction.log.yaml" + + +@unittest.skipIf(MCP_AVAILABLE, "optional mcp dependency is installed") +class McpServerDependencyTests(unittest.TestCase): + """Verify the graceful behavior when the optional SDK is unavailable.""" + + def test_create_mcp_server_requires_optional_dependency(self) -> None: + """Raise a dedicated error when the MCP SDK is not installed.""" + with self.assertRaises(DependencyUnavailableError): + create_mcp_server() + + def test_main_returns_error_when_dependency_is_missing(self) -> None: + """Return a non-zero exit code with a helpful install hint.""" + stderr = io.StringIO() + with redirect_stderr(stderr): + code = main() + + self.assertEqual(code, 1) + self.assertIn("wellplot[mcp]", stderr.getvalue()) + + +@unittest.skipUnless(MCP_AVAILABLE, "optional mcp dependency is not installed") +class McpServerIntegrationTests(unittest.TestCase): + """Verify the stdio MCP surface against the real SDK.""" + + def test_stdio_server_exposes_tools_resources_and_prompts(self) -> None: + """Start the stdio server and exercise its MCP contract.""" + import anyio + + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + anyio.run(self._exercise_stdio_server, Path(tmpdir)) + + async def _exercise_stdio_server(self, tmpdir: Path) -> None: + from mcp.client.session import ClientSession + from mcp.client.stdio import StdioServerParameters, stdio_client + + export_dir = tmpdir / "exported-example" + saved_logfile = tmpdir / "saved.log.yaml" + server = StdioServerParameters( + command=sys.executable, + args=["-m", "wellplot.mcp.server"], + cwd=str(REPO_ROOT), + ) + async with stdio_client(server) as streams, ClientSession(*streams) as session: + await session.initialize() + + tools = await session.list_tools() + resources = await session.list_resources() + prompts = await session.list_prompts() + templates = await session.list_resource_templates() + validation = await session.call_tool( + "validate_logfile", + {"logfile_path": CBL_MAIN_LOGFILE}, + ) + cbl_main_inspection = await session.call_tool( + "inspect_logfile", + {"logfile_path": CBL_MAIN_LOGFILE}, + ) + section = cbl_main_inspection.structuredContent["sections"][0] + preview = await session.call_tool( + "preview_section_png", + { + "logfile_path": CBL_MAIN_LOGFILE, + "section_id": section["id"], + "dpi": 72, + }, + ) + preview_track = await session.call_tool( + "preview_track_png", + { + "logfile_path": CBL_MAIN_LOGFILE, + "section_id": section["id"], + "track_ids": [section["track_ids"][1]], + "dpi": 72, + }, + ) + preview_window = await session.call_tool( + "preview_window_png", + { + "logfile_path": PRODUCTION_LOGFILE, + "depth_range": [100.0, 200.0], + "section_ids": ["main_pass"], + "dpi": 72, + }, + ) + text_validation = await session.call_tool( + "validate_logfile_text", + { + "yaml_text": EXAMPLE_LOGFILE_TEXT, + "base_dir": "examples", + }, + ) + formatting = await session.call_tool( + "format_logfile_text", + { + "yaml_text": EXAMPLE_LOGFILE_TEXT, + "base_dir": "examples", + }, + ) + exported = await session.call_tool( + "export_example_bundle", + { + "example_id": "cbl_log_example", + "output_dir": str(export_dir), + }, + ) + saved = await session.call_tool( + "save_logfile_text", + { + "yaml_text": EXAMPLE_LOGFILE_TEXT, + "output_path": str(saved_logfile), + "base_dir": "examples", + }, + ) + + self.assertEqual( + [tool.name for tool in tools.tools], + [ + "validate_logfile", + "inspect_logfile", + "preview_logfile_png", + "preview_section_png", + "preview_track_png", + "preview_window_png", + "render_logfile_to_file", + "export_example_bundle", + "validate_logfile_text", + "format_logfile_text", + "save_logfile_text", + ], + ) + self.assertEqual( + [str(resource.uri) for resource in resources.resources], + [ + "wellplot://schema/logfile.json", + "wellplot://examples/production/index.json", + ], + ) + self.assertEqual( + [prompt.name for prompt in prompts.prompts], + [ + "review_logfile", + "preview_logfile", + "start_from_example", + ], + ) + self.assertEqual( + [template.uriTemplate for template in templates.resourceTemplates], + [ + "wellplot://examples/production/{example_id}/README.md", + "wellplot://examples/production/{example_id}/base.template.yaml", + "wellplot://examples/production/{example_id}/full_reconstruction.log.yaml", + "wellplot://examples/production/{example_id}/data-notes.md", + ], + ) + self.assertEqual( + validation.structuredContent, + { + "valid": True, + "message": "Valid logfile.", + "name": "CBL Main Configuration", + "render_backend": "matplotlib", + "section_ids": ["main"], + }, + ) + self.assertEqual(len(preview.content), 1) + self.assertEqual(preview.content[0].type, "image") + self.assertEqual(getattr(preview.content[0], "mimeType", None), "image/png") + self.assertEqual(len(preview_track.content), 1) + self.assertEqual(preview_track.content[0].type, "image") + self.assertEqual(getattr(preview_track.content[0], "mimeType", None), "image/png") + self.assertEqual(len(preview_window.content), 1) + self.assertEqual(preview_window.content[0].type, "image") + self.assertEqual(getattr(preview_window.content[0], "mimeType", None), "image/png") + self.assertEqual(text_validation.structuredContent["valid"], True) + self.assertEqual(text_validation.structuredContent["section_ids"], ["main"]) + self.assertEqual(formatting.structuredContent["name"], "CBL Main Configuration") + self.assertIn("version: 1", formatting.structuredContent["yaml_text"]) + self.assertNotIn("\ntemplate:\n", formatting.structuredContent["yaml_text"]) + self.assertEqual(exported.structuredContent["example_id"], "cbl_log_example") + self.assertEqual( + [Path(path).name for path in exported.structuredContent["written_files"]], + [ + "README.md", + "base.template.yaml", + "full_reconstruction.log.yaml", + "data-notes.md", + ], + ) + self.assertEqual(saved.structuredContent["name"], "CBL Main Configuration") + self.assertEqual(saved.structuredContent["output_path"], str(saved_logfile)) + self.assertTrue(export_dir.exists()) + self.assertTrue(saved_logfile.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py new file mode 100644 index 0000000..71e30ed --- /dev/null +++ b/tests/test_mcp_service.py @@ -0,0 +1,371 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Service-layer tests for the optional wellplot MCP support.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from wellplot.errors import PathAccessError, TemplateValidationError +from wellplot.mcp import service + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") + + +class McpServiceTests(unittest.TestCase): + """Verify the pure-Python MCP service helpers.""" + + def test_validate_logfile_success(self) -> None: + """Validate a real example logfile under the repository root.""" + result = service.validate_logfile( + "examples/cbl_main.log.yaml", + root=REPO_ROOT, + ) + + self.assertTrue(result.valid) + self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.render_backend, "matplotlib") + self.assertEqual(result.section_ids, ["main"]) + + def test_validate_logfile_returns_invalid_for_schema_error(self) -> None: + """Return a structured invalid result for logfile/schema failures.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + logfile_path = root / "broken.log.yaml" + logfile_path.write_text("- not-a-mapping\n", encoding="utf-8") + + result = service.validate_logfile(str(logfile_path), root=root) + + self.assertFalse(result.valid) + self.assertEqual(result.name, "") + self.assertEqual(result.render_backend, "") + self.assertEqual(result.section_ids, []) + self.assertIn("root must be a mapping", result.message) + + def test_validate_logfile_text_success(self) -> None: + """Validate unsaved logfile text relative to the provided base directory.""" + result = service.validate_logfile_text( + EXAMPLE_LOGFILE_TEXT, + base_dir="examples", + root=REPO_ROOT, + ) + + self.assertTrue(result.valid) + self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.render_backend, "matplotlib") + self.assertEqual(result.section_ids, ["main"]) + + def test_validate_logfile_text_returns_invalid_for_schema_error(self) -> None: + """Return a structured invalid result for broken logfile text.""" + result = service.validate_logfile_text( + "- not-a-mapping\n", + root=REPO_ROOT, + ) + + self.assertFalse(result.valid) + self.assertEqual(result.name, "") + self.assertEqual(result.render_backend, "") + self.assertEqual(result.section_ids, []) + self.assertIn("root must be a mapping", result.message) + + def test_validate_logfile_blocks_template_outside_root(self) -> None: + """Reject template inheritance paths that escape the allowed server root.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + outside_template = Path(tempfile.gettempdir()) / "outside-template.log.yaml" + logfile_path = root / "job.log.yaml" + logfile_path.write_text( + "\n".join( + [ + "template:", + f" path: {outside_template}", + "version: 1", + "name: Escaping template", + ] + ) + + "\n", + encoding="utf-8", + ) + + with self.assertRaises(PathAccessError): + service.validate_logfile(str(logfile_path), root=root) + + def test_inspect_logfile_returns_multisection_metadata(self) -> None: + """Inspect a production example and expose section/source summaries.""" + result = service.inspect_logfile( + "examples/production/cbl_log_example/full_reconstruction.log.yaml", + root=REPO_ROOT, + ) + + self.assertEqual(result.name, "CBL Log Example Full Reconstruction") + self.assertEqual(result.render_backend, "matplotlib") + self.assertEqual(result.section_ids, ["main_pass", "repeat_pass"]) + self.assertTrue(result.has_remarks) + self.assertEqual(len(result.sections), 2) + self.assertEqual(result.sections[0].id, "main_pass") + self.assertIn("workspace/data/CBL_Main.dlis", result.sections[0].source_path) + self.assertIn("depth", result.sections[0].track_ids) + + def test_preview_logfile_png_returns_png_bytes(self) -> None: + """Render a PNG preview from a real logfile path.""" + png_bytes = service.preview_logfile_png( + "examples/cbl_main.log.yaml", + dpi=96, + root=REPO_ROOT, + ) + + self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + + def test_preview_section_png_returns_png_bytes(self) -> None: + """Render a section-scoped PNG preview from a real logfile path.""" + png_bytes = service.preview_section_png( + "examples/cbl_main.log.yaml", + section_id="main", + dpi=96, + root=REPO_ROOT, + ) + + self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + + def test_preview_track_png_returns_png_bytes(self) -> None: + """Render a track-scoped PNG preview from a real logfile path.""" + inspection = service.inspect_logfile( + "examples/cbl_main.log.yaml", + root=REPO_ROOT, + ) + png_bytes = service.preview_track_png( + "examples/cbl_main.log.yaml", + section_id=inspection.sections[0].id, + track_ids=[inspection.sections[0].track_ids[1]], + dpi=96, + root=REPO_ROOT, + ) + + self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + + def test_preview_window_png_returns_png_bytes(self) -> None: + """Render a depth-window PNG preview from a real logfile path.""" + inspection = service.inspect_logfile( + "examples/production/cbl_log_example/full_reconstruction.log.yaml", + root=REPO_ROOT, + ) + top_depth = inspection.sections[0].depth_range[0] + png_bytes = service.preview_window_png( + "examples/production/cbl_log_example/full_reconstruction.log.yaml", + depth_range=(top_depth, top_depth + 100.0), + section_ids=[inspection.sections[0].id], + dpi=96, + root=REPO_ROOT, + ) + + self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + + def test_preview_section_png_rejects_unknown_section(self) -> None: + """Reject explicit section preview requests for missing section ids.""" + with self.assertRaises(TemplateValidationError): + service.preview_section_png( + "examples/cbl_main.log.yaml", + section_id="missing", + root=REPO_ROOT, + ) + + def test_preview_track_png_rejects_empty_track_selection(self) -> None: + """Reject track-scoped preview requests without track ids.""" + with self.assertRaises(ValueError): + service.preview_track_png( + "examples/cbl_main.log.yaml", + section_id="main", + track_ids=[], + root=REPO_ROOT, + ) + + def test_preview_window_png_rejects_zero_height_depth_range(self) -> None: + """Reject depth-window previews whose interval has no height.""" + with self.assertRaises(ValueError): + service.preview_window_png( + "examples/cbl_main.log.yaml", + depth_range=(100.0, 100.0), + root=REPO_ROOT, + ) + + def test_render_logfile_to_file_writes_pdf(self) -> None: + """Render a real logfile to an explicit output file path.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_path = Path(tmpdir) / "mcp-render.pdf" + result = service.render_logfile_to_file( + "examples/cbl_main.log.yaml", + str(output_path), + root=REPO_ROOT, + ) + + self.assertEqual(result.backend, "matplotlib") + self.assertEqual(result.output_path, str(output_path)) + self.assertTrue(output_path.exists()) + self.assertGreater(output_path.stat().st_size, 0) + + def test_render_logfile_to_file_rejects_existing_path_without_overwrite(self) -> None: + """Protect existing files unless overwrite is explicitly enabled.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_path = Path(tmpdir) / "mcp-render.pdf" + output_path.write_bytes(b"existing") + + with self.assertRaises(FileExistsError): + service.render_logfile_to_file( + "examples/cbl_main.log.yaml", + str(output_path), + root=REPO_ROOT, + ) + + def test_export_example_bundle_writes_packaged_files(self) -> None: + """Export one packaged example bundle into a writable directory.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_dir = Path(tmpdir) / "example-bundle" + result = service.export_example_bundle( + "cbl_log_example", + str(output_dir), + root=REPO_ROOT, + ) + + self.assertEqual(result.example_id, "cbl_log_example") + self.assertEqual(result.output_dir, str(output_dir)) + self.assertEqual( + [Path(path).name for path in result.written_files], + list(service.PRODUCTION_EXAMPLE_FILES), + ) + for filename in service.PRODUCTION_EXAMPLE_FILES: + self.assertTrue((output_dir / filename).exists()) + + def test_export_example_bundle_rejects_existing_target_without_overwrite(self) -> None: + """Protect example bundle writes unless overwrite is explicitly enabled.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_dir = Path(tmpdir) / "example-bundle" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "README.md").write_text("existing\n", encoding="utf-8") + + with self.assertRaises(FileExistsError): + service.export_example_bundle( + "cbl_log_example", + str(output_dir), + root=REPO_ROOT, + ) + + def test_export_example_bundle_blocks_output_outside_root(self) -> None: + """Reject example bundle writes that escape the configured server root.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + with self.assertRaises(PathAccessError): + service.export_example_bundle( + "cbl_log_example", + "../outside", + root=root, + ) + + def test_format_logfile_text_returns_normalized_yaml(self) -> None: + """Return canonical logfile YAML text through the serializer path.""" + result = service.format_logfile_text( + EXAMPLE_LOGFILE_TEXT, + base_dir="examples", + root=REPO_ROOT, + ) + + self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.render_backend, "matplotlib") + self.assertEqual(result.section_ids, ["main"]) + self.assertIn("version: 1", result.yaml_text) + self.assertIn("render:", result.yaml_text) + self.assertNotIn("\ntemplate:\n", result.yaml_text) + + def test_save_logfile_text_writes_rebased_normalized_yaml(self) -> None: + """Write normalized logfile YAML that still validates from the new location.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_path = Path(tmpdir) / "saved" / "normalized.log.yaml" + result = service.save_logfile_text( + EXAMPLE_LOGFILE_TEXT, + str(output_path), + base_dir="examples", + root=REPO_ROOT, + ) + + self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.output_path, str(output_path)) + self.assertTrue(output_path.exists()) + saved_text = output_path.read_text(encoding="utf-8") + self.assertIn("version: 1", saved_text) + self.assertNotIn("\ntemplate:\n", saved_text) + validated = service.validate_logfile(str(output_path), root=REPO_ROOT) + self.assertTrue(validated.valid) + + def test_save_logfile_text_rejects_existing_path_without_overwrite(self) -> None: + """Protect saved logfile writes unless overwrite is explicitly enabled.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: + output_path = Path(tmpdir) / "normalized.log.yaml" + output_path.write_text("existing\n", encoding="utf-8") + + with self.assertRaises(FileExistsError): + service.save_logfile_text( + EXAMPLE_LOGFILE_TEXT, + str(output_path), + base_dir="examples", + root=REPO_ROOT, + ) + + def test_schema_resource_is_json(self) -> None: + """Expose the packaged schema resource as JSON text.""" + resource = service.schema_resource() + payload = json.loads(resource.text) + + self.assertEqual(resource.mime_type, "application/json") + self.assertIsInstance(payload, dict) + self.assertIn("$schema", payload) + + def test_production_example_manifest_lists_examples(self) -> None: + """Expose the packaged production example manifest.""" + resource = service.production_example_manifest_resource() + payload = json.loads(resource.text) + + self.assertEqual(resource.mime_type, "application/json") + example_ids = [item["id"] for item in payload["examples"]] + self.assertEqual(example_ids, list(service.PRODUCTION_EXAMPLE_IDS)) + + def test_production_example_resource_returns_packaged_text(self) -> None: + """Load packaged example assets through the resource helper.""" + resource = service.production_example_resource("cbl_log_example", "README.md") + + self.assertEqual(resource.mime_type, "text/markdown") + self.assertIn("CBL/VDL Reconstruction Example", resource.text) + + def test_start_from_example_prompt_embeds_goal_and_example(self) -> None: + """Embed the requested goal and packaged example resources in the prompt.""" + prompt = service.start_from_example_prompt( + "forge16b_porosity_example", + "Create a variant with simplified headers.", + ) + + self.assertIn("Create a variant with simplified headers.", prompt) + self.assertIn("base.template.yaml", prompt) + self.assertIn("full_reconstruction.log.yaml", prompt) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 974f803..ced200f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,28 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -327,6 +349,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -509,6 +590,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[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, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -960,6 +1087,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "1.27.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/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -1443,6 +1595,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1452,6 +1735,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.21" @@ -1502,6 +1799,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1878,6 +2212,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -1892,6 +2239,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "striplog" version = "0.9.2" @@ -1954,6 +2314,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" @@ -1972,6 +2344,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "sys_platform != 'emscripten'" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -2035,6 +2420,7 @@ all = [ { name = "ipykernel" }, { name = "lasio" }, { name = "matplotlib" }, + { name = "mcp" }, { name = "pandas" }, { name = "pint" }, { name = "plotly" }, @@ -2052,6 +2438,9 @@ las = [ { name = "setuptools" }, { name = "welly" }, ] +mcp = [ + { name = "mcp" }, +] notebook = [ { name = "ipykernel" }, ] @@ -2089,6 +2478,8 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.8" }, { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.8" }, { name = "matplotlib", marker = "extra == 'pdf'", specifier = ">=3.8" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1,<2" }, { name = "numpy", specifier = ">=1.26" }, { name = "pandas", marker = "extra == 'all'", specifier = ">=2.2" }, { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.2" }, @@ -2102,7 +2493,7 @@ requires-dist = [ { name = "welly", marker = "extra == 'all'", specifier = ">=0.5.2" }, { name = "welly", marker = "extra == 'las'", specifier = ">=0.5.2" }, ] -provides-extras = ["las", "dlis", "pdf", "interactive", "notebook", "pandas", "units", "all"] +provides-extras = ["las", "dlis", "pdf", "interactive", "mcp", "notebook", "pandas", "units", "all"] [package.metadata.requires-dev] dev = [ From 9b142b49cb30f9074cb6716b68d0f0a4d22fddb0 Mon Sep 17 00:00:00 2001 From: Carlos Schrupp Date: Tue, 28 Apr 2026 14:55:43 -0400 Subject: [PATCH 2/5] Fix MCP rollout formatting --- scripts/smoke_installed_wheel.py | 6 ++---- src/wellplot/mcp/service.py | 6 +----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/scripts/smoke_installed_wheel.py b/scripts/smoke_installed_wheel.py index fd9caf7..29f0766 100644 --- a/scripts/smoke_installed_wheel.py +++ b/scripts/smoke_installed_wheel.py @@ -147,7 +147,7 @@ def main() -> int: raise RuntimeError("wellplot MCP server did not expose a runnable server instance.") schema_payload = schema_resource().text - if "\"$schema\"" not in schema_payload: + if '"$schema"' not in schema_payload: raise RuntimeError("wellplot MCP schema resource did not contain JSON schema text.") example_ids = [item["id"] for item in production_example_manifest()["examples"]] @@ -192,9 +192,7 @@ async def _exercise_stdio_server() -> None: {"logfile_path": "examples/cbl_main.log.yaml"}, ) if validation.structuredContent.get("valid") is not True: - raise RuntimeError( - "Installed wheel MCP validate_logfile call did not succeed." - ) + raise RuntimeError("Installed wheel MCP validate_logfile call did not succeed.") anyio.run(_exercise_stdio_server) diff --git a/src/wellplot/mcp/service.py b/src/wellplot/mcp/service.py index c82964b..5c06b00 100644 --- a/src/wellplot/mcp/service.py +++ b/src/wellplot/mcp/service.py @@ -329,11 +329,7 @@ def _ensure_known_track_ids(spec: LogFileSpec, section_id: str, track_ids: list[ _ensure_known_section(spec, section_id) section = _section_map_from_spec(spec)[section_id] tracks = list(section.get("tracks", [])) - available = { - str(track.get("id", "")) - for track in tracks - if isinstance(track, dict) - } + available = {str(track.get("id", "")) for track in tracks if isinstance(track, dict)} missing = [track_id for track_id in track_ids if track_id not in available] if missing: raise TemplateValidationError( From 409892e193cc76ada9afbf63964cb2ca353de092 Mon Sep 17 00:00:00 2001 From: Carlos Schrupp Date: Tue, 28 Apr 2026 15:20:51 -0400 Subject: [PATCH 3/5] Fix MCP CI dependency coverage --- .github/workflows/ci.yml | 3 ++ scripts/smoke_installed_wheel.py | 92 ++++++++++++++++++++------------ tests/test_mcp_server.py | 4 ++ tests/test_mcp_service.py | 14 +++++ 4 files changed, 79 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 088745b..64f038e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,9 @@ jobs: print(load_las, load_dlis, MatplotlibRenderer, PlotlyRenderer) PY + - name: Run MCP tests with optional dependencies + run: uv run python -m unittest tests.test_mcp_service tests.test_mcp_server -v + package: name: Package Build and Wheel Smoke Test runs-on: ubuntu-latest diff --git a/scripts/smoke_installed_wheel.py b/scripts/smoke_installed_wheel.py index 29f0766..59538f0 100644 --- a/scripts/smoke_installed_wheel.py +++ b/scripts/smoke_installed_wheel.py @@ -23,6 +23,7 @@ import importlib.util import os +import shutil import sys from io import StringIO from pathlib import Path @@ -156,43 +157,66 @@ def main() -> int: "wellplot MCP example manifest did not expose the expected examples." ) - server_command = Path(sys.executable).resolve().with_name("wellplot-mcp") - if not server_command.exists(): - candidate = server_command.with_suffix(".exe") - if candidate.exists(): - server_command = candidate - else: - raise RuntimeError("Installed wheel smoke test could not locate wellplot-mcp.") + server_command_text = shutil.which("wellplot-mcp") + server_command = Path(server_command_text).resolve() if server_command_text else None + server_args: list[str] = [] + if server_command is None: + server_command = Path(sys.executable).resolve().with_name("wellplot-mcp") + if not server_command.exists(): + candidate = server_command.with_suffix(".exe") + if candidate.exists(): + server_command = candidate + else: + server_command = Path(sys.executable).resolve() + server_args = ["-m", "wellplot.mcp.server"] async def _exercise_stdio_server() -> None: - server_params = StdioServerParameters( - command=str(server_command), - cwd=str(Path.cwd()), - ) - async with stdio_client(server_params) as streams, ClientSession(*streams) as session: - await session.initialize() - tools = await session.list_tools() - tool_names = [tool.name for tool in tools.tools] - required_tools = { - "validate_logfile", - "preview_section_png", - "preview_track_png", - "preview_window_png", - "export_example_bundle", - "validate_logfile_text", - "format_logfile_text", - "save_logfile_text", - } - if not required_tools.issubset(tool_names): - raise RuntimeError( - "Installed wheel MCP server did not expose the expected tool set." - ) - validation = await session.call_tool( - "validate_logfile", - {"logfile_path": "examples/cbl_main.log.yaml"}, + with TemporaryDirectory(dir=Path.cwd(), prefix="mcp-smoke-") as tmpdir: + export_dir = Path(tmpdir) / "exported-example" + relative_export_dir = Path(os.path.relpath(export_dir, start=Path.cwd())).as_posix() + + server_params = StdioServerParameters( + command=str(server_command), + args=server_args, + cwd=str(Path.cwd()), ) - if validation.structuredContent.get("valid") is not True: - raise RuntimeError("Installed wheel MCP validate_logfile call did not succeed.") + async with ( + stdio_client(server_params) as streams, + ClientSession(*streams) as session, + ): + await session.initialize() + tools = await session.list_tools() + tool_names = [tool.name for tool in tools.tools] + required_tools = { + "validate_logfile", + "preview_section_png", + "preview_track_png", + "preview_window_png", + "export_example_bundle", + "validate_logfile_text", + "format_logfile_text", + "save_logfile_text", + } + if not required_tools.issubset(tool_names): + raise RuntimeError( + "Installed wheel MCP server did not expose the expected tool set." + ) + exported = await session.call_tool( + "export_example_bundle", + { + "example_id": "cbl_log_example", + "output_dir": relative_export_dir, + }, + ) + if exported.structuredContent.get("example_id") != "cbl_log_example": + raise RuntimeError( + "Installed wheel MCP export_example_bundle call did not succeed." + ) + if len(exported.structuredContent.get("written_files", [])) != 4: + raise RuntimeError( + "Installed wheel MCP export_example_bundle call wrote an " + "unexpected file set." + ) anyio.run(_exercise_stdio_server) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 97286f6..7999936 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -33,6 +33,8 @@ from wellplot.mcp.server import create_mcp_server, main MCP_AVAILABLE = importlib.util.find_spec("mcp") is not None +HAS_LAS = importlib.util.find_spec("lasio") is not None +HAS_DLIS = importlib.util.find_spec("dlisio") is not None REPO_ROOT = Path(__file__).resolve().parents[1] EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") CBL_MAIN_LOGFILE = "examples/cbl_main.log.yaml" @@ -59,6 +61,8 @@ def test_main_returns_error_when_dependency_is_missing(self) -> None: @unittest.skipUnless(MCP_AVAILABLE, "optional mcp dependency is not installed") +@unittest.skipUnless(HAS_LAS, "lasio is not installed") +@unittest.skipUnless(HAS_DLIS, "dlisio is not installed") class McpServerIntegrationTests(unittest.TestCase): """Verify the stdio MCP surface against the real SDK.""" diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 71e30ed..c18f5a8 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -21,6 +21,7 @@ from __future__ import annotations +import importlib.util import json import tempfile import unittest @@ -29,6 +30,8 @@ from wellplot.errors import PathAccessError, TemplateValidationError from wellplot.mcp import service +HAS_LAS = importlib.util.find_spec("lasio") is not None +HAS_DLIS = importlib.util.find_spec("dlisio") is not None REPO_ROOT = Path(__file__).resolve().parents[1] EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") @@ -36,6 +39,7 @@ class McpServiceTests(unittest.TestCase): """Verify the pure-Python MCP service helpers.""" + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_validate_logfile_success(self) -> None: """Validate a real example logfile under the repository root.""" result = service.validate_logfile( @@ -63,6 +67,7 @@ def test_validate_logfile_returns_invalid_for_schema_error(self) -> None: self.assertEqual(result.section_ids, []) self.assertIn("root must be a mapping", result.message) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_validate_logfile_text_success(self) -> None: """Validate unsaved logfile text relative to the provided base directory.""" result = service.validate_logfile_text( @@ -111,6 +116,7 @@ def test_validate_logfile_blocks_template_outside_root(self) -> None: with self.assertRaises(PathAccessError): service.validate_logfile(str(logfile_path), root=root) + @unittest.skipUnless(HAS_DLIS, "dlisio is not installed") def test_inspect_logfile_returns_multisection_metadata(self) -> None: """Inspect a production example and expose section/source summaries.""" result = service.inspect_logfile( @@ -127,6 +133,7 @@ def test_inspect_logfile_returns_multisection_metadata(self) -> None: self.assertIn("workspace/data/CBL_Main.dlis", result.sections[0].source_path) self.assertIn("depth", result.sections[0].track_ids) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_logfile_png_returns_png_bytes(self) -> None: """Render a PNG preview from a real logfile path.""" png_bytes = service.preview_logfile_png( @@ -137,6 +144,7 @@ def test_preview_logfile_png_returns_png_bytes(self) -> None: self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_section_png_returns_png_bytes(self) -> None: """Render a section-scoped PNG preview from a real logfile path.""" png_bytes = service.preview_section_png( @@ -148,6 +156,7 @@ def test_preview_section_png_returns_png_bytes(self) -> None: self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_track_png_returns_png_bytes(self) -> None: """Render a track-scoped PNG preview from a real logfile path.""" inspection = service.inspect_logfile( @@ -164,6 +173,7 @@ def test_preview_track_png_returns_png_bytes(self) -> None: self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + @unittest.skipUnless(HAS_DLIS, "dlisio is not installed") def test_preview_window_png_returns_png_bytes(self) -> None: """Render a depth-window PNG preview from a real logfile path.""" inspection = service.inspect_logfile( @@ -181,6 +191,7 @@ def test_preview_window_png_returns_png_bytes(self) -> None: self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_section_png_rejects_unknown_section(self) -> None: """Reject explicit section preview requests for missing section ids.""" with self.assertRaises(TemplateValidationError): @@ -209,6 +220,7 @@ def test_preview_window_png_rejects_zero_height_depth_range(self) -> None: root=REPO_ROOT, ) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_render_logfile_to_file_writes_pdf(self) -> None: """Render a real logfile to an explicit output file path.""" with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: @@ -281,6 +293,7 @@ def test_export_example_bundle_blocks_output_outside_root(self) -> None: root=root, ) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_format_logfile_text_returns_normalized_yaml(self) -> None: """Return canonical logfile YAML text through the serializer path.""" result = service.format_logfile_text( @@ -296,6 +309,7 @@ def test_format_logfile_text_returns_normalized_yaml(self) -> None: self.assertIn("render:", result.yaml_text) self.assertNotIn("\ntemplate:\n", result.yaml_text) + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_save_logfile_text_writes_rebased_normalized_yaml(self) -> None: """Write normalized logfile YAML that still validates from the new location.""" with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: From 325c7a1ab2b8d381bac014ea0459d74f0e277962 Mon Sep 17 00:00:00 2001 From: Carlos Schrupp Date: Tue, 28 Apr 2026 15:38:14 -0400 Subject: [PATCH 4/5] Use synthetic fixtures for MCP CI tests --- tests/_mcp_fixtures.py | 150 ++++++++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 65 ++++++++++------- tests/test_mcp_service.py | 73 +++++++++++-------- 3 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 tests/_mcp_fixtures.py diff --git a/tests/_mcp_fixtures.py b/tests/_mcp_fixtures.py new file mode 100644 index 0000000..96a5ef5 --- /dev/null +++ b/tests/_mcp_fixtures.py @@ -0,0 +1,150 @@ +############################################################################### +# +# Copyright (C) 2026 Carlos Schrupp +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +############################################################################### + +"""Shared MCP test fixtures backed by repository-contained temporary files.""" + +from __future__ import annotations + +import copy +import os +from dataclasses import dataclass +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +_BASE_LOGFILE = REPO_ROOT / "examples" / "cbl_main.log.yaml" +_BASE_LOGFILE_MAPPING = yaml.safe_load(_BASE_LOGFILE.read_text(encoding="utf-8")) +_TEMPLATE_PATH = REPO_ROOT / "templates" / "wireline_base.template.yaml" +_MINIMAL_LAS_TEXT = """~Version Information + VERS. 2.0: CWLS log ASCII standard - VERSION 2.0 + WRAP. NO: One line per depth step +~Well Information + STRT.M 1000.0: START DEPTH + STOP.M 1020.0: STOP DEPTH + STEP.M 1.0: STEP + NULL. -999.25: NULL VALUE + COMP. WELLPLOT: COMPANY + WELL. MCP FIXTURE-01: WELL + FLD . DEMO-FLD: FIELD +~Curve Information + DEPT.M : Depth + CBL .mV : Cement bond log amplitude + VDL .mV : Variable density log scalar proxy + GR .gAPI : Gamma ray + CALI.in : Caliper + RT .ohm.m : Resistivity +~ASCII Log Data +1000.0 48.0 10.0 70.0 8.60 1.10 +1002.0 47.5 11.0 72.0 8.55 1.20 +1004.0 47.0 12.0 74.0 8.50 1.30 +1006.0 46.5 13.0 76.0 8.45 1.40 +1008.0 46.0 14.0 78.0 8.40 1.50 +1010.0 45.5 15.0 80.0 8.35 1.60 +1012.0 45.0 16.0 82.0 8.30 1.70 +1014.0 44.5 17.0 84.0 8.25 1.80 +1016.0 44.0 18.0 86.0 8.20 1.90 +1018.0 43.5 19.0 88.0 8.15 2.00 +1020.0 43.0 20.0 90.0 8.10 2.10 +""" + + +@dataclass(frozen=True) +class McpFixturePaths: + """Paths and text for synthetic MCP logfile fixtures.""" + + fixture_dir: Path + las_path: Path + single_logfile: Path + multi_logfile: Path + single_logfile_relative: str + multi_logfile_relative: str + single_logfile_text: str + + +def create_mcp_fixture_paths(fixture_dir: Path, *, repo_root: Path = REPO_ROOT) -> McpFixturePaths: + """Create tracked-repo-relative MCP test fixtures under one temporary directory.""" + fixture_dir.mkdir(parents=True, exist_ok=True) + las_path = fixture_dir / "fixture.las" + las_path.write_text(_MINIMAL_LAS_TEXT, encoding="utf-8") + + single_mapping = copy.deepcopy(_BASE_LOGFILE_MAPPING) + single_mapping["template"] = { + "path": Path(os.path.relpath(_TEMPLATE_PATH, start=fixture_dir)).as_posix() + } + single_mapping["name"] = "MCP Single Fixture" + single_mapping["render"]["output_path"] = "./fixture-render.pdf" + section = single_mapping["document"]["layout"]["log_sections"][0] + section["subtitle"] = "Fixture Main" + section["data"]["source_path"] = "./fixture.las" + section["data"]["source_format"] = "auto" + + single_logfile = fixture_dir / "single.log.yaml" + single_logfile.write_text( + yaml.safe_dump(single_mapping, sort_keys=False), + encoding="utf-8", + ) + + multi_mapping = copy.deepcopy(single_mapping) + multi_mapping["name"] = "MCP Multi Fixture" + multi_layout = multi_mapping["document"]["layout"] + multi_layout["remarks"] = [ + { + "title": "Fixture Remarks", + "lines": ["Synthetic LAS-backed MCP multisection fixture."], + "alignment": "left", + } + ] + + main_section = copy.deepcopy(section) + main_section["id"] = "main_pass" + main_section["title"] = "Main Pass" + main_section["subtitle"] = "Fixture Main Pass" + main_section["depth_range"] = [1000.0, 1020.0] + + repeat_section = copy.deepcopy(section) + repeat_section["id"] = "repeat_pass" + repeat_section["title"] = "Repeat Pass" + repeat_section["subtitle"] = "Fixture Repeat Pass" + repeat_section["depth_range"] = [1006.0, 1018.0] + + multi_layout["log_sections"] = [main_section, repeat_section] + base_bindings = single_mapping["document"]["bindings"]["channels"] + multi_mapping["document"]["bindings"]["channels"] = [] + for section_id in ("main_pass", "repeat_pass"): + for binding in base_bindings: + section_binding = copy.deepcopy(binding) + section_binding["section"] = section_id + multi_mapping["document"]["bindings"]["channels"].append(section_binding) + + multi_logfile = fixture_dir / "multi.log.yaml" + multi_logfile.write_text( + yaml.safe_dump(multi_mapping, sort_keys=False), + encoding="utf-8", + ) + + return McpFixturePaths( + fixture_dir=fixture_dir, + las_path=las_path, + single_logfile=single_logfile, + multi_logfile=multi_logfile, + single_logfile_relative=Path(os.path.relpath(single_logfile, start=repo_root)).as_posix(), + multi_logfile_relative=Path(os.path.relpath(multi_logfile, start=repo_root)).as_posix(), + single_logfile_text=single_logfile.read_text(encoding="utf-8"), + ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 7999936..18af2bf 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -23,22 +23,19 @@ import importlib.util import io +import os import sys import tempfile import unittest from contextlib import redirect_stderr from pathlib import Path +from _mcp_fixtures import REPO_ROOT, McpFixturePaths, create_mcp_fixture_paths from wellplot.errors import DependencyUnavailableError from wellplot.mcp.server import create_mcp_server, main MCP_AVAILABLE = importlib.util.find_spec("mcp") is not None HAS_LAS = importlib.util.find_spec("lasio") is not None -HAS_DLIS = importlib.util.find_spec("dlisio") is not None -REPO_ROOT = Path(__file__).resolve().parents[1] -EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") -CBL_MAIN_LOGFILE = "examples/cbl_main.log.yaml" -PRODUCTION_LOGFILE = "examples/production/cbl_log_example/full_reconstruction.log.yaml" @unittest.skipIf(MCP_AVAILABLE, "optional mcp dependency is installed") @@ -62,7 +59,6 @@ def test_main_returns_error_when_dependency_is_missing(self) -> None: @unittest.skipUnless(MCP_AVAILABLE, "optional mcp dependency is not installed") @unittest.skipUnless(HAS_LAS, "lasio is not installed") -@unittest.skipUnless(HAS_DLIS, "dlisio is not installed") class McpServerIntegrationTests(unittest.TestCase): """Verify the stdio MCP surface against the real SDK.""" @@ -71,14 +67,17 @@ def test_stdio_server_exposes_tools_resources_and_prompts(self) -> None: import anyio with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: - anyio.run(self._exercise_stdio_server, Path(tmpdir)) + anyio.run( + self._exercise_stdio_server, + create_mcp_fixture_paths(Path(tmpdir)), + ) - async def _exercise_stdio_server(self, tmpdir: Path) -> None: + async def _exercise_stdio_server(self, fixture_paths: McpFixturePaths) -> None: from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client - export_dir = tmpdir / "exported-example" - saved_logfile = tmpdir / "saved.log.yaml" + export_dir = fixture_paths.fixture_dir / "exported-example" + saved_logfile = fixture_paths.fixture_dir / "saved.log.yaml" server = StdioServerParameters( command=sys.executable, args=["-m", "wellplot.mcp.server"], @@ -93,17 +92,22 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: templates = await session.list_resource_templates() validation = await session.call_tool( "validate_logfile", - {"logfile_path": CBL_MAIN_LOGFILE}, + {"logfile_path": fixture_paths.single_logfile_relative}, ) cbl_main_inspection = await session.call_tool( "inspect_logfile", - {"logfile_path": CBL_MAIN_LOGFILE}, + {"logfile_path": fixture_paths.single_logfile_relative}, ) section = cbl_main_inspection.structuredContent["sections"][0] + multi_inspection = await session.call_tool( + "inspect_logfile", + {"logfile_path": fixture_paths.multi_logfile_relative}, + ) + multi_section = multi_inspection.structuredContent["sections"][0] preview = await session.call_tool( "preview_section_png", { - "logfile_path": CBL_MAIN_LOGFILE, + "logfile_path": fixture_paths.single_logfile_relative, "section_id": section["id"], "dpi": 72, }, @@ -111,7 +115,7 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: preview_track = await session.call_tool( "preview_track_png", { - "logfile_path": CBL_MAIN_LOGFILE, + "logfile_path": fixture_paths.single_logfile_relative, "section_id": section["id"], "track_ids": [section["track_ids"][1]], "dpi": 72, @@ -120,24 +124,31 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: preview_window = await session.call_tool( "preview_window_png", { - "logfile_path": PRODUCTION_LOGFILE, - "depth_range": [100.0, 200.0], - "section_ids": ["main_pass"], + "logfile_path": fixture_paths.multi_logfile_relative, + "depth_range": [ + multi_section["depth_range"][0], + multi_section["depth_range"][0] + 8.0, + ], + "section_ids": [multi_section["id"]], "dpi": 72, }, ) text_validation = await session.call_tool( "validate_logfile_text", { - "yaml_text": EXAMPLE_LOGFILE_TEXT, - "base_dir": "examples", + "yaml_text": fixture_paths.single_logfile_text, + "base_dir": Path( + os.path.relpath(fixture_paths.fixture_dir, start=REPO_ROOT) + ).as_posix(), }, ) formatting = await session.call_tool( "format_logfile_text", { - "yaml_text": EXAMPLE_LOGFILE_TEXT, - "base_dir": "examples", + "yaml_text": fixture_paths.single_logfile_text, + "base_dir": Path( + os.path.relpath(fixture_paths.fixture_dir, start=REPO_ROOT) + ).as_posix(), }, ) exported = await session.call_tool( @@ -150,9 +161,11 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: saved = await session.call_tool( "save_logfile_text", { - "yaml_text": EXAMPLE_LOGFILE_TEXT, + "yaml_text": fixture_paths.single_logfile_text, "output_path": str(saved_logfile), - "base_dir": "examples", + "base_dir": Path( + os.path.relpath(fixture_paths.fixture_dir, start=REPO_ROOT) + ).as_posix(), }, ) @@ -201,7 +214,7 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: { "valid": True, "message": "Valid logfile.", - "name": "CBL Main Configuration", + "name": "MCP Single Fixture", "render_backend": "matplotlib", "section_ids": ["main"], }, @@ -217,7 +230,7 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: self.assertEqual(getattr(preview_window.content[0], "mimeType", None), "image/png") self.assertEqual(text_validation.structuredContent["valid"], True) self.assertEqual(text_validation.structuredContent["section_ids"], ["main"]) - self.assertEqual(formatting.structuredContent["name"], "CBL Main Configuration") + self.assertEqual(formatting.structuredContent["name"], "MCP Single Fixture") self.assertIn("version: 1", formatting.structuredContent["yaml_text"]) self.assertNotIn("\ntemplate:\n", formatting.structuredContent["yaml_text"]) self.assertEqual(exported.structuredContent["example_id"], "cbl_log_example") @@ -230,7 +243,7 @@ async def _exercise_stdio_server(self, tmpdir: Path) -> None: "data-notes.md", ], ) - self.assertEqual(saved.structuredContent["name"], "CBL Main Configuration") + self.assertEqual(saved.structuredContent["name"], "MCP Single Fixture") self.assertEqual(saved.structuredContent["output_path"], str(saved_logfile)) self.assertTrue(export_dir.exists()) self.assertTrue(saved_logfile.exists()) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index c18f5a8..53a291f 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -27,28 +27,39 @@ import unittest from pathlib import Path +from _mcp_fixtures import REPO_ROOT, create_mcp_fixture_paths from wellplot.errors import PathAccessError, TemplateValidationError from wellplot.mcp import service HAS_LAS = importlib.util.find_spec("lasio") is not None -HAS_DLIS = importlib.util.find_spec("dlisio") is not None -REPO_ROOT = Path(__file__).resolve().parents[1] -EXAMPLE_LOGFILE_TEXT = (REPO_ROOT / "examples" / "cbl_main.log.yaml").read_text(encoding="utf-8") class McpServiceTests(unittest.TestCase): """Verify the pure-Python MCP service helpers.""" + @classmethod + def setUpClass(cls) -> None: + """Create synthetic LAS-backed logfile fixtures once for the test class.""" + super().setUpClass() + cls._fixture_tmpdir = tempfile.TemporaryDirectory(dir=REPO_ROOT, prefix="mcp-service-") + cls._fixture_paths = create_mcp_fixture_paths(Path(cls._fixture_tmpdir.name)) + + @classmethod + def tearDownClass(cls) -> None: + """Remove the shared synthetic fixture directory after all tests finish.""" + cls._fixture_tmpdir.cleanup() + super().tearDownClass() + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_validate_logfile_success(self) -> None: """Validate a real example logfile under the repository root.""" result = service.validate_logfile( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, root=REPO_ROOT, ) self.assertTrue(result.valid) - self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.name, "MCP Single Fixture") self.assertEqual(result.render_backend, "matplotlib") self.assertEqual(result.section_ids, ["main"]) @@ -71,13 +82,13 @@ def test_validate_logfile_returns_invalid_for_schema_error(self) -> None: def test_validate_logfile_text_success(self) -> None: """Validate unsaved logfile text relative to the provided base directory.""" result = service.validate_logfile_text( - EXAMPLE_LOGFILE_TEXT, - base_dir="examples", + self._fixture_paths.single_logfile_text, + base_dir=self._fixture_paths.fixture_dir.relative_to(REPO_ROOT), root=REPO_ROOT, ) self.assertTrue(result.valid) - self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.name, "MCP Single Fixture") self.assertEqual(result.render_backend, "matplotlib") self.assertEqual(result.section_ids, ["main"]) @@ -116,28 +127,28 @@ def test_validate_logfile_blocks_template_outside_root(self) -> None: with self.assertRaises(PathAccessError): service.validate_logfile(str(logfile_path), root=root) - @unittest.skipUnless(HAS_DLIS, "dlisio is not installed") + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_inspect_logfile_returns_multisection_metadata(self) -> None: - """Inspect a production example and expose section/source summaries.""" + """Inspect a multisection fixture and expose section/source summaries.""" result = service.inspect_logfile( - "examples/production/cbl_log_example/full_reconstruction.log.yaml", + self._fixture_paths.multi_logfile_relative, root=REPO_ROOT, ) - self.assertEqual(result.name, "CBL Log Example Full Reconstruction") + self.assertEqual(result.name, "MCP Multi Fixture") self.assertEqual(result.render_backend, "matplotlib") self.assertEqual(result.section_ids, ["main_pass", "repeat_pass"]) self.assertTrue(result.has_remarks) self.assertEqual(len(result.sections), 2) self.assertEqual(result.sections[0].id, "main_pass") - self.assertIn("workspace/data/CBL_Main.dlis", result.sections[0].source_path) + self.assertEqual(result.sections[0].source_path, str(self._fixture_paths.las_path)) self.assertIn("depth", result.sections[0].track_ids) @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_logfile_png_returns_png_bytes(self) -> None: """Render a PNG preview from a real logfile path.""" png_bytes = service.preview_logfile_png( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, dpi=96, root=REPO_ROOT, ) @@ -148,7 +159,7 @@ def test_preview_logfile_png_returns_png_bytes(self) -> None: def test_preview_section_png_returns_png_bytes(self) -> None: """Render a section-scoped PNG preview from a real logfile path.""" png_bytes = service.preview_section_png( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, section_id="main", dpi=96, root=REPO_ROOT, @@ -160,11 +171,11 @@ def test_preview_section_png_returns_png_bytes(self) -> None: def test_preview_track_png_returns_png_bytes(self) -> None: """Render a track-scoped PNG preview from a real logfile path.""" inspection = service.inspect_logfile( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, root=REPO_ROOT, ) png_bytes = service.preview_track_png( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, section_id=inspection.sections[0].id, track_ids=[inspection.sections[0].track_ids[1]], dpi=96, @@ -173,17 +184,17 @@ def test_preview_track_png_returns_png_bytes(self) -> None: self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) - @unittest.skipUnless(HAS_DLIS, "dlisio is not installed") + @unittest.skipUnless(HAS_LAS, "lasio is not installed") def test_preview_window_png_returns_png_bytes(self) -> None: """Render a depth-window PNG preview from a real logfile path.""" inspection = service.inspect_logfile( - "examples/production/cbl_log_example/full_reconstruction.log.yaml", + self._fixture_paths.multi_logfile_relative, root=REPO_ROOT, ) top_depth = inspection.sections[0].depth_range[0] png_bytes = service.preview_window_png( - "examples/production/cbl_log_example/full_reconstruction.log.yaml", - depth_range=(top_depth, top_depth + 100.0), + self._fixture_paths.multi_logfile_relative, + depth_range=(top_depth, top_depth + 8.0), section_ids=[inspection.sections[0].id], dpi=96, root=REPO_ROOT, @@ -196,7 +207,7 @@ def test_preview_section_png_rejects_unknown_section(self) -> None: """Reject explicit section preview requests for missing section ids.""" with self.assertRaises(TemplateValidationError): service.preview_section_png( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, section_id="missing", root=REPO_ROOT, ) @@ -226,7 +237,7 @@ def test_render_logfile_to_file_writes_pdf(self) -> None: with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: output_path = Path(tmpdir) / "mcp-render.pdf" result = service.render_logfile_to_file( - "examples/cbl_main.log.yaml", + self._fixture_paths.single_logfile_relative, str(output_path), root=REPO_ROOT, ) @@ -297,12 +308,12 @@ def test_export_example_bundle_blocks_output_outside_root(self) -> None: def test_format_logfile_text_returns_normalized_yaml(self) -> None: """Return canonical logfile YAML text through the serializer path.""" result = service.format_logfile_text( - EXAMPLE_LOGFILE_TEXT, - base_dir="examples", + self._fixture_paths.single_logfile_text, + base_dir=self._fixture_paths.fixture_dir.relative_to(REPO_ROOT), root=REPO_ROOT, ) - self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.name, "MCP Single Fixture") self.assertEqual(result.render_backend, "matplotlib") self.assertEqual(result.section_ids, ["main"]) self.assertIn("version: 1", result.yaml_text) @@ -315,13 +326,13 @@ def test_save_logfile_text_writes_rebased_normalized_yaml(self) -> None: with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmpdir: output_path = Path(tmpdir) / "saved" / "normalized.log.yaml" result = service.save_logfile_text( - EXAMPLE_LOGFILE_TEXT, + self._fixture_paths.single_logfile_text, str(output_path), - base_dir="examples", + base_dir=self._fixture_paths.fixture_dir.relative_to(REPO_ROOT), root=REPO_ROOT, ) - self.assertEqual(result.name, "CBL Main Configuration") + self.assertEqual(result.name, "MCP Single Fixture") self.assertEqual(result.output_path, str(output_path)) self.assertTrue(output_path.exists()) saved_text = output_path.read_text(encoding="utf-8") @@ -338,9 +349,9 @@ def test_save_logfile_text_rejects_existing_path_without_overwrite(self) -> None with self.assertRaises(FileExistsError): service.save_logfile_text( - EXAMPLE_LOGFILE_TEXT, + self._fixture_paths.single_logfile_text, str(output_path), - base_dir="examples", + base_dir=self._fixture_paths.fixture_dir.relative_to(REPO_ROOT), root=REPO_ROOT, ) From 8b47fe0dfb32aa0399ac6267a8720c679b2863b9 Mon Sep 17 00:00:00 2001 From: Carlos Schrupp Date: Tue, 28 Apr 2026 17:15:17 -0400 Subject: [PATCH 5/5] Support both MCP unittest invocation modes --- tests/test_mcp_server.py | 5 ++++- tests/test_mcp_service.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 18af2bf..c2256a3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -30,7 +30,10 @@ from contextlib import redirect_stderr from pathlib import Path -from _mcp_fixtures import REPO_ROOT, McpFixturePaths, create_mcp_fixture_paths +try: + from tests._mcp_fixtures import REPO_ROOT, McpFixturePaths, create_mcp_fixture_paths +except ModuleNotFoundError: # pragma: no cover - exercised by unittest discovery mode + from _mcp_fixtures import REPO_ROOT, McpFixturePaths, create_mcp_fixture_paths from wellplot.errors import DependencyUnavailableError from wellplot.mcp.server import create_mcp_server, main diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 53a291f..98cf2b4 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -27,7 +27,10 @@ import unittest from pathlib import Path -from _mcp_fixtures import REPO_ROOT, create_mcp_fixture_paths +try: + from tests._mcp_fixtures import REPO_ROOT, create_mcp_fixture_paths +except ModuleNotFoundError: # pragma: no cover - exercised by unittest discovery mode + from _mcp_fixtures import REPO_ROOT, create_mcp_fixture_paths from wellplot.errors import PathAccessError, TemplateValidationError from wellplot.mcp import service