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,228 @@
1
+ import copy
2
+ import difflib
3
+ import itertools
4
+ import json
5
+
6
+ import numpy
7
+
8
+ from kalfa.commands.sweep.space import draw, is_int, space_values, spread
9
+ from kalfa.core import aliases
10
+ from kalfa.core.errors import BuildError
11
+ from kalfa.records import reader
12
+ from kalfa.records.sweep import mode_of
13
+ from kalfa.std.common import positive_int
14
+
15
+
16
+ SAMPLERS = ("tpe", "random")
17
+ OPTUNA_MISSING = "optuna needs the optuna package (uv sync installs the dependencies of kalfa)"
18
+ STRATEGY_HINT = "grid, random, optuna or {use: <name or import path>, <param>: ...}"
19
+
20
+
21
+ def seed_value(seed):
22
+ return None if seed is None else int(seed)
23
+
24
+
25
+ def resumed_seed(seed, count):
26
+ return None if seed is None else int(numpy.random.SeedSequence([seed, count]).generate_state(1)[0])
27
+
28
+
29
+ class Grid:
30
+ def __init__(self, steps=None):
31
+ self.steps = None if steps is None else positive_int(steps, "grid steps")
32
+
33
+ def axes(self, space):
34
+ axes = {}
35
+ for name, (kind, values) in space_values(space).items():
36
+ if kind == "list":
37
+ axes[name] = values
38
+ elif self.steps is None:
39
+ raise BuildError(f"sweep.space.{name}: the {kind} range needs steps on the grid, as in "
40
+ f"strategy: {{use: grid, steps: 5}}, or a list of values")
41
+ else:
42
+ axes[name] = spread(kind, values, self.steps)
43
+ return axes
44
+
45
+ def points(self, space, seed=0):
46
+ axes = self.axes(space)
47
+ names = list(axes)
48
+ return [dict(zip(names, copy.deepcopy(combination))) for combination in itertools.product(*axes.values())]
49
+
50
+
51
+ class Random:
52
+ def __init__(self, trials, seed=None):
53
+ self.trials = positive_int(trials, "random trials")
54
+ self.seed = seed_value(seed)
55
+
56
+ def points(self, space, seed=0):
57
+ dimensions = space_values(space)
58
+ generator = numpy.random.default_rng(self.seed if self.seed is not None else seed)
59
+ found, seen = [], set()
60
+ for _ in range(self.trials * 10):
61
+ point = {name: draw(generator, kind, values) for name, (kind, values) in dimensions.items()}
62
+ key = json.dumps(point, sort_keys=True, default=str)
63
+ if key in seen:
64
+ continue
65
+ seen.add(key)
66
+ found.append(point)
67
+ if len(found) == self.trials:
68
+ break
69
+ return found
70
+
71
+
72
+ def optuna_module():
73
+ try:
74
+ import optuna
75
+ except ImportError as exc:
76
+ raise BuildError(OPTUNA_MISSING) from exc
77
+ return optuna
78
+
79
+
80
+ class Optuna:
81
+ def __init__(self, trials, sampler="tpe", seed=None):
82
+ self.trials = positive_int(trials, "optuna trials")
83
+ if sampler not in SAMPLERS:
84
+ raise BuildError(f"the optuna sampler is tpe or random, got {sampler!r}")
85
+ self.sampler = sampler
86
+ self.seed = seed_value(seed)
87
+ self.study = None
88
+ optuna_module()
89
+
90
+ def start(self, objective, seed=None):
91
+ optuna = optuna_module()
92
+ optuna.logging.set_verbosity(optuna.logging.WARNING)
93
+ maker = optuna.samplers.TPESampler if self.sampler == "tpe" else optuna.samplers.RandomSampler
94
+ direction = "maximize" if mode_of(objective) == "max" else "minimize"
95
+ self.study = optuna.create_study(direction=direction, sampler=maker(seed=self.seed if seed is None else seed))
96
+ return self.study
97
+
98
+ def resume(self, space, objective, points):
99
+ optuna = optuna_module()
100
+ points = list(points)
101
+ study = self.start(objective, resumed_seed(self.seed, len(points)))
102
+ dimensions = space_values(space)
103
+ told = 0
104
+ for values, value in points:
105
+ trial = finished_trial(optuna, dimensions, values, value)
106
+ if trial is not None:
107
+ study.add_trial(trial)
108
+ told += 1
109
+ return told
110
+
111
+ def ask(self, space, objective):
112
+ dimensions = space_values(space)
113
+ study = self.study if self.study is not None else self.start(objective)
114
+ trial = study.ask()
115
+ return trial, {name: suggest(trial, name, kind, values) for name, (kind, values) in dimensions.items()}
116
+
117
+ def tell(self, trial, value):
118
+ optuna = optuna_module()
119
+ if value is None or not reader.is_finite(value):
120
+ self.study.tell(trial, state=optuna.trial.TrialState.FAIL)
121
+ else:
122
+ self.study.tell(trial, float(value))
123
+
124
+
125
+ def is_plain(value):
126
+ return value is None or isinstance(value, (bool, int, float, str))
127
+
128
+
129
+ def suggest(trial, name, kind, values):
130
+ if kind == "list":
131
+ if all(is_plain(value) for value in values):
132
+ return trial.suggest_categorical(name, values)
133
+ return copy.deepcopy(values[trial.suggest_categorical(name, list(range(len(values))))])
134
+ low, high = values
135
+ if kind == "int":
136
+ return int(trial.suggest_int(name, low, high))
137
+ return float(trial.suggest_float(name, low, high, log=kind == "log"))
138
+
139
+
140
+ def distribution_of(optuna, kind, values, value):
141
+ found = optuna.distributions
142
+ if kind == "list":
143
+ if all(is_plain(choice) for choice in values):
144
+ return (found.CategoricalDistribution(values), value) if value in values else None
145
+ index = next((index for index, choice in enumerate(values) if choice == value), None)
146
+ return None if index is None else (found.CategoricalDistribution(list(range(len(values)))), index)
147
+ low, high = values
148
+ if not reader.is_finite(value) or not low <= value <= high:
149
+ return None
150
+ if kind == "int":
151
+ return (found.IntDistribution(low, high), int(value)) if is_int(value) else None
152
+ return found.FloatDistribution(low, high, log=kind == "log"), float(value)
153
+
154
+
155
+ def finished_trial(optuna, dimensions, values, value):
156
+ if not isinstance(values, dict):
157
+ return None
158
+ params, distributions = {}, {}
159
+ for name, (kind, choices) in dimensions.items():
160
+ found = distribution_of(optuna, kind, choices, values[name]) if name in values else None
161
+ if found is None:
162
+ return None
163
+ distributions[name], params[name] = found
164
+ try:
165
+ if value is None or not reader.is_finite(value):
166
+ return optuna.trial.create_trial(params=params, distributions=distributions,
167
+ state=optuna.trial.TrialState.FAIL)
168
+ return optuna.trial.create_trial(params=params, distributions=distributions, value=float(value))
169
+ except ValueError:
170
+ return None
171
+
172
+
173
+ STRATEGIES = {"grid": Grid, "random": Random, "optuna": Optuna}
174
+
175
+
176
+ def strategy_call(raw):
177
+ if raw is None:
178
+ return "grid", {}
179
+ if isinstance(raw, str) and raw:
180
+ return raw, {}
181
+ if isinstance(raw, dict) and isinstance(raw.get("use"), str) and raw["use"]:
182
+ return raw["use"], {str(key): value for key, value in raw.items() if key != "use"}
183
+ raise BuildError(f"sweep.strategy is {STRATEGY_HINT}, got {raw!r}")
184
+
185
+
186
+ def strategy_names():
187
+ return [*STRATEGIES, *(entry.name for entry in aliases.catalog(slot="strategy") if entry.name not in STRATEGIES)]
188
+
189
+
190
+ def strategy_target(use):
191
+ if use in STRATEGIES:
192
+ return STRATEGIES[use]
193
+ entry = aliases.entry_of(use)
194
+ if entry is not None:
195
+ if entry.slot not in (None, "strategy"):
196
+ raise BuildError(f"{use!r} is a {entry.slot}, not a sweep strategy; the strategies are "
197
+ + ", ".join(strategy_names()))
198
+ return aliases.import_target(entry.target)
199
+ if aliases.is_import_path(use):
200
+ return aliases.import_target(use)
201
+ close = difflib.get_close_matches(use, strategy_names(), n=3, cutoff=0.6)
202
+ hint = f"; did you mean {', '.join(close)}" if close else ""
203
+ raise BuildError(f"{use!r} is not a sweep strategy; the strategies are {', '.join(strategy_names())} or an "
204
+ f"import path such as pkg.module.Class{hint}")
205
+
206
+
207
+ def strategy_of(raw):
208
+ use, options = strategy_call(raw)
209
+ target = strategy_target(use)
210
+ try:
211
+ strategy = target(**options)
212
+ except BuildError:
213
+ raise
214
+ except Exception as exc:
215
+ raise BuildError(f"sweep.strategy {use}: {type(exc).__name__}: {exc}") from exc
216
+ if callable(getattr(strategy, "points", None)):
217
+ return strategy
218
+ if not (callable(getattr(strategy, "ask", None)) and callable(getattr(strategy, "tell", None))):
219
+ raise BuildError(f"sweep.strategy {use}: a strategy has points(space, seed), or ask(space, objective) and "
220
+ f"tell(trial, value)")
221
+ positive_int(getattr(strategy, "trials", None), f"sweep.strategy {use}: trials")
222
+ return strategy
223
+
224
+
225
+ def strategy_note(raw):
226
+ if isinstance(raw, dict):
227
+ return copy.deepcopy(raw)
228
+ return {"use": raw if isinstance(raw, str) else "grid"}
kalfa/core/__init__.py ADDED
File without changes
kalfa/core/aliases.py ADDED
@@ -0,0 +1,229 @@
1
+ import difflib
2
+ import importlib
3
+ import importlib.util
4
+ import inspect
5
+ import logging
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .errors import BuildError, error
11
+
12
+
13
+ LOGGER = logging.getLogger("kalfa.aliases")
14
+ HINT = "kalfa legos lists the names"
15
+
16
+
17
+ @dataclass
18
+ class Entry:
19
+ name: str
20
+ target: str
21
+ slot: str | None = None
22
+ doc: str = ""
23
+ params: dict = field(default_factory=dict)
24
+ examples: list = field(default_factory=list)
25
+ plugin: bool = False
26
+
27
+
28
+ ALIASES = {}
29
+ SEARCH_DIRS = []
30
+
31
+
32
+ def target_path(target):
33
+ if isinstance(target, str):
34
+ return target
35
+ if inspect.ismodule(target):
36
+ return target.__name__
37
+ module = getattr(target, "__module__", None)
38
+ qualname = getattr(target, "__qualname__", None) or getattr(target, "__name__", None)
39
+ if module and qualname:
40
+ return f"{module}.{qualname}"
41
+ return repr(target)
42
+
43
+
44
+ def imports_as(path, target):
45
+ try:
46
+ return import_target(path) is target
47
+ except BuildError:
48
+ return False
49
+
50
+
51
+ def same_target(first, second):
52
+ if first is second:
53
+ return True
54
+ if isinstance(first, str) != isinstance(second, str):
55
+ path, target = (first, second) if isinstance(first, str) else (second, first)
56
+ if imports_as(path, target):
57
+ return True
58
+ if not isinstance(first, str) and not isinstance(second, str):
59
+ if "<" in target_path(first) or "<" in target_path(second):
60
+ return False
61
+ return target_path(first) == target_path(second)
62
+
63
+
64
+ def register(name, target, *, slot=None, doc="", params=None, examples=None, plugin=False):
65
+ if not isinstance(name, str) or not name:
66
+ raise BuildError(f"an alias name must be a non empty string, got {name!r}")
67
+ if target is None:
68
+ raise BuildError(f"{name!r}: the target must be an import path or an object")
69
+ entry = Entry(name, target, slot, doc or "", dict(params or {}), list(examples or []), bool(plugin))
70
+ existing = ALIASES.get(name)
71
+ if existing is not None:
72
+ if same_target(existing.target, target):
73
+ if doc and not existing.doc:
74
+ existing.doc = doc
75
+ if slot and not existing.slot:
76
+ existing.slot = slot
77
+ return existing
78
+ if existing.plugin and plugin:
79
+ LOGGER.warning("alias %r moves from %s to %s", name, target_path(existing.target), target_path(target))
80
+ else:
81
+ raise BuildError(f"{name!r} is already registered as {target_path(existing.target)}; "
82
+ f"{target_path(target)} cannot take the name ({HINT})")
83
+ ALIASES[name] = entry
84
+ return entry
85
+
86
+
87
+ def annotate(name, params=None, examples=None):
88
+ entry = ALIASES.get(name) if isinstance(name, str) else None
89
+ if entry is None:
90
+ raise BuildError(f"{name!r} is not a registered name, so it takes no notes ({HINT})")
91
+ entry.params.update(params or {})
92
+ for example in examples or ():
93
+ if example not in entry.examples:
94
+ entry.examples.append(example)
95
+ return entry
96
+
97
+
98
+ def lego(name, *, slot=None, doc="", params=None, examples=None):
99
+ def decorate(target):
100
+ text = doc or (getattr(target, "__doc__", None) or "").strip()
101
+ register(name, target, slot=slot, doc=text, params=params, examples=examples, plugin=True)
102
+ return target
103
+ return decorate
104
+
105
+
106
+ def is_import_path(text):
107
+ return isinstance(text, str) and "." in text and not text.startswith(".")
108
+
109
+
110
+ def resolve(name):
111
+ entry = ALIASES.get(name) if isinstance(name, str) else None
112
+ if entry is not None:
113
+ return entry.target
114
+ if is_import_path(name):
115
+ return name
116
+ raise BuildError(f"{name!r} is not a registered name and not an import path such as pkg.module.Class ({HINT})")
117
+
118
+
119
+ def suggestion(name):
120
+ close = difflib.get_close_matches(str(name), list(ALIASES), n=3, cutoff=0.6)
121
+ return "did you mean " + ", ".join(close) if close else None
122
+
123
+
124
+ def add_search_dir(directory):
125
+ if directory and str(directory) not in SEARCH_DIRS:
126
+ SEARCH_DIRS.append(str(directory))
127
+ if str(directory) not in sys.path:
128
+ sys.path.insert(0, str(directory))
129
+
130
+
131
+ def missing_prefix(exc, module_name):
132
+ missing = getattr(exc, "name", None)
133
+ return bool(missing) and (missing == module_name or module_name.startswith(missing + "."))
134
+
135
+
136
+ def import_target(path):
137
+ if not isinstance(path, str):
138
+ return path
139
+ if not path or path.startswith(".") or path.endswith("."):
140
+ raise BuildError(f"{path!r} is not an import path such as pkg.module.Class")
141
+ parts = path.split(".")
142
+ if parts[0] not in sys.modules:
143
+ importlib.invalidate_caches()
144
+ failure = None
145
+ for cut in range(len(parts), 0, -1):
146
+ module_name = ".".join(parts[:cut])
147
+ try:
148
+ module = importlib.import_module(module_name)
149
+ except ModuleNotFoundError as exc:
150
+ if not missing_prefix(exc, module_name):
151
+ raise BuildError(f"{path}: importing {module_name} failed: {exc}") from exc
152
+ failure = exc
153
+ continue
154
+ except Exception as exc:
155
+ raise BuildError(f"{path}: importing {module_name} failed: {type(exc).__name__}: {exc}") from exc
156
+ target = module
157
+ for attribute in parts[cut:]:
158
+ try:
159
+ target = getattr(target, attribute)
160
+ except AttributeError as exc:
161
+ raise BuildError(f"{path}: {target_path(target)} has no attribute {attribute!r}") from exc
162
+ return target
163
+ raise BuildError(f"{path}: {failure}")
164
+
165
+
166
+ def plugin_file(name):
167
+ candidate = Path(name)
168
+ if candidate.suffix != ".py":
169
+ return None
170
+ roots = [Path.cwd(), *[Path(directory) for directory in SEARCH_DIRS]]
171
+ if candidate.is_absolute():
172
+ roots = [None]
173
+ for root in roots:
174
+ path = candidate if root is None else root / candidate
175
+ if path.is_file():
176
+ return path.resolve()
177
+ return None
178
+
179
+
180
+ def import_file(path):
181
+ name = path.stem
182
+ found = sys.modules.get(name)
183
+ if found is not None and getattr(found, "__file__", None) and Path(found.__file__).resolve() == path:
184
+ return found
185
+ spec = importlib.util.spec_from_file_location(name, path)
186
+ module = importlib.util.module_from_spec(spec)
187
+ sys.modules[name] = module
188
+ try:
189
+ spec.loader.exec_module(module)
190
+ except BaseException:
191
+ sys.modules.pop(name, None)
192
+ raise
193
+ return module
194
+
195
+
196
+ def import_plugins(names, problems):
197
+ loaded = []
198
+ importlib.invalidate_caches()
199
+ for name in names or ():
200
+ try:
201
+ path = plugin_file(str(name))
202
+ if path is not None:
203
+ loaded.append(import_file(path))
204
+ elif isinstance(name, str) and name.endswith(".py"):
205
+ raise ImportError(f"no file {name} under the working directory or the config directories")
206
+ else:
207
+ loaded.append(importlib.import_module(str(name)))
208
+ except Exception as exc:
209
+ problems.append(error("plugin_import", f"{name}: {type(exc).__name__}: {exc}", "plugins",
210
+ "a plugin is a module on sys.path or a .py file next to the config"))
211
+ return loaded
212
+
213
+
214
+ def catalog(slot=None, word=None):
215
+ needle = word.lower() if isinstance(word, str) and word else None
216
+ found = []
217
+ for name in sorted(ALIASES):
218
+ entry = ALIASES[name]
219
+ if slot is not None and entry.slot != slot:
220
+ continue
221
+ if needle is not None and needle not in name.lower() and needle not in entry.doc.lower():
222
+ continue
223
+ found.append(entry)
224
+ return found
225
+
226
+
227
+ def entry_of(name):
228
+ return ALIASES.get(name)
229
+