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.
@@ -0,0 +1,389 @@
1
+ """FTP, FTPS (implicit TLS) and FTPES (explicit TLS) through libcurl (pycurl).
2
+
3
+ One easy handle per instance is reused for every operation; libcurl keeps the
4
+ control connection in its cache, so each worker logs in once. Credentials are
5
+ passed through CURLOPT_USERNAME/PASSWORD, never inside the URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import posixpath
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from gitftp.auth import Credentials
17
+ from gitftp.errors import DownloadError, UploadError
18
+ from gitftp.output import Output
19
+ from gitftp.transport import listing
20
+ from gitftp.transport.base import Entry, ProgressFn, RemoteNotFound, Transport
21
+ from gitftp.url import RemoteURL, Scheme
22
+
23
+ # libcurl error codes we interpret
24
+ E_UNSUPPORTED_PROTOCOL = 1
25
+ E_COULDNT_RESOLVE_PROXY = 5
26
+ E_COULDNT_RESOLVE_HOST = 6
27
+ E_COULDNT_CONNECT = 7
28
+ E_REMOTE_ACCESS_DENIED = 9
29
+ E_FTP_COULDNT_RETR_FILE = 19
30
+ E_QUOTE_ERROR = 21
31
+ E_UPLOAD_FAILED = 25
32
+ E_OPERATION_TIMEDOUT = 28
33
+ E_SSL_CONNECT_ERROR = 35
34
+ E_ABORTED_BY_CALLBACK = 42
35
+ E_PEER_FAILED_VERIFICATION = 60
36
+ E_USE_SSL_FAILED = 64
37
+ E_LOGIN_DENIED = 67
38
+ E_REMOTE_FILE_NOT_FOUND = 78
39
+ E_SSL_CACERT_BADFILE = 77
40
+ E_SSL_PEER_CERTIFICATE = 51
41
+
42
+ NOT_FOUND_CODES = frozenset(
43
+ {E_REMOTE_ACCESS_DENIED, E_FTP_COULDNT_RETR_FILE, E_REMOTE_FILE_NOT_FOUND}
44
+ )
45
+ TLS_CODES = frozenset(
46
+ {E_SSL_CONNECT_ERROR, E_PEER_FAILED_VERIFICATION, E_SSL_CACERT_BADFILE, E_SSL_PEER_CERTIFICATE}
47
+ )
48
+
49
+
50
+ class CurlOptions:
51
+ """Transport settings coming from the command line and config."""
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ insecure: bool = False,
57
+ cacert: str | None = None,
58
+ active: bool = False,
59
+ disable_epsv: bool = False,
60
+ proxy: str | None = None,
61
+ trace: Callable[[str], None] | None = None,
62
+ ) -> None:
63
+ self.insecure = insecure
64
+ self.cacert = cacert
65
+ self.active = active
66
+ self.disable_epsv = disable_epsv
67
+ self.proxy = proxy
68
+ self.trace = trace
69
+
70
+
71
+ def check_available(scheme: Scheme) -> None:
72
+ try:
73
+ import pycurl
74
+ except ImportError as e:
75
+ raise DownloadError(
76
+ "pycurl is not available; install git-ftp with its wheels or the libcurl "
77
+ "development headers."
78
+ ) from e
79
+ info = pycurl.version_info()
80
+ protocols = set(info[8])
81
+ if "ftp" not in protocols:
82
+ raise DownloadError("Protocol 'ftp' is not supported by the installed libcurl.")
83
+ if scheme.secure and ("ftps" not in protocols or not info[5]):
84
+ raise DownloadError(
85
+ f"Protocol '{scheme.value}' is not supported by the installed libcurl (no TLS)."
86
+ )
87
+
88
+
89
+ def describe_error(code: int, message: str, url: RemoteURL) -> str:
90
+ """Upstream's ``check_curl_exit_status`` texts, with a few TLS hints added."""
91
+ display = url.display()
92
+ if code == E_REMOTE_ACCESS_DENIED:
93
+ return (
94
+ "Access to resource denied. This usually means that the file or directory "
95
+ "does not exist. Wrong path?"
96
+ )
97
+ if code == E_LOGIN_DENIED:
98
+ return f"Can't access remote '{display}'. Failed to log in. Correct user and password?"
99
+ if code == E_REMOTE_FILE_NOT_FOUND:
100
+ return "The resource does not exist."
101
+ if code in TLS_CODES:
102
+ return (
103
+ f"Can't access remote '{display}'. TLS verification failed ({message}). "
104
+ "Use --cacert to trust the server certificate or --insecure to skip verification."
105
+ )
106
+ if code == E_USE_SSL_FAILED:
107
+ return f"Can't access remote '{display}'. The server refused TLS ({message})."
108
+ return f"Can't access remote '{display}'. Network down? Wrong URL? ({message})"
109
+
110
+
111
+ def _discard(_data: bytes) -> None:
112
+ """Header sink: without it libcurl prints FTP pseudo-headers to stdout."""
113
+
114
+
115
+ def _discard_body(data: bytes) -> int:
116
+ """Body sink for NOBODY requests (SIZE/MDTM replies are delivered as body too)."""
117
+ return len(data)
118
+
119
+
120
+ class CurlFtpTransport(Transport):
121
+ def __init__(
122
+ self, url: RemoteURL, creds: Credentials, options: CurlOptions, out: Output
123
+ ) -> None:
124
+ super().__init__()
125
+ self.url = url
126
+ self.creds = creds
127
+ self.options = options
128
+ self.out = out
129
+ self._curl: Any = None
130
+ self._mlsd_supported = True
131
+ self._progress: ProgressFn | None = None
132
+
133
+ # -- lifecycle ---------------------------------------------------------
134
+ def open(self) -> None:
135
+ import pycurl
136
+
137
+ self._pycurl = pycurl
138
+ self._curl = pycurl.Curl()
139
+
140
+ def close(self) -> None:
141
+ if self._curl is not None:
142
+ self._curl.close()
143
+ self._curl = None
144
+
145
+ # -- callbacks ---------------------------------------------------------
146
+ def _xferinfo(self, dl_total: int, dl_now: int, ul_total: int, ul_now: int) -> int:
147
+ if self.cancel.is_set():
148
+ return 1
149
+ if self._progress is not None:
150
+ if ul_total or ul_now:
151
+ self._progress(ul_now, ul_total)
152
+ else:
153
+ self._progress(dl_now, dl_total)
154
+ return 0
155
+
156
+ def _debug(self, kind: int, data: bytes) -> None:
157
+ if self.options.trace is None or kind > 2:
158
+ return
159
+ prefix = {0: "* ", 1: "< ", 2: "> "}[kind]
160
+ text = data.decode("utf-8", "replace").rstrip("\r\n")
161
+ for line in text.split("\n"):
162
+ self.options.trace(prefix + line.rstrip("\r"))
163
+
164
+ # -- request setup -----------------------------------------------------
165
+ def _prepare(self, target: str) -> Any:
166
+ pc = self._pycurl
167
+ c = self._curl
168
+ c.reset()
169
+ c.setopt(pc.URL, target)
170
+ if hasattr(pc, "PROTOCOLS_STR"):
171
+ c.setopt(pc.PROTOCOLS_STR, "ftp,ftps")
172
+ else: # pragma: no cover - older libcurl
173
+ c.setopt(pc.PROTOCOLS, pc.PROTO_FTP | pc.PROTO_FTPS)
174
+ if self.creds.user:
175
+ c.setopt(pc.USERNAME, self.creds.user.encode("utf-8", "surrogateescape"))
176
+ if self.creds.password is not None:
177
+ c.setopt(pc.PASSWORD, self.creds.password.encode("utf-8", "surrogateescape"))
178
+ c.setopt(pc.NETRC, pc.NETRC_IGNORED)
179
+ else:
180
+ c.setopt(pc.NETRC, pc.NETRC_OPTIONAL)
181
+ c.setopt(pc.NOSIGNAL, 1)
182
+ c.setopt(pc.TCP_KEEPALIVE, 1)
183
+ c.setopt(pc.CONNECTTIMEOUT_MS, 30000)
184
+ c.setopt(pc.LOW_SPEED_LIMIT, 1)
185
+ c.setopt(pc.LOW_SPEED_TIME, 60)
186
+ c.setopt(pc.FTP_RESPONSE_TIMEOUT, 60)
187
+ c.setopt(pc.FTP_SKIP_PASV_IP, 1)
188
+ if self.options.active:
189
+ c.setopt(pc.FTPPORT, "-")
190
+ elif self.options.disable_epsv:
191
+ c.setopt(pc.FTP_USE_EPSV, 0)
192
+ if self.options.proxy:
193
+ c.setopt(pc.PROXY, self.options.proxy)
194
+ if self.url.scheme.secure:
195
+ c.setopt(pc.USE_SSL, pc.USESSL_ALL)
196
+ c.setopt(pc.SSLVERSION, pc.SSLVERSION_TLSv1_2)
197
+ if self.options.insecure:
198
+ c.setopt(pc.SSL_VERIFYPEER, 0)
199
+ c.setopt(pc.SSL_VERIFYHOST, 0)
200
+ if self.options.cacert:
201
+ c.setopt(pc.CAINFO, self.options.cacert)
202
+ c.setopt(pc.HEADERFUNCTION, _discard)
203
+ c.setopt(pc.WRITEFUNCTION, _discard_body)
204
+ c.setopt(pc.NOPROGRESS, 0)
205
+ c.setopt(pc.XFERINFOFUNCTION, self._xferinfo)
206
+ if self.options.trace is not None:
207
+ c.setopt(pc.VERBOSE, 1)
208
+ c.setopt(pc.DEBUGFUNCTION, self._debug)
209
+ return c
210
+
211
+ def _perform(self, c: Any) -> None:
212
+ """Perform, re-raising libcurl errors as :class:`CurlError`."""
213
+ try:
214
+ c.perform()
215
+ except self._pycurl.error as e:
216
+ args: tuple[Any, ...] = tuple(e.args)
217
+ code = int(args[0]) if args else 0
218
+ message = str(args[1]) if len(args) > 1 else ""
219
+ raise CurlError(code, message) from None
220
+
221
+ def _file_url(self, path: str) -> str:
222
+ return self.url.curl_file_url(path)
223
+
224
+ # -- operations --------------------------------------------------------
225
+ def get(self, path: str) -> bytes:
226
+ buf = io.BytesIO()
227
+ c = self._prepare(self._file_url(path))
228
+ c.setopt(self._pycurl.WRITEDATA, buf)
229
+ try:
230
+ self._perform(c)
231
+ except CurlError as e:
232
+ if e.code in NOT_FOUND_CODES:
233
+ raise RemoteNotFound(path) from e
234
+ raise DownloadError(describe_error(e.code, e.message, self.url)) from e
235
+ return buf.getvalue()
236
+
237
+ def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None:
238
+ self._progress = progress
239
+ try:
240
+ with open(local, "wb") as fh:
241
+ c = self._prepare(self._file_url(path))
242
+ c.setopt(self._pycurl.WRITEDATA, fh)
243
+ try:
244
+ self._perform(c)
245
+ except CurlError as e:
246
+ if e.code in NOT_FOUND_CODES:
247
+ raise RemoteNotFound(path) from e
248
+ raise DownloadError(describe_error(e.code, e.message, self.url)) from e
249
+ finally:
250
+ self._progress = None
251
+
252
+ def _upload(self, reader: Any, size: int, remote: str) -> None:
253
+ pc = self._pycurl
254
+ c = self._prepare(self._file_url(remote))
255
+ c.setopt(pc.UPLOAD, 1)
256
+ c.setopt(pc.READDATA, reader)
257
+ c.setopt(pc.INFILESIZE_LARGE, size)
258
+ c.setopt(pc.FTP_CREATE_MISSING_DIRS, 2)
259
+ try:
260
+ self._perform(c)
261
+ except CurlError as e:
262
+ raise UploadError(describe_error(e.code, e.message, self.url)) from e
263
+
264
+ def put(
265
+ self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None
266
+ ) -> None:
267
+ self._progress = progress
268
+ try:
269
+ with open(local, "rb") as fh:
270
+ self._upload(fh, size, remote)
271
+ finally:
272
+ self._progress = None
273
+
274
+ def put_bytes(self, data: bytes, remote: str) -> None:
275
+ self._upload(io.BytesIO(data), len(data), remote)
276
+
277
+ def delete(self, path: str) -> None:
278
+ pc = self._pycurl
279
+ directory, name = posixpath.split(path)
280
+ c = self._prepare(self.url.curl_dir_url(directory))
281
+ c.setopt(pc.NOBODY, 1)
282
+ c.setopt(pc.POSTQUOTE, [b"DELE " + name.encode("utf-8", "surrogateescape")])
283
+ try:
284
+ self._perform(c)
285
+ except CurlError as e:
286
+ if e.code == E_REMOTE_ACCESS_DENIED:
287
+ return # the directory is gone, so is the file
288
+ if e.code == E_QUOTE_ERROR and not self.exists(path):
289
+ return
290
+ raise UploadError(f"Could not delete '{path}': {e.message}") from e
291
+
292
+ def mkdir_p(self, directory: str) -> None:
293
+ pc = self._pycurl
294
+ c = self._prepare(self.url.curl_dir_url(directory))
295
+ c.setopt(pc.NOBODY, 1)
296
+ c.setopt(pc.FTP_CREATE_MISSING_DIRS, 2)
297
+ try:
298
+ self._perform(c)
299
+ except CurlError as e:
300
+ raise UploadError(describe_error(e.code, e.message, self.url)) from e
301
+
302
+ def stat(self, path: str) -> Entry | None:
303
+ pc = self._pycurl
304
+ c = self._prepare(self._file_url(path))
305
+ c.setopt(pc.NOBODY, 1)
306
+ c.setopt(pc.OPT_FILETIME, 1)
307
+ try:
308
+ self._perform(c)
309
+ except CurlError as e:
310
+ if e.code in NOT_FOUND_CODES or e.code == E_QUOTE_ERROR:
311
+ return None
312
+ raise DownloadError(describe_error(e.code, e.message, self.url)) from e
313
+ mtime = c.getinfo(pc.INFO_FILETIME)
314
+ size_attr = getattr(pc, "CONTENT_LENGTH_DOWNLOAD_T", pc.CONTENT_LENGTH_DOWNLOAD)
315
+ size = c.getinfo(size_attr)
316
+ return Entry(
317
+ name=posixpath.basename(path),
318
+ is_dir=False,
319
+ size=int(size) if size is not None and size >= 0 else None,
320
+ mtime=int(mtime) if mtime is not None and mtime >= 0 else None,
321
+ )
322
+
323
+ def exists(self, path: str) -> bool:
324
+ return self.stat(path) is not None
325
+
326
+ def _list_raw(self, directory: str, custom: str | None, names_only: bool = False) -> bytes:
327
+ pc = self._pycurl
328
+ buf = io.BytesIO()
329
+ c = self._prepare(self.url.curl_dir_url(directory))
330
+ c.setopt(pc.WRITEDATA, buf)
331
+ if custom:
332
+ c.setopt(pc.CUSTOMREQUEST, custom)
333
+ if names_only:
334
+ c.setopt(pc.DIRLISTONLY, 1)
335
+ self._perform(c)
336
+ return buf.getvalue()
337
+
338
+ def list_dir(self, path: str) -> list[Entry]:
339
+ directory = path.strip("/")
340
+ if self._mlsd_supported:
341
+ try:
342
+ data = self._list_raw(directory, "MLSD")
343
+ except CurlError as e:
344
+ if e.code == E_REMOTE_ACCESS_DENIED:
345
+ raise RemoteNotFound(path) from e
346
+ if e.code in (E_FTP_COULDNT_RETR_FILE, E_QUOTE_ERROR, E_REMOTE_FILE_NOT_FOUND):
347
+ self._mlsd_supported = False
348
+ else:
349
+ raise DownloadError(describe_error(e.code, e.message, self.url)) from e
350
+ else:
351
+ entries = listing.parse_mlsd(data)
352
+ if entries or not data.strip():
353
+ return entries
354
+ self._mlsd_supported = False
355
+ try:
356
+ data = self._list_raw(directory, None)
357
+ except CurlError as e:
358
+ if e.code in (E_REMOTE_ACCESS_DENIED, E_REMOTE_FILE_NOT_FOUND):
359
+ raise RemoteNotFound(path) from e
360
+ raise DownloadError(describe_error(e.code, e.message, self.url)) from e
361
+ entries = listing.parse_list(data)
362
+ if entries or not data.strip():
363
+ return entries
364
+ # Unparsable LIST format: fall back to names and probe each one.
365
+ names = listing.parse_nlst(self._list_raw(directory, None, names_only=True))
366
+ result = []
367
+ for name in names:
368
+ child = f"{directory}/{name}" if directory else name
369
+ try:
370
+ self._list_raw(child, None, names_only=True)
371
+ result.append(Entry(name=name, is_dir=True))
372
+ except CurlError:
373
+ st = self.stat(child)
374
+ result.append(
375
+ Entry(
376
+ name=name,
377
+ is_dir=False,
378
+ size=st.size if st else None,
379
+ mtime=st.mtime if st else None,
380
+ )
381
+ )
382
+ return result
383
+
384
+
385
+ class CurlError(Exception):
386
+ def __init__(self, code: int, message: str) -> None:
387
+ super().__init__(code, message)
388
+ self.code = code
389
+ self.message = message
@@ -0,0 +1,155 @@
1
+ """Parsers for FTP directory listings (MLSD, Unix/DOS LIST, NLST)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import re
7
+ from datetime import datetime, timezone
8
+
9
+ from gitftp.transport.base import Entry
10
+
11
+ _MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
12
+
13
+ _UNIX_RE = re.compile(
14
+ r"^(?P<type>[-dlcbps])(?P<perm>[rwxsStTlL\-+@.]{9,})\s+"
15
+ r"(?:\d+\s+)?" # link count (may be absent on some servers)
16
+ r"(?P<owner>\S+)\s+(?:(?P<group>\S+)\s+)?"
17
+ r"(?P<size>\d+)\s+"
18
+ r"(?P<month>[A-Za-z]{3})\s+(?P<day>\d{1,2})\s+"
19
+ r"(?:(?P<year>\d{4})|(?P<hour>\d{1,2}):(?P<minute>\d{2}))\s+"
20
+ r"(?P<name>.+)$"
21
+ )
22
+ _DOS_RE = re.compile(
23
+ r"^(?P<month>\d{2})-(?P<day>\d{2})-(?P<year>\d{2,4})\s+"
24
+ r"(?P<hour>\d{2}):(?P<minute>\d{2})(?P<ampm>[AP]M)\s+"
25
+ r"(?P<size><DIR>|\d+)\s+(?P<name>.+)$"
26
+ )
27
+
28
+
29
+ def _decode(data: bytes) -> list[str]:
30
+ text = data.decode("utf-8", "surrogateescape")
31
+ return [line.rstrip("\r") for line in text.split("\n") if line.strip("\r")]
32
+
33
+
34
+ def _parse_mlsd_time(value: str) -> int | None:
35
+ m = re.match(r"^(\d{14})(?:\.\d+)?$", value)
36
+ if not m:
37
+ return None
38
+ dt = datetime.strptime(m.group(1), "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
39
+ return int(dt.timestamp())
40
+
41
+
42
+ def parse_mlsd(data: bytes) -> list[Entry]:
43
+ entries: list[Entry] = []
44
+ for line in _decode(data):
45
+ facts_part, sep, name = line.partition(" ")
46
+ if not sep or not name:
47
+ continue
48
+ facts: dict[str, str] = {}
49
+ for fact in facts_part.split(";"):
50
+ if "=" in fact:
51
+ k, v = fact.split("=", 1)
52
+ facts[k.lower()] = v
53
+ ftype = facts.get("type", "file").lower()
54
+ if ftype in ("cdir", "pdir") or name in (".", ".."):
55
+ continue
56
+ is_link = ftype.startswith("os.unix=slink") or ftype == "os.unix=symlink"
57
+ is_dir = ftype == "dir"
58
+ size = int(facts["size"]) if facts.get("size", "").isdigit() else None
59
+ mtime = _parse_mlsd_time(facts["modify"]) if "modify" in facts else None
60
+ entries.append(
61
+ Entry(
62
+ name=name, is_dir=is_dir, size=size, mtime=mtime, mtime_exact=True, is_link=is_link
63
+ )
64
+ )
65
+ return entries
66
+
67
+
68
+ def _guess_year_ts(month: int, day: int, hour: int, minute: int, now: datetime) -> int:
69
+ year = now.year
70
+ try:
71
+ dt = datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
72
+ except ValueError:
73
+ return 0
74
+ if dt.timestamp() > now.timestamp() + 86400:
75
+ dt = dt.replace(year=year - 1)
76
+ return int(dt.timestamp())
77
+
78
+
79
+ def parse_list(data: bytes, now: datetime | None = None) -> list[Entry]:
80
+ """Parse a ``LIST`` reply in Unix ``ls -l`` or DOS/IIS format."""
81
+ now = now or datetime.now(timezone.utc)
82
+ entries: list[Entry] = []
83
+ for line in _decode(data):
84
+ if line.lower().startswith("total "):
85
+ continue
86
+ m = _UNIX_RE.match(line)
87
+ if m:
88
+ name = m.group("name")
89
+ is_link = m.group("type") == "l"
90
+ if is_link and " -> " in name:
91
+ name = name.split(" -> ", 1)[0]
92
+ if name in (".", ".."):
93
+ continue
94
+ month = _MONTHS.get(m.group("month").lower())
95
+ if month is None:
96
+ continue
97
+ day = int(m.group("day"))
98
+ if m.group("year"):
99
+ dt = datetime(int(m.group("year")), month, day, tzinfo=timezone.utc)
100
+ mtime = int(dt.timestamp())
101
+ else:
102
+ mtime = _guess_year_ts(
103
+ month, day, int(m.group("hour")), int(m.group("minute")), now
104
+ )
105
+ entries.append(
106
+ Entry(
107
+ name=name,
108
+ is_dir=m.group("type") == "d",
109
+ size=int(m.group("size")),
110
+ mtime=mtime,
111
+ mtime_exact=False,
112
+ is_link=is_link,
113
+ )
114
+ )
115
+ continue
116
+ m = _DOS_RE.match(line)
117
+ if m:
118
+ name = m.group("name")
119
+ if name in (".", ".."):
120
+ continue
121
+ year = int(m.group("year"))
122
+ if year < 100:
123
+ year += 2000 if year < 70 else 1900
124
+ hour = int(m.group("hour")) % 12
125
+ if m.group("ampm") == "PM":
126
+ hour += 12
127
+ dt = datetime(
128
+ year,
129
+ int(m.group("month")),
130
+ int(m.group("day")),
131
+ hour,
132
+ int(m.group("minute")),
133
+ tzinfo=timezone.utc,
134
+ )
135
+ is_dir = m.group("size") == "<DIR>"
136
+ entries.append(
137
+ Entry(
138
+ name=name,
139
+ is_dir=is_dir,
140
+ size=None if is_dir else int(m.group("size")),
141
+ mtime=int(dt.timestamp()),
142
+ mtime_exact=False,
143
+ )
144
+ )
145
+ return entries
146
+
147
+
148
+ def parse_nlst(data: bytes) -> list[str]:
149
+ names = []
150
+ for line in _decode(data):
151
+ name = line.rsplit("/", 1)[-1] if line.endswith("/") is False else line.rstrip("/")
152
+ name = name.rsplit("/", 1)[-1]
153
+ if name and name not in (".", ".."):
154
+ names.append(name)
155
+ return names
@@ -0,0 +1,73 @@
1
+ """Scheme to transport factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from gitftp.auth import Credentials
9
+ from gitftp.errors import UnknownProtocolError
10
+ from gitftp.output import Output
11
+ from gitftp.transport.base import Connector, Transport
12
+ from gitftp.url import RemoteURL, Scheme
13
+
14
+
15
+ @dataclass
16
+ class TransportOptions:
17
+ insecure: bool = False
18
+ cacert: str | None = None
19
+ active: bool = False
20
+ disable_epsv: bool = False
21
+ proxy: str | None = None
22
+ trace: Callable[[str], None] | None = None
23
+ known_hosts_files: list[str] | None = None
24
+
25
+
26
+ def check_available(scheme: Scheme) -> None:
27
+ if scheme is Scheme.SFTP:
28
+ from gitftp.transport import sftp
29
+
30
+ sftp.check_available()
31
+ else:
32
+ from gitftp.transport import curlftp
33
+
34
+ curlftp.check_available(scheme)
35
+
36
+
37
+ def connector(
38
+ url: RemoteURL, creds: Credentials, topts: TransportOptions, out: Output
39
+ ) -> Connector:
40
+ """Return a factory that opens a fresh connection each time it is called."""
41
+ if url.scheme is Scheme.SFTP:
42
+ from gitftp.transport.sftp import DirCache, SftpOptions, SftpTransport
43
+
44
+ cache = DirCache()
45
+ sopts = SftpOptions(
46
+ insecure=topts.insecure, trace=topts.trace, known_hosts_files=topts.known_hosts_files
47
+ )
48
+
49
+ def open_sftp() -> Transport:
50
+ t = SftpTransport(url, creds, sopts, out, dircache=cache)
51
+ t.open()
52
+ return t
53
+
54
+ return open_sftp
55
+ if url.scheme in (Scheme.FTP, Scheme.FTPS, Scheme.FTPES):
56
+ from gitftp.transport.curlftp import CurlFtpTransport, CurlOptions
57
+
58
+ copts = CurlOptions(
59
+ insecure=topts.insecure,
60
+ cacert=topts.cacert,
61
+ active=topts.active,
62
+ disable_epsv=topts.disable_epsv,
63
+ proxy=topts.proxy,
64
+ trace=topts.trace,
65
+ )
66
+
67
+ def open_ftp() -> Transport:
68
+ t = CurlFtpTransport(url, creds, copts, out)
69
+ t.open()
70
+ return t
71
+
72
+ return open_ftp
73
+ raise UnknownProtocolError(f"Protocol unknown '{url.scheme.value}://'.")