├── tests ├── __init__.py ├── test_cli.py └── test_config.py ├── workenv ├── __init__.py ├── __main__.py ├── io.py ├── constants.py ├── cli.py ├── actions.py ├── bash.py └── config.py ├── requirements.txt ├── .gitignore ├── .pre-commit-config.yaml ├── changelog.rst ├── .github └── workflows │ ├── pypi.yml │ └── ci.yml ├── LICENSE ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /workenv/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manage top-level imports 3 | """ 4 | 5 | __version__ = "2.1.1" 6 | -------------------------------------------------------------------------------- /workenv/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import run 2 | 3 | if __name__ == "__main__": 4 | run() 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Dev 2 | pytest 3 | pytest-cov 4 | mypy 5 | types-PyYAML 6 | uv 7 | 8 | # Project 9 | pyyaml 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache/* 3 | .mypy_cache/* 4 | .coverage 5 | htmlcov/* 6 | *.log 7 | .eggs 8 | build 9 | dist 10 | *.egg-info 11 | .tox 12 | -------------------------------------------------------------------------------- /workenv/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple io wrappers 3 | """ 4 | 5 | import sys 6 | 7 | 8 | def echo(line): 9 | sys.stdout.write(line) 10 | sys.stdout.write("\n") 11 | 12 | 13 | def error(line): 14 | sys.stderr.write(line) 15 | sys.stderr.write("\n") 16 | -------------------------------------------------------------------------------- /workenv/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants 3 | """ 4 | 5 | COMMAND_NAME = "we" 6 | COMMAND_VAR = "_WORKENV_COMMAND" 7 | COMPLETE_VAR = "_WORKENV_COMPLETE" 8 | CONFIG_DEFAULT_FILENAME = "~/.workenv_config.yml" 9 | CONFIG_ENV_VAR = "WORKENV_CONFIG_PATH" 10 | PROJECT_DEFAULT_FILENAME = "workenv.yaml" 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: trailing-whitespace 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.4.10 9 | hooks: 10 | # Run the linter with import sorting 11 | - id: ruff 12 | args: ["check", "--select", "I", "--fix"] 13 | # Run the formatter 14 | - id: ruff-format -------------------------------------------------------------------------------- /changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 2.1.2 - 2025-11-07 6 | ================== 7 | 8 | Bugfix: 9 | 10 | * Fix infinite recursion during yaml parse 11 | 12 | 13 | 2.1.1 - 2024-10-29 14 | ================== 15 | 16 | Bugfix: 17 | 18 | * Correct initialisation of deferred projects 19 | 20 | 21 | 2.1.0 - 2024-08-14 22 | ================== 23 | 24 | Features: 25 | 26 | * Add ``config`` project attribute to support configs in project dir 27 | * Can use as a module, ``python -m workenv`` 28 | * Improve error handling 29 | 30 | 31 | 2.0.1 - 2023-01-08 32 | ================== 33 | 34 | Features: 35 | 36 | * Add ``{{project.slug}}`` support 37 | 38 | 39 | 2.0.0 - 2020-06-21 40 | ================== 41 | 42 | * First public release 43 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Build and publish to PyPI 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.11 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install --upgrade setuptools build wheel 23 | - name: Build a binary wheel and a source tarball 24 | run: | 25 | python -m build 26 | - name: Publish to PyPI 27 | if: startsWith(github.ref, 'refs/tags') 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: py-${{ matrix.python }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | - python: "3.12" 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | - name: Set Python path 27 | run: | 28 | echo "PYTHONPATH=." >> $GITHUB_ENV 29 | - name: Test 30 | run: | 31 | pytest 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v4 34 | with: 35 | name: ${{ matrix.python }} 36 | env: 37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Workenv is licensed under the BSD License 2 | ========================================= 3 | 4 | Copyright (c) 2020, Richard Terry, http://radiac.net/ 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | Neither the name of the software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "workenv" 7 | description = "Manage local work environments" 8 | dynamic = ["version"] 9 | authors = [ 10 | { name="Richard Terry", email="code@radiac.net" }, 11 | ] 12 | readme = "README.md" 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Operating System :: OS Independent", 18 | "Environment :: Console", 19 | "Topic :: System :: System Shells", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3", 22 | ] 23 | keywords = ["bash"] 24 | requires-python = ">=3.8" 25 | dependencies = [ 26 | "pyyaml", 27 | ] 28 | 29 | [project.scripts] 30 | workenv = "workenv.cli:run" 31 | 32 | [project.urls] 33 | Homepage = "https://radiac.net/projects/workenv/" 34 | Changelog = "https://github.com/radiac/workenv/blob/main/changelog.rst" 35 | Repository = "https://github.com/radiac/workenv" 36 | Issues = "https://github.com/radiac/workenv/issues" 37 | 38 | [tool.setuptools] 39 | include-package-data = true 40 | 41 | [tool.setuptools.packages.find] 42 | include = ["workenv*"] 43 | exclude = ["docs*", "tests*", "src*", "dist*"] 44 | 45 | [tool.setuptools.dynamic] 46 | version = {attr = "workenv.__version__"} 47 | 48 | [tool.pytest.ini_options] 49 | addopts = "--cov=workenv --cov-report=term --cov-report=html" 50 | testpaths = [ 51 | "tests", 52 | "workenv", 53 | ] 54 | 55 | [tool.coverage.run] 56 | source = ["workenv"] 57 | 58 | [tool.black] 59 | line-length = 88 60 | target-version = ["py312"] 61 | include = "\\.pyi?$" 62 | 63 | [tool.isort] 64 | multi_line_output = 3 65 | line_length = 88 66 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 67 | include_trailing_comma = true 68 | lines_after_imports = 2 69 | skip = [".git"] 70 | 71 | [tool.mypy] 72 | follow_imports = "skip" 73 | ignore_missing_imports = true 74 | 75 | [tool.doc8] 76 | max-line-length = 88 77 | ignore-path = ["*.txt"] 78 | 79 | [tool.ruff] 80 | line-length = 88 81 | lint.select = ["E", "F"] 82 | lint.ignore = [ 83 | "E501", # line length 84 | ] 85 | exclude = [ 86 | ".git", 87 | "dist", 88 | ] 89 | -------------------------------------------------------------------------------- /workenv/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line definition 3 | """ 4 | 5 | import os 6 | import sys 7 | from pathlib import Path 8 | 9 | from .actions import registry as action_registry 10 | from .bash import autocomplete 11 | from .config import Config, ConfigError 12 | from .constants import COMMAND_VAR, CONFIG_DEFAULT_FILENAME, CONFIG_ENV_VAR 13 | from .io import echo, error 14 | 15 | 16 | def get_config_path() -> Path: 17 | path_str = os.environ.get(CONFIG_ENV_VAR, CONFIG_DEFAULT_FILENAME) 18 | return Path(path_str).expanduser() 19 | 20 | 21 | def run(): 22 | try: 23 | config = Config(file=get_config_path()) 24 | except ConfigError as e: 25 | error(f"Could not load config: {e.message}") 26 | return 27 | 28 | completions = autocomplete(config) 29 | if completions is not None: 30 | for completion in completions: 31 | echo(completion) 32 | return 33 | 34 | args = [arg for arg in sys.argv[1:] if not arg.startswith("--")] 35 | actions = [action[2:] for action in sys.argv[1:] if action.startswith("--")] 36 | 37 | if len(actions) > 1 or (len(actions) == 0 and len(args) == 0) or len(args) > 2: 38 | command_name = os.environ.get(COMMAND_VAR, "we") 39 | error(f"Usage: {command_name} []") 40 | error(f"Usage: {command_name} [ []]") 41 | return 42 | 43 | if actions: 44 | action = actions[0].lower() 45 | if action in action_registry: 46 | action_registry[action](config, actions, args) 47 | return 48 | else: 49 | error(f"Unknown action {action}") 50 | return 51 | 52 | project_name = args[0] 53 | if project_name not in config.projects: 54 | error(f"Unknown project {project_name}") 55 | return 56 | project = config.projects[project_name] 57 | 58 | if len(args) == 2: 59 | command_name = args[1] 60 | if command_name not in project.commands: 61 | error(f"Unknown command {command_name} for {project_name}") 62 | return 63 | command = project.commands[command_name] 64 | 65 | # If command is common, we need to change its context 66 | if command.parent != project: 67 | command = command.clone_to(project) 68 | 69 | shell_cmds = command() 70 | else: 71 | shell_cmds = project() 72 | 73 | for shell_cmd in shell_cmds: 74 | echo(shell_cmd) 75 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test command line 3 | """ 4 | 5 | import sys 6 | 7 | import pytest 8 | 9 | from workenv.cli import run 10 | 11 | config_sample = """ 12 | _common: 13 | env: 14 | COMMON: value_common_{{project.name}} 15 | commands: 16 | open: 17 | run: xdg-open . 18 | env: 19 | PROJECT_NAME: "{{project.name}}" 20 | project: 21 | path: /path/1 22 | env: 23 | PROJECT: value_project_{{project.name}} 24 | run: pwd 25 | commands: 26 | list: 27 | run: ls 28 | """ 29 | 30 | 31 | @pytest.fixture 32 | def config_file(monkeypatch, tmp_path): 33 | file = tmp_path / "workenv_config.yml" 34 | monkeypatch.setenv("WORKENV_CONFIG_PATH", str(file)) 35 | return file 36 | 37 | 38 | def test_run_project(capsys, monkeypatch, config_file): 39 | config_file.write_text(config_sample) 40 | monkeypatch.setattr(sys, "argv", ["workenv", "project"]) 41 | run() 42 | captured = capsys.readouterr() 43 | assert ( 44 | captured.out 45 | == "\n".join( 46 | [ 47 | "cd /path/1", 48 | "export COMMON=value_common_project", 49 | "export PROJECT=value_project_project", 50 | "pwd", 51 | ] 52 | ) 53 | + "\n" 54 | ) 55 | 56 | 57 | def test_run_common_command(capsys, monkeypatch, config_file): 58 | config_file.write_text(config_sample) 59 | monkeypatch.setattr(sys, "argv", ["workenv", "project", "open"]) 60 | run() 61 | captured = capsys.readouterr() 62 | assert ( 63 | captured.out 64 | == "\n".join( 65 | [ 66 | "cd /path/1", 67 | "export COMMON=value_common_project", 68 | "export PROJECT_NAME=project", 69 | "xdg-open .", 70 | ] 71 | ) 72 | + "\n" 73 | ) 74 | 75 | 76 | def test_run_project_command(capsys, monkeypatch, config_file): 77 | config_file.write_text(config_sample) 78 | monkeypatch.setattr(sys, "argv", ["workenv", "project", "list"]) 79 | run() 80 | captured = capsys.readouterr() 81 | assert ( 82 | captured.out 83 | == "\n".join( 84 | [ 85 | "cd /path/1", 86 | "export COMMON=value_common_project", 87 | "export PROJECT=value_project_project", 88 | "ls", 89 | ] 90 | ) 91 | + "\n" 92 | ) 93 | 94 | 95 | # TODO: 96 | def test_add_no_arguments(): 97 | """ 98 | Traceback (most recent call last): 99 | File "/home/radiac/work/projects/workenv/venv/bin/workenv", line 11, in 100 | load_entry_point('workenv', 'console_scripts', 'workenv')() 101 | File "/home/radiac/work/projects/workenv/repo/workenv/cli.py", line 42, in run 102 | action_registry[action](config, actions, args) 103 | File "/home/radiac/work/projects/workenv/repo/workenv/actions.py", line 57, in add 104 | project_name, command_name = (args + [None])[0:2] 105 | ValueError: not enough values to unpack (expected 2, got 1) 106 | """ 107 | -------------------------------------------------------------------------------- /workenv/actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line actions to manage workenv 3 | """ 4 | 5 | import os 6 | import subprocess 7 | from pathlib import Path 8 | 9 | from . import bash 10 | from .config import Command, DeferredProject, Project 11 | from .constants import COMMAND_NAME, PROJECT_DEFAULT_FILENAME 12 | from .io import echo, error 13 | 14 | registry = {} 15 | 16 | 17 | def action(fn): 18 | def wrap(config, actions, args): 19 | return fn(config, actions, args) 20 | 21 | registry[fn.__name__] = fn 22 | return wrap 23 | 24 | 25 | @action 26 | def install(config, actions, args): 27 | """ 28 | Install workenv into your bashrc 29 | """ 30 | command_name = COMMAND_NAME 31 | if len(args) == 1: 32 | command_name = args[0] 33 | elif len(args) > 1: 34 | error("Usage: workenv --install []") 35 | return 36 | bash.install(command_name) 37 | echo(f"Installed as {command_name}, open a new shell to use") 38 | 39 | 40 | @action 41 | def edit(config, actions, args): 42 | """ 43 | Open the yaml source in the shell editor 44 | """ 45 | if len(args) > 0: 46 | error("Usage: workenv --edit") 47 | editor = os.environ.get("EDITOR") or "vim" 48 | subprocess.call([editor, config.file]) 49 | 50 | 51 | @action 52 | def add(config, actions, args): 53 | """ 54 | Register a new project or command using the current path 55 | """ 56 | cwd = Path.cwd() 57 | if len(args) == 0: 58 | error("Must specify a project name to add it") 59 | return 60 | project_name, command_name = (args + [None])[0:2] 61 | 62 | # Get or create project 63 | if not command_name and project_name in config.projects: 64 | error(f"Project {project_name} already exists") 65 | return 66 | 67 | if project_name not in config.projects: 68 | if (cwd / PROJECT_DEFAULT_FILENAME).is_file(): 69 | config.projects[project_name] = DeferredProject( 70 | config=config, 71 | name=project_name, 72 | path=cwd, 73 | ) 74 | else: 75 | config.projects[project_name] = Project( 76 | config=config, 77 | name=project_name, 78 | path=cwd, 79 | source=[], 80 | env={}, 81 | run=[], 82 | parent=None, 83 | ) 84 | project = config.projects[project_name] 85 | 86 | if not command_name: 87 | config.save() 88 | echo(f"Added project {project_name}") 89 | return 90 | 91 | if command_name in project.commands: 92 | error(f"Command {command_name} already exists in project {project_name}") 93 | return 94 | 95 | project.commands[command_name] = Command( 96 | config=config, 97 | name=command_name, 98 | path=cwd, 99 | source=[], 100 | env={}, 101 | run=[], 102 | parent=project, 103 | ) 104 | 105 | config.save() 106 | echo(f"Added command {command_name} to project {project_name}") 107 | 108 | 109 | @action 110 | def remove(config, actions, args): 111 | """ 112 | Remove a project from workenv 113 | """ 114 | if len(args) == 0: 115 | error("Must specify a project name to remove it") 116 | return 117 | 118 | project_name = args[0] 119 | 120 | if project_name not in config.projects: 121 | error(f"Project {project_name} not found") 122 | 123 | del config.projects[project_name] 124 | config.save() 125 | echo(f"Removed {project_name}") 126 | -------------------------------------------------------------------------------- /workenv/bash.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bash autocompletion 3 | 4 | Based on click 5 | """ 6 | 7 | import datetime 8 | import os 9 | import re 10 | import shutil 11 | import sys 12 | from pathlib import Path 13 | 14 | from .constants import COMMAND_VAR, COMPLETE_VAR, CONFIG_DEFAULT_FILENAME 15 | 16 | # The setup script to be added to .bashrc 17 | INSTALLATION_SCRIPT_BASH = """ 18 | # workenv autocomplete 19 | eval "$(%(command_var)s=%(command_name)s %(complete_var)s=setup %(script_path)s)" 20 | 21 | """ 22 | 23 | # The completion script to run from .bashrc 24 | COMPLETION_ECHO = """ 25 | echo "\\$ $CMD" 26 | """ 27 | COMPLETION_HISTORY = """ 28 | history -s $CMD 29 | """ 30 | COMPLETION_SCRIPT_BASH = """ 31 | %(command_name)s() { 32 | local IFS=$'\n' 33 | if [[ "$@" =~ (^| )--.* ]]; then 34 | %(script_path)s "$@" 35 | else 36 | CMDS=`%(script_path)s "$@"`; 37 | for CMD in $CMDS; do 38 | %(script_echo)s 39 | %(script_history)s 40 | eval $CMD 41 | done 42 | fi 43 | } 44 | %(complete_func)s() { 45 | local IFS=$'\n' 46 | COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ 47 | COMP_CWORD=$COMP_CWORD \\ 48 | %(complete_var)s=complete \\ 49 | %(script_path)s ) ) 50 | return 0 51 | } 52 | %(complete_func)s_setup() { 53 | local IFS=$' ' 54 | local COMPLETION_OPTIONS="" 55 | local BASH_VERSION_ARR=(${BASH_VERSION//./ }) 56 | # Only BASH version 4.4 and later have the nosort option. 57 | if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] \ 58 | && [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then 59 | COMPLETION_OPTIONS="-o nosort" 60 | fi 61 | complete $COMPLETION_OPTIONS -F %(complete_func)s %(command_name)s 62 | } 63 | %(complete_func)s_setup 64 | """ 65 | 66 | 67 | def get_script_path(): 68 | script_path = Path(sys.argv[0]) 69 | return script_path 70 | 71 | 72 | def split_arg_string(string): 73 | """Given an argument string this attempts to split it into small parts.""" 74 | rv = [] 75 | for match in re.finditer( 76 | r"('([^'\\]*(?:\\.[^'\\]*)*)'|\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\S+)\s*", 77 | string, 78 | re.S, 79 | ): 80 | arg = match.group().strip() 81 | if arg[:1] == arg[-1:] and arg[:1] in "\"'": 82 | arg = arg[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape") 83 | try: 84 | arg = type(string)(arg) 85 | except UnicodeError: 86 | pass 87 | rv.append(arg) 88 | return rv 89 | 90 | 91 | def get_completion_script(config, command_name): 92 | return ( 93 | COMPLETION_SCRIPT_BASH 94 | % { 95 | "complete_func": f"_{command_name}_completion", 96 | "command_name": command_name, 97 | "script_path": get_script_path(), 98 | "complete_var": COMPLETE_VAR, 99 | "script_echo": COMPLETION_ECHO if config.verbose else "", 100 | "script_history": COMPLETION_HISTORY if config.history else "", 101 | } 102 | ).strip() + ";" 103 | 104 | 105 | def autocomplete(config): 106 | complete_var = os.environ.get(COMPLETE_VAR) 107 | if complete_var is None: 108 | return None 109 | 110 | elif complete_var == "setup": 111 | command_name = os.environ.get(COMMAND_VAR) 112 | return [get_completion_script(config, command_name)] 113 | 114 | elif complete_var == "complete": 115 | return get_completion_words(config) 116 | 117 | else: 118 | raise ValueError(f"Unexpected value for env var {COMPLETE_VAR}: {complete_var}") 119 | 120 | 121 | def get_completion_words(config): 122 | if "COMP_WORDS" not in os.environ or "COMP_CWORD" not in os.environ: 123 | return None 124 | 125 | cwords = split_arg_string(os.environ["COMP_WORDS"]) 126 | cword = int(os.environ["COMP_CWORD"]) 127 | args = cwords[1:cword] 128 | try: 129 | incomplete = cwords[cword] 130 | except IndexError: 131 | incomplete = "" 132 | 133 | if len(args) == 0: 134 | # Completing a project 135 | completions = config.get_project_names() 136 | elif len(args) == 1: 137 | project = config.projects.get(args[0]) 138 | if not project: 139 | completions = [] 140 | completions = project.get_command_names() 141 | else: 142 | return [] 143 | 144 | # Filter 145 | return [ 146 | completion for completion in completions if completion.startswith(incomplete) 147 | ] 148 | 149 | 150 | def install(command_name): 151 | # Find path to script 152 | script_path = get_script_path() 153 | 154 | # Ensure there's a config file 155 | config_path = Path(CONFIG_DEFAULT_FILENAME).expanduser() 156 | config_path.touch() 157 | 158 | # Find and backup the bashrc 159 | bashrc = Path("~/.bashrc").expanduser() 160 | timestamp = datetime.datetime.now().strftime("%Y%M%d%H%m%S") 161 | shutil.copyfile(bashrc, bashrc.parent / f".bashrc.bak.{timestamp}") 162 | 163 | # Add to .bashrc 164 | with bashrc.open("a") as file: 165 | file.write( 166 | INSTALLATION_SCRIPT_BASH 167 | % { 168 | "command_var": COMMAND_VAR, 169 | "complete_var": COMPLETE_VAR, 170 | "command_name": command_name, 171 | "script_path": script_path, 172 | } 173 | ) 174 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test workenv/config.py from_dict 3 | """ 4 | 5 | from pathlib import Path 6 | 7 | from workenv.config import Command, Config, Project, var_pattern 8 | 9 | 10 | def test_project_attributes__parsed_to_project(): 11 | conf = Config() 12 | conf.loads( 13 | """ 14 | project: 15 | path: /path/1 16 | source: 17 | - /path/1/src/1 18 | - /path/1/src/2 19 | env: 20 | key: value 21 | run: 22 | - /path/1/run/1 23 | - /path/1/run/2 24 | """ 25 | ) 26 | 27 | assert list(conf.projects.keys()) == ["project"] 28 | 29 | project = conf.projects["project"] 30 | assert isinstance(project, Project) 31 | assert project.path == Path("/path/1") 32 | assert project.source == ["/path/1/src/1", "/path/1/src/2"] 33 | assert project.env == {"key": "value"} 34 | assert project.run == ["/path/1/run/1", "/path/1/run/2"] 35 | 36 | 37 | def test_project_command__parsed_to_command(): 38 | conf = Config() 39 | conf.loads( 40 | """ 41 | project: 42 | commands: 43 | command: 44 | path: /path/1/cmd/1 45 | source: 46 | - /path/1/cmd/1/src/1 47 | - /path/1/cmd/1/src/2 48 | run: 49 | - /path/1/cmd/1/run/1 50 | - /path/1/cmd/1/run/2 51 | """ 52 | ) 53 | 54 | assert list(conf.projects.keys()) == ["project"] 55 | 56 | project = conf.projects["project"] 57 | assert isinstance(project, Project) 58 | assert list(project.commands.keys()) == ["command"] 59 | 60 | project_command = project.commands["command"] 61 | assert isinstance(project_command, Command) 62 | assert project_command.path == Path("/path/1/cmd/1") 63 | assert project_command.source == ["/path/1/cmd/1/src/1", "/path/1/cmd/1/src/2"] 64 | assert project_command.run == ["/path/1/cmd/1/run/1", "/path/1/cmd/1/run/2"] 65 | 66 | 67 | def test_command_without_path__inherits_from_project(): 68 | conf = Config() 69 | conf.loads( 70 | """ 71 | project: 72 | path: /path/1 73 | commands: 74 | command: 75 | """ 76 | ) 77 | 78 | assert list(conf.projects.keys()) == ["project"] 79 | 80 | project = conf.projects["project"] 81 | assert isinstance(project, Project) 82 | assert project.path == Path("/path/1") 83 | assert project.commands["command"].path == Path("/path/1") 84 | 85 | 86 | def test_command_without_path__inherits_path_from_project(): 87 | conf = Config() 88 | conf.loads( 89 | """ 90 | project: 91 | path: /path/1 92 | commands: 93 | command: 94 | """ 95 | ) 96 | 97 | assert conf.projects["project"].commands["command"].path == Path("/path/1") 98 | 99 | 100 | def test_command_without_path_and_source__inherits_source_from_project(): 101 | conf = Config() 102 | conf.loads( 103 | """ 104 | project: 105 | source: /path/1/src/1 106 | commands: 107 | command: 108 | """ 109 | ) 110 | 111 | assert conf.projects["project"].commands["command"].source == ["/path/1/src/1"] 112 | 113 | 114 | def test_command_without_env__inherits_env_from_project(): 115 | conf = Config() 116 | conf.loads( 117 | """ 118 | project: 119 | env: 120 | key: value 121 | commands: 122 | command: 123 | """ 124 | ) 125 | 126 | assert conf.projects["project"].commands["command"].env == {"key": "value"} 127 | 128 | 129 | def test_common_source__project_includes_common_source(): 130 | conf = Config() 131 | conf.loads( 132 | """ 133 | _common: 134 | source: /path/1/src/1 135 | project: 136 | source: /path/2/src/1 137 | """ 138 | ) 139 | 140 | assert conf.projects["project"].source == ["/path/1/src/1", "/path/2/src/1"] 141 | 142 | 143 | def test_common_env__project_includes_common_env(): 144 | conf = Config() 145 | conf.loads( 146 | """ 147 | _common: 148 | env: 149 | key1: value1 150 | project: 151 | env: 152 | key2: value2 153 | """ 154 | ) 155 | 156 | assert conf.projects["project"].env == { 157 | "key1": "value1", 158 | "key2": "value2", 159 | } 160 | 161 | 162 | def test_common_run__project_includes_common_run(): 163 | conf = Config() 164 | conf.loads( 165 | """ 166 | _common: 167 | run: /path/1/run/1 168 | project: 169 | run: /path/2/run/1 170 | """ 171 | ) 172 | 173 | assert conf.projects["project"].run == ["/path/1/run/1", "/path/2/run/1"] 174 | 175 | 176 | def test_common_command__project_includes_common_command(): 177 | conf = Config() 178 | conf.loads( 179 | """ 180 | _common: 181 | commands: 182 | open: 183 | run: xdg-open . 184 | project: 185 | path: /path/1 186 | """ 187 | ) 188 | 189 | assert "open" in conf.projects["project"].commands 190 | assert conf.projects["project"].commands["open"].run == ["xdg-open ."] 191 | 192 | 193 | def test_common_command__clone_to_project__path_correct(): 194 | conf = Config() 195 | conf.loads( 196 | """ 197 | _common: 198 | commands: 199 | open: 200 | run: xdg-open . 201 | project: 202 | path: /path/1 203 | """ 204 | ) 205 | 206 | project = conf.projects["project"] 207 | command = project.commands["open"] 208 | assert command.parent is not None 209 | assert command.parent != project 210 | 211 | clone = command.clone_to(project) 212 | assert clone.parent == project 213 | 214 | assert clone.path == Path("/path/1") 215 | 216 | 217 | def test_common_command_env__clone_to_project__env_substitution_correct(): 218 | conf = Config() 219 | conf.loads( 220 | """ 221 | _common: 222 | commands: 223 | open: 224 | run: xdg-open . 225 | env: 226 | PROJECT: "{{project.name}}" 227 | project: 228 | path: /path/1 229 | """ 230 | ) 231 | 232 | project = conf.projects["project"] 233 | command = project.commands["open"] 234 | clone = command.clone_to(project) 235 | assert "PROJECT" in clone.env 236 | assert clone.env["PROJECT"] == "{{project.name}}" 237 | assert list(clone()) == ["cd /path/1", "export PROJECT=project", "xdg-open ."] 238 | 239 | 240 | def test_variable__variable_found(): 241 | matches = var_pattern.findall(r"{{project.name}}") 242 | assert matches == ["name"] 243 | 244 | 245 | def test_variables_with_text__variables_found(): 246 | matches = var_pattern.findall(r"one {{project.name}} two {{project.path}} three") 247 | assert matches == ["name", "path"] 248 | 249 | 250 | def test_variables_in_project__variables_replaced(): 251 | conf = Config() 252 | conf.loads( 253 | """ 254 | _common: 255 | env: 256 | key1: value1_{{project.name}} 257 | project: 258 | env: 259 | key2: value2_{{project.name}} 260 | """ 261 | ) 262 | 263 | assert conf.projects["project"].env == { 264 | "key1": "value1_{{project.name}}", 265 | "key2": "value2_{{project.name}}", 266 | } 267 | 268 | assert list(conf.projects["project"]()) == [ 269 | "export key1=value1_project", 270 | "export key2=value2_project", 271 | ] 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # workenv 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/workenv.svg)](https://pypi.org/project/workenv/) 4 | [![Tests](https://github.com/radiac/workenv/actions/workflows/ci.yml/badge.svg)](https://github.com/radiac/workenv/actions/workflows/ci.yml) 5 | [![codecov](https://codecov.io/gh/radiac/workenv/graph/badge.svg?token=EWUKFNYIPX)](https://codecov.io/gh/radiac/workenv) 6 | 7 | A shortcut for jumping between local work environments in bash, and carrying out tasks 8 | within them. 9 | 10 | Requires Python 3.7+ and bash. 11 | 12 | 13 | ## Quick example 14 | 15 | Example `~/.workenv_config.yml`: 16 | 17 | ```yaml 18 | myproject: 19 | path: /path/to/myproject 20 | source: venv/bin/activate 21 | run: 22 | - nvm use 23 | commands: 24 | database: 25 | run: docker-compose up database 26 | otherproject: 27 | file: /path/to/otherproject 28 | ``` 29 | 30 | Example usage: 31 | 32 | ```bash 33 | # Jump to /path/to/myproject with the local python virtual environment and nvm 34 | we myproject 35 | 36 | # Jump to /path/to/myproject and run the database container 37 | we myproject database 38 | 39 | # Bash completion support 40 | we m d 41 | ``` 42 | 43 | There is also support for a `_common` project with values applied to all projects, and 44 | for projects which define their own settings locally in `,workenv.yml` files - see docs 45 | below. 46 | 47 | 48 | ## Installation 49 | 50 | **Recommended**: Install using [pipx](https://pypa.github.io/pipx/): 51 | 52 | ```bash 53 | pipx install workenv 54 | workenv --install 55 | ``` 56 | 57 | **Alternative**: Install to a virtual environment with:: 58 | 59 | ```bash 60 | cd path/to/installation 61 | python -m venv venv 62 | source venv/bin/activate 63 | pip install workenv 64 | workenv --install 65 | ``` 66 | 67 | Both of these options will add the command as `we` by adding a line to your `.bashrc`. 68 | 69 | If you would prefer a different command name, you can specify it when installing: 70 | 71 | ```bash 72 | workenv --install workon 73 | ``` 74 | 75 | Restart your shell session for your change to take effect. 76 | 77 | To uninstall, remove the line from `.bashrc`, and either uninstall with pipx or delete 78 | your virtual environment. 79 | 80 | 81 | ## Configuration 82 | 83 | Add the current path as a new project: 84 | 85 | ```bash 86 | we --add projectname 87 | ``` 88 | 89 | Add the current path as a new command:: 90 | 91 | ```bash 92 | we --add projectname command 93 | ``` 94 | 95 | Open your `.workenv_config.yml` for customisation:: 96 | 97 | ```bash 98 | we --edit 99 | ``` 100 | 101 | The top level of the YAML file are the names of the projects. 102 | 103 | Values can substitute the project name with `{{project.name}}` or `{{project.slug}}`. 104 | 105 | 106 | ### Special rules 107 | 108 | There are two special top-level YAML objects: 109 | 110 | #### `_config` 111 | 112 | Controls settings: 113 | 114 | * `verbose` - if `true`, show bash commands when running them 115 | * `history` - if `true`, add the commands to history 116 | 117 | #### `_common` 118 | 119 | Common project which can define a common `source`, `env`, `run` and `commands` 120 | which will be added to all other projects, regardless of whether they define their 121 | own. 122 | 123 | The common project cannot specify a path. 124 | 125 | 126 | ### Project rules 127 | 128 | A project can have the following attributes: 129 | 130 | #### `path` 131 | 132 | The path to set as the current working directory. This will be the first command run. 133 | 134 | Example: 135 | 136 | ```yaml 137 | myproject: 138 | path: /path/to/foo 139 | ``` 140 | 141 | Bash equivalent: 142 | 143 | ```bash 144 | cd /path/to/foo 145 | ``` 146 | 147 | #### `source` 148 | 149 | Path or paths to call using `source` 150 | 151 | Example: 152 | 153 | ```yaml 154 | myproject: 155 | source: 156 | - venv/bin/activate 157 | - .env 158 | ``` 159 | 160 | Bash equivalent: 161 | 162 | ```bash 163 | source venv/bin/activate 164 | source .env 165 | ``` 166 | 167 | #### `env` 168 | 169 | Dict of environment variables to set 170 | 171 | Example: 172 | 173 | ```yaml 174 | myproject: 175 | env: 176 | COMPOSE_PROJECT_NAME: my_project 177 | ``` 178 | 179 | Bash equivalent: 180 | 181 | ```bash 182 | export COMPOSE_PROJECT_NAME=my_project 183 | ``` 184 | 185 | #### `run` 186 | 187 | Command or list of commands to run 188 | 189 | Example: 190 | 191 | ```yaml 192 | myproject: 193 | run: 194 | - nvm use 195 | - yvm use 196 | ``` 197 | 198 | Bash equivalent:: 199 | 200 | ```bash 201 | nvm use 202 | yvm use 203 | ``` 204 | 205 | #### `commands` 206 | 207 | Dict of Command objects 208 | 209 | Example: 210 | 211 | ```yaml 212 | myproject: 213 | commands: 214 | database: 215 | run: docker-compose up database 216 | ``` 217 | 218 | Usage: 219 | 220 | ```bash 221 | we myproject database 222 | ``` 223 | 224 | Bash equivalent: 225 | 226 | ```bash 227 | docker-compose up database 228 | ``` 229 | 230 | A command will inherit the `path` and `env` of its parent project, unless it defines its 231 | own. 232 | 233 | It will inherit the `source` of its parent project only if it does not specify its own 234 | path or source. 235 | 236 | A command can have the same attributes as a project, except it cannot define its own 237 | `commands`. 238 | 239 | 240 | ## Full example 241 | 242 | Putting together all the options above into a sample `.workenv_config.yml`: 243 | 244 | ```yaml 245 | _config: 246 | verbose: true 247 | history: false 248 | _common: 249 | env: 250 | COMPOSE_PROJECT_NAME: '{{project.slug}}' 251 | PS1: '"\[\e[01;35m\]{{project.slug}}>\[\e[00m\]$PS1"' 252 | commands: 253 | open: 254 | run: xdg-open . 255 | myproject: 256 | path: /path/to/myproject 257 | source: 258 | - venv/bin/activate 259 | - .env 260 | run: 261 | - ./manage.py migrate 262 | - ./manage.py runserver 0:8000 263 | commands: 264 | database: 265 | run: docker compose up database 266 | other: 267 | path: /path/to/other 268 | something-else: 269 | config: /path/to/somethingelse 270 | ``` 271 | 272 | `we myproject` is equivalent to typing: 273 | 274 | ```bash 275 | cd /path/to/myproject 276 | source venv/bin/activate 277 | source .env 278 | export COMPOSE_PROJECT_NAME=myproject 279 | ./manage.py migrate 280 | ./manage.py runserver 0:8000 281 | ``` 282 | 283 | `we myproject database` is equivalent to typing: 284 | 285 | ```bash 286 | cd /path/to/myproject 287 | source venv/bin/activate 288 | source .env 289 | export COMPOSE_PROJECT_NAME=myproject 290 | docker compose up database 291 | ``` 292 | 293 | `we other` is equivalent to typing: 294 | 295 | ```bash 296 | cd /path/to/other 297 | export COMPOSE_PROJECT_NAME=other 298 | ``` 299 | 300 | `we other open` is equivalent to: 301 | 302 | ```bash 303 | cd /path/to/myproject 304 | export COMPOSE_PROJECT_NAME=other 305 | xdg-open . 306 | ``` 307 | 308 | and `something-else` will be configured in `/path/to/somethingelse/.workenv.yml`; `path` 309 | will be automatically set to that dir: 310 | 311 | ```yaml 312 | source: 313 | - venv/bin/activate 314 | - .env 315 | run: 316 | - ./manage.py migrate 317 | - ./manage.py runserver 0:8000 318 | commands: 319 | database: 320 | run: docker compose up database 321 | ``` 322 | -------------------------------------------------------------------------------- /workenv/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config loader 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import re 8 | import unicodedata 9 | from functools import cached_property 10 | from pathlib import Path 11 | from typing import Any, Dict, List, Optional, Type, TypeVar 12 | 13 | import yaml 14 | 15 | from .constants import PROJECT_DEFAULT_FILENAME 16 | 17 | CommandType = TypeVar("CommandType", bound="Command") 18 | ProjectType = TypeVar("ProjectType", bound="Project") 19 | 20 | var_pattern = re.compile(r"\{\{\s*project\.([a-z]+)\s*\}\}") 21 | 22 | 23 | class ConfigError(Exception): 24 | pass 25 | 26 | 27 | class Command: 28 | config: Config 29 | name: str 30 | _path: Optional[Path] 31 | _source: List[str] 32 | _env: Dict[str, str] 33 | _run: List[str] 34 | parent: Optional[Command] 35 | _replacements: Optional[Dict[str, str]] 36 | 37 | def __init__( 38 | self, 39 | config: Config, 40 | name: str, 41 | path: Optional[Path], 42 | source: List[str], 43 | env: Dict[str, str], 44 | run: List[str], 45 | parent: Optional[Command], 46 | ): 47 | self.config = config 48 | self.name = name 49 | self._path = path 50 | self._source = source 51 | self._env = env 52 | self._run = run 53 | self.parent = parent 54 | self._replacements = None 55 | 56 | @classmethod 57 | def from_dict( 58 | cls: Type[CommandType], 59 | config: Config, 60 | name: str, 61 | data: Dict[str, Any], 62 | parent: Optional[CommandType] = None, 63 | ) -> CommandType: 64 | path: Optional[Path] = None 65 | if data.get("path"): 66 | path = Path(data["path"]) 67 | 68 | source: List[str] = [] 69 | if "source" in data: 70 | if isinstance(data["source"], str): 71 | source.append(data["source"]) 72 | else: 73 | source.extend(data["source"]) 74 | 75 | env: Dict[str, str] = {} 76 | if "env" in data: 77 | env.update(data["env"]) 78 | 79 | run: List[str] = [] 80 | if "run" in data: 81 | if isinstance(data["run"], str): 82 | run.append(data["run"]) 83 | else: 84 | run.extend(data["run"]) 85 | 86 | command = cls( 87 | config=config, 88 | name=name, 89 | path=path, 90 | source=source, 91 | env=env, 92 | run=run, 93 | parent=parent, 94 | ) 95 | return command 96 | 97 | def __call__(self): 98 | """ 99 | Generate list of commands to run 100 | """ 101 | if self.path: 102 | path = self.replace_values(str(self.path)) 103 | yield f"cd {path}" 104 | 105 | for source in self.source: 106 | source = self.replace_values(source) 107 | yield f"source {source}" 108 | 109 | for key, val in self.env.items(): 110 | val = self.replace_values(val) 111 | yield f"export {key}={val}" 112 | 113 | for run in self.run: 114 | run = self.replace_values(run) 115 | yield run 116 | 117 | @property 118 | def replacements(self): 119 | """ 120 | Get data for variable replacement 121 | """ 122 | if not self._replacements: 123 | self._replacements = dict( 124 | name=self.get_project_name(), 125 | slug=self.get_project_slug(), 126 | ) 127 | return self._replacements 128 | 129 | def replace_values(self, value: str): 130 | """ 131 | Replace template values 132 | """ 133 | # This currently uses a naive regex which doesn't support escaping 134 | # If this ever causes problems we can switch it for a proper parser 135 | value = var_pattern.sub( 136 | lambda matchobj: self.replacements.get(matchobj.group(1), ""), value 137 | ) 138 | return value 139 | 140 | def to_dict(self): 141 | data = {} 142 | if self._path: 143 | data["path"] = str(self._path) 144 | 145 | for attr in ["source", "env", "run"]: 146 | val = getattr(self, f"_{attr}") 147 | if len(val) > 0: 148 | data[attr] = val 149 | 150 | return data 151 | 152 | def get_project_name(self): 153 | if self.parent: 154 | return self.parent.name 155 | return self.name 156 | 157 | def get_project_slug(self): 158 | # Based on Django's slugify() 159 | name = self.get_project_name() 160 | 161 | slug = ( 162 | unicodedata.normalize("NFKD", name) 163 | .encode("ascii", "ignore") 164 | .decode("ascii") 165 | ) 166 | slug = re.sub(r"[^\w\s-]", "", slug.lower()) 167 | slug = re.sub(r"[-\s]+", "-", slug).strip("-_") 168 | return slug 169 | 170 | def clone_to(self, parent: Command) -> Command: 171 | clone = self.from_dict( 172 | config=self.config, name=self.name, data=self.to_dict(), parent=parent 173 | ) 174 | return clone 175 | 176 | @property 177 | def path(self): 178 | if self._path is None and self.parent: 179 | return self.parent.path 180 | return self._path 181 | 182 | @property 183 | def source(self): 184 | """ 185 | Inherit from parent if source and path not set 186 | """ 187 | # Look for common 188 | common = [] 189 | if self.config.common_project: 190 | common = self.config.common_project.source 191 | 192 | if self.parent and self._path is None and not self._source: 193 | return common + self.parent.source 194 | 195 | return common + self._source 196 | 197 | @property 198 | def env(self): 199 | data = {} 200 | if self.config.common_project: 201 | data.update(self.config.common_project.env) 202 | 203 | if self.parent and not self._env: 204 | data.update(self.parent.env) 205 | else: 206 | data.update(self._env) 207 | return data 208 | 209 | @property 210 | def run(self): 211 | common = [] 212 | if self.config.common_project: 213 | common = self.config.common_project.run 214 | 215 | return common + self._run 216 | 217 | 218 | class Project(Command): 219 | _commands: Dict[str, Command] 220 | 221 | @classmethod 222 | def from_dict( 223 | cls: Type[ProjectType], 224 | config: Config, 225 | name: str, 226 | data: Dict[str, Any], 227 | parent: Optional[ProjectType] = None, 228 | ) -> ProjectType: 229 | project = super().from_dict(config, name, data, parent) 230 | 231 | if "commands" in data: 232 | if not isinstance(data["commands"], dict): 233 | raise ConfigError( 234 | f"Unexpected commands in {name} - expected dict," 235 | f" but found {type(data['commands']).__name__}" 236 | ) 237 | 238 | cmd_name: str 239 | cmd_data: Dict[str, Any] 240 | for cmd_name, cmd_data in data["commands"].items(): 241 | if cmd_data is None: 242 | cmd_data = {} 243 | 244 | # Create command 245 | command = Command.from_dict( 246 | config=config, name=cmd_name, data=cmd_data, parent=project 247 | ) 248 | project.add_command(cmd_name, command) 249 | return project 250 | 251 | def __init__(self, *args, **kwargs): 252 | super().__init__(*args, **kwargs) 253 | self._commands = {} 254 | 255 | @property 256 | def commands(self) -> Dict[str, Command]: 257 | cmds: Dict[str, Command] = {} 258 | if self.config.common_project: 259 | cmds.update(self.config.common_project.commands) 260 | cmds.update(self._commands) 261 | return cmds 262 | 263 | def add_command(self: Project, name: str, command: Command): 264 | self._commands[name] = command 265 | 266 | def get_command_names(self): 267 | return list(self.commands.keys()) 268 | 269 | def to_dict(self): 270 | data = super().to_dict() 271 | if self._commands: 272 | data["commands"] = { 273 | command_name: command.to_dict() 274 | for command_name, command in self._commands.items() 275 | } 276 | return data 277 | 278 | 279 | class Common(Project): 280 | """ 281 | Common project 282 | """ 283 | 284 | @property 285 | def path(self): 286 | """ 287 | Common project cannot have a path 288 | """ 289 | return None 290 | 291 | @property 292 | def source(self): 293 | return self._source 294 | 295 | @property 296 | def env(self): 297 | return self._env 298 | 299 | @property 300 | def run(self): 301 | return self._run 302 | 303 | @property 304 | def commands(self) -> Dict[str, Command]: 305 | return self._commands 306 | 307 | 308 | class DeferredProject: 309 | def __init__(self, config, name, path): 310 | self._config = config 311 | self._name = name 312 | self._path_str = path 313 | self._path = Path(path) 314 | 315 | @property 316 | def name(self): 317 | return self._name 318 | 319 | def to_dict(self): 320 | data = {"config": str(self._path_str)} 321 | return data 322 | 323 | @cached_property 324 | def project(self): 325 | if self._path.is_dir(): 326 | self._path /= PROJECT_DEFAULT_FILENAME 327 | raw = self._path.read_text() 328 | data = yaml.safe_load(raw) 329 | data["path"] = str(self._path.parent) 330 | project = Project.from_dict( 331 | config=self._config, 332 | name=self._name, 333 | data=data, 334 | ) 335 | return project 336 | 337 | def __getattr__(self, attr): 338 | return getattr(self.project, attr) 339 | 340 | def __call__(self): 341 | return self.project() 342 | 343 | 344 | class Config: 345 | file: Optional[Path] 346 | projects: Dict[str, Project | DeferredProject] 347 | common_project: Optional[Project] 348 | 349 | # Config variables 350 | verbose = False 351 | history = False 352 | 353 | def __init__(self, file: Optional[Path] = None): 354 | self.file = file 355 | self.projects = {} 356 | self.common_project = None 357 | 358 | if file and file.is_file(): 359 | self.load() 360 | 361 | def load(self): 362 | """ 363 | Load from self.file 364 | """ 365 | if not self.file: 366 | raise ConfigError("Cannot load a config without specifying the file") 367 | if not self.file.is_file(): 368 | raise ConfigError("Config file does not exist") 369 | 370 | raw = self.file.read_text() 371 | self.loads(raw) 372 | 373 | def loads(self, raw: str): 374 | """ 375 | Load from a string 376 | """ 377 | parsed = yaml.safe_load(raw) 378 | for name, data in parsed.items(): 379 | if data is None: 380 | data = {} 381 | if name == "_config": 382 | self.from_dict(data) 383 | elif name == "_common": 384 | if "path" in data: 385 | raise ConfigError("Common config cannot define a path") 386 | self.common_project = Common.from_dict(self, name, data) 387 | elif "config" in data: 388 | self.projects[name] = DeferredProject(self, name, data["config"]) 389 | else: 390 | self.projects[name] = Project.from_dict(self, name, data) 391 | 392 | def get_project_names(self): 393 | return list(self.projects.keys()) 394 | 395 | def from_dict(self, data): 396 | """ 397 | Load config values from _config definition dict 398 | """ 399 | self.verbose = data.get("verbose", False) 400 | self.history = data.get("history", False) 401 | 402 | def to_dict(self): 403 | """ 404 | Always be explicit with config values - don't worry about adding them when 405 | writing the yaml 406 | """ 407 | return { 408 | "verbose": self.verbose, 409 | "history": self.history, 410 | } 411 | 412 | def to_yaml(self): 413 | projects = { 414 | "_config": self.to_dict(), 415 | } 416 | 417 | if self.common_project: 418 | projects["_common"] = self.common_project.to_dict() 419 | 420 | for project in self.projects.values(): 421 | projects[project.name] = project.to_dict() 422 | 423 | raw = yaml.dump(projects, sort_keys=True) 424 | return raw 425 | 426 | def save(self): 427 | if self.file is None: 428 | raise ConfigError("Cannot save a config without specifying the file") 429 | 430 | raw = self.to_yaml() 431 | 432 | self.file.write_text(raw) 433 | --------------------------------------------------------------------------------