qmlmathplot 0.4.4__py3-none-any.whl → 0.6.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.
- qmlmathplot/__init__.py +48 -27
- qmlmathplot/calc/__init__.py +11 -0
- qmlmathplot/calc/__main__.py +61 -0
- qmlmathplot/calc/build.py +103 -0
- qmlmathplot/client.py +243 -0
- qmlmathplot/curve.py +272 -161
- qmlmathplot/curveplotter.py +638 -0
- qmlmathplot/ggb_compute.py +47 -0
- qmlmathplot/ggb_eligibility.py +69 -0
- qmlmathplot/ggb_path.py +386 -0
- qmlmathplot/ggb_reference.py +77 -0
- qmlmathplot/interval.py +83 -387
- qmlmathplot/interval_reference.py +526 -0
- qmlmathplot/model.py +12 -1282
- qmlmathplot/pathcurve.py +214 -0
- qmlmathplot/plot.py +36 -3
- qmlmathplot/program.py +32 -0
- qmlmathplot/protocol.py +216 -0
- qmlmathplot/qml/CpuCurves.qml +24 -0
- qmlmathplot/qml/PlotCurves.qml +21 -19
- qmlmathplot/qml/PlotView.qml +5 -2
- qmlmathplot/qsb.py +9 -4
- qmlmathplot/rhi.py +503 -0
- qmlmathplot/shaderbuild.py +52 -0
- qmlmathplot/shaders/__init__.py +1 -0
- qmlmathplot/shaders/common/arg_reduction.glsl +6 -0
- qmlmathplot/shaders/common/real_pow.glsl +4 -0
- qmlmathplot/shaders/common/seg_dist.glsl +7 -0
- qmlmathplot/shaders/common/span_cov.glsl +5 -0
- qmlmathplot/shaders/common/uniforms.glsl +8 -0
- qmlmathplot/shaders/fullscreen.vert +12 -0
- qmlmathplot/shaders/implicit.frag +283 -0
- qmlmathplot/shaders/interval_lib.glsl +328 -0
- qmlmathplot/shaders/interval_path.comp +105 -0
- qmlmathplot/shaders/path.frag +10 -0
- qmlmathplot/shaders/path.vert +11 -0
- qmlmathplot/shaders/path_state.glsl +222 -0
- qmlmathplot/view.py +6 -2
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.6.0.dist-info}/METADATA +115 -162
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.6.0.dist-info}/RECORD +44 -16
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.6.0.dist-info}/WHEEL +1 -1
- qmlmathplot-0.6.0.dist-info/licenses/LICENSE +674 -0
- qmlmathplot-0.6.0.dist-info/licenses/LICENSES/EUPL-1.2.txt +287 -0
- qmlmathplot/canvas.py +0 -107
- qmlmathplot/figure.py +0 -516
- qmlmathplot/pyplot.py +0 -370
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.6.0.dist-info}/entry_points.txt +0 -0
qmlmathplot/__init__.py
CHANGED
|
@@ -20,7 +20,7 @@ QtWidgets layout in one line:
|
|
|
20
20
|
|
|
21
21
|
Qt Quick (QML) — the component plus the model:
|
|
22
22
|
|
|
23
|
-
import QmlMathPlot
|
|
23
|
+
import QmlMathPlot 2.0
|
|
24
24
|
PlotView { plot: myPlot; anchors.fill: parent }
|
|
25
25
|
|
|
26
26
|
from PySide6.QtGui import QGuiApplication
|
|
@@ -50,30 +50,24 @@ Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
|
|
|
50
50
|
|
|
51
51
|
from typing import TYPE_CHECKING
|
|
52
52
|
|
|
53
|
-
from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
54
|
-
from .curve import Curve, CurveListModel, ImplicitCurve
|
|
55
|
-
from .figure import Axes, Figure
|
|
56
|
-
from .input import ViewInput
|
|
57
|
-
from .overlay import Annotation, Guide
|
|
58
|
-
from .plot import Plot
|
|
59
|
-
from .qsb import cache_dir, cache_info, clear_cache, prune_cache
|
|
60
|
-
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
61
|
-
|
|
62
53
|
if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
|
|
63
|
-
from .
|
|
54
|
+
from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
55
|
+
from .curve import Curve, CurveListModel, ImplicitCurve
|
|
56
|
+
from .input import ViewInput
|
|
57
|
+
from .overlay import Annotation, Guide
|
|
58
|
+
from .plot import Plot
|
|
59
|
+
from .qsb import cache_dir, cache_info, clear_cache, prune_cache
|
|
60
|
+
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
64
61
|
from .widget import MathPlotWidget
|
|
65
62
|
|
|
66
63
|
__all__ = [
|
|
67
64
|
"Annotation",
|
|
68
|
-
"Axes",
|
|
69
65
|
"cache_dir",
|
|
70
66
|
"cache_info",
|
|
71
67
|
"Camera",
|
|
72
68
|
"clear_cache",
|
|
73
69
|
"Curve",
|
|
74
70
|
"CurveListModel",
|
|
75
|
-
"Figure",
|
|
76
|
-
"FigureCanvasQt",
|
|
77
71
|
"Guide",
|
|
78
72
|
"HOME_SIZE",
|
|
79
73
|
"HOME_VIEW",
|
|
@@ -91,18 +85,45 @@ __all__ = [
|
|
|
91
85
|
]
|
|
92
86
|
|
|
93
87
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
88
|
+
#: name -> (module, attribute). ``None`` for the attribute means "the module itself".
|
|
89
|
+
_LAZY: dict[str, tuple[str, str | None]] = {
|
|
90
|
+
"Annotation": (".overlay", "Annotation"),
|
|
91
|
+
"cache_dir": (".qsb", "cache_dir"),
|
|
92
|
+
"cache_info": (".qsb", "cache_info"),
|
|
93
|
+
"Camera": (".camera", "Camera"),
|
|
94
|
+
"clear_cache": (".qsb", "clear_cache"),
|
|
95
|
+
"Curve": (".curve", "Curve"),
|
|
96
|
+
"CurveListModel": (".curve", "CurveListModel"),
|
|
97
|
+
"Guide": (".overlay", "Guide"),
|
|
98
|
+
"HOME_SIZE": (".camera", "HOME_SIZE"),
|
|
99
|
+
"HOME_VIEW": (".camera", "HOME_VIEW"),
|
|
100
|
+
"ImplicitCurve": (".curve", "ImplicitCurve"),
|
|
101
|
+
"MathPlotWidget": (".widget", "MathPlotWidget"),
|
|
102
|
+
"nice_ticks": (".camera", "nice_ticks"),
|
|
103
|
+
"Plot": (".plot", "Plot"),
|
|
104
|
+
"prune_cache": (".qsb", "prune_cache"),
|
|
105
|
+
"qml_component_path": (".view", "qml_component_path"),
|
|
106
|
+
"QML_MAJOR": (".view", "QML_MAJOR"),
|
|
107
|
+
"QML_MINOR": (".view", "QML_MINOR"),
|
|
108
|
+
"QML_URI": (".view", "QML_URI"),
|
|
109
|
+
"register_qml_types": (".view", "register_qml_types"),
|
|
110
|
+
"ViewInput": (".input", "ViewInput"),
|
|
111
|
+
}
|
|
102
112
|
|
|
103
|
-
return FigureCanvasQt
|
|
104
|
-
if name == "pyplot":
|
|
105
|
-
from importlib import import_module
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
def __getattr__(name: str) -> object:
|
|
115
|
+
"""Resolve public names lazily.
|
|
116
|
+
|
|
117
|
+
Nothing is imported at package-import time: ``import qmlmathplot`` must not load Qt or
|
|
118
|
+
sympy, so a host that only needs the calculator protocol (or a QML-only app) pays for
|
|
119
|
+
nothing it does not use. The same laziness keeps the calculator process free of Qt.
|
|
120
|
+
"""
|
|
121
|
+
target = _LAZY.get(name)
|
|
122
|
+
if target is None:
|
|
123
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
124
|
+
from importlib import import_module
|
|
125
|
+
|
|
126
|
+
module = import_module(target[0], __name__)
|
|
127
|
+
value = module if target[1] is None else getattr(module, target[1])
|
|
128
|
+
globals()[name] = value
|
|
129
|
+
return value
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The calculator side: sympy lives here, not in the model.
|
|
2
|
+
|
|
3
|
+
``build_program`` is Qt-free, so it runs both in-process and inside the worker launched by
|
|
4
|
+
``python -m qmlmathplot.calc``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .build import build_program, python_sources
|
|
10
|
+
|
|
11
|
+
__all__ = ["build_program", "python_sources"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Worker process entry point: ``python -m qmlmathplot.calc``.
|
|
2
|
+
|
|
3
|
+
Reads length-prefixed JSON requests from stdin and writes one response per request to
|
|
4
|
+
stdout. Nothing but frames is ever written to stdout (import-time noise from sympy goes to
|
|
5
|
+
stderr, which the client drains), so the channel is safe to parse.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ..protocol import (
|
|
13
|
+
SCHEMA,
|
|
14
|
+
Program,
|
|
15
|
+
decode_frames,
|
|
16
|
+
encode_frame,
|
|
17
|
+
parse_request,
|
|
18
|
+
response_payload,
|
|
19
|
+
)
|
|
20
|
+
from .build import build_program
|
|
21
|
+
|
|
22
|
+
__all__ = ["main"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _error_program(payload: object) -> tuple[int, Program]:
|
|
26
|
+
text = ""
|
|
27
|
+
request_id = 0
|
|
28
|
+
if isinstance(payload, dict):
|
|
29
|
+
request_id = int(payload.get("id", 0) or 0)
|
|
30
|
+
text = str(payload.get("text", ""))
|
|
31
|
+
return request_id, Program(text=text, error="malformed request")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
stdin = sys.stdin.buffer
|
|
36
|
+
stdout = sys.stdout.buffer
|
|
37
|
+
buffer = b""
|
|
38
|
+
while True:
|
|
39
|
+
chunk = stdin.read1(65536) if hasattr(stdin, "read1") else stdin.read(65536)
|
|
40
|
+
if not chunk:
|
|
41
|
+
break
|
|
42
|
+
buffer += chunk
|
|
43
|
+
try:
|
|
44
|
+
frames, buffer = decode_frames(buffer, limit=64)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
stdout.write(encode_frame({"schema": SCHEMA, "id": 0, "error": str(exc)}))
|
|
47
|
+
stdout.flush()
|
|
48
|
+
return 1
|
|
49
|
+
for payload in frames:
|
|
50
|
+
try:
|
|
51
|
+
request_id, text, implicit = parse_request(payload)
|
|
52
|
+
program = build_program(text, implicit=implicit)
|
|
53
|
+
except Exception: # noqa: BLE001 - a bad frame must not kill the worker
|
|
54
|
+
request_id, program = _error_program(payload)
|
|
55
|
+
stdout.write(encode_frame(response_payload(request_id, program)))
|
|
56
|
+
stdout.flush()
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Text -> :class:`Program`. The only place in the package that needs sympy at runtime.
|
|
2
|
+
|
|
3
|
+
Kept Qt-free on purpose: the same code runs in-process (:class:`qmlmathplot.client.
|
|
4
|
+
InlineCalculator`) and inside the worker process (``python -m qmlmathplot.calc``). The
|
|
5
|
+
routing rules are the model's established ones: an equation or a free ``y`` is a level set,
|
|
6
|
+
an expression the interval gate accepts is drawn by the compute path, everything else goes
|
|
7
|
+
through the CPU plotter. Shader sources and Python sources are generated here so the model
|
|
8
|
+
never parses an expression again.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sympy as sp
|
|
14
|
+
from sympy.printing.numpy import NumPyPrinter
|
|
15
|
+
|
|
16
|
+
from ..ggb_compute import path_compute_source
|
|
17
|
+
from ..interval import compute_supported
|
|
18
|
+
from ..model import implicit_shader_sources
|
|
19
|
+
from ..protocol import Program
|
|
20
|
+
|
|
21
|
+
__all__ = ["build_program", "python_sources"]
|
|
22
|
+
|
|
23
|
+
_EXTRA_SYMBOLS = "substitute parameters first"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse(text: str) -> tuple[str, sp.Expr, bool]:
|
|
27
|
+
"""Canonical text, normalized expression, and whether the input was an equation.
|
|
28
|
+
|
|
29
|
+
Mirrors ``qmlmathplot.curve._parse``: an ``Eq(lhs, rhs)`` becomes the level set
|
|
30
|
+
``lhs - rhs = 0`` and is always implicit.
|
|
31
|
+
"""
|
|
32
|
+
expr = sp.sympify(text, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
|
|
33
|
+
if not isinstance(expr, sp.Basic):
|
|
34
|
+
raise ValueError(f"cannot parse {text!r}")
|
|
35
|
+
if isinstance(expr, sp.Eq):
|
|
36
|
+
return text, expr.lhs - expr.rhs, True
|
|
37
|
+
return text, expr, False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def python_sources(
|
|
41
|
+
expr: sp.Expr, args: tuple[str, ...], name: str
|
|
42
|
+
) -> tuple[str | None, str | None]:
|
|
43
|
+
"""``(math_source, numpy_source)``: complete function definitions, or ``None``.
|
|
44
|
+
|
|
45
|
+
The two differ in the module they call (``math`` vs ``numpy``) and in vectorisation; the
|
|
46
|
+
model uses ``math`` for its scalar sampler and ``numpy`` for vectorised work.
|
|
47
|
+
"""
|
|
48
|
+
math_source = _define(expr, args, name, lambda node: sp.pycode(node))
|
|
49
|
+
numpy_source = _define(expr, args, name, NumPyPrinter().doprint)
|
|
50
|
+
return math_source, numpy_source
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _define(
|
|
54
|
+
expr: sp.Expr,
|
|
55
|
+
args: tuple[str, ...],
|
|
56
|
+
name: str,
|
|
57
|
+
printer: object,
|
|
58
|
+
) -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
body = printer(expr) # type: ignore[operator] - a callable passed by the caller
|
|
61
|
+
except Exception: # noqa: BLE001 - some expressions have no printer representation
|
|
62
|
+
return None
|
|
63
|
+
signature = ", ".join(args)
|
|
64
|
+
return f"def {name}({signature}):\n return {body}\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_program(text: str, *, implicit: bool | None = None) -> Program:
|
|
68
|
+
"""Turn one expression into a :class:`Program`; parse errors become ``Program.error``."""
|
|
69
|
+
text = str(text)
|
|
70
|
+
try:
|
|
71
|
+
_, expr, is_equation = _parse(text)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 - reported through the program
|
|
73
|
+
return Program(text=text, error=f"{type(exc).__name__}: {exc}")
|
|
74
|
+
names = {symbol.name for symbol in expr.free_symbols}
|
|
75
|
+
if names - {"x", "y"}:
|
|
76
|
+
return Program(
|
|
77
|
+
text=text,
|
|
78
|
+
error=(f"only x (or x and y) may be free, got {sorted(names - {'x', 'y'})}: "
|
|
79
|
+
f"{_EXTRA_SYMBOLS}"),
|
|
80
|
+
)
|
|
81
|
+
level_set = bool(implicit) or is_equation or "y" in names
|
|
82
|
+
args: tuple[str, ...] = ("x", "y") if level_set else ("x",)
|
|
83
|
+
kind = "implicit" if level_set else "adaptive"
|
|
84
|
+
glsl: dict[str, str] = {}
|
|
85
|
+
error = ""
|
|
86
|
+
try:
|
|
87
|
+
if level_set:
|
|
88
|
+
glsl["fragment"] = implicit_shader_sources(expr)[1]
|
|
89
|
+
elif compute_supported(expr):
|
|
90
|
+
kind = "intervals"
|
|
91
|
+
glsl["compute"] = path_compute_source(expr)
|
|
92
|
+
except Exception as exc: # noqa: BLE001 - the model keeps the last working shader
|
|
93
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
94
|
+
math_source, numpy_source = python_sources(expr, args, "F" if level_set else "f")
|
|
95
|
+
return Program(
|
|
96
|
+
text=text,
|
|
97
|
+
kind=kind, # type: ignore[arg-type]
|
|
98
|
+
args=args,
|
|
99
|
+
glsl=glsl,
|
|
100
|
+
numpy=numpy_source,
|
|
101
|
+
math=math_source,
|
|
102
|
+
error=error,
|
|
103
|
+
)
|
qmlmathplot/client.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Calculators: the injection point between the model and the expression source.
|
|
2
|
+
|
|
3
|
+
The model never parses text. It asks a :class:`Calculator` for a
|
|
4
|
+
:class:`~qmlmathplot.protocol.Program` and consumes that. Two implementations ship here:
|
|
5
|
+
|
|
6
|
+
* :class:`InlineCalculator` — builds the program in the calling thread. Needed by scripts,
|
|
7
|
+
headless use and the test suite; it imports sympy lazily, on the first request.
|
|
8
|
+
* :class:`ProcessCalculator` — asks a worker process (``python -m qmlmathplot.calc``). The
|
|
9
|
+
host owns the process: when to start it, how to ship it, when to stop it.
|
|
10
|
+
|
|
11
|
+
Both deliver through the same ``ready`` / ``failed`` signals, so the model has exactly one
|
|
12
|
+
state machine. A host may implement its own calculator (any language, any transport); it
|
|
13
|
+
only has to produce programs.
|
|
14
|
+
|
|
15
|
+
Results carry an opaque **token** chosen by the caller (the model uses its generation
|
|
16
|
+
counter). The calculator never interprets it, so several curves may share one calculator
|
|
17
|
+
without coordinating ids, and a stale answer is discarded by the caller.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from collections import deque
|
|
28
|
+
|
|
29
|
+
from PySide6.QtCore import QObject, Signal
|
|
30
|
+
|
|
31
|
+
from .protocol import (
|
|
32
|
+
Program,
|
|
33
|
+
decode_frames,
|
|
34
|
+
encode_frame,
|
|
35
|
+
parse_response,
|
|
36
|
+
request_payload,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = ["Calculator", "InlineCalculator", "ProcessCalculator", "calculator_command"]
|
|
40
|
+
|
|
41
|
+
#: Environment override for the worker command line (any executable/shell words).
|
|
42
|
+
COMMAND_ENV = "QMLMATHPLOT_CALC"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def calculator_command() -> list[str]:
|
|
46
|
+
"""The worker command: ``$QMLMATHPLOT_CALC`` if set, else this interpreter's module."""
|
|
47
|
+
override = os.environ.get(COMMAND_ENV)
|
|
48
|
+
if override:
|
|
49
|
+
return shlex.split(override, posix=False)
|
|
50
|
+
return [sys.executable, "-m", "qmlmathplot.calc"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Calculator(QObject):
|
|
54
|
+
"""Asynchronous expression source: ``request`` now, ``ready``/``failed`` later."""
|
|
55
|
+
|
|
56
|
+
#: ``(token, Program)``. Always emitted for a well-formed request, including parse errors
|
|
57
|
+
#: (which arrive as ``Program.error``).
|
|
58
|
+
ready = Signal(object, object)
|
|
59
|
+
#: ``(token, message)`` — transport failures only (worker died, timeout, bad frame).
|
|
60
|
+
failed = Signal(object, str)
|
|
61
|
+
|
|
62
|
+
def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
|
|
63
|
+
"""Ask for a program. The result signals carry ``token`` back."""
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
"""Release resources (the inline calculator has none)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InlineCalculator(Calculator):
|
|
71
|
+
"""Build programs in-process — the default, and what the tests use."""
|
|
72
|
+
|
|
73
|
+
def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
|
|
74
|
+
try:
|
|
75
|
+
from .calc import build_program
|
|
76
|
+
except ImportError: # the core install has no sympy; the host can inject a process
|
|
77
|
+
self.failed.emit(
|
|
78
|
+
token,
|
|
79
|
+
"the in-process calculator needs sympy: pip install 'qmlmathplot[calc]' "
|
|
80
|
+
"(or inject a ProcessCalculator / your own Calculator)",
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
self.ready.emit(token, build_program(text, implicit=implicit))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProcessCalculator(Calculator):
|
|
87
|
+
"""Ask a worker process; framed JSON over its stdin/stdout.
|
|
88
|
+
|
|
89
|
+
The worker is started on the first request and restarted after a crash. One request is in
|
|
90
|
+
flight per token; the reader and stderr-drain threads spend their time blocked in C
|
|
91
|
+
reads, so they do not hold the GIL.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
command: list[str] | None = None,
|
|
97
|
+
*,
|
|
98
|
+
timeout: float = 10.0,
|
|
99
|
+
parent: QObject | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(parent)
|
|
102
|
+
self._command = list(command) if command is not None else calculator_command()
|
|
103
|
+
self._timeout = float(timeout)
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
106
|
+
self._pending: dict[int, tuple[object, threading.Timer]] = {}
|
|
107
|
+
self._stderr: deque[str] = deque(maxlen=20)
|
|
108
|
+
self._next_id = 0
|
|
109
|
+
self._closed = False
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ public
|
|
112
|
+
def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
|
|
113
|
+
if self._closed:
|
|
114
|
+
raise RuntimeError("this calculator has been closed")
|
|
115
|
+
self._ensure_started()
|
|
116
|
+
self._next_id += 1
|
|
117
|
+
wire_id = self._next_id
|
|
118
|
+
payload = request_payload(wire_id, text, implicit)
|
|
119
|
+
timer = threading.Timer(self._timeout, self._on_timeout, args=(wire_id,))
|
|
120
|
+
timer.daemon = True
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._pending[wire_id] = (token, timer)
|
|
123
|
+
timer.start()
|
|
124
|
+
try:
|
|
125
|
+
assert self._process is not None and self._process.stdin is not None
|
|
126
|
+
self._process.stdin.write(encode_frame(payload))
|
|
127
|
+
self._process.stdin.flush()
|
|
128
|
+
except (OSError, ValueError) as exc:
|
|
129
|
+
self._finish(wire_id, error=f"calculator write failed: {exc}")
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
self._closed = True
|
|
133
|
+
process = self._process
|
|
134
|
+
self._process = None
|
|
135
|
+
if process is not None:
|
|
136
|
+
try:
|
|
137
|
+
process.terminate()
|
|
138
|
+
process.wait(timeout=2.0)
|
|
139
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
140
|
+
process.kill()
|
|
141
|
+
with self._lock:
|
|
142
|
+
timers = [timer for _token, timer in self._pending.values()]
|
|
143
|
+
self._pending.clear()
|
|
144
|
+
for timer in timers:
|
|
145
|
+
timer.cancel()
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------- internals
|
|
148
|
+
def _ensure_started(self) -> None:
|
|
149
|
+
if self._process is not None and self._process.poll() is None:
|
|
150
|
+
return
|
|
151
|
+
flags = 0
|
|
152
|
+
if sys.platform == "win32": # no console window from a GUI host (AGENTS 57)
|
|
153
|
+
flags = subprocess.CREATE_NO_WINDOW
|
|
154
|
+
self._process = subprocess.Popen(
|
|
155
|
+
self._command,
|
|
156
|
+
stdin=subprocess.PIPE,
|
|
157
|
+
stdout=subprocess.PIPE,
|
|
158
|
+
stderr=subprocess.PIPE,
|
|
159
|
+
creationflags=flags,
|
|
160
|
+
)
|
|
161
|
+
threading.Thread(target=self._read_stdout, name="qmlmathplot-calc-out",
|
|
162
|
+
daemon=True).start()
|
|
163
|
+
threading.Thread(target=self._read_stderr, name="qmlmathplot-calc-err",
|
|
164
|
+
daemon=True).start()
|
|
165
|
+
|
|
166
|
+
def _read_stdout(self) -> None:
|
|
167
|
+
process = self._process
|
|
168
|
+
if process is None or process.stdout is None:
|
|
169
|
+
return
|
|
170
|
+
buffer = b""
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
read = getattr(process.stdout, "read1", process.stdout.read)
|
|
174
|
+
chunk = read(65536)
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
break
|
|
177
|
+
if not chunk:
|
|
178
|
+
break
|
|
179
|
+
buffer += chunk
|
|
180
|
+
try:
|
|
181
|
+
frames, buffer = decode_frames(buffer, limit=64)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
self._fail_all(f"calculator sent a bad frame: {exc}")
|
|
184
|
+
break
|
|
185
|
+
for payload in frames:
|
|
186
|
+
try:
|
|
187
|
+
wire_id, program = parse_response(payload)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
self._fail_all(f"calculator sent a bad response: {exc}")
|
|
190
|
+
continue
|
|
191
|
+
self._deliver(wire_id, program)
|
|
192
|
+
self._fail_all(self._death_message())
|
|
193
|
+
|
|
194
|
+
def _read_stderr(self) -> None:
|
|
195
|
+
process = self._process
|
|
196
|
+
if process is None or process.stderr is None:
|
|
197
|
+
return
|
|
198
|
+
while True:
|
|
199
|
+
line = process.stderr.readline()
|
|
200
|
+
if not line:
|
|
201
|
+
break
|
|
202
|
+
text = line.decode("utf-8", "replace").rstrip()
|
|
203
|
+
if text:
|
|
204
|
+
with self._lock:
|
|
205
|
+
self._stderr.append(text)
|
|
206
|
+
|
|
207
|
+
def _death_message(self) -> str:
|
|
208
|
+
with self._lock:
|
|
209
|
+
tail = list(self._stderr)[-3:]
|
|
210
|
+
return "calculator process exited" + (": " + " | ".join(tail) if tail else "")
|
|
211
|
+
|
|
212
|
+
def _deliver(self, wire_id: int, program: Program) -> None:
|
|
213
|
+
entry = self._take(wire_id)
|
|
214
|
+
if entry is None:
|
|
215
|
+
return # already timed out / closed
|
|
216
|
+
token, timer = entry
|
|
217
|
+
timer.cancel()
|
|
218
|
+
self.ready.emit(token, program)
|
|
219
|
+
|
|
220
|
+
def _on_timeout(self, wire_id: int) -> None:
|
|
221
|
+
entry = self._take(wire_id)
|
|
222
|
+
if entry is not None:
|
|
223
|
+
token, _timer = entry
|
|
224
|
+
self.failed.emit(token, f"calculator timed out after {self._timeout:.1f} s")
|
|
225
|
+
|
|
226
|
+
def _finish(self, wire_id: int, *, error: str) -> None:
|
|
227
|
+
entry = self._take(wire_id)
|
|
228
|
+
if entry is not None:
|
|
229
|
+
token, timer = entry
|
|
230
|
+
timer.cancel()
|
|
231
|
+
self.failed.emit(token, error)
|
|
232
|
+
|
|
233
|
+
def _take(self, wire_id: int) -> tuple[object, threading.Timer] | None:
|
|
234
|
+
with self._lock:
|
|
235
|
+
return self._pending.pop(wire_id, None)
|
|
236
|
+
|
|
237
|
+
def _fail_all(self, message: str) -> None:
|
|
238
|
+
with self._lock:
|
|
239
|
+
pending = list(self._pending.values())
|
|
240
|
+
self._pending.clear()
|
|
241
|
+
for token, timer in pending:
|
|
242
|
+
timer.cancel()
|
|
243
|
+
self.failed.emit(token, message)
|