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.
Files changed (148) hide show
  1. kalfa/__init__.py +19 -0
  2. kalfa/api.py +284 -0
  3. kalfa/board/__init__.py +1 -0
  4. kalfa/board/answers.py +419 -0
  5. kalfa/board/server.py +536 -0
  6. kalfa/board/static/api.js +176 -0
  7. kalfa/board/static/app.js +122 -0
  8. kalfa/board/static/board.css +228 -0
  9. kalfa/board/static/charts.css +46 -0
  10. kalfa/board/static/components/badge.js +14 -0
  11. kalfa/board/static/components/chart.js +204 -0
  12. kalfa/board/static/components/diagram.js +857 -0
  13. kalfa/board/static/components/error.js +17 -0
  14. kalfa/board/static/components/loading.js +7 -0
  15. kalfa/board/static/components/progress.js +25 -0
  16. kalfa/board/static/components/settings.js +418 -0
  17. kalfa/board/static/components/spark.js +43 -0
  18. kalfa/board/static/components/table.js +156 -0
  19. kalfa/board/static/diagram.css +76 -0
  20. kalfa/board/static/format.js +138 -0
  21. kalfa/board/static/index.html +34 -0
  22. kalfa/board/static/plot.js +594 -0
  23. kalfa/board/static/predictions.css +20 -0
  24. kalfa/board/static/store.js +215 -0
  25. kalfa/board/static/views/compare.js +185 -0
  26. kalfa/board/static/views/home.js +90 -0
  27. kalfa/board/static/views/record/config.js +81 -0
  28. kalfa/board/static/views/record/curves.js +137 -0
  29. kalfa/board/static/views/record/data.js +378 -0
  30. kalfa/board/static/views/record/files.js +128 -0
  31. kalfa/board/static/views/record/log.js +164 -0
  32. kalfa/board/static/views/record/model.js +286 -0
  33. kalfa/board/static/views/record/overview.js +216 -0
  34. kalfa/board/static/views/record/plots.js +103 -0
  35. kalfa/board/static/views/record/predictions.js +834 -0
  36. kalfa/board/static/views/record.js +179 -0
  37. kalfa/board/static/views/runs.js +124 -0
  38. kalfa/board/static/views/sweep.js +282 -0
  39. kalfa/board/watch.py +87 -0
  40. kalfa/cli.py +371 -0
  41. kalfa/commands/__init__.py +0 -0
  42. kalfa/commands/common.py +8 -0
  43. kalfa/commands/export.py +117 -0
  44. kalfa/commands/generate.py +31 -0
  45. kalfa/commands/guide/__init__.py +101 -0
  46. kalfa/commands/guide/about.py +93 -0
  47. kalfa/commands/guide/calls.py +102 -0
  48. kalfa/commands/guide/sections.py +186 -0
  49. kalfa/commands/guide/seen.py +307 -0
  50. kalfa/commands/guide/show.py +106 -0
  51. kalfa/commands/guide/training.py +333 -0
  52. kalfa/commands/guide/words.py +97 -0
  53. kalfa/commands/legos.py +508 -0
  54. kalfa/commands/plots.py +27 -0
  55. kalfa/commands/report/__init__.py +64 -0
  56. kalfa/commands/report/page.py +112 -0
  57. kalfa/commands/report/predictions.py +216 -0
  58. kalfa/commands/report/sections.py +356 -0
  59. kalfa/commands/stop.py +60 -0
  60. kalfa/commands/sweep/__init__.py +202 -0
  61. kalfa/commands/sweep/checks.py +98 -0
  62. kalfa/commands/sweep/plan.py +183 -0
  63. kalfa/commands/sweep/points.py +123 -0
  64. kalfa/commands/sweep/runner.py +100 -0
  65. kalfa/commands/sweep/space.py +67 -0
  66. kalfa/commands/sweep/strategies.py +228 -0
  67. kalfa/core/__init__.py +0 -0
  68. kalfa/core/aliases.py +229 -0
  69. kalfa/core/build.py +727 -0
  70. kalfa/core/check.py +550 -0
  71. kalfa/core/coerce.py +36 -0
  72. kalfa/core/context.py +149 -0
  73. kalfa/core/device.py +63 -0
  74. kalfa/core/errors.py +45 -0
  75. kalfa/core/graph.py +304 -0
  76. kalfa/core/history.py +70 -0
  77. kalfa/core/loop.py +710 -0
  78. kalfa/core/make.py +121 -0
  79. kalfa/core/plan.py +156 -0
  80. kalfa/core/predict.py +290 -0
  81. kalfa/core/protocols.py +122 -0
  82. kalfa/core/record.py +347 -0
  83. kalfa/core/refs.py +88 -0
  84. kalfa/core/rng.py +100 -0
  85. kalfa/core/rules.py +513 -0
  86. kalfa/core/run.py +568 -0
  87. kalfa/core/spec.py +413 -0
  88. kalfa/core/state.py +100 -0
  89. kalfa/core/wrap.py +671 -0
  90. kalfa/records/__init__.py +24 -0
  91. kalfa/records/compare.py +86 -0
  92. kalfa/records/reader.py +561 -0
  93. kalfa/records/stats/__init__.py +36 -0
  94. kalfa/records/stats/choices.py +282 -0
  95. kalfa/records/stats/classes.py +236 -0
  96. kalfa/records/stats/measures.py +144 -0
  97. kalfa/records/stats/pairs.py +225 -0
  98. kalfa/records/stats/tables.py +153 -0
  99. kalfa/records/sweep.py +244 -0
  100. kalfa/std/__init__.py +17 -0
  101. kalfa/std/actions.py +35 -0
  102. kalfa/std/common.py +56 -0
  103. kalfa/std/data/__init__.py +1 -0
  104. kalfa/std/data/base.py +45 -0
  105. kalfa/std/data/columns.py +190 -0
  106. kalfa/std/data/flow.py +317 -0
  107. kalfa/std/data/images.py +443 -0
  108. kalfa/std/data/options.py +147 -0
  109. kalfa/std/data/pre.py +84 -0
  110. kalfa/std/data/splits.py +140 -0
  111. kalfa/std/data/stream.py +294 -0
  112. kalfa/std/data/table.py +642 -0
  113. kalfa/std/data/text.py +436 -0
  114. kalfa/std/data/transforms.py +327 -0
  115. kalfa/std/data/weights.py +113 -0
  116. kalfa/std/data/window.py +135 -0
  117. kalfa/std/figure.py +37 -0
  118. kalfa/std/init.py +72 -0
  119. kalfa/std/loggers.py +105 -0
  120. kalfa/std/losses.py +161 -0
  121. kalfa/std/metrics.py +463 -0
  122. kalfa/std/models.py +122 -0
  123. kalfa/std/notes.py +1553 -0
  124. kalfa/std/objectives.py +360 -0
  125. kalfa/std/plots/__init__.py +75 -0
  126. kalfa/std/plots/classification.py +207 -0
  127. kalfa/std/plots/common.py +44 -0
  128. kalfa/std/plots/curves.py +56 -0
  129. kalfa/std/plots/images.py +188 -0
  130. kalfa/std/plots/pairs.py +66 -0
  131. kalfa/std/plots/predictions.py +154 -0
  132. kalfa/std/samplers.py +261 -0
  133. kalfa/std/samples.py +72 -0
  134. kalfa/std/schedules.py +74 -0
  135. kalfa/std/steps.py +133 -0
  136. kalfa/std/triggers.py +135 -0
  137. kalfa/summary.py +296 -0
  138. kalfa/tui/__init__.py +4 -0
  139. kalfa/tui/app.py +107 -0
  140. kalfa/tui/config.py +61 -0
  141. kalfa/tui/guide.py +100 -0
  142. kalfa/tui/jobs.py +97 -0
  143. kalfa/tui/legos.py +62 -0
  144. kalfa/tui/records.py +439 -0
  145. kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
  146. kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
  147. kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
  148. 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
@@ -0,0 +1,4 @@
1
+ def open_tui(root=".", config=None):
2
+ from .app import KalfaApp
3
+
4
+ KalfaApp(root=root, config=config).run()
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))