Coverage for src/lektor_ng/publisher.py: 59%
546 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 01:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 01:05 +0000
1from __future__ import annotations
3import errno
4import hashlib
5import io
6import os
7import posixpath
8import urllib.parse
9from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
10from contextlib import AbstractContextManager, ExitStack, contextmanager, suppress
11from ftplib import Error as FTPError
12from inspect import cleandoc
13from pathlib import Path
14from subprocess import DEVNULL, PIPE, STDOUT, CalledProcessError, CompletedProcess
15from tempfile import TemporaryDirectory
16from typing import TYPE_CHECKING, Any, NoReturn
17from urllib.parse import urlsplit
18from warnings import warn
20from werkzeug.datastructures import MultiDict
22from lektor_ng.compat import werkzeug_urls_URL
23from lektor_ng.exception import LektorException
24from lektor_ng.utils import bool_from_string, locate_executable, portable_popen
26if TYPE_CHECKING: # pragma: no cover
27 from _typeshed import StrOrBytesPath, StrPath
28 from lektor.environment import Environment
31def _parse_query(query: str, **kwargs: Any) -> MultiDict:
32 return MultiDict(urllib.parse.parse_qsl(query, **kwargs))
35def _ascii_host(host: str) -> str:
36 """Translate internationalized domain name to IDNA-encoded ASCII."""
37 return host.encode("idna").decode("ascii")
40@contextmanager
41def _ssh_key_file(credentials: Mapping[str, str] | None) -> Iterator[StrPath | None]:
42 with ExitStack() as stack:
43 key_file: StrPath | None
44 key_file = credentials.get("key_file") if credentials else None
45 key = credentials.get("key") if credentials else None
46 if not key_file and key:
47 if ":" in key:
48 key_type, _, key = key.partition(":")
49 key_type = key_type.upper()
50 else:
51 key_type = "RSA"
52 key_file = Path(stack.enter_context(TemporaryDirectory()), "keyfile")
53 with key_file.open("w", encoding="utf-8") as f:
54 f.write(f"-----BEGIN {key_type} PRIVATE KEY-----\n")
55 f.writelines(key[x : x + 64] + "\n" for x in range(0, len(key), 64))
56 f.write(f"-----END {key_type} PRIVATE KEY-----\n")
57 yield key_file
60@contextmanager
61def _ssh_command(credentials: Mapping[str, str] | None, port: int | None = None) -> Iterator[str | None]:
62 with _ssh_key_file(credentials) as key_file:
63 args = []
64 if port:
65 args.append(f" -p {port}")
66 if key_file:
67 args.append(f' -i "{key_file}" -o IdentitiesOnly=yes')
68 if args:
69 ssh_command = "ssh" + " ".join(args)
70 else:
71 ssh_command = None
73 yield ssh_command
76class PublishError(LektorException):
77 """Raised by publishers if something goes wrong."""
80class Command(AbstractContextManager["Command"]):
81 """A wrapper around subprocess.Popen to facilitate streaming output via generator.
83 :param argline: Command with arguments to execute.
84 :param cwd: Optional. Directory in which to execute command.
85 :param env: Optional. Environment with which to run command.
86 :param capture: Default `True`. Whether to capture stdout and stderr.
87 :param silent: Default `False`. Discard output altogether.
88 :param check: Default `False`.
89 If set, raise ``CalledProcessError`` on non-zero return code.
90 :param input: Optional. A string to feed to the subprocess via stdin.
91 :param capture_stdout: Default `False`. Capture stdout and
92 return in ``CompletedProcess.stdout``.
94 Basic Usage
95 ===========
97 To run a command, returning any output on stdout or stderr to the caller
98 as an iterable (generator), while checking the return code from the command:
100 def run_command(argline):
101 # This passes the output
102 rv = yield from Command(argline)
103 if rv.returncode != 0:
104 raise RuntimeError("Command failed!")
106 This could be called as follows:
108 for outline in run_command(('ls')):
109 print(outline.rstrip())
111 Supplying input via stdin, Capturing stdout
112 ===========================================
114 The following example shows how input may be fed to a subprocess via stdin,
115 and how stdout may be captured for further processing.
117 def run_wc(input):
118 rv = yield from Command(
119 ('wc'), check=True, input=input, capture_stdout=True
120 )
121 lines, words, chars = rv.stdout.split()
122 print(f"{words} words, {chars} chars")
124 stderr_lines = list(run_wc("a few words"))
125 # prints "3 words, 11 chars"
127 Note that ``check=True`` will cause a ``CalledProcessError`` to be raised if the
128 ``wc`` subprocess returns a non-zero return code.
130 """
132 def __init__(
133 self,
134 argline: Iterable[str],
135 *,
136 cwd: StrOrBytesPath | None = None,
137 env: Mapping[str, str] | None = None,
138 capture: bool = True,
139 silent: bool = False,
140 check: bool = False,
141 input: str | None = None,
142 capture_stdout: bool = False,
143 ) -> None:
144 kwargs: dict[str, Any] = {"cwd": cwd}
145 if env:
146 kwargs["env"] = {**os.environ, **env}
147 if silent:
148 kwargs["stdout"] = DEVNULL
149 kwargs["stderr"] = DEVNULL
150 capture = False
151 if input is not None:
152 kwargs["stdin"] = PIPE
153 if capture or capture_stdout:
154 kwargs["stdout"] = PIPE
155 if capture:
156 kwargs["stderr"] = STDOUT if not capture_stdout else PIPE
158 # Python >= 3.7 has sane encoding defaults in the case that the system is
159 # (likely mis-)configured to use ASCII as the default encoding (PEP538).
160 # It also provides a way for the user to force the use of UTF-8 (PEP540).
161 kwargs["text"] = True
162 kwargs["errors"] = "replace"
164 self.capture = capture # b/c - unused
165 self.check = check
166 self._stdout = None
168 with ExitStack() as stack:
169 self._cmd = stack.enter_context(portable_popen(list(argline), **kwargs))
170 self._closer: Callable[[], None] | None = stack.pop_all().close
172 if input is not None or capture_stdout:
173 self._output = self._communicate(input, capture_stdout, capture)
174 elif capture:
175 self._output = self._cmd.stdout
176 else:
177 self._output = None
179 def _communicate(self, input: str | None, capture_stdout: bool, capture: bool) -> Iterator[str] | None:
180 proc = self._cmd
181 try:
182 if capture_stdout:
183 self._stdout, errout = proc.communicate(input)
184 else:
185 errout, _ = proc.communicate(input)
186 except BaseException:
187 proc.kill()
188 with suppress(CalledProcessError):
189 self.close()
190 raise
191 if capture:
192 return iter(errout.splitlines())
193 return None
195 def close(self) -> None:
196 """Wait for subprocess to complete.
198 If check=True was passed to the constructor, raises ``CalledProcessError``
199 if the subprocess returns a non-zero status code.
200 """
201 closer, self._closer = self._closer, None
202 if closer:
203 # This waits for process and closes standard file descriptors
204 closer()
205 if self.check:
206 rc = self._cmd.poll()
207 if rc != 0:
208 raise CalledProcessError(rc, self._cmd.args, self._stdout)
210 def wait(self) -> int:
211 """Wait for subprocess to complete. Return status code."""
212 self._cmd.wait()
213 self.close()
214 return self._cmd.returncode
216 def result(self) -> CompletedProcess[str]:
217 """Wait for subprocess to complete. Return ``CompletedProcess`` instance.
219 If ``capture_stdout=True`` was passed to the constructor, the output
220 captured from stdout will be available on the ``.stdout`` attribute
221 of the return value.
222 """
223 return CompletedProcess(self._cmd.args, self.wait(), self._stdout)
225 @property
226 def returncode(self) -> int | None:
227 """Return exit status of the subprocess.
229 Or ``None`` if the subprocess is still alive.
230 """
231 return self._cmd.returncode
233 def __exit__(self, *__: object) -> None:
234 self.close()
236 def __iter__(self) -> Generator[str, None, CompletedProcess[str]]:
237 """A generator with yields any captured output and returns a
238 ``CompletedProcess``.
240 If ``capture`` is ``True`` (the default). Both stdout and stderr are available
241 in the iterator output.
243 If ``capture_stdout`` is set, stdout is captured to a string which is made
244 available via ``CompletedProcess.stdout`` attribute of the return value. Stderr
245 output is available via the iterator output, as normal.
246 """
247 if self._output is None:
248 raise RuntimeError("Not capturing")
249 for line in self._output:
250 yield line.rstrip()
251 return self.result()
253 safe_iter = __iter__ # b/c - deprecated
255 @property
256 def output(self) -> Iterator[str]: # b/c - deprecated
257 return self.safe_iter()
260class Publisher:
261 def __init__(self, env: Environment, output_path: str) -> None:
262 self.env = env
263 self.output_path = os.path.abspath(output_path)
265 def fail(self, message: str) -> NoReturn:
266 # pylint: disable=no-self-use
267 raise PublishError(message)
269 def publish(
270 self,
271 target_url: str,
272 credentials: Mapping[str, str] | None = None,
273 **extra: Any,
274 ) -> Iterator[str]:
275 raise NotImplementedError()
278class RsyncPublisher(Publisher):
279 @contextmanager
280 def get_command(self, target_url, credentials):
281 credentials = credentials or {}
282 argline = ["rsync", "-rclzv", "--exclude=.lektor"]
283 target = []
284 env = {}
286 url = urlsplit(target_url)
287 options = _parse_query(url.query, keep_blank_values=True)
288 exclude = options.getlist("exclude")
289 for file in exclude:
290 argline.extend(("--exclude", file))
292 delete = options.get("delete", False) in ("", "on", "yes", "true", "1", None)
293 if delete:
294 argline.append("--delete-after")
296 with _ssh_command(credentials, url.port) as ssh_command:
297 if ssh_command:
298 argline.extend(("-e", ssh_command))
300 username = credentials.get("username") or url.username
301 if username:
302 target.append(username + "@")
304 if url.hostname is not None:
305 target.append(_ascii_host(url.hostname))
306 target.append(":")
307 target.append(url.path.rstrip("/") + "/")
309 argline.append(self.output_path.rstrip("/\\") + "/")
310 argline.append("".join(target))
311 yield Command(argline, env=env)
313 def publish(self, target_url, credentials=None, **extra):
314 with self.get_command(target_url, credentials) as client:
315 yield from client
318class FtpConnection:
319 def __init__(self, target_url, credentials=None):
320 credentials = credentials or {}
321 url = urlsplit(target_url)
322 if url.hostname is None:
323 raise PublishError("No host name was specified in the target URL ({target_url})")
324 self.con = self.make_connection()
325 self.url = url
326 self.username = credentials.get("username") or url.username
327 self.password = credentials.get("password") or url.password
328 self.log_buffer = []
329 self._known_folders = set()
331 @staticmethod
332 def make_connection():
333 # pylint: disable=import-outside-toplevel
334 from ftplib import FTP
336 return FTP()
338 def drain_log(self):
339 log = self.log_buffer[:]
340 del self.log_buffer[:]
341 for chunk in log:
342 for line in chunk.splitlines():
343 if not isinstance(line, str):
344 line = line.decode("utf-8", "replace")
345 yield line.rstrip()
347 def connect(self):
348 options = _parse_query(self.url.query, keep_blank_values=True)
349 assert self.url.hostname is not None
350 host = _ascii_host(self.url.hostname)
351 port = self.url.port or 21
353 log = self.log_buffer
354 log.append("000 Connecting to server ...")
355 try:
356 log.append(self.con.connect(host, port))
357 except Exception as e:
358 log.append("000 Could not connect.")
359 log.append(str(e))
360 return False
362 try:
363 credentials = {}
364 if self.username:
365 credentials["user"] = self.username
366 if self.password:
367 credentials["passwd"] = self.password
368 log.append(self.con.login(**credentials))
370 except Exception as e:
371 log.append("000 Could not authenticate.")
372 log.append(str(e))
373 return False
375 passive = options.get("passive") in ("on", "yes", "true", "1", None)
376 log.append("000 Using passive mode: %s" % (passive and "yes" or "no"))
377 self.con.set_pasv(passive)
379 try:
380 log.append(self.con.cwd(self.url.path))
381 except Exception as e:
382 log.append(str(e))
383 return False
385 log.append("000 Connected!")
386 return True
388 def mkdir(self, path, recursive=True):
389 if not isinstance(path, str):
390 path = path.decode("utf-8")
391 if path in self._known_folders:
392 return
393 dirname, _ = posixpath.split(path)
394 if dirname and recursive:
395 self.mkdir(dirname)
396 try:
397 self.con.mkd(path)
398 except FTPError as e:
399 msg = str(e)
400 if msg[:4] != "550 ":
401 self.log_buffer.append(str(e))
402 return
403 self._known_folders.add(path)
405 def append(self, filename, data):
406 if not isinstance(filename, str):
407 filename = filename.decode("utf-8")
409 input = io.BytesIO(data.encode("utf-8"))
411 try:
412 self.con.storbinary("APPE " + filename, input)
413 except FTPError as e:
414 self.log_buffer.append(str(e))
415 return False
416 return True
418 def get_file(self, filename, out=None):
419 if not isinstance(filename, str):
420 filename = filename.decode("utf-8")
421 getvalue = False
422 if out is None:
423 out = io.BytesIO()
424 getvalue = True
425 try:
426 self.con.retrbinary("RETR " + filename, out.write)
427 except FTPError as e:
428 msg = str(e)
429 if msg[:4] != "550 ":
430 self.log_buffer.append(e)
431 return None
432 if getvalue:
433 return out.getvalue().decode("utf-8")
434 return out
436 def upload_file(self, filename, src, mkdir=False):
437 if isinstance(src, str):
438 src = io.BytesIO(src.encode("utf-8"))
439 if mkdir:
440 directory = posixpath.dirname(filename)
441 if directory:
442 self.mkdir(directory, recursive=True)
443 if not isinstance(filename, str):
444 filename = filename.decode("utf-8")
445 try:
446 self.con.storbinary("STOR " + filename, src, blocksize=32768)
447 except FTPError as e:
448 self.log_buffer.append(str(e))
449 return False
450 return True
452 def rename_file(self, src, dst):
453 try:
454 self.con.rename(src, dst)
455 except FTPError as e:
456 self.log_buffer.append(str(e))
457 try:
458 self.con.delete(dst)
459 except Exception as e:
460 self.log_buffer.append(str(e))
461 try:
462 self.con.rename(src, dst)
463 except Exception as e:
464 self.log_buffer.append(str(e))
466 def delete_file(self, filename):
467 if isinstance(filename, str):
468 filename = filename.encode("utf-8")
469 try:
470 self.con.delete(filename)
471 except Exception as e:
472 self.log_buffer.append(str(e))
474 def delete_folder(self, filename):
475 if isinstance(filename, str):
476 filename = filename.encode("utf-8")
477 try:
478 self.con.rmd(filename)
479 except Exception as e:
480 self.log_buffer.append(str(e))
481 self._known_folders.discard(filename)
484class FtpTlsConnection(FtpConnection):
485 @staticmethod
486 def make_connection():
487 # pylint: disable=import-outside-toplevel
488 from ftplib import FTP_TLS
490 return FTP_TLS()
492 def connect(self):
493 connected = super().connect()
494 if connected:
495 # Upgrade data connection to TLS.
496 self.con.prot_p() # pylint: disable=no-member
497 return connected
500class FtpPublisher(Publisher):
501 connection_class = FtpConnection
503 @staticmethod
504 def read_existing_artifacts(con):
505 contents = con.get_file(".lektor/listing")
506 if not contents:
507 return {}, set()
508 duplicates = set()
509 rv = {}
510 # Later records override earlier ones. There can be duplicate
511 # entries if the file was not compressed.
512 for line in contents.splitlines():
513 items = line.split("|")
514 if len(items) == 2:
515 if not isinstance(items[0], str):
516 artifact_name = items[0].decode("utf-8")
517 else:
518 artifact_name = items[0]
519 if artifact_name in rv:
520 duplicates.add(artifact_name)
521 rv[artifact_name] = items[1]
522 return rv, duplicates
524 def iter_artifacts(self):
525 """Iterates over all artifacts in the build folder and yields the
526 artifacts.
527 """
528 for dirpath, dirnames, filenames in os.walk(self.output_path):
529 dirnames[:] = [x for x in dirnames if not self.env.is_ignored_artifact(x)]
530 for filename in filenames:
531 if self.env.is_ignored_artifact(filename):
532 continue
533 full_path = os.path.join(self.output_path, dirpath, filename)
534 local_path = full_path[len(self.output_path) :].lstrip(os.path.sep)
535 if os.path.altsep:
536 local_path = local_path.lstrip(os.path.altsep)
537 h = hashlib.sha1()
538 try:
539 with open(full_path, "rb") as f:
540 while 1:
541 item = f.read(4096)
542 if not item:
543 break
544 h.update(item)
545 except OSError as e:
546 if e.errno != errno.ENOENT:
547 raise
548 yield (
549 local_path.replace(os.path.sep, "/"),
550 full_path,
551 h.hexdigest(),
552 )
554 @staticmethod
555 def get_temp_filename(filename):
556 dirname, basename = posixpath.split(filename)
557 return posixpath.join(dirname, "." + basename + ".tmp")
559 def upload_artifact(self, con, artifact_name, source_file, checksum):
560 with open(source_file, "rb") as source:
561 tmp_dst = self.get_temp_filename(artifact_name)
562 con.log_buffer.append(f"000 Updating {artifact_name}")
563 con.upload_file(tmp_dst, source, mkdir=True)
564 con.rename_file(tmp_dst, artifact_name)
565 con.append(".lektor/listing", f"{artifact_name}|{checksum}\n")
567 def consolidate_listing(self, con, current_artifacts):
568 server_artifacts, duplicates = self.read_existing_artifacts(con)
569 known_folders = set()
570 for artifact_name in current_artifacts.keys():
571 known_folders.add(posixpath.dirname(artifact_name))
573 for artifact_name in server_artifacts:
574 if artifact_name not in current_artifacts:
575 con.log_buffer.append(f"000 Deleting {artifact_name}")
576 con.delete_file(artifact_name)
577 folder = posixpath.dirname(artifact_name)
578 if folder not in known_folders:
579 con.log_buffer.append(f"000 Deleting {folder}")
580 con.delete_folder(folder)
582 if duplicates or server_artifacts != current_artifacts:
583 listing = []
584 for artifact_name, checksum in current_artifacts.items():
585 listing.append(f"{artifact_name}|{checksum}\n")
586 listing.sort()
587 con.upload_file(".lektor/.listing.tmp", "".join(listing))
588 con.rename_file(".lektor/.listing.tmp", ".lektor/listing")
590 def publish(self, target_url, credentials=None, **extra):
591 con = self.connection_class(target_url, credentials)
592 connected = con.connect()
593 yield from con.drain_log()
594 if not connected:
595 return
597 yield "000 Reading server state ..."
598 con.mkdir(".lektor")
599 committed_artifacts, _ = self.read_existing_artifacts(con)
600 yield from con.drain_log()
602 yield "000 Begin sync ..."
603 current_artifacts = {}
604 for artifact_name, filename, checksum in self.iter_artifacts():
605 current_artifacts[artifact_name] = checksum
606 if checksum != committed_artifacts.get(artifact_name):
607 self.upload_artifact(con, artifact_name, filename, checksum)
608 yield from con.drain_log()
609 yield "000 Sync done!"
611 yield "000 Consolidating server state ..."
612 self.consolidate_listing(con, current_artifacts)
613 yield from con.drain_log()
615 yield "000 All done!"
618class FtpTlsPublisher(FtpPublisher):
619 connection_class = FtpTlsConnection
622class GitRepo(AbstractContextManager["GitRepo"]):
623 """A temporary git repository.
625 This class provides some lower-level utility methods which may be
626 externally useful, but the main use case is:
628 def publish(html_output):
629 gitrepo = GitRepo(html_output)
630 yield from gitrepo.publish_ghpages(
631 push_url="git@github.com:owner/repo.git",
632 branch="gh-pages"
633 )
635 :param work_tree: The work tree for the repository.
636 """
638 def __init__(self, work_tree: StrPath) -> None:
639 environ = {**os.environ, "GIT_WORK_TREE": str(work_tree)}
641 for what, default in [("NAME", "Lektor Bot"), ("EMAIL", "bot@getlektor.com")]:
642 value = environ.get(f"GIT_AUTHOR_{what}") or environ.get(f"GIT_COMMITTER_{what}") or default
643 for key in f"GIT_AUTHOR_{what}", f"GIT_COMMITTER_{what}":
644 environ[key] = environ.get(key) or value
646 with ExitStack() as stack:
647 environ["GIT_DIR"] = stack.enter_context(TemporaryDirectory(suffix=".git"))
648 self.environ = environ
649 self.run("init", "--quiet")
651 self._exit_stack = stack.pop_all()
653 def __exit__(self, *__: object) -> None:
654 self._exit_stack.close()
656 def _popen(self, args: Sequence[str], **kwargs: Any) -> Command:
657 cmd = ["git"]
658 cmd.extend(args)
659 return Command(cmd, env=self.environ, **kwargs)
661 def popen(
662 self,
663 *args: str,
664 check: bool = True,
665 input: str | None = None,
666 capture_stdout: bool = False,
667 ) -> Command:
668 """Run a git subcommand."""
669 return self._popen(args, check=check, input=input, capture_stdout=capture_stdout)
671 def run(
672 self,
673 *args: str,
674 check: bool = True,
675 input: str | None = None,
676 capture_stdout: bool = False,
677 ) -> CompletedProcess[str]:
678 """Run a git subcommand and wait for completion."""
679 return self._popen(args, check=check, input=input, capture_stdout=capture_stdout, capture=False).result()
681 def set_ssh_credentials(self, credentials: Mapping[str, str]) -> None:
682 """Set up git ssh credentials.
684 This repository will be configured to used whatever SSH credentials
685 can found in ``credentials`` (if any).
686 """
687 stack = self._exit_stack
688 ssh_command = stack.enter_context(_ssh_command(credentials))
689 if ssh_command:
690 self.environ.setdefault("GIT_SSH_COMMAND", ssh_command)
692 def set_https_credentials(self, credentials: Mapping[str, str]) -> None:
693 """Set up git http(s) credentials.
695 This repository will be configured to used whatever HTTP credentials
696 can found in ``credentials`` (if any).
697 """
698 username = credentials.get("username", "")
699 password = credentials.get("password")
700 if username or password:
701 userpass = f"{username}:{password}" if password else username
702 git_dir = self.environ["GIT_DIR"]
703 cred_file = Path(git_dir, "lektor_cred_file")
704 # pylint: disable=unspecified-encoding
705 cred_file.write_text(f"https://{userpass}@github.com\n")
706 self.run("config", "credential.helper", f'store --file "{cred_file}"')
708 def add_to_index(self, filename: str, content: str) -> None:
709 """Create a file in the index.
711 This creates file named ``filename`` with content ``content`` in the git
712 index.
713 """
714 oid = self.run("hash-object", "-w", "--stdin", input=content, capture_stdout=True).stdout.strip()
715 self.run("update-index", "--add", "--cacheinfo", "100644", oid, filename)
717 def publish_ghpages(
718 self,
719 push_url: str,
720 branch: str,
721 cname: str | None = None,
722 preserve_history: bool = True,
723 ) -> Iterator[str]:
724 """Publish the contents of the work tree to GitHub pages.
726 :param push_url: The URL to push to.
727 :param branch: The branch to push to
728 :param cname: Optional. Create a top-level ``CNAME`` with given contents.
729 """
730 refspec = f"refs/heads/{branch}"
731 if preserve_history:
732 yield "Fetching existing head"
733 fetch_cmd = self.popen("fetch", "--depth=1", push_url, refspec, check=False)
734 yield from _prefix_output(fetch_cmd)
735 if fetch_cmd.returncode == 0:
736 # If fetch was succesful, reset HEAD to remote head
737 yield from _prefix_output(self.popen("reset", "--soft", "FETCH_HEAD"))
738 else:
739 # otherwise assume remote branch does not exist
740 yield f"Creating new branch {branch}"
742 # At this point, the index is still empty. Add all but .lektor dir to index
743 yield from _prefix_output(self.popen("add", "--force", "--all", "--", ".", ":(exclude).lektor"))
744 if cname is not None:
745 self.add_to_index("CNAME", f"{cname}\n")
747 # Check for changes
748 diff_cmd = self.popen("diff", "--cached", "--no-renames", "--exit-code", "--quiet", check=False)
749 yield from _prefix_output(diff_cmd)
750 if diff_cmd.returncode == 0:
751 yield "No changes to publish☺"
752 elif diff_cmd.returncode == 1:
753 yield "Creating commit"
754 yield from _prefix_output(self.popen("commit", "--quiet", "--message", "Synchronized build"))
755 push_cmd = ["push", push_url, f"HEAD:{refspec}"]
756 if not preserve_history:
757 push_cmd.insert(1, "--force")
758 yield "Pushing to github"
759 yield from _prefix_output(self.popen(*push_cmd))
760 yield "Success!"
761 else:
762 diff_cmd.result().check_returncode() # raise error
765def _prefix_output(lines: Iterable[str], prefix: str = "> ") -> Iterator[str]:
766 """Add prefix to lines."""
767 return (f"{prefix}{line}" for line in lines)
770class GithubPagesPublisher(Publisher):
771 """Publish to GitHub pages."""
773 def publish(
774 self,
775 target_url: str,
776 credentials: Mapping[str, str] | None = None,
777 **extra: Any,
778 ) -> Iterator[str]:
779 if not locate_executable("git"):
780 self.fail("git executable not found; cannot deploy.")
782 push_url, branch, cname, preserve_history, warnings = self._parse_url(target_url)
783 creds = self._parse_credentials(credentials, target_url)
785 yield from iter(warnings)
787 with GitRepo(self.output_path) as repo:
788 if push_url.startswith("https:"):
789 repo.set_https_credentials(creds)
790 else:
791 repo.set_ssh_credentials(creds)
792 yield from repo.publish_ghpages(push_url, branch, cname, preserve_history)
794 def _parse_url(self, target_url: str) -> tuple[str, str, str | None, bool, Sequence[str]]:
795 url = urlsplit(target_url)
796 if not url.hostname:
797 self.fail("github owner missing from target URL")
798 gh_owner = url.hostname.lower()
799 gh_project = url.path.strip("/").lower()
800 if not gh_project:
801 self.fail("github project missing from target URL")
803 params = _parse_query(url.query, keep_blank_values=True)
804 cname = params.get("cname")
805 branch = params.get("branch")
806 preserve_history = bool_from_string(params.get("preserve_history"), True)
808 warnings = []
810 if not branch:
811 if gh_project == f"{gh_owner}.github.io":
812 warnings.extend(cleandoc(self._EXPLICIT_BRANCH_SUGGESTED_MSG).splitlines())
813 warn(
814 " ".join(cleandoc(self._DEFAULT_BRANCH_DEPRECATION_MSG).splitlines()),
815 # deprecated in version 3.4.0
816 category=FutureWarning,
817 stacklevel=1,
818 )
819 branch = "master"
820 else:
821 branch = "gh-pages"
823 if url.scheme in ("ghpages", "ghpages+ssh"):
824 push_url = f"ssh://git@github.com/{gh_owner}/{gh_project}.git"
825 default_port = 22
826 else:
827 push_url = f"https://github.com/{gh_owner}/{gh_project}.git"
828 default_port = 443
829 if url.port and url.port != default_port:
830 self.fail("github does not support pushing to non-standard ports")
832 return push_url, branch, cname, preserve_history, warnings
834 _EXPLICIT_BRANCH_SUGGESTED_MSG = """
835 ================================================================
836 WARNING!!! You should explicitly set the name of the published
837 branch of your GitHub pages repository.
839 The default branch for new GitHub pages repositories has changed
840 to 'main', but Lektor still defaults to the old value, 'master'.
841 In a future version of Lektor, the default branch name will
842 changed to match the new GitHub default.
844 For details, see
845 https://getlektor.com/docs/deployment/ghpages/#pushing-to-an-explicit-branch
846 ================================================================
847 """
849 _DEFAULT_BRANCH_DEPRECATION_MSG = """
850 Currently, by default, Lektor pushes to the 'master' branch when
851 deploying to GitHub pages repositories. In a future version of
852 Lektor, the default branch will GitHub's new default, 'main'.
853 It is suggested that you explicitly set which branch to push to.
854 """
856 @staticmethod
857 def _parse_credentials(credentials: Mapping[str, str] | None, target_url: str) -> Mapping[str, str]:
858 url = urlsplit(target_url)
859 creds = dict(credentials or {})
860 # Fill in default username/password from target url
861 for key, default in [
862 ("username", url.username),
863 ("password", url.password),
864 ]:
865 if not creds.get(key) and default:
866 creds[key] = default
867 return creds
870builtin_publishers = {
871 "rsync": RsyncPublisher,
872 "ftp": FtpPublisher,
873 "ftps": FtpTlsPublisher,
874 "ghpages": GithubPagesPublisher,
875 "ghpages+https": GithubPagesPublisher,
876 "ghpages+ssh": GithubPagesPublisher,
877}
880def publish(env, target, output_path, credentials=None, **extra):
881 target_url = _CompatURLStr(target)
882 url = urlsplit(target_url)
883 publisher = env.publishers.get(url.scheme)
884 if publisher is None:
885 raise PublishError(f'"{url.scheme}" is an unknown scheme.')
886 return publisher(env, output_path).publish(target_url, credentials, **extra)
889class _CompatURLStr(str):
890 """A string that provides some features of the werkzeug.urls.URL split URL class.
892 We used to pass a ``werkzeug.urls.URL`` instance as the ``target_url`` argument to
893 the ``Publisher.publish`` method. Werkzeug has deprecated the ``URL`` class, so
894 now we've changed our API to just pass a ``str`` for ``target_url``.
896 There are however, Lektor plugins out in the wild that provide their own custom
897 Publisher classes, and they expect a ``URL`` instance for ``target_url``.
899 Here we provide most of the methods and attributes of ``URL`` that might be of use
900 to a publisher, so as to try not to break all those existing plugins.
902 .. tip::
903 New plugins may preserve compatibility with older versions of Lektor by
904 first coercing their ``target_url`` parameter to a ``str`` before use.
905 (This works because ``werkzeug.urls.URL.__str__`` returns the reassembled URL.)
906 E.g. using ``urllib.parse.urlsplit`` to parse the URL:
908 .. code:: python
909 from urllib.parse import urlsplit
911 class CustomPublisher(Publisher):
912 def publish(self, target_url, credentials=None, **extra):
913 url = urlsplit(str(target_url))
914 host = url.hostname
915 ...
916 """
918 def __getattr__(self, name: str):
919 if name.startswith("_"):
920 raise AttributeError(name)
921 url = werkzeug_urls_URL(*urlsplit(self))
922 rv = getattr(url, name)
923 warn(
924 "Since Lektor version 3.4, the 'target_url' parameter to the "
925 "'Publisher.publish' method is now a string rather than a "
926 "werkzeug.urls.URL instance. To ease the transition, some "
927 "methods and attributes of werkzeugs.urls.URL are being emulated, "
928 "however that will not last forever. The plugin should be updated "
929 "to treat 'target_url' as a string.",
930 category=DeprecationWarning,
931 stacklevel=2,
932 )
933 return rv