feat: v0.1.0
Version I use myself. Fully functional, may contain bugs, use at your own risk. Co-authored-by: Dmitry Fedotov <dmitry@uint32.ru> Co-committed-by: Dmitry Fedotov <dmitry@uint32.ru>
This commit was merged in pull request #1.
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__
|
||||||
|
test
|
||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 dmitry
|
Copyright (c) 2025 Dmitry Fedotov
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
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
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
|||||||
4
Makefile
Normal file
4
Makefile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.PNONY: install
|
||||||
|
|
||||||
|
install:
|
||||||
|
sudo install userctx.py /usr/local/bin/userctx
|
||||||
205
README.md
205
README.md
@@ -1,3 +1,206 @@
|
|||||||
# userctx
|
# userctx
|
||||||
|
|
||||||
A tool for switching user's software configs
|
Manage you configurations and themes with ease.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
You just need to copy the file to a directory
|
||||||
|
in your $PATH (for example: /usr/loca/bin) and
|
||||||
|
copy/create a config file.
|
||||||
|
```bash
|
||||||
|
sudo install userctx.py /usr/local/bin/userctx
|
||||||
|
mkdir -p ~/.config/userctx
|
||||||
|
cp config.toml ~/.config/userctx
|
||||||
|
```
|
||||||
|
|
||||||
|
## userctx basics
|
||||||
|
**userctx** manages configuration *contexts*. A context is a directory somewhere
|
||||||
|
in your system which stores actual configuration files for your apps
|
||||||
|
similar to what resides in ~/.config.
|
||||||
|
|
||||||
|
Typically a context directory looks like this.
|
||||||
|
```bash
|
||||||
|
/home/dmitry/.config/userctx/Goldfish
|
||||||
|
├── sway
|
||||||
|
│ └── theme.conf
|
||||||
|
└── wofi
|
||||||
|
└── style.css
|
||||||
|
|
||||||
|
```
|
||||||
|
In the above example, context "Goldfish" contains configs for apps "sway" and "wofi".
|
||||||
|
These configs will be applied when you apply context "Goldfish".
|
||||||
|
|
||||||
|
When a context is applied the app configs from context directory are get
|
||||||
|
symlinked to youe ~/.config/ folder for each managed app. Goldfish/wofi/style.css symlinks to
|
||||||
|
\~/.config/wofi/style.css, Goldfish/sway/theme.conf symlinks to \~/.config/sway/theme.conf etc.
|
||||||
|
|
||||||
|
This default behaviour can be changed and specific actions or customizations
|
||||||
|
can be configured on per-app basis by editing **userctx** config file.
|
||||||
|
The default location for the config is ~/.config/userctx/config.toml.
|
||||||
|
|
||||||
|
**userctx** can also be configured to run scripts, trigger your apps to
|
||||||
|
reload configs etc. For a detailed overview of all config options see below.
|
||||||
|
|
||||||
|
There are two commands **userctx** understands: *list* and *apply*.
|
||||||
|
- *list* lists available contexts in a manner suitable for dmenu-like apps
|
||||||
|
- *apply* \<context\> applies named context
|
||||||
|
|
||||||
|
To test your configuration run:
|
||||||
|
```bash
|
||||||
|
userctx --nop apply <context_name>
|
||||||
|
```
|
||||||
|
The program will then only output what it will do without changing
|
||||||
|
anything in your home directory.
|
||||||
|
|
||||||
|
For a quickstart and simple examples jump to "Examples" section below.
|
||||||
|
|
||||||
|
## Configuring userctx
|
||||||
|
**userctx** is configured through a TOML file located at `~/.config/userctx/config.toml` by default. The configuration is split into two main sections: `[general]` and `[apps]`.
|
||||||
|
|
||||||
|
### The `[general]` section
|
||||||
|
This section contains global settings for **userctx**.
|
||||||
|
|
||||||
|
- `dry_run` (boolean, optional): If set to `true`, **userctx** will only print the actions it would take without making any changes to the filesystem. This is useful for testing your configuration. The command-line flag `--nop` overrides this setting.
|
||||||
|
- `apps` (array of strings, required): A list of application names that **userctx** should manage. For each application in this list, **userctx** will look for a corresponding configuration section in the `[apps]` table.
|
||||||
|
- `source_path` (string, optional): The base path where your contexts are stored. Defaults to `$XDG_CONFIG_HOME/userctx` or `~/.config/userctx`.
|
||||||
|
- `target_path` (string, optional): The base path where application configurations are located. Defaults to `$XDG_CONFIG_HOME` or `~/.config`.
|
||||||
|
|
||||||
|
### The `[apps]` section
|
||||||
|
For each application defined in the `general.apps` array, you can have a dedicated section to specify its configuration.
|
||||||
|
|
||||||
|
You can create **[apps.<you_app>]** section in the config to specify **userctx** behaviour for the app. The following options are supported.
|
||||||
|
|
||||||
|
- `source_path` (string, optional): Overrides the global `source_path` for this specific application.
|
||||||
|
- `target_path` (string, optional): Overrides the global `target_path` for this specific application. The final destination path for an application's configuration will be `<target_path>/<app_name>`.
|
||||||
|
- `actions` (array of strings, optional): A list of actions to perform for the application. The available actions are `symlink`, `script`, `exec`, and `reload`. If not specified, the default actions are `[symlink, script, exec, reload]` if the corresponding keys (`symlink`, `script`, `exec`, `reload`) are present in the app's configuration.
|
||||||
|
- `symlink` (table, optional): A map of source files to destination files for creating symlinks. The source is relative to the context's application directory (e.g., `<source_path>/<context_name>/<app_name>`), and the destination is relative to the application's `target_path`. You can use `*` as a wildcard to match all files in the source directory.
|
||||||
|
- `exec` (string, optional): A shell command or script to execute. The script is executed in a shell environment with the following environment variables set:
|
||||||
|
- `CONTEXT_NAME`: The name of the context being applied.
|
||||||
|
- `CONTEXT_SRC`: The source directory for the current application's context.
|
||||||
|
- `CONTEXT_DST`: The target directory for the current application's configuration.
|
||||||
|
- `script` (array of strings, optional): A list of script files to execute. The scripts are looked for in the application's context directory. If the list is empty or not provided, **userctx** will look for and execute any file ending with `.sh` in the context directory.
|
||||||
|
- `reload` (string, optional): A shell command to execute to reload the application's configuration. This is typically used to make the application aware of the changes applied by **userctx**.
|
||||||
|
|
||||||
|
TODO: a more detailed explanation of wildcard symlinking.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
### Basic usecase: we only need symlinks
|
||||||
|
Let's add configuration for "foot" terminal emulator, which
|
||||||
|
will be applied when we apply context "Goldfish" assuming
|
||||||
|
we would like to switch foot's visual theme when swithching context.
|
||||||
|
|
||||||
|
1. First we need to create a separate file for visuals config in our
|
||||||
|
context directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/.config/userctx/Goldfish/foot/
|
||||||
|
vim ~/.config/userctx/Goldfish/foot/theme.ini
|
||||||
|
```
|
||||||
|
And put the awesome "Tempus Day" into theme.ini:
|
||||||
|
```
|
||||||
|
# -*- conf -*-
|
||||||
|
# theme: Tempus Day
|
||||||
|
# author: Protesilaos Stavrou (https://protesilaos.com)
|
||||||
|
# description: Light theme with warm colours (WCAG AA compliant)
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
foreground = 464340
|
||||||
|
# original background
|
||||||
|
# background = f8f2e5
|
||||||
|
background = ffffff
|
||||||
|
regular0 = 464340
|
||||||
|
regular1 = c81000
|
||||||
|
regular2 = 107410
|
||||||
|
regular3 = 806000
|
||||||
|
regular4 = 385dc4
|
||||||
|
regular5 = b63052
|
||||||
|
regular6 = 007070
|
||||||
|
regular7 = e7e3d7
|
||||||
|
bright0 = 68607d
|
||||||
|
bright1 = b24000
|
||||||
|
bright2 = 427040
|
||||||
|
bright3 = 6f6600
|
||||||
|
bright4 = 0f64c4
|
||||||
|
bright5 = 8050a7
|
||||||
|
bright6 = 336c87
|
||||||
|
bright7 = f8f2e5
|
||||||
|
```
|
||||||
|
You could as well symlink any file (pre-existing theme for foot)
|
||||||
|
to ~/.config/userctx/Goldfish/foot/theme.ini
|
||||||
|
|
||||||
|
2. To include this theme file from main foot config add the following line
|
||||||
|
to $HOME/.config/foot/foot.ini (edit to match your user's homedir):
|
||||||
|
```
|
||||||
|
include=/home/dmitry/.config/foot/theme.ini
|
||||||
|
```
|
||||||
|
Do not forget to remove style settings from foot.ini so they do not
|
||||||
|
conflict with separate theme.ini.
|
||||||
|
|
||||||
|
3. Finally, tell userctx to manage foot config for you.
|
||||||
|
Edit ~/.config/userctx/config.toml and add "foot" to "apps"
|
||||||
|
array in "general" section of the config.
|
||||||
|
```
|
||||||
|
[general]
|
||||||
|
apps = [
|
||||||
|
"foot",
|
||||||
|
"sway",
|
||||||
|
"wofi",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
4. Test you configuration
|
||||||
|
```bash
|
||||||
|
userctx --nop apply Goldfish
|
||||||
|
```
|
||||||
|
The **--nop** (no-op) flag tells **userctx** to perform a dry-run. It will just output
|
||||||
|
what it is going to do when you actually apply context.
|
||||||
|
|
||||||
|
If all looks good - that's it. When you issue **userctx apply Goldfish**
|
||||||
|
a symlink will be created in your homedir:
|
||||||
|
|
||||||
|
~/.config/foot/theme.ini -> ~/.config/userctx/Goldfish/foot/theme.ini
|
||||||
|
|
||||||
|
### More advanced usecase: run command and hot-reload
|
||||||
|
Let's configure **userctx** to apply theme to helix editor.
|
||||||
|
|
||||||
|
1. Similar to the above section, create ~/.config/userctx/Goldfish/helix/helix.toml
|
||||||
|
with the following contents:
|
||||||
|
```toml
|
||||||
|
inherits = "github_light"
|
||||||
|
"ui.background" = {}
|
||||||
|
```
|
||||||
|
Now edit ~/.config/userctx/config.toml.
|
||||||
|
|
||||||
|
Add "helix" to "general" section.
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
apps = [
|
||||||
|
"foot",
|
||||||
|
"sway",
|
||||||
|
"helix",
|
||||||
|
"wofi",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
2. Add "apps.helix" section:
|
||||||
|
```toml
|
||||||
|
[apps.helix]
|
||||||
|
symlink."*" = "themes/current_theme.toml"
|
||||||
|
exec = """sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml"""
|
||||||
|
reload = "pkill -USR1 hx"
|
||||||
|
```
|
||||||
|
Here we instruct **userctx** to symlink any (single) file it finds in "helix" subdirectory of context folder
|
||||||
|
to ~/.config/helix/themes/current_theme.toml
|
||||||
|
|
||||||
|
Then we run sed to change the config file. This part is not really necessare if helix is
|
||||||
|
configured to use theme named "current_theme" and you're sure that config won't change.
|
||||||
|
We could just replace the file and issue USR1. The sed part if for the case when config
|
||||||
|
is changed by user or other app.
|
||||||
|
|
||||||
|
Finally we set the reload command which
|
||||||
|
will tell helix to reload config.
|
||||||
|
|
||||||
|
3. Test your configuration,
|
||||||
|
```bash
|
||||||
|
userctx --nop apply Goldfish
|
||||||
|
```
|
||||||
|
|
||||||
|
See also template "config.toml" with numerous app settings.
|
||||||
|
|
||||||
|
|||||||
181
config.toml
Normal file
181
config.toml
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
[general]
|
||||||
|
# if dry run is set to true
|
||||||
|
# userctx will only validate config
|
||||||
|
# and tell you what it will do to apply context
|
||||||
|
# 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.
|
||||||
|
# Uncomment apps below.
|
||||||
|
apps = [
|
||||||
|
"sway",
|
||||||
|
"wofi",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# section that demonstrates all the available
|
||||||
|
# features of userctx
|
||||||
|
[apps.example]
|
||||||
|
# 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 this 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"
|
||||||
|
|
||||||
|
# If symlinks mapping is not empty then only instructions from the map
|
||||||
|
# will be applied. If you want to redefine linking rules only for single
|
||||||
|
# file and symlink others according to default rules then add the following
|
||||||
|
# rule as well.
|
||||||
|
symlink."*" = "*"
|
||||||
|
|
||||||
|
# As a special case, providing the following mapping symlinks
|
||||||
|
# a SINGLE file (the first one found) from context directory
|
||||||
|
# to a specified name (see helix section below).
|
||||||
|
# This reads "symlinks ANY file you find to this path".
|
||||||
|
# symlink."*" = "some/destination.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"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# if "script" array is not empty
|
||||||
|
# then userctx 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")
|
||||||
|
script = [
|
||||||
|
"script1.sh",
|
||||||
|
"script2.sh",
|
||||||
|
]
|
||||||
|
|
||||||
|
# if "reload" is present, the command will be executed after everythong alse is done
|
||||||
|
reload = "pkill -USR1 example"
|
||||||
|
|
||||||
|
# "actions" array specifies the order of actions when
|
||||||
|
# applying context. If omitted, the default behaviour
|
||||||
|
# it to symlink, run scripts, run exec part then run reload
|
||||||
|
# command (if relevant actions are specified).
|
||||||
|
# If nothig has been customized in apps configuration here
|
||||||
|
# (no commands array, not symlinks mapping etc.) then userctx
|
||||||
|
# will just symlink anythong it finds to target_path.
|
||||||
|
# If actions array is declared empty (actions = []) then
|
||||||
|
# userctx will do nothig.
|
||||||
|
actions = [
|
||||||
|
"symlink",
|
||||||
|
"exec",
|
||||||
|
"script",
|
||||||
|
"reload",
|
||||||
|
]
|
||||||
|
|
||||||
|
#########################
|
||||||
|
# settings for some
|
||||||
|
# commonly used apps
|
||||||
|
#########################
|
||||||
|
|
||||||
|
[apps.niri_old]
|
||||||
|
# Config for niri prior to 25.11.
|
||||||
|
# For current niri version which supports includes
|
||||||
|
# in the config just omit specific config options.
|
||||||
|
target_path = "~/.config/niri/configs"
|
||||||
|
|
||||||
|
# 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, putting those to ~/.config/niri/configs
|
||||||
|
# and them concatenating them after applying changes to theme.
|
||||||
|
# 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]
|
||||||
|
# $CONTEXT_SRC/helix.toml -> $CONTEXT_DST/themes/current_theme.toml
|
||||||
|
symlink."*" = "themes/current_theme.toml"
|
||||||
|
exec = """sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml"""
|
||||||
|
reload = "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 = ["script"]
|
||||||
|
|
||||||
|
[apps.mako]
|
||||||
|
reload = "makoctl reload"
|
||||||
|
|
||||||
|
[apps.sway]
|
||||||
|
reload = "swaymsg reload"
|
||||||
|
|
||||||
|
[apps.kitty]
|
||||||
|
reload = 'pkill -USR1 kitty'
|
||||||
|
|
||||||
|
[apps.waybar]
|
||||||
|
reload = '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
|
||||||
|
# which gets symlinked to ~/.config/swaybg/wallpaper
|
||||||
|
symlink."*" = "wallpaper"
|
||||||
|
reload = 'systemctl --user restart swaybg.service'
|
||||||
|
|
||||||
|
|
||||||
300
userctx.py
Executable file
300
userctx.py
Executable file
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, sys, subprocess, tomllib, argparse
|
||||||
|
|
||||||
|
COMMAND_LIST = 'list'
|
||||||
|
COMMAND_APPLY = 'apply'
|
||||||
|
COMMAND_CTX_NAME = 'context_name'
|
||||||
|
COMMANDS_ALL = [COMMAND_LIST, COMMAND_APPLY]
|
||||||
|
|
||||||
|
SECTION_GENERAL = 'general'
|
||||||
|
KEY_APPS = 'apps'
|
||||||
|
KEY_DRY_RUN = 'dry_run'
|
||||||
|
|
||||||
|
SECTION_APPS = 'apps'
|
||||||
|
KEY_SRC_PATH = 'source_path'
|
||||||
|
KEY_DST_PATH = 'target_path'
|
||||||
|
KEY_RELOAD = 'reload'
|
||||||
|
KEY_ACTIONS = 'actions'
|
||||||
|
KEY_SYMLINK = 'symlink'
|
||||||
|
KEY_SCRIPT = 'script'
|
||||||
|
KEY_EXEC = 'exec'
|
||||||
|
ACTIONS_ALL = [KEY_SYMLINK, KEY_SCRIPT, KEY_EXEC, KEY_RELOAD]
|
||||||
|
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_SRC = 'CONTEXT_SRC'
|
||||||
|
ENV_CONTEXT_DST = 'CONTEXT_DST'
|
||||||
|
|
||||||
|
APP_NAME = os.path.basename(sys.argv[0])
|
||||||
|
APP_DESC = 'A simple configuration switcher for various usage scenarios.'
|
||||||
|
HOME = os.path.expanduser('~')
|
||||||
|
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||||
|
|
||||||
|
class OS(object):
|
||||||
|
def __init__(self, chdir='', env=None):
|
||||||
|
self.shell = os.getenv('SHELL') or 'bash'
|
||||||
|
if env: env.update(os.environ)
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def exec_command(self, command: list[str], quiet=False) -> int:
|
||||||
|
return subprocess.run(command, capture_output=quiet, env=self.env).returncode
|
||||||
|
|
||||||
|
def exec_script(self, path: str) -> None:
|
||||||
|
return self.exec_command([self.shell, path])
|
||||||
|
|
||||||
|
def exec_snippet(self, code: str) -> None:
|
||||||
|
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):
|
||||||
|
def __init__(self, name: str, src: str, dst: str, conf: dict, dry_run=True):
|
||||||
|
self.name = name
|
||||||
|
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.symlinks = conf.get(KEY_SYMLINK)
|
||||||
|
self.scripts = conf.get(KEY_SCRIPT)
|
||||||
|
self.code = conf.get(KEY_EXEC)
|
||||||
|
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.dry_run = dry_run
|
||||||
|
|
||||||
|
def execute(self) -> None:
|
||||||
|
if self.dry_run:
|
||||||
|
print("Configured actions:", self.actions)
|
||||||
|
for a in self.actions:
|
||||||
|
if a == KEY_SYMLINK:
|
||||||
|
self._symlink()
|
||||||
|
elif a == KEY_SCRIPT:
|
||||||
|
self._run_scripts()
|
||||||
|
elif a == KEY_EXEC:
|
||||||
|
self._run_code(self.code)
|
||||||
|
elif a == KEY_RELOAD:
|
||||||
|
self._run_code(self.reload)
|
||||||
|
|
||||||
|
def _symlink(self) -> None:
|
||||||
|
entries = self.shell.scan_dir(self.src)
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
if self.symlinks is None: # will symlink everything we have then
|
||||||
|
self.symlinks = {'*': '*'}
|
||||||
|
not_included = [e for e in entries if e not in self.symlinks]
|
||||||
|
|
||||||
|
if '*' in self.symlinks:
|
||||||
|
target = self.symlinks['*']
|
||||||
|
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:
|
||||||
|
if not self.scripts: # nothing provided explicitly
|
||||||
|
self.scripts = [e for e in self.shell.scan_dir(self.src) if e.endswith('.sh')]
|
||||||
|
if self.dry_run:
|
||||||
|
print(f'will execute scripts from {self.src}:', self.scripts)
|
||||||
|
return
|
||||||
|
for s in self.scripts:
|
||||||
|
self.shell.exec_script(os.path.join(self.src, s))
|
||||||
|
|
||||||
|
def _run_code(self, snippet: str) -> None:
|
||||||
|
if self.dry_run:
|
||||||
|
print("will execute script:", snippet)
|
||||||
|
return
|
||||||
|
self.shell.exec_snippet(snippet)
|
||||||
|
|
||||||
|
class ContextManagerConfig(object):
|
||||||
|
def __init__(self, conf_file_path: str, dry_run: bool):
|
||||||
|
try:
|
||||||
|
with open(conf_file_path, 'rb') as f: self.config = tomllib.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError('error parsing "{config_file_path}":', e)
|
||||||
|
|
||||||
|
def is_arr_of_str(x) -> bool:
|
||||||
|
return isinstance(x, list) and all(isinstance(s, str) for s in x)
|
||||||
|
|
||||||
|
def is_dict_of_str(x) -> bool:
|
||||||
|
return isinstance(x, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in x.items())
|
||||||
|
|
||||||
|
def user_path(s: str) -> str:
|
||||||
|
return os.path.expanduser(s)
|
||||||
|
|
||||||
|
if SECTION_GENERAL not in self.config or not isinstance(self.config[SECTION_GENERAL], dict):
|
||||||
|
raise ValueError(f'section "{SECTION_GENERAL}" must be present in the config and must be a valid mapping (table)')
|
||||||
|
|
||||||
|
# Will extensively check what's inside config.toml without additinal imports.
|
||||||
|
# Also, fuck dynamic typing!
|
||||||
|
self.tasks = self.config[SECTION_GENERAL].get(KEY_APPS)
|
||||||
|
if not self.tasks or not is_arr_of_str(self.tasks):
|
||||||
|
raise ValueError(f'invalid config: key "{KEY_APPS}" in section "{SECTION_GENERAL}" must be a non-empty array of strings')
|
||||||
|
|
||||||
|
errors, err_count = list(), 0 # fuck it again
|
||||||
|
self.dry_run = (self.config[SECTION_GENERAL].get(KEY_DRY_RUN) or False) or dry_run
|
||||||
|
if not isinstance(self.dry_run, bool):
|
||||||
|
err_count += 1
|
||||||
|
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_SRC_PATH) or os.path.join(XDG_CONFIG_HOME, 'userctx'))
|
||||||
|
if not os.path.isabs(self.home):
|
||||||
|
err_count += 1
|
||||||
|
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_DST_PATH) or XDG_CONFIG_HOME)
|
||||||
|
if not os.path.isabs(self.managed_path):
|
||||||
|
err_count += 1
|
||||||
|
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}"')
|
||||||
|
|
||||||
|
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():
|
||||||
|
errstr = f'for app "{name}"'
|
||||||
|
for k in list(t.keys()): # we might change t
|
||||||
|
v = t[k]
|
||||||
|
if k not in APPS_VALID_KEYS:
|
||||||
|
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')
|
||||||
|
elif k == KEY_SYMLINK and not is_dict_of_str(v):
|
||||||
|
err_count += 1
|
||||||
|
errors.append(f'{err_count}. {errstr} key "{k}" must be a table (mapping)')
|
||||||
|
elif k in [KEY_SCRIPT, KEY_ACTIONS] and not is_arr_of_str(v):
|
||||||
|
err_count += 1
|
||||||
|
errors.append(f'{err_count}. {errstr} key "{k}" must be array of strings')
|
||||||
|
elif k == KEY_ACTIONS and not all(a in ACTIONS_ALL for a in v):
|
||||||
|
err_count += 1
|
||||||
|
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}')
|
||||||
|
elif k == KEY_RELOAD and not isinstance(v, str):
|
||||||
|
err_count += 1
|
||||||
|
errors.append(f'{err_count}. {errstr} "{KEY_RELOAD}" must ba a string')
|
||||||
|
elif k in [KEY_SRC_PATH, KEY_DST_PATH] and not isinstance(v, str):
|
||||||
|
err_count += 1
|
||||||
|
errors.append(f'{err_count}. {errstr} key "{k}" must contain a string')
|
||||||
|
elif k in [KEY_SRC_PATH, KEY_DST_PATH]:
|
||||||
|
p = user_path(v)
|
||||||
|
if not os.path.isabs(p):
|
||||||
|
err_count += 1
|
||||||
|
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")
|
||||||
|
if errors:
|
||||||
|
print(f'{err_count} errors found in the config "{conf_file_path}":')
|
||||||
|
print('\n'.join(errors))
|
||||||
|
raise ValueError('invalid config')
|
||||||
|
|
||||||
|
def get_contexts_list(self) -> list[str]:
|
||||||
|
return [entry.name for entry in os.scandir(self.home) if entry.is_dir()]
|
||||||
|
|
||||||
|
def get_tasks_for_context(self, context_name: str) -> list[Task]:
|
||||||
|
ctx_path = os.path.join(self.home, context_name)
|
||||||
|
if not (os.path.exists(ctx_path) and os.path.isdir(ctx_path)):
|
||||||
|
raise ValueError(f'context directory "{context_name}" not found in "{self.home}"')
|
||||||
|
out = list()
|
||||||
|
for name in self.tasks:
|
||||||
|
src_path = os.path.join(ctx_path, name)
|
||||||
|
dst_path = os.path.join(self.managed_path, name)
|
||||||
|
t = Task(name, src_path, dst_path, self.config[SECTION_APPS].get(name) or dict(), dry_run=self.dry_run)
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class ContextManager(object):
|
||||||
|
def __init__(self, config: ContextManagerConfig):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def list_contexts(self) -> None:
|
||||||
|
print('\n'.join(self.config.get_contexts_list()))
|
||||||
|
|
||||||
|
def apply_context(self, context_name: str) -> None:
|
||||||
|
print(f'loading and applying context: "{context_name}"')
|
||||||
|
try:
|
||||||
|
tasks = self.config.get_tasks_for_context(context_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'error getting tasks for context "{context_name}":', e)
|
||||||
|
return
|
||||||
|
if not tasks:
|
||||||
|
print(f'no tasks found for context "{context_name}"')
|
||||||
|
return
|
||||||
|
errors = list()
|
||||||
|
for task in tasks:
|
||||||
|
print(f'running task "{task.name}"...', end=' ')
|
||||||
|
try:
|
||||||
|
task.execute()
|
||||||
|
print("OK")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append((task.name, e))
|
||||||
|
print('FAILED')
|
||||||
|
if errors:
|
||||||
|
print('Errors:')
|
||||||
|
for t in errors:
|
||||||
|
print(f'task: "{t[0]}", error: "{t[1]}"')
|
||||||
|
|
||||||
|
|
||||||
|
class Runner(object):
|
||||||
|
def run(self) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=APP_NAME, description=APP_DESC)
|
||||||
|
p.add_argument('-c', '--config', help='path to config file, default: %(default)s',
|
||||||
|
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')
|
||||||
|
applyp = subp.add_parser(COMMAND_APPLY, help='load context (provide name of context)')
|
||||||
|
applyp.add_argument(COMMAND_CTX_NAME, help='name of context to load')
|
||||||
|
listp = subp.add_parser(COMMAND_LIST, help='list available contexts')
|
||||||
|
try: # just in case
|
||||||
|
args = p.parse_args()
|
||||||
|
except Exception as e:
|
||||||
|
print("error parsing command-line arguments:", e)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
config = ContextManagerConfig(args.config, args.nop)
|
||||||
|
except Exception as e:
|
||||||
|
print("error loading config:", e)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
ctxmgr = ContextManager(config)
|
||||||
|
if args.command == COMMAND_LIST:
|
||||||
|
ctxmgr.list_contexts()
|
||||||
|
elif args.command == COMMAND_APPLY:
|
||||||
|
ctxmgr.apply_context(args.__dict__[COMMAND_CTX_NAME]) # guaranteed to be present by argparse
|
||||||
|
except Exception as e:
|
||||||
|
print(f'error executing command:', e)
|
||||||
|
return 3
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sys.exit(Runner().run())
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user