zettcode-engine 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,271 @@
1
+ """The file system as the tools see it: paths taken from the working directory, text read in ranges, written whole."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from .output import MAX_OUTPUT_BYTES, utf8_prefix
12
+
13
+ #: The largest text file a tool reads whole, writes, or searches.
14
+ MAX_FILE_BYTES = 2 * 1024 * 1024
15
+
16
+ #: How much of a line is read at a time, so one enormous line cannot use the memory of a whole file.
17
+ FRAGMENT_CHARACTERS = 4096
18
+
19
+
20
+ # -- where a path points ---------------------------------------------------------------------------------------
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Located:
25
+ """A path a tool was asked about, resolved against the directory the process is in.
26
+
27
+ Attributes:
28
+ given: The path as the model wrote it, which is what an error message quotes back.
29
+ directory: The working directory, resolved.
30
+ path: Where ``given`` points: absolute paths and ``~`` are taken as they are, anything else is relative to
31
+ ``directory``; symbolic links are followed.
32
+ """
33
+
34
+ given: str
35
+ directory: Path
36
+ path: Path
37
+
38
+ @classmethod
39
+ def of(cls, given: str) -> Located:
40
+ """Resolve ``given`` against the working directory."""
41
+ directory = Path.cwd().resolve()
42
+ candidate = Path(given).expanduser()
43
+ target = candidate.resolve() if candidate.is_absolute() else (directory / candidate).resolve()
44
+ return cls(given, directory, target)
45
+
46
+ @property
47
+ def shown(self) -> str:
48
+ """Return the path as a result should name it: relative to the working directory for a relative ``given``."""
49
+ if Path(self.given).expanduser().is_absolute():
50
+ return self.path.as_posix()
51
+ return Path(os.path.relpath(self.path, self.directory)).as_posix()
52
+
53
+ def require_file(self) -> Path:
54
+ """Return the path, which has to be an existing regular file.
55
+
56
+ Raises:
57
+ ValueError: With the path as given, when there is no such file.
58
+ """
59
+ if not self.path.is_file():
60
+ raise ValueError(f"File does not exist: {self.given}")
61
+ return self.path
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Pattern:
66
+ """A glob pattern, split into the directory to search from and the expression to match below it.
67
+
68
+ Attributes:
69
+ root: Where the search starts: the filesystem root for an absolute pattern, else the working directory.
70
+ expression: What is matched below ``root``, as ``Path.glob`` takes it.
71
+ absolute: Whether matches are reported as absolute paths (the pattern was one) or relative to ``directory``.
72
+ directory: The working directory, resolved.
73
+ """
74
+
75
+ root: Path
76
+ expression: str
77
+ absolute: bool
78
+ directory: Path
79
+
80
+ @classmethod
81
+ def parse(cls, pattern: str) -> Pattern:
82
+ """Split ``pattern`` the way ``glob`` and ``grep`` take it."""
83
+ directory = Path.cwd().resolve()
84
+ candidate = Path(pattern).expanduser()
85
+ if candidate.is_absolute():
86
+ root = Path(candidate.anchor)
87
+ return cls(root, candidate.relative_to(root).as_posix(), True, directory)
88
+ return cls(directory, candidate.as_posix(), False, directory)
89
+
90
+ def matches(self) -> list[Path]:
91
+ """Return everything the pattern matches, in sorted order."""
92
+ return sorted(self.root.glob(self.expression))
93
+
94
+ def render(self, match: Path) -> str:
95
+ """Return a match in the style the pattern was written in."""
96
+ if self.absolute:
97
+ return match.absolute().as_posix()
98
+ return Path(os.path.relpath(match.absolute(), self.directory)).as_posix()
99
+
100
+
101
+ # -- reading and writing text ------------------------------------------------------------------------------------
102
+
103
+
104
+ def read_text(path: Path) -> str:
105
+ """Return a whole text file.
106
+
107
+ Raises:
108
+ ValueError: When the file is over :data:`MAX_FILE_BYTES`.
109
+ UnicodeDecodeError: When it is not UTF-8.
110
+ """
111
+ if path.stat().st_size > MAX_FILE_BYTES:
112
+ raise ValueError(f"File exceeds the {MAX_FILE_BYTES}-byte limit")
113
+ return path.read_text(encoding="utf-8")
114
+
115
+
116
+ def write_text(path: Path, content: str) -> None:
117
+ """Replace a file with ``content`` atomically: it is written beside the file and renamed over it.
118
+
119
+ A reader of the file sees the old text or the new text, never half of it, and a failed write leaves the old
120
+ text and no stray file behind. Missing parent directories are made.
121
+
122
+ Raises:
123
+ ValueError: When the text is over :data:`MAX_FILE_BYTES`.
124
+ """
125
+ encoded = content.encode("utf-8")
126
+ if len(encoded) > MAX_FILE_BYTES:
127
+ raise ValueError(f"Content exceeds the {MAX_FILE_BYTES}-byte limit")
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ temporary: Path | None = None
130
+ try:
131
+ with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, delete=False) as handle:
132
+ handle.write(encoded)
133
+ temporary = Path(handle.name)
134
+ os.replace(temporary, path)
135
+ finally:
136
+ if temporary is not None:
137
+ temporary.unlink(missing_ok=True)
138
+
139
+
140
+ def read_lines(located: Located, offset: int, limit: int) -> str:
141
+ """Return ``limit`` lines of a UTF-8 file from line ``offset``, with a note on how to continue when more remains.
142
+
143
+ The result is held to :data:`MAX_OUTPUT_BYTES`. A line longer than that is cut, and the note says so: the
144
+ reader has no column to resume from, because one 2 MB line is not what the tool is for — the shell reads it.
145
+ A cut inside an ordinary line is not that: the budget was used up before it, and the note then names the line
146
+ to start again from. The file is read a fragment at a time and never whole.
147
+
148
+ Args:
149
+ located: The file; it has to exist.
150
+ offset: One-based first line.
151
+ limit: Most lines to return.
152
+
153
+ Raises:
154
+ ValueError: When the file is not UTF-8 text.
155
+ """
156
+ selected: list[str] = []
157
+ number, used, line_start = 1, 0, 0
158
+ next_offset: int | None = None
159
+ cut_at: int | None = None
160
+ oversized = False
161
+ try:
162
+ with located.path.open(encoding="utf-8") as source:
163
+ while fragment := source.readline(FRAGMENT_CHARACTERS):
164
+ if number >= offset + limit:
165
+ next_offset = number
166
+ break
167
+ if number >= offset:
168
+ part = utf8_prefix(fragment, MAX_OUTPUT_BYTES - used)
169
+ selected.append(part)
170
+ used += len(part.encode("utf-8"))
171
+ if len(part) < len(fragment):
172
+ # Whether this line could fill a call of its own decides what the note says, and only
173
+ # what is left of the line knows that. Reading on is bounded by one budget.
174
+ cut_at = number
175
+ oversized = _line_longer_than_budget(
176
+ source, used - line_start, fragment[len(part) :], MAX_OUTPUT_BYTES
177
+ )
178
+ break
179
+ if fragment.endswith("\n"):
180
+ number, line_start = number + 1, used
181
+ except UnicodeDecodeError as error:
182
+ raise ValueError(f"File is not UTF-8 text: {located.given}") from error
183
+ content = "".join(selected)
184
+ if cut_at is not None and oversized:
185
+ content += (
186
+ f"\n... [line truncated; line {cut_at} is longer than the {MAX_OUTPUT_BYTES // 1024} KB output limit "
187
+ "and was cut here; read it with the shell]"
188
+ )
189
+ elif cut_at is not None:
190
+ content += f"\n... [output truncated; continue with offset={cut_at}]"
191
+ elif next_offset is not None:
192
+ content += f"\n... [more lines; continue with offset={next_offset}]"
193
+ return content
194
+
195
+
196
+ def _line_longer_than_budget(source: TextIO, seen: int, tail: str, budget: int) -> bool:
197
+ """Return whether the line continues for more than ``budget`` bytes in all.
198
+
199
+ Args:
200
+ source: The file, positioned just after the fragment the cut happened in.
201
+ seen: How many bytes of this line are already in the result.
202
+ tail: The rest of the fragment the cut happened in, which is not in the result yet.
203
+ budget: What one result may carry.
204
+
205
+ The line is read on to its end, but never past ``budget`` bytes: past that point the answer is known, and
206
+ the rest of a runaway line is what the shell is for.
207
+ """
208
+ fragment = tail
209
+ while True:
210
+ seen += len(fragment.encode("utf-8"))
211
+ if seen > budget:
212
+ return True
213
+ if not fragment or fragment.endswith("\n"):
214
+ return False
215
+ fragment = source.readline(FRAGMENT_CHARACTERS)
216
+
217
+
218
+ # -- editing ------------------------------------------------------------------------------------------------------
219
+
220
+
221
+ def apply_edit(content: str, old_string: str, new_string: str, replace_all: bool = False) -> tuple[str, int]:
222
+ """Replace ``old_string`` in ``content`` once, or everywhere when ``replace_all`` says so.
223
+
224
+ By default ``old_string`` must appear exactly once in the text, so include the surrounding characters that
225
+ make it unique; ``new_string`` may be empty, which deletes the match.
226
+
227
+ Returns:
228
+ The new text and how many occurrences were replaced.
229
+
230
+ Raises:
231
+ ValueError: Saying what to do, when the old text is not found or is not unique. Nothing has been written
232
+ by then, since the caller writes once, after the replacement has passed.
233
+ """
234
+ matches = content.count(old_string)
235
+ if matches == 0:
236
+ raise ValueError(f"Text to replace was not found: {_excerpt(old_string)}. {_miss_hint(content, old_string)}")
237
+ if matches > 1 and not replace_all:
238
+ raise ValueError(
239
+ f"Text to replace is not unique; found {matches} matches "
240
+ f"(include more surrounding lines, or set replace_all to change every one): {_excerpt(old_string)}"
241
+ )
242
+ updated = content.replace(old_string, new_string, -1 if replace_all else 1)
243
+ return updated, matches if replace_all else 1
244
+
245
+
246
+ def _miss_hint(content: str, old_string: str) -> str:
247
+ """Say why an exact match failed, so the model fixes ``old_string`` instead of leaving the tool."""
248
+ if " ".join(content.split()).count(" ".join(old_string.split())) > 0:
249
+ return (
250
+ "A match exists that differs only in whitespace or indentation; "
251
+ "read the file again and copy the lines exactly. Nothing was written."
252
+ )
253
+ return "Read the file again and copy old_string from its current content. Nothing was written."
254
+
255
+
256
+ def _excerpt(value: str, *, limit: int = 60) -> str:
257
+ """Shorten one matched snippet for an error message."""
258
+ collapsed = " ".join(value.split())
259
+ return repr(f"{collapsed[:limit]}\u2026" if len(collapsed) > limit else collapsed)
260
+
261
+
262
+ __all__ = [
263
+ "FRAGMENT_CHARACTERS",
264
+ "MAX_FILE_BYTES",
265
+ "Located",
266
+ "Pattern",
267
+ "apply_edit",
268
+ "read_lines",
269
+ "read_text",
270
+ "write_text",
271
+ ]
zettcode/error_text.py ADDED
@@ -0,0 +1,217 @@
1
+ """Say why a request failed, in words a row can show.
2
+
3
+ A leaf the engine, the SDK and the views all use, so none of them has to import another to say it: the runtime composes
4
+ the extensions, an extension reports its failures with this, and a function both of them need cannot live in either
5
+ without the one importing the other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from zett_agent.providers.base import ProviderTimeoutError
13
+
14
+ from zettcode._compat import BaseExceptionGroup
15
+
16
+ #: The most characters of what a server said that an error carries: enough for a sentence, not a page of HTML.
17
+ MAX_DETAIL = 200
18
+
19
+ #: What to try, by the status a server answered with. Only the ones where the fix is the reader's own to make.
20
+ HINTS = {
21
+ 401: "check the token with /config",
22
+ 403: "check the token and what it may use, with /config",
23
+ 404: "check the model id and the address with /config",
24
+ }
25
+
26
+ #: Something that looks like a credential in text a server sent back, which must not reach the screen:
27
+ #: ``sk-proj-****abcd`` is how a provider says which key it refused, and the end of a key is still part of it.
28
+ _SECRET = re.compile(r"\b(?:sk|pk|rk|key|tok|ghp|xox[a-z])[-_][A-Za-z0-9_*.\-]{4,}|Bearer\s+\S+", re.IGNORECASE)
29
+ _TAGS = re.compile(r"<[^>]+>")
30
+
31
+
32
+ #: The statuses that say the server was too busy or too broken to answer, and not that the request was wrong.
33
+ TRANSIENT_STATUSES = frozenset({408, 409, 425, 429})
34
+
35
+ #: What a failure that may pass says to do: the request is stored, and the library knows where it stopped.
36
+ CONTINUE_HINT = "/continue tries the request again from where it stopped."
37
+
38
+
39
+ def failure_text(error: BaseException) -> str:
40
+ """Return the line a failed request is shown as: what failed, and ``/continue`` when trying again may work."""
41
+ text = f"error: {describe_error(error)}"
42
+ if not may_pass(error):
43
+ return text
44
+ return f"{text.rstrip('.')}. {CONTINUE_HINT}"
45
+
46
+
47
+ def may_pass(error: BaseException) -> bool:
48
+ """Return whether a failure is the provider's or the network's, so the same request may well succeed later.
49
+
50
+ A provider that sent nothing in time, a connection that dropped, a server that answered 5xx, 429 or 408: asking
51
+ again is what the reader would do. A wrong token, a model that is not there or a request the server refused
52
+ would fail again, so those say nothing of the kind.
53
+ """
54
+ pending: list[BaseException] = [error]
55
+ seen: set[int] = set()
56
+ while pending:
57
+ current = pending.pop()
58
+ if id(current) in seen:
59
+ continue
60
+ seen.add(id(current))
61
+ if isinstance(current, BaseExceptionGroup):
62
+ pending.extend(current.exceptions)
63
+ continue
64
+ status = getattr(current, "status_code", None)
65
+ if isinstance(status, int) and not isinstance(status, bool) and (status >= 500 or status in TRANSIENT_STATUSES):
66
+ return True
67
+ if isinstance(current, (ProviderTimeoutError, OSError, TimeoutError)):
68
+ return True
69
+ if type(current).__module__.startswith("httpx"):
70
+ return True
71
+ if current.__cause__ is not None:
72
+ pending.append(current.__cause__)
73
+ return False
74
+
75
+
76
+ def describe_error(error: BaseException) -> str:
77
+ """Return the reasons an exception carries, not the group that wraps them, and what the server said.
78
+
79
+ A failure inside a task group arrives as an ``ExceptionGroup`` whose own
80
+ text says nothing — "unhandled errors in a TaskGroup (1 sub-exception)" —
81
+ and a server refusing a connection is reported exactly that way. The reasons
82
+ are the leaves, so that is what a row shows, each one once, however deeply
83
+ the groups nest.
84
+
85
+ A provider's own errors say what failed and not why: ``Provider stream request failed`` is the whole of
86
+ a 404 for a model that does not exist, a 429 for an account out of quota, and a 502 from a proxy. The why
87
+ is on the exception that caused it, so each leaf is followed down its causes to the HTTP status and what
88
+ the server said, and that is added in brackets — with what to try, where the fix is the reader's.
89
+
90
+ Args:
91
+ error: The exception a request failed with.
92
+
93
+ Returns:
94
+ The leaf reasons joined by ``"; "``, or the class name when a reason has
95
+ no text of its own.
96
+ """
97
+ reasons: list[str] = []
98
+ pending: list[BaseException] = [error]
99
+ while pending:
100
+ current = pending.pop(0)
101
+ if isinstance(current, BaseExceptionGroup):
102
+ pending[:0] = list(current.exceptions)
103
+ continue
104
+ text = _leaf_text(current)
105
+ if text not in reasons:
106
+ reasons.append(text)
107
+ return "; ".join(reasons)
108
+
109
+
110
+ def _leaf_text(error: BaseException) -> str:
111
+ """Return one exception's text with the cause that explains it, when it has one."""
112
+ own = _server_said(error)
113
+ if own is not None:
114
+ return own # an SDK error raised as it is: its own text is a dict printed after "Error code: 404 -"
115
+ text = _SECRET.sub("\u2026", str(error).strip()) or type(error).__name__
116
+ detail = _cause_detail(error)
117
+ if not detail or detail.lower() in text.lower():
118
+ return text
119
+ if text.lower() in detail.lower():
120
+ return detail # the wrapper already said the first half of what the cause says in full
121
+ said = _nearest_status(error)
122
+ if said is not None and said[1] and said[1].lower() in text.lower():
123
+ # The library wrote the server's words into its own sentence ("openai returned HTTP 401: ..."), so they are
124
+ # not said twice; what it cannot know is what to try.
125
+ hint = HINTS.get(said[0])
126
+ return f"{text} \u2014 {hint}" if hint else text
127
+ return f"{text} ({detail})"
128
+
129
+
130
+ def _nearest_status(error: BaseException) -> tuple[int, str] | None:
131
+ """Return the status and the server's words of the nearest cause that carries a status, or ``None``."""
132
+ seen = {id(error)}
133
+ cause = error.__cause__
134
+ while cause is not None and id(cause) not in seen:
135
+ seen.add(id(cause))
136
+ status = getattr(cause, "status_code", None)
137
+ if isinstance(status, int) and not isinstance(status, bool):
138
+ return status, _message_of(cause)
139
+ cause = cause.__cause__
140
+ return None
141
+
142
+
143
+ def _cause_detail(error: BaseException) -> str | None:
144
+ """Return what the nearest cause says: what a server answered, or what the connection did."""
145
+ seen = {id(error)}
146
+ cause = error.__cause__
147
+ while cause is not None and id(cause) not in seen:
148
+ seen.add(id(cause))
149
+ said = _server_said(cause)
150
+ if said is not None:
151
+ return said
152
+ if isinstance(cause, (OSError, TimeoutError)) or type(cause).__module__.startswith("httpx"):
153
+ reason = str(cause).strip()
154
+ return f"{type(cause).__name__}: {reason}" if reason else type(cause).__name__
155
+ cause = cause.__cause__
156
+ return None
157
+
158
+
159
+ def _server_said(error: BaseException) -> str | None:
160
+ """Return ``HTTP 404: the model does not exist — check ...`` for an error that carries a status, else ``None``."""
161
+ status = getattr(error, "status_code", None)
162
+ if isinstance(status, bool) or not isinstance(status, int):
163
+ return None
164
+ message = _message_of(error)
165
+ said = f"HTTP {status}: {message}" if message else f"HTTP {status}"
166
+ hint = HINTS.get(status)
167
+ return f"{said} \u2014 {hint}" if hint else said
168
+
169
+
170
+ def _message_of(error: BaseException) -> str:
171
+ """Return the sentence a server gave for a failure, whatever shape its error body had, cleaned for one line."""
172
+ message = _library_said(error)
173
+ if message:
174
+ return _one_line(message)
175
+ body = getattr(error, "body", None)
176
+ if isinstance(body, dict):
177
+ inner = body.get("error")
178
+ source = inner if isinstance(inner, dict) else body
179
+ found = source.get("message") if isinstance(source, dict) else None
180
+ message = found if isinstance(found, str) else ""
181
+ elif isinstance(body, str):
182
+ message = body
183
+ if not message:
184
+ text = str(getattr(error, "message", "") or "")
185
+ message = re.sub(r"^Error code: \d+\s*-?\s*", "", text)
186
+ if message.lstrip().startswith(("{", "[")):
187
+ message = "" # the dict printed back: nothing in it that was not already tried above
188
+ return _one_line(message)
189
+
190
+
191
+ def _library_said(error: BaseException) -> str:
192
+ """Return what the library already read out of the response, when it is its own typed HTTP error.
193
+
194
+ ``ProviderHTTPError`` carries the provider's explanation (``reason``), the start of the body when it gave none
195
+ (``preview``), and whether it was a web page; that is the same reading this module does of an SDK error, done
196
+ once where the response is, so it is taken as it is. A web page is named as one and not shown as markup.
197
+ """
198
+ reason, preview = getattr(error, "reason", None), getattr(error, "preview", None)
199
+ if getattr(error, "is_html", False) is True:
200
+ return (
201
+ f"a web page, not an API response \u2014 {preview}"
202
+ if isinstance(preview, str) and preview
203
+ else "a web page, not an API response"
204
+ )
205
+ for found in (reason, preview):
206
+ if isinstance(found, str) and found.strip():
207
+ return found
208
+ return ""
209
+
210
+
211
+ def _one_line(message: str) -> str:
212
+ """Return a server's words on one line, without markup or anything that looks like a key, and cut short."""
213
+ message = _TAGS.sub(" ", message)
214
+ message = _SECRET.sub("\u2026", " ".join(message.split()))
215
+ if len(message) > MAX_DETAIL:
216
+ message = message[: MAX_DETAIL - 1].rstrip() + "\u2026"
217
+ return message
zettcode/filelock.py ADDED
@@ -0,0 +1,125 @@
1
+ """An exclusive lock on a file, which the operating system lets go of when the holder dies.
2
+
3
+ It makes the session broker a singleton and marks a session as open in one terminal. Starting the broker is a
4
+ race — two sessions launched together both find nobody home and both start one — so the one that gets the lock
5
+ keeps running and the other exits. A lock on a file is what
6
+ makes that safe: unlike a pid written into a file, it cannot go stale. A holder that was killed
7
+ releases it with the rest of its descriptors, and the next one takes it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+ from typing import IO
16
+
17
+ #: How often a caller that is willing to wait looks at the lock again.
18
+ POLL_SECONDS = 0.02
19
+
20
+
21
+ class SingletonLock:
22
+ """An exclusive, non-blocking lock on one file, held until :meth:`release` or the process ends.
23
+
24
+ The platform's own call is imported where it is used: ``fcntl`` exists only on POSIX and
25
+ ``msvcrt`` only on Windows, and the module has to import on both.
26
+ """
27
+
28
+ def __init__(self, path: Path) -> None:
29
+ self.path = path
30
+ self._file: IO[bytes] | None = None
31
+
32
+ @property
33
+ def held(self) -> bool:
34
+ """Return whether this object holds the lock now."""
35
+ return self._file is not None
36
+
37
+ def acquire(self, wait: float = 0.0) -> bool:
38
+ """Take the lock if nobody holds it, and say whether it was taken.
39
+
40
+ Args:
41
+ wait: Seconds to keep trying when somebody holds it. A client that is only *looking* (see
42
+ :func:`is_held`) takes the lock for an instant, and a holder that arrives at that
43
+ instant must not conclude that another holder exists; a short wait tells the two apart.
44
+ """
45
+ deadline = time.monotonic() + max(0.0, wait)
46
+ while True:
47
+ if self._try():
48
+ return True
49
+ if time.monotonic() >= deadline:
50
+ return False
51
+ time.sleep(POLL_SECONDS)
52
+
53
+ def _try(self) -> bool:
54
+ if self._file is not None:
55
+ return True
56
+ self.path.parent.mkdir(parents=True, exist_ok=True)
57
+ handle = open(self.path, "a+b") # noqa: SIM115 - the handle is the lock, and outlives this call
58
+ try:
59
+ if os.name == "nt":
60
+ import msvcrt
61
+
62
+ handle.seek(0)
63
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) # type: ignore[attr-defined,unused-ignore]
64
+ else:
65
+ import fcntl
66
+
67
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
68
+ except OSError:
69
+ handle.close()
70
+ return False
71
+ self._file = handle
72
+ return True
73
+
74
+ def still_ours(self) -> bool:
75
+ """Return whether the file at this path is still the one this lock is held on.
76
+
77
+ Somebody who deletes the directory by hand removes the lock file out from under a running holder, and
78
+ the next one to start would create a new file and take a lock on *that*: two holders, each the only one
79
+ holding its own lock. A holder that checks now and then can see it happened and step aside. Where a file that is open cannot be deleted (Windows), it is always true.
80
+ """
81
+ handle = self._file
82
+ if handle is None:
83
+ return False
84
+ if os.name == "nt":
85
+ return True
86
+ try:
87
+ here, there = os.stat(self.path), os.fstat(handle.fileno())
88
+ except OSError:
89
+ return False
90
+ return (here.st_ino, here.st_dev) == (there.st_ino, there.st_dev)
91
+
92
+ def release(self) -> None:
93
+ """Let go of the lock; safe to call when it is not held, and safe to call twice."""
94
+ handle, self._file = self._file, None
95
+ if handle is None:
96
+ return
97
+ try:
98
+ if os.name == "nt":
99
+ import msvcrt
100
+
101
+ handle.seek(0)
102
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined,unused-ignore]
103
+ else:
104
+ import fcntl
105
+
106
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
107
+ except OSError:
108
+ pass
109
+ finally:
110
+ handle.close()
111
+
112
+
113
+ def is_held(path: Path) -> bool:
114
+ """Return whether some process holds the lock at ``path`` right now.
115
+
116
+ This is the one reliable way to ask whether a broker is alive: unlike a process id in a file (which
117
+ outlives the process, and may be reused by another) or a port number (which may belong to anything
118
+ by now), the lock is the operating system's own record, and it disappears with the holder however
119
+ the holder died. The answer is a snapshot, and the lock is taken for an instant to find it out.
120
+ """
121
+ probe = SingletonLock(path)
122
+ if probe.acquire():
123
+ probe.release()
124
+ return False
125
+ return True