From 9941ca048a7d7426feef43cd811fee5447bd254a Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Wed, 1 Oct 2025 17:15:41 +0200 Subject: [PATCH 1/6] Fallback to PATH when discovering hap executable --- hapless/main.py | 4 ++-- hapless/utils.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hapless/main.py b/hapless/main.py index 9cf32a2..8cd748b 100644 --- a/hapless/main.py +++ b/hapless/main.py @@ -14,7 +14,7 @@ from hapless.formatters import Formatter from hapless.hap import Hap, Status from hapless.ui import ConsoleUI -from hapless.utils import kill_proc_tree, logger, wait_created +from hapless.utils import get_exec_path, kill_proc_tree, logger, wait_created class Hapless: @@ -223,7 +223,7 @@ def run_hap( self._check_fast_failure(hap) def _run_via_spawn(self, hap: Hap) -> None: - exec_path = Path(sys.argv[0]) + exec_path = get_exec_path() proc = subprocess.Popen( [f"{exec_path}", "__internal_wrap_hap", f"{hap.hid}"], start_new_session=True, diff --git a/hapless/utils.py b/hapless/utils.py index 724b645..76ad594 100644 --- a/hapless/utils.py +++ b/hapless/utils.py @@ -1,5 +1,6 @@ import logging import os +import shutil import signal import sys import time @@ -122,6 +123,19 @@ def isatty() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() +def get_exec_path() -> Path: + if str(sys.argv[0]).endswith("hap"): + exec_path = sys.argv[0] + else: + logger.warning("Unusual invocation, checking `hap` in PATH") + exec_path = shutil.which("hap") + + if exec_path is None: + raise RuntimeError("Cannot find `hap` executable, please reinstall hapless") + + return Path(exec_path) + + def configure_logger() -> logging.Logger: logger = structlog.get_logger() level = logging.DEBUG if config.DEBUG else logging.CRITICAL From bbeec0b2c5a897c5a0e9fc5eb4df9b02651e9e29 Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Thu, 2 Oct 2025 09:22:49 +0200 Subject: [PATCH 2/6] Add tail_lines util function --- hapless/cli.py | 4 ++-- hapless/utils.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/hapless/cli.py b/hapless/cli.py index c98caaf..e6a0a44 100644 --- a/hapless/cli.py +++ b/hapless/cli.py @@ -99,7 +99,7 @@ def errors(hap_alias: str, follow: bool): default=False, help="Include failed haps for the removal.", ) -def clean(clean_all): +def clean(clean_all: bool): hapless.clean(clean_all=clean_all) @@ -124,7 +124,7 @@ def cleanall(): default=False, help="Verify command launched does not fail immediately.", ) -def run(cmd, name, check): +def run(cmd: tuple[str, ...], name: str, check: bool): hap = hapless.get_hap(name) if hap is not None: console.error(f"Hap with such name already exists: {hap}") diff --git a/hapless/utils.py b/hapless/utils.py index 76ad594..855deb6 100644 --- a/hapless/utils.py +++ b/hapless/utils.py @@ -4,6 +4,7 @@ import signal import sys import time +from collections import deque from contextlib import nullcontext from functools import wraps from pathlib import Path @@ -136,6 +137,11 @@ def get_exec_path() -> Path: return Path(exec_path) +def tail_lines(filepath: Path, n: int = 20): + with open(filepath) as f: + return deque(f, n) + + def configure_logger() -> logging.Logger: logger = structlog.get_logger() level = logging.DEBUG if config.DEBUG else logging.CRITICAL From 009904976f3c6c95dd57caf71e9251182e6f4db8 Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Thu, 2 Oct 2025 09:28:20 +0200 Subject: [PATCH 3/6] Fix typechecks for tests directory --- .github/workflows/tests.yml | 1 - pyproject.toml | 4 +--- tests/conftest.py | 2 +- tests/test_hap_workdir.py | 1 + 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0963559..ddbf808 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,7 +54,6 @@ jobs: run: | poetry run ty --version poetry run ty check . - poetry run ty check tests/ || true - name: Run unittests run: | diff --git a/pyproject.toml b/pyproject.toml index fa04a49..66db9a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,7 @@ extend-select = [ ] [tool.ty.src] -exclude = [ - "tests/", -] +exclude = [] [tool.pytest.ini_options] env = [ diff --git a/tests/conftest.py b/tests/conftest.py index 20ab04f..c677cd6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,7 @@ def runner() -> Generator[CliRunner, None, None]: with cli_runner.isolated_filesystem() as path: hapless_test = Hapless(hapless_dir=Path(path)) with patch("hapless.cli.hapless", hapless_test) as hapless_mock: - cli_runner.hapless = hapless_mock + cli_runner.hapless = hapless_mock # ty: ignore[unresolved-attribute] yield cli_runner diff --git a/tests/test_hap_workdir.py b/tests/test_hap_workdir.py index b627ca4..6a9f4f5 100644 --- a/tests/test_hap_workdir.py +++ b/tests/test_hap_workdir.py @@ -43,6 +43,7 @@ def blocking_run(*args, **kwargs): ) restarted_hap = hapless.get_hap("hap-same-name") + assert restarted_hap is not None assert restarted_hap.rc == 0 assert restarted_hap.stdout_path.exists() assert "Correct file is being run" in restarted_hap.stdout_path.read_text() From 4a7e389dbe5c4fcbbaea4a8651b112edf4b5c25c Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Thu, 2 Oct 2025 09:38:54 +0200 Subject: [PATCH 4/6] Add ty badge --- DEVELOP.md | 7 +++++++ README.md | 4 ++-- hapless/cli.py | 4 ++-- pyproject.toml | 5 ++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/DEVELOP.md b/DEVELOP.md index 118a3ad..073c566 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -14,6 +14,13 @@ poetry run pytest -sv tests make tests ``` +Lint the code and run type checking + +```bash +make lint +make ty +``` + Install git hooks for the automatic linting and code formatting with [pre-commit](https://pre-commit.com/) ```bash diff --git a/README.md b/README.md index c3d8cd8..f58bc1e 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hapless) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![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) +[![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) [![EditorConfig](https://img.shields.io/badge/-EditorConfig-grey?logo=editorconfig)](https://editorconfig.org/) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) diff --git a/hapless/cli.py b/hapless/cli.py index e6a0a44..edff9e1 100644 --- a/hapless/cli.py +++ b/hapless/cli.py @@ -1,6 +1,6 @@ import sys from shlex import join as shlex_join -from typing import Optional +from typing import Optional, Tuple import click @@ -124,7 +124,7 @@ def cleanall(): default=False, help="Verify command launched does not fail immediately.", ) -def run(cmd: tuple[str, ...], name: str, check: bool): +def run(cmd: Tuple[str, ...], name: str, check: bool): hap = hapless.get_hap(name) if hap is not None: console.error(f"Hap with such name already exists: {hap}") diff --git a/pyproject.toml b/pyproject.toml index 66db9a5..b4a85f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,11 +42,10 @@ structlog = "^25.4.0" # Optional dependencies installed as extras pytest = { version = "^7.4.4", optional = true } pytest-cov = { version = "^3.0.0", optional = true } -ruff = { version = "^0.9.0", optional = true } -nox = { version = "^2024.10.9", optional = true } pytest-env = { version = "^1.1.2", optional = true } +ruff = { version = "^0.9.0", optional = true } ty = { version = "^0.0.1a21", optional = true } - +nox = { version = "^2024.10.9", optional = true } [tool.poetry.extras] dev = [ From d781eed3cc455f2248c95c727ec20c8a49e7e341 Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Thu, 2 Oct 2025 09:49:27 +0200 Subject: [PATCH 5/6] Add tests module for cli utils testing --- tests/test_cli_utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_cli_utils.py diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py new file mode 100644 index 0000000..a0cb60d --- /dev/null +++ b/tests/test_cli_utils.py @@ -0,0 +1,18 @@ +from unittest.mock import patch + +import pytest + +from hapless.cli_utils import get_or_exit + + +@patch("hapless.cli_utils.hapless") +def test_get_or_exit_non_existing(hapless_mock, capsys): + with patch.object(hapless_mock, "get_hap", return_value=None) as get_hap_mock: + with pytest.raises(SystemExit) as e: + get_or_exit("hap-me") + + assert e.value.code == 1 + get_hap_mock.assert_called_once_with("hap-me") + + captured = capsys.readouterr() + assert "No such hap: hap-me" in captured.out From b8c0236ccd90ee0f6d68c1ae41a22aed6cf90aa9 Mon Sep 17 00:00:00 2001 From: Misha Behersky Date: Thu, 2 Oct 2025 09:57:17 +0200 Subject: [PATCH 6/6] Add tests for get_or_exit function --- tests/test_cli_utils.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py index a0cb60d..e826dd8 100644 --- a/tests/test_cli_utils.py +++ b/tests/test_cli_utils.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import Mock, PropertyMock, patch import pytest @@ -16,3 +16,23 @@ def test_get_or_exit_non_existing(hapless_mock, capsys): captured = capsys.readouterr() assert "No such hap: hap-me" in captured.out + + +@patch("hapless.cli_utils.hapless") +def test_get_or_exit_not_accessible(hapless_mock, capsys): + hap_mock = Mock(owner="someone-else") + prop_mock = PropertyMock(return_value=False) + type(hap_mock).accessible = prop_mock + with patch.object(hapless_mock, "get_hap", return_value=hap_mock) as get_hap_mock: + with pytest.raises(SystemExit) as e: + get_or_exit("hap-not-mine") + + assert e.value.code == 1 + get_hap_mock.assert_called_once_with("hap-not-mine") + prop_mock.assert_called_once_with() + + captured = capsys.readouterr() + assert ( + "Cannot manage hap launched by another user. Owner: someone-else" + in captured.out + )