working version after refactor

This commit is contained in:
2025-12-21 15:59:56 +03:00
parent 27c7677100
commit f5c0480f88
5 changed files with 220 additions and 399 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
__pycache__ __pycache__
test

36
TODO.md
View File

@@ -1,49 +1,49 @@
How this must work. How this must work.
1. On first launch themere reads ~/.config/themer/config and aquires list of apps it should manage 1. On first launch userctx reads ~/.config/userctx/config and aquires list of apps it should manage
2. Themer backs up whole config directories to ~/.config/themer so further manipulations are safe 2. userctx backs up whole config directories to ~/.config/userctx so further manipulations are safe
Default name is themer/original, but user can provide a different name from command line. Default name is userctx/original, but user can provide a different name from command line.
example: example:
themer backup [--name <name>] [--apps TODO] userctx backup [--name <name>] [--apps TODO]
default name is "original" default name is "original"
default apps list is read from config. command line overrides config completely, no merging. default apps list is read from config. command line overrides config completely, no merging.
Backup algorithm: Backup algorithm:
- If directory ~/.config/sway does not exist or is empty will do nothing - If directory ~/.config/sway does not exist or is empty will do nothing
- if directory exists AND not empty then create directory ~/.config/themer/original/sway - if directory exists AND not empty then create directory ~/.config/userctx/original/sway
- copy contents of all files AND direcotries in it to ~/.config/themer/original/sway - copy contents of all files AND direcotries in it to ~/.config/userctx/original/sway
- if any file failed to copy - rollback and remove ~/.config/themer/original/sway - if any file failed to copy - rollback and remove ~/.config/userctx/original/sway
- remove original files (and directories) from ~/.config/sway - remove original files (and directories) from ~/.config/sway
- create symlinks to copied files in ~/.config/sway - create symlinks to copied files in ~/.config/sway
3. Swithing algorithm: 3. Swithing algorithm:
User requests theme change with: User requests theme change with:
themer apply <theme> userctx apply <theme>
Themer tries to apply configs as follows: userctx tries to apply configs as follows:
- looks up a list of apps it should manage; if list is empty - return non-zero - looks up a list of apps it should manage; if list is empty - return non-zero
- looks up requested theme name in ~/.config/themer/<name>; if not found - return non-zero - looks up requested theme name in ~/.config/userctx/<name>; if not found - return non-zero
- looks up config dir ~/.config/themer/<theme>/<app>; if none found - return non-zero - looks up config dir ~/.config/userctx/<theme>/<app>; if none found - return non-zero
- for each <filename> in ~/.config/themer/<theme>/<app> themer does - for each <filename> in ~/.config/userctx/<theme>/<app> userctx does
``` ```
mkdir -p ~/.config/<app> # creates app config directory if it does not exist (else no-op) mkdir -p ~/.config/<app> # creates app config directory if it does not exist (else no-op)
rm -f ~/.config/<app>/<filename> # tries to delete the config if it exists (else no-op) rm -f ~/.config/<app>/<filename> # tries to delete the config if it exists (else no-op)
ln -s ~/.config/themer/<theme>/<app>/<filename> ~/.config/<app>/<filename> # symlinks the file from requested theme user dir ln -s ~/.config/userctx/<theme>/<app>/<filename> ~/.config/<app>/<filename> # symlinks the file from requested theme user dir
``` ```
- after creating symlinks themer tries to let the app know that config changed - after creating symlinks userctx tries to let the app know that config changed
- if app has hot-reload (niri, alacritty, hyprland) - do nothing - if app has hot-reload (niri, alacritty, hyprland) - do nothing
- if app re-reads configs on start (fuzzel, wofi) - do nothing - if app re-reads configs on start (fuzzel, wofi) - do nothing
- if app obeys USR1/URS2 - issue the signal - if app obeys USR1/URS2 - issue the signal
- if app is a systemd service - exec systemctl --user restart <app> - if app is a systemd service - exec systemctl --user restart <app>
This way a single copy of config files are kept in ~/.config/themer. This way a single copy of config files are kept in ~/.config/userctx.
This way user can choose which files to manage themselves and which themer This way user can choose which files to manage themselves and which userctx
should manage. should manage.
4. cli interface 4. cli interface
themer list - returns newline-separated list (suitable for dmenu) userctx list - returns newline-separated list (suitable for dmenu)
themer apply $(themer list | fuzzel -d) - a usable way to select theme with dmenu-like apps userctx apply $(userctx list | fuzzel -d) - a usable way to select theme with dmenu-like apps
TODO: TODO:

View File

@@ -5,85 +5,87 @@
# tasks # tasks
dry_run = true dry_run = true
# "tasks" array must not be empty # "apps" array tells userctx what to do (which apps
# it tells userctx what to do (which apps # should be actually managed). If it is empty or not
# should be actually managed) # set, then userctx will do nothing.
tasks = [ apps = [
"sway", #"sway",
"gtk", #"gtk",
"helix", #"helix",
"foot", #"foot",
"wofi", #"wofi",
"mako", #"mako",
"myapp",
] ]
# target directory where managed configs # target directory where managed configs
# are located # are located
# defaults to $XDG_CONFIG_HOME if specifically set # defaults to $XDG_CONFIG_HOME if specifically set
# or ~/.config if $XGD_CONFIG_HOME is not set # or ~/.config if $XGD_CONFIG_HOME is not set
managed_directory = "~/.config" target_path = "~/.config"
# directory where contexts are stored # directory where contexts are stored
# default is "~/.config/userctx" # default is "~/.config/userctx"
contexts_path = "~/.config/userctx" source_path = "~/code/userctx/test"
# This section of config is intended to document [apps]
# what userctx can do. Assume we are example2.scripts = ["s1", "s2"]
# building a config for app "example" myapp.symlink."*" = "*"
[task.example] myapp.exec = """echo "hello world""""
# the default path to apply your context to is myapp.actions = {}
myapp.script = []
# section that demonstrates all the available
# features of userctx
[apps.example]
# Path to appliaction configs is read from
# general.source_path. The default source path is
# ~/.config/userctx/<context_name>/example for this
# app.
# Individual path for app can be provided via
# "source_path" for particular app. Then userctx will try to lookup
# app settings in /source_path/<context_name>
# Default is general.source_path/<context_name>/<app_name>.
# For this example it will expand to
# ~/.config/userctx/<context_name/example.
# TODO: do we really need this?
source_path = "~/tmp/contexts"
# The default path to apply your context to is
# ~/.config/example (for app name "example") # ~/.config/example (for app name "example")
# "target_path" overrides this. # if not overridden with general.target_path.
# If set then $CONTEXT_DST env will hold this value and # Setting "target_path" overrides this.
# $CONTEXT_DST env will hold the actual destination path.
# symlinks will be created relative to this specified # symlinks will be created relative to this specified
# path. # path.
target_path = "~/.config/example/configs" target_path = "~/.config/example/configs"
# "actions" array defines which steps should be # "symlink" map defines
# executed to apply content from
# context directory to users home
# directory. If actions array is empty the default
# behaviour is to just symlink everything from
# $CONTEXT_SRC to $CONTEXT_DST
actions = [
"symlinks", # do the symlinking as specified in "symlinks array below"
"code", # execute shell script from object "code" below
"commands", # run commands from "commands array below"
"scripts", # run scripts from "scripts" array below
]
# "symlinks" array of mappings (inline tables of TOML) defines
# which files will be symlinked to users home # which files will be symlinked to users home
# directory. If "symlinks" key is present in # directory.
# "actions" above but this "symlinks" array is omitted # Names of files in thes mapping are relative to
# then userctx treats all files in the context directory as # context directory and target directory
# files that must be symlinked to users home. # by default source is ~/.config/userctx/<context_name>/example
symlinks = [ # and target directory is ~/.config/example
# names of files in these mapping are relative to # for app named "example"
# context directory and target directory #
# by default source is ~/.config/userctx/<context_name>/example for # this will link file from context dir to destination dir
# task (app) named "example" # $CONTEXT_SRC/context_file.conf -> $CONTEXT_DST/symlink_to_context_file.conf
# default target directory is ~/.config/example for symlink."context_file.conf" = "symlink_to_context_file.conf"
# task (app) named "example"
#
# this will link file from context dir to destination dir
# $CONTEXT_SRC/context_file.conf -> $CONTEXT_DST/symlink_to_context_file.conf
{"context_file.conf" = "symlink_to_context_file.conf"},
# this will link file to subdirectory inside destination dir # this will link file to subdirectory inside destination dir
# directory must be created beforehand # Note that destination subdir must be created beforehand.
# $CONTEXT_SRC/other_file.conf -> $CONTEXT_DST/subdir/other_file.conf # $CONTEXT_SRC/other_file.conf -> $CONTEXT_DST/subdir/other_file.conf
{"other_file.conf" = "subdir/other_file.conf" }, symlink."other_file.conf" = "subdir/other_file.conf"
# also possible option symlink."subdir/yet_another_file.conf" = "yet_another_file.conf"
{"subdir/yet_another_file.conf" = "yet_another_file.conf" },
]
# "code" object let's you write
# a custom script that will apply contents of # "exec" let's you write a reload command or
# your context. Note that string is a multiline string # even a bigger custom script that will apply contents of
# strating and ending with three double-qoutes. # your context. Note the triple double-qoutes for multiline
# This string will be passed as is to $SHELL -c <string> # strings (see TOML spec).
code = """ # This string will be passed as is to $SHELL for execution.
exec = """
# this is a normal shell script which will # this is a normal shell script which will
# be executed by user's $SHELL or 'bash' binary # be executed by user's $SHELL or 'bash' binary
# if users $SHELL is empty # if users $SHELL is empty
@@ -97,13 +99,6 @@ code = """
echo "$CONTEXT_NAME" echo "$CONTEXT_NAME"
""" """
# Another way in run something when applying context
# is to define "commands" array.
commands = [
['first_command', '--arg1', 'value', '--arg2', 'value2'],
['second_command', '--arg1', 'value', '--arg2', 'value2'],
]
# scripts array will try to find # scripts array will try to find
# named files in $CONTEXT_SRC and # named files in $CONTEXT_SRC and
# execute them with $SHELL <script> # execute them with $SHELL <script>
@@ -111,26 +106,23 @@ commands = [
# and action "scripts" is present in # and action "scripts" is present in
# "actions" array above then all files # "actions" array above then all files
# in $CONTEXT_SRC will be treated as scripts # in $CONTEXT_SRC will be treated as scripts
# (note the "task.gtk section below") # (note the "apps.gtk section below")
scripts = [ scripts = [
"script1.sh", "script1.sh",
"script2.sh", "script2.sh",
] ]
# reload command (if set) will be executed after reload = "pkill -USR1 example"
# all actions
reload = ['echo', 'this is my reload command']
######################### #########################
# settings for some # settings for some
# commonly used apps # commonly used apps
######################### #########################
[task.niri] [apps.niri]
target_path = "~/.config/niri/configs" target_path = "~/.config/niri/configs"
actions = [ actions = [
"symlinks", "symlink",
"code",
] ]
# The snippet below is needed for niri 25.8 which did not # The snippet below is needed for niri 25.8 which did not
@@ -141,50 +133,49 @@ actions = [
# symlink configs found in the context dir to # symlink configs found in the context dir to
# ~/.config/niri/configs, which then get concatenated. # ~/.config/niri/configs, which then get concatenated.
# For niri since 25.11 you can just put your includes # For niri since 25.11 you can just put your includes
# into the context dir and omit [task.niri] altogether. # into the context dir and omit [apps.niri] altogether.
code = """ exec = """
cat ~/.config/niri/configs/* > ~/.config/niri/config.kdl cat ~/.config/niri/configs/* > ~/.config/niri/config.kdl
""" """
[task.helix] [apps.helix]
actions = [ actions = [
"symlinks", "exec",
"code", "symlink",
] ]
# context must contain file named "helix.toml" which describes a theme. # context must contain file named "helix.toml" which describes a theme.
symlinks = [
# $CONTEXT_SRC/helix.toml -> $CONTEXT_DST/themes/current_theme.toml # $CONTEXT_SRC/helix.toml -> $CONTEXT_DST/themes/current_theme.toml
{"helix.toml" = "themes/current_theme.toml"} symlink."helix.toml" = "themes/current_theme.toml"
]
code = """ exec = """
sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml
""" """
reload = ['pkill', '-USR1', 'hx'] reload = "pkill -USR1 hx"
[task.gtk] [apps.gtk]
# To setup the look of GTK you may put a simple shell script # To setup the look of GTK you may put a simple shell script
# into ~/.userctx/<context_name>/gtk/theme.sh with contents similar to: # into ~/.userctx/<context_name>/gtk/theme.sh with contents similar to:
# gsettings set org.gnome.desktop.interface color-scheme 'default' # gsettings set org.gnome.desktop.interface color-scheme 'default'
# gsettings set org.gnome.desktop.interface gtk-theme 'Yaru' # gsettings set org.gnome.desktop.interface gtk-theme 'Yaru'
# gsettings set org.gnome.desktop.interface icon-theme 'Yaru' # gsettings set org.gnome.desktop.interface icon-theme 'Yaru'
actions = ["scripts"] actions = ["script"]
[task.mako] [apps.mako]
reload = ["makoctl", "reload"] reload = "makoctl reload"
[task.sway] [apps.sway]
reload = ["swaymsg", "reload"] reload = "swaymsg reload"
[task.kitty] [apps.kitty]
reload = ['pkill', '-USR1', 'kitty'] reload = 'pkill -USR1 kitty'
[task.waybar] [apps.waybar]
reload = ['pkill', '-USR2', 'waybar'] reload = 'pkill -USR2 waybar'
[task.swaybg] [apps.swaybg]
# This config is for swaybg setup as s systemd service. # This config is for swaybg setup as s systemd service.
# Unit runs "swaybg -m fill -i "%h/.config/swaybg/wallpaper". # Unit runs "swaybg -m fill -i "%h/.config/swaybg/wallpaper".
# ~/.config/userctx/<context>/swaybg/ contains single file # ~/.config/userctx/<context>/swaybg/ contains single file
# called "wallpaper" which gets symlinked to ~/.config/swaybg/wallpaper # called "wallpaper" which gets symlinked to ~/.config/swaybg/wallpaper
target_path = '~/.config/swaybg' target_path = '~/.config/swaybg'
reload = ['systemctl', '--user', 'restart', 'swaybg.service'] reload = 'systemctl --user restart swaybg.service'

View File

@@ -1,189 +0,0 @@
[general]
# if dry run is set to true
# userctx will only validate config
# and tell you what it will do to execute
# tasks
dry_run = true
# "apps" array tells userctx what to do (which apps
# should be actually managed). If it is empty or not
# set, then userctx will do nothing.
apps = [
"sway",
"gtk",
"helix",
"foot",
"wofi",
"mako",
]
# target directory where managed configs
# are located
# defaults to $XDG_CONFIG_HOME if specifically set
# or ~/.config if $XGD_CONFIG_HOME is not set
target_path = "~/.config"
# directory where contexts are stored
# default is "~/.config/userctx"
source_path = "~/.config/userctx"
[apps]
helix.symlink."*" = "themes/theme.toml"
helix.exec = "pkill -USR1 hx"
example2.scripts = ["s1", "s2"]
[apps.example]
symlink."this.file" = "that.file"
symlink."*" = "theme.conf" # TODO: handle any file to particular name
exec = 'echo "this simulates reload command"'
[apps.niri]
exec = "this simulates code for niri restart"
[apps.niri.symlink]
"another.file" = "another.link"
# section that demonstrates all the available
# features of userctx
[apps.example]
# Path to appliaction configs is read from
# general.soucre_path. The default source path is
# ~/.config/userctx/<context_name>/example for this
# app.
# Individual path for app can be provided via
# "source_path" for particular app. Then userctx will try to lookup
# app settings in /source_path/<context_name>
# Default is general.source_path/<context_name>/<app_name>.
# For this example it will expand to
# ~/.config/userctx/<context_name/example.
# TODO: do we really need this?
source_path = "~/tmp/contexts"
# The default path to apply your context to is
# ~/.config/example (for app name "example")
# if not overridden with general.target_path.
# Setting "target_path" overrides this.
# $CONTEXT_DST env will hold the actual destination path.
# symlinks will be created relative to this specified
# path.
target_path = "~/.config/example/configs"
# "symlink" map defines
# which files will be symlinked to users home
# directory.
# Names of files in thes mapping are relative to
# context directory and target directory
# by default source is ~/.config/userctx/<context_name>/example
# and target directory is ~/.config/example
# for app named "example"
#
# this will link file from context dir to destination dir
# $CONTEXT_SRC/context_file.conf -> $CONTEXT_DST/symlink_to_context_file.conf
symlink."context_file.conf" = "symlink_to_context_file.conf"
# this will link file to subdirectory inside destination dir
# Note that destination subdir must be created beforehand.
# $CONTEXT_SRC/other_file.conf -> $CONTEXT_DST/subdir/other_file.conf
symlink."other_file.conf" = "subdir/other_file.conf"
symlink."subdir/yet_another_file.conf" = "yet_another_file.conf"
# "exec" let's you write a reload command or
# even a bigger custom script that will apply contents of
# your context. Note the triple double-qoutes for multiline
# strings (see TOML spec).
# This string will be passed as is to $SHELL for execution.
exec = """
# this is a normal shell script which will
# be executed by user's $SHELL or 'bash' binary
# if users $SHELL is empty
# users envs are available
echo "$HOME"
# additional envs are available
echo "$CONTEXT_SRC"
echo "$CONTEXT_DST"
echo "$CONTEXT_NAME"
"""
# scripts array will try to find
# named files in $CONTEXT_SRC and
# execute them with $SHELL <script>
# IMPORTANT: if array is empty
# and action "scripts" is present in
# "actions" array above then all files
# in $CONTEXT_SRC will be treated as scripts
# (note the "apps.gtk section below")
scripts = [
"script1.sh",
"script2.sh",
]
#########################
settings for some
commonly used apps
#########################
[apps.niri]
target_path = "~/.config/niri/configs"
actions = [
"symlinks",
"code",
]
# The snippet below is needed for niri 25.8 which did not
# support imports/includes in the config. I'm splitting
# config into several parts and them concatenating them
# after applying changes to theme. The other parts are left intact.
# Also note the custom target path above. It instructs userctx to
# symlink configs found in the context dir to
# ~/.config/niri/configs, which then get concatenated.
# For niri since 25.11 you can just put your includes
# into the context dir and omit [apps.niri] altogether.
exec = """
cat ~/.config/niri/configs/* > ~/.config/niri/config.kdl
"""
[apps.helix]
actions = [
"symlinks",
"code",
]
# context must contain file named "helix.toml" which describes a theme.
# $CONTEXT_SRC/helix.toml -> $CONTEXT_DST/themes/current_theme.toml
symlink."helix.toml" = "themes/current_theme.toml"
exec = """
sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml
pkill -USR1 hx
"""
[apps.gtk]
# To setup the look of GTK you may put a simple shell script
# into ~/.userctx/<context_name>/gtk/theme.sh with contents similar to:
# gsettings set org.gnome.desktop.interface color-scheme 'default'
# gsettings set org.gnome.desktop.interface gtk-theme 'Yaru'
# gsettings set org.gnome.desktop.interface icon-theme 'Yaru'
actions = ["scripts"]
[apps.mako]
exec = "makoctl reload"
[apps.sway]
exec = "swaymsg reload"
[apps.kitty]
exec = 'pkill -USR1 kitty'
[apps.waybar]
exec = 'pkill -USR2 waybar'
[apps.swaybg]
# This config is for swaybg setup as s systemd service.
# Unit runs "swaybg -m fill -i "%h/.config/swaybg/wallpaper".
# ~/.config/userctx/<context>/swaybg/ contains single file
# called "wallpaper" which gets symlinked to ~/.config/swaybg/wallpaper
target_path = '~/.config/swaybg'
reload = ['systemctl', '--user', 'restart', 'swaybg.service']

View File

@@ -8,21 +8,19 @@ COMMAND_CTX_NAME = 'context_name'
COMMANDS_ALL = [COMMAND_LIST, COMMAND_LOAD, COMMAND_SAVE] COMMANDS_ALL = [COMMAND_LIST, COMMAND_LOAD, COMMAND_SAVE]
SECTION_GENERAL = 'general' SECTION_GENERAL = 'general'
KEY_CONTEXTS_PATH = 'contexts_path' KEY_APPS = 'apps'
KEY_TASKS = 'tasks'
KEY_DRY_RUN = 'dry_run' KEY_DRY_RUN = 'dry_run'
KEY_MANAGED_DIR = 'managed_directory'
SECTION_TASK = 'task' SECTION_APPS = 'apps'
KEY_SRC_PATH = 'source_path' KEY_SRC_PATH = 'source_path'
KEY_DST_PATH = 'target_path' KEY_DST_PATH = 'target_path'
KEY_RELOAD = 'reload' KEY_RELOAD = 'reload'
KEY_ACTIONS = 'actions' KEY_ACTIONS = 'actions'
KEY_SYMLINK = 'symlinks' KEY_SYMLINK = 'symlink'
KEY_COMMAND = 'commands' KEY_SCRIPT = 'script'
KEY_SCRIPT = 'scripts' KEY_EXEC = 'exec'
KEY_CODE = 'code' ACTIONS_ALL = [KEY_SYMLINK, KEY_SCRIPT, KEY_EXEC, KEY_RELOAD]
ACTIONS_ALL = [KEY_SYMLINK, KEY_COMMAND, KEY_SCRIPT, KEY_CODE] APPS_VALID_KEYS = [KEY_ACTIONS, KEY_SYMLINK, KEY_SCRIPT, KEY_EXEC, KEY_RELOAD, KEY_SRC_PATH, KEY_DST_PATH]
ENV_CONTEXT_NAME = 'CONTEXT_NAME' ENV_CONTEXT_NAME = 'CONTEXT_NAME'
ENV_CONTEXT_SRC = 'CONTEXT_SRC' ENV_CONTEXT_SRC = 'CONTEXT_SRC'
@@ -48,71 +46,93 @@ class OS(object):
def exec_snippet(self, code: str) -> None: def exec_snippet(self, code: str) -> None:
return self.exec_command([self.shell, "-c", code]) return self.exec_command([self.shell, "-c", code])
def scan_dir(self, path: str) -> list[str]:
out = []
def scan(dir: str, prefix: str):
entries = list(os.scandir(dir)) # extract values from iterator
out.extend([os.path.join(prefix, e.name) for e in entries if e.is_file()])
for d in [e.name for e in entries if e.is_dir()]:
scan(os.path.join(dir, d), os.path.join(prefix, d))
scan(path, '')
return out
class Task(object): class Task(object):
def __init__(self, name: str, src: str, dst: str, conf: dict, dry_run=True): def __init__(self, name: str, src: str, dst: str, conf: dict, dry_run=True):
self.name = name self.name = name
self.src = conf.get(KEY_SRC_PATH) or src # default is ~/.config/userctx/<context>/<task> self.src = conf.get(KEY_SRC_PATH) or src # default is ~/.config/userctx/<context>/<task>
self.dst = conf.get(KEY_DST_PATH) or dst # default is ~/.config/<task> self.dst = conf.get(KEY_DST_PATH) or dst # default is ~/.config/<task>
self.actions = conf.get(KEY_ACTIONS) or [KEY_SYMLINK] # default is to just symlink all
self.symlinks = conf.get(KEY_SYMLINK) self.symlinks = conf.get(KEY_SYMLINK)
self.commands = conf.get(KEY_COMMAND)
self.scripts = conf.get(KEY_SCRIPT) self.scripts = conf.get(KEY_SCRIPT)
self.code = conf.get(KEY_CODE) self.code = conf.get(KEY_EXEC)
self.reload_command = conf.get(KEY_RELOAD) self.reload = conf.get(KEY_RELOAD)
self.actions = conf.get(KEY_ACTIONS)
if self.actions is None: # not set, actions = [] is a no-op conf for app
self.actions = [KEY_SYMLINK]
if self.scripts is not None: # may be an empty array
self.actions.append(KEY_SCRIPT)
if self.code is not None:
self.actions.append(KEY_EXEC)
if self.reload is not None:
self.actions.append(KEY_RELOAD)
self.shell = OS(env={ENV_CONTEXT_NAME: self.name, ENV_CONTEXT_SRC: self.src, ENV_CONTEXT_DST: self.dst}) self.shell = OS(env={ENV_CONTEXT_NAME: self.name, ENV_CONTEXT_SRC: self.src, ENV_CONTEXT_DST: self.dst})
self.dry_run = dry_run self.dry_run = dry_run
def execute(self) -> None: def execute(self) -> None:
if self.dry_run:
print("Configured actions:", self.actions)
for a in self.actions: for a in self.actions:
if a == KEY_SYMLINK: if a == KEY_SYMLINK:
self._symlink() self._symlink()
elif a == KEY_COMMAND:
self._run_commands()
elif a == KEY_SCRIPT: elif a == KEY_SCRIPT:
self._run_scripts() self._run_scripts()
elif a == KEY_CODE: elif a == KEY_EXEC:
self._run_code() self._run_code(self.code)
if self.reload_command and not self.dry_run: elif a == KEY_RELOAD:
self.shell.exec_command(self.reload_command) self._run_code(self.reload)
def _symlink(self) -> None: def _symlink(self) -> None:
if not self.symlinks: # will symlink everything we have then entries = self.shell.scan_dir(self.src)
self.symlinks = [{entry.name: entry.name} for entry in os.scandir(self.src)] if not entries:
for l in self.symlinks: return
for k, v in l.items(): if self.symlinks is None: # will symlink everything we have then
src, dst = os.path.join(self.src, k), os.path.join(self.dst, v) self.symlinks = {'*': '*'}
if self.dry_run: not_included = [e for e in entries if e not in self.symlinks]
print("will remove:", dst, "will symlink:", src, "to", dst)
continue if '*' in self.symlinks:
if os.path.lexists(dst): os.remove(dst) target = self.symlinks['*']
os.symlink(src, dst) if target == '*': # symlink."*" = "*" in config
self.symlinks.update(dict(zip(not_included, not_included)))
else: # symlink."*" = "some/path/target.file"
self.symlinks[not_included[0]] = target
del self.symlinks['*']
for k, v in self.symlinks.items():
src, dst = os.path.join(self.src, k), os.path.join(self.dst, v)
if self.dry_run:
print("will remove:", dst)
print("will symlink:", src, "to", dst)
continue
if os.path.lexists(dst): os.remove(dst)
os.symlink(src, dst)
def _run_scripts(self) -> None: def _run_scripts(self) -> None:
if not self.scripts: # nothing provided explicitly if not self.scripts: # nothing provided explicitly
self.scripts = [entry.name for entry in os.scandir(self.src) if entry.is_file()] self.scripts = [e for e in self.shell.scan_dir(self.src) if e.endswith('.sh')]
if self.dry_run: if self.dry_run:
print(f'will execute scripts from {self.src}:', self.scripts) print(f'will execute scripts from {self.src}:', self.scripts)
return return
for s in self.scripts: for s in self.scripts:
self.shell.exec_script(os.path.join(self.src, s)) self.shell.exec_script(os.path.join(self.src, s))
def _run_code(self) -> None: def _run_code(self, snippet: str) -> None:
if self.dry_run: if self.dry_run:
print("will execute code:", self.code) print("will execute script:", snippet)
return return
self.shell.exec_snippet(self.code) self.shell.exec_snippet(snippet)
def _run_commands(self) -> None:
if self.dry_run:
print("will execute:", self.commands)
return
for cmd in self.commands:
self.shell.exec_command(cmd)
class ContextManagerConfig(object): class ContextManagerConfig(object):
def __init__(self, conf_file_path: str): def __init__(self, conf_file_path: str, dry_run: bool):
try: try:
with open(conf_file_path, 'rb') as f: self.config = tomllib.load(f) with open(conf_file_path, 'rb') as f: self.config = tomllib.load(f)
except Exception as e: except Exception as e:
@@ -127,73 +147,69 @@ class ContextManagerConfig(object):
def user_path(s: str) -> str: def user_path(s: str) -> str:
return os.path.expanduser(s) return os.path.expanduser(s)
if SECTION_GENERAL not in self.config: if SECTION_GENERAL not in self.config or not isinstance(self.config[SECTION_GENERAL], dict):
raise ValueError(f'section "{SECTION_GENERAL}" not found in the config') raise ValueError(f'section "{SECTION_GENERAL}" must be present in the config and must be a valid mapping (table)')
# Will extnsively check what's inside config.toml without additinal imports. # Will extensively check what's inside config.toml without additinal imports.
# Also, fuck dynamic typing! # Also, fuck dynamic typing!
self.tasks = self.config[SECTION_GENERAL].get(KEY_TASKS) self.tasks = self.config[SECTION_GENERAL].get(KEY_APPS)
if not self.tasks or not is_arr_of_str(self.tasks): if not self.tasks or not is_arr_of_str(self.tasks):
raise ValueError(f'invalid config: key "{KEY_TASKS}" in section "{SECTION_GENERAL}" must be a non-empty array of strings') raise ValueError(f'invalid config: key "{KEY_APPS}" in section "{SECTION_GENERAL}" must be a non-empty array of strings')
if SECTION_TASK not in self.config: # valid case with all defaults
self.config[SECTION_TASK] = dict()
errors, err_count = list(), 0 # fuck it again errors, err_count = list(), 0 # fuck it again
self.dry_run = self.config[SECTION_GENERAL].get(KEY_DRY_RUN) or False self.dry_run = (self.config[SECTION_GENERAL].get(KEY_DRY_RUN) or False) or dry_run
if not isinstance(self.dry_run, bool): if not isinstance(self.dry_run, bool):
err_count += 1 err_count += 1
errors.append(f'{err_count}. key "{KEY_DRY_RUN}" in section "{SECTION_GENERAL} of the config must be bool"') errors.append(f'{err_count}. key "{KEY_DRY_RUN}" in section "{SECTION_GENERAL} of the config must be bool"')
self.home = user_path(self.config[SECTION_GENERAL].get(KEY_CONTEXTS_PATH) or os.path.join(XDG_CONFIG_HOME, 'userctx')) self.home = user_path(self.config[SECTION_GENERAL].get(KEY_SRC_PATH) or os.path.join(XDG_CONFIG_HOME, 'userctx'))
if not os.path.isabs(self.home): if not os.path.isabs(self.home):
err_count +=1 err_count += 1
errors.append(f'{err_count}. path "{self.home}" in key "{KEY_CONTEXTS_PATH}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.home}"') errors.append(f'{err_count}. path "{self.home}" in key "{KEY_SRC_PATH}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.home}"')
self.managed_path = user_path(self.config[SECTION_GENERAL].get(KEY_MANAGED_DIR) or XDG_CONFIG_HOME) self.managed_path = user_path(self.config[SECTION_GENERAL].get(KEY_DST_PATH) or XDG_CONFIG_HOME)
if not os.path.isabs(self.managed_path): if not os.path.isabs(self.managed_path):
err_count +=1 err_count += 1
errors.append(f'{err_count}. path "{self.managed_path}" in key "{KEY_MANAGED_DIR}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.managed_path}"') errors.append(f'{err_count}. path "{self.managed_path}" in key "{KEY_DST_PATH}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.managed_path}"')
tasks = self.config.get(SECTION_TASK) if SECTION_APPS not in self.config: # valid case with all defaults
self.config[SECTION_APPS] = dict()
tasks = self.config.get(SECTION_APPS)
for name, t in tasks.items(): for name, t in tasks.items():
errstr = f'for task "{name}"' errstr = f'for app "{name}"'
for k in list(t.keys()): # we might change t for k in list(t.keys()): # we might change t
v = t[k] v = t[k]
if k in [KEY_SRC_PATH, KEY_DST_PATH, KEY_CODE] and not isinstance(v, str): if k not in APPS_VALID_KEYS:
err_count +=1 err_count += 1
errors.append(f'{err_count}. {errstr} unknown key "{k}"')
continue
if k == KEY_EXEC and not isinstance(v, str):
err_count += 1
errors.append(f'{err_count}. {errstr} key "{k}" must be a string') errors.append(f'{err_count}. {errstr} key "{k}" must be a string')
continue elif k == KEY_SYMLINK and not is_dict_of_str(v):
elif k in [KEY_COMMAND, KEY_SCRIPT, KEY_RELOAD, KEY_ACTIONS, KEY_SYMLINK] and not isinstance(v, list): err_count += 1
err_count +=1 errors.append(f'{err_count}. {errstr} key "{k}" must be a table (mapping)')
errors.append(f'{err_count}. {errstr} key "{k}" must be an array') elif k in [KEY_SCRIPT, KEY_ACTIONS] and not is_arr_of_str(v):
continue err_count += 1
if k == KEY_COMMAND and not all(is_arr_of_str(elem) for elem in v): errors.append(f'{err_count}. {errstr} key "{k}" must be array of strings')
err_count +=1 elif k == KEY_ACTIONS and not all(a in ACTIONS_ALL for a in v):
errors.append(f'{err_count}. {errstr} all elements of "{KEY_COMMAND}" must be arrays of strings') err_count += 1
if k == KEY_SCRIPT and not is_arr_of_str(v): errors.append(f'{err_count}. {errstr} invalid actions found: {[a for a in v if a not in ACTIONS_ALL]} in key "{k}", valid actions are: {ACTIONS_ALL}')
err_count +=1 elif k == KEY_RELOAD and not isinstance(v, str):
errors.append(f'{err_count}. {errstr} "{KEY_SCRIPT}" must be array of strings') err_count += 1
if k == KEY_SYMLINK and not all(is_dict_of_str(elem) for elem in v): errors.append(f'{err_count}. {errstr} "{KEY_RELOAD}" must ba a string')
err_count +=1 elif k in [KEY_SRC_PATH, KEY_DST_PATH] and not isinstance(v, str):
errors.append(f'{err_count}. {errstr} all elements of "{KEY_SYMLINK}" array must be mappings (inline tables) of strings') err_count += 1
if k == KEY_ACTIONS and not is_arr_of_str(v): errors.append(f'{err_count}. {errstr} key "{k}" must contain a string')
err_count +=1 elif k in [KEY_SRC_PATH, KEY_DST_PATH]:
errors.append(f'{err_count}. {errstr} "{KEY_ACTIONS}" must be array if strings')
if k == KEY_ACTIONS and not all(a in ACTIONS_ALL for a in v):
err_count +=1
errors.append(f'{err_count}. {errstr} found invalid actions: {[a for a in v if a not in ACTIONS_ALL]} in key "{KEY_ACTIONS}", valid actions are: {ACTIONS_ALL}')
if k == KEY_RELOAD and not is_arr_of_str(v):
err_count +=1
errors.append(f'{err_count}. {errstr} "{KEY_RELOAD}" must be array of strings')
if k in [KEY_SRC_PATH, KEY_DST_PATH]:
p = user_path(v) p = user_path(v)
if not os.path.isabs(p): if not os.path.isabs(p):
err_count +=1 err_count += 1
errors.append(f'{err_count}. {errstr} path "{v}" in key "{k}" must be either absolute or relative to home: "~/{v}"') errors.append(f'{err_count}. {errstr} path "{v}" in key "{k}" must be either absolute or relative to home: "~/{v}"')
t[k] = p # substituting path (e.g. "~/somedir" -> "/home/user/somedir") t[k] = p # substituting path (e.g. "~/somedir" -> "/home/user/somedir")
if errors: if errors:
print(f'{err_count} errors found in the config ({conf_file_path}):') print(f'{err_count} errors found in the config "{conf_file_path}":')
print('\n'.join(errors)) print('\n'.join(errors))
raise ValueError('invalid config') raise ValueError('invalid config')
@@ -206,9 +222,9 @@ class ContextManagerConfig(object):
raise ValueError(f'context directory "{context_name}" not found in "{self.home}"') raise ValueError(f'context directory "{context_name}" not found in "{self.home}"')
out = list() out = list()
for name in self.tasks: for name in self.tasks:
task_path = os.path.join(ctx_path, name) src_path = os.path.join(ctx_path, name)
target_path = os.path.join(self.managed_path, name) dst_path = os.path.join(self.managed_path, name)
t = Task(name, task_path, target_path, self.config[SECTION_TASK].get(name) or dict(), dry_run=self.dry_run) t = Task(name, src_path, dst_path, self.config[SECTION_APPS].get(name) or dict(), dry_run=self.dry_run)
out.append(t) out.append(t)
return out return out
@@ -217,10 +233,10 @@ class ContextManager(object):
def __init__(self, config: ContextManagerConfig): def __init__(self, config: ContextManagerConfig):
self.config = config self.config = config
def print_contexts_list(self) -> str: def print_contexts_list(self) -> None:
print('\n'.join(self.config.get_contexts_list())) print('\n'.join(self.config.get_contexts_list()))
def apply_context(self, context_name: str) -> None: def load_context(self, context_name: str) -> None:
print(f'loading and applying context: "{context_name}"') print(f'loading and applying context: "{context_name}"')
try: try:
tasks = self.config.get_tasks_for_context(context_name) tasks = self.config.get_tasks_for_context(context_name)
@@ -250,6 +266,8 @@ class Runner(object):
p = argparse.ArgumentParser(prog=APP_NAME, description=APP_DESC) p = argparse.ArgumentParser(prog=APP_NAME, description=APP_DESC)
p.add_argument('-c', '--config', help='path to config file, default: %(default)s', p.add_argument('-c', '--config', help='path to config file, default: %(default)s',
default=os.path.join(XDG_CONFIG_HOME, 'userctx', 'config.toml')) default=os.path.join(XDG_CONFIG_HOME, 'userctx', 'config.toml'))
p.add_argument('-n', '--nop', action='store_true', default=False,
help='do nothing, just print out what userctx will do')
subp = p.add_subparsers(help='command to execute', dest='command') subp = p.add_subparsers(help='command to execute', dest='command')
loadp = subp.add_parser(COMMAND_LOAD, help='load context (provide name of context)') loadp = subp.add_parser(COMMAND_LOAD, help='load context (provide name of context)')
loadp.add_argument(COMMAND_CTX_NAME, help='name of context to load') loadp.add_argument(COMMAND_CTX_NAME, help='name of context to load')
@@ -262,7 +280,7 @@ class Runner(object):
print("error parsing command-line arguments:", e) print("error parsing command-line arguments:", e)
return 1 return 1
try: try:
config = ContextManagerConfig(args.config) config = ContextManagerConfig(args.config, args.nop)
except Exception as e: except Exception as e:
print("error loading config:", e) print("error loading config:", e)
return 2 return 2
@@ -271,7 +289,7 @@ class Runner(object):
if args.command == COMMAND_LIST: if args.command == COMMAND_LIST:
ctxmgr.print_contexts_list() ctxmgr.print_contexts_list()
elif args.command == COMMAND_LOAD: elif args.command == COMMAND_LOAD:
ctxmgr.apply_context(args.__dict__[COMMAND_CTX_NAME]) # guaranteed to be present by argparse ctxmgr.load_context(args.__dict__[COMMAND_CTX_NAME]) # guaranteed to be present by argparse
elif args.command == COMMAND_SAVE: elif args.command == COMMAND_SAVE:
raise Exception('"save" not implemented') raise Exception('"save" not implemented')
except Exception as e: except Exception as e: