From 95af3a84afddd2d8ae12ebca40100aa0c0355bce Mon Sep 17 00:00:00 2001 From: jonas H Date: Fri, 2 Oct 2026 15:29:35 +0200 Subject: [PATCH] commit local changes. --- prg/{funny => }/SJNet_2038_timer.py | 0 prg/SJNet_ts-problem.py | 62 +- prg/funny/SJNet_2038_timer2.py | 22 - prg/sjaur/.gitignore | 11 + prg/sjaur/LICENSE | 21 + prg/sjaur/README.md | 201 +++++ prg/sjaur/pyproject.toml | 62 ++ prg/sjaur/src/sjaur/cli.py | 34 +- prg/sjaur/src/sjaur/installer.py | 42 +- prg/sjaur/src/sjaur/metadata.py | 9 + prg/sjaur/src/sjaur/repodb.py | 162 ++++ prg/sjaur/src/sjaur/tui/app.py | 62 +- prg/sjaur/src/sjaur/tui/details.py | 34 +- prg/sjaur/tests/conftest.py | 103 +++ prg/sjaur/tests/test_cache.py | 277 +++++++ prg/sjaur/tests/test_installer.py | 189 +++++ prg/sjaur/tests/test_metadata.py | 113 +++ prg/sjaur/tests/test_query.py | 77 ++ prg/sjaur/tests/test_repodb.py | 176 ++++ prg/sjaur/tests/test_search.py | 201 +++++ prg/sjaur/tests/test_tui.py | 236 ++++++ prg/uptime/app.py | 1171 +++++++++++++++++++++++++++ prg/uptime/reports/2026-09-27.png | Bin 0 -> 87647 bytes prg/uptime/reports/history.sqlite | Bin 0 -> 114688 bytes 24 files changed, 3187 insertions(+), 78 deletions(-) rename prg/{funny => }/SJNet_2038_timer.py (100%) delete mode 100644 prg/funny/SJNet_2038_timer2.py create mode 100644 prg/sjaur/.gitignore create mode 100644 prg/sjaur/LICENSE create mode 100644 prg/sjaur/README.md create mode 100644 prg/sjaur/pyproject.toml create mode 100644 prg/sjaur/src/sjaur/repodb.py create mode 100644 prg/sjaur/tests/conftest.py create mode 100644 prg/sjaur/tests/test_cache.py create mode 100644 prg/sjaur/tests/test_installer.py create mode 100644 prg/sjaur/tests/test_metadata.py create mode 100644 prg/sjaur/tests/test_query.py create mode 100644 prg/sjaur/tests/test_repodb.py create mode 100644 prg/sjaur/tests/test_search.py create mode 100644 prg/sjaur/tests/test_tui.py create mode 100644 prg/uptime/app.py create mode 100644 prg/uptime/reports/2026-09-27.png create mode 100644 prg/uptime/reports/history.sqlite 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 0000000000000000000000000000000000000000..35ea07106020e42c75dc3333db246c82c54dcaa2 GIT binary patch literal 87647 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 literal 0 HcmV?d00001 diff --git a/prg/uptime/reports/history.sqlite b/prg/uptime/reports/history.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..198541476824ac5a9f19bb2309cbdcd7ce7bde76 GIT binary patch literal 114688 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 literal 0 HcmV?d00001