zettcode-sdk 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.
@@ -0,0 +1,126 @@
1
+ """Answering what the model asks of the program: a shell command to approve, a question to answer.
2
+
3
+ A run cannot be unattended without a rule for these, because the agent is suspended inside the tool call until
4
+ someone answers. The rule is: pass a handler to the run and it answers before the event is yielded; answer the event
5
+ yourself while you are iterating; or do neither, and a command is denied and a question declined. Nothing hangs.
6
+
7
+ A handler may be a plain function or a coroutine function. What it returns decides the answer::
8
+
9
+ def only_git(request):
10
+ return request.command.startswith("git ") # True approves, False denies
11
+
12
+ async def ask_a_person(request):
13
+ request.approve(remember=True) # answering yourself is fine; return None
14
+
15
+ ``None`` leaves the request unanswered, which means the default (deny, decline) applies once the stream is asked for
16
+ its next event. A run started with ``defer_answers=True`` does not apply the default then: it keeps the request open,
17
+ so a handler that only shows the request to a person (and returns ``None``) can have it answered later, from a panel,
18
+ by the event's own ``approve`` or ``answer``. Whatever is still open when the run ends gets the default.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import inspect
24
+ from collections.abc import Awaitable, Callable
25
+ from typing import TypeAlias
26
+
27
+ from .events import ApprovalRequested, QuestionAsked
28
+
29
+ __all__ = [
30
+ "ApprovalHandler",
31
+ "QuestionHandler",
32
+ "approve_all",
33
+ "approve_commands",
34
+ "deny_all",
35
+ ]
36
+
37
+ #: Decides one shell command: ``True`` approves, ``False`` denies, ``None`` leaves it to the request's own methods.
38
+ ApprovalHandler: TypeAlias = Callable[[ApprovalRequested], "bool | None | Awaitable[bool | None]"]
39
+
40
+ #: Answers one question: the text answers it, ``None`` leaves it to the request's own methods.
41
+ QuestionHandler: TypeAlias = Callable[[QuestionAsked], "str | None | Awaitable[str | None]"]
42
+
43
+ #: Characters that let a shell run something other than the command that was typed: chaining, pipes,
44
+ #: redirection, substitution, grouping and newlines. A command holding one is never matched by a prefix rule.
45
+ SHELL_META = frozenset(";&|<>$`\n\r(){}\\")
46
+
47
+
48
+ def approve_all(request: ApprovalRequested) -> bool:
49
+ """Approve every command. Only for a workspace and a model you would trust with a shell."""
50
+ return True
51
+
52
+
53
+ def deny_all(request: ApprovalRequested) -> bool:
54
+ """Deny every command; the model is told and carries on without running anything."""
55
+ return False
56
+
57
+
58
+ def approve_commands(*commands: str) -> ApprovalHandler:
59
+ """Return a handler that approves only the commands named, and denies the rest.
60
+
61
+ A command matches when it *is* one of ``commands`` or continues one with arguments (``"git status"`` matches
62
+ ``git status`` and ``git status -s``, not ``git statusx``). It never matches a command holding a character the
63
+ shell treats specially (``;``, ``&``, ``|``, redirection, substitution, newlines), because ``git status; rm -rf
64
+ ~`` starts with ``git status`` too.
65
+
66
+ Args:
67
+ commands: The commands to allow, such as ``"git status"``, ``"git diff"``, ``"pytest"``.
68
+
69
+ Raises:
70
+ ValueError: A command is blank, or holds a character the shell treats specially.
71
+ """
72
+ allowed: list[str] = []
73
+ for command in commands:
74
+ command = command.strip()
75
+ if not command:
76
+ raise ValueError("A command to allow cannot be blank")
77
+ if SHELL_META & set(command):
78
+ raise ValueError(f"{command!r} holds a shell character; allow the plain command and not a pipeline")
79
+ allowed.append(command)
80
+ rules = tuple(allowed)
81
+
82
+ def decide(request: ApprovalRequested) -> bool:
83
+ text = request.command.strip()
84
+ if not text or SHELL_META & set(text):
85
+ return False
86
+ return any(text == rule or text.startswith(rule + " ") for rule in rules)
87
+
88
+ return decide
89
+
90
+
91
+ async def apply_approval(request: ApprovalRequested, handler: ApprovalHandler | None) -> None:
92
+ """Let ``handler`` answer ``request``; it stays unanswered when there is none or it declines to say.
93
+
94
+ Raises:
95
+ TypeError: The handler returned something other than ``True``, ``False`` or ``None``; a truthy string
96
+ answering "yes" is exactly the mistake that must not approve a shell command.
97
+ """
98
+ if handler is None:
99
+ return
100
+ outcome = handler(request)
101
+ if inspect.isawaitable(outcome):
102
+ outcome = await outcome
103
+ if request.answered or outcome is None:
104
+ return
105
+ if not isinstance(outcome, bool):
106
+ raise TypeError(f"An approval handler returns True, False or None, not {type(outcome).__name__}")
107
+ request.approve() if outcome else request.deny()
108
+
109
+
110
+ async def apply_question(request: QuestionAsked, handler: QuestionHandler | None) -> None:
111
+ """Let ``handler`` answer ``request``; a blank answer or ``None`` leaves it for the default.
112
+
113
+ Raises:
114
+ TypeError: The handler returned something other than text or ``None``.
115
+ """
116
+ if handler is None:
117
+ return
118
+ outcome = handler(request)
119
+ if inspect.isawaitable(outcome):
120
+ outcome = await outcome
121
+ if request.answered or outcome is None:
122
+ return
123
+ if not isinstance(outcome, str):
124
+ raise TypeError(f"A question handler returns text or None, not {type(outcome).__name__}")
125
+ if outcome.strip():
126
+ request.answer(outcome)
@@ -0,0 +1,299 @@
1
+ """This session's line to the others: who is active, a message sent, a message received.
2
+
3
+ The broker client (:mod:`zettcode.broker.client`) knows the wire; this is the part the rest of the
4
+ application holds. The shell tells it what the session is now and gives it a place to hand a received
5
+ message to, and the ``send_message`` tool and the ``@`` completion ask it who else is there.
6
+
7
+ It owns one fact the client cannot know: which message the running turn is answering. An agent that
8
+ is answering another agent carries that message's hop count on, which is what stops two sessions
9
+ that keep replying to each other; a turn that began with the reader's own words starts at none.
10
+
11
+ Nothing here blocks or raises into the shell. Without a broker every method answers as if nobody
12
+ else were running, and a send says so.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from collections.abc import Callable, Sequence
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from zettcode.broker.client import BrokerClient, Delivered, Incoming, Receipt, RosterEntry, SessionInfo
22
+ from zettcode.broker.models import GroupDelivered, GroupInfo
23
+ from zettcode.broker.protocol import VIA_AGENT, ImageData
24
+ from zettcode.config import ZettCodeConfig
25
+ from zettcode.modes import MODE_STEER
26
+ from zettcode.relay import Relayed
27
+
28
+ if TYPE_CHECKING: # pragma: no cover - annotations only
29
+ from pathlib import Path
30
+
31
+
32
+ #: How long pieces of text are gathered before they are sent as one, so a stream is a few lines a second and not one per token.
33
+ STREAM_BATCH = 0.06
34
+
35
+ #: The most characters of text sent in one stream line, well inside what the broker passes (see ``MAX_STREAM``).
36
+ STREAM_CHUNK = 4000
37
+
38
+
39
+ class Messenger:
40
+ """The shell's and the agent's one handle on the other sessions."""
41
+
42
+ def __init__(self, client: BrokerClient | None = None, *, workspace: str | Path = "") -> None:
43
+ """Wrap a client; one is built for the default broker when none is given.
44
+
45
+ Args:
46
+ client: The connection to use. Its callbacks are taken over, so give a client that nothing
47
+ else is listening to.
48
+ workspace: The directory this session works in, which other sessions are shown.
49
+ """
50
+ self.workspace = str(workspace)
51
+ self.client = client if client is not None else BrokerClient()
52
+ self.client.on_message = self._received
53
+ self.client.on_roster = self._roster_changed
54
+ self.client.on_conflict = self._conflicted
55
+ self.client.on_problem = self._troubled
56
+ #: Where a received message goes; the shell sets it. It returns what this session says to the
57
+ #: sender: whether it took the message, and what will become of it.
58
+ self.receiver: Callable[[Relayed], Receipt] | None = None
59
+ #: Called, on the loop, whenever the roster or the connection changes, so a view can repaint.
60
+ self.on_change: Callable[[], None] | None = None
61
+ #: Called with a sentence when another process holds this session's id, and with ``None`` after.
62
+ self.on_conflict: Callable[[str | None], None] | None = None
63
+ #: Called with a sentence when the broker has been out of reach for a while, and with ``None`` when it is back.
64
+ self.on_problem: Callable[[str | None], None] | None = None
65
+ #: The message the running turn answers, if it answers one.
66
+ self.answering: Relayed | None = None
67
+ #: Whether a message the sender marked ``direct`` is run as this session's own prompt; said in the roster.
68
+ self.direct = False
69
+ self._started = False
70
+ self._piece: tuple[str, list[str]] | None = None
71
+ self._flush: asyncio.TimerHandle | None = None
72
+
73
+ @classmethod
74
+ def create(cls, config: ZettCodeConfig) -> Messenger | None:
75
+ """Return the messenger this configuration asks for, or ``None`` when sessions are switched off."""
76
+ if not config.sessions_enabled:
77
+ return None
78
+ return cls(workspace=config.workspace)
79
+
80
+ # -- the life of the connection ---------------------------------------------------------------
81
+ @property
82
+ def started(self) -> bool:
83
+ """Return whether :meth:`start` has run, which is when it is safe to tell it about the session."""
84
+ return self._started
85
+
86
+ async def start(self) -> None:
87
+ """Begin connecting in the background. Returns at once, whether or not a broker exists."""
88
+ self._started = True
89
+ await self.client.start()
90
+
91
+ async def close(self) -> None:
92
+ """Leave the broker. Quick, and safe to call twice."""
93
+ self._started = False
94
+ self.receiver = None
95
+ await self.client.close()
96
+
97
+ # -- what this session is ---------------------------------------------------------------------
98
+ def describe(self, session_id: str, title: str | None, *, busy: bool) -> None:
99
+ """Say what this session is now. Cheap when nothing changed, so callers need not check.
100
+
101
+ Args:
102
+ session_id: The active session.
103
+ title: Its title, or ``None`` while it has none.
104
+ busy: Whether a request or command is running.
105
+ """
106
+ info = SessionInfo(
107
+ session_id=session_id,
108
+ title=title or "",
109
+ workspace=self.workspace,
110
+ status="working" if busy else "idle",
111
+ direct=self.direct,
112
+ )
113
+ current = self.client.session
114
+ if current is not None and (current.session_id, current.title, current.status, current.direct) == (
115
+ info.session_id,
116
+ info.title,
117
+ info.status,
118
+ info.direct,
119
+ ):
120
+ return
121
+ self.client.set_session(info)
122
+
123
+ def retire(self) -> None:
124
+ """Say that this process has no session to be reached at: it only shows one that another process has open."""
125
+ if self.client.session is not None:
126
+ self.client.set_session(None)
127
+
128
+ # -- the others -------------------------------------------------------------------------------
129
+ @property
130
+ def connected(self) -> bool:
131
+ """Return whether the broker is reachable right now."""
132
+ return self.client.connected
133
+
134
+ @property
135
+ def conflict(self) -> str | None:
136
+ """Return why this session is not registered, or ``None``."""
137
+ return self.client.conflict
138
+
139
+ @property
140
+ def handle(self) -> str:
141
+ """Return the name the others call this session by, or ``""`` before it is registered."""
142
+ mine = self.client.session
143
+ if mine is None:
144
+ return ""
145
+ return next((entry.handle for entry in self.client.roster if entry.session_id == mine.session_id), "")
146
+
147
+ def peers(self) -> tuple[RosterEntry, ...]:
148
+ """Return the other active sessions, from memory: this is called as a draft is typed."""
149
+ return self.client.others()
150
+
151
+ def groups(self) -> tuple[GroupInfo, ...]:
152
+ """Return the group chats this session may write to, from memory."""
153
+ return self.client.groups
154
+
155
+ async def manage_group(self, action: str, name: str, members: list[str] | None = None) -> GroupInfo | None:
156
+ """Create a group, change who is in it, leave it or delete it (see :meth:`BrokerClient.manage_group`)."""
157
+ return await self.client.manage_group(action, name, members)
158
+
159
+ async def join_group(self, name: str) -> GroupInfo | None:
160
+ """Put this session in a group as the broker's owner may (see :meth:`BrokerClient.join_group`)."""
161
+ return await self.client.join_group(name)
162
+
163
+ async def history(self, *, with_: str = "", group: str = "", limit: int = 20) -> tuple[list[dict[str, Any]], str]:
164
+ """Return stored messages with one session, in a group, or all this session's own (see the client)."""
165
+ return await self.client.history(with_=with_, group=group, limit=limit)
166
+
167
+ # -- watching a turn --------------------------------------------------------------------------
168
+ @property
169
+ def on_stream(self) -> Callable[[str, dict[str, Any]], None] | None:
170
+ """Called with a watched session's id and one thing its turn said."""
171
+ return self.client.on_stream
172
+
173
+ @on_stream.setter
174
+ def on_stream(self, callback: Callable[[str, dict[str, Any]], None] | None) -> None:
175
+ self.client.on_stream = callback
176
+
177
+ def watch(self, session_id: str) -> None:
178
+ """Ask to be told what a session's turns say as they happen."""
179
+ self.client.watch(session_id)
180
+
181
+ def unwatch(self, session_id: str) -> None:
182
+ """Stop being told."""
183
+ self.client.unwatch(session_id)
184
+
185
+ def stream(self, kind: str, **fields: Any) -> None:
186
+ """Say one thing about the running turn to whoever watches it; a no-op when nobody does.
187
+
188
+ Pieces of text are gathered for :data:`STREAM_BATCH` seconds and go as one; anything else sends what was
189
+ gathered first, so the order is the order they happened in.
190
+ """
191
+ if not self.client.watched:
192
+ self._piece = None
193
+ if self._flush is not None:
194
+ self._flush.cancel()
195
+ self._flush = None
196
+ return
197
+ if kind in ("text", "thinking"):
198
+ text = fields.get("text", "")
199
+ if self._piece is not None and self._piece[0] != kind:
200
+ self._send_piece()
201
+ if self._piece is None:
202
+ self._piece = (kind, [])
203
+ self._piece[1].append(text)
204
+ if self._flush is None:
205
+ self._flush = asyncio.get_running_loop().call_later(STREAM_BATCH, self._send_piece)
206
+ return
207
+ self._send_piece()
208
+ self.client.publish({"t": kind, **fields})
209
+
210
+ def _send_piece(self) -> None:
211
+ if self._flush is not None:
212
+ self._flush.cancel()
213
+ self._flush = None
214
+ piece, self._piece = self._piece, None
215
+ if piece is None:
216
+ return
217
+ kind, parts = piece
218
+ if kind != "text":
219
+ self.client.publish({"t": kind})
220
+ return
221
+ # A burst of text is cut into lines the broker will pass: one longer than it allows is dropped there, whole.
222
+ text = "".join(parts)
223
+ for start in range(0, len(text), STREAM_CHUNK):
224
+ self.client.publish({"t": "text", "text": text[start : start + STREAM_CHUNK]})
225
+
226
+ # -- sending ----------------------------------------------------------------------------------
227
+ async def send(
228
+ self,
229
+ to: str,
230
+ text: str,
231
+ *,
232
+ via: str = VIA_AGENT,
233
+ direct: bool = False,
234
+ mode: str = MODE_STEER,
235
+ images: Sequence[ImageData] = (),
236
+ ) -> Delivered:
237
+ """Send a message and return once the other session has taken it.
238
+
239
+ A message sent by this session's agent while it is answering another one carries that message's
240
+ hop count on; a message the reader sent starts again at none.
241
+
242
+ Args:
243
+ to: The session's handle or id.
244
+ text: What to say.
245
+ via: How it came: the agent's tool, or the reader.
246
+ direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
247
+ mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
248
+ ``"followup"`` waits until that request ends.
249
+ images: Pictures to send with a ``direct`` message.
250
+
251
+ Raises:
252
+ SendError: Nobody was reached, and why.
253
+ """
254
+ hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
255
+ return await self.client.send(to, text, hops=hops, via=via, direct=direct, mode=mode, images=images)
256
+
257
+ async def send_group(
258
+ self, group: str, text: str, *, via: str = VIA_AGENT, mode: str = MODE_STEER
259
+ ) -> GroupDelivered:
260
+ """Write to a group chat this session is in; the hop count is carried on as for :meth:`send`.
261
+
262
+ Raises:
263
+ SendError: Nobody was reached, and why (``denied`` when this session is not in the group).
264
+ """
265
+ hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
266
+ return await self.client.send_group(group, text, hops=hops, via=via, mode=mode)
267
+
268
+ # -- what the client reports ------------------------------------------------------------------
269
+ def _received(self, incoming: Incoming) -> Receipt:
270
+ """Turn the client's message into the shell's and ask the shell to take it."""
271
+ receiver = self.receiver
272
+ if receiver is None:
273
+ return Receipt(False, "this session is not taking messages yet")
274
+ sender = incoming.sender
275
+ return receiver(
276
+ Relayed(
277
+ sender_id=sender.session_id,
278
+ sender=sender.handle,
279
+ text=incoming.text,
280
+ via=incoming.via,
281
+ hops=incoming.hops,
282
+ direct=incoming.direct,
283
+ mode=incoming.mode,
284
+ group=incoming.group,
285
+ images=incoming.images,
286
+ )
287
+ )
288
+
289
+ def _roster_changed(self, roster: tuple[RosterEntry, ...]) -> None:
290
+ if self.on_change is not None:
291
+ self.on_change()
292
+
293
+ def _conflicted(self, message: str | None) -> None:
294
+ if self.on_conflict is not None:
295
+ self.on_conflict(message)
296
+
297
+ def _troubled(self, message: str | None) -> None:
298
+ if self.on_problem is not None:
299
+ self.on_problem(message)
zettcode/sdk/models.py ADDED
@@ -0,0 +1,156 @@
1
+ """The models a config file holds: list them, add one, change one, remove one.
2
+
3
+ An editor that hosts ZettCode has to be able to set a model up before there is any client: a fresh machine has no
4
+ ``~/.zettcode/config.toml``, and a client cannot be made without a model. These functions work on the file itself, so
5
+ they answer with no client, no provider, and no network — the terminal's ``/config`` edits the same file with the same
6
+ helpers, so a model added here reads there, and the other way round.
7
+
8
+ Nothing is believed until it is read back: the new file is parsed and every entry validated before the call returns,
9
+ and a write that would not parse is undone.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ from zettcode import config as _config
18
+ from zettcode.config_edit import add_model as insert_model
19
+ from zettcode.config_edit import file_entries, replace_model, write_text_atomically
20
+ from zettcode.paths import config_path
21
+
22
+ from .types import ModelInfo
23
+
24
+ __all__ = ["add_model", "list_models", "remove_model", "update_model"]
25
+
26
+
27
+ def model_info(model: _config.ModelConfig, *, active: bool = False) -> ModelInfo:
28
+ """Return one configured model as an SDK value; ``active`` is for a client to say, a file cannot."""
29
+ return ModelInfo(
30
+ name=model.shown_name,
31
+ model=model.model,
32
+ context_window=model.context_window,
33
+ multimodal=model.multimodal,
34
+ efforts=tuple(level.value for level in model.efforts),
35
+ active=active,
36
+ compact_percent=model.compact_percent,
37
+ web_search=model.web_search,
38
+ web_search_name=model.web_search_name,
39
+ display_model=model.display_model,
40
+ base_url=model.base_url,
41
+ api=model.api.value,
42
+ temperature=model.temperature,
43
+ can_search=model.can_search,
44
+ search_name=model.search_name,
45
+ )
46
+
47
+
48
+ def _infos(models: tuple[_config.ModelConfig, ...]) -> tuple[ModelInfo, ...]:
49
+ """Return the configured models as SDK values; none is marked active, which only a client knows."""
50
+ return tuple(model_info(model) for model in models)
51
+
52
+
53
+ def _path(path: str | Path | None) -> Path:
54
+ """Return the config file an edit goes to."""
55
+ return config_path(path)
56
+
57
+
58
+ def _read_text(target: Path) -> str:
59
+ """Return the file's text, or nothing when it does not exist yet."""
60
+ return target.read_text(encoding="utf-8") if target.is_file() else ""
61
+
62
+
63
+ def _write(target: Path, before: str | None, text: str) -> tuple[_config.ModelConfig, ...]:
64
+ """Write ``text``, read it back, and undo the write when what came back cannot be used."""
65
+ target.parent.mkdir(parents=True, exist_ok=True)
66
+ write_text_atomically(target, text)
67
+ try:
68
+ return _config.read_models(target)
69
+ except ValueError:
70
+ if before is None:
71
+ target.unlink(missing_ok=True)
72
+ else:
73
+ write_text_atomically(target, before)
74
+ raise
75
+
76
+
77
+ def list_models(path: str | Path | None = None) -> tuple[ModelInfo, ...]:
78
+ """Return the models the config file lists, or nothing when it lists none.
79
+
80
+ Raises:
81
+ ValueError: The file does not parse, or an entry is not usable as one.
82
+ """
83
+ return _infos(_config.read_models(_path(path)))
84
+
85
+
86
+ def add_model(entry: Mapping[str, object], *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
87
+ """Add one model to the config file and return every model it now lists.
88
+
89
+ The entry uses the file's own keys — ``model``, ``token``, and optionally ``display_model``, ``base_url``,
90
+ ``anthropic``, ``responses_api``, ``multimodal``, ``web_search``, ``web_search_name``, ``context_window``,
91
+ ``compact_percent``,
92
+ ``reasoning_efforts`` — and is validated by reading the file back before returning. The first model is written
93
+ by hand (the file has no block to insert after); later ones go through the same editor the terminal's ``/config``
94
+ uses, so the rest of the file, comments included, is left as it was.
95
+
96
+ Raises:
97
+ ValueError: The entry is not a usable model, or the file would not parse.
98
+ """
99
+ target = _path(path)
100
+ # Unconditionally: `read_models` answers () for a file that is not there yet and refuses one that is not a
101
+ # file, which is what a caller naming a directory has to hear rather than an IsADirectoryError from the write.
102
+ _config.read_models(target)
103
+ before = target.read_text(encoding="utf-8") if target.is_file() else None
104
+ text = insert_model(before or "", entry, where=target)
105
+ return _infos(_write(target, before, text))
106
+
107
+
108
+ def _find(models: tuple[_config.ModelConfig, ...], name: str) -> int:
109
+ """Return the place of the model called ``name`` (its display name, or its id), as the runtime resolves it."""
110
+ index = _config.model_index(models, name)
111
+ if index is None:
112
+ raise ValueError(f"No model called {name!r}; configured: {', '.join(model.shown_name for model in models)}")
113
+ return index
114
+
115
+
116
+ def update_model(name: str, changes: Mapping[str, object], *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
117
+ """Change the model called ``name``, leaving every key not named as it is, and return the new list.
118
+
119
+ ``changes`` holds the file's keys. The token is kept unless a new one is given, so a window or a display name can
120
+ be changed without repeating a secret. A key whose value is ``None`` is removed from the entry, which is how a
121
+ temperature is cleared and how one protocol flag gives way to another without being left behind as ``false``.
122
+
123
+ Raises:
124
+ ValueError: No model is called ``name``, or the result is not usable.
125
+ """
126
+ target = _path(path)
127
+ models = _config.read_models(target)
128
+ index = _find(models, name)
129
+ before = _read_text(target)
130
+ # The file's own keys, not the model's: a token the environment supplied belongs to the configuration, and
131
+ # writing it here would copy a secret into the file this dialog shows.
132
+ entry = dict(file_entries(before, where=target)[index])
133
+ for key, value in changes.items():
134
+ if value is None:
135
+ entry.pop(key, None)
136
+ else:
137
+ entry[key] = value
138
+ text = replace_model(before, index, entry, where=target)
139
+ return _infos(_write(target, before, text))
140
+
141
+
142
+ def remove_model(name: str, *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
143
+ """Remove the model called ``name`` and return the rest.
144
+
145
+ Raises:
146
+ ValueError: No model is called ``name``, or it is the last one: a configuration with no models is one no
147
+ client can be made from, so another has to be added first.
148
+ """
149
+ from zettcode.config_edit import remove_model as drop_model
150
+
151
+ target = _path(path)
152
+ models = _config.read_models(target)
153
+ index = _find(models, name)
154
+ before = _read_text(target)
155
+ text = drop_model(before, index, where=target)
156
+ return _infos(_write(target, before, text))
zettcode/sdk/py.typed ADDED
File without changes