dkitchen 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dkitchen-0.2.2/src/dkitchen.egg-info → dkitchen-0.3.0}/PKG-INFO +2 -1
- {dkitchen-0.2.2 → dkitchen-0.3.0}/README.md +1 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/pyproject.toml +1 -1
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/config.py +40 -1
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/ding.py +74 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0/src/dkitchen.egg-info}/PKG-INFO +2 -1
- {dkitchen-0.2.2 → dkitchen-0.3.0}/tests/test_codex_session.py +399 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/tests/test_kitchen.py +22 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/LICENSE +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/setup.cfg +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/__init__.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/orchestrate.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/refs.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen/watch.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen.egg-info/SOURCES.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen.egg-info/dependency_links.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen.egg-info/entry_points.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen.egg-info/requires.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/src/dkitchen.egg-info/top_level.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/tests/test_records.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.0}/tests/test_reference_scripts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dkitchen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
|
|
5
5
|
Author: 917Dhj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
120
120
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
123
124
|
ding board # who is working on what, in this repo
|
|
124
125
|
ding send '#42' --from orchestrator --message-file note.md
|
|
125
126
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -88,6 +88,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
88
88
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
91
92
|
ding board # who is working on what, in this repo
|
|
92
93
|
ding send '#42' --from orchestrator --message-file note.md
|
|
93
94
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dkitchen"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,12 +27,16 @@ Every key is optional. The defaults match the English templates in kitchen/:
|
|
|
27
27
|
[log]
|
|
28
28
|
issue = 1 # the pinned orchestrator log ticket
|
|
29
29
|
pending = "Waiting for the user"
|
|
30
|
+
|
|
31
|
+
[sessions]
|
|
32
|
+
name = "【#{issue}】{name}" # `ding new --issue N --name NAME`; also {repo}
|
|
30
33
|
"""
|
|
31
34
|
from __future__ import annotations
|
|
32
35
|
|
|
33
36
|
from dataclasses import dataclass, fields as dataclass_fields, replace
|
|
34
37
|
from pathlib import Path
|
|
35
38
|
import re
|
|
39
|
+
import string
|
|
36
40
|
import subprocess
|
|
37
41
|
import sys
|
|
38
42
|
|
|
@@ -81,6 +85,38 @@ class Log:
|
|
|
81
85
|
pending: str = "Waiting for the user"
|
|
82
86
|
|
|
83
87
|
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Sessions:
|
|
90
|
+
name: str = "【#{issue}】{name}"
|
|
91
|
+
|
|
92
|
+
def session_name(self, *, issue: int, name: str, repo: str) -> str:
|
|
93
|
+
return self.name.format(issue=issue, name=name, repo=repo)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
SESSION_FIELDS = ("issue", "name", "repo")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def session_template(value: object) -> str:
|
|
100
|
+
"""A session name template using only SESSION_FIELDS, checked when the config is read."""
|
|
101
|
+
if not isinstance(value, str) or not value.strip():
|
|
102
|
+
raise ValueError(f"{FILE}: [sessions] name must be a nonempty string")
|
|
103
|
+
allowed = ", ".join(f"{{{field}}}" for field in SESSION_FIELDS)
|
|
104
|
+
try:
|
|
105
|
+
for _, field, spec, conversion in string.Formatter().parse(value):
|
|
106
|
+
if field is None:
|
|
107
|
+
continue
|
|
108
|
+
if field not in SESSION_FIELDS:
|
|
109
|
+
raise ValueError(f"{FILE}: [sessions] name uses {{{field}}}; use only {allowed}")
|
|
110
|
+
if conversion or "{" in spec:
|
|
111
|
+
raise ValueError(f"{FILE}: [sessions] name: write {{{field}}} without a conversion or nested field")
|
|
112
|
+
Sessions(value).session_name(issue=1, name="name", repo="owner/name")
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
if str(exc).startswith(FILE):
|
|
115
|
+
raise
|
|
116
|
+
raise ValueError(f"{FILE}: [sessions] name is not a valid template: {exc}") from exc
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
84
120
|
@dataclass(frozen=True)
|
|
85
121
|
class Config:
|
|
86
122
|
root: Path
|
|
@@ -89,6 +125,7 @@ class Config:
|
|
|
89
125
|
status: Status = Status()
|
|
90
126
|
labels: Labels = Labels()
|
|
91
127
|
log: Log = Log()
|
|
128
|
+
sessions: Sessions = Sessions()
|
|
92
129
|
|
|
93
130
|
def repository(self) -> str:
|
|
94
131
|
"""The configured repository, or the one gh sees from the repository root."""
|
|
@@ -116,6 +153,8 @@ def section(cls, values: object, name: str):
|
|
|
116
153
|
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
|
|
117
154
|
raise ValueError(f"{FILE}: [{name}] fields must be a nonempty list of field names")
|
|
118
155
|
value = tuple(item.strip() for item in value)
|
|
156
|
+
elif key == "name" and cls is Sessions:
|
|
157
|
+
value = session_template(value)
|
|
119
158
|
elif key == "issue":
|
|
120
159
|
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
121
160
|
raise ValueError(f"{FILE}: [{name}] issue must be a positive issue number")
|
|
@@ -141,7 +180,7 @@ def load(cwd: Path | None = None) -> Config:
|
|
|
141
180
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
142
181
|
except tomllib.TOMLDecodeError as exc:
|
|
143
182
|
raise ValueError(f"{path}: {exc}") from exc
|
|
144
|
-
sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log}
|
|
183
|
+
sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log, "sessions": Sessions}
|
|
145
184
|
unknown = sorted(set(data) - {"repo", *sections})
|
|
146
185
|
if unknown:
|
|
147
186
|
raise ValueError(f"{FILE}: unknown keys: {', '.join(unknown)}")
|
|
@@ -324,6 +324,11 @@ def github_repository() -> str:
|
|
|
324
324
|
return repo
|
|
325
325
|
|
|
326
326
|
|
|
327
|
+
def ownership_note(repo: str, number: int, thread_id: str) -> str:
|
|
328
|
+
return (f"You own ticket {repo}#{number}. Before anything else, post a comment on it "
|
|
329
|
+
f"with the line `Codex owner: {thread_id}`.\n\n")
|
|
330
|
+
|
|
331
|
+
|
|
327
332
|
def session_references(body: str | None, label: str = "session") -> list[str]:
|
|
328
333
|
if body is None:
|
|
329
334
|
return []
|
|
@@ -1162,6 +1167,46 @@ def message_text(message: str | None, message_file: str | None, sender: str | No
|
|
|
1162
1167
|
return message
|
|
1163
1168
|
|
|
1164
1169
|
|
|
1170
|
+
def new_task(args) -> int:
|
|
1171
|
+
thread_id = None
|
|
1172
|
+
stage = "validation"
|
|
1173
|
+
try:
|
|
1174
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
1175
|
+
if cwd is None or not cwd.is_dir():
|
|
1176
|
+
raise ValueError("--cwd must name an existing directory.")
|
|
1177
|
+
message = message_text(args.message, args.message_file, None)
|
|
1178
|
+
if not message.strip():
|
|
1179
|
+
raise ValueError("The first message (--message or --message-file) must not be empty.")
|
|
1180
|
+
stage = "connection"
|
|
1181
|
+
with app_server(socket_path(args.socket)) as call:
|
|
1182
|
+
stage = "thread/start"
|
|
1183
|
+
thread = call(stage, {"cwd": str(cwd), "ephemeral": False}).get("thread")
|
|
1184
|
+
try:
|
|
1185
|
+
thread_id = str(UUID(thread["id"]))
|
|
1186
|
+
except (KeyError, ValueError, TypeError, AttributeError) as exc:
|
|
1187
|
+
raise ValueError("Malformed App Server thread ID.") from exc
|
|
1188
|
+
stage = "thread/name/set"
|
|
1189
|
+
call(stage, {"threadId": thread_id, "name": args.name})
|
|
1190
|
+
if args.issue is not None:
|
|
1191
|
+
message = ownership_note(args.repo, args.issue, thread_id) + message
|
|
1192
|
+
stage = "turn/start"
|
|
1193
|
+
turn = call(stage, {"threadId": thread_id,
|
|
1194
|
+
"input": [{"type": "text", "text": message}]}).get("turn")
|
|
1195
|
+
if (not isinstance(turn, dict) or not isinstance(turn.get("id"), str)
|
|
1196
|
+
or not turn["id"] or turn.get("status") not in ("inProgress", "completed")):
|
|
1197
|
+
raise ValueError("App Server did not return an accepted turn.")
|
|
1198
|
+
print(thread_id)
|
|
1199
|
+
return 0
|
|
1200
|
+
except (OSError, ValueError, RuntimeError, KeyboardInterrupt) as exc:
|
|
1201
|
+
recovery = ""
|
|
1202
|
+
if thread_id is not None:
|
|
1203
|
+
recovery = f"; session {thread_id}; inspect Desktop or ding board before retrying"
|
|
1204
|
+
elif stage == "thread/start":
|
|
1205
|
+
recovery = "; session creation is unconfirmed; inspect Desktop or ding board before retrying"
|
|
1206
|
+
print(f"ding: new {stage}: {str(exc) or type(exc).__name__}{recovery}", file=sys.stderr)
|
|
1207
|
+
return 130 if isinstance(exc, KeyboardInterrupt) else 1
|
|
1208
|
+
|
|
1209
|
+
|
|
1165
1210
|
def send_task(args) -> int:
|
|
1166
1211
|
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
|
|
1167
1212
|
record = state / "ding/sent.jsonl"
|
|
@@ -1324,6 +1369,16 @@ def main(argv=None) -> int:
|
|
|
1324
1369
|
installed = "unknown"
|
|
1325
1370
|
parser.add_argument("--version", action="version", version=f"ding {installed}")
|
|
1326
1371
|
commands = parser.add_subparsers(dest="action", required=True)
|
|
1372
|
+
sub = commands.add_parser("new", help="Create a saved Codex session and start its first message")
|
|
1373
|
+
sub.add_argument("--cwd", required=True, help="Existing working directory for the session")
|
|
1374
|
+
sub.add_argument("--name", required=True, help="Session title; with --issue, filled into the "
|
|
1375
|
+
"[sessions] name template")
|
|
1376
|
+
sub.add_argument("--issue", type=int, help="Ticket this session owns: formats --name with the [sessions] "
|
|
1377
|
+
"name template and asks the session to post its Codex owner line")
|
|
1378
|
+
message = sub.add_mutually_exclusive_group(required=True)
|
|
1379
|
+
message.add_argument("--message", help="First message text, or '-' to read stdin")
|
|
1380
|
+
message.add_argument("--message-file", help="UTF-8 file holding the first message")
|
|
1381
|
+
sub.add_argument("--socket", help="Existing local App Server Unix socket path")
|
|
1327
1382
|
board = commands.add_parser("board", help="List sessions, run states, and local unwrapped tasks")
|
|
1328
1383
|
board.add_argument("--all", action="store_true", help="Include all non-archived sessions")
|
|
1329
1384
|
board.add_argument("--status", action="store_true", help="Show the latest executor comment status block")
|
|
@@ -1337,6 +1392,7 @@ def main(argv=None) -> int:
|
|
|
1337
1392
|
help="Minimum lifetime CPU percent per core outside tmux panes (default: 10)")
|
|
1338
1393
|
for action, summary in (("read", "Print a session's latest final reply"),
|
|
1339
1394
|
("status", "Print a session's App Server status as JSON"),
|
|
1395
|
+
("rename", "Change a session's name without starting a model turn"),
|
|
1340
1396
|
("send", "Send a message to a session"),
|
|
1341
1397
|
("run", "Run a command and message a session when it exits"),
|
|
1342
1398
|
("launch", "Run a command in a new tmux session and wake this Codex session when it exits")):
|
|
@@ -1350,6 +1406,8 @@ def main(argv=None) -> int:
|
|
|
1350
1406
|
sub.add_argument("--socket", help="Existing local App Server Unix socket path")
|
|
1351
1407
|
if action == "read":
|
|
1352
1408
|
sub.add_argument("--head", action="store_true", help="Print only the first paragraph of the final reply")
|
|
1409
|
+
if action == "rename":
|
|
1410
|
+
sub.add_argument("--name", required=True, help="New session name, preserved exactly as supplied")
|
|
1353
1411
|
if action in {"send", "run", "launch"}:
|
|
1354
1412
|
message = sub.add_mutually_exclusive_group(required=action != "launch")
|
|
1355
1413
|
message.add_argument("--message", help="Message text, or '-' to read stdin")
|
|
@@ -1402,11 +1460,22 @@ def main(argv=None) -> int:
|
|
|
1402
1460
|
command = argv[separator + 1:]
|
|
1403
1461
|
argv = argv[:separator]
|
|
1404
1462
|
args = parser.parse_args(argv)
|
|
1463
|
+
if args.action == "rename" and not args.name.strip():
|
|
1464
|
+
parser.error("--name must contain non-whitespace text")
|
|
1405
1465
|
if getattr(args, "issue", None) is not None and args.issue <= 0:
|
|
1406
1466
|
parser.error("--issue must be a positive issue number")
|
|
1407
1467
|
if args.action in {"run", "launch"} and not command:
|
|
1408
1468
|
parser.error(f"{args.action} requires a task command after --")
|
|
1409
1469
|
try:
|
|
1470
|
+
if args.action == "new":
|
|
1471
|
+
if not args.name.strip():
|
|
1472
|
+
parser.error("--name must contain non-whitespace text")
|
|
1473
|
+
if args.issue is not None and args.cwd and Path(args.cwd).expanduser().is_dir():
|
|
1474
|
+
from dkitchen import config
|
|
1475
|
+
settings = config.load(Path(args.cwd).expanduser().resolve())
|
|
1476
|
+
args.repo = settings.repository()
|
|
1477
|
+
args.name = settings.sessions.session_name(issue=args.issue, name=args.name, repo=args.repo)
|
|
1478
|
+
return new_task(args)
|
|
1410
1479
|
if args.action == "check-refs":
|
|
1411
1480
|
from dkitchen import refs
|
|
1412
1481
|
return refs.check_refs(args)
|
|
@@ -1442,6 +1511,11 @@ def main(argv=None) -> int:
|
|
|
1442
1511
|
if args.action == "run":
|
|
1443
1512
|
return run_task(args, command)
|
|
1444
1513
|
path = socket_path(args.socket)
|
|
1514
|
+
if args.action == "rename":
|
|
1515
|
+
with app_server(path) as call:
|
|
1516
|
+
call("thread/name/set", {"threadId": args.thread_id, "name": args.name})
|
|
1517
|
+
print(args.thread_id)
|
|
1518
|
+
return 0
|
|
1445
1519
|
result = query(args.thread_id, path, status_only=args.action == "status")
|
|
1446
1520
|
if args.action == "read" and args.head:
|
|
1447
1521
|
result = reply_head(result)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dkitchen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
|
|
5
5
|
Author: 917Dhj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
120
120
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
123
124
|
ding board # who is working on what, in this repo
|
|
124
125
|
ding send '#42' --from orchestrator --message-file note.md
|
|
125
126
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -252,6 +252,310 @@ def test_status_cli(capsys, monkeypatch):
|
|
|
252
252
|
assert json.loads(capsys.readouterr().out) == {"type": "active", "activeFlags": []}
|
|
253
253
|
|
|
254
254
|
|
|
255
|
+
@pytest.mark.parametrize("missing", ["--cwd", "--name", "--message"])
|
|
256
|
+
def test_new_requires_options(tmp_path, monkeypatch, capsys, missing):
|
|
257
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
258
|
+
options = {"--cwd": str(tmp_path), "--name": "Build", "--message": "Start"}
|
|
259
|
+
argv = ["new"]
|
|
260
|
+
for flag, value in options.items():
|
|
261
|
+
if flag != missing:
|
|
262
|
+
argv.extend([flag, value])
|
|
263
|
+
with pytest.raises(SystemExit) as exc:
|
|
264
|
+
ding.main(argv)
|
|
265
|
+
assert exc.value.code == 2
|
|
266
|
+
assert missing in capsys.readouterr().err
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_new_rejects_message_and_message_file(tmp_path, monkeypatch, capsys):
|
|
270
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
271
|
+
note = tmp_path / "note.md"
|
|
272
|
+
note.write_text("Start", encoding="utf-8")
|
|
273
|
+
with pytest.raises(SystemExit) as exc:
|
|
274
|
+
ding.main(["new", "--cwd", str(tmp_path), "--name", "Build",
|
|
275
|
+
"--message", "Start", "--message-file", str(note)])
|
|
276
|
+
assert exc.value.code == 2
|
|
277
|
+
assert "not allowed with argument" in capsys.readouterr().err
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
|
|
281
|
+
def test_new_rejects_blank_name_like_rename(tmp_path, monkeypatch, capsys, name):
|
|
282
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("blank name must not look up"))
|
|
283
|
+
with pytest.raises(SystemExit) as exc:
|
|
284
|
+
ding.main(["new", "--cwd", str(tmp_path), "--name", name, "--message", "Start"])
|
|
285
|
+
assert exc.value.code == 2
|
|
286
|
+
captured = capsys.readouterr()
|
|
287
|
+
assert captured.out == ""
|
|
288
|
+
assert "--name must contain non-whitespace text" in captured.err
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.parametrize("invalid", ["missing-dir", "file", "empty-cwd",
|
|
292
|
+
"blank-message", "empty-message", "blank-stdin", "empty-stdin",
|
|
293
|
+
"blank-file", "empty-file", "missing-file"])
|
|
294
|
+
def test_new_validates_before_socket_lookup(tmp_path, monkeypatch, capsys, invalid):
|
|
295
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must validate before socket lookup"))
|
|
296
|
+
monkeypatch.chdir(tmp_path)
|
|
297
|
+
cwd, message_option = str(tmp_path), ["--message", "Start"]
|
|
298
|
+
if invalid == "missing-dir":
|
|
299
|
+
cwd = str(tmp_path / "missing")
|
|
300
|
+
elif invalid == "file":
|
|
301
|
+
cwd = str(tmp_path / "file")
|
|
302
|
+
Path(cwd).touch()
|
|
303
|
+
elif invalid == "empty-cwd":
|
|
304
|
+
cwd = ""
|
|
305
|
+
else:
|
|
306
|
+
text = " \t\r\n" if invalid.startswith("blank") else ""
|
|
307
|
+
if invalid.endswith("stdin"):
|
|
308
|
+
monkeypatch.setattr(ding.sys, "stdin", io.StringIO(text))
|
|
309
|
+
message_option = ["--message", "-"]
|
|
310
|
+
elif invalid.endswith("file"):
|
|
311
|
+
note = tmp_path / "note.md"
|
|
312
|
+
if invalid != "missing-file":
|
|
313
|
+
note.write_text(text, encoding="utf-8")
|
|
314
|
+
message_option = ["--message-file", str(note)]
|
|
315
|
+
else:
|
|
316
|
+
message_option = ["--message", text]
|
|
317
|
+
assert ding.main(["new", "--cwd", cwd, "--name", "Build", *message_option]) == 1
|
|
318
|
+
captured = capsys.readouterr()
|
|
319
|
+
assert captured.out == ""
|
|
320
|
+
assert "new validation" in captured.err
|
|
321
|
+
assert ("--cwd" if invalid in {"missing-dir", "file", "empty-cwd"} else
|
|
322
|
+
"note.md" if invalid == "missing-file" else "--message") in captured.err
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def ticket_repo(tmp_path, template=None):
|
|
326
|
+
text = 'repo = "example/project"\n'
|
|
327
|
+
if template is not None:
|
|
328
|
+
text += f"[sessions]\nname = {json.dumps(template)}\n"
|
|
329
|
+
(tmp_path / ".dkitchen.toml").write_text(text, encoding="utf-8")
|
|
330
|
+
return tmp_path
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@pytest.mark.parametrize("template, expected", [
|
|
334
|
+
(None, "【#57】Ablation {seed}"), ("{repo}#{issue:04d} {name}", "example/project#0057 Ablation {seed}"),
|
|
335
|
+
])
|
|
336
|
+
def test_new_issue_formats_name_and_asks_for_owner_line(tmp_path, monkeypatch, capsys, template, expected):
|
|
337
|
+
cwd = ticket_repo(tmp_path, template)
|
|
338
|
+
requests = []
|
|
339
|
+
|
|
340
|
+
def call(method, params):
|
|
341
|
+
requests.append((method, params))
|
|
342
|
+
return {"thread/start": {"thread": {"id": THREAD_ID}}, "thread/name/set": {},
|
|
343
|
+
"turn/start": {"turn": {"id": "turn-1", "status": "inProgress"}}}[method]
|
|
344
|
+
|
|
345
|
+
for function in ("gh_json", "gh_records", "github_repository"):
|
|
346
|
+
monkeypatch.setattr(ding, function, lambda *a: pytest.fail("must not read GitHub"))
|
|
347
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
348
|
+
assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Ablation {seed}",
|
|
349
|
+
"--message", "Brief", "--socket", "/test.sock"]) == 0
|
|
350
|
+
assert requests[1] == ("thread/name/set", {"threadId": THREAD_ID, "name": expected})
|
|
351
|
+
assert requests[2][1]["input"] == [{"type": "text", "text":
|
|
352
|
+
f"You own ticket example/project#57. Before anything else, post a comment on it "
|
|
353
|
+
f"with the line `Codex owner: {THREAD_ID}`.\n\nBrief"}]
|
|
354
|
+
assert capsys.readouterr().out == THREAD_ID + "\n"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_new_issue_still_needs_a_name(tmp_path, monkeypatch, capsys):
|
|
358
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
359
|
+
with pytest.raises(SystemExit) as exc:
|
|
360
|
+
ding.main(["new", "--cwd", str(ticket_repo(tmp_path)), "--issue", "57", "--message", "Start"])
|
|
361
|
+
assert exc.value.code == 2
|
|
362
|
+
assert "--name" in capsys.readouterr().err
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@pytest.mark.parametrize("issue", ["0", "-3"])
|
|
366
|
+
def test_new_issue_must_be_positive(tmp_path, monkeypatch, capsys, issue):
|
|
367
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
368
|
+
with pytest.raises(SystemExit) as exc:
|
|
369
|
+
ding.main(["new", "--cwd", str(tmp_path), "--issue", issue, "--name", "Build", "--message", "Start"])
|
|
370
|
+
assert exc.value.code == 2
|
|
371
|
+
assert "positive issue number" in capsys.readouterr().err
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_new_issue_with_blank_name_is_refused_before_the_template(tmp_path, monkeypatch, capsys):
|
|
375
|
+
cwd = ticket_repo(tmp_path, "#{issue} {name}")
|
|
376
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
377
|
+
with pytest.raises(SystemExit) as exc:
|
|
378
|
+
ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", " \t", "--message", "Start"])
|
|
379
|
+
assert exc.value.code == 2
|
|
380
|
+
assert "--name must contain non-whitespace text" in capsys.readouterr().err
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pytest.mark.parametrize("failure", ["template", "repository"])
|
|
384
|
+
def test_new_issue_config_failure_creates_nothing(tmp_path, monkeypatch, capsys, failure):
|
|
385
|
+
if failure == "template":
|
|
386
|
+
cwd = ticket_repo(tmp_path, "{title}")
|
|
387
|
+
else:
|
|
388
|
+
cwd = tmp_path
|
|
389
|
+
real_run = subprocess.run
|
|
390
|
+
|
|
391
|
+
def gh_repo_view(argv, *args, **kwargs):
|
|
392
|
+
if argv[:3] == ["gh", "repo", "view"]:
|
|
393
|
+
return subprocess.CompletedProcess(argv, 1, "", "no git remotes found")
|
|
394
|
+
return real_run(argv, *args, **kwargs)
|
|
395
|
+
|
|
396
|
+
monkeypatch.setattr(subprocess, "run", gh_repo_view)
|
|
397
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
398
|
+
assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Build", "--message", "Start"]) == 1
|
|
399
|
+
captured = capsys.readouterr()
|
|
400
|
+
assert captured.out == ""
|
|
401
|
+
assert ("{title}" if failure == "template" else "no git remotes found") in captured.err
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@pytest.mark.parametrize("source, status, explicit_socket", [
|
|
405
|
+
("argument", "inProgress", True), ("stdin", "completed", False), ("file", "inProgress", False),
|
|
406
|
+
])
|
|
407
|
+
def test_new_protocol_and_exact_text(tmp_path, monkeypatch, capsys, source, status, explicit_socket):
|
|
408
|
+
cwd = tmp_path / "project space" / "nested"
|
|
409
|
+
cwd.mkdir(parents=True)
|
|
410
|
+
(tmp_path / "work").symlink_to(cwd, target_is_directory=True)
|
|
411
|
+
monkeypatch.chdir(tmp_path)
|
|
412
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
413
|
+
message = ' Review "quoted"; $(not-shell)\r\n世界\n'
|
|
414
|
+
name = " 检查\n配置 "
|
|
415
|
+
thread_id = "00000000-0000-4000-8000-0000000000ab"
|
|
416
|
+
requests, opened, closed = [], [], []
|
|
417
|
+
|
|
418
|
+
def call(method, params):
|
|
419
|
+
requests.append((method, params))
|
|
420
|
+
return {"thread/start": {"thread": {"id": "{" + thread_id.upper() + "}"}},
|
|
421
|
+
"thread/name/set": {}, "turn/start": {"turn": {"id": "turn-1", "status": status}}}[method]
|
|
422
|
+
|
|
423
|
+
@contextmanager
|
|
424
|
+
def server(path):
|
|
425
|
+
opened.append(path)
|
|
426
|
+
try:
|
|
427
|
+
yield call
|
|
428
|
+
finally:
|
|
429
|
+
closed.append(path)
|
|
430
|
+
|
|
431
|
+
def discover(directory):
|
|
432
|
+
assert not explicit_socket
|
|
433
|
+
assert directory == Path(f"/tmp/codex-daemon-{ding.os.getuid()}")
|
|
434
|
+
return "/test.sock"
|
|
435
|
+
|
|
436
|
+
monkeypatch.setattr(ding, "app_server", server)
|
|
437
|
+
monkeypatch.setattr(ding, "discover_socket", discover)
|
|
438
|
+
monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("new has no target"))
|
|
439
|
+
monkeypatch.setattr(ding, "deliver_message", lambda *a: pytest.fail("first message must not queue"))
|
|
440
|
+
monkeypatch.setattr(ding, "send_task", lambda *a: pytest.fail("first message must not use send"))
|
|
441
|
+
message_option = ["--message", message]
|
|
442
|
+
if source == "stdin":
|
|
443
|
+
monkeypatch.setattr(ding.sys, "stdin", io.StringIO(message))
|
|
444
|
+
message_option = ["--message", "-"]
|
|
445
|
+
elif source == "file":
|
|
446
|
+
(tmp_path / "note.md").write_bytes(message.encode("utf-8"))
|
|
447
|
+
message_option = ["--message-file", "note.md"]
|
|
448
|
+
options = ["--socket", "/test.sock"] if explicit_socket else []
|
|
449
|
+
assert ding.main(["new", "--cwd", "work", "--name", name, *message_option, *options]) == 0
|
|
450
|
+
assert requests == [
|
|
451
|
+
("thread/start", {"cwd": str(cwd), "ephemeral": False}),
|
|
452
|
+
("thread/name/set", {"threadId": thread_id, "name": name}),
|
|
453
|
+
("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": message}]}),
|
|
454
|
+
]
|
|
455
|
+
assert opened == closed == ["/test.sock"]
|
|
456
|
+
captured = capsys.readouterr()
|
|
457
|
+
assert captured.out == thread_id + "\n"
|
|
458
|
+
assert captured.err == ""
|
|
459
|
+
assert not (tmp_path / "state/ding").exists()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@pytest.mark.parametrize("point", ["socket lookup", "connection"])
|
|
463
|
+
@pytest.mark.parametrize("error", [OSError("unavailable"), KeyboardInterrupt()])
|
|
464
|
+
def test_new_connection_failure_never_creates(tmp_path, monkeypatch, capsys, point, error):
|
|
465
|
+
def fail(*args):
|
|
466
|
+
raise error
|
|
467
|
+
|
|
468
|
+
monkeypatch.setattr(ding, "socket_path", fail if point == "socket lookup" else lambda *a: "/test.sock")
|
|
469
|
+
monkeypatch.setattr(ding, "app_server", fail if point == "connection" else
|
|
470
|
+
lambda *a: pytest.fail("socket lookup failed"))
|
|
471
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start"]) == (
|
|
472
|
+
130 if isinstance(error, KeyboardInterrupt) else 1)
|
|
473
|
+
captured = capsys.readouterr()
|
|
474
|
+
assert captured.out == ""
|
|
475
|
+
assert "new connection" in captured.err
|
|
476
|
+
assert "creation is unconfirmed" not in captured.err
|
|
477
|
+
assert THREAD_ID not in captured.err
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@pytest.mark.parametrize("stage", ["thread/start", "thread/name/set", "turn/start"])
|
|
481
|
+
@pytest.mark.parametrize("error", [ding.AppServerError("request rejected"), TimeoutError("response lost"),
|
|
482
|
+
ding.AppServerUnavailable("connection closed"), KeyboardInterrupt()])
|
|
483
|
+
def test_new_failure_preserves_session_without_retry(tmp_path, monkeypatch, capsys, stage, error):
|
|
484
|
+
requests, connections = [], []
|
|
485
|
+
stages = ["thread/start", "thread/name/set", "turn/start"]
|
|
486
|
+
|
|
487
|
+
def call(method, params):
|
|
488
|
+
requests.append(method)
|
|
489
|
+
if method == stage:
|
|
490
|
+
raise error
|
|
491
|
+
return {"thread": {"id": THREAD_ID}}
|
|
492
|
+
|
|
493
|
+
def server(path):
|
|
494
|
+
connections.append(path)
|
|
495
|
+
return server_calls(call)
|
|
496
|
+
|
|
497
|
+
monkeypatch.setattr(ding, "app_server", server)
|
|
498
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
499
|
+
"--socket", "/test.sock"]) == (130 if isinstance(error, KeyboardInterrupt) else 1)
|
|
500
|
+
assert requests == stages[:stages.index(stage) + 1]
|
|
501
|
+
assert connections == ["/test.sock"]
|
|
502
|
+
captured = capsys.readouterr()
|
|
503
|
+
assert captured.out == ""
|
|
504
|
+
assert f"new {stage}" in captured.err
|
|
505
|
+
assert (str(error) or type(error).__name__) in captured.err
|
|
506
|
+
assert "inspect Desktop or ding board before retrying" in captured.err
|
|
507
|
+
if stage == "thread/start":
|
|
508
|
+
assert "session creation is unconfirmed" in captured.err
|
|
509
|
+
assert THREAD_ID not in captured.err
|
|
510
|
+
else:
|
|
511
|
+
assert f"session {THREAD_ID}" in captured.err
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@pytest.mark.parametrize("reply", [{}, {"thread": None}, {"thread": []}, {"thread": {}},
|
|
515
|
+
{"thread": {"id": None}}, {"thread": {"id": 7}},
|
|
516
|
+
{"thread": {"id": "invalid"}}])
|
|
517
|
+
def test_new_rejects_invalid_thread_before_naming(tmp_path, monkeypatch, capsys, reply):
|
|
518
|
+
requests = []
|
|
519
|
+
|
|
520
|
+
def call(method, params):
|
|
521
|
+
requests.append(method)
|
|
522
|
+
return reply
|
|
523
|
+
|
|
524
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
525
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
526
|
+
"--socket", "/test.sock"]) == 1
|
|
527
|
+
assert requests == ["thread/start"]
|
|
528
|
+
captured = capsys.readouterr()
|
|
529
|
+
assert captured.out == ""
|
|
530
|
+
assert "new thread/start: Malformed App Server thread ID" in captured.err
|
|
531
|
+
assert "session creation is unconfirmed" in captured.err
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@pytest.mark.parametrize("turn", [None, [], {}, {"id": None, "status": "inProgress"},
|
|
535
|
+
{"id": "", "status": "inProgress"}, {"id": 7, "status": "inProgress"},
|
|
536
|
+
{"id": "turn-1"}, {"id": "turn-1", "status": "failed"},
|
|
537
|
+
{"id": "turn-1", "status": "interrupted"},
|
|
538
|
+
{"id": "turn-1", "status": "future-state"},
|
|
539
|
+
{"id": "turn-1", "status": {}}])
|
|
540
|
+
def test_new_rejects_unaccepted_turn_and_keeps_id(tmp_path, monkeypatch, capsys, turn):
|
|
541
|
+
requests = []
|
|
542
|
+
|
|
543
|
+
def call(method, params):
|
|
544
|
+
requests.append(method)
|
|
545
|
+
return {"thread/start": {"thread": {"id": THREAD_ID}},
|
|
546
|
+
"thread/name/set": {}, "turn/start": {"turn": turn}}[method]
|
|
547
|
+
|
|
548
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
549
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
550
|
+
"--socket", "/test.sock"]) == 1
|
|
551
|
+
assert requests == ["thread/start", "thread/name/set", "turn/start"]
|
|
552
|
+
captured = capsys.readouterr()
|
|
553
|
+
assert captured.out == ""
|
|
554
|
+
assert "new turn/start: App Server did not return an accepted turn" in captured.err
|
|
555
|
+
assert f"session {THREAD_ID}" in captured.err
|
|
556
|
+
assert "inspect Desktop or ding board before retrying" in captured.err
|
|
557
|
+
|
|
558
|
+
|
|
255
559
|
def test_send_persists_intent_before_delivery(monkeypatch, tmp_path):
|
|
256
560
|
calls = []
|
|
257
561
|
message = 'text "quoted"; $(not-shell)\nnext'
|
|
@@ -2829,3 +3133,98 @@ def test_pane_representative_skips_empty_ancestor_command_and_breaks_ties_by_pid
|
|
|
2829
3133
|
panes = {10: {"pane_id": "%1", "session": "work", "window": "task"}}
|
|
2830
3134
|
assert [row["pid"] for row in ding.unwrapped_processes(
|
|
2831
3135
|
processes, panes, min_age=600, min_cpu=10, proc=tmp_path)] == [11]
|
|
3136
|
+
|
|
3137
|
+
|
|
3138
|
+
@pytest.mark.parametrize("arguments", [
|
|
3139
|
+
[], [THREAD_ID], ["--name", "New name"], [THREAD_ID, "--name"],
|
|
3140
|
+
[THREAD_ID, "--issue", "42", "--name", "New name"],
|
|
3141
|
+
])
|
|
3142
|
+
def test_rename_requires_one_target_and_name(monkeypatch, capsys, arguments):
|
|
3143
|
+
monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("invalid arguments must not resolve"))
|
|
3144
|
+
with pytest.raises(SystemExit) as exc:
|
|
3145
|
+
ding.main(["rename", *arguments])
|
|
3146
|
+
assert exc.value.code == 2
|
|
3147
|
+
assert capsys.readouterr().out == ""
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
@pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
|
|
3151
|
+
@pytest.mark.parametrize("target", [["Build"], ["--issue", "42"]])
|
|
3152
|
+
def test_rename_rejects_blank_name_before_lookup(monkeypatch, capsys, name, target):
|
|
3153
|
+
for function in ("resolve_target", "socket_path", "app_server", "github_repository", "gh_json"):
|
|
3154
|
+
monkeypatch.setattr(ding, function, lambda *a: pytest.fail("blank name must not look up"))
|
|
3155
|
+
with pytest.raises(SystemExit) as exc:
|
|
3156
|
+
ding.main(["rename", *target, "--name", name])
|
|
3157
|
+
assert exc.value.code == 2
|
|
3158
|
+
captured = capsys.readouterr()
|
|
3159
|
+
assert captured.out == ""
|
|
3160
|
+
assert "--name must contain non-whitespace text" in captured.err
|
|
3161
|
+
|
|
3162
|
+
|
|
3163
|
+
@pytest.mark.parametrize("target,issue", [
|
|
3164
|
+
(THREAD_ID, None), ("Build", None), (None, 42), ("#42", None),
|
|
3165
|
+
("example/project#42", None), ("https://github.com/example/project/issues/42", None),
|
|
3166
|
+
])
|
|
3167
|
+
def test_rename_delegates_target_resolution(monkeypatch, capsys, target, issue):
|
|
3168
|
+
resolved = []
|
|
3169
|
+
calls = []
|
|
3170
|
+
|
|
3171
|
+
def resolve(value, number, path):
|
|
3172
|
+
resolved.append((value, number, path))
|
|
3173
|
+
return THREAD_ID
|
|
3174
|
+
|
|
3175
|
+
def call(method, params):
|
|
3176
|
+
calls.append((method, params))
|
|
3177
|
+
return {}
|
|
3178
|
+
|
|
3179
|
+
monkeypatch.setattr(ding, "resolve_target", resolve)
|
|
3180
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
3181
|
+
arguments = [target] if target is not None else ["--issue", str(issue)]
|
|
3182
|
+
assert ding.main(["rename", *arguments, "--name", "New name", "--socket", "/test.sock"]) == 0
|
|
3183
|
+
assert resolved == [(target, issue, "/test.sock")]
|
|
3184
|
+
assert calls == [("thread/name/set", {"threadId": THREAD_ID, "name": "New name"})]
|
|
3185
|
+
captured = capsys.readouterr()
|
|
3186
|
+
assert captured.out == THREAD_ID + "\n"
|
|
3187
|
+
assert captured.err == ""
|
|
3188
|
+
|
|
3189
|
+
|
|
3190
|
+
@pytest.mark.parametrize("reply,status,error", [
|
|
3191
|
+
({"result": {}}, 0, None),
|
|
3192
|
+
({"error": {"code": -32602, "message": "name refused"}}, 1, "name refused"),
|
|
3193
|
+
("", 1, "closed the connection"),
|
|
3194
|
+
({"result": None}, 1, "Malformed App Server result"),
|
|
3195
|
+
])
|
|
3196
|
+
def test_rename_rpc_acknowledgement_and_failure(tmp_path, monkeypatch, capsys, reply, status, error):
|
|
3197
|
+
websocket = pytest.importorskip("websocket")
|
|
3198
|
+
ws = Connection([{"result": {}}, reply])
|
|
3199
|
+
monkeypatch.setattr(websocket, "create_connection", lambda *a, **kw: ws)
|
|
3200
|
+
name = ' 评测 "quoted" $(not-shell)\nnext '
|
|
3201
|
+
path = str(tmp_path / "server")
|
|
3202
|
+
with socket.socket(socket.AF_UNIX) as server:
|
|
3203
|
+
server.bind(path)
|
|
3204
|
+
server.listen(1)
|
|
3205
|
+
assert ding.main(["rename", "{" + THREAD_ID + "}", "--name", name, "--socket", path]) == status
|
|
3206
|
+
captured = capsys.readouterr()
|
|
3207
|
+
assert captured.out == (THREAD_ID + "\n" if status == 0 else "")
|
|
3208
|
+
assert (error in captured.err) if error else captured.err == ""
|
|
3209
|
+
assert [request["method"] for request in ws.requests] == ["initialize", "initialized", "thread/name/set"]
|
|
3210
|
+
assert ws.requests[-1]["params"] == {"threadId": THREAD_ID, "name": name}
|
|
3211
|
+
assert ws.closed
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
@pytest.mark.parametrize("target", ["Build", "#42"])
|
|
3215
|
+
def test_rename_refuses_ambiguous_targets(issue_data, monkeypatch, capsys, target):
|
|
3216
|
+
issue, comments = issue_data
|
|
3217
|
+
comments.append({"body": f"Codex session: {OTHER_ID}"})
|
|
3218
|
+
calls = []
|
|
3219
|
+
|
|
3220
|
+
def call(method, params):
|
|
3221
|
+
calls.append(method)
|
|
3222
|
+
assert method == "thread/list"
|
|
3223
|
+
return {"data": [thread(), thread(OTHER_ID, "Build")]}
|
|
3224
|
+
|
|
3225
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
3226
|
+
assert ding.main(["rename", target, "--name", "New name", "--socket", "/test.sock"]) == 1
|
|
3227
|
+
captured = capsys.readouterr()
|
|
3228
|
+
assert captured.out == ""
|
|
3229
|
+
assert THREAD_ID in captured.err and OTHER_ID in captured.err
|
|
3230
|
+
assert calls == ["thread/list"]
|
|
@@ -70,6 +70,28 @@ def test_bad_config_is_refused_with_its_key(tmp_path, text, error):
|
|
|
70
70
|
settings(tmp_path, text)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_session_name_template_defaults_and_fills_names_verbatim(tmp_path):
|
|
74
|
+
assert settings(tmp_path).sessions.session_name(issue=57, name="Fix {x} 100%", repo="o/r") == \
|
|
75
|
+
"【#57】Fix {x} 100%"
|
|
76
|
+
loaded = settings(tmp_path, '[sessions]\nname = "{repo}#{issue:04d} {name}"')
|
|
77
|
+
assert loaded.sessions.session_name(issue=7, name="Plot", repo="o/r") == "o/r#0007 Plot"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("template, error", [
|
|
81
|
+
("", "nonempty string"),
|
|
82
|
+
("{issue} {title}", r"uses \{title\}; use only \{issue\}, \{name\}, \{repo\}"),
|
|
83
|
+
("{} {name}", r"uses \{\}"),
|
|
84
|
+
("{name.upper}", r"uses \{name.upper\}"),
|
|
85
|
+
("{name!r}", "without a conversion"),
|
|
86
|
+
("{name", "not a valid template"),
|
|
87
|
+
("{name:d}", "not a valid template"),
|
|
88
|
+
("{issue:{name}}", "nested field"),
|
|
89
|
+
])
|
|
90
|
+
def test_bad_session_name_template_is_refused_on_load(tmp_path, template, error):
|
|
91
|
+
with pytest.raises(ValueError, match=error):
|
|
92
|
+
settings(tmp_path, f"[sessions]\nname = {json.dumps(template)}")
|
|
93
|
+
|
|
94
|
+
|
|
73
95
|
def test_board_status_falls_back_to_the_config_fields(tmp_path, monkeypatch, capsys):
|
|
74
96
|
monkeypatch.chdir(tmp_path)
|
|
75
97
|
(tmp_path / config.FILE).write_text(CHINESE, encoding="utf-8")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|