acadfetch 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.
@@ -0,0 +1,289 @@
1
+ """The transfer engine.
2
+
3
+ Resume is built on three ideas:
4
+
5
+ * bytes always land in ``<name>.part`` and are renamed only after verification,
6
+ so a killer ``Ctrl-C`` never leaves a truncated file looking finished;
7
+ * the validator (ETag / Last-Modified) lives in a sidecar so ``If-Range`` can
8
+ prove the remote object did not change between runs;
9
+ * the retry loop re-uses the partial file instead of restarting, which makes a
10
+ mid-stream socket drop cost at most one chunk.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import os
18
+ import threading
19
+ import time
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from dataclasses import dataclass, replace
22
+ from pathlib import Path
23
+
24
+ from . import naming, verify
25
+ from .client import Client
26
+ from .consts import CHUNK
27
+ from .errors import HttpError, PartialError
28
+ from .progress import Progress
29
+ from .sources.base import File, Record, Result
30
+ from .units import human
31
+
32
+ TRANSFER_ATTEMPTS = 3
33
+ META_SUFFIX = ".part.meta.json"
34
+
35
+
36
+ @dataclass
37
+ class Options:
38
+ out: Path
39
+ concurrency: int = 3
40
+ retries: int = TRANSFER_ATTEMPTS
41
+ dry_run: bool = False
42
+ max_bytes: int | None = None
43
+ progress: Progress | None = None
44
+ verify_only: bool = False
45
+
46
+
47
+ def _read_meta(path: Path) -> dict:
48
+ try:
49
+ return json.loads(path.read_text("utf-8"))
50
+ except (OSError, ValueError):
51
+ return {}
52
+
53
+
54
+ def _write_meta(path: Path, data: dict) -> None:
55
+ tmp = path.with_suffix(".tmp")
56
+ tmp.write_text(json.dumps(data), "utf-8")
57
+ os.replace(tmp, path)
58
+
59
+
60
+ def _drop(*paths: Path) -> None:
61
+ for path in paths:
62
+ with contextlib.suppress(OSError):
63
+ path.unlink()
64
+
65
+
66
+ def _transfer(client: Client, file: File, part: Path, meta_path: Path, opts: Options) -> int:
67
+ """Move bytes into ``part``, resuming if possible. Returns bytes written."""
68
+ meta = _read_meta(meta_path) if part.exists() else {}
69
+ if meta.get("url") not in (None, file.url):
70
+ _drop(part, meta_path)
71
+ meta = {}
72
+
73
+ have = part.stat().st_size if part.exists() else 0
74
+ if file.size is not None and have > file.size:
75
+ _drop(part, meta_path)
76
+ have = 0
77
+
78
+ headers = {}
79
+ if have:
80
+ headers["Range"] = f"bytes={have}-"
81
+ validator = meta.get("etag") or meta.get("last_modified")
82
+ if validator:
83
+ headers["If-Range"] = validator
84
+
85
+ written = 0
86
+ resumed = 0
87
+ try:
88
+ resp = client.open(file.url, headers)
89
+ except HttpError as exc:
90
+ if exc.status == 416 and have:
91
+ return 0 # already complete
92
+ raise
93
+
94
+ with resp:
95
+ status = getattr(resp, "status", 200)
96
+ if have and status == 206:
97
+ mode, offset, resumed = "ab", have, have
98
+ else:
99
+ mode, offset = "wb", 0
100
+ _write_meta(
101
+ meta_path,
102
+ {
103
+ "url": file.url,
104
+ "etag": resp.headers.get("ETag"),
105
+ "last_modified": resp.headers.get("Last-Modified"),
106
+ "size": file.size,
107
+ },
108
+ )
109
+ declared = resp.headers.get("Content-Length")
110
+ with part.open(mode) as fh:
111
+ while True:
112
+ chunk = resp.read(CHUNK)
113
+ if not chunk:
114
+ break
115
+ fh.write(chunk)
116
+ written += len(chunk)
117
+ if opts.progress:
118
+ opts.progress.advance(len(chunk))
119
+ if opts.max_bytes and offset + written > opts.max_bytes:
120
+ raise HttpError(0, file.url, f"exceeds --max-size ({human(opts.max_bytes)})")
121
+ if declared is not None and int(declared) != written:
122
+ raise HttpError(0, file.url, f"short read: got {written}, expected {declared}")
123
+ if resumed:
124
+ return written
125
+ return written
126
+
127
+
128
+ def fetch_file(client: Client, file: File, opts: Options, taken: set[str]) -> Result:
129
+ name = naming.unique(naming.sanitize(file.name), taken)
130
+ final = opts.out / name
131
+ part = opts.out / (name + ".part")
132
+ meta_path = opts.out / (name + META_SUFFIX)
133
+
134
+ if final.exists():
135
+ verdict = verify.matches(final, file.checksum)
136
+ sized_ok = file.size is None or final.stat().st_size == file.size
137
+ if verdict is True or (verdict is None and sized_ok):
138
+ return Result(
139
+ file=file,
140
+ state="skipped",
141
+ path=str(final),
142
+ bytes=final.stat().st_size,
143
+ verified=verdict,
144
+ )
145
+
146
+ if opts.dry_run:
147
+ return Result(file=file, state="skipped", path=str(final), verified=None)
148
+
149
+ resumed_from = part.stat().st_size if part.exists() else 0
150
+ progress = opts.progress
151
+ if progress:
152
+ progress.start(name, file.size)
153
+ try:
154
+ return _attempt(client, file, part, meta_path, final, opts, resumed_from)
155
+ finally:
156
+ if progress:
157
+ progress.stop(name)
158
+
159
+
160
+ def _attempt(
161
+ client: Client,
162
+ file: File,
163
+ part: Path,
164
+ meta_path: Path,
165
+ final: Path,
166
+ opts: Options,
167
+ resumed_from: int,
168
+ ) -> Result:
169
+ """Retry loop, verification and final rename for one file.
170
+
171
+ A 403 that looks like a WAF block page is retried a couple of times with
172
+ the partial file kept, then the file's fallback URLs are tried (a legacy
173
+ endpoint for the same bytes). Everything else terminal fails immediately.
174
+ """
175
+ last: Exception | None = None
176
+ blocked = 0
177
+ candidates = [file.url, *file.fallbacks]
178
+ done = False
179
+ for index, url in enumerate(candidates):
180
+ source = file if url == file.url else replace(file, url=url)
181
+ for attempt in range(opts.retries + 1):
182
+ try:
183
+ _transfer(client, source, part, meta_path, opts)
184
+ done = True
185
+ break
186
+ except HttpError as exc:
187
+ last = exc
188
+ if exc.status == 0 and "exceeds --max-size" in str(exc):
189
+ return Result(file=file, state="failed", error=str(exc))
190
+ if exc.status == 403 and exc.transient and blocked < client.block_retries:
191
+ blocked += 1
192
+ elif exc.status in (401, 403, 404, 410):
193
+ break # terminal for this URL; a fallback may still work
194
+ except (OSError, ConnectionError) as exc:
195
+ last = exc
196
+ if attempt < opts.retries:
197
+ time.sleep(min(2**attempt, 10))
198
+ resumed_from = part.stat().st_size if part.exists() else 0
199
+ if done:
200
+ break
201
+ if index < len(candidates) - 1:
202
+ _drop(part, meta_path)
203
+ resumed_from = 0
204
+ continue
205
+ _drop(part, meta_path)
206
+ return Result(file=file, state="failed", error=str(last))
207
+
208
+ if last is not None and not part.exists():
209
+ return Result(file=file, state="failed", error=str(last))
210
+
211
+ if file.size is not None and part.exists() and part.stat().st_size != file.size:
212
+ return Result(
213
+ file=file,
214
+ state="failed",
215
+ error=f"size mismatch (want {file.size})",
216
+ bytes=part.stat().st_size,
217
+ )
218
+
219
+ verdict = verify.matches(part, file.checksum)
220
+ if verdict is False:
221
+ _drop(part, meta_path)
222
+ try:
223
+ _transfer(client, file, part, meta_path, opts)
224
+ verdict = verify.matches(part, file.checksum)
225
+ except Exception as exc: # noqa: BLE001
226
+ return Result(file=file, state="failed", error=f"re-download failed: {exc}")
227
+ if verdict is False:
228
+ _drop(part, meta_path)
229
+ return Result(file=file, state="failed", error="checksum mismatch")
230
+
231
+ os.replace(part, final)
232
+ _drop(meta_path)
233
+ return Result(
234
+ file=file,
235
+ state="downloaded",
236
+ path=str(final),
237
+ bytes=final.stat().st_size,
238
+ verified=verdict,
239
+ resumed_from=resumed_from,
240
+ )
241
+
242
+
243
+ def fetch_record(
244
+ record: Record, opts: Options, client: Client, only: list[str] | None = None
245
+ ) -> list[Result]:
246
+ opts.out.mkdir(parents=True, exist_ok=True)
247
+ files = record.files
248
+ if only:
249
+ wanted = {n.lower() for n in only}
250
+ files = [
251
+ f
252
+ for f in files
253
+ if naming.sanitize(f.name).lower() in wanted or f.name.lower() in wanted
254
+ ]
255
+ if not files:
256
+ raise PartialError([])
257
+
258
+ if opts.max_bytes:
259
+ _enforce_total(record, opts.max_bytes)
260
+
261
+ taken: set[str] = set()
262
+ lock = threading.Lock()
263
+ results: list[Result] = []
264
+
265
+ if opts.concurrency <= 1:
266
+ for file in files:
267
+ results.append(fetch_file(client, file, opts, taken))
268
+ return results
269
+
270
+ with ThreadPoolExecutor(max_workers=opts.concurrency) as pool:
271
+ futures = {}
272
+ for file in files:
273
+ with lock:
274
+ futures[pool.submit(fetch_file, client, file, opts, taken)] = file
275
+ for future in as_completed(futures):
276
+ results.append(future.result())
277
+ order = {id(f): i for i, f in enumerate(record.files)}
278
+ results.sort(key=lambda r: order.get(id(r.file), 0))
279
+ return results
280
+
281
+
282
+ def _enforce_total(record: Record, limit: int) -> None:
283
+ total = record.total_bytes
284
+ if total is None:
285
+ return
286
+ if total > limit:
287
+ from .errors import SourceError
288
+
289
+ raise SourceError(f"record is {human(total)}, over --max-size ({human(limit)})")
acadfetch/errors.py ADDED
@@ -0,0 +1,43 @@
1
+ """Exception types. Every failure mode gets a distinct exit code path."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AcFError(Exception):
7
+ """Base class for all acadfetch errors."""
8
+
9
+
10
+ class TargetError(AcFError):
11
+ """The target string could not be parsed or resolved."""
12
+
13
+
14
+ class SourceError(AcFError):
15
+ """A repository API returned an unusable response."""
16
+
17
+
18
+ class HttpError(AcFError):
19
+ """An HTTP request failed after all retries.
20
+
21
+ ``transient`` marks a failure that is worth retrying further up, currently
22
+ only a 403 that came from a WAF/load-balancer block page rather than from
23
+ the API's own permission check.
24
+ """
25
+
26
+ def __init__(self, status: int, url: str, detail: str = "", transient: bool = False) -> None:
27
+ self.status = status
28
+ self.url = url
29
+ self.transient = transient
30
+ super().__init__(f"HTTP {status} for {url}{': ' + detail if detail else ''}")
31
+
32
+
33
+ class VerifyError(AcFError):
34
+ """Downloaded bytes do not match the checksum published by the source."""
35
+
36
+
37
+ class PartialError(AcFError):
38
+ """One or more files failed while others succeeded."""
39
+
40
+ def __init__(self, failures) -> None: # noqa: ANN001
41
+ self.failures = list(failures)
42
+ names = ", ".join(f.name for f in self.failures)
43
+ super().__init__(f"{len(self.failures)} file(s) failed: {names}")
acadfetch/manifest.py ADDED
@@ -0,0 +1,89 @@
1
+ """manifest.json: the reproducibility artifact.
2
+
3
+ The point of a manifest is that someone else (or you, in two years) can prove
4
+ the bytes on disk are the bytes that were published, and can tell which
5
+ license they are allowed to redistribute under.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from . import consts, naming, verify
16
+ from .auth import Token
17
+ from .sources.base import Record, Result
18
+
19
+ SCHEMA = 1
20
+
21
+
22
+ def build(record: Record, results: list[Result], token: Token | None = None) -> dict:
23
+ by_url = {r.file.url: r for r in results}
24
+ return {
25
+ "schema": SCHEMA,
26
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
27
+ "tool": {"name": "acadfetch", "version": consts.__version__},
28
+ "source": record.source,
29
+ "target": record.target,
30
+ "doi": record.doi,
31
+ "title": record.title,
32
+ "license": record.license,
33
+ "publisher": record.publisher,
34
+ "version": record.version,
35
+ "landing": record.landing,
36
+ # Provenance only: the variable name and a tail, never the secret.
37
+ "auth": {"env": token.env, "hint": token.hint} if token else None,
38
+ "files": [
39
+ {
40
+ "name": naming.sanitize(f.name),
41
+ "url": f.url,
42
+ "size": f.size,
43
+ "checksum": f.checksum,
44
+ "sha256": _sha256(by_url.get(f.url)),
45
+ "bytes": by_url[f.url].bytes if f.url in by_url else 0,
46
+ "state": by_url[f.url].state if f.url in by_url else "missing",
47
+ "verified": by_url[f.url].verified if f.url in by_url else None,
48
+ }
49
+ for f in record.files
50
+ ],
51
+ }
52
+
53
+
54
+ def _sha256(result: Result | None) -> str | None:
55
+ if result is None or not result.path:
56
+ return None
57
+ path = Path(result.path)
58
+ if not path.is_file():
59
+ return None
60
+ return verify.hash_file(path, "sha256")
61
+
62
+
63
+ def write(path: Path, data: dict) -> None:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ tmp = path.with_suffix(path.suffix + ".tmp")
66
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", "utf-8")
67
+ os.replace(tmp, path)
68
+
69
+
70
+ def load(path: Path) -> dict:
71
+ data = json.loads(path.read_text("utf-8"))
72
+ if data.get("schema") != SCHEMA:
73
+ raise ValueError(f"unsupported manifest schema: {data.get('schema')!r}")
74
+ return data
75
+
76
+
77
+ def verify_manifest(path: Path, root: Path) -> list[dict]:
78
+ """Re-hash every recorded file. Returns one row per file."""
79
+ data = load(path)
80
+ rows = []
81
+ for entry in data.get("files") or []:
82
+ target = root / entry["name"]
83
+ if not target.is_file():
84
+ rows.append({"name": entry["name"], "status": "missing"})
85
+ continue
86
+ actual = verify.hash_file(target, "sha256")
87
+ status = "ok" if actual == entry.get("sha256") else "MISMATCH"
88
+ rows.append({"name": entry["name"], "status": status, "sha256": actual})
89
+ return rows
acadfetch/naming.py ADDED
@@ -0,0 +1,88 @@
1
+ """Turn arbitrary repository file names into safe local paths.
2
+
3
+ Repository metadata is untrusted: it can contain path separators, NULs,
4
+ Windows reserved device names, 300-character names, and duplicates that
5
+ would silently overwrite each other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import unicodedata
13
+ from urllib.parse import unquote, urlsplit
14
+
15
+ MAX_STEM = 120
16
+ MAX_SUFFIX = 20
17
+ MAX_FULL = 180
18
+
19
+ _ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f\x7f]')
20
+ _WS = re.compile(r"[ \t\n\r\f\v]+")
21
+
22
+ # CON, NUL, COM1..9, LPT1..9 are unusable as file names on Windows even with
23
+ # an extension, in every directory.
24
+ _RESERVED = {
25
+ "CON",
26
+ "PRN",
27
+ "AUX",
28
+ "NUL",
29
+ *(f"COM{i}" for i in range(1, 10)),
30
+ *(f"LPT{i}" for i in range(1, 10)),
31
+ }
32
+ _RESERVED_WITH_EXT = {
33
+ f"{base}.{ext}" for base in _RESERVED for ext in ("txt", "tar", "gz", "zip", "csv")
34
+ }
35
+
36
+
37
+ _COMPRESS = {".gz", ".bz2", ".xz", ".zst", ".zip", ".7z", ".rar"}
38
+ _EXT = re.compile(r"\.[A-Za-z0-9][A-Za-z0-9._-]*$")
39
+
40
+
41
+ def _split_ext(name: str) -> tuple[str, str]:
42
+ """Split one extension, or a two-part one for ``archive.tar.gz`` style names."""
43
+ stem, ext = os.path.splitext(name)
44
+ if not ext or len(ext) > MAX_SUFFIX or not _EXT.fullmatch(ext):
45
+ return name, ""
46
+ if ext.lower() in _COMPRESS:
47
+ inner_stem, inner_ext = os.path.splitext(stem)
48
+ if inner_stem and _EXT.fullmatch(inner_ext) and len(inner_ext) <= MAX_SUFFIX:
49
+ return inner_stem, inner_ext + ext
50
+ return stem, ext
51
+
52
+
53
+ def sanitize(name: str, fallback: str = "file") -> str:
54
+ """Reduce ``name`` to a single safe path component."""
55
+ raw = unquote(name or "")
56
+ raw = urlsplit(raw).path if ("://" in raw or raw.count("/") > 1) else raw
57
+ raw = raw.replace("\\", "/").rsplit("/", 1)[-1]
58
+ raw = unicodedata.normalize("NFC", raw)
59
+ raw = _WS.sub(" ", raw) # tabs/newlines become spaces, not underscores
60
+ raw = _ILLEGAL.sub("_", raw)
61
+ raw = _WS.sub(" ", raw).strip()
62
+ raw = raw.strip(". ")
63
+
64
+ stem, ext = _split_ext(raw)
65
+ stem = stem.strip(". ") or fallback
66
+ if stem.upper() in _RESERVED or f"{stem}{ext}".upper() in _RESERVED_WITH_EXT:
67
+ stem = "_" + stem
68
+ if len(stem) > MAX_STEM:
69
+ stem = stem[:MAX_STEM].rstrip(". ")
70
+ out = stem + ext[:MAX_SUFFIX]
71
+ if len(out) > MAX_FULL:
72
+ keep = MAX_FULL - len(ext[:MAX_SUFFIX])
73
+ out = stem[:keep].rstrip(". ") + ext[:MAX_SUFFIX]
74
+ return out or fallback
75
+
76
+
77
+ def unique(name: str, taken: set[str]) -> str:
78
+ """Return a name not already in ``taken`` (case-insensitively)."""
79
+ if name.lower() not in taken:
80
+ taken.add(name.lower())
81
+ return name
82
+ stem, ext = os.path.splitext(name)
83
+ for i in range(2, 10000):
84
+ candidate = f"{stem} ({i}){ext}"
85
+ if candidate.lower() not in taken:
86
+ taken.add(candidate.lower())
87
+ return candidate
88
+ raise RuntimeError(f"could not dedupe {name!r}")
acadfetch/progress.py ADDED
@@ -0,0 +1,161 @@
1
+ """Aggregate transfer progress.
2
+
3
+ One line for the whole run, not one per file: with ``-j 3`` three downloads are
4
+ in flight and three interleaved lines are unreadable. On a TTY the line is
5
+ redrawn in place; when stdout is a pipe (CI, ``tee``) it degrades to occasional
6
+ whole lines so the log stays greppable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from collections.abc import Callable
16
+ from typing import TextIO
17
+
18
+ from .units import duration, human
19
+
20
+ SMOOTHING_SECONDS = 1.0
21
+ TTY_INTERVAL = 0.15
22
+ PIPE_INTERVAL = 5.0
23
+ MAX_WIDTH = 120
24
+ NAME_WIDTH = 40
25
+
26
+
27
+ def _clip(text: str, width: int) -> str:
28
+ return text if len(text) <= width else text[: width - 1] + "…"
29
+
30
+
31
+ class Progress:
32
+ def __init__(
33
+ self,
34
+ stream: TextIO | None = None,
35
+ enabled: bool = True,
36
+ is_tty: bool | None = None,
37
+ clock: Callable[[], float] = time.monotonic,
38
+ ) -> None:
39
+ self.stream = stream if stream is not None else sys.stdout
40
+ self.enabled = enabled
41
+ self.is_tty = (self.stream.isatty() if is_tty is None else is_tty) and enabled
42
+ self._clock = clock
43
+ self._lock = threading.RLock() # _render() re-enters under _maybe_emit
44
+ self._active: dict[str, int] = {}
45
+ self._done = 0
46
+ self._total = 0
47
+ self._samples: deque[tuple[float, int]] = deque()
48
+ self._last_emit = 0.0
49
+ self._printed = 0
50
+ self._last_count = 0
51
+ self._dirty = False
52
+ self._wrote = False
53
+
54
+ # -- structure ---------------------------------------------------------
55
+ def start(self, name: str, size: int | None) -> None:
56
+ if not self.enabled:
57
+ return
58
+ with self._lock:
59
+ self._active[name] = size or 0
60
+ self._total += size or 0
61
+ self._sample()
62
+ self._maybe_emit(force=True)
63
+
64
+ def stop(self, name: str) -> None:
65
+ if not self.enabled:
66
+ return
67
+ self._maybe_emit(force=True) # one last frame that still counts the file
68
+ with self._lock:
69
+ self._active.pop(name, None)
70
+
71
+ def advance(self, count: int) -> None:
72
+ if not self.enabled or count <= 0:
73
+ return
74
+ with self._lock:
75
+ self._done += count
76
+ self._sample()
77
+ self._maybe_emit()
78
+
79
+ # -- rendering ---------------------------------------------------------
80
+ def _sample(self) -> None:
81
+ now = self._clock()
82
+ self._samples.append((now, self._done))
83
+ while len(self._samples) > 2 and now - self._samples[0][0] > SMOOTHING_SECONDS * 4:
84
+ self._samples.popleft()
85
+ self._dirty = True
86
+
87
+ def _speed(self) -> float:
88
+ if len(self._samples) < 2:
89
+ return 0.0
90
+ (t0, b0), (t1, b1) = self._samples[0], self._samples[-1]
91
+ span = t1 - t0
92
+ if span < 0.2:
93
+ return 0.0
94
+ return max(0.0, (b1 - b0) / span)
95
+
96
+ def _eta(self, speed: float) -> float | None:
97
+ if not self._total or speed <= 0:
98
+ return None
99
+ return max(0.0, (self._total - self._done) / speed)
100
+
101
+ def _render(self) -> str:
102
+ speed = self._speed()
103
+ with self._lock:
104
+ active = list(self._active)
105
+ # After the last file stops there is nothing active; keep showing how
106
+ # many files the run covered instead of dropping to "0 file(s)".
107
+ count = len(active) or self._last_count
108
+ self._last_count = count
109
+ parts = [f"{count} file(s)", f"{human(self._done)}"]
110
+ if len(active) == 1:
111
+ parts[0] = _clip(active[0], NAME_WIDTH)
112
+ if self._total:
113
+ parts[1] = f"{human(self._done)}/{human(self._total)}"
114
+ if speed > 0:
115
+ parts.append(f"{human(speed)}/s")
116
+ eta = self._eta(speed)
117
+ if eta is not None:
118
+ parts.append(f"ETA {duration(eta)}")
119
+ line = " ".join(parts)
120
+ return line[:MAX_WIDTH]
121
+
122
+ def _maybe_emit(self, force: bool = False) -> None:
123
+ if not self.enabled:
124
+ return
125
+ now = self._clock()
126
+ interval = TTY_INTERVAL if self.is_tty else PIPE_INTERVAL
127
+ with self._lock:
128
+ if not self._dirty:
129
+ return
130
+ if not force and now - self._last_emit < interval:
131
+ return
132
+ line = self._render()
133
+ self._last_emit = now
134
+ self._dirty = False
135
+ self._write(line, newline=not self.is_tty)
136
+
137
+ def _write(self, line: str, newline: bool) -> None:
138
+ try:
139
+ if self.is_tty:
140
+ pad = max(0, self._printed - len(line))
141
+ self.stream.write("\r" + line + " " * pad)
142
+ self._printed = len(line)
143
+ else:
144
+ self.stream.write(line + ("\n" if newline else ""))
145
+ self.stream.flush()
146
+ self._wrote = True
147
+ except (OSError, ValueError): # stream closed under us
148
+ self.enabled = False
149
+
150
+ def finish(self) -> None:
151
+ if not self.enabled:
152
+ return
153
+ # A run where every file was already on disk never started one, so a
154
+ # frame here would only say "0 file(s) 0 B" right before the tally.
155
+ if self._wrote:
156
+ line = self._render()
157
+ self._write(line, newline=True)
158
+ if self.is_tty:
159
+ self.stream.write("\n")
160
+ self.stream.flush()
161
+ self.enabled = False
acadfetch/py.typed ADDED
File without changes