labforge 0.1.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.
- labforge/__init__.py +17 -0
- labforge/controls.py +278 -0
- labforge/figures.py +92 -0
- labforge/lab.py +165 -0
- labforge/mathtext.py +140 -0
- labforge/pages/__init__.py +6 -0
- labforge/pages/analysis.py +99 -0
- labforge/pages/panel.py +109 -0
- labforge/pages/scroll.py +61 -0
- labforge/pages/simulation.py +71 -0
- labforge/pages/theory.py +40 -0
- labforge/pages/visualization.py +30 -0
- labforge/param.py +186 -0
- labforge/registry.py +36 -0
- labforge/scan.py +75 -0
- labforge/shell.py +220 -0
- labforge/state.py +74 -0
- labforge/theme.py +323 -0
- labforge/ui.py +167 -0
- labforge-0.1.0.dist-info/METADATA +232 -0
- labforge-0.1.0.dist-info/RECORD +23 -0
- labforge-0.1.0.dist-info/WHEEL +4 -0
- labforge-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analysis page: one tab per registered analysis, with a dispatched result pane.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import flet as ft
|
|
6
|
+
|
|
7
|
+
from .. import ui
|
|
8
|
+
from ..figures import figure_to_base64, is_figure, unpack_figure
|
|
9
|
+
from . import panel
|
|
10
|
+
|
|
11
|
+
EMPTY = "No analyses registered — add one with Lab.add_analysis."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def section_controls(entry, state, page):
|
|
15
|
+
"""
|
|
16
|
+
One analysis's description, kwarg controls, Compute action and result pane,
|
|
17
|
+
as a list of controls. Shared by the tabbed page and the scroll layout.
|
|
18
|
+
"""
|
|
19
|
+
pane = ft.Container()
|
|
20
|
+
|
|
21
|
+
def show(value):
|
|
22
|
+
# Swap only the content: the mounted Container stays, so the tab survives.
|
|
23
|
+
pane.content = render_result(value)
|
|
24
|
+
|
|
25
|
+
return panel.section_controls(
|
|
26
|
+
entry, state, state.analysis_values[entry.title], page, "Compute", pane, show
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build(state, page):
|
|
31
|
+
"""Build the tabbed analysis page."""
|
|
32
|
+
return panel.build("Analysis", state.lab.analyses, state, page, section_controls, EMPTY)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render_result(value):
|
|
36
|
+
"""
|
|
37
|
+
Dispatch an analysis return to a display control.
|
|
38
|
+
|
|
39
|
+
What an analysis may return is deliberately loose: the author writes plain
|
|
40
|
+
Python and the shape of the return picks the treatment.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
value: object
|
|
45
|
+
dict, list of dicts, DataFrame-like, str, Figure or (fig, ax), or
|
|
46
|
+
anything with a repr as the fallback.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
A Flet control rendering the value.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return two_column_table(value)
|
|
54
|
+
if isinstance(value, list) and value and all(isinstance(row, dict) for row in value):
|
|
55
|
+
return records_table(value)
|
|
56
|
+
if hasattr(value, "columns") and hasattr(value, "itertuples"): # DataFrame, duck-typed
|
|
57
|
+
return records_table(
|
|
58
|
+
[dict(zip(value.columns, row)) for row in value.itertuples(index=False)]
|
|
59
|
+
)
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
return ui.body(value)
|
|
62
|
+
if is_figure(value):
|
|
63
|
+
return ui.image(figure_to_base64(unpack_figure(value)))
|
|
64
|
+
return ui.body(f"`{value!r}`")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_cell(value):
|
|
68
|
+
"""Compact display form of one table cell."""
|
|
69
|
+
return f"{value:.4g}" if isinstance(value, float) else str(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def two_column_table(mapping):
|
|
73
|
+
"""A {quantity: value} dict as a two-column table."""
|
|
74
|
+
return ui.data_table(
|
|
75
|
+
columns=[
|
|
76
|
+
ft.DataColumn(ft.Text("QUANTITY")),
|
|
77
|
+
ft.DataColumn(ft.Text("VALUE"), numeric=True),
|
|
78
|
+
],
|
|
79
|
+
rows=[
|
|
80
|
+
ft.DataRow(
|
|
81
|
+
cells=[ft.DataCell(ft.Text(str(key))), ft.DataCell(ft.Text(format_cell(value)))]
|
|
82
|
+
)
|
|
83
|
+
for key, value in mapping.items()
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def records_table(records):
|
|
89
|
+
"""A list of homogeneous dicts as a table, columns taken from the first record."""
|
|
90
|
+
columns = list(records[0])
|
|
91
|
+
return ui.data_table(
|
|
92
|
+
columns=[ft.DataColumn(ft.Text(str(name).upper())) for name in columns],
|
|
93
|
+
rows=[
|
|
94
|
+
ft.DataRow(
|
|
95
|
+
cells=[ft.DataCell(ft.Text(format_cell(record.get(name)))) for name in columns]
|
|
96
|
+
)
|
|
97
|
+
for record in records
|
|
98
|
+
],
|
|
99
|
+
)
|
labforge/pages/panel.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared machinery for the visualization and analysis pages, which differ only in
|
|
3
|
+
what they put on screen.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import flet as ft
|
|
7
|
+
|
|
8
|
+
from .. import ui
|
|
9
|
+
from ..controls import build_control
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def section_controls(entry, state, values, page, action, output, show):
|
|
13
|
+
"""
|
|
14
|
+
Build one registered entry as a stackable list: the optional description,
|
|
15
|
+
the controls card, and output.
|
|
16
|
+
|
|
17
|
+
The output control is persistent: show() mutates it in place rather than
|
|
18
|
+
replacing it, so the open tab survives a re-render. The first paint runs
|
|
19
|
+
from the stored kwargs, so returning to a page shows finished output rather
|
|
20
|
+
than a blank pane.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
values: dict
|
|
25
|
+
The entry's persisted {kwarg: value}; controls read and commit to it.
|
|
26
|
+
action: str
|
|
27
|
+
Action button label ("Render", "Compute").
|
|
28
|
+
output: Control
|
|
29
|
+
The persistent pane show() writes into.
|
|
30
|
+
show: callable
|
|
31
|
+
show(result) -> None, writing the entry function's return into output.
|
|
32
|
+
"""
|
|
33
|
+
bindings = {
|
|
34
|
+
name: build_control(name, param, values, page) for name, param in entry.params.items()
|
|
35
|
+
}
|
|
36
|
+
status = ui.mono("")
|
|
37
|
+
|
|
38
|
+
def refresh():
|
|
39
|
+
kwargs = {name: binding.read() for name, binding in bindings.items()}
|
|
40
|
+
try:
|
|
41
|
+
show(entry.func(state.data, **kwargs))
|
|
42
|
+
status.value = ""
|
|
43
|
+
except Exception as err: # surface the author's failure, keep the app alive
|
|
44
|
+
status.value = f"{entry.title} raised {type(err).__name__}: {err}"
|
|
45
|
+
|
|
46
|
+
def on_action(e):
|
|
47
|
+
refresh()
|
|
48
|
+
page.update()
|
|
49
|
+
|
|
50
|
+
refresh() # first paint, from the kwargs the last visit left behind
|
|
51
|
+
controls_card = ui.card(
|
|
52
|
+
ft.Column(
|
|
53
|
+
spacing=12,
|
|
54
|
+
controls=[
|
|
55
|
+
*[binding.row for binding in bindings.values()],
|
|
56
|
+
ui.action_button(action, on_action),
|
|
57
|
+
status,
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
header = [ui.body(entry.desc)] if entry.desc else []
|
|
62
|
+
return [*header, controls_card, output]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build(title, entries, state, page, section, empty):
|
|
66
|
+
"""
|
|
67
|
+
Build a tabbed page — one tab per registered entry, behind the run gate —
|
|
68
|
+
or the placeholder / run-gate scaffold when there is nothing to show.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
section: callable
|
|
73
|
+
section(entry, state, page) -> list of controls for one entry.
|
|
74
|
+
empty: str
|
|
75
|
+
Placeholder text shown when nothing is registered.
|
|
76
|
+
"""
|
|
77
|
+
if not entries:
|
|
78
|
+
return ui.page_scaffold(title, [ui.placeholder(empty)])
|
|
79
|
+
if not state.has_data():
|
|
80
|
+
return ui.page_scaffold(title, [ui.needs_run()])
|
|
81
|
+
|
|
82
|
+
# Flet 0.86 Tabs is a controller wrapping a separate TabBar and TabBarView,
|
|
83
|
+
# whose control count must equal length.
|
|
84
|
+
tabs = ft.Tabs(
|
|
85
|
+
length=len(entries),
|
|
86
|
+
expand=True,
|
|
87
|
+
content=ft.Column(
|
|
88
|
+
expand=True,
|
|
89
|
+
controls=[
|
|
90
|
+
ft.TabBar(tabs=[ft.Tab(label=entry.title) for entry in entries]),
|
|
91
|
+
ft.TabBarView(
|
|
92
|
+
expand=True,
|
|
93
|
+
# Each tab body scrolls itself; see the column note below.
|
|
94
|
+
controls=[
|
|
95
|
+
ft.Column(
|
|
96
|
+
expand=True,
|
|
97
|
+
scroll=ft.ScrollMode.AUTO,
|
|
98
|
+
spacing=12,
|
|
99
|
+
controls=section(entry, state, page),
|
|
100
|
+
)
|
|
101
|
+
for entry in entries
|
|
102
|
+
],
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
# Deliberately not a scrolling column: an expanding TabBarView needs bounded
|
|
108
|
+
# ancestor heights, so each tab body scrolls on its own instead.
|
|
109
|
+
return ft.Column(expand=True, spacing=16, controls=[ui.heading(title), tabs])
|
labforge/pages/scroll.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scroll layout: the whole lab as one continuous page, refreshed in place by Run.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import flet as ft
|
|
6
|
+
|
|
7
|
+
from .. import ui
|
|
8
|
+
from . import analysis, simulation, theory, visualization
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def divider():
|
|
12
|
+
"""Hairline rule between sections."""
|
|
13
|
+
return ft.Divider(height=1, color=ft.Colors.OUTLINE_VARIANT)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def results_content(state, page):
|
|
17
|
+
"""The visualization and analysis sections, or the run gate before data."""
|
|
18
|
+
if not state.has_data():
|
|
19
|
+
return ft.Column(spacing=16, controls=[ui.heading("Results"), ui.needs_run()])
|
|
20
|
+
|
|
21
|
+
# Both kinds stack the same way; only the section builder differs.
|
|
22
|
+
sections = [(entry, visualization.section_controls) for entry in state.lab.vizzes]
|
|
23
|
+
sections += [(entry, analysis.section_controls) for entry in state.lab.analyses]
|
|
24
|
+
controls = []
|
|
25
|
+
for entry, section in sections:
|
|
26
|
+
controls += [ui.heading(entry.title), *section(entry, state, page), divider()]
|
|
27
|
+
if controls:
|
|
28
|
+
controls.pop() # no rule after the last section
|
|
29
|
+
return ft.Column(spacing=16, controls=controls)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build(state, page):
|
|
33
|
+
"""
|
|
34
|
+
Build the single-page layout for the whole lab.
|
|
35
|
+
|
|
36
|
+
With no navigation there is no rebuild-on-navigate to refresh downstream
|
|
37
|
+
sections, so Run gets an after_run hook that rebuilds the mounted results
|
|
38
|
+
container in place; the handler's page.update() then paints it in one flush.
|
|
39
|
+
Tabs are avoided deliberately: an expanding TabBarView needs bounded
|
|
40
|
+
ancestor heights, which a scrolling column cannot provide.
|
|
41
|
+
"""
|
|
42
|
+
results = ft.Container()
|
|
43
|
+
|
|
44
|
+
def refresh():
|
|
45
|
+
results.content = results_content(state, page)
|
|
46
|
+
|
|
47
|
+
refresh()
|
|
48
|
+
return ft.Column(
|
|
49
|
+
expand=True,
|
|
50
|
+
scroll=ft.ScrollMode.AUTO,
|
|
51
|
+
spacing=16,
|
|
52
|
+
controls=[
|
|
53
|
+
ui.heading("Theory"),
|
|
54
|
+
*theory.content(state),
|
|
55
|
+
divider(),
|
|
56
|
+
ui.heading("Simulation"),
|
|
57
|
+
*simulation.section(state, page, after_run=refresh),
|
|
58
|
+
divider(),
|
|
59
|
+
results,
|
|
60
|
+
],
|
|
61
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simulation page: the worker's controls, a Run action and a status line.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import flet as ft
|
|
6
|
+
|
|
7
|
+
from .. import ui
|
|
8
|
+
from ..controls import build_control
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def section(state, page, after_run=None):
|
|
12
|
+
"""
|
|
13
|
+
Build the worker's intro prose and controls card as a stackable list.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
after_run: callable
|
|
18
|
+
Invoked after a successful run, before the page update — the scroll
|
|
19
|
+
layout uses it to rebuild the visualization and analysis sections.
|
|
20
|
+
"""
|
|
21
|
+
worker = state.lab.worker
|
|
22
|
+
bindings = {
|
|
23
|
+
name: build_control(name, param, state.worker_values, page)
|
|
24
|
+
for name, param in worker.params.items()
|
|
25
|
+
}
|
|
26
|
+
# Flet 0.86 has no SnackBar, so status lives in the tree where page.update()
|
|
27
|
+
# always reaches it.
|
|
28
|
+
status = ui.mono(state.run_summary)
|
|
29
|
+
|
|
30
|
+
def on_run(e):
|
|
31
|
+
for name, binding in bindings.items():
|
|
32
|
+
state.worker_values[name] = binding.read()
|
|
33
|
+
try:
|
|
34
|
+
# Synchronous in the handler: workers are expected fast. page.run_task
|
|
35
|
+
# with a worker thread is the seam if long runs ever need a live UI.
|
|
36
|
+
state.run()
|
|
37
|
+
status.value = state.run_summary
|
|
38
|
+
if after_run is not None:
|
|
39
|
+
after_run()
|
|
40
|
+
except Exception as err: # surface the worker's failure, keep the app alive
|
|
41
|
+
status.value = f"Worker raised {type(err).__name__}: {err}"
|
|
42
|
+
page.update()
|
|
43
|
+
|
|
44
|
+
scannable = [name for name, param in worker.params.items() if param.scan]
|
|
45
|
+
scan_note = (
|
|
46
|
+
f" Parameters marked scan ({', '.join(scannable)}) accept comma-separated "
|
|
47
|
+
"values; the worker then runs once per point of the cartesian grid."
|
|
48
|
+
if scannable
|
|
49
|
+
else ""
|
|
50
|
+
)
|
|
51
|
+
intro = ui.body(
|
|
52
|
+
f"Set the parameters of **{worker.func.__name__}** and press Run. "
|
|
53
|
+
"The visualization and analysis sections read the most recent result." + scan_note
|
|
54
|
+
)
|
|
55
|
+
controls_card = ui.card(
|
|
56
|
+
ft.Column(
|
|
57
|
+
spacing=12,
|
|
58
|
+
controls=[
|
|
59
|
+
ui.subheading("Parameters"),
|
|
60
|
+
*[binding.row for binding in bindings.values()],
|
|
61
|
+
ui.action_button("Run", on_run),
|
|
62
|
+
status,
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return [intro, controls_card]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build(state, page):
|
|
70
|
+
"""Build the simulation page for the registered worker."""
|
|
71
|
+
return ui.page_scaffold("Simulation", section(state, page))
|
labforge/pages/theory.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Theory page: markdown prose with $$...$$ blocks rendered as LaTeX images.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .. import ui
|
|
8
|
+
|
|
9
|
+
EQUATION_BLOCK = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
|
|
10
|
+
|
|
11
|
+
EMPTY = "No theory registered — pass a markdown file or string to Lab.set_theory."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def content(state):
|
|
15
|
+
"""
|
|
16
|
+
The theory controls — prose and equations, or a placeholder when unset.
|
|
17
|
+
|
|
18
|
+
Flet's Markdown control has no reliable LaTeX support, so displayed
|
|
19
|
+
equations are carried in $$...$$ blocks and rendered as images instead.
|
|
20
|
+
Inline math should use Unicode symbols in the prose.
|
|
21
|
+
"""
|
|
22
|
+
source = state.lab.theory_source
|
|
23
|
+
if not source:
|
|
24
|
+
return [ui.placeholder(EMPTY)]
|
|
25
|
+
controls = []
|
|
26
|
+
for index, piece in enumerate(EQUATION_BLOCK.split(source)):
|
|
27
|
+
piece = piece.strip()
|
|
28
|
+
if not piece:
|
|
29
|
+
continue
|
|
30
|
+
# split() alternates prose and captures, so odd indices are the equations.
|
|
31
|
+
# An equation collapses to one line: matplotlib renders each line of a
|
|
32
|
+
# multi-line string separately, and a line with an unmatched $ renders
|
|
33
|
+
# as literal text rather than math.
|
|
34
|
+
controls.append(ui.equation(" ".join(piece.split())) if index % 2 else ui.body(piece))
|
|
35
|
+
return controls
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build(state, page):
|
|
39
|
+
"""Build the Theory page from the lab's markdown source."""
|
|
40
|
+
return ui.page_scaffold("Theory", content(state))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization page: one tab per registered viz, each rendering a figure.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .. import ui
|
|
6
|
+
from ..figures import figure_to_base64, unpack_figure
|
|
7
|
+
from . import panel
|
|
8
|
+
|
|
9
|
+
EMPTY = "No visualizations registered — add one with Lab.add_viz."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def section_controls(entry, state, page):
|
|
13
|
+
"""
|
|
14
|
+
One viz's description, kwarg controls, Render action and figure, as a list
|
|
15
|
+
of controls. Shared by the tabbed page and the scroll layout.
|
|
16
|
+
"""
|
|
17
|
+
image = ui.image("")
|
|
18
|
+
|
|
19
|
+
def show(value):
|
|
20
|
+
# Swap only the src: the mounted Image stays, so the open tab survives.
|
|
21
|
+
image.src = figure_to_base64(unpack_figure(value))
|
|
22
|
+
|
|
23
|
+
return panel.section_controls(
|
|
24
|
+
entry, state, state.viz_values[entry.title], page, "Render", image, show
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build(state, page):
|
|
29
|
+
"""Build the tabbed visualization page."""
|
|
30
|
+
return panel.build("Visualization", state.lab.vizzes, state, page, section_controls, EMPTY)
|
labforge/param.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Param specs and the registration-time normalization of an author's spec dict.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Param:
|
|
12
|
+
"""
|
|
13
|
+
One adjustable kwarg of a worker, viz or analysis function.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
kind: str
|
|
18
|
+
"scalar" (float), "int", or "tuple".
|
|
19
|
+
default: object
|
|
20
|
+
Starting value; filled from the function signature default when None.
|
|
21
|
+
bounds: tuple
|
|
22
|
+
(lo, hi) numeric range. Bounded scalars and ints render as sliders,
|
|
23
|
+
unbounded ones as validated text fields.
|
|
24
|
+
step: float
|
|
25
|
+
Slider granularity; (hi - lo) / 100 when omitted, 1 for ints.
|
|
26
|
+
scan: bool
|
|
27
|
+
Worker kwargs only: accept comma-separated values to sweep a grid.
|
|
28
|
+
size: int
|
|
29
|
+
Tuple length (kind "tuple" only).
|
|
30
|
+
label: str
|
|
31
|
+
Control label; the kwarg name when omitted.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: str = "scalar"
|
|
35
|
+
default: object = None
|
|
36
|
+
bounds: tuple = None
|
|
37
|
+
step: float = None
|
|
38
|
+
scan: bool = False
|
|
39
|
+
size: int = 2
|
|
40
|
+
label: str = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Shorthand strings accepted in spec dicts, checked before the N-tuple pattern.
|
|
44
|
+
SHORTHANDS = {
|
|
45
|
+
"scalar": dict(kind="scalar"),
|
|
46
|
+
"int": dict(kind="int"),
|
|
47
|
+
"array": dict(kind="scalar", scan=True),
|
|
48
|
+
"scalar or array": dict(kind="scalar", scan=True),
|
|
49
|
+
"int or array": dict(kind="int", scan=True),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
TUPLE_PATTERN = re.compile(r"^(\d+)-tuple$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_shorthand(text):
|
|
56
|
+
"""
|
|
57
|
+
Convert a shorthand string — a SHORTHANDS key or "N-tuple" (e.g. "2-tuple")
|
|
58
|
+
— into a Param with kind/scan/size set; default left to signature inference.
|
|
59
|
+
"""
|
|
60
|
+
key = text.strip().lower()
|
|
61
|
+
if key in SHORTHANDS:
|
|
62
|
+
return Param(**SHORTHANDS[key])
|
|
63
|
+
match = TUPLE_PATTERN.match(key)
|
|
64
|
+
if match:
|
|
65
|
+
return Param(kind="tuple", size=int(match.group(1)))
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Unknown param shorthand {text!r}; expected one of " f"{sorted(SHORTHANDS)} or 'N-tuple'."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def infer_param(default):
|
|
72
|
+
"""Infer a Param from a signature default: bool/int, tuple, else scalar."""
|
|
73
|
+
# bool before int: bool is an int subclass, and a checkbox-ish 0/1 is closer
|
|
74
|
+
# to the author's intent than a float slider.
|
|
75
|
+
if isinstance(default, bool):
|
|
76
|
+
return Param(kind="int", default=int(default))
|
|
77
|
+
if isinstance(default, int):
|
|
78
|
+
return Param(kind="int", default=default)
|
|
79
|
+
if isinstance(default, (tuple, list)):
|
|
80
|
+
return Param(kind="tuple", default=tuple(default), size=len(default))
|
|
81
|
+
return Param(kind="scalar", default=float(default))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_spec(func, spec, skip_first=False, allow_scan=True):
|
|
85
|
+
"""
|
|
86
|
+
Validate a spec dict against a function signature and fill in defaults.
|
|
87
|
+
|
|
88
|
+
Every named keyword-capable parameter of func gets a Param: spec entries
|
|
89
|
+
(Param instances or shorthand strings) take precedence, and unspecced
|
|
90
|
+
parameters are inferred from their signature defaults. Fails loudly at
|
|
91
|
+
registration on unknown spec keys, unresolvable defaults, out-of-bounds
|
|
92
|
+
defaults, or a scan spec where scanning is not allowed — so a malformed app
|
|
93
|
+
dies at assembly rather than mid-navigation.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
func: callable
|
|
98
|
+
The registered worker, viz or analysis function.
|
|
99
|
+
spec: dict
|
|
100
|
+
{kwarg name: Param or shorthand str}, or None for pure inference.
|
|
101
|
+
skip_first: bool
|
|
102
|
+
Skip the leading positional parameter (the data argument of viz and
|
|
103
|
+
analysis functions).
|
|
104
|
+
allow_scan: bool
|
|
105
|
+
Reject scan=True entries when False (scanning is a worker concept).
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
dict of {name: Param} in signature order, every default resolved.
|
|
110
|
+
"""
|
|
111
|
+
spec = dict(spec or {})
|
|
112
|
+
signature = inspect.signature(func)
|
|
113
|
+
parameters = list(signature.parameters.values())
|
|
114
|
+
if skip_first:
|
|
115
|
+
parameters = parameters[1:]
|
|
116
|
+
|
|
117
|
+
named = [
|
|
118
|
+
p
|
|
119
|
+
for p in parameters
|
|
120
|
+
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
121
|
+
]
|
|
122
|
+
# A **kwargs function can take spec keys its signature never names.
|
|
123
|
+
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters)
|
|
124
|
+
|
|
125
|
+
known = {p.name for p in named}
|
|
126
|
+
for key in spec:
|
|
127
|
+
if key not in known and not has_var_keyword:
|
|
128
|
+
raise ValueError(f"{func.__name__} has no parameter {key!r}.")
|
|
129
|
+
|
|
130
|
+
# Signature order drives the control layout; **kwargs-only entries trail it.
|
|
131
|
+
normalized = {}
|
|
132
|
+
order = [p.name for p in named] + [k for k in spec if k not in known]
|
|
133
|
+
defaults = {p.name: p.default for p in named}
|
|
134
|
+
for name in order:
|
|
135
|
+
entry = spec.get(name)
|
|
136
|
+
if isinstance(entry, str):
|
|
137
|
+
entry = parse_shorthand(entry)
|
|
138
|
+
# Resolve the default: spec first, signature second, error last.
|
|
139
|
+
signature_default = defaults.get(name, inspect.Parameter.empty)
|
|
140
|
+
if entry is None:
|
|
141
|
+
if signature_default is inspect.Parameter.empty:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{func.__name__} parameter {name!r} needs a spec entry or a "
|
|
144
|
+
"signature default."
|
|
145
|
+
)
|
|
146
|
+
entry = infer_param(signature_default)
|
|
147
|
+
elif entry.default is None:
|
|
148
|
+
if signature_default is inspect.Parameter.empty:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"{func.__name__} parameter {name!r} has no default in its spec "
|
|
151
|
+
"or signature."
|
|
152
|
+
)
|
|
153
|
+
entry.default = signature_default
|
|
154
|
+
validate_param(func, name, entry, allow_scan)
|
|
155
|
+
normalized[name] = entry
|
|
156
|
+
return normalized
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_param(func, name, param, allow_scan):
|
|
160
|
+
"""
|
|
161
|
+
Check one resolved Param's internal consistency, raising ValueError.
|
|
162
|
+
|
|
163
|
+
Also coerces the default to the declared kind and fills a slider step for
|
|
164
|
+
bounded params, so controls.py can trust both.
|
|
165
|
+
"""
|
|
166
|
+
where = f"{func.__name__} parameter {name!r}"
|
|
167
|
+
if param.kind not in ("scalar", "int", "tuple"):
|
|
168
|
+
raise ValueError(f"{where}: unknown kind {param.kind!r}.")
|
|
169
|
+
if param.scan and not allow_scan:
|
|
170
|
+
raise ValueError(f"{where}: scan specs are only valid on the worker.")
|
|
171
|
+
if param.kind == "tuple":
|
|
172
|
+
if param.scan:
|
|
173
|
+
raise ValueError(f"{where}: tuple params cannot be scanned.")
|
|
174
|
+
param.default = tuple(param.default)
|
|
175
|
+
if len(param.default) != param.size:
|
|
176
|
+
raise ValueError(f"{where}: default {param.default} does not match size {param.size}.")
|
|
177
|
+
return
|
|
178
|
+
param.default = int(param.default) if param.kind == "int" else float(param.default)
|
|
179
|
+
if param.bounds is not None:
|
|
180
|
+
if len(param.bounds) != 2 or not param.bounds[0] < param.bounds[1]:
|
|
181
|
+
raise ValueError(f"{where}: bounds must be an increasing (lo, hi) pair.")
|
|
182
|
+
lo, hi = param.bounds
|
|
183
|
+
if not lo <= param.default <= hi:
|
|
184
|
+
raise ValueError(f"{where}: default {param.default} outside bounds {param.bounds}.")
|
|
185
|
+
if param.step is None:
|
|
186
|
+
param.step = 1 if param.kind == "int" else (hi - lo) / 100
|
labforge/registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The value records the Lab stores per registered function.
|
|
3
|
+
|
|
4
|
+
Dataclasses rather than bare tuples, so page code reads entry.title, not
|
|
5
|
+
entry[1]. A shared contract between lab.py, state.py and the pages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class WorkerSpec:
|
|
13
|
+
"""The data-producing function and its control spec."""
|
|
14
|
+
|
|
15
|
+
func: callable
|
|
16
|
+
params: dict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VizSpec:
|
|
21
|
+
"""One visualization tab: a figure-returning function plus its controls."""
|
|
22
|
+
|
|
23
|
+
func: callable
|
|
24
|
+
title: str
|
|
25
|
+
desc: str
|
|
26
|
+
params: dict
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AnalysisSpec:
|
|
31
|
+
"""One analysis tab: a result-returning function plus its controls."""
|
|
32
|
+
|
|
33
|
+
func: callable
|
|
34
|
+
title: str
|
|
35
|
+
desc: str
|
|
36
|
+
params: dict
|