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,20 @@
1
+ """The ``snapshot`` action: turn a remote directory into a new repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from gitftp import mirror
10
+ from gitftp.cli.options import common_options, prepare, url_argument
11
+
12
+
13
+ @click.command("snapshot", short_help="Download a remote directory into a new repository.")
14
+ @common_options
15
+ @url_argument
16
+ @click.argument("directory", required=False, metavar="[DIRECTORY]")
17
+ @click.pass_context
18
+ def command(ctx: click.Context, /, url: str | None, directory: str | None, **kw: Any) -> None:
19
+ opts, out = prepare(ctx, kw)
20
+ mirror.run_snapshot(opts, url, directory, out)
@@ -0,0 +1,19 @@
1
+ """The ``unlock`` action (new): remove a stale remote lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from gitftp import mirror
10
+ from gitftp.cli.options import common_options, session_for, url_argument
11
+
12
+
13
+ @click.command("unlock", short_help="Remove a stale remote lock left by an interrupted deploy.")
14
+ @common_options
15
+ @url_argument
16
+ @click.pass_context
17
+ def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
18
+ _opts, session = session_for(ctx, url, kw, need_repo=False)
19
+ mirror.run_unlock(session)
@@ -0,0 +1,19 @@
1
+ """The ``version`` action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from gitftp.cli.options import common_options
10
+ from gitftp.version import runtime_info, version_line
11
+
12
+
13
+ @click.command("version", short_help="Print the version.")
14
+ @common_options
15
+ def command(verbose: int = 0, **_kw: Any) -> None:
16
+ click.echo(version_line())
17
+ if verbose:
18
+ for line in runtime_info():
19
+ click.echo(line)
gitftp/cli/group.py ADDED
@@ -0,0 +1,68 @@
1
+ """The click group and the action registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from gitftp.version import version_line
11
+
12
+ ACTIONS: tuple[str, ...] = (
13
+ "init",
14
+ "push",
15
+ "catchup",
16
+ "show",
17
+ "log",
18
+ "download",
19
+ "pull",
20
+ "snapshot",
21
+ "add-scope",
22
+ "remove-scope",
23
+ "unlock",
24
+ "help",
25
+ "version",
26
+ )
27
+
28
+ _MODULES = {name: f"gitftp.cli.cmd_{name.replace('-', '_')}" for name in ACTIONS}
29
+
30
+
31
+ class GitFtpGroup(click.Group):
32
+ def list_commands(self, ctx: click.Context) -> list[str]:
33
+ return list(ACTIONS)
34
+
35
+
36
+ def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
37
+ if not value or ctx.resilient_parsing:
38
+ return
39
+ click.echo(version_line())
40
+ ctx.exit(0)
41
+
42
+
43
+ def build_group() -> click.Group:
44
+ @click.group(
45
+ cls=GitFtpGroup,
46
+ name="git-ftp",
47
+ context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
48
+ invoke_without_command=True,
49
+ help="Git powered FTP, FTPS, FTPES and SFTP client.",
50
+ )
51
+ @click.option(
52
+ "--version",
53
+ is_flag=True,
54
+ expose_value=False,
55
+ is_eager=True,
56
+ callback=_print_version,
57
+ help="Print the version and exit.",
58
+ )
59
+ @click.pass_context
60
+ def group(ctx: click.Context, /, **_kw: Any) -> None:
61
+ if ctx.invoked_subcommand is None:
62
+ click.echo("git-ftp <action> [<options>] [<url>]")
63
+ ctx.exit(2)
64
+
65
+ for name, module_name in _MODULES.items():
66
+ module = importlib.import_module(module_name)
67
+ group.add_command(module.command, name=name)
68
+ return group
gitftp/cli/options.py ADDED
@@ -0,0 +1,208 @@
1
+ """Options shared by every action, plus helpers for command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar
8
+
9
+ import click
10
+
11
+ from gitftp.options import CliOptions
12
+ from gitftp.output import Output
13
+ from gitftp.session import Session, open_session
14
+
15
+ F = TypeVar("F", bound=Callable[..., Any])
16
+
17
+ # Options that take a value; the CLI normaliser must not mistake the value for the action.
18
+ VALUE_OPTIONS = frozenset(
19
+ {
20
+ "-u",
21
+ "--user",
22
+ "-p",
23
+ "--passwd",
24
+ "--password",
25
+ "--password-command",
26
+ "-k",
27
+ "--keychain",
28
+ "--key",
29
+ "--pubkey",
30
+ "--key-passphrase",
31
+ "-b",
32
+ "--branch",
33
+ "-c",
34
+ "--commit",
35
+ "-s",
36
+ "--scope",
37
+ "--syncroot",
38
+ "--remote-root",
39
+ "--cacert",
40
+ "-x",
41
+ "--proxy",
42
+ "-j",
43
+ "--jobs",
44
+ }
45
+ )
46
+ OPTIONAL_VALUE_OPTIONS = frozenset({"-u", "--user", "-k", "--keychain", "-s", "--scope"})
47
+ _LONG = {"-u": "--user", "-k": "--keychain", "-s": "--scope"}
48
+
49
+
50
+ def long_name(opt: str) -> str:
51
+ return _LONG.get(opt, opt)
52
+
53
+
54
+ def common_options(f: F) -> F:
55
+ decorators = [
56
+ click.option(
57
+ "-u",
58
+ "--user",
59
+ "user",
60
+ is_flag=False,
61
+ flag_value="",
62
+ default=None,
63
+ metavar="[USER]",
64
+ help="FTP login name (bare -u: the local user).",
65
+ ),
66
+ click.option(
67
+ "-p",
68
+ "--passwd",
69
+ "--password",
70
+ "password",
71
+ default=None,
72
+ metavar="PASSWORD",
73
+ help="FTP password.",
74
+ ),
75
+ click.option(
76
+ "-P",
77
+ "--ask-passwd",
78
+ "ask_password",
79
+ is_flag=True,
80
+ help="Ask for the password interactively.",
81
+ ),
82
+ click.option(
83
+ "--password-command",
84
+ default=None,
85
+ metavar="CMD",
86
+ help="Shell command whose first output line is the password.",
87
+ ),
88
+ click.option(
89
+ "-k",
90
+ "--keychain",
91
+ "keychain",
92
+ is_flag=False,
93
+ flag_value="",
94
+ default=None,
95
+ metavar="[[ACCOUNT]@[HOST]]",
96
+ help="macOS keychain entry (bare -k: guess).",
97
+ ),
98
+ click.option("--key", default=None, metavar="FILE", help="SFTP private key."),
99
+ click.option("--pubkey", default=None, metavar="FILE", help="SFTP public key."),
100
+ click.option(
101
+ "--key-passphrase",
102
+ default=None,
103
+ metavar="TEXT",
104
+ help="Passphrase of the SFTP private key.",
105
+ ),
106
+ click.option(
107
+ "-b",
108
+ "--branch",
109
+ default=None,
110
+ metavar="BRANCH",
111
+ help="Deploy this branch instead of the current one.",
112
+ ),
113
+ click.option(
114
+ "-c",
115
+ "--commit",
116
+ default=None,
117
+ metavar="SHA",
118
+ help="Treat SHA as the deployed commit instead of reading the remote log.",
119
+ ),
120
+ click.option(
121
+ "-s",
122
+ "--scope",
123
+ "scope",
124
+ is_flag=False,
125
+ flag_value="",
126
+ default=None,
127
+ metavar="[SCOPE]",
128
+ help="Configuration scope (bare -s: current branch).",
129
+ ),
130
+ click.option(
131
+ "--syncroot",
132
+ default=None,
133
+ metavar="DIR",
134
+ help="Deploy only this directory, as the remote root.",
135
+ ),
136
+ click.option(
137
+ "--remote-root",
138
+ default=None,
139
+ metavar="DIR",
140
+ help="Remote directory, replacing the path in the URL.",
141
+ ),
142
+ click.option(
143
+ "--cacert", default=None, metavar="FILE", help="CA certificate bundle for FTPS/FTPES."
144
+ ),
145
+ click.option("-x", "--proxy", default=None, metavar="URL", help="Proxy URL."),
146
+ click.option(
147
+ "-j",
148
+ "--jobs",
149
+ type=int,
150
+ default=None,
151
+ metavar="N",
152
+ help="Parallel connections (default 4, 1 = sequential).",
153
+ ),
154
+ click.option(
155
+ "-a", "--all", "all", is_flag=True, help="Upload all files, not only changes."
156
+ ),
157
+ click.option("-A", "--active", is_flag=True, help="Use FTP active mode."),
158
+ click.option("-l", "--lock", is_flag=True, help="Lock the remote during the deploy."),
159
+ click.option("-D", "--dry-run", is_flag=True, help="Show what would happen."),
160
+ click.option("-f", "--force", is_flag=True, help="Skip the lock check and questions."),
161
+ click.option("-n", "--silent", is_flag=True, help="Print nothing but fatal errors."),
162
+ click.option("-v", "--verbose", count=True, help="Verbose (-vv: protocol trace)."),
163
+ click.option(
164
+ "--insecure", is_flag=True, help="Do not verify TLS certificates / host keys."
165
+ ),
166
+ click.option("--disable-epsv", is_flag=True, help="Use PASV instead of EPSV."),
167
+ click.option("--no-commit", is_flag=True, help="pull: merge without committing."),
168
+ click.option(
169
+ "--changed-only",
170
+ is_flag=True,
171
+ help="download/pull: only files that changed locally as well.",
172
+ ),
173
+ click.option("--no-verify", is_flag=True, help="Skip the pre-ftp-push hook."),
174
+ click.option("--no-post-hooks", is_flag=True, help="Skip the post-ftp-push hook."),
175
+ click.option(
176
+ "--enable-post-errors", is_flag=True, help="Fail when the post-ftp-push hook fails."
177
+ ),
178
+ click.option("--auto-init", is_flag=True, help="push: init when the remote has no log."),
179
+ click.option(
180
+ "--worktree",
181
+ is_flag=True,
182
+ help="Upload files from a temporary git worktree so edits to the working "
183
+ "tree during the upload are ignored.",
184
+ ),
185
+ ]
186
+ for d in reversed(decorators):
187
+ f = d(f)
188
+ return f
189
+
190
+
191
+ url_argument = click.argument("url", required=False, metavar="[URL]")
192
+
193
+
194
+ def prepare(ctx: click.Context, kw: dict[str, Any]) -> tuple[CliOptions, Output]:
195
+ opts = CliOptions.from_kwargs(kw)
196
+ out: Output = ctx.obj if isinstance(ctx.obj, Output) else Output()
197
+ out.level = opts.level
198
+ ctx.obj = out
199
+ return opts, out
200
+
201
+
202
+ def session_for(
203
+ ctx: click.Context, url: str | None, kw: dict[str, Any], *, need_repo: bool = True
204
+ ) -> tuple[CliOptions, Session]:
205
+ opts, out = prepare(ctx, kw)
206
+ session = open_session(opts, url, out, cwd=Path.cwd(), need_repo=need_repo)
207
+ ctx.call_on_close(session.close)
208
+ return opts, session
gitftp/config.py ADDED
@@ -0,0 +1,170 @@
1
+ """Configuration lookup with upstream's precedence.
2
+
3
+ Order for a key ``k`` with scope ``s`` (first *found* wins, even if the value
4
+ is empty, because ``git-ftp.<scope>.url ""`` is the documented way to mask a
5
+ default):
6
+
7
+ 1. ``.git-ftp-config`` in the repository: ``git-ftp.<s>.k``
8
+ 2. ``.git-ftp-config``: ``git-ftp.k``
9
+ 3. git config (system, global, local merged): ``git-ftp.<s>.k``
10
+ 4. git config: ``git-ftp.k``
11
+
12
+ A valueless key (a bare ``insecure`` line) is read as true, as git reads it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Mapping
19
+ from pathlib import Path
20
+
21
+ from gitftp import url as urlmod
22
+ from gitftp.errors import GitError, MissingArgumentError, UsageError
23
+ from gitftp.gitrepo import GitRunner
24
+
25
+ SECTION = "git-ftp"
26
+ CONFIG_FILE = ".git-ftp-config"
27
+ DEFAULT_DEPLOYED_SHA1_FILE = ".git-ftp.log"
28
+ DEFAULT_JOBS = 4
29
+ SCOPE_RE = re.compile(r"^[-0-9a-zA-Z_/]+$")
30
+
31
+ KEYS = (
32
+ "url",
33
+ "user",
34
+ "password",
35
+ "password-command",
36
+ "keychain",
37
+ "cacert",
38
+ "insecure",
39
+ "disable-epsv",
40
+ "proxy",
41
+ "no-commit",
42
+ "branch",
43
+ "syncroot",
44
+ "key",
45
+ "pubkey",
46
+ "key-passphrase",
47
+ "remote-root",
48
+ "deployedsha1file",
49
+ "jobs",
50
+ "worktree",
51
+ )
52
+
53
+ _TRUE = frozenset({"true", "yes", "on", "1"})
54
+ _FALSE = frozenset({"false", "no", "off", "0", ""})
55
+
56
+
57
+ def parse_bool(value: str | None) -> bool | None:
58
+ """Git's boolean spellings. ``None`` (valueless) is true; unknown text is ``None``."""
59
+ if value is None:
60
+ return True
61
+ v = value.strip().lower()
62
+ if v in _TRUE:
63
+ return True
64
+ if v in _FALSE:
65
+ return False
66
+ return None
67
+
68
+
69
+ class Config:
70
+ def __init__(
71
+ self,
72
+ scope: str | None,
73
+ file_cfg: Mapping[str, str | None],
74
+ git_cfg: Mapping[str, str | None],
75
+ ) -> None:
76
+ self.scope = scope
77
+ self._sources: tuple[Mapping[str, str | None], ...] = (file_cfg, git_cfg)
78
+
79
+ @classmethod
80
+ def load(cls, git: GitRunner, root: Path | None, scope: str | None) -> Config:
81
+ file_cfg: Mapping[str, str | None] = {}
82
+ base = root if root is not None else git.cwd
83
+ cfg_file = base / CONFIG_FILE
84
+ if cfg_file.is_file():
85
+ file_cfg = git.config_list(cfg_file)
86
+ git_cfg = git.config_list()
87
+ return cls(scope, file_cfg, git_cfg)
88
+
89
+ # -- lookups -----------------------------------------------------------
90
+ def lookup_raw(self, key: str) -> tuple[bool, str | None]:
91
+ """(found, value); value is None for a valueless key."""
92
+ for src in self._sources:
93
+ if self.scope:
94
+ scoped = f"{SECTION}.{self.scope}.{key}"
95
+ if scoped in src:
96
+ return True, src[scoped]
97
+ plain = f"{SECTION}.{key}"
98
+ if plain in src:
99
+ return True, src[plain]
100
+ return False, None
101
+
102
+ def lookup(self, key: str) -> str | None:
103
+ """The value, or ``None`` when the key is absent. A valueless key reads as ''."""
104
+ found, value = self.lookup_raw(key)
105
+ if not found:
106
+ return None
107
+ return "" if value is None else value
108
+
109
+ def get(self, key: str, default: str = "") -> str:
110
+ value = self.lookup(key)
111
+ return default if value is None else value
112
+
113
+ def get_bool(self, key: str, default: bool = False) -> bool:
114
+ found, value = self.lookup_raw(key)
115
+ if not found:
116
+ return default
117
+ parsed = parse_bool(value)
118
+ if parsed is None:
119
+ raise UsageError(f"Invalid boolean value '{value}' for git-ftp.{key}.")
120
+ return parsed
121
+
122
+ def get_int(self, key: str, default: int) -> int:
123
+ value = self.lookup(key)
124
+ if value is None or value == "":
125
+ return default
126
+ try:
127
+ return int(value)
128
+ except ValueError:
129
+ raise UsageError(f"Invalid number '{value}' for git-ftp.{key}.") from None
130
+
131
+ def git_option(self, dotted: str) -> str | None:
132
+ """An ordinary (never scoped) git option such as ``http.proxy``."""
133
+ value = self._sources[1].get(dotted.lower())
134
+ return value
135
+
136
+
137
+ # -- scopes -----------------------------------------------------------------
138
+ def validate_scope(name: str, *, from_option: bool) -> str:
139
+ if not name:
140
+ raise MissingArgumentError("Missing scope argument.")
141
+ if not SCOPE_RE.match(name):
142
+ if from_option:
143
+ raise UsageError(f"Invalid scope name '{name}'.")
144
+ raise UsageError("Invalid scope name. Only these characters are allowed: 0-9 a-z A-Z - _ /")
145
+ return name
146
+
147
+
148
+ def add_scope(git: GitRunner, scope: str, raw_url: str) -> None:
149
+ """``git ftp add-scope``: store url/user/password in the local git config."""
150
+ validate_scope(scope, from_option=False)
151
+ if not raw_url:
152
+ raise MissingArgumentError("Missing URL argument.")
153
+ u = urlmod.parse(raw_url)
154
+ lead = "/" if u.absolute else ""
155
+ bare = (
156
+ f"{u.scheme.value}://{u.host}/{lead}{u.path}".rstrip("/")
157
+ if u.path
158
+ else (f"{u.scheme.value}://{u.host}/{lead}".rstrip("/"))
159
+ )
160
+ git.config_set(f"{SECTION}.{scope}.url", bare)
161
+ if u.user is not None:
162
+ git.config_set(f"{SECTION}.{scope}.user", u.user)
163
+ if u.password is not None:
164
+ git.config_set(f"{SECTION}.{scope}.password", u.password)
165
+
166
+
167
+ def remove_scope(git: GitRunner, scope: str) -> None:
168
+ validate_scope(scope, from_option=False)
169
+ if not git.config_remove_section(f"{SECTION}.{scope}"):
170
+ raise GitError(f"Cannot find scope {scope}.")