quantized-lab 0.8.0__py3-none-any.whl

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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,111 @@
1
+ """Plugin contract v1: the stable, versioned shape a quantized plugin declares.
2
+
3
+ A plugin is a Python module (a drop-in ``.py`` file or an installed package
4
+ exposing the ``quantized.plugins`` entry point) that defines a manifest and,
5
+ optionally, any of three contribution lists. This module owns the *contract*
6
+ (the manifest + its validation and the discovery-result record); the *loader*
7
+ (:mod:`quantized.plugins.loader`) owns discovery and registration.
8
+
9
+ Manifest — required::
10
+
11
+ QZ_PLUGIN = {"name": "My Plugin", "version": "1.2.0", "api_version": 1}
12
+
13
+ Contributions — any subset, each a list of plain dicts (see docs/plugins.md):
14
+
15
+ - ``PARSERS`` — ``{"extensions": [".ext"], "read": path -> DataStruct|dict,
16
+ "sniff"?: bytes -> bool}``
17
+ - ``FIT_MODELS`` — ``{"name": str, "params": [str, ...],
18
+ "fn": (x, params) -> y, "guess"?: [float, ...]}``
19
+ - ``STEPS`` — ``{"name": str, "fn": (DataStruct, params) -> DataStruct}``
20
+
21
+ Pure ``plugins`` layer — no fastapi/pydantic imports (guarded by
22
+ ``test_repo_integrity``). Plugins can never reach ``quantized.routes``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass
29
+ from typing import Any
30
+
31
+ __all__ = [
32
+ "API_VERSION",
33
+ "InvalidManifest",
34
+ "PluginInfo",
35
+ "PluginManifest",
36
+ "validate_manifest",
37
+ ]
38
+
39
+ # The contract version this quantized build speaks. A plugin declaring any other
40
+ # ``api_version`` is skipped (logged) — never loaded against an incompatible host.
41
+ API_VERSION = 1
42
+
43
+
44
+ class InvalidManifest(ValueError):
45
+ """A module's ``QZ_PLUGIN`` manifest is missing or not v1-compatible."""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class PluginManifest:
50
+ """The validated ``QZ_PLUGIN`` metadata of one plugin."""
51
+
52
+ name: str
53
+ version: str
54
+ api_version: int
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PluginInfo:
59
+ """What one discovered plugin is and what it contributed.
60
+
61
+ The record surfaced by ``qz plugin list`` and cached by the loader.
62
+ ``source`` is the stable identifier used for enable/disable (the module file
63
+ stem for a drop-in plugin, or the entry-point name for a packaged one); the
64
+ manifest ``name`` is human-facing and only known once the manifest validates.
65
+ """
66
+
67
+ source: str # module stem (file plugin) | entry-point name — the identifier
68
+ origin: str # "file" | "entry_point"
69
+ status: str # "loaded" | "disabled" | "error"
70
+ name: str = "" # manifest name (blank until the manifest validates)
71
+ version: str = ""
72
+ error: str = "" # problem text: import/manifest failure, or rejected contributions
73
+ parsers: tuple[str, ...] = ()
74
+ fit_models: tuple[str, ...] = ()
75
+ steps: tuple[str, ...] = ()
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "source": self.source,
80
+ "origin": self.origin,
81
+ "status": self.status,
82
+ "name": self.name,
83
+ "version": self.version,
84
+ "error": self.error,
85
+ "parsers": list(self.parsers),
86
+ "fit_models": list(self.fit_models),
87
+ "steps": list(self.steps),
88
+ }
89
+
90
+
91
+ def validate_manifest(module: object) -> PluginManifest:
92
+ """Read and validate ``module.QZ_PLUGIN``.
93
+
94
+ Raises :class:`InvalidManifest` when the manifest is missing, malformed, or
95
+ declares an ``api_version`` this build does not speak. On success the
96
+ manifest is guaranteed to have a non-empty ``name`` and ``api_version`` equal
97
+ to :data:`API_VERSION`.
98
+ """
99
+ raw = getattr(module, "QZ_PLUGIN", None)
100
+ if not isinstance(raw, Mapping):
101
+ raise InvalidManifest("module defines no QZ_PLUGIN dict")
102
+ api = raw.get("api_version")
103
+ if api != API_VERSION:
104
+ raise InvalidManifest(
105
+ f"unsupported api_version {api!r} (this quantized speaks v{API_VERSION})"
106
+ )
107
+ name = raw.get("name")
108
+ if not isinstance(name, str) or not name.strip():
109
+ raise InvalidManifest("QZ_PLUGIN['name'] must be a non-empty string")
110
+ version = str(raw.get("version", "0"))
111
+ return PluginManifest(name=name.strip(), version=version, api_version=API_VERSION)
@@ -0,0 +1,394 @@
1
+ """Plugin discovery + loading (gap #8).
2
+
3
+ Discovers plugins from two sources and registers their contributions through the
4
+ SAME chokepoints the built-ins use (so there is never a second dispatch path):
5
+
6
+ - drop-in ``.py`` modules in ``<config_dir>/plugins/`` (``config_dir`` reused
7
+ from :mod:`quantized.io.import_filters` — the repo's one config-dir concept);
8
+ - installed packages exposing the ``quantized.plugins`` entry-point group.
9
+
10
+ Trust model: a plugin is arbitrary Python you chose to install — treated exactly
11
+ like anything you ``pip install``. There is NO sandbox (sandboxing a Python
12
+ interpreter is a losing game). Install only plugins you trust. See
13
+ ``docs/plugins.md``.
14
+
15
+ Robustness: a plugin that raises on import, declares a missing/incompatible
16
+ manifest, or tries to shadow a built-in extension is LOGGED and SKIPPED — it
17
+ never takes down startup or the other plugins.
18
+
19
+ Pure ``plugins`` layer — no fastapi/pydantic imports (guarded by
20
+ ``test_repo_integrity``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.util
26
+ import json
27
+ import logging
28
+ import sys
29
+ from collections.abc import Callable, Iterator, Mapping
30
+ from importlib import metadata
31
+ from pathlib import Path
32
+ from types import ModuleType
33
+ from typing import Any
34
+
35
+ from quantized.calc.fit_models import FIT_MODELS, register_model, unregister_model
36
+ from quantized.datastruct import DataStruct
37
+ from quantized.io.import_filters import config_dir
38
+ from quantized.io.registry import (
39
+ Parser,
40
+ Sniffer,
41
+ register_parser,
42
+ unregister_plugin_parsers,
43
+ )
44
+ from quantized.plugins import steps as step_registry
45
+ from quantized.plugins.contract import (
46
+ InvalidManifest,
47
+ PluginInfo,
48
+ PluginManifest,
49
+ validate_manifest,
50
+ )
51
+
52
+ __all__ = [
53
+ "disable_plugin",
54
+ "disabled_sources",
55
+ "enable_plugin",
56
+ "ENTRY_POINT_GROUP",
57
+ "load_plugins",
58
+ "loaded_plugins",
59
+ "plugins_dir",
60
+ "unload_plugins",
61
+ ]
62
+
63
+ _log = logging.getLogger("quantized.plugins")
64
+
65
+ ENTRY_POINT_GROUP = "quantized.plugins"
66
+ _PLUGINS_SUBDIR = "plugins"
67
+ _CONFIG_FILENAME = "plugins.json"
68
+ _SNIFF_BYTES = 65536 # bytes handed to a plugin's optional bytes -> bool sniffer
69
+ _INF = float("inf")
70
+
71
+ # Cache of the most recent load, and the model names it registered (so an
72
+ # idempotent reload / test teardown can pop exactly those from the shared
73
+ # FIT_MODELS without touching a built-in model).
74
+ _LOADED: list[PluginInfo] = []
75
+ _PLUGIN_MODEL_NAMES: list[str] = []
76
+
77
+
78
+ # ── Public directory / config helpers ───────────────────────────────────────
79
+ def plugins_dir() -> Path:
80
+ """The drop-in plugins directory (``<config_dir>/plugins``), created on demand."""
81
+ directory = config_dir() / _PLUGINS_SUBDIR
82
+ directory.mkdir(parents=True, exist_ok=True)
83
+ return directory
84
+
85
+
86
+ def disabled_sources() -> set[str]:
87
+ """Source identifiers the user has disabled (``<config_dir>/plugins.json``).
88
+
89
+ Shape: ``{"disabled": ["source-name", ...]}``. Missing/corrupt -> none
90
+ disabled. A disabled plugin is never imported (a broken plugin can be parked
91
+ without deleting it). Public read accessor; see :func:`enable_plugin` /
92
+ :func:`disable_plugin` for the mutating counterpart used by ``qz plugin
93
+ enable``/``disable`` (:mod:`quantized.cli`).
94
+ """
95
+ path = config_dir() / _CONFIG_FILENAME
96
+ if not path.is_file():
97
+ return set()
98
+ try:
99
+ raw = json.loads(path.read_text(encoding="utf-8"))
100
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
101
+ return set()
102
+ disabled = raw.get("disabled") if isinstance(raw, Mapping) else None
103
+ if not isinstance(disabled, list):
104
+ return set()
105
+ return {str(item) for item in disabled}
106
+
107
+
108
+ def _write_disabled_sources(disabled: set[str]) -> None:
109
+ """Persist ``disabled`` as the ``disabled`` list in ``plugins.json``.
110
+
111
+ Preserves any other top-level keys already in the file (forward-
112
+ compatible with future ``plugins.json`` content) and creates the file if
113
+ it does not yet exist. A corrupt existing file is treated as empty rather
114
+ than raising, matching :func:`disabled_sources`'s own corrupt-file
115
+ handling.
116
+ """
117
+ path = config_dir() / _CONFIG_FILENAME
118
+ payload: dict[str, Any] = {}
119
+ if path.is_file():
120
+ try:
121
+ raw = json.loads(path.read_text(encoding="utf-8"))
122
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
123
+ raw = None
124
+ if isinstance(raw, Mapping):
125
+ payload = dict(raw)
126
+ payload["disabled"] = sorted(disabled)
127
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
128
+
129
+
130
+ def enable_plugin(source: str) -> None:
131
+ """Remove ``source`` from the disabled list (idempotent — a no-op if it
132
+ was not disabled). Does not itself reload; call :func:`load_plugins`
133
+ afterwards to pick up the change."""
134
+ _write_disabled_sources(disabled_sources() - {source})
135
+
136
+
137
+ def disable_plugin(source: str) -> None:
138
+ """Add ``source`` to the disabled list (idempotent; creates
139
+ ``plugins.json`` if absent). Does not itself reload; call
140
+ :func:`load_plugins` afterwards to pick up the change."""
141
+ _write_disabled_sources(disabled_sources() | {source})
142
+
143
+
144
+ # ── Discovery ────────────────────────────────────────────────────────────────
145
+ def _discover() -> Iterator[tuple[str, str, Callable[[], ModuleType]]]:
146
+ """Yield ``(source, origin, importer)`` for every discovered plugin.
147
+
148
+ File plugins first (sorted, deterministic), then entry points. ``importer``
149
+ is a thunk that imports and returns the module (and may raise — the caller
150
+ isolates it).
151
+ """
152
+ for path in sorted(plugins_dir().glob("*.py")):
153
+ if path.name.startswith("_"):
154
+ continue
155
+ yield path.stem, "file", _file_importer(path.stem, path)
156
+ for entry in _entry_points():
157
+ yield entry.name, "entry_point", _ep_importer(entry)
158
+
159
+
160
+ def _entry_points() -> list[Any]:
161
+ try:
162
+ found = metadata.entry_points(group=ENTRY_POINT_GROUP)
163
+ except Exception: # pragma: no cover - importlib.metadata is robust
164
+ return []
165
+ return sorted(found, key=lambda e: e.name)
166
+
167
+
168
+ def _file_importer(source: str, path: Path) -> Callable[[], ModuleType]:
169
+ def _load() -> ModuleType:
170
+ module_name = f"quantized_plugins.{source}"
171
+ spec = importlib.util.spec_from_file_location(module_name, path)
172
+ if spec is None or spec.loader is None:
173
+ raise ImportError(f"cannot create an import spec for {path}")
174
+ module = importlib.util.module_from_spec(spec)
175
+ sys.modules[module_name] = module
176
+ try:
177
+ spec.loader.exec_module(module)
178
+ except Exception:
179
+ sys.modules.pop(module_name, None)
180
+ raise
181
+ return module
182
+
183
+ return _load
184
+
185
+
186
+ def _ep_importer(entry: Any) -> Callable[[], ModuleType]:
187
+ def _load() -> ModuleType:
188
+ loaded: ModuleType = entry.load()
189
+ return loaded
190
+
191
+ return _load
192
+
193
+
194
+ # ── Loading ──────────────────────────────────────────────────────────────────
195
+ def load_plugins() -> list[PluginInfo]:
196
+ """Discover, validate, and register all plugins; return one info per plugin.
197
+
198
+ Idempotent: a prior load's registrations are removed first, so this can be
199
+ called again to reload. Called once at app startup (:func:`quantized.app.create_app`).
200
+ """
201
+ unload_plugins()
202
+ disabled = disabled_sources()
203
+ infos: list[PluginInfo] = []
204
+ for source, origin, importer in _discover():
205
+ if source in disabled:
206
+ infos.append(PluginInfo(source=source, origin=origin, status="disabled"))
207
+ continue
208
+ infos.append(_load_one(source, origin, importer))
209
+ _LOADED[:] = infos
210
+ return infos
211
+
212
+
213
+ def loaded_plugins() -> list[PluginInfo]:
214
+ """The plugins from the most recent :func:`load_plugins` (empty if never run)."""
215
+ return list(_LOADED)
216
+
217
+
218
+ def unload_plugins() -> None:
219
+ """Remove all plugin-registered contributions (idempotent reload / tests)."""
220
+ unregister_plugin_parsers()
221
+ for name in _PLUGIN_MODEL_NAMES:
222
+ unregister_model(name)
223
+ _PLUGIN_MODEL_NAMES.clear()
224
+ step_registry.unregister_plugin_steps()
225
+ _LOADED.clear()
226
+
227
+
228
+ def _load_one(
229
+ source: str, origin: str, importer: Callable[[], ModuleType]
230
+ ) -> PluginInfo:
231
+ try:
232
+ module = importer()
233
+ except Exception as exc: # a plugin must never crash startup
234
+ _log.warning("plugin %r failed to import: %s", source, exc)
235
+ return PluginInfo(
236
+ source=source, origin=origin, status="error", error=f"import failed: {exc}"
237
+ )
238
+ try:
239
+ manifest = validate_manifest(module)
240
+ except InvalidManifest as exc:
241
+ _log.warning("plugin %r has an invalid manifest: %s", source, exc)
242
+ return PluginInfo(source=source, origin=origin, status="error", error=str(exc))
243
+ return _register(source, origin, manifest, module)
244
+
245
+
246
+ # ── Contribution registration (per-contribution isolation) ──────────────────
247
+ def _register(
248
+ source: str, origin: str, manifest: PluginManifest, module: ModuleType
249
+ ) -> PluginInfo:
250
+ problems: list[str] = []
251
+ parsers = _register_parsers(getattr(module, "PARSERS", None), problems)
252
+ models = _register_models(getattr(module, "FIT_MODELS", None), problems)
253
+ steps = _register_steps(getattr(module, "STEPS", None), manifest.name, problems)
254
+ _log.info(
255
+ "plugin %r (v%s) loaded: %d parser(s), %d model(s), %d step(s)",
256
+ manifest.name, manifest.version, len(parsers), len(models), len(steps),
257
+ )
258
+ return PluginInfo(
259
+ source=source,
260
+ origin=origin,
261
+ status="loaded",
262
+ name=manifest.name,
263
+ version=manifest.version,
264
+ error="; ".join(problems),
265
+ parsers=tuple(parsers),
266
+ fit_models=tuple(models),
267
+ steps=tuple(steps),
268
+ )
269
+
270
+
271
+ def _as_specs(value: Any) -> list[Any]:
272
+ return list(value) if isinstance(value, list | tuple) else []
273
+
274
+
275
+ def _register_parsers(specs: Any, problems: list[str]) -> list[str]:
276
+ registered: list[str] = []
277
+ for spec in _as_specs(specs):
278
+ if not isinstance(spec, Mapping):
279
+ problems.append("bad parser spec (not a mapping)")
280
+ continue
281
+ exts = spec.get("extensions")
282
+ read = spec.get("read")
283
+ sniff = spec.get("sniff")
284
+ if not isinstance(exts, list | tuple) or not exts or read is None:
285
+ problems.append("bad parser spec (needs 'extensions' + 'read')")
286
+ continue
287
+ parser = _wrap_read(read)
288
+ wrapped_sniff = _wrap_sniff(sniff) if sniff is not None else None
289
+ for raw_ext in exts:
290
+ ext = str(raw_ext)
291
+ try:
292
+ register_parser([ext], parser, sniff=wrapped_sniff)
293
+ except ValueError as exc:
294
+ problems.append(str(exc))
295
+ continue
296
+ registered.append(_norm_ext(ext))
297
+ return registered
298
+
299
+
300
+ def _register_models(specs: Any, problems: list[str]) -> list[str]:
301
+ registered: list[str] = []
302
+ for spec in _as_specs(specs):
303
+ if not isinstance(spec, Mapping):
304
+ problems.append("bad fit-model spec (not a mapping)")
305
+ continue
306
+ name = spec.get("name")
307
+ fn = spec.get("fn")
308
+ params_raw = spec.get("params", spec.get("param_names"))
309
+ if (
310
+ not isinstance(name, str)
311
+ or not name
312
+ or fn is None
313
+ or not isinstance(params_raw, list | tuple)
314
+ ):
315
+ problems.append(f"bad fit-model spec for {name!r}")
316
+ continue
317
+ if name in FIT_MODELS:
318
+ problems.append(f"fit model '{name}' is already registered")
319
+ continue
320
+ params = [str(p) for p in params_raw]
321
+ n = len(params)
322
+ guess = spec.get("guess")
323
+ p0 = [float(v) for v in guess] if isinstance(guess, list | tuple) else [1.0] * n
324
+ if len(p0) != n:
325
+ p0 = (p0 + [1.0] * n)[:n]
326
+ register_model(name, "Plugin", fn, params, p0, [-_INF] * n, [_INF] * n)
327
+ _PLUGIN_MODEL_NAMES.append(name)
328
+ registered.append(name)
329
+ return registered
330
+
331
+
332
+ def _register_steps(specs: Any, plugin_name: str, problems: list[str]) -> list[str]:
333
+ registered: list[str] = []
334
+ for spec in _as_specs(specs):
335
+ if not isinstance(spec, Mapping):
336
+ problems.append("bad step spec (not a mapping)")
337
+ continue
338
+ name = spec.get("name")
339
+ fn = spec.get("fn")
340
+ if not isinstance(name, str) or not name or fn is None:
341
+ problems.append(f"bad step spec for {name!r}")
342
+ continue
343
+ try:
344
+ step_registry.register_step(name, fn, plugin=plugin_name)
345
+ except ValueError as exc:
346
+ problems.append(str(exc))
347
+ continue
348
+ registered.append(name)
349
+ return registered
350
+
351
+
352
+ # ── Adapters: plugin callable shapes -> registry callable shapes ────────────
353
+ def _norm_ext(ext: str) -> str:
354
+ lowered = ext.lower()
355
+ return lowered if lowered.startswith(".") else f".{lowered}"
356
+
357
+
358
+ def _wrap_read(read: Any) -> Parser:
359
+ """Adapt a plugin ``read(path)`` (returns a DataStruct OR a DataStruct dict)
360
+ to the registry's ``Callable[[Path], DataStruct]``."""
361
+
362
+ def parser(path: Path) -> DataStruct:
363
+ result = read(path)
364
+ if isinstance(result, DataStruct):
365
+ return result
366
+ if isinstance(result, Mapping):
367
+ return DataStruct.from_dict(result)
368
+ raise TypeError(
369
+ "plugin parser 'read' must return a DataStruct or a DataStruct dict, "
370
+ f"got {type(result).__name__}"
371
+ )
372
+
373
+ return parser
374
+
375
+
376
+ def _wrap_sniff(sniff: Any) -> Sniffer:
377
+ """Adapt a plugin ``sniff(bytes)`` to the registry's ``Callable[[Path], bool]``.
378
+
379
+ The first :data:`_SNIFF_BYTES` bytes of the file are handed to the plugin. A
380
+ sniffer that raises or a file that cannot be read is treated as "no match"
381
+ (a plugin sniffer must never break resolution for other parsers)."""
382
+
383
+ def _sniff_path(path: Path) -> bool:
384
+ try:
385
+ with path.open("rb") as handle:
386
+ data = handle.read(_SNIFF_BYTES)
387
+ except OSError:
388
+ return False
389
+ try:
390
+ return bool(sniff(data))
391
+ except Exception:
392
+ return False
393
+
394
+ return _sniff_path
@@ -0,0 +1,90 @@
1
+ """Pipeline-step registry for plugin-contributed steps (gap #8).
2
+
3
+ A step is a pure transform ``fn(DataStruct, params) -> DataStruct``. Plugin API
4
+ v1 REGISTERS and LISTS steps server-side; surfacing them in the frontend
5
+ pipeline palette and replaying them in templates/batches is a later ecosystem
6
+ item. Giving steps a small pure home here means the contract is complete now,
7
+ without a premature route.
8
+
9
+ All steps are plugin-contributed (there are no built-in steps), so
10
+ :func:`unregister_plugin_steps` simply clears the registry.
11
+
12
+ Pure ``plugins`` layer — imports only :mod:`quantized.datastruct`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from quantized.datastruct import DataStruct
22
+
23
+ __all__ = [
24
+ "StepSpec",
25
+ "get_step",
26
+ "list_steps",
27
+ "register_step",
28
+ "run_step",
29
+ "unregister_plugin_steps",
30
+ ]
31
+
32
+ StepFn = Callable[[DataStruct, dict[str, Any]], DataStruct]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class StepSpec:
37
+ """One registered pipeline step: a stable ``name`` -> pure transform."""
38
+
39
+ name: str
40
+ fn: StepFn
41
+ plugin: str = ""
42
+
43
+
44
+ _STEPS: dict[str, StepSpec] = {}
45
+
46
+
47
+ def register_step(name: str, fn: StepFn, *, plugin: str = "") -> None:
48
+ """Register a pipeline step under ``name``.
49
+
50
+ Refuses (``ValueError``) an empty name or a duplicate — a plugin cannot
51
+ silently override another plugin's step.
52
+ """
53
+ if not name or not name.strip():
54
+ raise ValueError("step name must be a non-empty string")
55
+ if name in _STEPS:
56
+ raise ValueError(f"step '{name}' is already registered")
57
+ _STEPS[name] = StepSpec(name=name, fn=fn, plugin=plugin)
58
+
59
+
60
+ def list_steps() -> list[str]:
61
+ """Names of all registered steps, sorted for a stable listing."""
62
+ return sorted(_STEPS)
63
+
64
+
65
+ def get_step(name: str) -> StepSpec:
66
+ """The :class:`StepSpec` registered under ``name`` (``KeyError`` if unknown)."""
67
+ if name not in _STEPS:
68
+ raise KeyError(f"unknown step: {name!r}")
69
+ return _STEPS[name]
70
+
71
+
72
+ def run_step(
73
+ name: str, data: DataStruct, params: dict[str, Any] | None = None
74
+ ) -> DataStruct:
75
+ """Run a registered step — the seam the later execution-wiring item builds on.
76
+
77
+ Raises ``KeyError`` for an unknown step and ``TypeError`` if the step returns
78
+ something other than a :class:`DataStruct`.
79
+ """
80
+ result = get_step(name).fn(data, params or {})
81
+ if not isinstance(result, DataStruct):
82
+ raise TypeError(
83
+ f"step '{name}' must return a DataStruct, got {type(result).__name__}"
84
+ )
85
+ return result
86
+
87
+
88
+ def unregister_plugin_steps() -> None:
89
+ """Clear all plugin-registered steps (idempotent reload / test isolation)."""
90
+ _STEPS.clear()
@@ -0,0 +1,7 @@
1
+ """Thin FastAPI adapters: validate → call io/calc → serialize. No business logic.
2
+
3
+ One small router per domain (parsers, plot, corrections, fitting, calc_*,
4
+ workspace, export, session, jobs, dev). Added from M1 #5 onward.
5
+ """
6
+
7
+ from __future__ import annotations
@@ -0,0 +1,62 @@
1
+ """Small bounded LRU cache of one Origin project's per-book DataStructs, keyed
2
+ by ``(resolved path, mtime)`` -- the lazy per-book transport's speed-up
3
+ (``ORIGIN_FILE_DECODE_PLAN`` #38).
4
+
5
+ ``/api/parsers/import``/``upload`` already parse the WHOLE project once
6
+ (``read_origin_project_all``) to build the primary payload + the per-book
7
+ inventory; this module caches that same book list so a later
8
+ ``/api/parsers/books/data`` fetch (a book's first activation in the UI) is a
9
+ plain dict lookup, not a second ~4s reparse of a 122-book / 8.5M-cell project.
10
+
11
+ Lives in ``routes/`` (not ``io/``): it is a transport-layer cache of HTTP
12
+ access patterns, not a parsing concern -- ``io/origin_project`` stays a pure
13
+ reader with no notion of "recently imported". Bounded (a handful of projects)
14
+ and mtime-keyed so an edited-on-disk file is never served stale.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import OrderedDict
20
+ from pathlib import Path
21
+
22
+ from quantized.datastruct import DataStruct
23
+
24
+ __all__ = ["cache_project_books", "get_cached_book"]
25
+
26
+ _MAX_PROJECTS = 4
27
+ _cache: OrderedDict[tuple[str, float], dict[str, DataStruct]] = OrderedDict()
28
+
29
+
30
+ def _book_id(ds: DataStruct) -> str:
31
+ return str(ds.metadata.get("origin_book", ""))
32
+
33
+
34
+ def cache_project_books(path: Path, books: list[DataStruct]) -> None:
35
+ """Cache every book of one already-parsed project, keyed by the path's
36
+ CURRENT mtime. A later call with a changed mtime lands under a new key
37
+ (the old one just ages out via the LRU bound below) -- automatic
38
+ invalidation on file edit, no explicit eviction needed for that case."""
39
+ try:
40
+ mtime = path.stat().st_mtime
41
+ except OSError:
42
+ return
43
+ key = (str(path), mtime)
44
+ _cache[key] = {_book_id(b): b for b in books}
45
+ _cache.move_to_end(key)
46
+ while len(_cache) > _MAX_PROJECTS:
47
+ _cache.popitem(last=False)
48
+
49
+
50
+ def get_cached_book(path: Path, book_id: str) -> DataStruct | None:
51
+ """The requested book if the project is cached at its CURRENT mtime, else
52
+ ``None`` (cold cache, evicted, or the file changed on disk since caching)."""
53
+ try:
54
+ mtime = path.stat().st_mtime
55
+ except OSError:
56
+ return None
57
+ key = (str(path), mtime)
58
+ by_id = _cache.get(key)
59
+ if by_id is None:
60
+ return None
61
+ _cache.move_to_end(key)
62
+ return by_id.get(book_id)
@@ -0,0 +1,27 @@
1
+ """Shared export helpers (MIME types, filename sanitization, attachment headers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _FIGURE_MIME = {
8
+ "pdf": "application/pdf",
9
+ "svg": "image/svg+xml",
10
+ "png": "image/png",
11
+ "tiff": "image/tiff",
12
+ }
13
+ _DPI_MIN, _DPI_MAX = 50, 1200 # clamp: guards against absurd allocations
14
+
15
+
16
+ def _safe_name(name: str, ext: str) -> str:
17
+ """Filename safe for a Content-Disposition header: keep only word chars,
18
+ dot, dash; guarantee the extension. Prevents CRLF/quote injection +
19
+ path traversal."""
20
+ base = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._") or "export"
21
+ if not base.lower().endswith(ext):
22
+ base += ext
23
+ return base
24
+
25
+
26
+ def _attachment(name: str) -> dict[str, str]:
27
+ return {"Content-Disposition": f'attachment; filename="{name}"'}