zettcode-engine 0.3.4__tar.gz → 0.4.0__tar.gz

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 (91) hide show
  1. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/PKG-INFO +1 -1
  2. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/context.py +9 -0
  3. zettcode_engine-0.4.0/src/zettcode/engine/plugins/graph.py +96 -0
  4. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/loader.py +257 -40
  5. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/plugin.py +3 -0
  6. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/switches.py +9 -2
  7. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/plugin_tui.py +2 -2
  8. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/version.py +1 -1
  9. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/.gitignore +0 -0
  10. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/pyproject.toml +0 -0
  11. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/_compat.py +0 -0
  12. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/budget.py +0 -0
  13. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/config.py +0 -0
  14. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/config_edit.py +0 -0
  15. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/__init__.py +0 -0
  16. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/__init__.py +0 -0
  17. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/base.py +0 -0
  18. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/context.py +0 -0
  19. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/exports.py +0 -0
  20. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/interaction.py +0 -0
  21. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/sessions.py +0 -0
  22. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/settings.py +0 -0
  23. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/turns.py +0 -0
  24. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/core.py +0 -0
  25. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/__init__.py +0 -0
  26. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/ask.py +0 -0
  27. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/side.py +0 -0
  28. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/title.py +0 -0
  29. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/__init__.py +0 -0
  30. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/script.js +0 -0
  31. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/style.css +0 -0
  32. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/template.html +0 -0
  33. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/__init__.py +0 -0
  34. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/capabilities.py +0 -0
  35. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/compaction.py +0 -0
  36. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/context.py +0 -0
  37. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/files.py +0 -0
  38. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/guidance.py +0 -0
  39. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/harness.py +0 -0
  40. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/harness_switch.py +0 -0
  41. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/mcp_notice.py +0 -0
  42. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/relay.py +0 -0
  43. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/subagents.py +0 -0
  44. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/tool_output.py +0 -0
  45. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/truncation.py +0 -0
  46. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/usage.py +0 -0
  47. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/harness_paths.py +0 -0
  48. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/__init__.py +0 -0
  49. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/approval.py +0 -0
  50. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/readonly.py +0 -0
  51. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/sandbox.py +0 -0
  52. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/pipeline.py +0 -0
  53. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/__init__.py +0 -0
  54. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/commands.py +0 -0
  55. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/extension.py +0 -0
  56. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/groups.py +0 -0
  57. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/mixins.py +0 -0
  58. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/services.py +0 -0
  59. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/settings.py +0 -0
  60. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/state.py +0 -0
  61. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/py.typed +0 -0
  62. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/server_tools.py +0 -0
  63. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/__init__.py +0 -0
  64. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/layout.py +0 -0
  65. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/ledger.py +0 -0
  66. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/locks.py +0 -0
  67. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/metadata.py +0 -0
  68. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/records.py +0 -0
  69. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/store.py +0 -0
  70. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/__init__.py +0 -0
  71. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/boundary.py +0 -0
  72. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/files.py +0 -0
  73. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/images.py +0 -0
  74. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/output.py +0 -0
  75. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/shell.py +0 -0
  76. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/workspace.py +0 -0
  77. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/error_text.py +0 -0
  78. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/filelock.py +0 -0
  79. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/logging_setup.py +0 -0
  80. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/mention.py +0 -0
  81. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/modes.py +0 -0
  82. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/paths.py +0 -0
  83. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/processes.py +0 -0
  84. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/prompt.py +0 -0
  85. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/providers.py +0 -0
  86. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/py.typed +0 -0
  87. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/registry.py +0 -0
  88. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/relay.py +0 -0
  89. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/statusline_parts.py +0 -0
  90. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/wire.py +0 -0
  91. {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/wording.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-engine
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: The agent core of ZettCode: the runtime over zett-agent, and the leaf types it is written on
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -196,6 +196,15 @@ class PluginContext:
196
196
  """Return the object available under ``name``, or ``None`` when there is none (see :meth:`service`)."""
197
197
  return lookup({**self._available, **self._services}, name, kind)
198
198
 
199
+ def inject(self, name: str, service: object) -> object:
200
+ """Make an object available to the other plugins under ``name``; the same as :meth:`provide`.
201
+
202
+ A plugin that lists this one in ``deps`` is activated after it and finds the object with :meth:`service`.
203
+ What the object is, and how it is used, is for the plugins to agree on: the loader only keeps the name
204
+ unique and the order right.
205
+ """
206
+ return self.provide(name, service)
207
+
199
208
  def provide(self, name: str, service: object) -> object:
200
209
  """Register an object under a name, so other parts of the application can look it up.
201
210
 
@@ -0,0 +1,96 @@
1
+ """Put plugins in dependency order, and say which of them cannot be loaded at all.
2
+
3
+ A plugin names the plugins it needs in :attr:`~zettcode.engine.plugins.plugin.Plugin.deps`. :func:`order` turns those
4
+ names into a topological order — a plugin is always activated after every plugin it needs — and into the reasons a
5
+ plugin has to be left out: a plugin it needs is absent, or the needs go round in a circle. The order is stable: among
6
+ the plugins that are ready, the one that was offered first goes first, so a graph with no edges keeps exactly the
7
+ order it was given in, and the built-in plugins stay ahead of the installed ones unless one of them needs a later one.
8
+
9
+ The module knows names and edges only, not plugins, so it is one pure function that a test can feed strings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Node:
19
+ """One plugin as the graph sees it: the name it is known by and the names it needs."""
20
+
21
+ name: str
22
+ deps: tuple[str, ...] = ()
23
+
24
+
25
+ def order(nodes: list[Node]) -> tuple[list[int], dict[int, str]]:
26
+ """Return the activation order of ``nodes`` and the reason each node that cannot be activated has.
27
+
28
+ Args:
29
+ nodes: The candidates, in the order they were offered.
30
+
31
+ Returns:
32
+ The indices of the nodes that can be activated, dependencies first, and ``{index: why}`` for the ones that
33
+ cannot. A node that needs a failed node fails too, so the reasons name the first thing missing and the chain
34
+ it took down is in the later reasons. A name offered twice belongs to its first node; the second is ordered
35
+ like any other and left for the loader to refuse as a duplicate.
36
+ """
37
+ owner: dict[str, int] = {}
38
+ for index, node in enumerate(nodes):
39
+ owner.setdefault(node.name, index)
40
+
41
+ failed: dict[int, str] = {}
42
+ changed = True
43
+ while changed:
44
+ changed = False
45
+ for index, node in enumerate(nodes):
46
+ if index in failed:
47
+ continue
48
+ for dep in node.deps:
49
+ if dep == node.name:
50
+ failed[index] = f"plugin {node.name!r} lists itself in deps"
51
+ elif dep not in owner:
52
+ failed[index] = f"needs plugin {dep!r}, which is not installed or is turned off"
53
+ elif owner[dep] in failed:
54
+ failed[index] = f"needs plugin {dep!r}, which cannot be loaded"
55
+ else:
56
+ continue
57
+ changed = True
58
+ break
59
+
60
+ alive = [index for index in range(len(nodes)) if index not in failed]
61
+ waiting = {index: {owner[dep] for dep in nodes[index].deps if owner[dep] != index} for index in alive}
62
+ ordered: list[int] = []
63
+ done: set[int] = set()
64
+ remaining = list(alive)
65
+ while remaining:
66
+ ready = next((index for index in remaining if waiting[index] <= done), None)
67
+ if ready is None:
68
+ break
69
+ ordered.append(ready)
70
+ done.add(ready)
71
+ remaining.remove(ready)
72
+
73
+ for index in remaining:
74
+ cycle = _cycle(index, nodes, owner, set(remaining))
75
+ failed[index] = (
76
+ f"dependency cycle: {' -> '.join(cycle)}" if cycle else "needs a plugin that is part of a dependency cycle"
77
+ )
78
+ return ordered, failed
79
+
80
+
81
+ def _cycle(start: int, nodes: list[Node], owner: dict[str, int], within: set[int]) -> list[str]:
82
+ """Return the names on a path from ``start`` back to itself inside ``within``, or ``[]`` when it is on none."""
83
+ stack: list[tuple[int, list[int]]] = [(start, [start])]
84
+ seen: set[int] = set()
85
+ while stack:
86
+ index, path = stack.pop()
87
+ for dep in nodes[index].deps:
88
+ target = owner.get(dep)
89
+ if target is None or target not in within:
90
+ continue
91
+ if target == start:
92
+ return [nodes[step].name for step in (*path, start)]
93
+ if target not in seen:
94
+ seen.add(target)
95
+ stack.append((target, [*path, target]))
96
+ return []
@@ -27,13 +27,19 @@ from __future__ import annotations
27
27
 
28
28
  import hashlib
29
29
  import importlib.util
30
+ import logging
31
+ import re
30
32
  import sys
33
+ from collections.abc import Callable
34
+ from dataclasses import dataclass
35
+ from functools import partial
31
36
  from pathlib import Path
32
37
  from typing import TYPE_CHECKING, Any
33
38
 
34
39
  from zettcode.config import read_disabled_plugins
35
40
  from zettcode.providers import BUILTIN_PROVIDERS
36
41
 
42
+ from . import graph
37
43
  from .context import PluginContext
38
44
  from .extension import HOST_NAME, PluginExtension
39
45
  from .plugin import Plugin, Plugins, PluginSetting
@@ -47,9 +53,18 @@ if TYPE_CHECKING: # pragma: no cover - annotations only
47
53
  from zettcode.mention import MentionProvider
48
54
  from zettcode.providers import Provider
49
55
 
56
+ logger = logging.getLogger("zettcode.plugins")
57
+
50
58
  #: Entry-point group a distribution publishes its plugin under.
51
59
  PLUGIN_ENTRY_POINT_GROUP = "zettcode.plugins"
52
60
 
61
+ #: Entry-point group the distributions of the program itself publish the plugins only a screen has any use for under
62
+ #: (the presenters, the pets, the palettes). They are loaded the same way, ahead of the installed plugins, but they are
63
+ #: released with the program (one version, one tag) and are not something a reader installs or removes, so the plugin
64
+ #: tooling and the release line for plugins never see them. A process with no terminal (the SDK, the server) does not
65
+ #: load them: they would import the whole terminal to offer it nothing.
66
+ TERMINAL_PART_GROUP = "zettcode.terminal"
67
+
53
68
 
54
69
  def installed_entry_points() -> tuple[EntryPoint, ...]:
55
70
  """Return every plugin entry point installed in this environment.
@@ -59,7 +74,9 @@ def installed_entry_points() -> tuple[EntryPoint, ...]:
59
74
  """
60
75
  from importlib.metadata import entry_points
61
76
 
62
- return tuple(sorted(entry_points(group=PLUGIN_ENTRY_POINT_GROUP), key=lambda point: point.name))
77
+ parts = sorted(entry_points(group=TERMINAL_PART_GROUP), key=lambda point: point.name)
78
+ plugins = sorted(entry_points(group=PLUGIN_ENTRY_POINT_GROUP), key=lambda point: point.name)
79
+ return (*parts, *plugins)
63
80
 
64
81
 
65
82
  def installed_files(directory: Path) -> tuple[Path, ...]:
@@ -124,6 +141,7 @@ def load_plugins(
124
141
  entry_points: Iterable[EntryPoint] | None = None,
125
142
  builtins: Sequence[Plugin[Any]],
126
143
  shared: Mapping[str, object] | None = None,
144
+ required: Sequence[str] = (),
127
145
  ) -> Plugins:
128
146
  """Load the builtin plugins and every enabled distribution into one bundle.
129
147
 
@@ -143,7 +161,48 @@ def load_plugins(
143
161
  Returns:
144
162
  The host extension, services, and load failures the plugins produced.
145
163
  """
146
- return run(Loader(config, shared), config, builtins=builtins, entry_points=entry_points)
164
+ return run(Loader(config, shared), config, builtins=builtins, entry_points=entry_points, required=required)
165
+
166
+
167
+ class RequiredPluginError(RuntimeError):
168
+ """A plugin the program cannot run without did not load.
169
+
170
+ Attributes:
171
+ missing: ``{name: why}`` for each required plugin that is not among the loaded ones, in the order they were
172
+ asked for. The message lists them, so a front end can print it as it is.
173
+ """
174
+
175
+ def __init__(self, missing: dict[str, str]) -> None:
176
+ self.missing = dict(missing)
177
+ lines = "; ".join(f"{name}: {why}" for name, why in self.missing.items())
178
+ super().__init__(f"cannot start without the required plugins ({lines})")
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class _Candidate:
183
+ """One plugin on its way to ``accept``, or the failure of one that could not even be built.
184
+
185
+ A failure travels with the others so it is reported where it was found, in discovery order, and not before or
186
+ after the plugins around it.
187
+ """
188
+
189
+ plugin: Plugin[Any] | None
190
+ origin: str
191
+ builtin: bool = False
192
+ label: str | None = None
193
+ error: BaseException | None = None
194
+ #: Whether it came from the program's own entry-point group, which is ahead of every installed plugin.
195
+ part: bool = False
196
+
197
+ @property
198
+ def name(self) -> str:
199
+ if self.plugin is None:
200
+ return f"\0failed:{self.origin}:{self.label}"
201
+ return self.plugin.name or self.origin or type(self.plugin).__name__
202
+
203
+ @property
204
+ def deps(self) -> tuple[str, ...]:
205
+ return tuple(self.plugin.deps) if self.plugin is not None else ()
147
206
 
148
207
 
149
208
  def run(
@@ -152,50 +211,193 @@ def run(
152
211
  *,
153
212
  builtins: Sequence[Plugin[Any]],
154
213
  entry_points: Iterable[EntryPoint] | None,
214
+ required: Sequence[str] = (),
215
+ terminal: bool = False,
155
216
  ) -> Plugins:
156
217
  """Feed the builtins, the enabled distributions and the single-file plugins to ``loader``, and return what it bundled.
157
218
 
158
- The builtins go first, so a plugin that registers under one of their names replaces it in place.
219
+ The candidates are offered builtins first, then distributions, then files, and are activated in dependency order
220
+ (:mod:`~zettcode.engine.plugins.graph`): stable, so without ``deps`` that is the order they were offered in.
159
221
  Builtins always load, while ``[plugins] enabled`` only gates the distributions and the files.
222
+
223
+ Args:
224
+ required: Names of plugins the program cannot run without. A ``disable`` line that names one — or one of the
225
+ plugins it needs — is ignored and logged, and one that still did not load makes the call raise. A name is
226
+ matched against the entry-point name (the origin) as well as ``Plugin.name``, so a required plugin keeps
227
+ the two equal; one whose ``name`` differs cannot be pulled back in once disabled.
228
+ terminal: Whether the loader has a screen, which is what the parts in the ``zettcode.terminal`` group wait for.
229
+
230
+ Raises:
231
+ RequiredPluginError: A plugin in ``required`` is not loaded.
160
232
  """
233
+ candidates: list[_Candidate] = []
161
234
  for plugin in builtins:
162
- loader.accept(plugin, origin=plugin.name or type(plugin).__name__, builtin=True)
163
- loader.builtins_loaded()
235
+ candidates.append(_Candidate(plugin, plugin.name or type(plugin).__name__, builtin=True))
236
+ if candidates[-1].origin not in loader.builtins:
237
+ loader.builtins.append(candidates[-1].origin)
238
+ # Offered by origin, so the ones a ``disable`` line holds back can still be pulled in when the program needs them.
239
+ sources: dict[str, Callable[[], list[_Candidate]]] = {}
240
+ # With `[plugins] enabled = false` nothing is loaded but the parts of the program (the `zettcode.terminal` group,
241
+ # which that switch is not about) and the plugins the program requires.
242
+ points = tuple(entry_points) if entry_points is not None else installed_entry_points()
243
+ # The parts of the program that need a screen are for a loader that has one.
244
+ points = tuple(point for point in points if terminal or getattr(point, "group", None) != TERMINAL_PART_GROUP)
245
+ parts = {point.name for point in points if getattr(point, "group", None) == TERMINAL_PART_GROUP}
246
+ # The configuration file's own list and the switch file's, merged for the one caller that loads. The switch
247
+ # module cannot be imported here (it imports this one), so the union is written out; `off_names` is the same
248
+ # rule for the front ends.
249
+ disabled = set(config.disabled_plugins) | set(read_disabled_plugins(config.plugins_file))
250
+ #: Who has each name, and who asked for a name that was taken, so the second can be told why it did not load.
251
+ owners: dict[str, str] = {}
252
+ owners_dist: dict[str, str | None] = {}
253
+ taken: dict[str, list[str]] = {}
254
+ for point in points:
255
+ who = _described(point)
256
+ if point.name in sources:
257
+ # The program's parts come first, so a later plugin under the same name is the one that loses; it must
258
+ # not take the place of a part, a required one least of all. The same distribution listed twice (a stale
259
+ # `dist-info` beside an editable copy) is an environment quirk, not a clash.
260
+ if _distribution(point) is None or _distribution(point) != owners_dist.get(point.name):
261
+ taken.setdefault(point.name, []).append(who)
262
+ continue
263
+ owners[point.name] = who
264
+ owners_dist[point.name] = _distribution(point)
265
+ sources[point.name] = partial(_from_point, loader, point)
164
266
  if config.plugins_enabled:
165
- points = tuple(entry_points) if entry_points is not None else installed_entry_points()
166
- # The configuration file's own list and the switch file's, merged for the one caller that loads. The switch
167
- # module cannot be imported here (it imports this one), so the union is written out; `off_names` is the same
168
- # rule for the front ends.
169
- disabled = set(config.disabled_plugins) | set(read_disabled_plugins(config.plugins_file))
170
- for point in points:
171
- if point.name in disabled:
267
+ for path in installed_files(config.plugins_dir):
268
+ if path.stem in sources:
269
+ taken.setdefault(path.stem, []).append(f"the file {path.name}")
172
270
  continue
173
- try:
174
- plugin = _instantiate(point)
175
- except KeyboardInterrupt:
176
- raise
177
- except BaseException as error:
178
- loader.fail(point.name, error)
271
+ owners[path.stem] = f"the file {path.name}"
272
+ sources[path.stem] = partial(_from_file, loader, path)
273
+ held = {origin for origin in sources if origin in disabled or (not config.plugins_enabled and origin not in parts)}
274
+ for origin in sources:
275
+ if origin not in held:
276
+ candidates.extend(sources[origin]())
277
+ # A name on that list can only reach a distribution or a file, unless the program needs it: the plugins it lists
278
+ # as builtin loaded above, and the required ones, with what they need, are pulled in below. A line that names one
279
+ # does nothing, and a reader who wrote it is told so instead of left to think the part is off.
280
+ for origin in sorted(disabled & set(loader.builtins)):
281
+ logger.warning(
282
+ "plugins.required",
283
+ extra={"plugin": origin, "reason": "part of the program; [plugins] disable does not reach it"},
284
+ )
285
+ protected = set(required)
286
+ while True:
287
+ by_name = {candidate.name: candidate for candidate in candidates}
288
+ wanted: set[str] = set()
289
+ queue = [name for name in protected]
290
+ while queue:
291
+ name = queue.pop()
292
+ if name in wanted:
179
293
  continue
180
- loader.accept(plugin, origin=point.name)
181
- for path in installed_files(config.plugins_dir):
182
- origin = path.stem
294
+ wanted.add(name)
295
+ if name in by_name:
296
+ queue.extend(by_name[name].deps)
297
+ pulled = sorted(origin for origin in held if origin in wanted)
298
+ if not pulled:
299
+ break
300
+ for origin in pulled:
301
+ held.discard(origin)
183
302
  if origin in disabled:
303
+ logger.warning(
304
+ "plugins.required",
305
+ extra={
306
+ "plugin": origin,
307
+ "reason": "the program cannot run without it; [plugins] disable does not reach it",
308
+ },
309
+ )
310
+ candidates.extend(sources[origin]())
311
+ # A name that was taken is reported only when the one that has it was actually offered: a part the reader switched
312
+ # off refused nothing, and its row on the plugins page says "off", not "failed".
313
+ for name, losers in taken.items():
314
+ if name in held:
315
+ continue
316
+ for who in losers:
317
+ # Not a failure of the plugin that has the name: it loaded, and its row says so. The loser has no row of
318
+ # its own (it shares the name, or is a file with nothing but the name), so it is written to the log.
319
+ logger.warning(
320
+ "plugins.name_taken",
321
+ extra={"plugin": name, "who": who, "reason": f"{owners[name]} already has the name {name!r}"},
322
+ )
323
+ ordered, refused = graph.order([graph.Node(c.name, c.deps) for c in candidates])
324
+ loaded: set[int] = set()
325
+ for index in sorted(refused):
326
+ candidate = candidates[index]
327
+ loader.fail(candidate.origin, refused[index], label=candidate.label)
328
+ names_loaded: set[str] = set()
329
+ for index in ordered:
330
+ candidate = candidates[index]
331
+ lost = next((dep for dep in candidate.deps if dep not in names_loaded), None)
332
+ if candidate.plugin is None:
333
+ assert candidate.error is not None
334
+ loader.fail(candidate.origin, candidate.error, label=candidate.label)
335
+ elif lost is not None:
336
+ # It was ready, but a plugin it needs failed to activate: the failure is that plugin's, and this one is
337
+ # reported as the casualty it is rather than activated without what it asked for.
338
+ loader.fail(candidate.origin, f"needs plugin {lost!r}, which cannot be loaded", label=candidate.label)
339
+ elif loader.accept(
340
+ candidate.plugin,
341
+ origin=candidate.origin,
342
+ builtin=candidate.builtin,
343
+ part=candidate.part,
344
+ label=candidate.label,
345
+ ):
346
+ loaded.add(index)
347
+ names_loaded.add(candidate.name)
348
+ # A required plugin is the program's own as much as a compiled-in one: no switch reaches it, and a list says so.
349
+ loader.builtins.extend(name for name in required if name not in loader.builtins)
350
+ if required:
351
+ have = {candidates[index].name for index in loaded}
352
+ absent: dict[str, str] = {}
353
+ for name in required:
354
+ if name in have:
184
355
  continue
185
- try:
186
- plugins, failures = _file_plugins(path)
187
- except KeyboardInterrupt:
188
- raise
189
- except BaseException as error:
190
- loader.fail(origin, error)
191
- continue
192
- for class_name, failure in failures:
193
- loader.fail(origin, failure, label=class_name)
194
- for plugin in plugins:
195
- loader.accept(plugin, origin=origin, label=type(plugin).__name__)
356
+ why = next((reason for origin, reason in loader.failed if origin == name), None)
357
+ absent[name] = why or "not installed"
358
+ if absent:
359
+ raise RequiredPluginError(absent)
196
360
  return loader.bundle()
197
361
 
198
362
 
363
+ def _distribution(point: EntryPoint) -> str | None:
364
+ """Return the name of the distribution an entry point came from, or ``None`` when it does not say."""
365
+ dist = getattr(point, "dist", None)
366
+ name = getattr(dist, "name", None)
367
+ return re.sub(r"[-_.]+", "-", str(name)).lower() if name else None
368
+
369
+
370
+ def _described(point: EntryPoint) -> str:
371
+ """Say who an entry point is, for a message that has to name what to uninstall."""
372
+ dist = _distribution(point)
373
+ value = getattr(point, "value", None)
374
+ return f"{point.name!r} ({dist or 'unknown distribution'}" + (f", {value})" if value else ")")
375
+
376
+
377
+ def _from_point(loader: Loader, point: EntryPoint) -> list[_Candidate]:
378
+ """Resolve one entry point into a candidate; a failure is a candidate that carries it."""
379
+ part = getattr(point, "group", None) == TERMINAL_PART_GROUP
380
+ try:
381
+ return [_Candidate(_instantiate(point), point.name, part=part)]
382
+ except KeyboardInterrupt:
383
+ raise
384
+ except BaseException as error:
385
+ return [_Candidate(None, point.name, error=error, part=part)]
386
+
387
+
388
+ def _from_file(loader: Loader, path: Path) -> list[_Candidate]:
389
+ """Import one single-file plugin into candidates; a failure is recorded and gives none."""
390
+ origin = path.stem
391
+ try:
392
+ plugins, failures = _file_plugins(path)
393
+ except KeyboardInterrupt:
394
+ raise
395
+ except BaseException as error:
396
+ return [_Candidate(None, origin, error=error)]
397
+ broken = [_Candidate(None, origin, label=class_name, error=failure) for class_name, failure in failures]
398
+ return [*broken, *(_Candidate(plugin, origin, label=type(plugin).__name__) for plugin in plugins)]
399
+
400
+
199
401
  class Loader:
200
402
  """Accumulate accepted plugins into the one bundle the application gets."""
201
403
 
@@ -212,6 +414,8 @@ class Loader:
212
414
  self.failed: list[tuple[str, str]] = []
213
415
  #: The origins of the compiled-in plugins, in load order, so a bundle can say which rows are the program's.
214
416
  self.builtins: list[str] = []
417
+ #: Whether the plugin being accepted is the program's own (built in, or one of its parts); read by ``merge``.
418
+ self.program = False
215
419
  self._identities: set[str] = set()
216
420
  self._extension_names: set[str] = {HOST_NAME}
217
421
  self._mention_kinds: set[str] = set()
@@ -269,9 +473,6 @@ class Loader:
269
473
  self.mentions.extend(context.mentions)
270
474
  self.plain_commands.extend(context.commands.commands)
271
475
 
272
- def builtins_loaded(self) -> None:
273
- """Called once the builtin plugins have all been offered, before any distribution."""
274
-
275
476
  def bundle(self) -> Plugins:
276
477
  """Return everything the accepted plugins contributed."""
277
478
  host = PluginExtension(self.plugins) if self.plugins else None
@@ -293,15 +494,30 @@ class Loader:
293
494
  reason = _reason(error)
294
495
  self.failed.append((origin, f"{label}: {reason}" if label else reason))
295
496
 
296
- def accept(self, plugin: Plugin[Any], *, origin: str, builtin: bool = False, label: str | None = None) -> None:
497
+ def accept(
498
+ self,
499
+ plugin: Plugin[Any],
500
+ *,
501
+ origin: str,
502
+ builtin: bool = False,
503
+ part: bool = False,
504
+ label: str | None = None,
505
+ ) -> bool:
297
506
  """Activate one plugin and merge what it registered, or report why not.
298
507
 
508
+ Returns:
509
+ Whether the plugin was activated and merged.
510
+
299
511
  Args:
300
512
  plugin: The plugin to activate.
301
513
  origin: The name of the distribution (or of the compiled-in plugin) it came from.
302
514
  builtin: Whether it is compiled into the program, which is what makes it impossible to switch off.
515
+ part: Whether it is one of the program's own parts (the ``zettcode.terminal`` group). Unlike a builtin it
516
+ can be switched off, unless the loader's caller requires it; what it registers is the program's own,
517
+ which is what :attr:`program` tells :meth:`merge` while it runs.
303
518
  label: The class name, when this plugin came from a file that can hold several; a failure says which.
304
519
  """
520
+ self.program = builtin or part
305
521
  name = plugin.name or origin or type(plugin).__name__
306
522
  if builtin and origin not in self.builtins:
307
523
  # Recorded before it is activated: a compiled-in plugin is the program's own whether or not it loaded,
@@ -315,10 +531,10 @@ class Loader:
315
531
  f"setting {plugin.setting.path} names no boolean setting of the configuration",
316
532
  label=label,
317
533
  )
318
- return
534
+ return False
319
535
  if name in self._identities:
320
536
  self.fail(origin, f"duplicate plugin name {name!r}", label=label)
321
- return
537
+ return False
322
538
  if not plugin.name:
323
539
  plugin.name = name
324
540
  if not plugin.origin:
@@ -331,14 +547,15 @@ class Loader:
331
547
  raise
332
548
  except BaseException as error:
333
549
  self.fail(origin, error, label=label)
334
- return
550
+ return False
335
551
  clash = self.clash(container)
336
552
  if clash is not None:
337
553
  self.fail(origin, clash, label=label)
338
- return
554
+ return False
339
555
  self._identities.add(name)
340
556
  self.merge(container)
341
557
  self.plugins.append(plugin)
558
+ return True
342
559
 
343
560
 
344
561
  def _is_bool_setting(config: ZettCodeConfig, setting: PluginSetting) -> bool:
@@ -90,6 +90,9 @@ class Plugin(PluginAgentMixin, Generic[_C], ABC):
90
90
  #: switch file are matched against it.
91
91
  origin: str = ""
92
92
  description: ClassVar[str] = ""
93
+ #: The names of the plugins this one needs. The loader activates a plugin after every plugin it lists, and
94
+ #: leaves it out, reported, when one of them is absent, turned off, failed, or the lists go round in a circle.
95
+ deps: ClassVar[tuple[str, ...]] = ()
93
96
  priority: ClassVar[int] = 100
94
97
  #: The configuration key that decides whether a compiled-in plugin does its work, or ``None`` when there is none:
95
98
  #: a plugin the reader cannot turn off is left out of the list rather than shown without a switch.
@@ -123,8 +123,15 @@ def catalog(
123
123
  points = tuple(entry_points) if entry_points is not None else loader.installed_entry_points()
124
124
  seen = {entry.origin for entry in installed} | {origin for origin, _why in bundle.failed}
125
125
  for point in points:
126
- if point.name not in seen:
127
- installed.append(_installed(point.name, point.name, config, off=off))
126
+ # A required plugin has an entry point and is the program's own: it is not a row nobody can move.
127
+ if point.name in seen or point.name in builtins:
128
+ continue
129
+ # A part for a screen is listed by a process that has one. A bundle that holds none of it (the SDK, the
130
+ # server) is not offering switches for things it never loads — unless one is switched off, which is the one
131
+ # state in which a part is absent from a bundle that would have it.
132
+ if getattr(point, "group", None) == loader.TERMINAL_PART_GROUP and point.name not in off:
133
+ continue
134
+ installed.append(_installed(point.name, point.name, config, off=off))
128
135
  # A single-file plugin that is switched off is not in the bundle (the loader never imported it), so the folder is
129
136
  # what says it exists: it stays on the page, off, where it can be switched back on.
130
137
  for path in loader.installed_files(config.plugins_dir):
@@ -62,8 +62,8 @@ class TuiGroup(ABC):
62
62
  ) -> Any:
63
63
  """Add a command that may open a page, ask the shell to act, or wait for a request to end.
64
64
 
65
- ``handler`` is called with a :class:`~zettcode.app.commands.CommandContext` and returns a
66
- :class:`~zettcode.app.commands.CommandResult`. A command whose whole answer is text belongs on
65
+ ``handler`` is called with a :class:`~zettcode.plugin_api.CommandContext` and returns a
66
+ :class:`~zettcode.plugin_api.CommandResult`. A command whose whole answer is text belongs on
67
67
  ``ctx.commands`` instead, where a process without a screen can run it too.
68
68
  """
69
69
 
@@ -8,4 +8,4 @@ of the dependency graph: everything else may import `zettcode.version`, nothing
8
8
  from __future__ import annotations
9
9
 
10
10
  #: The version of the program and of every distribution built beside it.
11
- __version__ = "0.3.4"
11
+ __version__ = "0.4.0"