From adc6734ff310d96f91deb361df93ed04c378ad27 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 6 Nov 2026 13:51:22 +0000 Subject: [PATCH 1/6] Add /analyse: syntax and undefined-name diagnostics for Mantid scripts A stateless HTTP service the FIA script editors can call on a debounce, returning Monaco-shaped diagnostics for a reduction script without running it. Built FROM a pinned Mantid runner tag, because only the interpreter that runs the scripts can answer questions about them. - Warm-up imports the framework in a background thread (the import deadlocks inside an event loop) and gates readiness on a real algorithm having run: Mantid 7's AlgorithmManager reports nothing useful, so readiness creates and deletes a probe workspace instead. - Positions are Monaco's - 1-based lines, 1-based UTF-16 columns, exclusive ends. CPython reports code-point offsets, jedi code-point columns, pyflakes UTF-8 byte offsets: four conventions, and positions.py is the only module that knows them. - compile()'s verdict merged with jedi's error-recovering coverage, so one mistake yields one diagnostic - the cause where it happened rather than the effect where parsing gave up. Ranges are never zero-width because Monaco draws nothing for one. - Framework names come from dir() on the live modules at warm-up, rendered as a generated preamble, and pyflakes reports undefined names over the top. Only star imports of those modelled modules are neutralised: blanking `from isis_powder import *` claims facility names were supplied, which measured as three false positives on a script that runs correctly. - Analysis runs in a forkserver process pool sized from the cgroup CPU quota, and every worker completes a probe analysis before readiness: threads measured 4.6x slower than serial on this CPU-bound work, processes 18x faster than threads, and an unwarmed worker pays jedi's import inside a user's keystroke. - script_lsp is at 100% statement and branch coverage without Mantid installed; the two framework-only functions are excluded with a stated reason. --- .dockerignore | 15 ++ .gitignore | 15 ++ container/mantid_python_script_lsp.D | 33 ++++ mypy.ini | 15 ++ pyproject.toml | 111 ++++++++++++ script_lsp/__init__.py | 1 + script_lsp/analyse.py | 76 ++++++++ script_lsp/app.py | 79 ++++++++ script_lsp/lint.py | 91 ++++++++++ script_lsp/models.py | 166 +++++++++++++++++ script_lsp/pool.py | 172 ++++++++++++++++++ script_lsp/positions.py | 147 +++++++++++++++ script_lsp/preamble.py | 155 ++++++++++++++++ script_lsp/syntax.py | 198 ++++++++++++++++++++ script_lsp/warmup.py | 133 ++++++++++++++ spike/bench.py | 218 ++++++++++++++++++++++ spike/make_corpus.py | 109 +++++++++++ test/conftest.py | 81 +++++++++ test/test_analyse.py | 116 ++++++++++++ test/test_app.py | 31 ++++ test/test_health.py | 83 +++++++++ test/test_lint.py | 181 +++++++++++++++++++ test/test_models.py | 168 +++++++++++++++++ test/test_pool.py | 260 +++++++++++++++++++++++++++ test/test_positions.py | 139 ++++++++++++++ test/test_preamble.py | 167 +++++++++++++++++ test/test_syntax.py | 217 ++++++++++++++++++++++ 27 files changed, 3177 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 container/mantid_python_script_lsp.D create mode 100644 mypy.ini create mode 100644 pyproject.toml create mode 100644 script_lsp/__init__.py create mode 100644 script_lsp/analyse.py create mode 100644 script_lsp/app.py create mode 100644 script_lsp/lint.py create mode 100644 script_lsp/models.py create mode 100644 script_lsp/pool.py create mode 100644 script_lsp/positions.py create mode 100644 script_lsp/preamble.py create mode 100644 script_lsp/syntax.py create mode 100644 script_lsp/warmup.py create mode 100644 spike/bench.py create mode 100644 spike/make_corpus.py create mode 100644 test/conftest.py create mode 100644 test/test_analyse.py create mode 100644 test/test_app.py create mode 100644 test/test_health.py create mode 100644 test/test_lint.py create mode 100644 test/test_models.py create mode 100644 test/test_pool.py create mode 100644 test/test_positions.py create mode 100644 test/test_preamble.py create mode 100644 test/test_syntax.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab96cda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +coverage.xml +test/ +spike/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8bc3cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +coverage.xml + +# Built by spike/make_corpus.py from scripts pulled off a live FIA API. That is facility +# data-reduction code, so it stays out of the repository even though the generator is checked in. +spike/corpus/ diff --git a/container/mantid_python_script_lsp.D b/container/mantid_python_script_lsp.D new file mode 100644 index 0000000..2d62d77 --- /dev/null +++ b/container/mantid_python_script_lsp.D @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# The service is only useful if it agrees with what actually runs the scripts, so the base is a +# Mantid runner image pinned to a runner tag. Moving it is a deliberate act - it changes which names a +# script may use and what warm-up costs - never a rebuild side effect. +ARG RUNNER_IMAGE=ghcr.io/fiaisis/mantid +ARG RUNNER_TAG=7.0.0 +FROM ${RUNNER_IMAGE}:${RUNNER_TAG} + +ARG RUNNER_IMAGE +ARG RUNNER_TAG + +LABEL org.opencontainers.image.title="mantid-python-script-lsp" \ + org.opencontainers.image.description="Mantid-aware Python analysis for the FIA script editors" \ + org.opencontainers.image.base.name="${RUNNER_IMAGE}:${RUNNER_TAG}" + +WORKDIR /mantid_python_script_lsp + +COPY . /mantid_python_script_lsp +RUN python -m pip install --no-cache-dir . + +ENV PYTHONUNBUFFERED=1 \ + MPLBACKEND=Agg + +# Port 80, like the other FIA service images (see ../FIA-API/container/fia_api.D), so the Service and +# its targetPort look the same whichever FIA backend they point at. Binding below 1024 means running +# as root, as they do; hardening belongs in the Deployment's pod securityContext (runAsNonRoot needs +# a different port), not in a per-image user that could not bind the port anyway. +EXPOSE 80 + +# The base image's ENTRYPOINT is the runner's (`python -c