kalfa 3.0.0.dev0__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.
- kalfa/__init__.py +19 -0
- kalfa/api.py +284 -0
- kalfa/board/__init__.py +1 -0
- kalfa/board/answers.py +419 -0
- kalfa/board/server.py +536 -0
- kalfa/board/static/api.js +176 -0
- kalfa/board/static/app.js +122 -0
- kalfa/board/static/board.css +228 -0
- kalfa/board/static/charts.css +46 -0
- kalfa/board/static/components/badge.js +14 -0
- kalfa/board/static/components/chart.js +204 -0
- kalfa/board/static/components/diagram.js +857 -0
- kalfa/board/static/components/error.js +17 -0
- kalfa/board/static/components/loading.js +7 -0
- kalfa/board/static/components/progress.js +25 -0
- kalfa/board/static/components/settings.js +418 -0
- kalfa/board/static/components/spark.js +43 -0
- kalfa/board/static/components/table.js +156 -0
- kalfa/board/static/diagram.css +76 -0
- kalfa/board/static/format.js +138 -0
- kalfa/board/static/index.html +34 -0
- kalfa/board/static/plot.js +594 -0
- kalfa/board/static/predictions.css +20 -0
- kalfa/board/static/store.js +215 -0
- kalfa/board/static/views/compare.js +185 -0
- kalfa/board/static/views/home.js +90 -0
- kalfa/board/static/views/record/config.js +81 -0
- kalfa/board/static/views/record/curves.js +137 -0
- kalfa/board/static/views/record/data.js +378 -0
- kalfa/board/static/views/record/files.js +128 -0
- kalfa/board/static/views/record/log.js +164 -0
- kalfa/board/static/views/record/model.js +286 -0
- kalfa/board/static/views/record/overview.js +216 -0
- kalfa/board/static/views/record/plots.js +103 -0
- kalfa/board/static/views/record/predictions.js +834 -0
- kalfa/board/static/views/record.js +179 -0
- kalfa/board/static/views/runs.js +124 -0
- kalfa/board/static/views/sweep.js +282 -0
- kalfa/board/watch.py +87 -0
- kalfa/cli.py +371 -0
- kalfa/commands/__init__.py +0 -0
- kalfa/commands/common.py +8 -0
- kalfa/commands/export.py +117 -0
- kalfa/commands/generate.py +31 -0
- kalfa/commands/guide/__init__.py +101 -0
- kalfa/commands/guide/about.py +93 -0
- kalfa/commands/guide/calls.py +102 -0
- kalfa/commands/guide/sections.py +186 -0
- kalfa/commands/guide/seen.py +307 -0
- kalfa/commands/guide/show.py +106 -0
- kalfa/commands/guide/training.py +333 -0
- kalfa/commands/guide/words.py +97 -0
- kalfa/commands/legos.py +508 -0
- kalfa/commands/plots.py +27 -0
- kalfa/commands/report/__init__.py +64 -0
- kalfa/commands/report/page.py +112 -0
- kalfa/commands/report/predictions.py +216 -0
- kalfa/commands/report/sections.py +356 -0
- kalfa/commands/stop.py +60 -0
- kalfa/commands/sweep/__init__.py +202 -0
- kalfa/commands/sweep/checks.py +98 -0
- kalfa/commands/sweep/plan.py +183 -0
- kalfa/commands/sweep/points.py +123 -0
- kalfa/commands/sweep/runner.py +100 -0
- kalfa/commands/sweep/space.py +67 -0
- kalfa/commands/sweep/strategies.py +228 -0
- kalfa/core/__init__.py +0 -0
- kalfa/core/aliases.py +229 -0
- kalfa/core/build.py +727 -0
- kalfa/core/check.py +550 -0
- kalfa/core/coerce.py +36 -0
- kalfa/core/context.py +149 -0
- kalfa/core/device.py +63 -0
- kalfa/core/errors.py +45 -0
- kalfa/core/graph.py +304 -0
- kalfa/core/history.py +70 -0
- kalfa/core/loop.py +710 -0
- kalfa/core/make.py +121 -0
- kalfa/core/plan.py +156 -0
- kalfa/core/predict.py +290 -0
- kalfa/core/protocols.py +122 -0
- kalfa/core/record.py +347 -0
- kalfa/core/refs.py +88 -0
- kalfa/core/rng.py +100 -0
- kalfa/core/rules.py +513 -0
- kalfa/core/run.py +568 -0
- kalfa/core/spec.py +413 -0
- kalfa/core/state.py +100 -0
- kalfa/core/wrap.py +671 -0
- kalfa/records/__init__.py +24 -0
- kalfa/records/compare.py +86 -0
- kalfa/records/reader.py +561 -0
- kalfa/records/stats/__init__.py +36 -0
- kalfa/records/stats/choices.py +282 -0
- kalfa/records/stats/classes.py +236 -0
- kalfa/records/stats/measures.py +144 -0
- kalfa/records/stats/pairs.py +225 -0
- kalfa/records/stats/tables.py +153 -0
- kalfa/records/sweep.py +244 -0
- kalfa/std/__init__.py +17 -0
- kalfa/std/actions.py +35 -0
- kalfa/std/common.py +56 -0
- kalfa/std/data/__init__.py +1 -0
- kalfa/std/data/base.py +45 -0
- kalfa/std/data/columns.py +190 -0
- kalfa/std/data/flow.py +317 -0
- kalfa/std/data/images.py +443 -0
- kalfa/std/data/options.py +147 -0
- kalfa/std/data/pre.py +84 -0
- kalfa/std/data/splits.py +140 -0
- kalfa/std/data/stream.py +294 -0
- kalfa/std/data/table.py +642 -0
- kalfa/std/data/text.py +436 -0
- kalfa/std/data/transforms.py +327 -0
- kalfa/std/data/weights.py +113 -0
- kalfa/std/data/window.py +135 -0
- kalfa/std/figure.py +37 -0
- kalfa/std/init.py +72 -0
- kalfa/std/loggers.py +105 -0
- kalfa/std/losses.py +161 -0
- kalfa/std/metrics.py +463 -0
- kalfa/std/models.py +122 -0
- kalfa/std/notes.py +1553 -0
- kalfa/std/objectives.py +360 -0
- kalfa/std/plots/__init__.py +75 -0
- kalfa/std/plots/classification.py +207 -0
- kalfa/std/plots/common.py +44 -0
- kalfa/std/plots/curves.py +56 -0
- kalfa/std/plots/images.py +188 -0
- kalfa/std/plots/pairs.py +66 -0
- kalfa/std/plots/predictions.py +154 -0
- kalfa/std/samplers.py +261 -0
- kalfa/std/samples.py +72 -0
- kalfa/std/schedules.py +74 -0
- kalfa/std/steps.py +133 -0
- kalfa/std/triggers.py +135 -0
- kalfa/summary.py +296 -0
- kalfa/tui/__init__.py +4 -0
- kalfa/tui/app.py +107 -0
- kalfa/tui/config.py +61 -0
- kalfa/tui/guide.py +100 -0
- kalfa/tui/jobs.py +97 -0
- kalfa/tui/legos.py +62 -0
- kalfa/tui/records.py +439 -0
- kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
- kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
- kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
- kalfa-3.0.0.dev0.dist-info/entry_points.txt +2 -0
kalfa/summary.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numbers
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas
|
|
8
|
+
|
|
9
|
+
from .core.errors import KalfaError
|
|
10
|
+
from .records import reader
|
|
11
|
+
from .records import sweep as sweeping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FIXED = ("name", "status", "turn", "total", "seconds")
|
|
15
|
+
RESERVED = (*FIXED, "best", "best_turn")
|
|
16
|
+
HIDDEN = ("train/", "lr/")
|
|
17
|
+
SETS = {"train": 0, "val": 1, "test": 2}
|
|
18
|
+
normal_mode = reader.normal_mode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Summary:
|
|
23
|
+
records: list
|
|
24
|
+
table: object
|
|
25
|
+
by: str | None
|
|
26
|
+
mode: str
|
|
27
|
+
best: dict | None = None
|
|
28
|
+
folds: list | None = None
|
|
29
|
+
sweep: dict | None = None
|
|
30
|
+
points: list = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def to_markdown(self):
|
|
33
|
+
return markdown_of(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def under(root):
|
|
37
|
+
root = Path(root)
|
|
38
|
+
found = []
|
|
39
|
+
for entry in reader.records_under(root):
|
|
40
|
+
if entry.get("kind") == "sweep":
|
|
41
|
+
found.extend(Path(point) for point in sweeping.point_dirs(root / entry["path"]))
|
|
42
|
+
else:
|
|
43
|
+
found.append(root / entry["path"])
|
|
44
|
+
return found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def expand(paths):
|
|
48
|
+
if isinstance(paths, (str, os.PathLike)):
|
|
49
|
+
paths = [paths]
|
|
50
|
+
directories, seen, root = [], set(), None
|
|
51
|
+
for given in paths:
|
|
52
|
+
path = Path(given)
|
|
53
|
+
if reader.is_record(path):
|
|
54
|
+
found = [path]
|
|
55
|
+
elif sweeping.is_sweep(path):
|
|
56
|
+
found = [Path(point) for point in sweeping.point_dirs(path)]
|
|
57
|
+
if not found:
|
|
58
|
+
raise KalfaError(f"no records under {given}")
|
|
59
|
+
if root is None:
|
|
60
|
+
root = path
|
|
61
|
+
elif path.is_dir():
|
|
62
|
+
found = under(path)
|
|
63
|
+
if not found:
|
|
64
|
+
raise KalfaError(f"no records under {given}")
|
|
65
|
+
else:
|
|
66
|
+
raise KalfaError(f"not a record, a sweep root or a directory of records: {given}")
|
|
67
|
+
for directory in found:
|
|
68
|
+
key = directory.resolve()
|
|
69
|
+
if key not in seen:
|
|
70
|
+
seen.add(key)
|
|
71
|
+
directories.append(str(directory))
|
|
72
|
+
sweep = sweeping.read_sweep(root) if root is not None else None
|
|
73
|
+
points = sweeping.sweep_points(root) if root is not None else []
|
|
74
|
+
return directories, sweep, points
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_series(summaries):
|
|
78
|
+
if not summaries:
|
|
79
|
+
return None, "min"
|
|
80
|
+
best = summaries[0].get("best")
|
|
81
|
+
if isinstance(best, dict) and best.get("series"):
|
|
82
|
+
return str(best["series"]), normal_mode(best.get("mode"))
|
|
83
|
+
last = summaries[0].get("last") or {}
|
|
84
|
+
by = next((key for key in last if str(key).startswith("val/")), None)
|
|
85
|
+
return by, "min"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def value_of(summary, by, mode):
|
|
89
|
+
if by is None or summary.get("status") == "failed":
|
|
90
|
+
return None, None
|
|
91
|
+
best = summary.get("best")
|
|
92
|
+
if isinstance(best, dict) and best.get("series") == by and normal_mode(best.get("mode")) == normal_mode(mode):
|
|
93
|
+
return best.get("value"), best.get("turn")
|
|
94
|
+
return reader.best_in(reader.read_history(summary["path"]), by, normal_mode(mode))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sort_key(row, maximize):
|
|
98
|
+
value = row["best"]
|
|
99
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
100
|
+
return (1, 0.0)
|
|
101
|
+
return (0, -float(value) if maximize else float(value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def series_key(series):
|
|
105
|
+
name = str(series)
|
|
106
|
+
return (SETS.get(name.split("/", 1)[0], len(SETS)), name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def column_of(param):
|
|
110
|
+
key = str(param)
|
|
111
|
+
if key in RESERVED or key.startswith("last_"):
|
|
112
|
+
return f"param_{key}"
|
|
113
|
+
return key
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def table_of(summaries, by, mode):
|
|
117
|
+
params, series = [], []
|
|
118
|
+
for summary in summaries:
|
|
119
|
+
for key in summary.get("params") or {}:
|
|
120
|
+
if key not in params:
|
|
121
|
+
params.append(key)
|
|
122
|
+
for key in summary.get("last") or {}:
|
|
123
|
+
if key != by and key not in series:
|
|
124
|
+
series.append(key)
|
|
125
|
+
columns = [*FIXED, *(column_of(key) for key in params), "best", "best_turn"]
|
|
126
|
+
if by is not None:
|
|
127
|
+
columns.append(f"last_{by}")
|
|
128
|
+
series.sort(key=series_key)
|
|
129
|
+
columns.extend(f"last_{key}" for key in series)
|
|
130
|
+
rows = []
|
|
131
|
+
for summary in summaries:
|
|
132
|
+
given = summary.get("params") or {}
|
|
133
|
+
last = summary.get("last") or {}
|
|
134
|
+
row = {key: summary.get(key) for key in FIXED}
|
|
135
|
+
row.update({column_of(key): given.get(key) for key in params})
|
|
136
|
+
row["best"], row["best_turn"] = value_of(summary, by, mode)
|
|
137
|
+
if by is not None:
|
|
138
|
+
row[f"last_{by}"] = last.get(by)
|
|
139
|
+
row.update({f"last_{key}": last.get(key) for key in series})
|
|
140
|
+
rows.append(row)
|
|
141
|
+
maximize = normal_mode(mode) == "max"
|
|
142
|
+
rows.sort(key=lambda row: sort_key(row, maximize))
|
|
143
|
+
return pandas.DataFrame(rows, columns=columns)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def clean(value):
|
|
147
|
+
if isinstance(value, numbers.Real) and not isinstance(value, bool) and math.isnan(value):
|
|
148
|
+
return None
|
|
149
|
+
if isinstance(value, numbers.Integral) and not isinstance(value, bool):
|
|
150
|
+
return int(value)
|
|
151
|
+
if isinstance(value, numbers.Real) and not isinstance(value, bool):
|
|
152
|
+
return float(value)
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def summarize(paths, by=None, mode=None, top=None):
|
|
157
|
+
directories, sweep, points = expand(paths)
|
|
158
|
+
summaries = [reader.summary_of(directory) for directory in directories]
|
|
159
|
+
default_by, default_mode = default_series(summaries)
|
|
160
|
+
by = by if by is not None else default_by
|
|
161
|
+
mode = normal_mode(mode if mode is not None else default_mode)
|
|
162
|
+
table = table_of(summaries, by, mode)
|
|
163
|
+
if top is not None:
|
|
164
|
+
table = table.head(int(top)).reset_index(drop=True)
|
|
165
|
+
best = {key: clean(value) for key, value in table.iloc[0].to_dict().items()} if len(table) else None
|
|
166
|
+
if best is not None and best["best"] is None:
|
|
167
|
+
best = None
|
|
168
|
+
folds = None
|
|
169
|
+
if any("fold" in (summary.get("params") or {}) for summary in summaries):
|
|
170
|
+
folds = sweeping.group_folds(summaries)
|
|
171
|
+
return Summary(records=summaries, table=table, by=by, mode=mode, best=best, folds=folds, sweep=sweep,
|
|
172
|
+
points=list(points or []))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cell(value, column=None):
|
|
176
|
+
value = clean(value)
|
|
177
|
+
if value is None:
|
|
178
|
+
return ""
|
|
179
|
+
if isinstance(value, bool):
|
|
180
|
+
return str(value)
|
|
181
|
+
if isinstance(value, int):
|
|
182
|
+
return str(value)
|
|
183
|
+
if isinstance(value, float):
|
|
184
|
+
return format(value, ".6g" if column == "seconds" else ".4g")
|
|
185
|
+
return str(value)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def hidden(column, by):
|
|
189
|
+
name = str(column)
|
|
190
|
+
if not name.startswith("last_"):
|
|
191
|
+
return False
|
|
192
|
+
series = name[len("last_"):]
|
|
193
|
+
return series != by and series.startswith(HIDDEN)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def columns_of(summary, columns):
|
|
197
|
+
present = [str(column) for column in summary.table.columns]
|
|
198
|
+
if columns is None:
|
|
199
|
+
return [column for column in present if not hidden(column, summary.by)]
|
|
200
|
+
if isinstance(columns, str):
|
|
201
|
+
if columns == "all":
|
|
202
|
+
return present
|
|
203
|
+
columns = [part.strip() for part in columns.split(",") if part.strip()]
|
|
204
|
+
chosen = [str(column) for column in columns]
|
|
205
|
+
unknown = [column for column in chosen if column not in present]
|
|
206
|
+
if unknown:
|
|
207
|
+
raise KalfaError(f"unknown columns: {', '.join(unknown)}")
|
|
208
|
+
return chosen
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def rows_of(summary, top, columns=None):
|
|
212
|
+
table = summary.table
|
|
213
|
+
if top is not None:
|
|
214
|
+
table = table.head(int(top))
|
|
215
|
+
chosen = columns_of(summary, columns)
|
|
216
|
+
table = table[chosen]
|
|
217
|
+
return chosen, [[cell(value, column) for value, column in zip(row, chosen)]
|
|
218
|
+
for row in table.itertuples(index=False, name=None)]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def text_of(summary, top=None, columns=None):
|
|
222
|
+
columns, rows = rows_of(summary, top, columns)
|
|
223
|
+
widths = [max([len(column), *(len(row[position]) for row in rows)]) for position, column in enumerate(columns)]
|
|
224
|
+
lines = [" ".join(column.ljust(width) for column, width in zip(columns, widths)).rstrip()]
|
|
225
|
+
for row in rows:
|
|
226
|
+
lines.append(" ".join(value.ljust(width) for value, width in zip(row, widths)).rstrip())
|
|
227
|
+
if not rows:
|
|
228
|
+
lines.append("no records")
|
|
229
|
+
return "\n".join(lines)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def fold_label(group):
|
|
233
|
+
members = group.get("members") or []
|
|
234
|
+
folds = ", ".join(cell((member.get("params") or {}).get("fold")) for member in members)
|
|
235
|
+
where = ", ".join(f"{key}={cell(value)}" for key, value in (group.get("group") or {}).items())
|
|
236
|
+
return f"folds {folds} of {where}" if where else f"folds {folds}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def fold_series(summary, group, every=False):
|
|
240
|
+
names = sorted(group.get("mean") or {}, key=series_key)
|
|
241
|
+
if every:
|
|
242
|
+
return names
|
|
243
|
+
return [name for name in names if name == summary.by or not name.startswith(HIDDEN)]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def folds_table(summary):
|
|
247
|
+
params = []
|
|
248
|
+
for group in summary.folds or []:
|
|
249
|
+
for key in group.get("group") or {}:
|
|
250
|
+
if key not in params:
|
|
251
|
+
params.append(key)
|
|
252
|
+
rows = []
|
|
253
|
+
for group in summary.folds or []:
|
|
254
|
+
given = group.get("group") or {}
|
|
255
|
+
for name in fold_series(summary, group, every=True):
|
|
256
|
+
rows.append([*(given.get(key) for key in params), len(group.get("members") or []), name,
|
|
257
|
+
group["mean"][name], group["std"].get(name)])
|
|
258
|
+
return pandas.DataFrame(rows, columns=[*(column_of(key) for key in params), "records", "series", "mean", "std"])
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def folds_text(summary):
|
|
262
|
+
lines = []
|
|
263
|
+
for group in summary.folds or []:
|
|
264
|
+
names = fold_series(summary, group)
|
|
265
|
+
width = max([len("series"), *(len(name) for name in names)])
|
|
266
|
+
lines.extend([f"{fold_label(group)}: mean and std over {len(group.get('members') or [])} records",
|
|
267
|
+
f" {'series'.ljust(width)} {'mean'.ljust(10)} std"])
|
|
268
|
+
lines.extend(f" {name.ljust(width)} {cell(group['mean'][name]).ljust(10)} {cell(group['std'].get(name))}"
|
|
269
|
+
for name in names)
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def escape(text):
|
|
274
|
+
return text.replace("|", "\\|")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def markdown_of(summary, top=None, columns="all"):
|
|
278
|
+
columns, rows = rows_of(summary, top, columns)
|
|
279
|
+
lines = ["| " + " | ".join(escape(column) for column in columns) + " |", "|" + "---|" * len(columns)]
|
|
280
|
+
for row in rows:
|
|
281
|
+
lines.append("| " + " | ".join(escape(value) for value in row) + " |")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def write(summary, out):
|
|
286
|
+
out = Path(out)
|
|
287
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
csv = out / "summary.csv"
|
|
289
|
+
markdown = out / "summary.md"
|
|
290
|
+
summary.table.to_csv(csv, index=False, float_format="%.12g")
|
|
291
|
+
markdown.write_text(markdown_of(summary) + "\n", encoding="utf-8")
|
|
292
|
+
if not summary.folds:
|
|
293
|
+
return str(csv), str(markdown)
|
|
294
|
+
folds = out / "folds.csv"
|
|
295
|
+
folds_table(summary).to_csv(folds, index=False, float_format="%.12g")
|
|
296
|
+
return str(csv), str(markdown), str(folds)
|
kalfa/tui/__init__.py
ADDED
kalfa/tui/app.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from kalfa.core.errors import KalfaError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from textual.app import App
|
|
8
|
+
from textual.widgets import Footer, Header, TabbedContent, TabPane
|
|
9
|
+
|
|
10
|
+
from kalfa.tui.config import ConfigView
|
|
11
|
+
from kalfa.tui.guide import GuideView
|
|
12
|
+
from kalfa.tui.jobs import JobOutput, OutputLog, follow, spawn
|
|
13
|
+
from kalfa.tui.legos import LegosView
|
|
14
|
+
from kalfa.tui.records import RecordsView
|
|
15
|
+
except ImportError as missing:
|
|
16
|
+
App = None
|
|
17
|
+
TEXTUAL_ERROR = missing
|
|
18
|
+
else:
|
|
19
|
+
TEXTUAL_ERROR = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PRIMARY = {"records": "#records-table", "legos": "#legos-tree", "config": "#config-output", "guide": "#guide-sections"}
|
|
23
|
+
STYLE = """
|
|
24
|
+
#tabs, #tabs > ContentSwitcher, TabPane, #records-view, #legos-view, #config-view, #legos-tree { height: 1fr; }
|
|
25
|
+
#records-main { width: 3fr; height: 1fr; }
|
|
26
|
+
#records-table { height: 1fr; }
|
|
27
|
+
#records-order { height: 1; padding: 0 1; color: $text-muted; }
|
|
28
|
+
#records-side, #legos-side { width: 2fr; }
|
|
29
|
+
#legos-scroll { width: 3fr; }
|
|
30
|
+
#legos-tree { overflow-x: hidden; }
|
|
31
|
+
#records-info { height: 2fr; }
|
|
32
|
+
#record-detail, #record-sparks, #legos-page, .field { height: auto; padding: 0 1; }
|
|
33
|
+
.spark { height: 2; margin-bottom: 1; }
|
|
34
|
+
#records-log, #config-output { height: 1fr; border-top: solid $primary; }
|
|
35
|
+
.label { width: 10; padding: 1 1 0 1; }
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_textual():
|
|
40
|
+
if App is None:
|
|
41
|
+
raise KalfaError(f"the terminal interface needs the textual package (uv sync installs the dependencies of "
|
|
42
|
+
f"kalfa): {TEXTUAL_ERROR}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KalfaApp(App if App is not None else object):
|
|
46
|
+
TITLE = "kalfa"
|
|
47
|
+
CSS = STYLE
|
|
48
|
+
AUTO_FOCUS = "#records-table"
|
|
49
|
+
BINDINGS = [("1", "tab('records')", "records"), ("2", "tab('legos')", "legos"), ("3", "tab('config')", "config"),
|
|
50
|
+
("4", "tab('guide')", "guide"), ("escape", "tab", "leave field"), ("q", "quit", "quit")]
|
|
51
|
+
|
|
52
|
+
def __init__(self, root=".", config=None):
|
|
53
|
+
require_textual()
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.root = str(root)
|
|
56
|
+
self.config = config
|
|
57
|
+
self.board = None
|
|
58
|
+
self.closing = threading.Event()
|
|
59
|
+
self.sub_title = self.root
|
|
60
|
+
|
|
61
|
+
def compose(self):
|
|
62
|
+
yield Header()
|
|
63
|
+
with TabbedContent(initial="records", id="tabs"):
|
|
64
|
+
with TabPane("1 records", id="records"):
|
|
65
|
+
yield RecordsView(id="records-view")
|
|
66
|
+
with TabPane("2 legos", id="legos"):
|
|
67
|
+
yield LegosView(id="legos-view")
|
|
68
|
+
with TabPane("3 config", id="config"):
|
|
69
|
+
yield ConfigView(id="config-view")
|
|
70
|
+
with TabPane("4 guide", id="guide"):
|
|
71
|
+
yield GuideView(id="guide-view")
|
|
72
|
+
yield Footer()
|
|
73
|
+
|
|
74
|
+
def action_tab(self, name=None):
|
|
75
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
76
|
+
tabs.active = name = name or tabs.active or "records"
|
|
77
|
+
self.query_one(PRIMARY[name]).focus()
|
|
78
|
+
|
|
79
|
+
def on_tabbed_content_tab_activated(self, event):
|
|
80
|
+
focused, pane = self.focused, event.pane
|
|
81
|
+
if pane is not None and pane.id in PRIMARY and (focused is None or pane not in focused.ancestors_with_self):
|
|
82
|
+
self.query_one(PRIMARY[pane.id]).focus()
|
|
83
|
+
|
|
84
|
+
def launch(self, argv, label, target, detach=True):
|
|
85
|
+
output = self.query_one(target, OutputLog)
|
|
86
|
+
try:
|
|
87
|
+
process, path = spawn(argv, label, detach=detach)
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
output.say(f"{label}: could not start: {exc}")
|
|
90
|
+
return None
|
|
91
|
+
output.say(f"{label}: pid {process.pid}, output in {path}")
|
|
92
|
+
|
|
93
|
+
def emit(text):
|
|
94
|
+
try:
|
|
95
|
+
return output.post_message(JobOutput(text))
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
threading.Thread(target=follow, args=(path, process, emit, self.closing), kwargs={"label": label},
|
|
100
|
+
daemon=True).start()
|
|
101
|
+
return process
|
|
102
|
+
|
|
103
|
+
def on_unmount(self):
|
|
104
|
+
self.closing.set()
|
|
105
|
+
if self.board is not None and self.board.poll() is None:
|
|
106
|
+
self.board.terminate()
|
|
107
|
+
self.board.wait(timeout=5)
|
kalfa/tui/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from textual.containers import Horizontal, Vertical
|
|
4
|
+
from textual.widgets import Input, Static
|
|
5
|
+
|
|
6
|
+
from kalfa.cli import record_configs
|
|
7
|
+
from kalfa.core.check import check
|
|
8
|
+
from kalfa.core.errors import KalfaError
|
|
9
|
+
from kalfa.tui.jobs import OutputLog, cli_argv, pairs_of, words_of
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
FIELDS = (("config", "config-path", "YAML files, later ones override, or a record"),
|
|
13
|
+
("--set", "config-sets", "PATH=VALUE pairs separated by spaces"),
|
|
14
|
+
("-p", "config-params", "NAME=VALUE pairs separated by spaces"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigView(Vertical):
|
|
18
|
+
BINDINGS = [("c", "checked(False)", "check"), ("p", "checked(True)", "plan"), ("x", "start('run')", "run"),
|
|
19
|
+
("w", "start('sweep')", "sweep")]
|
|
20
|
+
|
|
21
|
+
def compose(self):
|
|
22
|
+
config = str(self.app.config or "")
|
|
23
|
+
for label, name, hint in FIELDS:
|
|
24
|
+
with Horizontal(classes="field"):
|
|
25
|
+
yield Static(label, classes="label", markup=False)
|
|
26
|
+
yield Input(value=config if name == "config-path" else "", placeholder=hint, id=name)
|
|
27
|
+
yield OutputLog(id="config-output")
|
|
28
|
+
|
|
29
|
+
def say(self, text):
|
|
30
|
+
self.query_one("#config-output", OutputLog).say(text)
|
|
31
|
+
|
|
32
|
+
def fields(self):
|
|
33
|
+
paths = words_of(self.query_one("#config-path", Input).value)
|
|
34
|
+
if not paths:
|
|
35
|
+
raise KalfaError("write a config path first")
|
|
36
|
+
sets = pairs_of(self.query_one("#config-sets", Input).value, "--set")
|
|
37
|
+
return paths, sets, pairs_of(self.query_one("#config-params", Input).value, "-p")
|
|
38
|
+
|
|
39
|
+
def action_checked(self, plan=False):
|
|
40
|
+
try:
|
|
41
|
+
paths, sets, params = self.fields()
|
|
42
|
+
self.say(f"check {' '.join(paths)}")
|
|
43
|
+
found = check(paths=record_configs(paths), sets=sets, params=params)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
self.say(f"error: {exc}" if isinstance(exc, KalfaError) else f"error: {type(exc).__name__}: {exc}")
|
|
46
|
+
return
|
|
47
|
+
self.say(found.text())
|
|
48
|
+
if plan and found.ok:
|
|
49
|
+
self.say(json.dumps(found.plan, indent=1, default=str))
|
|
50
|
+
|
|
51
|
+
def action_start(self, command):
|
|
52
|
+
try:
|
|
53
|
+
paths, sets, params = self.fields()
|
|
54
|
+
except KalfaError as exc:
|
|
55
|
+
self.say(f"error: {exc}")
|
|
56
|
+
return
|
|
57
|
+
self.app.launch(cli_argv(command, *paths, sets=sets, params=params), command, "#config-output")
|
|
58
|
+
|
|
59
|
+
def on_input_submitted(self, event):
|
|
60
|
+
self.query_one("#config-output", OutputLog).focus()
|
|
61
|
+
self.action_checked()
|
kalfa/tui/guide.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
2
|
+
from textual.widgets import Input, OptionList, Static
|
|
3
|
+
from textual.widgets.option_list import Option
|
|
4
|
+
|
|
5
|
+
from kalfa.commands import guide
|
|
6
|
+
from kalfa.core.errors import KalfaError
|
|
7
|
+
from kalfa.tui.jobs import pairs_of, words_of
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
EMPTY = ("the guide explains the config of the config tab, and its path is empty: write a config path there (key 3), "
|
|
11
|
+
"then come back to this tab or press r here")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GuideView(Horizontal):
|
|
15
|
+
BINDINGS = [("r", "reload", "reload")]
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
GuideView { height: 1fr; }
|
|
18
|
+
#guide-sections { width: 24; height: 1fr; }
|
|
19
|
+
#guide-scroll { width: 1fr; height: 1fr; }
|
|
20
|
+
#guide-scroll > Static { height: auto; padding: 0 1; }
|
|
21
|
+
#guide-head, #guide-message { margin-bottom: 1; }
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, **options):
|
|
25
|
+
super().__init__(**options)
|
|
26
|
+
self.sections = {}
|
|
27
|
+
self.picked = None
|
|
28
|
+
self.skip = None
|
|
29
|
+
self.shown = None
|
|
30
|
+
|
|
31
|
+
def compose(self):
|
|
32
|
+
yield OptionList(id="guide-sections", markup=False)
|
|
33
|
+
yield VerticalScroll(id="guide-scroll")
|
|
34
|
+
|
|
35
|
+
async def on_show(self):
|
|
36
|
+
await self.action_reload()
|
|
37
|
+
|
|
38
|
+
def fields(self):
|
|
39
|
+
value = self.app.query_one
|
|
40
|
+
paths = words_of(value("#config-path", Input).value)
|
|
41
|
+
sets = pairs_of(value("#config-sets", Input).value, "--set")
|
|
42
|
+
return paths, sets, pairs_of(value("#config-params", Input).value, "-p")
|
|
43
|
+
|
|
44
|
+
async def action_reload(self):
|
|
45
|
+
try:
|
|
46
|
+
paths, sets, params = self.fields()
|
|
47
|
+
except KalfaError as exc:
|
|
48
|
+
await self.show_message(f"error: {exc}")
|
|
49
|
+
return
|
|
50
|
+
if not paths:
|
|
51
|
+
await self.show_message(EMPTY)
|
|
52
|
+
return
|
|
53
|
+
doc = guide.document(paths, sets, params)
|
|
54
|
+
await self.show(doc)
|
|
55
|
+
|
|
56
|
+
async def fill(self, widgets, names, shown=None):
|
|
57
|
+
scroll = self.query_one("#guide-scroll", VerticalScroll)
|
|
58
|
+
await scroll.remove_children()
|
|
59
|
+
await scroll.mount_all(widgets)
|
|
60
|
+
listing = self.query_one("#guide-sections", OptionList)
|
|
61
|
+
listing.clear_options()
|
|
62
|
+
listing.add_options([Option(name, id=name) for name in names])
|
|
63
|
+
if self.picked in names:
|
|
64
|
+
listing.highlighted = names.index(self.picked)
|
|
65
|
+
elif names:
|
|
66
|
+
self.skip = names[0]
|
|
67
|
+
listing.highlighted = 0
|
|
68
|
+
if self.picked not in names and shown != self.shown:
|
|
69
|
+
scroll.scroll_home(animate=False)
|
|
70
|
+
self.shown = shown
|
|
71
|
+
|
|
72
|
+
async def show_message(self, text):
|
|
73
|
+
self.sections = {}
|
|
74
|
+
await self.fill([Static(text, id="guide-message", markup=False)], [])
|
|
75
|
+
|
|
76
|
+
async def show(self, doc):
|
|
77
|
+
self.sections = {section["name"]: Static(guide.section_renderable(section, doc), classes="guide-section")
|
|
78
|
+
for section in doc["sections"]}
|
|
79
|
+
head = Static(guide.head_renderable(doc), id="guide-head")
|
|
80
|
+
await self.fill([head, *self.sections.values()], list(self.sections), doc["title"])
|
|
81
|
+
|
|
82
|
+
def on_option_list_option_highlighted(self, event):
|
|
83
|
+
if event.option_list.id != "guide-sections" or event.option_id not in self.sections:
|
|
84
|
+
return
|
|
85
|
+
if event.option_id == self.skip:
|
|
86
|
+
self.skip = None
|
|
87
|
+
return
|
|
88
|
+
self.skip = None
|
|
89
|
+
self.picked = event.option_id
|
|
90
|
+
self.call_after_refresh(self.scroll_to_section, event.option_id)
|
|
91
|
+
|
|
92
|
+
def on_option_list_option_selected(self, event):
|
|
93
|
+
if event.option_list.id == "guide-sections" and event.option_id in self.sections:
|
|
94
|
+
self.picked = event.option_id
|
|
95
|
+
self.call_after_refresh(self.scroll_to_section, event.option_id)
|
|
96
|
+
|
|
97
|
+
def scroll_to_section(self, name):
|
|
98
|
+
widget = self.sections.get(name)
|
|
99
|
+
if widget is not None and widget.is_attached:
|
|
100
|
+
self.query_one("#guide-scroll", VerticalScroll).scroll_to_widget(widget, animate=False, top=True)
|
kalfa/tui/jobs.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shlex
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.widgets import RichLog
|
|
10
|
+
|
|
11
|
+
from kalfa.core.errors import KalfaError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LOG_LINES = 5000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def words_of(text):
|
|
18
|
+
try:
|
|
19
|
+
return shlex.split(text or "")
|
|
20
|
+
except ValueError as exc:
|
|
21
|
+
raise KalfaError(f"cannot split {text!r}: {exc}") from None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pairs_of(text, flag="--set"):
|
|
25
|
+
words = words_of(text)
|
|
26
|
+
bad = [word for word in words if "=" not in word or not word.partition("=")[0].strip()]
|
|
27
|
+
if bad:
|
|
28
|
+
raise KalfaError(f"{flag} takes NAME=VALUE pairs separated by spaces, got {' '.join(bad)}")
|
|
29
|
+
return words
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cli_argv(command, *arguments, sets=(), params=()):
|
|
33
|
+
argv = [sys.executable, "-m", "kalfa.cli", command, *(str(argument) for argument in arguments)]
|
|
34
|
+
argv += [part for item in sets for part in ("--set", item)]
|
|
35
|
+
return argv + [part for item in params for part in ("--param", item)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def spawn(argv, label, detach=True, directory=None):
|
|
39
|
+
handle, path = tempfile.mkstemp(prefix=f"kalfa-{label}-", suffix=".log", dir=directory)
|
|
40
|
+
environment = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
|
|
41
|
+
with os.fdopen(handle, "wb") as output:
|
|
42
|
+
process = subprocess.Popen([str(part) for part in argv], stdout=output, stderr=subprocess.STDOUT,
|
|
43
|
+
stdin=subprocess.DEVNULL, env=environment, start_new_session=detach)
|
|
44
|
+
return process, path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def shown_line(raw):
|
|
48
|
+
return raw.decode("utf-8", errors="replace").rstrip("\r").rsplit("\r", 1)[-1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def follow(path, process, emit, stop, every=0.2, label="job"):
|
|
52
|
+
offset, pending = 0, b""
|
|
53
|
+
while not stop.is_set():
|
|
54
|
+
finished = process.poll() is not None
|
|
55
|
+
try:
|
|
56
|
+
with open(path, "rb") as handle:
|
|
57
|
+
handle.seek(offset)
|
|
58
|
+
data = handle.read()
|
|
59
|
+
except OSError:
|
|
60
|
+
data = b""
|
|
61
|
+
offset += len(data)
|
|
62
|
+
*lines, pending = (pending + data).split(b"\n")
|
|
63
|
+
for raw in lines:
|
|
64
|
+
if emit(shown_line(raw)) is False:
|
|
65
|
+
return None
|
|
66
|
+
if finished and not data:
|
|
67
|
+
if pending:
|
|
68
|
+
emit(shown_line(pending))
|
|
69
|
+
emit(f"{label} exited with {process.returncode}")
|
|
70
|
+
return process.returncode
|
|
71
|
+
if not data:
|
|
72
|
+
stop.wait(every)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JobOutput(Message):
|
|
77
|
+
def __init__(self, text):
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.text = text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OutputLog(RichLog):
|
|
83
|
+
def __init__(self, **options):
|
|
84
|
+
super().__init__(max_lines=LOG_LINES, wrap=True, markup=False, highlight=False, **options)
|
|
85
|
+
self.plain = []
|
|
86
|
+
|
|
87
|
+
def say(self, text):
|
|
88
|
+
text = str(text)
|
|
89
|
+
self.plain.extend(text.splitlines() or [""])
|
|
90
|
+
del self.plain[:-LOG_LINES]
|
|
91
|
+
self.write(text)
|
|
92
|
+
|
|
93
|
+
def on_job_output(self, message):
|
|
94
|
+
self.say(message.text)
|
|
95
|
+
|
|
96
|
+
def render(self):
|
|
97
|
+
return Text("\n".join(self.plain))
|