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
zettcode/processes.py ADDED
@@ -0,0 +1,126 @@
1
+ """Reading a running process's command line, so one of this program's own can be told from another program.
2
+
3
+ A process id in a file anyone could have written is not a name: it can be stale, and the operating system reuses ids.
4
+ So a process is acted on only after its command line has been read and recognised as ``python -m <module>`` — the one
5
+ shape that makes it ours. This module is the part of that which does not know what a broker or a gateway is, shared by
6
+ both so there is one spelling of it.
7
+
8
+ It imports the standard library only (and not ``psutil``: the command line is readable with the tools the system
9
+ already has), and it is a leaf, so a package that owns a process may use it without reaching into another's.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import subprocess
17
+ from typing import Any
18
+
19
+
20
+ def command_line(pid: int) -> str | None:
21
+ """Return the command line of a running process, or ``None`` when it is gone or cannot be read.
22
+
23
+ Asked of the operating system, because a process id is not a name: ``ps`` on POSIX, PowerShell's process
24
+ table on Windows. Neither is guaranteed to exist, and not finding out is an answer the caller handles by
25
+ doing nothing.
26
+ """
27
+ if pid <= 0 or pid == os.getpid():
28
+ return None
29
+ if os.name == "nt":
30
+ command = [
31
+ "powershell",
32
+ "-NoProfile",
33
+ "-NonInteractive",
34
+ "-Command",
35
+ f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').CommandLine",
36
+ ]
37
+ else:
38
+ command = ["ps", "-ww", "-o", "command=", "-p", str(int(pid))] # -ww: no cut at the width of a terminal
39
+ try:
40
+ done = subprocess.run(command, capture_output=True, text=True, timeout=10, check=False) # noqa: S603
41
+ except (OSError, subprocess.SubprocessError):
42
+ return None
43
+ text = done.stdout.strip()
44
+ return text if done.returncode == 0 and text else None
45
+
46
+
47
+ def is_module_command(text: str, module: str) -> bool:
48
+ """Return whether a command line is a Python interpreter started with ``-m module`` and nothing else in the way.
49
+
50
+ The ``-m`` has to follow the interpreter directly (single-letter options such as ``-u`` may sit between), so
51
+ ``python -c "..." -m module`` and ``python script.py -m module`` — a different program with a flag that
52
+ happens to read the same — are not taken for it. The path to the interpreter may hold spaces, and on Windows
53
+ it is quoted.
54
+ """
55
+ # Before the interpreter there is only its path: a space followed by a dash would be an option of some other
56
+ # program (``sh -c python -m module`` is a shell, not the module).
57
+ pattern = rf"^(?:\S|\s(?!-))*?python[\d.]*(?:\.exe)?\"?\s+(?:-[A-Za-z]\s+)*-m\s+{re.escape(module)}(?:\s|$)"
58
+ return re.search(pattern, text, re.IGNORECASE) is not None
59
+
60
+
61
+ def runs_module(pid: int, module: str) -> bool:
62
+ """Return whether the process is ``python -m module``: the one thing that makes it ours to end."""
63
+ text = command_line(pid)
64
+ return text is not None and is_module_command(text, module)
65
+
66
+
67
+ def still_there(pid: int) -> bool:
68
+ """Return whether a process with that id exists, without signalling it.
69
+
70
+ This is the question that is left when a command line cannot be read: a process that is there but unreadable is
71
+ not the same as one that is gone, and ending (or replacing) the wrong one is worse than reporting the doubt.
72
+ On Windows ``os.kill(pid, 0)`` is not a probe — it terminates the process — so the process table is asked through
73
+ ``OpenProcess`` instead, and a handle that opens is asked for its exit code: a process that has ended but whose
74
+ object is still around answers with its exit code, not ``STILL_ACTIVE``. Only ``ERROR_INVALID_PARAMETER`` on the
75
+ open means "no such process"; a refusal for lack of rights, and any other failure, still counts as it being there.
76
+ """
77
+ if pid <= 0 or pid == os.getpid():
78
+ return False
79
+ if os.name == "nt":
80
+ import ctypes
81
+
82
+ kernel32 = _kernel32()
83
+ handle = kernel32.OpenProcess(0x1000, False, int(pid)) # PROCESS_QUERY_LIMITED_INFORMATION
84
+ if not handle:
85
+ # Only ERROR_INVALID_PARAMETER means there is no such process; a refusal for lack of rights, or any
86
+ # other failure, still counts as it being there.
87
+ return int(ctypes.get_last_error()) != 87 # type: ignore[attr-defined]
88
+ try:
89
+ code = ctypes.c_ulong(0)
90
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
91
+ return True # cannot tell: keep the doubt
92
+ return code.value == 259 # STILL_ACTIVE
93
+ finally:
94
+ kernel32.CloseHandle(handle)
95
+ try:
96
+ os.kill(pid, 0)
97
+ except ProcessLookupError:
98
+ return False
99
+ except PermissionError:
100
+ return True
101
+ except OSError:
102
+ return False
103
+ return True
104
+
105
+
106
+ #: Bound the first time a Windows process is asked about; ``None`` everywhere else.
107
+ _KERNEL32: Any = None
108
+
109
+
110
+ def _kernel32() -> Any:
111
+ """Return the one kernel32 binding, with the process calls typed once rather than per question."""
112
+ global _KERNEL32
113
+ if _KERNEL32 is None:
114
+ import ctypes
115
+
116
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
117
+ kernel32.OpenProcess.argtypes = (ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32)
118
+ kernel32.OpenProcess.restype = ctypes.c_void_p
119
+ kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
120
+ kernel32.GetExitCodeProcess.argtypes = (ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong))
121
+ kernel32.GetExitCodeProcess.restype = ctypes.c_int
122
+ _KERNEL32 = kernel32
123
+ return _KERNEL32
124
+
125
+
126
+ __all__ = ["command_line", "is_module_command", "runs_module", "still_there"]
zettcode/prompt.py ADDED
@@ -0,0 +1,94 @@
1
+ """The parts of a user turn: text, ``@`` references, and the images placed among them.
2
+
3
+ The composer splits a draft into these, so the shell resolves a reference without
4
+ re-parsing the text, and the turn that reaches the model keeps each picture where its
5
+ writer put it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import TypeAlias
13
+
14
+ from zettcode.mention import MentionRegistry
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class MentionPart:
19
+ """One ``@`` reference in a turn, kept as typed.
20
+
21
+ The composer splits a draft at these tokens, so the shell resolves them
22
+ without re-parsing the text and the editor can style them where they sit.
23
+ """
24
+
25
+ token: str
26
+ #: The words the model reads in the token's place, when it is not to see the token itself. ``None``
27
+ #: leaves the token as typed; the typed text is what the transcript and the store keep either way.
28
+ says: str | None = None
29
+
30
+
31
+ #: One piece of a user turn, in the order it was written: a run of text, or the
32
+ #: encoded bytes and media type of an image placed where that run ends.
33
+ PromptPart: TypeAlias = str | MentionPart | tuple[bytes, str]
34
+
35
+
36
+ def plain_text_part(part: PromptPart) -> str:
37
+ """Return the text one part contributes; an image contributes nothing."""
38
+ if isinstance(part, str):
39
+ return part
40
+ if isinstance(part, MentionPart):
41
+ return part.token
42
+ return ""
43
+
44
+
45
+ def model_text_part(part: PromptPart) -> str:
46
+ """Return the text one part is to the model: a reference that was resolved reads as what it says."""
47
+ if isinstance(part, MentionPart) and part.says is not None:
48
+ return part.says
49
+ return plain_text_part(part)
50
+
51
+
52
+ def mention_parts(parts: Sequence[PromptPart], mentions: MentionRegistry) -> tuple[PromptPart, ...]:
53
+ """Return a turn with each reference that has words of its own carrying them.
54
+
55
+ ``@work`` typed for a session that is running is not a word the model knows, so it is put in the
56
+ model's reading as ``session "work"`` and :func:`mention_hint` explains it. Anything else — a skill, a
57
+ file, a name nobody has — keeps the token as it was typed.
58
+ """
59
+ if not mentions.providers:
60
+ return tuple(parts)
61
+ resolved: list[PromptPart] = []
62
+ for part in parts:
63
+ says = mentions.rename_token(part.token) if isinstance(part, MentionPart) else None
64
+ resolved.append(MentionPart(part.token, says) if says is not None and isinstance(part, MentionPart) else part)
65
+ return tuple(resolved)
66
+
67
+
68
+ def plain_text(parts: Sequence[PromptPart]) -> str:
69
+ """Return a turn as the reader typed it, with its ``@`` tokens back in place."""
70
+ return "".join(plain_text_part(part) for part in parts)
71
+
72
+
73
+ def carries_image(parts: Sequence[PromptPart]) -> bool:
74
+ """Return whether one turn holds an image beside its text."""
75
+ return any(not isinstance(part, (str, MentionPart)) for part in parts)
76
+
77
+
78
+ def mention_hint(parts: Sequence[PromptPart], mentions: MentionRegistry) -> str | None:
79
+ """Return the instructions a turn's ``@`` references contribute, if any.
80
+
81
+ ``None`` means nothing is appended: no provider is registered, the draft has
82
+ no reference, or every token is unknown — the model then reads exactly what
83
+ was typed. Each reference that resolves contributes one block, in the order
84
+ the tokens appear.
85
+ """
86
+ if not mentions.providers:
87
+ return None
88
+ hints = [
89
+ hint
90
+ for part in parts
91
+ if isinstance(part, MentionPart)
92
+ if (hint := mentions.expand_token(part.token)) is not None
93
+ ]
94
+ return "\n\n".join(hints) if hints else None
zettcode/providers.py ADDED
@@ -0,0 +1,215 @@
1
+ """Providers: the hosts a model can be reached at, described once so ``/config`` can offer them.
2
+
3
+ A model entry needs an API (chat completions, Responses API, or Anthropic Messages API), an
4
+ address, a token and a model id. For a well-known host the first two are the same for everyone, so a
5
+ :class:`Provider` carries them and the reader only supplies the token and a model. A host nobody has
6
+ described is ``custom``: the reader gives the address too.
7
+
8
+ This module knows nothing about the shell, the terminal or ``zett-agent`` — a plugin imports it to
9
+ describe a host of its own (``container.register_provider(Provider(...))``) and the configuration
10
+ code imports it to turn a choice into a ``[[models]]`` entry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections.abc import Iterable
17
+ from dataclasses import dataclass
18
+ from urllib.parse import urlparse
19
+
20
+ from zettcode._compat import StrEnum
21
+
22
+
23
+ class Api(StrEnum):
24
+ """The three request protocols one model entry can speak.
25
+
26
+ The value is the name a reader types (``--api responses``) and the word the picker shows, and a
27
+ member compares equal to that string, so it can be used wherever the text was.
28
+
29
+ Attributes:
30
+ CHAT: An OpenAI-compatible chat-completions endpoint; the default.
31
+ RESPONSES: The Responses API, on an OpenAI-compatible endpoint.
32
+ ANTHROPIC: Anthropic's Messages API, which is not OpenAI-compatible.
33
+ """
34
+
35
+ CHAT = "chat"
36
+ RESPONSES = "responses"
37
+ ANTHROPIC = "anthropic"
38
+
39
+ @property
40
+ def description(self) -> str:
41
+ """Return what the protocol is, in words, for the question that asks for one."""
42
+ return _DESCRIPTIONS[self]
43
+
44
+ @property
45
+ def keys(self) -> dict[str, object]:
46
+ """Return the ``[[models]]`` keys that select this protocol: none for chat, one for the others.
47
+
48
+ The three flags (``chat`` is the third) are alternatives, never layers, so a rewrite clears all of them before
49
+ it sets one. Chat completions is what an entry that sets none already is, so it needs no key; a reader who
50
+ picks it outright is given ``chat = true`` by the editor (see ``Draft.chat``), to say so in the file.
51
+ """
52
+ if self is Api.ANTHROPIC:
53
+ return {"anthropic": True}
54
+ if self is Api.RESPONSES:
55
+ return {"responses_api": True}
56
+ return {}
57
+
58
+ @classmethod
59
+ def parse(cls, text: str) -> Api:
60
+ """Return the protocol a reader typed, in any case; raise ``ValueError`` listing the choices."""
61
+ try:
62
+ return cls(text.strip().lower())
63
+ except ValueError:
64
+ raise ValueError(f"the api must be one of {', '.join(cls)}, not {text!r}") from None
65
+
66
+
67
+ #: How each protocol is named beside a choice. The wording rule for everything a list shows next to a name — here,
68
+ #: a provider's :attr:`~Provider.note`, and the notes of the other pickers — is a lower-case phrase with no
69
+ #: leading article and no full stop; a proper noun (Responses API, Anthropic) keeps its capital.
70
+ _DESCRIPTIONS = {
71
+ Api.CHAT: "chat completions",
72
+ Api.RESPONSES: "Responses API",
73
+ Api.ANTHROPIC: "Anthropic Messages API",
74
+ }
75
+
76
+ #: The flags a ``[[models]]`` entry sets to choose a protocol; every one is cleared before one is set. ``chat`` is
77
+ #: among them because a file may spell the default out, and a rewrite to another protocol must not leave it behind.
78
+ API_FLAG_KEYS = ("chat", "anthropic", "responses_api")
79
+
80
+ #: What a provider is called when typed: lower case, so ``/config add deepseek`` has one spelling.
81
+ NAME = re.compile(r"[a-z][a-z0-9_-]{0,31}\Z")
82
+
83
+ #: The name that stands for "a host not described here"; it is not a provider, it is the absence of one.
84
+ CUSTOM = "custom"
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class Provider:
89
+ """One host a model can be reached at.
90
+
91
+ Attributes:
92
+ name: Identifier typed after ``/config add``; lower case letters, digits, ``-`` and ``_``.
93
+ label: What the picker shows.
94
+ api: Which request protocol the host speaks: ``chat``, ``responses`` or ``anthropic``.
95
+ base_url: The address the client appends its route to. ``None`` leaves it to the SDK's own
96
+ default, which is right for the vendor the SDK belongs to.
97
+ token_env: The environment variable the vendor's own tools read the token from, named in
98
+ the hint so a reader knows which key is wanted. It is never read from here.
99
+ models: Model ids worth suggesting. Left empty by the built-ins: a list of model names goes
100
+ stale faster than this program is released, so the reader types the one they mean.
101
+ multimodal: Whether models at this host take images, as the default for a new entry.
102
+ note: One line of help shown beside the provider in the picker: a lower-case phrase with no leading
103
+ article and no full stop, as every note in a list is (``chat completions``, ``local models``).
104
+ Left empty, the picker shows the protocol's own name.
105
+ """
106
+
107
+ name: str
108
+ label: str
109
+ api: Api = Api.CHAT
110
+ base_url: str | None = None
111
+ token_env: str | None = None
112
+ models: tuple[str, ...] = ()
113
+ multimodal: bool = False
114
+ note: str = ""
115
+
116
+ def __post_init__(self) -> None:
117
+ """Reject a description that could not produce a working entry."""
118
+ if not NAME.match(self.name):
119
+ raise ValueError(
120
+ f"Provider name {self.name!r} must be lower case letters, digits, '-' or '_', starting with a letter"
121
+ )
122
+ if self.name == CUSTOM:
123
+ raise ValueError(f"{CUSTOM!r} is reserved: it stands for a host that is not described")
124
+ if not self.label.strip():
125
+ raise ValueError(f"Provider {self.name!r} needs a label")
126
+ try:
127
+ object.__setattr__(self, "api", Api.parse(self.api))
128
+ except ValueError as error:
129
+ raise ValueError(f"Provider {self.name!r}: {error}") from None
130
+ if self.base_url is not None:
131
+ check_url(self.base_url, what=f"Provider {self.name!r} base_url")
132
+
133
+
134
+ def check_url(value: str, *, what: str = "url") -> str:
135
+ """Return ``value`` trimmed if it is an http or https address, else raise ``ValueError``.
136
+
137
+ Args:
138
+ value: What the reader typed or a plugin declared.
139
+ what: How to name it in the error.
140
+ """
141
+ cleaned = value.strip()
142
+ parsed = urlparse(cleaned)
143
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
144
+ raise ValueError(f"{what} must be an http or https address such as https://api.example.com/v1")
145
+ return cleaned
146
+
147
+
148
+ #: The hosts described out of the box. Each is an address and a protocol, nothing a reader could not
149
+ #: find on the vendor's own page; none of them lists models (see :attr:`Provider.models`).
150
+ BUILTIN_PROVIDERS: tuple[Provider, ...] = (
151
+ Provider(
152
+ "openai",
153
+ "OpenAI",
154
+ api=Api.CHAT,
155
+ base_url="https://api.openai.com/v1",
156
+ token_env="OPENAI_API_KEY",
157
+ note="chat completions; change the api later for Responses API",
158
+ ),
159
+ Provider(
160
+ "anthropic",
161
+ "Anthropic",
162
+ api=Api.ANTHROPIC,
163
+ base_url="https://api.anthropic.com",
164
+ token_env="ANTHROPIC_API_KEY",
165
+ note="Anthropic Messages API",
166
+ ),
167
+ Provider(
168
+ "deepseek",
169
+ "DeepSeek",
170
+ base_url="https://api.deepseek.com",
171
+ token_env="DEEPSEEK_API_KEY",
172
+ note="chat completions",
173
+ ),
174
+ Provider(
175
+ "openrouter",
176
+ "OpenRouter",
177
+ base_url="https://openrouter.ai/api/v1",
178
+ token_env="OPENROUTER_API_KEY",
179
+ note="chat completions; one key for many vendors, model ids look like vendor/name",
180
+ ),
181
+ Provider(
182
+ "groq",
183
+ "Groq",
184
+ base_url="https://api.groq.com/openai/v1",
185
+ token_env="GROQ_API_KEY",
186
+ note="chat completions",
187
+ ),
188
+ Provider(
189
+ "ollama",
190
+ "Ollama (local)",
191
+ base_url="http://localhost:11434/v1",
192
+ note="chat completions on this machine; no key is checked, any text will do for the token",
193
+ ),
194
+ )
195
+
196
+
197
+ def find_provider(name: str, providers: Iterable[Provider]) -> Provider | None:
198
+ """Return the provider called ``name`` (case does not matter), or ``None``."""
199
+ wanted = name.strip().lower()
200
+ return next((provider for provider in providers if provider.name == wanted), None)
201
+
202
+
203
+ def merge_providers(*groups: Iterable[Provider]) -> tuple[Provider, ...]:
204
+ """Join provider lists in order; the first to claim a name keeps it.
205
+
206
+ A plugin that describes ``openai`` again does not replace the built-in: two hosts with one name
207
+ would make ``/config add openai`` ambiguous, and the one that was there first is the one a
208
+ reader has already seen.
209
+ """
210
+ merged: list[Provider] = []
211
+ for group in groups:
212
+ for provider in group:
213
+ if all(existing.name != provider.name for existing in merged):
214
+ merged.append(provider)
215
+ return tuple(merged)
zettcode/py.typed ADDED
File without changes
zettcode/registry.py ADDED
@@ -0,0 +1,63 @@
1
+ """Named items merged from ordered providers.
2
+
3
+ The shell keeps two catalogs a plugin may extend: the composer's slash commands
4
+ and its ``@`` resources. Both merge the same way — providers in order, and the
5
+ first provider to claim a name owns it — so both are one :class:`Registry` over
6
+ one :class:`Provider` shape, and the builtins are providers like any other.
7
+
8
+ A leaf with no imports of the project: the engine's ``@`` resources and the shell's commands both build on it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Sequence
15
+ from typing import Generic, TypeVar
16
+
17
+ #: The kind of item a registry holds, such as a slash command.
18
+ Item = TypeVar("Item")
19
+
20
+
21
+ class Provider(Generic[Item], ABC):
22
+ """One source of named items a registry merges."""
23
+
24
+ @property
25
+ @abstractmethod
26
+ def items(self) -> Sequence[Item]:
27
+ """Return the items this provider contributes, in display order."""
28
+
29
+
30
+ class Registry(Generic[Item]):
31
+ """Merge providers in order; the first provider to claim a name owns it."""
32
+
33
+ def __init__(self, providers: Sequence[Provider[Item]] = ()) -> None:
34
+ """Start from the given providers, most trusted first."""
35
+ self._providers = list(providers)
36
+
37
+ def register(self, provider: Provider[Item]) -> Provider[Item]:
38
+ """Append one provider and return it, for a caller that wants to keep it."""
39
+ self._providers.append(provider)
40
+ return provider
41
+
42
+ @property
43
+ def providers(self) -> tuple[Provider[Item], ...]:
44
+ """Return the providers in order."""
45
+ return tuple(self._providers)
46
+
47
+ def items(self) -> tuple[Item, ...]:
48
+ """Return the merged items, an earlier provider's name winning a clash."""
49
+ seen: set[str] = set()
50
+ merged: list[Item] = []
51
+ for provider in self._providers:
52
+ for item in provider.items:
53
+ name = getattr(item, "name", None)
54
+ if name is not None:
55
+ if name in seen:
56
+ continue
57
+ seen.add(name)
58
+ merged.append(item)
59
+ return tuple(merged)
60
+
61
+ def find(self, name: str) -> Item | None:
62
+ """Return the item with this name, or ``None`` when nothing claims it."""
63
+ return next((item for item in self.items() if getattr(item, "name", None) == name), None)