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/deploy.py ADDED
@@ -0,0 +1,341 @@
1
+ """The init / push / catchup engine, step for step as upstream's ``action_push``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from gitftp import changeset as csmod
10
+ from gitftp.errors import (
11
+ Aborted,
12
+ DownloadError,
13
+ GitError,
14
+ GitFtpError,
15
+ HookError,
16
+ UploadError,
17
+ UsageError,
18
+ )
19
+ from gitftp.gitrepo import UnknownCommit
20
+ from gitftp.hooks import POST_PUSH, PRE_PUSH, run_hook
21
+ from gitftp.lock import RemoteLock
22
+ from gitftp.options import CliOptions
23
+ from gitftp.session import Session
24
+ from gitftp.transfer import DeleteTask, TransferError, TransferPool, UploadTask
25
+ from gitftp.transport import registry
26
+ from gitftp.transport.base import RemoteNotFound
27
+
28
+
29
+ class Action(enum.Enum):
30
+ INIT = "init"
31
+ PUSH = "push"
32
+ CATCHUP = "catchup"
33
+
34
+
35
+ @dataclass
36
+ class DeployOptions:
37
+ force: bool = False
38
+ dry_run: bool = False
39
+ all: bool = False
40
+ auto_init: bool = False
41
+ commit: str | None = None
42
+ branch: str | None = None
43
+ lock: bool = False
44
+ no_verify: bool = False
45
+ no_post_hooks: bool = False
46
+ enable_post_errors: bool = False
47
+
48
+ @classmethod
49
+ def from_cli(cls, opts: CliOptions) -> DeployOptions:
50
+ return cls(
51
+ force=opts.force,
52
+ dry_run=opts.dry_run,
53
+ all=opts.all,
54
+ auto_init=opts.auto_init,
55
+ commit=opts.commit,
56
+ branch=opts.branch,
57
+ lock=opts.lock,
58
+ no_verify=opts.no_verify,
59
+ no_post_hooks=opts.no_post_hooks,
60
+ enable_post_errors=opts.enable_post_errors,
61
+ )
62
+
63
+ def for_submodule(self) -> DeployOptions:
64
+ """What upstream forwards to the recursive invocation (always ``--force``)."""
65
+ return DeployOptions(force=True, dry_run=self.dry_run, all=self.all)
66
+
67
+
68
+ @dataclass
69
+ class DeployResult:
70
+ local_sha: str
71
+ deployed_sha: str | None
72
+ uploaded: list[str] = field(default_factory=list)
73
+ deleted: list[str] = field(default_factory=list)
74
+ up_to_date: bool = False
75
+
76
+
77
+ def run(action: Action, session: Session, opts: DeployOptions) -> DeployResult:
78
+ return _Run(action, session, opts).run()
79
+
80
+
81
+ class _Run:
82
+ def __init__(self, action: Action, session: Session, opts: DeployOptions) -> None:
83
+ self.action = action
84
+ self.session = session
85
+ self.opts = opts
86
+ self.out = session.out
87
+ self.repo = session.require_repo()
88
+ self.url = session.url
89
+ self._source_root: Path = self.repo.root
90
+
91
+ # -- entry -------------------------------------------------------------
92
+ def run(self) -> DeployResult:
93
+ self.session.git.check_version()
94
+ if self.repo.is_dirty():
95
+ raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
96
+ original_branch: str | None = None
97
+ branch = self.opts.branch or self.session.cfg.get("branch")
98
+ if branch:
99
+ original_branch = self.repo.current_branch()
100
+ if not self.repo.checkout(branch):
101
+ raise GitError(f"'{branch}' is not a valid branch! Exiting...")
102
+ try:
103
+ registry.check_available(self.url.scheme)
104
+ if self.action is Action.CATCHUP:
105
+ return self._catchup()
106
+ return self._deploy()
107
+ finally:
108
+ if original_branch:
109
+ self.repo.checkout(original_branch)
110
+
111
+ # -- catchup -----------------------------------------------------------
112
+ def _catchup(self) -> DeployResult:
113
+ local = self.repo.head_sha()
114
+ self._upload_log(local, None)
115
+ for sub, initialised in self.repo.submodules(self.session.syncroot).items():
116
+ if not initialised:
117
+ continue
118
+ self.out.info(f"Catching up submodule {sub}.")
119
+ subsession = self.session.for_submodule(sub)
120
+ try:
121
+ run(Action.CATCHUP, subsession, self.opts.for_submodule())
122
+ finally:
123
+ subsession.close()
124
+ return DeployResult(local_sha=local, deployed_sha=None)
125
+
126
+ # -- init / push -------------------------------------------------------
127
+ def _deploy(self) -> DeployResult:
128
+ deployed, take_all = self._deployed_sha()
129
+ local = self.repo.head_sha()
130
+ cs = self._changeset(deployed, take_all, local)
131
+ if cs is None:
132
+ self.out.info(f"No changed files for {self.url.name()}. Everything up-to-date.")
133
+ return DeployResult(local_sha=local, deployed_sha=deployed, up_to_date=True)
134
+
135
+ scope_or_host = self.session.scope or self.url.host
136
+ if not self.opts.no_verify:
137
+ rc = run_hook(
138
+ self.repo,
139
+ PRE_PUSH,
140
+ [scope_or_host, self.url.display(), local, deployed or ""],
141
+ cs.hook_status(),
142
+ self.out,
143
+ )
144
+ if rc:
145
+ raise HookError(f"{PRE_PUSH} hook failed with exit code {rc}.")
146
+
147
+ lock = RemoteLock(
148
+ self.session.primary,
149
+ local,
150
+ enabled=self.opts.lock,
151
+ force=self.opts.force,
152
+ dry_run=self.opts.dry_run,
153
+ out=self.out,
154
+ )
155
+ lock.acquire()
156
+ try:
157
+ self._run_sync(cs, local)
158
+ self._upload_log(local, deployed)
159
+ finally:
160
+ lock.release()
161
+
162
+ if not self.opts.no_post_hooks:
163
+ rc = run_hook(
164
+ self.repo,
165
+ POST_PUSH,
166
+ [scope_or_host, self.url.display(), local, deployed or ""],
167
+ b"",
168
+ self.out,
169
+ )
170
+ if rc:
171
+ if self.opts.enable_post_errors:
172
+ raise HookError(f"{POST_PUSH} hook failed with exit code {rc}.")
173
+ self.out.debug(f"{POST_PUSH} hook failed with exit code {rc}, ignoring.")
174
+ return DeployResult(
175
+ local_sha=local, deployed_sha=deployed, uploaded=cs.uploads, deleted=cs.deletes
176
+ )
177
+
178
+ def _deployed_sha(self) -> tuple[str | None, bool]:
179
+ s = self.session
180
+ log_file = s.deployed_sha1_file
181
+ if self.action is Action.INIT:
182
+ self.out.debug(f"Checking remote access to {self.url.display()}.")
183
+ try:
184
+ s.primary.mkdir_p("")
185
+ data = s.primary.get(log_file)
186
+ except RemoteNotFound:
187
+ data = b""
188
+ except DownloadError as e:
189
+ raise UploadError(str(e)) from e
190
+ if data.strip():
191
+ raise UsageError("Commit found, use 'git ftp push' to sync. Exiting...")
192
+ return None, True
193
+
194
+ if self.opts.commit:
195
+ self.out.debug(f"Using commit {self.opts.commit} as the last deployed commit.")
196
+ return self.opts.commit, self.opts.all
197
+
198
+ self.out.debug(f"Retrieving last commit from {self.url.display()}.")
199
+ try:
200
+ data = s.primary.get(log_file)
201
+ except RemoteNotFound:
202
+ data = b""
203
+ except DownloadError as e:
204
+ raise DownloadError(f"Could not get last commit from {self.url.display()}. {e}") from e
205
+ deployed = data.decode("utf-8", "replace").strip().split("\n")[0].strip()
206
+ if not deployed:
207
+ if self.opts.auto_init:
208
+ s.primary.mkdir_p("")
209
+ self.out.debug(
210
+ f"Uploading all files since no commit was found at {self.url.display()}."
211
+ )
212
+ return None, True
213
+ raise DownloadError(
214
+ "Could not get last commit. Use 'git ftp init' for the initial push."
215
+ )
216
+ self.out.debug(f"Last deployed SHA1 for {self.url.name()} is {deployed}.")
217
+ return deployed, self.opts.all
218
+
219
+ def _changeset(
220
+ self, deployed: str | None, take_all: bool, local: str
221
+ ) -> csmod.ChangeSet | None:
222
+ repo, syncroot, out = self.repo, self.session.syncroot, self.out
223
+ if not take_all and deployed == local:
224
+ return None
225
+ try:
226
+ return csmod.build(repo, syncroot, deployed, take_all, out)
227
+ except UnknownCommit:
228
+ pass
229
+ if self.opts.force:
230
+ out.info("Unknown SHA1 object, could not determine changed files, taking all files.")
231
+ return csmod.build(repo, syncroot, deployed, True, out)
232
+ out.info(
233
+ "Unknown SHA1 object, make sure you are deploying the right branch "
234
+ "and it is up-to-date."
235
+ )
236
+ answer = out.ask("Do you want to ignore and upload all files again? [y/N]: ")
237
+ if answer == "":
238
+ out.info("Aborting...")
239
+ raise UsageError("")
240
+ if answer.lower() != "y":
241
+ out.info("Aborting...")
242
+ raise Aborted()
243
+ return csmod.build(repo, syncroot, deployed, True, out)
244
+
245
+ def _run_sync(self, cs: csmod.ChangeSet, local: str) -> None:
246
+ """Read the upload from a temporary worktree when ``--worktree`` is set."""
247
+ if self.session.worktree and not self.opts.dry_run and cs.uploads:
248
+ self.out.debug("Creating a temporary worktree for a consistent upload.")
249
+ with self.repo.temporary_worktree(local) as tree:
250
+ self._source_root = tree
251
+ try:
252
+ self._sync(cs)
253
+ finally:
254
+ self._source_root = self.repo.root
255
+ else:
256
+ self._sync(cs)
257
+
258
+ def _source(self, path: str) -> Path:
259
+ """Where to read the bytes of ``path`` from.
260
+
261
+ Tracked files come from the worktree (``_source_root``); an untracked file
262
+ added by ``.git-ftp-include`` is not in the commit, so it falls back to the
263
+ live working tree.
264
+ """
265
+ candidate = self._source_root / path
266
+ return candidate if candidate.exists() else self.repo.root / path
267
+
268
+ def _sync(self, cs: csmod.ChangeSet) -> None:
269
+ out, s = self.out, self.session
270
+ total = cs.total()
271
+ if total == 0:
272
+ out.info("There are no files to sync.")
273
+ return
274
+ out.info(f"{total} file{'s' if total != 1 else ''} to sync:")
275
+ done = 0
276
+ uploads: list[UploadTask] = []
277
+ for path in cs.uploads:
278
+ done += 1
279
+ out.info(f"[{done} of {total}] Buffered for upload '{path}'.")
280
+ if path in cs.submodules:
281
+ self._sync_submodule(path)
282
+ continue
283
+ local = self._source(path)
284
+ if local.is_dir():
285
+ out.debug(f"Skipping directory '{path}'.")
286
+ continue
287
+ uploads.append(
288
+ UploadTask(
289
+ local=local,
290
+ remote=csmod.remote_path(path, s.syncroot),
291
+ size=local.stat().st_size,
292
+ label=path,
293
+ )
294
+ )
295
+ deletes: list[DeleteTask] = []
296
+ with TransferPool(s.connect, s.jobs, out, primary=s.primary) as pool:
297
+ if uploads and not self.opts.dry_run:
298
+ out.info("Uploading ...")
299
+ try:
300
+ pool.upload(uploads)
301
+ except TransferError as e:
302
+ raise UploadError(f"Could not upload files. {e}") from e
303
+ for path in cs.deletes:
304
+ done += 1
305
+ out.info(f"[{done} of {total}] Buffered for delete '{path}'.")
306
+ deletes.append(DeleteTask(remote=csmod.remote_path(path, s.syncroot), label=path))
307
+ if deletes and not self.opts.dry_run:
308
+ out.info("Deleting ...")
309
+ errors = pool.delete(deletes)
310
+ for err in errors:
311
+ out.debug(f"Could not delete {err.label}, continuing... ({err.cause})")
312
+ if errors:
313
+ out.warn("Some files and/or directories could not be deleted.")
314
+
315
+ def _sync_submodule(self, path: str) -> None:
316
+ display = csmod.remote_path(path, self.session.syncroot)
317
+ self.out.info(f"Handling submodule sync for {display}.")
318
+ subsession = self.session.for_submodule(path)
319
+ sub_opts = self.opts.for_submodule()
320
+ try:
321
+ try:
322
+ run(self.action, subsession, sub_opts)
323
+ except DownloadError:
324
+ if self.action is not Action.PUSH:
325
+ raise
326
+ self.out.info(f"Could not push {display}, trying to init...")
327
+ run(Action.INIT, subsession, sub_opts)
328
+ except GitFtpError as e:
329
+ raise UploadError(f"Failed to sync submodules. ({e})") from e
330
+ finally:
331
+ subsession.close()
332
+
333
+ def _upload_log(self, local: str, deployed: str | None) -> None:
334
+ s, out = self.session, self.out
335
+ out.debug(f"Uploading commit log to {self.url.display()}{s.deployed_sha1_file}.")
336
+ if not self.opts.dry_run:
337
+ try:
338
+ s.primary.put_bytes(f"{local}\n".encode(), s.deployed_sha1_file)
339
+ except (UploadError, DownloadError) as e:
340
+ raise UploadError(f"Could not upload. {e}") from e
341
+ out.info(f"Last deployment changed from {deployed or ''} to {local}.")
gitftp/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ """Exit codes and the exception hierarchy.
2
+
3
+ Every layer raises a :class:`GitFtpError` subclass; only :func:`gitftp.cli.main`
4
+ maps them to process exit codes. The numeric codes are upstream git-ftp's.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+
11
+
12
+ class ExitCode(enum.IntEnum):
13
+ """Process exit codes, identical to upstream git-ftp 1.6.0."""
14
+
15
+ OK = 0
16
+ UNKNOWN = 1
17
+ USAGE = 2
18
+ MISSING_ARGUMENTS = 3
19
+ UPLOAD = 4
20
+ DOWNLOAD = 5
21
+ UNKNOWN_PROTOCOL = 6
22
+ REMOTE_LOCKED = 7
23
+ GIT = 8
24
+ HOOK = 9
25
+ FILESYSTEM = 10
26
+ INTERRUPTED = 130
27
+
28
+
29
+ class GitFtpError(Exception):
30
+ """Base class: a fatal condition with an exit code."""
31
+
32
+ code: ExitCode = ExitCode.UNKNOWN
33
+
34
+ def __init__(self, message: str, *, code: ExitCode | None = None) -> None:
35
+ super().__init__(message)
36
+ self.message = message
37
+ if code is not None:
38
+ self.code = code
39
+
40
+ def __str__(self) -> str:
41
+ return self.message
42
+
43
+
44
+ class UsageError(GitFtpError):
45
+ code = ExitCode.USAGE
46
+
47
+
48
+ class MissingArgumentError(GitFtpError):
49
+ code = ExitCode.MISSING_ARGUMENTS
50
+
51
+
52
+ class UploadError(GitFtpError):
53
+ code = ExitCode.UPLOAD
54
+
55
+
56
+ class DownloadError(GitFtpError):
57
+ code = ExitCode.DOWNLOAD
58
+
59
+
60
+ class UnknownProtocolError(GitFtpError):
61
+ code = ExitCode.UNKNOWN_PROTOCOL
62
+
63
+
64
+ class RemoteLockedError(GitFtpError):
65
+ code = ExitCode.REMOTE_LOCKED
66
+
67
+
68
+ class GitError(GitFtpError):
69
+ code = ExitCode.GIT
70
+
71
+
72
+ class HookError(GitFtpError):
73
+ code = ExitCode.HOOK
74
+
75
+
76
+ class FilesystemError(GitFtpError):
77
+ code = ExitCode.FILESYSTEM
78
+
79
+
80
+ class Aborted(GitFtpError):
81
+ """The user declined at a prompt. Not an error: exit 0, as upstream."""
82
+
83
+ code = ExitCode.OK
84
+
85
+ def __init__(self, message: str = "Aborting...") -> None:
86
+ super().__init__(message)