Table of Contents
pipx install edwh
# or: uvenv install edwh
# or with all plugins:
pipx install[plugins]
# or with specific plugins:
pipx install[multipass,restic]
# managing plugins later:
edwh plugins
edwh plugin.add multipass
edwh plugin.remove multipass# to see all available commands:
ew # or `edwh`
# to see help about a specific namespace:
ew help <namespace> # e.g. `ew help plugin`
# to see help about a specific command:
ew help <command> # e.g. `ew help plugin.list` edwh sudo verifies your sudo password and safely stores it temporarily so commands that require sudo can run without
prompting again.
By default, EDWH uses the operating system keyring (for example, GNOME Secret Service on Linux). This is the preferred backend when the desktop session and its keyring are available.
When the system keyring is locked, edwh sudo offers an SSH-agent fallback. If you accept, EDWH lists the public keys
available through ssh-add -L; select the key whose agent should unlock the encrypted EDWH keyring. The selection is
recorded in ~/.config/ssh-agent-keyring/config.json, the SSH-agent backend's documented configuration file, and later
EDWH commands automatically use that backend.
The SSH-agent backend requires a reachable agent, SSH_AUTH_SOCK, and the selected key loaded in that agent. For a
remote development machine, connect with agent forwarding (ssh -A or ForwardAgent yes). The sudo password remains
encrypted on disk; access depends on the selected SSH agent rather than a second keyring password.
edwh lint runs Ruff and Ty by default. Disable either tool for a specific project in its pyproject.toml:
[tool.edwh.lint]
ruff = false
ty = falseedwh fmt sorts imports and reformats Python code with Ruff.
edwh plugin.release warns when a project still releases with
python-semantic-release, and when the --hatch build fallback is selected.
Silence either for a project:
[tool.edwh.release]
warn-psr = false
warn-hatch-build = falseWarnings about the build configuration itself (Hatchling, the uv_build pin, a
hatch build release command) come from
vommit, which runs the build. Its
readme documents them under "Build checks", including how to silence one. When
using python-semantic-release they are not reported at all; that prints its
deprecation notice instead.
edwh test.run runs pytest with coverage by default. It prefers ./venv/bin/pytest, falls back to
pytest from PATH, and requires pytest-cov in the selected environment.
edwh test.run
edwh test.run -k test_specific_behavior
edwh test.run --html
edwh test.run --no-coverageTo generate both the terminal summary and an HTML report on every run, add this
to pyproject.toml:
[tool.edwh.test]
html = trueIf a project provides its own test.tasks.py, edwh warns about the override and that local test.run
replaces the built-in task.
edwh worktree <branch> builds a second, isolated environment for a branch: a git worktree plus the
gitignored config, its own ports and hostnames, and a seeded database. edwh worktree.rm <branch>
removes it again, containers and volumes included. If no local branch exists, it checks fetched
upstream branches first and creates a tracking branch when it finds one.
edwh worktree.setup # configure this project once (writes [worktree] to .toml)
edwh worktree feature/login # create + start
edwh worktree feature/login --no-up # create, do not start
edwh worktree.list # branches, slugs, paths, ports, running containers
cd $(edwh worktree.path feature/login)
edwh worktree.rm feature/login # containers, volumes, directory and branchWorktrees live under ~/.cache/edwh/worktrees/<repo>-<slug>; override with $EDWH_WORKTREE_ROOT
or root in the config. The slugified directory name keeps $PROJECT, the compose project and the
volume/container prefixes identical.
worktree copies the .env, deletes the keys that must be unique, and reruns
edwh setup --non-interactive so your local.setup regenerates them via next_value /
next_available_port. Port allocation also scans git worktree list, so environments find each
other even though they are not adjacent directories.
[worktree]
copy = [".env", ".toml", "shared_keys/"] # gitignored paths to carry over
reset = ["*_PORT", "SCHEMA_VERSION"] # fnmatch globs; deleted so local.setup recomputes them
seed = "clone" # fresh | clone | devdb
[worktree.env]
# rewritten instead of regenerated; {value} {repo} {branch} {slug} available. All fields have defaults.
PROJECT = "{repo}-{slug}"worktree.setup proposes both lists: copy comes from .gitignore, and reset is detected from
keys on the host side of a ports: mapping, a Traefik Host() rule, a Caddy caddy site-address
label, or HOSTINGDOMAIN(S), plus keys with a unique value in every existing checkout on this
machine. Values shared by any checkout are otherwise left unticked, since they are likely shared or
machine-specific config.
COMPOSE_PROJECT_NAME is always reset, since a copied value would fuse the environments. Ticked
keys collapse back to a glob when the glob covers exactly your selection.
Resetting only produces a new value when local.setup's default is environment-aware
(next_value, ports, os.getcwd()). With a constant default like "localhost" it silently
rewrites the same value, and both environments share it. Those keys need a [worktree.env]
template instead; after setup, worktree warns about any reset that came back identical.
HOSTINGDOMAIN and HOSTINGDOMAINS are the exception: when either is in reset, worktree
asks for its replacement before it creates anything. It never copies the source value or accepts
an empty answer. Scripts can provide the answer with --env HOSTINGDOMAIN=branch.localhost
(repeat --env for each requested hostname key). Do not also put a prompted hostname in
[worktree.env]: that section is only for automatic templates.
Seeding:
fresh- leave it empty and letmigratefill it.clone- copy the source's named docker volumes via a throwaway container each. Project-agnostic and current data, but services mounted on a copied volume pause in the source, soworktreeasks first unless--yes. Note it copies everything: N worktrees means N copies of the full volume.devdb- rundevdb.recoverafterupusing the trimmededwh-devdb-pluginsnapshot (putmigrate/data/snapshot/incopy). Postgres only, much smaller than a clone.
After seeding, a worktree task in your project's tasks.py runs last, inside the new worktree
(e.g. c.run("./bin/load-fixtures")).
Traefik routes on Host(), so overlapping hostnames make it pick a router at random, breaking both
environments. Rewriting HOSTINGDOMAIN covers all rules, but needs wildcard DNS one label deeper
and therefore a wildcard certificate (or CERTRESOLVER=default locally).
worktree compares the traefik Host() labels of the new environment against every other
checkout and refuses to up on an overlap. --force starts it anyway.
Commands are loaded in the following order:
-
EDWH Package:
- Loaded into the global namespace and its own namespaces (like
edwh plugins.).
- Loaded into the global namespace and its own namespaces (like
-
Plugins:
- Loaded into their own namespaces (like
edwh mp.).
- Loaded into their own namespaces (like
-
Current Directory:
- Loaded into the
local.namespace. If it doesn't exist, it traverses up the directory tree - (e.g.,
../tasks.py,../../tasks.py).
- Loaded into the
-
Other Local Tasks:
- Other local tasks with their own namespace are loaded (e.g.,
namespace.tasks.py) and can be invoked withedwh namespace.command.
- Other local tasks with their own namespace are loaded (e.g.,
-
Personal Global Tasks:
- Personal global tasks (e.g.,
~/.config/edwh/tasks.py) are also loaded into the global namespace, useful for shortcuts, custom aliases, etc. (+add_alias).
- Personal global tasks (e.g.,
-
Personal Namespaced Tasks:
- Personal tasks with their own namespace (e.g.,
~/.config/edwh/namespace.tasks.py). Similar to a plugin, but for personal use.
- Personal tasks with their own namespace (e.g.,
- pip name:
edwh-multipass-plugin - github:
educationwarehouse/edwh-multipass-plugin - plugin name:
edwh[multipass] - subcommand namespace:
mp
- pip name:
edwh-restic-plugin - github:
educationwarehouse/edwh-restic-plugin - plugin name:
edwh[restic] - subcommand namespace:
restic
- pip name:
edwh-pipcompile-plugin - github:
educationwarehouse/edwh-pipcompile-plugin - plugin name:
edwh[pip] - subcommand namespace:
pip
- pip name:
edwh-bundler-plugin - github:
educationwarehouse/edwh-bundler-plugin - plugin name:
edwh[bundler] - subcommand namespace:
bundle
- pip name:
edwh-server-provisioning-plugin - github:
educationwarehouse/server_provisioning - plugin name:
edwh[server-provisioning] - subcommand namespace:
remote
- pip name:
edwh-b2-plugin - github:
educationwarehouse/edwh-b2-plugin - plugin name:
edwh[b2] - subcommand namespace:
b2
- pip name:
edwh-locust-plugin - github:
educationwarehouse/edwh-locust-plugin - plugin name:
edwh[locust] - subcommand namespace:
locust
- pip name:
edwh-sshkey-plugin - github:
educationwarehouse/edwh-sshkey-plugin - plugin name
edwh[sshkey] - subcommand namespace
sshkey
- pip name:
edwh-sshfs-plugin - github:
educationwarehouse/edwh-sshfs-plugin - plugin name
edwh[sshfs] - subcommand namespace
sshfs
- pip name:
edwh-files-plugin - github:
educationwarehouse/edwh-files-plugin - plugin name
edwh[files] - subcommand namespace
file
- pip name:
edwh-whitelabel-plugin - github:
educationwarehouse/edwh-whitelabel-plugin - plugin name
edwh[whitelabel] - subcommand namespace
wl
- pip name:
edwh-devdb-plugin - github:
educationwarehouse/edwh-devdb-plugin - plugin name
edwh[devdb] - subcommand namespace
devdb
The edwh.improved_task decorator enhances the functionality of the standard @task decorator from Invoke by
introducing additional features:
-
Flags: You can now specify custom flags for command line arguments. This allows you to define aliases, rename arguments (e.g., using
--jsonfor an argument namedas_json), and create custom short flags (e.g.,--excludecan also be represented as-x). -
Hookable: The improved task supports hooks that allow you to run additional tasks after the main task execution. If the
hookableoption is set toTrue, any tasks found across namespaces with the same name will be executed in sequence, passing along the context and any provided arguments.
The return value of a hookable task will be available in the context under the key result.
Using a dictionary as the return value is recommended, as it allows you to merge the results of multiple cascading
tasks.
from edwh import improved_task as task
@task(flags={"exclude": ["--exclude", "-x"], "as_json": ["--json"]}, hookable=True)
def process_data(ctx, exclude: str, as_json: bool = False):
# Task implementation here
return {
"data": [],
}
# other plugin (or local tasks.py) can now also specify 'process_data':
@task()
def process_data(ctx, exclude: str):
# the cascading function can choose whether to include the arguments `exclude` and `as_json` or not.
# this can be cherry-picked as long as the names match the arguments of the main function.
print(
ctx["result"] # will contain {"data": []}
)edwh is distributed under the terms of the MIT license.