Skip to content

Commit

Permalink
Merge pull request #17 from olzhasar/config
Browse files Browse the repository at this point in the history
Configuring via pyproject.toml
  • Loading branch information
olzhasar authored Jun 11, 2023
2 parents 46f861c + eb96828 commit 7ed8fd4
Show file tree
Hide file tree
Showing 12 changed files with 342 additions and 68 deletions.
11 changes: 5 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
jobs:
test:
uses: ./.github/workflows/test.yml
build:
pypi:
needs: test
runs-on: ubuntu-latest
steps:
Expand All @@ -15,11 +15,10 @@ jobs:
uses: JRubics/poetry-publish@v1.17
with:
pypi_token: ${{ secrets.PYPI_TOKEN }}
tagged-release:
github-release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: marvinpinto/action-automatic-releases@v1.2.1
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false
- uses: docker://antonyurchenko/git-release:v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ cython_debug/

# vale
vale_styles

# tmp dir
tests/tmp
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [0.3.3] - 2023-06-11

### Features

- Configuring `pytest-watcher` via `pyproject.toml` file

## [0.3.2] - 2023-06-08

### Features
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Example:
- [Using a different test runner](#using-a-different-test-runner)
- [Watching different patterns](#watching-different-patterns)
- [Delay](#delay)
- [Configuring](#configuring)
- [Differences with pytest-watch](#differences-with-pytest-watch)
- [Compatibility](#compatibility)
- [License](#license)
Expand Down Expand Up @@ -134,6 +135,20 @@ ptw .

- `pytest-watch` doesn't start tests immediately by default. You can customize this behavior using `--now` flag.

## Configuring

You can configure `pytest-watcher` via `pyproject.toml` file. Here is the default configuration:

```toml
[tool.pytest-watcher]
now = false
delay = 0.2
runner = "pytest"
runner_args = []
patterns = ["*.py"]
ignore_patterns = []
```

## Compatibility

The code is compatible with Python versions 3.7+
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pytest-watcher"
version = "0.3.2"
version = "0.3.3"
description = "Automatically rerun your tests on file modifications"
authors = ["Olzhas Arystanov <o.arystanov@gmail.com>"]
license = "MIT"
Expand Down
90 changes: 90 additions & 0 deletions pytest_watcher/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from argparse import Namespace
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Mapping, Optional

from .constants import DEFAULT_DELAY

try:
import tomlib
except ImportError:
from pip._vendor import tomli as tomlib


CONFIG_SECTION_NAME = "pytest-watcher"
CLI_FIELDS = {"now", "delay", "runner", "patterns", "ignore_patterns"}
CONFIG_FIELDS = CLI_FIELDS | {"runner_args"}


@dataclass
class Config:
path: Path
now: bool = False
delay: float = DEFAULT_DELAY
runner: str = "pytest"
runner_args: List[str] = field(default_factory=list)
patterns: List[str] = field(default_factory=list)
ignore_patterns: List[str] = field(default_factory=list)

@classmethod
def create(
cls, namespace: Namespace, extra_args: Optional[List[str]] = None
) -> "Config":
instance = cls(path=namespace.path)

config_path = find_config(namespace.path)
if config_path:
parsed = parse_config(config_path)
instance._update_from_mapping(parsed)

instance._update_from_namespace(namespace, extra_args or [])
return instance

def _update_from_mapping(self, data: Mapping):
for key, val in data.items():
setattr(self, key, val)

def _update_from_namespace(
self, namespace: Namespace, runner_args: Optional[List[str]]
):
self.path = namespace.path

for f in CLI_FIELDS:
val = getattr(namespace, f)
if val:
setattr(self, f, val)

if runner_args:
self.runner_args = runner_args


def find_config(cwd: Path) -> Optional[Path]:
filename = "pyproject.toml"

for path in (cwd, *cwd.parents):
config_path = path.joinpath(filename)

if config_path.exists():
return config_path

return None


def parse_config(path: Path) -> Mapping:
with open(path, "rb") as f:
try:
data = tomlib.load(f)
except Exception as exc:
raise SystemExit(f"Error parsing pyproject.toml\n{exc}")

try:
data = data["tool"][CONFIG_SECTION_NAME]
except KeyError:
return {}

for key in data.keys():
if key not in CONFIG_FIELDS:
raise SystemExit(
f"Error parsing pyproject.toml.\nUnrecognized option: {key}"
)
return data
4 changes: 4 additions & 0 deletions pytest_watcher/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VERSION = "0.3.3"

DEFAULT_DELAY = 0.2
LOOP_DELAY = 0.1
59 changes: 20 additions & 39 deletions pytest_watcher/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional, Sequence
from typing import List, Optional, Sequence, Tuple

from watchdog import events
from watchdog.observers import Observer
from watchdog.utils.patterns import match_any_paths

VERSION = "0.3.2"
DEFAULT_DELAY = 0.2
LOOP_DELAY = 0.1
from .config import Config
from .constants import DEFAULT_DELAY, LOOP_DELAY, VERSION

trigger_lock = threading.Lock()
trigger = None
Expand All @@ -25,17 +23,6 @@
logger = logging.getLogger(__name__)


@dataclass
class ParsedArguments:
path: Path
now: bool
delay: float
runner: str
patterns: str
ignore_patterns: str
runner_args: Sequence[str]


def emit_trigger():
"""
Emits trigger to run pytest
Expand Down Expand Up @@ -94,7 +81,7 @@ def _invoke_runner(runner: str, args: Sequence[str]) -> None:
subprocess.run([runner, *args])


def parse_arguments(args: Sequence[str]) -> ParsedArguments:
def parse_arguments(args: Sequence[str]) -> Tuple[argparse.Namespace, List[str]]:
def _parse_patterns(arg: str):
return arg.split(",")

Expand All @@ -112,43 +99,33 @@ def _parse_patterns(arg: str):
parser.add_argument(
"--delay",
type=float,
default=DEFAULT_DELAY,
required=False,
help="The delay (in seconds) before triggering"
f"the test run (default: {DEFAULT_DELAY})",
)
parser.add_argument(
"--runner",
type=str,
default="pytest",
required=False,
help="Specify the executable for running the tests (default: pytest)",
)
parser.add_argument(
"--patterns",
default=["*.py"],
type=_parse_patterns,
required=False,
help="File patterns to watch, specified as comma-separated"
"Unix-style patterns (default: '*.py')",
)
parser.add_argument(
"--ignore-patterns",
default=[],
type=_parse_patterns,
required=False,
help="File patterns to ignore, specified as comma-separated"
"Unix-style patterns (default: '')",
)
parser.add_argument("--version", action="version", version=VERSION)

namespace, runner_args = parser.parse_known_args(args)

return ParsedArguments(
path=namespace.path,
now=namespace.now,
delay=namespace.delay,
runner=namespace.runner,
patterns=namespace.patterns,
ignore_patterns=namespace.ignore_patterns,
runner_args=runner_args,
)
return parser.parse_known_args(args)


def main_loop(*, runner: str, runner_args: Sequence[str], delay: float) -> None:
Expand All @@ -165,27 +142,31 @@ def main_loop(*, runner: str, runner_args: Sequence[str], delay: float) -> None:


def run():
args = parse_arguments(sys.argv[1:])
namespace, runner_args = parse_arguments(sys.argv[1:])

config = Config.create(namespace=namespace, extra_args=runner_args)

event_handler = EventHandler(
patterns=args.patterns, ignore_patterns=args.ignore_patterns
patterns=config.patterns, ignore_patterns=config.ignore_patterns
)

observer = Observer()

observer.schedule(event_handler, args.path, recursive=True)
observer.schedule(event_handler, config.path, recursive=True)
observer.start()

sys.stdout.write(f"pytest-watcher version {VERSION}\n")
sys.stdout.write(f"Runner command: {args.runner} {' '.join(args.runner_args)}\n")
sys.stdout.write(f"Waiting for file changes in {args.path.absolute()}\n")
sys.stdout.write(f"Runner command: {config.runner} {' '.join(config.runner_args)}\n")
sys.stdout.write(f"Waiting for file changes in {config.path.absolute()}\n")

if args.now:
if config.now:
emit_trigger()

try:
while True:
main_loop(runner=args.runner, runner_args=args.runner_args, delay=args.delay)
main_loop(
runner=config.runner, runner_args=config.runner_args, delay=config.delay
)
finally:
observer.stop()
observer.join()
22 changes: 22 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest
from pytest_mock import MockerFixture

Expand Down Expand Up @@ -27,3 +29,23 @@ def mock_main_loop(mocker: MockerFixture):
mock = mocker.patch("pytest_watcher.watcher.main_loop", autospec=True)
mock.side_effect = InterruptedError
return mock


@pytest.fixture(scope="session")
def tmp_path() -> Path:
return Path("tests/tmp")


@pytest.fixture(scope="session", autouse=True)
def create_tmp_dir(tmp_path: Path):
tmp_path.mkdir(exist_ok=True)


@pytest.fixture
def pyproject_toml_path(tmp_path: Path):
path = tmp_path.joinpath("pyproject.toml")
path.touch()

yield path

path.unlink()
Loading

0 comments on commit 7ed8fd4

Please sign in to comment.