feat: v0.1.0 #1

Merged
dmitry merged 12 commits from dev into main 2025-12-27 12:05:30 +03:00
6 changed files with 692 additions and 2 deletions

2
.gitignore vendored Normal file
View File

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

View File

@@ -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
View File

@@ -0,0 +1,4 @@
.PNONY: install
install:
sudo install userctx.py /usr/local/bin/userctx

205
README.md
View File

@@ -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
View 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
View 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()