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/__init__.py +3 -0
- cf_tool/__main__.py +6 -0
- cf_tool/build.py +211 -0
- cf_tool/cli/__init__.py +117 -0
- cf_tool/cli/account.py +32 -0
- cf_tool/cli/common.py +262 -0
- cf_tool/cli/completion.py +226 -0
- cf_tool/cli/configure.py +386 -0
- cf_tool/cli/problems.py +399 -0
- cf_tool/cli/setup.py +195 -0
- cf_tool/cli/submitting.py +309 -0
- cf_tool/cli/testing.py +354 -0
- cf_tool/codeforces/__init__.py +3 -0
- cf_tool/codeforces/browser.py +346 -0
- cf_tool/codeforces/client.py +521 -0
- cf_tool/codeforces/crypto.py +41 -0
- cf_tool/codeforces/gateway.py +288 -0
- cf_tool/codeforces/http.py +213 -0
- cf_tool/codeforces/pages.py +767 -0
- cf_tool/codeforces/session.py +103 -0
- cf_tool/doctor.py +124 -0
- cf_tool/errors.py +37 -0
- cf_tool/judge.py +481 -0
- cf_tool/languages.py +1195 -0
- cf_tool/listen.py +178 -0
- cf_tool/paths.py +57 -0
- cf_tool/process.py +431 -0
- cf_tool/settings.py +386 -0
- cf_tool/stress.py +167 -0
- cf_tool/ui.py +455 -0
- cf_tool/views.py +306 -0
- cf_tool/workspace.py +777 -0
- cf_tool_cli-1.0.0.dist-info/METADATA +106 -0
- cf_tool_cli-1.0.0.dist-info/RECORD +38 -0
- cf_tool_cli-1.0.0.dist-info/WHEEL +5 -0
- cf_tool_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cf_tool_cli-1.0.0.dist-info/licenses/LICENSE +48 -0
- cf_tool_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|