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,442 @@
1
+ """SFTP through paramiko.
2
+
3
+ Deliberate difference from upstream: host keys are verified against
4
+ ``~/.ssh/known_hosts`` and ``/etc/ssh/ssh_known_hosts``; ``--insecure`` skips
5
+ the check. Authentication order: --key, ssh-agent, password.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import errno
12
+ import logging
13
+ import os
14
+ import posixpath
15
+ import socket
16
+ import stat as statmod
17
+ import threading
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from gitftp.auth import Credentials
23
+ from gitftp.errors import DownloadError, MissingArgumentError, UploadError
24
+ from gitftp.output import Output
25
+ from gitftp.transport.base import Entry, ProgressFn, RemoteNotFound, TransferCancelled, Transport
26
+ from gitftp.url import RemoteURL
27
+
28
+ DEFAULT_PORT = 22
29
+ SYSTEM_KNOWN_HOSTS = ["/etc/ssh/ssh_known_hosts"]
30
+
31
+
32
+ def check_available() -> None:
33
+ try:
34
+ import paramiko # noqa: F401
35
+ except ImportError as e:
36
+ raise DownloadError("paramiko is not available; sftp support is disabled.") from e
37
+
38
+
39
+ class DirCache:
40
+ """Remembers created directories so parallel workers do not race on mkdir."""
41
+
42
+ def __init__(self) -> None:
43
+ self.lock = threading.Lock()
44
+ self.known: set[str] = set()
45
+
46
+
47
+ class SftpOptions:
48
+ def __init__(
49
+ self,
50
+ *,
51
+ insecure: bool = False,
52
+ trace: Callable[[str], None] | None = None,
53
+ known_hosts_files: list[str] | None = None,
54
+ ) -> None:
55
+ self.insecure = insecure
56
+ self.trace = trace
57
+ self.known_hosts_files = known_hosts_files
58
+
59
+
60
+ class _TraceHandler(logging.Handler):
61
+ def __init__(self, fn: Callable[[str], None]) -> None:
62
+ super().__init__(logging.DEBUG)
63
+ self.fn = fn
64
+
65
+ def emit(self, record: logging.LogRecord) -> None:
66
+ self.fn("* " + record.getMessage())
67
+
68
+
69
+ class SftpTransport(Transport):
70
+ def __init__(
71
+ self,
72
+ url: RemoteURL,
73
+ creds: Credentials,
74
+ options: SftpOptions,
75
+ out: Output,
76
+ dircache: DirCache | None = None,
77
+ ) -> None:
78
+ super().__init__()
79
+ self.url = url
80
+ self.creds = creds
81
+ self.options = options
82
+ self.out = out
83
+ self.dircache = dircache or DirCache()
84
+ self._transport: Any = None
85
+ self._sftp: Any = None
86
+ self._loaded_key: Any = None
87
+
88
+ # -- lifecycle ---------------------------------------------------------
89
+ def open(self) -> None:
90
+ import paramiko
91
+
92
+ host = self.url.hostname
93
+ port = self.url.port or DEFAULT_PORT
94
+ # Load the private key before opening the socket: a locked key with no
95
+ # passphrase should fail fast without a pointless connection (and without
96
+ # leaving a half-open session on the server).
97
+ if self.creds.key:
98
+ self._loaded_key = self._load_private_key()
99
+ if self.options.trace is not None:
100
+ logger = logging.getLogger("paramiko.transport")
101
+ logger.setLevel(logging.DEBUG)
102
+ if not any(isinstance(h, _TraceHandler) for h in logger.handlers):
103
+ logger.addHandler(_TraceHandler(self.options.trace))
104
+ try:
105
+ sock = socket.create_connection((host, port), timeout=30)
106
+ except OSError as e:
107
+ raise DownloadError(
108
+ f"Can't access remote '{self.url.display()}'. Network down? Wrong URL? ({e})"
109
+ ) from e
110
+ t = paramiko.Transport(sock)
111
+ expected = None
112
+ if not self.options.insecure:
113
+ expected = self._known_host_keys(host, port)
114
+ if expected:
115
+ opts = t.get_security_options()
116
+ preferred = [k for k in expected if k in opts.key_types]
117
+ opts.key_types = tuple(
118
+ preferred + [k for k in opts.key_types if k not in preferred]
119
+ )
120
+ try:
121
+ t.start_client(timeout=30)
122
+ except (paramiko.SSHException, OSError) as e:
123
+ t.close()
124
+ raise DownloadError(
125
+ f"Can't access remote '{self.url.display()}'. SSH handshake failed ({e})."
126
+ ) from e
127
+ if not self.options.insecure:
128
+ self._verify_host_key(t, host, port, expected or {})
129
+ self._transport = t
130
+ try:
131
+ self._authenticate(t)
132
+ except BaseException:
133
+ t.close()
134
+ raise
135
+ sftp = paramiko.SFTPClient.from_transport(t)
136
+ if sftp is None: # pragma: no cover
137
+ t.close()
138
+ raise DownloadError("Could not open the SFTP subsystem.")
139
+ chan = sftp.get_channel()
140
+ if chan is not None:
141
+ chan.settimeout(60)
142
+ self._sftp = sftp
143
+
144
+ def close(self) -> None:
145
+ if self._sftp is not None:
146
+ with contextlib.suppress(Exception):
147
+ self._sftp.close()
148
+ self._sftp = None
149
+ if self._transport is not None:
150
+ with contextlib.suppress(Exception):
151
+ self._transport.close()
152
+ self._transport = None
153
+
154
+ # -- host keys ---------------------------------------------------------
155
+ def _known_hosts_paths(self) -> list[Path]:
156
+ if self.options.known_hosts_files is not None:
157
+ return [Path(p) for p in self.options.known_hosts_files]
158
+ paths = [Path.home() / ".ssh" / "known_hosts"]
159
+ paths += [Path(p) for p in SYSTEM_KNOWN_HOSTS]
160
+ return paths
161
+
162
+ def _known_host_keys(self, host: str, port: int) -> dict[str, Any]:
163
+ import paramiko
164
+
165
+ hostkeys = paramiko.HostKeys()
166
+ for path in self._known_hosts_paths():
167
+ if path.is_file():
168
+ try:
169
+ hostkeys.load(str(path))
170
+ except OSError:
171
+ continue
172
+ name = host if port == DEFAULT_PORT else f"[{host}]:{port}"
173
+ found = hostkeys.lookup(name)
174
+ if found is None and port != DEFAULT_PORT:
175
+ found = None
176
+ return dict(found) if found else {}
177
+
178
+ def _verify_host_key(self, t: Any, host: str, port: int, expected: dict[str, Any]) -> None:
179
+ key = t.get_remote_server_key()
180
+ name = host if port == DEFAULT_PORT else f"[{host}]:{port}"
181
+ hint = f"ssh-keyscan -p {port} {host} >> ~/.ssh/known_hosts"
182
+ if not expected:
183
+ t.close()
184
+ raise DownloadError(
185
+ f"Host key verification failed: '{name}' is not in known_hosts. "
186
+ f"Add it with '{hint}' or pass --insecure."
187
+ )
188
+ known = expected.get(key.get_name())
189
+ if known is None or known.asbytes() != key.asbytes():
190
+ t.close()
191
+ raise DownloadError(
192
+ f"Host key verification failed: the {key.get_name()} key of '{name}' does not "
193
+ "match known_hosts. If the server changed, update known_hosts "
194
+ f"({hint}) or pass --insecure."
195
+ )
196
+
197
+ # -- auth --------------------------------------------------------------
198
+ def _load_private_key(self) -> Any:
199
+ import paramiko
200
+
201
+ assert self.creds.key
202
+ path = self.creds.key
203
+ passphrase = self.creds.key_passphrase
204
+ classes: list[type[paramiko.PKey]] = [
205
+ paramiko.Ed25519Key,
206
+ paramiko.RSAKey,
207
+ paramiko.ECDSAKey,
208
+ ]
209
+ for _attempt in range(2):
210
+ last: Exception | None = None
211
+ for cls in classes:
212
+ try:
213
+ return cls.from_private_key_file(path, password=passphrase or None)
214
+ except paramiko.PasswordRequiredException:
215
+ last = None
216
+ break
217
+ except paramiko.SSHException as e:
218
+ last = e
219
+ else:
220
+ raise DownloadError(f"Could not read private key '{path}': {last}")
221
+ # Needs a passphrase
222
+ if passphrase:
223
+ raise DownloadError(f"Wrong passphrase for private key '{path}'.")
224
+ if not self.out.stdin.isatty() and self.out._stdin is None:
225
+ raise MissingArgumentError(
226
+ f"Private key '{path}' is encrypted; give --key-passphrase."
227
+ )
228
+ passphrase = self.out.prompt_secret(f"Enter passphrase for {path}: ")
229
+ self.out.add_secret(passphrase)
230
+ raise DownloadError(f"Could not read private key '{path}'.")
231
+
232
+ def _authenticate(self, t: Any) -> None:
233
+ import paramiko
234
+
235
+ user = self.creds.user or os.environ.get("USER") or os.environ.get("USERNAME") or ""
236
+ if not user:
237
+ import getpass
238
+
239
+ user = getpass.getuser()
240
+ errors: list[str] = []
241
+ if self.creds.key:
242
+ key = self._loaded_key if self._loaded_key is not None else self._load_private_key()
243
+ try:
244
+ t.auth_publickey(user, key)
245
+ return
246
+ except paramiko.AuthenticationException as e:
247
+ errors.append(f"key: {e}")
248
+ if os.environ.get("SSH_AUTH_SOCK"):
249
+ try:
250
+ agent_keys = paramiko.Agent().get_keys()
251
+ except paramiko.SSHException:
252
+ agent_keys = ()
253
+ for key in agent_keys:
254
+ try:
255
+ t.auth_publickey(user, key)
256
+ return
257
+ except paramiko.AuthenticationException:
258
+ continue
259
+ if agent_keys:
260
+ errors.append("agent: no accepted key")
261
+ if self.creds.password is not None:
262
+ password = self.creds.password
263
+ try:
264
+ t.auth_password(user, password)
265
+ return
266
+ except paramiko.BadAuthenticationType as e:
267
+ errors.append(f"password: {e}")
268
+ except paramiko.AuthenticationException as e:
269
+ errors.append(f"password: {e}")
270
+ try:
271
+
272
+ def handler(_title: str, _instr: str, prompts: Any) -> list[str]:
273
+ return [password for _ in prompts]
274
+
275
+ t.auth_interactive(user, handler)
276
+ return
277
+ except paramiko.AuthenticationException:
278
+ pass
279
+ if not errors:
280
+ raise MissingArgumentError(
281
+ "No SFTP credentials: give a password, a key (--key) or run an ssh-agent."
282
+ )
283
+ raise UploadError(
284
+ f"Can't access remote '{self.url.display()}'. Failed to log in. "
285
+ "Correct user, password or key?"
286
+ )
287
+
288
+ # -- helpers -----------------------------------------------------------
289
+ def _path(self, rel: str) -> str:
290
+ return self.url.sftp_path(rel)
291
+
292
+ def _check_cancel(self) -> None:
293
+ if self.cancel.is_set():
294
+ raise TransferCancelled()
295
+
296
+ def _callback(self, progress: ProgressFn | None) -> Callable[[int, int], None]:
297
+ def cb(done: int, total: int) -> None:
298
+ if self.cancel.is_set():
299
+ raise TransferCancelled()
300
+ if progress is not None:
301
+ progress(done, total)
302
+
303
+ return cb
304
+
305
+ @staticmethod
306
+ def _is_enoent(e: OSError) -> bool:
307
+ return isinstance(e, FileNotFoundError) or e.errno == errno.ENOENT
308
+
309
+ # -- operations --------------------------------------------------------
310
+ def get(self, path: str) -> bytes:
311
+ try:
312
+ with self._sftp.open(self._path(path), "rb") as fh:
313
+ data: bytes = fh.read()
314
+ return data
315
+ except OSError as e:
316
+ if self._is_enoent(e):
317
+ raise RemoteNotFound(path) from e
318
+ raise DownloadError(f"Could not read '{path}': {e}") from e
319
+
320
+ def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None:
321
+ try:
322
+ with open(local, "wb") as fh:
323
+ self._sftp.getfo(self._path(path), fh, callback=self._callback(progress))
324
+ except OSError as e:
325
+ if self._is_enoent(e):
326
+ raise RemoteNotFound(path) from e
327
+ raise DownloadError(f"Could not download '{path}': {e}") from e
328
+
329
+ def put(
330
+ self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None
331
+ ) -> None:
332
+ self.mkdir_p(posixpath.dirname(remote))
333
+ try:
334
+ with open(local, "rb") as fh:
335
+ self._sftp.putfo(
336
+ fh,
337
+ self._path(remote),
338
+ file_size=size,
339
+ callback=self._callback(progress),
340
+ confirm=False,
341
+ )
342
+ except OSError as e:
343
+ raise UploadError(f"Could not upload '{remote}': {e}") from e
344
+
345
+ def put_bytes(self, data: bytes, remote: str) -> None:
346
+ import io
347
+
348
+ self.mkdir_p(posixpath.dirname(remote))
349
+ try:
350
+ self._sftp.putfo(
351
+ io.BytesIO(data), self._path(remote), file_size=len(data), confirm=False
352
+ )
353
+ except OSError as e:
354
+ raise UploadError(f"Could not upload '{remote}': {e}") from e
355
+
356
+ def delete(self, path: str) -> None:
357
+ try:
358
+ self._sftp.remove(self._path(path))
359
+ except OSError as e:
360
+ if self._is_enoent(e):
361
+ return
362
+ raise UploadError(f"Could not delete '{path}': {e}") from e
363
+
364
+ def mkdir_p(self, directory: str) -> None:
365
+ base = self.url.sftp_path("")
366
+ base = "" if base == "." else base
367
+ full = posixpath.normpath(posixpath.join(base, directory.strip("/")))
368
+ if full in (".", "", "/"):
369
+ return
370
+ lead = "/" if full.startswith("/") else ""
371
+ acc = ""
372
+ for part in [p for p in full.split("/") if p]:
373
+ acc = f"{acc}/{part}" if acc else lead + part
374
+ with self.dircache.lock:
375
+ if acc in self.dircache.known:
376
+ continue
377
+ try:
378
+ st = self._sftp.stat(acc)
379
+ if not statmod.S_ISDIR(st.st_mode or 0):
380
+ raise UploadError(f"'{acc}' exists on the remote but is not a directory.")
381
+ except OSError as e:
382
+ if not self._is_enoent(e):
383
+ raise UploadError(f"Could not stat '{acc}': {e}") from e
384
+ try:
385
+ self._sftp.mkdir(acc)
386
+ except OSError as e2:
387
+ try:
388
+ self._sftp.stat(acc)
389
+ except OSError:
390
+ raise UploadError(f"Could not create directory '{acc}': {e2}") from e2
391
+ self.dircache.known.add(acc)
392
+
393
+ def stat(self, path: str) -> Entry | None:
394
+ try:
395
+ st = self._sftp.stat(self._path(path))
396
+ except OSError as e:
397
+ if self._is_enoent(e):
398
+ return None
399
+ raise DownloadError(f"Could not stat '{path}': {e}") from e
400
+ return Entry(
401
+ name=posixpath.basename(path),
402
+ is_dir=statmod.S_ISDIR(st.st_mode or 0),
403
+ size=st.st_size,
404
+ mtime=int(st.st_mtime) if st.st_mtime is not None else None,
405
+ )
406
+
407
+ def exists(self, path: str) -> bool:
408
+ return self.stat(path) is not None
409
+
410
+ def list_dir(self, path: str) -> list[Entry]:
411
+ target = self._path(path.strip("/") + "/" if path.strip("/") else "")
412
+ if target.endswith("/") and len(target) > 1:
413
+ target = target.rstrip("/")
414
+ try:
415
+ attrs = self._sftp.listdir_attr(target)
416
+ except OSError as e:
417
+ if self._is_enoent(e):
418
+ raise RemoteNotFound(path) from e
419
+ raise DownloadError(f"Could not list '{path}': {e}") from e
420
+ entries = []
421
+ for a in attrs:
422
+ if a.filename in (".", ".."):
423
+ continue
424
+ mode = a.st_mode or 0
425
+ is_link = statmod.S_ISLNK(mode)
426
+ is_dir = statmod.S_ISDIR(mode)
427
+ if is_link:
428
+ child = posixpath.join(target, a.filename) if target != "." else a.filename
429
+ try:
430
+ is_dir = statmod.S_ISDIR(self._sftp.stat(child).st_mode or 0)
431
+ except OSError:
432
+ is_dir = False
433
+ entries.append(
434
+ Entry(
435
+ name=a.filename,
436
+ is_dir=is_dir,
437
+ size=a.st_size,
438
+ mtime=int(a.st_mtime) if a.st_mtime is not None else None,
439
+ is_link=is_link,
440
+ )
441
+ )
442
+ return entries
gitftp/url.py ADDED
@@ -0,0 +1,204 @@
1
+ """Remote URL parsing and rendering.
2
+
3
+ Fixes over upstream: ``user:pass@host`` userinfo is credentials (upstream took
4
+ ``user`` for the host), ``host:2121/path`` without a scheme is accepted as
5
+ ftp (the built-in help promised it), and ``--remote-root`` replaces the path.
6
+ Credentials never appear in any rendered URL except :meth:`RemoteURL.display`,
7
+ which masks the password.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import enum
13
+ import re
14
+ from dataclasses import dataclass
15
+ from urllib.parse import quote, unquote
16
+
17
+ from gitftp.errors import MissingArgumentError, UnknownProtocolError
18
+
19
+
20
+ class Scheme(str, enum.Enum):
21
+ FTP = "ftp"
22
+ FTPS = "ftps" # implicit TLS
23
+ FTPES = "ftpes" # explicit TLS (AUTH TLS)
24
+ SFTP = "sftp"
25
+
26
+ @property
27
+ def secure(self) -> bool:
28
+ return self in (Scheme.FTPS, Scheme.FTPES)
29
+
30
+ @property
31
+ def curl_scheme(self) -> str:
32
+ """libcurl has no ``ftpes``; explicit TLS is ``ftp://`` plus CURLOPT_USE_SSL."""
33
+ return "ftp" if self is Scheme.FTPES else self.value
34
+
35
+
36
+ DEFAULT_SCHEME = Scheme.FTP
37
+ _SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$", re.DOTALL)
38
+ _PORT_HOST_RE = re.compile(r"^[^/:]+:\d+$")
39
+
40
+
41
+ @dataclass
42
+ class RemoteURL:
43
+ scheme: Scheme
44
+ host: str # hostname, with ``:port`` when given
45
+ path: str = "" # "" or "dir/sub/" (no leading slash, exactly one trailing slash)
46
+ absolute: bool = False # the raw path began with a second '/' (``host//var/www``)
47
+ user: str | None = None
48
+ password: str | None = None
49
+
50
+ # -- normalisation -----------------------------------------------------
51
+ def set_path(self, raw: str) -> None:
52
+ raw = raw.replace("\\", "/")
53
+ self.absolute = raw.startswith("/")
54
+ stripped = raw.strip("/")
55
+ self.path = f"{stripped}/" if stripped else ""
56
+
57
+ def child(self, rel_dir: str) -> RemoteURL:
58
+ """The URL of a subdirectory (used for submodules)."""
59
+ rel = rel_dir.strip("/")
60
+ return RemoteURL(
61
+ scheme=self.scheme,
62
+ host=self.host,
63
+ path=f"{self.path}{rel}/" if rel else self.path,
64
+ absolute=self.absolute,
65
+ user=self.user,
66
+ password=self.password,
67
+ )
68
+
69
+ @property
70
+ def hostname(self) -> str:
71
+ host, _ = _split_host_port(self.host)
72
+ return host
73
+
74
+ @property
75
+ def port(self) -> int | None:
76
+ _, port = _split_host_port(self.host)
77
+ return port
78
+
79
+ # -- rendering ---------------------------------------------------------
80
+ def display(self) -> str:
81
+ """For messages and hooks: ``ftp://user:***@host/path/``."""
82
+ cred = f"{self.user}:***@" if self.user else ""
83
+ lead = "/" if self.absolute else ""
84
+ return f"{self.scheme.value}://{cred}{self.host}/{lead}{self.path}"
85
+
86
+ def name(self) -> str:
87
+ """``host/path/`` as used in "No changed files for ..." messages."""
88
+ lead = "/" if self.absolute else ""
89
+ return f"{self.host}/{lead}{self.path}"
90
+
91
+ def curl_base(self) -> str:
92
+ return f"{self.scheme.curl_scheme}://{self.host}"
93
+
94
+ def _curl_path(self, rel: str) -> str:
95
+ lead = "%2F" if self.absolute else ""
96
+ return lead + escape_path(self.path + rel)
97
+
98
+ def curl_file_url(self, rel: str) -> str:
99
+ return f"{self.curl_base()}/{self._curl_path(rel)}"
100
+
101
+ def curl_dir_url(self, rel_dir: str = "") -> str:
102
+ rel = rel_dir.strip("/")
103
+ if rel:
104
+ rel += "/"
105
+ return f"{self.curl_base()}/{self._curl_path(rel)}"
106
+
107
+ def sftp_path(self, rel: str = "") -> str:
108
+ """Path for the SFTP subsystem: absolute, ``~/``-relative or login-dir relative."""
109
+ base = self.path
110
+ if base.startswith("~/"):
111
+ base = base[2:]
112
+ lead = "/" if self.absolute else ""
113
+ p = f"{lead}{base}{rel}"
114
+ return p if p else "."
115
+
116
+
117
+ def _split_host_port(host: str) -> tuple[str, int | None]:
118
+ if host.startswith("["): # [v6]:port
119
+ end = host.find("]")
120
+ if end != -1:
121
+ rest = host[end + 1 :]
122
+ if rest.startswith(":") and rest[1:].isdigit():
123
+ return host[1:end], int(rest[1:])
124
+ return host[1:end], None
125
+ if host.count(":") == 1:
126
+ name, _, port = host.partition(":")
127
+ if port.isdigit():
128
+ return name, int(port)
129
+ return host, None
130
+
131
+
132
+ _SAFE_PATH = frozenset(
133
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/"
134
+ )
135
+
136
+
137
+ def escape_path(path: str) -> str:
138
+ """Percent-encode a remote path for a URL; ``/`` is kept.
139
+
140
+ Encodes everything libcurl would otherwise misread: control bytes, space,
141
+ non-ASCII, and ``" # % < > ? [ ] ^ ` { | } \\``.
142
+ """
143
+ raw = path.encode("utf-8", "surrogateescape")
144
+ out = []
145
+ for b in raw:
146
+ if b in _SAFE_PATH:
147
+ out.append(chr(b))
148
+ else:
149
+ out.append(f"%{b:02X}")
150
+ return "".join(out)
151
+
152
+
153
+ def escape_userinfo(s: str) -> str:
154
+ return quote(s, safe="")
155
+
156
+
157
+ def unescape(s: str) -> str:
158
+ return unquote(s, errors="surrogateescape")
159
+
160
+
161
+ def parse(raw: str) -> RemoteURL:
162
+ """Parse ``[scheme://][user[:password]@]host[:port][/path]``."""
163
+ raw = raw.strip()
164
+ if not raw:
165
+ raise MissingArgumentError("Remote host not set.")
166
+
167
+ m = _SCHEME_RE.match(raw)
168
+ if m:
169
+ scheme_name, rest = m.group(1).lower(), m.group(2)
170
+ try:
171
+ scheme = Scheme(scheme_name)
172
+ except ValueError:
173
+ raise UnknownProtocolError(f"Protocol unknown '{scheme_name}://'.") from None
174
+ else:
175
+ rest = raw
176
+ head = rest.split("/", 1)[0]
177
+ # "ftp:host" (one slash missing) or "foo:bar" is not a host:port form.
178
+ if (
179
+ ":" in head
180
+ and "@" not in head
181
+ and not _PORT_HOST_RE.match(head)
182
+ and re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", head)
183
+ and not head.split(":", 1)[1].isdigit()
184
+ ):
185
+ name = head.split(":", 1)[0]
186
+ raise UnknownProtocolError(f"Protocol unknown '{name}:'.")
187
+ scheme = DEFAULT_SCHEME
188
+
189
+ # userinfo: split at the LAST '@' of the authority part (passwords may contain '@').
190
+ authority, sep, path = rest.partition("/")
191
+ user: str | None = None
192
+ password: str | None = None
193
+ if "@" in authority:
194
+ userinfo, _, hostport = authority.rpartition("@")
195
+ authority = hostport
196
+ u, has_pw, p = userinfo.partition(":")
197
+ user = unescape(u)
198
+ password = unescape(p) if has_pw else None
199
+ if not authority:
200
+ raise MissingArgumentError("Remote host not set.")
201
+
202
+ url = RemoteURL(scheme=scheme, host=authority, user=user, password=password)
203
+ url.set_path(path if sep else "")
204
+ return url
gitftp/version.py ADDED
@@ -0,0 +1,35 @@
1
+ """Version information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__", "runtime_info", "version_line"]
6
+
7
+ try:
8
+ from gitftp._version import __version__
9
+ except ImportError: # pragma: no cover - only in an unbuilt checkout
10
+ __version__ = "0.0.0+unknown"
11
+
12
+
13
+ def version_line() -> str:
14
+ """The line printed by ``git-ftp --version``, in upstream's format."""
15
+ return f"git-ftp version {__version__}"
16
+
17
+
18
+ def runtime_info() -> list[str]:
19
+ """Which transports and libraries this installation has."""
20
+ lines: list[str] = []
21
+ try:
22
+ import pycurl
23
+
24
+ info = pycurl.version_info()
25
+ protocols = " ".join(p for p in info[8] if p in ("ftp", "ftps"))
26
+ lines.append(f"libcurl {info[1]} ({info[5] or 'no TLS'}), protocols: {protocols}")
27
+ except ImportError:
28
+ lines.append("libcurl: pycurl not available (ftp, ftps and ftpes disabled)")
29
+ try:
30
+ import paramiko
31
+
32
+ lines.append(f"paramiko {paramiko.__version__} (sftp)")
33
+ except ImportError:
34
+ lines.append("paramiko: not available (sftp disabled)")
35
+ return lines