dynamic-systems-testing-framework 1.0.1__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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""`Action`: a button whose label is its caption and whose disabled state
|
|
2
|
+
carries the sentence that says why and what enables it, beside it
|
|
3
|
+
(DESIGN.md "Disabled states explain themselves": never a bare grey
|
|
4
|
+
button). The header's Save / Discard / Export and every capability's
|
|
5
|
+
button at a placement are Actions; `IconAction` is one drawn as an
|
|
6
|
+
icon, its caption or its reason the tooltip."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from nicegui import ui
|
|
13
|
+
from nicegui.elements.mixins.text_element import TextElement
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Action:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
on_click: Callable[[], None],
|
|
20
|
+
*,
|
|
21
|
+
props: str,
|
|
22
|
+
marker: str,
|
|
23
|
+
why: bool = True,
|
|
24
|
+
filled: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
#: `filled`: `unelevated` while enabled, `flat` while disabled —
|
|
27
|
+
#: Quasar's disabled only dims a filled button, and the header's
|
|
28
|
+
#: Save read as the page's active primary action at 0 pending
|
|
29
|
+
#: edits. Its colour stays, so disabled it reads as Discard does.
|
|
30
|
+
self.filled = filled
|
|
31
|
+
with ui.row().classes("items-center no-wrap gap-2") as self.container:
|
|
32
|
+
self.button = ui.button(on_click=on_click).props(props).mark(marker)
|
|
33
|
+
self.why: ui.label | None = (
|
|
34
|
+
ui.label().classes("dstf-why").mark(f"{marker}:why") if why else None
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def set(self, caption: str, reason: str | None) -> None:
|
|
38
|
+
"""``reason`` None enables the button; a sentence disables it and is
|
|
39
|
+
shown beside it (when this Action carries a why-line)."""
|
|
40
|
+
self.button.set_text(caption)
|
|
41
|
+
if reason is None:
|
|
42
|
+
self.button.enable()
|
|
43
|
+
else:
|
|
44
|
+
self.button.disable()
|
|
45
|
+
if self.filled:
|
|
46
|
+
if reason is None:
|
|
47
|
+
self.button.props(remove="flat", add="unelevated")
|
|
48
|
+
else:
|
|
49
|
+
self.button.props(remove="unelevated", add="flat")
|
|
50
|
+
if self.why is not None:
|
|
51
|
+
self.why.set_text(reason or "")
|
|
52
|
+
self.why.set_visibility(reason is not None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class IconAction:
|
|
56
|
+
"""An Action drawn as an icon button: the caption — or, disabled, the
|
|
57
|
+
sentence that says why — is its tooltip and its accessible name (an
|
|
58
|
+
icon's ligature text is not a name). A disabled Quasar button takes no
|
|
59
|
+
pointer events, so the tooltip sits on a span around it (as Remove
|
|
60
|
+
plot's does)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, on_click: Callable[[], None], *, icon: str, marker: str) -> None:
|
|
63
|
+
with ui.element("span").classes("dstf-icon-action") as self.container:
|
|
64
|
+
self.button = (
|
|
65
|
+
ui.button(icon=icon, on_click=on_click)
|
|
66
|
+
.props("outline dense")
|
|
67
|
+
.mark(marker)
|
|
68
|
+
)
|
|
69
|
+
self.tip = ui.tooltip("").mark(f"{marker}:tip")
|
|
70
|
+
|
|
71
|
+
def set(self, caption: str, reason: str | None) -> None:
|
|
72
|
+
text = caption if reason is None else reason
|
|
73
|
+
self.tip.set_text(text)
|
|
74
|
+
self.button.props["aria-label"] = text
|
|
75
|
+
self.button.update()
|
|
76
|
+
if reason is None:
|
|
77
|
+
self.button.enable()
|
|
78
|
+
else:
|
|
79
|
+
self.button.disable()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MenuAction:
|
|
83
|
+
"""An Action drawn as an item of a bar's ⋮ menu: its caption the item's
|
|
84
|
+
text (in a label: a menu item has no text of its own to set).
|
|
85
|
+
Disabled, it reads ``label`` — the action's plain name — over the
|
|
86
|
+
sentence that says why: its caption counts what it would act on, and
|
|
87
|
+
with nothing to act on the menu offered "Accept 0 results as
|
|
88
|
+
baselines" and "Queue 0 (1 already queued)", their reasons a hover
|
|
89
|
+
away."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self, on_click: Callable[[], None], *, marker: str, label: str
|
|
93
|
+
) -> None:
|
|
94
|
+
self.label = label
|
|
95
|
+
self.button = (
|
|
96
|
+
ui.menu_item(on_click=on_click)
|
|
97
|
+
.props('role="menuitem"')
|
|
98
|
+
.classes("dstf-menu-action")
|
|
99
|
+
.mark(marker)
|
|
100
|
+
)
|
|
101
|
+
with self.button, ui.item_section():
|
|
102
|
+
self.text = ui.label().classes("dstf-menu-name")
|
|
103
|
+
self.why = ui.label().classes("dstf-why").mark(f"{marker}:why")
|
|
104
|
+
self.why.set_visibility(False)
|
|
105
|
+
|
|
106
|
+
def set(self, caption: str, reason: str | None) -> None:
|
|
107
|
+
self.text.set_text(caption if reason is None else self.label)
|
|
108
|
+
self.why.set_text(reason or "")
|
|
109
|
+
self.why.set_visibility(reason is not None)
|
|
110
|
+
if reason is None:
|
|
111
|
+
self.button.enable()
|
|
112
|
+
else:
|
|
113
|
+
self.button.disable()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Pill:
|
|
117
|
+
"""The tinted status pill of a criterion or a tree node (DESIGN.md
|
|
118
|
+
"Status badges": tinted inside a page, filled only in the header). It
|
|
119
|
+
repaints in place — a verdict that flips must not cost the row it sits
|
|
120
|
+
in."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, status: str, marker: str) -> None:
|
|
123
|
+
self.label = ui.label().classes("dstf-pill").mark(marker)
|
|
124
|
+
self.status = ""
|
|
125
|
+
self.set(status)
|
|
126
|
+
|
|
127
|
+
def set(self, status: str, text: str | None = None) -> None:
|
|
128
|
+
if self.status:
|
|
129
|
+
self.label.classes(remove=f"dstf-pill-{self.status}")
|
|
130
|
+
self.status = status
|
|
131
|
+
self.label.classes(add=f"dstf-pill-{status}")
|
|
132
|
+
self.label.set_text(text if text is not None else status)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: How much of a label with no index the tail keeps (Q20).
|
|
136
|
+
TAIL_CHARS = 12
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def middle_split(text: str) -> tuple[str, str]:
|
|
140
|
+
"""``(head, tail)``: the tail is the text from the last ``[`` — the
|
|
141
|
+
index says which element — or its last `TAIL_CHARS` characters when it
|
|
142
|
+
has none, or when that ``[`` is further back than that: a tail from
|
|
143
|
+
``volume[1]`` on took the width and hid the head (D140, Q20)."""
|
|
144
|
+
at = text.rfind("[")
|
|
145
|
+
if at == -1 or len(text) - at > TAIL_CHARS:
|
|
146
|
+
at = max(0, len(text) - TAIL_CHARS)
|
|
147
|
+
return text[:at], text[at:]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _Part(TextElement):
|
|
151
|
+
"""A span of text: not a `ui.label`, whose `div` would break the line."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, cls: str) -> None:
|
|
154
|
+
super().__init__(tag="span", text="")
|
|
155
|
+
self.classes(cls)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class MiddleCut(ui.element):
|
|
159
|
+
"""A variable label cut in the MIDDLE (D140, Q20): the head — what the
|
|
160
|
+
variable is — ellipsized, the tail — which element — whole. Both are
|
|
161
|
+
inline-blocks in an inline-block (never a flex pair: HIGH 6 of the
|
|
162
|
+
plan's review), the head's width capped at the element's less the
|
|
163
|
+
tail's ``--tail`` (its length in ``ch``), so the element's innerText,
|
|
164
|
+
a copy of it and its accessible name are the whole label; its title is
|
|
165
|
+
too. ``text`` / ``set_text`` as a label's, so a reader of ``.text`` is
|
|
166
|
+
unchanged. Model ids keep their start cut (the Tests drawer)."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, text: str = "") -> None:
|
|
169
|
+
super().__init__("span")
|
|
170
|
+
self.classes("dstf-mid")
|
|
171
|
+
with self:
|
|
172
|
+
self.head = _Part("dstf-mid-head")
|
|
173
|
+
self.tail = _Part("dstf-mid-tail")
|
|
174
|
+
self.text = ""
|
|
175
|
+
self.set_text(text)
|
|
176
|
+
|
|
177
|
+
def set_text(self, text: str) -> None:
|
|
178
|
+
# A repaint of the text it has sends nothing, as a label's does.
|
|
179
|
+
if text == self.text and "title" in self.props:
|
|
180
|
+
return
|
|
181
|
+
self.text = text
|
|
182
|
+
head, tail = middle_split(text)
|
|
183
|
+
self.head.set_text(head)
|
|
184
|
+
self.tail.set_text(tail)
|
|
185
|
+
self.style(f"--tail: {len(tail)}ch")
|
|
186
|
+
# On the props dict: a label can hold a `"`.
|
|
187
|
+
self.props["title"] = text
|
|
188
|
+
self.update()
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""A test's situation, as the Test page's status strip says it: what state
|
|
2
|
+
the test is in, the tone the strip is drawn in, and the one sentence that
|
|
3
|
+
says it. Pure — no Session, no UI: the page reads the inputs, this decides.
|
|
4
|
+
|
|
5
|
+
The situations are checked in :class:`Situation`'s order; the first that
|
|
6
|
+
applies wins (a test the queue holds before pending edits, edits before
|
|
7
|
+
anything the last scoring said).
|
|
8
|
+
|
|
9
|
+
There is no state for edits to the simulation settings (the owner's ruling
|
|
10
|
+
Q2 A drew one): ``/simulation`` is not stageable — `patch_apply`'s default
|
|
11
|
+
whitelist is ``/comparison``, ``/metrics`` and ``/plots``, and widening it
|
|
12
|
+
changes ``spec-update``, a seam another repository relies on. It waits for
|
|
13
|
+
a simulation-settings editor."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from ..backends.pool import ItemState
|
|
23
|
+
from ..comparison.tree_spec import tree_variables
|
|
24
|
+
from ..reporting.status import FAIL, NO_REF, NOT_RUN, PASS, SIM_FAIL
|
|
25
|
+
from .capabilities import plural
|
|
26
|
+
from .live_rows import CANCELLED, NOT_AVAILABLE
|
|
27
|
+
from .queue_rows import one_line
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ..backends.types import TestResult
|
|
31
|
+
from ..discovery.test_registry import TestModel
|
|
32
|
+
from .live_rows import Partial
|
|
33
|
+
from .queue_rows import QueueRow
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Situation(Enum):
|
|
37
|
+
#: the simulation queue holds the test, waiting (D150)
|
|
38
|
+
QUEUED = "queued"
|
|
39
|
+
RUNNING = "running"
|
|
40
|
+
EDITS = "edits"
|
|
41
|
+
#: pending edits whose criteria name a variable the last run did not
|
|
42
|
+
#: record: Queue is the one next action, Save is quiet (the owner's O1)
|
|
43
|
+
EDITS_NEW = "edits_new"
|
|
44
|
+
SCORING_FAILED = "scoring_failed"
|
|
45
|
+
WAITING = "waiting"
|
|
46
|
+
#: its last run in the queue was stopped: it has no result, and the
|
|
47
|
+
#: one before was replaced when that run started
|
|
48
|
+
CANCELLED = "cancelled"
|
|
49
|
+
#: the queue could not start it — no worker could start: the
|
|
50
|
+
#: backend's message; its last result stands
|
|
51
|
+
START_FAILED = "start_failed"
|
|
52
|
+
NO_RESULT = "no_result"
|
|
53
|
+
SIM_FAIL = "sim_fail"
|
|
54
|
+
#: saved criteria name a variable the last run did not record: a
|
|
55
|
+
#: baseline accepted now would not hold it (the owner's O3)
|
|
56
|
+
NEW_VARIABLES = "new_variables"
|
|
57
|
+
NO_REF = "no_ref"
|
|
58
|
+
#: the comparison refused the test (`reporting.status.refusal`): it
|
|
59
|
+
#: fails with a reason and nothing scored, and accepting does not
|
|
60
|
+
#: clear it — no capability names this as its next action
|
|
61
|
+
REFUSED = "refused"
|
|
62
|
+
FAIL = "fail"
|
|
63
|
+
SIMULATE_ONLY = "simulate_only"
|
|
64
|
+
PASS = "pass"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Strip:
|
|
69
|
+
situation: Situation
|
|
70
|
+
tone: str # "grey" | "red" | "amber"
|
|
71
|
+
sentence: str
|
|
72
|
+
#: A line under the strip, said once: why a running test's page shows
|
|
73
|
+
#: no growing trace (D151). Empty for every other strip.
|
|
74
|
+
note: str = ""
|
|
75
|
+
#: What the sentence is one line of — a failed run's message in full,
|
|
76
|
+
#: shown on hover. Empty where the sentence is the whole of it.
|
|
77
|
+
tip: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_ACCEPT_IT = "If the new behaviour is right, accept it as the baseline."
|
|
81
|
+
|
|
82
|
+
#: What a failed run's strip ends with: its one next action.
|
|
83
|
+
_FIX_IT = "fix the model or its settings, then queue it again."
|
|
84
|
+
#: The comparator's words for a failed run that said nothing of why.
|
|
85
|
+
_NO_REASON = "Simulation failed"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _why(failure: str | None) -> str:
|
|
89
|
+
"""A failed run's own reason as one line with no full stop — the
|
|
90
|
+
backend's message, as the Suite table's Message column has it — or
|
|
91
|
+
``""`` where the run recorded none."""
|
|
92
|
+
line = one_line(failure or "").rstrip(".")
|
|
93
|
+
return "" if line == _NO_REASON else line
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: The strip of a test whose run was cancelled (D150).
|
|
97
|
+
CANCELLED_SENTENCE = (
|
|
98
|
+
"Cancelled — no result (its earlier result was replaced when the run started)."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
#: Between the EDITS count and what the edits do to the verdict — in the
|
|
102
|
+
#: strip's sentence and on the pending drawer's line under each test.
|
|
103
|
+
SEP = " · "
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def verdict_phrase(status: str | None, saved_status: str | None) -> str:
|
|
107
|
+
"""What the edits do to the verdict: the EDITS clause after the count,
|
|
108
|
+
and the pending drawer's verdict line."""
|
|
109
|
+
if saved_status is None:
|
|
110
|
+
return f"a new entry, scored {status or '…'}"
|
|
111
|
+
if status == saved_status:
|
|
112
|
+
return f"verdict stays {status}"
|
|
113
|
+
return f"verdict would change {saved_status} → {status or '…'}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unrecorded_variables(
|
|
117
|
+
test: TestModel | None, result: TestResult | None, diagnostics: Iterable[str]
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
"""The variables ``test``'s criteria name (`tree_variables`, the rule
|
|
120
|
+
every backend tracks by) that ``result`` does not hold. ``[]`` without
|
|
121
|
+
a result: there is nothing to rerun FOR yet. ``[]`` for a FAILED one,
|
|
122
|
+
which holds no variables: every criterion's would count, and a rerun
|
|
123
|
+
asked for to score them over a simulation that fails."""
|
|
124
|
+
if test is None or result is None or not result.success:
|
|
125
|
+
return []
|
|
126
|
+
have = {v.name for v in result.variables}
|
|
127
|
+
return [
|
|
128
|
+
n for n in tree_variables(test.metric_tree_spec, diagnostics) if n not in have
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def new_variables(
|
|
133
|
+
test: TestModel | None, result: TestResult | None, diagnostics: Iterable[str]
|
|
134
|
+
) -> int:
|
|
135
|
+
"""How many variables only a rerun records: those of
|
|
136
|
+
:func:`unrecorded_variables`. A variable the result FILE holds is not
|
|
137
|
+
one of them — `Session.stage` re-reads the model when a criterion names
|
|
138
|
+
it (the owner's O2), so the result holds it by the time this counts.
|
|
139
|
+
0 for a simulate-only test: nothing is ever scored there, so no
|
|
140
|
+
variable is one a rerun would score (never NEW_VARIABLES or
|
|
141
|
+
EDITS_NEW)."""
|
|
142
|
+
if test is not None and test.simulate_only:
|
|
143
|
+
return 0
|
|
144
|
+
return len(unrecorded_variables(test, result, diagnostics))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _queue_it(n: int) -> str:
|
|
148
|
+
return f"queue the test to score {'it' if n == 1 else 'them'}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def strip_for(
|
|
152
|
+
*,
|
|
153
|
+
queue: QueueRow | None = None,
|
|
154
|
+
edits: int = 0,
|
|
155
|
+
status: str | None = None,
|
|
156
|
+
saved_status: str | None = None,
|
|
157
|
+
has_result: bool = False,
|
|
158
|
+
loading: bool = False,
|
|
159
|
+
scoring_failed: bool = False,
|
|
160
|
+
simulate_only: bool = False,
|
|
161
|
+
fails: int = 0,
|
|
162
|
+
criteria: int = 0,
|
|
163
|
+
advisory: int = 0,
|
|
164
|
+
new_variables: int = 0,
|
|
165
|
+
refused: str | None = None,
|
|
166
|
+
partial: Partial | None = None,
|
|
167
|
+
failure: str | None = None,
|
|
168
|
+
) -> Strip:
|
|
169
|
+
"""The strip for one test. ``queue`` is its row in the simulation
|
|
170
|
+
queue (`queue_rows.queue_row`), None when the queue lists no item of
|
|
171
|
+
it; ``edits`` its pending ops; ``status`` / ``saved_status`` the
|
|
172
|
+
classifier's word for the comparison as edited / as saved (None while
|
|
173
|
+
unscored, or — saved — for an entry the loaded spec does not have;
|
|
174
|
+
``reporting.status.SCORING_FAILED`` — saved — when scoring the saved
|
|
175
|
+
spec raised);
|
|
176
|
+
``has_result`` whether the last run finished it; ``loading`` whether
|
|
177
|
+
the project is still being read — an unscored test is then waiting for
|
|
178
|
+
it, never without a result, which may not be read yet; ``fails`` /
|
|
179
|
+
``criteria`` / ``advisory`` its leaves failing, in all, and failing
|
|
180
|
+
inside a warn; ``new_variables`` the variables its criteria name that
|
|
181
|
+
the last run did not record (:func:`new_variables`); ``refused`` why
|
|
182
|
+
the comparison refused the test (`reporting.status.refusal`), or None;
|
|
183
|
+
``partial`` what its last run left when it did not finish
|
|
184
|
+
(`Session.partial`), or None; ``failure`` the message its comparison
|
|
185
|
+
carries (`TestComparison.error_message`) — for a failed run the
|
|
186
|
+
backend's own, read back from the run's stored result, so it is there
|
|
187
|
+
after the queue's row is cleared and after the tool is started again."""
|
|
188
|
+
S = Situation
|
|
189
|
+
if queue is not None and queue.state is ItemState.WAITING:
|
|
190
|
+
return Strip(S.QUEUED, "grey", f"Queued — {queue.words}")
|
|
191
|
+
if queue is not None and queue.state is ItemState.RUNNING:
|
|
192
|
+
where = f" on worker {queue.worker}" if queue.worker is not None else ""
|
|
193
|
+
if queue.cancelling:
|
|
194
|
+
# A stop was asked and it has not ended yet (`QueueRow`).
|
|
195
|
+
return Strip(S.RUNNING, "grey", f"Cancelling{where}…")
|
|
196
|
+
# How far its result file has got, where the backend has a live
|
|
197
|
+
# view (D151); its seconds, and the line that says why, where not.
|
|
198
|
+
if queue.progress is not None:
|
|
199
|
+
return Strip(S.RUNNING, "grey", f"Running{where} — {queue.progress}")
|
|
200
|
+
return Strip(
|
|
201
|
+
S.RUNNING,
|
|
202
|
+
"grey",
|
|
203
|
+
f"Running{where} — {queue.elapsed or 0:.0f} s",
|
|
204
|
+
note=NOT_AVAILABLE,
|
|
205
|
+
)
|
|
206
|
+
if edits:
|
|
207
|
+
sentence = f"{plural(edits, 'unsaved edit')}{SEP}{verdict_phrase(status, saved_status)}"
|
|
208
|
+
if new_variables:
|
|
209
|
+
new = f"{plural(new_variables, 'new variable')}: {_queue_it(new_variables)}"
|
|
210
|
+
return Strip(S.EDITS_NEW, "amber", f"{sentence}{SEP}{new}")
|
|
211
|
+
return Strip(S.EDITS, "amber", sentence)
|
|
212
|
+
if scoring_failed:
|
|
213
|
+
return Strip(S.SCORING_FAILED, "red", "Scoring failed; the tree card says why.")
|
|
214
|
+
if status is None and (has_result or loading):
|
|
215
|
+
return Strip(S.WAITING, "grey", "Scoring…")
|
|
216
|
+
if partial is not None and status in (None, NOT_RUN, SIM_FAIL):
|
|
217
|
+
# Its last run did not finish and left a partial result (D151):
|
|
218
|
+
# where it stopped, and that nothing of it is scored. A cancel is
|
|
219
|
+
# nobody's failure; a timeout and a crash are the test's.
|
|
220
|
+
if partial.how == CANCELLED:
|
|
221
|
+
return Strip(S.CANCELLED, "grey", partial.sentence)
|
|
222
|
+
why = _why(failure) if status == SIM_FAIL else ""
|
|
223
|
+
return Strip(
|
|
224
|
+
S.SIM_FAIL,
|
|
225
|
+
"red",
|
|
226
|
+
f"{partial.sentence}. {why} — {_FIX_IT}" if why else partial.sentence,
|
|
227
|
+
tip=(failure or "") if why else "",
|
|
228
|
+
)
|
|
229
|
+
if queue is not None and queue.state is ItemState.CANCELLED:
|
|
230
|
+
return Strip(S.CANCELLED, "grey", CANCELLED_SENTENCE)
|
|
231
|
+
if queue is not None and queue.state is ItemState.FAILED and not queue.started:
|
|
232
|
+
said = queue.message or ""
|
|
233
|
+
return Strip(S.START_FAILED, "red", one_line(said) or queue.words, tip=said)
|
|
234
|
+
# NOT RUN is the classifier's word for this situation (D145): the
|
|
235
|
+
# header's badge and the strip say one thing. A SIM_FAIL is a run that
|
|
236
|
+
# reached a verdict, and reads as one below.
|
|
237
|
+
if status == NOT_RUN or (not has_result and status is None):
|
|
238
|
+
return Strip(S.NO_RESULT, "grey", "No result yet — queue it to simulate it.")
|
|
239
|
+
if status == SIM_FAIL:
|
|
240
|
+
# The backend's own reason, not only that it failed: it was on the
|
|
241
|
+
# queue's row alone, and Clear finished took it away.
|
|
242
|
+
why = _why(failure)
|
|
243
|
+
if why:
|
|
244
|
+
return Strip(S.SIM_FAIL, "red", f"{why} — {_FIX_IT}", tip=failure or "")
|
|
245
|
+
return Strip(
|
|
246
|
+
S.SIM_FAIL,
|
|
247
|
+
"red",
|
|
248
|
+
f"The simulation failed. {_FIX_IT[0].upper()}{_FIX_IT[1:]}",
|
|
249
|
+
)
|
|
250
|
+
if refused and status == FAIL:
|
|
251
|
+
# Before NEW_VARIABLES: a rerun records the variable, and the test
|
|
252
|
+
# is refused all the same. The reason, never "accept it": accepting
|
|
253
|
+
# keeps the reference's comparison settings, the refusal with them.
|
|
254
|
+
# A reason that ends its own sentence (`… did you mean "max_value"?`)
|
|
255
|
+
# is not given a second stop.
|
|
256
|
+
stop = "" if refused.endswith(("?", "!", ".")) else "."
|
|
257
|
+
return Strip(S.REFUSED, "red", f"Not scored: {refused}{stop}")
|
|
258
|
+
if new_variables:
|
|
259
|
+
return Strip(
|
|
260
|
+
S.NEW_VARIABLES,
|
|
261
|
+
"amber",
|
|
262
|
+
f"{plural(new_variables, 'new variable')} the last run did not "
|
|
263
|
+
f"record: {_queue_it(new_variables)}.",
|
|
264
|
+
)
|
|
265
|
+
if status == NO_REF:
|
|
266
|
+
return Strip(
|
|
267
|
+
S.NO_REF, "amber", "No baseline yet — accept this result as the first one."
|
|
268
|
+
)
|
|
269
|
+
if status == FAIL:
|
|
270
|
+
if fails:
|
|
271
|
+
what = f"Fails {fails} of {plural(criteria, 'criterion', 'criteria')}."
|
|
272
|
+
else:
|
|
273
|
+
what = "Fails against its baseline."
|
|
274
|
+
return Strip(S.FAIL, "red", f"{what} {_ACCEPT_IT}")
|
|
275
|
+
assert status == PASS, status
|
|
276
|
+
if simulate_only:
|
|
277
|
+
# The classifier's PASS for a test whose criterion is "it ran".
|
|
278
|
+
return Strip(
|
|
279
|
+
S.SIMULATE_ONLY, "grey", "Simulate-only: it runs, and nothing is compared."
|
|
280
|
+
)
|
|
281
|
+
if advisory:
|
|
282
|
+
return Strip(
|
|
283
|
+
S.PASS,
|
|
284
|
+
"grey",
|
|
285
|
+
f"Passes against its baseline, with {plural(advisory, 'advisory failure')}.",
|
|
286
|
+
)
|
|
287
|
+
return Strip(S.PASS, "grey", "Passes against its baseline.")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The spec as the Session holds it: the document as loaded, its content
|
|
2
|
+
hash, and the ops staged against it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import hashlib
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..config import ConfigError
|
|
15
|
+
from ..discovery.patch_apply import apply_patch_to_document
|
|
16
|
+
from ..discovery.spec_parser import spec_document_from_bytes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PatchOp:
|
|
21
|
+
"""One RFC 6902 op against one model's spec entry. ``path`` is relative
|
|
22
|
+
to the entry, as ``apply_patch`` reads it."""
|
|
23
|
+
|
|
24
|
+
model_id: str
|
|
25
|
+
op: str # "add" | "replace" | "remove"
|
|
26
|
+
path: str
|
|
27
|
+
value: Any = None
|
|
28
|
+
#: The ``add /metrics`` `Session.stage` prepends to a flat entry's first
|
|
29
|
+
#: per-leaf edit: the implicit tree written out, scoring the same. The
|
|
30
|
+
#: pending drawer diffs against the saved entry WITH it (`preview_base`)
|
|
31
|
+
#: so an edit reads as itself, not as the whole tree added; and while
|
|
32
|
+
#: the entry's ``metrics`` is this tree and nothing more, the ops that
|
|
33
|
+
#: wrote it are no edit — they are dropped, whatever else is pending
|
|
34
|
+
#: but an edit of the ``variable_overrides`` the tree is built from
|
|
35
|
+
#: (`Session.stage`). Not part of the op: not in `raw()`, not in its
|
|
36
|
+
#: equality.
|
|
37
|
+
materializes: bool = field(default=False, compare=False)
|
|
38
|
+
#: An ``add /metrics`` that is an EDIT of the tree its entry scores by,
|
|
39
|
+
#: given whole — one gesture on a folded criteria row (D152,
|
|
40
|
+
#: `family_rows.as_one`) — rather than a new tree: `Session.stage`
|
|
41
|
+
#: writes a flat entry's implicit tree out first, marked, exactly as
|
|
42
|
+
#: for a per-leaf op, so the preview diffs the edit against that tree
|
|
43
|
+
#: and says Save writes it. Not part of the op, as ``materializes``.
|
|
44
|
+
edits_tree: bool = field(default=False, compare=False)
|
|
45
|
+
#: What the op did, in words, for the preview to say above its entry's
|
|
46
|
+
#: diff — ``(key, sentence)`` pairs: where a later op replaces this one
|
|
47
|
+
#: (`Session.stage`), a sentence under the same key replaces its
|
|
48
|
+
#: sentence and any other is kept, so a value typed over is one line
|
|
49
|
+
#: and two gestures are two. Not part of the op.
|
|
50
|
+
notes: tuple[tuple[str, str], ...] = field(default=(), compare=False)
|
|
51
|
+
#: Which act of the user's staged it (`Session.stage` numbers its
|
|
52
|
+
#: calls): one Ctrl+click on a flat test's plot is two ops — the tree
|
|
53
|
+
#: written out, and the row — and one edit; so is an add over twenty
|
|
54
|
+
#: Indices, and a change of a folded row's twenty criteria. An op that
|
|
55
|
+
#: supersedes the last one keeps that one's number: a value typed over
|
|
56
|
+
#: is the same edit. None for an op nothing staged (a test's own list):
|
|
57
|
+
#: it counts as itself (`edit_counts`). Not part of the op.
|
|
58
|
+
gesture: int | None = field(default=None, compare=False)
|
|
59
|
+
|
|
60
|
+
def raw(self) -> dict:
|
|
61
|
+
"""The op as ``apply_patch`` and the envelope carry it."""
|
|
62
|
+
raw: dict = {"op": self.op, "path": self.path}
|
|
63
|
+
if self.op != "remove":
|
|
64
|
+
raw["value"] = copy.deepcopy(self.value)
|
|
65
|
+
return raw
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def edit_counts(pending: Iterable[PatchOp]) -> Counter[str]:
|
|
69
|
+
"""By model, how many EDITS ``pending`` holds of its entry: the things
|
|
70
|
+
a person did (`PatchOp.gesture`), not the ops they took. The ONE count
|
|
71
|
+
— the header's ``N pending edits``, Save's caption, the Suite's Edits
|
|
72
|
+
cell, the Tests drawer's mark, the strip's ``N unsaved edits`` and the
|
|
73
|
+
preview's ``N edits`` all read it, so no two of them can disagree."""
|
|
74
|
+
edits = {
|
|
75
|
+
(op.model_id, ("op", index) if op.gesture is None else op.gesture)
|
|
76
|
+
for index, op in enumerate(pending)
|
|
77
|
+
}
|
|
78
|
+
return Counter(model_id for model_id, _ in edits)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def edit_count(pending: Iterable[PatchOp]) -> int:
|
|
82
|
+
"""How many edits ``pending`` holds in all (`edit_counts`)."""
|
|
83
|
+
return sum(edit_counts(pending).values())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def content_hash(path: Path) -> str | None:
|
|
87
|
+
"""SHA-256 of the file's bytes; ``None`` when it does not exist."""
|
|
88
|
+
try:
|
|
89
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def json_equal(a: Any, b: Any) -> bool:
|
|
95
|
+
"""Whether ``a`` and ``b`` are one JSON value: objects by their members
|
|
96
|
+
in any order, arrays in order, numbers by value (``1`` is ``1.0``), and
|
|
97
|
+
``true`` is no number — Python's ``==`` calls ``True`` and ``1`` equal."""
|
|
98
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
99
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
100
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
101
|
+
return a.keys() == b.keys() and all(json_equal(a[k], b[k]) for k in a)
|
|
102
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
103
|
+
return len(a) == len(b) and all(json_equal(x, y) for x, y in zip(a, b))
|
|
104
|
+
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
105
|
+
return False
|
|
106
|
+
return bool(a == b)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class SpecDocument:
|
|
111
|
+
path: Path
|
|
112
|
+
loaded: dict # as read; never mutated
|
|
113
|
+
content_hash: str | None # of the bytes ``loaded`` came from
|
|
114
|
+
pending: list[PatchOp] = field(default_factory=list)
|
|
115
|
+
#: ``(len(pending), document)`` — the last replay, reused while pending
|
|
116
|
+
#: only grows (the Session never edits an op in place; it replaces the
|
|
117
|
+
#: document). A rescore over every test is then one replay, not one per
|
|
118
|
+
#: test.
|
|
119
|
+
_effective_cache: tuple[int, dict] | None = field(
|
|
120
|
+
default=None, repr=False, compare=False
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def load(cls, path: Path) -> SpecDocument:
|
|
125
|
+
"""Read ``path`` once: the hash and the document come from the same
|
|
126
|
+
bytes. A missing file is the empty document; one that is no spec —
|
|
127
|
+
no UTF-8 text, no JSON, a non-object root, a non-list ``tests`` — is
|
|
128
|
+
a :class:`ConfigError` (``spec_parser.spec_document_from_bytes``,
|
|
129
|
+
the one decoder every command reads the file through), because the
|
|
130
|
+
Session must not save over a file it could not read. A Save writes
|
|
131
|
+
a file read with a BOM back without it."""
|
|
132
|
+
try:
|
|
133
|
+
raw = path.read_bytes()
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
return cls(path=path, loaded={"tests": []}, content_hash=None)
|
|
136
|
+
except OSError as e:
|
|
137
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
138
|
+
data = spec_document_from_bytes(raw, str(path))
|
|
139
|
+
return cls(path=path, loaded=data, content_hash=hashlib.sha256(raw).hexdigest())
|
|
140
|
+
|
|
141
|
+
def on_disk_changed(self) -> bool:
|
|
142
|
+
return content_hash(self.path) != self.content_hash
|
|
143
|
+
|
|
144
|
+
def grouped(self) -> list[tuple[str, list[dict]]]:
|
|
145
|
+
"""Pending ops as ``(model_id, raw ops)`` groups: models in
|
|
146
|
+
first-seen order, ops in staging order."""
|
|
147
|
+
groups: dict[str, list[dict]] = {}
|
|
148
|
+
for op in self.pending:
|
|
149
|
+
groups.setdefault(op.model_id, []).append(op.raw())
|
|
150
|
+
return list(groups.items())
|
|
151
|
+
|
|
152
|
+
def saved_entry(self, model_id: str) -> dict | None:
|
|
153
|
+
"""The model's entry as LOADED — whatever is pending — as a copy the
|
|
154
|
+
caller may keep; ``None`` when the loaded document has no entry for
|
|
155
|
+
it (a pending op may be creating one)."""
|
|
156
|
+
from ..discovery.spec_update import spec_entry
|
|
157
|
+
|
|
158
|
+
entry = spec_entry(self.loaded, model_id)
|
|
159
|
+
return copy.deepcopy(entry) if entry is not None else None
|
|
160
|
+
|
|
161
|
+
def as_saved(self, model_id: str) -> bool:
|
|
162
|
+
"""Whether the model's entry with the pending ops applied is the
|
|
163
|
+
one the file holds (:func:`json_equal`): ops that put it back are no
|
|
164
|
+
edit of it. A model the file has no entry for is as saved while its
|
|
165
|
+
entry would hold its model and nothing else — an entry that says
|
|
166
|
+
nothing (a tree added to an annotation-only test, then removed).
|
|
167
|
+
The document's half of the rule: a ``metrics`` member that is the
|
|
168
|
+
tree the saved entry already scores by is no edit either, and only
|
|
169
|
+
the Session knows that tree — it takes those ops out before asking
|
|
170
|
+
this (`Session._its_tree_written_out`)."""
|
|
171
|
+
from ..discovery.spec_update import spec_entry
|
|
172
|
+
|
|
173
|
+
bare = {"model": model_id}
|
|
174
|
+
return json_equal(
|
|
175
|
+
self.entry(model_id) or bare, spec_entry(self.loaded, model_id) or bare
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def preview_base(self, model_id: str) -> dict | None:
|
|
179
|
+
"""What the pending drawer diffs the model's entry against: the
|
|
180
|
+
saved entry with the model's materializing ops applied (a new entry
|
|
181
|
+
starts as ``{"model": model_id}``), a copy; with none of them, the
|
|
182
|
+
saved entry (``None`` for a new one)."""
|
|
183
|
+
from ..discovery.spec_update import spec_entry
|
|
184
|
+
|
|
185
|
+
ops = [
|
|
186
|
+
op.raw()
|
|
187
|
+
for op in self.pending
|
|
188
|
+
if op.model_id == model_id and op.materializes
|
|
189
|
+
]
|
|
190
|
+
if not ops:
|
|
191
|
+
return self.saved_entry(model_id)
|
|
192
|
+
data = copy.deepcopy(self.loaded)
|
|
193
|
+
apply_patch_to_document(data, model_id, ops)
|
|
194
|
+
return copy.deepcopy(spec_entry(data, model_id))
|
|
195
|
+
|
|
196
|
+
def effective(self) -> dict:
|
|
197
|
+
"""A copy of ``loaded`` with every pending op applied."""
|
|
198
|
+
return copy.deepcopy(self._effective())
|
|
199
|
+
|
|
200
|
+
def entry(self, model_id: str) -> dict | None:
|
|
201
|
+
"""The model's entry in the effective document — read it, never
|
|
202
|
+
edit it (it is the cached replay)."""
|
|
203
|
+
from ..discovery.spec_update import spec_entry
|
|
204
|
+
|
|
205
|
+
return spec_entry(self._effective(), model_id)
|
|
206
|
+
|
|
207
|
+
def _effective(self) -> dict:
|
|
208
|
+
n = len(self.pending)
|
|
209
|
+
if self._effective_cache is not None and self._effective_cache[0] == n:
|
|
210
|
+
return self._effective_cache[1]
|
|
211
|
+
data = copy.deepcopy(self.loaded)
|
|
212
|
+
for model_id, ops in self.grouped():
|
|
213
|
+
apply_patch_to_document(data, model_id, ops)
|
|
214
|
+
self._effective_cache = (n, data)
|
|
215
|
+
return data
|