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
@@ -0,0 +1,307 @@
1
+ import inspect
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from kalfa.commands.guide.words import and_list, unique, value_text
6
+ from kalfa.core import aliases, spec
7
+ from kalfa.core.build import signature_of
8
+ from kalfa.core.refs import SINGULAR
9
+
10
+
11
+ TABLES = ("models", "losses", "metrics", "optimizers", "plots", "rules")
12
+ LAYER_LABELS = ("--set", "-p", "mapping")
13
+ INDEX = re.compile(r"(\[\d+\])+$")
14
+ PARTS = re.compile(r"[.\[]")
15
+ DRAWN = {"log": "log uniform", "float": "uniform", "int": "integers"}
16
+ STEPPED = {"log": "values in geometric steps", "float": "values in even steps", "int": "integers in even steps"}
17
+
18
+
19
+ def raw_of(config):
20
+ merged = {}
21
+ for layer in getattr(config, "layers", None) or ():
22
+ if isinstance(layer.data, dict):
23
+ spec.merge(merged, layer.data, "", [], {}, layer.label)
24
+ return merged
25
+
26
+
27
+ def walk(value, path, found, pick):
28
+ if isinstance(value, dict):
29
+ for key, item in value.items():
30
+ walk(item, spec.join(path, key), found, pick)
31
+ elif isinstance(value, list):
32
+ for index, item in enumerate(value):
33
+ walk(item, spec.join(path, index), found, pick)
34
+ elif isinstance(value, str):
35
+ found.extend((path, item) for item in pick(value))
36
+ return found
37
+
38
+
39
+ def tokens(text):
40
+ return [match.group(1) for match in spec.TOKEN.finditer(text) if match.group(1)]
41
+
42
+
43
+ def tokens_in(value):
44
+ return unique(name for _, name in walk(value, "", [], tokens))
45
+
46
+
47
+ def uses_of(raw):
48
+ uses = {}
49
+ for path, name in walk(raw, "", [], tokens):
50
+ uses.setdefault(name.split(".")[0], []).append(INDEX.sub("", path))
51
+ return {name: unique(paths) for name, paths in uses.items()}
52
+
53
+
54
+ def refs_of(data):
55
+ return walk(data, "", [], lambda text: [text] if text.startswith("@") else [])
56
+
57
+
58
+ def ref_name(value, section):
59
+ lead = f"@{section}."
60
+ return value[len(lead):].split(".")[0] if isinstance(value, str) and value.startswith(lead) else None
61
+
62
+
63
+ def ref_meaning(text):
64
+ parts = str(text)[1:].split(".")
65
+ root, rest = parts[0], parts[1:]
66
+ if root in SINGULAR and rest:
67
+ kind, name, inner = SINGULAR[root], rest[0], rest[1:]
68
+ if not inner:
69
+ return f"the {kind} {name}"
70
+ if root == "models" and inner[0] == "ema":
71
+ tail = f" (its {'.'.join(inner[1:])})" if inner[1:] else ""
72
+ return f"the EMA copy of the model {name}{tail}"
73
+ if root == "models":
74
+ return f"the submodule {'.'.join(inner)} of the model {name}"
75
+ return f"the attribute {'.'.join(inner)} of the {kind} {name}"
76
+ if root == "data":
77
+ return f"{'.'.join(rest)}, which the data class exposes once it is fitted" if rest else "the data object"
78
+ if parts == ["generate"]:
79
+ return "the sampler of the generate section"
80
+ if parts == ["train", "step"]:
81
+ return "the step object"
82
+ return f"the node {'.'.join(parts)}"
83
+
84
+
85
+ def holder_of(where):
86
+ parts = [part for part in PARTS.split(where) if part]
87
+ if parts and parts[0] in TABLES + ("train",) and len(parts) > 1:
88
+ return ".".join(parts[:2])
89
+ return parts[0] if parts else where
90
+
91
+
92
+ def quiet(where):
93
+ if where.startswith("optimizers.") and (".over" in where or where.endswith(".minimizes")):
94
+ return True
95
+ return where == "train.predicts" or (where.startswith("rules.") and ".set" in where)
96
+
97
+
98
+ def grid_of(raw):
99
+ from kalfa.commands.sweep.strategies import Grid, strategy_call, strategy_target
100
+
101
+ try:
102
+ use, options = strategy_call(raw)
103
+ target = strategy_target(use)
104
+ except Exception:
105
+ return None
106
+ if not (inspect.isclass(target) and issubclass(target, Grid)):
107
+ return None
108
+ try:
109
+ return target(**options)
110
+ except Exception:
111
+ return Grid()
112
+
113
+
114
+ def default_of(target, name):
115
+ if target is None:
116
+ return None
117
+ parameter = signature_of(target).params.get(name)
118
+ if parameter is None or parameter.default is inspect.Parameter.empty:
119
+ return None
120
+ return parameter.default
121
+
122
+
123
+ class Seen:
124
+ def __init__(self, checked, paths=()):
125
+ self.config = checked.config
126
+ self.tree = checked.tree
127
+ self.layers = [layer for layer in getattr(self.config, "layers", None) or () if isinstance(layer.data, dict)]
128
+ self.main = str(Path(paths[0]).expanduser().resolve()) if paths else None
129
+ self.raw = raw_of(self.config)
130
+ data = getattr(self.config, "data", None)
131
+ self.data = data if isinstance(data, dict) else {}
132
+ params = getattr(self.config, "params", None)
133
+ self.params = params if isinstance(params, dict) else {}
134
+ self.uses = uses_of(self.raw)
135
+ self.refs = refs_of(self.data)
136
+ self.grid = grid_of(self.mapping("sweep").get("strategy")) if self.space() else None
137
+
138
+ def value(self, *keys):
139
+ current, rest = (self.params, keys[1:]) if keys and keys[0] == "params" else (self.data, keys)
140
+ for key in rest:
141
+ if not isinstance(current, dict) or key not in current:
142
+ return None
143
+ current = current[key]
144
+ return current
145
+
146
+ def mapping(self, *keys):
147
+ found = self.value(*keys)
148
+ return found if isinstance(found, dict) else {}
149
+
150
+ def node(self, path):
151
+ return self.tree.nodes.get(path) if self.tree is not None else None
152
+
153
+ def target(self, path):
154
+ node = self.node(path)
155
+ return node.target if node is not None else None
156
+
157
+ def tabled(self):
158
+ return getattr(self.target("data"), "predictions", True) is not False
159
+
160
+ def found(self, use):
161
+ if self.tree is None or not aliases.is_import_path(use):
162
+ return None
163
+ return next((node.target for node in self.tree.nodes.values() if node.use == use and node.target is not None),
164
+ None)
165
+
166
+ def predicts(self):
167
+ models = self.mapping("models")
168
+ asked = self.mapping("train").get("predicts")
169
+ if asked is False:
170
+ return None
171
+ if asked is None:
172
+ return next(iter(models)) if len(models) == 1 else None
173
+ name = ref_name(asked, "models")
174
+ return name if name in models else None
175
+
176
+ def first_output(self, model):
177
+ outputs = self.mapping("models", model).get("outputs")
178
+ if isinstance(outputs, str):
179
+ return outputs
180
+ return str(outputs[0]) if isinstance(outputs, list) and outputs else model
181
+
182
+ def targets(self):
183
+ data = self.mapping("data")
184
+ targets = data.get("targets")
185
+ if isinstance(targets, dict):
186
+ return [str(name) for name in targets]
187
+ found = aliases.target_path(self.target("data")) if self.target("data") is not None else ""
188
+ if found.endswith(".ImageFolder"):
189
+ return ["label"] if data.get("labels", True) is not False else []
190
+ return ["targets"] if found.endswith(".TextLines") else None
191
+
192
+ def minimizers(self, loss):
193
+ return [name for name, item in self.mapping("optimizers").items()
194
+ if isinstance(item, dict) and ref_name(item.get("minimizes"), "losses") == loss]
195
+
196
+ def patches(self):
197
+ for name, rule in self.mapping("rules").items():
198
+ if isinstance(rule, dict) and isinstance(rule.get("set"), dict):
199
+ for path, value in rule["set"].items():
200
+ yield str(name), str(path), value
201
+
202
+ def readers(self, path):
203
+ holders = [holder_of(where) for where, text in self.refs
204
+ if (text[1:] == path or text[1:].startswith(path + ".")) and not quiet(where)]
205
+ return [holder for holder in unique(holders) if holder != path]
206
+
207
+ def space(self):
208
+ space = self.mapping("sweep").get("space")
209
+ return space if isinstance(space, dict) else {}
210
+
211
+
212
+ def grid_count(grid, entry):
213
+ try:
214
+ return len(grid.axes({"range": entry})["range"])
215
+ except Exception:
216
+ return None
217
+
218
+
219
+ def range_text(entry, grid=None):
220
+ if isinstance(entry, list):
221
+ return and_list(value_text(item) for item in entry)
222
+ if isinstance(entry, dict) and len(entry) == 1:
223
+ kind, bounds = next(iter(entry.items()))
224
+ if isinstance(bounds, list) and len(bounds) == 2:
225
+ ends = f"from {value_text(bounds[0])} to {value_text(bounds[1])}"
226
+ if grid is None or kind not in STEPPED:
227
+ return f"{DRAWN.get(kind, kind)} {ends}"
228
+ count = grid_count(grid, entry)
229
+ if count == 1:
230
+ return f"only {value_text(bounds[0])}"
231
+ return f"{count} {STEPPED[kind]} {ends}" if count else f"{STEPPED[kind]} {ends}"
232
+ return value_text(entry)
233
+
234
+
235
+ def swept(seen, name):
236
+ space = seen.space()
237
+ if name not in space:
238
+ return None
239
+ entry = space[name]
240
+ verb = "tries" if isinstance(entry, list) or seen.grid is not None else "draws"
241
+ return f"the sweep {verb} {range_text(entry, seen.grid)}"
242
+
243
+
244
+ def param_value_notes(seen, value):
245
+ notes = []
246
+ for name in tokens_in(value):
247
+ base, _, field = name.partition(".")
248
+ if base == "datetime" and base not in seen.params:
249
+ notes.append("$datetime$ is the start time of the run, such as 20261109_143000")
250
+ continue
251
+ if base not in seen.params:
252
+ continue
253
+ found = seen.params[base]
254
+ if field:
255
+ found = found.get(field) if isinstance(found, dict) else None
256
+ sweep = swept(seen, base) if not field else None
257
+ notes.append(f"${name}$ is {value_text(found)}" + (f" here; {sweep}" if sweep else ""))
258
+ return notes
259
+
260
+
261
+ def layer_name(label):
262
+ return label if label in LAYER_LABELS else Path(str(label)).name
263
+
264
+
265
+ def dig(data, keys):
266
+ current = data
267
+ for key in keys:
268
+ if not isinstance(current, dict) or key not in current:
269
+ return False, None
270
+ current = current[key]
271
+ return True, current
272
+
273
+
274
+ def layer_notes(seen, keys, value):
275
+ found = [layer for layer in seen.layers if dig(layer.data, keys)[0]]
276
+ if not found or [layer.file for layer in found] == [seen.main]:
277
+ return []
278
+ names = unique(layer_name(layer.label) for layer in found)
279
+ if dig(found[-1].data, keys)[1] != value:
280
+ return [f"merged from {and_list(names)}"]
281
+ if len(found) > 1:
282
+ return [f"{names[-1]} writes it over {and_list(names[:-1])}"]
283
+ return [f"written by {names[-1]}"]
284
+
285
+
286
+ def override_notes(seen, path):
287
+ notes = []
288
+ for written, label in (getattr(seen.config, "overrides", None) or {}).items():
289
+ if written.startswith((path + ".", path + "[")):
290
+ notes.append(f"{layer_name(label)} writes {written[len(path) + 1:]} over an earlier layer")
291
+ return notes
292
+
293
+
294
+ def patch_text(path, value):
295
+ if isinstance(value, dict) and set(value) == {"times"}:
296
+ return f"multiplies {path} by {value_text(value['times'])}"
297
+ if isinstance(value, dict) and set(value) == {"plus"}:
298
+ return f"adds {value_text(value['plus'])} to {path}"
299
+ return f"sets {path} to {value_text(value)}"
300
+
301
+
302
+ def patch_notes(seen, path):
303
+ notes = []
304
+ for rule, written, value in seen.patches():
305
+ if written == path or written.startswith((path + ".", path + "[")):
306
+ notes.append(f"rule {rule} {patch_text('it' if written == path else written[len(path) + 1:], value)}")
307
+ return notes
@@ -0,0 +1,106 @@
1
+ import textwrap
2
+
3
+ from rich.console import Group
4
+ from rich.padding import Padding
5
+ from rich.syntax import Syntax
6
+ from rich.text import Text
7
+
8
+ from kalfa.commands.guide.about import RUN, RUN_KEYS
9
+ from kalfa.commands.guide.seen import PARTS
10
+ from kalfa.commands.guide.words import and_list, flat
11
+
12
+
13
+ TEXT_WIDTH = 100
14
+ THEME = "ansi_dark"
15
+
16
+
17
+ def status_text(doc):
18
+ errors = sum(problem["level"] == "error" for problem in doc["problems"])
19
+ warnings = len(doc["problems"]) - errors
20
+ pairs = ((errors, "error"), (warnings, "warning"))
21
+ counts = [f"{count} {word}{'s' if count != 1 else ''}" for count, word in pairs if count]
22
+ if not counts:
23
+ return "ok"
24
+ return ("ok, " if errors == 0 else "") + and_list(counts)
25
+
26
+
27
+ def problem_line(problem, own=None):
28
+ where = f"{problem['path']}: " if problem.get("path") and problem["path"] != own else ""
29
+ hint = f" ({problem['hint']})" if problem.get("hint") else ""
30
+ return f"{problem['level']} {problem['code']}: {where}{problem['message']}{hint}"
31
+
32
+
33
+ def section_name(path):
34
+ if not path:
35
+ return None
36
+ first = PARTS.split(path)[0]
37
+ return RUN if first in RUN_KEYS else first
38
+
39
+
40
+ def placed(doc):
41
+ attached = [problem for section in doc["sections"] for entry in section["entries"]
42
+ for problem in entry["problems"]]
43
+ names = {section["name"] for section in doc["sections"]}
44
+ own, loose = {}, []
45
+ for problem in doc["problems"]:
46
+ if problem in attached:
47
+ continue
48
+ name = section_name(problem.get("path"))
49
+ if name in names:
50
+ own.setdefault(name, []).append(problem)
51
+ else:
52
+ loose.append(problem)
53
+ return own, loose
54
+
55
+
56
+ def wrapped(text, lead, rest=None):
57
+ rest = lead if rest is None else rest
58
+ return textwrap.wrap(flat(text), width=TEXT_WIDTH, initial_indent=lead, subsequent_indent=rest,
59
+ break_long_words=False, break_on_hyphens=False) or [lead.rstrip()]
60
+
61
+
62
+ def text(doc):
63
+ own, loose = placed(doc)
64
+ lines = [f"{doc['title']}: {status_text(doc)}"]
65
+ lines += [line for problem in loose for line in wrapped(problem_line(problem), " ", " ")]
66
+ for section in doc["sections"]:
67
+ lines += ["", section["name"], "=" * len(section["name"])] + wrapped(section["about"], "")
68
+ lines += [line for problem in own.get(section["name"], []) for line in wrapped(problem_line(problem), " ")]
69
+ for entry in section["entries"]:
70
+ lines.append("")
71
+ lines += [" " + line for line in entry["yaml"].splitlines()]
72
+ lines += [line for note in entry["notes"] for line in wrapped(note, " ")]
73
+ lines += [line for problem in entry["problems"]
74
+ for line in wrapped(problem_line(problem, entry["path"]), " ", " ")]
75
+ return "\n".join(lines)
76
+
77
+
78
+ def problem_text(problem, own=None):
79
+ return Text(problem_line(problem, own), style="red" if problem["level"] == "error" else "yellow")
80
+
81
+
82
+ def head_renderable(doc):
83
+ _, loose = placed(doc)
84
+ style = "bold green" if doc["ok"] and not doc["problems"] else "bold yellow" if doc["ok"] else "bold red"
85
+ title = Text.assemble((doc["title"], "bold"), ": ", (status_text(doc), style))
86
+ return Group(title, *(Padding(problem_text(problem), (0, 0, 0, 2)) for problem in loose))
87
+
88
+
89
+ def entry_renderable(entry):
90
+ parts = [Syntax(entry["yaml"], "yaml", theme=THEME, background_color="default", word_wrap=True)]
91
+ if entry["notes"]:
92
+ parts.append(Padding(Text("\n".join(entry["notes"]), style="dim"), (0, 0, 0, 4)))
93
+ parts += [Padding(problem_text(problem, entry["path"]), (0, 0, 0, 4)) for problem in entry["problems"]]
94
+ return Padding(Group(*parts), (1, 0, 0, 2))
95
+
96
+
97
+ def section_renderable(section, doc=None):
98
+ own, _ = placed(doc) if doc is not None else ({}, [])
99
+ bar = Padding(Text(section["name"], style="bold"), (0, 1), style=f"bold white on {section['color']}", expand=True)
100
+ about = Padding(Text(section["about"], style="italic"), (0, 1))
101
+ problems = [Padding(problem_text(problem), (0, 0, 0, 2)) for problem in own.get(section["name"], [])]
102
+ return Group(bar, about, *problems, *(entry_renderable(entry) for entry in section["entries"]), Text(""))
103
+
104
+
105
+ def renderable(doc):
106
+ return Group(head_renderable(doc), Text(""), *(section_renderable(section, doc) for section in doc["sections"]))