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.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. 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
File without changes