#!/usr/bin/env python3 """Manage Labyricorn's allowlisted project sources and check devlog readiness.""" from __future__ import annotations import argparse import configparser from dataclasses import dataclass import importlib import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import threading from typing import Any from project_providers import ( ProjectProviderError, derive_repository_urls, validate_public_https_url, ) SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") SECTION_RE = re.compile(r"(?m)^\[([^\]\r\n]+)\][^\r\n]*(?:\r?\n|$)") KNOWN_OPTIONS = ("repository", "web_url", "api_url", "branch", "featured_order") class ProjectEditorError(Exception): """An expected project-editor problem suitable for display to a user.""" def _python_has_lektor(executable: Path) -> bool: if not executable.is_file(): return False options: dict[str, Any] = { "capture_output": True, "text": True, "timeout": 10, "check": False, } if os.name == "nt": options["creationflags"] = subprocess.CREATE_NO_WINDOW try: result = subprocess.run( [str(executable), "-c", "import lektor, tkinter"], **options, ) except (OSError, subprocess.SubprocessError): return False return result.returncode == 0 def _find_lektor_python(site_root: Path) -> Path | None: """Locate a Python runtime that can import both Lektor and Tkinter.""" candidates: list[Path] = [] configured = os.environ.get("LABYRICORN_LEKTOR_PYTHON", "").strip() if configured: candidates.append(Path(configured).expanduser()) if os.name == "nt": candidates.extend( ( site_root / ".venv" / "Scripts" / "python.exe", Path(os.environ.get("LOCALAPPDATA", "")) / "pipx" / "pipx" / "venvs" / "lektor" / "Scripts" / "python.exe", Path.home() / ".local" / "pipx" / "venvs" / "lektor" / "Scripts" / "python.exe", ) ) else: candidates.extend( ( site_root / ".venv" / "bin" / "python", Path.home() / ".local" / "pipx" / "venvs" / "lektor" / "bin" / "python", ) ) lektor_command = shutil.which("lektor") if lektor_command: candidates.append( Path(lektor_command).resolve().parent / ("python.exe" if os.name == "nt" else "python") ) seen: set[Path] = set() for candidate in candidates: candidate = candidate.resolve() if candidate in seen: continue seen.add(candidate) if _python_has_lektor(candidate): return candidate return None def _relaunch_with_lektor(site_root: Path) -> bool: try: import lektor # noqa: F401 except ModuleNotFoundError: pass else: return False if os.environ.get("LABYRICORN_LEKTOR_RELAUNCHED") == "1": return False executable = _find_lektor_python(site_root) if executable is None: return False gui_executable = executable if os.name == "nt": pythonw = executable.with_name("pythonw.exe") if pythonw.is_file(): gui_executable = pythonw child_environment = {**os.environ, "LABYRICORN_LEKTOR_RELAUNCHED": "1"} subprocess.Popen( [str(gui_executable), str(Path(__file__).resolve()), *sys.argv[1:]], cwd=site_root, env=child_environment, ) return True def _load_project_importer() -> Any: try: return importlib.import_module("project_sources") except ModuleNotFoundError as exc: if exc.name != "lektor": raise raise ProjectEditorError( "The Lektor Python package required by the project importer is unavailable. " "Run Project Editor with the same Python environment used for Lektor builds." ) from exc def _validate_url(value: str, field: str) -> str: """Mirror the importer's credential-free public HTTPS URL policy.""" try: return validate_public_https_url(value, field) except ProjectProviderError as exc: raise ProjectEditorError(f"{exc}.") from exc @dataclass(frozen=True) class ProjectSource: project_id: str repository: str web_url: str api_url: str branch: str featured_order: int | None unknown_options: tuple[tuple[str, str], ...] registry_bytes: bytes def importer_source(self) -> dict[str, str | int]: source: dict[str, str | int] = { "project_id": self.project_id, "repository": self.repository, "web_url": self.web_url, "api_url": self.api_url, "branch": self.branch, } if self.featured_order is not None: source["featured_order"] = self.featured_order return source @dataclass(frozen=True) class DevlogReadiness: status: str summary: str details: str ready: bool = False def _clean_stored_values( project_id: str, repository: str, web_url: str, api_url: str, branch: str, featured_order: str | int | None, ) -> tuple[str, str, str, str, str, int | None]: project_id = project_id.strip() if not SLUG_RE.fullmatch(project_id): raise ProjectEditorError( "Project ID must use lowercase letters, numbers, and single hyphens only." ) repository = _validate_url(repository.strip(), "Repository URL") web_url = _validate_url(web_url.strip(), "Repository web URL") api_url = _validate_url(api_url.strip(), "Repository API URL") branch = branch.strip() if not branch or not BRANCH_RE.fullmatch(branch) or ".." in branch: raise ProjectEditorError("Branch contains unsupported characters.") if featured_order is None or str(featured_order).strip() == "": parsed_order = None else: try: parsed_order = int(featured_order) except (TypeError, ValueError) as exc: raise ProjectEditorError("Featured order must be an integer or left blank.") from exc return project_id, repository, web_url, api_url, branch, parsed_order def _clean_editor_values( project_id: str, web_url: str, branch: str, featured_order: str | int | None, ) -> tuple[str, str, str, str, str, int | None]: try: urls = derive_repository_urls(web_url) except ProjectProviderError as exc: raise ProjectEditorError(str(exc)) from exc return _clean_stored_values( project_id, urls.repository, urls.web_url, urls.api_url, branch, featured_order, ) def _parse_registry(raw: bytes) -> tuple[str, configparser.ConfigParser]: try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: raise ProjectEditorError("Project source registry is not valid UTF-8.") from exc parser = configparser.ConfigParser(interpolation=None) try: parser.read_string(text) except configparser.Error as exc: raise ProjectEditorError(f"Cannot parse project source registry: {exc}") from exc return text, parser def _source_from_section( project_id: str, section: configparser.SectionProxy, raw: bytes ) -> ProjectSource: required = {"repository", "web_url", "api_url", "branch"} missing = required - set(section) if missing: raise ProjectEditorError( f"{project_id}: missing registry fields {', '.join(sorted(missing))}." ) cleaned = _clean_stored_values( project_id, section["repository"], section["web_url"], section["api_url"], section["branch"], section.get("featured_order"), ) unknown = tuple((key, value) for key, value in section.items() if key not in KNOWN_OPTIONS) return ProjectSource(*cleaned, unknown, raw) def _render_option(name: str, value: str) -> str: lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n") return f"{name} = {lines[0]}" + "".join(f"\n\t{line}" for line in lines[1:]) def _render_section(source: ProjectSource) -> str: rows = [ f"[{source.project_id}]", _render_option("repository", source.repository), _render_option("web_url", source.web_url), _render_option("api_url", source.api_url), _render_option("branch", source.branch), ] if source.featured_order is not None: rows.append(_render_option("featured_order", str(source.featured_order))) rows.extend(_render_option(key, value) for key, value in source.unknown_options) return "\n".join(rows) + "\n" def _section_bounds(text: str, project_id: str) -> tuple[int, int]: matches = list(SECTION_RE.finditer(text)) for index, match in enumerate(matches): if match.group(1).strip() == project_id: end = matches[index + 1].start() if index + 1 < len(matches) else len(text) return match.start(), end raise ProjectEditorError(f"Project source no longer exists: {project_id}.") class ProjectSourceRegistry: """Conservative editor for configs/project-sources.ini.""" def __init__(self, site_root: Path): self.site_root = site_root.resolve() registry_path = self.site_root / "configs" / "project-sources.ini" if registry_path.is_symlink(): raise ProjectEditorError("Refusing to manage a linked project source registry.") self.path = registry_path.resolve() projects_root = (self.site_root / "content" / "projects").resolve() if len(list(self.site_root.glob("*.lektorproject"))) != 1: raise ProjectEditorError( f"{self.site_root}: expected exactly one .lektorproject file." ) if not self.path.is_file(): raise ProjectEditorError(f"Project source registry is missing: {self.path}.") if not self.path.is_relative_to(self.site_root): raise ProjectEditorError("Refusing to manage an out-of-tree registry.") if not projects_root.is_dir() or not (projects_root / "contents.lr").is_file(): raise ProjectEditorError(f"Lektor project section is missing: {projects_root}.") self.list_sources() def _read(self) -> bytes: try: return self.path.read_bytes() except OSError as exc: raise ProjectEditorError(f"Cannot read {self.path}: {exc}") from exc def list_sources(self) -> list[ProjectSource]: raw = self._read() _text, parser = _parse_registry(raw) if not parser.sections(): raise ProjectEditorError("Project source registry must contain at least one project.") return [_source_from_section(project_id, parser[project_id], raw) for project_id in parser.sections()] def load_source(self, project_id: str) -> ProjectSource: for source in self.list_sources(): if source.project_id == project_id: return source raise ProjectEditorError(f"Project source does not exist: {project_id}.") @staticmethod def _write_atomic(path: Path, payload: bytes) -> None: temporary: Path | None = None try: descriptor, name = tempfile.mkstemp(prefix=".project-sources-", suffix=".ini", dir=path.parent) os.close(descriptor) temporary = Path(name) temporary.write_bytes(payload) os.replace(temporary, path) except OSError as exc: if temporary is not None: temporary.unlink(missing_ok=True) raise ProjectEditorError(f"Cannot write {path}: {exc}") from exc def _validate_proposed(self, payload: bytes) -> None: _text, parser = _parse_registry(payload) if not parser.sections(): raise ProjectEditorError("The importer requires at least one configured project source.") for project_id in parser.sections(): _source_from_section(project_id, parser[project_id], payload) def create_source( self, project_id: str, web_url: str, branch: str, featured_order: str | int | None, ) -> ProjectSource: cleaned = _clean_editor_values(project_id, web_url, branch, featured_order) raw = self._read() text, parser = _parse_registry(raw) if cleaned[0] in parser: raise ProjectEditorError(f"Project source already exists: {cleaned[0]}.") source = ProjectSource(*cleaned, (), raw) proposed = (text.rstrip() + "\n\n" + _render_section(source)).encode("utf-8") self._validate_proposed(proposed) self._write_atomic(self.path, proposed) return self.load_source(source.project_id) def save_source( self, original: ProjectSource, web_url: str, branch: str, featured_order: str | int | None, ) -> ProjectSource: raw = self._read() if raw != original.registry_bytes: raise ProjectEditorError("The project registry changed on disk; reload before saving.") cleaned = _clean_editor_values( original.project_id, web_url, branch, featured_order ) updated = ProjectSource(*cleaned, original.unknown_options, raw) text, _parser = _parse_registry(raw) start, end = _section_bounds(text, original.project_id) proposed = (text[:start] + _render_section(updated) + text[end:].lstrip("\r\n")).encode("utf-8") self._validate_proposed(proposed) self._write_atomic(self.path, proposed) return self.load_source(updated.project_id) def delete_source(self, source: ProjectSource) -> None: raw = self._read() if raw != source.registry_bytes: raise ProjectEditorError("The project registry changed on disk; reload before deleting.") text, parser = _parse_registry(raw) if len(parser.sections()) == 1: raise ProjectEditorError( "The importer requires at least one configured source; the last project cannot be removed." ) start, end = _section_bounds(text, source.project_id) proposed = (text[:start].rstrip() + "\n\n" + text[end:].lstrip("\r\n")).encode("utf-8") self._validate_proposed(proposed) self._write_atomic(self.path, proposed) def corrective_guidance(source: ProjectSource) -> str: return ( "This project is configured to publish a devlog, but its required remote " "Labyricorn structure is missing or incomplete.\n\n" "To correct this:\n\n" f"1. Clone the project repository:\n git clone {source.repository}\n\n" "2. Copy scripts/devlog_editor.py into the cloned repository root as devlog_editor.py.\n\n" "3. From that repository root, run:\n python devlog_editor.py\n\n" "4. Choose “Initialize Labyricorn Project & Devlog” if no publishing structure " "exists, or “Initialize Devlog” if the project record already exists.\n\n" "5. Create and save one or more devlog entries when appropriate. An empty " "validated devlog index is accepted by the current importer.\n\n" "6. Prefer the editor's “Publish Labyricorn Changes” action. It safely creates " "the publishing commit and pushes it; alternatively, commit and push with Git.\n\n" "7. Confirm the .labyricorn changes are visible in the remote repository.\n\n" "8. Return to Project Editor and click “Check Devlog Status” again.\n\n" "Project Editor only diagnoses the remote repository. It never clones it " "persistently, edits it, commits, or pushes." ) def _incomplete(source: ProjectSource, diagnostic: str, *, not_initialized: bool = False) -> DevlogReadiness: status = "Not initialized" if not_initialized else "Required devlog structure is incomplete" return DevlogReadiness(status, diagnostic, diagnostic + "\n\n" + corrective_guidance(source)) def check_devlog_readiness(source: ProjectSource, importer: Any | None = None) -> DevlogReadiness: """Inspect a configured source using the importer's exact read-only validation.""" if shutil.which("git") is None: return DevlogReadiness( "Repository inaccessible", "Git is not installed or is not available on PATH.", "Install Git, restart Project Editor, and run the check again. No remote repository was changed.", ) try: importer = importer or _load_project_importer() except ProjectEditorError as exc: return DevlogReadiness( "Unable to check", "The site's authoritative project validator is unavailable in this Python environment.", str(exc), ) importer_source = source.importer_source() try: repository_metadata = importer.fetch_json(source.api_url, require_public=True) except importer.RepositoryNotPublicError: return DevlogReadiness( "Repository inaccessible", "The repository API is not anonymously accessible.", "The importer accepts only public repositories. Confirm that the configured repository is public and that the API URL is correct.", ) except importer.ProjectSourceError as exc: return DevlogReadiness( "Repository inaccessible", "Unable to verify the remote repository because its public API could not be reached.", f"Network or provider API error: {exc}\n\nCheck the network and configured API URL, then try again.", ) if not isinstance(repository_metadata, dict) or repository_metadata.get("private") is not False: return DevlogReadiness( "Repository inaccessible", "The provider did not confirm that this repository is public.", "The site importer requires an anonymous provider API response with private=false.", ) if repository_metadata.get("default_branch") != source.branch: return _incomplete( source, f"The provider reports default branch {repository_metadata.get('default_branch')!r}, but the site is configured for {source.branch!r}.", ) try: with tempfile.TemporaryDirectory(prefix="labyricorn-project-check-") as temporary: mirror, fetched = importer.ensure_mirror(importer_source, Path(temporary)) if not fetched: raise importer.ProjectSourceError("anonymous Git fetch failed") commit = importer.resolve_branch(mirror, source.branch) _files, records = importer.validate_snapshot(importer_source, mirror, commit) except FileNotFoundError: return DevlogReadiness( "Repository inaccessible", "Git is not installed or is not available on PATH.", "Install Git, restart Project Editor, and run the check again.", ) except (OSError, subprocess.SubprocessError) as exc: return DevlogReadiness( "Repository inaccessible", "Unable to fetch the repository anonymously.", f"Network, authentication, or Git error: {exc}\n\nCheck repository access and try again.", ) except (importer.ProjectSourceError, ValueError) as exc: diagnostic = str(exc) if "initial repository fetch failed" in diagnostic: return DevlogReadiness( "Repository inaccessible", "Unable to fetch the repository anonymously.", "The repository may be unavailable, private, or blocked by a network/authentication problem.", ) missing_devlog = ( "publishing tree is missing" in diagnostic and ".labyricorn/devlog/contents.lr" in diagnostic ) return _incomplete(source, diagnostic, not_initialized=missing_devlog) project = records[".labyricorn/project/contents.lr"] entry_count = sum( 1 for path in records if path.startswith(".labyricorn/devlog/") and path != ".labyricorn/devlog/contents.lr" ) details = ( f"Repository: {source.web_url}\n" f"Branch: {source.branch}\n" f"Validated commit: {commit[:10]}\n" f"Remote project: {project['title']} ({project['project_id']})\n" f"Valid devlog entries: {entry_count}\n\n" "The remote snapshot passed the same schema, path, attachment, and commit checks used by the site importer. The check was read-only and its temporary mirror was removed." ) return DevlogReadiness( "Ready", f"{project['title']} is ready for devlog import at {commit[:10]}.", details, True, ) def run_gui(registry: ProjectSourceRegistry) -> None: import tkinter as tk from tkinter import messagebox, ttk from tkinter.scrolledtext import ScrolledText class ProjectEditorApp: def __init__(self) -> None: self.root = tk.Tk() self.root.title("Labyricorn Project Editor") self.root.minsize(900, 680) self.current: ProjectSource | None = None self.mode = "new" self.snapshot: tuple[str, ...] | None = None self.checking = False outer = ttk.Frame(self.root, padding=12) outer.grid(row=0, column=0, sticky="nsew") self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) outer.columnconfigure(1, weight=1) outer.rowconfigure(0, weight=1) browser = ttk.LabelFrame(outer, text="Configured projects", padding=8) browser.grid(row=0, column=0, sticky="ns", padx=(0, 10)) browser.rowconfigure(0, weight=1) self.tree = ttk.Treeview(browser, columns=("branch",), show="tree headings", height=24) self.tree.heading("#0", text="Project ID") self.tree.heading("branch", text="Branch") self.tree.column("#0", width=165) self.tree.column("branch", width=95) self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") self.tree.bind("", lambda _event: self.edit_selected()) ttk.Button(browser, text="New", command=self.new_source).grid(row=1, column=0, sticky="ew", pady=(8, 0)) ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) self.delete_button = ttk.Button(browser, text="Delete", command=self.delete_current, state="disabled") self.delete_button.grid(row=1, column=2, sticky="ew", pady=(8, 0)) form = ttk.LabelFrame(outer, text="Site-side project source", padding=10) form.grid(row=0, column=1, sticky="nsew") form.columnconfigure(1, weight=1) form.rowconfigure(9, weight=1) self.vars = { name: tk.StringVar() for name in ("project_id", "web_url", "branch", "featured_order") } labels = ( ("Project ID", "project_id"), ("Public repository URL", "web_url"), ("Branch", "branch"), ("Featured order (optional)", "featured_order"), ) for row, (label, name) in enumerate(labels): ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=3) entry = ttk.Entry(form, textvariable=self.vars[name]) entry.grid(row=row, column=1, sticky="ew", pady=3) if name == "project_id": self.id_entry = entry ttk.Label( form, text=( "Project prose, technologies/tags, and images are owned by the external repository under " ".labyricorn/project/. This editor does not copy or modify them. Every configured source is " "expected by the current importer to provide a .labyricorn/devlog/ index. Git and API URLs " "are derived for public GitHub and Gitea repositories." ), wraplength=590, foreground="#6b5a32", ).grid(row=6, column=0, columnspan=2, sticky="ew", pady=(8, 10)) actions = ttk.Frame(form) actions.grid(row=7, column=0, columnspan=2, sticky="ew") self.check_button = ttk.Button(actions, text="Check Devlog Status", command=self.check_status, state="disabled") self.check_button.grid(row=0, column=0) ttk.Button(actions, text="Save Project", command=self.save).grid(row=0, column=1, padx=(8, 0)) self.status_var = tk.StringVar(value="Devlog Status: Configuration does not require devlogs") ttk.Label(form, textvariable=self.status_var, font=("TkDefaultFont", 10, "bold")).grid(row=8, column=0, columnspan=2, sticky="w", pady=(12, 5)) self.details = ScrolledText(form, wrap=tk.WORD, height=14, state="disabled") self.details.grid(row=9, column=0, columnspan=2, sticky="nsew") self.footer_var = tk.StringVar() ttk.Label(outer, textvariable=self.footer_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0)) self.root.protocol("WM_DELETE_WINDOW", self.close) self.refresh() self.new_source(False) def values(self) -> tuple[str, ...]: return tuple(self.vars[name].get() for name in self.vars) def set_details(self, value: str) -> None: self.details.configure(state="normal") self.details.delete("1.0", tk.END) self.details.insert("1.0", value) self.details.configure(state="disabled") def may_discard(self) -> bool: return self.snapshot is None or self.values() == self.snapshot or messagebox.askyesno( "Discard unsaved changes?", "Discard the unsaved project-source changes?", icon="warning", parent=self.root ) def refresh(self, preserve: bool = True) -> None: selected = self.tree.selection()[0] if preserve and self.tree.selection() else None self.tree.delete(*self.tree.get_children()) try: sources = registry.list_sources() except ProjectEditorError as exc: messagebox.showerror("Cannot load projects", str(exc), parent=self.root) return for source in sources: self.tree.insert("", tk.END, iid=source.project_id, text=source.project_id, values=(source.branch,)) if selected and self.tree.exists(selected): self.tree.selection_set(selected) self.footer_var.set(f"{len(sources)} configured project source{'s' if len(sources) != 1 else ''}") def new_source(self, confirm: bool = True) -> None: if confirm and not self.may_discard(): return self.mode = "new" self.current = None self.id_entry.configure(state="normal") for variable in self.vars.values(): variable.set("") self.vars["branch"].set("main") self.delete_button.configure(state="disabled") self.check_button.configure(state="disabled") self.status_var.set("Devlog Status: Configuration does not require devlogs") self.set_details("Save this source to make it part of the site's project importer configuration.") self.snapshot = self.values() def edit_selected(self) -> None: selection = self.tree.selection() if not selection: messagebox.showinfo("Select a project", "Select a configured project to edit.", parent=self.root) return if not self.may_discard(): return try: source = registry.load_source(selection[0]) except ProjectEditorError as exc: messagebox.showerror("Cannot load project", str(exc), parent=self.root) return self.mode = "edit" self.current = source self.vars["project_id"].set(source.project_id) self.vars["web_url"].set(source.web_url) self.vars["branch"].set(source.branch) self.vars["featured_order"].set("" if source.featured_order is None else str(source.featured_order)) self.id_entry.configure(state="readonly") self.delete_button.configure(state="normal") self.check_button.configure(state="normal") self.status_var.set("Devlog Status: Not checked") self.set_details(f"Configured repository: {source.web_url}\nClick “Check Devlog Status” for a read-only remote validation.") self.snapshot = self.values() def save(self) -> None: values = self.values() try: if self.mode == "new": source = registry.create_source(*values) else: if self.current is None: raise ProjectEditorError("No configured project is loaded.") source = registry.save_source(self.current, *values[1:]) except ProjectEditorError as exc: messagebox.showerror("Cannot save project", str(exc), parent=self.root) return self.current = source self.mode = "edit" self.vars["project_id"].set(source.project_id) self.vars["web_url"].set(source.web_url) self.vars["branch"].set(source.branch) self.vars["featured_order"].set( "" if source.featured_order is None else str(source.featured_order) ) self.id_entry.configure(state="readonly") self.delete_button.configure(state="normal") self.check_button.configure(state="normal") self.snapshot = self.values() self.refresh(False) self.tree.selection_set(source.project_id) self.tree.see(source.project_id) self.status_var.set("Devlog Status: Not checked") self.set_details("Project source saved. Run the manual readiness check to inspect the remote repository.") self.footer_var.set(f"Saved configs/project-sources.ini [{source.project_id}]") def delete_current(self) -> None: if self.current is None: return source = self.current if not messagebox.askyesno( "Delete configured project?", f"Remove {source.project_id!r} from the site project-source registry?\n\nRepository: {source.web_url}\n\nThis removes only the site-side configuration. It does not delete or modify the external repository.", icon="warning", parent=self.root, ): return try: registry.delete_source(source) except ProjectEditorError as exc: messagebox.showerror("Cannot delete project", str(exc), parent=self.root) return project_id = source.project_id self.snapshot = None self.refresh(False) self.new_source(False) self.footer_var.set(f"Removed [{project_id}] from configs/project-sources.ini; external repository unchanged") def check_status(self) -> None: if self.current is None or self.checking: return if self.values() != self.snapshot: messagebox.showinfo("Save before checking", "Save or discard the project-source changes before checking the configured remote.", parent=self.root) return source = self.current self.checking = True self.check_button.configure(state="disabled") self.status_var.set("Devlog Status: Checking…") self.set_details("Checking public provider metadata and validating a temporary read-only Git mirror…") def worker() -> None: try: result = check_devlog_readiness(source) except Exception as exc: # Keep unexpected operational failures out of Tk's worker thread. result = DevlogReadiness( "Unable to check", "An unexpected error stopped the read-only validation.", str(exc), ) self.root.after(0, self.finish_check, result) threading.Thread(target=worker, daemon=True).start() def finish_check(self, result: DevlogReadiness) -> None: self.checking = False self.check_button.configure(state="normal" if self.current else "disabled") self.status_var.set(f"Devlog Status: {result.status}") self.set_details(result.summary + "\n\n" + result.details) def close(self) -> None: if self.may_discard(): self.root.destroy() try: app = ProjectEditorApp() except tk.TclError as exc: raise ProjectEditorError(f"Cannot open the Tkinter window: {exc}") from exc app.root.mainloop() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1]) args = parser.parse_args() site_root = args.site_root.resolve() if _relaunch_with_lektor(site_root): return 0 try: run_gui(ProjectSourceRegistry(site_root)) except ProjectEditorError as exc: parser.exit(1, f"project-editor: ERROR: {exc}\n") return 0 if __name__ == "__main__": raise SystemExit(main())