├── tests ├── __init__.py ├── test_cli_utils.py ├── test_formatters.py ├── conftest.py ├── test_utils.py ├── test_spawn.py ├── test_cli_signals.py ├── test_lib_usage.py ├── test_hap_workdir.py ├── test_hap.py ├── test_ui.py ├── test_cli.py └── test_hapless.py ├── examples ├── script.sh ├── samename.py ├── nested │ └── samename.py ├── fast.py ├── redirect_logs.py ├── long_running.py ├── fail_fast.py └── show_details.py ├── hapless ├── __init__.py ├── cli_utils.py ├── config.py ├── ui.py ├── utils.py ├── formatters.py ├── cli.py ├── hap.py └── main.py ├── tools └── create_hap.py ├── .editorconfig ├── noxfile.py ├── .pre-commit-config.yaml ├── Makefile ├── .github └── workflows │ ├── publish-to-pypi.yml │ ├── publish-to-test-pypi.yml │ ├── tests.yml │ ├── workflow-build-package.yml │ └── publish-new-release.yml ├── DEVELOP.md ├── .gitignore ├── pyproject.toml ├── README.md ├── USAGE.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for i in {1..10} 4 | do 5 | echo "Processing $i..." 6 | sleep 2 7 | done 8 | -------------------------------------------------------------------------------- /hapless/__init__.py: -------------------------------------------------------------------------------- 1 | from .hap import Hap as Hap 2 | from .hap import Status as Status 3 | from .main import Hapless as Hapless 4 | -------------------------------------------------------------------------------- /examples/samename.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Correct file is being run", flush=True) 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /examples/nested/samename.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Malicious code execution", flush=True) 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /examples/fast.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def main(): 5 | print("Starting", flush=True) 6 | time.sleep(5) 7 | print("I am done", flush=True) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /examples/redirect_logs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | 4 | 5 | def main(): 6 | print("This is stdout", flush=True, file=sys.stdout) 7 | print("This is stderr", flush=True, file=sys.stderr) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /tools/create_hap.py: -------------------------------------------------------------------------------- 1 | from hapless import Hapless 2 | 3 | 4 | def main(): 5 | hapless = Hapless() 6 | hap = hapless.create_hap("echo 'Test empty hap'") 7 | print(f"Created {hap}, current status: {hap.status}") 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /examples/long_running.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def main(): 5 | # this should be running for about 2 hours 6 | for i in range(1000): 7 | print(f"Iteration {i}...", flush=True) 8 | time.sleep(10) 9 | 10 | 11 | if __name__ == "__main__": 12 | main() 13 | -------------------------------------------------------------------------------- /examples/fail_fast.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | 5 | def main(): 6 | print("Failing in 1 sec", flush=True) 7 | time.sleep(1) 8 | print("This script just fails", file=sys.stderr, flush=True) 9 | sys.exit(1) 10 | 11 | 12 | if __name__ == "__main__": 13 | main() 14 | -------------------------------------------------------------------------------- /examples/show_details.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | 6 | def main(): 7 | print("This is arguments") 8 | print(f"{sys.argv}") 9 | print("This is environment", flush=True) 10 | for key, value in os.environ.items(): 11 | print(f"{key} : {value}", flush=True) 12 | 13 | 14 | if __name__ == "__main__": 15 | main() 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 2 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | # 4 space indentation 16 | [*.{py,toml}] 17 | indent_size = 4 18 | 19 | [Makefile] 20 | indent_size = 4 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | POETRY_EXEC = "poetry1.8" 4 | TARGET_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 5 | 6 | 7 | @nox.session(python=TARGET_VERSIONS) 8 | def test(session: nox.Session) -> None: 9 | # session.install(".") 10 | session.install(".[dev]") 11 | session.run("pytest", "tests") 12 | 13 | 14 | @nox.session(python=TARGET_VERSIONS) 15 | def check_python(session: nox.Session) -> None: 16 | session.run("python", "--version") 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.12.12 9 | hooks: 10 | - id: ruff-check 11 | args: ["--fix"] 12 | - id: ruff-format 13 | - repo: https://github.com/python-poetry/poetry 14 | rev: "1.8.5" 15 | hooks: 16 | - id: poetry-check 17 | - id: poetry-lock 18 | files: "^(pyproject.toml|poetry.lock)$" 19 | -------------------------------------------------------------------------------- /hapless/cli_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from hapless import Hap, Hapless, config 6 | 7 | hapless = Hapless(hapless_dir=config.HAPLESS_DIR) 8 | console = hapless.ui 9 | 10 | 11 | def get_or_exit(hap_alias: str) -> Hap: 12 | hap = hapless.get_hap(hap_alias) 13 | if hap is None: 14 | console.error(f"No such hap: {hap_alias}") 15 | return sys.exit(1) 16 | if not hap.accessible: 17 | console.error(f"Cannot manage hap launched by another user. Owner: {hap.owner}") 18 | return sys.exit(1) 19 | return hap 20 | 21 | 22 | hap_argument = click.argument( 23 | "hap_alias", 24 | metavar="hap", 25 | required=True, 26 | ) 27 | hap_argument_optional = click.argument( 28 | "hap_alias", 29 | metavar="hap", 30 | required=False, 31 | ) 32 | -------------------------------------------------------------------------------- /hapless/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import environ 5 | 6 | env = environ.Env() 7 | 8 | DEBUG = env.bool("HAPLESS_DEBUG", default=False) 9 | 10 | HAPLESS_DIR: Optional[Path] = env("HAPLESS_DIR", cast=Path, default=None) 11 | 12 | COLOR_MAIN = "#fdca40" 13 | COLOR_ACCENT = "#3aaed8" 14 | COLOR_ERROR = "#f64740" 15 | STATUS_COLORS = { 16 | "running": "#f79824", 17 | "paused": "#f6efee", 18 | "success": "#4aad52", 19 | "failed": COLOR_ERROR, 20 | } 21 | 22 | ICON_HAP = "⚡️" 23 | ICON_INFO = "🧲" 24 | ICON_STATUS = "•" 25 | ICON_KILLED = "💀" 26 | 27 | FAILFAST_TIMEOUT = env.int("HAPLESS_FAILFAST_TIMEOUT", default=5) 28 | DATETIME_FORMAT = "%H:%M:%S %Y/%m/%d" 29 | TRUNCATE_LENGTH = 36 30 | RESTART_DELIM = "@" 31 | 32 | NO_FORK = env.bool("HAPLESS_NO_FORK", default=False) 33 | 34 | REDIRECT_STDERR = env.bool("HAPLESS_REDIRECT_STDERR", default=False) 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @rm -rf dist/ 4 | @rm -rf build/ 5 | @poetry build 6 | 7 | 8 | .PHONY: tests 9 | tests: 10 | @poetry run pytest -sv tests 11 | 12 | 13 | .PHONY: coverage 14 | coverage: 15 | @poetry run pytest --cov=hapless tests 16 | 17 | 18 | .PHONY: coverage-report 19 | coverage-report: 20 | @poetry run pytest --cov=hapless --cov-report=html tests 21 | 22 | 23 | .PHONY: nox 24 | nox: 25 | @NOX_DEFAULT_VENV_BACKEND=uv \ 26 | poetry run nox -s test 27 | 28 | 29 | .PHONY: format 30 | format: 31 | @poetry run ruff format . 32 | 33 | 34 | .PHONY: lint 35 | lint: 36 | @poetry run ruff check . 37 | 38 | 39 | .PHONY: ty 40 | ty: 41 | @poetry run ty check . 42 | 43 | 44 | .PHONY: tag 45 | tag: 46 | @VERSION=$$(poetry version --short); \ 47 | git tag -a "v$$VERSION" -m "Version $$VERSION"; \ 48 | git push origin "v$$VERSION"; \ 49 | echo "Created and pushed tag v$$VERSION" 50 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: "[PyPI] 📦 Publish Python 🐍 distribution" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | build-package: 12 | uses: ./.github/workflows/workflow-build-package.yml 13 | with: 14 | python_version: "3.x" 15 | poetry_version: "1.8.5" 16 | generate_temp_version: ${{ github.event_name == 'pull_request' && true || false }} 17 | 18 | publish-to-pypi: 19 | name: Publish Python 🐍 distribution 📦 to PyPI 20 | # only publish to PyPI on tag pushes 21 | if: startsWith(github.ref, 'refs/tags/') 22 | needs: 23 | - build-package 24 | runs-on: ubuntu-latest 25 | 26 | environment: 27 | name: pypi 28 | url: https://pypi.org/p/hapless 29 | 30 | permissions: 31 | id-token: write # IMPORTANT: mandatory for trusted publishing 32 | 33 | steps: 34 | - name: Download all the dists 35 | uses: actions/download-artifact@v4 36 | with: 37 | name: python-package-distributions 38 | path: dist/ 39 | 40 | - name: Publish distribution 📦 to PyPI 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: "[TestPyPI] Test publish package" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-package: 13 | uses: ./.github/workflows/workflow-build-package.yml 14 | with: 15 | python_version: "3.x" 16 | poetry_version: "1.8.5" 17 | generate_temp_version: ${{ github.event_name == 'pull_request' && true || false }} 18 | 19 | publish-to-testpypi: 20 | name: Publish Python 🐍 distribution 📦 to TestPyPI 21 | needs: 22 | - build-package 23 | runs-on: ubuntu-latest 24 | 25 | environment: 26 | name: testpypi 27 | url: https://test.pypi.org/p/hapless 28 | 29 | permissions: 30 | id-token: write # IMPORTANT: mandatory for trusted publishing 31 | 32 | steps: 33 | - name: Download all the dists 34 | uses: actions/download-artifact@v4 35 | with: 36 | name: python-package-distributions 37 | path: dist/ 38 | 39 | - name: Publish distribution 📦 to TestPyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | repository-url: https://test.pypi.org/legacy/ 43 | skip-existing: true 44 | -------------------------------------------------------------------------------- /tests/test_cli_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, PropertyMock, patch 2 | 3 | import pytest 4 | 5 | from hapless.cli_utils import get_or_exit 6 | 7 | 8 | @patch("hapless.cli_utils.hapless") 9 | def test_get_or_exit_non_existing(hapless_mock, capsys): 10 | with patch.object(hapless_mock, "get_hap", return_value=None) as get_hap_mock: 11 | with pytest.raises(SystemExit) as e: 12 | get_or_exit("hap-me") 13 | 14 | assert e.value.code == 1 15 | get_hap_mock.assert_called_once_with("hap-me") 16 | 17 | captured = capsys.readouterr() 18 | assert "No such hap: hap-me" in captured.out 19 | 20 | 21 | @patch("hapless.cli_utils.hapless") 22 | def test_get_or_exit_not_accessible(hapless_mock, capsys): 23 | hap_mock = Mock(owner="someone-else") 24 | prop_mock = PropertyMock(return_value=False) 25 | type(hap_mock).accessible = prop_mock 26 | with patch.object(hapless_mock, "get_hap", return_value=hap_mock) as get_hap_mock: 27 | with pytest.raises(SystemExit) as e: 28 | get_or_exit("hap-not-mine") 29 | 30 | assert e.value.code == 1 31 | get_hap_mock.assert_called_once_with("hap-not-mine") 32 | prop_mock.assert_called_once_with() 33 | 34 | captured = capsys.readouterr() 35 | assert ( 36 | "Cannot manage hap launched by another user. Owner: someone-else" 37 | in captured.out 38 | ) 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | # Pinned to this version to still support Python 3.8 13 | POETRY_VERSION: "1.8.5" 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0"] 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v5 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install Poetry 32 | uses: snok/install-poetry@v1 33 | with: 34 | version: ${{ env.POETRY_VERSION }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | poetry install --all-extras 39 | 40 | - name: Check codestyle with ruff 41 | if: ${{ always() }} 42 | run: | 43 | poetry run ruff --version 44 | poetry run ruff format --diff . 45 | 46 | - name: Lint code with ruff 47 | if: ${{ always() }} 48 | run: | 49 | poetry run ruff check . 50 | 51 | - name: Type check code with ty 52 | if: ${{ always() }} 53 | run: | 54 | poetry run ty --version 55 | poetry run ty check . 56 | 57 | - name: Run unittests 58 | run: | 59 | poetry run pytest -sv -W error tests 60 | -------------------------------------------------------------------------------- /.github/workflows/workflow-build-package.yml: -------------------------------------------------------------------------------- 1 | name: Reusable workflow for building Python package 📦 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python_version: 7 | default: "3.x" 8 | required: false 9 | type: string 10 | poetry_version: 11 | required: true 12 | type: string 13 | generate_temp_version: 14 | default: false 15 | required: false 16 | type: boolean 17 | 18 | jobs: 19 | build: 20 | name: Build distribution 📦 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v5 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ inputs.python_version }} 31 | 32 | - name: Install Poetry 33 | uses: snok/install-poetry@v1 34 | with: 35 | version: ${{ inputs.poetry_version }} 36 | 37 | - name: Generate temp version for the current commit 38 | if: ${{ inputs.generate_temp_version }} 39 | run: | 40 | CURRENT_VERSION=$(poetry version --short) 41 | NEW_VERSION="${CURRENT_VERSION}.dev${{ github.run_number }}${{ github.run_attempt }}" 42 | echo "Run number: ${{ github.run_number }}" 43 | echo "Run attempt: ${{ github.run_attempt }}" 44 | echo "New version: ${NEW_VERSION}" 45 | poetry version ${NEW_VERSION} 46 | 47 | - name: Build package 48 | run: poetry build 49 | 50 | - name: Store the distribution packages 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: python-package-distributions 54 | path: dist/ 55 | -------------------------------------------------------------------------------- /tests/test_formatters.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from hapless.formatters import JSONFormatter, TableFormatter 4 | from hapless.hap import Hap, Status 5 | from hapless.main import Hapless 6 | 7 | 8 | def test_table_formatter_status_text(): 9 | formatter = TableFormatter() 10 | result = formatter._get_status_text(Status.SUCCESS) 11 | assert result.plain == "• success" 12 | 13 | 14 | def test_json_formatter_single_hap(hap: Hap): 15 | formatter = JSONFormatter() 16 | result = formatter.format_one(hap) 17 | assert isinstance(result, str) 18 | assert result[0] == "{" 19 | assert hap.name in result 20 | assert '"pid": null' in result 21 | assert result[-1] == "}" 22 | # Check if it is a valid JSON 23 | obj = json.loads(result) 24 | assert isinstance(obj, dict) 25 | assert "name" in obj 26 | assert obj["name"] == hap.name 27 | 28 | 29 | def test_json_formatter_multiple_haps(hapless: Hapless): 30 | haps = [ 31 | hapless.create_hap("true", name="hap1"), 32 | hapless.create_hap("true", name="hap2"), 33 | hapless.create_hap("true", name="hap3"), 34 | ] 35 | 36 | formatter = JSONFormatter() 37 | result = formatter.format_list(haps) 38 | assert isinstance(result, str) 39 | assert result[0] == "[" 40 | assert "hap1" in result 41 | assert "hap2" in result 42 | assert "hap3" in result 43 | assert "null" in result 44 | assert "{" in result 45 | assert "}" in result 46 | assert result[-1] == "]" 47 | # Check if it is a valid JSON 48 | objects = json.loads(result) 49 | assert isinstance(objects, list) 50 | assert len(objects) == len(haps) 51 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Generator 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | import structlog 8 | from click.testing import CliRunner 9 | from structlog import testing 10 | 11 | from hapless.hap import Hap 12 | from hapless.main import Hapless 13 | 14 | 15 | class LogCapture(testing.LogCapture): 16 | @property 17 | def text(self) -> str: 18 | return "\n".join( 19 | f"{entry['log_level'].upper()}: {entry['event']}" for entry in self.entries 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def runner() -> Generator[CliRunner, None, None]: 25 | cli_runner = CliRunner() 26 | with cli_runner.isolated_filesystem() as path: 27 | hapless_test = Hapless(hapless_dir=Path(path)) 28 | with patch("hapless.cli.hapless", hapless_test) as hapless_mock: 29 | cli_runner.hapless = hapless_mock # ty: ignore[unresolved-attribute] 30 | yield cli_runner 31 | 32 | 33 | @pytest.fixture 34 | def hap(tmp_path: Path) -> Generator[Hap, None, None]: 35 | hapless = Hapless(hapless_dir=tmp_path) 36 | yield hapless.create_hap("false") 37 | 38 | 39 | @pytest.fixture 40 | def hapless(tmp_path: Path, monkeypatch) -> Generator[Hapless, None, None]: 41 | monkeypatch.chdir(tmp_path) 42 | yield Hapless(hapless_dir=tmp_path, quiet=True) 43 | 44 | 45 | @pytest.fixture(name="log_output") 46 | def structlog_log_output(): 47 | log_capture = LogCapture() 48 | structlog.configure( 49 | processors=[log_capture], 50 | wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG), 51 | ) 52 | return log_capture 53 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | ### Development 2 | 3 | Install [Poetry](https://python-poetry.org/) and project's dependencies 4 | 5 | ```bash 6 | poetry install 7 | ``` 8 | 9 | Add new feature and launch tests 10 | 11 | ```bash 12 | poetry run pytest -sv tests 13 | # or 14 | make tests 15 | ``` 16 | 17 | Lint the code and run type checking 18 | 19 | ```bash 20 | make lint 21 | make ty 22 | ``` 23 | 24 | Install git hooks for the automatic linting and code formatting with [pre-commit](https://pre-commit.com/) 25 | 26 | ```bash 27 | pre-commit install 28 | pre-commit run --all-files # to initialize for the first time 29 | pre-commit run ruff-check # run a hook individually 30 | ``` 31 | 32 | Enable extra logging 33 | 34 | ```bash 35 | export HAPLESS_DEBUG=1 36 | ``` 37 | 38 | Check coverage 39 | 40 | ```bash 41 | poetry run pytest --cov=hapless --cov-report=html tests/ 42 | # or 43 | make coverage-report 44 | ``` 45 | 46 | Run against multiple Python versions with [nox](https://nox.thea.codes/en/stable/index.html) 47 | 48 | ```bash 49 | # Use uv as a default backend for virtual environment creation 50 | export NOX_DEFAULT_VENV_BACKEND=uv 51 | 52 | # Show available nox sessions 53 | nox --list 54 | 55 | # Check all of the defined Python versions are available 56 | nox -s check_versions 57 | 58 | # Run tests against all versions 59 | nox -s test 60 | ``` 61 | 62 | ### Releasing 63 | 64 | Bump a version with features you want to include and build a package 65 | 66 | ```bash 67 | poetry version patch # patch version update 68 | poetry version minor 69 | poetry version major # choose one based on semver rules 70 | poetry build 71 | ``` 72 | 73 | Building and uploading package to PyPI is done automatically by GitHub workflow on tag creating 74 | 75 | ```bash 76 | make tag 77 | ``` 78 | -------------------------------------------------------------------------------- /hapless/ui.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from rich.console import Console 4 | from rich.live import Live 5 | 6 | from hapless import config 7 | from hapless.formatters import Formatter, TableFormatter 8 | from hapless.hap import Hap 9 | 10 | 11 | class ConsoleUI: 12 | def __init__(self, disable: bool = False) -> None: 13 | self.console = Console(highlight=False, quiet=disable) 14 | self.default_formatter = TableFormatter() 15 | self.disable = disable 16 | 17 | def print(self, *args, **kwargs): 18 | return self.console.print(*args, **kwargs) 19 | 20 | def print_plain(self, text: str): 21 | return self.console.print( 22 | text, 23 | markup=False, 24 | highlight=False, 25 | emoji=False, 26 | no_wrap=True, 27 | overflow="ignore", 28 | crop=False, 29 | ) 30 | 31 | def error(self, message: str): 32 | return self.console.print( 33 | f"{config.ICON_INFO} {message}", 34 | style=f"{config.COLOR_ERROR} bold", 35 | overflow="ignore", 36 | crop=False, 37 | ) 38 | 39 | def get_live(self): 40 | return Live( 41 | console=self.console, 42 | refresh_per_second=10, 43 | transient=True, 44 | ) 45 | 46 | def stats(self, haps: List[Hap], formatter: Optional[Formatter] = None): 47 | if not haps: 48 | self.console.print( 49 | f"{config.ICON_INFO} No haps are currently running", 50 | style=f"{config.COLOR_MAIN} bold", 51 | ) 52 | return 53 | formatter = formatter or self.default_formatter 54 | haps_data = formatter.format_list(haps) 55 | self.console.print(haps_data, soft_wrap=True) 56 | 57 | def show_one(self, hap: Hap, formatter: Optional[Formatter] = None): 58 | formatter = formatter or self.default_formatter 59 | hap_data = formatter.format_one(hap) 60 | self.console.print(hap_data, soft_wrap=True) 61 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | from pathlib import Path 4 | from unittest.mock import Mock 5 | 6 | import click 7 | import pytest 8 | 9 | from hapless.utils import allow_missing, kill_proc_tree, validate_signal 10 | 11 | 12 | def read_file(path): 13 | with open(path) as f: 14 | return f.read() 15 | 16 | 17 | def test_allow_missing_no_file(): 18 | path = Path("does-not-exist") 19 | decorated = allow_missing(read_file) 20 | result = decorated(path) 21 | assert result is None 22 | 23 | 24 | def test_allow_missing_file_exists(tmp_path): 25 | path = tmp_path / "file.txt" 26 | path.write_text("content") 27 | decorated = allow_missing(read_file) 28 | result = decorated(path) 29 | assert result == "content" 30 | 31 | 32 | def test_validate_signal_wrong_code(): 33 | ctx = click.get_current_context(silent=True) 34 | param = Mock() 35 | with pytest.raises(click.BadParameter) as excinfo: 36 | validate_signal(ctx, param, "not-a-number") 37 | 38 | assert str(excinfo.value) == "Signal should be a valid integer value" 39 | 40 | 41 | def test_validate_signal_out_of_bounds(): 42 | ctx = click.get_current_context(silent=True) 43 | param = Mock() 44 | code = signal.NSIG 45 | with pytest.raises(click.BadParameter) as excinfo: 46 | validate_signal(ctx, param, code) 47 | 48 | assert str(excinfo.value) == f"{code} is not a valid signal code" 49 | 50 | 51 | def test_kill_proc_tree_fails_with_current_pid(): 52 | pid = os.getpid() 53 | with pytest.raises(ValueError) as excinfo: 54 | kill_proc_tree(pid) 55 | 56 | assert str(excinfo.value) == "Would not kill myself" 57 | 58 | 59 | def test_allow_missing_as_property(): 60 | class Dummy: 61 | @allow_missing 62 | def stat(self): 63 | return Path("does-not-exist").stat() 64 | 65 | @property 66 | @allow_missing 67 | def stat_prop(self): 68 | return Path("does-not-exist").stat() 69 | 70 | dummy = Dummy() 71 | res = dummy.stat() 72 | assert res is None 73 | assert dummy.stat_prop is None 74 | -------------------------------------------------------------------------------- /.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 | .ruff_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 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 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /.github/workflows/publish-new-release.yml: -------------------------------------------------------------------------------- 1 | name: "Publish new release" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - release 7 | types: 8 | - closed 9 | 10 | jobs: 11 | release: 12 | name: Publish new release 13 | runs-on: ubuntu-latest 14 | # NOTE: only merged pull requests should trigger this job 15 | if: github.event.pull_request.merged == true 16 | steps: 17 | # Extract version from the branch name 18 | # TODO: just split by slash 19 | - name: Extract version from branch name (for release branches) 20 | if: startsWith(github.event.pull_request.head.ref, 'rc/') 21 | run: | 22 | BRANCH_NAME="${{ github.event.pull_request.head.ref }}" 23 | VERSION=${BRANCH_NAME#rc/} 24 | echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV 25 | 26 | - name: Extract version from branch name (for hotfix branches) 27 | if: startsWith(github.event.pull_request.head.ref, 'hotfix/') 28 | run: | 29 | BRANCH_NAME="${{ github.event.pull_request.head.ref }}" 30 | VERSION=${BRANCH_NAME#hotfix/} 31 | echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV 32 | 33 | - name: Checkout code 34 | uses: actions/checkout@v3 35 | 36 | - name: Get changelog entry 37 | id: changelog_reader 38 | uses: mindsers/changelog-reader-action@v2 39 | with: 40 | version: ${{ env.RELEASE_VERSION }} 41 | 42 | # Create Github release with notes and tag 43 | - name: Create release 44 | uses: misha-brt/create-release@v0.0.1 45 | # NOTE: override default Github token if needed 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | target_commitish: ${{ github.event.pull_request.merge_commit_sha }} 50 | tag_name: ${{ env.RELEASE_VERSION }} 51 | name: ${{ env.RELEASE_VERSION }} 52 | body: ${{ steps.changelog_reader.outputs.changes }} 53 | draft: false 54 | prerelease: false 55 | 56 | - name: Merge release back into main branch 57 | run: | 58 | gh api \ 59 | --method POST \ 60 | -H "Accept: application/vnd.github+json" \ 61 | /repos/bmwant/hapless/merges \ 62 | -f base='main' \ 63 | -f head='release' \ 64 | -f commit_message='Merge release into main' 65 | env: 66 | # NOTE: override default Github token if needed 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | -------------------------------------------------------------------------------- /tests/test_spawn.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, PropertyMock, patch 2 | 3 | import pytest 4 | 5 | from hapless import cli 6 | from hapless.hap import Status 7 | 8 | 9 | @patch("hapless.cli.get_or_exit") 10 | def test_internal_wrap_hap(get_or_exit_mock, runner): 11 | hap_mock = Mock( 12 | status=Status.UNBOUND, 13 | ) 14 | prop_mock = PropertyMock() 15 | type(hap_mock).stderr_path = prop_mock 16 | get_or_exit_mock.return_value = hap_mock 17 | with patch.object(runner.hapless, "_wrap_subprocess") as wrap_mock: 18 | result = runner.invoke(cli.cli, ["__internal_wrap_hap", "hap-me"]) 19 | assert result.exit_code == 0 20 | get_or_exit_mock.assert_called_once_with("hap-me") 21 | wrap_mock.assert_called_once_with(hap_mock) 22 | prop_mock.assert_not_called() 23 | 24 | 25 | @patch("hapless.cli.get_or_exit") 26 | def test_internal_wrap_hap_not_unbound(get_or_exit_mock, runner, tmp_path, log_output): 27 | hap_mock = Mock( 28 | status=Status.FAILED, 29 | __str__=lambda self: "hap-me", 30 | ) 31 | prop_mock = PropertyMock(return_value=tmp_path / "stderr.log") 32 | type(hap_mock).stderr_path = prop_mock 33 | get_or_exit_mock.return_value = hap_mock 34 | with patch.object(runner.hapless, "_wrap_subprocess") as wrap_mock: 35 | result = runner.invoke(cli.cli, ["__internal_wrap_hap", "hap-me"]) 36 | assert result.exit_code == 1 37 | get_or_exit_mock.assert_called_once_with("hap-me") 38 | prop_mock.assert_called_once_with() 39 | wrap_mock.assert_not_called() 40 | assert ( 41 | "ERROR: Hap hap-me has to be unbound, found instead Status.FAILED" 42 | in log_output.text 43 | ) 44 | 45 | 46 | @patch("hapless.cli.get_or_exit") 47 | @patch("hapless.cli.isatty") 48 | def test_wrap_cannot_be_launched_interactively( 49 | isatty_mock, get_or_exit_mock, runner, log_output 50 | ): 51 | isatty_mock.return_value = True 52 | get_or_exit_mock.side_effect = lambda _: pytest.fail("Should not be called") 53 | with patch.object(runner.hapless, "_wrap_subprocess") as wrap_mock, patch( 54 | "hapless.config.DEBUG", False 55 | ): 56 | result = runner.invoke(cli.cli, ["__internal_wrap_hap", "hap-me"]) 57 | assert result.exit_code == 1 58 | get_or_exit_mock.assert_not_called() 59 | wrap_mock.assert_not_called() 60 | assert ( 61 | "CRITICAL: Internal command is not supposed to be run manually" 62 | in log_output.text 63 | ) 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "hapless" 3 | version = "0.14.0" 4 | description = "Run and track processes in background" 5 | authors = ["Misha Behersky "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/bmwant/hapless" 9 | keywords = ["cli", "job", "runner", "background", "process"] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Console", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: Unix", 16 | "Topic :: Utilities", 17 | "Topic :: System", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Programming Language :: Unix Shell", 28 | ] 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.8" 32 | psutil = "^6.1.0" 33 | humanize = "^4.1.0" 34 | click = "^8.1.2" 35 | rich = "^13.5.2" 36 | django-environ = [ 37 | { version = "^0.11.2", python = "<3.9" }, 38 | { version = "^0.12.0", python = ">=3.9" }, 39 | ] 40 | typing-extensions = "4.0.0" 41 | structlog = "^25.4.0" 42 | # Optional dependencies installed as extras 43 | pytest = { version = "^7.4.4", optional = true } 44 | pytest-cov = { version = "^3.0.0", optional = true } 45 | pytest-env = { version = "^1.1.2", optional = true } 46 | ruff = { version = "^0.9.0", optional = true } 47 | ty = { version = "^0.0.1a21", optional = true } 48 | nox = { version = "^2024.10.9", optional = true } 49 | 50 | [tool.poetry.extras] 51 | dev = [ 52 | "pytest", 53 | "pytest-cov", 54 | "pytest-env", 55 | "ruff", 56 | "ty", 57 | "nox", 58 | ] 59 | 60 | [tool.poetry.group.dev.dependencies] 61 | pytest = "^7.4.4" 62 | pytest-cov = "^3.0.0" 63 | ruff = "^0.9.0" 64 | nox = "^2024.10.9" 65 | 66 | [tool.poetry.scripts] 67 | hap = 'hapless.cli:cli' 68 | 69 | [tool.poetry.urls] 70 | "Blog post" = "https://bmwant.link/hapless-easily-run-and-manage-background-processes/" 71 | 72 | [tool.ruff] 73 | line-length = 88 74 | target-version = "py38" 75 | 76 | # ruff check 77 | [tool.ruff.lint] 78 | extend-select = [ 79 | # isort 80 | "I", 81 | ] 82 | 83 | [tool.ty.src] 84 | exclude = [] 85 | 86 | [tool.pytest.ini_options] 87 | env = [ 88 | "HAPLESS_DEBUG=false", 89 | "HAPLESS_NO_FORK=false", 90 | "HAPLESS_REDIRECT_STDERR=false", 91 | ] 92 | 93 | [build-system] 94 | requires = ["poetry-core>=1.0.0"] 95 | build-backend = "poetry.core.masonry.api" 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## hapless 2 | 3 | ![Checks](https://github.com/bmwant/hapless/actions/workflows/tests.yml/badge.svg) 4 | [![PyPI](https://img.shields.io/pypi/v/hapless)](https://pypi.org/project/hapless/) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hapless) 6 | 7 | 8 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 9 | [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) 10 | [![EditorConfig](https://img.shields.io/badge/-EditorConfig-grey?logo=editorconfig)](https://editorconfig.org/) 11 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 12 | 13 | > **hapless** (*adjective*) - (especially of a person) unfortunate. A developer who accidentally launched long-running process in the foreground. 14 | 15 | Simplest way of running and tracking processes in the background. 16 | 17 | [![asciicast](https://asciinema.org/a/489924.svg)](https://asciinema.org/a/489924?speed=2) 18 | 19 | ### Installation 20 | 21 | ```bash 22 | pip install hapless 23 | 24 | # or to make sure proper pip is used for the given python executable 25 | python -m pip install hapless 26 | ``` 27 | 28 | Install into user-specific directory in case of any permissions-related issues. 29 | 30 | ```bash 31 | pip install --user hapless 32 | python -m pip install --user hapless 33 | ``` 34 | 35 | ### Usage 36 | 37 | ```bash 38 | # Run arbitrary script 39 | hap run -- python long_running.py 40 | 41 | # Show summary table 42 | hap 43 | 44 | # Display status of the specific process 45 | hap status 1 46 | ``` 47 | 48 | See [USAGE.md](https://github.com/bmwant/hapless/blob/main/USAGE.md) for the complete list of commands and available parameters. 49 | 50 | ### Contribute 51 | 52 | See [DEVELOP.md](https://github.com/bmwant/hapless/blob/main/DEVELOP.md) to setup your local development environment and feel free to create a pull request with a new feature. 53 | 54 | ### Releases 55 | 56 | Changes made in each release can be found on the [Releases](https://github.com/bmwant/hapless/releases) page. 57 | 58 | Python versions compatibility: 59 | 60 | * `0.11.1` is the last version to support Python `3.7` 61 | 62 | ### See also 63 | 64 | * [Rich](https://rich.readthedocs.io/en/stable/introduction.html) console UI library. 65 | * [Supervisor](http://supervisord.org/) full-fledged process manager. 66 | * [podmena](https://github.com/bmwant/podmena) provides nice emoji icons to commit messages. 67 | 68 | ### Support 🇺🇦 Ukraine in the war! 69 | 70 | 🇺🇦 Donate to [this foundation](https://prytulafoundation.org/en) in case you want to help. Every donation matter! 71 | -------------------------------------------------------------------------------- /tests/test_cli_signals.py: -------------------------------------------------------------------------------- 1 | import signal 2 | from unittest.mock import Mock, patch 3 | 4 | from hapless import cli 5 | 6 | 7 | @patch("hapless.cli.get_or_exit") 8 | def test_kill_invocation(get_or_exit_mock, runner): 9 | hap_mock = Mock() 10 | get_or_exit_mock.return_value = hap_mock 11 | with patch.object(runner.hapless, "kill") as kill_mock: 12 | result = runner.invoke(cli.cli, ["kill", "hap-name"]) 13 | assert result.exit_code == 0 14 | get_or_exit_mock.assert_called_once_with("hap-name") 15 | kill_mock.assert_called_once_with([hap_mock]) 16 | 17 | 18 | @patch("hapless.cli.get_or_exit") 19 | def test_killall_invocation(get_or_exit_mock, runner): 20 | with patch.object(runner.hapless, "get_haps", return_value=[]) as get_haps_mock: 21 | with patch.object(runner.hapless, "kill") as kill_mock: 22 | result = runner.invoke(cli.cli, ["kill", "--all"]) 23 | assert result.exit_code == 0 24 | get_or_exit_mock.assert_not_called() 25 | get_haps_mock.assert_called_once_with() 26 | kill_mock.assert_called_once_with([]) 27 | 28 | 29 | def test_kill_improper_invocation(runner): 30 | with patch.object(runner.hapless, "kill") as kill_mock: 31 | result = runner.invoke(cli.cli, ["kill"]) 32 | assert result.exit_code == 2 33 | assert "Provide hap alias to kill" in result.output 34 | kill_mock.assert_not_called() 35 | 36 | 37 | def test_killall_improper_invocation(runner): 38 | with patch.object(runner.hapless, "kill") as kill_mock: 39 | result = runner.invoke(cli.cli, ["kill", "hap-name", "-a"]) 40 | assert result.exit_code == 2 41 | assert "Cannot use --all flag while hap id provided" in result.output 42 | kill_mock.assert_not_called() 43 | 44 | 45 | @patch("hapless.cli.get_or_exit") 46 | def test_signal_invocation(get_or_exit_mock, runner): 47 | hap_mock = Mock() 48 | get_or_exit_mock.return_value = hap_mock 49 | with patch.object(runner.hapless, "signal") as signal_mock: 50 | result = runner.invoke(cli.cli, ["signal", "hap-name", "9"]) 51 | assert result.exit_code == 0 52 | get_or_exit_mock.assert_called_once_with("hap-name") 53 | signal_mock.assert_called_once_with(hap_mock, 9) 54 | 55 | 56 | def test_signal_invalid_hap(runner): 57 | with patch.object(runner.hapless, "signal") as signal_mock: 58 | result = runner.invoke(cli.cli, ["signal", "invalid-hap", "9"]) 59 | assert result.exit_code == 1 60 | assert "No such hap" in result.output 61 | signal_mock.assert_not_called() 62 | 63 | 64 | @patch("hapless.cli.get_or_exit") 65 | def test_signal_wrong_code(get_or_exit_mock, runner): 66 | hap_mock = Mock() 67 | get_or_exit_mock.return_value = hap_mock 68 | wrong_code = f"{signal.NSIG}" 69 | with patch.object(runner.hapless, "signal") as signal_mock: 70 | result = runner.invoke(cli.cli, ["signal", "hap-name", wrong_code]) 71 | assert result.exit_code == 2 72 | assert f"{wrong_code} is not a valid signal code" in result.output 73 | get_or_exit_mock.assert_not_called() 74 | signal_mock.assert_not_called() 75 | 76 | 77 | @patch("hapless.cli.get_or_exit") 78 | def test_signal_inactive_hap(get_or_exit_mock, runner): 79 | hap_mock = Mock(active=False) 80 | get_or_exit_mock.return_value = hap_mock 81 | result = runner.invoke(cli.cli, ["signal", "hap-name", "15"]) 82 | assert result.exit_code == 0 83 | assert "Cannot send signal to the inactive hap" in result.output 84 | get_or_exit_mock.assert_called_once_with("hap-name") 85 | -------------------------------------------------------------------------------- /tests/test_lib_usage.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from hapless import Hap, Hapless, Status 7 | 8 | 9 | def test_creation(tmp_path: Path): 10 | hapless = Hapless(hapless_dir=tmp_path) 11 | hap1 = hapless.create_hap("echo one", name="hap-one") 12 | hap2 = hapless.create_hap("echo two", name="hap-two") 13 | 14 | assert isinstance(hap1, Hap) 15 | assert isinstance(hap2, Hap) 16 | assert hap1.hid != hap2.hid 17 | assert hap1.name == "hap-one" 18 | assert hap2.name == "hap-two" 19 | 20 | assert hap1.status == Status.UNBOUND 21 | 22 | 23 | def test_quiet_mode(tmp_path: Path, capsys): 24 | """ 25 | Check that quiet mode suppresses output. 26 | """ 27 | hapless = Hapless(hapless_dir=tmp_path, quiet=True) 28 | hap = hapless.create_hap("false") 29 | with pytest.raises(SystemExit) as e: 30 | hapless.pause_hap(hap) 31 | 32 | captured = capsys.readouterr() 33 | assert e.value.code == 1 34 | assert captured.out == "" 35 | assert captured.err == "" 36 | 37 | 38 | def test_can_set_redirection_on_create(hapless: Hapless): 39 | """ 40 | Test that redirection can be set programmatically when using Hapless interface. 41 | """ 42 | hap = hapless.create_hap( 43 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr\")'", 44 | name="hap-stderr", 45 | redirect_stderr=True, 46 | ) 47 | 48 | assert hap.stderr_path == hap.stdout_path 49 | hapless.run_hap(hap, blocking=True) 50 | assert hap.stdout_path.exists() 51 | assert hap.stdout_path.read_text() == "redirected stderr" 52 | 53 | # Redirect state should be preserved between re-reads 54 | hap_reread = hapless.get_hap(hap.hid) 55 | assert hap_reread is not None 56 | assert hap_reread.stderr_path == hap_reread.stdout_path 57 | assert not hap_reread._stderr_path.exists() 58 | 59 | 60 | def test_toggling_redirect_state(hapless: Hapless): 61 | hap1 = hapless.create_hap( 62 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr1\")'", 63 | name="hap1-stderr", 64 | redirect_stderr=True, 65 | ) 66 | hap2 = hapless.create_hap( 67 | cmd="python -c 'import sys; sys.stderr.write(\"not redirected stderr2\")'", 68 | name="hap2-stderr", 69 | redirect_stderr=False, 70 | ) 71 | hap3 = hapless.create_hap( 72 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr3\")'", 73 | name="hap3-stderr", 74 | redirect_stderr=True, 75 | ) 76 | 77 | assert hap1.redirect_stderr is True 78 | assert hap2.redirect_stderr is False 79 | assert hap3.redirect_stderr is True 80 | 81 | # Run all three haps 82 | hapless.run_hap(hap1, blocking=True) 83 | hapless.run_hap(hap2, blocking=True) 84 | hapless.run_hap(hap3, blocking=True) 85 | 86 | assert hap1.stdout_path.exists() 87 | assert hap1.stdout_path.read_text() == "redirected stderr1" 88 | 89 | assert hap2.stdout_path.exists() 90 | assert hap2.stderr_path.exists() 91 | assert hap2.stdout_path.read_text() == "" 92 | assert hap2.stderr_path.read_text() == "not redirected stderr2" 93 | 94 | assert hap3.stdout_path.exists() 95 | assert hap3.stdout_path.read_text() == "redirected stderr3" 96 | 97 | # Check redirect state is the same after re-reading 98 | haps = hapless.get_haps() 99 | redirects = list(map(operator.attrgetter("redirect_stderr"), haps)) 100 | assert redirects == [True, False, True] 101 | -------------------------------------------------------------------------------- /tests/test_hap_workdir.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from hapless.main import Hapless 7 | 8 | TESTS_DIR = Path(__file__).parent 9 | EXAMPLES_DIR = TESTS_DIR.parent / "examples" 10 | 11 | 12 | def test_restart_uses_same_working_dir(hapless: Hapless, monkeypatch): 13 | monkeypatch.chdir(EXAMPLES_DIR) 14 | hap = hapless.create_hap( 15 | cmd="python ./samename.py", 16 | name="hap-same-name", 17 | ) 18 | hid = hap.hid 19 | assert hap.workdir == EXAMPLES_DIR 20 | 21 | hapless.run_hap(hap, blocking=True) 22 | assert hap.rc == 0 23 | assert hap.stdout_path.exists() 24 | assert "Correct file is being run" in hap.stdout_path.read_text() 25 | 26 | # Contains script with the same filename 27 | monkeypatch.chdir(EXAMPLES_DIR / "nested") 28 | 29 | original_run = hapless.run_command 30 | 31 | def blocking_run(*args, **kwargs): 32 | kwargs["blocking"] = True 33 | return original_run(*args, **kwargs) 34 | 35 | with patch.object(hapless, "run_command", side_effect=blocking_run) as run_mock: 36 | hapless.restart(hap) 37 | run_mock.assert_called_once_with( 38 | cmd="python ./samename.py", 39 | workdir=EXAMPLES_DIR, 40 | hid=hid, 41 | name="hap-same-name@1", 42 | redirect_stderr=False, 43 | ) 44 | 45 | restarted_hap = hapless.get_hap("hap-same-name") 46 | assert restarted_hap is not None 47 | assert restarted_hap.rc == 0 48 | assert restarted_hap.stdout_path.exists() 49 | assert "Correct file is being run" in restarted_hap.stdout_path.read_text() 50 | assert "Malicious code execution" not in restarted_hap.stdout_path.read_text() 51 | 52 | 53 | def test_same_workdir_is_used_even_on_dir_change(hapless: Hapless, monkeypatch): 54 | monkeypatch.chdir(EXAMPLES_DIR) 55 | hap = hapless.create_hap( 56 | cmd="python -c 'import os; print(os.getcwd())'", 57 | name="hap-workdir", 58 | ) 59 | assert hap.workdir == EXAMPLES_DIR 60 | 61 | monkeypatch.chdir(TESTS_DIR) 62 | hapless.run_hap(hap, blocking=True) 63 | assert hap.rc == 0 64 | assert hap.stdout_path.exists() 65 | assert f"{EXAMPLES_DIR}" in hap.stdout_path.read_text() 66 | assert f"{TESTS_DIR}" not in hap.stdout_path.read_text() 67 | 68 | 69 | def test_different_scripts_called_if_directory_differs(hapless: Hapless, monkeypatch): 70 | monkeypatch.chdir(EXAMPLES_DIR) 71 | same_cmd = "python ./samename.py" 72 | hap1 = hapless.create_hap( 73 | cmd=same_cmd, 74 | name="hap-same-cmd-1", 75 | ) 76 | assert hap1.workdir == EXAMPLES_DIR 77 | 78 | hapless.run_hap(hap1, blocking=True) 79 | assert hap1.rc == 0 80 | assert hap1.stdout_path.exists() 81 | assert "Correct file is being run" in hap1.stdout_path.read_text() 82 | 83 | # Contains script with the same filename 84 | monkeypatch.chdir(EXAMPLES_DIR / "nested") 85 | hap2 = hapless.create_hap( 86 | cmd=same_cmd, 87 | name="hap-same-cmd-2", 88 | ) 89 | assert hap2.workdir == EXAMPLES_DIR / "nested" 90 | 91 | hapless.run_hap(hap2, blocking=True) 92 | assert hap2.rc == 0 93 | assert hap2.stdout_path.exists() 94 | assert "Malicious code execution" in hap2.stdout_path.read_text() 95 | 96 | assert hap1.cmd == hap2.cmd 97 | assert hap1.workdir != hap2.workdir 98 | 99 | 100 | def test_incorrect_workdir_provided(hapless: Hapless, tmp_path): 101 | not_a_dir = tmp_path / "filename" 102 | not_a_dir.touch() 103 | with pytest.raises(ValueError) as e: 104 | hap = hapless.create_hap(cmd="false", workdir=not_a_dir) # noqa: F841 105 | 106 | assert str(e.value) == "Workdir should be a path to existing directory" 107 | 108 | 109 | def test_fallback_workdir_to_current_dir( 110 | hapless: Hapless, monkeypatch, tmp_path_factory 111 | ): 112 | fallback_workdir = tmp_path_factory.mktemp("hap_fallback_workdir") 113 | monkeypatch.chdir(fallback_workdir) 114 | hap = hapless.create_hap( 115 | cmd="true", 116 | workdir=None, 117 | name="hap-workdir-fallback", 118 | ) 119 | assert hap.workdir == fallback_workdir 120 | assert hapless.dir != hap.workdir 121 | -------------------------------------------------------------------------------- /hapless/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import signal 5 | import sys 6 | import time 7 | from collections import deque 8 | from contextlib import nullcontext 9 | from functools import wraps 10 | from pathlib import Path 11 | from typing import Callable, Optional, TypeVar 12 | 13 | import click 14 | import psutil 15 | import structlog 16 | from rich.spinner import Spinner 17 | from rich.text import Text 18 | from typing_extensions import ParamSpec 19 | 20 | from hapless import config 21 | 22 | P = ParamSpec("P") 23 | R = TypeVar("R") 24 | 25 | 26 | def allow_missing(func: Callable[P, R]) -> Callable[P, Optional[R]]: 27 | @wraps(func) 28 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Optional[R]: 29 | try: 30 | return func(*args, **kwargs) 31 | except FileNotFoundError: 32 | pass 33 | 34 | return wrapper 35 | 36 | 37 | class timed(object): 38 | def __init__(self): 39 | self._start = time.time() 40 | self._end = None 41 | 42 | @property 43 | def elapsed(self): 44 | if self._end is not None: 45 | return self._end - self._start 46 | 47 | def __enter__(self): 48 | return self 49 | 50 | def __exit__(self, *args): 51 | self._end = time.time() 52 | return self 53 | 54 | 55 | class dummy_live(nullcontext): 56 | def update(self, *args, **kwargs): 57 | pass 58 | 59 | 60 | def wait_created( 61 | path: Path, 62 | interval: float = 0.1, 63 | timeout: float = config.FAILFAST_TIMEOUT, 64 | *, 65 | live_context=dummy_live(), 66 | ) -> bool: 67 | start = time.time() 68 | with live_context: 69 | elapsed = 0 70 | while not path.exists() and elapsed < timeout: 71 | elapsed = time.time() - start 72 | spinner = Spinner( 73 | "dots", 74 | text=Text.from_markup( 75 | f"checking process health for " 76 | f"[bold {config.COLOR_MAIN}]{int(timeout - elapsed)}s[/]..." 77 | ), 78 | style=f"{config.COLOR_MAIN}", 79 | ) 80 | live_context.update(spinner) 81 | time.sleep(interval) 82 | return path.exists() 83 | 84 | 85 | def validate_signal(ctx, param, value): 86 | try: 87 | signal_code = int(value) 88 | except ValueError: 89 | raise click.BadParameter("Signal should be a valid integer value") 90 | 91 | try: 92 | return signal.Signals(signal_code) 93 | except ValueError: 94 | raise click.BadParameter(f"{signal_code} is not a valid signal code") 95 | 96 | 97 | def kill_proc_tree(pid, sig=signal.SIGKILL, include_parent=True): 98 | if pid == os.getpid(): 99 | raise ValueError("Would not kill myself") 100 | 101 | try: 102 | parent = psutil.Process(pid) 103 | except psutil.NoSuchProcess: 104 | logger.warning(f"Process {pid} is already gone, nothing to kill") 105 | return 106 | 107 | children = parent.children(recursive=True) 108 | if include_parent: 109 | children.append(parent) 110 | for p in children: 111 | try: 112 | p.send_signal(sig) 113 | logger.debug(f"Sent {sig} to {p.pid} process") 114 | except psutil.NoSuchProcess: 115 | pass 116 | 117 | 118 | def get_mtime(path: Path) -> Optional[float]: 119 | if path.exists(): 120 | return os.path.getmtime(path) 121 | 122 | 123 | def isatty() -> bool: 124 | return sys.stdin.isatty() and sys.stdout.isatty() 125 | 126 | 127 | def get_exec_path() -> Path: 128 | if str(sys.argv[0]).endswith("hap"): 129 | exec_path = sys.argv[0] 130 | else: 131 | logger.warning("Unusual invocation, checking `hap` in PATH") 132 | exec_path = shutil.which("hap") 133 | 134 | if exec_path is None: 135 | raise RuntimeError("Cannot find `hap` executable, please reinstall hapless") 136 | 137 | return Path(exec_path) 138 | 139 | 140 | def tail_lines(filepath: Path, n: int = 20): 141 | with open(filepath) as f: 142 | return deque(f, n) 143 | 144 | 145 | def configure_logger() -> logging.Logger: 146 | logger = structlog.get_logger() 147 | level = logging.DEBUG if config.DEBUG else logging.CRITICAL 148 | structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(level)) 149 | return logger 150 | 151 | 152 | logger = configure_logger() 153 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | `Hap` is a tiny wrapper around the process allowing to track its status during execution and upon completion. Unlike other managers it does not run any daemon process and does not require any configuration files to get started. 4 | 5 | `hap-alias` is either a hap id (integer identificator) or hap name (string identificator). Note that you need to replace this placeholder in commands below with an actual value. 6 | 7 | ### ✏️ Running scripts 8 | 9 | ➡️ Run a simple script 10 | 11 | ```bash 12 | hap run ./examples/script.sh 13 | hap run python ./examples/fast.py 14 | ``` 15 | 16 | ➡️ Run script accepting arguments 17 | 18 | ```bash 19 | hap run -- python ./examples/show_details.py -v --name details --count=5 20 | ``` 21 | 22 | Use `--` delimiter in order to separate target script and its arguments. Otherwise arguments will be interpreted as a keys for `hap` executable. 23 | 24 | > NOTE: as of version `0.4.0` specifying double dash is optional and required only if parameters for your target command match parameters of `hap run` command itself 25 | 26 | ```bash 27 | # wouldn't work, as run has its own `--help` option 28 | hap run python --help 29 | 30 | # will work as expected without `--` delimiter 31 | hap run ./examples/show_details.py --flag --param=value 32 | ``` 33 | 34 | ➡️ Check script for early failures right after launch 35 | 36 | ```bash 37 | hap run --check python ./examples/fail_fast.py 38 | ``` 39 | 40 | ### ✏️ Checking status 41 | 42 | ➡️ Show summary for all haps 43 | 44 | ```bash 45 | hap 46 | # or 47 | hap status # equivalent 48 | hap show # same as above 49 | ``` 50 | 51 | ➡️ Check status of the specific hap 52 | 53 | ```bash 54 | hap show [hap-alias] 55 | # or 56 | hap status [hap-alias] 57 | ``` 58 | 59 | ➡️ Show detailed status for the hap (including environment variables) 60 | 61 | ```bash 62 | hap show -v [hap-alias] 63 | hap show --verbose [hap-alias] # same as above 64 | # or 65 | hap status -v [hap-alias] 66 | hap status --verbose [hap-alias] # same as above 67 | ``` 68 | 69 | ### ✏️ Checking logs 70 | 71 | ➡️ Print process logs to the console 72 | 73 | ```bash 74 | hap logs [hap-alias] 75 | ``` 76 | 77 | ➡️ Stream logs continuously to the console 78 | 79 | ```bash 80 | hap logs -f [hap-alias] 81 | # or 82 | hap logs --follow [hap-alias] 83 | ``` 84 | 85 | ### ✏️ Other commands 86 | 87 | ➡️ Suspend (pause) a hap. Sends `SIGSTOP` signal to the process 88 | 89 | ```bash 90 | hap pause [hap-alias] 91 | # or 92 | hap suspend [hap-alias] 93 | ``` 94 | 95 | ➡️ Resume paused hap. Sends `SIGCONT` signal to the suspended process 96 | 97 | ```bash 98 | hap resume [hap-alias] 99 | ``` 100 | 101 | ➡️ Send specific signal to the process by its code 102 | 103 | ```bash 104 | hap signal [hap-alias] 9 # sends SIGKILL 105 | hap signal [hap-alias] 15 # sends SIGTERM 106 | ``` 107 | 108 | ➡️ Remove haps from the list. 109 | 110 | - Without any parameters removes only successfully finished haps (with `0` return code). Provide `--all` flag to remove failed haps as well. Used to make list more concise in case you have a lot of things running at once and you are not interested in results/error logs of completed ones. 111 | 112 | ```bash 113 | hap clean 114 | 115 | # Remove all finished haps (both successful and failed) 116 | hap clean --all 117 | # Same as above 118 | hap cleanall 119 | ``` 120 | 121 | ➡️ Restart a hap. 122 | 123 | - When restart command is called, hap will stop the process and start it again. The command is rerun from the current working directory. 124 | 125 | ```bash 126 | hap restart [hap-alias] 127 | ``` 128 | 129 | ➡️ Rename existing hap. 130 | 131 | - Change name of the existing hap to the new one. Does not allow to have duplicate names. Hap id (integer identificator) stays the same after renaming. 132 | 133 | ```bash 134 | hap rename [hap-alias] [new-hap-name] 135 | # e.g. you can invoke with hap ID like 136 | hap rename 4 hap-new-name 137 | # or by hap current name like 138 | hap rename hap-name hap-new-name 139 | ``` 140 | 141 | ### ✏️ State directory 142 | 143 | - By default state directory is picked automatically within system's temporary directory. In case you want to store state somewhere else, you can override it by setting dedicated environment variable 144 | 145 | ```bash 146 | export HAPLESS_DIR="/home/myuser/mystate" 147 | 148 | hap run echo hello 149 | ``` 150 | 151 | - To check that correct directory is used you can open detailed view for the hap and check that both `stdout`/`stderr` files are stored under the desired location. 152 | 153 | ```bash 154 | hap show -v [hap-alias] 155 | ``` 156 | 157 | Alternatively, you can set debug flag and state folder will be printed on each invocation 158 | 159 | ```bash 160 | export HAPLESS_DEBUG=1 161 | hap 162 | # DEBUG:hapless:Initialized within /home/myuser/mystate dir 163 | unset HAPLESS_DEBUG 164 | ``` 165 | 166 | > NOTE: make sure to update your shell initialization file `.profile`/`.bashrc`/`.zshrc`/etc for the change to persist between different terminal sessions. Otherwise, state will be saved in custom directory only within current shell. 167 | -------------------------------------------------------------------------------- /tests/test_hap.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import json 3 | import os 4 | import re 5 | from io import StringIO 6 | from pathlib import Path 7 | from unittest.mock import MagicMock, patch 8 | 9 | import pytest 10 | from rich.console import Console 11 | 12 | from hapless.hap import Hap, Status 13 | from hapless.main import Hapless 14 | 15 | 16 | def all_equal(iterable): 17 | return len(set(iterable)) <= 1 18 | 19 | 20 | def test_random_name_generation(): 21 | name_length = 8 22 | name_count = 4 23 | names = [] 24 | for _ in range(name_count): 25 | new_name = Hap.get_random_name(length=name_length) 26 | assert len(new_name) == name_length 27 | names.append(new_name) 28 | 29 | assert not all_equal(names) 30 | 31 | 32 | def test_unbound_hap(hap: Hap): 33 | assert isinstance(hap.name, str) 34 | assert hap.name.startswith("hap-") 35 | assert hap.pid is None 36 | assert hap.proc is None 37 | assert hap.rc is None 38 | assert hap.cmd == "false" 39 | assert hap.status == Status.UNBOUND 40 | assert hap.env is None 41 | assert hap.restarts == 0 42 | assert not hap.active 43 | 44 | assert hap.stdout_path.exists() 45 | # NOTE: this file behaves as a flag, if it exists we do not want to redirect stderr 46 | assert hap.stderr_path.exists() 47 | assert hap.start_time is None 48 | assert hap.end_time is None 49 | 50 | assert hap.runtime == "a moment" 51 | 52 | assert hap.accessible is True 53 | # Current user should be the owner of the hap 54 | assert hap.owner == getpass.getuser() 55 | assert isinstance(hap.workdir, Path) 56 | assert str(hap.workdir) == os.getcwd() 57 | 58 | 59 | def test_hap_path_should_be_a_directory(tmp_path): 60 | hap_path = Path(tmp_path) / "hap-path" 61 | hap_path.touch() 62 | 63 | with pytest.raises(ValueError) as e: 64 | Hap(hap_path) 65 | 66 | assert f"Path {hap_path} is not a directory" == str(e.value) 67 | 68 | 69 | def test_default_restarts(hap: Hap): 70 | assert hap.restarts == 0 71 | 72 | 73 | def test_correct_restarts_value(tmp_path): 74 | hap = Hap(Path(tmp_path), name="hap-name@2", cmd="true") 75 | assert hap.restarts == 2 76 | 77 | 78 | def test_raw_name(tmp_path): 79 | hap = Hap(Path(tmp_path), name="hap-name@3", cmd="true") 80 | assert hap.name == "hap-name" 81 | assert hap.raw_name == "hap-name@3" 82 | 83 | 84 | @pytest.mark.parametrize("redirect_stderr", [True, False]) 85 | def test_correct_redirect_state(tmp_path, log_output, redirect_stderr): 86 | hap = Hap( 87 | Path(tmp_path), 88 | name="hap-name", 89 | cmd="doesnotexist", 90 | redirect_stderr=redirect_stderr, 91 | ) 92 | 93 | assert hap.redirect_stderr is redirect_stderr 94 | assert ("stderr will be redirected to stdout" in log_output.text) is redirect_stderr 95 | 96 | 97 | def test_hap_inaccessible(hap: Hap): 98 | with patch("os.access", return_value=False) as access_mock: 99 | assert hap.accessible is False 100 | access_mock.assert_called_once_with( 101 | hap.path, os.F_OK | os.R_OK | os.W_OK | os.X_OK 102 | ) 103 | 104 | 105 | def test_hap_owner_unknown_uid(hap: Hap): 106 | mocked_stat = MagicMock() 107 | mocked_stat.st_uid = 6249 108 | mocked_stat.st_gid = 6251 109 | 110 | with patch("pathlib.Path.stat", return_value=mocked_stat): 111 | assert hap.owner == "6249:6251" 112 | 113 | 114 | def test_serialize(hap: Hap): 115 | serialized = hap.serialize() 116 | assert isinstance(serialized, dict) 117 | assert serialized["hid"] == hap.hid 118 | assert serialized["name"] == hap.name 119 | assert serialized["pid"] is None 120 | assert serialized["rc"] is None 121 | assert serialized["cmd"] == hap.cmd 122 | assert serialized["status"] == hap.status.value 123 | assert serialized["runtime"] == hap.runtime 124 | assert serialized["start_time"] is None 125 | assert serialized["end_time"] is None 126 | assert serialized["restarts"] == str(hap.restarts) 127 | assert serialized["stdout_file"] == str(hap.stdout_path) 128 | assert serialized["stderr_file"] == str(hap.stderr_path) 129 | # check all the fields are json-serializable 130 | result = json.dumps(serialized) 131 | assert isinstance(result, str) 132 | assert "workdir" in result 133 | 134 | 135 | def test_represent_unbound_hap(hapless: Hapless, capsys): 136 | hap = hapless.create_hap("echo print", name="hap-print") 137 | assert f"{hap}" == "#1 (hap-print)" 138 | # default is 139 | # 140 | repr_pattern = r"" 141 | assert re.match(repr_pattern, repr(hap)) 142 | 143 | # Test rich representation 144 | buffer = StringIO() 145 | test_console = Console( 146 | file=buffer, 147 | force_terminal=True, 148 | color_system="truecolor", 149 | ) 150 | test_console.print(hap) 151 | result = buffer.getvalue().strip() 152 | assert ( 153 | result 154 | == "hap ⚡️\x1b[1;36m1\x1b[0m \x1b[1m(\x1b[0m\x1b[1;38;2;253;202;64mhap-print\x1b[0m\x1b[1m)\x1b[0m" 155 | ) 156 | -------------------------------------------------------------------------------- /hapless/formatters.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | from importlib.metadata import version 4 | from itertools import filterfalse 5 | from typing import List 6 | 7 | from rich import box 8 | from rich.console import Group, RenderableType 9 | from rich.panel import Panel 10 | from rich.table import Table 11 | from rich.text import Text 12 | 13 | from hapless import config 14 | from hapless.hap import Hap, Status 15 | 16 | 17 | class Formatter(abc.ABC): 18 | """ 19 | Abstract base class for formatting Hap objects. 20 | """ 21 | 22 | def __init__(self, verbose: bool = False): 23 | self.verbose = verbose 24 | 25 | @abc.abstractmethod 26 | def format_one(self, hap: Hap) -> RenderableType: 27 | pass 28 | 29 | @abc.abstractmethod 30 | def format_list(self, haps: List[Hap]) -> RenderableType: 31 | pass 32 | 33 | 34 | class TableFormatter(Formatter): 35 | """ 36 | Formats Hap objects as a rich table. 37 | """ 38 | 39 | def _get_status_text(self, status: Status) -> Text: 40 | color = config.STATUS_COLORS.get(status, "dim") 41 | status_text = Text() 42 | status_text.append(config.ICON_STATUS, style=color) 43 | status_text.append(f" {status.value}") 44 | return status_text 45 | 46 | def format_one(self, hap: Hap) -> Group: 47 | status_table = Table(show_header=False, show_footer=False, box=box.SIMPLE) 48 | 49 | status_text = self._get_status_text(hap.status) 50 | status_table.add_row("Status:", status_text) 51 | 52 | status_table.add_row("PID:", f"{hap.pid or '-'}") 53 | 54 | if hap.rc is not None: 55 | status_table.add_row("Return code:", f"{hap.rc}") 56 | 57 | cmd_text = Text(f"{hap.cmd}", style=f"{config.COLOR_ACCENT} bold") 58 | if self.verbose: 59 | status_table.add_row("") 60 | status_table.add_row("Command:", cmd_text) 61 | status_table.add_row("Working dir:", f"{hap.workdir}") 62 | status_table.add_row("") 63 | else: 64 | status_table.add_row("Command:", cmd_text) 65 | 66 | proc = hap.proc 67 | if self.verbose and proc is not None: 68 | status_table.add_row("Parent PID:", f"{proc.ppid()}") 69 | status_table.add_row("User:", f"{proc.username()}") 70 | 71 | if self.verbose and hap.redirect_stderr: 72 | # Paths are the same, show one line only 73 | status_table.add_row("Logs file:", f"{hap.stdout_path}") 74 | 75 | if self.verbose and not hap.redirect_stderr: 76 | status_table.add_row("Stdout file:", f"{hap.stdout_path}") 77 | status_table.add_row("Stderr file:", f"{hap.stderr_path}") 78 | 79 | start_time = hap.start_time 80 | end_time = hap.end_time 81 | if self.verbose and start_time: 82 | status_table.add_row("Start time:", f"{start_time}") 83 | 84 | if self.verbose and end_time: 85 | status_table.add_row("End time:", f"{end_time}") 86 | 87 | status_table.add_row("Runtime:", f"{hap.runtime}") 88 | 89 | status_panel = Panel( 90 | status_table, 91 | expand=self.verbose, 92 | title=f"Hap {config.ICON_HAP}{hap.hid}", 93 | subtitle=hap.name, 94 | ) 95 | result = Group(status_panel) 96 | 97 | environ = hap.env 98 | if self.verbose and environ is not None: 99 | env_table = Table(show_header=False, show_footer=False, box=None) 100 | env_table.add_column("", justify="right") 101 | env_table.add_column("", justify="left", style=config.COLOR_ACCENT) 102 | 103 | for key, value in environ.items(): 104 | env_table.add_row(key, Text(value, overflow="fold")) 105 | 106 | env_panel = Panel( 107 | env_table, 108 | title="Environment", 109 | subtitle=f"{len(environ)} items", 110 | border_style=config.COLOR_MAIN, 111 | ) 112 | result = Group(status_panel, env_panel) 113 | return result 114 | 115 | def format_list(self, haps: List[Hap]) -> Table: 116 | package_name = __package__ or __name__.split(".")[0] 117 | package_version = version(package_name) 118 | table = Table( 119 | show_header=True, 120 | header_style=f"{config.COLOR_MAIN} bold", 121 | box=box.HEAVY_EDGE, 122 | caption_style="dim", 123 | caption_justify="right", 124 | ) 125 | table.add_column("#", style="dim", min_width=2) 126 | table.add_column("Name") 127 | table.add_column("PID") 128 | if self.verbose: 129 | table.add_column("Command") 130 | table.add_column("Owner") 131 | table.add_column("Status") 132 | table.add_column("RC", justify="right") 133 | table.add_column("Runtime", justify="right") 134 | 135 | active_haps = 0 136 | for hap in haps: 137 | active_haps += 1 if hap.active else 0 138 | name = Text(hap.name) 139 | if hap.restarts: 140 | name += Text(f"{config.RESTART_DELIM}{hap.restarts}", style="dim") 141 | pid_text = ( 142 | f"{hap.pid}" if hap.active else Text(f"{hap.pid or '-'}", style="dim") 143 | ) 144 | command_text = Text( 145 | f"{hap.cmd}", overflow="ellipsis", style=f"{config.COLOR_ACCENT}" 146 | ) 147 | status_text = self._get_status_text(hap.status) 148 | command_text.truncate(config.TRUNCATE_LENGTH) 149 | row = [ 150 | f"{hap.hid}", 151 | name, 152 | pid_text, 153 | command_text if self.verbose else None, 154 | hap.owner if self.verbose else None, 155 | status_text, 156 | f"{hap.rc}" if hap.rc is not None else "", 157 | hap.runtime, 158 | ] 159 | table.add_row(*filterfalse(lambda x: x is None, row)) 160 | 161 | if self.verbose: 162 | table.title = f"{config.ICON_HAP} {package_name}, {package_version}" 163 | table.caption = f"{active_haps} active / {len(haps)} total" 164 | 165 | return table 166 | 167 | 168 | class JSONFormatter(Formatter): 169 | """ 170 | Formats Hap objects as a valid JSON. 171 | """ 172 | 173 | def format_one(self, hap: Hap) -> str: 174 | return json.dumps(hap.serialize()) 175 | 176 | def format_list(self, haps: List[Hap]) -> str: 177 | return json.dumps([hap.serialize() for hap in haps]) 178 | -------------------------------------------------------------------------------- /hapless/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from shlex import join as shlex_join 3 | from typing import Optional, Tuple 4 | 5 | import click 6 | 7 | from hapless import config 8 | from hapless.cli_utils import ( 9 | console, 10 | get_or_exit, 11 | hap_argument, 12 | hap_argument_optional, 13 | hapless, 14 | ) 15 | from hapless.formatters import JSONFormatter, TableFormatter 16 | from hapless.hap import Status 17 | from hapless.utils import isatty, logger, validate_signal 18 | 19 | 20 | @click.group(invoke_without_command=True) 21 | @click.version_option(message="hapless, version %(version)s") 22 | @click.option("-v", "--verbose", is_flag=True, default=False) 23 | @click.option( 24 | "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." 25 | ) 26 | @click.pass_context 27 | def cli(ctx, verbose: bool, json_output: bool): 28 | if ctx.invoked_subcommand is None: 29 | _status(None, verbose=verbose, json_output=json_output) 30 | 31 | 32 | @cli.command(short_help="Display information about haps.") 33 | @hap_argument_optional 34 | @click.option("-v", "--verbose", is_flag=True, default=False) 35 | @click.option( 36 | "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." 37 | ) 38 | def status(hap_alias: Optional[str], verbose: bool, json_output: bool): 39 | _status(hap_alias, verbose, json_output=json_output) 40 | 41 | 42 | @cli.command(short_help="Same as a status.") 43 | @hap_argument_optional 44 | @click.option("-v", "--verbose", is_flag=True, default=False) 45 | @click.option( 46 | "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." 47 | ) 48 | def show(hap_alias: Optional[str], verbose: bool, json_output: bool): 49 | _status(hap_alias, verbose, json_output=json_output) 50 | 51 | 52 | def _status( 53 | hap_alias: Optional[str] = None, 54 | verbose: bool = False, 55 | json_output: bool = False, 56 | ): 57 | formatter_cls = JSONFormatter if json_output else TableFormatter 58 | formatter = formatter_cls(verbose=verbose) 59 | if hap_alias is not None: 60 | hap = get_or_exit(hap_alias) 61 | hapless.show(hap, formatter=formatter) 62 | else: 63 | haps = hapless.get_haps(accessible_only=False) 64 | hapless.stats(haps, formatter=formatter) 65 | 66 | 67 | @cli.command(short_help="Output logs for a hap.") 68 | @hap_argument 69 | @click.option("-f", "--follow", is_flag=True, default=False) 70 | @click.option("-e", "--stderr", is_flag=True, default=False) 71 | def logs(hap_alias: str, follow: bool, stderr: bool): 72 | hap = get_or_exit(hap_alias) 73 | hapless.logs(hap, stderr=stderr, follow=follow) 74 | 75 | 76 | @cli.command(short_help="Output error logs for a hap.") 77 | @hap_argument 78 | @click.option( 79 | "-f", 80 | "--follow", 81 | is_flag=True, 82 | default=False, 83 | help="Print new log lines as they are added.", 84 | ) 85 | def errors(hap_alias: str, follow: bool): 86 | """ 87 | Output stderr logs for a hap. Same as running `logs -e` command. 88 | """ 89 | hap = get_or_exit(hap_alias) 90 | hapless.logs(hap, stderr=True, follow=follow) 91 | 92 | 93 | @cli.command(short_help="Remove successfully completed haps.") 94 | @click.option( 95 | "-a", 96 | "--all", 97 | "clean_all", 98 | is_flag=True, 99 | default=False, 100 | help="Include failed haps for the removal.", 101 | ) 102 | def clean(clean_all: bool): 103 | hapless.clean(clean_all=clean_all) 104 | 105 | 106 | @cli.command(short_help="Remove all finished haps, including failed ones.") 107 | def cleanall(): 108 | hapless.clean(clean_all=True) 109 | 110 | 111 | @cli.command( 112 | short_help="Execute background command as a hap.", 113 | context_settings=dict( 114 | ignore_unknown_options=True, 115 | ), 116 | ) 117 | @click.argument("cmd", nargs=-1) 118 | @click.option( 119 | "-n", "--name", help="Provide your own alias for the hap instead of a default one." 120 | ) 121 | @click.option( 122 | "--check", 123 | is_flag=True, 124 | default=False, 125 | help="Verify command launched does not fail immediately.", 126 | ) 127 | def run(cmd: Tuple[str, ...], name: str, check: bool): 128 | hap = hapless.get_hap(name) 129 | if hap is not None: 130 | console.error(f"Hap with such name already exists: {hap}") 131 | return sys.exit(1) 132 | 133 | # NOTE: click doesn't like `required` property for `cmd` argument 134 | # https://click.palletsprojects.com/en/latest/arguments/#variadic-arguments 135 | cmd_escaped = shlex_join(cmd).strip() 136 | if not cmd_escaped: 137 | console.error("You have to provide a command to run") 138 | return sys.exit(1) 139 | hapless.run_command(cmd_escaped, name=name, check=check) 140 | 141 | 142 | @cli.command(short_help="Pause a specific hap.") 143 | @hap_argument 144 | def pause(hap_alias: str): 145 | hap = get_or_exit(hap_alias) 146 | hapless.pause_hap(hap) 147 | 148 | 149 | @cli.command(short_help="Resume execution of a paused hap.") 150 | @hap_argument 151 | def resume(hap_alias: str): 152 | hap = get_or_exit(hap_alias) 153 | hapless.resume_hap(hap) 154 | 155 | 156 | @cli.command(short_help="Terminate a specific hap / all haps.") 157 | @hap_argument_optional 158 | @click.option("-a", "--all", "killall", is_flag=True, default=False) 159 | def kill(hap_alias: Optional[str], killall: bool): 160 | if hap_alias is not None and killall: 161 | raise click.BadOptionUsage( 162 | "killall", "Cannot use --all flag while hap id provided" 163 | ) 164 | 165 | if hap_alias is None and not killall: 166 | raise click.BadArgumentUsage("Provide hap alias to kill") 167 | 168 | if killall: 169 | haps = hapless.get_haps() 170 | hapless.kill(haps) 171 | else: 172 | # NOTE: `hap_alias` is guaranteed not to be None here 173 | hap = get_or_exit(hap_alias) # ty: ignore[invalid-argument-type] 174 | hapless.kill([hap]) 175 | 176 | 177 | @cli.command(short_help="Send an arbitrary signal to a hap.") 178 | @hap_argument 179 | @click.argument("signal", callback=validate_signal, metavar="signal-code") 180 | def signal(hap_alias: str, signal): 181 | hap = get_or_exit(hap_alias) 182 | hapless.signal(hap, signal) 183 | 184 | 185 | @cli.command(short_help="Kill the hap and start it again.") 186 | @hap_argument 187 | def restart(hap_alias: str): 188 | hap = get_or_exit(hap_alias) 189 | hapless.restart(hap) 190 | 191 | 192 | @cli.command(short_help="Set new name/alias for the existing hap.") 193 | @hap_argument 194 | @click.argument("new_name", metavar="new-name", required=True) 195 | def rename(hap_alias: str, new_name: str): 196 | hap = get_or_exit(hap_alias) 197 | same_name_hap = hapless.get_hap(new_name) 198 | if same_name_hap is not None: 199 | console.print(f"Hap with such name already exists: {same_name_hap}") 200 | return sys.exit(1) 201 | hapless.rename_hap(hap, new_name) 202 | 203 | 204 | @cli.command("__internal_wrap_hap", hidden=True) 205 | @hap_argument 206 | def _wrap_hap(hap_alias: str) -> None: 207 | if isatty() and not config.DEBUG: 208 | logger.critical("Internal command is not supposed to be run manually") 209 | return sys.exit(1) 210 | 211 | hap = get_or_exit(hap_alias) 212 | if hap.status != Status.UNBOUND: 213 | message = f"Hap {hap} has to be unbound, found instead {str(hap.status)}\n" 214 | with open(hap.stderr_path, "a") as f: 215 | f.write(message) 216 | logger.error(message) 217 | return sys.exit(1) 218 | hapless._wrap_subprocess(hap) 219 | 220 | 221 | if __name__ == "__main__": 222 | cli() 223 | -------------------------------------------------------------------------------- /tests/test_ui.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Generator 3 | from unittest.mock import ANY, PropertyMock, patch 4 | 5 | import pytest 6 | 7 | from hapless import config 8 | from hapless.formatters import TableFormatter 9 | from hapless.main import Hapless 10 | from hapless.ui import ConsoleUI 11 | 12 | 13 | @pytest.fixture 14 | def hapless_with_ui(tmp_path: Path) -> Generator[Hapless, None, None]: 15 | hapless = Hapless(hapless_dir=tmp_path, quiet=False) 16 | # Set a fixed width for the console to be able to assert on content that could fit 17 | hapless.ui.console.width = 180 18 | yield hapless 19 | 20 | 21 | def test_default_formatter_is_table(): 22 | ui = ConsoleUI() 23 | assert isinstance(ui.default_formatter, TableFormatter) 24 | 25 | 26 | def test_long_hids_are_visible(capsys, hapless: Hapless): 27 | ui = ConsoleUI() 28 | long_hid = "9876543210" 29 | hap = hapless.create_hap("true", hid=long_hid) 30 | ui.stats([hap]) 31 | captured = capsys.readouterr() 32 | assert long_hid in captured.out 33 | assert hap.name in captured.out 34 | 35 | 36 | def test_summary_and_details_disabled_in_quiet_mode(capsys, hapless: Hapless): 37 | hap = hapless.create_hap("true") 38 | 39 | hapless.show(hap, formatter=TableFormatter()) 40 | captured = capsys.readouterr() 41 | assert not captured.out 42 | assert not captured.err 43 | 44 | hapless.stats([hap], formatter=TableFormatter()) 45 | captured = capsys.readouterr() 46 | assert not captured.out 47 | assert not captured.err 48 | 49 | 50 | def test_disabled(capsys): 51 | ui = ConsoleUI(disable=True) 52 | ui.print("This should not be printed") 53 | ui.error("This should not be printed either") 54 | captured = capsys.readouterr() 55 | assert captured.out == "" 56 | assert captured.err == "" 57 | 58 | 59 | def test_pause_message(hapless_with_ui: Hapless, capsys): 60 | hapless = hapless_with_ui 61 | hap = hapless.create_hap("true") 62 | with patch.object(hap, "proc") as proc_mock: 63 | hapless.pause_hap(hap) 64 | 65 | proc_mock.suspend.assert_called_once_with() 66 | captured = capsys.readouterr() 67 | assert "Paused" in captured.out 68 | 69 | with patch("sys.exit") as exit_mock, patch.object(hap, "proc", new=None): 70 | hapless.pause_hap(hap) 71 | 72 | exit_mock.assert_called_once_with(1) 73 | captured = capsys.readouterr() 74 | assert "Cannot pause" in captured.out 75 | assert "is not running" in captured.out 76 | 77 | 78 | def test_resume_message(hapless_with_ui: Hapless, capsys): 79 | hapless = hapless_with_ui 80 | hap = hapless.create_hap("true") 81 | with patch.object(hap, "proc") as proc_mock: 82 | proc_mock.status.return_value = "stopped" 83 | hapless.resume_hap(hap) 84 | 85 | proc_mock.resume.assert_called_once_with() 86 | captured = capsys.readouterr() 87 | assert "Resumed" in captured.out 88 | 89 | with patch("sys.exit") as exit_mock, patch.object(hap, "proc", new=None): 90 | hapless.resume_hap(hap) 91 | 92 | exit_mock.assert_called_once_with(1) 93 | captured = capsys.readouterr() 94 | assert "Cannot resume" in captured.out 95 | assert "is not suspended" in captured.out 96 | 97 | 98 | def test_rename_message(hapless_with_ui: Hapless, capsys): 99 | hapless = hapless_with_ui 100 | hap = hapless.create_hap("true", name="old-name") 101 | hapless.rename_hap(hap, new_name="new-name") 102 | 103 | captured = capsys.readouterr() 104 | assert "Renamed" in captured.out 105 | assert "old-name" in captured.out 106 | assert "new-name" in captured.out 107 | 108 | 109 | def test_workdir_is_displayed_in_verbose_mode( 110 | hapless_with_ui: Hapless, tmp_path, capsys 111 | ): 112 | hapless = hapless_with_ui 113 | hap = hapless.create_hap("true", workdir=tmp_path) 114 | hapless.show(hap, formatter=TableFormatter(verbose=True)) 115 | 116 | captured = capsys.readouterr() 117 | assert "Command:" in captured.out 118 | assert "Working dir:" in captured.out 119 | assert f"{tmp_path}" in captured.out 120 | 121 | 122 | def test_launching_message(hapless_with_ui: Hapless, capsys): 123 | hapless = hapless_with_ui 124 | hap = hapless.create_hap("true") 125 | 126 | with patch.object( 127 | hapless, "_wrap_subprocess" 128 | ) as wrap_subprocess_mock, patch.object( 129 | hapless, "_run_via_spawn" 130 | ) as run_spawn_mock, patch.object( 131 | hapless, "_run_via_fork" 132 | ) as run_fork_mock, patch.object( 133 | hapless, "_check_fast_failure" 134 | ) as check_fast_failure_mock: 135 | hapless.run_hap(hap) 136 | 137 | wrap_subprocess_mock.assert_not_called() 138 | run_spawn_mock.assert_not_called() 139 | run_fork_mock.assert_called_once_with(hap) 140 | check_fast_failure_mock.assert_not_called() 141 | 142 | captured = capsys.readouterr() 143 | assert "Launching hap" in captured.out 144 | 145 | 146 | def test_check_fast_failure_ok_message(hapless_with_ui: Hapless, capsys): 147 | hapless = hapless_with_ui 148 | hap = hapless.create_hap("echo check", name="hap-check-message") 149 | with patch.object( 150 | hapless, "_wrap_subprocess" 151 | ) as wrap_subprocess_mock, patch.object( 152 | hapless, "_run_via_spawn" 153 | ) as run_spawn_mock, patch.object( 154 | hapless, "_run_via_fork" 155 | ) as run_fork_mock, patch.object( 156 | hapless, "_check_fast_failure", wraps=hapless._check_fast_failure 157 | ) as check_fast_failure_mock, patch( 158 | "hapless.main.wait_created", return_value=False 159 | ) as wait_created_mock: 160 | hapless.run_hap(hap, check=True) 161 | 162 | wrap_subprocess_mock.assert_not_called() 163 | run_spawn_mock.assert_not_called() 164 | run_fork_mock.assert_called_once_with(hap) 165 | check_fast_failure_mock.assert_called_once_with(hap) 166 | wait_created_mock.assert_called_once() 167 | 168 | captured = capsys.readouterr() 169 | assert "Launching hap" in captured.out 170 | assert "hap-check-message" in captured.out 171 | assert "Hap is healthy and still running" in captured.out 172 | 173 | 174 | def test_check_fast_failure_error_message(hapless_with_ui: Hapless, capsys): 175 | hapless = hapless_with_ui 176 | hap = hapless.create_hap("false", name="hap-check-failed-msg") 177 | with patch.object(type(hap), "rc", new_callable=PropertyMock) as rc_mock, patch( 178 | "hapless.main.wait_created", return_value=True 179 | ) as wait_created_mock: 180 | rc_mock.return_value = 1 181 | 182 | with pytest.raises(SystemExit) as e: 183 | hapless._check_fast_failure(hap) 184 | assert e.value.code == 1 185 | 186 | assert hap.rc == 1 187 | wait_created_mock.assert_called_once_with( 188 | hap._rc_file, 189 | live_context=ANY, 190 | interval=ANY, 191 | timeout=ANY, 192 | ) 193 | 194 | captured = capsys.readouterr() 195 | assert "Hap exited too quickly" in captured.out 196 | assert "Hap is healthy" not in captured.out 197 | 198 | 199 | def test_check_fast_failure_quick_but_success(hapless_with_ui: Hapless, capsys): 200 | hapless = hapless_with_ui 201 | timeout = config.FAILFAST_TIMEOUT 202 | hap = hapless.create_hap("true", name="hap-check-fast-ok") 203 | with patch.object( 204 | type(hap), "rc", new_callable=PropertyMock, return_value=0 205 | ), patch("hapless.main.wait_created", return_value=True) as wait_created_mock: 206 | hapless._check_fast_failure(hap) 207 | 208 | assert hap.rc == 0 209 | wait_created_mock.assert_called_once_with( 210 | hap._rc_file, 211 | live_context=ANY, 212 | interval=ANY, 213 | timeout=timeout, 214 | ) 215 | 216 | captured = capsys.readouterr() 217 | assert f"Hap finished successfully in less than {timeout} seconds" in captured.out 218 | assert "Hap exited too quickly" not in captured.out 219 | assert "Hap is healthy" not in captured.out 220 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from contextlib import ExitStack 2 | from unittest.mock import Mock, patch 3 | 4 | from hapless import cli 5 | 6 | 7 | def test_executable_invocation(runner): 8 | result = runner.invoke(cli.cli) 9 | 10 | assert result.exit_code == 0 11 | 12 | assert "No haps are currently running" in result.output 13 | 14 | 15 | def test_version_invocation(runner): 16 | result = runner.invoke(cli.cli, ["--version"]) 17 | 18 | assert result.exit_code == 0 19 | assert result.output.startswith("hapless, version") 20 | 21 | 22 | def test_help_invocation(runner): 23 | result = runner.invoke(cli.cli, ["--help"]) 24 | 25 | assert result.exit_code == 0 26 | assert "Show this message and exit" in result.output 27 | 28 | 29 | @patch("hapless.cli._status") 30 | def test_no_command_invokes_status(status_mock, runner): 31 | result = runner.invoke(cli.cli) 32 | 33 | assert result.exit_code == 0 34 | status_mock.assert_called_once_with(None, verbose=False, json_output=False) 35 | 36 | 37 | @patch("hapless.cli._status") 38 | def test_show_command_invokes_status(status_mock, runner): 39 | result = runner.invoke(cli.cli, ["show", "hap-me"]) 40 | 41 | assert result.exit_code == 0 42 | status_mock.assert_called_once_with("hap-me", False, json_output=False) 43 | 44 | 45 | @patch("hapless.cli._status") 46 | def test_status_command_invokes_status(status_mock, runner): 47 | result = runner.invoke(cli.cli, ["status", "hap-me"]) 48 | 49 | assert result.exit_code == 0 50 | status_mock.assert_called_once_with("hap-me", False, json_output=False) 51 | 52 | 53 | @patch("hapless.cli._status") 54 | def test_status_accepts_json_argument(status_mock, runner): 55 | result = runner.invoke(cli.cli, ["status", "hap-me", "--json"]) 56 | 57 | assert result.exit_code == 0 58 | status_mock.assert_called_once_with("hap-me", False, json_output=True) 59 | 60 | 61 | @patch("hapless.cli.get_or_exit") 62 | def test_logs_invocation(get_or_exit_mock, runner): 63 | hap_mock = Mock() 64 | get_or_exit_mock.return_value = hap_mock 65 | with patch.object(runner.hapless, "logs") as logs_mock: 66 | result = runner.invoke(cli.cli, ["logs", "hap-me", "--follow"]) 67 | assert result.exit_code == 0 68 | get_or_exit_mock.assert_called_once_with("hap-me") 69 | logs_mock.assert_called_once_with(hap_mock, stderr=False, follow=True) 70 | 71 | 72 | @patch("hapless.cli.get_or_exit") 73 | def test_logs_stderr_invocation(get_or_exit_mock, runner): 74 | hap_mock = Mock() 75 | get_or_exit_mock.return_value = hap_mock 76 | with patch.object(runner.hapless, "logs") as logs_mock: 77 | result = runner.invoke(cli.cli, ["logs", "hap-me", "--stderr"]) 78 | assert result.exit_code == 0 79 | get_or_exit_mock.assert_called_once_with("hap-me") 80 | logs_mock.assert_called_once_with(hap_mock, stderr=True, follow=False) 81 | 82 | 83 | @patch("hapless.cli.get_or_exit") 84 | def test_errors_invocation(get_or_exit_mock, runner): 85 | hap_mock = Mock() 86 | get_or_exit_mock.return_value = hap_mock 87 | with patch.object(runner.hapless, "logs") as logs_mock: 88 | result = runner.invoke(cli.cli, ["errors", "hap-me", "--follow"]) 89 | assert result.exit_code == 0 90 | get_or_exit_mock.assert_called_once_with("hap-me") 91 | logs_mock.assert_called_once_with(hap_mock, stderr=True, follow=True) 92 | 93 | 94 | @patch("hapless.cli.get_or_exit") 95 | def test_errors_help_invocation(get_or_exit_mock, runner): 96 | with patch.object(runner.hapless, "logs") as logs_mock: 97 | result = runner.invoke(cli.cli, ["errors", "--help"]) 98 | assert result.exit_code == 0 99 | assert "Output stderr logs for a hap" in result.output 100 | assert "--follow" in result.output 101 | assert "--stderr" not in result.output 102 | get_or_exit_mock.assert_not_called() 103 | logs_mock.assert_not_called() 104 | 105 | 106 | def test_run_invocation(runner): 107 | with patch.object(runner.hapless, "run_command") as run_command_mock: 108 | result = runner.invoke(cli.cli, ["run", "script", "--check"]) 109 | assert result.exit_code == 0 110 | run_command_mock.assert_called_once_with("script", name=None, check=True) 111 | 112 | 113 | def test_run_invocation_with_arguments(runner): 114 | with patch.object(runner.hapless, "run_command") as run_command_mock: 115 | result = runner.invoke( 116 | cli.cli, ["run", "--check", "--", "script", "--script-param"] 117 | ) 118 | assert result.exit_code == 0 119 | run_command_mock.assert_called_once_with( 120 | "script --script-param", name=None, check=True 121 | ) 122 | 123 | 124 | def test_run_invocation_name_provided(runner): 125 | with patch.object(runner.hapless, "run_command") as run_command_mock: 126 | result = runner.invoke( 127 | cli.cli, ["run", "--name", "hap-name", "--", "script", "--script-param"] 128 | ) 129 | assert result.exit_code == 0 130 | run_command_mock.assert_called_once_with( 131 | "script --script-param", name="hap-name", check=False 132 | ) 133 | 134 | 135 | def test_run_invocation_same_name_provided(runner): 136 | name = "hap-name" 137 | cmd = "script" 138 | with patch.object(runner.hapless, "run_command") as run_command_mock: 139 | result_pass = runner.invoke(cli.cli, ["run", "--name", name, cmd]) 140 | assert result_pass.exit_code == 0 141 | run_command_mock.assert_called_once_with( 142 | cmd, 143 | name=name, 144 | check=False, 145 | ) 146 | # make sure record for the hap has been actually created 147 | runner.hapless.create_hap(cmd=cmd, name=name) 148 | 149 | # call again with the same name 150 | result_fail = runner.invoke(cli.cli, ["run", "--name", name, cmd]) 151 | assert result_fail.exit_code == 1 152 | assert run_command_mock.call_count == 1 153 | 154 | 155 | def test_run_empty_invocation(runner): 156 | with patch.object(runner.hapless, "run_command") as run_command_mock: 157 | result = runner.invoke(cli.cli, "run ") 158 | assert result.exit_code == 1 159 | assert "You have to provide a command to run" in result.output 160 | assert not run_command_mock.called 161 | 162 | 163 | def test_clean_invocation(runner): 164 | with patch.object(runner.hapless, "clean") as clean_mock: 165 | result = runner.invoke(cli.cli, ["clean", "--all"]) 166 | assert result.exit_code == 0 167 | clean_mock.assert_called_once_with(clean_all=True) 168 | 169 | 170 | def test_cleanall_invocation(runner): 171 | with patch.object(runner.hapless, "clean") as clean_mock: 172 | result = runner.invoke(cli.cli, ["cleanall"]) 173 | assert result.exit_code == 0 174 | clean_mock.assert_called_once_with(clean_all=True) 175 | 176 | 177 | @patch("hapless.cli.get_or_exit") 178 | def test_pause_invocation(get_or_exit_mock, runner): 179 | hap_mock = Mock() 180 | get_or_exit_mock.return_value = hap_mock 181 | with patch.object(runner.hapless, "pause_hap") as pause_mock: 182 | result = runner.invoke(cli.cli, ["pause", "hap-me"]) 183 | assert result.exit_code == 0 184 | get_or_exit_mock.assert_called_once_with("hap-me") 185 | pause_mock.assert_called_once_with(hap_mock) 186 | 187 | 188 | @patch("hapless.cli.get_or_exit") 189 | def test_resume_invocation(get_or_exit_mock, runner): 190 | hap_mock = Mock() 191 | get_or_exit_mock.return_value = hap_mock 192 | with patch.object(runner.hapless, "resume_hap") as resume_mock: 193 | result = runner.invoke(cli.cli, ["resume", "hap-me"]) 194 | assert result.exit_code == 0 195 | get_or_exit_mock.assert_called_once_with("hap-me") 196 | resume_mock.assert_called_once_with(hap_mock) 197 | 198 | 199 | @patch("hapless.cli.get_or_exit") 200 | def test_restart_invocation(get_or_exit_mock, runner): 201 | hap_mock = Mock() 202 | get_or_exit_mock.return_value = hap_mock 203 | with patch.object(runner.hapless, "restart") as restart_mock: 204 | result = runner.invoke(cli.cli, ["restart", "hap-me"]) 205 | assert result.exit_code == 0 206 | get_or_exit_mock.assert_called_once_with("hap-me") 207 | restart_mock.assert_called_once_with(hap_mock) 208 | 209 | 210 | @patch("hapless.cli.get_or_exit") 211 | def test_rename_invocation(get_or_exit_mock, runner): 212 | hap_mock = Mock() 213 | get_or_exit_mock.return_value = hap_mock 214 | with patch.object(runner.hapless, "rename_hap") as rename_mock: 215 | result = runner.invoke(cli.cli, ["rename", "hap-me", "new-hap-name"]) 216 | assert result.exit_code == 0 217 | get_or_exit_mock.assert_called_once_with("hap-me") 218 | rename_mock.assert_called_once_with(hap_mock, "new-hap-name") 219 | 220 | 221 | @patch("hapless.cli.get_or_exit") 222 | def test_rename_name_exists(get_or_exit_mock, runner): 223 | hap_mock = Mock() 224 | other_hap = Mock() 225 | get_or_exit_mock.return_value = hap_mock 226 | # NOTE: Python 3.8 compatibility 227 | with ExitStack() as stack: 228 | rename_mock = stack.enter_context(patch.object(runner.hapless, "rename_hap")) 229 | get_hap_mock = stack.enter_context( 230 | patch.object(runner.hapless, "get_hap", return_value=other_hap) 231 | ) 232 | 233 | result = runner.invoke(cli.cli, ["rename", "hap-me", "new-hap-name"]) 234 | assert result.exit_code == 1 235 | assert "Hap with such name already exists" in result.output 236 | get_or_exit_mock.assert_called_once_with("hap-me") 237 | get_hap_mock.assert_called_once_with("new-hap-name") 238 | rename_mock.assert_not_called() 239 | -------------------------------------------------------------------------------- /hapless/hap.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pwd 4 | import random 5 | import string 6 | import time 7 | from datetime import datetime 8 | from enum import Enum 9 | from functools import cached_property 10 | from pathlib import Path 11 | from typing import Dict, Optional, Union, cast 12 | 13 | import humanize 14 | import psutil 15 | 16 | from hapless import config 17 | from hapless.utils import allow_missing, get_mtime, logger 18 | 19 | 20 | class Status(str, Enum): 21 | # Created status 22 | UNBOUND = "unbound" 23 | # Active statuses 24 | PAUSED = "paused" 25 | RUNNING = "running" 26 | # Finished statuses 27 | FAILED = "failed" 28 | SUCCESS = "success" 29 | 30 | 31 | class Hap(object): 32 | def __init__( 33 | self, 34 | hap_path: Path, 35 | *, 36 | name: Optional[str] = None, 37 | cmd: Optional[str] = None, 38 | workdir: Optional[Union[str, Path]] = None, 39 | redirect_stderr: bool = False, 40 | ) -> None: 41 | if not hap_path.is_dir(): 42 | raise ValueError(f"Path {hap_path} is not a directory") 43 | 44 | self._hap_path = hap_path 45 | self._hid: str = hap_path.name 46 | 47 | self._pid_file = hap_path / "pid" 48 | self._rc_file = hap_path / "rc" 49 | self._name_file = hap_path / "name" 50 | self._cmd_file = hap_path / "cmd" 51 | self._workdir_file = hap_path / "workdir" 52 | self._env_file = hap_path / "env" 53 | 54 | self._stdout_path = hap_path / "stdout.log" 55 | self._stderr_path = hap_path / "stderr.log" 56 | 57 | self._set_logfiles(redirect_stderr) 58 | self._set_raw_name(name) 59 | self._set_command_context(cmd, workdir) 60 | 61 | def set_name(self, name: str): 62 | with open(self._name_file, "w") as f: 63 | f.write(name) 64 | 65 | def set_return_code(self, rc: int): 66 | with open(self._rc_file, "w") as f: 67 | f.write(f"{rc}") 68 | 69 | def _set_raw_name(self, raw_name: Optional[str]): 70 | """ 71 | Set name for the first time on hap creation. 72 | """ 73 | if raw_name is None: 74 | suffix = self.get_random_name() 75 | raw_name = f"hap-{suffix}" 76 | 77 | if self.raw_name is None: 78 | self.set_name(raw_name) 79 | 80 | def _set_command_context( 81 | self, 82 | cmd: Optional[str], 83 | workdir: Optional[Union[str, Path]], 84 | ) -> None: 85 | """ 86 | Set command and working directory for the first time on hap creation. 87 | """ 88 | if self.cmd is None: 89 | if cmd is None: 90 | raise ValueError("Command to run is not provided") 91 | with open(self._cmd_file, "w") as f: 92 | f.write(cmd) 93 | 94 | if self.workdir is None: 95 | workdir = Path(workdir or os.getcwd()) 96 | # NOTE: should be ValueError, but we need defaults to keep 97 | # compatibility with the older versions 98 | if not workdir.exists() or not workdir.is_dir(): 99 | raise ValueError("Workdir should be a path to existing directory") 100 | 101 | with open(self._workdir_file, "w") as f: 102 | f.write(f"{workdir}") 103 | 104 | def _set_pid(self, pid: int): 105 | with open(self._pid_file, "w") as pid_file: 106 | pid_file.write(f"{pid}") 107 | 108 | if not psutil.pid_exists(pid): 109 | raise RuntimeError(f"Process with pid {pid} is gone") 110 | 111 | def _set_logfiles(self, redirect_stderr: bool): 112 | if redirect_stderr: 113 | logger.debug("Process stderr will be redirected to stdout file") 114 | if not self._stdout_path.exists() and not redirect_stderr: 115 | self._stderr_path.touch(exist_ok=True, mode=0o644) 116 | self._stdout_path.touch(exist_ok=True, mode=0o644) 117 | 118 | def _set_env(self): 119 | proc = self.proc 120 | 121 | environ = {} 122 | if proc is not None: 123 | try: 124 | environ = proc.environ() 125 | except (ProcessLookupError, psutil.NoSuchProcess) as e: 126 | logger.error(f"Cannot get environment: {e}") 127 | 128 | if not environ: 129 | logger.warning( 130 | "Cannot get environment from the process. " 131 | "Fallback to current environment" 132 | ) 133 | environ = dict(os.environ) 134 | 135 | with open(self._env_file, "w") as env_file: 136 | env_file.write(json.dumps(environ)) 137 | 138 | def bind(self, pid: int): 139 | """ 140 | Associate hap object with existing process by pid. 141 | """ 142 | try: 143 | self._set_pid(pid) 144 | self._set_env() 145 | except (RuntimeError, psutil.AccessDenied) as e: 146 | logger.error(f"Cannot bind due to {e}") 147 | 148 | @staticmethod 149 | def get_random_name(length: int = 6) -> str: 150 | return "".join( 151 | random.sample( 152 | string.ascii_lowercase + string.digits, 153 | length, 154 | ) 155 | ) 156 | 157 | # TODO: add extended status to show panel proc.status() 158 | @property 159 | def status(self) -> Status: 160 | if self.pid is None and self.rc is None: 161 | # No existing process or no return code from the finished one 162 | return Status.UNBOUND 163 | 164 | proc = self.proc 165 | if proc is not None: 166 | if proc.status() == psutil.STATUS_STOPPED: 167 | return Status.PAUSED 168 | return Status.RUNNING 169 | 170 | if self.rc != 0: 171 | return Status.FAILED 172 | 173 | return Status.SUCCESS 174 | 175 | @cached_property 176 | def proc(self): 177 | # NOTE: this is cached for the instance lifetime, fits our use case 178 | if self.pid is None: 179 | return 180 | 181 | try: 182 | return psutil.Process(self.pid) 183 | except psutil.NoSuchProcess as e: 184 | logger.warning(f"Cannot find process: {e}") 185 | 186 | @property 187 | @allow_missing 188 | def cmd(self) -> Optional[str]: 189 | with open(self._cmd_file) as f: 190 | return f.read() 191 | 192 | @property 193 | @allow_missing 194 | def workdir(self) -> Optional[Path]: 195 | with open(self._workdir_file) as f: 196 | return Path(f.read()) 197 | 198 | @property 199 | @allow_missing 200 | def rc(self) -> Optional[int]: 201 | with open(self._rc_file) as f: 202 | return int(f.read()) 203 | 204 | @property 205 | def runtime(self) -> str: 206 | proc = self.proc 207 | runtime = 0 208 | if proc is not None: 209 | runtime = time.time() - proc.create_time() 210 | elif self._pid_file.exists(): 211 | start_time = cast(float, get_mtime(self._pid_file)) 212 | finish_time = cast( 213 | float, get_mtime(self._rc_file) or get_mtime(self.stderr_path) 214 | ) 215 | runtime = finish_time - start_time 216 | 217 | return humanize.naturaldelta(runtime) 218 | 219 | @property 220 | def start_time(self) -> Optional[str]: 221 | if self._pid_file.exists(): 222 | return datetime.fromtimestamp(os.path.getmtime(self._pid_file)).strftime( 223 | config.DATETIME_FORMAT 224 | ) 225 | logger.info("No start time as hap has not started yet") 226 | 227 | @property 228 | def end_time(self) -> Optional[str]: 229 | if self._rc_file.exists(): 230 | return datetime.fromtimestamp(os.path.getmtime(self._rc_file)).strftime( 231 | config.DATETIME_FORMAT 232 | ) 233 | logger.info("No end time as hap has not finished yet") 234 | 235 | @property 236 | def active(self) -> bool: 237 | return self.proc is not None 238 | 239 | @property 240 | def hid(self) -> str: 241 | return self._hid 242 | 243 | @property 244 | @allow_missing 245 | def pid(self) -> Optional[int]: 246 | with open(self._pid_file) as f: 247 | return int(f.read()) 248 | 249 | @property 250 | @allow_missing 251 | def env(self) -> Dict[str, str]: 252 | proc = self.proc 253 | if proc is not None: 254 | return proc.environ() 255 | 256 | with open(self._env_file) as f: 257 | return json.loads(f.read()) 258 | 259 | @property 260 | @allow_missing 261 | def raw_name(self) -> Optional[str]: 262 | with open(self._name_file) as f: 263 | return f.read().strip() 264 | 265 | @cached_property 266 | def name(self) -> str: 267 | """ 268 | Base name without restarts counter. 269 | """ 270 | return self.raw_name.split(config.RESTART_DELIM)[0] 271 | 272 | @cached_property 273 | def restarts(self) -> int: 274 | _, *rest = self.raw_name.rsplit("@", maxsplit=1) 275 | return int(rest[0]) if rest else 0 276 | 277 | @property 278 | def path(self) -> Path: 279 | return self._hap_path 280 | 281 | @property 282 | def stdout_path(self) -> Path: 283 | return self._stdout_path 284 | 285 | @property 286 | def stderr_path(self) -> Path: 287 | if self.redirect_stderr: 288 | return self._stdout_path 289 | return self._stderr_path 290 | 291 | @property 292 | def redirect_stderr(self) -> bool: 293 | return not self._stderr_path.exists() 294 | 295 | @property 296 | def accessible(self) -> bool: 297 | """ 298 | Check if current user has control over the hap. 299 | NOTE: EAFP is preferable here instead 300 | https://docs.python.org/3/library/os.html#os.access 301 | """ 302 | return os.access(self.path, os.F_OK | os.R_OK | os.W_OK | os.X_OK) 303 | 304 | @property 305 | def owner(self) -> str: 306 | stat = self.path.stat() 307 | try: 308 | owner = pwd.getpwuid(stat.st_uid).pw_name 309 | except KeyError: 310 | owner = f"{stat.st_uid}:{stat.st_gid}" 311 | return owner 312 | 313 | def serialize(self) -> dict: 314 | """ 315 | Serialize hap object into a dictionary. 316 | """ 317 | return { 318 | "hid": self.hid, 319 | "name": self.name, 320 | "pid": str(self.pid) if self.pid is not None else None, 321 | "rc": str(self.rc) if self.rc is not None else None, 322 | "cmd": self.cmd, 323 | "workdir": str(self.workdir), 324 | "status": self.status.value, 325 | "runtime": self.runtime, 326 | "start_time": self.start_time, 327 | "end_time": self.end_time, 328 | "restarts": str(self.restarts), 329 | "stdout_file": str(self.stdout_path), 330 | "stderr_file": str(self.stderr_path), 331 | } 332 | 333 | def __str__(self) -> str: 334 | return f"#{self.hid} ({self.name})" 335 | 336 | def __repr__(self) -> str: 337 | return f"<{self.__class__.__name__} {self} object at {hex(id(self))}>" 338 | 339 | def __rich__(self) -> str: 340 | pid_text = f"with PID [[{config.COLOR_MAIN} bold]{self.pid}[/]]" 341 | rich_text = ( 342 | f"hap {config.ICON_HAP}{self.hid} " 343 | f"([{config.COLOR_MAIN} bold]{self.name}[/])" 344 | ) 345 | if self.pid: 346 | return f"{rich_text} {pid_text}" 347 | return rich_text 348 | -------------------------------------------------------------------------------- /tests/test_hapless.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import ANY, Mock, patch 3 | 4 | import pytest 5 | 6 | from hapless.main import Hapless 7 | 8 | 9 | def test_get_next_hap_id(hapless: Hapless): 10 | result = hapless._get_next_hap_id() 11 | assert result == "1" 12 | 13 | 14 | def test_get_hap_dirs_empty(hapless: Hapless): 15 | result = hapless._get_hap_dirs() 16 | assert result == [] 17 | 18 | 19 | def test_get_hap_dirs_with_hap(hapless: Hapless, hap): 20 | result = hapless._get_hap_dirs() 21 | assert result == [hap.hid] 22 | 23 | 24 | def test_create_hap(hapless: Hapless): 25 | result = hapless.create_hap("echo hello") 26 | assert result.cmd == "echo hello" 27 | assert result.hid == "1" 28 | assert result.name is not None 29 | assert isinstance(result.name, str) 30 | assert result.name.startswith("hap-") 31 | 32 | 33 | def test_create_hap_custom_hid(hapless: Hapless): 34 | result = hapless.create_hap(cmd="echo hello", hid="42", name="hap-name") 35 | assert result.cmd == "echo hello" 36 | assert result.hid == "42" 37 | assert result.name == "hap-name" 38 | 39 | 40 | def test_get_hap_works_with_restarts(hapless: Hapless): 41 | raw_name = "hap-name@2" 42 | hapless.create_hap(cmd="true", name=raw_name) 43 | hap = hapless.get_hap(hap_alias="hap-name") 44 | assert hap is not None 45 | assert hap.raw_name == raw_name 46 | assert hap.name == "hap-name" 47 | 48 | # Check ignoring restarts suffix 49 | no_hap = hapless.get_hap(hap_alias=raw_name) 50 | assert no_hap is None 51 | 52 | 53 | def test_rename_hap_preserves_restarts(hapless: Hapless): 54 | raw_name = "hap-name@3" 55 | hapless.create_hap(cmd="true", name=raw_name) 56 | hap = hapless.get_hap(hap_alias="hap-name") 57 | assert hap is not None 58 | assert hap.raw_name == raw_name 59 | assert hap.name == "hap-name" 60 | 61 | hapless.rename_hap(hap, "hap-new-name") 62 | no_hap = hapless.get_hap(hap_alias="hap-name") 63 | # Cannot get with with an old name 64 | assert no_hap is None 65 | 66 | hap = hapless.get_hap(hap_alias="hap-new-name") 67 | assert hap is not None 68 | assert hap.restarts == 3 69 | assert hap.name == "hap-new-name" 70 | assert hap.raw_name == "hap-new-name@3" 71 | 72 | 73 | def test_get_haps_only_accessible(hapless: Hapless): 74 | hap1 = hapless.create_hap("true", name="hap1") 75 | hap2 = hapless.create_hap("true", name="hap2") # noqa: F841 76 | hap3 = hapless.create_hap("true", name="hap3") # noqa: F841 77 | 78 | # NOTE: order is guaranteed, so we can rely on this side effect 79 | with patch( 80 | "os.access", 81 | side_effect=(True, False, False), 82 | ) as access_mock: 83 | haps = hapless.get_haps() 84 | assert access_mock.call_count == 3 85 | assert len(haps) == 1 86 | assert haps[0].name == hap1.name 87 | 88 | 89 | def test_get_haps_return_all_entries(hapless: Hapless): 90 | hap1 = hapless.create_hap("true", name="hap1") 91 | hap2 = hapless.create_hap("true", name="hap2") # noqa: F841 92 | hap3 = hapless.create_hap("true", name="hap3") # noqa: F841 93 | 94 | with patch("os.access", return_value=False) as access_mock: 95 | haps = hapless.get_haps(accessible_only=False) 96 | # filter function just ignores accessible attribute 97 | access_mock.assert_not_called() 98 | assert len(haps) == 3 99 | assert hap1.accessible is False 100 | assert hap2.accessible is False 101 | assert hap3.accessible is False 102 | assert access_mock.call_count == 3 103 | 104 | 105 | def test_state_dir_is_not_accessible(tmp_path, capsys): 106 | with patch("os.utime", side_effect=PermissionError): 107 | with pytest.raises(SystemExit) as e: 108 | Hapless(hapless_dir=tmp_path) 109 | 110 | captured = capsys.readouterr() 111 | 112 | assert "is not accessible by user" in captured.out 113 | assert e.value.code == 1 114 | 115 | 116 | def test_state_dir_is_overriden(tmp_path: Path): 117 | custom_state_dir = tmp_path / "custom" 118 | hapless = Hapless(hapless_dir=custom_state_dir) 119 | 120 | assert isinstance(hapless.dir, Path) 121 | assert hapless.dir == custom_state_dir 122 | 123 | hap = hapless.create_hap(cmd="echo hello", hid="42", name="hap-name") 124 | assert hap.path.parent == custom_state_dir 125 | assert hap.path == custom_state_dir / hap.hid 126 | 127 | 128 | def test_run_hap_invocation(hapless: Hapless): 129 | """ 130 | Check child launches a subprocess and exits. 131 | """ 132 | hap = hapless.create_hap("echo test", name="hap-name") 133 | with patch("os.fork", return_value=0) as fork_mock, patch( 134 | "os.setsid" 135 | ) as setsid_mock, patch("os._exit") as os_exit_mock, patch( 136 | "sys.exit" 137 | ) as sys_exit_mock, patch.object( 138 | hapless, "_wrap_subprocess" 139 | ) as wrap_subprocess_mock, patch.object( 140 | hapless, "_check_fast_failure" 141 | ) as check_fast_failure_mock: 142 | hapless.run_hap(hap) 143 | 144 | fork_mock.assert_called_once_with() 145 | setsid_mock.assert_called_once_with() 146 | wrap_subprocess_mock.assert_called_once_with(hap) 147 | check_fast_failure_mock.assert_not_called() 148 | os_exit_mock.assert_called_once_with(0) 149 | sys_exit_mock.assert_not_called() 150 | 151 | 152 | def test_run_hap_parent_process(hapless: Hapless): 153 | """ 154 | Check parent continues after the fork. 155 | """ 156 | hap = hapless.create_hap("echo test", name="hap-name") 157 | with patch("os.fork", return_value=12345) as fork_mock, patch.object( 158 | hapless, "_wrap_subprocess" 159 | ) as wrap_subprocess_mock, patch.object( 160 | hapless, "_check_fast_failure" 161 | ) as check_fast_failure_mock, patch.object( 162 | hapless, "_run_via_fork", wraps=hapless._run_via_fork 163 | ) as run_via_fork_mock: 164 | hapless.run_hap(hap) 165 | 166 | run_via_fork_mock.assert_called_once_with(hap) 167 | fork_mock.assert_called_once() 168 | wrap_subprocess_mock.assert_not_called() 169 | check_fast_failure_mock.assert_not_called() 170 | 171 | 172 | def test_run_hap_parent_process_blocking(hapless: Hapless): 173 | hap = hapless.create_hap("echo test", name="hap-blocking") 174 | with patch("os.fork") as fork_mock, patch.object( 175 | hapless, "_wrap_subprocess" 176 | ) as wrap_subprocess_mock, patch.object( 177 | hapless, "_check_fast_failure" 178 | ) as check_fast_failure_mock: 179 | hapless.run_hap(hap, blocking=True) 180 | 181 | # No forking, called directly in the parent process 182 | fork_mock.assert_not_called() 183 | check_fast_failure_mock.assert_not_called() 184 | wrap_subprocess_mock.assert_called_once_with(hap) 185 | 186 | 187 | def test_run_hap_parent_process_with_check(hapless: Hapless): 188 | """ 189 | Check parent process does not run a subprocess, but calls check. 190 | """ 191 | hap = hapless.create_hap("echo test", name="hap-check") 192 | with patch("os.fork", return_value=12345) as fork_mock, patch.object( 193 | hapless, "_wrap_subprocess" 194 | ) as wrap_subprocess_mock, patch.object( 195 | hapless, "_check_fast_failure" 196 | ) as check_fast_failure_mock: 197 | hapless.run_hap(hap, check=True) 198 | 199 | fork_mock.assert_called_once() 200 | wrap_subprocess_mock.assert_not_called() 201 | check_fast_failure_mock.assert_called_once_with(hap) 202 | 203 | 204 | def test_run_command_invocation(hapless: Hapless): 205 | with patch.object(hapless, "run_hap") as run_hap_mock: 206 | hapless.run_command("echo test") 207 | run_hap_mock.assert_called_once_with(ANY, check=False, blocking=False) 208 | 209 | 210 | def test_run_command_accepts_redirect_stderr_parameter(hapless: Hapless): 211 | hap_mock = Mock() 212 | with patch.object(hapless, "run_hap") as run_hap_mock, patch.object( 213 | hapless, "create_hap", return_value=hap_mock 214 | ) as create_hap_mock: 215 | hapless.run_command("echo redirect", redirect_stderr=True) 216 | create_hap_mock.assert_called_once_with( 217 | cmd="echo redirect", 218 | workdir=None, 219 | hid=None, 220 | name=None, 221 | redirect_stderr=True, 222 | ) 223 | run_hap_mock.assert_called_once_with(hap_mock, check=False, blocking=False) 224 | 225 | 226 | def test_redirect_stderr(hapless: Hapless): 227 | with patch("hapless.config.REDIRECT_STDERR", True): 228 | hap = hapless.create_hap( 229 | "python -c 'import sys; sys.stderr.write(\"redirected stderr\")'", 230 | name="hap-stderr", 231 | ) 232 | assert hap.stderr_path == hap.stdout_path 233 | hapless.run_hap(hap, blocking=True) 234 | assert hap.stdout_path.exists() 235 | assert hap.stdout_path.read_text() == "redirected stderr" 236 | 237 | 238 | def test_redirect_toggling_via_env_value(hapless: Hapless): 239 | with patch("hapless.config.REDIRECT_STDERR", True): 240 | hap1 = hapless.create_hap( 241 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr1\")'", 242 | name="hap1-stderr", 243 | ) 244 | with patch("hapless.config.REDIRECT_STDERR", False): 245 | hap2 = hapless.create_hap( 246 | cmd="python -c 'import sys; sys.stderr.write(\"not redirected stderr2\")'", 247 | name="hap2-stderr", 248 | ) 249 | with patch("hapless.config.REDIRECT_STDERR", True): 250 | hap3 = hapless.create_hap( 251 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr3\")'", 252 | name="hap3-stderr", 253 | ) 254 | 255 | assert hap1.redirect_stderr is True 256 | assert hap2.redirect_stderr is False 257 | assert hap3.redirect_stderr is True 258 | 259 | # Run all three haps 260 | hapless.run_hap(hap1, blocking=True) 261 | hapless.run_hap(hap2, blocking=True) 262 | hapless.run_hap(hap3, blocking=True) 263 | 264 | assert hap1.stdout_path == hap1.stderr_path 265 | assert hap1.stdout_path.exists() 266 | assert hap1.stdout_path.read_text() == "redirected stderr1" 267 | 268 | assert hap2.stdout_path != hap2.stderr_path 269 | assert hap2.stdout_path.exists() 270 | assert hap2.stderr_path.exists() 271 | assert hap2.stdout_path.read_text() == "" 272 | assert hap2.stderr_path.read_text() == "not redirected stderr2" 273 | 274 | assert hap3.stdout_path == hap3.stderr_path 275 | assert hap3.stdout_path.exists() 276 | assert hap3.stdout_path.read_text() == "redirected stderr3" 277 | 278 | 279 | def test_redirect_state_is_not_affected_after_creation(hapless: Hapless): 280 | hap = hapless.create_hap( 281 | cmd="python -c 'import sys; sys.stderr.write(\"redirected stderr\")'", 282 | name="hap-redirect", 283 | redirect_stderr=True, 284 | ) 285 | 286 | with patch("hapless.config.REDIRECT_STDERR", False): 287 | hapless.run_hap(hap, blocking=True) 288 | 289 | assert hap.redirect_stderr is True 290 | assert hap.stdout_path == hap.stderr_path 291 | assert hap.stdout_path.exists() 292 | assert hap.stdout_path.read_text() == "redirected stderr" 293 | 294 | 295 | @pytest.mark.parametrize("redirect_stderr", [True, False]) 296 | def test_restart_preserves_redirect_state(hapless: Hapless, redirect_stderr: bool): 297 | hap = hapless.create_hap( 298 | cmd="doesnotexist", 299 | name="hap-redirect-state", 300 | redirect_stderr=redirect_stderr, 301 | ) 302 | hid = hap.hid 303 | assert hap.redirect_stderr is redirect_stderr 304 | 305 | with patch.object(hapless, "kill") as kill_mock, patch.object( 306 | hapless, "get_hap", return_value=hap 307 | ) as get_hap_mock, patch( 308 | "hapless.main.wait_created", 309 | return_value=True, 310 | ) as wait_created_mock, patch.object(hapless, "run_command") as run_command_mock: 311 | hapless.restart(hap) 312 | 313 | kill_mock.assert_not_called() 314 | get_hap_mock.assert_called_once_with(hid) 315 | wait_created_mock.assert_called_once_with(ANY, timeout=1) 316 | run_command_mock.assert_called_once_with( 317 | cmd="doesnotexist", 318 | workdir=hapless.dir, 319 | hid=hid, 320 | name="hap-redirect-state@1", 321 | redirect_stderr=redirect_stderr, 322 | ) 323 | 324 | 325 | def test_same_handle_can_be_closed_twice(tmp_path): 326 | filepath = tmp_path / "samehandle.log" 327 | filepath.touch() 328 | stdout_handle = filepath.open("w") 329 | stderr_handle = stdout_handle 330 | stdout_handle.close() 331 | stderr_handle.close() 332 | assert stdout_handle.closed 333 | assert stderr_handle.closed 334 | 335 | 336 | def test_spawn_is_used_instead_of_fork(hapless: Hapless): 337 | hap = hapless.create_hap("echo spawn1", name="hap-spawn-1") 338 | with patch("hapless.config.NO_FORK", True), patch.object( 339 | hapless, "_wrap_subprocess" 340 | ) as wrap_subprocess_mock, patch.object( 341 | hapless, "_run_via_fork" 342 | ) as run_fork_mock, patch.object( 343 | hapless, "_run_via_spawn" 344 | ) as run_spawn_mock, patch.object( 345 | hapless, "_check_fast_failure" 346 | ) as check_fast_failure_mock: 347 | hapless.run_hap(hap) 348 | 349 | run_spawn_mock.assert_called_once_with(hap) 350 | wrap_subprocess_mock.assert_not_called() 351 | run_fork_mock.assert_not_called() 352 | check_fast_failure_mock.assert_not_called() 353 | 354 | 355 | def test_wrap_subprocess(hapless: Hapless): 356 | hap = hapless.create_hap(cmd="echo subprocess", name="hap-subprocess") 357 | with patch("subprocess.Popen") as popen_mock, patch.object( 358 | hap, "bind" 359 | ) as bind_mock, patch.object(hap, "set_return_code") as set_return_code_mock: 360 | popen_mock.return_value.pid = 12345 361 | popen_mock.return_value.wait.return_value = 0 362 | hapless._wrap_subprocess(hap) 363 | 364 | bind_mock.assert_called_once_with(12345) 365 | popen_mock.assert_called_once_with( 366 | "echo subprocess", 367 | cwd=hap.workdir, 368 | shell=True, 369 | executable=ANY, 370 | stdout=ANY, 371 | stderr=ANY, 372 | ) 373 | set_return_code_mock.assert_called_once_with(0) 374 | -------------------------------------------------------------------------------- /hapless/main.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | import shutil 4 | import signal 5 | import subprocess 6 | import sys 7 | import tempfile 8 | from pathlib import Path 9 | from typing import Dict, List, Optional, Union 10 | 11 | import psutil 12 | 13 | from hapless import config 14 | from hapless.formatters import Formatter 15 | from hapless.hap import Hap, Status 16 | from hapless.ui import ConsoleUI 17 | from hapless.utils import get_exec_path, kill_proc_tree, logger, wait_created 18 | 19 | 20 | class Hapless: 21 | def __init__( 22 | self, 23 | hapless_dir: Optional[Union[Path, str]] = None, 24 | *, 25 | quiet: bool = False, 26 | ): 27 | self.ui = ConsoleUI(disable=quiet) 28 | user = getpass.getuser() 29 | default_dir = Path(tempfile.gettempdir()) / "hapless" 30 | 31 | hapless_dir = Path(hapless_dir or default_dir) 32 | try: 33 | if not hapless_dir.exists(): 34 | hapless_dir.mkdir(parents=True, exist_ok=True) 35 | os.utime(hapless_dir) 36 | except PermissionError as e: 37 | logger.error(f"Cannot initialize state directory {hapless_dir}: {e}") 38 | self.ui.error( 39 | f"State directory {hapless_dir} is not accessible by user {user}" 40 | ) 41 | sys.exit(1) 42 | 43 | self._hapless_dir = hapless_dir 44 | logger.debug(f"Initialized within {self._hapless_dir} dir") 45 | 46 | def stats(self, haps: List[Hap], formatter: Formatter): 47 | self.ui.stats(haps, formatter=formatter) 48 | 49 | def show(self, hap: Hap, formatter: Formatter): 50 | self.ui.show_one(hap, formatter=formatter) 51 | 52 | @property 53 | def dir(self) -> Path: 54 | return self._hapless_dir 55 | 56 | def _get_hap_dirs(self) -> List[str]: 57 | hap_dirs = filter(str.isdigit, os.listdir(self._hapless_dir)) 58 | return sorted(hap_dirs, key=int) 59 | 60 | def _get_hap_names_map(self) -> Dict[str, str]: 61 | names = {} 62 | for dir in self._get_hap_dirs(): 63 | filename = self._hapless_dir / dir / "name" 64 | if filename.exists(): 65 | with open(filename) as f: 66 | raw_name = f.read().strip() 67 | name = raw_name.split(config.RESTART_DELIM)[0] 68 | names[name] = dir 69 | return names 70 | 71 | def _get_next_hap_id(self) -> str: 72 | dirs = self._get_hap_dirs() 73 | next_num = 1 if not dirs else int(dirs[-1]) + 1 74 | return f"{next_num}" 75 | 76 | def get_hap(self, hap_alias: str) -> Optional[Hap]: 77 | dirs = self._get_hap_dirs() 78 | # Check by hap id 79 | if hap_alias in dirs: 80 | return Hap(self._hapless_dir / hap_alias) 81 | 82 | # Check by hap name 83 | names_map = self._get_hap_names_map() 84 | if hap_alias in names_map: 85 | return Hap(self._hapless_dir / names_map[hap_alias]) 86 | 87 | def _get_all_haps(self) -> List[Hap]: 88 | """ 89 | Get all haps available in the hapless state directory. 90 | Current user might only be able to access subset of them. 91 | """ 92 | haps = [] 93 | if not self._hapless_dir.exists(): 94 | return haps 95 | 96 | for dir in self._get_hap_dirs(): 97 | hap_path = self._hapless_dir / dir 98 | haps.append(Hap(hap_path)) 99 | return haps 100 | 101 | def get_haps(self, accessible_only=True) -> List[Hap]: 102 | """ 103 | Get all haps that are managable by the current user. 104 | If `accessible_only` is set to False, all haps will be returned. 105 | """ 106 | 107 | def filter_haps(hap_arg: Hap) -> bool: 108 | return hap_arg.accessible if accessible_only else True 109 | 110 | haps = list(filter(filter_haps, self._get_all_haps())) 111 | return haps 112 | 113 | def create_hap( 114 | self, 115 | cmd: str, 116 | workdir: Optional[Union[str, Path]] = None, 117 | hid: Optional[str] = None, 118 | name: Optional[str] = None, 119 | *, 120 | redirect_stderr: Optional[bool] = None, 121 | ) -> Hap: 122 | hid = hid or self._get_next_hap_id() 123 | hap_dir = self._hapless_dir / f"{hid}" 124 | hap_dir.mkdir() 125 | if redirect_stderr is None: 126 | redirect_stderr = config.REDIRECT_STDERR 127 | if workdir is None or not Path(workdir).exists(): 128 | workdir = os.getcwd() 129 | return Hap( 130 | hap_dir, 131 | name=name, 132 | cmd=cmd, 133 | workdir=workdir, 134 | redirect_stderr=redirect_stderr, 135 | ) 136 | 137 | def _wrap_subprocess(self, hap: Hap): 138 | try: 139 | stdout_pipe = open(hap.stdout_path, "w") 140 | stderr_pipe = stdout_pipe 141 | if not hap.redirect_stderr: 142 | stderr_pipe = open(hap.stderr_path, "w") 143 | shell_exec = os.getenv("SHELL") 144 | if shell_exec is not None: 145 | logger.debug(f"Using {shell_exec} to run hap") 146 | proc = subprocess.Popen( 147 | hap.cmd, 148 | cwd=hap.workdir, 149 | shell=True, 150 | executable=shell_exec, 151 | stdout=stdout_pipe, 152 | stderr=stderr_pipe, 153 | ) 154 | 155 | pid = proc.pid 156 | logger.debug(f"Attaching hap {hap} to pid {pid}") 157 | hap.bind(pid) 158 | 159 | retcode = proc.wait() 160 | finally: 161 | stdout_pipe.close() 162 | stderr_pipe.close() 163 | 164 | hap.set_return_code(retcode) 165 | 166 | def _check_fast_failure(self, hap: Hap) -> None: 167 | timeout = config.FAILFAST_TIMEOUT 168 | wait_created( 169 | hap._rc_file, 170 | live_context=self.ui.get_live(), 171 | interval=0.5, 172 | timeout=timeout, 173 | ) 174 | return_code: Optional[int] = hap.rc 175 | if return_code is None: 176 | # no return code yet, process is still running 177 | self.ui.print( 178 | f"{config.ICON_INFO} Hap is healthy " 179 | f"and still running after {timeout} seconds", 180 | style=f"{config.COLOR_ACCENT} bold", 181 | ) 182 | elif return_code == 0: 183 | # finished quickly, but successfully 184 | self.ui.print( 185 | f"{config.ICON_INFO} Hap finished successfully " 186 | f"in less than {timeout} seconds", 187 | style=f"{config.COLOR_ACCENT} bold", 188 | ) 189 | else: 190 | # non-zero return code 191 | self.ui.error("Hap exited too quickly. stderr message:") 192 | self.ui.print(hap.stderr_path.read_text()) 193 | sys.exit(1) 194 | 195 | def run_hap( 196 | self, 197 | hap: Hap, 198 | check: bool = False, 199 | *, 200 | blocking: bool = False, 201 | ) -> None: 202 | """ 203 | Run hap in a separate process. 204 | If `check` is True, it will check for fast failure and exit 205 | if hap terminates too quickly. 206 | """ 207 | if blocking: 208 | # NOTE: this is for the testing purposes only 209 | self._wrap_subprocess(hap) 210 | return 211 | 212 | self.ui.print(f"{config.ICON_INFO} Launching", hap) 213 | # TODO: or sys.platform == "win32" 214 | if config.NO_FORK: 215 | logger.debug("Forking is disabled, running using spawn") 216 | self._run_via_spawn(hap) 217 | else: 218 | logger.debug("Running hap using fork") 219 | self._run_via_fork(hap) 220 | 221 | logger.debug(f"Parent process continues with pid {os.getpid()}") 222 | if check: 223 | self._check_fast_failure(hap) 224 | 225 | def _run_via_spawn(self, hap: Hap) -> None: 226 | exec_path = get_exec_path() 227 | proc = subprocess.Popen( 228 | [f"{exec_path}", "__internal_wrap_hap", f"{hap.hid}"], 229 | start_new_session=True, 230 | stdin=subprocess.DEVNULL, 231 | stdout=subprocess.DEVNULL, 232 | stderr=subprocess.DEVNULL, 233 | ) 234 | logger.debug(f"Running subprocess in child with pid {proc.pid}") 235 | logger.debug(f"Using executable at {exec_path}") 236 | 237 | def _run_via_fork(self, hap: Hap) -> None: 238 | pid = os.fork() 239 | if pid == 0: 240 | os.setsid() 241 | logger.debug(f"Running subprocess in child with pid {os.getpid()}") 242 | self._wrap_subprocess(hap) 243 | # NOTE: to prevent deadlocks in multi-threaded environments 244 | # https://docs.python.org/3/library/os.html#os._exit 245 | os._exit(0) 246 | 247 | def run_command( 248 | self, 249 | cmd: str, 250 | workdir: Optional[Union[str, Path]] = None, 251 | hid: Optional[str] = None, 252 | name: Optional[str] = None, 253 | check: bool = False, 254 | *, 255 | redirect_stderr: Optional[bool] = None, 256 | blocking: bool = False, 257 | ) -> None: 258 | """ 259 | For the command provided create a hap and run it. 260 | If `hid` or `name` is not provided, it will be generated automatically. 261 | """ 262 | hap = self.create_hap( 263 | cmd=cmd, 264 | workdir=workdir, 265 | hid=hid, 266 | name=name, 267 | redirect_stderr=redirect_stderr, 268 | ) 269 | self.run_hap(hap, check=check, blocking=blocking) 270 | 271 | def pause_hap(self, hap: Hap): 272 | proc = hap.proc 273 | if proc is not None: 274 | proc.suspend() 275 | self.ui.print(f"{config.ICON_INFO} Paused", hap) 276 | else: 277 | self.ui.error(f"Cannot pause. Hap {hap} is not running") 278 | sys.exit(1) 279 | 280 | def resume_hap(self, hap: Hap): 281 | proc = hap.proc 282 | if proc is not None and proc.status() == psutil.STATUS_STOPPED: 283 | proc.resume() 284 | self.ui.print(f"{config.ICON_INFO} Resumed", hap) 285 | else: 286 | self.ui.error(f"Cannot resume. Hap {hap} is not suspended") 287 | sys.exit(1) 288 | 289 | def logs(self, hap: Hap, stderr: bool = False, follow: bool = False): 290 | filepath = hap.stderr_path if stderr else hap.stdout_path 291 | if follow: 292 | self.ui.print( 293 | f"{config.ICON_INFO} Streaming {filepath} file...", 294 | style=f"{config.COLOR_MAIN} bold", 295 | ) 296 | return subprocess.run(["tail", "-f", filepath]) 297 | 298 | text = filepath.read_text() 299 | if not text: 300 | self.ui.error("No logs found") 301 | return 302 | 303 | self.ui.print( 304 | f"{config.ICON_INFO} Showing logs at {filepath}", 305 | style=f"{config.COLOR_MAIN} bold", 306 | ) 307 | self.ui.print_plain(text) 308 | 309 | def _clean_haps(self, filter_haps) -> int: 310 | haps = list(filter(filter_haps, self.get_haps())) 311 | for hap in haps: 312 | logger.debug(f"Removing {hap.path}") 313 | shutil.rmtree(hap.path, ignore_errors=True) 314 | return len(haps) 315 | 316 | def _clean_one(self, hap: Hap): 317 | def to_clean(hap_arg: Hap) -> bool: 318 | return hap_arg.hid == hap.hid 319 | 320 | haps_count = self._clean_haps(filter_haps=to_clean) 321 | logger.debug(f"Deleted {haps_count} haps") 322 | 323 | def clean(self, clean_all: bool = False): 324 | def to_clean(hap: Hap) -> bool: 325 | return hap.status == Status.SUCCESS or ( 326 | hap.status == Status.FAILED and clean_all 327 | ) 328 | 329 | haps_count = self._clean_haps(filter_haps=to_clean) 330 | 331 | if haps_count: 332 | self.ui.print( 333 | f"{config.ICON_INFO} Deleted {haps_count} finished haps", 334 | style=f"{config.COLOR_MAIN} bold", 335 | ) 336 | else: 337 | self.ui.error("Nothing to clean") 338 | 339 | def kill(self, haps: List[Hap], verbose: bool = True) -> int: 340 | killed_counter = 0 341 | for hap in haps: 342 | if hap.active: 343 | logger.info(f"Killing {hap}...") 344 | kill_proc_tree(hap.pid) 345 | killed_counter += 1 346 | 347 | if killed_counter and verbose: 348 | self.ui.print( 349 | f"{config.ICON_KILLED} Killed {killed_counter} active haps", 350 | style=f"{config.COLOR_MAIN} bold", 351 | ) 352 | elif verbose: 353 | self.ui.error("No active haps to kill") 354 | return killed_counter 355 | 356 | def signal(self, hap: Hap, sig: signal.Signals): 357 | if hap.active: 358 | sig_text = ( 359 | f"[bold]{sig.name}[/] ([{config.COLOR_MAIN}]{signal.strsignal(sig)}[/])" 360 | ) 361 | self.ui.print(f"{config.ICON_INFO} Sending {sig_text} to hap {hap}") 362 | hap.proc.send_signal(sig) 363 | else: 364 | self.ui.error("Cannot send signal to the inactive hap") 365 | 366 | def restart(self, hap: Hap) -> None: 367 | hid, name, cmd, workdir, restarts, redirect_stderr = ( 368 | hap.hid, 369 | hap.name, 370 | hap.cmd, 371 | hap.workdir, 372 | hap.restarts, 373 | hap.redirect_stderr, 374 | ) 375 | if hap.active: 376 | self.kill([hap], verbose=False) 377 | 378 | hap_killed = self.get_hap(hid) 379 | while hap_killed.active: 380 | # NOTE: re-read is required as `proc` is a cached property 381 | hap_killed = self.get_hap(hid) 382 | 383 | rc_exists = wait_created(hap_killed._rc_file, timeout=1) 384 | if not rc_exists: 385 | logger.error( 386 | f"Hap {hap_killed} process was killed, " 387 | f"but parent did not write return code" 388 | ) 389 | 390 | self._clean_one(hap_killed) 391 | 392 | name = f"{name}{config.RESTART_DELIM}{restarts + 1}" 393 | self.run_command( 394 | cmd=cmd, 395 | workdir=workdir, 396 | hid=hid, 397 | name=name, 398 | redirect_stderr=redirect_stderr, 399 | ) 400 | 401 | def rename_hap(self, hap: Hap, new_name: str): 402 | rich_text = ( 403 | f"{config.ICON_INFO} Renamed [{config.COLOR_ACCENT}]{hap.name}[/] " 404 | f"to [{config.COLOR_MAIN} bold]{new_name}[/]" 405 | ) 406 | if hap.restarts: 407 | new_name = f"{new_name}{config.RESTART_DELIM}{hap.restarts}" 408 | hap.set_name(new_name) 409 | self.ui.print(rich_text) 410 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "argcomplete" 5 | version = "3.6.2" 6 | description = "Bash tab completion for argparse" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"}, 11 | {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"}, 12 | ] 13 | 14 | [package.extras] 15 | test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] 16 | 17 | [[package]] 18 | name = "click" 19 | version = "8.1.8" 20 | description = "Composable command line interface toolkit" 21 | optional = false 22 | python-versions = ">=3.7" 23 | files = [ 24 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 25 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 26 | ] 27 | 28 | [package.dependencies] 29 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 30 | 31 | [[package]] 32 | name = "colorama" 33 | version = "0.4.6" 34 | description = "Cross-platform colored terminal text." 35 | optional = false 36 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 37 | files = [ 38 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 39 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 40 | ] 41 | 42 | [[package]] 43 | name = "colorlog" 44 | version = "6.9.0" 45 | description = "Add colours to the output of Python's logging module." 46 | optional = false 47 | python-versions = ">=3.6" 48 | files = [ 49 | {file = "colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff"}, 50 | {file = "colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2"}, 51 | ] 52 | 53 | [package.dependencies] 54 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 55 | 56 | [package.extras] 57 | development = ["black", "flake8", "mypy", "pytest", "types-colorama"] 58 | 59 | [[package]] 60 | name = "coverage" 61 | version = "7.6.1" 62 | description = "Code coverage measurement for Python" 63 | optional = false 64 | python-versions = ">=3.8" 65 | files = [ 66 | {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, 67 | {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, 68 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, 69 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, 70 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, 71 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, 72 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, 73 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, 74 | {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, 75 | {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, 76 | {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, 77 | {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, 78 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, 79 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, 80 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, 81 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, 82 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, 83 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, 84 | {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, 85 | {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, 86 | {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, 87 | {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, 88 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, 89 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, 90 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, 91 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, 92 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, 93 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, 94 | {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, 95 | {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, 96 | {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, 97 | {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, 98 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, 99 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, 100 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, 101 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, 102 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, 103 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, 104 | {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, 105 | {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, 106 | {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, 107 | {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, 108 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, 109 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, 110 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, 111 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, 112 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, 113 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, 114 | {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, 115 | {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, 116 | {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, 117 | {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, 118 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, 119 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, 120 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, 121 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, 122 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, 123 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, 124 | {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, 125 | {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, 126 | {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, 127 | {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, 128 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, 129 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, 130 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, 131 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, 132 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, 133 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, 134 | {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, 135 | {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, 136 | {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, 137 | {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, 138 | ] 139 | 140 | [package.dependencies] 141 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 142 | 143 | [package.extras] 144 | toml = ["tomli"] 145 | 146 | [[package]] 147 | name = "distlib" 148 | version = "0.4.0" 149 | description = "Distribution utilities" 150 | optional = false 151 | python-versions = "*" 152 | files = [ 153 | {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, 154 | {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, 155 | ] 156 | 157 | [[package]] 158 | name = "django-environ" 159 | version = "0.11.2" 160 | description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." 161 | optional = false 162 | python-versions = ">=3.6,<4" 163 | files = [ 164 | {file = "django-environ-0.11.2.tar.gz", hash = "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be"}, 165 | {file = "django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05"}, 166 | ] 167 | 168 | [package.extras] 169 | develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] 170 | docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] 171 | testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] 172 | 173 | [[package]] 174 | name = "django-environ" 175 | version = "0.12.0" 176 | description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." 177 | optional = false 178 | python-versions = "<4,>=3.9" 179 | files = [ 180 | {file = "django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca"}, 181 | {file = "django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a"}, 182 | ] 183 | 184 | [package.extras] 185 | develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)", "sphinx (>=5.0)", "sphinx-notfound-page"] 186 | docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-notfound-page"] 187 | testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"] 188 | 189 | [[package]] 190 | name = "exceptiongroup" 191 | version = "1.2.2" 192 | description = "Backport of PEP 654 (exception groups)" 193 | optional = false 194 | python-versions = ">=3.7" 195 | files = [ 196 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 197 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 198 | ] 199 | 200 | [package.extras] 201 | test = ["pytest (>=6)"] 202 | 203 | [[package]] 204 | name = "filelock" 205 | version = "3.16.1" 206 | description = "A platform independent file lock." 207 | optional = false 208 | python-versions = ">=3.8" 209 | files = [ 210 | {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, 211 | {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, 212 | ] 213 | 214 | [package.extras] 215 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] 216 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] 217 | typing = ["typing-extensions (>=4.12.2)"] 218 | 219 | [[package]] 220 | name = "humanize" 221 | version = "4.10.0" 222 | description = "Python humanize utilities" 223 | optional = false 224 | python-versions = ">=3.8" 225 | files = [ 226 | {file = "humanize-4.10.0-py3-none-any.whl", hash = "sha256:39e7ccb96923e732b5c2e27aeaa3b10a8dfeeba3eb965ba7b74a3eb0e30040a6"}, 227 | {file = "humanize-4.10.0.tar.gz", hash = "sha256:06b6eb0293e4b85e8d385397c5868926820db32b9b654b932f57fa41c23c9978"}, 228 | ] 229 | 230 | [package.extras] 231 | tests = ["freezegun", "pytest", "pytest-cov"] 232 | 233 | [[package]] 234 | name = "iniconfig" 235 | version = "2.1.0" 236 | description = "brain-dead simple config-ini parsing" 237 | optional = false 238 | python-versions = ">=3.8" 239 | files = [ 240 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 241 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 242 | ] 243 | 244 | [[package]] 245 | name = "markdown-it-py" 246 | version = "3.0.0" 247 | description = "Python port of markdown-it. Markdown parsing, done right!" 248 | optional = false 249 | python-versions = ">=3.8" 250 | files = [ 251 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 252 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 253 | ] 254 | 255 | [package.dependencies] 256 | mdurl = ">=0.1,<1.0" 257 | 258 | [package.extras] 259 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 260 | code-style = ["pre-commit (>=3.0,<4.0)"] 261 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 262 | linkify = ["linkify-it-py (>=1,<3)"] 263 | plugins = ["mdit-py-plugins"] 264 | profiling = ["gprof2dot"] 265 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 266 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 267 | 268 | [[package]] 269 | name = "mdurl" 270 | version = "0.1.2" 271 | description = "Markdown URL utilities" 272 | optional = false 273 | python-versions = ">=3.7" 274 | files = [ 275 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 276 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 277 | ] 278 | 279 | [[package]] 280 | name = "nox" 281 | version = "2024.10.9" 282 | description = "Flexible test automation." 283 | optional = false 284 | python-versions = ">=3.8" 285 | files = [ 286 | {file = "nox-2024.10.9-py3-none-any.whl", hash = "sha256:1d36f309a0a2a853e9bccb76bbef6bb118ba92fa92674d15604ca99adeb29eab"}, 287 | {file = "nox-2024.10.9.tar.gz", hash = "sha256:7aa9dc8d1c27e9f45ab046ffd1c3b2c4f7c91755304769df231308849ebded95"}, 288 | ] 289 | 290 | [package.dependencies] 291 | argcomplete = ">=1.9.4,<4" 292 | colorlog = ">=2.6.1,<7" 293 | packaging = ">=20.9" 294 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 295 | virtualenv = ">=20.14.1" 296 | 297 | [package.extras] 298 | tox-to-nox = ["jinja2", "tox"] 299 | uv = ["uv (>=0.1.6)"] 300 | 301 | [[package]] 302 | name = "packaging" 303 | version = "25.0" 304 | description = "Core utilities for Python packages" 305 | optional = false 306 | python-versions = ">=3.8" 307 | files = [ 308 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 309 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 310 | ] 311 | 312 | [[package]] 313 | name = "platformdirs" 314 | version = "4.3.6" 315 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 316 | optional = false 317 | python-versions = ">=3.8" 318 | files = [ 319 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 320 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 321 | ] 322 | 323 | [package.extras] 324 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 325 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 326 | type = ["mypy (>=1.11.2)"] 327 | 328 | [[package]] 329 | name = "pluggy" 330 | version = "1.5.0" 331 | description = "plugin and hook calling mechanisms for python" 332 | optional = false 333 | python-versions = ">=3.8" 334 | files = [ 335 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 336 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 337 | ] 338 | 339 | [package.extras] 340 | dev = ["pre-commit", "tox"] 341 | testing = ["pytest", "pytest-benchmark"] 342 | 343 | [[package]] 344 | name = "psutil" 345 | version = "6.1.1" 346 | description = "Cross-platform lib for process and system monitoring in Python." 347 | optional = false 348 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 349 | files = [ 350 | {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, 351 | {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, 352 | {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, 353 | {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, 354 | {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, 355 | {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, 356 | {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, 357 | {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, 358 | {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, 359 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, 360 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, 361 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, 362 | {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, 363 | {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, 364 | {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, 365 | {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, 366 | {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, 367 | ] 368 | 369 | [package.extras] 370 | dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] 371 | test = ["pytest", "pytest-xdist", "setuptools"] 372 | 373 | [[package]] 374 | name = "pygments" 375 | version = "2.19.2" 376 | description = "Pygments is a syntax highlighting package written in Python." 377 | optional = false 378 | python-versions = ">=3.8" 379 | files = [ 380 | {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, 381 | {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, 382 | ] 383 | 384 | [package.extras] 385 | windows-terminal = ["colorama (>=0.4.6)"] 386 | 387 | [[package]] 388 | name = "pytest" 389 | version = "7.4.4" 390 | description = "pytest: simple powerful testing with Python" 391 | optional = false 392 | python-versions = ">=3.7" 393 | files = [ 394 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 395 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 396 | ] 397 | 398 | [package.dependencies] 399 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 400 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 401 | iniconfig = "*" 402 | packaging = "*" 403 | pluggy = ">=0.12,<2.0" 404 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 405 | 406 | [package.extras] 407 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 408 | 409 | [[package]] 410 | name = "pytest-cov" 411 | version = "3.0.0" 412 | description = "Pytest plugin for measuring coverage." 413 | optional = false 414 | python-versions = ">=3.6" 415 | files = [ 416 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 417 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 418 | ] 419 | 420 | [package.dependencies] 421 | coverage = {version = ">=5.2.1", extras = ["toml"]} 422 | pytest = ">=4.6" 423 | 424 | [package.extras] 425 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 426 | 427 | [[package]] 428 | name = "pytest-env" 429 | version = "1.1.3" 430 | description = "pytest plugin that allows you to add environment variables." 431 | optional = true 432 | python-versions = ">=3.8" 433 | files = [ 434 | {file = "pytest_env-1.1.3-py3-none-any.whl", hash = "sha256:aada77e6d09fcfb04540a6e462c58533c37df35fa853da78707b17ec04d17dfc"}, 435 | {file = "pytest_env-1.1.3.tar.gz", hash = "sha256:fcd7dc23bb71efd3d35632bde1bbe5ee8c8dc4489d6617fb010674880d96216b"}, 436 | ] 437 | 438 | [package.dependencies] 439 | pytest = ">=7.4.3" 440 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 441 | 442 | [package.extras] 443 | test = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "pytest-mock (>=3.12)"] 444 | 445 | [[package]] 446 | name = "rich" 447 | version = "13.9.4" 448 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 449 | optional = false 450 | python-versions = ">=3.8.0" 451 | files = [ 452 | {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, 453 | {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, 454 | ] 455 | 456 | [package.dependencies] 457 | markdown-it-py = ">=2.2.0" 458 | pygments = ">=2.13.0,<3.0.0" 459 | typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} 460 | 461 | [package.extras] 462 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 463 | 464 | [[package]] 465 | name = "ruff" 466 | version = "0.9.10" 467 | description = "An extremely fast Python linter and code formatter, written in Rust." 468 | optional = false 469 | python-versions = ">=3.7" 470 | files = [ 471 | {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, 472 | {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, 473 | {file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"}, 474 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"}, 475 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"}, 476 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"}, 477 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"}, 478 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"}, 479 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"}, 480 | {file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"}, 481 | {file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"}, 482 | {file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"}, 483 | {file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"}, 484 | {file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"}, 485 | {file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"}, 486 | {file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"}, 487 | {file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"}, 488 | {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, 489 | ] 490 | 491 | [[package]] 492 | name = "structlog" 493 | version = "25.4.0" 494 | description = "Structured Logging for Python" 495 | optional = false 496 | python-versions = ">=3.8" 497 | files = [ 498 | {file = "structlog-25.4.0-py3-none-any.whl", hash = "sha256:fe809ff5c27e557d14e613f45ca441aabda051d119ee5a0102aaba6ce40eed2c"}, 499 | {file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"}, 500 | ] 501 | 502 | [package.dependencies] 503 | typing-extensions = {version = "*", markers = "python_version < \"3.11\""} 504 | 505 | [[package]] 506 | name = "tomli" 507 | version = "2.2.1" 508 | description = "A lil' TOML parser" 509 | optional = false 510 | python-versions = ">=3.8" 511 | files = [ 512 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 513 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 514 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 515 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 516 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 517 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 518 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 519 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 520 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 521 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 522 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 523 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 524 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 525 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 526 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 527 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 528 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 529 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 530 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 531 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 532 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 533 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 534 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 535 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 536 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 537 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 538 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 539 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 540 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 541 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 542 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 543 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 544 | ] 545 | 546 | [[package]] 547 | name = "ty" 548 | version = "0.0.1a21" 549 | description = "An extremely fast Python type checker, written in Rust." 550 | optional = true 551 | python-versions = ">=3.8" 552 | files = [ 553 | {file = "ty-0.0.1a21-py3-none-linux_armv6l.whl", hash = "sha256:1f276ceab23a1410aec09508248c76ae0989c67fb7a0c287e0d4564994295531"}, 554 | {file = "ty-0.0.1a21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c3bc66fcae41eff133cfe326dd65d82567a2fb5d4efe2128773b10ec2766819"}, 555 | {file = "ty-0.0.1a21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cc0880ec344fbdf736b05d8d0da01f0caaaa02409bd9a24b68d18d0127a79b0e"}, 556 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:334d2a212ebf42a0e55d57561926af7679fe1e878175e11dcb81ad8df892844e"}, 557 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8c769987d00fbc33054ff7e342633f475ea10dc43bc60fb9fb056159d48cb90"}, 558 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:218d53e7919e885bd98e9196d9cb952d82178b299aa36da6f7f39333eb7400ed"}, 559 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:84243455f295ed850bd53f7089819321807d4e6ee3b1cbff6086137ae0259466"}, 560 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87a200c21e02962e8a27374d9d152582331d57d709672431be58f4f898bf6cad"}, 561 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be8f457d7841b7ead2a3f6b65ba668abc172a1150a0f1f6c0958af3725dbb61a"}, 562 | {file = "ty-0.0.1a21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1474d883129bb63da3b2380fc7ead824cd3baf6a9551e6aa476ffefc58057af3"}, 563 | {file = "ty-0.0.1a21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0efba2e52b58f536f4198ba5c4a36cac2ba67d83ec6f429ebc7704233bcda4c3"}, 564 | {file = "ty-0.0.1a21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5dfc73299d441cc6454e36ed0a976877415024143dfca6592dc36f7701424383"}, 565 | {file = "ty-0.0.1a21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba13d03b9e095216ceb4e4d554a308517f28ab0a6e4dcd07cfe94563e4c2c489"}, 566 | {file = "ty-0.0.1a21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9463cac96b8f1bb5ba740fe1d42cd6bd152b43c5b159b2f07f8fd629bcdded34"}, 567 | {file = "ty-0.0.1a21-py3-none-win32.whl", hash = "sha256:ecf41706b803827b0de8717f32a434dad1e67be9f4b8caf403e12013179ea06a"}, 568 | {file = "ty-0.0.1a21-py3-none-win_amd64.whl", hash = "sha256:7505aeb8bf2a62f00f12cfa496f6c965074d75c8126268776565284c8a12d5dd"}, 569 | {file = "ty-0.0.1a21-py3-none-win_arm64.whl", hash = "sha256:21f708d02b6588323ffdbfdba38830dd0ecfd626db50aa6006b296b5470e52f9"}, 570 | {file = "ty-0.0.1a21.tar.gz", hash = "sha256:e941e9a9d1e54b03eeaf9c3197c26a19cf76009fd5e41e16e5657c1c827bd6d3"}, 571 | ] 572 | 573 | [[package]] 574 | name = "typing-extensions" 575 | version = "4.0.0" 576 | description = "Backported and Experimental Type Hints for Python 3.6+" 577 | optional = false 578 | python-versions = ">=3.6" 579 | files = [ 580 | {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, 581 | {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, 582 | ] 583 | 584 | [[package]] 585 | name = "virtualenv" 586 | version = "20.33.1" 587 | description = "Virtual Python Environment builder" 588 | optional = false 589 | python-versions = ">=3.8" 590 | files = [ 591 | {file = "virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67"}, 592 | {file = "virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8"}, 593 | ] 594 | 595 | [package.dependencies] 596 | distlib = ">=0.3.7,<1" 597 | filelock = ">=3.12.2,<4" 598 | platformdirs = ">=3.9.1,<5" 599 | 600 | [package.extras] 601 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 602 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 603 | 604 | [extras] 605 | dev = ["nox", "pytest", "pytest-cov", "pytest-env", "ruff", "ty"] 606 | 607 | [metadata] 608 | lock-version = "2.0" 609 | python-versions = "^3.8" 610 | content-hash = "fdac269193e39067486405e6c25359a613ece9d22638a06b60154b6f60c0529b" 611 | --------------------------------------------------------------------------------