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.
Files changed (144) hide show
  1. optivibe_mcp-0.2.0/PKG-INFO +43 -0
  2. optivibe_mcp-0.2.0/README.md +19 -0
  3. optivibe_mcp-0.2.0/pyproject.toml +70 -0
  4. optivibe_mcp-0.2.0/setup.cfg +4 -0
  5. optivibe_mcp-0.2.0/src/optivibe_doctor/__init__.py +2 -0
  6. optivibe_mcp-0.2.0/src/optivibe_doctor/__main__.py +17 -0
  7. optivibe_mcp-0.2.0/src/optivibe_doctor/_classify.py +465 -0
  8. optivibe_mcp-0.2.0/src/optivibe_doctor/_contract.py +256 -0
  9. optivibe_mcp-0.2.0/src/optivibe_doctor/_model.py +84 -0
  10. optivibe_mcp-0.2.0/src/optivibe_doctor/_render.py +221 -0
  11. optivibe_mcp-0.2.0/src/optivibe_doctor/_runner.py +1622 -0
  12. optivibe_mcp-0.2.0/src/optivibe_doctor/_worker.py +1503 -0
  13. optivibe_mcp-0.2.0/src/optivibe_harness/__init__.py +197 -0
  14. optivibe_mcp-0.2.0/src/optivibe_harness/__main__.py +541 -0
  15. optivibe_mcp-0.2.0/src/optivibe_harness/_bootstrap.py +136 -0
  16. optivibe_mcp-0.2.0/src/optivibe_harness/_io.py +447 -0
  17. optivibe_mcp-0.2.0/src/optivibe_harness/_stdio_log.py +80 -0
  18. optivibe_mcp-0.2.0/src/optivibe_harness/artifact_naming.py +263 -0
  19. optivibe_mcp-0.2.0/src/optivibe_harness/artifact_sink.py +455 -0
  20. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/__init__.py +18 -0
  21. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/__main__.py +1138 -0
  22. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/bench.py +1324 -0
  23. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/metrics.py +233 -0
  24. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/plot.py +574 -0
  25. optivibe_mcp-0.2.0/src/optivibe_harness/catalog/registry.py +776 -0
  26. optivibe_mcp-0.2.0/src/optivibe_harness/composite.py +291 -0
  27. optivibe_mcp-0.2.0/src/optivibe_harness/engine_ledger.py +611 -0
  28. optivibe_mcp-0.2.0/src/optivibe_harness/enums.py +113 -0
  29. optivibe_mcp-0.2.0/src/optivibe_harness/errors.py +878 -0
  30. optivibe_mcp-0.2.0/src/optivibe_harness/interaction_log.py +424 -0
  31. optivibe_mcp-0.2.0/src/optivibe_harness/journal.py +175 -0
  32. optivibe_mcp-0.2.0/src/optivibe_harness/lazy.py +393 -0
  33. optivibe_mcp-0.2.0/src/optivibe_harness/mc_stats.py +126 -0
  34. optivibe_mcp-0.2.0/src/optivibe_harness/process_reaper.py +258 -0
  35. optivibe_mcp-0.2.0/src/optivibe_harness/server.py +1129 -0
  36. optivibe_mcp-0.2.0/src/optivibe_harness/server_mcp.py +699 -0
  37. optivibe_mcp-0.2.0/src/optivibe_harness/session.py +1496 -0
  38. optivibe_mcp-0.2.0/src/optivibe_harness/tools/__init__.py +14 -0
  39. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_analysis_common.py +332 -0
  40. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_aperture_cells.py +360 -0
  41. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_asphere_cells.py +785 -0
  42. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_beam_reach.py +659 -0
  43. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_by_number_refs.py +111 -0
  44. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_cb_cells.py +745 -0
  45. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_cb_solve_guard.py +616 -0
  46. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_clearance_common.py +342 -0
  47. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_collimation.py +763 -0
  48. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_config_common.py +900 -0
  49. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_error_note.py +77 -0
  50. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_field_curv_plot.py +208 -0
  51. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_finding.py +895 -0
  52. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_fold_gaps.py +721 -0
  53. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_glass_explain_read.py +361 -0
  54. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grin_cells.py +665 -0
  55. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grin_index_common.py +548 -0
  56. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_grounding.py +203 -0
  57. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_image_gate.py +59 -0
  58. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_judgment.py +553 -0
  59. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_geometry.py +1517 -0
  60. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_native.py +710 -0
  61. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_rays.py +415 -0
  62. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_layout_register.py +707 -0
  63. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_lens_common.py +352 -0
  64. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_lens_state.py +163 -0
  65. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mce_catalog.py +422 -0
  66. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mce_cells.py +502 -0
  67. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_measurement_common.py +637 -0
  68. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_ceiling.py +1040 -0
  69. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_cells.py +1012 -0
  70. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_io.py +154 -0
  71. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_merit_math.py +422 -0
  72. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_mtf_field_plot.py +394 -0
  73. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_optimize_common.py +5445 -0
  74. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_plot_publish.py +314 -0
  75. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_plot_style.py +200 -0
  76. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_ray_coverage.py +445 -0
  77. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_ray_fan_plot.py +172 -0
  78. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_run_budget.py +183 -0
  79. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_cells.py +1017 -0
  80. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_refs.py +1465 -0
  81. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_solve_trace.py +875 -0
  82. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_spot_validity.py +243 -0
  83. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_structural_common.py +471 -0
  84. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tol_cells.py +267 -0
  85. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tolerance_catalog.py +752 -0
  86. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_tolerance_common.py +2359 -0
  87. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_workspace_paths.py +252 -0
  88. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_zoom_array.py +402 -0
  89. optivibe_mcp-0.2.0/src/optivibe_harness/tools/_zoom_solve.py +667 -0
  90. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_field_curv.py +679 -0
  91. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_graphic.py +286 -0
  92. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_measure.py +3036 -0
  93. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_mtf.py +584 -0
  94. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_mtf_field.py +959 -0
  95. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_operand.py +603 -0
  96. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_ray_fan.py +689 -0
  97. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_raytrace.py +307 -0
  98. optivibe_mcp-0.2.0/src/optivibe_harness/tools/analysis_spot.py +721 -0
  99. optivibe_mcp-0.2.0/src/optivibe_harness/tools/aperture_ramp.py +703 -0
  100. optivibe_mcp-0.2.0/src/optivibe_harness/tools/aperture_surface.py +426 -0
  101. optivibe_mcp-0.2.0/src/optivibe_harness/tools/asphere_surface.py +881 -0
  102. optivibe_mcp-0.2.0/src/optivibe_harness/tools/beam_verify.py +190 -0
  103. optivibe_mcp-0.2.0/src/optivibe_harness/tools/cb_surface.py +1522 -0
  104. optivibe_mcp-0.2.0/src/optivibe_harness/tools/clearance.py +1914 -0
  105. optivibe_mcp-0.2.0/src/optivibe_harness/tools/collimation_verify.py +233 -0
  106. optivibe_mcp-0.2.0/src/optivibe_harness/tools/engine_release.py +305 -0
  107. optivibe_mcp-0.2.0/src/optivibe_harness/tools/finding_record.py +472 -0
  108. optivibe_mcp-0.2.0/src/optivibe_harness/tools/freeze_semi.py +560 -0
  109. optivibe_mcp-0.2.0/src/optivibe_harness/tools/get_system_info.py +55 -0
  110. optivibe_mcp-0.2.0/src/optivibe_harness/tools/glass_explain.py +386 -0
  111. optivibe_mcp-0.2.0/src/optivibe_harness/tools/glass_trial.py +1240 -0
  112. optivibe_mcp-0.2.0/src/optivibe_harness/tools/grin_surface.py +976 -0
  113. optivibe_mcp-0.2.0/src/optivibe_harness/tools/layout_render.py +5755 -0
  114. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_describe.py +409 -0
  115. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_glass.py +612 -0
  116. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_normalize.py +1181 -0
  117. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_spec.py +2635 -0
  118. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_surface.py +900 -0
  119. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_system.py +533 -0
  120. optivibe_mcp-0.2.0/src/optivibe_harness/tools/lens_vignetting.py +887 -0
  121. optivibe_mcp-0.2.0/src/optivibe_harness/tools/load_design.py +627 -0
  122. optivibe_mcp-0.2.0/src/optivibe_harness/tools/mce_config.py +1086 -0
  123. optivibe_mcp-0.2.0/src/optivibe_harness/tools/mce_reset.py +402 -0
  124. optivibe_mcp-0.2.0/src/optivibe_harness/tools/merit_math.py +530 -0
  125. optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_merit.py +3812 -0
  126. optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_merit_io.py +1712 -0
  127. optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_run.py +4191 -0
  128. optivibe_mcp-0.2.0/src/optivibe_harness/tools/optimize_variable.py +293 -0
  129. optivibe_mcp-0.2.0/src/optivibe_harness/tools/place_element.py +1291 -0
  130. optivibe_mcp-0.2.0/src/optivibe_harness/tools/ray_aiming.py +167 -0
  131. optivibe_mcp-0.2.0/src/optivibe_harness/tools/reflective.py +2366 -0
  132. optivibe_mcp-0.2.0/src/optivibe_harness/tools/save_snapshot.py +114 -0
  133. optivibe_mcp-0.2.0/src/optivibe_harness/tools/scale_lens.py +525 -0
  134. optivibe_mcp-0.2.0/src/optivibe_harness/tools/surface_solve.py +2262 -0
  135. optivibe_mcp-0.2.0/src/optivibe_harness/tools/tolerance_run.py +1214 -0
  136. optivibe_mcp-0.2.0/src/optivibe_harness/tools/variable_lifecycle.py +353 -0
  137. optivibe_mcp-0.2.0/src/optivibe_harness/tools/workspace.py +6548 -0
  138. optivibe_mcp-0.2.0/src/optivibe_harness/tools/zoom_compose.py +1702 -0
  139. optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/PKG-INFO +43 -0
  140. optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/SOURCES.txt +142 -0
  141. optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/dependency_links.txt +1 -0
  142. optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/entry_points.txt +3 -0
  143. optivibe_mcp-0.2.0/src/optivibe_mcp.egg-info/requires.txt +11 -0
  144. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """optivibe doctor — the install diagnostic. Run it with ``python -m optivibe_doctor``."""
2
+ SCHEMA_VERSION = "optivibe.doctor/v1"
@@ -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]