Files
labyricorn-site/scripts/project_editor.py
T
Labyricorn 69f9cfea41
Deploy production / deploy (push) Successful in 15s
commit
2026-08-14 10:05:48 -07:00

805 lines
33 KiB
Python

#!/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("<Double-1>", 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())