pulli 0.2.1__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.
- pulli/__init__.py +2 -0
- pulli/cli.py +316 -0
- pulli/discovery.py +541 -0
- pulli/pull.py +276 -0
- pulli/spinner.py +101 -0
- pulli/status.py +441 -0
- pulli/tree.py +447 -0
- pulli-0.2.1.dist-info/METADATA +207 -0
- pulli-0.2.1.dist-info/RECORD +12 -0
- pulli-0.2.1.dist-info/WHEEL +4 -0
- pulli-0.2.1.dist-info/entry_points.txt +2 -0
- pulli-0.2.1.dist-info/licenses/LICENSE +21 -0
pulli/status.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Collect git status for each discovered repo.
|
|
2
|
+
|
|
3
|
+
For each repo we run a small set of git commands to get:
|
|
4
|
+
- branch (current ref name)
|
|
5
|
+
- ahead/behind vs upstream
|
|
6
|
+
- dirty (uncommitted changes)
|
|
7
|
+
- remote url (for display)
|
|
8
|
+
|
|
9
|
+
We avoid `git status -sb` parsing quirks by using porcelain + rev-list.
|
|
10
|
+
|
|
11
|
+
The single most important correctness rule in this module: **behind/ahead
|
|
12
|
+
must come from one rev-list invocation.** A previous version ran
|
|
13
|
+
`rev-list @{u}...HEAD` and then `rev-list HEAD..@{u}` as two separate git
|
|
14
|
+
calls. If the remote moved in between (or a fetch interleaved), the two
|
|
15
|
+
halves describe different worlds and the result is a bogus ahead/behind
|
|
16
|
+
pair. `rev-list --left-right --count A...B` answers both sides
|
|
17
|
+
atomically in one process, so that is the only form we use.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .discovery import RepoNode, iter_repos
|
|
31
|
+
from .spinner import Spinner
|
|
32
|
+
|
|
33
|
+
# ── shared git plumbing ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
#: Fetch exit codes that only mean "the network/remote was unavailable".
|
|
36
|
+
#: Anything else (bad config, broken objects, permission denied) is a real
|
|
37
|
+
#: failure and should be surfaced.
|
|
38
|
+
_OFFLINE_FETCH_CODES = frozenset({
|
|
39
|
+
5, # couldn't read Username / auth prompt failed
|
|
40
|
+
7, # couldn't connect
|
|
41
|
+
17, # remote end hung up unexpectedly
|
|
42
|
+
23, # write error
|
|
43
|
+
28, # operation timed out
|
|
44
|
+
35, # SSL connect error
|
|
45
|
+
56, # connection reset by peer
|
|
46
|
+
58, # could not read from remote repository
|
|
47
|
+
65, # no match for destination
|
|
48
|
+
68, # ssh: could not resolve hostname
|
|
49
|
+
69, # ssh: no route to host
|
|
50
|
+
110, # connection timed out
|
|
51
|
+
111, # connection refused
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
_env_lock = threading.Lock()
|
|
55
|
+
_env_cache: dict[str, str] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def git_env() -> dict[str, str]:
|
|
59
|
+
"""A process-wide git environment that never blocks on prompts.
|
|
60
|
+
|
|
61
|
+
Built once and cached: the subprocess API is global state, so mutating
|
|
62
|
+
os.environ per-call would race across the fetch threads. Read-only
|
|
63
|
+
checkouts must not abort because a remote is unreachable.
|
|
64
|
+
"""
|
|
65
|
+
global _env_cache
|
|
66
|
+
with _env_lock:
|
|
67
|
+
if _env_cache is None:
|
|
68
|
+
env = dict(os.environ)
|
|
69
|
+
# Non-interactive: never block a fetch on a credential prompt.
|
|
70
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
71
|
+
env["GIT_ASKPASS"] = env.get("GIT_ASKPASS", "echo")
|
|
72
|
+
env["SSH_ASKPASS"] = env.get("SSH_ASKPASS", "echo")
|
|
73
|
+
# Hanging the SSH connection is better than a 30s frozen tree.
|
|
74
|
+
# We still bound the subprocess with a timeout.
|
|
75
|
+
env.setdefault("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
|
|
76
|
+
# Deterministic, locale-independent parsing of git output.
|
|
77
|
+
env.setdefault("LC_ALL", "C")
|
|
78
|
+
_env_cache = env
|
|
79
|
+
return _env_cache
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def _push_env() -> Iterator[None]:
|
|
84
|
+
"""Temporarily apply the sanitized git environment to os.environ.
|
|
85
|
+
|
|
86
|
+
Only for strictly sequential code paths. Everywhere else (fetch and
|
|
87
|
+
status both run in a thread pool) the env is passed per-subprocess:
|
|
88
|
+
mutating os.environ there would race across threads and leak
|
|
89
|
+
GIT_TERMINAL_PROMPT into unrelated processes.
|
|
90
|
+
"""
|
|
91
|
+
wanted = git_env()
|
|
92
|
+
saved = {k: os.environ.get(k) for k in wanted}
|
|
93
|
+
os.environ.update(wanted)
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
finally:
|
|
97
|
+
for k, v in saved.items():
|
|
98
|
+
if v is None:
|
|
99
|
+
os.environ.pop(k, None)
|
|
100
|
+
else:
|
|
101
|
+
os.environ[k] = v
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_git(
|
|
105
|
+
repo: Path,
|
|
106
|
+
*args: str,
|
|
107
|
+
timeout: int = 30,
|
|
108
|
+
env: dict[str, str] | None = None,
|
|
109
|
+
) -> tuple[int, str, str]:
|
|
110
|
+
"""Run a git command in `repo`, return (returncode, stdout, stderr)."""
|
|
111
|
+
try:
|
|
112
|
+
r = subprocess.run(
|
|
113
|
+
["git", *args],
|
|
114
|
+
cwd=str(repo),
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
env=env if env is not None else git_env(),
|
|
119
|
+
)
|
|
120
|
+
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
return 128, "", f"git timed out after {timeout}s"
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return 127, "", "git executable not found"
|
|
125
|
+
except OSError as e: # e.g. cwd vanished between discovery and now
|
|
126
|
+
return 128, "", str(e)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _git_one(repo: Path, *args: str, env: dict[str, str] | None = None) -> str:
|
|
130
|
+
"""Run git, return stdout (stripped) or '' on failure."""
|
|
131
|
+
rc, out, _ = run_git(repo, *args, env=env)
|
|
132
|
+
return out if rc == 0 else ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── remote urls ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _remote_url(repo: Path, env: dict[str, str] | None = None) -> str:
|
|
139
|
+
"""URL of the preferred remote, or '' when there is none."""
|
|
140
|
+
url = _git_one(repo, "remote", "get-url", "origin", env=env)
|
|
141
|
+
if url:
|
|
142
|
+
return url
|
|
143
|
+
# No `origin` (fresh `git init`, a renamed remote, a submodule whose
|
|
144
|
+
# remote is only recorded in .gitmodules). Fall back to the first
|
|
145
|
+
# configured remote so the tree is still informative.
|
|
146
|
+
out = _git_one(repo, "remote", env=env)
|
|
147
|
+
for name in out.splitlines():
|
|
148
|
+
name = name.strip()
|
|
149
|
+
if not name:
|
|
150
|
+
continue
|
|
151
|
+
url = _git_one(repo, "remote", "get-url", name, env=env)
|
|
152
|
+
if url:
|
|
153
|
+
return url
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── fetch ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _classify_fetch(rc: int, err: str) -> str | None:
|
|
161
|
+
"""Return None if the fetch genuinely succeeded, else a short reason."""
|
|
162
|
+
if rc == 0:
|
|
163
|
+
return None
|
|
164
|
+
if rc == 128:
|
|
165
|
+
low = err.lower()
|
|
166
|
+
# A hung/failed remote is *not* a repo problem — the tree can still be
|
|
167
|
+
# built from local refs, and pulli stays useful offline.
|
|
168
|
+
if any(s in low for s in ("timed out", "timeout", "could not resolve",
|
|
169
|
+
"connection", "network", "unreachable",
|
|
170
|
+
"no route to host", "couldn't read",
|
|
171
|
+
"authentication", "permission denied (publickey)",
|
|
172
|
+
"could not read from remote", "early eof",
|
|
173
|
+
"the remote end hung up", "unable to access")):
|
|
174
|
+
return "unreachable"
|
|
175
|
+
if "not a git repository" in low or "does not appear" in low:
|
|
176
|
+
return "not a git repository"
|
|
177
|
+
return err.splitlines()[0] if err else "fetch failed"
|
|
178
|
+
if rc in _OFFLINE_FETCH_CODES:
|
|
179
|
+
return "unreachable"
|
|
180
|
+
return err.splitlines()[0] if err else f"fetch failed (exit {rc})"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def fetch_all(
|
|
184
|
+
nodes: list[RepoNode],
|
|
185
|
+
*,
|
|
186
|
+
timeout: float = 8.0,
|
|
187
|
+
quiet: bool = False,
|
|
188
|
+
workers: int = 8,
|
|
189
|
+
use_color: bool = True,
|
|
190
|
+
) -> list[RepoNode]:
|
|
191
|
+
"""Fetch every repo so ahead/behind reflects the remote, not the last
|
|
192
|
+
local fetch.
|
|
193
|
+
|
|
194
|
+
Fetches run in a bounded thread pool (the default spawns one OS thread
|
|
195
|
+
per repo, which is a lot for a 50-repo tree) and each one is bounded by
|
|
196
|
+
`timeout`. Returns the list of repos whose fetch failed; `node.error` is
|
|
197
|
+
filled in with a short reason, so callers can tell "offline" (harmless)
|
|
198
|
+
from "broken repo" (worth reporting).
|
|
199
|
+
|
|
200
|
+
When stderr is a TTY, a spinner animates on stderr with live progress
|
|
201
|
+
("Fetching remotes… 3/8"), so a multi-second fetch never looks frozen.
|
|
202
|
+
Off a TTY it is a no-op, so piped/captured output stays byte-clean.
|
|
203
|
+
"""
|
|
204
|
+
if not nodes:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
candidates = [n for n in nodes if not n.error]
|
|
208
|
+
failed: list[RepoNode] = []
|
|
209
|
+
lock = threading.Lock()
|
|
210
|
+
env = git_env()
|
|
211
|
+
|
|
212
|
+
spinner = Spinner(f"Fetching remotes… 0/{len(candidates)}", use_color=use_color)
|
|
213
|
+
done = 0
|
|
214
|
+
|
|
215
|
+
def _do(node: RepoNode) -> None:
|
|
216
|
+
nonlocal done
|
|
217
|
+
rc, _, err = run_git(node.path, "fetch", "--quiet", "--prune",
|
|
218
|
+
timeout=timeout, env=env)
|
|
219
|
+
reason = _classify_fetch(rc, err)
|
|
220
|
+
if reason is None:
|
|
221
|
+
pass
|
|
222
|
+
else:
|
|
223
|
+
if not quiet:
|
|
224
|
+
print(f" fetch failed: {node.rel} — {reason}", file=sys.stderr)
|
|
225
|
+
with lock:
|
|
226
|
+
# An unreachable remote is expected (offline, VPN, credentials)
|
|
227
|
+
# and is NOT a broken repo. Recording it as `error` would make
|
|
228
|
+
# pulli report a perfectly healthy repo as broken and exit
|
|
229
|
+
# non-zero, which is exactly wrong on a train.
|
|
230
|
+
node.fetch_failed = True
|
|
231
|
+
node.fetch_reason = reason
|
|
232
|
+
failed.append(node)
|
|
233
|
+
with lock:
|
|
234
|
+
done += 1
|
|
235
|
+
spinner.update(f"Fetching remotes… {done}/{len(candidates)}")
|
|
236
|
+
|
|
237
|
+
spinner.start()
|
|
238
|
+
try:
|
|
239
|
+
n_workers = max(1, min(workers, len(candidates)))
|
|
240
|
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
241
|
+
# pool.map re-raises; _do never raises (git failures are captured
|
|
242
|
+
# as return codes), so consume the iterator to completion.
|
|
243
|
+
for _ in pool.map(_do, candidates):
|
|
244
|
+
pass
|
|
245
|
+
finally:
|
|
246
|
+
spinner.stop()
|
|
247
|
+
return failed
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── streaming status ────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def fetch_and_status(
|
|
254
|
+
nodes: list[RepoNode],
|
|
255
|
+
*,
|
|
256
|
+
timeout: float = 8.0,
|
|
257
|
+
quiet: bool = False,
|
|
258
|
+
workers: int = 8,
|
|
259
|
+
use_color: bool = True,
|
|
260
|
+
fetch: bool = True,
|
|
261
|
+
on_done=None,
|
|
262
|
+
) -> list[RepoNode]:
|
|
263
|
+
"""Fetch + collect status for every repo, streaming results as they land.
|
|
264
|
+
|
|
265
|
+
Each repo is processed by its own worker: fetch (unless `fetch=False`),
|
|
266
|
+
then collect status, then `on_done(node)` is called so a live renderer
|
|
267
|
+
can show the repo's line the moment it is ready — instead of waiting for
|
|
268
|
+
the slowest remote and printing everything at once.
|
|
269
|
+
|
|
270
|
+
Returns the list of repos whose fetch failed (unreachable / offline),
|
|
271
|
+
the same contract as `fetch_all`. `on_done` is called from worker
|
|
272
|
+
threads, so it must be thread-safe (the `LiveTree` locks internally).
|
|
273
|
+
"""
|
|
274
|
+
if not nodes:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
candidates = [n for n in nodes if not n.error]
|
|
278
|
+
failed: list[RepoNode] = []
|
|
279
|
+
lock = threading.Lock()
|
|
280
|
+
env = git_env()
|
|
281
|
+
|
|
282
|
+
def _do(node: RepoNode) -> None:
|
|
283
|
+
if fetch:
|
|
284
|
+
rc, _, err = run_git(node.path, "fetch", "--quiet", "--prune",
|
|
285
|
+
timeout=timeout, env=env)
|
|
286
|
+
reason = _classify_fetch(rc, err)
|
|
287
|
+
if reason is not None:
|
|
288
|
+
if not quiet:
|
|
289
|
+
print(f" fetch failed: {node.rel} — {reason}", file=sys.stderr)
|
|
290
|
+
with lock:
|
|
291
|
+
node.fetch_failed = True
|
|
292
|
+
node.fetch_reason = reason
|
|
293
|
+
failed.append(node)
|
|
294
|
+
_collect_one(node)
|
|
295
|
+
if on_done is not None:
|
|
296
|
+
on_done(node)
|
|
297
|
+
|
|
298
|
+
n_workers = max(1, min(workers, len(candidates)))
|
|
299
|
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
300
|
+
for _ in pool.map(_do, candidates):
|
|
301
|
+
pass
|
|
302
|
+
return failed
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ── status collection ────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def collect_status(root: RepoNode, workers: int = 8) -> None:
|
|
309
|
+
"""Fill in branch/ahead/behind/dirty/upstream/error on every repo below
|
|
310
|
+
`root` (and on `root` itself if it is one).
|
|
311
|
+
|
|
312
|
+
Repos are inspected concurrently: the work is a handful of small git
|
|
313
|
+
calls per repo, so it is entirely I/O- and process-bound, and doing it
|
|
314
|
+
sequentially made a 50-repo tree visibly sluggish. Each repo writes only
|
|
315
|
+
to its own node, so no locking is needed.
|
|
316
|
+
"""
|
|
317
|
+
nodes = list(iter_repos(root))
|
|
318
|
+
if not nodes:
|
|
319
|
+
return
|
|
320
|
+
if len(nodes) == 1 or workers <= 1:
|
|
321
|
+
for n in nodes:
|
|
322
|
+
_collect_one(n)
|
|
323
|
+
return
|
|
324
|
+
n_workers = max(1, min(workers, len(nodes)))
|
|
325
|
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
326
|
+
for _ in pool.map(_collect_one, nodes):
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _short_reason(err: str) -> str:
|
|
331
|
+
"""First meaningful line of a git error, for a status line."""
|
|
332
|
+
for line in (err or "").splitlines():
|
|
333
|
+
s = line.strip()
|
|
334
|
+
if s and not s.lower().startswith("hint:"):
|
|
335
|
+
return s
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _collect_one(node: RepoNode) -> None:
|
|
340
|
+
"""Inspect one repo and fill in its status fields."""
|
|
341
|
+
repo = node.path
|
|
342
|
+
# _push_env mutates os.environ, which is process-global, so it cannot be
|
|
343
|
+
# used from the thread pool. Pass the sanitized env explicitly instead.
|
|
344
|
+
_collect_one_locked(node, repo, env=git_env())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _collect_one_locked(node: RepoNode, repo: Path, env: dict[str, str]) -> None:
|
|
348
|
+
rc, _, err = run_git(repo, "rev-parse", "--is-inside-work-tree", env=env)
|
|
349
|
+
if rc != 0:
|
|
350
|
+
node.error = _short_reason(err) or "not a git work tree"
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
node.url = _remote_url(repo, env)
|
|
354
|
+
|
|
355
|
+
# Branch / ref name. HEAD may be detached.
|
|
356
|
+
branch = _git_one(repo, "symbolic-ref", "--quiet", "--short", "HEAD", env=env)
|
|
357
|
+
if not branch:
|
|
358
|
+
# detached HEAD — show short sha
|
|
359
|
+
sha = _git_one(repo, "rev-parse", "--short", "HEAD", env=env)
|
|
360
|
+
node.branch = f"({sha})" if sha else "(detached)"
|
|
361
|
+
else:
|
|
362
|
+
node.branch = branch
|
|
363
|
+
|
|
364
|
+
# Upstream tracking ref, e.g. origin/main
|
|
365
|
+
upstream = _git_one(
|
|
366
|
+
repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", env=env
|
|
367
|
+
)
|
|
368
|
+
node.upstream = upstream or None
|
|
369
|
+
|
|
370
|
+
# ahead/behind vs upstream — ONE rev-list for both sides, so the two
|
|
371
|
+
# numbers can never describe different revisions.
|
|
372
|
+
if upstream:
|
|
373
|
+
counts = _git_one(repo, "rev-list", "--left-right", "--count",
|
|
374
|
+
f"{upstream}...HEAD", env=env)
|
|
375
|
+
if counts:
|
|
376
|
+
parts = counts.split()
|
|
377
|
+
if len(parts) == 2:
|
|
378
|
+
# `rev-list --left-right --count A...B` prints "<left>\t<right>"
|
|
379
|
+
# i.e. "behind\tahead" for A=upstream, B=HEAD.
|
|
380
|
+
try:
|
|
381
|
+
node.behind, node.ahead = int(parts[0]), int(parts[1])
|
|
382
|
+
except ValueError:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
# in-progress merge/rebase/bisect: pulling is at best rude, at worst
|
|
386
|
+
# corrupting. Surface it as dirty so every consumer skips the repo.
|
|
387
|
+
state = _git_one(repo, "rev-parse", "--git-path", "MERGE_HEAD", env=env)
|
|
388
|
+
if state and (repo / state).exists():
|
|
389
|
+
node.operation = "merge in progress"
|
|
390
|
+
else:
|
|
391
|
+
for marker, label in (
|
|
392
|
+
("rebase-merge", "rebase in progress"),
|
|
393
|
+
("rebase-apply", "rebase in progress"),
|
|
394
|
+
("rebase-merge-interactive", "rebase in progress"),
|
|
395
|
+
("BISECT_LOG", "bisect in progress"),
|
|
396
|
+
("CHERRY_PICK_HEAD", "cherry-pick in progress"),
|
|
397
|
+
("REVERT_HEAD", "revert in progress"),
|
|
398
|
+
):
|
|
399
|
+
p = _git_one(repo, "rev-parse", "--git-path", marker, env=env)
|
|
400
|
+
if p and (repo / p).exists():
|
|
401
|
+
node.operation = label
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
# dirty? porcelain status; parse the file paths for display.
|
|
405
|
+
rc, out, _ = run_git(repo, "status", "--porcelain", env=env)
|
|
406
|
+
if rc == 0:
|
|
407
|
+
node.dirty_files = [_porcelain_path(l) for l in out.splitlines() if l.strip()]
|
|
408
|
+
node.dirty = bool(node.dirty_files)
|
|
409
|
+
else:
|
|
410
|
+
node.dirty = None
|
|
411
|
+
node.dirty_files = []
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _porcelain_path(line: str) -> str:
|
|
415
|
+
"""Path from a `git status --porcelain` line (`XY PATH` or
|
|
416
|
+
`XY OLD -> NEW`); strips git's quoting and rename source. Works on
|
|
417
|
+
stripped lines (leading status-column space may already be gone)."""
|
|
418
|
+
s = line.strip()
|
|
419
|
+
path = s[2:].lstrip() if len(s) > 2 else s
|
|
420
|
+
if " -> " in path:
|
|
421
|
+
path = path.split(" -> ", 1)[1]
|
|
422
|
+
return _unquote(path.strip())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _unquote(s: str) -> str:
|
|
426
|
+
"""Decode git's C-style quoting (`"a b\\t.txt"`) used for odd paths."""
|
|
427
|
+
if len(s) < 2 or not s.startswith('"') or not s.endswith('"'):
|
|
428
|
+
return s
|
|
429
|
+
body = s[1:-1]
|
|
430
|
+
out: list[str] = []
|
|
431
|
+
i = 0
|
|
432
|
+
while i < len(body):
|
|
433
|
+
c = body[i]
|
|
434
|
+
if c == "\\" and i + 1 < len(body):
|
|
435
|
+
nxt = body[i + 1]
|
|
436
|
+
out.append({"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\"}.get(nxt, nxt))
|
|
437
|
+
i += 2
|
|
438
|
+
else:
|
|
439
|
+
out.append(c)
|
|
440
|
+
i += 1
|
|
441
|
+
return "".join(out)
|