kalfa 3.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kalfa/__init__.py +19 -0
- kalfa/api.py +284 -0
- kalfa/board/__init__.py +1 -0
- kalfa/board/answers.py +419 -0
- kalfa/board/server.py +536 -0
- kalfa/board/static/api.js +176 -0
- kalfa/board/static/app.js +122 -0
- kalfa/board/static/board.css +228 -0
- kalfa/board/static/charts.css +46 -0
- kalfa/board/static/components/badge.js +14 -0
- kalfa/board/static/components/chart.js +204 -0
- kalfa/board/static/components/diagram.js +857 -0
- kalfa/board/static/components/error.js +17 -0
- kalfa/board/static/components/loading.js +7 -0
- kalfa/board/static/components/progress.js +25 -0
- kalfa/board/static/components/settings.js +418 -0
- kalfa/board/static/components/spark.js +43 -0
- kalfa/board/static/components/table.js +156 -0
- kalfa/board/static/diagram.css +76 -0
- kalfa/board/static/format.js +138 -0
- kalfa/board/static/index.html +34 -0
- kalfa/board/static/plot.js +594 -0
- kalfa/board/static/predictions.css +20 -0
- kalfa/board/static/store.js +215 -0
- kalfa/board/static/views/compare.js +185 -0
- kalfa/board/static/views/home.js +90 -0
- kalfa/board/static/views/record/config.js +81 -0
- kalfa/board/static/views/record/curves.js +137 -0
- kalfa/board/static/views/record/data.js +378 -0
- kalfa/board/static/views/record/files.js +128 -0
- kalfa/board/static/views/record/log.js +164 -0
- kalfa/board/static/views/record/model.js +286 -0
- kalfa/board/static/views/record/overview.js +216 -0
- kalfa/board/static/views/record/plots.js +103 -0
- kalfa/board/static/views/record/predictions.js +834 -0
- kalfa/board/static/views/record.js +179 -0
- kalfa/board/static/views/runs.js +124 -0
- kalfa/board/static/views/sweep.js +282 -0
- kalfa/board/watch.py +87 -0
- kalfa/cli.py +371 -0
- kalfa/commands/__init__.py +0 -0
- kalfa/commands/common.py +8 -0
- kalfa/commands/export.py +117 -0
- kalfa/commands/generate.py +31 -0
- kalfa/commands/guide/__init__.py +101 -0
- kalfa/commands/guide/about.py +93 -0
- kalfa/commands/guide/calls.py +102 -0
- kalfa/commands/guide/sections.py +186 -0
- kalfa/commands/guide/seen.py +307 -0
- kalfa/commands/guide/show.py +106 -0
- kalfa/commands/guide/training.py +333 -0
- kalfa/commands/guide/words.py +97 -0
- kalfa/commands/legos.py +508 -0
- kalfa/commands/plots.py +27 -0
- kalfa/commands/report/__init__.py +64 -0
- kalfa/commands/report/page.py +112 -0
- kalfa/commands/report/predictions.py +216 -0
- kalfa/commands/report/sections.py +356 -0
- kalfa/commands/stop.py +60 -0
- kalfa/commands/sweep/__init__.py +202 -0
- kalfa/commands/sweep/checks.py +98 -0
- kalfa/commands/sweep/plan.py +183 -0
- kalfa/commands/sweep/points.py +123 -0
- kalfa/commands/sweep/runner.py +100 -0
- kalfa/commands/sweep/space.py +67 -0
- kalfa/commands/sweep/strategies.py +228 -0
- kalfa/core/__init__.py +0 -0
- kalfa/core/aliases.py +229 -0
- kalfa/core/build.py +727 -0
- kalfa/core/check.py +550 -0
- kalfa/core/coerce.py +36 -0
- kalfa/core/context.py +149 -0
- kalfa/core/device.py +63 -0
- kalfa/core/errors.py +45 -0
- kalfa/core/graph.py +304 -0
- kalfa/core/history.py +70 -0
- kalfa/core/loop.py +710 -0
- kalfa/core/make.py +121 -0
- kalfa/core/plan.py +156 -0
- kalfa/core/predict.py +290 -0
- kalfa/core/protocols.py +122 -0
- kalfa/core/record.py +347 -0
- kalfa/core/refs.py +88 -0
- kalfa/core/rng.py +100 -0
- kalfa/core/rules.py +513 -0
- kalfa/core/run.py +568 -0
- kalfa/core/spec.py +413 -0
- kalfa/core/state.py +100 -0
- kalfa/core/wrap.py +671 -0
- kalfa/records/__init__.py +24 -0
- kalfa/records/compare.py +86 -0
- kalfa/records/reader.py +561 -0
- kalfa/records/stats/__init__.py +36 -0
- kalfa/records/stats/choices.py +282 -0
- kalfa/records/stats/classes.py +236 -0
- kalfa/records/stats/measures.py +144 -0
- kalfa/records/stats/pairs.py +225 -0
- kalfa/records/stats/tables.py +153 -0
- kalfa/records/sweep.py +244 -0
- kalfa/std/__init__.py +17 -0
- kalfa/std/actions.py +35 -0
- kalfa/std/common.py +56 -0
- kalfa/std/data/__init__.py +1 -0
- kalfa/std/data/base.py +45 -0
- kalfa/std/data/columns.py +190 -0
- kalfa/std/data/flow.py +317 -0
- kalfa/std/data/images.py +443 -0
- kalfa/std/data/options.py +147 -0
- kalfa/std/data/pre.py +84 -0
- kalfa/std/data/splits.py +140 -0
- kalfa/std/data/stream.py +294 -0
- kalfa/std/data/table.py +642 -0
- kalfa/std/data/text.py +436 -0
- kalfa/std/data/transforms.py +327 -0
- kalfa/std/data/weights.py +113 -0
- kalfa/std/data/window.py +135 -0
- kalfa/std/figure.py +37 -0
- kalfa/std/init.py +72 -0
- kalfa/std/loggers.py +105 -0
- kalfa/std/losses.py +161 -0
- kalfa/std/metrics.py +463 -0
- kalfa/std/models.py +122 -0
- kalfa/std/notes.py +1553 -0
- kalfa/std/objectives.py +360 -0
- kalfa/std/plots/__init__.py +75 -0
- kalfa/std/plots/classification.py +207 -0
- kalfa/std/plots/common.py +44 -0
- kalfa/std/plots/curves.py +56 -0
- kalfa/std/plots/images.py +188 -0
- kalfa/std/plots/pairs.py +66 -0
- kalfa/std/plots/predictions.py +154 -0
- kalfa/std/samplers.py +261 -0
- kalfa/std/samples.py +72 -0
- kalfa/std/schedules.py +74 -0
- kalfa/std/steps.py +133 -0
- kalfa/std/triggers.py +135 -0
- kalfa/summary.py +296 -0
- kalfa/tui/__init__.py +4 -0
- kalfa/tui/app.py +107 -0
- kalfa/tui/config.py +61 -0
- kalfa/tui/guide.py +100 -0
- kalfa/tui/jobs.py +97 -0
- kalfa/tui/legos.py +62 -0
- kalfa/tui/records.py +439 -0
- kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
- kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
- kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
- kalfa-3.0.0.dev0.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
|