mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 20:18:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc668600ba | ||
|
|
d4dc676878 | ||
|
|
cc33ce97ae | ||
|
|
bb6d65428a | ||
|
|
a94b0e2db8 | ||
|
|
8c54220dbc | ||
|
|
e8d292be13 | ||
|
|
6c054681ee | ||
|
|
7bbd8a2cd8 | ||
|
|
55ad44b43c | ||
|
|
a4ab83493f | ||
|
|
71a7e867ad |
@@ -160,6 +160,8 @@ Decman has built-in functionality for managing files and directories. Additional
|
|||||||
- [aur](/docs/aur.md)
|
- [aur](/docs/aur.md)
|
||||||
- [flatpak](/docs/flatpak.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.
|
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.
|
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":
|
if socket.gethostname() == "laptop":
|
||||||
# add brightness controls to your laptop
|
# add brightness controls to your laptop
|
||||||
decman.pacman.packages += ["brightnessctl"]
|
decman.pacman.packages |= {"brightnessctl"}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|||||||
+8
-4
@@ -127,7 +127,7 @@ The architecture of the computer's CPU. Currently, this is only used by the AUR
|
|||||||
decman.config.arch = "x86_64"
|
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.
|
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
|
import decman
|
||||||
|
|
||||||
# To declare a directory, add it's target path and create a Directory object
|
# 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",
|
source_directory="./dotfiles/nvim",
|
||||||
bin_files=False,
|
bin_files=False,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -208,7 +208,7 @@ Ownership, permissions, and parent directories are enforced on creation. Missing
|
|||||||
|
|
||||||
### Symlink
|
### 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
|
```py
|
||||||
import decman
|
import decman
|
||||||
@@ -216,6 +216,9 @@ import decman
|
|||||||
# Replaces sudo with doas
|
# Replaces sudo with doas
|
||||||
# /usr/bin/sudo -> /usr/bin/doas
|
# /usr/bin/sudo -> /usr/bin/doas
|
||||||
decman.symlinks["/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
|
## Modules
|
||||||
@@ -351,9 +354,10 @@ def file_variables(self) -> dict[str, str]:
|
|||||||
Defines symlinks fro the module.
|
Defines symlinks fro the module.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
def symlinks(self) -> dict[str, str]:
|
def symlinks(self) -> dict[str, str | Symlink]:
|
||||||
return {
|
return {
|
||||||
"/etc/resolv.conf": "/run/systemd/resolve/resolv.conf",
|
"/etc/resolv.conf": "/run/systemd/resolve/resolv.conf",
|
||||||
|
"/home/me/.config/app/file.conf": Symlink("/home/me/.file.conf", owner="me"),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decman-flatpak"
|
name = "decman-flatpak"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = ["decman==1.1.0"]
|
dependencies = ["decman==1.2.2"]
|
||||||
|
|
||||||
[project.entry-points."decman.plugins"]
|
[project.entry-points."decman.plugins"]
|
||||||
flatpak = "decman.plugins.flatpak:Flatpak"
|
flatpak = "decman.plugins.flatpak:Flatpak"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decman-pacman"
|
name = "decman-pacman"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decman==1.1.0",
|
"decman==1.2.2",
|
||||||
"pyalpm",
|
"pyalpm",
|
||||||
"requests",
|
"requests",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -454,6 +454,12 @@ class ForeignPackageManager:
|
|||||||
raise ForeignPackageManagerError("Failed to compare versions using vercmp.") from error
|
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:
|
class PackageBuilder:
|
||||||
"""
|
"""
|
||||||
Used for building packages in a chroot.
|
Used for building packages in a chroot.
|
||||||
@@ -642,7 +648,7 @@ class PackageBuilder:
|
|||||||
if p not in self._pkgs_in_chroot:
|
if p not in self._pkgs_in_chroot:
|
||||||
cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)
|
cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)
|
||||||
_, cmd_output = command.check_run_result(cmd, command.run(cmd))
|
_, 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)
|
to_remove.add(real_pkgname)
|
||||||
|
|
||||||
if to_remove:
|
if to_remove:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decman-systemd"
|
name = "decman-systemd"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = ["decman==1.1.0"]
|
dependencies = ["decman==1.2.2"]
|
||||||
|
|
||||||
[project.entry-points."decman.plugins"]
|
[project.entry-points."decman.plugins"]
|
||||||
systemd = "decman.plugins.systemd:Systemd"
|
systemd = "decman.plugins.systemd:Systemd"
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class Systemd(plugins.Plugin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
cmd = self.commands.user_daemon_reload(user)
|
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):
|
def reload_daemon(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decman"
|
name = "decman"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
description = "Declarative package & configuration manager for Arch Linux."
|
description = "Declarative package & configuration manager for Arch Linux."
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import typing
|
|||||||
# Re-exports
|
# Re-exports
|
||||||
from decman.core.command import prg
|
from decman.core.command import prg
|
||||||
from decman.core.error import SourceError
|
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.module import Module
|
||||||
from decman.core.store import Store
|
from decman.core.store import Store
|
||||||
from decman.plugins import Plugin, available_plugins
|
from decman.plugins import Plugin, available_plugins
|
||||||
@@ -51,6 +51,7 @@ __all__ = [
|
|||||||
"SourceError",
|
"SourceError",
|
||||||
"File",
|
"File",
|
||||||
"Directory",
|
"Directory",
|
||||||
|
"Symlink",
|
||||||
"Module",
|
"Module",
|
||||||
"Store",
|
"Store",
|
||||||
"Plugin",
|
"Plugin",
|
||||||
@@ -63,7 +64,7 @@ __all__ = [
|
|||||||
# -----------------------------------------
|
# -----------------------------------------
|
||||||
files: dict[str, File] = {}
|
files: dict[str, File] = {}
|
||||||
directories: dict[str, Directory] = {}
|
directories: dict[str, Directory] = {}
|
||||||
symlinks: dict[str, str] = {}
|
symlinks: dict[str, str | Symlink] = {}
|
||||||
modules: list[Module] = []
|
modules: list[Module] = []
|
||||||
execution_order: list[str] = [
|
execution_order: list[str] = [
|
||||||
"files",
|
"files",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def update_files(
|
|||||||
modules: list[module.Module],
|
modules: list[module.Module],
|
||||||
files: dict[str, fs.File],
|
files: dict[str, fs.File],
|
||||||
directories: dict[str, fs.Directory],
|
directories: dict[str, fs.Directory],
|
||||||
symlinks: dict[str, str],
|
symlinks: dict[str, str | fs.Symlink],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -204,39 +204,18 @@ def _install_directories(
|
|||||||
return checked_files, changed_files
|
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(
|
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]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
checked_files = []
|
checked_files = []
|
||||||
changed_files = []
|
changed_files = []
|
||||||
|
|
||||||
for link_name, target in symlinks.items():
|
for link_name, target in symlinks.items():
|
||||||
output.print_debug(f"Checking symlink {link_name}.")
|
output.print_debug(f"Checking symlink {link_name}.")
|
||||||
try:
|
checked_files.append(link_name)
|
||||||
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)
|
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
|
return checked_files, changed_files
|
||||||
|
|||||||
+106
-15
@@ -8,6 +8,20 @@ import decman.core.error as errors
|
|||||||
import decman.core.output as output
|
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:
|
class File:
|
||||||
"""
|
"""
|
||||||
Declarative file specification describing how a file should be materialized at a target path.
|
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)
|
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:
|
if not dry_run:
|
||||||
create_missing_dirs(target_directory, self.uid, self.gid)
|
create_missing_dirs(target_directory, self.uid, self.gid)
|
||||||
|
|
||||||
@@ -219,6 +218,98 @@ class File:
|
|||||||
return True
|
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:
|
class Directory:
|
||||||
"""
|
"""
|
||||||
Declarative specification for copying the contents of a source directory into a target
|
Declarative specification for copying the contents of a source directory into a target
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class Module:
|
|||||||
"""
|
"""
|
||||||
return {}
|
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
|
Override this method to return symlinks that should be created as a part of this
|
||||||
module.
|
module.
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decman"
|
name = "decman"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -107,7 +107,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decman-flatpak"
|
name = "decman-flatpak"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
source = { editable = "plugins/decman-flatpak" }
|
source = { editable = "plugins/decman-flatpak" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "decman" },
|
{ name = "decman" },
|
||||||
@@ -118,7 +118,7 @@ requires-dist = [{ name = "decman", editable = "." }]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decman-pacman"
|
name = "decman-pacman"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
source = { editable = "plugins/decman-pacman" }
|
source = { editable = "plugins/decman-pacman" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "decman" },
|
{ name = "decman" },
|
||||||
@@ -147,7 +147,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decman-systemd"
|
name = "decman-systemd"
|
||||||
version = "1.1.0"
|
version = "1.2.2"
|
||||||
source = { editable = "plugins/decman-systemd" }
|
source = { editable = "plugins/decman-systemd" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "decman" },
|
{ name = "decman" },
|
||||||
|
|||||||
Reference in New Issue
Block a user