git-ftp 2.0.0.dev0__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.
gitftp/mirror.py ADDED
@@ -0,0 +1,413 @@
1
+ """Native download / pull / snapshot (upstream used ``lftp mirror``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from gitftp import deploy
11
+ from gitftp.changeset import remote_path
12
+ from gitftp.errors import DownloadError, FilesystemError, GitError, UsageError
13
+ from gitftp.gitrepo import GitRepo
14
+ from gitftp.ignore import IgnoreRules
15
+ from gitftp.lock import LOCK_FILE, RemoteLock
16
+ from gitftp.options import CliOptions
17
+ from gitftp.output import Output
18
+ from gitftp.session import Session, open_session
19
+ from gitftp.transfer import DownloadTask, TransferError, TransferPool
20
+ from gitftp.transport import registry
21
+ from gitftp.transport.base import Entry, RemoteNotFound, Transport
22
+
23
+ PROTECTED = frozenset({".git", ".git-ftp-ignore", ".git-ftp-include", ".git-ftp-config", LOCK_FILE})
24
+ PART_SUFFIX = ".git-ftp-part"
25
+
26
+
27
+ @dataclass
28
+ class MirrorOptions:
29
+ dry_run: bool = False
30
+ changed_only: bool = False
31
+ lock: bool = False
32
+ force: bool = False
33
+ no_commit: bool = False
34
+
35
+ @classmethod
36
+ def from_cli(cls, opts: CliOptions) -> MirrorOptions:
37
+ return cls(
38
+ dry_run=opts.dry_run,
39
+ changed_only=opts.changed_only,
40
+ lock=opts.lock,
41
+ force=opts.force,
42
+ no_commit=opts.no_commit,
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class MirrorPlan:
48
+ downloads: list[DownloadTask] = field(default_factory=list)
49
+ local_deletes: list[Path] = field(default_factory=list)
50
+ mkdirs: list[Path] = field(default_factory=list)
51
+ probes: list[str] = field(default_factory=list)
52
+
53
+
54
+ # -- scanning -----------------------------------------------------------------
55
+ def scan_remote(pool: TransferPool, out: Output) -> dict[str, Entry]:
56
+ """Recursive listing: ``rel`` -> Entry, directories keyed with a trailing '/'."""
57
+ result: dict[str, Entry] = {}
58
+ level = [""]
59
+ while level:
60
+
61
+ def list_one(t: Transport, d: str) -> list[Entry]:
62
+ return t.list_dir(d)
63
+
64
+ listings = pool.map(list_one, level, fail_fast=True, label=lambda d: d or "/")
65
+ next_level: list[str] = []
66
+ for directory, entries in zip(level, listings, strict=True):
67
+ if not isinstance(entries, list):
68
+ continue
69
+ for e in entries:
70
+ rel = f"{directory}{e.name}"
71
+ if e.is_dir:
72
+ result[rel + "/"] = e
73
+ next_level.append(rel + "/")
74
+ else:
75
+ result[rel] = e
76
+ level = next_level
77
+ out.debug(f"Listed {len(result)} remote entries so far.")
78
+ return result
79
+
80
+
81
+ def scan_local(base: Path) -> dict[str, os.stat_result]:
82
+ result: dict[str, os.stat_result] = {}
83
+ if not base.is_dir():
84
+ return result
85
+ for dirpath, dirnames, filenames in os.walk(base):
86
+ dirnames[:] = [d for d in dirnames if d != ".git"]
87
+ rel_dir = Path(dirpath).relative_to(base).as_posix()
88
+ prefix = "" if rel_dir == "." else rel_dir + "/"
89
+ for d in dirnames:
90
+ result[f"{prefix}{d}/"] = os.lstat(Path(dirpath, d))
91
+ for f in filenames:
92
+ result[f"{prefix}{f}"] = os.lstat(Path(dirpath, f))
93
+ return result
94
+
95
+
96
+ def _excluded(rel: str, syncroot: str, ignore: IgnoreRules, deployed_file: str) -> bool:
97
+ parts = rel.strip("/").split("/")
98
+ if any(p == ".git" for p in parts):
99
+ return True
100
+ if rel in (deployed_file, deployed_file + "/"):
101
+ return True
102
+ if parts[-1] in PROTECTED and len(parts) == 1:
103
+ return True
104
+ if parts[-1].endswith(PART_SUFFIX):
105
+ return True
106
+ candidate = rel.rstrip("/")
107
+ return ignore.matches(syncroot + candidate) or ignore.matches(candidate)
108
+
109
+
110
+ def build_plan(
111
+ remote: dict[str, Entry],
112
+ local: dict[str, os.stat_result],
113
+ *,
114
+ base: Path,
115
+ syncroot: str,
116
+ ignore: IgnoreRules,
117
+ deployed_file: str,
118
+ only: set[str] | None,
119
+ allow_delete: bool,
120
+ protected: set[str] | None = None,
121
+ ) -> MirrorPlan:
122
+ plan = MirrorPlan()
123
+ protected = protected or set()
124
+ conflicts: list[Path] = []
125
+ for rel, entry in remote.items():
126
+ if _excluded(rel, syncroot, ignore, deployed_file):
127
+ continue
128
+ if entry.is_dir:
129
+ if rel not in local:
130
+ if rel.rstrip("/") in local:
131
+ conflicts.append(base / rel.rstrip("/"))
132
+ plan.mkdirs.append(base / rel.rstrip("/"))
133
+ continue
134
+ if only is not None and rel not in only:
135
+ continue
136
+ st = local.get(rel)
137
+ if rel + "/" in local:
138
+ conflicts.append(base / rel)
139
+ st = None
140
+ task = DownloadTask(
141
+ remote=rel, local=base / rel, size=entry.size, mtime=entry.mtime, label=rel
142
+ )
143
+ if st is None or (entry.size is not None and entry.size != st.st_size):
144
+ plan.downloads.append(task)
145
+ elif entry.mtime is not None and entry.mtime_exact:
146
+ if entry.mtime > int(st.st_mtime) + 1:
147
+ plan.downloads.append(task)
148
+ else:
149
+ plan.probes.append(rel)
150
+ if allow_delete:
151
+ for rel in local:
152
+ if _excluded(rel, syncroot, ignore, deployed_file):
153
+ continue
154
+ if only is not None or rel.rstrip("/") in protected:
155
+ continue
156
+ if rel in remote:
157
+ continue
158
+ if rel.endswith("/"):
159
+ # Only delete a directory when it does not exist remotely at all.
160
+ plan.local_deletes.append(base / rel.rstrip("/"))
161
+ elif rel + "/" in remote:
162
+ continue # handled as a conflict
163
+ else:
164
+ plan.local_deletes.append(base / rel)
165
+ plan.local_deletes = conflicts + plan.local_deletes
166
+ plan.downloads.sort(key=lambda t: t.remote)
167
+ plan.mkdirs.sort()
168
+ return plan
169
+
170
+
171
+ def _safe_remove(target: Path, base: Path) -> None:
172
+ resolved = target.resolve()
173
+ root = base.resolve()
174
+ if resolved == root or root not in resolved.parents:
175
+ raise FilesystemError(f"Refusing to delete '{target}' outside of '{base}'.")
176
+ if ".git" in resolved.relative_to(root).parts:
177
+ raise FilesystemError(f"Refusing to delete '{target}' inside .git.")
178
+ if target.is_dir() and not target.is_symlink():
179
+ shutil.rmtree(target)
180
+ else:
181
+ target.unlink()
182
+
183
+
184
+ def apply_plan(
185
+ plan: MirrorPlan, pool: TransferPool, base: Path, out: Output, dry_run: bool
186
+ ) -> None:
187
+ if dry_run:
188
+ for p in plan.local_deletes:
189
+ out.info(f"Would delete local '{p.relative_to(base).as_posix()}'.")
190
+ for p in plan.mkdirs:
191
+ out.info(f"Would create directory '{p.relative_to(base).as_posix()}'.")
192
+ for t in plan.downloads:
193
+ out.info(f"Would download '{t.label}'.")
194
+ return
195
+ deleted = 0
196
+ seen: set[Path] = set()
197
+ for p in sorted(plan.local_deletes, key=lambda x: (-len(x.parts), str(x))):
198
+ if p in seen or not (p.exists() or p.is_symlink()):
199
+ continue
200
+ seen.add(p)
201
+ _safe_remove(p, base)
202
+ deleted += 1
203
+ for d in plan.mkdirs:
204
+ d.mkdir(parents=True, exist_ok=True)
205
+ if plan.downloads:
206
+ try:
207
+ pool.download(plan.downloads)
208
+ except TransferError as e:
209
+ raise DownloadError(f"Could not download files. {e}") from e
210
+ out.info(f"Downloaded {len(plan.downloads)} file(s), deleted {deleted} local file(s).")
211
+
212
+
213
+ def download_remote_updates(session: Session, opts: MirrorOptions, only: set[str] | None) -> None:
214
+ repo = session.require_repo()
215
+ out = session.out
216
+ base = repo.root / session.syncroot if session.syncroot else repo.root
217
+ ignore = IgnoreRules.load(repo.root)
218
+ with TransferPool(session.connect, session.jobs, out, primary=session.primary) as pool:
219
+ out.debug(f"Mirroring {session.url.name()}")
220
+ try:
221
+ remote = scan_remote(pool, out)
222
+ except TransferError as e:
223
+ if isinstance(e.cause, RemoteNotFound):
224
+ raise DownloadError(
225
+ f"Remote directory '{session.url.display()}' does not exist."
226
+ ) from e
227
+ raise DownloadError(f"Could not list '{session.url.display()}'. {e.cause}") from e
228
+ local = scan_local(base)
229
+ # Local files git ignores (build output, secrets) are never deleted by a mirror.
230
+ git_ignored = repo.ignored([f"{session.syncroot}{rel.rstrip('/')}" for rel in local])
231
+ plan = build_plan(
232
+ remote,
233
+ local,
234
+ base=base,
235
+ syncroot=session.syncroot,
236
+ ignore=ignore,
237
+ deployed_file=session.deployed_sha1_file,
238
+ only=only,
239
+ allow_delete=not opts.changed_only,
240
+ protected={p[len(session.syncroot) :] for p in git_ignored},
241
+ )
242
+ if plan.probes:
243
+
244
+ def probe(t: Transport, rel: str) -> Entry | None:
245
+ return t.stat(rel)
246
+
247
+ stats = pool.map(probe, plan.probes, fail_fast=True, label=str)
248
+ for rel, st in zip(plan.probes, stats, strict=True):
249
+ if not isinstance(st, Entry) or st.mtime is None:
250
+ continue
251
+ lst = local[rel]
252
+ if st.mtime > int(lst.st_mtime) + 1:
253
+ plan.downloads.append(
254
+ DownloadTask(
255
+ remote=rel, local=base / rel, size=st.size, mtime=st.mtime, label=rel
256
+ )
257
+ )
258
+ plan.downloads.sort(key=lambda t: t.remote)
259
+ apply_plan(plan, pool, base, out, opts.dry_run)
260
+
261
+
262
+ def _deployed_sha(session: Session) -> str:
263
+ try:
264
+ data = session.primary.get(session.deployed_sha1_file)
265
+ except RemoteNotFound as e:
266
+ raise DownloadError(
267
+ "Could not get last commit. Use 'git ftp init' for the initial push."
268
+ ) from e
269
+ sha = data.decode("utf-8", "replace").strip()
270
+ if not sha:
271
+ raise DownloadError("Could not get last commit. Use 'git ftp init' for the initial push.")
272
+ return sha
273
+
274
+
275
+ def _changed_only(repo: GitRepo, session: Session, a: str, b: str | None) -> set[str]:
276
+ names = repo.diff_names_between(a, b)
277
+ only = {remote_path(n, session.syncroot) for n in names}
278
+ session.out.debug("Only pulling diff files:\n" + "\n".join(sorted(only)))
279
+ return only
280
+
281
+
282
+ # -- actions ------------------------------------------------------------------
283
+ def run_download(session: Session, opts: MirrorOptions) -> None:
284
+ repo = session.require_repo()
285
+ registry.check_available(session.url.scheme)
286
+ if repo.is_dirty():
287
+ raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
288
+ if repo.has_any_changes():
289
+ raise GitError("Dirty repository: Having untracked files. Exiting...")
290
+ local = repo.head_sha()
291
+ only = None
292
+ if opts.changed_only:
293
+ only = _changed_only(repo, session, _deployed_sha(session), None)
294
+ lock = RemoteLock(
295
+ session.primary,
296
+ local,
297
+ enabled=opts.lock,
298
+ force=opts.force,
299
+ dry_run=opts.dry_run,
300
+ out=session.out,
301
+ )
302
+ lock.acquire()
303
+ try:
304
+ download_remote_updates(session, opts, only)
305
+ finally:
306
+ lock.release()
307
+
308
+
309
+ def run_pull(session: Session, opts: MirrorOptions) -> None:
310
+ repo = session.require_repo()
311
+ out = session.out
312
+ registry.check_available(session.url.scheme)
313
+ if repo.is_dirty():
314
+ raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
315
+ current = repo.current_branch()
316
+ deployed = _deployed_sha(session)
317
+ out.debug(f"Last deployed SHA1 for {session.url.name()} is {deployed}.")
318
+ only = _changed_only(repo, session, current, deployed) if opts.changed_only else None
319
+ if not repo.checkout(deployed):
320
+ raise GitError(f"Could not checkout {deployed}.")
321
+ stashed = False
322
+ new = deployed
323
+ try:
324
+ stashed = repo.stash_push_untracked()
325
+ lock = RemoteLock(
326
+ session.primary,
327
+ deployed,
328
+ enabled=opts.lock,
329
+ force=opts.force,
330
+ dry_run=opts.dry_run,
331
+ out=out,
332
+ )
333
+ lock.acquire()
334
+ try:
335
+ download_remote_updates(session, opts, only)
336
+ finally:
337
+ lock.release()
338
+ if not opts.dry_run:
339
+ repo.add_all()
340
+ if repo.has_any_changes():
341
+ body = repo.diff_head_name_status()
342
+ if repo.commit("[git-ftp] remotely untracked modifications", body):
343
+ new = repo.head_sha()
344
+ out.debug(
345
+ f"Uploading commit log to {session.url.display()}{session.deployed_sha1_file}."
346
+ )
347
+ session.primary.put_bytes(f"{new}\n".encode(), session.deployed_sha1_file)
348
+ out.info(f"Last deployment changed from {deployed} to {new}.")
349
+ finally:
350
+ if stashed:
351
+ proc = repo.run("stash", "pop", "-q", ok_codes=())
352
+ if proc.returncode != 0:
353
+ out.warn("Could not restore the stash; run 'git stash pop' yourself.")
354
+ repo.checkout(current)
355
+ if opts.dry_run:
356
+ return
357
+ out.info(f"From {session.url.name()}")
358
+ out.info(f" {deployed}..{new}")
359
+ no_commit = opts.no_commit or session.cfg.get_bool("no-commit")
360
+ if repo.merge(new, no_commit) != 0:
361
+ raise GitError("Merge failed.")
362
+
363
+
364
+ def run_snapshot(cli: CliOptions, url_arg: str | None, directory: str | None, out: Output) -> None:
365
+ session = open_session(cli, url_arg, out, need_repo=False, need_url=False)
366
+ if not (url_arg or os.environ.get("GIT_FTP_URL") or session.cfg.get("url")):
367
+ raise UsageError("Error: give a URL to snapshot.")
368
+ registry.check_available(session.url.scheme)
369
+ raw = url_arg or os.environ.get("GIT_FTP_URL") or session.cfg.get("url")
370
+ target = directory or (raw.rstrip("/").rsplit("/", 1)[-1] if "/" in raw.rstrip("/") else "")
371
+ if not target or "://" in target:
372
+ target = session.url.hostname
373
+ try:
374
+ try:
375
+ data = session.primary.get(session.deployed_sha1_file)
376
+ except RemoteNotFound:
377
+ data = b""
378
+ if data.strip():
379
+ raise UsageError(
380
+ f"Commit found at {session.url.display()}{session.deployed_sha1_file}.\n"
381
+ "The remote directory is managed by another Git repository already.\n"
382
+ "Use 'git ftp pull' inside that repository to download the remote changes,\n"
383
+ "or delete the file on the server to start a new snapshot."
384
+ )
385
+ dest = Path(target).absolute()
386
+ try:
387
+ dest.mkdir(parents=True, exist_ok=True)
388
+ except OSError as e:
389
+ raise FilesystemError(f"Error creating directory '{target}'. Aborting.") from e
390
+ if any(dest.iterdir()):
391
+ raise FilesystemError(
392
+ f"Error: The destination directory '{target}' is not empty. Aborting."
393
+ )
394
+ repo = GitRepo.init(dest)
395
+ session.repo = repo
396
+ session.git = repo
397
+ session.syncroot = ""
398
+ download_remote_updates(session, MirrorOptions(), None)
399
+ try:
400
+ repo.add_dot()
401
+ except GitError as e:
402
+ raise GitError("Git: error adding changed files") from e
403
+ if not repo.commit(f"Download {session.url.display()} with git-ftp", allow_empty=True):
404
+ raise GitError("Git: error committing the changes")
405
+ deploy.run(deploy.Action.CATCHUP, session, deploy.DeployOptions())
406
+ finally:
407
+ session.close()
408
+
409
+
410
+ def run_unlock(session: Session) -> None:
411
+ registry.check_available(session.url.scheme)
412
+ session.primary.delete(LOCK_FILE)
413
+ session.out.info("Remote lock removed.")
gitftp/options.py ADDED
@@ -0,0 +1,72 @@
1
+ """Command-line options as a plain dataclass (independent of click)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from gitftp.auth import AuthFlags
9
+ from gitftp.output import Level
10
+
11
+
12
+ @dataclass
13
+ class CliOptions:
14
+ user: str | None = None
15
+ password: str | None = None
16
+ ask_password: bool = False
17
+ password_command: str | None = None
18
+ keychain: str | None = None
19
+ key: str | None = None
20
+ pubkey: str | None = None
21
+ key_passphrase: str | None = None
22
+ branch: str | None = None
23
+ commit: str | None = None
24
+ scope: str | None = None
25
+ syncroot: str | None = None
26
+ remote_root: str | None = None
27
+ cacert: str | None = None
28
+ proxy: str | None = None
29
+ jobs: int | None = None
30
+ all: bool = False
31
+ active: bool = False
32
+ lock: bool = False
33
+ dry_run: bool = False
34
+ force: bool = False
35
+ silent: bool = False
36
+ verbose: int = 0
37
+ insecure: bool = False
38
+ disable_epsv: bool = False
39
+ no_commit: bool = False
40
+ changed_only: bool = False
41
+ no_verify: bool = False
42
+ no_post_hooks: bool = False
43
+ enable_post_errors: bool = False
44
+ auto_init: bool = False
45
+ worktree: bool = False
46
+
47
+ @classmethod
48
+ def from_kwargs(cls, kw: dict[str, Any]) -> CliOptions:
49
+ fields = {f for f in cls.__dataclass_fields__}
50
+ return cls(**{k: v for k, v in kw.items() if k in fields})
51
+
52
+ @property
53
+ def level(self) -> Level:
54
+ if self.silent:
55
+ return Level.SILENT
56
+ if self.verbose >= 2:
57
+ return Level.TRACE
58
+ if self.verbose == 1:
59
+ return Level.VERBOSE
60
+ return Level.NORMAL
61
+
62
+ def auth_flags(self) -> AuthFlags:
63
+ return AuthFlags(
64
+ user=self.user,
65
+ password=self.password,
66
+ ask_password=self.ask_password,
67
+ password_command=self.password_command,
68
+ keychain=self.keychain,
69
+ key=self.key,
70
+ pubkey=self.pubkey,
71
+ key_passphrase=self.key_passphrase,
72
+ )
gitftp/output.py ADDED
@@ -0,0 +1,132 @@
1
+ """All user-facing output.
2
+
3
+ Upstream semantics are kept: progress lines go to stdout at normal verbosity,
4
+ diagnostics (``write_log``) are shown only with ``-v``, and ``-n`` silences
5
+ progress. Fixed: fatal errors always go to stderr, at every verbosity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import enum
11
+ import getpass
12
+ import sys
13
+ import threading
14
+ import time
15
+ from typing import TextIO
16
+
17
+
18
+ class Level(enum.IntEnum):
19
+ SILENT = -1
20
+ NORMAL = 0
21
+ VERBOSE = 1
22
+ TRACE = 2
23
+
24
+
25
+ class Output:
26
+ """Thread-safe printer with secret redaction."""
27
+
28
+ def __init__(
29
+ self,
30
+ level: Level = Level.NORMAL,
31
+ stdout: TextIO | None = None,
32
+ stderr: TextIO | None = None,
33
+ stdin: TextIO | None = None,
34
+ ) -> None:
35
+ self.level = level
36
+ self._stdout = stdout
37
+ self._stderr = stderr
38
+ self._stdin = stdin
39
+ self._lock = threading.Lock()
40
+ self._secrets: list[str] = []
41
+
42
+ # Streams are resolved lazily so click's CliRunner can swap sys.stdout in tests.
43
+ @property
44
+ def stdout(self) -> TextIO:
45
+ return self._stdout or sys.stdout
46
+
47
+ @property
48
+ def stderr(self) -> TextIO:
49
+ return self._stderr or sys.stderr
50
+
51
+ @property
52
+ def stdin(self) -> TextIO:
53
+ return self._stdin or sys.stdin
54
+
55
+ @property
56
+ def tracing(self) -> bool:
57
+ return self.level >= Level.TRACE
58
+
59
+ @property
60
+ def verbose(self) -> bool:
61
+ return self.level >= Level.VERBOSE
62
+
63
+ # -- secrets -----------------------------------------------------------
64
+ def add_secret(self, secret: str | None) -> None:
65
+ if secret and secret not in self._secrets:
66
+ self._secrets.append(secret)
67
+
68
+ def redact(self, text: str) -> str:
69
+ for s in self._secrets:
70
+ text = text.replace(s, "***")
71
+ return text
72
+
73
+ # -- writing -----------------------------------------------------------
74
+ def _write(self, stream: TextIO, text: str) -> None:
75
+ with self._lock:
76
+ stream.write(text + "\n")
77
+ stream.flush()
78
+
79
+ @staticmethod
80
+ def _stamp() -> str:
81
+ return time.strftime("%a %b %e %H:%M:%S %Z %Y")
82
+
83
+ def info(self, msg: str) -> None:
84
+ """A progress line. Plain at NORMAL, timestamped at VERBOSE, hidden at SILENT."""
85
+ if self.level == Level.NORMAL:
86
+ self._write(self.stdout, msg)
87
+ elif self.level >= Level.VERBOSE:
88
+ self._write(self.stdout, f"{self._stamp()}: {msg}")
89
+
90
+ def debug(self, msg: str) -> None:
91
+ """Upstream ``write_log``: shown only with ``-v``."""
92
+ if self.level >= Level.VERBOSE:
93
+ self._write(self.stderr, f"{self._stamp()}: {self.redact(msg)}")
94
+
95
+ def warn(self, msg: str) -> None:
96
+ if self.level > Level.SILENT:
97
+ self._write(self.stderr, f"WARNING: {self.redact(msg)}")
98
+
99
+ def trace(self, msg: str) -> None:
100
+ """Protocol-level chatter, shown with ``-vv``."""
101
+ if self.level >= Level.TRACE:
102
+ self._write(self.stderr, self.redact(msg))
103
+
104
+ def fatal(self, msg: str) -> None:
105
+ """Always printed, always to stderr."""
106
+ self._write(self.stderr, f"fatal: {self.redact(msg)}")
107
+
108
+ def raw(self, text: str) -> None:
109
+ """Verbatim text to stdout regardless of level (help, version)."""
110
+ with self._lock:
111
+ self.stdout.write(text)
112
+ if not text.endswith("\n"):
113
+ self.stdout.write("\n")
114
+ self.stdout.flush()
115
+
116
+ # -- reading -----------------------------------------------------------
117
+ def ask(self, prompt: str) -> str:
118
+ """Print ``prompt`` (no newline) and read one line; EOF reads as ''."""
119
+ with self._lock:
120
+ self.stdout.write(prompt)
121
+ self.stdout.flush()
122
+ line = self.stdin.readline()
123
+ return line.rstrip("\r\n")
124
+
125
+ def prompt_secret(self, prompt: str) -> str:
126
+ if self._stdin is not None or not self.stdin.isatty():
127
+ # Non-interactive: read a line without echo games (tests, pipes).
128
+ with self._lock:
129
+ self.stderr.write(prompt)
130
+ self.stderr.flush()
131
+ return self.stdin.readline().rstrip("\r\n")
132
+ return getpass.getpass(prompt, stream=self.stderr)