diff --git a/prg/funny/SJNet_2038_timer.py b/prg/SJNet_2038_timer.py similarity index 100% rename from prg/funny/SJNet_2038_timer.py rename to prg/SJNet_2038_timer.py diff --git a/prg/SJNet_ts-problem.py b/prg/SJNet_ts-problem.py index 3b7ef61..22fa8bf 100644 --- a/prg/SJNet_ts-problem.py +++ b/prg/SJNet_ts-problem.py @@ -23,10 +23,10 @@ def get_distance_point(p1, p2): return wurzel(dx**2 + dy**2) -def get_distance_points(p): +def get_distance_points(ps): distance = 0 - for i in range(len(p) - 1): - distance += get_distance_point(p[i], p[i + 1]) + for i in range(len(ps) - 1): + distance += get_distance_point(ps[i], ps[i + 1]) return distance def add_point(x=None, y=None): @@ -58,28 +58,62 @@ def bruteforce(): print(f"Possible ways: {max_ways}") time.sleep(2) ways = [] - for i in range(max_ways): - new_way = [] - while len(new_way) < len(points): - n = random.choice(points) - if n not in new_way: - new_way.append(n) - distance = get_distance_points(new_way) - if mode == "verbose": - print(f"\r{i + 1}: {new_way}, distance: {distance}", end="") - ways.append({"way": new_way, "distance": distance}) + while len(ways) < max_ways: + new_way_dict = get_way() + if new_way_dict not in ways: + ways.append(new_way_dict) + if mode == "verbose": + print(f"\r {new_way_dict}") + print(f"\rWays: {len(ways)}/{max_ways}", end="") return ways +def get_way(): + new_way = [] + while len(new_way) < len(points): + n = random.choice(points) + if n not in new_way: + new_way.append(n) + distance = get_distance_points(new_way) + return {"way": new_way, "distance": distance} + +def smartforce(limit): + way_dict = get_way() + start = time.monotonic() + while start + limit > time.monotonic(): + if mode == "verbose": + print(f"\rWay: {way_dict['way']}, distance: {way_dict['distance']}", end="") + time.sleep(2) + route = way_dict['way'] + i = random.randrange(len(route)) + j = random.randrange(len(route)) + + route[i], route[j] = route[j], route[i] + new_distance = get_distance_points(route) + if new_distance < way_dict['distance']: + way_dict = {"way": route, "distance": new_distance} + else: + route[i], route[j] = route[j], route[i] + return way_dict + + def start(): print("TSM algorithm") + print("\nBRUTEFORCE\n") start = time.monotonic() ways = bruteforce() end = time.monotonic() - print(f"Took: {end - start}") + print(f"Took: {(end - start)/60}Min") distances = [] for i in range(len(ways)): distances.append(ways[i]["distance"]) print(f"Fastest: {min(distances)}, slowest: {max(distances)}") + print("\n") + time.sleep(2) + limit = float(input("Enter limit for smartforce: ")) + way = smartforce(limit) + print(f"Smartforce: {way}") + print("\n") + time.sleep(2) diff --git a/prg/funny/SJNet_2038_timer2.py b/prg/funny/SJNet_2038_timer2.py deleted file mode 100644 index 5e2b689..0000000 --- a/prg/funny/SJNet_2038_timer2.py +++ /dev/null @@ -1,22 +0,0 @@ -import time -from datetime import datetime, timezone - -ziel = datetime(2038, 1, 19, 3, 14, 7, tzinfo=timezone.utc) - -while True: - jetzt = datetime.now(timezone.utc) - rest = ziel - jetzt - - tage = rest.days - stunden = rest.seconds // 3600 - minuten = (rest.seconds % 3600) // 60 - sekunden = rest.seconds % 60 - - print( - f"\rY2038 Countdown: {tage} Tage " - f"{stunden:02d}:{minuten:02d}:{sekunden:02d}", - end="", - flush=True - ) - - time.sleep(1) diff --git a/prg/sjaur/.gitignore b/prg/sjaur/.gitignore new file mode 100644 index 0000000..fd04785 --- /dev/null +++ b/prg/sjaur/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage diff --git a/prg/sjaur/LICENSE b/prg/sjaur/LICENSE new file mode 100644 index 0000000..aa037b4 --- /dev/null +++ b/prg/sjaur/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jonas H + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/prg/sjaur/README.md b/prg/sjaur/README.md new file mode 100644 index 0000000..869d948 --- /dev/null +++ b/prg/sjaur/README.md @@ -0,0 +1,201 @@ +# sjaur + +Fast, fuzzy **TUI search for the [Arch User Repository](https://aur.archlinux.org) and the official Arch Linux repositories** — with real package installation, built on the official AUR metadata dump and your local pacman sync databases. + +``` +┌ Search: firfox ────────────────────────────────────────────────────────┐ +├──────────────────────────────────────┬─────────────────────────────────┤ +│ firefox 130.0-1 +3500 │ Name firefox │ +│ firefox-nightly 131.0b1 +120 │ Version 130.0-1 │ +│ firefox-wayland 129.0 +90 │ Maintainer jonas │ +│ … │ Votes 3500 │ +│ │ Popularity 214.53 │ +│ │ … │ +├──────────────────────────────────────┴─────────────────────────────────┤ +│ 9 results · 24 ms · cache 2.1 hours old │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +Typo-tolerant ranking across name, description, maintainer, keywords, +provides, and dependency fields — all served from a local snapshot, so +searching never touches the network. + +## Install + +```bash +cd sjaur +python -m venv .venv +.venv/bin/pip install . +``` + +Then link the executable onto your `PATH` (or copy it): + +```bash +sudo ln -s "$(pwd)/.venv/bin/sjaur" /usr/local/bin/sjaur +# or, without root: +mkdir -p ~/.local/bin && ln -s "$(pwd)/.venv/bin/sjaur" ~/.local/bin/sjaur +``` + +Requirements: Python ≥ 3.10 and Linux (deps: `rapidfuzz`, `textual`). + +## Usage + +```bash +sjaur # launch the TUI +sjaur --update # force a metadata refresh, then exit +sjaur --version +sjaur --help +``` + +Optional: + +- `--cache-dir ` overrides the cache location (default `~/.cache/sjaur`) +- `--no-repos` searches the AUR only (skip official repository packages) +- `--repos-dir ` overrides the pacman sync database directory + (default `/var/lib/pacman/sync`) + +## Official repository packages + +By default, sjaur merges the packages of your **local pacman sync databases** +(`core`, `extra`, …) into the search index. They appear with a repository +badge (`firefox [extra]`), can be installed directly via +`sudo pacman -S ` (`i` in the TUI — no build step, pacman asks for +confirmation), and work with all search features including `@maintainer` +(the packager email is used, e.g. `@heftig`). Votes, popularity, and +out-of-date status are AUR-only concepts and are hidden for repo packages. + +The databases are read-only and stay where pacman manages them; run +`pacman -Sy` as usual to refresh them. On systems without sync databases, +sjaur falls back to AUR-only search with a warning. + +## Search syntax + +| Query | Meaning | +| --------------------------- | ---------------------------------------------------------------- | +| `firefox` | fuzzy search across all package fields | +| `@max_musterman` | fuzzy-search the **maintainer** field | +| `@max_musterman firefox` | maintainer search combined with a normal search | +| `@john python editor` | maintainer query is `john`; `python editor` is the normal query | + +Rules: + +- Only the **first whitespace-delimited word** after `@` is the maintainer + query; everything after it is the normal search. +- Matching stays fuzzy: `@max_musterman` also finds `max_mustermann`, + `max-musterman` or `max_musterman123` — no exact match required. +- With `@`, the result score is + `maintainer_similarity × 0.65 + package_score × 0.35`, so maintainer matches + dominate while the text query still orders the results. +- The parser is table-driven, so future operators (e.g. `#keyword`, + `depends:python`) can be added without touching the search engine. + +### Live search + +Results update **while you type** — no Enter needed: + +1. On every keystroke the current list is instantly narrowed client-side + (packages that no longer match disappear immediately). +2. After a short pause (0.18 s), the full fuzzy search over all fields runs + in the background and refines the list. + +Editing or retyping a recent query restores its result set instantly from an +in-memory cache; the status line shows `live` while you are typing and the +measured search time once the background search has finished. + +## Keys + +| Key | Action | +| ---------------------- | ----------------------------------------- | +| typing | **live search** — results update on every keystroke | +| `↑` / `↓` | navigate results | +| `enter` | open package details (`esc` returns) | +| `i` / `ctrl+i` | install the selected package | +| `tab` / `shift+tab` | switch between search field and results | +| `page up` / `page down`, `home` / `end` | scroll / jump | +| `ctrl+r` | reload AUR metadata now | +| `f1` | help overlay | +| `esc` | clear search / leave current view | +| `ctrl+c` | quit | + +## Installing packages + +With a package highlighted, press `i` (or `ctrl+i`) and confirm with `y`. +sjaur then runs the classic AUR workflow with plain system tools: + +1. **Clone** the package repository from + `https://aur.archlinux.org/.git` into `~/.cache/sjaur/build/` + (an existing clone is **reused**; confirm with `u` instead of `y` to + hard-reset and update it first). +2. **Review** the PKGBUILD — it is printed to the terminal, and you can open + it in `$EDITOR` before deciding. **Always read it**: the AUR ships + user-produced build scripts. +3. **Build & install** via `makepkg --syncdeps --install`. pacman's + confirmation, the sudo password prompt, and the whole build output appear + directly in your terminal; the TUI suspends and resumes afterwards. + +The result is reported in the terminal; press Enter to return to sjaur. +If anything fails (clone error, dependency problem, aborted build), the +message explains what happened and the TUI keeps running. + +> **Security note:** AUR packages are user-produced content. sjaur shows the +> PKGBUILD (and any bundled install scripts) before building — never install +> packages whose build steps you do not understand. + +## Package details + +Selecting a package shows everything the local snapshot knows — name, +version, description, maintainer, votes, popularity, last modified, +out-of-date status, upstream URL, AUR page, licenses, groups, keywords, +depends, makedepends, checkdepends, optdepends, provides, conflicts, replaces +— without any additional network request. + +## Cache behavior + +`sjaur` keeps a decompressed copy of the official extended metadata dump +(`https://aur.archlinux.org/packages-meta-ext-v1.json.gz`) in: + +``` +~/.cache/sjaur/packages-meta-ext-v1.json +``` + +- At startup the cache's **modification time** is checked; if it is younger + than **24 hours** it is used as-is, otherwise a fresh dump is downloaded. +- The replacement is **atomic**: the dump is downloaded to a temporary file, + decompressed, validated as JSON, and only then swapped in with + `os.replace`. A failed download never destroys a working cache. +- If the download fails but a (stale) cache exists, the old cache is used and + a warning is shown. If there is no usable cache at all, `sjaur` exits with + a clear error. +- `SJAUR_CACHE_DIR` overrides the cache directory (XDG `XDG_CACHE_HOME` is + respected). + +## Development + +```bash +python -m venv .venv +.venv/bin/pip install -e '.[dev]' +.venv/bin/pytest # run the test suite +.venv/bin/mypy src # type-check +``` + +Project layout: + +``` +sjaur/ +├── src/sjaur/ +│ ├── cli.py argument parsing and startup flow +│ ├── cache.py download / decompress / validate / atomic replace +│ ├── metadata.py typed Package model, tolerant dump parsing +│ ├── repodb.py local pacman sync database reader +│ ├── query.py extensible query parser (@maintainer, …) +│ ├── search.py fuzzy scoring and ranking engine +│ ├── installer.py clone / review / makepkg / pacman install workflow +│ ├── config.py paths and constants +│ ├── errors.py user-facing error types +│ └── tui/ Textual application, detail view +└── tests/ +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/prg/sjaur/pyproject.toml b/prg/sjaur/pyproject.toml new file mode 100644 index 0000000..8201088 --- /dev/null +++ b/prg/sjaur/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sjaur" +version = "0.1.0" +description = "Fast, fuzzy TUI search for the Arch User Repository (AUR) using the official metadata dump." +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "jonas H", email = "dev@serverjonas.de" }] +requires-python = ">=3.10" +keywords = ["aur", "archlinux", "search", "tui", "fuzzy"] +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", + "Topic :: Utilities", +] +dependencies = [ + "rapidfuzz>=3.6", + "numpy>=1.24", + "textual>=0.80", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "mypy>=1.10", +] + +[project.scripts] +sjaur = "sjaur.cli:main" + +[project.urls] +Homepage = "https://aur.archlinux.org" +AUR = "https://aur.archlinux.org" + +[tool.hatch.build.targets.wheel] +packages = ["src/sjaur"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.mypy] +ignore_missing_imports = true +check_untyped_defs = true + +[[tool.mypy.overrides]] +# numpy is never imported by sjaur (it is only a transitive dependency of +# rapidfuzz); its stubs require a >= 3.12 parser. +module = "numpy.*" +follow_imports = "skip" diff --git a/prg/sjaur/src/sjaur/cli.py b/prg/sjaur/src/sjaur/cli.py index 072518a..bd5e1f6 100644 --- a/prg/sjaur/src/sjaur/cli.py +++ b/prg/sjaur/src/sjaur/cli.py @@ -36,6 +36,17 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="cache directory override (default: ~/.cache/sjaur)", ) + parser.add_argument( + "--no-repos", + action="store_true", + help="search only the AUR, skip official repository packages", + ) + parser.add_argument( + "--repos-dir", + type=Path, + default=None, + help="pacman sync database directory override (default: /var/lib/pacman/sync)", + ) return parser @@ -89,19 +100,34 @@ def main(argv: list[str] | None = None) -> int: if skipped: console.print(f"[yellow]warning:[/yellow] skipped {skipped} malformed package entries") + # Official repository packages from the local pacman sync databases. + repo_note = "" + if not args.no_repos: + from .repodb import load_repo_packages + + repo_packages, loaded = load_repo_packages(args.repos_dir) + if repo_packages: + packages.extend(repo_packages) + repo_note = f" · {len(repo_packages)} official packages ({', '.join(loaded)})" + elif not loaded: + console.print( + "[yellow]warning:[/yellow] no pacman sync databases found; " + "searching the AUR only (run 'sudo pacman -Sy' to populate them)" + ) + if args.update: - console.print(f"AUR metadata is up to date ({len(packages)} packages).") + console.print(f"Metadata is up to date ({len(packages)} packages).{repo_note}") return 0 if bundle.source == "downloaded": - console.print(f"Downloaded fresh AUR metadata ({len(packages)} packages).") + console.print(f"Downloaded fresh AUR metadata ({len(packages)} packages).{repo_note}") elif bundle.source == "stale-cache": - console.print(f"Using existing cached metadata ({len(packages)} packages).") + console.print(f"Using existing cached metadata ({len(packages)} packages).{repo_note}") else: console.print( f"Using cached AUR metadata " f"({humanize_delta(bundle.cache_age) if bundle.cache_age else 'unknown age'} old, " - f"{len(packages)} packages)." + f"{len(packages)} packages).{repo_note}" ) with console.status("Building search index…"): diff --git a/prg/sjaur/src/sjaur/installer.py b/prg/sjaur/src/sjaur/installer.py index 38bc11c..3946605 100644 --- a/prg/sjaur/src/sjaur/installer.py +++ b/prg/sjaur/src/sjaur/installer.py @@ -16,6 +16,7 @@ live and sudo/pacman can prompt for input. from __future__ import annotations +import os import shutil import subprocess from dataclasses import dataclass @@ -34,7 +35,8 @@ MAKEPKG_DEP_ARGS = ("--syncdeps", "--install") class InstallStep: """One planned action of the install workflow.""" - kind: str # "clone" | "update" | "reuse" | "makepkg" + # "clone" | "update" | "reuse" | "makepkg" | "pacman" + kind: str name: str # package name @@ -43,16 +45,23 @@ def builds_dir() -> Path: return get_cache_dir() / "build" -def plan_install(name: str, target_root: Path, *, refresh: bool = False) -> list[InstallStep]: +def plan_install( + name: str, target_root: Path, *, refresh: bool = False, repository: str = "aur" +) -> list[InstallStep]: """Compute the steps needed to install ``name`` below ``target_root``. Pure function (no I/O except the existence check on the target path), which makes the workflow decisions easy to test. + Official repository packages (``repository`` != "aur") are installed via + pacman and need no build directory at all. For AUR packages: + - No clone yet -> clone fresh, then build. - ``refresh=True`` -> hard-reset and update the existing clone, then build. - Otherwise -> reuse the existing clone, then build. """ + if repository not in ("", "aur"): + return [InstallStep("pacman", name)] target = target_root / name if not target.is_dir(): return [ @@ -70,14 +79,19 @@ def plan_install(name: str, target_root: Path, *, refresh: bool = False) -> list ] -def check_environment() -> list[str]: - """Return human-readable problems that would break an install.""" +def check_environment(*, include_build_tools: bool = True) -> list[str]: + """Return human-readable problems that would break an install. + + Building AUR packages needs git/makepkg/pacman; official repository + packages only need pacman (``include_build_tools=False``). + """ + tools: list[tuple[str, str]] = [] + if include_build_tools: + tools.append(("git", "required to clone AUR package repositories")) + tools.append(("makepkg", "required to build packages")) + tools.append(("pacman", "required to install packages")) problems: list[str] = [] - for tool, hint in ( - ("git", "required to clone AUR package repositories"), - ("makepkg", "required to build packages"), - ("pacman", "required to install built packages"), - ): + for tool, hint in tools: if shutil.which(tool) is None: problems.append(f"'{tool}' not found in PATH — {hint}") return problems @@ -103,6 +117,16 @@ def run_step(step: InstallStep, target_root: Path) -> None: if not (target / "PKGBUILD").is_file(): raise SjaurError(f"{target} does not contain a PKGBUILD.") _run(["makepkg", *MAKEPKG_DEP_ARGS], cwd=target) + elif step.kind == "pacman": + pacman = ["sudo", "pacman"] if os.geteuid() != 0 else ["pacman"] + _run( + [ + *pacman, + "-S", + "--needed", + step.name, + ] + ) else: raise SjaurError(f"Unknown install step: {step.kind!r}") diff --git a/prg/sjaur/src/sjaur/metadata.py b/prg/sjaur/src/sjaur/metadata.py index c605f1f..c00cfd3 100644 --- a/prg/sjaur/src/sjaur/metadata.py +++ b/prg/sjaur/src/sjaur/metadata.py @@ -44,6 +44,9 @@ class Package: replaces: tuple[str, ...] groups: tuple[str, ...] licenses: tuple[str, ...] + #: Repository the package comes from: "aur" for Arch User Repository + #: packages, or a pacman sync database name ("core", "extra", ...). + repository: str = "aur" @property def aur_url(self) -> str: @@ -53,6 +56,11 @@ class Package: def is_orphan(self) -> bool: return not self.maintainer + @property + def is_repo_package(self) -> bool: + """True for packages from an official repository (not the AUR).""" + return self.repository not in ("", "aur") + def parse_packages(raw: Any) -> tuple[list[Package], int]: """Convert the raw JSON payload into :class:`Package` records. @@ -96,6 +104,7 @@ def parse_packages(raw: Any) -> tuple[list[Package], int]: replaces=_strings(entry.get("Replaces")), groups=_strings(entry.get("Groups")), licenses=_strings(entry.get("License")), + repository="aur", ) ) return packages, skipped diff --git a/prg/sjaur/src/sjaur/repodb.py b/prg/sjaur/src/sjaur/repodb.py new file mode 100644 index 0000000..e3a1660 --- /dev/null +++ b/prg/sjaur/src/sjaur/repodb.py @@ -0,0 +1,162 @@ +"""Read official repository packages from local pacman sync databases. + +The sync databases (``/var/lib/pacman/sync/core.db``, ``extra.db``, ...) are +gzip-compressed tar archives; each package is a directory of ``%KEY%``- +sectioned files. Sync DBs carry every field (including dependencies and +licenses) in a single ``desc`` file; the ``depends`` file layout is also +accepted for robustness. This module converts the entries into the same +:class:`~sjaur.metadata.Package` records used for AUR packages, so the search +engine, TUI, and installer treat both sources uniformly. + +Reading the DBs requires no special privileges (they are world-readable); +packages are loaded even when the databases are outdated — pacman itself is +the tool that refreshes them via ``pacman -Sy``. +""" + +from __future__ import annotations + +import tarfile +from pathlib import Path + +from .metadata import Package + + +def sync_db_paths(pacman_db_dir: Path | None = None) -> list[Path]: + """Return the available sync database files, sorted by name. + + Defaults to ``/var/lib/pacman/sync``; the pacman database directory can + be overridden for testing. + """ + sync_dir = pacman_db_dir or Path("/var/lib/pacman/sync") + try: + return sorted(path for path in sync_dir.glob("*.db") if path.is_file()) + except OSError: + return [] + + +def load_repo_packages(pacman_db_dir: Path | None = None) -> tuple[list[Package], list[str]]: + """Load every package from the local sync databases. + + Returns ``(packages, repositories_loaded)``. Missing or unreadable + databases are skipped silently — an Arch system without sync DBs still + gets a working AUR-only sjaur, and partial failures (one broken archive) + degrade to the repositories that do parse. + """ + packages: list[Package] = [] + loaded: list[str] = [] + for db_path in sync_db_paths(pacman_db_dir): + repository = db_path.name.removesuffix(".db") + try: + with tarfile.open(db_path, "r:gz") as archive: + for entry in _parse_archive(archive, repository): + packages.append(entry) + except (tarfile.TarError, OSError): + # Corrupt or unreadable database: skip it rather than fail. + continue + loaded.append(repository) + return packages, loaded + + +def _parse_archive(archive: tarfile.TarFile, repository: str) -> list[Package]: + """Extract one repository's packages from an open tar archive.""" + members: dict[str, dict[str, dict[str, list[str]]]] = {} + for member in archive: + if not member.isfile(): + continue + parts = member.name.split("/") + if len(parts) != 2 or parts[1] not in ("desc", "depends"): + continue + # NOTE: sync DBs put everything into "desc"; a separate "depends" + # file is accepted as an additional source and merged per package. + directory, filename = parts + try: + handle = archive.extractfile(member) + except (tarfile.TarError, OSError): + continue + if handle is None: + continue + try: + raw = handle.read().decode("utf-8", errors="replace") + except (tarfile.TarError, OSError): + continue + sections = members.setdefault(directory, {}) + sections[filename] = _parse_sections(raw) + + packages: list[Package] = [] + for sections in members.values(): + package = _build_package(sections, repository) + if package is not None: + packages.append(package) + return packages + + +def _build_package( + sections: dict[str, dict[str, list[str]]], repository: str +) -> Package | None: + desc = sections.get("desc", {}) + # Sync DBs carry dependency fields inside "desc"; a separate "depends" + # file (if present) takes precedence per key. + depends = {**desc, **sections.get("depends", {})} + names = desc.get("name") + if not names or not names[0]: + return None + name = names[0] + packager = desc.get("packager", [""])[0] + # "Balló György " -> "bgyorgy", so that the + # maintainer field works for @maintainer searches and badges. + if "<" in packager: + email = packager.split("<")[-1].rstrip(">").strip() + maintainer = email.split("@")[0] or email + else: + maintainer = packager.strip() + try: + builddate = int(desc.get("builddate", ["0"])[0]) + except ValueError: + builddate = 0 + return Package( + name=name, + version=desc.get("version", [""])[0], + description=desc.get("description", [""])[0], + maintainer=maintainer, + votes=0, # votes/popularity are AUR-only concepts + popularity=0.0, + out_of_date=None, + last_modified=builddate, + first_submitted=builddate, + url=desc.get("url", [""])[0], + url_path="", + package_base=desc.get("base", [name])[0], + keywords=(), + depends=tuple(depends.get("depends", ())), + make_depends=tuple(depends.get("make_depends", ())), + check_depends=tuple(depends.get("check_depends", ())), + opt_depends=tuple(depends.get("opt_depends", ())), + provides=tuple(depends.get("provides", ())), + conflicts=tuple(depends.get("conflicts", ())), + replaces=tuple(depends.get("replaces", ())), + groups=tuple(depends.get("groups", ())), + licenses=tuple(depends.get("licenses", ())), + repository=repository, + ) + + +def _parse_sections(raw: str) -> dict[str, list[str]]: + """Parse ``%KEY%``-sectioned file content into normalized field lists.""" + # pacman's key spelling -> Package field spelling. + renames = { + "desc": "description", + "makedepends": "make_depends", + "checkdepends": "check_depends", + "optdepends": "opt_depends", + "license": "licenses", + } + result: dict[str, list[str]] = {} + current: str | None = None + for line in raw.splitlines(): + if line.startswith("%") and line.endswith("%") and len(line) > 2: + current = renames.get(line[1:-1].lower(), line[1:-1].lower()) + result.setdefault(current, []) + elif current is not None and line.strip(): + result[current].append(line) + return result + diff --git a/prg/sjaur/src/sjaur/tui/app.py b/prg/sjaur/src/sjaur/tui/app.py index dad46b8..eec47c1 100644 --- a/prg/sjaur/src/sjaur/tui/app.py +++ b/prg/sjaur/src/sjaur/tui/app.py @@ -130,20 +130,40 @@ class InstallerConfirmScreen(ModalScreen["InstallerConfirmed | None"]): classes="title", markup=True, ) - if self.package.maintainer: + if self.package.is_repo_package: + yield Static( + f"Official repository package ({self.package.repository}) — " + "installed via pacman.", + classes="keys", + ) + elif self.package.maintainer: yield Static(f"Maintainer: {self.package.maintainer}", classes="keys") else: yield Static(Text("This package is orphaned.", style="yellow"), classes="keys") - yield Static( - "The PKGBUILD will be shown for review before anything is built. " - "Never build packages you do not understand.", - classes="warning", - ) - yield Static( - "[bold]y[/bold] install · [bold]u[/bold] update clone & install · [bold]n[/bold] cancel", - classes="keys", - markup=True, - ) + if self.package.is_repo_package: + yield Static( + "pacman shows the package details and asks for confirmation " + "before anything is installed.", + classes="warning", + ) + else: + yield Static( + "The PKGBUILD will be shown for review before anything is built. " + "Never build packages you do not understand.", + classes="warning", + ) + if self.package.is_repo_package: + yield Static( + "[bold]y[/bold] install via pacman · [bold]n[/bold] cancel", + classes="keys", + markup=True, + ) + else: + yield Static( + "[bold]y[/bold] install · [bold]u[/bold] update clone & install · [bold]n[/bold] cancel", + classes="keys", + markup=True, + ) def action_confirm(self) -> None: self.dismiss(InstallerConfirmed(self.package, refresh=False)) @@ -356,7 +376,9 @@ class SJaurApp(App[None]): line.append(package.name, style="bold") if package.version: line.append(f" {package.version}", style="dim") - if package.votes: + if package.is_repo_package: + line.append(f" [{package.repository}]", style="cyan") + elif package.votes: line.append(f" +{package.votes}", style="yellow") if package.maintainer: line.append(f" @{package.maintainer}", style="dim") @@ -442,8 +464,8 @@ class SJaurApp(App[None]): self._install_package(result.package, refresh=result.refresh) def _install_package(self, package: Package, *, refresh: bool) -> None: - """Run the full install flow with the terminal handed over to makepkg.""" - problems = check_environment() + """Run the full install flow with the terminal handed over to pacman/makepkg.""" + problems = check_environment(include_build_tools=not package.is_repo_package) if problems: self._set_status(Text(f"Cannot install: {'; '.join(problems)}", style="red")) return @@ -459,11 +481,13 @@ class SJaurApp(App[None]): self._search_input.focus() def _install_flow(self, package: Package, refresh: bool) -> None: - """Suspend the TUI and run clone/review/makepkg on the main thread.""" + """Suspend the TUI and run clone/review/makepkg (or pacman) on the main thread.""" from ..installer import builds_dir root = builds_dir() - steps = plan_install(package.name, root, refresh=refresh) + steps = plan_install( + package.name, root, refresh=refresh, repository=package.repository + ) print(f"\n=== Installing {package.name} {package.version} ===") try: with self.suspend(): @@ -474,6 +498,12 @@ class SJaurApp(App[None]): print(f"--> Updating the existing clone of {package.name}…") elif step.kind == "reuse": print(f"--> Reusing the existing clone of {package.name}.") + elif step.kind == "pacman": + print( + f"--> Installing {package.name} from the " + f"{package.repository} repository " + "(you may be asked for your sudo password)…" + ) elif step.kind == "makepkg": print("--> Review the PKGBUILD before building.") if not self._review_pkgbuild(root / package.name): diff --git a/prg/sjaur/src/sjaur/tui/details.py b/prg/sjaur/src/sjaur/tui/details.py index 4aa6bd5..7bc7566 100644 --- a/prg/sjaur/src/sjaur/tui/details.py +++ b/prg/sjaur/src/sjaur/tui/details.py @@ -19,7 +19,9 @@ def render_package_details(package: Package) -> Table: table.add_column(ratio=1) table.add_row("Name", Text(package.name, style="bold")) - if package.package_base and package.package_base != package.name: + if package.is_repo_package: + table.add_row("Repository", Text(package.repository, style="cyan")) + elif package.package_base and package.package_base != package.name: table.add_row("Package Base", package.package_base) table.add_row("Version", package.version or "unknown") if package.description: @@ -28,26 +30,32 @@ def render_package_details(package: Package) -> Table: table.add_row("Maintainer", package.maintainer) else: table.add_row("Maintainer", Text("orphaned", style="yellow")) - table.add_row("Votes", str(package.votes)) - table.add_row("Popularity", f"{package.popularity:.2f}") + if not package.is_repo_package: + table.add_row("Votes", str(package.votes)) + table.add_row("Popularity", f"{package.popularity:.2f}") table.add_row( "Last Modified", format_timestamp(package.last_modified) if package.last_modified else "unknown", ) - table.add_row( - "First Submitted", - format_timestamp(package.first_submitted) if package.first_submitted else "unknown", - ) - if package.out_of_date: + if not package.is_repo_package: table.add_row( - "Out of Date", - Text(f"yes — since {format_timestamp(package.out_of_date)}", style="bold red"), + "First Submitted", + format_timestamp(package.first_submitted) if package.first_submitted else "unknown", ) - else: - table.add_row("Out of Date", Text("no", style="green")) + if not package.is_repo_package: + if package.out_of_date: + table.add_row( + "Out of Date", + Text(f"yes — since {format_timestamp(package.out_of_date)}", style="bold red"), + ) + else: + table.add_row("Out of Date", Text("no", style="green")) if package.url: table.add_row("URL", package.url) - table.add_row("AUR Page", package.aur_url) + if package.is_repo_package: + table.add_row("Installed with", f"pacman -S {package.name}") + else: + table.add_row("AUR Page", package.aur_url) if package.licenses: table.add_row("Licenses", ", ".join(package.licenses)) if package.groups: diff --git a/prg/sjaur/tests/conftest.py b/prg/sjaur/tests/conftest.py new file mode 100644 index 0000000..14b4332 --- /dev/null +++ b/prg/sjaur/tests/conftest.py @@ -0,0 +1,103 @@ +"""Shared fixtures for the sjaur test suite.""" + +from __future__ import annotations + +import pytest + +from sjaur.metadata import Package +from sjaur.search import SearchEngine + + +def make_package(name: str, **extra) -> Package: + """Build a Package with sensible defaults; override anything via kwargs.""" + return Package( + name=name, + version=extra.get("version", "1.0.0-1"), + description=extra.get("description", ""), + maintainer=extra.get("maintainer", ""), + votes=extra.get("votes", 0), + popularity=extra.get("popularity", 0.0), + out_of_date=extra.get("out_of_date"), + last_modified=extra.get("last_modified", 0), + first_submitted=extra.get("first_submitted", 0), + url=extra.get("url", ""), + url_path=extra.get("url_path", ""), + package_base=extra.get("package_base", name), + keywords=extra.get("keywords", ()), + depends=extra.get("depends", ()), + make_depends=extra.get("make_depends", ()), + check_depends=extra.get("check_depends", ()), + opt_depends=extra.get("opt_depends", ()), + provides=extra.get("provides", ()), + conflicts=extra.get("conflicts", ()), + replaces=extra.get("replaces", ()), + groups=extra.get("groups", ()), + licenses=extra.get("licenses", ()), + ) + + +@pytest.fixture +def engine() -> SearchEngine: + """A small, representative snapshot used by most search tests.""" + packages = [ + make_package( + "firefox", + version="130.0-1", + description="Standalone web browser from mozilla.org", + maintainer="jonas", + votes=3500, + keywords=("browser", "web"), + ), + make_package( + "firefox-nightly", + description="Firefox Web Browser — nightly build", + maintainer="john_doe", + votes=120, + ), + make_package( + "firefox-wayland", + description="Firefox with Wayland patches", + maintainer="max_mustermann", + votes=90, + ), + make_package( + "firefox-extension-uorigin", + description="Firefox extension for managing redirect rules", + maintainer="jane", + votes=15, + ), + make_package( + "chromium", + description="A web browser built from Chromium source", + maintainer="jonas", + votes=2800, + provides=("chromium-browser",), + ), + make_package( + "python-pytest", + description="Python testing tool with full-featured assertions", + maintainer="max_musterman123", + votes=950, + depends=("python",), + keywords=("testing", "python"), + ), + make_package( + "python-editor", + description="Programmatically open an editor for python code", + maintainer="orphan", + votes=40, + ), + make_package( + "fzf", + description="Command-line fuzzy finder", + maintainer="alice", + votes=2100, + ), + make_package( + "libreoffice-fresh", + description="Office productivity suite", + maintainer="", + votes=1500, + ), + ] + return SearchEngine(packages) diff --git a/prg/sjaur/tests/test_cache.py b/prg/sjaur/tests/test_cache.py new file mode 100644 index 0000000..a1fa56c --- /dev/null +++ b/prg/sjaur/tests/test_cache.py @@ -0,0 +1,277 @@ +"""Tests for the cache manager. + +The downloader is monkeypatched, so no test touches the network. Atomic +replacement, freshness handling, corruption recovery, and stale fallback are +all covered. +""" + +from __future__ import annotations + +import gzip +import json +import os +import time +from pathlib import Path + +import pytest + +from sjaur.cache import CacheManager, download_file, humanize_delta +from sjaur.errors import CacheCorruptError, DownloadError, SjaurError + +DATA_V1 = [{"Name": "firefox"}, {"Name": "python"}] +DATA_V2 = [{"Name": "firefox"}, {"Name": "python"}, {"Name": "neovim"}] + + +def gzipped(data) -> bytes: + return gzip.compress(json.dumps(data).encode()) + + +def install_downloader(monkeypatch, data) -> None: + """Make ``download_file`` produce a valid dump instead of using network.""" + + def fake_download(url, dest, progress=None, timeout=None): + dest.write_bytes(gzipped(data)) + + monkeypatch.setattr("sjaur.cache.download_file", fake_download) + + +def install_failing_downloader(monkeypatch, message="Connection timed out.") -> None: + def fake_download(url, dest, progress=None, timeout=None): + raise DownloadError(message) + + monkeypatch.setattr("sjaur.cache.download_file", fake_download) + + +def write_cache_file(cache: CacheManager, content: bytes) -> None: + cache.cache_dir.mkdir(parents=True, exist_ok=True) + cache.metadata_path.write_bytes(content) + + +def make_cache_stale(cache: CacheManager, hours: float = 25.0) -> None: + old = time.time() - hours * 3600 + os.utime(cache.metadata_path, (old, old)) + + +def no_tmp_files(cache: CacheManager) -> bool: + return list(cache.cache_dir.glob("*.tmp")) == [] + + +# -- fresh/missing/expired ---------------------------------------------------- + + +def test_missing_cache_is_downloaded(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + install_downloader(monkeypatch, DATA_V1) + + bundle = cache.ensure_fresh() + + assert bundle.source == "downloaded" + assert bundle.data == DATA_V1 + assert bundle.warning is None + assert cache.metadata_path.is_file() + assert no_tmp_files(cache) + + +def test_fresh_cache_is_used_without_download(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + + def boom(*args, **kwargs): + raise AssertionError("download must not run for a fresh cache") + + monkeypatch.setattr("sjaur.cache.download_file", boom) + bundle = cache.ensure_fresh() + + assert bundle.source == "cache" + assert bundle.data == DATA_V1 + assert cache.cache_age() is not None and cache.cache_age().total_seconds() < 60 + + +def test_expired_cache_is_replaced(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + make_cache_stale(cache) + install_downloader(monkeypatch, DATA_V2) + + bundle = cache.ensure_fresh() + + assert bundle.source == "downloaded" + assert bundle.data == DATA_V2 + assert json.loads(cache.metadata_path.read_text()) == DATA_V2 + assert cache.cache_age().total_seconds() < 60 + assert no_tmp_files(cache) + + +def test_force_bypasses_freshness(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + install_downloader(monkeypatch, DATA_V2) + + bundle = cache.ensure_fresh(force=True) + + assert bundle.source == "downloaded" + assert bundle.data == DATA_V2 + + +# -- download failures --------------------------------------------------------- + + +def test_failed_download_keeps_stale_cache(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + make_cache_stale(cache) + install_failing_downloader(monkeypatch, "Connection timed out.") + + bundle = cache.ensure_fresh() + + assert bundle.source == "stale-cache" + assert bundle.data == DATA_V1 + assert bundle.warning is not None + assert "Connection timed out." in bundle.warning + assert "Using existing cached metadata" in bundle.warning + # Old cache untouched, no leftovers. + assert json.loads(cache.metadata_path.read_text()) == DATA_V1 + assert no_tmp_files(cache) + + +def test_failed_download_without_cache_raises(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + install_failing_downloader(monkeypatch) + + with pytest.raises(SjaurError) as excinfo: + cache.ensure_fresh() + assert "timed out" in str(excinfo.value) + assert not cache.metadata_path.exists() + + +def test_forced_failed_download_falls_back_to_fresh_cache(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + install_failing_downloader(monkeypatch) + + bundle = cache.ensure_fresh(force=True) + + assert bundle.source == "stale-cache" + assert bundle.data == DATA_V1 + + +# -- corruption handling -------------------------------------------------------- + + +def test_corrupt_gzip_download_is_rejected_without_touching_cache(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps(DATA_V1).encode()) + make_cache_stale(cache) + + def corrupt_download(url, dest, progress=None, timeout=None): + dest.write_bytes(b"this is not gzip data") + + monkeypatch.setattr("sjaur.cache.download_file", corrupt_download) + + bundle = cache.ensure_fresh() + + assert bundle.source == "stale-cache" + assert bundle.data == DATA_V1 + assert json.loads(cache.metadata_path.read_text()) == DATA_V1 + assert no_tmp_files(cache) + + +def test_fresh_but_corrupt_cache_is_replaced(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, b"{not valid json") + install_downloader(monkeypatch, DATA_V2) + + bundle = cache.ensure_fresh() + + assert bundle.source == "downloaded" + assert bundle.data == DATA_V2 + + +def test_fresh_but_corrupt_cache_and_failed_download_raises(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, b"{not valid json") + install_failing_downloader(monkeypatch) + + with pytest.raises(SjaurError): + cache.ensure_fresh() + + +def test_structurally_wrong_cache_is_rejected(tmp_path): + cache = CacheManager(tmp_path / "cache") + write_cache_file(cache, json.dumps({"not": "a list"}).encode()) + + with pytest.raises(CacheCorruptError): + cache._load() + + +def test_empty_dump_is_valid_but_yields_no_packages(tmp_path, monkeypatch): + cache = CacheManager(tmp_path / "cache") + install_downloader(monkeypatch, []) + + bundle = cache.ensure_fresh() + + assert bundle.data == [] + + +def test_unwritable_cache_dir_raises_friendly_error(tmp_path): + # A *file* where the cache directory should be triggers a mkdir failure. + blocker = tmp_path / "blocker" + blocker.write_text("I am a file, not a directory") + cache = CacheManager(blocker) + + with pytest.raises(SjaurError) as excinfo: + cache.ensure_fresh() + assert "cache directory" in str(excinfo.value) + + +# -- download_file unit tests (local file:// URL, no network) -------------------- + + +def test_download_file_copies_content(tmp_path): + source = tmp_path / "source.gz" + source.write_bytes(gzipped(DATA_V1)) + dest = tmp_path / "dest.gz" + + download_file(source.as_uri(), dest) + + assert dest.read_bytes() == source.read_bytes() + + +def test_download_file_missing_source_raises(tmp_path): + dest = tmp_path / "dest.gz" + with pytest.raises(DownloadError): + download_file((tmp_path / "missing.gz").as_uri(), dest) + + +def test_progress_callback_reports_bytes(tmp_path): + source = tmp_path / "source.gz" + source.write_bytes(gzipped(DATA_V1)) + dest = tmp_path / "dest.gz" + seen = [] + + download_file(source.as_uri(), dest, progress=lambda done, total: seen.append((done, total))) + + assert seen and seen[-1][0] == source.stat().st_size + + +# -- helpers --------------------------------------------------------------------- + + +def test_humanize_delta(): + from datetime import timedelta + + assert humanize_delta(timedelta(seconds=10)) == "10 seconds" + assert humanize_delta(timedelta(minutes=5)) == "5 minutes" + assert "hours" in humanize_delta(timedelta(hours=3)) + assert "days" in humanize_delta(timedelta(days=3)) + + +def test_cache_age_missing_file(tmp_path): + cache = CacheManager(tmp_path / "cache") + assert cache.cache_age() is None + assert not cache.is_fresh() + + +def test_metadata_filename(tmp_path): + cache = CacheManager(tmp_path) + assert cache.metadata_path == Path(tmp_path) / "packages-meta-ext-v1.json" diff --git a/prg/sjaur/tests/test_installer.py b/prg/sjaur/tests/test_installer.py new file mode 100644 index 0000000..494ea8f --- /dev/null +++ b/prg/sjaur/tests/test_installer.py @@ -0,0 +1,189 @@ +"""Tests for the installer: pure planning logic and the run_step executor.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sjaur.installer import ( + InstallStep, + check_environment, + plan_install, + run_step, +) +from sjaur.errors import SjaurError + + +# -- plan_install (pure logic) --------------------------------------------------- + + +def test_plan_install_without_existing_clone(tmp_path): + steps = plan_install("yay", tmp_path) + assert steps == [ + InstallStep("clone", "yay"), + InstallStep("makepkg", "yay"), + ] + + +def test_plan_install_reuses_existing_clone(tmp_path): + (tmp_path / "yay").mkdir() + steps = plan_install("yay", tmp_path) + assert steps == [ + InstallStep("reuse", "yay"), + InstallStep("makepkg", "yay"), + ] + + +def test_plan_install_refresh_updates_existing_clone(tmp_path): + (tmp_path / "yay").mkdir() + steps = plan_install("yay", tmp_path, refresh=True) + assert steps == [ + InstallStep("update", "yay"), + InstallStep("makepkg", "yay"), + ] + + +def test_plan_install_refresh_clones_when_missing(tmp_path): + steps = plan_install("yay", tmp_path, refresh=True) + assert [step.kind for step in steps] == ["clone", "makepkg"] + + +def test_plan_install_repo_package_uses_pacman(tmp_path): + for repository in ("core", "extra", "multilib"): + steps = plan_install("firefox", tmp_path, repository=repository) + assert steps == [InstallStep("pacman", "firefox")] + + +def test_run_step_pacman_uses_sudo_for_non_root(tmp_path, monkeypatch): + import sjaur.installer as installer + + recorded = {} + + def fake_run(cmd, cwd=None, check=False): + recorded["cmd"] = cmd + + class Result: + returncode = 0 + + return Result() + + monkeypatch.setattr(installer.subprocess, "run", fake_run) + monkeypatch.setattr(installer.os, "geteuid", lambda: 1000) + + run_step(InstallStep("pacman", "firefox"), tmp_path) + + assert recorded["cmd"] == ["sudo", "pacman", "-S", "--needed", "firefox"] + + +def test_run_step_pacman_as_root_needs_no_sudo(tmp_path, monkeypatch): + import sjaur.installer as installer + + recorded = {} + + def fake_run(cmd, cwd=None, check=False): + recorded["cmd"] = cmd + + class Result: + returncode = 0 + + return Result() + + monkeypatch.setattr(installer.subprocess, "run", fake_run) + monkeypatch.setattr(installer.os, "geteuid", lambda: 0) + + run_step(InstallStep("pacman", "firefox"), tmp_path) + + assert recorded["cmd"] == ["pacman", "-S", "--needed", "firefox"] + + +def test_check_environment_repo_packages_only_need_pacman(monkeypatch): + import sjaur.installer as installer + + monkeypatch.setattr( + installer.shutil, + "which", + lambda name: None if name in ("git", "makepkg") else "/usr/bin/pacman", + ) + assert check_environment(include_build_tools=False) == [] + assert len(check_environment(include_build_tools=True)) == 2 + + +# -- run_step (subprocess execution) ---------------------------------------------- + + +def test_run_step_clone_creates_directory(tmp_path, monkeypatch): + import subprocess + + import sjaur.installer as installer + + # Point the AUR URL template at a local bare repository: no network. + source = tmp_path / "aur-mock" + source.mkdir() + subprocess.run(["git", "init", "-q", "--bare", str(source / "yay.git")], check=True) + monkeypatch.setattr(installer, "AUR_GIT_URL", str(source / "{name}.git")) + + target_root = tmp_path / "builds" + target_root.mkdir() + run_step(InstallStep("clone", "yay"), target_root) + + assert (target_root / "yay" / ".git").is_dir() + + +def test_run_step_reuse_is_a_noop(tmp_path): + (tmp_path / "whatever").mkdir() + run_step(InstallStep("reuse", "whatever"), tmp_path) # must not raise + + +def test_run_step_makepkg_without_pkgbuild_raises(tmp_path): + (tmp_path / "pkg").mkdir() + with pytest.raises(SjaurError) as excinfo: + run_step(InstallStep("makepkg", "pkg"), tmp_path) + assert "PKGBUILD" in str(excinfo.value) + + +def test_run_step_makepkg_failure_raises(tmp_path): + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "PKGBUILD").write_text("pkgname=pkg\n") + with pytest.raises(SjaurError) as excinfo: + run_step(InstallStep("makepkg", "pkg"), tmp_path) + assert "makepkg" in str(excinfo.value) + + +def test_run_step_unknown_kind_raises(tmp_path): + with pytest.raises(SjaurError): + run_step(InstallStep("explode", "pkg"), tmp_path) + + +# -- check_environment ------------------------------------------------------------- + + +def test_check_environment_finds_basic_tools(): + # git/makepkg/pacman exist in CI-like Linux environments; if any is + # missing the message names the tool. + problems = check_environment() + assert isinstance(problems, list) + for problem in problems: + assert "not found in PATH" in problem + + +def test_check_environment_reports_missing_tool(monkeypatch): + import sjaur.installer as installer + + monkeypatch.setattr(installer.shutil, "which", lambda name: None) + problems = check_environment() + assert len(problems) == 3 + assert any("git" in problem for problem in problems) + + +def test_missing_binary_in_run_step_raises(tmp_path, monkeypatch): + import sjaur.installer as installer + + def raise_missing(*args, **kwargs): + raise FileNotFoundError("git") + + monkeypatch.setattr(installer.subprocess, "run", raise_missing) + with pytest.raises(SjaurError) as excinfo: + run_step(InstallStep("clone", "x"), tmp_path) + assert "git" in str(excinfo.value) diff --git a/prg/sjaur/tests/test_metadata.py b/prg/sjaur/tests/test_metadata.py new file mode 100644 index 0000000..63f486a --- /dev/null +++ b/prg/sjaur/tests/test_metadata.py @@ -0,0 +1,113 @@ +"""Tests for metadata parsing (tolerant of real-world dump quirks).""" + +from __future__ import annotations + +import pytest + +from sjaur.errors import CacheCorruptError +from sjaur.metadata import format_timestamp, parse_packages + + +def test_parse_minimal_entry(): + packages, skipped = parse_packages([{"Name": "firefox"}]) + assert skipped == 0 + (package,) = packages + assert package.name == "firefox" + assert package.version == "" + assert package.description == "" + assert package.maintainer == "" + assert package.votes == 0 + assert package.popularity == 0.0 + assert package.out_of_date is None + assert package.is_orphan + assert package.aur_url == "https://aur.archlinux.org/packages/firefox" + + +def test_parse_full_entry(): + entry = { + "Name": "cower", + "PackageBase": "cower", + "Version": "14-2", + "Description": "A simple AUR agent", + "URL": "http://example.com", + "NumVotes": 590, + "Popularity": 24.59, + "OutOfDate": None, + "Maintainer": "falconindy", + "FirstSubmitted": 1293676237, + "LastModified": 1441804093, + "Depends": ["curl", "openssl"], + "MakeDepends": ["perl"], + "Keywords": ["aur"], + "License": ["MIT"], + } + packages, skipped = parse_packages([entry]) + assert skipped == 0 + (package,) = packages + assert package.name == "cower" + assert package.version == "14-2" + assert package.votes == 590 + assert package.popularity == 24.59 + assert package.out_of_date is None + assert package.maintainer == "falconindy" + assert package.depends == ("curl", "openssl") + assert package.make_depends == ("perl",) + assert package.keywords == ("aur",) + assert package.licenses == ("MIT",) + assert not package.is_orphan + + +def test_out_of_date_timestamp_is_kept(): + packages, _ = parse_packages([{"Name": "x", "OutOfDate": 1720000000}]) + assert packages[0].out_of_date == 1720000000 + + +def test_missing_fields_are_omitted_by_server(): + # The dump omits fields a package does not have; lists must be empty, not None. + packages, _ = parse_packages([{"Name": "x", "Depends": None, "Keywords": []}]) + assert packages[0].depends == () + assert packages[0].keywords == () + + +def test_malformed_entries_are_skipped_and_counted(): + packages, skipped = parse_packages( + [ + {"Name": "ok"}, + "not a dict", + 42, + {"Version": "1.0"}, # no Name + {"Name": " "}, # blank Name + None, + ] + ) + assert [p.name for p in packages] == ["ok"] + assert skipped == 5 + + +def test_non_list_payload_is_rejected(): + with pytest.raises(CacheCorruptError): + parse_packages({"Name": "firefox"}) + + +def test_type_mismatches_do_not_crash(): + entry = { + "Name": "weird", + "Version": 123, + "NumVotes": "many", + "Popularity": None, + "Depends": "python", # wrong container type + "Keywords": [1, None, "ok"], + } + packages, _ = parse_packages([entry]) + package = packages[0] + assert package.version == "" + assert package.votes == 0 + assert package.popularity == 0.0 + assert package.depends == () + assert package.keywords == ("ok",) + + +def test_format_timestamp(): + assert format_timestamp(None) == "unknown" + assert format_timestamp(0) == "unknown" + assert format_timestamp(1441804093) == "2015-09-09 13:08 UTC" diff --git a/prg/sjaur/tests/test_query.py b/prg/sjaur/tests/test_query.py new file mode 100644 index 0000000..8390e45 --- /dev/null +++ b/prg/sjaur/tests/test_query.py @@ -0,0 +1,77 @@ +"""Tests for the query parser, including the required @maintainer syntax.""" + +from __future__ import annotations + +from sjaur.query import OPERATORS, SearchQuery, parse_query + + +def test_plain_query(): + query = parse_query("firefox") + assert query.text == "firefox" + assert query.maintainer is None + assert query.filters == () + + +def test_maintainer_only(): + query = parse_query("@max_musterman") + assert query.maintainer == "max_musterman" + assert query.text == "" + + +def test_maintainer_with_package_query(): + query = parse_query("@max_musterman firefox") + assert query.maintainer == "max_musterman" + assert query.text == "firefox" + + +def test_only_first_word_is_maintainer_query(): + query = parse_query("@john python editor") + assert query.maintainer == "john" + assert query.text == "python editor" + + +def test_separated_operator_token(): + query = parse_query("@ john firefox") + assert query.maintainer == "john" + assert query.text == "firefox" + + +def test_multiple_at_tokens_uses_first(): + query = parse_query("@alice @bob firefox") + assert query.maintainer == "alice" + assert query.text == "firefox" + + +def test_separated_operator_alone_takes_next_word(): + # "@ word" behaves like "@word": the next token is the maintainer query. + query = parse_query("@ firefox") + assert query.maintainer == "firefox" + assert query.text == "" + + +def test_trailing_operator_without_value_is_ignored(): + query = parse_query("firefox @") + assert query.maintainer is None + assert query.text == "firefox" + + +def test_at_inside_word_is_not_an_operator(): + query = parse_query("user@example.com") + assert query.maintainer is None + assert query.text == "user@example.com" + + +def test_empty_query(): + query = parse_query("") + assert query == SearchQuery() + + +def test_whitespace_is_normalized(): + query = parse_query(" firefox nightly ") + assert query.text == "firefox nightly" + + +def test_operator_registry_is_extensible(): + # Future operators must only need a table entry plus a SearchQuery field. + assert "@" in OPERATORS + assert OPERATORS["@"] == "maintainer" diff --git a/prg/sjaur/tests/test_repodb.py b/prg/sjaur/tests/test_repodb.py new file mode 100644 index 0000000..a0e2287 --- /dev/null +++ b/prg/sjaur/tests/test_repodb.py @@ -0,0 +1,176 @@ +"""Tests for the pacman sync database parser (built from a synthetic archive).""" + +from __future__ import annotations + +import gzip +import io +import tarfile + +from sjaur.repodb import load_repo_packages, sync_db_paths + +DESC = """%FILENAME% +linux-6.16.8-1-x86_64.pkg.tar.zst + +%NAME% +linux + +%BASE% +linux + +%VERSION% +6.16.8-1 + +%DESC% +The Linux kernel and modules + +%URL% +https://github.com/torvalds/linux + +%ARCH% +x86_64 + +%PACKAGER% +Jan Alexander Steffens (heftig) + +%BUILDDATE% +1757429406 +""" + +DEPENDS = """%DEPENDS% +coreutils +initramfs +kmod + +%OPTDEPENDS% +linux-firmware: kernel firmware for hw drivers + +%MAKEDEPENDS% +bc +cpio + +%PROVIDES% +VIRTUALBOX-GUEST-MODULES +WIREGUARD-MODULE + +%CONFLICTS% +linux-libre + +%REPLACES% +virtualbox-guest-modules-arch + +%GROUPS% +base + +%LICENSE% +GPL2 +""" + + +def build_db(tmp_path, repository: str, entries: dict[str, tuple[str, str]]) -> None: + """Write a gzip-compressed tar archive mimicking a pacman sync DB.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + for directory, (desc, depends) in entries.items(): + for filename, content in ( + ("desc", desc), + ("depends", depends), + ): + data = content.encode() + info = tarfile.TarInfo(name=f"{directory}/{filename}") + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + (tmp_path / f"{repository}.db").write_bytes(gzip.compress(buffer.getvalue())) + + +def test_sync_db_paths_sorted_and_filtered(tmp_path): + build_db(tmp_path, "extra", {}) + (tmp_path / "local").mkdir() + (tmp_path / "core.db.bak").write_text("not a db") + + paths = sync_db_paths(tmp_path) + assert [path.name for path in paths] == ["extra.db"] + + +def test_sync_db_paths_missing_dir(tmp_path): + assert sync_db_paths(tmp_path / "does-not-exist") == [] + + +def test_load_repo_packages_parses_all_fields(tmp_path): + build_db(tmp_path, "core", {"linux-6.16.8-1": (DESC, DEPENDS)}) + + packages, loaded = load_repo_packages(tmp_path) + + assert loaded == ["core"] + (package,) = packages + assert package.name == "linux" + assert package.version == "6.16.8-1" + assert package.description == "The Linux kernel and modules" + assert package.repository == "core" + assert package.is_repo_package + assert not package.is_orphan + # Packager email is reduced to the username for @maintainer searches. + assert package.maintainer == "heftig" + assert package.url == "https://github.com/torvalds/linux" + assert package.last_modified == 1757429406 + assert package.depends == ("coreutils", "initramfs", "kmod") + assert package.opt_depends == ("linux-firmware: kernel firmware for hw drivers",) + assert package.make_depends == ("bc", "cpio") + assert package.provides == ("VIRTUALBOX-GUEST-MODULES", "WIREGUARD-MODULE") + assert package.conflicts == ("linux-libre",) + assert package.replaces == ("virtualbox-guest-modules-arch",) + assert package.groups == ("base",) + assert package.licenses == ("GPL2",) + assert package.votes == 0 # AUR-only concept + assert package.aur_url == "https://aur.archlinux.org/packages/linux" + + +def test_load_repo_packages_multiple_repositories(tmp_path): + bash_desc = DESC.replace("linux", "bash").replace("6.16.8-1", "5.3-1") + build_db(tmp_path, "core", {"bash-5.3-1": (bash_desc, "")}) + build_db(tmp_path, "extra", {"linux-6.16.8-1": (DESC, DEPENDS)}) + + packages, loaded = load_repo_packages(tmp_path) + + assert loaded == ["core", "extra"] + assert len(packages) == 2 + repos = {package.name: package.repository for package in packages} + assert repos == {"bash": "core", "linux": "extra"} + + +def test_corrupt_database_is_skipped(tmp_path): + build_db(tmp_path, "core", {"linux": (DESC, DEPENDS)}) + (tmp_path / "broken.db").write_bytes(b"this is not a tar archive") + + packages, loaded = load_repo_packages(tmp_path) + + assert loaded == ["core"] + assert [package.name for package in packages] == ["linux"] + + +def test_entries_without_name_are_skipped(tmp_path): + build_db(tmp_path, "extra", {"broken-1.0-1": ("%VERSION%\n1.0-1\n", "")}) + + packages, loaded = load_repo_packages(tmp_path) + + assert loaded == ["extra"] + assert packages == [] + + +def test_packager_without_email_is_kept_verbatim(tmp_path): + desc = DESC.replace( + "Jan Alexander Steffens (heftig) ", "Arch Linux" + ) + build_db(tmp_path, "core", {"linux": (desc, "")}) + + packages, _ = load_repo_packages(tmp_path) + + assert packages[0].maintainer == "Arch Linux" + + +def test_builddate_garbage_becomes_zero(tmp_path): + desc = DESC.replace("1757429406", "not-a-date") + build_db(tmp_path, "core", {"linux": (desc, "")}) + + packages, _ = load_repo_packages(tmp_path) + + assert packages[0].last_modified == 0 diff --git a/prg/sjaur/tests/test_search.py b/prg/sjaur/tests/test_search.py new file mode 100644 index 0000000..76bfad0 --- /dev/null +++ b/prg/sjaur/tests/test_search.py @@ -0,0 +1,201 @@ +"""Tests for the search engine: ranking, fuzzy matching, and @maintainer.""" + +from __future__ import annotations + +from sjaur.query import parse_query +from sjaur.search import MAINTAINER_MATCH_THRESHOLD, SearchEngine + + +def names(results): + return [result.package.name for result in results] + + +def test_exact_name_match_first(engine): + results = engine.search(parse_query("firefox")) + assert names(results)[0] == "firefox" + # All firefox variants are found. + assert {"firefox", "firefox-nightly", "firefox-wayland"} <= set(names(results)) + + +def test_name_prefix_ranks_above_substring(engine): + results = engine.search(parse_query("fire")) + top = names(results) + assert "firefox" in top[:3] # prefix match + # The substring-only "firefox-extension-uorigin" must not outrank the + # prefix matches. + assert top.index("firefox") < top.index("firefox-extension-uorigin") + + +def test_typo_still_finds_package(engine): + results = engine.search(parse_query("firfox")) + assert "firefox" in names(results) + + +def test_description_match(engine): + results = engine.search(parse_query("office productivity")) + assert "libreoffice-fresh" in names(results) + + +def test_description_tokens_all_must_appear(engine): + results = engine.search(parse_query("productivity suite")) + assert names(results)[0] == "libreoffice-fresh" + + +def test_maintainer_field_contributes_to_normal_search(engine): + results = engine.search(parse_query("alice")) + assert "fzf" in names(results) + + +def test_maintainer_operator_fuzzy(engine): + results = engine.search(parse_query("@max_musterman")) + found = names(results) + # Fuzzy: similar maintainers match despite the typo. + assert "python-pytest" in found # max_musterman123 + assert "firefox-wayland" in found # max_mustermann + # Unrelated maintainers must not appear. + assert "firefox" not in found + assert "chromium" not in found + + +def test_maintainer_operator_with_package_query(engine): + results = engine.search(parse_query("@jonas browser")) + found = names(results) + assert {"firefox", "chromium"} <= set(found) + # Maintained by others. + assert "python-pytest" not in found + + +def test_maintainer_query_boosts_maintainer_matches(engine): + # "jonas" maintains firefox and chromium; both rank top for the plain + # text search, and the @-combined search scores at least as high overall. + plain = engine.search(parse_query("jonas")) + combined = engine.search(parse_query("@jonas jonas")) + assert names(plain)[0] == "firefox" # shorter name wins the prefix tie + assert names(combined)[0] in ("firefox", "chromium") + assert combined[0].score >= plain[0].score + + +def test_maintainer_below_threshold_is_excluded(engine): + results = engine.search(parse_query("@zzzzzzzzzz")) + assert results == [] + + +def test_orphan_packages_have_no_maintainer_matches(engine): + # An empty (orphaned) maintainer cannot fuzzy-match an @query, but the + # package itself is still reachable through a normal search. + results = engine.search(parse_query("@orph")) + found = names(results) + assert "python-editor" in found # maintainer "orphan" + assert "libreoffice-fresh" not in found + assert "libreoffice-fresh" in names(engine.search(parse_query("office"))) + + +def test_combined_scoring_formula(engine): + # With a maintainer query, maintainer similarity carries most of the + # weight but the package score still orders results. + results = engine.search(parse_query("@jonas standalone")) + firefox = next(r for r in results if r.package.name == "firefox") + chromium = next(r for r in results if r.package.name == "chromium") + assert firefox.score > 0 and chromium.score > 0 + # firefox's description contains "standalone", chromium's does not. + assert firefox.score > chromium.score + + +def test_provides_field_is_searched(engine): + results = engine.search(parse_query("chromium-browser")) + assert names(results)[0] == "chromium" + + +def test_dependencies_are_searched_with_lower_priority(engine): + results = engine.search(parse_query("pytest")) + # python-pytest matches by name; anything matching only via depends must + # not outrank it. + assert names(results)[0] == "python-pytest" + + +def test_empty_results(engine): + assert engine.search(parse_query("zzzznotapackagezzzz")) == [] + + +def test_empty_query_returns_popular_packages(engine): + results = engine.search(parse_query("")) + top = names(results) + assert len(top) > 0 + # Most-voted package first. + votes = [r.package.votes for r in results] + assert votes == sorted(votes, reverse=True) + + +def test_results_are_deterministic(engine): + first = engine.search(parse_query("web browser")) + second = engine.search(parse_query("web browser")) + assert names(first) == names(second) + assert [r.score for r in first] == [r.score for r in second] + + +def test_limit(engine): + results = engine.search(parse_query("firefox"), limit=2) + assert len(results) == 2 + + +def test_whitespace_only_query_behaves_like_empty(engine): + results = engine.search(parse_query(" ")) + assert results == engine.search(parse_query("")) + + +def test_maintainer_threshold_is_fuzzy_not_exact(): + # The threshold must be low enough that small typos still match. + assert MAINTAINER_MATCH_THRESHOLD < 60.0 + + +def test_index_is_built_once_and_reused(engine): + # Repeated searches must not rebuild the index (same engine object). + first = engine.search(parse_query("firefox")) + assert engine._names is not None + second = engine.search(parse_query("firefox")) + assert names(first) == names(second) + + +def test_repo_and_aur_packages_of_same_name_are_both_kept(): + from conftest import make_package + + aur = make_package("python-editor", votes=42) + repo = make_package("python-editor", votes=0) + object.__setattr__(repo, "repository", "extra") + + combined = SearchEngine([aur, repo]) + assert len(combined.packages) == 2 + # Both survive a search and rank identically (votes tie-break first). + results = combined.search(parse_query("python-editor")) + assert [r.package.repository for r in results if r.package.name == "python-editor"] == [ + "aur", + "extra", + ] + + +def test_quick_filter_returns_matching_subset(engine): + from conftest import make_package + from sjaur.search import SearchResult + + base = engine.search(parse_query("firefox")) + narrowed = engine.quick_filter(base, "firefo") + assert {r.package.name for r in narrowed} <= {r.package.name for r in base} + assert "firefox" in {r.package.name for r in narrowed} + + # Fuzzy-only hits survive filtering by the same text (no flicker), + # and correcting the typo keeps real name matches while dropping + # unrelated fuzzy noise. + typo = engine.search(parse_query("firfox")) + assert engine.quick_filter(typo, "firfox") != [] + corrected = engine.quick_filter(typo, "firefox") + assert "firefox" in {r.package.name for r in corrected} + assert "ffbox" not in {r.package.name for r in corrected} + + # Multi-word queries require every token to be present. + web = engine.search(parse_query("web")) + assert engine.quick_filter(web, "web browser") != [] + + # Empty text keeps everything; unknown packages are dropped. + assert engine.quick_filter(base, "") == base + stranger = SearchResult(package=make_package("not-in-engine"), score=1.0) + assert engine.quick_filter([stranger], "firefox") == [] diff --git a/prg/sjaur/tests/test_tui.py b/prg/sjaur/tests/test_tui.py new file mode 100644 index 0000000..76755d2 --- /dev/null +++ b/prg/sjaur/tests/test_tui.py @@ -0,0 +1,236 @@ +"""Headless smoke tests for the TUI (uses Textual's test driver, no terminal).""" + +from __future__ import annotations + +import asyncio + +import pytest +from rich.table import Table +from textual.widgets import ListView, Static + +from sjaur.tui.app import InstallerConfirmScreen, SJaurApp + + +def pytest_fail(message: str): + pytest.fail(message) + + +def run(coro): + return asyncio.run(coro) + + +def test_tui_shows_popular_results_and_live_search(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + list_view = app.query_one("#results", ListView) + initial_count = len(list_view.children) + assert initial_count > 0 # empty input shows the most-voted packages + + # Type a query; results update live. + await pilot.click("#search") + for char in "firefox": + await pilot.press(char) + await pilot.pause(1.0) # debounce + background search worker + + current = [r.package.name for r in app._current_results] + assert "firefox" in current + assert len(current) < initial_count + + run(scenario()) + + +def test_tui_details_pane_follows_selection(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + details = app.query_one("#details", Static) + renderable = details.content + # The first popular package's details are rendered on startup. + assert isinstance(renderable, Table) + + # Highlight another row; the detail pane follows. + list_view = app.query_one("#results", ListView) + list_view.index = 2 + await pilot.pause() + assert app.query_one("#details", Static).content is not renderable + + run(scenario()) + + +def test_tui_updates_results_on_every_keystroke(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + popular = [r.package.name for r in app._current_results] + + await pilot.click("#search") + await pilot.press("f", "z", "f") + await pilot.pause(0.05) # less than the debounce: still instant feedback + live = [r.package.name for r in app._current_results] + assert set(live) < set(popular) # narrowed immediately, not after enter + + await pilot.pause(1.0) # background search refines further + assert "fzf" in [r.package.name for r in app._current_results] + assert "fzf" in app._results_cache # finished search is cached + + run(scenario()) + + +def test_tui_cached_results_are_restored_instantly(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + await pilot.click("#search") + for _ in range(3): + await pilot.press("f", "z", "f") + await pilot.pause(1.0) + fzf_results = [r.package.name for r in app._current_results] + + # Clear and retype: results come back from the cache. + await pilot.press("backspace", "backspace", "backspace") + await pilot.pause(1.0) + assert app._current_results # popular list restored + await pilot.press("f", "z", "f") + await pilot.pause(0.05) + assert [r.package.name for r in app._current_results] == fzf_results + + run(scenario()) + + +def test_tui_install_flow_is_requested_for_selected_package(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + calls = [] + app._install_flow = lambda package, refresh: calls.append( + (package.name, refresh) + ) + + # Highlight a package and open the install dialog. + app.action_focus_results() + await pilot.pause() + await pilot.press("ctrl+i") + await pilot.pause() + assert isinstance(app.screen, InstallerConfirmScreen) + + # 'n' cancels: nothing is installed. + await pilot.press("n") + await pilot.pause() + assert calls == [] + + # Re-open and confirm with 'y'. + await pilot.press("ctrl+i") + await pilot.pause() + await pilot.press("y") + await pilot.pause() + assert len(calls) == 1 + assert calls[0][1] is False + + # 'u' confirms with a refresh (update the clone first). + await pilot.press("ctrl+i") + await pilot.pause() + await pilot.press("u") + await pilot.pause() + assert calls[-1][1] is True + + # The plain 'i' key (results focused) opens the dialog as well. + await pilot.press("escape") + await pilot.pause() + app.action_focus_results() + await pilot.pause() + await pilot.press("i") + await pilot.pause() + assert isinstance(app.screen, InstallerConfirmScreen) + + run(scenario()) + + +def test_tui_install_reports_environment_problems(engine, monkeypatch): + async def scenario(): + import sjaur.tui.app as app_module + + monkeypatch.setattr( + app_module, + "check_environment", + lambda **kwargs: ["'git' not found in PATH — required"], + ) + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + app._install_flow = lambda package, refresh: pytest_fail("flow must not run") + + app.action_focus_results() + await pilot.pause() + await pilot.press("ctrl+i") + await pilot.pause() + await pilot.press("y") + await pilot.pause() + assert "git" in str(app.query_one("#status", Static).content) + + run(scenario()) + + +def test_tui_install_flow_error_is_reported(engine, monkeypatch): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + + def failing_flow(package, refresh): + from sjaur.errors import SjaurError + + raise SjaurError("makepkg failed with exit code 4") + + app._install_flow = failing_flow + app._install_package(engine.packages[0], refresh=False) + await pilot.pause() + # The report goes to the suspended terminal, so no status error: + # the app must simply survive and stay usable. + assert app._search_input.has_focus + + run(scenario()) + + +def test_tui_escape_clears_search(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + await pilot.click("#search") + for char in "fzf": + await pilot.press(char) + await pilot.pause(1.0) + assert app._current_results + + await pilot.press("escape") + await pilot.pause(1.0) + assert app._search_input.value == "" + + run(scenario()) + + +def test_tui_enter_expands_details_and_escape_returns(engine): + async def scenario(): + app = SJaurApp(engine=engine, cache=None, cache_note="test") + async with app.run_test(size=(120, 36)) as pilot: + await pilot.pause() + content = app.query_one("#content") + + # Focus the results and open the selected package. + app.action_focus_results() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert content.has_class("expanded") + + await pilot.press("escape") + await pilot.pause() + assert not content.has_class("expanded") + + run(scenario()) diff --git a/prg/uptime/app.py b/prg/uptime/app.py new file mode 100644 index 0000000..9e6812a --- /dev/null +++ b/prg/uptime/app.py @@ -0,0 +1,1171 @@ + +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +serverjonas Network – automatischer Wochenstatus + +Funktionen: +- Sammelt öffentliche Uptime-Kuma-Heartbeats +- Speichert die Daten dauerhaft in reports/history.sqlite +- Erstellt sonntags zwischen 11:00 und 18:00 Uhr einen A4-PNG-Report +- Erstellt pro Kalendertag maximal einen Report +- Dokumentiert Datenlücken und bestätigte DOWN-Meldungen + +Die Zeiten der Uptime-Kuma-API werden, falls keine Zeitzone +angegeben ist, als UTC interpretiert. +""" + +from __future__ import annotations + +import io +import json +import logging +import sqlite3 +import time +import urllib.request +import urllib.error + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +# ============================================================ +# KONFIGURATION +# ============================================================ + +API_URL = ( + "https://health.mjlteam.de/" + "api/status-page/heartbeat/serverjonas" +) + +MONITORS = { + 16: "serverjonas Main", + 13: "serverjonas DEV Server", +} + +POLL_SECONDS = 60 + +# Sonntags zwischen diesen Uhrzeiten darf der Report entstehen. +REPORT_START_HOUR = 11 +REPORT_END_HOUR = 18 + +# Betrachteter Zeitraum: die letzten 7 * 24 Stunden. +REPORT_DAYS = 7 + +BASE_DIR = Path(__file__).resolve().parent +REPORT_DIR = BASE_DIR / "reports" +DB_PATH = REPORT_DIR / "history.sqlite" + +# A4 bei 300 DPI +WIDTH = 2480 +HEIGHT = 3508 +MARGIN = 150 + +# Farben +BG = "#F4F6FA" +WHITE = "#FFFFFF" +DARK = "#172033" +MUTED = "#657087" +GREEN = "#168653" +RED = "#C73545" +ORANGE = "#D58A20" +BLUE = "#3268B2" +LIGHT_GREEN = "#E4F4EB" +LIGHT_RED = "#FBE8EA" +LIGHT_BLUE = "#E8EFFB" +BORDER = "#DCE2EC" + +# Nach diesem Abstand wird eine Lücke im Heartbeat-Verlauf markiert. +# Bei einem Minutenintervall sind 150 Sekunden ein sinnvoller Startwert. +GAP_THRESHOLD_SECONDS = 150 + +# HTTP +HTTP_TIMEOUT = 20 +USER_AGENT = "serverjonas-network-status-collector/1.0" + + +# ============================================================ +# LOGGING UND VERZEICHNISSE +# ============================================================ + +REPORT_DIR.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s", + handlers=[ + logging.StreamHandler(), + logging.FileHandler( + REPORT_DIR / "collector.log", + encoding="utf-8", + ), + ], +) + + +# ============================================================ +# ZEIT UND DATENBANK +# ============================================================ + +def now_local() -> datetime: + """Aktuelle lokale Zeit inklusive Zeitzone.""" + return datetime.now().astimezone() + + +def parse_api_time(value: str) -> datetime: + """ + API-Zeitstempel parsen. + + Zeitstempel ohne Zeitzone werden als UTC behandelt. + Speicherung erfolgt anschließend als Unix-Zeitstempel. + """ + value = value.strip().replace("Z", "+00:00") + dt = datetime.fromisoformat(value) + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + return dt + + +def db_connect() -> sqlite3.Connection: + con = sqlite3.connect(DB_PATH, timeout=30) + con.row_factory = sqlite3.Row + return con + + +def init_db() -> None: + with db_connect() as con: + con.execute(""" + CREATE TABLE IF NOT EXISTS heartbeats ( + monitor_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + status INTEGER NOT NULL, + msg TEXT, + ping REAL, + PRIMARY KEY (monitor_id, timestamp, status) + ) + """) + + con.execute(""" + CREATE INDEX IF NOT EXISTS idx_heartbeat_time + ON heartbeats(timestamp) + """) + + con.execute(""" + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + + con.execute(""" + CREATE TABLE IF NOT EXISTS collection_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + success INTEGER NOT NULL, + detail TEXT + ) + """) + + con.execute(""" + INSERT OR IGNORE INTO metadata(key, value) + VALUES ('first_run', ?) + """, (str(time.time()),)) + + +def record_run(success: bool, detail: str = "") -> None: + with db_connect() as con: + con.execute( + """ + INSERT INTO collection_runs(timestamp, success, detail) + VALUES (?, ?, ?) + """, + (time.time(), int(success), detail[:1000]), + ) + + +# ============================================================ +# API-ABFRAGE +# ============================================================ + +def fetch_api() -> dict: + request = urllib.request.Request( + API_URL, + headers={"User-Agent": USER_AGENT}, + ) + + with urllib.request.urlopen( + request, + timeout=HTTP_TIMEOUT, + ) as response: + if response.status != 200: + raise RuntimeError( + f"HTTP-Status {response.status}" + ) + + raw = response.read() + + return json.loads(raw.decode("utf-8")) + + +def collect_heartbeats() -> int: + """ + Holt die öffentlich verfügbaren Heartbeats und archiviert + neue Einträge. Bereits vorhandene Datensätze werden ignoriert. + """ + data = fetch_api() + + heartbeat_list = data.get("heartbeatList", {}) + inserted = 0 + + with db_connect() as con: + for monitor_id, entries in heartbeat_list.items(): + try: + monitor_id = int(monitor_id) + except (TypeError, ValueError): + continue + + if monitor_id not in MONITORS: + continue + + if not isinstance(entries, list): + continue + + for item in entries: + try: + timestamp = parse_api_time( + item["time"] + ).timestamp() + + status = int(item["status"]) + msg = str(item.get("msg") or "") + + ping = item.get("ping") + if ping is not None: + ping = float(ping) + + cursor = con.execute( + """ + INSERT OR IGNORE INTO heartbeats + (monitor_id, timestamp, status, msg, ping) + VALUES (?, ?, ?, ?, ?) + """, + ( + monitor_id, + timestamp, + status, + msg, + ping, + ), + ) + + inserted += cursor.rowcount + + except ( + KeyError, + TypeError, + ValueError, + OverflowError, + ) as exc: + logging.warning( + "Ungültiger Heartbeat: %s", exc + ) + + record_run(True, f"{inserted} neue Heartbeats") + logging.info( + "Sammlung erfolgreich: %s neue Heartbeats", + inserted, + ) + + return inserted + + +# ============================================================ +# DATENABFRAGE UND ANALYSE +# ============================================================ + +def get_heartbeats( + monitor_id: int, + start: float, + end: float, +) -> list[dict]: + with db_connect() as con: + rows = con.execute( + """ + SELECT timestamp, status, msg, ping + FROM heartbeats + WHERE monitor_id = ? + AND timestamp >= ? + AND timestamp <= ? + ORDER BY timestamp ASC + """, + (monitor_id, start, end), + ).fetchall() + + return [dict(row) for row in rows] + + +def local_datetime(timestamp: float) -> datetime: + return datetime.fromtimestamp(timestamp).astimezone() + + +def fmt_time(timestamp: float) -> str: + return local_datetime(timestamp).strftime( + "%d.%m.%Y %H:%M:%S" + ) + + +def fmt_duration(seconds: float) -> str: + seconds = max(0, int(seconds)) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + parts = [] + + if days: + parts.append(f"{days} T") + if hours: + parts.append(f"{hours} h") + if minutes: + parts.append(f"{minutes} min") + + if not parts: + parts.append(f"{seconds} s") + + return " ".join(parts) + + +def analyse_monitor( + monitor_id: int, + start: float, + end: float, +) -> dict: + rows = get_heartbeats(monitor_id, start, end) + + result = { + "id": monitor_id, + "name": MONITORS[monitor_id], + "rows": rows, + "count": len(rows), + "up": 0, + "down": 0, + "unknown": 0, + "gaps": [], + "incidents": [], + "last_status": None, + "last_time": None, + "uptime_pct": None, + "known_down_seconds": 0, + } + + if not rows: + return result + + for row in rows: + status = row["status"] + + if status == 1: + result["up"] += 1 + elif status == 0: + result["down"] += 1 + else: + result["unknown"] += 1 + + result["last_status"] = rows[-1]["status"] + result["last_time"] = rows[-1]["timestamp"] + + # Datenlücken und Statuswechsel untersuchen. + current_down = None + previous = None + up_seconds = 0.0 + observed_seconds = 0.0 + + for row in rows: + ts = row["timestamp"] + status = row["status"] + + if previous is not None: + delta = ts - previous["timestamp"] + + if delta > GAP_THRESHOLD_SECONDS: + result["gaps"].append({ + "start": previous["timestamp"], + "end": ts, + "duration": delta, + }) + + # Nur Intervalle zwischen bekannten Statusmeldungen + # in die beobachtete Zeit einrechnen. + if 0 < delta <= GAP_THRESHOLD_SECONDS: + observed_seconds += delta + + if previous["status"] == 1: + up_seconds += delta + + if status == 0 and current_down is None: + current_down = ts + + elif status == 1 and current_down is not None: + duration = ts - current_down + + result["incidents"].append({ + "start": current_down, + "end": ts, + "duration": duration, + "confirmed": True, + }) + + result["known_down_seconds"] += duration + current_down = None + + previous = row + + # Ein bis zum Ende noch nicht behobener DOWN-Zustand. + if current_down is not None: + duration = max(0, end - current_down) + + result["incidents"].append({ + "start": current_down, + "end": None, + "duration": duration, + "confirmed": True, + }) + + result["known_down_seconds"] += duration + + if observed_seconds > 0: + result["uptime_pct"] = ( + up_seconds / observed_seconds + ) * 100 + + return result + + +def daily_uptime( + rows: list[dict], + day_start: float, + day_end: float, +) -> float | None: + """ + Berechnet eine beobachtungsbasierte Tages-Uptime. + Größere Heartbeat-Lücken werden nicht als UP gewertet. + """ + relevant = [ + row for row in rows + if day_start <= row["timestamp"] <= day_end + ] + + if len(relevant) < 2: + return None + + up = 0.0 + observed = 0.0 + + for a, b in zip(relevant, relevant[1:]): + delta = b["timestamp"] - a["timestamp"] + + if 0 < delta <= GAP_THRESHOLD_SECONDS: + observed += delta + + if a["status"] == 1: + up += delta + + if observed <= 0: + return None + + return 100.0 * up / observed + + +# ============================================================ +# BERICHTSGRAFIK +# ============================================================ + +def load_font(size: int, bold: bool = False): + candidates = [] + + if bold: + candidates += [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ] + else: + candidates += [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + ] + + for path in candidates: + if Path(path).exists(): + return ImageFont.truetype(path, size) + + return ImageFont.load_default() + + +def draw_text( + draw: ImageDraw.ImageDraw, + xy, + text: str, + size: int = 30, + fill=DARK, + bold: bool = False, +): + draw.text( + xy, + str(text), + font=load_font(size, bold), + fill=fill, + ) + + +def rounded_box( + draw, + xy, + fill=WHITE, + outline=BORDER, + radius=28, +): + draw.rounded_rectangle( + xy, + radius=radius, + fill=fill, + outline=outline, + width=2, + ) + + +def draw_wrapped_text( + draw, + text: str, + x: int, + y: int, + max_width: int, + size: int = 25, + fill=DARK, + line_gap: int = 10, +): + font = load_font(size) + words = str(text).split() + lines = [] + current = "" + + for word in words: + candidate = ( + f"{current} {word}".strip() + ) + + if draw.textlength(candidate, font=font) <= max_width: + current = candidate + else: + if current: + lines.append(current) + current = word + + if current: + lines.append(current) + + for line in lines: + draw.text( + (x, y), + line, + font=font, + fill=fill, + ) + y += size + line_gap + + return y + + +def draw_chart( + draw, + x: int, + y: int, + width: int, + height: int, + analyses: dict, + start: float, + end: float, +): + # Hintergrund + rounded_box( + draw, + (x, y, x + width, y + height), + fill=WHITE, + ) + + draw_text( + draw, + (x + 35, y + 25), + "VERLAUF DER VERFÜGBARKEIT", + 30, + bold=True, + ) + + chart_x = x + 100 + chart_y = y + 100 + chart_w = width - 150 + chart_h = height - 180 + + # Raster und Achsenbeschriftungen + for pct in (0, 25, 50, 75, 100): + yy = chart_y + chart_h * (100 - pct) / 100 + + draw.line( + (chart_x, yy, chart_x + chart_w, yy), + fill=BORDER, + width=2, + ) + + draw_text( + draw, + (x + 25, int(yy) - 15), + f"{pct}%", + 20, + fill=MUTED, + ) + + colors = { + 16: BLUE, + 13: GREEN, + } + + # Tageswerte verbinden + for monitor_id, analysis in analyses.items(): + points = [] + + for i in range(7): + day_start = start + i * 86400 + day_end = min(day_start + 86400, end) + + value = daily_uptime( + analysis["rows"], + day_start, + day_end, + ) + + if value is None: + continue + + px = chart_x + chart_w * ( + (i + 0.5) / 7 + ) + + py = chart_y + chart_h * ( + 1 - value / 100 + ) + + points.append((int(px), int(py))) + + if len(points) >= 2: + draw.line( + points, + fill=colors[monitor_id], + width=7, + joint="curve", + ) + + for px, py in points: + r = 10 + draw.ellipse( + (px-r, py-r, px+r, py+r), + fill=colors[monitor_id], + ) + + # Tagesnamen + for i in range(7): + day_start = start + i * 86400 + day = local_datetime(day_start) + + px = chart_x + chart_w * ( + (i + 0.5) / 7 + ) + + draw.text( + (int(px), chart_y + chart_h + 20), + day.strftime("%a"), + font=load_font(22), + fill=MUTED, + anchor="mt", + ) + + +def create_report() -> Path: + now = now_local() + end = now.timestamp() + start = end - REPORT_DAYS * 86400 + + report_date = now.strftime("%Y-%m-%d") + output = REPORT_DIR / f"{report_date}.png" + + if output.exists(): + logging.info( + "Report für %s existiert bereits.", + report_date, + ) + return output + + analyses = { + monitor_id: analyse_monitor( + monitor_id, + start, + end, + ) + for monitor_id in MONITORS + } + + image = Image.new( + "RGB", + (WIDTH, HEIGHT), + BG, + ) + + draw = ImageDraw.Draw(image) + + x = MARGIN + content_w = WIDTH - 2 * MARGIN + y = 120 + + # Kopfbereich + draw_text( + draw, + (x, y), + "SERVERJONAS NETWORK", + 58, + bold=True, + ) + + y += 85 + + draw_text( + draw, + (x, y), + "WÖCHENTLICHER SYSTEMSTATUS", + 34, + fill=MUTED, + ) + + y += 55 + + draw_text( + draw, + (x, y), + f"Erstellt: {now.strftime('%d.%m.%Y %H:%M')} Uhr", + 25, + fill=MUTED, + ) + + y += 60 + + draw.line( + (x, y, x + content_w, y), + fill=BORDER, + width=3, + ) + + y += 40 + + # KPI-Karten + card_gap = 25 + card_w = (content_w - 2 * card_gap) // 3 + card_h = 220 + + total_incidents = sum( + len(a["incidents"]) + for a in analyses.values() + ) + + total_gaps = sum( + len(a["gaps"]) + for a in analyses.values() + ) + + main = analyses[16] + uptime = main["uptime_pct"] + + uptime_text = ( + f"{uptime:.2f}%" + if uptime is not None + else "N/A" + ) + + cards = [ + ( + "MAIN · UPTIME", + uptime_text, + LIGHT_BLUE, + BLUE, + ), + ( + "BESTÄTIGTE STÖRUNGEN", + str(total_incidents), + LIGHT_RED if total_incidents else LIGHT_GREEN, + RED if total_incidents else GREEN, + ), + ( + "DATENLÜCKEN", + str(total_gaps), + LIGHT_BLUE if total_gaps else LIGHT_GREEN, + BLUE if total_gaps else GREEN, + ), + ] + + for i, (label, value, bg, color) in enumerate(cards): + cx = x + i * (card_w + card_gap) + + rounded_box( + draw, + (cx, y, cx + card_w, y + card_h), + fill=bg, + outline=bg, + ) + + draw_text( + draw, + (cx + 25, y + 30), + label, + 22, + fill=color, + bold=True, + ) + + draw_text( + draw, + (cx + 25, y + 95), + value, + 48, + fill=DARK, + bold=True, + ) + + y += card_h + 35 + + # Zeitraum + draw_text( + draw, + (x, y), + "AUSWERTUNGSZEITRAUM", + 27, + bold=True, + ) + + y += 45 + + draw_text( + draw, + (x, y), + f"{fmt_time(start)} bis {fmt_time(end)}", + 24, + fill=MUTED, + ) + + y += 55 + + # Chart + chart_h = 650 + + draw_chart( + draw, + x, + y, + content_w, + chart_h, + analyses, + start, + end, + ) + + y += chart_h + 40 + + # Monitorübersicht + draw_text( + draw, + (x, y), + "MONITORÜBERSICHT", + 30, + bold=True, + ) + + y += 55 + + for monitor_id, analysis in analyses.items(): + box_h = 230 + + rounded_box( + draw, + (x, y, x + content_w, y + box_h), + fill=WHITE, + ) + + draw_text( + draw, + (x + 30, y + 25), + analysis["name"], + 30, + bold=True, + ) + + last_status = analysis["last_status"] + + if last_status == 1: + status_text = "LETZTER STATUS: UP" + status_color = GREEN + elif last_status == 0: + status_text = "LETZTER STATUS: DOWN" + status_color = RED + elif last_status is None: + status_text = "KEINE DATEN" + status_color = ORANGE + else: + status_text = ( + f"LETZTER STATUS: {last_status}" + ) + status_color = ORANGE + + draw_text( + draw, + (x + 30, y + 80), + status_text, + 25, + fill=status_color, + bold=True, + ) + + draw_text( + draw, + (x + 30, y + 130), + ( + f"Heartbeats: {analysis['count']} | " + f"UP: {analysis['up']} | " + f"DOWN: {analysis['down']} | " + f"Sonstige: {analysis['unknown']}" + ), + 23, + fill=MUTED, + ) + + if analysis["last_time"] is not None: + last_seen = fmt_time( + analysis["last_time"] + ) + else: + last_seen = "Keine Daten" + + draw_text( + draw, + (x + 30, y + 175), + f"Letzter Heartbeat: {last_seen}", + 21, + fill=MUTED, + ) + + y += box_h + 20 + + # Störungsprotokoll + y += 10 + + draw_text( + draw, + (x, y), + "STÖRUNGSPROTOKOLL", + 30, + bold=True, + ) + + y += 55 + + all_incidents = [] + + for monitor_id, analysis in analyses.items(): + for incident in analysis["incidents"]: + all_incidents.append( + (analysis["name"], incident) + ) + + all_incidents.sort( + key=lambda item: item[1]["start"] + ) + + if not all_incidents: + rounded_box( + draw, + (x, y, x + content_w, y + 110), + fill=LIGHT_GREEN, + outline=LIGHT_GREEN, + ) + + draw_text( + draw, + (x + 25, y + 30), + "Keine abgeschlossenen oder laufenden " + "DOWN-Phasen in den gespeicherten Daten.", + 23, + fill=GREEN, + ) + + y += 130 + + else: + for name, incident in all_incidents[:8]: + end_text = ( + fmt_time(incident["end"]) + if incident["end"] is not None + else "Noch nicht behoben" + ) + + text = ( + f"{name}: {fmt_time(incident['start'])}" + f" – {end_text} " + f"({fmt_duration(incident['duration'])})" + ) + + y = draw_wrapped_text( + draw, + text, + x + 10, + y, + content_w - 20, + size=23, + ) + 15 + + # Datenqualität / Fußnote + y = max(y + 35, HEIGHT - 430) + + draw.line( + (x, y, x + content_w, y), + fill=BORDER, + width=3, + ) + + y += 25 + + draw_text( + draw, + (x, y), + "DATENQUALITÄT & HINWEISE", + 25, + bold=True, + ) + + y += 45 + + notes = [ + ( + "Quelle: öffentliche Uptime-Kuma-Statusseiten-API." + ), + ( + "Die Datenhistorie beginnt mit dem ersten " + "Sammellauf dieses Skripts." + ), + ( + "Die öffentliche API liefert nur begrenzte " + "ältere Heartbeats." + ), + ( + "Datenlücken sind keine bestätigten Ausfälle." + ), + ( + "Die Uptime im Diagramm basiert auf den " + "beobachteten Heartbeat-Intervallen." + ), + ( + "Ein bestätigter DOWN-Status beweist nicht, " + "dass der physische Server ausgeschaltet war." + ), + ] + + for note in notes: + y = draw_wrapped_text( + draw, + "• " + note, + x + 5, + y, + content_w - 10, + size=19, + fill=MUTED, + line_gap=5, + ) + 8 + + # Footer + draw.text( + (WIDTH // 2, HEIGHT - 80), + "serverjonas Network | Automatischer Wochenreport", + font=load_font(20), + fill=MUTED, + anchor="mm", + ) + + # Speichern + image.save( + output, + format="PNG", + optimize=True, + dpi=(300, 300), + ) + + logging.info("Report erstellt: %s", output) + + return output + + +# ============================================================ +# ZEITPLAN +# ============================================================ + +def should_create_report(now: datetime) -> bool: + return ( + now.weekday() == 6 + and REPORT_START_HOUR <= now.hour < REPORT_END_HOUR + ) + + +def main() -> None: + init_db() + + logging.info("serverjonas Report-Sammler gestartet.") + logging.info("Datenbank: %s", DB_PATH) + logging.info("Report-Ordner: %s", REPORT_DIR) + + while True: + try: + collect_heartbeats() + + except ( + urllib.error.URLError, + urllib.error.HTTPError, + TimeoutError, + json.JSONDecodeError, + OSError, + RuntimeError, + ) as exc: + logging.exception( + "Fehler beim Abrufen der API" + ) + record_run(False, str(exc)) + + now = now_local() + + if should_create_report(now): + report_path = ( + REPORT_DIR / + f"{now.strftime('%Y-%m-%d')}.png" + ) + + if not report_path.exists(): + try: + create_report() + except Exception as exc: + logging.exception( + "Report-Erstellung fehlgeschlagen" + ) + record_run(False, f"Report: {exc}") + + time.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + main() diff --git a/prg/uptime/reports/2026-09-27.png b/prg/uptime/reports/2026-09-27.png new file mode 100644 index 0000000..35ea071 Binary files /dev/null and b/prg/uptime/reports/2026-09-27.png differ diff --git a/prg/uptime/reports/history.sqlite b/prg/uptime/reports/history.sqlite new file mode 100644 index 0000000..1985414 Binary files /dev/null and b/prg/uptime/reports/history.sqlite differ