Compare commits

...

2 Commits

Author SHA1 Message Date
bb31919858 clean empty directories 2026-02-13 17:01:18 +02:00
d0ebd6559c monor fixes and typos 2026-02-13 15:45:40 +02:00
12 changed files with 88 additions and 56 deletions

View File

@@ -71,7 +71,7 @@ class TreeMigrate(Handler):
@staticmethod @staticmethod
def fix_symlinks(paths: RepositoryPaths) -> None: def fix_symlinks(paths: RepositoryPaths) -> None:
""" """
fix packages archives symlinks fix package archive symlinks
Args: Args:
paths(RepositoryPaths): new repository paths paths(RepositoryPaths): new repository paths

View File

@@ -88,16 +88,14 @@ class Repo(LazyLogging):
check_output("repo-add", *self.sign_args, str(self.repo_path), check_output("repo-add", *self.sign_args, str(self.repo_path),
cwd=self.root, logger=self.logger, user=self.uid) cwd=self.root, logger=self.logger, user=self.uid)
def remove(self, package_name: str | None, filename: Path) -> None: def remove(self, package_name: str, filename: Path) -> None:
""" """
remove package from repository remove package from repository
Args: Args:
package_name(str | None): package name to remove. If none set, it will be guessed from filename package_name(str): package name to remove
filename(Path): package filename to remove filename(Path): package filename to remove
""" """
package_name = package_name or filename.name.rsplit("-", maxsplit=3)[0]
# remove package and signature (if any) from filesystem # remove package and signature (if any) from filesystem
for full_path in self.root.glob(f"**/{filename.name}*"): for full_path in self.root.glob(f"**/{filename.name}*"):
full_path.unlink() full_path.unlink()

View File

@@ -19,6 +19,7 @@
# #
import datetime import datetime
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from ahriman.core.alpm.repo import Repo from ahriman.core.alpm.repo import Repo
@@ -75,6 +76,7 @@ class ArchiveTree(LazyLogging):
def _repo(self, root: Path) -> Repo: def _repo(self, root: Path) -> Repo:
""" """
constructs :class:`ahriman.core.alpm.repo.Repo` object for given path constructs :class:`ahriman.core.alpm.repo.Repo` object for given path
Args: Args:
root(Path): root of the repository root(Path): root of the repository
@@ -83,6 +85,21 @@ class ArchiveTree(LazyLogging):
""" """
return Repo(self.repository_id.name, self.paths, self.sign_args, root) return Repo(self.repository_id.name, self.paths, self.sign_args, root)
def directories_fix(self, paths: set[Path]) -> None:
"""
remove empty repository directories recursively
Args:
paths(set[Path]): repositories to check
"""
root = self.paths.archive / "repos"
for repository in paths:
parents = [repository] + list(repository.parents[:-1])
for parent in parents:
path = root / parent
if not list(path.iterdir()):
path.rmdir()
def repository_for(self, date: datetime.date | None = None) -> Path: def repository_for(self, date: datetime.date | None = None) -> Path:
""" """
get full path to repository at the specified date get full path to repository at the specified date
@@ -126,9 +143,12 @@ class ArchiveTree(LazyLogging):
if self._package_symlinks_create(single, root, archive): if self._package_symlinks_create(single, root, archive):
repo.add(root / single.filename) repo.add(root / single.filename)
def symlinks_fix(self) -> None: def symlinks_fix(self) -> Iterator[Path]:
""" """
remove broken symlinks across repositories for all dates remove broken symlinks across repositories for all dates
Yields:
Path: path of the sub-repository with removed symlinks
""" """
for path in walk(self.paths.archive / "repos"): for path in walk(self.paths.archive / "repos"):
root = path.parent root = path.parent
@@ -141,7 +161,11 @@ class ArchiveTree(LazyLogging):
if path.exists(): if path.exists():
continue # filter out not broken symlinks continue # filter out not broken symlinks
self._repo(root).remove(None, path) # here we don't have access to original archive, so we have to guess name based on archive name
# normally it should be fine to do so
package_name = path.name.rsplit("-", maxsplit=3)[0]
self._repo(root).remove(package_name, path)
yield path.parent.relative_to(self.paths.archive / "repos")
def tree_create(self) -> None: def tree_create(self) -> None:
""" """

View File

@@ -66,4 +66,5 @@ class ArchiveTrigger(Trigger):
""" """
trigger action which will be called before the stop of the application trigger action which will be called before the stop of the application
""" """
self.tree.symlinks_fix() repositories = set(self.tree.symlinks_fix())
self.tree.directories_fix(repositories)

View File

@@ -26,7 +26,7 @@ from ahriman.application.handlers.handler import Handler
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.utils import package_like, symlink_relative from ahriman.core.utils import atomic_move, package_like, symlink_relative
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization from ahriman.models.pacman_synchronization import PacmanSynchronization
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@@ -81,6 +81,6 @@ def move_packages(repository_paths: RepositoryPaths, pacman: Pacman) -> None:
for source in artifacts: for source in artifacts:
# move package to the archive directory # move package to the archive directory
target = repository_paths.archive_for(package.base) / source.name target = repository_paths.archive_for(package.base) / source.name
source.rename(target) atomic_move(source, target)
# create symlink to the archive # create symlink to the archive
symlink_relative(source, target) symlink_relative(source, target)

View File

@@ -143,7 +143,7 @@ class Executor(PackageInfo, Cleaner):
remove package base from repository remove package base from repository
Args: Args:
package_base(str): package base name: package_base(str): package base name
""" """
try: try:
with self.in_event(package_base, EventType.PackageRemoved): with self.in_event(package_base, EventType.PackageRemoved):

View File

@@ -292,6 +292,7 @@ class RepositoryPaths(LazyLogging):
""" """
for directory in ( for directory in (
self.cache_for(package_base), self.cache_for(package_base),
self.archive_for(package_base),
): ):
shutil.rmtree(directory, ignore_errors=True) shutil.rmtree(directory, ignore_errors=True)

View File

@@ -71,20 +71,6 @@ def test_repo_remove(repo: Repo, package_ahriman: Package, mocker: MockerFixture
assert package_ahriman.base in check_output_mock.call_args[0] assert package_ahriman.base in check_output_mock.call_args[0]
def test_repo_remove_guess_package(repo: Repo, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must call repo-remove on package removal if no package name set
"""
filepath = package_ahriman.packages[package_ahriman.base].filepath
mocker.patch("pathlib.Path.glob", return_value=[])
check_output_mock = mocker.patch("ahriman.core.alpm.repo.check_output")
repo.remove(None, filepath)
check_output_mock.assert_called_once() # it will be checked later
assert check_output_mock.call_args[0][0] == "repo-remove"
assert package_ahriman.base in check_output_mock.call_args[0]
def test_repo_remove_fail_no_file(repo: Repo, mocker: MockerFixture) -> None: def test_repo_remove_fail_no_file(repo: Repo, mocker: MockerFixture) -> None:
""" """
must fail removal on missing file must fail removal on missing file

View File

@@ -29,6 +29,25 @@ def test_repository_for(archive_tree: ArchiveTree) -> None:
assert set(map("{:02d}".format, utcnow().timetuple()[:3])).issubset(path.parts) assert set(map("{:02d}".format, utcnow().timetuple()[:3])).issubset(path.parts)
def test_directories_fix(archive_tree: ArchiveTree, mocker: MockerFixture) -> None:
"""
must remove empty directories recursively
"""
root = archive_tree.paths.archive / "repos"
(root / "a" / "b").mkdir(parents=True, exist_ok=True)
(root / "a" / "b" / "file").touch()
(root / "a" / "b" / "c" / "d").mkdir(parents=True, exist_ok=True)
_original_rmdir = Path.rmdir
rmdir_mock = mocker.patch("pathlib.Path.rmdir", autospec=True, side_effect=_original_rmdir)
archive_tree.directories_fix({Path("a") / "b" / "c" / "d"})
rmdir_mock.assert_has_calls([
MockCall(root / "a" / "b" / "c" / "d"),
MockCall(root / "a" / "b" / "c"),
])
def test_symlinks_create(archive_tree: ArchiveTree, package_ahriman: Package, package_python_schedule: Package, def test_symlinks_create(archive_tree: ArchiveTree, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """
@@ -80,7 +99,7 @@ def test_symlinks_fix(archive_tree: ArchiveTree, mocker: MockerFixture) -> None:
_original_exists = Path.exists _original_exists = Path.exists
def exists_mock(path: Path) -> bool: def exists_mock(path: Path) -> bool:
if path.name == "symlink": if path.name.startswith("symlink"):
return True return True
return _original_exists(path) return _original_exists(path)
@@ -88,13 +107,20 @@ def test_symlinks_fix(archive_tree: ArchiveTree, mocker: MockerFixture) -> None:
mocker.patch("pathlib.Path.exists", autospec=True, side_effect=exists_mock) mocker.patch("pathlib.Path.exists", autospec=True, side_effect=exists_mock)
walk_mock = mocker.patch("ahriman.core.archive.archive_tree.walk", return_value=[ walk_mock = mocker.patch("ahriman.core.archive.archive_tree.walk", return_value=[
archive_tree.repository_for() / filename archive_tree.repository_for() / filename
for filename in ("symlink", "broken_symlink", "file") for filename in (
"symlink-1.0.0-1-x86_64.pkg.tar.zst",
"broken_symlink-1.0.0-1-x86_64.pkg.tar.zst",
"file-1.0.0-1-x86_64.pkg.tar.zst",
)
]) ])
remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
archive_tree.symlinks_fix() assert list(archive_tree.symlinks_fix()) == [
archive_tree.repository_for().relative_to(archive_tree.paths.archive / "repos"),
]
walk_mock.assert_called_once_with(archive_tree.paths.archive / "repos") walk_mock.assert_called_once_with(archive_tree.paths.archive / "repos")
remove_mock.assert_called_once_with(None, archive_tree.repository_for() / "broken_symlink") remove_mock.assert_called_once_with(
"broken_symlink", archive_tree.repository_for() / "broken_symlink-1.0.0-1-x86_64.pkg.tar.zst")
def test_symlinks_fix_foreign_repository(archive_tree: ArchiveTree, mocker: MockerFixture) -> None: def test_symlinks_fix_foreign_repository(archive_tree: ArchiveTree, mocker: MockerFixture) -> None:
@@ -104,7 +130,7 @@ def test_symlinks_fix_foreign_repository(archive_tree: ArchiveTree, mocker: Mock
_original_exists = Path.exists _original_exists = Path.exists
def exists_mock(path: Path) -> bool: def exists_mock(path: Path) -> bool:
if path.name == "symlink": if path.name.startswith("symlink"):
return True return True
return _original_exists(path) return _original_exists(path)
@@ -112,11 +138,15 @@ def test_symlinks_fix_foreign_repository(archive_tree: ArchiveTree, mocker: Mock
mocker.patch("pathlib.Path.exists", autospec=True, side_effect=exists_mock) mocker.patch("pathlib.Path.exists", autospec=True, side_effect=exists_mock)
mocker.patch("ahriman.core.archive.archive_tree.walk", return_value=[ mocker.patch("ahriman.core.archive.archive_tree.walk", return_value=[
archive_tree.repository_for().with_name("i686") / filename archive_tree.repository_for().with_name("i686") / filename
for filename in ("symlink", "broken_symlink", "file") for filename in (
"symlink-1.0.0-1-x86_64.pkg.tar.zst",
"broken_symlink-1.0.0-1-x86_64.pkg.tar.zst",
"file-1.0.0-1-x86_64.pkg.tar.zst",
)
]) ])
remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
archive_tree.symlinks_fix() assert list(archive_tree.symlinks_fix()) == []
remove_mock.assert_not_called() remove_mock.assert_not_called()

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.archive import ArchiveTrigger from ahriman.core.archive import ArchiveTrigger
@@ -27,6 +28,10 @@ def test_on_stop(archive_trigger: ArchiveTrigger, mocker: MockerFixture) -> None
""" """
must fix broken symlinks on stop must fix broken symlinks on stop
""" """
symlinks_mock = mocker.patch("ahriman.core.archive.archive_tree.ArchiveTree.symlinks_fix") local = Path("local")
symlinks_mock = mocker.patch("ahriman.core.archive.archive_tree.ArchiveTree.symlinks_fix", return_value=[local])
directories_mock = mocker.patch("ahriman.core.archive.archive_tree.ArchiveTree.directories_fix")
archive_trigger.on_stop() archive_trigger.on_stop()
symlinks_mock.assert_called_once_with() symlinks_mock.assert_called_once_with()
directories_mock.assert_called_once_with({local})

View File

@@ -54,18 +54,17 @@ def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, packag
mocker.patch("pathlib.Path.is_file", autospec=True, side_effect=is_file) mocker.patch("pathlib.Path.is_file", autospec=True, side_effect=is_file)
mocker.patch("pathlib.Path.exists", return_value=True) mocker.patch("pathlib.Path.exists", return_value=True)
archive_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) archive_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
rename_mock = mocker.patch("pathlib.Path.rename") move_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.atomic_move")
symlink_mock = mocker.patch("pathlib.Path.symlink_to") symlink_mock = mocker.patch("pathlib.Path.symlink_to")
move_packages(repository_paths, pacman) move_packages(repository_paths, pacman)
archive_mock.assert_has_calls([ archive_mock.assert_has_calls([
MockCall(repository_paths.repository / "file.pkg.tar.xz", pacman), MockCall(repository_paths.repository / filename, pacman)
MockCall(repository_paths.repository / "file2.pkg.tar.xz", pacman), for filename in ("file.pkg.tar.xz", "file2.pkg.tar.xz")
]) ])
rename_mock.assert_has_calls([ move_mock.assert_has_calls([
MockCall(repository_paths.archive_for(package_ahriman.base) / "file.pkg.tar.xz"), MockCall(repository_paths.repository / filename, repository_paths.archive_for(package_ahriman.base) / filename)
MockCall(repository_paths.archive_for(package_ahriman.base) / "file.pkg.tar.xz.sig"), for filename in ("file.pkg.tar.xz", "file.pkg.tar.xz.sig", "file2.pkg.tar.xz")
MockCall(repository_paths.archive_for(package_ahriman.base) / "file2.pkg.tar.xz"),
]) ])
symlink_mock.assert_has_calls([ symlink_mock.assert_has_calls([
MockCall( MockCall(
@@ -73,20 +72,7 @@ def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, packag
".." / ".." /
".." / ".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) / repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"file.pkg.tar.xz" filename
), )
MockCall( for filename in ("file.pkg.tar.xz", "file.pkg.tar.xz.sig", "file2.pkg.tar.xz")
Path("..") /
".." /
".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"file.pkg.tar.xz.sig"
),
MockCall(
Path("..") /
".." /
".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"file2.pkg.tar.xz"
),
]) ])

View File

@@ -271,6 +271,7 @@ def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package,
""" """
paths = { paths = {
repository_paths.cache_for(package_ahriman.base), repository_paths.cache_for(package_ahriman.base),
repository_paths.archive_for(package_ahriman.base),
} }
rmtree_mock = mocker.patch("shutil.rmtree") rmtree_mock = mocker.patch("shutil.rmtree")