adhd is a small Python program for managing development environments for
multiple Python projects (it can reasonably be used for most anything, but it
has a Python-oriented focus on features). It started as a way to manage environment
variables in a more controlled way than .env files, and grew into what it is now.
If you're the sort of person who works on a lot of individual development
projects, each with its own unique build/run/deploy steps, adhd provides
a way of managing that without needing to revisit the project's README.
If you're the sort of person who also forgets details a lot (maybe because
you have ADHD), this tool can be quite helpful.
adhd is similar to a make in that you can define jobs to be run, and those
jobs in turn can define dependent jobs that will also be run. Jobs can be
conditionally run depending on the return codes of user-defined shell commands.
The original purpose of adhd was as a replacement for .env files, but of course
running jobs made sense, and then you need job ordering and dependencies, and plugins
sound cool, So here we are.
It is important to note that
adhdis not meant to replacemakeor other tools, and it's not expected that youradhdconfig will live in the project directory to be shared with others. Ratheradhdis a way to personalize and simplify your workflow without impacting other people working on the same project. In many cases anadhdproject will be nothing more than a thin wrapper aroundmake.
adhd has the following features:
- Automatic creation and activation of a Python virtual environment.
- Automatic installation of Python dependencies via
requirements.txt. - Automatic check of
requirements.txtfor updates. - Automatic AWS MFA session management.
- Automatic launching of web pages.
- User-defined jobs with dependency resolution and ability to skip jobs based on the output of shell commands.
- User-defined environment variables with dependency resolution.
- Ability to run arbitrary shell commands from the CLI.
- Configuration can be kept outside project's git repository.
- Does not need to be run from project directory.
- Reasonably self-documenting project configuration.
- Manages multiple projects, and allows creating a common interface for varied development environments.
-
Install:
$ git clone git@github.com:cwells/adhd.git ~/.adhd $ pip install -r ~/.adhd/requirements.txt $ chmod +x ~/.adhd/bin/adhd $ ln -s ~/.adhd/bin/adhd ~/.local/bin/adhd -
Start the included Django app:
$ adhd example django/up -
Stop the Django app:
$ adhd example django/down -
Cleanup (you'll be prompted to remove directory):
$ adhd example django/destroy -
To see the example config (you probably should have read this first):
$ cat ~/.adhd/projects/example.yaml
The adhd CLI has the following interface:
$ adhd example --help
Usage: adhd [OPTIONS] PROJECT [COMMAND]..
Options:
--home / --no-home Change to project HOME directory
-e, --env ENV Define env var(s) from CLI
-p, --plugin PLUGIN Manage plugins using plugin:[on|off]
--explain Display help text from job and its dependencies
-v, --verbose Send stdout of all jobs to console
--debug Generate extremely verbose output
-f, --force Bypass skip checks
--help Show this message and exit
Enter a virtual environment:
$ adhd example -- bash
Run a predefined job to start ./manage.py shell:
$ adhd example django/shell
If you run arbitrary shell commands, remember to put
--before the command so that your shell knows which options are foradhdand which are for the command.
There is no install. Extract the archive (or git clone) into ~/.adhd and
create a symlink on your $PATH to ~/.adhd/bin/adhd, e.g.
$ ln -s ~/.adhd/bin/adhd ~/.local/bin/adhd
The adhd configuration is dynamic, based upon the name of the executable (by
default adhd). If your executable is named adhd, then the config directory
will be ~/.adhd. If you create a symlink
$ ln -s ~/.adhd/bin/adhd ~/.local/bin/woot
then the configuration will be looked for in ~/.woot when you run woot. This allows you to simply manage multiple versions of adhd across disparate projects without involving packages.
A working Django project is included in the projects/ directory. It requires no
setup, just run:
$ adhd example django/up
This will:
- create a virtualenv and install Django
- run
django-admin startproject(Django docs here) to create a basic Django project - and finally, open the front page in your browser to verify functionality.
A typical config dir will look something like this:
.adhd
├── bin/
│ ├── adhd
│ ├── lib/
│ └── plugins/
├── projects/
│ ├── project1.yaml
│ ├── project2.yaml
│ ├── project3.yaml
│ ├── ...
│ └── projectn.yaml
└── requirements.txt
Each project file will have the following form:
home: str # path to project directory
venv: Optional[str] # path to virtual environment
tmp: Optional[str] # path to store temporary files
requirements: Optional[list[str]] # list of required binaries
pager: Literal["color"]|bool # type of pager: bool == enable/disable, "color" enable with color support.
plugins: # dictionary of plugin configurations. see "adhd example help plugins --verbose"
<identifier>: { <plugin configuration> }
jobs:
<identifier>:
after: Optional[str | list[str]] # jobs or plugins that this job depends upon
confirm: Optional[str] # Ask user a y/n question and abort if no
env: Optional[dict[str, Any]] # define job-specific env vars
help: Optional[str] # help text for this job
home: Optional[str] # ff not set defaults to global value
interactive: Optional[bool] # let output go to the console
lock: Optional[bool] # Whether to acquire lock while running job. Useful if job doesn't exit.
open: Optional[str | list[str]] # URI(s) to open after command
run: str | list[str] # the command(s) to be run
skip: Optional[bool] # skip dependency if value is True
sleep: Optional[int] # seconds to sleep after executing command
<identifier>:
env: Optional[dict[str, Any]] # define global environment variablesThe
confirmdirective allows for the use of colors and emoji as described in the Rich documentation.
adhd acquires a lock when entering "critical" sections, e.g. sections that may alter the filesystem. This includes both loading plugins as well as tasks. Sometimes this behavior isn't desired, specifically when a task may not terminate (e.g. opening an interactive shell) as this will prevent you from running adhd for that same project in another terminal. If you know a job doesn't alter the system in a way that will interfere with another instance of adhd, then you can set lock: false and the lock will not be acquired.
Access and set environment variables
!env performs variable substitution on strings containing ${var}. If a the
config references an undefined variable, the program leave the reference intact,
assuming the subshell will be able to resolve it.
ARCHIVE: !env ${USER}-archive.tgzNote that there are two phases for variable substitution: assembly-time (while evaluating the YAML source) and run-time (in the shell environment), so
FOO: ${BAR}would simply evaluate to the literal string"${BAR}", which is generally what you want, as$BARwill presumably be in the environment when it's needed.On the other hand,
FOO: !env ${BAR}causes the program to attempt to resolve the value of${BAR}as soon as possible so that it can be used in other parts of the configuration. As such, a dependency-resolution tree is maintained to ensure required values are present when the variable is evaluated.This is why you can use variables with other tags such as
!shell: the shell just ends up using the unevaluated string, but it doesn't matter since the value will be present in the environment.
One reason to use !env is that environment variables are not passed through
from the parent shell by default ($PATH being a notable exception).
If you want to pass through a variable, use the following form:
HOME: !env ${HOME}Without !env, ${HOME} would just be the plain string and be empty in the
subshell.
There are built-in variables that can be used in the config, but won't be passed to the user's command:
${__DATE__}- static date string, in the format"%Y%m%d"${__TIME__}- static time string, in the format"%H%M%S"
Example use:
ARCHIVE: !env ../../staging_dataset_${__DATE__}.tar.gzExecute arbitrary shell commands and use their results. There are a handful of variants:
!shell_eq_0 <command> executes the command in a subshell, and evaluates to true if the exit status of the command is zero, otherwise evaluates to
false.
Inverse functionality is available as !shell_neq_0.
skip: !shell_eq_0 fuser -s 8000/tcpIn the above example, the job would be skipped if a process were seen listening
on port 8000/tcp.
!shell_stdout command executes the command in a subshell, and evaluates to
the value the command writes to stdout.
EXTERNAL_ROUTE: !shell_stdout ip -json route get 1 | jq -r '.[0].prefsrc'In the above example, EXTERNAL_ROUTE would be the IP address of the machine's
external interface.
string concatenation
-
!catconcatenates a list of strings with no space between each item. -
!catsconcatenates a list of strings with a single space between each item. -
!pathconcatenates a list of strings with '/' and returns a normalized path (~and..will be substituted and collapsed). -
!urlconcatenates a list of strings with '/' into a URL. -
!existsconcatenates list of strings into a path and returns True if path exists. -
!not_existsconcatenates list of strings into a path and returns False if path exists.SECRET: !cat [ because, is, hat ] API_ENDPOINT: !url [ https://domain.com/cust/, *cust_id, /api ] DATA_DIR: !path [ "~", foo, bar, data_dir ] binary: !exists [ "~/.bin", *bin_name ]
CAVEAT:
!existsand!pathtags will not have the project home as their working directory due to the fact that this information isn't available when they are evaluated. This means you must prefix any paths with*home, e.g.:skip: !exists [ *home, "/tmp/process.pid" ]This is unlike
rundirectives, which will have the project home as their current working directory. This is a bug and will be addressed in a future release.
It may be useful to load other YAML files (e.g. for common env variables in project home directory). The format of the included file is exactly the same as the primary project file. Dictionaries will be recursively merged.
!include more.yamlYou may define jobs in the jobs section of the YAML config file. A job can
depend on other jobs or plugins, indicated by the key after:
django/up:
run: ./manage.py runserver &
skip: !shell_eq_0 fuser -s 8000/tcp
after: [ plugin:python, django/bootstrap, django/migrate ]If django/up is run, it will first load the python plugin, then run
django/bootstrap and django/migrate (and these in turn may have other
dependencies). If you don't autoload want a job to run, you can add the skip
directive, followed by a test that evaluates the output of a shell command.
Jobs and plugins are only run once, regardless of how many other jobs may depend on them.
Every job can have one or more tasks that compose it (it's possible to have no tasks and only have dependencies). Tasks are simply shell commands, run in sequence.
A job with a single task:
django/up:
help: Start the Django web server.
run: "./manage.py runserver &"
skip: !shell_eq_0 fuser -s 8000/tcp
interactive: true
after: [ plugin:python, plugin:aws, docker/up, django/seed ]
A job with no tasks:
stack/up:
help: Bring up all required services.
after: [ docker/up, django/up, ngrok/up ]
Tasks typically run in their own subprocess. Each item in the run list will
be run in a separate process:
db/sync:
help: Sync staging database to dev database.
run:
- echo "Starting database sync..."
- pg_dumpall staging > db.sql
- psql dev < db.sql
- rm -f db.sql
- echo "Finished syncing database."
confirm: \nSync staging database to dev?
This means that you cannot set an env variable in one line and then use it in the next. Each task gets a clean slate.
If you want tasks to share a subprocess (similar to make's .ONESHELL
option), use the following format:
db/sync:
help: Sync staging database to dev database.
run: |
echo "Starting database sync..."
TMPFILE=db.sql
pg_dumpall staging > ${TMPFILE}
psql dev < ${TMPFILE}
rm -f ${TMPFILE}
echo "Finished syncing database."
confirm: \nSync staging database to dev?
You can see available jobs using the --help-jobs option:
$ adhd example --help-jobs
which would output
$ adhd example --help-jobs
⚪ up ................ Run django/up and open the front and admin pages.
⚪ down .............. Alias for django/down.
⚪ shell ............. Open a shell with the Python virtual environment activated.
⚪ django/up ......... Start the Django web server.
⚪ django/shell ...... Start an interactive Django Python REPL.
⚪ django/down ....... Stop the Django web server.
⚪ django/migrate .... Run Django database migrations
⚪ django/bootstrap .. Bootstrap the Django project.
⚪ django/destroy .... Remove installation directory (you will be prompted first).
And don't forget that you can run arbitrary shell commands. Assuming you have configured the AWS plugin, you can try something like:
$ adhd example -- aws s3 ls
which would output something like:
Starting aws s3 ls
2020-04-28 11:27:21 my-prod-files
2020-05-21 14:07:06 my-stage-files
You can also enter the virtual environment just by spawning a subshell:
$ adhd example bash -p python:on
⚫ Finished installing Python packages
$ python
Python 3.11.4 (main, Jun 7 2023, 00:00:00) [GCC 13.1.1 20230511 (Red Hat 13.1.1-2)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import django
>>> django.setup()
>>>
If you have autoload: false for the python plugin, then the virtual env won't be started.
You can forcibly load the plugin from the cli, which will cause the virtual env to be activated:
$ adhd example --plugin python:on bash
Alternately, you can just add another command to make this automatic:
jobs:
shell:
help: Enter the Python virtual environment for this project.
run: !env ${SHELL}
interactive: true
after: plugin:pythonand then run
$ adhd example shell
You can get a list of available plugins and their help with:
$ adhd example help plugins
⚫ mod_aws Configure AWS session with MFA.
aws:
profile: default # profile name from .aws/credentials
username: john.doe # AWS username
account: 123456789012 # AWS account ID
region: eu-west-1 # AWS region
mfa:
device: MyDevice # last part of ARN "arn:aws:iam::123456789012:mfa/MyDevice"
expiry: 86400 # TTL for token (will prompt for MFA code upon expiry)
Session will be cached in "tmp" for "expiry" seconds and you wont be prompted
for MFA code until that time, even across multple invokations and multiple shells.
⚫ mod_python Configure Python virtual environment.
python:
venv: ~/myproject/.venv # location venv will be created
requirements: ~/myproject/requirements.txt # optional requirements.txt to be installed
packages: [ requests, PyYAML==5.4.1 ] # additional packages to install
If `virtualenv` package is missing, plugin will still work with an existing
virtual environment, but won't be able to create a new one.
You may enable or disable individual plugins on the command line.
$ adhd example --plugin aws:off bash # don't prompt for mfa code
$ adhd example --plugin python:on bash # ensure we enter venv
Plugins can be enabled in the plugins section of your project config:
plugins:
python:
autoload: false
venv: !path [ *home, venv ]
packages: [ Django ]
dotenv:
autoload: true
files:
- !path [ ~/.test.env ]The autoload key specifies whether to load the plugin at startup, or to only make
it available as a job dependency. Note that if one dependency loads a plugin, it
will be available from that point forward.
jobs:
aws/shell:
help: Shell with AWS authentication.
after: plugin:awsSome plugins support unloading as a dependency using unplug instead of plugin:
jobs:
safe/shell:
help: Shell without AWS authentication.
after: unplug:awsNote that any processes that were already run will not be affected by unloading a plugin.
Some plugins provide methods that can be used as dependencies:
stack/up:
help: Bring up all required services.
after:
- docker/up
- django/up
- plugin:ngrok.statusThe above code will print the ngrok public URLs after bringing up the stack.
You may also call plugins and their methods from the cli:
# bring up ngrok tunnel
$ adhd example plugin:ngrok
# check the tunnel stqtus
$ adhd example plugin:ngrok.status
Some plugin methods may require the plugin to be loaded first. If so, they will automatically load the plugin. This is noted in the plugin's help.
Currently bash completions are supported by the file ~/.adhd/bin/bash_completion.py.
To enable completions, add the following to your ~/.bashrc:
complete -C ~/.adhd/bin/bash_completion.py adhd
or, if you've symlinked adhd to a different name, say ~/.local/bin/foo,
complete -C ~/.adhd/bin/bash_completion.py foo
To help speed up completion, completions are cached alongside their project YAML with the same basename as the YAML, but with as a hidden file with the extension .completions.
For example, given a project file of foo.yaml, the completion cache will be named .foo.completions in the same directory.