tinyleaf 0.1.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.
- tinyleaf/__init__.py +1 -0
- tinyleaf/cli.py +165 -0
- tinyleaf/compiler.py +451 -0
- tinyleaf/git_ops.py +185 -0
- tinyleaf/handlers.py +982 -0
- tinyleaf/registry.py +158 -0
- tinyleaf/server.py +329 -0
- tinyleaf/static/index.html +3253 -0
- tinyleaf/vendor.py +288 -0
- tinyleaf-0.1.0.dist-info/METADATA +106 -0
- tinyleaf-0.1.0.dist-info/RECORD +15 -0
- tinyleaf-0.1.0.dist-info/WHEEL +5 -0
- tinyleaf-0.1.0.dist-info/entry_points.txt +2 -0
- tinyleaf-0.1.0.dist-info/licenses/LICENSE +21 -0
- tinyleaf-0.1.0.dist-info/top_level.txt +1 -0
tinyleaf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tinyleaf/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""CLI entry point for tinyleaf."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
from tinyleaf import registry
|
|
10
|
+
from tinyleaf.server import run_server
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "tinyleaf")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="tinyleaf",
|
|
18
|
+
description="Lightweight web-based LaTeX editor",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"project_path",
|
|
22
|
+
nargs="?",
|
|
23
|
+
help="Single project directory to open",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--projects-dir",
|
|
27
|
+
metavar="DIR",
|
|
28
|
+
help="Legacy: migrate subdirs into registry, then use registry mode",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--config-dir",
|
|
32
|
+
metavar="DIR",
|
|
33
|
+
default=DEFAULT_CONFIG_DIR,
|
|
34
|
+
help=f"Config directory for project registry (default: {DEFAULT_CONFIG_DIR})",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--docker",
|
|
38
|
+
action=argparse.BooleanOptionalAction,
|
|
39
|
+
default=True,
|
|
40
|
+
help="Use Docker for compilation (default: enabled)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--image",
|
|
44
|
+
default="oaklight/texlive:alpine-science-cn",
|
|
45
|
+
help="Docker image to use (default: oaklight/texlive:alpine-science-cn)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--port",
|
|
49
|
+
type=int,
|
|
50
|
+
default=8080,
|
|
51
|
+
help="Server port (default: 8080)",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--host",
|
|
55
|
+
default="127.0.0.1",
|
|
56
|
+
help="Server host (default: 127.0.0.1)",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--no-browser",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Don't auto-open browser on start",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
args = parser.parse_args()
|
|
65
|
+
|
|
66
|
+
# Validate
|
|
67
|
+
if args.project_path and args.projects_dir:
|
|
68
|
+
parser.error("Cannot specify both project_path and --projects-dir")
|
|
69
|
+
|
|
70
|
+
# Determine mode
|
|
71
|
+
if args.project_path:
|
|
72
|
+
mode = "single"
|
|
73
|
+
project_path = os.path.abspath(args.project_path)
|
|
74
|
+
if not os.path.isdir(project_path):
|
|
75
|
+
print(f"Error: '{project_path}' is not a directory", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
config_dir = None
|
|
78
|
+
else:
|
|
79
|
+
mode = "multi"
|
|
80
|
+
config_dir = os.path.abspath(args.config_dir)
|
|
81
|
+
registry.ensure_config_dir(config_dir)
|
|
82
|
+
project_path = None
|
|
83
|
+
|
|
84
|
+
# Backward compat: migrate --projects-dir subdirs into registry
|
|
85
|
+
if args.projects_dir:
|
|
86
|
+
projects_dir = os.path.abspath(args.projects_dir)
|
|
87
|
+
if os.path.isdir(projects_dir):
|
|
88
|
+
count = _migrate_projects_dir(config_dir, projects_dir)
|
|
89
|
+
if count:
|
|
90
|
+
print(f" Migrated {count} project(s) from {projects_dir}")
|
|
91
|
+
|
|
92
|
+
# Auto-download vendor JS modules on first start
|
|
93
|
+
from tinyleaf import vendor
|
|
94
|
+
|
|
95
|
+
vendor_dir = os.path.join(config_dir, "vendor")
|
|
96
|
+
if not vendor.is_vendor_ready(vendor_dir):
|
|
97
|
+
proxy = vendor.load_proxy(config_dir)
|
|
98
|
+
print(" Downloading JS modules...")
|
|
99
|
+
if proxy:
|
|
100
|
+
print(f" Using proxy: {proxy}")
|
|
101
|
+
try:
|
|
102
|
+
vendor.download_vendor(vendor_dir, proxy=proxy)
|
|
103
|
+
print(" JS modules ready")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f" Warning: failed to download JS modules: {e}", file=sys.stderr)
|
|
106
|
+
print(" Editor will try CDN as fallback", file=sys.stderr)
|
|
107
|
+
|
|
108
|
+
# Check compilation backend
|
|
109
|
+
use_docker = args.docker
|
|
110
|
+
if not use_docker and not shutil.which("latexmk"):
|
|
111
|
+
print(
|
|
112
|
+
"Warning: 'latexmk' not found in PATH. Add --docker to use Docker for compilation.",
|
|
113
|
+
file=sys.stderr,
|
|
114
|
+
)
|
|
115
|
+
print("Continuing anyway — compilation will fail without latexmk.", file=sys.stderr)
|
|
116
|
+
|
|
117
|
+
if use_docker and not shutil.which("docker"):
|
|
118
|
+
print("Error: --docker specified but 'docker' not found in PATH", file=sys.stderr)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
config = {
|
|
122
|
+
"mode": mode,
|
|
123
|
+
"project_path": project_path,
|
|
124
|
+
"config_dir": config_dir,
|
|
125
|
+
"use_docker": use_docker,
|
|
126
|
+
"docker_image": args.image,
|
|
127
|
+
"host": args.host,
|
|
128
|
+
"port": args.port,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
url = f"http://{args.host}:{args.port}"
|
|
132
|
+
print(f"Starting tinyleaf ({mode} mode)")
|
|
133
|
+
if use_docker:
|
|
134
|
+
print(f" Compiler: Docker ({args.image})")
|
|
135
|
+
else:
|
|
136
|
+
print(" Compiler: local latexmk")
|
|
137
|
+
if mode == "single":
|
|
138
|
+
print(f" Project: {project_path}")
|
|
139
|
+
else:
|
|
140
|
+
print(f" Config: {config_dir}")
|
|
141
|
+
print(f" URL: {url}")
|
|
142
|
+
|
|
143
|
+
if not args.no_browser:
|
|
144
|
+
webbrowser.open(url)
|
|
145
|
+
|
|
146
|
+
run_server(config)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _migrate_projects_dir(config_dir, projects_dir):
|
|
150
|
+
"""Migrate subdirectories of a projects dir into the registry."""
|
|
151
|
+
count = 0
|
|
152
|
+
for entry in sorted(os.listdir(projects_dir)):
|
|
153
|
+
full = os.path.join(projects_dir, entry)
|
|
154
|
+
if not os.path.isdir(full) or entry.startswith("."):
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
registry.register_project(config_dir, entry, full)
|
|
158
|
+
count += 1
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass # name collision, skip
|
|
161
|
+
return count
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
tinyleaf/compiler.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Compilation backend for tinyleaf.
|
|
2
|
+
|
|
3
|
+
Supports local latexmk and Docker-based compilation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CompileJob:
|
|
13
|
+
"""Tracks a single compilation run."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
compile_id,
|
|
18
|
+
project_dir,
|
|
19
|
+
main_file,
|
|
20
|
+
engine,
|
|
21
|
+
use_docker,
|
|
22
|
+
docker_image,
|
|
23
|
+
registry_mirror=None,
|
|
24
|
+
):
|
|
25
|
+
self.compile_id = compile_id
|
|
26
|
+
self.project_dir = project_dir
|
|
27
|
+
self.main_file = main_file
|
|
28
|
+
self.engine = engine
|
|
29
|
+
self.use_docker = use_docker
|
|
30
|
+
self.docker_image = docker_image
|
|
31
|
+
self.registry_mirror = registry_mirror
|
|
32
|
+
self.log_lines = []
|
|
33
|
+
self.status = "running" # running | success | error | cancelled
|
|
34
|
+
self.pdf_path = None
|
|
35
|
+
self.proc = None # subprocess.Popen reference for cancellation
|
|
36
|
+
self._cancelled = False
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._done_event = threading.Event()
|
|
39
|
+
|
|
40
|
+
def append_log(self, line, level="info"):
|
|
41
|
+
with self._lock:
|
|
42
|
+
self.log_lines.append({"line": line, "level": level})
|
|
43
|
+
|
|
44
|
+
def get_logs_from(self, index):
|
|
45
|
+
with self._lock:
|
|
46
|
+
return list(self.log_lines[index:])
|
|
47
|
+
|
|
48
|
+
def finish(self, status, pdf_path=None):
|
|
49
|
+
self.status = status
|
|
50
|
+
self.pdf_path = pdf_path
|
|
51
|
+
self._done_event.set()
|
|
52
|
+
|
|
53
|
+
def wait(self, timeout=None):
|
|
54
|
+
self._done_event.wait(timeout)
|
|
55
|
+
|
|
56
|
+
def cancel(self):
|
|
57
|
+
"""Cancel this compilation by killing the subprocess."""
|
|
58
|
+
self._cancelled = True
|
|
59
|
+
if self.proc and self.proc.poll() is None:
|
|
60
|
+
self.proc.kill()
|
|
61
|
+
self.append_log("Cancelled by user.", level="warning")
|
|
62
|
+
self.finish("cancelled")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_cancelled(self):
|
|
66
|
+
return self._cancelled
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_done(self):
|
|
70
|
+
return self._done_event.is_set()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Global compile job registry
|
|
74
|
+
_jobs: dict[str, CompileJob] = {}
|
|
75
|
+
_jobs_lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_job(compile_id):
|
|
79
|
+
with _jobs_lock:
|
|
80
|
+
return _jobs.get(compile_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cancel_compile(compile_id):
|
|
84
|
+
"""Cancel a running compilation job.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if the job was found and cancelled, False otherwise.
|
|
88
|
+
"""
|
|
89
|
+
job = get_job(compile_id)
|
|
90
|
+
if not job or job.is_done:
|
|
91
|
+
return False
|
|
92
|
+
job.cancel()
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def start_compile(
|
|
97
|
+
project_dir,
|
|
98
|
+
main_file="main.tex",
|
|
99
|
+
engine="pdflatex",
|
|
100
|
+
use_docker=False,
|
|
101
|
+
docker_image="oaklight/texlive:alpine-science-cn",
|
|
102
|
+
registry_mirror=None,
|
|
103
|
+
):
|
|
104
|
+
"""Start a compilation and return the compile_id.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
project_dir: Absolute path to the project directory.
|
|
108
|
+
main_file: Main .tex file relative to project_dir.
|
|
109
|
+
engine: Compilation engine (pdflatex, lualatex, xelatex).
|
|
110
|
+
use_docker: Whether to use Docker for compilation.
|
|
111
|
+
docker_image: Docker image to use.
|
|
112
|
+
registry_mirror: Optional registry mirror (e.g. "docker.1ms.run").
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
compile_id string.
|
|
116
|
+
"""
|
|
117
|
+
compile_id = uuid.uuid4().hex[:12]
|
|
118
|
+
job = CompileJob(
|
|
119
|
+
compile_id,
|
|
120
|
+
project_dir,
|
|
121
|
+
main_file,
|
|
122
|
+
engine,
|
|
123
|
+
use_docker,
|
|
124
|
+
docker_image,
|
|
125
|
+
registry_mirror=registry_mirror,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
with _jobs_lock:
|
|
129
|
+
_jobs[compile_id] = job
|
|
130
|
+
|
|
131
|
+
thread = threading.Thread(target=_run_compile, args=(job,), daemon=True)
|
|
132
|
+
thread.start()
|
|
133
|
+
return compile_id
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_latexmk_args(engine, main_file):
|
|
137
|
+
"""Build latexmk command arguments."""
|
|
138
|
+
engine_flag = {
|
|
139
|
+
"pdflatex": "-pdf",
|
|
140
|
+
"lualatex": "-lualatex",
|
|
141
|
+
"xelatex": "-xelatex",
|
|
142
|
+
}.get(engine, "-pdf")
|
|
143
|
+
|
|
144
|
+
return [
|
|
145
|
+
"latexmk",
|
|
146
|
+
engine_flag,
|
|
147
|
+
"-cd",
|
|
148
|
+
"-synctex=1",
|
|
149
|
+
"-interaction=nonstopmode",
|
|
150
|
+
"-file-line-error",
|
|
151
|
+
main_file,
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_multipass_cmds(engine, main_file):
|
|
156
|
+
"""Build multi-pass compile commands for direct engine use.
|
|
157
|
+
|
|
158
|
+
Flow: engine → bibtex → engine → engine (3 passes with bibliography).
|
|
159
|
+
"""
|
|
160
|
+
base = os.path.splitext(main_file)[0]
|
|
161
|
+
common_flags = ["-synctex=1", "-interaction=nonstopmode", "-file-line-error"]
|
|
162
|
+
engine_cmd = [engine] + common_flags + [main_file]
|
|
163
|
+
bibtex_cmd = ["bibtex", base]
|
|
164
|
+
return [engine_cmd, bibtex_cmd, engine_cmd, engine_cmd]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_compile(job: CompileJob):
|
|
168
|
+
"""Run the compilation in a background thread."""
|
|
169
|
+
try:
|
|
170
|
+
if job.engine == "latexmk":
|
|
171
|
+
# latexmk auto-detects engine from .latexmkrc or defaults to pdflatex
|
|
172
|
+
cmds = [_build_latexmk_args("pdflatex", job.main_file)]
|
|
173
|
+
elif job.engine in ("pdflatex", "lualatex", "xelatex"):
|
|
174
|
+
if _has_latexmk(job):
|
|
175
|
+
cmds = [_build_latexmk_args(job.engine, job.main_file)]
|
|
176
|
+
else:
|
|
177
|
+
cmds = _build_multipass_cmds(job.engine, job.main_file)
|
|
178
|
+
else:
|
|
179
|
+
cmds = [_build_latexmk_args("pdflatex", job.main_file)]
|
|
180
|
+
|
|
181
|
+
if job.use_docker:
|
|
182
|
+
# Auto-pull image if not available locally
|
|
183
|
+
if not _docker_image_exists(job.docker_image):
|
|
184
|
+
if job.is_cancelled:
|
|
185
|
+
return
|
|
186
|
+
if not _docker_pull(job, job.docker_image, job.registry_mirror):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if job.is_cancelled:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
docker_prefix = [
|
|
193
|
+
"docker",
|
|
194
|
+
"run",
|
|
195
|
+
"--rm",
|
|
196
|
+
"-v",
|
|
197
|
+
f"{job.project_dir}:/workspace",
|
|
198
|
+
"-w",
|
|
199
|
+
"/workspace",
|
|
200
|
+
job.docker_image,
|
|
201
|
+
]
|
|
202
|
+
cmds = [docker_prefix + c for c in cmds]
|
|
203
|
+
|
|
204
|
+
for cmd_idx, cmd in enumerate(cmds):
|
|
205
|
+
if job.is_cancelled:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
job.append_log(f"$ {' '.join(cmd)}", level="info")
|
|
209
|
+
|
|
210
|
+
proc = subprocess.Popen(
|
|
211
|
+
cmd,
|
|
212
|
+
stdout=subprocess.PIPE,
|
|
213
|
+
stderr=subprocess.STDOUT,
|
|
214
|
+
cwd=job.project_dir if not job.use_docker else None,
|
|
215
|
+
text=True,
|
|
216
|
+
bufsize=1,
|
|
217
|
+
)
|
|
218
|
+
job.proc = proc
|
|
219
|
+
|
|
220
|
+
for line in proc.stdout:
|
|
221
|
+
if job.is_cancelled:
|
|
222
|
+
break
|
|
223
|
+
line = line.rstrip("\n")
|
|
224
|
+
level = _classify_log_line(line)
|
|
225
|
+
job.append_log(line, level=level)
|
|
226
|
+
|
|
227
|
+
proc.wait()
|
|
228
|
+
|
|
229
|
+
if job.is_cancelled:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
# bibtex may fail if no .bib — that's okay, continue
|
|
233
|
+
is_bibtex = cmd[-1].endswith((".aux",)) or "bibtex" in cmd
|
|
234
|
+
if proc.returncode != 0 and not is_bibtex:
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# Find output PDF
|
|
238
|
+
base = os.path.splitext(job.main_file)[0]
|
|
239
|
+
pdf_name = base + ".pdf"
|
|
240
|
+
pdf_full = os.path.join(job.project_dir, pdf_name)
|
|
241
|
+
|
|
242
|
+
last_rc = proc.returncode
|
|
243
|
+
if last_rc == 0 and os.path.exists(pdf_full):
|
|
244
|
+
job.finish("success", pdf_path=pdf_name)
|
|
245
|
+
else:
|
|
246
|
+
job.append_log(
|
|
247
|
+
f"Compilation failed (exit code {last_rc})",
|
|
248
|
+
level="error",
|
|
249
|
+
)
|
|
250
|
+
if os.path.exists(pdf_full):
|
|
251
|
+
job.finish("error", pdf_path=pdf_name)
|
|
252
|
+
else:
|
|
253
|
+
job.finish("error")
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
if not job.is_cancelled:
|
|
257
|
+
job.append_log(f"Internal error: {e}", level="error")
|
|
258
|
+
job.finish("error")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _classify_log_line(line):
|
|
262
|
+
"""Classify a log line as info/warning/error."""
|
|
263
|
+
lower = line.lower()
|
|
264
|
+
if "error" in lower or "!" in line[:5]:
|
|
265
|
+
return "error"
|
|
266
|
+
if "warning" in lower or "overfull" in lower or "underfull" in lower:
|
|
267
|
+
return "warning"
|
|
268
|
+
return "info"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _has_latexmk(job):
|
|
272
|
+
"""Check if latexmk is available (in Docker or locally)."""
|
|
273
|
+
try:
|
|
274
|
+
if job.use_docker:
|
|
275
|
+
r = subprocess.run(
|
|
276
|
+
["docker", "run", "--rm", job.docker_image, "which", "latexmk"],
|
|
277
|
+
capture_output=True,
|
|
278
|
+
timeout=15,
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
r = subprocess.run(["which", "latexmk"], capture_output=True, timeout=5)
|
|
282
|
+
return r.returncode == 0
|
|
283
|
+
except Exception:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _docker_image_exists(image):
|
|
288
|
+
"""Check if a Docker image exists locally."""
|
|
289
|
+
try:
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
["docker", "image", "inspect", image],
|
|
292
|
+
capture_output=True,
|
|
293
|
+
timeout=10,
|
|
294
|
+
)
|
|
295
|
+
return result.returncode == 0
|
|
296
|
+
except Exception:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _docker_pull(job, image, registry_mirror=None):
|
|
301
|
+
"""Pull a Docker image, streaming output to the compile job log.
|
|
302
|
+
|
|
303
|
+
If registry_mirror is set, pulls from the mirror and retags to the
|
|
304
|
+
original name.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
True if pull succeeded, False otherwise.
|
|
308
|
+
"""
|
|
309
|
+
if registry_mirror:
|
|
310
|
+
# e.g. "oaklight/texlive:tag" -> "docker.1ms.run/oaklight/texlive:tag"
|
|
311
|
+
pull_image = f"{registry_mirror}/{image}"
|
|
312
|
+
job.append_log(
|
|
313
|
+
f"Image '{image}' not found locally, pulling from mirror {registry_mirror}..."
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
pull_image = image
|
|
317
|
+
job.append_log(f"Image '{image}' not found locally, pulling...")
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
proc = subprocess.Popen(
|
|
321
|
+
["docker", "pull", pull_image],
|
|
322
|
+
stdout=subprocess.PIPE,
|
|
323
|
+
stderr=subprocess.STDOUT,
|
|
324
|
+
text=True,
|
|
325
|
+
bufsize=1,
|
|
326
|
+
)
|
|
327
|
+
job.proc = proc
|
|
328
|
+
|
|
329
|
+
for line in proc.stdout:
|
|
330
|
+
if job.is_cancelled:
|
|
331
|
+
proc.kill()
|
|
332
|
+
proc.wait()
|
|
333
|
+
return False
|
|
334
|
+
job.append_log(line.rstrip("\n"))
|
|
335
|
+
proc.wait()
|
|
336
|
+
|
|
337
|
+
if job.is_cancelled:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
if proc.returncode != 0:
|
|
341
|
+
job.append_log(f"Failed to pull image '{pull_image}'", level="error")
|
|
342
|
+
job.finish("error")
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
# Retag if pulled from mirror
|
|
346
|
+
if registry_mirror and pull_image != image:
|
|
347
|
+
subprocess.run(["docker", "tag", pull_image, image], timeout=10)
|
|
348
|
+
subprocess.run(["docker", "rmi", pull_image], capture_output=True, timeout=10)
|
|
349
|
+
job.append_log(f"Retagged '{pull_image}' -> '{image}'")
|
|
350
|
+
|
|
351
|
+
job.append_log("Image ready.")
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
job.append_log(f"Pull error: {e}", level="error")
|
|
356
|
+
job.finish("error")
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def docker_pull_image(image, registry_mirror=None):
|
|
361
|
+
"""Pull a Docker image (standalone, not tied to a compile job).
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
image: Full image name (e.g. "oaklight/texlive:alpine-science-cn").
|
|
365
|
+
registry_mirror: Optional registry mirror host.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Tuple of (success: bool, message: str).
|
|
369
|
+
"""
|
|
370
|
+
if registry_mirror:
|
|
371
|
+
pull_image = f"{registry_mirror}/{image}"
|
|
372
|
+
else:
|
|
373
|
+
pull_image = image
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
proc = subprocess.Popen(
|
|
377
|
+
["docker", "pull", pull_image],
|
|
378
|
+
stdout=subprocess.PIPE,
|
|
379
|
+
stderr=subprocess.STDOUT,
|
|
380
|
+
text=True,
|
|
381
|
+
)
|
|
382
|
+
with _pull_procs_lock:
|
|
383
|
+
_pull_procs[image] = proc
|
|
384
|
+
|
|
385
|
+
output_lines = []
|
|
386
|
+
for line in proc.stdout:
|
|
387
|
+
output_lines.append(line)
|
|
388
|
+
proc.wait()
|
|
389
|
+
|
|
390
|
+
with _pull_procs_lock:
|
|
391
|
+
_pull_procs.pop(image, None)
|
|
392
|
+
|
|
393
|
+
if proc.returncode != 0:
|
|
394
|
+
stderr = "".join(output_lines).strip()
|
|
395
|
+
if proc.returncode == -9 or proc.returncode == -15:
|
|
396
|
+
return False, "Cancelled"
|
|
397
|
+
return False, stderr or "Pull failed"
|
|
398
|
+
|
|
399
|
+
if registry_mirror and pull_image != image:
|
|
400
|
+
subprocess.run(["docker", "tag", pull_image, image], timeout=10)
|
|
401
|
+
subprocess.run(["docker", "rmi", pull_image], capture_output=True, timeout=10)
|
|
402
|
+
|
|
403
|
+
return True, "OK"
|
|
404
|
+
except subprocess.TimeoutExpired:
|
|
405
|
+
return False, "Pull timed out"
|
|
406
|
+
except Exception as e:
|
|
407
|
+
with _pull_procs_lock:
|
|
408
|
+
_pull_procs.pop(image, None)
|
|
409
|
+
return False, str(e)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Track standalone pull processes for cancellation
|
|
413
|
+
_pull_procs: dict[str, subprocess.Popen] = {}
|
|
414
|
+
_pull_procs_lock = threading.Lock()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def cancel_docker_pull(image):
|
|
418
|
+
"""Cancel a running standalone docker pull.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
True if a pull was found and killed, False otherwise.
|
|
422
|
+
"""
|
|
423
|
+
with _pull_procs_lock:
|
|
424
|
+
proc = _pull_procs.pop(image, None)
|
|
425
|
+
if proc and proc.poll() is None:
|
|
426
|
+
proc.kill()
|
|
427
|
+
return True
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def docker_remove_image(image):
|
|
432
|
+
"""Remove a local Docker image.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
image: Full image name to remove.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Tuple of (success: bool, message: str).
|
|
439
|
+
"""
|
|
440
|
+
try:
|
|
441
|
+
result = subprocess.run(
|
|
442
|
+
["docker", "rmi", image],
|
|
443
|
+
capture_output=True,
|
|
444
|
+
text=True,
|
|
445
|
+
timeout=30,
|
|
446
|
+
)
|
|
447
|
+
if result.returncode == 0:
|
|
448
|
+
return True, "OK"
|
|
449
|
+
return False, result.stderr.strip() or "Remove failed"
|
|
450
|
+
except Exception as e:
|
|
451
|
+
return False, str(e)
|