├── tests ├── __init__.py ├── testproj │ ├── .flake8 │ ├── bad.py │ ├── good.py │ └── pyproject.toml ├── test_tools.py ├── sleep_and_exit_on_signal.py └── test_jumpthegun.py ├── src ├── jumpthegun │ ├── _vendor │ │ ├── __init__.py │ │ └── filelock │ │ │ ├── py.typed │ │ │ ├── VERSION │ │ │ ├── version.py │ │ │ ├── _error.py │ │ │ ├── _util.py │ │ │ ├── README.md │ │ │ ├── LICENSE │ │ │ ├── __init__.py │ │ │ ├── _unix.py │ │ │ ├── _soft.py │ │ │ ├── _windows.py │ │ │ └── _api.py │ ├── __version__.py │ ├── __init__.py │ ├── env_vars.py │ ├── config.py │ ├── utils.py │ ├── runtime_dir.py │ ├── tools.py │ ├── io_redirect.py │ └── jumpthegunctl.py └── jumpthegun.sh ├── test_requirements.txt ├── .flake8 ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── .github └── workflows │ └── main.yml ├── pyproject.toml ├── docs └── awscli.md ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/VERSION: -------------------------------------------------------------------------------- 1 | 3.9.0 2 | -------------------------------------------------------------------------------- /src/jumpthegun/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | -------------------------------------------------------------------------------- /tests/testproj/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest >= 7.2.1, < 8 2 | tox >= 4, < 5 3 | -------------------------------------------------------------------------------- /src/jumpthegun/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __version__ # noqa: F401 2 | -------------------------------------------------------------------------------- /tests/testproj/bad.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | def foo() -> str: 5 | return ("foo") 6 | -------------------------------------------------------------------------------- /tests/testproj/good.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def foo() -> str: 6 | return "foo" + os.sep + sys.getdefaultencoding() 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-exclude = 3 | jumpthegun/_vendor/, 4 | venv/, 5 | .venv/, 6 | tests/testproj/ 7 | select = E,F,W 8 | ignore = E203, E266, E501, W503 9 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/version.py: -------------------------------------------------------------------------------- 1 | # file generated by setuptools_scm 2 | # don't change, don't track in version control 3 | __version__ = version = '3.9.0' 4 | __version_tuple__ = version_tuple = (3, 9, 0) 5 | -------------------------------------------------------------------------------- /tests/testproj/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "testproj" 3 | 4 | [project.optional-dependencies] 5 | lint = [ 6 | "black", 7 | "flake8", 8 | "isort", 9 | ] 10 | 11 | [tool.black] 12 | line-length = 88 13 | target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313', 'py314'] 14 | include = '\.pyi?$' 15 | 16 | [tool.isort] 17 | atomic = true 18 | profile = "black" 19 | line_length = 88 20 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_error.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Timeout(TimeoutError): 5 | """Raised when the lock could not be acquired in *timeout* seconds.""" 6 | 7 | def __init__(self, lock_file: str) -> None: 8 | #: The path of the file lock. 9 | self.lock_file = lock_file 10 | 11 | def __str__(self) -> str: 12 | return f"The file lock '{self.lock_file}' could not be acquired." 13 | 14 | 15 | __all__ = [ 16 | "Timeout", 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_tools.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jumpthegun.tools import ToolExceptionBase, get_tool_entrypoint 4 | 5 | 6 | def test_find_pip(): 7 | """Test failing to find an entrypoint for a non-existent script.""" 8 | assert callable(get_tool_entrypoint("pip").load()) 9 | 10 | 11 | def test_find_nonexistent_entrypoint(): 12 | """Test failing to find an entrypoint for a non-existent script.""" 13 | with pytest.raises(ToolExceptionBase): 14 | get_tool_entrypoint("DOES_NOT_EXIST") 15 | -------------------------------------------------------------------------------- /tests/sleep_and_exit_on_signal.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import sys 3 | import time 4 | 5 | 6 | def main(): 7 | def signal_handler(signum, frame): 8 | print(f"Received signal: {signum}", flush=True) 9 | sys.exit() 10 | 11 | signal.signal(signal.SIGINT, signal_handler) 12 | signal.signal(signal.SIGTERM, signal_handler) 13 | signal.signal(signal.SIGUSR1, signal_handler) 14 | signal.signal(signal.SIGUSR2, signal_handler) 15 | 16 | print("Sleeping...", flush=True) 17 | time.sleep(60.0) 18 | print("Done.", flush=True) 19 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import stat 5 | 6 | 7 | def raise_on_exist_ro_file(filename: str) -> None: 8 | try: 9 | file_stat = os.stat(filename) # use stat to do exists + can write to check without race condition 10 | except OSError: 11 | return None # swallow does not exist or other errors 12 | 13 | if file_stat.st_mtime != 0: # if os.stat returns but modification is zero that's an invalid os.stat - ignore it 14 | if not (file_stat.st_mode & stat.S_IWUSR): 15 | raise PermissionError(f"Permission denied: {filename!r}") 16 | 17 | 18 | __all__ = [ 19 | "raise_on_exist_ro_file", 20 | ] 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black-pre-commit-mirror 3 | rev: 25.12.0 4 | hooks: 5 | - id: black 6 | entry: black --check 7 | exclude: '^src/jumpthegun/_vendor|^tests/testproj' 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 7.0.0 10 | hooks: 11 | - id: isort 12 | entry: isort --check 13 | exclude: '^src/jumpthegun/_vendor|^tests/testproj' 14 | - repo: https://github.com/PyCQA/flake8 15 | rev: 7.3.0 16 | hooks: 17 | - id: flake8 18 | entry: flake8 19 | exclude: '^src/jumpthegun/_vendor|^tests/testproj' 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.19.0 22 | hooks: 23 | - id: mypy 24 | exclude: '^src/jumpthegun/_vendor|^tests' 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39,310,311,312,313,314}, lint 3 | 4 | [testenv] 5 | setenv = PYTHONPATH = {toxinidir}/src 6 | deps = 7 | -r{toxinidir}/test_requirements.txt 8 | commands = 9 | pytest tests {posargs} 10 | passenv = 11 | HOME 12 | TMPDIR 13 | USER 14 | XDG_* 15 | 16 | [testenv:lint] 17 | setenv = PYTHONPATH = {toxinidir}/src 18 | skip_install = True 19 | deps = 20 | black == 25.12.0 21 | isort == 7.0.0 22 | flake8 == 7.3.0 23 | mypy == 1.19.0 24 | commands = 25 | black --check {toxinidir}/src {toxinidir}/tests 26 | isort --check {toxinidir}/src {toxinidir}/tests 27 | flake8 {toxinidir}/src {toxinidir}/tests 28 | mypy {toxinidir}/src 29 | 30 | [gh-actions] 31 | python = 32 | 3.8: py38 33 | 3.9: py39 34 | 3.10: py310 35 | 3.11: py311 36 | 3.12: py312 37 | 3.13: py313 38 | 3.14: py314 39 | -------------------------------------------------------------------------------- /src/jumpthegun/env_vars.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from typing import Dict, Set 4 | 5 | all = [ 6 | "EnvVarsDiff", 7 | "apply_env_with_diff", 8 | "calc_env_diff", 9 | ] 10 | 11 | 12 | @dataclass 13 | class EnvVarsDiff: 14 | changed: Dict[str, str] 15 | deleted: Set[str] 16 | 17 | 18 | def calc_env_diff(before: Dict[str, str], after: Dict[str, str]) -> EnvVarsDiff: 19 | changed = dict(set(after.items()) - set(before.items())) 20 | deleted = set(before) - set(after) 21 | return EnvVarsDiff(changed=changed, deleted=deleted) 22 | 23 | 24 | def apply_env_with_diff(env: Dict[str, str], diff: EnvVarsDiff) -> None: 25 | for var_name in diff.deleted: 26 | env.pop(var_name, None) 27 | env.update(diff.changed) 28 | for env_var_name in set(os.environ) - set(env): 29 | del os.environ[env_var_name] 30 | os.environ.update(env) 31 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/README.md: -------------------------------------------------------------------------------- 1 | # py-filelock 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/filelock)](https://pypi.org/project/filelock/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/filelock.svg)](https://pypi.org/project/filelock/) 6 | [![Documentation 7 | status](https://readthedocs.org/projects/py-filelock/badge/?version=latest)](https://py-filelock.readthedocs.io/en/latest/?badge=latest) 8 | [![Code style: 9 | black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 10 | [![Downloads](https://pepy.tech/badge/filelock/month)](https://pepy.tech/project/filelock/month) 11 | [![check](https://github.com/tox-dev/py-filelock/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/py-filelock/actions/workflows/check.yml) 12 | 13 | For more information checkout the [official documentation](https://py-filelock.readthedocs.io/en/latest/index.html). 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-2023 Tal Einat 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 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/jumpthegun/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | 8 | @dataclass(frozen=True) 9 | class JumpTheGunConfig: 10 | idle_timeout_seconds: Optional[int] = 4 * 60 * 60 # 4 hours 11 | 12 | def __post_init__(self): 13 | if self.idle_timeout_seconds is None: 14 | pass 15 | elif isinstance(self.idle_timeout_seconds, int): 16 | if self.idle_timeout_seconds <= 0: 17 | raise ValueError("idle_timeout_seconds must be positive.") 18 | else: 19 | raise TypeError("idle_timeout_seconds must be an int or None.") 20 | 21 | 22 | def read_config() -> JumpTheGunConfig: 23 | config_dir = get_xdg_config_dir() 24 | if not config_dir.exists(): 25 | return JumpTheGunConfig() 26 | config_file = config_dir / "jumpthegun.json" 27 | if not config_file.exists(): 28 | return JumpTheGunConfig() 29 | with config_file.open(encoding="utf-8") as f: 30 | config_data = json.load(f) 31 | config = JumpTheGunConfig(**config_data) 32 | return config 33 | 34 | 35 | def get_xdg_config_dir() -> Path: 36 | env_var = os.environ.get("XDG_CONFIG_HOME") 37 | if env_var: 38 | return Path(env_var) 39 | return Path.home() / ".config" 40 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A platform independent file lock that supports the with-statement. 3 | 4 | .. autodata:: filelock.__version__ 5 | :no-value: 6 | 7 | """ 8 | from __future__ import annotations 9 | 10 | import sys 11 | import warnings 12 | from typing import TYPE_CHECKING 13 | 14 | from ._api import AcquireReturnProxy, BaseFileLock 15 | from ._error import Timeout 16 | from ._soft import SoftFileLock 17 | from ._unix import UnixFileLock, has_fcntl 18 | from ._windows import WindowsFileLock 19 | from .version import version 20 | 21 | #: version of the project as a string 22 | __version__: str = version 23 | 24 | 25 | if sys.platform == "win32": # pragma: win32 cover 26 | _FileLock: type[BaseFileLock] = WindowsFileLock 27 | else: # pragma: win32 no cover 28 | if has_fcntl: 29 | _FileLock: type[BaseFileLock] = UnixFileLock 30 | else: 31 | _FileLock = SoftFileLock 32 | if warnings is not None: 33 | warnings.warn("only soft file lock is available") 34 | 35 | #: Alias for the lock, which should be used for the current platform. On Windows, this is an alias for 36 | # :class:`WindowsFileLock`, on Unix for :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`. 37 | if TYPE_CHECKING: 38 | FileLock = SoftFileLock 39 | else: 40 | FileLock = _FileLock 41 | 42 | 43 | __all__ = [ 44 | "__version__", 45 | "FileLock", 46 | "SoftFileLock", 47 | "Timeout", 48 | "UnixFileLock", 49 | "WindowsFileLock", 50 | "BaseFileLock", 51 | "AcquireReturnProxy", 52 | ] 53 | -------------------------------------------------------------------------------- /src/jumpthegun/utils.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | def pid_exists(pid: int): 8 | """Check whether a process with the given pid exists.""" 9 | if not (isinstance(pid, int) and pid > 0): 10 | raise ValueError(f"Invalid PID: {pid}") 11 | 12 | try: 13 | os.kill(pid, 0) 14 | except OSError as err: 15 | if err.errno == errno.ESRCH: 16 | # No such process. 17 | return False 18 | elif err.errno == errno.EPERM: 19 | # Permission denied - such a process must exist. 20 | return True 21 | else: 22 | raise 23 | else: 24 | return True 25 | 26 | 27 | def daemonize(output_file_path: Path): 28 | """Do the double-fork dance to daemonize.""" 29 | # See: 30 | # * https://stackoverflow.com/a/5386753 31 | # * https://www.win.tue.nl/~aeb/linux/lk/lk-10.html 32 | pid = os.fork() 33 | if pid > 0: 34 | sys.exit(0) 35 | os.setsid() 36 | pid = os.fork() 37 | if pid > 0: 38 | sys.exit(0) 39 | 40 | # redirect standard file descriptors 41 | if sys.__stdin__ is not None: 42 | stdin = open("/dev/null", "rb") 43 | os.dup2(stdin.fileno(), sys.__stdin__.fileno()) 44 | if sys.__stdout__ is not None or sys.__stderr__ is not None: 45 | stdouterr = open(output_file_path, "ab") 46 | if sys.__stdout__ is not None: 47 | sys.__stdout__.flush() 48 | os.dup2(stdouterr.fileno(), sys.__stdout__.fileno()) 49 | if sys.__stderr__ is not None: 50 | sys.__stderr__.flush() 51 | os.dup2(stdouterr.fileno(), sys.__stderr__.fileno()) 52 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_unix.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from typing import cast 6 | 7 | from ._api import BaseFileLock 8 | 9 | #: a flag to indicate if the fcntl API is available 10 | has_fcntl = False 11 | if sys.platform == "win32": # pragma: win32 cover 12 | 13 | class UnixFileLock(BaseFileLock): 14 | """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" 15 | 16 | def _acquire(self) -> None: 17 | raise NotImplementedError 18 | 19 | def _release(self) -> None: 20 | raise NotImplementedError 21 | 22 | else: # pragma: win32 no cover 23 | try: 24 | import fcntl 25 | except ImportError: 26 | pass 27 | else: 28 | has_fcntl = True 29 | 30 | class UnixFileLock(BaseFileLock): 31 | """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" 32 | 33 | def _acquire(self) -> None: 34 | open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC 35 | fd = os.open(self._lock_file, open_mode) 36 | try: 37 | fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) 38 | except OSError: 39 | os.close(fd) 40 | else: 41 | self._lock_file_fd = fd 42 | 43 | def _release(self) -> None: 44 | # Do not remove the lockfile: 45 | # https://github.com/tox-dev/py-filelock/issues/31 46 | # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition 47 | fd = cast(int, self._lock_file_fd) 48 | self._lock_file_fd = None 49 | fcntl.flock(fd, fcntl.LOCK_UN) 50 | os.close(fd) 51 | 52 | 53 | __all__ = [ 54 | "has_fcntl", 55 | "UnixFileLock", 56 | ] 57 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_soft.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from errno import EACCES, EEXIST, ENOENT 6 | 7 | from ._api import BaseFileLock 8 | from ._util import raise_on_exist_ro_file 9 | 10 | 11 | class SoftFileLock(BaseFileLock): 12 | """Simply watches the existence of the lock file.""" 13 | 14 | def _acquire(self) -> None: 15 | raise_on_exist_ro_file(self._lock_file) 16 | # first check for exists and read-only mode as the open will mask this case as EEXIST 17 | mode = ( 18 | os.O_WRONLY # open for writing only 19 | | os.O_CREAT 20 | | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists 21 | | os.O_TRUNC # truncate the file to zero byte 22 | ) 23 | try: 24 | fd = os.open(self._lock_file, mode) 25 | except OSError as exception: 26 | if exception.errno == EEXIST: # expected if cannot lock 27 | pass 28 | elif exception.errno == ENOENT: # No such file or directory - parent directory is missing 29 | raise 30 | elif exception.errno == EACCES and sys.platform != "win32": # pragma: win32 no cover 31 | # Permission denied - parent dir is R/O 32 | raise # note windows does not allow you to make a folder r/o only files 33 | else: 34 | self._lock_file_fd = fd 35 | 36 | def _release(self) -> None: 37 | os.close(self._lock_file_fd) # type: ignore # the lock file is definitely not None 38 | self._lock_file_fd = None 39 | try: 40 | os.remove(self._lock_file) 41 | except OSError: # the file is already deleted and that's what we want 42 | pass 43 | 44 | 45 | __all__ = [ 46 | "SoftFileLock", 47 | ] 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | # We want to run on external PRs, but not on our own internal PRs as they'll be run 8 | # by the push to the branch. Without this if check, checks are duplicated since 9 | # internal PRs match both the push and pull_request events. 10 | if: 11 | github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v6 18 | 19 | - name: Set up latest Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: '3.14' 23 | 24 | - name: Install dependencies 25 | run: python -m pip install tox 26 | 27 | - name: Lint 28 | run: tox -e lint 29 | 30 | test: 31 | # We want to run on external PRs, but not on our own internal PRs as they'll be run 32 | # by the push to the branch. Without this if check, checks are duplicated since 33 | # internal PRs match both the push and pull_request events. 34 | if: 35 | github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 36 | 37 | strategy: 38 | matrix: 39 | os: [ubuntu-22.04, ubuntu-24.04, macos-15, macos-15-intel] 40 | python_version: ['3.8' ,'3.9' ,'3.10' ,'3.11', '3.12', '3.13', '3.14'] 41 | 42 | runs-on: ${{ matrix.os }} 43 | 44 | steps: 45 | - name: Checkout code 46 | uses: actions/checkout@v6 47 | 48 | - name: Set up latest Python 49 | uses: actions/setup-python@v6 50 | with: 51 | python-version: ${{ matrix.python_version }} 52 | 53 | - name: Install dependencies 54 | run: | 55 | pip install --upgrade pip 56 | pip install 'tox>=4,<5' 'tox-gh-actions>=3,<4' 57 | 58 | - name: Test 59 | run: tox 60 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_windows.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from errno import ENOENT 6 | from typing import cast 7 | 8 | from ._api import BaseFileLock 9 | from ._util import raise_on_exist_ro_file 10 | 11 | if sys.platform == "win32": # pragma: win32 cover 12 | import msvcrt 13 | 14 | class WindowsFileLock(BaseFileLock): 15 | """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" 16 | 17 | def _acquire(self) -> None: 18 | raise_on_exist_ro_file(self._lock_file) 19 | mode = ( 20 | os.O_RDWR # open for read and write 21 | | os.O_CREAT # create file if not exists 22 | | os.O_TRUNC # truncate file if not empty 23 | ) 24 | try: 25 | fd = os.open(self._lock_file, mode) 26 | except OSError as exception: 27 | if exception.errno == ENOENT: # No such file or directory 28 | raise 29 | else: 30 | try: 31 | msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) 32 | except OSError: 33 | os.close(fd) 34 | else: 35 | self._lock_file_fd = fd 36 | 37 | def _release(self) -> None: 38 | fd = cast(int, self._lock_file_fd) 39 | self._lock_file_fd = None 40 | msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) 41 | os.close(fd) 42 | 43 | try: 44 | os.remove(self._lock_file) 45 | # Probably another instance of the application hat acquired the file lock. 46 | except OSError: 47 | pass 48 | 49 | else: # pragma: win32 no cover 50 | 51 | class WindowsFileLock(BaseFileLock): 52 | """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" 53 | 54 | def _acquire(self) -> None: 55 | raise NotImplementedError 56 | 57 | def _release(self) -> None: 58 | raise NotImplementedError 59 | 60 | 61 | __all__ = [ 62 | "WindowsFileLock", 63 | ] 64 | -------------------------------------------------------------------------------- /src/jumpthegun/runtime_dir.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import random 4 | import shlex 5 | import string 6 | import subprocess 7 | import tempfile 8 | from pathlib import Path 9 | 10 | from jumpthegun._vendor.filelock import FileLock 11 | 12 | 13 | def get_jumpthegun_runtime_dir() -> Path: 14 | runtime_dir = os.getenv("XDG_RUNTIME_DIR") 15 | if runtime_dir: 16 | service_runtime_dir = Path(runtime_dir) / "jumpthegun" 17 | service_runtime_dir.mkdir(exist_ok=True, mode=0o700) 18 | return service_runtime_dir 19 | 20 | temp_dir_path = Path(tempfile.gettempdir()) 21 | service_runtime_dirs = list( 22 | temp_dir_path.glob(f"jumpthegun-{os.getenv('USER')}-??????") 23 | ) 24 | if service_runtime_dirs: 25 | if len(service_runtime_dirs) > 1: 26 | raise Exception("Error: Multiple service runtime dirs found.") 27 | return service_runtime_dirs[0] 28 | 29 | lock = FileLock(temp_dir_path / f"jumpthegun-{os.getenv('USER')}.lock") 30 | with lock: 31 | service_runtime_dirs = list( 32 | temp_dir_path.glob(f"jumpthegun-{os.getenv('USER')}-??????") 33 | ) 34 | if service_runtime_dirs: 35 | return service_runtime_dirs[0] 36 | 37 | random_part = "".join(random.choices(string.ascii_letters, k=6)) 38 | service_runtime_dir = ( 39 | temp_dir_path / f"jumpthegun-{os.getenv('USER')}-{random_part}" 40 | ) 41 | service_runtime_dir.mkdir(exist_ok=False, mode=0o700) 42 | return service_runtime_dir 43 | 44 | 45 | def get_unique_paths_base_for_tool(tool_name: str) -> Path: 46 | tool_executable_path: bytes = subprocess.run( 47 | f"command -v {shlex.quote(tool_name)}", 48 | shell=True, 49 | check=True, 50 | capture_output=True, 51 | ).stdout.strip() 52 | return get_unique_paths_base_for_executable(tool_executable_path) 53 | 54 | 55 | def get_unique_paths_base_for_executable(executable_path: bytes) -> Path: 56 | tool_path_hash: str = hashlib.sha256(executable_path).hexdigest()[:8] 57 | unique_paths_base: Path = get_jumpthegun_runtime_dir() / tool_path_hash 58 | return unique_paths_base 59 | -------------------------------------------------------------------------------- /src/jumpthegun/tools.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib.metadata import EntryPoint, entry_points 3 | from typing import Dict 4 | 5 | __all__ = [ 6 | "get_tool_entrypoint", 7 | "ToolExceptionBase", 8 | "EntrypointNotFound", 9 | "MultipleEntrypointFound", 10 | ] 11 | 12 | testing_tools: Dict[str, str] = { 13 | "__test_sleep_and_exit_on_signal": "sleep_and_exit_on_signal:main", 14 | } 15 | 16 | well_known_tools: Dict[str, str] = { 17 | "aws": "awscli.clidriver:main", 18 | } 19 | 20 | all_known_tools = {**well_known_tools, **testing_tools} 21 | 22 | 23 | class ToolExceptionBase(Exception): 24 | """Exception raised for CLI tool-related exceptions.""" 25 | 26 | tool_name: str 27 | 28 | def __init__(self, tool_name) -> None: 29 | super().__init__(tool_name) 30 | self.tool_name = tool_name 31 | 32 | 33 | class EntrypointNotFound(ToolExceptionBase): 34 | """Exception raised for unsupported CLI tools.""" 35 | 36 | def __str__(self) -> str: 37 | return f"Console entrypoint not found: {self.tool_name}" 38 | 39 | 40 | class MultipleEntrypointFound(ToolExceptionBase): 41 | """Exception raised for unsupported CLI tools.""" 42 | 43 | def __str__(self) -> str: 44 | return f"Multiple console entrypoints: {self.tool_name}" 45 | 46 | 47 | def get_tool_entrypoint(tool_name: str) -> EntryPoint: 48 | """Get an entrypoint function for a CLI tool.""" 49 | tool_entrypoint_str = all_known_tools.get(tool_name) 50 | if tool_entrypoint_str is not None: 51 | entrypoint = EntryPoint( 52 | name=tool_name, 53 | value=tool_entrypoint_str, 54 | group="console_scripts", 55 | ) 56 | return entrypoint 57 | 58 | entrypoints: tuple[EntryPoint, ...] 59 | all_entrypoints = entry_points() 60 | if sys.version_info < (3, 10): 61 | entrypoints = tuple( 62 | ep for ep in all_entrypoints["console_scripts"] if ep.name == tool_name 63 | ) 64 | else: 65 | entrypoints = tuple( 66 | all_entrypoints.select(group="console_scripts", name=tool_name) 67 | ) 68 | 69 | if not entrypoints: 70 | raise EntrypointNotFound(tool_name) 71 | elif len(entrypoints) == 1: 72 | return next(iter(entrypoints)) 73 | else: 74 | raise MultipleEntrypointFound(tool_name) 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jumpthegun" 3 | description = "Make Python CLI tools win the speed race, by cheating!" 4 | license = { text = "Apache-2.0" } 5 | requires-python = ">=3.8" 6 | authors = [ 7 | { name = "Tal Einat", email = "taleinat@gmail.com" }, 8 | ] 9 | keywords = [ 10 | "cli", 11 | ] 12 | classifiers = [ 13 | "Development Status :: 1 - Planning", 14 | "Environment :: Console", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: MacOS :: MacOS X", 18 | "Operating System :: POSIX :: Linux", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: 3.14", 28 | "Topic :: Software Development :: Libraries", 29 | ] 30 | readme = "README.md" 31 | dynamic = ["version"] 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | "pytest>=7,<8", 36 | ] 37 | 38 | [project.scripts] 39 | jumpthegunctl = "jumpthegun.jumpthegunctl:main" 40 | 41 | [project.urls] 42 | Homepage = "https://github.com/taleinat/jumpthegun" 43 | 44 | [build-system] 45 | requires = ["hatchling>=1.8.0"] 46 | build-backend = "hatchling.build" 47 | 48 | [tool.hatch.version] 49 | path = "src/jumpthegun/__version__.py" 50 | 51 | [tool.hatch.build.targets.sdist] 52 | exclude = [ 53 | "/.github", 54 | "/docs", 55 | ] 56 | [tool.hatch.build.targets.sdist.shared-data] 57 | "src/jumpthegun.sh" = "bin/jumpthegun" 58 | 59 | [tool.hatch.build.targets.wheel] 60 | only-include = ["src"] 61 | sources = ["src"] 62 | [tool.hatch.build.targets.wheel.shared-data] 63 | "src/jumpthegun.sh" = "bin/jumpthegun" 64 | 65 | [tool.black] 66 | line-length = 88 67 | target-version = ["py38", "py39", "py310", "py311", "py312", "py313", "py314"] 68 | include = '\.pyi?$' 69 | extend-exclude = 'src/jumpthegun/_vendor|tests/testproj' 70 | 71 | [tool.isort] 72 | atomic = true 73 | profile = "black" 74 | line_length = 88 75 | skip_gitignore = true 76 | extend_skip_glob = ["src/jumpthegun/_vendor", "tests/testproj"] 77 | known_first_party = ["jumpthegun"] 78 | 79 | [tool.mypy] 80 | mypy_path='./src' 81 | exclude = [ 82 | "^tests/" 83 | ] 84 | -------------------------------------------------------------------------------- /docs/awscli.md: -------------------------------------------------------------------------------- 1 | # Running the AWS CLI with JumpTheGun 2 | 3 | To run the AWS CLI with JumpTheGun you must install the AWS CLI from source. 4 | Note that this is supported, but is not the default installation method. 5 | 6 | 7 | ## Installing the AWS CLI from Source 8 | 9 | 10 | ### Notes 11 | 12 | 1. If you already have the AWS CLI installed, you'll likely want to uninstall 13 | that version of it first, or install into an alternate location. 14 | 15 | 2. To install into an alternate location, use the `--prefix` flag when running 16 | `./congigure` and/or the `DESTDIR` env var when running `make install`. 17 | See [the official installation instructions](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-source-install.html#source-getting-started-install-instructions) 18 | for more details. 19 | 20 | 3. You may set e.g. `PYTHON=/usr/local/bin/python3.11` for the `./configure` 21 | command to use a specific version of Python. 22 | 23 | 24 | ### Prerequisites 25 | 26 | 1. Be able to run GNU Autotools generated files such as `configure` and 27 | `Makefile`. If Autotools is not already installed in your environment or you 28 | need to update them, then follow the installation instructions found in 29 | [How do I install the Autotools (as user)?](https://www.gnu.org/software/automake/faq/autotools-faq.html#How-do-I-install-the-Autotools-_0028as-user_0029_003f) 30 | or [Basic Installation](https://www.gnu.org/savannah-checkouts/gnu/automake/manual/automake.html#Basic-Installation) 31 | in the GNU documentation. 32 | 33 | 2. Have Python 3.8 or later installed. 34 | 35 | 36 | ### Installation 37 | 38 | 1. Download and extract the latest AWS CLI source tarball: 39 | ```shell 40 | $ curl -o awscli.tar.gz https://awscli.amazonaws.com/awscli.tar.gz 41 | $ tar -xzf awscli.tar.gz 42 | ``` 43 | 44 | 2. Build: 45 | ```shell 46 | $ cd awscli-* 47 | $ ./configure --with-download-deps 48 | $ make 49 | ``` 50 | 51 | 3. Install: 52 | ```shell 53 | $ make install 54 | ``` 55 | 56 | You may need to use `sudo` for this last command. 57 | 58 | 4. Test: 59 | ```shell 60 | aws --version 61 | ``` 62 | 63 | 64 | ## Running with JumpTheGun 65 | 66 | That hard part is over! With JumpTheGun installed, run: 67 | ```shell 68 | $ jumpthegun run aws ... 69 | ``` 70 | 71 | Note that a daemon will not yet be running for the first invocation, so it will 72 | not be faster than normal. 73 | 74 | 75 | ## References 76 | 77 | * [AWS Docs: Building and installing the AWS CLI from source](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-source-install.html) 78 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Ruff stuff: 141 | .ruff_cache/ 142 | 143 | # PyPI configuration file 144 | .pypirc 145 | 146 | # Test environments 147 | .testenvs/ 148 | -------------------------------------------------------------------------------- /src/jumpthegun/io_redirect.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | import socket 4 | import sys 5 | from typing import Any, BinaryIO, Optional, Union, cast 6 | 7 | 8 | class SocketOutputRedirector: 9 | """Helper class for redirecting stdout and stderr. 10 | 11 | This redirects to a socket, writing in the JumpTheGun protocol. 12 | 13 | Use the .override_outputs_for_imports() context manager to 14 | temporarily override stdout and stderr, buffering all data written 15 | to them. 16 | 17 | Later, use .set_socket() to set the socket to be written to and 18 | override stdout and stderr in a final manner. At this point, any 19 | buffered data will be written to the socket. 20 | """ 21 | 22 | _stdout_buffer: io.StringIO 23 | _stderr_buffer: io.StringIO 24 | 25 | def __init__(self): 26 | self._stdout_buffer = io.StringIO() 27 | self._stderr_buffer = io.StringIO() 28 | 29 | @contextlib.contextmanager 30 | def override_outputs_for_imports(self): 31 | prev_stdout = sys.stdout 32 | prev_stderr = sys.stderr 33 | 34 | sys.stdout = self._stdout_buffer 35 | sys.stderr = self._stderr_buffer 36 | try: 37 | yield 38 | finally: 39 | sys.stdout = prev_stdout 40 | sys.stderr = prev_stderr 41 | 42 | def set_socket(self, conn: socket.socket): 43 | stdout_socket_writer = SocketWriter(prefix=b"1") 44 | stdout_socket_writer.set_socket(conn) 45 | sock_stdout = io.TextIOWrapper( 46 | cast(BinaryIO, stdout_socket_writer), write_through=True 47 | ) 48 | sock_stdout.write(self._stdout_buffer.getvalue()) 49 | sys.stdout.flush() 50 | sys.stdout = sock_stdout 51 | 52 | stderr_socket_writer = SocketWriter(prefix=b"2") 53 | stderr_socket_writer.set_socket(conn) 54 | sock_stderr = io.TextIOWrapper( 55 | cast(BinaryIO, stderr_socket_writer), write_through=True 56 | ) 57 | sock_stderr.write(self._stderr_buffer.getvalue()) 58 | sys.stderr.flush() 59 | sys.stderr = sock_stderr 60 | 61 | 62 | class SocketWriter(io.RawIOBase): 63 | """Output adapter implementing the file interface. 64 | 65 | This writes lines to a socket in the JumpTheGun protocol. 66 | 67 | The socket is set after initialization via .set_socket(). 68 | """ 69 | 70 | _sock: Optional[socket.socket] 71 | 72 | def __init__(self, prefix: bytes) -> None: 73 | self._sock = None 74 | self._prefix = prefix 75 | 76 | def readable(self) -> bool: 77 | return False 78 | 79 | def writable(self) -> bool: 80 | return True 81 | 82 | def write(self, b: Union[bytes, bytearray]) -> int: # type: ignore[override] 83 | if self._sock is None: 84 | raise Exception("SocketWriter socket must be set before calling .write()") 85 | n_newlines = b.count(10) 86 | # print(b"%b%d\n%b\n" % (self._prefix, n_newlines, b), file=sys.__stderr__) 87 | self._sock.sendall(b"%b%d\n%b\n" % (self._prefix, n_newlines, b)) 88 | # print("DONE WRITING", file=sys.__stderr__) 89 | with memoryview(b) as view: 90 | return view.nbytes 91 | 92 | def fileno(self) -> Any: 93 | return self._sock.fileno() if self._sock is not None else None 94 | 95 | def set_socket(self, sock: socket.socket): 96 | if self._sock is not None: 97 | raise Exception("SockerWriter socket may only be set once") 98 | self._sock = sock 99 | 100 | def has_socket(self) -> bool: 101 | return self._sock is not None 102 | 103 | 104 | class StdinWrapper(io.RawIOBase): 105 | """Input adapter implementing the file interface. 106 | 107 | This reads lines from a socket in the JumpTheGun protocol. 108 | """ 109 | 110 | def __init__(self, sock: socket.socket) -> None: 111 | self._sock = sock 112 | self._buf = bytearray() 113 | 114 | def readable(self) -> bool: 115 | return True 116 | 117 | def writable(self) -> bool: 118 | return False 119 | 120 | def readline(self, size: Optional[int] = -1) -> bytes: 121 | if size is None: 122 | size = -1 123 | self._sock.sendall(b"3\n") 124 | buf = self._buf 125 | while size: 126 | chunk = self._sock.recv(size if size != -1 else 4096) 127 | if not chunk: 128 | self._buf = bytearray() 129 | break 130 | idx = chunk.find(10) # ord("\n") == 10 131 | if idx >= 0: 132 | size = idx + 1 133 | if len(chunk) >= size: 134 | buf.extend(chunk[:size]) 135 | self._buf = bytearray(chunk[size:]) 136 | break 137 | buf.extend(chunk) 138 | size = size - len(chunk) if size != -1 else -1 139 | 140 | return buf 141 | 142 | read = readline 143 | 144 | def fileno(self) -> Any: 145 | return self._sock.fileno() 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Jump the Gun 2 | ============ 3 | Make Python CLI tools win the speed race, by cheating! 4 | 5 | Actions Status 6 | PyPI 7 | Downloads 8 | License: MIT 9 | 10 | ```shell 11 | # In any Python (>= 3.8) environment: 12 | pip install jumpthegun 13 | 14 | # For any CLI tool written in Python (>= 3.8): 15 | jumpthegun run [...] 16 | 17 | # Example: 18 | jumpthegun run black --check . 19 | 20 | # More details: 21 | jumpthegun --help 22 | ``` 23 | 24 | 25 | ## Why? 26 | 27 | CLI tools should be fast. Ideally, running them should be near-instant. 28 | 29 | This is especially significant, for example, when running code linting and 30 | formatting tools on just a few files while developing. 31 | 32 | 33 | ## How? 34 | 35 | ✨ Magic! ✨ 36 | 37 | JumpTheGun makes Python CLI tools start very fast, by entirely avoiding the 38 | time taken for Python interpreter startup and module imports. 39 | 40 | It works by: 41 | 42 | 1. Running a daemon process in the background for each CLI tool. This 43 | initializes Python and imports the CLI tool's code in advance. 44 | 2. The daemon listens on a local TCP socket and uses fork to quickly create 45 | sub-processes with everything already initialized. 46 | 3. The `jumpthegun` command is implemented as a Bash script which connects to 47 | the daemon, passes the command-line arguments, and then passes input and 48 | output back and forth. 49 | 50 | Some juicy details: 51 | 52 | * Communication is done using a custom protocol, suitable for a simple 53 | implementation in Bash. 54 | * `jumpthegun run` works even if a daemon is not already running; it will run 55 | a new background daemon in this case. 56 | * JumpTheGun daemons have a timeout, so after a period of inactivity the 57 | daemon will exit. The default timeout is 4 hours. This is configurable via 58 | the config file; see [Configuration](#configuration). 59 | * JumpTheGun needs to import a CLI tool's code and find which function to call 60 | to run it. It gets that info inspecting the tool's entrypoint, as per the 61 | [PyPA Specification](https://packaging.python.org/en/latest/specifications/entry-points/), 62 | via `importlib.metadata.entry_points()`. 63 | * To be able to run CLI tools installed in a different Python environment, 64 | JumpTheGun finds the Python interpreter used by the CLI tool, and if 65 | JumpTheGun isn't available in it, it runs that Python with PYTHONPATH set 66 | to include an additional directory with a copy of JumpTheGun's code. 67 | 68 | 69 | ## Configuration 70 | 71 | The config file is named `jumpthegun.json`. It is searched for in the 72 | following locations, in order: 73 | 1. `$XDG_CONFIG_HOME` 74 | 2. `~/.config/` 75 | 76 | The top-level object should be a mapping. The following keys are supported: 77 | 78 | * `idle_timeout_seconds`: Period with no activity after which the daemon exits. 79 | Default: 4 hours. 80 | 81 | 82 | ## Caveats 83 | 84 | * JumpTheGun is in early stages of development. It works for me; beyond that 85 | I can make no promises. Every detail is likely to change in the future. 86 | * Windows is not supported. 87 | * Uses fork, with all of its caveats. For example, tools that run background 88 | threads during module import will break. JumpTheGun does not check for such 89 | issues. 90 | * Uses local TCP sockets, so firewalls, VPNs etc. may cause issues. 91 | * Does not support running standalone Python scripts which aren't installed 92 | as part of a package. 93 | * Tested with Python 3.7 to 3.11, with x86-64 Ubuntu 20.04 and recent macOS on 94 | an ARM Mac. 95 | * Requires having Bash installed. 96 | 97 | 98 | ## Using with pre-commit 99 | 100 | [pre-commit](https://pre-commit.com/) is awesome, but running linters in 101 | pre-commit hooks makes commits slower, even when running only on staged files. 102 | JumpTheGun fixes that! 103 | 104 | Example config (`.pre-commit-config.yaml`): 105 | ```yaml 106 | repos: 107 | - repo: https://github.com/PyCQA/flake8 108 | rev: 6.0.0 109 | hooks: 110 | - id: flake8 111 | entry: jumpthegun run flake8 112 | additional_dependencies: 113 | - jumpthegun 114 | ``` 115 | 116 | You may need to run `pre-commit install --install-hooks` if you've changed the 117 | config in an existing working copy of a project. 118 | 119 | Then, edit your `.git/hooks/pre-commit` and make this change: 120 | 121 | ```shell 122 | if [ -x "$INSTALL_PYTHON" ]; then 123 | #exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}" 124 | exec jumpthegun run pre-commit "${ARGS[@]}" 125 | ``` 126 | 127 | 128 | ## Copyright & License 129 | 130 | Copyright 2022-2023 Tal Einat. 131 | 132 | Licensed under [the MIT License](LICENSE). 133 | 134 | 135 | Version 3.90 of the filelock library is included in this codebase as-is. It is 136 | made available under the terms of the Unlicense software license. See it's 137 | LICENSE file for details. 138 | -------------------------------------------------------------------------------- /src/jumpthegun.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eEu -o pipefail 3 | 4 | function usage() { 5 | echo "Usage: $0 command tool_name ..." 6 | echo 7 | echo "Available commands:" 8 | echo 9 | echo "run tool_name [OPTIONS] [arg ...] Run a CLI tool." 10 | echo "start tool_name Start a daemon for a CLI tool." 11 | echo "stop tool_name Stop a daemon for a CLI tool." 12 | echo "restart tool_name Restart a daemon for a CLI tool." 13 | echo 14 | } 15 | 16 | function err_exit() { 17 | err_msg="$1" 18 | echo "$err_msg" >&2 19 | exit 1 20 | } 21 | 22 | function get_service_runtime_dir() { 23 | runtime_dir="${XDG_RUNTIME_DIR:-}" 24 | if [ -n "$runtime_dir" ]; then 25 | echo -n "$runtime_dir/jumpthegun" 26 | else 27 | temp_dir="${TMPDIR:-/tmp}" 28 | shopt -s nullglob 29 | service_runtime_dirs=("$temp_dir/jumpthegun-$USER"-??????) 30 | shopt -u nullglob 31 | if [[ ${#service_runtime_dirs[@]} -eq 1 ]]; then 32 | echo -n "${service_runtime_dirs[0]}" 33 | elif [[ ${#service_runtime_dirs[@]} -gt 1 ]]; then 34 | err_exit "Error: Multiple service runtime dirs found." 35 | fi 36 | fi 37 | } 38 | 39 | function hash_str() { 40 | if [[ $OSTYPE == "darwin"* ]]; then 41 | echo -n "$1" | shasum -a 256 - | head -c 8 42 | else 43 | echo -n "$1" | sha256sum - | head -c 8 44 | fi 45 | } 46 | 47 | autorun=1 48 | case "${1:-}" in 49 | -h|--help) 50 | usage && exit 0 ;; 51 | start|stop|restart|version|--version) 52 | [[ "$2" =~ -h|--help ]] && usage && exit 0 53 | tool_name="$2" 54 | 55 | # Find the tool's Python executable and check if it has JumpTheGun installed. 56 | tool_path="$(command -v -- "$tool_name" 2>/dev/null)" || err_exit "Command not found: $tool_name" 57 | shebang="$(head -n 1 -- "$tool_path" 2>/dev/null)" 58 | [[ "${shebang:0:2}" == "#!" ]] || err_exit "No shebang (#!) found in script: $tool_path" 59 | if ! python_executable="$(${shebang#\#!} -c 'import sys; print(sys.executable); import jumpthegun' 2>/dev/null)"; then 60 | # Find JumpTheGun's code. 61 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 62 | jumpthegunctl_path="$SCRIPT_DIR/jumpthegunctl" 63 | jumpthegunctl_shebang="$(head -n 1 -- "$jumpthegunctl_path")" 64 | jumpthegunctl_python_executable="$(${jumpthegunctl_shebang#\#!} -c 'import sys; print(sys.executable)')" 65 | jumpthegun_lib_dir="$("$jumpthegunctl_python_executable" -c 'import jumpthegun, os; print(os.path.dirname(jumpthegun.__file__))')" 66 | 67 | # Make a copy of this version of JumpTheGun's code in a cache directory. 68 | dir_name="lib-$(hash_str "$python_executable|$(head -n 1 "$jumpthegun_lib_dir/__version__.py")")" 69 | cache_home=${XDG_CACHE_HOME:-"$HOME/.cache"} 70 | cache_dir="$cache_home/jumpthegun/$dir_name" 71 | if [ ! -d "$cache_dir" ]; then 72 | mkdir -p "$cache_dir" 73 | cp -r -- "$jumpthegun_lib_dir" "$cache_dir/jumpthegun" 74 | find "$cache_dir/jumpthegun" -type f -not -name '*.py' -exec rm {} + 75 | fi 76 | 77 | # Add the cache directory to PYTHONPATH. 78 | if [[ -n "${PYTHONPATH:-}" ]]; then 79 | export PYTHONPATH="$cache_dir:$PYTHONPATH" 80 | else 81 | export PYTHONPATH="$cache_dir" 82 | fi 83 | fi 84 | 85 | # Run JumpTheGun. 86 | set -f # Disable filename expansion (globbing). 87 | exec $python_executable -c "from jumpthegun.jumpthegunctl import main; main()" "$@" 88 | ;; 89 | run) 90 | shift 91 | [[ $# -eq 0 ]] && usage && exit 1 92 | if [[ "$1" == "--no-autorun" ]]; then 93 | autorun=0 94 | shift 95 | fi 96 | [[ "$1" =~ -h|--help ]] && usage && exit 0 97 | tool_name="$1" 98 | shift 99 | ;; 100 | *) 101 | usage && exit 1 ;; 102 | esac 103 | 104 | # Find service runtime directory. 105 | service_runtime_dir="$(get_service_runtime_dir)" 106 | if [[ -z "$service_runtime_dir" ]]; then 107 | [[ autorun -eq 1 ]] && "${BASH_SOURCE[0]}" start "$tool_name" &>/dev/null & 108 | exec "$tool_name" "$@" 109 | fi 110 | 111 | # Calculate the unique path for pid and socket files. 112 | tool_path="$(command -v -- "$tool_name")" 113 | tool_path_hash="$(hash_str "$tool_path")" 114 | unique_paths_base="$service_runtime_dir/$tool_path_hash" 115 | 116 | # Check that socket file exists. 117 | if [[ ! -f "$unique_paths_base.sock" ]]; then 118 | [[ autorun -eq 1 ]] && "${BASH_SOURCE[0]}" start "$tool_name" &>/dev/null & 119 | exec "$tool_name" "$@" 120 | fi 121 | 122 | # Open Unix domain socket connection 123 | exec 3<>"$unique_path_base.sock" 124 | 125 | # Close socket connection upon exit. 126 | function close_connection { 127 | exec 3<&- 128 | } 129 | trap close_connection EXIT 130 | 131 | 132 | # Read companion process PID. 133 | read -r -u 3 pid 134 | 135 | # Forward some signals. 136 | function forward_signal() { 137 | kill -s "$1" "$pid" 138 | } 139 | for sig in INT TERM USR1 USR2; do 140 | trap "forward_signal $sig" "$sig" 141 | done 142 | 143 | # Send argv and cwd. 144 | oLang="${LANG-}" oLcAll="${LC_ALL-}" 145 | LANG=C LC_ALL=C 146 | # Add an x in front to avoid special-casing having zero arguments. 147 | argv_str=$(printf ' %q' x "$@") 148 | # Remove the leading " x ". 149 | argv_str="${argv_str:3}" 150 | printf '%d\n%s%d\n%s' "${#argv_str}" "$argv_str" "${#PWD}" "$PWD" >&3 151 | LANG="$oLang" LC_ALL="$oLcAll" 152 | 153 | # Send env vars. 154 | x="$(mktemp)" 155 | env -0 > "$x" 2>/dev/null 156 | if [[ $OSTYPE == "darwin"* ]]; then 157 | stat -f %z "$x" >&3 158 | else 159 | stat -c %s "$x" >&3 160 | fi 161 | cat "$x" >&3 162 | rm "$x" 163 | 164 | 165 | # Read stdout and stderr from connection, line by line, and echo them. 166 | IFS= 167 | while read -r -u 3 line; do 168 | case "$line" in 169 | 1*) 170 | # stdout 171 | n_newlines="${line:1}" 172 | for (( i=1; i <= n_newlines; i++ )); do 173 | read -r -u 3 line 174 | echo "$line" 175 | done 176 | read -r -u 3 line 177 | echo -n "$line" 178 | ;; 179 | 2*) 180 | # stderr 181 | n_newlines="${line:1}" 182 | for (( i=1; i <= n_newlines; i++ )); do 183 | read -r -u 3 line 184 | echo "$line" >&2 185 | done 186 | read -r -u 3 line 187 | echo -n "$line" >&2 188 | ;; 189 | 3*) 190 | # stdin 191 | read -r line2 192 | echo "$line2" >&3 193 | ;; 194 | rc=*) 195 | # exit 196 | rc="${line:3}" 197 | exit "$rc" 198 | ;; 199 | *) 200 | echo "Error: Unexpected output from jumpthegun daemon." >&2 201 | exit 1 202 | ;; 203 | esac 204 | done 205 | -------------------------------------------------------------------------------- /tests/test_jumpthegun.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import signal 5 | import subprocess 6 | import sys 7 | import textwrap 8 | from pathlib import Path 9 | from typing import List, Union 10 | 11 | import pytest 12 | 13 | from jumpthegun.jumpthegunctl import get_pid_and_socket_file_paths 14 | 15 | 16 | def get_bin_path(project_path: Path) -> Path: 17 | venv_path = project_path.with_name(project_path.name + "_venv") 18 | bin_dir_name = "Scripts" if sys.platform == "win32" else "bin" 19 | return venv_path / bin_dir_name 20 | 21 | 22 | @pytest.fixture 23 | def testproj(request, testproj_with_jumpthegun, testproj_without_jumpthegun) -> Path: 24 | testproj_name = getattr(request, "param", "testproj_without_jumpthegun") 25 | if testproj_name == "testproj_with_jumpthegun": 26 | return testproj_with_jumpthegun 27 | elif testproj_name == "testproj_without_jumpthegun": 28 | return testproj_without_jumpthegun 29 | else: 30 | raise Exception(f"Invalid project name parameter provided: {testproj_name}") 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def testproj_with_jumpthegun() -> Path: 35 | return _setup_test_project("testproj_with_jumpthegun", with_jumpthegun=True) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def testproj_without_jumpthegun() -> Path: 40 | return _setup_test_project("testproj_without_jumpthegun", with_jumpthegun=False) 41 | 42 | 43 | def _setup_test_project(name: str, with_jumpthegun: bool) -> Path: 44 | root_dir = Path(__file__).parent.parent 45 | testenvs_dir = root_dir / ".testenvs" 46 | testenvs_dir.mkdir(exist_ok=True) 47 | ver_dir = testenvs_dir / sys.version.split()[0] 48 | ver_dir.mkdir(exist_ok=True) 49 | 50 | proj_dir = ver_dir / name 51 | if not proj_dir.exists(): 52 | sources_dir = Path(__file__).parent / "testproj" 53 | shutil.copytree(sources_dir, proj_dir) 54 | venv_path = get_bin_path(proj_dir).parent 55 | subprocess.run( 56 | [sys.executable, "-m", "venv", str(venv_path.resolve())], 57 | check=True, 58 | ) 59 | bin_path = get_bin_path(proj_dir) 60 | if with_jumpthegun: 61 | # Need pip >= 21.3 for editable installation without setup.py. 62 | # See: https://pip.pypa.io/en/stable/news/#v21-3 63 | subprocess.run( 64 | [str(bin_path / "pip"), "install", "--upgrade", "pip >= 21.3"], 65 | cwd=str(root_dir), 66 | check=True, 67 | ) 68 | subprocess.run( 69 | [str(bin_path / "pip"), "install", "black", "flake8", "isort"], 70 | cwd=str(root_dir), 71 | check=True, 72 | ) 73 | if with_jumpthegun: 74 | subprocess.run( 75 | [str(bin_path / "pip"), "install", "-e", "."], 76 | cwd=str(root_dir), 77 | check=True, 78 | ) 79 | sleep_and_exit_on_signal_script = textwrap.dedent( 80 | f"""\ 81 | #!{str(bin_path.absolute() / "python")} 82 | import sleep_and_exit_on_signal 83 | 84 | sleep_and_exit_on_signal.main() 85 | """ 86 | ) 87 | script_path = bin_path / "__test_sleep_and_exit_on_signal" 88 | script_path.write_text(sleep_and_exit_on_signal_script) 89 | script_path.chmod(0o755) 90 | 91 | module_file_path = ( 92 | bin_path.parent 93 | / "lib" 94 | / f"python{'.'.join(map(str, sys.version_info[:2]))}" 95 | / "site-packages" 96 | / "sleep_and_exit_on_signal.py" 97 | ) 98 | shutil.copyfile( 99 | Path(__file__).parent / "sleep_and_exit_on_signal.py", 100 | module_file_path, 101 | ) 102 | 103 | return proj_dir 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "testproj", 108 | ["testproj_with_jumpthegun", "testproj_without_jumpthegun"], 109 | ids=["testproj_with_jumpthegun", "testproj_without_jumpthegun"], 110 | indirect=True, 111 | ) 112 | @pytest.mark.parametrize( 113 | "tool_cmd", 114 | [ 115 | ["black", "--check", "."], 116 | ["isort", "--check", "."], 117 | ["flake8"], 118 | ], 119 | ids=lambda tool_cmd: tool_cmd[0], 120 | ) 121 | def test_jumpthegun_start_run_stop(testproj: Path, tool_cmd: List[str]) -> None: 122 | without_jumpthegun_proc = run(tool_cmd, proj_path=testproj) 123 | assert without_jumpthegun_proc.returncode != 0 124 | 125 | run(["jumpthegun", "start", tool_cmd[0]], proj_path=testproj, check=True) 126 | try: 127 | proc1 = run( 128 | ["jumpthegun", "run", "--no-autorun", *tool_cmd], proj_path=testproj 129 | ) 130 | proc2 = run(["jumpthegun", "run", *tool_cmd], proj_path=testproj) 131 | finally: 132 | run(["jumpthegun", "stop", tool_cmd[0]], proj_path=testproj, check=True) 133 | 134 | assert proc1.stdout == without_jumpthegun_proc.stdout 135 | assert proc1.stderr == without_jumpthegun_proc.stderr 136 | assert proc1.returncode == without_jumpthegun_proc.returncode 137 | 138 | assert proc2.stdout == without_jumpthegun_proc.stdout 139 | assert proc2.stderr == without_jumpthegun_proc.stderr 140 | assert proc2.returncode == without_jumpthegun_proc.returncode 141 | 142 | 143 | def test_jumpthegun_autorun(testproj: Path) -> None: 144 | tool_cmd = ["flake8"] 145 | 146 | without_jumpthegun_proc = run(tool_cmd, proj_path=testproj) 147 | assert without_jumpthegun_proc.returncode != 0 148 | 149 | try: 150 | proc1 = run( 151 | ["jumpthegun", "run", "--no-autorun", *tool_cmd], proj_path=testproj 152 | ) 153 | proc2 = run(["jumpthegun", "run", *tool_cmd], proj_path=testproj) 154 | proc3 = run( 155 | ["jumpthegun", "run", "--no-autorun", *tool_cmd], proj_path=testproj 156 | ) 157 | finally: 158 | run(["jumpthegun", "stop", tool_cmd[0]], proj_path=testproj, check=True) 159 | 160 | assert proc1.stdout == without_jumpthegun_proc.stdout 161 | assert proc1.stderr == without_jumpthegun_proc.stderr 162 | assert proc1.returncode == without_jumpthegun_proc.returncode 163 | 164 | assert proc2.stdout == without_jumpthegun_proc.stdout 165 | assert proc2.stderr == without_jumpthegun_proc.stderr 166 | assert proc2.returncode == without_jumpthegun_proc.returncode 167 | 168 | assert proc3.stdout == without_jumpthegun_proc.stdout 169 | assert proc3.stderr == without_jumpthegun_proc.stderr 170 | assert proc3.returncode == without_jumpthegun_proc.returncode 171 | 172 | 173 | @pytest.mark.parametrize( 174 | "testproj", 175 | ["testproj_with_jumpthegun", "testproj_without_jumpthegun"], 176 | ids=["testproj_with_jumpthegun", "testproj_without_jumpthegun"], 177 | indirect=True, 178 | ) 179 | @pytest.mark.parametrize( 180 | "signum", [signal.SIGINT, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2] 181 | ) 182 | def test_signal_forwarding(testproj: Path, signum: int) -> None: 183 | subcmd = ["__test_sleep_and_exit_on_signal"] 184 | run(["jumpthegun", "start", subcmd[0]], proj_path=testproj, check=True) 185 | try: 186 | proc = run( 187 | ["jumpthegun", "run", "--no-autorun", *subcmd], 188 | proj_path=testproj, 189 | background=True, 190 | ) 191 | assert proc.stdout is not None 192 | assert proc.stdout.readline() == b"Sleeping...\n" 193 | assert proc.poll() is None 194 | proc.send_signal(signum) 195 | proc.wait(5) 196 | assert b"Received signal" in proc.stdout.read() 197 | finally: 198 | try: 199 | run(["jumpthegun", "stop", subcmd[0]], proj_path=testproj, check=True) 200 | except subprocess.CalledProcessError as stop_proc: 201 | print("`jumpthegun stop` failed!") 202 | print("stop proc stdout:", stop_proc.stdout.decode()) 203 | print("stop proc stderr:", stop_proc.stderr.decode()) 204 | pid_file, _ = get_pid_and_socket_file_paths(subcmd[0]) 205 | out_file = pid_file.with_suffix(".out") 206 | print(out_file.read_text()) 207 | raise 208 | 209 | 210 | def run( 211 | cmd: List[str], proj_path: Path, background: bool = False, check: bool = False 212 | ) -> Union[subprocess.CompletedProcess, subprocess.Popen]: 213 | if background and check: 214 | raise ValueError("Must not set both background=True and check=True.") 215 | 216 | pass_through_env_vars = { 217 | key: value 218 | for key, value in os.environ.items() 219 | if re.fullmatch(r"HOME|TMPDIR|USER|XDG_.*", key) 220 | } 221 | 222 | bin_path = get_bin_path(proj_path).resolve() 223 | proc_kwargs = dict( 224 | cwd=str(proj_path), 225 | env={ 226 | **pass_through_env_vars, 227 | "PATH": f"{str(bin_path)}:{os.getenv('PATH', '')}".strip(":"), 228 | "VIRTUAL_ENV": str(bin_path.parent), 229 | }, 230 | stdin=subprocess.DEVNULL, 231 | stdout=subprocess.PIPE, 232 | stderr=subprocess.PIPE, 233 | ) 234 | 235 | if background: 236 | return subprocess.Popen(cmd, **proc_kwargs) 237 | else: 238 | try: 239 | return subprocess.run(cmd, check=check, **proc_kwargs) 240 | except subprocess.CalledProcessError as proc_exc: 241 | if proc_exc.stdout: 242 | print("Stdout:") 243 | print(proc_exc.stdout.decode()) 244 | if proc_exc.stdout: 245 | print("Stderr:") 246 | print(proc_exc.stderr.decode()) 247 | raise 248 | -------------------------------------------------------------------------------- /src/jumpthegun/jumpthegunctl.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import shlex 4 | import signal 5 | import socket 6 | import sys 7 | import time 8 | import traceback 9 | from pathlib import Path 10 | from typing import BinaryIO, Dict, Tuple, cast 11 | 12 | from .__version__ import __version__ 13 | from .config import read_config 14 | from .env_vars import apply_env_with_diff, calc_env_diff 15 | from .io_redirect import SocketOutputRedirector, StdinWrapper 16 | from .runtime_dir import get_unique_paths_base_for_tool 17 | from .tools import ToolExceptionBase, get_tool_entrypoint 18 | from .utils import daemonize as daemonize_func 19 | from .utils import pid_exists 20 | 21 | 22 | class InvalidCommand(Exception): 23 | def __init__(self, command: str): 24 | super().__init__(command) 25 | self.command = command 26 | 27 | 28 | class DaemonAlreadyExistsError(ToolExceptionBase): 29 | def __str__(self): 30 | return ( 31 | f'Jump the Gun daemon process for tool "{self.tool_name}" already exists.' 32 | ) 33 | 34 | 35 | class DaemonDoesNotExistError(ToolExceptionBase): 36 | def __str__(self): 37 | return ( 38 | f'Jump the Gun daemon process for tool "{self.tool_name}" does not exist.' 39 | ) 40 | 41 | 42 | def get_pid_and_socket_file_paths(tool_name: str) -> Tuple[Path, Path]: 43 | unique_paths_base = get_unique_paths_base_for_tool(tool_name) 44 | pid_file_path = unique_paths_base.with_suffix(".pid") 45 | socket_file_path = unique_paths_base.with_suffix(".sock") 46 | return pid_file_path, socket_file_path 47 | 48 | 49 | def remove_pid_and_socket_files(tool_name: str) -> None: 50 | for file_path in get_pid_and_socket_file_paths(tool_name): 51 | if file_path.exists(): 52 | try: 53 | file_path.unlink() 54 | except Exception: 55 | pass 56 | 57 | 58 | def daemon_teardown( 59 | sock: socket.socket, pid: int, pid_file_path: Path, socket_file_path: Path 60 | ) -> None: 61 | """Close socket and remove pid and socket files upon daemon shutdown.""" 62 | sock.close() 63 | if pid_file_path.exists(): 64 | file_pid = int(pid_file_path.read_text()) 65 | if file_pid == pid: 66 | pid_file_path.unlink(missing_ok=True) 67 | socket_file_path.unlink(missing_ok=True) 68 | 69 | 70 | def start(tool_name: str, daemonize: bool = True) -> None: 71 | config = read_config() 72 | 73 | # Import the tool and get its entrypoint function. 74 | # 75 | # Override sys.stdout and sys.stderr while loading the tool runner, 76 | # so that any references to them kept during module imports (e.g for 77 | # setting up logging) already reference the overrides. 78 | output_redirector = SocketOutputRedirector() 79 | with output_redirector.override_outputs_for_imports(): 80 | tool_entrypoint = get_tool_entrypoint(tool_name) 81 | env_before = dict(os.environ) 82 | tool_runner = tool_entrypoint.load() 83 | env_after = dict(os.environ) 84 | env_diff = calc_env_diff(env_before, env_after) 85 | 86 | pid_file_path, socket_file_path = get_pid_and_socket_file_paths(tool_name) 87 | 88 | if pid_file_path.exists(): 89 | file_pid = int(pid_file_path.read_text()) 90 | if pid_exists(file_pid): 91 | raise DaemonAlreadyExistsError(tool_name=tool_name) 92 | 93 | if daemonize: 94 | print(f'"jumpthegun {tool_name}" daemon process starting...') 95 | daemonize_func(pid_file_path.with_suffix(".out")) 96 | 97 | # Write pid file. 98 | pid = os.getpid() 99 | pid_file_path.write_bytes(b"%d\n" % pid) 100 | 101 | # Create and bind a Unix domain socket. 102 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 103 | sock.bind(str(socket_file_path)) 104 | # Set socket permissions to be accessible only by the owner. 105 | os.chmod(socket_file_path, 0o600) 106 | 107 | # Listen for connections. 108 | sock.listen() 109 | print(f"Listening on {socket_file_path} (pid={pid}) ...") 110 | sock.settimeout(config.idle_timeout_seconds) 111 | subproc_pids: set[int] = set() 112 | try: 113 | while True: 114 | conn, address = sock.accept() 115 | print("Got connection") 116 | newpid = os.fork() 117 | if newpid == 0: 118 | break 119 | 120 | # Avoid "zombie" processes: Reap completed sub-processes. 121 | done_subproc_pids = { 122 | x for x in subproc_pids if os.waitpid(x, os.WNOHANG)[0] != 0 123 | } 124 | subproc_pids -= done_subproc_pids 125 | subproc_pids.add(newpid) 126 | except BaseException as exc: 127 | # Server is exiting: Clean up as needed. 128 | daemon_teardown(sock, pid, pid_file_path, socket_file_path) 129 | if isinstance(exc, socket.timeout): 130 | print( 131 | f"Exiting after receiving no connections for {config.idle_timeout_seconds} seconds." 132 | ) 133 | return 134 | raise 135 | 136 | # Send pid. 137 | conn.sendall(b"%d\n" % os.getpid()) 138 | 139 | rfile = conn.makefile("rb", 0) 140 | 141 | # Read and set argv 142 | argv_bytes = rfile.read(int(rfile.readline())) 143 | sys.argv[1:] = shlex.split(argv_bytes.decode()) if argv_bytes else [] 144 | sys.argv[0] = tool_name 145 | 146 | # Read and set cwd 147 | pwd = rfile.read(int(rfile.readline())) 148 | if not pwd: 149 | raise Exception("Did not receive pwd from client.") 150 | os.chdir(pwd) 151 | 152 | # Read and set env vars 153 | env_vars_str: str = (rfile.read(int(rfile.readline())) or b"").decode() 154 | split_lines = (line.split("=", 1) for line in env_vars_str.split("\0")) 155 | env: Dict[str, str] = dict(line for line in split_lines if len(line) == 2) 156 | env.pop("_", None) 157 | apply_env_with_diff(env, env_diff) 158 | 159 | sys.stdin.close() 160 | sys.stdin = io.TextIOWrapper(cast(BinaryIO, StdinWrapper(conn))) 161 | output_redirector.set_socket(conn) 162 | 163 | # start_time = time.monotonic() 164 | exit_code: int 165 | try: 166 | retval = tool_runner() 167 | except BaseException as exc: 168 | # end_time = time.monotonic() 169 | # print(f"Time: {end_time - start_time}", file=sys.__stdout__) 170 | # print("EXCEPTION", str(exc), file=sys.__stderr__) 171 | if isinstance(exc, SystemExit): 172 | exit_code = exc.code if isinstance(exc.code, int) else 1 173 | else: 174 | traceback.print_exc() 175 | exit_code = 1 176 | # print(f"{exit_code=}", file=sys.__stdout__) 177 | if isinstance(exit_code, bool): 178 | exit_code = int(exit_code) 179 | elif not isinstance(exit_code, int): 180 | exit_code = 1 181 | else: 182 | if isinstance(retval, int): 183 | exit_code = retval 184 | else: 185 | exit_code = 0 186 | finally: 187 | conn.sendall(b"rc=%d\n" % exit_code) 188 | # print(f"Goodbye! rc={exit_code}", file=sys.__stdout__) 189 | 190 | sys.stdin.close() 191 | sys.stdout.close() 192 | sys.stderr.close() 193 | conn.shutdown(socket.SHUT_WR) 194 | sys.exit(0) 195 | 196 | 197 | def stop(tool_name: str) -> None: 198 | try: 199 | get_tool_entrypoint(tool_name) 200 | except ToolExceptionBase: 201 | raise DaemonDoesNotExistError(tool_name) 202 | 203 | try: 204 | pid_file_path, _ = get_pid_and_socket_file_paths(tool_name) 205 | if not pid_file_path.exists(): 206 | raise DaemonDoesNotExistError(tool_name) 207 | 208 | file_pid = int(pid_file_path.read_text()) 209 | if not pid_exists(file_pid): 210 | raise DaemonDoesNotExistError(tool_name) 211 | 212 | os.kill(file_pid, signal.SIGTERM) 213 | for _i in range(20): 214 | time.sleep(0.05) 215 | if not pid_exists(file_pid): 216 | break 217 | else: 218 | os.kill(file_pid, signal.SIGKILL) 219 | 220 | print(f'"jumpthegun {tool_name}" daemon process stopped.') 221 | 222 | finally: 223 | remove_pid_and_socket_files(tool_name) 224 | 225 | 226 | def print_usage() -> None: 227 | """Print a message about how to run jumpthegunctl.""" 228 | print(f"Usage: {sys.argv[0]} start|stop tool_name") 229 | 230 | 231 | def do_action(tool_name: str, action: str) -> None: 232 | """Apply an action (e.g. start or stop) for a given tool.""" 233 | if action == "start": 234 | start(tool_name) 235 | elif action == "stop": 236 | stop(tool_name) 237 | elif action == "restart": 238 | try: 239 | stop(tool_name) 240 | except DaemonDoesNotExistError: 241 | pass 242 | start(tool_name) 243 | else: 244 | raise InvalidCommand(action) 245 | 246 | 247 | def main() -> None: 248 | args = sys.argv[1:] 249 | 250 | if any(arg == "-h" or arg == "--help" for arg in args): 251 | print_usage() 252 | sys.exit(0) 253 | 254 | if len(args) == 1: 255 | (cmd,) = args 256 | if cmd == "version" or cmd == "--version": 257 | print(f"jumpthegun v{__version__}") 258 | sys.exit(0) 259 | elif len(args) == 2: 260 | (cmd, tool_name) = args 261 | tool_name = tool_name.strip().lower() 262 | 263 | try: 264 | do_action(tool_name=tool_name, action=cmd) 265 | except ToolExceptionBase as exc: 266 | print(str(exc)) 267 | sys.exit(1) 268 | except InvalidCommand as exc: 269 | print(str(exc)) 270 | else: 271 | sys.exit(0) 272 | 273 | print_usage() 274 | sys.exit(1) 275 | 276 | 277 | if __name__ == "__main__": 278 | main() 279 | -------------------------------------------------------------------------------- /src/jumpthegun/_vendor/filelock/_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import logging 5 | import os 6 | import time 7 | import warnings 8 | from abc import ABC, abstractmethod 9 | from threading import Lock 10 | from types import TracebackType 11 | from typing import Any 12 | 13 | from ._error import Timeout 14 | 15 | _LOGGER = logging.getLogger("filelock") 16 | 17 | 18 | # This is a helper class which is returned by :meth:`BaseFileLock.acquire` and wraps the lock to make sure __enter__ 19 | # is not called twice when entering the with statement. If we would simply return *self*, the lock would be acquired 20 | # again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak) 21 | class AcquireReturnProxy: 22 | """A context aware object that will release the lock file when exiting.""" 23 | 24 | def __init__(self, lock: BaseFileLock) -> None: 25 | self.lock = lock 26 | 27 | def __enter__(self) -> BaseFileLock: 28 | return self.lock 29 | 30 | def __exit__( 31 | self, 32 | exc_type: type[BaseException] | None, # noqa: U100 33 | exc_value: BaseException | None, # noqa: U100 34 | traceback: TracebackType | None, # noqa: U100 35 | ) -> None: 36 | self.lock.release() 37 | 38 | 39 | class BaseFileLock(ABC, contextlib.ContextDecorator): 40 | """Abstract base class for a file lock object.""" 41 | 42 | def __init__(self, lock_file: str | os.PathLike[Any], timeout: float = -1) -> None: 43 | """ 44 | Create a new lock object. 45 | 46 | :param lock_file: path to the file 47 | :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in 48 | the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it 49 | to a negative value. A timeout of 0 means, that there is exactly one attempt to acquire the file lock. 50 | """ 51 | # The path to the lock file. 52 | self._lock_file: str = os.fspath(lock_file) 53 | 54 | # The file descriptor for the *_lock_file* as it is returned by the os.open() function. 55 | # This file lock is only NOT None, if the object currently holds the lock. 56 | self._lock_file_fd: int | None = None 57 | 58 | # The default timeout value. 59 | self._timeout: float = timeout 60 | 61 | # We use this lock primarily for the lock counter. 62 | self._thread_lock: Lock = Lock() 63 | 64 | # The lock counter is used for implementing the nested locking mechanism. Whenever the lock is acquired, the 65 | # counter is increased and the lock is only released, when this value is 0 again. 66 | self._lock_counter: int = 0 67 | 68 | @property 69 | def lock_file(self) -> str: 70 | """:return: path to the lock file""" 71 | return self._lock_file 72 | 73 | @property 74 | def timeout(self) -> float: 75 | """ 76 | :return: the default timeout value, in seconds 77 | 78 | .. versionadded:: 2.0.0 79 | """ 80 | return self._timeout 81 | 82 | @timeout.setter 83 | def timeout(self, value: float | str) -> None: 84 | """ 85 | Change the default timeout value. 86 | 87 | :param value: the new value, in seconds 88 | """ 89 | self._timeout = float(value) 90 | 91 | @abstractmethod 92 | def _acquire(self) -> None: 93 | """If the file lock could be acquired, self._lock_file_fd holds the file descriptor of the lock file.""" 94 | raise NotImplementedError 95 | 96 | @abstractmethod 97 | def _release(self) -> None: 98 | """Releases the lock and sets self._lock_file_fd to None.""" 99 | raise NotImplementedError 100 | 101 | @property 102 | def is_locked(self) -> bool: 103 | """ 104 | 105 | :return: A boolean indicating if the lock file is holding the lock currently. 106 | 107 | .. versionchanged:: 2.0.0 108 | 109 | This was previously a method and is now a property. 110 | """ 111 | return self._lock_file_fd is not None 112 | 113 | def acquire( 114 | self, 115 | timeout: float | None = None, 116 | poll_interval: float = 0.05, 117 | *, 118 | poll_intervall: float | None = None, 119 | blocking: bool = True, 120 | ) -> AcquireReturnProxy: 121 | """ 122 | Try to acquire the file lock. 123 | 124 | :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~timeout` is and 125 | if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired 126 | :param poll_interval: interval of trying to acquire the lock file 127 | :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead 128 | :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the 129 | first attempt. Otherwise this method will block until the timeout expires or the lock is acquired. 130 | :raises Timeout: if fails to acquire lock within the timeout period 131 | :return: a context object that will unlock the file when the context is exited 132 | 133 | .. code-block:: python 134 | 135 | # You can use this method in the context manager (recommended) 136 | with lock.acquire(): 137 | pass 138 | 139 | # Or use an equivalent try-finally construct: 140 | lock.acquire() 141 | try: 142 | pass 143 | finally: 144 | lock.release() 145 | 146 | .. versionchanged:: 2.0.0 147 | 148 | This method returns now a *proxy* object instead of *self*, 149 | so that it can be used in a with statement without side effects. 150 | 151 | """ 152 | # Use the default timeout, if no timeout is provided. 153 | if timeout is None: 154 | timeout = self.timeout 155 | 156 | if poll_intervall is not None: 157 | msg = "use poll_interval instead of poll_intervall" 158 | warnings.warn(msg, DeprecationWarning, stacklevel=2) 159 | poll_interval = poll_intervall 160 | 161 | # Increment the number right at the beginning. We can still undo it, if something fails. 162 | with self._thread_lock: 163 | self._lock_counter += 1 164 | 165 | lock_id = id(self) 166 | lock_filename = self._lock_file 167 | start_time = time.monotonic() 168 | try: 169 | while True: 170 | with self._thread_lock: 171 | if not self.is_locked: 172 | _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) 173 | self._acquire() 174 | 175 | if self.is_locked: 176 | _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) 177 | break 178 | elif blocking is False: 179 | _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) 180 | raise Timeout(self._lock_file) 181 | elif 0 <= timeout < time.monotonic() - start_time: 182 | _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) 183 | raise Timeout(self._lock_file) 184 | else: 185 | msg = "Lock %s not acquired on %s, waiting %s seconds ..." 186 | _LOGGER.debug(msg, lock_id, lock_filename, poll_interval) 187 | time.sleep(poll_interval) 188 | except BaseException: # Something did go wrong, so decrement the counter. 189 | with self._thread_lock: 190 | self._lock_counter = max(0, self._lock_counter - 1) 191 | raise 192 | return AcquireReturnProxy(lock=self) 193 | 194 | def release(self, force: bool = False) -> None: 195 | """ 196 | Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0. Also 197 | note, that the lock file itself is not automatically deleted. 198 | 199 | :param force: If true, the lock counter is ignored and the lock is released in every case/ 200 | """ 201 | with self._thread_lock: 202 | 203 | if self.is_locked: 204 | self._lock_counter -= 1 205 | 206 | if self._lock_counter == 0 or force: 207 | lock_id, lock_filename = id(self), self._lock_file 208 | 209 | _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) 210 | self._release() 211 | self._lock_counter = 0 212 | _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) 213 | 214 | def __enter__(self) -> BaseFileLock: 215 | """ 216 | Acquire the lock. 217 | 218 | :return: the lock object 219 | """ 220 | self.acquire() 221 | return self 222 | 223 | def __exit__( 224 | self, 225 | exc_type: type[BaseException] | None, # noqa: U100 226 | exc_value: BaseException | None, # noqa: U100 227 | traceback: TracebackType | None, # noqa: U100 228 | ) -> None: 229 | """ 230 | Release the lock. 231 | 232 | :param exc_type: the exception type if raised 233 | :param exc_value: the exception value if raised 234 | :param traceback: the exception traceback if raised 235 | """ 236 | self.release() 237 | 238 | def __del__(self) -> None: 239 | """Called when the lock object is deleted.""" 240 | self.release(force=True) 241 | 242 | 243 | __all__ = [ 244 | "BaseFileLock", 245 | "AcquireReturnProxy", 246 | ] 247 | --------------------------------------------------------------------------------