commit local changes.

This commit is contained in:
2026-10-02 15:29:35 +02:00
parent 92e5b6e363
commit 95af3a84af
24 changed files with 3187 additions and 78 deletions
+42 -8
View File
@@ -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):
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"\r{i + 1}: {new_way}, distance: {distance}", end="")
ways.append({"way": new_way, "distance": distance})
return ways
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)
-22
View File
@@ -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)
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
dist/
build/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
+21
View File
@@ -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.
+201
View File
@@ -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 <dir>` overrides the cache location (default `~/.cache/sjaur`)
- `--no-repos` searches the AUR only (skip official repository packages)
- `--repos-dir <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 <name>` (`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/<name>.git` into `~/.cache/sjaur/build/<name>`
(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).
+62
View File
@@ -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"
+30 -4
View File
@@ -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…"):
+33 -9
View File
@@ -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}")
+9
View File
@@ -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
+162
View File
@@ -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@archlinux.org>" -> "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
+36 -6
View File
@@ -130,15 +130,35 @@ 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")
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",
@@ -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):
+9 -1
View File
@@ -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,16 +30,19 @@ def render_package_details(package: Package) -> Table:
table.add_row("Maintainer", package.maintainer)
else:
table.add_row("Maintainer", Text("orphaned", style="yellow"))
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",
)
if not package.is_repo_package:
table.add_row(
"First Submitted",
format_timestamp(package.first_submitted) if package.first_submitted else "unknown",
)
if not package.is_repo_package:
if package.out_of_date:
table.add_row(
"Out of Date",
@@ -47,6 +52,9 @@ def render_package_details(package: Package) -> Table:
table.add_row("Out of Date", Text("no", style="green"))
if package.url:
table.add_row("URL", package.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))
+103
View File
@@ -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)
+277
View File
@@ -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"
+189
View File
@@ -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)
+113
View File
@@ -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"
+77
View File
@@ -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"
+176
View File
@@ -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) <heftig@archlinux.org>
%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) <heftig@archlinux.org>", "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
+201
View File
@@ -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") == []
+236
View File
@@ -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())
+1171
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.