optivibe-mcp 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- optivibe_mcp-0.2.0/PKG-INFO +43 -0
- optivibe_mcp-0.2.0/README.md +19 -0
- optivibe_mcp-0.2.0/pyproject.toml +70 -0
- optivibe_mcp-0.2.0/setup.cfg +4 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/__init__.py +2 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/__main__.py +17 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_classify.py +465 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_contract.py +256 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_model.py +84 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_render.py +221 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_runner.py +1622 -0
- optivibe_mcp-0.2.0/src/optivibe_doctor/_worker.py +1503 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/__init__.py +197 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/__main__.py +541 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/_bootstrap.py +136 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/_io.py +447 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/_stdio_log.py +80 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/artifact_naming.py +263 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/artifact_sink.py +455 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/__init__.py +18 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/__main__.py +1138 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/bench.py +1324 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/metrics.py +233 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/plot.py +574 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/catalog/registry.py +776 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/composite.py +291 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/engine_ledger.py +611 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/enums.py +113 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/errors.py +878 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/interaction_log.py +424 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/journal.py +175 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/lazy.py +393 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/mc_stats.py +126 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/process_reaper.py +258 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/server.py +1129 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/server_mcp.py +699 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/session.py +1496 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/__init__.py +14 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_analysis_common.py +332 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_aperture_cells.py +360 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_asphere_cells.py +785 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_beam_reach.py +659 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_by_number_refs.py +111 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_cb_cells.py +745 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_cb_solve_guard.py +616 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_clearance_common.py +342 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_collimation.py +763 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_config_common.py +900 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_error_note.py +77 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_field_curv_plot.py +208 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_finding.py +895 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_fold_gaps.py +721 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_glass_explain_read.py +361 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grin_cells.py +665 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grin_index_common.py +548 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grounding.py +203 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_image_gate.py +59 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_judgment.py +553 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_geometry.py +1517 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_native.py +710 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_rays.py +415 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_register.py +707 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_lens_common.py +352 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_lens_state.py +163 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mce_catalog.py +422 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mce_cells.py +502 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_measurement_common.py +637 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_ceiling.py +1040 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_cells.py +1012 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_io.py +154 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_math.py +422 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mtf_field_plot.py +394 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_optimize_common.py +5445 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_plot_publish.py +314 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_plot_style.py +200 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_ray_coverage.py +445 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_ray_fan_plot.py +172 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_run_budget.py +183 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_cells.py +1017 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_refs.py +1465 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_trace.py +875 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_spot_validity.py +243 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_structural_common.py +471 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tol_cells.py +267 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tolerance_catalog.py +752 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tolerance_common.py +2359 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_workspace_paths.py +252 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_zoom_array.py +402 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/_zoom_solve.py +667 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_field_curv.py +679 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_graphic.py +286 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_measure.py +3036 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_mtf.py +584 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_mtf_field.py +959 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_operand.py +603 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_ray_fan.py +689 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_raytrace.py +307 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_spot.py +721 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/aperture_ramp.py +703 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/aperture_surface.py +426 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/asphere_surface.py +881 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/beam_verify.py +190 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/cb_surface.py +1522 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/clearance.py +1914 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/collimation_verify.py +233 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/engine_release.py +305 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/finding_record.py +472 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/freeze_semi.py +560 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/get_system_info.py +55 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/glass_explain.py +386 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/glass_trial.py +1240 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/grin_surface.py +976 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/layout_render.py +5755 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_describe.py +409 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_glass.py +612 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_normalize.py +1181 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_spec.py +2635 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_surface.py +900 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_system.py +533 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_vignetting.py +887 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/load_design.py +627 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/mce_config.py +1086 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/mce_reset.py +402 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/merit_math.py +530 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_merit.py +3812 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_merit_io.py +1712 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_run.py +4191 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_variable.py +293 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/place_element.py +1291 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/ray_aiming.py +167 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/reflective.py +2366 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/save_snapshot.py +114 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/scale_lens.py +525 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/surface_solve.py +2262 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/tolerance_run.py +1214 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/variable_lifecycle.py +353 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/workspace.py +6548 -0
- optivibe_mcp-0.2.0/src/optivibe_harness/tools/zoom_compose.py +1702 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/PKG-INFO +43 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/SOURCES.txt +142 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/dependency_links.txt +1 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/entry_points.txt +3 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/requires.txt +11 -0
- optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/top_level.txt +2 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: optivibe-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: OptiVibe harness: ZOS-API automation, typed tools, and the single OptiVibe MCP
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/chuang44-tiff/optivibe-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/chuang44-tiff/optivibe-mcp/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/chuang44-tiff/optivibe-mcp/blob/main/CHANGELOG.md
|
|
9
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: pythonnet
|
|
15
|
+
Requires-Dist: mcp<2,>=1.28
|
|
16
|
+
Requires-Dist: psutil
|
|
17
|
+
Requires-Dist: anyio
|
|
18
|
+
Requires-Dist: matplotlib>=3.6
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: pillow
|
|
21
|
+
Requires-Dist: optivibe-reference==0.2.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# optivibe-mcp
|
|
26
|
+
|
|
27
|
+
<!-- mcp-name: io.github.chuang44-tiff/optivibe-mcp -->
|
|
28
|
+
|
|
29
|
+
ZOS-API automation, typed tools, and the MCP server for optivibe-mcp. Drives
|
|
30
|
+
Zemax OpticStudio through the typed ZOS-API (.NET via pythonnet / `clr`), and
|
|
31
|
+
composes the `optivibe-reference` grounding layer in-process so everything is
|
|
32
|
+
served through one MCP.
|
|
33
|
+
|
|
34
|
+
It exposes more than 80 typed tools — loading and editing designs, analyses,
|
|
35
|
+
merit-function construction, optimization, and tolerancing — each returning a
|
|
36
|
+
uniform result envelope rather than raising, so a caller checks a read-back value
|
|
37
|
+
instead of assuming a call succeeded. OpticStudio is opened lazily on the first
|
|
38
|
+
design-touching call and reaped automatically; one design at a time.
|
|
39
|
+
|
|
40
|
+
Install with `pip install optivibe-mcp` (Windows, Python 3.11+, and a Zemax OpticStudio
|
|
41
|
+
licensed for the ZOS-API). The [repository README](https://github.com/chuang44-tiff/optivibe-mcp#readme) covers
|
|
42
|
+
building the reference data and registering the server with Claude Code, and `AGENTS.md` in
|
|
43
|
+
the repository describes the usage model.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# optivibe-mcp
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.chuang44-tiff/optivibe-mcp -->
|
|
4
|
+
|
|
5
|
+
ZOS-API automation, typed tools, and the MCP server for optivibe-mcp. Drives
|
|
6
|
+
Zemax OpticStudio through the typed ZOS-API (.NET via pythonnet / `clr`), and
|
|
7
|
+
composes the `optivibe-reference` grounding layer in-process so everything is
|
|
8
|
+
served through one MCP.
|
|
9
|
+
|
|
10
|
+
It exposes more than 80 typed tools — loading and editing designs, analyses,
|
|
11
|
+
merit-function construction, optimization, and tolerancing — each returning a
|
|
12
|
+
uniform result envelope rather than raising, so a caller checks a read-back value
|
|
13
|
+
instead of assuming a call succeeded. OpticStudio is opened lazily on the first
|
|
14
|
+
design-touching call and reaped automatically; one design at a time.
|
|
15
|
+
|
|
16
|
+
Install with `pip install optivibe-mcp` (Windows, Python 3.11+, and a Zemax OpticStudio
|
|
17
|
+
licensed for the ZOS-API). The [repository README](https://github.com/chuang44-tiff/optivibe-mcp#readme) covers
|
|
18
|
+
building the reference data and registering the server with Claude Code, and `AGENTS.md` in
|
|
19
|
+
the repository describes the usage model.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pyproject.toml — packaging metadata for optivibe_harness (the ZOS-API harness MCP).
|
|
2
|
+
#
|
|
3
|
+
# Exists so `pip install -e .` makes `python -m optivibe_harness` resolve when Claude
|
|
4
|
+
# Code launches the single OptiVibe MCP: the reference package
|
|
5
|
+
# already had a pyproject; the harness did not. setuptools src-layout, mirroring
|
|
6
|
+
# ../optivibe-reference/pyproject.toml.
|
|
7
|
+
#
|
|
8
|
+
# Runtime `dependencies` are declared HONESTLY (they are already present in the
|
|
9
|
+
# `optivibe-harness` conda env, so pip sees them satisfied): pythonnet bridges the
|
|
10
|
+
# ZOS-API .NET surface, mcp serves the stdio MCP loop, psutil drives the PID-scoped
|
|
11
|
+
# process reaper, and matplotlib + numpy render the headless meridional layout figures
|
|
12
|
+
# (Agg backend). The reference layer `optivibe-reference` is DECLARED (one-way: the
|
|
13
|
+
# harness imports the reference, never the reverse), so one `pip install optivibe-mcp`
|
|
14
|
+
# brings both. The distribution is `optivibe-mcp`; the import packages are
|
|
15
|
+
# `optivibe_harness` and `optivibe_doctor`. In a clone, install the reference editable
|
|
16
|
+
# FIRST (`pip install -e ../optivibe-reference`), or pip fetches it from PyPI.
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=61"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[project]
|
|
22
|
+
name = "optivibe-mcp"
|
|
23
|
+
version = "0.2.0"
|
|
24
|
+
description = "OptiVibe harness: ZOS-API automation, typed tools, and the single OptiVibe MCP"
|
|
25
|
+
requires-python = ">=3.11"
|
|
26
|
+
readme = "README.md"
|
|
27
|
+
license = {text = "Apache-2.0"}
|
|
28
|
+
classifiers = [
|
|
29
|
+
"License :: OSI Approved :: Apache Software License",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Operating System :: Microsoft :: Windows",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pythonnet",
|
|
35
|
+
# BOUNDED pin, kept in lockstep with environment.yml (the same fact, declared
|
|
36
|
+
# twice). mcp 2.0.0 removed the low-level ``Server.list_tools()`` /
|
|
37
|
+
# ``.call_tool()`` decorator surface this package builds its stdio server on,
|
|
38
|
+
# so an unpinned ``mcp`` resolves to a release that cannot construct the
|
|
39
|
+
# server -- and it fails SILENTLY, because stdio is redirected to devnull
|
|
40
|
+
# before the failing import runs, so the child exits 1 with nothing on stdout
|
|
41
|
+
# or stderr. The floor is the version the server loop is written against.
|
|
42
|
+
"mcp>=1.28,<2",
|
|
43
|
+
"psutil",
|
|
44
|
+
# mcp is built on anyio, and ``__main__`` calls ``anyio.run`` directly.
|
|
45
|
+
"anyio",
|
|
46
|
+
# ``text.parse_math`` (matplotlib 3.6) draws the MTF-vs-field figure.
|
|
47
|
+
"matplotlib>=3.6",
|
|
48
|
+
"numpy",
|
|
49
|
+
# matplotlib already requires Pillow; ``_layout_register`` imports ``PIL`` directly
|
|
50
|
+
# to read the native layout raster, so the dependency is declared, not inherited.
|
|
51
|
+
"pillow",
|
|
52
|
+
# The reference layer, composed in-process by the single OptiVibe MCP, pinned to
|
|
53
|
+
# the same release.
|
|
54
|
+
"optivibe-reference==0.2.0",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[project.optional-dependencies]
|
|
58
|
+
dev = ["pytest"]
|
|
59
|
+
|
|
60
|
+
[project.scripts]
|
|
61
|
+
optivibe-mcp = "optivibe_harness.__main__:main"
|
|
62
|
+
optivibe-doctor = "optivibe_doctor.__main__:main"
|
|
63
|
+
|
|
64
|
+
[project.urls]
|
|
65
|
+
Homepage = "https://github.com/chuang44-tiff/optivibe-mcp"
|
|
66
|
+
Issues = "https://github.com/chuang44-tiff/optivibe-mcp/issues"
|
|
67
|
+
Changelog = "https://github.com/chuang44-tiff/optivibe-mcp/blob/main/CHANGELOG.md"
|
|
68
|
+
|
|
69
|
+
[tool.setuptools.packages.find]
|
|
70
|
+
where = ["src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""``python -m optivibe_doctor`` — argv in, exit code out.
|
|
2
|
+
|
|
3
|
+
The ``if __name__`` guard is not a convention here, it is a correctness requirement. The
|
|
4
|
+
existing publication suite walks every module of every shipped package with
|
|
5
|
+
``pkgutil.walk_packages`` and imports every name it yields — and ``walk_packages`` yields
|
|
6
|
+
``__main__``. An unguarded ``raise SystemExit(main())`` would therefore execute the entire
|
|
7
|
+
doctor, spawn its children and open a session, *inside a test collection run*.
|
|
8
|
+
|
|
9
|
+
Nothing else belongs in this file. The parent does no filesystem I/O and imports nothing
|
|
10
|
+
outside the standard library, so there is no work here that could be done before ``main``.
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from ._runner import main
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
sys.exit(main())
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""The pure classifiers, plus the run-level state machine.
|
|
2
|
+
|
|
3
|
+
**No I/O.** Every function here is total over its declared domain, deterministic, and
|
|
4
|
+
free of side effects, which is what makes the classifier tables exhaustively enumerable
|
|
5
|
+
rather than merely sampled.
|
|
6
|
+
|
|
7
|
+
Rule 2 of the execution model lives here: a worker emits facts, and *this* module turns
|
|
8
|
+
facts into a status. Nothing downstream of a worker may hand-write a verdict.
|
|
9
|
+
|
|
10
|
+
Stdlib only.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ._contract import (
|
|
15
|
+
BASE_CHECKS, COVERAGE, CRITICAL_DEPS, MCP_MAX_EXCLUSIVE, MCP_MIN, REMEDY)
|
|
16
|
+
from ._model import NOT_REQUESTED, State, Status, Summary
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Exit codes. 0/1/2/3 come out of the state machine; 64 and 130 are the two the
|
|
20
|
+
# state machine never produces and the CLI raises directly.
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
EXIT_BY_STATE = {
|
|
23
|
+
State.READY: 0,
|
|
24
|
+
State.DEGRADED: 1,
|
|
25
|
+
State.BROKEN: 2,
|
|
26
|
+
State.INCOMPLETE: 3,
|
|
27
|
+
}
|
|
28
|
+
EXIT_USAGE = 64
|
|
29
|
+
EXIT_INTERRUPT = 130
|
|
30
|
+
|
|
31
|
+
#: The declared domains, published so a caller can see what "total" means here. A test
|
|
32
|
+
#: MUST enumerate its own copy of these: a universe read from the thing it grades moves
|
|
33
|
+
#: with the mutation it is supposed to catch.
|
|
34
|
+
DOMAIN_PRESENT = (True, False)
|
|
35
|
+
DOMAIN_TRISTATE = (True, False, None)
|
|
36
|
+
DOMAIN_DECLARED = (True, False)
|
|
37
|
+
DOMAIN_META_VERSION = ("1.2.3", None)
|
|
38
|
+
DOMAIN_ENRICHMENT_TIER = ("enriched", "synonyms_only", "partial", None)
|
|
39
|
+
DOMAIN_COVERAGE_KIND = tuple(COVERAGE)
|
|
40
|
+
DOMAIN_PIN_MODE = ("floor", "exact")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# plane
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
def classify_plane(present, opens, wired, identity_ok, stale_build=None):
|
|
47
|
+
"""Grade one reference data plane over ``present x opens x wired x identity_ok``.
|
|
48
|
+
|
|
49
|
+
``identity_ok`` is the plane's own *provenance* fact — the pinned identity token read
|
|
50
|
+
back through **that plane's** connection, or (for the file-backed corpus) the wired
|
|
51
|
+
connection's own database path resolving to the plane's own file. A non-null
|
|
52
|
+
connection property proves only that *something* was assigned; provenance proves it
|
|
53
|
+
was assigned the right thing, which is the whole of the ``misrouted`` verdict.
|
|
54
|
+
|
|
55
|
+
Returns ``(Status, reason)``. ``present`` is boolean by construction (it is an
|
|
56
|
+
``isfile`` result read in a worker); the tri-states carry ``None`` for "the worker
|
|
57
|
+
could not measure this", which is doctor's own failure and therefore UNKNOWN.
|
|
58
|
+
|
|
59
|
+
``stale_build`` (True only when the worker proved the opener refused a corpus whose ONLY
|
|
60
|
+
defect is the builder version) turns an ``opens is False`` into ``stale_build`` instead
|
|
61
|
+
of ``corrupt`` -- same verdict, a different cause and a different fix.
|
|
62
|
+
"""
|
|
63
|
+
if present is not True:
|
|
64
|
+
return (Status.WARN, "absent_expected")
|
|
65
|
+
if opens is False:
|
|
66
|
+
if stale_build is True:
|
|
67
|
+
return (Status.FAIL, "stale_build")
|
|
68
|
+
return (Status.FAIL, "corrupt")
|
|
69
|
+
if opens is not True:
|
|
70
|
+
return (Status.UNKNOWN, "opener_unreadable")
|
|
71
|
+
if wired is False:
|
|
72
|
+
return (Status.FAIL, "unwired")
|
|
73
|
+
if wired is not True:
|
|
74
|
+
return (Status.UNKNOWN, "wiring_unreadable")
|
|
75
|
+
if identity_ok is False:
|
|
76
|
+
return (Status.FAIL, "misrouted")
|
|
77
|
+
if identity_ok is not True:
|
|
78
|
+
return (Status.UNKNOWN, "identity_unreadable")
|
|
79
|
+
return (Status.PASS, "")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# dependency
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
def classify_dep(name, declared, meta_version, import_ok, import_error):
|
|
86
|
+
"""Grade one declared dependency. ``import_error`` is recorded, never graded.
|
|
87
|
+
|
|
88
|
+
The load-bearing row is ``declared and meta_version is not None and not import_ok``:
|
|
89
|
+
metadata present and the module unimportable is a *broken install*, not a healthy one,
|
|
90
|
+
and an implementation that grades on ``meta_version is not None`` reports it healthy.
|
|
91
|
+
"""
|
|
92
|
+
del import_error # carried into the finding's facts; it is evidence, not a verdict.
|
|
93
|
+
critical = name in CRITICAL_DEPS
|
|
94
|
+
bad = Status.FAIL if critical else Status.WARN
|
|
95
|
+
|
|
96
|
+
if declared:
|
|
97
|
+
if import_ok is True:
|
|
98
|
+
return (Status.PASS, "")
|
|
99
|
+
if import_ok is None:
|
|
100
|
+
return (Status.UNKNOWN, "not_measured")
|
|
101
|
+
# import_ok is False
|
|
102
|
+
if meta_version is not None:
|
|
103
|
+
return (bad, "declared_but_unimportable")
|
|
104
|
+
return (bad, "absent")
|
|
105
|
+
|
|
106
|
+
if import_ok is True:
|
|
107
|
+
return (Status.WARN, "undeclared_but_present")
|
|
108
|
+
return (Status.FAIL if critical else Status.UNKNOWN, "undeclared_and_absent")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# version
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
def _origin_is_independent(origin):
|
|
115
|
+
"""True when ``origin`` looks like a real package initialiser and not dist metadata.
|
|
116
|
+
|
|
117
|
+
A source version read out of a ``.dist-info`` tree is the *same* evidence as the
|
|
118
|
+
metadata version, so comparing them proves nothing — that is what
|
|
119
|
+
``sources_not_independent`` names.
|
|
120
|
+
"""
|
|
121
|
+
parts = str(origin).replace("\\", "/").split("/")
|
|
122
|
+
if any(part.endswith(".dist-info") for part in parts):
|
|
123
|
+
return False
|
|
124
|
+
return parts[-1] == "__init__.py"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def classify_version(meta, source, manifest, origin):
|
|
128
|
+
"""Grade the agreement of installed metadata, source literal and packaging manifest.
|
|
129
|
+
|
|
130
|
+
First match wins. There is deliberately **no FAIL state**: a version disagreement
|
|
131
|
+
misleads a reader, it does not break an install. ``manifest is None`` (a wheel
|
|
132
|
+
install with no checkout) is never a mismatch.
|
|
133
|
+
"""
|
|
134
|
+
if origin is not None and not _origin_is_independent(origin):
|
|
135
|
+
return (Status.UNKNOWN, "sources_not_independent")
|
|
136
|
+
if meta is None and source is None:
|
|
137
|
+
return (Status.UNKNOWN, "no_version_readable")
|
|
138
|
+
if meta is None:
|
|
139
|
+
return (Status.WARN, "metadata_missing")
|
|
140
|
+
if source is None:
|
|
141
|
+
# The verdict for an unreadable source lives on the import check; here we only
|
|
142
|
+
# report that the metadata value stands alone.
|
|
143
|
+
return (Status.WARN, "source_unreadable")
|
|
144
|
+
if origin is None:
|
|
145
|
+
# A source version with no provenance is not evidence.
|
|
146
|
+
return (Status.UNKNOWN, "origin_unreadable")
|
|
147
|
+
if meta != source:
|
|
148
|
+
return (Status.WARN, "version_skew")
|
|
149
|
+
if manifest is not None and manifest != source:
|
|
150
|
+
# The source is what runs; the manifest is hand-editable.
|
|
151
|
+
return (Status.WARN, "manifest_skew")
|
|
152
|
+
return (Status.PASS, "")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# mcp supported range
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
_VERSION_HEAD = re.compile(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_version(version):
|
|
162
|
+
"""Return a 3-tuple of ints for the leading release segment, or None."""
|
|
163
|
+
if not isinstance(version, str):
|
|
164
|
+
return None
|
|
165
|
+
match = _VERSION_HEAD.match(version)
|
|
166
|
+
if match is None:
|
|
167
|
+
return None
|
|
168
|
+
return tuple(int(part) if part else 0 for part in match.groups())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def classify_mcp_range(version):
|
|
172
|
+
"""Grade the installed ``mcp`` version against the range pinned in ``_contract``.
|
|
173
|
+
|
|
174
|
+
The signature takes **no metadata argument** on purpose: the supported range is a
|
|
175
|
+
release contract input, never a value read back from the wheel whose behaviour it is
|
|
176
|
+
supposed to bound. An out-of-range ``mcp`` that happens to construct a server is
|
|
177
|
+
still reported, because this check gates independently of ``mcp.construct``.
|
|
178
|
+
"""
|
|
179
|
+
if version is None:
|
|
180
|
+
return (Status.UNKNOWN, "version_unreadable")
|
|
181
|
+
parsed = _parse_version(version)
|
|
182
|
+
if parsed is None:
|
|
183
|
+
return (Status.UNKNOWN, "version_unparseable")
|
|
184
|
+
if MCP_MIN <= parsed < MCP_MAX_EXCLUSIVE:
|
|
185
|
+
return (Status.PASS, "")
|
|
186
|
+
return (Status.FAIL, "unsupported_range")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# tool
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
def classify_tool(tool, inner_ok, error_family, plane_status, plane_reason):
|
|
193
|
+
"""Grade one canned reference-tool call.
|
|
194
|
+
|
|
195
|
+
Grades the **INNER** ``result["ok"]`` only. The outer dispatch envelope is a recorded
|
|
196
|
+
fact and is never graded: a refusal travels as ``outer ok=True`` carrying an inner
|
|
197
|
+
``ok=False``, so grading the outer envelope reports every refusal as a success.
|
|
198
|
+
|
|
199
|
+
``tool`` and ``plane_reason`` are both in the signature because without them the two
|
|
200
|
+
cases that matter — a door refusing because its data is *absent* (normal) and a door
|
|
201
|
+
refusing because its data is present but *unwired* (a defect) — are indistinguishable.
|
|
202
|
+
"""
|
|
203
|
+
del tool # identity is carried into the finding; the verdict comes from the plane.
|
|
204
|
+
if inner_ok is True:
|
|
205
|
+
return (Status.PASS, "")
|
|
206
|
+
if inner_ok is None:
|
|
207
|
+
return (Status.UNKNOWN, "no_envelope")
|
|
208
|
+
family = error_family or ""
|
|
209
|
+
if (plane_status is Status.WARN and plane_reason == "absent_expected"
|
|
210
|
+
and family.endswith("_unavailable")):
|
|
211
|
+
return (Status.WARN, "plane_absent")
|
|
212
|
+
if plane_reason == "unwired":
|
|
213
|
+
return (Status.FAIL, "present_but_unwired")
|
|
214
|
+
if plane_reason == "misrouted":
|
|
215
|
+
return (Status.FAIL, "plane_misrouted")
|
|
216
|
+
if plane_status is Status.PASS and inner_ok is False:
|
|
217
|
+
return (Status.FAIL, "refused_while_plane_ok")
|
|
218
|
+
return (Status.FAIL, "malformed_envelope")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# enrichment
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
def classify_enrichment(tier):
|
|
225
|
+
"""Grade a reference build tier.
|
|
226
|
+
|
|
227
|
+
``synonyms_only`` is a **documented supported configuration** (an install without the
|
|
228
|
+
``[manual]`` extra) and must not gate. The tier is read from the build report; it is
|
|
229
|
+
never derived from a pending-description tally, which counts work-in-progress rather
|
|
230
|
+
than capability.
|
|
231
|
+
"""
|
|
232
|
+
if tier == "enriched":
|
|
233
|
+
return (Status.PASS, "")
|
|
234
|
+
if tier == "synonyms_only":
|
|
235
|
+
return (Status.PASS, "synonyms_only")
|
|
236
|
+
if tier == "partial":
|
|
237
|
+
return (Status.WARN, "partial")
|
|
238
|
+
return (Status.UNKNOWN, "tier_unreadable")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# boot
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
def classify_boot(returncode):
|
|
245
|
+
"""Grade the ``--boot`` grandchild's exit status.
|
|
246
|
+
|
|
247
|
+
Takes **no byte counts**: a healthy server that reaches EOF on stdin and a
|
|
248
|
+
catastrophic silent death both write 0 bytes to stdout and 0 to stderr, so byte counts
|
|
249
|
+
cannot separate them and an implementation that grades on them grades on noise.
|
|
250
|
+
"""
|
|
251
|
+
if returncode is None:
|
|
252
|
+
return (Status.UNKNOWN, "timeout")
|
|
253
|
+
if returncode == 0:
|
|
254
|
+
return (Status.PASS, "")
|
|
255
|
+
return (Status.FAIL, "nonzero_exit")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# env.pythonnet_runtime
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
def classify_pythonnet_runtime(value):
|
|
262
|
+
"""Grade the operator's pre-existing ``PYTHONNET_RUNTIME``.
|
|
263
|
+
|
|
264
|
+
Unset or exactly ``netfx`` passes. Anything else WARNs: the bootstrap only
|
|
265
|
+
``setdefault()``s ``netfx``, so a pre-set value wins, and the ZOS-API assemblies target
|
|
266
|
+
the .NET Framework. Without this arm the check would be a step that cannot fail — and
|
|
267
|
+
a step that cannot fail cannot gate.
|
|
268
|
+
"""
|
|
269
|
+
if value is None or value == "netfx":
|
|
270
|
+
return (Status.PASS, "")
|
|
271
|
+
return (Status.WARN, "runtime_override")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# coverage — ONE classifier, three call sites
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
def _difference(left, right):
|
|
278
|
+
"""The single set-difference implementation behind the whole coverage rule.
|
|
279
|
+
|
|
280
|
+
Three inline copies of ``pinned - universe`` would be three rules that can drift, and
|
|
281
|
+
a rule that can drift is not a rule. Everything that needs this relation — the verdict
|
|
282
|
+
and the message that names the members — comes through here.
|
|
283
|
+
"""
|
|
284
|
+
return tuple(sorted(set(left) - set(right)))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def classify_coverage(kind, pinned, universe, probed, blocked_by):
|
|
288
|
+
"""Grade one ``*.coverage`` check. First match wins — **the order IS the contract**.
|
|
289
|
+
|
|
290
|
+
``kind`` selects the reason tokens and the pin mode from ``COVERAGE``. The mode is
|
|
291
|
+
load-bearing rather than cosmetic: ``CRITICAL_DEPS`` is a **floor** (``matplotlib`` and
|
|
292
|
+
``numpy`` are legitimately declared and deliberately unpinned), while ``PLANES`` and
|
|
293
|
+
``REFERENCE_CASES`` are **exact identity sets**. Under a uniform symmetric reading
|
|
294
|
+
``dependency.coverage`` would WARN on every healthy install, make exit 1 permanent, and
|
|
295
|
+
train the reader to ignore the tool.
|
|
296
|
+
|
|
297
|
+
The two failures are deliberately **not** the same severity:
|
|
298
|
+
|
|
299
|
+
- a pinned member absent from the derived universe is ``FAIL`` — *the thing doctor
|
|
300
|
+
promised to check has vanished*, and a shrunken universe passes silently, which is
|
|
301
|
+
the "a check that stopped running reports green" shape;
|
|
302
|
+
- a universe member the pin does not claim is ``WARN`` — *something new appeared
|
|
303
|
+
ungraded*. That is a **doctor** coverage gap, not a target defect, and grading it
|
|
304
|
+
FAIL would make doctor cry wolf every time the product grows.
|
|
305
|
+
|
|
306
|
+
Rows 3 and 4 cannot arise from a literal pin. They exist because the domain must be
|
|
307
|
+
**total**: no input tuple may reach a state the contract does not describe, which is
|
|
308
|
+
the whole defect this closes.
|
|
309
|
+
"""
|
|
310
|
+
if blocked_by:
|
|
311
|
+
# Doctor measured correctly and declined to probe because a definitive
|
|
312
|
+
# prerequisite already reported FAIL/WARN. That is a statement about the target's
|
|
313
|
+
# configuration, so it is SKIP — and a SKIP never gates.
|
|
314
|
+
return (Status.SKIP, blocked_by)
|
|
315
|
+
if universe is None:
|
|
316
|
+
return (Status.UNKNOWN, "universe_unreadable")
|
|
317
|
+
if pinned is None:
|
|
318
|
+
return (Status.UNKNOWN, "pin_unreadable")
|
|
319
|
+
if probed is None:
|
|
320
|
+
return (Status.UNKNOWN, "probe_set_unreadable")
|
|
321
|
+
if _difference(universe, probed):
|
|
322
|
+
return (Status.UNKNOWN, "probe_incomplete")
|
|
323
|
+
rules = COVERAGE.get(kind)
|
|
324
|
+
if rules is None:
|
|
325
|
+
return (Status.UNKNOWN, "coverage_kind_unknown")
|
|
326
|
+
if _difference(pinned, universe):
|
|
327
|
+
return (Status.FAIL, rules["missing"])
|
|
328
|
+
if rules["pin_mode"] == "exact" and _difference(universe, pinned):
|
|
329
|
+
return (Status.WARN, rules["unpinned"])
|
|
330
|
+
return (Status.PASS, "")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def coverage_members(kind, pinned, universe, probed, reason):
|
|
334
|
+
"""The members a coverage ``reason`` names, through the same one difference.
|
|
335
|
+
|
|
336
|
+
A second place recomputing ``pinned - universe`` for the message could disagree with
|
|
337
|
+
the verdict, and a message that disagrees with its own verdict is worse than no
|
|
338
|
+
message. Returns ``()`` for a reason that names nothing.
|
|
339
|
+
"""
|
|
340
|
+
rules = COVERAGE.get(kind) or {}
|
|
341
|
+
if reason == "probe_incomplete" and universe is not None and probed is not None:
|
|
342
|
+
return _difference(universe, probed)
|
|
343
|
+
if universe is None or pinned is None:
|
|
344
|
+
return ()
|
|
345
|
+
if reason == rules.get("missing"):
|
|
346
|
+
return _difference(pinned, universe)
|
|
347
|
+
if reason and reason == rules.get("unpinned"):
|
|
348
|
+
return _difference(universe, pinned)
|
|
349
|
+
return ()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# remedies
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
_VENDOR_DATA = object() # resolved against checkout_present at lookup time
|
|
356
|
+
|
|
357
|
+
_REMEDY_EXACT = {
|
|
358
|
+
("dependency.mcp_range", "unsupported_range"): "mcp_range",
|
|
359
|
+
("env.pythonnet_runtime", "runtime_override"): "runtime_override",
|
|
360
|
+
("zemax.nethelper", "absent"): "nethelper",
|
|
361
|
+
}
|
|
362
|
+
_REMEDY_FAMILY = {
|
|
363
|
+
("version", "metadata_missing"): "reinstall",
|
|
364
|
+
("version", "version_skew"): "reinstall",
|
|
365
|
+
("plane", "absent_expected"): _VENDOR_DATA,
|
|
366
|
+
("plane", "unwired"): "unwired",
|
|
367
|
+
("plane", "stale_build"): "stale_build",
|
|
368
|
+
("tool", "plane_absent"): _VENDOR_DATA,
|
|
369
|
+
("tool", "present_but_unwired"): "unwired",
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def remedy_for(check, status, reason, checkout_present=True):
|
|
374
|
+
"""Return the pinned remedy string for a finding, or ``""``.
|
|
375
|
+
|
|
376
|
+
``checkout_present`` is the *only* thing that switches the missing-vendor-data remedy:
|
|
377
|
+
the build scripts live outside ``src/`` and ship in neither wheel, so on a
|
|
378
|
+
checkout-less install the repository-relative commands do not exist on disk. A remedy
|
|
379
|
+
the reader cannot run is a diagnostic failure, not a formatting detail.
|
|
380
|
+
|
|
381
|
+
A PASS or a SKIP never carries a remedy.
|
|
382
|
+
"""
|
|
383
|
+
if status in (Status.PASS, Status.SKIP):
|
|
384
|
+
return ""
|
|
385
|
+
if check == "reference.import":
|
|
386
|
+
return REMEDY["reference_missing"]
|
|
387
|
+
key = _REMEDY_EXACT.get((check, reason))
|
|
388
|
+
if key is None:
|
|
389
|
+
family = str(check).split(".")[0]
|
|
390
|
+
key = _REMEDY_FAMILY.get((family, reason))
|
|
391
|
+
if key is None:
|
|
392
|
+
return ""
|
|
393
|
+
if key is _VENDOR_DATA:
|
|
394
|
+
return REMEDY["vendor_data"] if checkout_present else REMEDY["vendor_data_no_checkout"]
|
|
395
|
+
return REMEDY[key]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# the state machine
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
def _skip_is_explained(finding, by_check):
|
|
402
|
+
"""True when a SKIP names a blocker doctor is willing to accept.
|
|
403
|
+
|
|
404
|
+
The anti-skip-creep guard: a SKIP naming nothing, naming an id that is not in this
|
|
405
|
+
run, or naming a finding that PASSED, is itself a defect. Otherwise "skip" quietly
|
|
406
|
+
becomes a place to put anything inconvenient, and the run still reports green.
|
|
407
|
+
"""
|
|
408
|
+
blocked_by = finding.blocked_by or ""
|
|
409
|
+
if not blocked_by:
|
|
410
|
+
return False
|
|
411
|
+
if blocked_by == NOT_REQUESTED:
|
|
412
|
+
return True
|
|
413
|
+
blocker = by_check.get(blocked_by)
|
|
414
|
+
return blocker is not None and blocker.status in (Status.FAIL, Status.WARN)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def summarize(findings, expected=BASE_CHECKS):
|
|
418
|
+
"""Reduce a run's findings to a ``Summary``.
|
|
419
|
+
|
|
420
|
+
``expected`` is the spine. Every spine id must appear **exactly once**: a missing id
|
|
421
|
+
and a duplicated id are both "doctor did not measure what it claims to have measured",
|
|
422
|
+
which is INCOMPLETE and never a health claim. Extras (derived dependency and tool
|
|
423
|
+
names) never affect spine completeness.
|
|
424
|
+
"""
|
|
425
|
+
findings = list(findings)
|
|
426
|
+
expected = tuple(expected)
|
|
427
|
+
|
|
428
|
+
seen = {}
|
|
429
|
+
for finding in findings:
|
|
430
|
+
seen[finding.check] = seen.get(finding.check, 0) + 1
|
|
431
|
+
by_check = {}
|
|
432
|
+
for finding in findings:
|
|
433
|
+
by_check.setdefault(finding.check, finding)
|
|
434
|
+
|
|
435
|
+
spine_ok = all(seen.get(check, 0) == 1 for check in expected)
|
|
436
|
+
skips_ok = all(_skip_is_explained(finding, by_check)
|
|
437
|
+
for finding in findings if finding.status is Status.SKIP)
|
|
438
|
+
empty = not findings
|
|
439
|
+
|
|
440
|
+
counts = {status.value: 0 for status in Status}
|
|
441
|
+
for finding in findings:
|
|
442
|
+
counts[finding.status.value] += 1
|
|
443
|
+
|
|
444
|
+
if empty or not spine_ok or not skips_ok or counts[Status.UNKNOWN.value]:
|
|
445
|
+
state = State.INCOMPLETE
|
|
446
|
+
elif counts[Status.FAIL.value]:
|
|
447
|
+
state = State.BROKEN
|
|
448
|
+
elif counts[Status.WARN.value]:
|
|
449
|
+
state = State.DEGRADED
|
|
450
|
+
else:
|
|
451
|
+
state = State.READY
|
|
452
|
+
|
|
453
|
+
return Summary(
|
|
454
|
+
state=state,
|
|
455
|
+
complete=(not empty) and spine_ok and skips_ok,
|
|
456
|
+
expected=expected,
|
|
457
|
+
received=tuple(finding.check for finding in findings),
|
|
458
|
+
counts=counts,
|
|
459
|
+
exit_code=EXIT_BY_STATE[state],
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def exit_code(summary):
|
|
464
|
+
"""Return the process exit code for a summary. Never a health claim on its own."""
|
|
465
|
+
return EXIT_BY_STATE[summary.state]
|