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.
- git_ftp-2.0.0.dev0.dist-info/METADATA +243 -0
- git_ftp-2.0.0.dev0.dist-info/RECORD +47 -0
- git_ftp-2.0.0.dev0.dist-info/WHEEL +4 -0
- git_ftp-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- git_ftp-2.0.0.dev0.dist-info/licenses/LICENSE +674 -0
- gitftp/__init__.py +5 -0
- gitftp/__main__.py +7 -0
- gitftp/_version.py +24 -0
- gitftp/auth.py +209 -0
- gitftp/changeset.py +86 -0
- gitftp/cli/__init__.py +111 -0
- gitftp/cli/cmd_add_scope.py +23 -0
- gitftp/cli/cmd_catchup.py +19 -0
- gitftp/cli/cmd_download.py +19 -0
- gitftp/cli/cmd_help.py +30 -0
- gitftp/cli/cmd_init.py +19 -0
- gitftp/cli/cmd_log.py +31 -0
- gitftp/cli/cmd_pull.py +19 -0
- gitftp/cli/cmd_push.py +21 -0
- gitftp/cli/cmd_remove_scope.py +23 -0
- gitftp/cli/cmd_show.py +31 -0
- gitftp/cli/cmd_snapshot.py +20 -0
- gitftp/cli/cmd_unlock.py +19 -0
- gitftp/cli/cmd_version.py +19 -0
- gitftp/cli/group.py +68 -0
- gitftp/cli/options.py +208 -0
- gitftp/config.py +170 -0
- gitftp/deploy.py +341 -0
- gitftp/errors.py +86 -0
- gitftp/gitrepo.py +293 -0
- gitftp/hooks.py +30 -0
- gitftp/ignore.py +83 -0
- gitftp/include.py +94 -0
- gitftp/lock.py +94 -0
- gitftp/mirror.py +413 -0
- gitftp/options.py +72 -0
- gitftp/output.py +132 -0
- gitftp/session.py +212 -0
- gitftp/transfer.py +269 -0
- gitftp/transport/__init__.py +1 -0
- gitftp/transport/base.py +93 -0
- gitftp/transport/curlftp.py +389 -0
- gitftp/transport/listing.py +155 -0
- gitftp/transport/registry.py +73 -0
- gitftp/transport/sftp.py +442 -0
- gitftp/url.py +204 -0
- gitftp/version.py +35 -0
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)."""
|
gitftp/transport/base.py
ADDED
|
@@ -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."""
|