commit local changes.
This commit is contained in:
+48
-14
@@ -23,10 +23,10 @@ def get_distance_point(p1, p2):
|
|||||||
|
|
||||||
return wurzel(dx**2 + dy**2)
|
return wurzel(dx**2 + dy**2)
|
||||||
|
|
||||||
def get_distance_points(p):
|
def get_distance_points(ps):
|
||||||
distance = 0
|
distance = 0
|
||||||
for i in range(len(p) - 1):
|
for i in range(len(ps) - 1):
|
||||||
distance += get_distance_point(p[i], p[i + 1])
|
distance += get_distance_point(ps[i], ps[i + 1])
|
||||||
return distance
|
return distance
|
||||||
|
|
||||||
def add_point(x=None, y=None):
|
def add_point(x=None, y=None):
|
||||||
@@ -58,28 +58,62 @@ def bruteforce():
|
|||||||
print(f"Possible ways: {max_ways}")
|
print(f"Possible ways: {max_ways}")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
ways = []
|
ways = []
|
||||||
for i in range(max_ways):
|
while len(ways) < max_ways:
|
||||||
new_way = []
|
new_way_dict = get_way()
|
||||||
while len(new_way) < len(points):
|
if new_way_dict not in ways:
|
||||||
n = random.choice(points)
|
ways.append(new_way_dict)
|
||||||
if n not in new_way:
|
if mode == "verbose":
|
||||||
new_way.append(n)
|
print(f"\r {new_way_dict}")
|
||||||
distance = get_distance_points(new_way)
|
print(f"\rWays: {len(ways)}/{max_ways}", end="")
|
||||||
if mode == "verbose":
|
|
||||||
print(f"\r{i + 1}: {new_way}, distance: {distance}", end="")
|
|
||||||
ways.append({"way": new_way, "distance": distance})
|
|
||||||
return ways
|
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():
|
def start():
|
||||||
print("TSM algorithm")
|
print("TSM algorithm")
|
||||||
|
print("\nBRUTEFORCE\n")
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
ways = bruteforce()
|
ways = bruteforce()
|
||||||
end = time.monotonic()
|
end = time.monotonic()
|
||||||
print(f"Took: {end - start}")
|
print(f"Took: {(end - start)/60}Min")
|
||||||
distances = []
|
distances = []
|
||||||
for i in range(len(ways)):
|
for i in range(len(ways)):
|
||||||
distances.append(ways[i]["distance"])
|
distances.append(ways[i]["distance"])
|
||||||
print(f"Fastest: {min(distances)}, slowest: {max(distances)}")
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
@@ -36,6 +36,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
default=None,
|
default=None,
|
||||||
help="cache directory override (default: ~/.cache/sjaur)",
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -89,19 +100,34 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if skipped:
|
if skipped:
|
||||||
console.print(f"[yellow]warning:[/yellow] skipped {skipped} malformed package entries")
|
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:
|
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
|
return 0
|
||||||
|
|
||||||
if bundle.source == "downloaded":
|
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":
|
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:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
f"Using cached AUR metadata "
|
f"Using cached AUR metadata "
|
||||||
f"({humanize_delta(bundle.cache_age) if bundle.cache_age else 'unknown age'} old, "
|
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…"):
|
with console.status("Building search index…"):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ live and sudo/pacman can prompt for input.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -34,7 +35,8 @@ MAKEPKG_DEP_ARGS = ("--syncdeps", "--install")
|
|||||||
class InstallStep:
|
class InstallStep:
|
||||||
"""One planned action of the install workflow."""
|
"""One planned action of the install workflow."""
|
||||||
|
|
||||||
kind: str # "clone" | "update" | "reuse" | "makepkg"
|
# "clone" | "update" | "reuse" | "makepkg" | "pacman"
|
||||||
|
kind: str
|
||||||
name: str # package name
|
name: str # package name
|
||||||
|
|
||||||
|
|
||||||
@@ -43,16 +45,23 @@ def builds_dir() -> Path:
|
|||||||
return get_cache_dir() / "build"
|
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``.
|
"""Compute the steps needed to install ``name`` below ``target_root``.
|
||||||
|
|
||||||
Pure function (no I/O except the existence check on the target path),
|
Pure function (no I/O except the existence check on the target path),
|
||||||
which makes the workflow decisions easy to test.
|
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.
|
- No clone yet -> clone fresh, then build.
|
||||||
- ``refresh=True`` -> hard-reset and update the existing clone, then build.
|
- ``refresh=True`` -> hard-reset and update the existing clone, then build.
|
||||||
- Otherwise -> reuse 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
|
target = target_root / name
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
return [
|
return [
|
||||||
@@ -70,14 +79,19 @@ def plan_install(name: str, target_root: Path, *, refresh: bool = False) -> list
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def check_environment() -> list[str]:
|
def check_environment(*, include_build_tools: bool = True) -> list[str]:
|
||||||
"""Return human-readable problems that would break an install."""
|
"""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] = []
|
problems: list[str] = []
|
||||||
for tool, hint in (
|
for tool, hint in tools:
|
||||||
("git", "required to clone AUR package repositories"),
|
|
||||||
("makepkg", "required to build packages"),
|
|
||||||
("pacman", "required to install built packages"),
|
|
||||||
):
|
|
||||||
if shutil.which(tool) is None:
|
if shutil.which(tool) is None:
|
||||||
problems.append(f"'{tool}' not found in PATH — {hint}")
|
problems.append(f"'{tool}' not found in PATH — {hint}")
|
||||||
return problems
|
return problems
|
||||||
@@ -103,6 +117,16 @@ def run_step(step: InstallStep, target_root: Path) -> None:
|
|||||||
if not (target / "PKGBUILD").is_file():
|
if not (target / "PKGBUILD").is_file():
|
||||||
raise SjaurError(f"{target} does not contain a PKGBUILD.")
|
raise SjaurError(f"{target} does not contain a PKGBUILD.")
|
||||||
_run(["makepkg", *MAKEPKG_DEP_ARGS], cwd=target)
|
_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:
|
else:
|
||||||
raise SjaurError(f"Unknown install step: {step.kind!r}")
|
raise SjaurError(f"Unknown install step: {step.kind!r}")
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class Package:
|
|||||||
replaces: tuple[str, ...]
|
replaces: tuple[str, ...]
|
||||||
groups: tuple[str, ...]
|
groups: tuple[str, ...]
|
||||||
licenses: 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
|
@property
|
||||||
def aur_url(self) -> str:
|
def aur_url(self) -> str:
|
||||||
@@ -53,6 +56,11 @@ class Package:
|
|||||||
def is_orphan(self) -> bool:
|
def is_orphan(self) -> bool:
|
||||||
return not self.maintainer
|
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]:
|
def parse_packages(raw: Any) -> tuple[list[Package], int]:
|
||||||
"""Convert the raw JSON payload into :class:`Package` records.
|
"""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")),
|
replaces=_strings(entry.get("Replaces")),
|
||||||
groups=_strings(entry.get("Groups")),
|
groups=_strings(entry.get("Groups")),
|
||||||
licenses=_strings(entry.get("License")),
|
licenses=_strings(entry.get("License")),
|
||||||
|
repository="aur",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return packages, skipped
|
return packages, skipped
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -130,20 +130,40 @@ class InstallerConfirmScreen(ModalScreen["InstallerConfirmed | None"]):
|
|||||||
classes="title",
|
classes="title",
|
||||||
markup=True,
|
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")
|
yield Static(f"Maintainer: {self.package.maintainer}", classes="keys")
|
||||||
else:
|
else:
|
||||||
yield Static(Text("This package is orphaned.", style="yellow"), classes="keys")
|
yield Static(Text("This package is orphaned.", style="yellow"), classes="keys")
|
||||||
yield Static(
|
if self.package.is_repo_package:
|
||||||
"The PKGBUILD will be shown for review before anything is built. "
|
yield Static(
|
||||||
"Never build packages you do not understand.",
|
"pacman shows the package details and asks for confirmation "
|
||||||
classes="warning",
|
"before anything is installed.",
|
||||||
)
|
classes="warning",
|
||||||
yield Static(
|
)
|
||||||
"[bold]y[/bold] install · [bold]u[/bold] update clone & install · [bold]n[/bold] cancel",
|
else:
|
||||||
classes="keys",
|
yield Static(
|
||||||
markup=True,
|
"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:
|
def action_confirm(self) -> None:
|
||||||
self.dismiss(InstallerConfirmed(self.package, refresh=False))
|
self.dismiss(InstallerConfirmed(self.package, refresh=False))
|
||||||
@@ -356,7 +376,9 @@ class SJaurApp(App[None]):
|
|||||||
line.append(package.name, style="bold")
|
line.append(package.name, style="bold")
|
||||||
if package.version:
|
if package.version:
|
||||||
line.append(f" {package.version}", style="dim")
|
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")
|
line.append(f" +{package.votes}", style="yellow")
|
||||||
if package.maintainer:
|
if package.maintainer:
|
||||||
line.append(f" @{package.maintainer}", style="dim")
|
line.append(f" @{package.maintainer}", style="dim")
|
||||||
@@ -442,8 +464,8 @@ class SJaurApp(App[None]):
|
|||||||
self._install_package(result.package, refresh=result.refresh)
|
self._install_package(result.package, refresh=result.refresh)
|
||||||
|
|
||||||
def _install_package(self, package: Package, *, refresh: bool) -> None:
|
def _install_package(self, package: Package, *, refresh: bool) -> None:
|
||||||
"""Run the full install flow with the terminal handed over to makepkg."""
|
"""Run the full install flow with the terminal handed over to pacman/makepkg."""
|
||||||
problems = check_environment()
|
problems = check_environment(include_build_tools=not package.is_repo_package)
|
||||||
if problems:
|
if problems:
|
||||||
self._set_status(Text(f"Cannot install: {'; '.join(problems)}", style="red"))
|
self._set_status(Text(f"Cannot install: {'; '.join(problems)}", style="red"))
|
||||||
return
|
return
|
||||||
@@ -459,11 +481,13 @@ class SJaurApp(App[None]):
|
|||||||
self._search_input.focus()
|
self._search_input.focus()
|
||||||
|
|
||||||
def _install_flow(self, package: Package, refresh: bool) -> None:
|
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
|
from ..installer import builds_dir
|
||||||
|
|
||||||
root = 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} ===")
|
print(f"\n=== Installing {package.name} {package.version} ===")
|
||||||
try:
|
try:
|
||||||
with self.suspend():
|
with self.suspend():
|
||||||
@@ -474,6 +498,12 @@ class SJaurApp(App[None]):
|
|||||||
print(f"--> Updating the existing clone of {package.name}…")
|
print(f"--> Updating the existing clone of {package.name}…")
|
||||||
elif step.kind == "reuse":
|
elif step.kind == "reuse":
|
||||||
print(f"--> Reusing the existing clone of {package.name}.")
|
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":
|
elif step.kind == "makepkg":
|
||||||
print("--> Review the PKGBUILD before building.")
|
print("--> Review the PKGBUILD before building.")
|
||||||
if not self._review_pkgbuild(root / package.name):
|
if not self._review_pkgbuild(root / package.name):
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ def render_package_details(package: Package) -> Table:
|
|||||||
table.add_column(ratio=1)
|
table.add_column(ratio=1)
|
||||||
|
|
||||||
table.add_row("Name", Text(package.name, style="bold"))
|
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("Package Base", package.package_base)
|
||||||
table.add_row("Version", package.version or "unknown")
|
table.add_row("Version", package.version or "unknown")
|
||||||
if package.description:
|
if package.description:
|
||||||
@@ -28,26 +30,32 @@ def render_package_details(package: Package) -> Table:
|
|||||||
table.add_row("Maintainer", package.maintainer)
|
table.add_row("Maintainer", package.maintainer)
|
||||||
else:
|
else:
|
||||||
table.add_row("Maintainer", Text("orphaned", style="yellow"))
|
table.add_row("Maintainer", Text("orphaned", style="yellow"))
|
||||||
table.add_row("Votes", str(package.votes))
|
if not package.is_repo_package:
|
||||||
table.add_row("Popularity", f"{package.popularity:.2f}")
|
table.add_row("Votes", str(package.votes))
|
||||||
|
table.add_row("Popularity", f"{package.popularity:.2f}")
|
||||||
table.add_row(
|
table.add_row(
|
||||||
"Last Modified",
|
"Last Modified",
|
||||||
format_timestamp(package.last_modified) if package.last_modified else "unknown",
|
format_timestamp(package.last_modified) if package.last_modified else "unknown",
|
||||||
)
|
)
|
||||||
table.add_row(
|
if not package.is_repo_package:
|
||||||
"First Submitted",
|
|
||||||
format_timestamp(package.first_submitted) if package.first_submitted else "unknown",
|
|
||||||
)
|
|
||||||
if package.out_of_date:
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
"Out of Date",
|
"First Submitted",
|
||||||
Text(f"yes — since {format_timestamp(package.out_of_date)}", style="bold red"),
|
format_timestamp(package.first_submitted) if package.first_submitted else "unknown",
|
||||||
)
|
)
|
||||||
else:
|
if not package.is_repo_package:
|
||||||
table.add_row("Out of Date", Text("no", style="green"))
|
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:
|
if package.url:
|
||||||
table.add_row("URL", 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:
|
if package.licenses:
|
||||||
table.add_row("Licenses", ", ".join(package.licenses))
|
table.add_row("Licenses", ", ".join(package.licenses))
|
||||||
if package.groups:
|
if package.groups:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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") == []
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
Reference in New Issue
Block a user