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/session.py ADDED
@@ -0,0 +1,212 @@
1
+ """Everything an action needs: repository, config, URL, credentials, connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from gitftp import auth
9
+ from gitftp.changeset import remote_path
10
+ from gitftp.config import DEFAULT_DEPLOYED_SHA1_FILE, DEFAULT_JOBS, Config, validate_scope
11
+ from gitftp.errors import GitError, MissingArgumentError
12
+ from gitftp.gitrepo import GitRepo, GitRunner
13
+ from gitftp.options import CliOptions
14
+ from gitftp.output import Output
15
+ from gitftp.transport import registry
16
+ from gitftp.transport.base import Transport
17
+ from gitftp.url import RemoteURL, parse
18
+
19
+
20
+ class Session:
21
+ def __init__(
22
+ self,
23
+ *,
24
+ out: Output,
25
+ git: GitRunner,
26
+ repo: GitRepo | None,
27
+ scope: str | None,
28
+ cfg: Config,
29
+ url: RemoteURL,
30
+ creds: auth.Credentials,
31
+ topts: registry.TransportOptions,
32
+ jobs: int,
33
+ deployed_sha1_file: str,
34
+ syncroot: str,
35
+ worktree: bool = False,
36
+ ) -> None:
37
+ self.out = out
38
+ self.git = git
39
+ self.repo = repo
40
+ self.scope = scope
41
+ self.cfg = cfg
42
+ self.url = url
43
+ self.creds = creds
44
+ self.topts = topts
45
+ self.jobs = jobs
46
+ self.deployed_sha1_file = deployed_sha1_file
47
+ self.syncroot = syncroot
48
+ self.worktree = worktree
49
+ self._connector = registry.connector(url, creds, topts, out)
50
+ self._primary: Transport | None = None
51
+
52
+ def log_settings(self) -> None:
53
+ """Upstream's ``set_remotes`` diagnostics (printed with ``-v``)."""
54
+ out = self.out
55
+ out.debug(f"Host is '{self.url.host}'.")
56
+ out.debug(f"User is '{self.creds.user}'.")
57
+ out.debug("Password is set." if self.creds.password is not None else "No password is set.")
58
+ if self.syncroot:
59
+ out.debug(f"Syncroot is '{self.syncroot}'.")
60
+ out.debug(f"Insecure is '{int(self.topts.insecure)}'.")
61
+ if self.topts.disable_epsv:
62
+ out.debug("Disable EPSV is '1'.")
63
+ if self.jobs != DEFAULT_JOBS:
64
+ out.debug(f"Jobs is '{self.jobs}'.")
65
+
66
+ def connect(self) -> Transport:
67
+ return self._connector()
68
+
69
+ @property
70
+ def primary(self) -> Transport:
71
+ if self._primary is None:
72
+ self._primary = self.connect()
73
+ return self._primary
74
+
75
+ def close(self) -> None:
76
+ if self._primary is not None:
77
+ try:
78
+ self._primary.close()
79
+ finally:
80
+ self._primary = None
81
+
82
+ def require_repo(self) -> GitRepo:
83
+ if self.repo is None:
84
+ raise GitError("Not a Git project? Exiting...")
85
+ return self.repo
86
+
87
+ def for_submodule(self, sub_path: str) -> Session:
88
+ """A session for a submodule at git path ``sub_path`` (inside the syncroot)."""
89
+ repo = self.require_repo()
90
+ sub_root = repo.root / sub_path
91
+ sub_repo = GitRepo(sub_root)
92
+ cfg = Config.load(sub_repo, sub_root, self.scope)
93
+ session = Session(
94
+ out=self.out,
95
+ git=sub_repo,
96
+ repo=sub_repo,
97
+ scope=self.scope,
98
+ cfg=cfg,
99
+ url=self.url.child(remote_path(sub_path, self.syncroot)),
100
+ creds=self.creds,
101
+ topts=self.topts,
102
+ jobs=self.jobs,
103
+ deployed_sha1_file=cfg.get("deployedsha1file", DEFAULT_DEPLOYED_SHA1_FILE),
104
+ syncroot="",
105
+ worktree=self.worktree,
106
+ )
107
+ session.log_settings()
108
+ return session
109
+
110
+
111
+ def resolve_syncroot(repo: GitRepo | None, value: str) -> str:
112
+ value = value.replace("\\", "/")
113
+ while value.startswith("./"):
114
+ value = value[2:]
115
+ value = value.strip("/")
116
+ if not value or value == ".":
117
+ return ""
118
+ if repo is not None and not (repo.root / value).is_dir():
119
+ raise GitError(f"'{value}' is not a directory! Exiting...")
120
+ return value + "/"
121
+
122
+
123
+ def open_session(
124
+ opts: CliOptions,
125
+ url_arg: str | None,
126
+ out: Output,
127
+ *,
128
+ cwd: Path | None = None,
129
+ need_repo: bool = True,
130
+ need_url: bool = True,
131
+ ) -> Session:
132
+ cwd = cwd or Path.cwd()
133
+ git = GitRunner(cwd)
134
+ git.check_version()
135
+
136
+ repo: GitRepo | None
137
+ if need_repo:
138
+ repo = GitRepo.discover(cwd)
139
+ else:
140
+ try:
141
+ repo = GitRepo.discover(cwd)
142
+ except GitError:
143
+ repo = None
144
+
145
+ scope: str | None = None
146
+ if opts.scope is not None:
147
+ if opts.scope:
148
+ scope = opts.scope
149
+ elif repo is not None:
150
+ scope = repo.current_branch()
151
+ else:
152
+ raise MissingArgumentError("Missing scope argument.")
153
+ validate_scope(scope, from_option=True)
154
+
155
+ cfg = Config.load(git, repo.root if repo else None, scope)
156
+
157
+ raw_url = url_arg or os.environ.get("GIT_FTP_URL") or cfg.get("url")
158
+ if not raw_url:
159
+ if need_url:
160
+ raise MissingArgumentError("Remote host not set.")
161
+ raw_url = "ftp://localhost"
162
+ url = parse(raw_url)
163
+ remote_root = opts.remote_root if opts.remote_root is not None else cfg.get("remote-root")
164
+ if remote_root:
165
+ url.set_path(remote_root)
166
+
167
+ creds = auth.resolve(cfg, url, opts.auth_flags(), out)
168
+ # Messages show the resolved login as upstream does (``user:***@host``); the
169
+ # password itself is never placed in the URL.
170
+ url.user = creds.user or None
171
+ url.password = None
172
+
173
+ insecure = opts.insecure or cfg.get_bool("insecure")
174
+ cacert = None
175
+ for candidate in (opts.cacert, cfg.get("cacert")):
176
+ if candidate and os.access(candidate, os.R_OK):
177
+ cacert = candidate
178
+ break
179
+ disable_epsv = (not opts.active) and (opts.disable_epsv or cfg.get_bool("disable-epsv"))
180
+ proxy = opts.proxy or cfg.get("proxy") or cfg.git_option("http.proxy") or None
181
+ topts = registry.TransportOptions(
182
+ insecure=insecure,
183
+ cacert=cacert,
184
+ active=opts.active,
185
+ disable_epsv=disable_epsv,
186
+ proxy=proxy,
187
+ trace=out.trace if out.tracing else None,
188
+ )
189
+
190
+ jobs = opts.jobs if opts.jobs is not None else cfg.get_int("jobs", DEFAULT_JOBS)
191
+ syncroot = resolve_syncroot(
192
+ repo, opts.syncroot if opts.syncroot is not None else cfg.get("syncroot")
193
+ )
194
+ deployed_sha1_file = cfg.get("deployedsha1file", DEFAULT_DEPLOYED_SHA1_FILE)
195
+ worktree = opts.worktree or cfg.get_bool("worktree")
196
+
197
+ session = Session(
198
+ out=out,
199
+ git=git,
200
+ repo=repo,
201
+ scope=scope,
202
+ cfg=cfg,
203
+ url=url,
204
+ creds=creds,
205
+ topts=topts,
206
+ jobs=max(1, jobs),
207
+ deployed_sha1_file=deployed_sha1_file,
208
+ syncroot=syncroot,
209
+ worktree=worktree,
210
+ )
211
+ session.log_settings()
212
+ return session
gitftp/transfer.py ADDED
@@ -0,0 +1,269 @@
1
+ """Parallel transfer engine.
2
+
3
+ A pool of worker threads, each with its own connection. Uploads and downloads
4
+ are fail-fast (the first failure cancels the rest); deletes collect their
5
+ failures, which upstream treats as warnings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import threading
13
+ from collections.abc import Callable, Sequence
14
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Generic, TypeVar
18
+
19
+ from gitftp.output import Output
20
+ from gitftp.transport.base import Connector, TransferCancelled, Transport
21
+
22
+ T = TypeVar("T")
23
+ R = TypeVar("R")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class UploadTask:
28
+ local: Path
29
+ remote: str
30
+ size: int
31
+ label: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class DeleteTask:
36
+ remote: str
37
+ label: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DownloadTask:
42
+ remote: str
43
+ local: Path
44
+ size: int | None
45
+ mtime: int | None
46
+ label: str
47
+
48
+
49
+ class TransferError(Exception):
50
+ def __init__(self, label: str, cause: BaseException) -> None:
51
+ super().__init__(f"{label}: {cause}")
52
+ self.label = label
53
+ self.cause = cause
54
+
55
+
56
+ class _Skipped:
57
+ def __repr__(self) -> str:
58
+ return "<skipped>"
59
+
60
+
61
+ SKIPPED = _Skipped()
62
+
63
+
64
+ class _Result(Generic[R]):
65
+ __slots__ = ("value",)
66
+
67
+ def __init__(self, value: R | TransferError | _Skipped) -> None:
68
+ self.value = value
69
+
70
+
71
+ class TransferPool:
72
+ def __init__(
73
+ self,
74
+ connect: Connector,
75
+ jobs: int,
76
+ out: Output,
77
+ *,
78
+ primary: Transport | None = None,
79
+ ) -> None:
80
+ self.connect = connect
81
+ self.jobs = max(1, jobs)
82
+ self.out = out
83
+ self.primary = primary
84
+ self._cancel = threading.Event()
85
+ self._local = threading.local()
86
+ self._opened: list[Transport] = []
87
+ self._opened_lock = threading.Lock()
88
+ self._own_primary = False
89
+
90
+ # -- lifecycle ---------------------------------------------------------
91
+ def __enter__(self) -> TransferPool:
92
+ if self.primary is not None:
93
+ self.primary.cancel = self._cancel
94
+ return self
95
+
96
+ def __exit__(self, *exc: object) -> None:
97
+ self.close()
98
+
99
+ def close(self) -> None:
100
+ if self.primary is not None and not self._own_primary:
101
+ # The session keeps using this connection after the pool; a cancelled
102
+ # pool must not poison it.
103
+ self.primary.cancel = threading.Event()
104
+ with self._opened_lock:
105
+ opened, self._opened = self._opened, []
106
+ for t in opened:
107
+ with contextlib.suppress(Exception):
108
+ t.close()
109
+ if self._own_primary and self.primary is not None:
110
+ with contextlib.suppress(Exception):
111
+ self.primary.close()
112
+ self.primary = None
113
+ self._own_primary = False
114
+
115
+ def cancel(self) -> None:
116
+ self._cancel.set()
117
+
118
+ @property
119
+ def cancelled(self) -> bool:
120
+ return self._cancel.is_set()
121
+
122
+ def _primary(self) -> Transport:
123
+ if self.primary is None:
124
+ self.primary = self.connect()
125
+ self.primary.cancel = self._cancel
126
+ self._own_primary = True
127
+ return self.primary
128
+
129
+ def _worker_transport(self) -> Transport:
130
+ t: Transport | None = getattr(self._local, "transport", None)
131
+ if t is not None:
132
+ return t
133
+ failure: BaseException | None = getattr(self._local, "failure", None)
134
+ if failure is not None:
135
+ raise failure
136
+ try:
137
+ t = self.connect()
138
+ except BaseException as e:
139
+ self._local.failure = e
140
+ raise
141
+ t.cancel = self._cancel
142
+ with self._opened_lock:
143
+ self._opened.append(t)
144
+ self._local.transport = t
145
+ return t
146
+
147
+ # -- the primitive -----------------------------------------------------
148
+ def map(
149
+ self,
150
+ fn: Callable[[Transport, T], R],
151
+ items: Sequence[T],
152
+ *,
153
+ fail_fast: bool = True,
154
+ label: Callable[[T], str] = str,
155
+ ) -> list[R | TransferError | _Skipped]:
156
+ if not items:
157
+ return []
158
+ if self.jobs == 1 or len(items) == 1:
159
+ return self._map_serial(fn, items, fail_fast=fail_fast, label=label)
160
+ return self._map_parallel(fn, items, fail_fast=fail_fast, label=label)
161
+
162
+ def _map_serial(
163
+ self,
164
+ fn: Callable[[Transport, T], R],
165
+ items: Sequence[T],
166
+ *,
167
+ fail_fast: bool,
168
+ label: Callable[[T], str],
169
+ ) -> list[R | TransferError | _Skipped]:
170
+ results: list[R | TransferError | _Skipped] = []
171
+ t = self._primary()
172
+ for item in items:
173
+ if self._cancel.is_set():
174
+ results.append(SKIPPED)
175
+ continue
176
+ try:
177
+ results.append(fn(t, item))
178
+ except TransferCancelled:
179
+ results.append(SKIPPED)
180
+ except Exception as e:
181
+ err = TransferError(label(item), e)
182
+ if fail_fast:
183
+ raise err from e
184
+ results.append(err)
185
+ return results
186
+
187
+ def _run_one(self, fn: Callable[[Transport, T], R], item: T) -> R | _Skipped:
188
+ if self._cancel.is_set():
189
+ return SKIPPED
190
+ t = self._worker_transport()
191
+ try:
192
+ return fn(t, item)
193
+ except TransferCancelled:
194
+ return SKIPPED
195
+ except Exception:
196
+ if self._cancel.is_set():
197
+ return SKIPPED
198
+ raise
199
+
200
+ def _map_parallel(
201
+ self,
202
+ fn: Callable[[Transport, T], R],
203
+ items: Sequence[T],
204
+ *,
205
+ fail_fast: bool,
206
+ label: Callable[[T], str],
207
+ ) -> list[R | TransferError | _Skipped]:
208
+ workers = min(self.jobs, len(items))
209
+ executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="git-ftp")
210
+ futures: list[Future[R | _Skipped]] = []
211
+ first_error: TransferError | None = None
212
+ results: dict[Future[R | _Skipped], R | TransferError | _Skipped] = {}
213
+ try:
214
+ for item in items:
215
+ futures.append(executor.submit(self._run_one, fn, item))
216
+ pending = set(futures)
217
+ while pending:
218
+ done, pending = wait(pending, timeout=0.5, return_when=FIRST_COMPLETED)
219
+ for fut in done:
220
+ idx = futures.index(fut)
221
+ try:
222
+ results[fut] = fut.result()
223
+ except Exception as e:
224
+ err = TransferError(label(items[idx]), e)
225
+ results[fut] = err
226
+ if fail_fast and first_error is None:
227
+ first_error = err
228
+ self._cancel.set()
229
+ except BaseException:
230
+ self._cancel.set()
231
+ executor.shutdown(wait=False, cancel_futures=True)
232
+ raise
233
+ executor.shutdown(wait=True)
234
+ if first_error is not None:
235
+ raise first_error
236
+ return [results[f] for f in futures]
237
+
238
+ # -- typed helpers -----------------------------------------------------
239
+ def upload(self, tasks: Sequence[UploadTask]) -> None:
240
+ def do(t: Transport, task: UploadTask) -> None:
241
+ t.put(task.local, task.remote, task.size)
242
+ self.out.debug(f"Uploaded '{task.label}'.")
243
+
244
+ self.map(do, tasks, fail_fast=True, label=lambda task: task.label)
245
+
246
+ def delete(self, tasks: Sequence[DeleteTask]) -> list[TransferError]:
247
+ def do(t: Transport, task: DeleteTask) -> None:
248
+ t.delete(task.remote)
249
+ self.out.debug(f"Deleted '{task.label}'.")
250
+
251
+ results = self.map(do, tasks, fail_fast=False, label=lambda task: task.label)
252
+ return [r for r in results if isinstance(r, TransferError)]
253
+
254
+ def download(self, tasks: Sequence[DownloadTask]) -> None:
255
+ def do(t: Transport, task: DownloadTask) -> None:
256
+ part = task.local.with_name(f".{task.local.name}.git-ftp-part")
257
+ task.local.parent.mkdir(parents=True, exist_ok=True)
258
+ try:
259
+ t.get_file(task.remote, part)
260
+ if task.mtime is not None:
261
+ os.utime(part, (task.mtime, task.mtime))
262
+ os.replace(part, task.local)
263
+ except BaseException:
264
+ with contextlib.suppress(OSError):
265
+ part.unlink()
266
+ raise
267
+ self.out.debug(f"Downloaded '{task.label}'.")
268
+
269
+ self.map(do, tasks, fail_fast=True, label=lambda task: task.label)
@@ -0,0 +1 @@
1
+ """Remote transports (FTP family via libcurl, SFTP via paramiko)."""
@@ -0,0 +1,93 @@
1
+ """The transport interface every backend implements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ class RemoteNotFound(Exception):
13
+ """The remote file or directory does not exist (not an access or network error)."""
14
+
15
+
16
+ class TransferCancelled(Exception):
17
+ """Raised inside a worker when the pool's cancel event was set mid-transfer."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Entry:
22
+ name: str
23
+ is_dir: bool
24
+ size: int | None = None
25
+ mtime: int | None = None # unix seconds, UTC
26
+ mtime_exact: bool = True # False for LIST-derived times (minute resolution)
27
+ is_link: bool = False
28
+
29
+
30
+ ProgressFn = Callable[[int, int], None]
31
+
32
+
33
+ class Transport(ABC):
34
+ """One open connection.
35
+
36
+ Paths are relative to the URL's remote directory and never start with '/'.
37
+ Instances are not thread-safe; the transfer pool gives each worker its own.
38
+ """
39
+
40
+ def __init__(self) -> None:
41
+ self.cancel = threading.Event()
42
+
43
+ @abstractmethod
44
+ def open(self) -> None: ...
45
+
46
+ @abstractmethod
47
+ def close(self) -> None: ...
48
+
49
+ @abstractmethod
50
+ def get(self, path: str) -> bytes:
51
+ """Read a small file. Raises RemoteNotFound, else DownloadError."""
52
+
53
+ @abstractmethod
54
+ def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None:
55
+ """Stream a remote file into ``local``."""
56
+
57
+ @abstractmethod
58
+ def put(
59
+ self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None
60
+ ) -> None:
61
+ """Upload a file, creating missing parent directories. Raises UploadError."""
62
+
63
+ @abstractmethod
64
+ def put_bytes(self, data: bytes, remote: str) -> None: ...
65
+
66
+ @abstractmethod
67
+ def delete(self, path: str) -> None:
68
+ """Remove a file; an already absent file is success. Raises UploadError."""
69
+
70
+ @abstractmethod
71
+ def mkdir_p(self, directory: str) -> None:
72
+ """Create ``directory`` and parents; '' is the remote root itself."""
73
+
74
+ @abstractmethod
75
+ def exists(self, path: str) -> bool: ...
76
+
77
+ @abstractmethod
78
+ def stat(self, path: str) -> Entry | None: ...
79
+
80
+ @abstractmethod
81
+ def list_dir(self, path: str) -> list[Entry]:
82
+ """Non-recursive listing without '.' and '..'. Raises RemoteNotFound."""
83
+
84
+ def __enter__(self) -> Transport:
85
+ self.open()
86
+ return self
87
+
88
+ def __exit__(self, *exc: object) -> None:
89
+ self.close()
90
+
91
+
92
+ Connector = Callable[[], Transport]
93
+ """Returns a fresh, *opened* connection; the pool calls it once per worker."""