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.
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/PKG-INFO +1 -1
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/context.py +9 -0
- zettcode_engine-0.4.0/src/zettcode/engine/plugins/graph.py +96 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/loader.py +257 -40
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/plugin.py +3 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/switches.py +9 -2
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/plugin_tui.py +2 -2
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/version.py +1 -1
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/.gitignore +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/pyproject.toml +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/_compat.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/budget.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/config.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/config_edit.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/base.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/context.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/exports.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/interaction.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/sessions.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/settings.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/agent/turns.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/core.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/ask.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/side.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/dialogue/title.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/script.js +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/style.css +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/template.html +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/capabilities.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/compaction.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/context.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/files.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/guidance.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/harness.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/harness_switch.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/mcp_notice.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/relay.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/subagents.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/tool_output.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/truncation.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/usage.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/harness_paths.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/approval.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/readonly.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/permission/sandbox.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/pipeline.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/commands.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/extension.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/groups.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/mixins.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/services.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/settings.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/plugins/state.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/py.typed +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/server_tools.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/layout.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/ledger.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/locks.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/metadata.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/records.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/storage/store.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/__init__.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/boundary.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/files.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/images.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/output.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/shell.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/tools/workspace.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/error_text.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/filelock.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/logging_setup.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/mention.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/modes.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/paths.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/processes.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/prompt.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/providers.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/py.typed +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/registry.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/relay.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/statusline_parts.py +0 -0
- {zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/wire.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
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.
|
|
66
|
-
:class:`~zettcode.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/export/trace/template.html
RENAMED
|
File without changes
|
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/capabilities.py
RENAMED
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/compaction.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/harness_switch.py
RENAMED
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/mcp_notice.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/tool_output.py
RENAMED
|
File without changes
|
{zettcode_engine-0.3.4 → zettcode_engine-0.4.0}/src/zettcode/engine/extensions/truncation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|