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