├── tests ├── __init__.py └── test_parallex.py ├── xontrib_commands ├── __init__.py ├── cmds │ ├── __init__.py │ ├── dotenv_cmd.py │ ├── reload.py │ ├── parallex.py │ ├── report_keys.py │ └── dev_cmd.py ├── main.py ├── utils.py └── argerize.py ├── docs ├── 2020-12-02-14-30-17.png └── 2020-12-02-14-30-47.png ├── .github └── workflows │ └── release.yml ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xontrib_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xontrib_commands/cmds/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/2020-12-02-14-30-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnoortheen/xontrib-commands/HEAD/docs/2020-12-02-14-30-17.png -------------------------------------------------------------------------------- /docs/2020-12-02-14-30-47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnoortheen/xontrib-commands/HEAD/docs/2020-12-02-14-30-47.png -------------------------------------------------------------------------------- /xontrib_commands/cmds/dotenv_cmd.py: -------------------------------------------------------------------------------- 1 | from xonsh.built_ins import XSH 2 | 3 | 4 | def load_env( 5 | file=".env", 6 | ): 7 | """Load environment variables from dotenv files 8 | 9 | Uses https://github.com/theskumar/python-dotenv 10 | 11 | Parameters 12 | ---------- 13 | file 14 | Path to the dotenv file 15 | """ 16 | if not XSH.env: 17 | return 18 | 19 | from dotenv import dotenv_values 20 | 21 | vals = dotenv_values(file) 22 | 23 | for name, val in vals.items(): 24 | print(f"Setting {name}") 25 | XSH.env[name] = val 26 | else: 27 | print(f"No env variables loaded from {file}.") 28 | -------------------------------------------------------------------------------- /xontrib_commands/main.py: -------------------------------------------------------------------------------- 1 | from xonsh.built_ins import XonshSession 2 | 3 | 4 | def _load_xontrib_(xsh: XonshSession, **_): 5 | from xontrib_commands.argerize import Command 6 | 7 | # register aliases 8 | from xontrib_commands.cmds import report_keys 9 | from xontrib_commands.cmds import parallex 10 | from xontrib_commands.cmds import reload 11 | from xontrib_commands.cmds import dotenv_cmd 12 | from xontrib_commands.cmds import dev_cmd 13 | 14 | Command.reg_no_thread(reload.reload_mods) 15 | Command.reg_no_thread(report_keys.report_key_bindings) 16 | Command.reg_no_thread(dev_cmd.dev) 17 | Command.reg_no_thread(dotenv_cmd.load_env) 18 | Command.reg_no_thread(parallex.parallex) 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # release to PyPI on new tags using OIDC 2 | 3 | name: Release to PyPI 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | release-build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.x" 20 | 21 | - name: build release distributions 22 | run: | 23 | pip install build 24 | python -m build 25 | 26 | - name: upload dists 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: release-dists 30 | path: dist/ 31 | 32 | pypi-publish: 33 | runs-on: ubuntu-latest 34 | needs: 35 | - release-build 36 | permissions: 37 | id-token: write 38 | 39 | steps: 40 | - name: Retrieve release distributions 41 | uses: actions/download-artifact@v4 42 | with: 43 | name: release-dists 44 | path: dist/ 45 | 46 | - name: Publish release distributions to PyPI 47 | uses: pypa/gh-action-pypi-publish@release/v1 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020, Noortheen Raja NJ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_parallex.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from xonsh.pytest import tools 4 | 5 | 6 | @pytest.fixture 7 | def parallex(xsh_with_aliases): 8 | return xsh_with_aliases.aliases["parallex"] 9 | 10 | 11 | def test_exec(parallex, capfd): 12 | parallex(["python --version", "pip --version"]) 13 | 14 | out, _ = capfd.readouterr() 15 | assert "Python" in out 16 | assert "pip" in out 17 | 18 | 19 | @tools.skip_if_on_windows 20 | @pytest.mark.parametrize( 21 | "cmp_fn, args", 22 | [ 23 | pytest.param(list, [], id="ordered"), 24 | pytest.param(set, ["--no-order"], id="interleaved"), 25 | ], 26 | ) 27 | def test_shell_ordered(cmp_fn, args, parallex, capfd): 28 | parallex( 29 | [ 30 | "python -uc 'import time; print(1); time.sleep(0.01); print(2)'", 31 | # elapse some time, so that the order will not be messed up in environments like macos 32 | "python -uc 'import time; time.sleep(0.0001); print(3); time.sleep(0.03); print(4)'", 33 | "--shell", 34 | *args, 35 | ], 36 | ) 37 | 38 | out, _ = capfd.readouterr() 39 | assert cmp_fn(out.split()) == cmp_fn("1234") 40 | -------------------------------------------------------------------------------- /xontrib_commands/cmds/reload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from arger import Argument 4 | from typing_extensions import Annotated 5 | 6 | 7 | def module_name_completer(**_): 8 | yield from sys.modules 9 | 10 | 11 | def reload_mods(name: Annotated[str, Argument(completer=module_name_completer)]): 12 | """Reload any python module in the current xonsh session. 13 | Helpful during development. 14 | 15 | Parameters 16 | ---------- 17 | name: 18 | Name of the module/package to reload. 19 | Giving partial names matches all the nested modules. 20 | 21 | Examples 22 | ------- 23 | $ reload-mods xontrib 24 | - this will reload all modules imported that starts with xontrib name 25 | 26 | Notes 27 | ----- 28 | Please use 29 | `import module` or `import module as mdl` patterns 30 | Using 31 | `from module import name` 32 | will not reload the name imported 33 | """ 34 | # todo: implement a watcher mode 35 | import importlib 36 | from rich.console import Console 37 | 38 | console = Console() 39 | modules = list( 40 | sys.modules 41 | ) # create a copy of names. so that it will not raise warning 42 | for mod_name in modules: 43 | mod = sys.modules[mod_name] 44 | if name and not mod_name.startswith(name): 45 | continue 46 | console.print(f"reload [cyan] {mod_name}[/cyan]") 47 | try: 48 | importlib.reload(mod) 49 | except Exception as e: 50 | console.print("[red]failed to reload [/red]", mod_name, e) 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [ 3 | { name = "Noortheen Raja NJ", email = "jnoortheen@gmail.com" }, 4 | ] 5 | requires-python = ">=3.8" 6 | dependencies = [ 7 | "xonsh>=0.12.5", 8 | "arger>=1.2.7; python_version >= \"3.6\" and python_version < \"4.0\"", 9 | "rich; python_version >= \"3.6\" and python_version < \"4.0\"", 10 | "python-dotenv>=0.19.1", 11 | ] 12 | name = "xontrib-commands" 13 | version = "0.4.4" 14 | description = "Useful xonsh-shell commands/alias functions" 15 | readme = "README.md" 16 | keywords = [ 17 | "xontrib", 18 | "xonsh", 19 | ] 20 | classifiers = [ 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Topic :: System :: Shells", 26 | "Topic :: System :: System Shells", 27 | "Topic :: Terminals", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | dev = [ 34 | "pytest", 35 | "black", 36 | ] 37 | 38 | [project.license] 39 | text = "MIT" 40 | 41 | [project.urls] 42 | Documentation = "https://github.com/jnoortheen/xontrib-commands/blob/master/README.md" 43 | Code = "https://github.com/jnoortheen/xontrib-commands" 44 | "Issue tracker" = "https://github.com/jnoortheen/xontrib-commands/issues" 45 | repository = "https://github.com/jnoortheen/xontrib-commands" 46 | 47 | [project.entry-points."xonsh.xontribs"] 48 | commands = "xontrib_commands.main" 49 | 50 | [tool.pdm.build] 51 | includes = [ 52 | "xontrib_commands", 53 | ] 54 | 55 | [build-system] 56 | requires = [ 57 | "pdm-pep517>=1.0.0", 58 | ] 59 | build-backend = "pdm.pep517.api" 60 | -------------------------------------------------------------------------------- /xontrib_commands/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from xonsh.built_ins import XSH 4 | 5 | 6 | def _get_proc_func_(): 7 | import inspect, opcode 8 | 9 | frame = inspect.currentframe() 10 | 11 | try: 12 | frame = frame.f_back 13 | next_opcode = opcode.opname[ord(str(frame.f_code.co_code[frame.f_lasti + 3]))] 14 | if next_opcode == "POP_TOP": 15 | # or next_opcode == "RETURN_VALUE": 16 | # include the above line in the if statement if you consider "return run()" to be assignment 17 | 18 | # "I was not assigned" 19 | return XSH.subproc_uncaptured 20 | finally: 21 | del frame 22 | return XSH.subproc_captured_stdout 23 | 24 | 25 | def run(*args, capture: Optional[bool] = None) -> str: 26 | """helper function to run shell commands inside xonsh session""" 27 | import shlex 28 | 29 | if capture is None: 30 | func = _get_proc_func_() 31 | elif capture: 32 | func = XSH.subproc_captured_stdout 33 | else: 34 | func = XSH.subproc_uncaptured 35 | 36 | cmd_args = list(args) 37 | if len(args) == 1 and isinstance(args[0], str) and " " in args[0]: 38 | first_arg = args[0] 39 | 40 | if " | " in first_arg: 41 | cmds = first_arg.split(" | ") 42 | cmds = map(lambda x: shlex.split(x), cmds) 43 | cmd_args = list(cmds) 44 | else: 45 | cmd_args = shlex.split(first_arg) 46 | # from xonsh.built_ins import ( 47 | # subproc_captured_stdout as capt, 48 | # subproc_uncaptured as run, 49 | # ) 50 | # 51 | # HAVE_UGLIFY = bool(capt(["which", "uglifyjs"])) 52 | # run( 53 | # ["uglifyjs", js_target, "--compress", UGLIFY_FLAGS], 54 | # "|", 55 | # ["uglifyjs", "--mangle", "--output", min_target], 56 | # ) 57 | return func(cmd_args) 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .DS_Store 131 | -------------------------------------------------------------------------------- /xontrib_commands/cmds/parallex.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List 3 | 4 | from xonsh.built_ins import XSH 5 | 6 | 7 | async def run_sp_parallel( 8 | *commands: str, shell=False, order_out=True, show_running=True 9 | ): 10 | import asyncio 11 | import asyncio.subprocess as asp 12 | import shlex 13 | 14 | def print_cmd(cmd): 15 | sys.stderr.buffer.write(f" $ {cmd}\n".encode()) 16 | sys.stderr.flush() 17 | 18 | async def run(cmd, capture=False): 19 | kwargs = dict(env=XSH.env.detype()) 20 | if capture: 21 | kwargs["stdout"] = asp.PIPE 22 | kwargs["stderr"] = asp.PIPE 23 | if show_running and not capture: 24 | print_cmd(cmd) 25 | if shell: 26 | proc = await asp.create_subprocess_shell(cmd, **kwargs) 27 | else: 28 | program, *args = shlex.split(cmd) 29 | proc = await asp.create_subprocess_exec(program, *args, **kwargs) 30 | if capture: 31 | stdout, stderr = await proc.communicate() 32 | if show_running: 33 | print_cmd(cmd) 34 | sys.stdout.buffer.write(stdout) 35 | sys.stdout.flush() 36 | sys.stderr.buffer.write(stderr) 37 | sys.stderr.flush() 38 | return proc.returncode 39 | else: 40 | return await proc.wait() 41 | 42 | def prepare_cmds(): 43 | for idx, cmd in enumerate(commands): 44 | # first command is not captured and will have tty 45 | is_first = idx != 0 46 | yield run(cmd, capture=is_first and order_out) 47 | 48 | return await asyncio.gather(*tuple(prepare_cmds())) 49 | 50 | 51 | def parallex( 52 | args: List[str], 53 | shell=False, 54 | no_order=False, 55 | hide_cmd=False, 56 | ): 57 | """Execute multiple subprocess in parallel 58 | 59 | Parameters 60 | ---------- 61 | args : 62 | individual commands need to be quoted and passed as separate arguments 63 | shell : 64 | each command should be run with system's commands 65 | no_order : 66 | commands output are interleaved and not ordered 67 | hide_cmd: 68 | -c, --hide-cmd, do not print the running command 69 | Examples 70 | -------- 71 | running linters in parallel 72 | $ parallex "flake8 ." "mypy xonsh" 73 | """ 74 | import asyncio 75 | 76 | order_out = not no_order 77 | results = asyncio.run( 78 | run_sp_parallel( 79 | *args, 80 | shell=shell, 81 | order_out=no_order, 82 | show_running=(order_out and (not hide_cmd)), 83 | ) 84 | ) 85 | if any(results): 86 | sys.exit(1) 87 | -------------------------------------------------------------------------------- /xontrib_commands/cmds/report_keys.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import inspect 3 | import typing as tp 4 | from collections import defaultdict 5 | 6 | from xontrib_commands.argerize import Command 7 | 8 | 9 | def format_key(k) -> str: 10 | if hasattr(k, "value"): 11 | return k.value.replace("c-", "Ctrl-").replace("s-", "Shift-") 12 | return str(k) 13 | 14 | 15 | def _get_keybindgs(): 16 | from prompt_toolkit.key_binding.key_bindings import Binding 17 | 18 | bindings: tp.List[Binding] = builtins.__xonsh__.shell.prompter.key_bindings.bindings 19 | 20 | keys = defaultdict(list) 21 | for i in bindings: 22 | 23 | key_formatted = " + ".join([format_key(k) for k in i.keys]) 24 | if key_formatted.startswith("Keys"): 25 | key_formatted = key_formatted.replace("Keys.", "") 26 | keys[i.handler].append(key_formatted) 27 | 28 | return keys 29 | 30 | 31 | def _grouped_by_modules(): 32 | bindings = _get_keybindgs() 33 | mod_keys = defaultdict(list) 34 | for handle, keys in bindings.items(): 35 | mod_keys[handle.__module__].append((handle, keys)) 36 | 37 | handle_counter = defaultdict(int) 38 | single_counter = [] 39 | for mod, handles in mod_keys.items(): 40 | count = len(handles) 41 | if count > 1: 42 | handle_counter[mod] = len(handles) 43 | else: 44 | single_counter.append(mod) 45 | 46 | final_cont = defaultdict(list) 47 | for mod in sorted(handle_counter): 48 | final_cont[mod] = mod_keys[mod] 49 | 50 | for mod in single_counter: 51 | final_cont["..."].extend(mod_keys[mod]) 52 | 53 | return final_cont 54 | 55 | 56 | @Command.reg 57 | def report_key_bindings(_stdout): 58 | """Show current Prompt-toolkit bindings in a nice table format""" 59 | 60 | from rich.console import Console 61 | from rich.table import Table 62 | 63 | mod_keys = _grouped_by_modules() 64 | tables = [] 65 | for mod, handles in mod_keys.items(): 66 | mod_table = Table( 67 | pad_edge=False, 68 | # box=None, 69 | expand=True, 70 | show_lines=True, 71 | title=f"[green]{mod}[/green]", 72 | ) 73 | mod_table.add_column("Keys", style="dim") 74 | mod_table.add_column("Description") 75 | for handle, keys in handles: 76 | module = f"{handle.__module__ if mod == '...' else ''}.{handle.__name__}" 77 | docstr = inspect.getdoc(handle) or "" 78 | mod_table.add_row( 79 | "\n".join(keys), ". ".join([docstr, f"[blue]fn: {module}[/blue]"]) 80 | ) 81 | tables.append(mod_table) 82 | 83 | console = Console() 84 | # https://bugs.python.org/issue37871 85 | # with console.pager(): 86 | console.print(*tables) 87 | -------------------------------------------------------------------------------- /xontrib_commands/argerize.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import arger 4 | 5 | from xonsh.built_ins import XSH 6 | from xonsh.cli_utils import get_argparse_formatter_class, ArgParserAlias 7 | 8 | 9 | class Arger(arger.Arger): 10 | def __init__(self, **kwargs): 11 | kwargs.setdefault("formatter_class", get_argparse_formatter_class()) 12 | super().__init__(**kwargs) 13 | 14 | def add_argument(self, *args, **kwargs): 15 | completer = kwargs.pop("completer", None) 16 | action = super().add_argument(*args, **kwargs) 17 | if completer: 18 | action.completer = completer 19 | return action 20 | 21 | 22 | class Command(ArgParserAlias): 23 | """Use arger to create commands from functions""" 24 | 25 | def __init__( 26 | self, func: Callable | Arger, threadable=True, capturable=True, **kwargs 27 | ): 28 | """Convert the given function to alias and also create a argparser for its parameters""" 29 | super().__init__() 30 | 31 | def get_prog_name(func): 32 | return func.__name__.strip("_").replace("_", "-") 33 | 34 | if isinstance(func, Arger): 35 | if func.func is not None: 36 | prog = get_prog_name(func.func) 37 | else: 38 | prog = func.prog 39 | self.arger = func 40 | self.kwargs = None 41 | else: 42 | prog = get_prog_name(func) 43 | kwargs["func"] = func 44 | kwargs["prog"] = prog 45 | self.kwargs = kwargs 46 | self.arger = None 47 | 48 | if not threadable: 49 | from xonsh.tools import unthreadable 50 | 51 | unthreadable(self) 52 | if not capturable: 53 | from xonsh.tools import uncapturable 54 | 55 | uncapturable(self) 56 | 57 | # convert to 58 | XSH.aliases[prog] = self 59 | 60 | @classmethod 61 | def reg(cls, func: Callable | Arger, **kwargs): 62 | """pickle safe way to register alias function""" 63 | cls(func, **kwargs) 64 | return func 65 | 66 | @classmethod 67 | def reg_no_thread(cls, func: Callable | Arger, **kwargs): 68 | """pickle safe way to register alias function that is not threadable""" 69 | kwargs.setdefault("threadable", False) 70 | return cls.reg(func, **kwargs) 71 | 72 | @classmethod 73 | def reg_no_cap(cls, func: Callable | Arger, **kwargs): 74 | """pickle safe way to register alias function that is not capturable""" 75 | kwargs.setdefault("capturable", False) 76 | return cls.reg(func, **kwargs) 77 | 78 | def build(self) -> "Arger": 79 | # override to return build parser 80 | return Arger(**self.kwargs) if self.arger is None else self.arger 81 | 82 | def __call__( 83 | self, args, stdin=None, stdout=None, stderr=None, spec=None, stack=None 84 | ): 85 | self.parser.set_defaults(_stdin=stdin) 86 | self.parser.set_defaults(_stdout=stdout) 87 | self.parser.set_defaults(_stderr=stderr) 88 | self.parser.set_defaults(_spec=spec) 89 | self.parser.set_defaults(_stack=stack) 90 | self.parser.run(*args, capture_sys=False) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xontrib-commands 2 | 3 | Useful xonsh-shell commands/alias/completer functions 4 | 5 | ## Installation 6 | 7 | To install use pip: 8 | 9 | ``` bash 10 | xpip install xontrib-commands 11 | # or: xpip install -U git+https://github.com/jnoortheen/xontrib-commands 12 | ``` 13 | 14 | ## Usage 15 | 16 | ``` bash 17 | xontrib load commands 18 | ``` 19 | 20 | ## building alias 21 | 22 | Use [`xontrib_commands.argerize:Command`](https://github.com/jnoortheen/xontrib-commands/blob/1bf016e08f192478c6322b2a859ae48567372bdb/xontrib_commands/argerize.py#L21) 23 | to build [arger](https://github.com/jnoortheen/arger) dispatcher 24 | for your functions. This will create a nice alias function with auto-completions support. 25 | 26 | ```py 27 | from xontrib_commands.argerize import Command 28 | 29 | @Command.reg 30 | def record_stats(pkg_name=".", path=".local/stats.txt"): 31 | stat = $(scc @(pkg_name)) 32 | echo @($(date) + stat) | tee -a @(path) 33 | ``` 34 | 35 | - Directly passing the `Arger` instances is also supported. 36 | 37 | ```py 38 | from xontrib_commands.argerize import Arger, Command 39 | 40 | arger = Arger(prog="tst", description="App Description goes here") 41 | 42 | @arger.add_cmd 43 | def create(name: str): 44 | """Create new test. 45 | 46 | :param name: Name of the test 47 | """ 48 | print(locals()) 49 | 50 | @arger.add_cmd 51 | def remove(*name: str): 52 | """Remove a test with variadic argument. 53 | 54 | :param name: tests to remove 55 | """ 56 | print(locals()) 57 | 58 | Command.reg(arger) 59 | ``` 60 | 61 | Now a full CLI is ready 62 | ```sh 63 | $ record-stats --help 64 | usage: xonsh [-h] [-p PKG_NAME] [-a PATH] 65 | 66 | optional arguments: 67 | -h, --help show this help message and exit 68 | -p PKG_NAME, --pkg-name PKG_NAME 69 | -a PATH, --path PATH 70 | ``` 71 | 72 | ## Commands 73 | 74 | - The following commands are available once the xontrib is loaded. 75 | 76 | ### 1. reload-mods 77 | 78 | ``` 79 | usage: reload-mods [-h] name 80 | 81 | Reload any python module in the current xonsh session. 82 | Helpful during development. 83 | 84 | positional arguments: 85 | name Name of the module/package to reload. Giving partial names matches all the nested modules. 86 | 87 | optional arguments: 88 | -h, --help show this help message and exit 89 | 90 | Examples 91 | ------- 92 | $ reload-mods xontrib 93 | - this will reload all modules imported that starts with xontrib name 94 | 95 | Notes 96 | ----- 97 | Please use 98 | `import module` or `import module as mdl` patterns 99 | Using 100 | `from module import name` 101 | will not reload the name imported 102 | 103 | ``` 104 | 105 | 106 | ### 2. report-key-bindings 107 | 108 | ``` 109 | usage: report-key-bindings [-h] 110 | 111 | Show current Prompt-toolkit bindings in a nice table format 112 | 113 | optional arguments: 114 | -h, --help show this help message and exit 115 | 116 | ``` 117 | 118 | 119 | ### 3. dev 120 | 121 | ``` 122 | dev - A command to cd into a directory. (Default action) 123 | 124 | Usage: 125 | dev [COMMAND] [OPTIONS] [NAME] 126 | 127 | Arguments: 128 | [NAME] - name of the folder to cd into. This searches for names under $PROJECT_PATHS or the ones registered with ``dev add`` 129 | 130 | Options: 131 | --help [SUBCOMMANDS...] - Display this help and exit 132 | 133 | Commands: 134 | add - Register the current folder to dev command. 135 | When using this, it will get saved in a file, also that is used during completions. 136 | ls - Show currently registered paths 137 | load-env FILE - Load environment variables from the given file into Xonsh session 138 | 139 | Using https://github.com/theskumar/python-dotenv 140 | 141 | Run "dev COMMAND --help" for more information on a command. 142 | 143 | ``` 144 | 145 | ### 4. parallex 146 | 147 | ``` 148 | usage: parallex [-h] [-s] [-n] [-c] [args ...] 149 | 150 | Execute multiple subprocess in parallel 151 | 152 | positional arguments: 153 | args individual commands need to be quoted and passed as separate arguments 154 | 155 | options: 156 | -h, --help 157 | show this help message and exit 158 | -s, --shell 159 | each command should be run with system's commands 160 | -n, --no-order 161 | commands output are interleaved and not ordered 162 | -c, --hide-cmd 163 | do not print the running command 164 | 165 | Examples 166 | -------- 167 | running linters in parallel 168 | $ parallex "flake8 ." "mypy xonsh" 169 | ``` 170 | -------------------------------------------------------------------------------- /xontrib_commands/cmds/dev_cmd.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import os 3 | import typing as tp 4 | from arger import Argument 5 | from pathlib import Path 6 | 7 | from xonsh.completers.tools import RichCompletion 8 | from xonsh.parsers.completion_context import CommandContext 9 | from xonsh.built_ins import XSH as xsh 10 | 11 | ENVS = {} 12 | 13 | 14 | class DevPaths: 15 | def __init__(self): 16 | self.file = ( 17 | Path(xsh.env.get("XDG_DATA_HOME", "~/.local/share")).resolve() 18 | / "dev-paths.json" 19 | ) 20 | 21 | def update_saved(self, *paths: str) -> None: 22 | import json 23 | 24 | if paths: 25 | content = json.dumps(list(set(paths))) 26 | self.file.write_text(content) 27 | 28 | def load(self) -> list[str]: 29 | import json 30 | 31 | if self.file.exists(): 32 | return json.loads(self.file.read_text()) or [] 33 | return [] 34 | 35 | def get_added_paths(self, add_path: str | None = None) -> tp.Dict[str, str]: 36 | paths = self.load() 37 | if add_path: 38 | paths.append(add_path) 39 | self.update_saved(*paths) 40 | 41 | return {os.path.split(p)[-1]: p for p in paths} 42 | 43 | def clean_paths(self): 44 | from rich.console import Console 45 | 46 | c = Console() 47 | paths = [] 48 | for path in self.load(): 49 | p = Path(path) 50 | if not p.exists(): 51 | c.log("Removing paths that no longer exists", {p.name: p}) 52 | else: 53 | paths.append(path) 54 | self.update_saved(*paths) 55 | 56 | 57 | dev_paths = DevPaths() 58 | 59 | def register_project(fn: tp.Callable = None): 60 | """Register new project. 61 | 62 | Parameters 63 | ---------- 64 | fn 65 | This function will get invoked upon finding the project_path. 66 | the function name will be used to search in $PROJECT_PATHS 67 | """ 68 | 69 | def _wrapper(): 70 | path = _start_proj_shell(fn.__name__) 71 | result = fn(path) 72 | 73 | return result 74 | 75 | ENVS[fn.__name__] = _wrapper 76 | return _wrapper 77 | 78 | 79 | def _start_proj_shell(env: tp.Union[str, Path]): 80 | if isinstance(env, Path) and env.exists(): 81 | path = str(env) 82 | else: 83 | path = find_proj_path(env) 84 | if not path: 85 | raise Exception("Project not found") 86 | os.chdir(path) 87 | return path 88 | 89 | 90 | def get_uniq_project_paths(*paths): 91 | """ 92 | >>> get_uniq_project_paths('~/src/py/', '~/src/py/_repos/', '~/src') 93 | {'~/src'} 94 | """ 95 | proj_roots: "set[str]" = set(paths) 96 | for path in list(proj_roots): 97 | rest = proj_roots.difference({path}) 98 | if path.startswith(tuple(rest)): 99 | proj_roots.remove(path) 100 | return proj_roots 101 | 102 | 103 | def _find_proj_path(name, *funcs): 104 | paths = xsh.env.get("PROJECT_PATHS", []) 105 | # todo: use cd from history 106 | # 1. check history item size. changin it to namedtuple might save some space 107 | # get_uniq_project_paths() - not using recurse directories, since it is slow 108 | found_paths = [] 109 | for direc in set(paths): 110 | root = Path(direc).expanduser() 111 | if not root.is_dir(): 112 | continue 113 | for path in root.iterdir(): 114 | if not path.is_dir(): 115 | continue 116 | for i, op in enumerate(funcs): 117 | if op(path.name, name): 118 | found_paths.append((i, path)) 119 | 120 | yield from map(operator.itemgetter(1),sorted(found_paths,key=operator.itemgetter(0))) 121 | 122 | 123 | def find_proj_path(name): 124 | """return first found path""" 125 | for path in _find_proj_path(name, operator.eq, str.startswith, operator.contains): 126 | return path 127 | 128 | 129 | def _list_cmds(): 130 | from rich.console import Console 131 | 132 | c = Console() 133 | paths = dev_paths.get_added_paths() 134 | c.print("Paths:", paths) 135 | 136 | 137 | def _add_current_path(): 138 | from rich.console import Console 139 | 140 | c = Console() 141 | path = Path.cwd().resolve() 142 | c.log("Adding cwd to project-paths", {path.name: path}) 143 | dev_paths.get_added_paths(str(path)) 144 | 145 | 146 | def proj_name_completer(**kwargs): 147 | command: CommandContext = kwargs.pop("command") 148 | for name, path in dev_paths.get_added_paths().items(): 149 | yield RichCompletion(name, description=path) 150 | yield from ENVS 151 | for path in _find_proj_path(command.prefix, str.startswith): 152 | yield RichCompletion(path.name, description=str(path)) 153 | 154 | 155 | def dev( 156 | name: tp.cast(str, Argument(nargs="?", completer=proj_name_completer)), 157 | add=False, 158 | ls=False, 159 | clean=False, 160 | ): 161 | """A command to cd into a directory 162 | 163 | Inspired from my own workflow and these commands 164 | - https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/pj 165 | - https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/wd 166 | 167 | Parameters 168 | ---------- 169 | name 170 | name of the folder to cd into. 171 | This searches for names under $PROJECT_PATHS 172 | or the ones registered with `dev --add` 173 | add 174 | register the current folder to dev command. 175 | When using this, it will get saved in a file, 176 | also that is used during completions. 177 | ls 178 | show currently registered paths 179 | clean 180 | remove paths that no longer exists 181 | 182 | Notes 183 | ----- 184 | One can use `register_project` function to register custom_callbacks to run on cd to project path 185 | 186 | Examples 187 | ------- 188 | > $PROJECT_PATHS = ["~/src/"] 189 | > dev proj-name # --> will cd into ~/src/proj-name 190 | > dev --add 191 | """ 192 | 193 | if clean: 194 | dev_paths.clean_paths() 195 | elif add: 196 | _add_current_path() 197 | elif ls or (name is None): 198 | _list_cmds() 199 | else: 200 | added_paths = dev_paths.get_added_paths() 201 | if name in ENVS: 202 | ENVS[name]() 203 | elif name in added_paths: 204 | path = added_paths[name] 205 | if os.path.exists(path): 206 | return _start_proj_shell(Path(path)) 207 | else: 208 | # old/expired link 209 | added_paths.pop(name) 210 | dev_paths.update_saved(*tuple(added_paths.values())) 211 | 212 | return _start_proj_shell(name) 213 | 214 | 215 | if __name__ == "__main__": 216 | from xonsh.main import setup 217 | 218 | setup( 219 | env=[ 220 | ("PROJECT_PATHS", ["~/src"]), 221 | ] 222 | ) 223 | print(list(_find_proj_path("", str.startswith))) 224 | --------------------------------------------------------------------------------