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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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}"'}
|