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,333 @@
1
+ import importlib
2
+ import inspect
3
+
4
+ from kalfa.commands.guide.calls import call_notes, lego_note, legos_in, param_lines, use_of
5
+ from kalfa.commands.guide.seen import default_of, patch_notes, patch_text, range_text, ref_meaning, ref_name
6
+ from kalfa.commands.guide.words import and_list, short, unique, value_text
7
+ from kalfa.core import aliases
8
+ from kalfa.core.build import SLOT_KEYS, criterion_function
9
+ from kalfa.core.check import is_criterion
10
+ from kalfa.core.protocols import Objective
11
+
12
+
13
+ TRIGGERS = ("after_epoch", "every", "plateau", "above", "below", "time_budget")
14
+
15
+
16
+ def criterion_notes(keys):
17
+ notes = []
18
+ if keys.get("pred") is not None:
19
+ notes.append(f"pred: compares the output {value_text(keys['pred'])}")
20
+ if keys.get("target") is not None:
21
+ notes.append(f"target: against the batch field {value_text(keys['target'])}")
22
+ on = keys.get("on")
23
+ if on is not None:
24
+ listed = on if isinstance(on, list) else [on]
25
+ notes.append(f"on: runs on the {and_list(listed)} {'set' if len(listed) == 1 else 'sets'} only")
26
+ if keys.get("every") is not None:
27
+ notes.append(f"every: computed every {value_text(keys['every'])} turns")
28
+ if keys.get("rescale") is True:
29
+ notes.append("rescale: compares in the original units, after undoing the preprocessor of the target")
30
+ elif keys.get("rescale") is False:
31
+ notes.append("rescale: false compares the preprocessed values")
32
+ if keys.get("sample_weight") is not None:
33
+ notes.append(f"sample_weight: the loss of every row is multiplied by the batch field "
34
+ f"{value_text(keys['sample_weight'])} before it is averaged")
35
+ return notes
36
+
37
+
38
+ def default_notes(seen, path, keys, metric):
39
+ node = seen.node(path)
40
+ if node is None or node.target is None or not is_criterion(node):
41
+ return []
42
+ targets = seen.targets()
43
+ target = keys.get("target")
44
+ if target is None and targets is not None and len(targets) == 1:
45
+ target = targets[0]
46
+ notes = []
47
+ missing = [key for key in ("pred", "target") if keys.get(key) is None]
48
+ model = seen.predicts()
49
+ pred_text = None
50
+ if model is not None:
51
+ output = seen.first_output(model)
52
+ pred_text = (f"the output of {model} (the predicts model)" if output == model
53
+ else f"{output} (the first output of the predicts model {model})")
54
+ target_text = f"the target {target}" if target is not None and keys.get("target") is None else None
55
+ if targets is None and keys.get("target") is None:
56
+ target_text = "the only target of the data"
57
+ if missing == ["pred", "target"] and pred_text and target_text:
58
+ notes.append(f"pred and target are not written, so kalfa compares {pred_text} with {target_text}")
59
+ elif "pred" in missing and pred_text:
60
+ notes.append(f"pred is not written, so kalfa takes {pred_text}")
61
+ elif "target" in missing and target_text:
62
+ notes.append(f"target is not written, so kalfa takes {target_text}")
63
+ if metric and keys.get("rescale") is None and isinstance(target, str):
64
+ notes += rescale_notes(seen, target, node.name)
65
+ return notes
66
+
67
+
68
+ def inverts(name):
69
+ entry = aliases.entry_of(name)
70
+ try:
71
+ target = aliases.import_target(entry.target if entry is not None else name)
72
+ except Exception:
73
+ return None
74
+ if not inspect.isclass(target):
75
+ return None
76
+ return hasattr(target, "inverse_transform") and bool(getattr(target, "rescales", True))
77
+
78
+
79
+ def rescale_notes(seen, target, name):
80
+ targets = seen.mapping("data", "targets")
81
+ if target not in targets:
82
+ return []
83
+ chain = targets[target]
84
+ names = [use_of(item) for item in (chain if isinstance(chain, list) else [chain]) if item is not None]
85
+ if not names:
86
+ return [f"rescale stays off: {target} is kept raw"]
87
+ if "label" in names:
88
+ return []
89
+ names = [item for item in names if item]
90
+ answers = [inverts(item) for item in names]
91
+ able = [item for item, answer in zip(names, answers) if answer]
92
+ if able:
93
+ return [f"rescale is on because {and_list(able)} can be inverted, so {name} speaks the original units of "
94
+ f"{target}"]
95
+ if answers and all(answer is False for answer in answers):
96
+ return [f"rescale stays off: {and_list(names)} cannot be inverted"]
97
+ return [f"rescale is on when a preprocessor of {target} can be inverted, so {name} speaks its original units"]
98
+
99
+
100
+ def kind_notes(seen, path, use):
101
+ target = seen.target(path)
102
+ if target is None or not use or aliases.entry_of(use) is not None:
103
+ return []
104
+ if inspect.isclass(target) and issubclass(target, Objective):
105
+ return ["an objective: it reads the outputs, the batch and the run context itself"]
106
+ if inspect.isroutine(target):
107
+ if criterion_function(target):
108
+ return ["a function of (pred, target), so it is the criterion itself and the other params are bound to it"]
109
+ return ["a function without two required params: kalfa calls it and uses what it returns"]
110
+ return []
111
+
112
+
113
+ def loss_notes(seen, key, value, keys, path):
114
+ name = str(key)
115
+ resolved = seen.mapping(*keys)
116
+ notes = call_notes(seen, path, value, SLOT_KEYS["loss"]) + kind_notes(seen, path, use_of(value))
117
+ notes += criterion_notes(resolved) + default_notes(seen, path, resolved, False)
118
+ minimizers = seen.minimizers(name)
119
+ if minimizers:
120
+ notes.append(f"{and_list(minimizers)} {'minimizes' if len(minimizers) == 1 else 'minimize'} it, so it gives "
121
+ f"the gradients")
122
+ elif any(reader.startswith("losses.") for reader in seen.readers(path)):
123
+ notes.append("no optimizer minimizes it directly")
124
+ elif seen.mapping("optimizers"):
125
+ notes.append("no optimizer minimizes it at the start, so it only observes")
126
+ for rule, written, item in seen.patches():
127
+ if written.endswith(".minimizes") and ref_name(item, "losses") == name:
128
+ notes.append(f"rule {rule} makes {written.split('.')[1]} minimize it")
129
+ return notes
130
+
131
+
132
+ def metric_notes(seen, key, value, keys, path):
133
+ resolved = seen.mapping(*keys)
134
+ notes = call_notes(seen, path, value, SLOT_KEYS["metric"])
135
+ return notes + criterion_notes(resolved) + default_notes(seen, path, resolved, True)
136
+
137
+
138
+ def over_note(over):
139
+ items = over if isinstance(over, list) else [over]
140
+ plain_models = [ref_name(item, "models") for item in items]
141
+ if len(items) > 1 and all(plain_models) and all(item == f"@models.{name}" for item, name in
142
+ zip(items, plain_models)):
143
+ return f"over: steps the parameters of the models {and_list(plain_models)}"
144
+ parts = []
145
+ for item in items:
146
+ if isinstance(item, dict):
147
+ own = ", ".join(f"{key} {value_text(value)}" for key, value in item.items() if key not in ("of", "match"))
148
+ text = f"the parameters of {ref_meaning(item.get('of'))}"
149
+ text += f" matching {item['match']}" if item.get("match") else ""
150
+ parts.append(text + (f" ({own})" if own else ""))
151
+ elif isinstance(item, str) and item.startswith("@"):
152
+ parts.append(f"the parameters of {ref_meaning(item)}")
153
+ else:
154
+ parts.append(value_text(item))
155
+ return f"over: steps {and_list(parts)}"
156
+
157
+
158
+ def optimizer_notes(seen, key, value, keys, path):
159
+ resolved = seen.mapping(*keys)
160
+ notes = call_notes(seen, path, value, SLOT_KEYS["optimizer"])
161
+ if "over" in resolved:
162
+ notes.append(over_note(resolved["over"]))
163
+ loss = ref_name(resolved.get("minimizes"), "losses")
164
+ if loss:
165
+ notes.append(f"minimizes: steps on the loss {loss}")
166
+ schedule = use_of(resolved.get("schedule"))
167
+ if schedule:
168
+ notes.append(f"schedule: multiplies the lr by {schedule} after every update")
169
+ notes.append(lego_note(schedule))
170
+ notes += param_lines(schedule, resolved.get("schedule"), lead="schedule.")
171
+ return notes + patch_notes(seen, path)
172
+
173
+
174
+ def checkpoint_notes(checkpoint):
175
+ if not isinstance(checkpoint, dict):
176
+ return []
177
+ notes = []
178
+ if checkpoint.get("watch"):
179
+ better = "higher" if checkpoint.get("mode") == "max" else "lower"
180
+ notes.append(f"keeps best.pt whenever {checkpoint['watch']} improves ({better} is better)")
181
+ text = "writes no last.pt" if checkpoint.get("last") is False else "writes last.pt every turn"
182
+ if checkpoint.get("every"):
183
+ text += f", turn_<k>.pt every {value_text(checkpoint['every'])} turns"
184
+ return notes + [text + " and final.pt at the end"]
185
+
186
+
187
+ def train_notes(seen, key, value, keys, path):
188
+ resolved = seen.value(*keys)
189
+ if path == "train":
190
+ return []
191
+ if key == "epochs":
192
+ return [f"trains {value_text(resolved)} turns, each one pass over the train set"]
193
+ if key == "updates" and isinstance(resolved, dict):
194
+ turn = f", {value_text(resolved['turn'])} per turn" if resolved.get("turn") else ""
195
+ return [f"trains {value_text(resolved.get('total'))} updates{turn}"]
196
+ if key == "step":
197
+ return call_notes(seen, "train.step", value)
198
+ if key == "predicts":
199
+ if resolved is False:
200
+ return ["false: no model predicts, for a run whose model makes no predictions, such as a DDPM denoiser"]
201
+ model = ref_name(resolved, "models")
202
+ tail = " and they fill the predictions table" if seen.tabled() else ", and this data class writes no table"
203
+ return [f"the predicts model is {model}: the step computes its outputs for every batch{tail}"] if model else []
204
+ if key == "evaluate" and isinstance(resolved, list):
205
+ return [f"evaluates the {and_list(resolved)} {'set' if len(resolved) == 1 else 'sets'} after every turn"]
206
+ if key == "checkpoint":
207
+ return checkpoint_notes(resolved)
208
+ if key == "report":
209
+ weights = "best.pt" if resolved == "best" else "the weights of the last turn"
210
+ return [f"the final predictions, plots and samples use {weights}"]
211
+ if key == "track" and isinstance(resolved, list):
212
+ return [f"writes {and_list(f'track/{item}' for item in resolved)} every turn"]
213
+ if key == "log":
214
+ return [lego_note(name, seen.found(name)) for name in unique(legos_in(value, True))]
215
+ return []
216
+
217
+
218
+ def trigger_text(use, params, target):
219
+ def get(name):
220
+ found = params.get(name)
221
+ return found if found is not None else default_of(target, name)
222
+
223
+ if use == "after_epoch":
224
+ return f"from turn {value_text(get('at'))}"
225
+ if use == "every":
226
+ return f"every {value_text(get('epochs'))} turns"
227
+ if use == "plateau":
228
+ text = f"after {value_text(get('patience'))} turns without {get('watch')} improving"
229
+ text += f" by {value_text(get('min_delta'))}" if get("min_delta") else ""
230
+ return text + (" (higher is better)" if get("mode") == "max" else "")
231
+ if use in ("above", "below"):
232
+ return f"when {get('watch')} is {use} {value_text(get('value'))}"
233
+ if use == "time_budget":
234
+ return f"once {value_text(get('minutes'))} minutes have passed"
235
+ return f"when {use} fires" if use else "when its trigger fires"
236
+
237
+
238
+ def rule_sentence(rule, target):
239
+ when, after = rule.get("when"), rule.get("after")
240
+ use = use_of(when)
241
+ if when is None:
242
+ fires = f"fires on the turn after rule {after} fired" if after else "fires as soon as it is ready"
243
+ else:
244
+ fires = f"fires {trigger_text(use, when if isinstance(when, dict) else {}, target)}"
245
+ fires = f"asked from the turn after rule {after} fired, it {fires}" if after else fires
246
+ effects = [patch_text(path, value) for path, value in (rule.get("set") or {}).items()]
247
+ if rule.get("stop"):
248
+ effects.append("stops the run after that turn")
249
+ if rule.get("run") is not None:
250
+ effects.append(f"runs {use_of(rule['run']) or value_text(rule['run'])}")
251
+ text = f"{fires} and {and_list(effects)}" if effects else f"{fires} but has no set, stop or run"
252
+ if rule.get("once") is False:
253
+ text += "; with once false it may fire again, and its trigger is reset after each firing"
254
+ return text[0].upper() + text[1:] + "."
255
+
256
+
257
+ def rule_notes(seen, key, value, keys, path):
258
+ resolved = seen.mapping(*keys)
259
+ if not resolved:
260
+ return []
261
+ notes = [rule_sentence(resolved, seen.target(f"{path}.when"))]
262
+ use = use_of(resolved.get("when"))
263
+ if use and use not in TRIGGERS:
264
+ notes += call_notes(seen, f"{path}.when", resolved.get("when"))
265
+ if resolved.get("run") is not None:
266
+ notes += call_notes(seen, f"{path}.run", resolved.get("run"))
267
+ return notes
268
+
269
+
270
+ def plot_notes(seen, key, value, keys, path):
271
+ notes = call_notes(seen, path, value)
272
+ target = seen.target(path)
273
+ if target is not None and not getattr(target, "__module__", "").startswith("kalfa.std"):
274
+ notes.append(f"a figure it returns is saved as plots/{key}.png")
275
+ return notes
276
+
277
+
278
+ def generate_notes(seen, key, value, keys, path):
279
+ if path == "generate":
280
+ return call_notes(seen, "generate", value)
281
+ if key == "use":
282
+ return [lego_note(value, seen.target("generate"))]
283
+ entry = aliases.entry_of(use_of(seen.mapping("generate")) or "")
284
+ note = entry.params.get(key) if entry is not None else None
285
+ return [f"{key}: {short(note)}"] if note else []
286
+
287
+
288
+ def strategy_name(use):
289
+ module = importlib.import_module("kalfa.commands.sweep")
290
+ target = getattr(module, "STRATEGIES", {}).get(use)
291
+ if target is None:
292
+ return use
293
+ path = aliases.target_path(target)
294
+ names = [entry.name for entry in aliases.catalog(slot="strategy") if aliases.target_path(entry.target) == path]
295
+ return names[0] if names else use
296
+
297
+
298
+ def point_count(seen):
299
+ from kalfa.commands.sweep.strategies import strategy_of
300
+
301
+ strategy = strategy_of(seen.mapping("sweep").get("strategy"))
302
+ if callable(getattr(strategy, "points", None)):
303
+ return len(strategy.points(seen.space(), 0))
304
+ return getattr(strategy, "trials", None)
305
+
306
+
307
+ def sweep_notes(seen, key, value, keys, path):
308
+ resolved = seen.value(*keys)
309
+ if key == "strategy":
310
+ use = use_of(resolved) or "grid"
311
+ name = strategy_name(use)
312
+ notes = [lego_note(name)] + param_lines(name, resolved)
313
+ try:
314
+ count = point_count(seen)
315
+ except Exception:
316
+ count = None
317
+ return notes + ([f"the sweep runs {count} points"] if count else [])
318
+ if key == "space" and isinstance(resolved, dict):
319
+ notes = []
320
+ for name, entry in resolved.items():
321
+ paths = seen.uses.get(str(name), [])
322
+ where = f", read as ${name}$ by {and_list(paths[:3])}" if paths else ""
323
+ notes.append(f"{name}: {range_text(entry, seen.grid)}{where}")
324
+ return notes
325
+ if key == "objective" and isinstance(resolved, dict):
326
+ better = "higher" if resolved.get("mode") == "max" else "lower"
327
+ at = resolved.get("at") or "best"
328
+ return [f"ranks the points by the {at} value of {resolved.get('watch')} over the turns of each point, "
329
+ f"{better} being better"]
330
+ if key == "record":
331
+ return [f"the sweep root: the points are written to {value_text(resolved)}/0000, "
332
+ f"{value_text(resolved)}/0001 and so on"]
333
+ return []
@@ -0,0 +1,97 @@
1
+ import io
2
+ import json
3
+
4
+ from ruamel.yaml import YAML
5
+
6
+
7
+ WIDTH = 88
8
+ SHORT = 150
9
+ FLOW = set(",[]{}")
10
+ GLOB = set("*?")
11
+ DUMPER = YAML(typ="safe")
12
+ DUMPER.width = 4096
13
+
14
+
15
+ def flat(text):
16
+ return " ".join(str(text or "").split())
17
+
18
+
19
+ def short(text):
20
+ text = flat(text)
21
+ return text.split("; ")[0] if len(text) > SHORT and "; " in text else text
22
+
23
+
24
+ def lower_first(text):
25
+ return text[0].lower() + text[1:] if len(text) > 1 and text[0].isupper() and text[1].islower() else text
26
+
27
+
28
+ def and_list(items):
29
+ items = [str(item) for item in items]
30
+ if len(items) < 2:
31
+ return "".join(items)
32
+ return ", ".join(items[:-1]) + " and " + items[-1]
33
+
34
+
35
+ def unique(items):
36
+ return list(dict.fromkeys(items))
37
+
38
+
39
+ def plain(text):
40
+ stream = io.StringIO()
41
+ DUMPER.dump(text, stream)
42
+ out = stream.getvalue()
43
+ for tail in ("\n...\n", "\n"):
44
+ if out.endswith(tail):
45
+ return out[:-len(tail)] == text
46
+ return False
47
+
48
+
49
+ def scalar_text(value, inner=False):
50
+ if value is None:
51
+ return "null"
52
+ if isinstance(value, bool):
53
+ return "true" if value else "false"
54
+ if isinstance(value, float) and value != value:
55
+ return ".nan"
56
+ if isinstance(value, float) and value in (float("inf"), float("-inf")):
57
+ return ".inf" if value > 0 else "-.inf"
58
+ if isinstance(value, (int, float)):
59
+ return repr(value)
60
+ text = str(value)
61
+ if text and plain(text) and not GLOB & set(text) and not (inner and (FLOW & set(text) or ": " in text)):
62
+ return text
63
+ return json.dumps(text, ensure_ascii=False)
64
+
65
+
66
+ def flow(value):
67
+ if isinstance(value, dict):
68
+ return "{" + ", ".join(f"{scalar_text(key, True)}: {flow(item)}" for key, item in value.items()) + "}"
69
+ if isinstance(value, (list, tuple)):
70
+ return "[" + ", ".join(flow(item) for item in value) + "]"
71
+ return scalar_text(value, True)
72
+
73
+
74
+ def value_text(value):
75
+ return value if isinstance(value, str) else flow(value)
76
+
77
+
78
+ def yaml_lines(key, value, indent=""):
79
+ head = f"{indent}{scalar_text(key)}:"
80
+ one = f"{head} {flow(value)}"
81
+ if not isinstance(value, (dict, list)) or not value or len(one) <= WIDTH:
82
+ return [one]
83
+ if isinstance(value, dict):
84
+ return [head] + [line for inner, item in value.items() for line in yaml_lines(inner, item, indent + " ")]
85
+ return [head] + [line for item in value for line in item_lines(item, indent + " ")]
86
+
87
+
88
+ def item_lines(item, indent):
89
+ one = f"{indent}- {flow(item)}"
90
+ if len(one) <= WIDTH or not isinstance(item, dict) or not item:
91
+ return [one]
92
+ lines = [line for key, value in item.items() for line in yaml_lines(key, value, indent + " ")]
93
+ return [f"{indent}- {lines[0][len(indent) + 2:]}"] + lines[1:]
94
+
95
+
96
+ def yaml_text(key, value):
97
+ return "\n".join(yaml_lines(key, value))