zettcode-engine 0.3.4__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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Which plugins load, and which the next start leaves out.
|
|
2
|
+
|
|
3
|
+
Two things decide it, and both are written now for the process that starts next:
|
|
4
|
+
|
|
5
|
+
* A plugin installed as a distribution is switched by `[plugins] disable` in the configuration file; an older
|
|
6
|
+
file of names beside the session store is still read and merged.
|
|
7
|
+
* A plugin compiled into the program has no file of its own. What turns its work off is a setting the reader already
|
|
8
|
+
has — ``[skills] enabled``, ``[mcp] enabled`` — which the plugin declares (see
|
|
9
|
+
:attr:`~zettcode.engine.plugins.plugin.Plugin.setting`). A compiled-in plugin that declares none cannot be switched
|
|
10
|
+
at all, so :func:`catalog` leaves it out rather than list a row with nothing to press.
|
|
11
|
+
|
|
12
|
+
The terminal's ``/plugins`` page, the server's panel and the SDK all come through this module, so they cannot disagree
|
|
13
|
+
about which names exist, which may be switched, or where the answer is kept.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from zettcode.config import read_disabled_plugins
|
|
26
|
+
from zettcode.config_edit import ConfigEditError, set_boolean, set_string_list, write_text_atomically
|
|
27
|
+
from zettcode.paths import config_path
|
|
28
|
+
|
|
29
|
+
from . import loader
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
32
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
33
|
+
from contextlib import AbstractContextManager
|
|
34
|
+
from importlib.metadata import EntryPoint
|
|
35
|
+
|
|
36
|
+
from zettcode.config import ZettCodeConfig
|
|
37
|
+
|
|
38
|
+
from .plugin import Plugins, PluginSetting
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class PluginEntry:
|
|
43
|
+
"""One plugin as a list of them shows it: what it is, where it came from, and whether the next start loads it.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
name: What the plugin calls itself.
|
|
47
|
+
origin: The distribution that carries it; the same as ``name`` for a plugin compiled into the program. This
|
|
48
|
+
is the name a switch is written with, because it is the one the loader matches.
|
|
49
|
+
builtin: Whether it is compiled into the program.
|
|
50
|
+
enabled: Whether the next start does its work.
|
|
51
|
+
failure: Why it could not load in this process, or ``""`` when it did.
|
|
52
|
+
setting: The configuration key that decides a compiled-in plugin (``"skills.enabled"``); empty for an
|
|
53
|
+
installed one, which ``[plugins] disable`` holds.
|
|
54
|
+
switchable: Whether there is anything a reader can do about it *here*. A compiled-in plugin that failed has no
|
|
55
|
+
switch, and neither has one whose switch belongs to a line the reader wrote; a list draws those rows
|
|
56
|
+
without one, and says why in ``why``.
|
|
57
|
+
why: Why it cannot be switched, when it cannot; empty otherwise.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
origin: str
|
|
62
|
+
builtin: bool = False
|
|
63
|
+
enabled: bool = True
|
|
64
|
+
failure: str = ""
|
|
65
|
+
setting: str = ""
|
|
66
|
+
switchable: bool = True
|
|
67
|
+
why: str = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class SwitchState:
|
|
72
|
+
"""What writing an installed plugin's switch needs, for a caller that did not build the whole settings object."""
|
|
73
|
+
|
|
74
|
+
plugins_file: Path
|
|
75
|
+
plugins_enabled: bool = True
|
|
76
|
+
disabled_plugins: tuple[str, ...] = ()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def off_names(config: ZettCodeConfig) -> tuple[str, ...]:
|
|
80
|
+
"""Return every name the next start will leave out, sorted: the configuration's own and the legacy switch file's."""
|
|
81
|
+
return tuple(sorted(set(config.disabled_plugins) | set(read_disabled_plugins(config.plugins_file))))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def catalog(
|
|
85
|
+
config: ZettCodeConfig,
|
|
86
|
+
bundle: Plugins,
|
|
87
|
+
*,
|
|
88
|
+
entry_points: Iterable[EntryPoint] | None = None,
|
|
89
|
+
) -> tuple[PluginEntry, ...]:
|
|
90
|
+
"""Return every plugin a list can show, in the order it shows them.
|
|
91
|
+
|
|
92
|
+
That is: the compiled-in plugins the reader can switch, each with the setting that decides it; the installed
|
|
93
|
+
distributions, in load order, with the ones the next start leaves out; then the ones that could not load, each
|
|
94
|
+
with the reason. A compiled-in plugin with no setting is not here at all: there is nothing a reader could do about
|
|
95
|
+
it.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
config: Settings the switches are read from.
|
|
99
|
+
bundle: What the loader produced, whose ``builtins`` says which rows are the program's own.
|
|
100
|
+
entry_points: The installed entry points; ``None`` reads the ones this environment has, so a test can pass
|
|
101
|
+
its own instead of installing a distribution.
|
|
102
|
+
"""
|
|
103
|
+
builtins = set(bundle.builtins)
|
|
104
|
+
off = set(off_names(config))
|
|
105
|
+
compiled: list[PluginEntry] = []
|
|
106
|
+
installed: list[PluginEntry] = []
|
|
107
|
+
for plugin in bundle.plugins:
|
|
108
|
+
origin = plugin.origin or plugin.name
|
|
109
|
+
setting = plugin.setting
|
|
110
|
+
if origin in builtins:
|
|
111
|
+
if setting is not None:
|
|
112
|
+
compiled.append(
|
|
113
|
+
PluginEntry(
|
|
114
|
+
plugin.name,
|
|
115
|
+
origin,
|
|
116
|
+
builtin=True,
|
|
117
|
+
enabled=bool(getattr(config, setting.field)),
|
|
118
|
+
setting=setting.path,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
installed.append(_installed(plugin.name, origin, config, off=off))
|
|
123
|
+
points = tuple(entry_points) if entry_points is not None else loader.installed_entry_points()
|
|
124
|
+
seen = {entry.origin for entry in installed} | {origin for origin, _why in bundle.failed}
|
|
125
|
+
for point in points:
|
|
126
|
+
if point.name not in seen:
|
|
127
|
+
installed.append(_installed(point.name, point.name, config, off=off))
|
|
128
|
+
# A single-file plugin that is switched off is not in the bundle (the loader never imported it), so the folder is
|
|
129
|
+
# what says it exists: it stays on the page, off, where it can be switched back on.
|
|
130
|
+
for path in loader.installed_files(config.plugins_dir):
|
|
131
|
+
if path.stem not in seen:
|
|
132
|
+
installed.append(_installed(path.stem, path.stem, config, off=off))
|
|
133
|
+
# A file can load some of its classes and fail others: every reason belongs on the file's one row, not on a
|
|
134
|
+
# second row named after the class, which no switch could ever write. A file that fails all of its classes still
|
|
135
|
+
# gets one row, with every reason joined.
|
|
136
|
+
reasons: dict[str, list[str]] = {}
|
|
137
|
+
for origin, why in bundle.failed:
|
|
138
|
+
if origin not in builtins:
|
|
139
|
+
reasons.setdefault(origin, []).append(why)
|
|
140
|
+
if reasons:
|
|
141
|
+
installed = [
|
|
142
|
+
replace(entry, failure="; ".join(reasons[entry.origin])) if entry.origin in reasons else entry
|
|
143
|
+
for entry in installed
|
|
144
|
+
]
|
|
145
|
+
loaded_origins = {entry.origin for entry in installed}
|
|
146
|
+
failures: dict[str, list[str]] = {}
|
|
147
|
+
for origin, why in bundle.failed:
|
|
148
|
+
if origin in builtins or origin not in loaded_origins:
|
|
149
|
+
failures.setdefault(origin, []).append(why)
|
|
150
|
+
failed = [
|
|
151
|
+
_failed(origin, "; ".join(whys), config, builtin=origin in builtins, off=off)
|
|
152
|
+
for origin, whys in failures.items()
|
|
153
|
+
]
|
|
154
|
+
# The group a row belongs to decides where it stands, so a group is one run of rows and gets one heading.
|
|
155
|
+
return tuple(
|
|
156
|
+
[
|
|
157
|
+
*compiled,
|
|
158
|
+
*(entry for entry in failed if entry.builtin),
|
|
159
|
+
*installed,
|
|
160
|
+
*(entry for entry in failed if not entry.builtin),
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _installed(name: str, origin: str, config: ZettCodeConfig, *, off: set[str]) -> PluginEntry:
|
|
166
|
+
"""Return one installed plugin's row: whether the next start loads it, and whether its switch can move."""
|
|
167
|
+
why = (
|
|
168
|
+
"installed plugins are off entirely: [plugins] enabled = false in the configuration file"
|
|
169
|
+
if not config.plugins_enabled
|
|
170
|
+
else ""
|
|
171
|
+
)
|
|
172
|
+
return PluginEntry(
|
|
173
|
+
name,
|
|
174
|
+
origin,
|
|
175
|
+
enabled=config.plugins_enabled and origin not in off,
|
|
176
|
+
switchable=why == "",
|
|
177
|
+
why=why,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _failed(origin: str, why: str, config: ZettCodeConfig, *, builtin: bool, off: set[str]) -> PluginEntry:
|
|
182
|
+
"""Return the row of a plugin that could not load.
|
|
183
|
+
|
|
184
|
+
A compiled-in one has no switch here: the object that declares its setting never loaded. An installed one keeps
|
|
185
|
+
its: ``[plugins] disable`` is exactly what would leave it out next time, and its row says both things.
|
|
186
|
+
"""
|
|
187
|
+
if builtin:
|
|
188
|
+
return PluginEntry(
|
|
189
|
+
origin,
|
|
190
|
+
origin,
|
|
191
|
+
builtin=True,
|
|
192
|
+
enabled=origin not in off,
|
|
193
|
+
failure=why,
|
|
194
|
+
switchable=False,
|
|
195
|
+
why="it could not load, so there is no switch to move",
|
|
196
|
+
)
|
|
197
|
+
row = _installed(origin, origin, config, off=off)
|
|
198
|
+
return PluginEntry(
|
|
199
|
+
row.name,
|
|
200
|
+
row.origin,
|
|
201
|
+
enabled=row.enabled,
|
|
202
|
+
failure=why,
|
|
203
|
+
switchable=row.switchable,
|
|
204
|
+
why=row.why,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _config_disabled(path: Path) -> list[str]:
|
|
209
|
+
"""Return the names ``[plugins] disable`` holds in the configuration file, validating the shape."""
|
|
210
|
+
from zettcode._compat import tomllib
|
|
211
|
+
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
return []
|
|
214
|
+
try:
|
|
215
|
+
text = path.read_text(encoding="utf-8")
|
|
216
|
+
except OSError:
|
|
217
|
+
return []
|
|
218
|
+
data = tomllib.loads(text) if text.strip() else {}
|
|
219
|
+
plugins = data.get("plugins", {})
|
|
220
|
+
if not isinstance(plugins, dict):
|
|
221
|
+
raise ValueError(f"{path} writes [plugins] in a way /plugins cannot edit; edit the file by hand")
|
|
222
|
+
values = plugins.get("disable", [])
|
|
223
|
+
if not isinstance(values, list) or any(not isinstance(name, str) or not name.strip() for name in values):
|
|
224
|
+
raise ValueError(f"{path} has a [plugins] disable that is not an array of names; edit the file by hand")
|
|
225
|
+
return [name.strip() for name in values]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _drop_legacy_names(path: Path, names: set[str]) -> bool:
|
|
229
|
+
"""Remove names this switch wrote from the old switch file; False when it could not."""
|
|
230
|
+
if not names:
|
|
231
|
+
return True
|
|
232
|
+
try:
|
|
233
|
+
with _lock(path):
|
|
234
|
+
current = set(read_disabled_plugins(path))
|
|
235
|
+
if not current & names:
|
|
236
|
+
return True
|
|
237
|
+
_write(path, current - names)
|
|
238
|
+
except (OSError, ValueError):
|
|
239
|
+
return False
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _write_off(config: ZettCodeConfig | SwitchState, enable: set[str], disable: set[str]) -> None:
|
|
244
|
+
"""Edit ``[plugins] disable`` in the configuration file, then drop the same names from the legacy switch file.
|
|
245
|
+
|
|
246
|
+
The configuration file is the one the reader edits and the one ``load_config`` reads, so the switch is written
|
|
247
|
+
there; a file that does not exist yet gets one with the table, and the models are added when the reader sets one.
|
|
248
|
+
A switch that changes nothing writes nothing and does not create the file. The read and the replace are held
|
|
249
|
+
under a lock, so two plugin switches (or two plugin-settings edits) cannot lose one another.
|
|
250
|
+
"""
|
|
251
|
+
target = config_path()
|
|
252
|
+
if target.exists() and not target.is_file():
|
|
253
|
+
raise ValueError(f"{target} is not a file; edit the plugin switches by hand")
|
|
254
|
+
changed = False
|
|
255
|
+
try:
|
|
256
|
+
with _lock(target):
|
|
257
|
+
text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
258
|
+
current = set(_config_disabled(target))
|
|
259
|
+
names = (current | disable) - enable
|
|
260
|
+
if names != current:
|
|
261
|
+
text = set_string_list(text, "plugins", "disable", sorted(names), where=target, command="/plugins")
|
|
262
|
+
write_text_atomically(target, text)
|
|
263
|
+
changed = True
|
|
264
|
+
except (OSError, ValueError) as error:
|
|
265
|
+
raise ValueError(f"the plugin switch could not be written: {error}") from error
|
|
266
|
+
if not _drop_legacy_names(config.plugins_file, enable | disable) and enable:
|
|
267
|
+
if changed:
|
|
268
|
+
raise ValueError(f"the configuration was changed, but {_still_in_legacy(enable)}")
|
|
269
|
+
raise ValueError(_still_in_legacy(enable))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _still_in_legacy(enable: set[str]) -> str:
|
|
273
|
+
"""Return the message for a switch the old file would keep off even though the configuration says otherwise."""
|
|
274
|
+
names = ", ".join(sorted(enable))
|
|
275
|
+
one = len(enable) == 1
|
|
276
|
+
return (
|
|
277
|
+
f"{names} {'is' if one else 'are'} still switched off in the old switch file; "
|
|
278
|
+
f"{'it' if one else 'they'} will not load until that file is writable or the "
|
|
279
|
+
f"{'name is' if one else 'names are'} removed there"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def set_many(
|
|
284
|
+
config: ZettCodeConfig,
|
|
285
|
+
changes: Mapping[str, bool],
|
|
286
|
+
*,
|
|
287
|
+
bundle: Plugins,
|
|
288
|
+
entry_points: Iterable[EntryPoint] | None = None,
|
|
289
|
+
) -> tuple[dict[str, bool], list[tuple[PluginSetting, bool]], list[str]]:
|
|
290
|
+
"""Write every switch down, each file once, and say what was written and what was refused.
|
|
291
|
+
|
|
292
|
+
A plugin compiled into the program is one key of the configuration file; an installed one is a name in
|
|
293
|
+
``[plugins] disable`` in that same file. Every such change goes into one edit of the configuration, and the names
|
|
294
|
+
this writes are dropped from the older switch file so it cannot go on overriding the config. A name that cannot be
|
|
295
|
+
written is reported and the rest still are, so a panel's Enter is not undone by one refusal.
|
|
296
|
+
|
|
297
|
+
Everything the rules need comes from the bundle and the configuration — which names exist, which are compiled in,
|
|
298
|
+
which declare a setting, which group is off — so a front end that has those two cannot apply a different rule
|
|
299
|
+
from another one.
|
|
300
|
+
|
|
301
|
+
Nothing loads or unloads here: the configuration is read once, at startup, so this only decides what that start
|
|
302
|
+
finds.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
config: Settings the configuration's own lists and the legacy switch file's location come from.
|
|
306
|
+
changes: The plugin's name or origin to the state to write for it.
|
|
307
|
+
bundle: What the loader produced: the names, the origins and the settings.
|
|
308
|
+
entry_points: The installed entry points; ``None`` reads the ones this environment has.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The origins written and their state, the configuration keys written (for a caller keeping one in memory), and
|
|
312
|
+
one message per name that could not be written.
|
|
313
|
+
"""
|
|
314
|
+
written: dict[str, bool] = {}
|
|
315
|
+
keys: list[tuple[PluginSetting, bool]] = []
|
|
316
|
+
problems: list[str] = []
|
|
317
|
+
settings, builtins, known = _rules(config, bundle, entry_points)
|
|
318
|
+
key_changes: list[tuple[str, PluginSetting, bool]] = []
|
|
319
|
+
off_changes: dict[str, bool] = {}
|
|
320
|
+
off = set(config.disabled_plugins) | set(read_disabled_plugins(config.plugins_file))
|
|
321
|
+
for asked, enabled in changes.items():
|
|
322
|
+
asked = asked.strip()
|
|
323
|
+
if not asked:
|
|
324
|
+
problems.append("A switch needs the name of a plugin")
|
|
325
|
+
continue
|
|
326
|
+
# A switch is written with the distribution's name; a plugin that calls itself something else is reached by
|
|
327
|
+
# either, and written with the one the loader matches.
|
|
328
|
+
name = known.get(asked, asked)
|
|
329
|
+
setting = settings.get(name)
|
|
330
|
+
if setting is not None:
|
|
331
|
+
key_changes.append((name, setting, enabled))
|
|
332
|
+
continue
|
|
333
|
+
if name in builtins:
|
|
334
|
+
if name in {origin for origin, _why in bundle.failed}:
|
|
335
|
+
problems.append(
|
|
336
|
+
f"{name} could not load, so the setting that switches it is not known here; "
|
|
337
|
+
"edit the configuration file to leave it out"
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
problems.append(
|
|
341
|
+
f"{name} is compiled into the program and has no setting of its own, so it cannot be switched"
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
if name not in known and name not in off:
|
|
345
|
+
problems.append(f"No plugin is called {asked!r}; ask for the list to see the ones there are")
|
|
346
|
+
continue
|
|
347
|
+
if not config.plugins_enabled:
|
|
348
|
+
problems.append(
|
|
349
|
+
f"{name} is switched by [plugins] enabled = false in the configuration file, which turns every "
|
|
350
|
+
"installed plugin off; edit that line to load them again"
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
off_changes[name] = enabled
|
|
354
|
+
written[name] = enabled
|
|
355
|
+
if off_changes:
|
|
356
|
+
try:
|
|
357
|
+
_write_off(
|
|
358
|
+
config,
|
|
359
|
+
enable={name for name, enabled in off_changes.items() if enabled},
|
|
360
|
+
disable={name for name, enabled in off_changes.items() if not enabled},
|
|
361
|
+
)
|
|
362
|
+
except ValueError as error:
|
|
363
|
+
# Nothing of this batch was written, so the refusals above are still worth reporting with it.
|
|
364
|
+
return {}, [], [*problems, str(error)]
|
|
365
|
+
written.update(off_changes)
|
|
366
|
+
if key_changes:
|
|
367
|
+
keys = [(setting, enabled) for _name, setting, enabled in key_changes]
|
|
368
|
+
try:
|
|
369
|
+
_write_settings(config, keys)
|
|
370
|
+
except ValueError as error:
|
|
371
|
+
# The installed names are already in the configuration; say which, so a reader is not left guessing.
|
|
372
|
+
landed = ", ".join(f"{name} {'on' if enabled else 'off'}" for name, enabled in written.items())
|
|
373
|
+
note = str(error) if not written else f"{error} (already written to the configuration: {landed})"
|
|
374
|
+
return written, [], [*problems, note]
|
|
375
|
+
written.update({name: enabled for name, _setting, enabled in key_changes})
|
|
376
|
+
return written, keys, problems
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _rules(
|
|
380
|
+
config: ZettCodeConfig, bundle: Plugins, entry_points: Iterable[EntryPoint] | None = None
|
|
381
|
+
) -> tuple[dict[str, PluginSetting], set[str], dict[str, str]]:
|
|
382
|
+
"""Return what a switch needs: the settings by origin, the origins compiled in, and every name to its origin.
|
|
383
|
+
|
|
384
|
+
A plugin may call itself something other than the distribution's name; both reach it, and both are written with the
|
|
385
|
+
origin, because that is the name the loader matches.
|
|
386
|
+
"""
|
|
387
|
+
builtins = set(bundle.builtins)
|
|
388
|
+
settings = {
|
|
389
|
+
plugin.origin or plugin.name: plugin.setting
|
|
390
|
+
for plugin in bundle.plugins
|
|
391
|
+
if plugin.setting is not None and (plugin.origin or plugin.name) in builtins
|
|
392
|
+
}
|
|
393
|
+
points = tuple(entry_points) if entry_points is not None else loader.installed_entry_points()
|
|
394
|
+
known: dict[str, str] = {}
|
|
395
|
+
# Every origin first — a distribution's, a compiled-in plugin's, a switched-off name's — because a plugin that
|
|
396
|
+
# calls itself one of those must not take it over: the page's switch for that name would write the plugin's
|
|
397
|
+
# origin instead, and leave the distribution it meant switched off.
|
|
398
|
+
for plugin in bundle.plugins:
|
|
399
|
+
origin = plugin.origin or plugin.name
|
|
400
|
+
known[origin] = origin
|
|
401
|
+
for name in (*builtins, *(point.name for point in points)):
|
|
402
|
+
known[name] = name
|
|
403
|
+
for path in loader.installed_files(config.plugins_dir):
|
|
404
|
+
known[path.stem] = path.stem
|
|
405
|
+
for name in (*read_disabled_plugins(config.plugins_file), *config.disabled_plugins):
|
|
406
|
+
known[name] = name
|
|
407
|
+
for plugin in bundle.plugins:
|
|
408
|
+
known.setdefault(plugin.name, plugin.origin or plugin.name)
|
|
409
|
+
return settings, builtins, known
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def set_enabled(
|
|
413
|
+
config: ZettCodeConfig,
|
|
414
|
+
name: str,
|
|
415
|
+
*,
|
|
416
|
+
enabled: bool,
|
|
417
|
+
bundle: Plugins,
|
|
418
|
+
entry_points: Iterable[EntryPoint] | None = None,
|
|
419
|
+
) -> PluginSetting | None:
|
|
420
|
+
"""Write one switch down, and return the configuration key it went to when that is where it went.
|
|
421
|
+
|
|
422
|
+
The rules are :func:`set_many`'s; this one raises instead of collecting, because a caller that asked for a single
|
|
423
|
+
name wants to hear why it could not be written.
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
ValueError: When the name cannot be written, or its file cannot.
|
|
427
|
+
"""
|
|
428
|
+
_, keys, problems = set_many(config, {name: enabled}, bundle=bundle, entry_points=entry_points)
|
|
429
|
+
if problems:
|
|
430
|
+
raise ValueError(problems[0])
|
|
431
|
+
return keys[0][0] if keys else None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def applied(config: ZettCodeConfig, setting: PluginSetting, enabled: bool) -> ZettCodeConfig:
|
|
435
|
+
"""Return ``config`` with the field a switch wrote updated, so this process shows what it decided."""
|
|
436
|
+
# The field name is a string the plugin declared, and its type is a bool, which no signature can say.
|
|
437
|
+
return replace(config, **{setting.field: enabled}) # type: ignore[arg-type]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def applied_installed(config: ZettCodeConfig, changes: Mapping[str, bool]) -> ZettCodeConfig:
|
|
441
|
+
"""Return ``config`` with the installed names a switch wrote reflected in ``disabled_plugins``.
|
|
442
|
+
|
|
443
|
+
This is what a page reads for its rows in this process: the file has the new list, but the running settings
|
|
444
|
+
object was built before it, so the row would otherwise still show the old state.
|
|
445
|
+
"""
|
|
446
|
+
names = set(config.disabled_plugins)
|
|
447
|
+
for name, enabled in changes.items():
|
|
448
|
+
names.discard(name) if enabled else names.add(name)
|
|
449
|
+
return replace(config, disabled_plugins=tuple(sorted(names)))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def set_installed(config: ZettCodeConfig | SwitchState, name: str, *, enabled: bool) -> None:
|
|
453
|
+
"""Write one installed plugin's switch to ``[plugins] disable``, without loading any plugin.
|
|
454
|
+
|
|
455
|
+
A management command asks this for a name it has already checked exists (an entry point, a single file in
|
|
456
|
+
:attr:`ZettCodeConfig.plugins_dir`, or a name the file already holds). Nothing is written while
|
|
457
|
+
``[plugins] enabled`` is off; a name in ``[plugins] disable`` is an ordinary switch and can be turned on.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
ValueError: When the name cannot be written there, or the file cannot be.
|
|
461
|
+
"""
|
|
462
|
+
name = name.strip()
|
|
463
|
+
if not name:
|
|
464
|
+
raise ValueError("A switch needs the name of a plugin")
|
|
465
|
+
if not config.plugins_enabled:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
"[plugins] enabled = false in the configuration file turns every installed plugin off; edit that line "
|
|
468
|
+
"to load them again"
|
|
469
|
+
)
|
|
470
|
+
_write_off(config, enable={name} if enabled else set(), disable=set() if enabled else {name})
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def forget_installed(config: ZettCodeConfig | SwitchState, name: str) -> bool:
|
|
474
|
+
"""Drop ``name`` from ``[plugins] disable`` and the legacy switch file, and say whether it was.
|
|
475
|
+
|
|
476
|
+
An uninstall calls this: a name left behind by a plugin that is gone would make a later install of the same name
|
|
477
|
+
start switched off, with nothing left to explain why. The list is read to find the name, but its rules are not
|
|
478
|
+
applied — the name is being retired, not switched.
|
|
479
|
+
"""
|
|
480
|
+
name = name.strip()
|
|
481
|
+
if not name:
|
|
482
|
+
return False
|
|
483
|
+
try:
|
|
484
|
+
in_config = name in _config_disabled(config_path())
|
|
485
|
+
except (OSError, ValueError):
|
|
486
|
+
in_config = name in config.disabled_plugins
|
|
487
|
+
found = in_config or name in read_disabled_plugins(config.plugins_file)
|
|
488
|
+
if not found:
|
|
489
|
+
return False
|
|
490
|
+
try:
|
|
491
|
+
_write_off(config, enable={name}, disable=set())
|
|
492
|
+
except ValueError:
|
|
493
|
+
return False
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _write_settings(config: ZettCodeConfig, keys: list[tuple[PluginSetting, bool]]) -> None:
|
|
498
|
+
"""Write every configuration key in one pass over one file.
|
|
499
|
+
|
|
500
|
+
The file is the one the other settings are written to (:func:`zettcode.paths.config_path`); a file that is not
|
|
501
|
+
there yet is created with the key.
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
ValueError: When one of the keys cannot be edited, or the file cannot be written.
|
|
505
|
+
"""
|
|
506
|
+
target = config_path()
|
|
507
|
+
if target.exists() and not target.is_file():
|
|
508
|
+
raise ValueError(f"{target} is not a file; edit the plugin switches by hand")
|
|
509
|
+
try:
|
|
510
|
+
with _lock(target):
|
|
511
|
+
text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
512
|
+
for setting, enabled in keys:
|
|
513
|
+
text = set_boolean(text, setting.table, setting.key, enabled, where=target, command="/plugins")
|
|
514
|
+
write_text_atomically(target, text)
|
|
515
|
+
except (ConfigEditError, OSError) as error:
|
|
516
|
+
raise ValueError(str(error)) from error
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _lock(path: Path) -> AbstractContextManager[object]:
|
|
520
|
+
"""Serialise a read-modify-write of one configuration file between two sessions running at once."""
|
|
521
|
+
from contextlib import contextmanager
|
|
522
|
+
|
|
523
|
+
from zettcode.filelock import SingletonLock
|
|
524
|
+
|
|
525
|
+
@contextmanager
|
|
526
|
+
def held() -> Iterator[None]:
|
|
527
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
528
|
+
lock = SingletonLock(path.with_suffix(".lock"))
|
|
529
|
+
if not lock.acquire(wait=2.0):
|
|
530
|
+
raise ValueError("another session is changing the plugin switches; try again in a moment")
|
|
531
|
+
try:
|
|
532
|
+
yield None
|
|
533
|
+
finally:
|
|
534
|
+
lock.release()
|
|
535
|
+
|
|
536
|
+
return held()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _write(path: Path, names: Iterable[str]) -> None:
|
|
540
|
+
"""Write the switch file in one replace, owner-only, so a crash cannot leave half a list."""
|
|
541
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
542
|
+
handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=".plugins-", suffix=".json")
|
|
543
|
+
try:
|
|
544
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
545
|
+
json.dump({"disabled": sorted(names)}, stream, indent=2)
|
|
546
|
+
stream.write("\n")
|
|
547
|
+
stream.flush()
|
|
548
|
+
os.fsync(stream.fileno())
|
|
549
|
+
os.chmod(temporary, 0o600)
|
|
550
|
+
os.replace(temporary, path)
|
|
551
|
+
except BaseException:
|
|
552
|
+
Path(temporary).unlink(missing_ok=True)
|
|
553
|
+
raise
|
zettcode/engine/py.typed
ADDED
|
File without changes
|