12 Commits
Author SHA1 Message Date
Kivi Kaitaniemi fc668600ba Release v1.2.2 2026-07-04 11:16:59 +03:00
Kivi Kaitaniemi d4dc676878 Merge pull request #73 from eeviriyi/fix-aur-nspawn-warning
Fix AUR chroot cleanup with nspawn warnings
2026-07-04 11:08:49 +03:00
eeviriyi cc33ce97ae Fix AUR chroot cleanup with nspawn warnings 2026-06-30 11:46:11 +08:00
Kivi Kaitaniemi bb6d65428a Merge pull request #69 from LiamS-H/patch-1
Fix File instantiated as Directory in README
2026-05-03 13:37:28 +03:00
Liam Stelly-Hawkes a94b0e2db8 Fix File instantiated as Directory in README 2026-05-02 05:33:31 +00:00
Kivi Kaitaniemi 8c54220dbc Release v1.2.1 2026-02-18 21:48:12 +02:00
Kivi Kaitaniemi e8d292be13 Set symlink ownership (fixes #60) 2026-02-18 20:54:29 +02:00
Kivi Kaitaniemi 6c054681ee Treat systemd user daemon reloading failures as warnings 2026-02-17 22:16:49 +02:00
Kivi Kaitaniemi 7bbd8a2cd8 Release v1.2.0 2026-02-16 21:53:00 +02:00
Kivi Kaitaniemi 55ad44b43c Merge pull request #57 from kiviktnm/feat/symlink-parent-folders
Add option to configure symlink initial parent folder permissions
2026-02-16 21:50:24 +02:00
Kivi Kaitaniemi a4ab83493f Add option to configure symlink initial parent folder permissions 2026-02-16 21:49:16 +02:00
Kivi Kaitaniemi 71a7e867ad Enhance README with user management and syntax update
Updated README to include user, group, and PGP key management information and modified package management syntax.
2026-02-08 12:06:58 +02:00
13 changed files with 145 additions and 62 deletions
+3 -1
View File
@@ -160,6 +160,8 @@ Decman has built-in functionality for managing files and directories. Additional
- [aur](/docs/aur.md)
- [flatpak](/docs/flatpak.md)
Additionally management of [users, groups and PGP keys](/docs/extras.md) is provided by built-in modules.
Plugins can be disabled if desired and flatpaks are disabled by default.
Please read the documentation to understand the functionality of those plugins in detail. Here are quick examples to show what the default plugins are capable of.
@@ -408,7 +410,7 @@ import decman
if socket.gethostname() == "laptop":
# add brightness controls to your laptop
decman.pacman.packages += ["brightnessctl"]
decman.pacman.packages |= {"brightnessctl"}
```
## Alternatives
+8 -4
View File
@@ -127,7 +127,7 @@ The architecture of the computer's CPU. Currently, this is only used by the AUR
decman.config.arch = "x86_64"
```
## Files and directories
## Files, directories and symlink
Decman functions as a dotfile manager. It will install the defined files, directories and symlinks to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman.
@@ -183,7 +183,7 @@ from decman import Directory
import decman
# To declare a directory, add it's target path and create a Directory object
decman.directories["/home/me/.config/nvim"] = File(
decman.directories["/home/me/.config/nvim"] = Directory(
source_directory="./dotfiles/nvim",
bin_files=False,
encoding="utf-8",
@@ -208,7 +208,7 @@ Ownership, permissions, and parent directories are enforced on creation. Missing
### Symlink
Declare a link to a target. Missing directories are created.
Declare a link to a target. Missing directories are created. If you need to configure parent folder permissions, use `Symlink` objects.
```py
import decman
@@ -216,6 +216,9 @@ import decman
# Replaces sudo with doas
# /usr/bin/sudo -> /usr/bin/doas
decman.symlinks["/usr/bin/sudo"] = "/usr/bin/doas"
# I don't know why would you ever do this but as an example
decman.symlinks["/home/me/.bin/mydoas"] = decman.Symlink("/usr/bin/doas", owner="me", group="users")
```
## Modules
@@ -351,9 +354,10 @@ def file_variables(self) -> dict[str, str]:
Defines symlinks fro the module.
```py
def symlinks(self) -> dict[str, str]:
def symlinks(self) -> dict[str, str | Symlink]:
return {
"/etc/resolv.conf": "/run/systemd/resolve/resolv.conf",
"/home/me/.config/app/file.conf": Symlink("/home/me/.file.conf", owner="me"),
}
```
+2 -2
View File
@@ -1,8 +1,8 @@
[project]
name = "decman-flatpak"
version = "1.1.0"
version = "1.2.2"
requires-python = ">=3.13"
dependencies = ["decman==1.1.0"]
dependencies = ["decman==1.2.2"]
[project.entry-points."decman.plugins"]
flatpak = "decman.plugins.flatpak:Flatpak"
+2 -2
View File
@@ -1,9 +1,9 @@
[project]
name = "decman-pacman"
version = "1.1.0"
version = "1.2.2"
requires-python = ">=3.13"
dependencies = [
"decman==1.1.0",
"decman==1.2.2",
"pyalpm",
"requests",
]
@@ -454,6 +454,12 @@ class ForeignPackageManager:
raise ForeignPackageManagerError("Failed to compare versions using vercmp.") from error
def _last_non_empty_line(text: str) -> str:
return next(
(line.strip() for line in reversed(text.splitlines()) if line.strip()), ""
)
class PackageBuilder:
"""
Used for building packages in a chroot.
@@ -642,7 +648,7 @@ class PackageBuilder:
if p not in self._pkgs_in_chroot:
cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)
_, cmd_output = command.check_run_result(cmd, command.run(cmd))
real_pkgname = cmd_output.strip()
real_pkgname = _last_non_empty_line(cmd_output)
to_remove.add(real_pkgname)
if to_remove:
+2 -2
View File
@@ -1,8 +1,8 @@
[project]
name = "decman-systemd"
version = "1.1.0"
version = "1.2.2"
requires-python = ">=3.13"
dependencies = ["decman==1.1.0"]
dependencies = ["decman==1.2.2"]
[project.entry-points."decman.plugins"]
systemd = "decman.plugins.systemd:Systemd"
@@ -248,7 +248,7 @@ class Systemd(plugins.Plugin):
"""
cmd = self.commands.user_daemon_reload(user)
command.prg(cmd, pty=config.debug_output)
command.prg(cmd, pty=config.debug_output, check=False)
def reload_daemon(self):
"""
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "decman"
version = "1.1.0"
version = "1.2.2"
description = "Declarative package & configuration manager for Arch Linux."
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
+3 -2
View File
@@ -3,7 +3,7 @@ import typing
# Re-exports
from decman.core.command import prg
from decman.core.error import SourceError
from decman.core.fs import Directory, File
from decman.core.fs import Directory, File, Symlink
from decman.core.module import Module
from decman.core.store import Store
from decman.plugins import Plugin, available_plugins
@@ -51,6 +51,7 @@ __all__ = [
"SourceError",
"File",
"Directory",
"Symlink",
"Module",
"Store",
"Plugin",
@@ -63,7 +64,7 @@ __all__ = [
# -----------------------------------------
files: dict[str, File] = {}
directories: dict[str, Directory] = {}
symlinks: dict[str, str] = {}
symlinks: dict[str, str | Symlink] = {}
modules: list[Module] = []
execution_order: list[str] = [
"files",
+4 -25
View File
@@ -13,7 +13,7 @@ def update_files(
modules: list[module.Module],
files: dict[str, fs.File],
directories: dict[str, fs.Directory],
symlinks: dict[str, str],
symlinks: dict[str, str | fs.Symlink],
dry_run: bool = False,
) -> bool:
"""
@@ -204,39 +204,18 @@ def _install_directories(
return checked_files, changed_files
def _is_symlink_to(path: str, target: str) -> bool:
if not os.path.islink(path):
return False
return os.readlink(path) == target
def _install_symlinks(
symlinks: dict[str, str], dry_run: bool = False
symlinks: dict[str, str | fs.Symlink], dry_run: bool = False
) -> tuple[list[str], list[str]]:
checked_files = []
changed_files = []
for link_name, target in symlinks.items():
output.print_debug(f"Checking symlink {link_name}.")
try:
checked_files.append(link_name)
if _is_symlink_to(link_name, target):
continue
target_link: fs.Symlink = target if type(target) is fs.Symlink else fs.Symlink(target) # type: ignore
if target_link.link_to(link_name, dry_run):
changed_files.append(link_name)
if dry_run:
continue
if os.path.lexists(link_name):
os.unlink(link_name)
os.makedirs(os.path.dirname(link_name), exist_ok=True)
os.symlink(target, link_name)
except OSError as error:
raise errors.FSSymlinkFailedError(
link_name, target, error.strerror or str(error)
) from error
return checked_files, changed_files
+106 -15
View File
@@ -8,6 +8,20 @@ import decman.core.error as errors
import decman.core.output as output
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
output.print_debug(f"Creating directory '{dirct}'.")
os.mkdir(dirct)
if uid is not None:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid)
class File:
"""
Declarative file specification describing how a file should be materialized at a target path.
@@ -129,21 +143,6 @@ class File:
target_directory = os.path.dirname(target)
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
output.print_debug(
f"While installing file '{target}' creating directory '{dirct}'."
)
os.mkdir(dirct)
if uid is not None:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid)
if not dry_run:
create_missing_dirs(target_directory, self.uid, self.gid)
@@ -219,6 +218,98 @@ class File:
return True
class Symlink:
"""
Declarative specification for linking a source to a destination.
Parameters:
``target``:
Path to an existing file to serve as the target of the symlink.
``owner``:
User name to own created parent directories.
``group``:
Group name to own created parent directories.
Raises:
``UserNotFoundError``
If ``owner`` does not exist on the system.
``GroupNotFoundError``
If ``group`` does not exist on the system.
"""
def __init__(
self,
target: str,
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
):
self.target = target
self.owner = owner
self.group = group
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def link_to(self, link_name: str, dry_run: bool = False) -> bool:
"""
Creates a symlink ``link_name`` -> ``target``.
Parameters:
``link_name``:
Path to the target file on disk.
Returns:
True if a new link was/would be created or modified.
False if the existing link already contained the desired target.
Raises:
FSSymlinkFailedError
If creating the symlink failed due to directory creation, file I/O, permission
changes, or ownership changes fail (e.g. permission denied, missing parent path
components, I/O errors).
"""
def _is_symlink_to(path: str, target: str) -> bool:
if not os.path.islink(path):
return False
return os.readlink(path) == target
output.print_debug(f"Checking symlink {link_name}.")
try:
if _is_symlink_to(link_name, self.target):
return False
if dry_run:
return True
target_directory = os.path.dirname(link_name)
create_missing_dirs(target_directory, self.uid, self.gid)
if os.path.lexists(link_name):
os.unlink(link_name)
os.symlink(self.target, link_name)
if self.uid is not None:
assert self.gid is not None, "If uid is set, then gid is set."
os.chown(link_name, self.uid, self.gid, follow_symlinks=False)
return True
except OSError as error:
raise errors.FSSymlinkFailedError(
link_name, self.target, error.strerror or str(error)
) from error
class Directory:
"""
Declarative specification for copying the contents of a source directory into a target
+1 -1
View File
@@ -105,7 +105,7 @@ class Module:
"""
return {}
def symlinks(self) -> dict[str, str]:
def symlinks(self) -> dict[str, str | fs.Symlink]:
"""
Override this method to return symlinks that should be created as a part of this
module.
Generated
+4 -4
View File
@@ -71,7 +71,7 @@ wheels = [
[[package]]
name = "decman"
version = "1.1.0"
version = "1.2.2"
source = { editable = "." }
[package.optional-dependencies]
@@ -107,7 +107,7 @@ dev = [
[[package]]
name = "decman-flatpak"
version = "1.1.0"
version = "1.2.2"
source = { editable = "plugins/decman-flatpak" }
dependencies = [
{ name = "decman" },
@@ -118,7 +118,7 @@ requires-dist = [{ name = "decman", editable = "." }]
[[package]]
name = "decman-pacman"
version = "1.1.0"
version = "1.2.2"
source = { editable = "plugins/decman-pacman" }
dependencies = [
{ name = "decman" },
@@ -147,7 +147,7 @@ dev = [
[[package]]
name = "decman-systemd"
version = "1.1.0"
version = "1.2.2"
source = { editable = "plugins/decman-systemd" }
dependencies = [
{ name = "decman" },