cf-tool-cli 1.0.0__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.
cf_tool/listen.py ADDED
@@ -0,0 +1,178 @@
1
+ """cf listen: problems from the Competitive Companion browser extension, so
2
+ the samples arrive without cf-tool fetching a Codeforces page.
3
+
4
+ The extension sends each problem as JSON (a POST to localhost) to a fixed
5
+ list of ports (its src/hosts/hosts.ts); cf takes one of them. A contest
6
+ comes as a batch: one POST per problem, each knowing the batch's size. Only
7
+ the extension is listened to: a request from a web page (it carries an
8
+ Origin) or one that isn't JSON is turned away, and so is a page that isn't
9
+ a Codeforces problem.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import socketserver
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from pathlib import Path
18
+ from typing import Callable
19
+ from urllib.parse import urlparse
20
+
21
+ from cf_tool import ui
22
+ from cf_tool.errors import CFError
23
+ from cf_tool.workspace import (
24
+ ProblemInfo,
25
+ Target,
26
+ load_info,
27
+ parse_target,
28
+ problem_folder,
29
+ read_spec,
30
+ save_info,
31
+ save_samples,
32
+ )
33
+
34
+ DEFAULT_PORT = 4244 # NOTE: Hightail's, a tool gone quiet; the extension still sends there
35
+ REQUEST_TIMEOUT = 5.0 # seconds a connection gets to send its problem
36
+ MAX_BODY = 16 * 1024 * 1024 # bytes
37
+ EXTENSION_ORIGINS = ("chrome-extension://", "moz-extension://", "safari-web-extension://")
38
+
39
+ # what to do with a problem once its folder is written: (folder, target, info)
40
+ OnProblem = Callable[[Path, Target, ProblemInfo], None]
41
+
42
+
43
+ def save_problem(data: dict, root: Path) -> tuple[Path, Target, ProblemInfo, int]:
44
+ """Write a received problem's tests and info into its folder under root,
45
+ as cf get would; returns (its folder, the problem, its info, the number
46
+ of tests). Raises CFError for what isn't a Codeforces problem."""
47
+ url = str(data.get("url", ""))
48
+ host = urlparse(url).hostname or ""
49
+ if host != "codeforces.com" and not host.endswith(".codeforces.com"):
50
+ raise CFError(f"Not a Codeforces problem: {url or data.get('name', '?')}")
51
+ parsed = read_spec(url)
52
+ if not parsed or not parsed.get("problem"):
53
+ raise CFError(f"Not a Codeforces problem's page: {url}")
54
+ target = parse_target([url])
55
+ folder = problem_folder(root, target)
56
+ tests = [
57
+ (str(test.get("input", "")), str(test.get("output", "")))
58
+ for test in data.get("tests", [])
59
+ if isinstance(test, dict)
60
+ ]
61
+ save_samples(folder, tests)
62
+ limit, memory = data.get("timeLimit"), data.get("memoryLimit")
63
+ known = load_info(folder) # NOTE: cf get read more from the statement than the extension sends
64
+ info = ProblemInfo(
65
+ name=str(data.get("name", "")),
66
+ url=url,
67
+ time_limit=limit / 1000 if isinstance(limit, (int, float)) else None, # NOTE: sent in ms
68
+ memory_limit=int(memory) if isinstance(memory, (int, float)) else None,
69
+ interactive=bool(data.get("interactive", False)),
70
+ )
71
+ if known is not None:
72
+ info.ignore_case, info.epsilon = known.ignore_case, known.epsilon
73
+ info.any_answer = known.any_answer
74
+ info.interactive = info.interactive or known.interactive
75
+ if len(known.cases) == len(tests):
76
+ info.cases = known.cases
77
+ save_info(folder, info)
78
+ return folder, target, info, len(tests)
79
+
80
+
81
+ class _Server(HTTPServer):
82
+ def server_bind(self) -> None:
83
+ # NOTE: HTTPServer's own also looks up this host's name, which can take
84
+ # \ half a minute on macOS; the name isn't needed here
85
+ socketserver.TCPServer.server_bind(self)
86
+ self.server_name, self.server_port = self.server_address[:2]
87
+
88
+
89
+ class _Handler(BaseHTTPRequestHandler):
90
+ timeout = REQUEST_TIMEOUT # NOTE: a connection that sends nothing can't hold up the next
91
+
92
+ def do_POST(self) -> None:
93
+ length = self.headers.get("Content-Length") or "0"
94
+ if not length.isdigit() or int(length) > MAX_BODY:
95
+ self._answer(413)
96
+ return
97
+ body = self.rfile.read(int(length)) # NOTE: read first: an unread body resets the answer
98
+ origin = self.headers.get("Origin")
99
+ if origin is not None and not origin.startswith(EXTENSION_ORIGINS):
100
+ self._answer(403) # NOTE: a web page (or a sandboxed one: "null"), not the extension
101
+ return
102
+ content_type = self.headers.get("Content-Type", "").split(";")[0].strip().lower()
103
+ if content_type != "application/json":
104
+ self._answer(415) # NOTE: a page can't send JSON here without asking first
105
+ return
106
+ self._answer(200)
107
+ self.server.received.append(body) # type: ignore[attr-defined]
108
+
109
+ def _answer(self, status: int) -> None:
110
+ self.send_response(status)
111
+ self.end_headers()
112
+
113
+ def log_message(self, format, *args) -> None: # noqa: A002 (the base class's name)
114
+ pass # NOTE: no request log on the terminal
115
+
116
+
117
+ def listen(
118
+ root: Path,
119
+ on_problem: OnProblem | None = None,
120
+ port: int = DEFAULT_PORT,
121
+ keep: bool = False,
122
+ ready: Callable[[int], None] | None = None,
123
+ ) -> None:
124
+ """Receive problems on localhost:port, save each under root and pass it
125
+ to on_problem (the starter code); returns once a whole batch (a contest,
126
+ or a single problem) arrived, unless keep. ready(port) is called once
127
+ listening."""
128
+ try:
129
+ server = _Server(("127.0.0.1", port), _Handler)
130
+ except OSError as e:
131
+ raise CFError(
132
+ f"Cannot listen on port {port} ({e.strerror or e}): try another with --port"
133
+ ) from e
134
+ server.received = [] # type: ignore[attr-defined]
135
+ batches: dict[str, set[str]] = {}
136
+ with server:
137
+ ui.cyan(
138
+ f"Listening on port {server.server_port}: click Competitive Companion's button "
139
+ "on a problem or contest page (Ctrl-C stops)"
140
+ )
141
+ if ready is not None:
142
+ ready(server.server_port)
143
+ while True:
144
+ server.handle_request()
145
+ for body in server.received: # type: ignore[attr-defined]
146
+ if _receive(body, root, on_problem, batches) and not keep:
147
+ return
148
+ server.received.clear() # type: ignore[attr-defined]
149
+
150
+
151
+ def _receive(
152
+ body: bytes, root: Path, on_problem: OnProblem | None, batches: dict[str, set[str]]
153
+ ) -> bool:
154
+ """Save one received problem and say so; returns whether its batch is now complete."""
155
+ try:
156
+ data = json.loads(body)
157
+ if not isinstance(data, dict):
158
+ raise ValueError("not an object")
159
+ folder, target, info, count = save_problem(data, root)
160
+ except (ValueError, TypeError, AttributeError) as e:
161
+ ui.red(f"Received something that isn't a problem ({e})")
162
+ return False
163
+ except CFError as e:
164
+ ui.red(str(e))
165
+ return False
166
+ if on_problem is not None:
167
+ on_problem(folder, target, info)
168
+ noun = "test" if count == 1 else "tests"
169
+ ui.line((f"{target.label}", "bold green"), (f" {info.name} {count} {noun} in {folder}", ""))
170
+ batch = data.get("batch") if isinstance(data.get("batch"), dict) else {}
171
+ seen = batches.setdefault(str(batch.get("id", "")), set())
172
+ seen.add(str(data.get("url", "")))
173
+ size = batch.get("size") if isinstance(batch.get("size"), int) else 1
174
+ if len(seen) >= size:
175
+ if size > 1:
176
+ ui.green(f"All {size} problems are here")
177
+ return True
178
+ return False
cf_tool/paths.py ADDED
@@ -0,0 +1,57 @@
1
+ """Where cf-tool keeps its files, and writing them safely.
2
+
3
+ The folders follow the XDG base directories:
4
+ - settings: $XDG_CONFIG_HOME/cf (config.toml, your starter code in templates/)
5
+ - state: $XDG_STATE_HOME/cf (the login session, caches of what Codeforces said)
6
+ - cache: $XDG_CACHE_HOME/cf (builds, cf-tool's bits/stdc++.h)
7
+
8
+ Read them as paths.CONFIG_DIR and so on at call time (tests point them elsewhere).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import os
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+
19
+ def _xdg(env_var: str, default: Path) -> Path:
20
+ value = os.environ.get(env_var, "")
21
+ return Path(value).expanduser() if value else default
22
+
23
+
24
+ CONFIG_DIR = _xdg("XDG_CONFIG_HOME", Path.home() / ".config") / "cf"
25
+ STATE_DIR = _xdg("XDG_STATE_HOME", Path.home() / ".local" / "state") / "cf"
26
+ CACHE_DIR = _xdg("XDG_CACHE_HOME", Path.home() / ".cache") / "cf"
27
+
28
+
29
+ def config_file() -> Path:
30
+ return CONFIG_DIR / "config.toml"
31
+
32
+
33
+ def templates_dir() -> Path:
34
+ """Where your own starter code lives: template.cpp, brute.cpp, gen.py, ..."""
35
+ return CONFIG_DIR / "templates"
36
+
37
+
38
+ def session_file() -> Path:
39
+ return STATE_DIR / "session"
40
+
41
+
42
+ def write_atomic(path: Path, text: str, mode: int = 0o644) -> None:
43
+ """Replace path with text in one step, created with the given permissions
44
+ (a Ctrl-C mid-write never leaves a truncated file behind). A symbolic link
45
+ stays one: the file it points to is written."""
46
+ path = Path(os.path.realpath(path)) # NOTE: a config.toml kept in a dotfiles repo
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
49
+ try:
50
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
51
+ f.write(text)
52
+ os.chmod(tmp, mode)
53
+ os.replace(tmp, path)
54
+ except BaseException:
55
+ with contextlib.suppress(OSError):
56
+ os.unlink(tmp)
57
+ raise
cf_tool/process.py ADDED
@@ -0,0 +1,431 @@
1
+ """Running a program: input in, output out, and how it went.
2
+
3
+ Each program runs in a process group of its own, so a time limit kills
4
+ everything it started (a wrapper script, a forked child) and nothing is left
5
+ running afterwards; Ctrl-C reaches it through cf-tool. Output is read as it
6
+ comes, up to a cap: past it the run stops, the way the judge stops it with
7
+ "Output limit exceeded", instead of filling memory. CPU time and peak memory
8
+ come from the kernel's accounting for the finished program (wait4); Windows
9
+ gives wall time and polled memory.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import collections
15
+ import contextlib
16
+ import os
17
+ import shutil
18
+ import signal
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import IO, Callable, Sequence
26
+
27
+ import psutil
28
+
29
+ from cf_tool.errors import CFError
30
+
31
+ try:
32
+ import resource
33
+ except ImportError: # Windows
34
+ resource = None # type: ignore[assignment]
35
+
36
+ OUTPUT_LIMIT = 64 * 1024 * 1024 # bytes a program may print before it is stopped
37
+ STDERR_KEPT = 64 * 1024 # the last bytes of its error output, kept for messages
38
+ STACK_LIMIT = 256 * 1024 * 1024 # bytes: the stack Codeforces gives
39
+ _CHUNK = 64 * 1024
40
+ _GRACE = 2.0 # seconds to wait for a killed program, and for its pipes to close
41
+
42
+ OK = "ok"
43
+ EXIT = "exit" # a non-zero exit code
44
+ SIGNAL = "signal" # killed by a signal: a crash
45
+ TIMEOUT = "timeout"
46
+ OUTPUT = "output" # printed more than its output limit
47
+
48
+ # Windows' codes for the usual crashes, as unsigned exit codes
49
+ _WINDOWS_CRASHES = {
50
+ 0xC0000005: "access violation",
51
+ 0xC00000FD: "stack overflow",
52
+ 0xC0000094: "integer division by zero",
53
+ 0xC0000409: "stack buffer overrun",
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Result:
59
+ """How a run went."""
60
+
61
+ status: str # OK, EXIT, SIGNAL, TIMEOUT or OUTPUT
62
+ code: int | None # its exit code (negative: the number of the signal that killed it)
63
+ stdout: bytes
64
+ stderr: bytes # its last STDERR_KEPT bytes
65
+ cpu: float # seconds of CPU time (wall time where the OS can't tell)
66
+ wall: float # seconds
67
+ memory: int # peak resident memory in bytes, 0 when unknown
68
+ timeout: float | None = None # the seconds it was allowed
69
+ output_limit: int = OUTPUT_LIMIT
70
+
71
+ @property
72
+ def ok(self) -> bool:
73
+ return self.status == OK
74
+
75
+ def reason(self) -> str:
76
+ """Why it didn't end well, in a few words: "exit status 1", "killed
77
+ by SIGSEGV", "killed after 6s", "printed over 64 MB"."""
78
+ if self.status == TIMEOUT:
79
+ return f"killed after {self.timeout:g}s"
80
+ if self.status == OUTPUT:
81
+ return f"printed over {self.output_limit // (1024 * 1024)} MB"
82
+ if self.status == SIGNAL and self.code is not None:
83
+ try:
84
+ return f"killed by {signal.Signals(-self.code).name}"
85
+ except ValueError:
86
+ return f"killed by signal {-self.code}"
87
+ crash = _WINDOWS_CRASHES.get(self.code or 0)
88
+ return crash or f"exit status {self.code}"
89
+
90
+
91
+ def split_command(command: str) -> list[str]:
92
+ """The words of a command line. Whitespace separates them; a quote of
93
+ either kind opens a span that the next quote of either kind closes.
94
+ Nothing else is special: backslashes stay, so Windows paths survive."""
95
+ words: list[str] = []
96
+ word: list[str] = []
97
+ quoted = False
98
+ for char in command:
99
+ if char.isspace() and not quoted:
100
+ if word:
101
+ words.append("".join(word))
102
+ word.clear()
103
+ elif char in "\"'":
104
+ if quoted:
105
+ words.append("".join(word))
106
+ word.clear()
107
+ quoted = not quoted
108
+ else:
109
+ word.append(char)
110
+ if word:
111
+ words.append("".join(word))
112
+ return words
113
+
114
+
115
+ # ┌──────────────────────────────────────────────────────────┐
116
+ # │ A program │
117
+ # └──────────────────────────────────────────────────────────┘
118
+
119
+
120
+ class Child:
121
+ """A started program in a process group of its own, waited for by a
122
+ thread; `done` is set once it ended, with its exit code, CPU time and
123
+ peak memory filled in."""
124
+
125
+ def __init__(
126
+ self,
127
+ argv: Sequence[str],
128
+ *,
129
+ cwd: Path,
130
+ stdin,
131
+ stdout,
132
+ stderr,
133
+ big_stack: bool = False,
134
+ ) -> None:
135
+ options: dict = {}
136
+ if os.name == "posix":
137
+ options["start_new_session"] = True
138
+ if big_stack and sys.platform.startswith("linux"):
139
+ options["preexec_fn"] = _raise_stack
140
+ else:
141
+ options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
142
+ argv = list(argv)
143
+ if os.name == "nt":
144
+ argv[0] = shutil.which(argv[0]) or argv[0] # NOTE: finds .bat and .cmd tools too
145
+ try:
146
+ self.process = subprocess.Popen(
147
+ argv, cwd=cwd, stdin=stdin, stdout=stdout, stderr=stderr, **options
148
+ )
149
+ except OSError as e:
150
+ raise CFError(f"Cannot run {argv[0]!r}: {e.strerror or e}") from e
151
+ self.started = time.perf_counter()
152
+ self.wall = 0.0
153
+ self.cpu: float | None = None
154
+ self.memory = 0
155
+ self.done = threading.Event()
156
+ threading.Thread(target=self._wait, daemon=True).start()
157
+ if os.name != "posix":
158
+ threading.Thread(target=self._poll_memory, daemon=True).start()
159
+
160
+ def _wait(self) -> None:
161
+ try:
162
+ if os.name == "posix":
163
+ _, status, usage = os.wait4(self.process.pid, 0)
164
+ self.process.returncode = os.waitstatus_to_exitcode(status)
165
+ self.cpu = usage.ru_utime + usage.ru_stime
166
+ # NOTE: ru_maxrss is in kilobytes on Linux, in bytes on macOS
167
+ self.memory = usage.ru_maxrss * (1 if sys.platform == "darwin" else 1024)
168
+ self._kill_group() # NOTE: whatever it left running goes with it
169
+ else:
170
+ self.process.wait()
171
+ self.cpu = _windows_cpu(self.process.pid)
172
+ finally:
173
+ self.wall = time.perf_counter() - self.started
174
+ self.done.set()
175
+
176
+ def kill(self) -> None:
177
+ """Kill the program and everything it started."""
178
+ if os.name == "posix":
179
+ self._kill_group()
180
+ return
181
+ try:
182
+ parent = psutil.Process(self.process.pid)
183
+ for child in parent.children(recursive=True):
184
+ child.kill()
185
+ parent.kill()
186
+ except psutil.Error:
187
+ pass
188
+
189
+ def _kill_group(self) -> None:
190
+ try:
191
+ os.killpg(self.process.pid, signal.SIGKILL)
192
+ except (ProcessLookupError, PermissionError):
193
+ pass # NOTE: the group is gone already
194
+
195
+ def _poll_memory(self) -> None:
196
+ try:
197
+ process = psutil.Process(self.process.pid)
198
+ while not self.done.is_set():
199
+ info = process.memory_info()
200
+ self.memory = max(self.memory, getattr(info, "peak_wset", info.rss))
201
+ time.sleep(0.01)
202
+ except psutil.Error:
203
+ pass
204
+
205
+
206
+ def _windows_cpu(pid: int) -> float | None:
207
+ try:
208
+ times = psutil.Process(pid).cpu_times()
209
+ return times.user + times.system
210
+ except psutil.Error:
211
+ return None
212
+
213
+
214
+ def _raise_stack() -> None:
215
+ """Give the program Codeforces' stack (runs in the child, before exec)."""
216
+ assert resource is not None
217
+ soft, hard = resource.getrlimit(resource.RLIMIT_STACK)
218
+ want = STACK_LIMIT if hard == resource.RLIM_INFINITY else min(STACK_LIMIT, hard)
219
+ if soft != resource.RLIM_INFINITY and soft < want:
220
+ resource.setrlimit(resource.RLIMIT_STACK, (want, hard))
221
+
222
+
223
+ class _Reader(threading.Thread):
224
+ """Reads a pipe to its end, keeping its first `limit` bytes (calling
225
+ over() once there are more) or only its last `tail` bytes."""
226
+
227
+ def __init__(
228
+ self,
229
+ pipe: IO[bytes],
230
+ *,
231
+ limit: int | None = None,
232
+ tail: int | None = None,
233
+ over: Callable[[], None] | None = None,
234
+ ) -> None:
235
+ super().__init__(daemon=True)
236
+ self.pipe, self.limit, self.tail, self.over = pipe, limit, tail, over
237
+ self.chunks: collections.deque[bytes] = collections.deque()
238
+ self.size = 0
239
+ self.exceeded = False
240
+ self.start()
241
+
242
+ def run(self) -> None:
243
+ fd = self.pipe.fileno()
244
+ try:
245
+ while chunk := os.read(fd, _CHUNK):
246
+ if self.exceeded:
247
+ continue # NOTE: still drained, so the program isn't blocked writing
248
+ self.chunks.append(chunk)
249
+ self.size += len(chunk)
250
+ if self.limit is not None and self.size > self.limit:
251
+ self.exceeded = True
252
+ if self.over is not None:
253
+ self.over()
254
+ while self.tail is not None and self.size - len(self.chunks[0]) >= self.tail:
255
+ self.size -= len(self.chunks.popleft())
256
+ except OSError:
257
+ pass
258
+ finally:
259
+ with contextlib.suppress(OSError):
260
+ self.pipe.close()
261
+
262
+ def data(self) -> bytes:
263
+ data = b"".join(list(self.chunks))
264
+ if self.limit is not None:
265
+ data = data[: self.limit]
266
+ if self.tail is not None:
267
+ data = data[-self.tail :]
268
+ return data
269
+
270
+
271
+ def _feed(pipe: IO[bytes], data: bytes) -> None:
272
+ """Write data to the program's input in the background, then close it."""
273
+
274
+ def write() -> None:
275
+ try:
276
+ pipe.write(data)
277
+ except OSError:
278
+ pass # NOTE: a program needn't read all of its input
279
+ finally:
280
+ with contextlib.suppress(OSError):
281
+ pipe.close()
282
+
283
+ threading.Thread(target=write, daemon=True).start()
284
+
285
+
286
+ # ┌──────────────────────────────────────────────────────────┐
287
+ # │ Running │
288
+ # └──────────────────────────────────────────────────────────┘
289
+
290
+
291
+ def run(
292
+ argv: Sequence[str],
293
+ *,
294
+ cwd: Path,
295
+ stdin: bytes | Path | None = None,
296
+ timeout: float | None,
297
+ output_limit: int = OUTPUT_LIMIT,
298
+ big_stack: bool = False,
299
+ ) -> Result:
300
+ """Run argv in cwd and wait for it to end; returns how it went.
301
+ - stdin: bytes to feed it, a file to read from, or nothing (end of input)
302
+ - running past timeout seconds, or printing over output_limit bytes,
303
+ kills it along with everything it started
304
+ - big_stack: Codeforces' 256 MB stack (where a process limit gives it)"""
305
+ in_file = stdin.open("rb") if isinstance(stdin, Path) else None
306
+ feed = stdin if isinstance(stdin, bytes) else None
307
+ try:
308
+ child = Child(
309
+ argv,
310
+ cwd=cwd,
311
+ stdin=in_file or (subprocess.PIPE if feed is not None else subprocess.DEVNULL),
312
+ stdout=subprocess.PIPE,
313
+ stderr=subprocess.PIPE,
314
+ big_stack=big_stack,
315
+ )
316
+ finally:
317
+ if in_file is not None:
318
+ in_file.close() # NOTE: the program has a copy of its own
319
+ try:
320
+ out = _Reader(child.process.stdout, limit=output_limit, over=child.kill)
321
+ err = _Reader(child.process.stderr, tail=STDERR_KEPT)
322
+ if feed is not None:
323
+ _feed(child.process.stdin, feed)
324
+ finished = child.done.wait(timeout)
325
+ if not finished:
326
+ child.kill()
327
+ child.done.wait(_GRACE)
328
+ except BaseException: # NOTE: Ctrl-C doesn't reach a program in its own process group
329
+ child.kill()
330
+ raise
331
+ out.join(_GRACE)
332
+ err.join(_GRACE)
333
+ return _result(child, finished, out, err, timeout, output_limit)
334
+
335
+
336
+ def _result(
337
+ child: Child,
338
+ finished: bool,
339
+ out: _Reader | None,
340
+ err: _Reader,
341
+ timeout: float | None,
342
+ output_limit: int = OUTPUT_LIMIT,
343
+ ) -> Result:
344
+ code = child.process.returncode
345
+ if out is not None and out.exceeded:
346
+ status = OUTPUT
347
+ elif not finished:
348
+ status = TIMEOUT
349
+ elif code == 0:
350
+ status = OK
351
+ elif code is not None and code < 0:
352
+ status = SIGNAL
353
+ else:
354
+ status = EXIT
355
+ return Result(
356
+ status=status,
357
+ code=code,
358
+ stdout=out.data() if out is not None else b"",
359
+ stderr=err.data(),
360
+ cpu=child.cpu if child.cpu is not None else child.wall,
361
+ wall=child.wall,
362
+ memory=child.memory,
363
+ timeout=timeout,
364
+ output_limit=output_limit,
365
+ )
366
+
367
+
368
+ def interact(
369
+ solution: Sequence[str],
370
+ interactor: Sequence[str],
371
+ *,
372
+ cwd: Path,
373
+ timeout: float,
374
+ solution_cwd: Path | None = None,
375
+ big_stack: bool = False,
376
+ ) -> tuple[Result, Result]:
377
+ """Run a solution against an interactor, each one's output the other's
378
+ input; returns (the solution's result, the interactor's). Steps:
379
+ - start both, wired together
380
+ - wait for both to end, at most timeout seconds
381
+ - once the interactor has ended (its verdict is in), give the solution a
382
+ moment to end too, then stop it"""
383
+ to_solution = os.pipe()
384
+ to_interactor = os.pipe()
385
+ try:
386
+ solver = Child(
387
+ solution,
388
+ cwd=solution_cwd or cwd,
389
+ stdin=to_solution[0],
390
+ stdout=to_interactor[1],
391
+ stderr=subprocess.PIPE,
392
+ big_stack=big_stack,
393
+ )
394
+ try:
395
+ judge = Child(
396
+ interactor,
397
+ cwd=cwd,
398
+ stdin=to_interactor[0],
399
+ stdout=to_solution[1],
400
+ stderr=subprocess.PIPE,
401
+ )
402
+ except BaseException:
403
+ solver.kill()
404
+ raise
405
+ finally:
406
+ for fd in (*to_solution, *to_interactor):
407
+ os.close(fd) # NOTE: only the two programs hold the pipes now
408
+ solver_err = _Reader(solver.process.stderr, tail=STDERR_KEPT)
409
+ judge_err = _Reader(judge.process.stderr, tail=STDERR_KEPT)
410
+ deadline = time.monotonic() + timeout
411
+ try:
412
+ while not (solver.done.is_set() and judge.done.is_set()):
413
+ left = deadline - time.monotonic()
414
+ if left <= 0:
415
+ break
416
+ if judge.done.is_set():
417
+ solver.done.wait(min(left, _GRACE)) # NOTE: the verdict is in
418
+ break
419
+ judge.done.wait(min(left, 0.05))
420
+ finally:
421
+ solver_done, judge_done = solver.done.is_set(), judge.done.is_set()
422
+ for child in (solver, judge):
423
+ if not child.done.is_set():
424
+ child.kill()
425
+ child.done.wait(_GRACE)
426
+ solver_err.join(_GRACE)
427
+ judge_err.join(_GRACE)
428
+ return (
429
+ _result(solver, solver_done, None, solver_err, timeout),
430
+ _result(judge, judge_done, None, judge_err, timeout),
431
+ )