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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""What a provider-run tool left behind: the rows for a hosted search, live and replayed.
|
|
2
|
+
|
|
3
|
+
A hosted tool is not called by the agent: the endpoint runs it, and the framework reports the call and its result as
|
|
4
|
+
events. Those events are not messages, so the store never sees them; but the provider's own blocks are kept as the
|
|
5
|
+
answer's opaque ``replay_blocks``, which the store writes and reads back. This module is the one place that turns
|
|
6
|
+
either of those into the words of a row, so a resumed session shows what the live one showed — except a hosted call
|
|
7
|
+
the provider closed without a result: the runtime strips that call's block so the next request is accepted, so the row
|
|
8
|
+
that explains it exists only in the live transcript.
|
|
9
|
+
|
|
10
|
+
The result of a search is a list of pages, each with a title, a URL and an opaque encrypted payload that must never be
|
|
11
|
+
put in front of the reader or sent back in a prompt as text: only the title and the URL are shown here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from zett_agent.model import NOT_REPORTED_STATUS
|
|
21
|
+
|
|
22
|
+
#: The most pages one row lists. A row is a note that a search happened, not the search's own page.
|
|
23
|
+
MAX_RESULTS = 5
|
|
24
|
+
|
|
25
|
+
#: Result blocks whose payload is the provider's own and is never shown as text.
|
|
26
|
+
_SEARCH_RESULT_TYPES = frozenset({"web_search_result", "search_result"})
|
|
27
|
+
|
|
28
|
+
#: Block types that record a hosted call rather than text.
|
|
29
|
+
_CALL_TYPES = frozenset({"server_tool_use"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ServerToolRun:
|
|
34
|
+
"""One hosted tool call as the transcript needs it.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
call_id: The provider's identifier for the call.
|
|
38
|
+
name: The tool's name, such as ``web_search``.
|
|
39
|
+
arguments: What it was asked, when the provider said; ``{"query": ...}`` for a search.
|
|
40
|
+
output: What it returned, as the provider reported it.
|
|
41
|
+
error: The provider's error code when the tool failed.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
call_id: str
|
|
45
|
+
name: str
|
|
46
|
+
arguments: Mapping[str, Any]
|
|
47
|
+
output: Any = None
|
|
48
|
+
error: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _results_in(output: Any) -> list[Mapping[str, Any]]:
|
|
52
|
+
"""Return the page blocks of a search result, wherever the provider put them.
|
|
53
|
+
|
|
54
|
+
The adapter hands a hosted tool's output over as the block's own ``content``: a list of pages for a search, with
|
|
55
|
+
the provider's encrypted payload beside each title and URL.
|
|
56
|
+
"""
|
|
57
|
+
match output:
|
|
58
|
+
case bool() | str() | int() | float() | None:
|
|
59
|
+
return []
|
|
60
|
+
case Mapping():
|
|
61
|
+
content = output.get("content")
|
|
62
|
+
if isinstance(content, Iterable) and not isinstance(content, str):
|
|
63
|
+
return [item for item in content if isinstance(item, Mapping)]
|
|
64
|
+
if output.get("type") in _SEARCH_RESULT_TYPES:
|
|
65
|
+
return [output]
|
|
66
|
+
return []
|
|
67
|
+
case Iterable():
|
|
68
|
+
return [item for item in output if isinstance(item, Mapping)]
|
|
69
|
+
case _:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe_search(output: Any) -> str:
|
|
74
|
+
"""Return a search result as the few lines a row shows: how many pages, and which.
|
|
75
|
+
|
|
76
|
+
Only a page's title and URL are shown; the encrypted payload the provider returns beside them is its own business
|
|
77
|
+
and is neither printed nor kept.
|
|
78
|
+
"""
|
|
79
|
+
pages = _results_in(output)
|
|
80
|
+
if not pages:
|
|
81
|
+
return "No pages"
|
|
82
|
+
lines = [f"{len(pages)} page{'s' if len(pages) != 1 else ''}"]
|
|
83
|
+
for page in pages[:MAX_RESULTS]:
|
|
84
|
+
title = page.get("title")
|
|
85
|
+
url = page.get("url")
|
|
86
|
+
named = title.strip() if isinstance(title, str) and title.strip() else ""
|
|
87
|
+
address = url.strip() if isinstance(url, str) and url.strip() else ""
|
|
88
|
+
lines.append(f"- {named} \u2014 {address}" if named and address else f"- {named or address}")
|
|
89
|
+
if len(pages) > MAX_RESULTS:
|
|
90
|
+
lines.append(f"\u2026 and {len(pages) - MAX_RESULTS} more")
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def not_reported(output: Any) -> bool:
|
|
95
|
+
"""Return whether a hosted call's output is the adapter's marker that no result was reported.
|
|
96
|
+
|
|
97
|
+
``zett-agent`` closes a call the provider left open with ``output={"status": NOT_REPORTED_STATUS}``. The status is
|
|
98
|
+
the adapter's own, never a provider result shape, so that one key is read as the marker here and in the event the
|
|
99
|
+
live row is closed with.
|
|
100
|
+
"""
|
|
101
|
+
return isinstance(output, Mapping) and output.get("status") == NOT_REPORTED_STATUS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def describe_output(run: ServerToolRun) -> str:
|
|
105
|
+
"""Return what one hosted tool said, in the words of its own kind.
|
|
106
|
+
|
|
107
|
+
A call the provider left open is closed by the runtime with ``output={"status": "not_reported"}``: the call
|
|
108
|
+
ended, but nothing about what it found was reported, so the row says that rather than claiming a result (or, as it
|
|
109
|
+
once did, showing the word ``Completed`` for a search that never answered).
|
|
110
|
+
"""
|
|
111
|
+
if run.error:
|
|
112
|
+
return run.error
|
|
113
|
+
if not_reported(run.output):
|
|
114
|
+
return "The provider did not report what this call returned"
|
|
115
|
+
if run.name == "web_search" or _results_in(run.output):
|
|
116
|
+
return describe_search(run.output)
|
|
117
|
+
# Another provider's hosted tool: its payload is already text or a plain structure.
|
|
118
|
+
return run.output if isinstance(run.output, str) else ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def runs_in_blocks(blocks: Iterable[Mapping[str, Any]]) -> list[ServerToolRun]:
|
|
122
|
+
"""Return the hosted tool calls one answer's ``replay_blocks`` record, results paired with their calls.
|
|
123
|
+
|
|
124
|
+
A block that is not a hosted call is skipped: a thinking block, or the answer's text. A call whose result is not
|
|
125
|
+
in the same answer keeps an empty output, which reads as a search that said nothing.
|
|
126
|
+
"""
|
|
127
|
+
calls: dict[str, ServerToolRun] = {}
|
|
128
|
+
order: list[str] = []
|
|
129
|
+
for block in blocks:
|
|
130
|
+
match block.get("type"):
|
|
131
|
+
case "server_tool_use":
|
|
132
|
+
call_id = block.get("id")
|
|
133
|
+
name = block.get("name")
|
|
134
|
+
if not isinstance(call_id, str) or not isinstance(name, str):
|
|
135
|
+
continue
|
|
136
|
+
call_input = block.get("input")
|
|
137
|
+
arguments = dict(call_input) if isinstance(call_input, Mapping) else {}
|
|
138
|
+
calls[call_id] = ServerToolRun(call_id, name, arguments)
|
|
139
|
+
order.append(call_id)
|
|
140
|
+
case "web_search_tool_result" | "server_tool_result" | "web_fetch_tool_result":
|
|
141
|
+
found = block.get("tool_use_id") or block.get("call_id")
|
|
142
|
+
if not isinstance(found, str) or found not in calls:
|
|
143
|
+
continue
|
|
144
|
+
error = block.get("error_code")
|
|
145
|
+
calls[found] = ServerToolRun(
|
|
146
|
+
found,
|
|
147
|
+
calls[found].name,
|
|
148
|
+
calls[found].arguments,
|
|
149
|
+
output=block,
|
|
150
|
+
error=str(error) if isinstance(error, str) and error else None,
|
|
151
|
+
)
|
|
152
|
+
return [calls[call_id] for call_id in order]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def answer_after_tools(blocks: Iterable[Mapping[str, Any]]) -> bool:
|
|
156
|
+
"""Return whether the answer's text came after its hosted tool, as the provider streamed them.
|
|
157
|
+
|
|
158
|
+
Anthropic runs a hosted search before it writes the answer, so the row belongs above it; a provider that writes
|
|
159
|
+
first and searches after (DeepSeek does, when it announces the search) has it below. The blocks record which.
|
|
160
|
+
"""
|
|
161
|
+
first_tool: int | None = None
|
|
162
|
+
for index, block in enumerate(blocks):
|
|
163
|
+
kind = block.get("type")
|
|
164
|
+
if first_tool is None and kind in _CALL_TYPES:
|
|
165
|
+
first_tool = index
|
|
166
|
+
elif kind == "text" and isinstance(block.get("text"), str) and block["text"].strip():
|
|
167
|
+
return first_tool is not None
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = [
|
|
172
|
+
"MAX_RESULTS",
|
|
173
|
+
"ServerToolRun",
|
|
174
|
+
"answer_after_tools",
|
|
175
|
+
"describe_output",
|
|
176
|
+
"describe_search",
|
|
177
|
+
"not_reported",
|
|
178
|
+
"runs_in_blocks",
|
|
179
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Session storage: the conversation log, its metadata, and the agent plugin.
|
|
2
|
+
|
|
3
|
+
One folder per workspace, one folder per session, and everything about a session in its own::
|
|
4
|
+
|
|
5
|
+
~/.zettcode/sessions/<workspace path, levels joined with ->/
|
|
6
|
+
├── workspace.txt <- the path the folder belongs to (see :mod:`layout`)
|
|
7
|
+
├── metadata.jsonl <- the old index; read for sessions that predate their own file
|
|
8
|
+
├── usage.json <- what ``/usage`` has counted so far, so it reads only what is new (see :mod:`ledger`)
|
|
9
|
+
├── sessions.json <- each session's entry count and token totals, so ``/resume`` reads no conversation
|
|
10
|
+
└── <session-id>/
|
|
11
|
+
├── data.jsonl <- the append-only message tree
|
|
12
|
+
├── metadata.json <- title and times: written on creation and rename only
|
|
13
|
+
├── session.lock <- held by the process that has the session open (see :mod:`locks`)
|
|
14
|
+
└── tool-output/ <- long output any tool produced, kept for the model to read
|
|
15
|
+
|
|
16
|
+
:mod:`records` owns the conversation's record types and the parsed read model,
|
|
17
|
+
:mod:`metadata` owns the per-session file and the old log, and :mod:`store` puts them
|
|
18
|
+
behind one API: the persistence mixin and the :class:`SessionStore` plugin. :mod:`layout` names the
|
|
19
|
+
workspace folder, and renames one that an older version named with base64.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .layout import claim_workspace, find_workspace, legacy_workspace_key, open_workspace, workspace_key
|
|
23
|
+
from .ledger import SessionStats
|
|
24
|
+
from .locks import LOCK_FILE, LOCKED_MESSAGE, NoLocks, SessionGuard, SessionLocked, SessionLocks, prune_empty
|
|
25
|
+
from .metadata import MAX_TITLE, SessionInfo
|
|
26
|
+
from .records import STORE_FILE, MessageLine, Session, now
|
|
27
|
+
from .store import SessionPersistenceMixin, SessionStore
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"LOCKED_MESSAGE",
|
|
31
|
+
"LOCK_FILE",
|
|
32
|
+
"MAX_TITLE",
|
|
33
|
+
"MessageLine",
|
|
34
|
+
"STORE_FILE",
|
|
35
|
+
"NoLocks",
|
|
36
|
+
"Session",
|
|
37
|
+
"SessionGuard",
|
|
38
|
+
"SessionInfo",
|
|
39
|
+
"SessionLocked",
|
|
40
|
+
"SessionLocks",
|
|
41
|
+
"SessionPersistenceMixin",
|
|
42
|
+
"SessionStats",
|
|
43
|
+
"SessionStore",
|
|
44
|
+
"claim_workspace",
|
|
45
|
+
"find_workspace",
|
|
46
|
+
"legacy_workspace_key",
|
|
47
|
+
"now",
|
|
48
|
+
"open_workspace",
|
|
49
|
+
"prune_empty",
|
|
50
|
+
"workspace_key",
|
|
51
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Which folder under the store root holds one workspace's sessions.
|
|
2
|
+
|
|
3
|
+
A workspace's folder is named after its path, the levels joined with ``-``::
|
|
4
|
+
|
|
5
|
+
/Users/me/projects/api -> ~/.zettcode/sessions/-Users-me-projects-api/
|
|
6
|
+
|
|
7
|
+
so ``ls ~/.zettcode/sessions`` says which folder is which project. Older versions named it with the
|
|
8
|
+
URL-safe base64 of the path (``L1VzZXJzL21l...``); a store written that way is **moved** to the new
|
|
9
|
+
name the first time it is opened, with one rename, which is atomic: a reader sees the folder under
|
|
10
|
+
the old name or under the new one and never half of each.
|
|
11
|
+
|
|
12
|
+
The move is a courtesy, not a requirement, and it is skipped whenever it is not safe:
|
|
13
|
+
|
|
14
|
+
* a session in the folder is open in another process (its ``session.lock`` is held), or the system
|
|
15
|
+
refuses the rename because a file in the folder is open (Windows does), and
|
|
16
|
+
* the rename fails for any other reason.
|
|
17
|
+
|
|
18
|
+
In every one of those the workspace **keeps its old name** and is used from there, exactly as before;
|
|
19
|
+
the next start tries again. Nothing is copied, so there is never a second copy to reconcile.
|
|
20
|
+
|
|
21
|
+
Joining with ``-`` is not injective (``/a-b/c`` and ``/a/b-c`` both read ``-a-b-c``), so each folder
|
|
22
|
+
records the path it belongs to in :data:`OWNER_FILE`. A folder that names a different path is not
|
|
23
|
+
shared: the workspace that finds it taken falls back to its base64 name, which is unique.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from zettcode.filelock import is_held
|
|
35
|
+
|
|
36
|
+
from .locks import LOCK_FILE
|
|
37
|
+
|
|
38
|
+
#: Longest workspace folder name; a longer path keeps a hash instead of its tail.
|
|
39
|
+
MAX_KEY_CHARS = 120
|
|
40
|
+
|
|
41
|
+
#: The file in a workspace folder that says which path the folder belongs to.
|
|
42
|
+
OWNER_FILE = "workspace.txt"
|
|
43
|
+
|
|
44
|
+
#: Everything a folder name keeps as it is; any other character becomes ``-``.
|
|
45
|
+
_UNSAFE = re.compile(r"[^\w.-]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve(workspace: str | Path) -> Path:
|
|
49
|
+
return Path(workspace).expanduser().resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def legacy_workspace_key(workspace: str | Path) -> str:
|
|
53
|
+
"""Return the name older versions gave the workspace's folder: the base64 of its path.
|
|
54
|
+
|
|
55
|
+
The URL-safe base64 of the resolved absolute path without padding. A path too long for one file
|
|
56
|
+
name keeps a short digest instead of its tail.
|
|
57
|
+
"""
|
|
58
|
+
encoded = base64.urlsafe_b64encode(str(_resolve(workspace)).encode()).decode().rstrip("=")
|
|
59
|
+
if len(encoded) <= MAX_KEY_CHARS:
|
|
60
|
+
return encoded
|
|
61
|
+
digest = hashlib.sha256(encoded.encode()).hexdigest()[:12]
|
|
62
|
+
return f"{encoded[: MAX_KEY_CHARS - 13]}-{digest}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def workspace_key(workspace: str | Path) -> str:
|
|
66
|
+
"""Return the folder name that groups one workspace's sessions: its path with ``-`` between the levels.
|
|
67
|
+
|
|
68
|
+
Every character that is not a letter, a digit, ``_``, ``.`` or ``-`` becomes ``-`` (so the path
|
|
69
|
+
separators, a drive's colon and a space all do), which keeps the name one folder and legal on every
|
|
70
|
+
platform. A name too long for one file keeps a short digest of the whole instead of its tail.
|
|
71
|
+
"""
|
|
72
|
+
text = _UNSAFE.sub("-", str(_resolve(workspace)))
|
|
73
|
+
if len(text) <= MAX_KEY_CHARS:
|
|
74
|
+
return text
|
|
75
|
+
digest = hashlib.sha256(str(_resolve(workspace)).encode()).hexdigest()[:12]
|
|
76
|
+
return f"{text[: MAX_KEY_CHARS - 13]}-{digest}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _owner(folder: Path) -> str | None:
|
|
80
|
+
"""Return the path a folder says it belongs to, or ``None`` when it does not say."""
|
|
81
|
+
try:
|
|
82
|
+
return (folder / OWNER_FILE).read_text(encoding="utf-8").strip() or None
|
|
83
|
+
except (OSError, UnicodeError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_ours(folder: Path, path: Path) -> bool:
|
|
88
|
+
"""Say whether a folder under the readable name may hold this workspace's sessions.
|
|
89
|
+
|
|
90
|
+
A folder that records no owner is taken as ours: it was made by a run that stopped before it wrote
|
|
91
|
+
the note, and only a path joined to the same name could be the other claimant.
|
|
92
|
+
"""
|
|
93
|
+
owner = _owner(folder)
|
|
94
|
+
return owner is None or owner == str(path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _claim(folder: Path, path: Path) -> None:
|
|
98
|
+
"""Write the note that says the folder is this path's, if it has none. Never fails."""
|
|
99
|
+
if _owner(folder) is not None:
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
(folder / OWNER_FILE).write_text(f"{path}\n", encoding="utf-8")
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _in_use(folder: Path) -> bool:
|
|
108
|
+
"""Say whether a process has a session in the folder open (its lock is held)."""
|
|
109
|
+
try:
|
|
110
|
+
locks = sorted(folder.glob(f"*/{LOCK_FILE}"))
|
|
111
|
+
except OSError:
|
|
112
|
+
return True
|
|
113
|
+
return any(is_held(lock) for lock in locks)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _choose(root: Path, path: Path, *, move: bool) -> Path:
|
|
117
|
+
"""Return the folder to use, moving the old one to the new name first when ``move`` allows."""
|
|
118
|
+
readable = root / workspace_key(path)
|
|
119
|
+
legacy = root / legacy_workspace_key(path)
|
|
120
|
+
if readable.is_dir():
|
|
121
|
+
return readable if _is_ours(readable, path) else legacy
|
|
122
|
+
if legacy == readable or not legacy.is_dir():
|
|
123
|
+
return readable
|
|
124
|
+
if not move or _in_use(legacy):
|
|
125
|
+
return legacy
|
|
126
|
+
try:
|
|
127
|
+
os.rename(legacy, readable)
|
|
128
|
+
except OSError:
|
|
129
|
+
# Another process may have moved it a moment ago; otherwise the system said no (a file is
|
|
130
|
+
# open), and the workspace stays where it was.
|
|
131
|
+
return readable if readable.is_dir() and _is_ours(readable, path) else legacy
|
|
132
|
+
_claim(readable, path)
|
|
133
|
+
return readable
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_workspace(root: str | Path, workspace: str | Path) -> Path:
|
|
137
|
+
"""Return the folder a workspace's sessions are in now, changing nothing on disk.
|
|
138
|
+
|
|
139
|
+
For a reader that must not create anything: it names the readable folder, the old one when that
|
|
140
|
+
is where the sessions still are, and the readable one when there are none yet.
|
|
141
|
+
"""
|
|
142
|
+
return _choose(Path(root), _resolve(workspace), move=False)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def open_workspace(root: str | Path, workspace: str | Path) -> Path:
|
|
146
|
+
"""Return the folder a workspace's sessions are written in, moving an old one to the readable name.
|
|
147
|
+
|
|
148
|
+
An old base64 folder is renamed to the readable name when nothing has it open, and stays where it is
|
|
149
|
+
when something does (see the module's notes). **Nothing is created**: the folder may not exist yet, and
|
|
150
|
+
a project that is opened and never used leaves no trace in the store. :func:`claim_workspace` makes it
|
|
151
|
+
when the first record is written.
|
|
152
|
+
"""
|
|
153
|
+
return _choose(Path(root), _resolve(workspace), move=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def claim_workspace(folder: Path, workspace: str | Path) -> None:
|
|
157
|
+
"""Create a workspace's folder if it is not there, and write the note saying whose it is."""
|
|
158
|
+
path = _resolve(workspace)
|
|
159
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
if folder.name == workspace_key(path):
|
|
161
|
+
_claim(folder, path)
|