diff --git a/README.md b/README.md index b381609..a60a1c3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ douki check src/ douki sync src/ ``` +By default, Douki respects `.gitignore` during file discovery. You can +disable that per run with `--no-respect-gitignore`, or set +`[tool.douki] respect-gitignore = false` in `pyproject.toml`. + ## What's Next? - [Installation](installation.md) — install via pip, conda, or from source diff --git a/docs/usage.md b/docs/usage.md index b255f8d..de1cf27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -356,10 +356,15 @@ def add(x, y): ## Configuration -You can configure Douki using a `pyproject.toml` file in your project root. Currently, Douki supports excluding files or directories using glob patterns. +You can configure Douki using a `pyproject.toml` file in your project root. +Douki supports excluding files or directories using glob patterns, and it +respects `.gitignore` during file discovery by default. ```toml [tool.douki] +# Respect `.gitignore` by default. Set to false to opt out globally. +respect-gitignore = true + # Exclude specific files or entire directories exclude = [ "tests/smoke/*", @@ -367,7 +372,10 @@ exclude = [ ] ``` -When running `douki sync` or `douki check`, any file matching these patterns will be ignored. +When running `douki sync`, `douki check`, or `douki migrate`, any file +matching these patterns will be ignored. You can override the `.gitignore` +setting for a single run with `--respect-gitignore` or +`--no-respect-gitignore`. --- diff --git a/src/douki/_base/config.py b/src/douki/_base/config.py index 7605d14..06fdc6c 100644 --- a/src/douki/_base/config.py +++ b/src/douki/_base/config.py @@ -6,7 +6,13 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import List +from typing import List, Tuple + +from douki._base.discovery import ( + DiscoveryConfig, + collect_source_files, + load_douki_discovery_config, +) class BaseConfig(ABC): @@ -17,30 +23,44 @@ class BaseConfig(ABC): source files are discovered. """ + @property @abstractmethod - def load_exclude_patterns(self, cwd: Path) -> List[str]: + def file_extensions(self) -> Tuple[str, ...]: + """ + title: File extensions handled by the language backend. + returns: + type: Tuple[str, Ellipsis] """ - title: Load exclude patterns from a config file. + ... # pragma: no cover + + def load_discovery_config(self, cwd: Path) -> DiscoveryConfig: + """ + title: Load shared discovery settings from project configuration. parameters: cwd: type: Path returns: - type: List[str] + type: DiscoveryConfig """ - ... # pragma: no cover + return load_douki_discovery_config(cwd) - @abstractmethod def collect_files( - self, paths: List[Path], excludes: List[str] + self, + paths: List[Path], + discovery: DiscoveryConfig, ) -> List[Path]: """ title: Expand paths into source files, filtering excluded ones. parameters: paths: type: List[Path] - excludes: - type: List[str] + discovery: + type: DiscoveryConfig returns: type: List[Path] """ - ... # pragma: no cover + return collect_source_files( + paths, + file_extensions=self.file_extensions, + discovery=discovery, + ) diff --git a/src/douki/_base/discovery.py b/src/douki/_base/discovery.py new file mode 100644 index 0000000..4339433 --- /dev/null +++ b/src/douki/_base/discovery.py @@ -0,0 +1,480 @@ +""" +title: Shared file discovery helpers for language backends. +""" + +from __future__ import annotations + +import re +import sys + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Pattern, Tuple + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +@dataclass(frozen=True) +class DiscoveryConfig: + """ + title: Shared file discovery settings. + attributes: + root: + type: Path + exclude_patterns: + type: Tuple[str, Ellipsis] + respect_gitignore: + type: bool + """ + + root: Path + exclude_patterns: Tuple[str, ...] = () + respect_gitignore: bool = True + + +@dataclass(frozen=True) +class _GitIgnoreRule: + """ + title: Parsed `.gitignore` rule. + attributes: + scope_dir: + type: Path + pattern: + type: str + regex: + type: Pattern[str] + negated: + type: bool + basename_only: + type: bool + directory_only: + type: bool + """ + + scope_dir: Path + pattern: str + regex: Pattern[str] + negated: bool + basename_only: bool + directory_only: bool + + +def load_douki_discovery_config(cwd: Path) -> DiscoveryConfig: + """ + title: Load shared discovery settings from `pyproject.toml`. + parameters: + cwd: + type: Path + returns: + type: DiscoveryConfig + """ + curr = cwd.resolve() + while True: + pyproject = curr / 'pyproject.toml' + if pyproject.is_file(): + return _load_pyproject_discovery_config(pyproject) + parent = curr.parent + if parent == curr: + break + curr = parent + return DiscoveryConfig(root=cwd.resolve()) + + +def collect_source_files( + paths: List[Path], + *, + file_extensions: Tuple[str, ...], + discovery: DiscoveryConfig, +) -> List[Path]: + """ + title: Expand paths into source files using shared discovery rules. + parameters: + paths: + type: List[Path] + file_extensions: + type: Tuple[str, Ellipsis] + discovery: + type: DiscoveryConfig + returns: + type: List[Path] + """ + matcher = _GitIgnoreMatcher(discovery.root) + result: List[Path] = [] + + for path in paths: + if path.is_dir(): + for child in path.rglob('*'): + if not child.is_file(): + continue + if not _matches_extension(child, file_extensions): + continue + if not _is_excluded( + child, + discovery=discovery, + matcher=matcher, + ): + result.append(child) + continue + + if _matches_extension(path, file_extensions) and not _is_excluded( + path, + discovery=discovery, + matcher=matcher, + ): + result.append(path) + + return sorted(set(result)) + + +def _load_pyproject_discovery_config(pyproject: Path) -> DiscoveryConfig: + """ + title: Load `[tool.douki]` discovery settings from a pyproject file. + parameters: + pyproject: + type: Path + returns: + type: DiscoveryConfig + """ + excludes: Tuple[str, ...] = () + respect_gitignore = True + + try: + with pyproject.open('rb') as handle: + data = tomllib.load(handle) + tool_douki = data.get('tool', {}).get('douki', {}) + raw_excludes = tool_douki.get('exclude', []) + if isinstance(raw_excludes, list): + excludes = tuple(str(pattern) for pattern in raw_excludes) + raw_respect_gitignore = tool_douki.get( + 'respect-gitignore', + True, + ) + if isinstance(raw_respect_gitignore, bool): + respect_gitignore = raw_respect_gitignore + except Exception: + pass + + return DiscoveryConfig( + root=pyproject.parent.resolve(), + exclude_patterns=excludes, + respect_gitignore=respect_gitignore, + ) + + +def _matches_extension(path: Path, file_extensions: Tuple[str, ...]) -> bool: + """ + title: Check whether a path uses one of the configured file extensions. + parameters: + path: + type: Path + file_extensions: + type: Tuple[str, Ellipsis] + returns: + type: bool + """ + return any(path.name.endswith(ext) for ext in file_extensions) + + +def _is_excluded( + path: Path, + *, + discovery: DiscoveryConfig, + matcher: '_GitIgnoreMatcher', +) -> bool: + """ + title: Check whether a path is excluded by config or `.gitignore`. + parameters: + path: + type: Path + discovery: + type: DiscoveryConfig + matcher: + type: _GitIgnoreMatcher + returns: + type: bool + """ + return _matches_exclude_patterns( + path, + exclude_patterns=discovery.exclude_patterns, + root=discovery.root, + ) or (discovery.respect_gitignore and matcher.is_ignored(path)) + + +def _matches_exclude_patterns( + path: Path, + *, + exclude_patterns: Tuple[str, ...], + root: Path, +) -> bool: + """ + title: Check whether a path matches any configured exclude pattern. + parameters: + path: + type: Path + exclude_patterns: + type: Tuple[str, Ellipsis] + root: + type: Path + returns: + type: bool + """ + if not exclude_patterns: + return False + + try: + path_str = path.resolve().relative_to(root).as_posix() + except ValueError: + path_str = path.resolve().as_posix() + + for pattern in exclude_patterns: + if _match_exclude_pattern(path_str, pattern): + return True + return False + + +def _match_exclude_pattern(path_str: str, pattern: str) -> bool: + """ + title: Match a configured exclude pattern against a relative path. + parameters: + path_str: + type: str + pattern: + type: str + returns: + type: bool + """ + normalized = pattern.replace('\\', '/').lstrip('/') + directory_only = normalized.endswith('/') + normalized = normalized.rstrip('/') + if not normalized: + return False + + regex = _compile_gitignore_regex(normalized) + prefixes = _relative_prefixes(path_str) + if directory_only: + prefixes = prefixes[:-1] + + if '/' in normalized: + return any(regex.fullmatch(prefix) for prefix in prefixes) + + parts = path_str.split('/') + if directory_only: + parts = parts[:-1] + basename_regex = _compile_gitignore_regex(normalized) + return any(basename_regex.fullmatch(part) for part in parts) + + +class _GitIgnoreMatcher: + """ + title: Lazy `.gitignore` matcher rooted at a discovery directory. + attributes: + _root: + type: Path + _rules_by_dir: + type: Dict[Path, Tuple[_GitIgnoreRule, Ellipsis]] + """ + + def __init__(self, root: Path) -> None: + self._root: Path = root.resolve() + self._rules_by_dir: Dict[Path, Tuple[_GitIgnoreRule, ...]] = {} + + def is_ignored(self, path: Path) -> bool: + """ + title: Check whether a path is ignored by any applicable `.gitignore`. + parameters: + path: + type: Path + returns: + type: bool + """ + resolved = path.resolve() + try: + resolved.relative_to(self._root) + except ValueError: + return False + + ignored = False + for scope_dir in self._iter_scope_dirs(resolved): + for rule in self._load_rules(scope_dir): + if _rule_matches_path(rule, resolved): + ignored = not rule.negated + return ignored + + def _iter_scope_dirs(self, path: Path) -> Iterable[Path]: + """ + title: Yield directories whose `.gitignore` files apply to `path`. + parameters: + path: + type: Path + returns: + type: Iterable[Path] + """ + rel_parent = path.relative_to(self._root).parent + curr = self._root + yield curr + for part in rel_parent.parts: + if part == '.': + continue + curr = curr / part + yield curr + + def _load_rules(self, scope_dir: Path) -> Tuple[_GitIgnoreRule, ...]: + """ + title: Load and cache parsed rules for a scope directory. + parameters: + scope_dir: + type: Path + returns: + type: Tuple[_GitIgnoreRule, Ellipsis] + """ + if scope_dir in self._rules_by_dir: + return self._rules_by_dir[scope_dir] + + gitignore = scope_dir / '.gitignore' + if not gitignore.is_file(): + self._rules_by_dir[scope_dir] = () + return () + + rules: List[_GitIgnoreRule] = [] + try: + lines = gitignore.read_text(encoding='utf-8').splitlines() + except OSError: + self._rules_by_dir[scope_dir] = () + return () + + for raw_line in lines: + rule = _parse_gitignore_rule(raw_line, scope_dir) + if rule is not None: + rules.append(rule) + + cached_rules = tuple(rules) + self._rules_by_dir[scope_dir] = cached_rules + return cached_rules + + +def _parse_gitignore_rule( + raw_line: str, + scope_dir: Path, +) -> _GitIgnoreRule | None: + """ + title: Parse a single `.gitignore` line into a match rule. + parameters: + raw_line: + type: str + scope_dir: + type: Path + returns: + type: _GitIgnoreRule | None + """ + line = raw_line.rstrip() + if not line: + return None + + if line.startswith('\\#') or line.startswith('\\!'): + line = line[1:] + elif line.startswith('#'): + return None + + negated = line.startswith('!') + if negated: + line = line[1:] + if not line: + return None + + directory_only = line.endswith('/') + if directory_only: + line = line.rstrip('/') + if not line: + return None + + if line.startswith('/'): + line = line.lstrip('/') + basename_only = '/' not in line + + return _GitIgnoreRule( + scope_dir=scope_dir.resolve(), + pattern=line, + regex=_compile_gitignore_regex(line), + negated=negated, + basename_only=basename_only, + directory_only=directory_only, + ) + + +def _rule_matches_path(rule: _GitIgnoreRule, path: Path) -> bool: + """ + title: Check whether a parsed `.gitignore` rule matches a path. + parameters: + rule: + type: _GitIgnoreRule + path: + type: Path + returns: + type: bool + """ + try: + rel_path = path.relative_to(rule.scope_dir).as_posix() + except ValueError: + return False + + if rule.basename_only: + parts = rel_path.split('/') + if rule.directory_only: + parts = parts[:-1] + return any(rule.regex.fullmatch(part) for part in parts) + + prefixes = _relative_prefixes(rel_path) + if rule.directory_only: + prefixes = prefixes[:-1] + return any(rule.regex.fullmatch(prefix) for prefix in prefixes) + + +def _relative_prefixes(path_str: str) -> List[str]: + """ + title: Return cumulative relative path prefixes for a path string. + parameters: + path_str: + type: str + returns: + type: List[str] + """ + parts = [part for part in path_str.split('/') if part] + prefixes: List[str] = [] + for idx in range(len(parts)): + prefixes.append('/'.join(parts[: idx + 1])) + return prefixes + + +def _compile_gitignore_regex(pattern: str) -> Pattern[str]: + """ + title: Compile a simplified gitignore-style glob into a regex. + parameters: + pattern: + type: str + returns: + type: Pattern[str] + """ + regex: List[str] = ['^'] + idx = 0 + while idx < len(pattern): + char = pattern[idx] + if char == '*': + if pattern[idx : idx + 2] == '**': + idx += 2 + if idx < len(pattern) and pattern[idx] == '/': + regex.append('(?:.*/)?') + idx += 1 + else: + regex.append('.*') + continue + regex.append('[^/]*') + elif char == '?': + regex.append('[^/]') + else: + regex.append(re.escape(char)) + idx += 1 + regex.append('$') + return re.compile(''.join(regex)) diff --git a/src/douki/_base/language.py b/src/douki/_base/language.py index 9e5562e..5f12ce6 100644 --- a/src/douki/_base/language.py +++ b/src/douki/_base/language.py @@ -53,7 +53,8 @@ def sync_source( returns: type: str """ - ... # pragma: no cover + _ = (source, migrate) + raise NotImplementedError # pragma: no cover # --------------------------------------------------------------------------- diff --git a/src/douki/_python/config.py b/src/douki/_python/config.py index b3c4ef7..330cd6b 100644 --- a/src/douki/_python/config.py +++ b/src/douki/_python/config.py @@ -4,111 +4,23 @@ from __future__ import annotations -import fnmatch -import sys - -from pathlib import Path -from typing import List - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib +from typing import Tuple from douki._base.config import BaseConfig +from douki._python.defaults import PYTHON_DEFAULTS class PythonConfig(BaseConfig): """ title: Python language configuration loader. - summary: >- - Reads exclude patterns from ``pyproject.toml`` and collects ``.py`` - files. + summary: Uses the shared discovery helpers to collect Python source files. """ - def load_exclude_patterns(self, cwd: Path) -> List[str]: - """ - title: Load exclude patterns from pyproject.toml in cwd or parents. - parameters: - cwd: - type: Path - returns: - type: List[str] - """ - curr = cwd.resolve() - while True: - pyproject = curr / 'pyproject.toml' - if pyproject.is_file(): - try: - with pyproject.open('rb') as f: - data = tomllib.load(f) - excludes = ( - data.get('tool', {}) - .get('douki', {}) - .get('exclude', []) - ) - if isinstance(excludes, list): - return [str(e) for e in excludes] - except Exception: - pass - break - parent = curr.parent - if parent == curr: - break - curr = parent - return [] - - def collect_files( - self, paths: List[Path], excludes: List[str] - ) -> List[Path]: + @property + def file_extensions(self) -> Tuple[str, ...]: """ - title: Expand directories to .py files and filter excluded. - parameters: - paths: - type: List[Path] - excludes: - type: List[str] + title: File extensions handled by the Python backend. returns: - type: List[Path] + type: Tuple[str, Ellipsis] """ - result: List[Path] = [] - for p in paths: - if p.is_dir(): - for child in p.rglob('*.py'): - if not _is_excluded(child, excludes): - result.append(child) - elif p.suffix == '.py': - if not _is_excluded(p, excludes): - result.append(p) - return sorted(set(result)) - - -def _is_excluded(path: Path, excludes: List[str]) -> bool: - """ - title: Check if path matches any of the exclude patterns. - parameters: - path: - type: Path - excludes: - type: List[str] - returns: - type: bool - """ - if not excludes: - return False - - try: - rel_path = path.resolve().relative_to(Path.cwd().resolve()) - path_str = rel_path.as_posix() - except ValueError: - # If the path is outside cwd, just use its absolute posix string. - path_str = path.resolve().as_posix() - - for pattern in excludes: - if fnmatch.fnmatch(path_str, pattern) or fnmatch.fnmatch( - path_str, f'*/{pattern}' - ): - return True - if path_str.startswith(pattern.rstrip('/') + '/'): - return True - return False + return PYTHON_DEFAULTS.file_extensions diff --git a/src/douki/cli.py b/src/douki/cli.py index f3c64d4..1a14667 100644 --- a/src/douki/cli.py +++ b/src/douki/cli.py @@ -53,6 +53,7 @@ def _main() -> None: def _resolve_files( files: Optional[List[Path]], lang: str, + respect_gitignore: Optional[bool], ) -> List[Path]: """ title: Turn the optional argument into a list of paths for the language. @@ -61,10 +62,16 @@ def _resolve_files( type: Optional[List[Path]] lang: type: str + respect_gitignore: + type: Optional[bool] returns: type: List[Path] """ - target_files = resolve_files(files, lang=lang) + target_files = resolve_files( + files, + lang=lang, + respect_gitignore=respect_gitignore, + ) if not target_files: console.print(f'[dim]No {lang} files found.[/]') raise typer.Exit(code=0) @@ -128,6 +135,11 @@ def sync( '--lang', help='Programming language to process (e.g. "python").', ), + respect_gitignore: Optional[bool] = typer.Option( + None, + '--respect-gitignore/--no-respect-gitignore', + help='Respect .gitignore patterns during file discovery.', + ), ) -> None: """ title: Apply docstring sync changes to files in-place. @@ -136,8 +148,15 @@ def sync( type: Optional[List[Path]] lang: type: str + respect_gitignore: + type: Optional[bool] + optional: true """ - target_files = _resolve_files(files, lang=lang) + target_files = _resolve_files( + files, + lang=lang, + respect_gitignore=respect_gitignore, + ) errors = False changed = 0 unchanged = 0 @@ -201,6 +220,11 @@ def check( '--lang', help='Programming language to process (e.g. "python").', ), + respect_gitignore: Optional[bool] = typer.Option( + None, + '--respect-gitignore/--no-respect-gitignore', + help='Respect .gitignore patterns during file discovery.', + ), ) -> None: """ title: Print a diff of proposed changes. Exit 1 if any. @@ -209,8 +233,15 @@ def check( type: Optional[List[Path]] lang: type: str + respect_gitignore: + type: Optional[bool] + optional: true """ - target_files = _resolve_files(files, lang=lang) + target_files = _resolve_files( + files, + lang=lang, + respect_gitignore=respect_gitignore, + ) any_diff = False errors = False @@ -265,6 +296,11 @@ def migrate( '--lang', help='Programming language to process (e.g. "python").', ), + respect_gitignore: Optional[bool] = typer.Option( + None, + '--respect-gitignore/--no-respect-gitignore', + help='Respect .gitignore patterns during file discovery.', + ), ) -> None: """ title: Migrate docstrings from another format to Douki YAML. @@ -275,9 +311,16 @@ def migrate( type: MigrateFormat lang: type: str + respect_gitignore: + type: Optional[bool] + optional: true """ migrate_val = from_format.value - target_files = _resolve_files(files, lang=lang) + target_files = _resolve_files( + files, + lang=lang, + respect_gitignore=respect_gitignore, + ) errors = False changed = 0 unchanged = 0 diff --git a/src/douki/sync.py b/src/douki/sync.py index 31dabf9..535d46f 100644 --- a/src/douki/sync.py +++ b/src/douki/sync.py @@ -8,6 +8,7 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from typing import List, Optional @@ -41,6 +42,7 @@ def resolve_files( files: Optional[List[Path]] = None, *, lang: str = 'python', + respect_gitignore: Optional[bool] = None, ) -> List[Path]: """ title: Resolve paths into source files for the given language. @@ -49,6 +51,9 @@ def resolve_files( type: Optional[List[Path]] lang: type: str + respect_gitignore: + type: Optional[bool] + optional: true returns: type: List[Path] """ @@ -56,9 +61,14 @@ def resolve_files( _ensure_plugins() language = get_language(lang) - excludes = language.config.load_exclude_patterns(Path.cwd()) + discovery = language.config.load_discovery_config(Path.cwd()) + if respect_gitignore is not None: + discovery = replace( + discovery, + respect_gitignore=respect_gitignore, + ) raw = files if files else [Path('.')] - return language.config.collect_files(raw, excludes) + return language.config.collect_files(raw, discovery) def sync_source( diff --git a/tests/test_cli.py b/tests/test_cli.py index c5ba2c3..a205e19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -383,10 +383,13 @@ def good() -> None: p_ignored = _write( tmp_path, 'ignored.py', - """\ - def bad() -> None: - pass - """, + '''\ + def bad(value: int) -> int: + """ + title: bad + """ + return value + ''', ) smoke_dir = tmp_path / 'tests' / 'smoke' @@ -401,7 +404,6 @@ def dirty() -> None: ) result = runner.invoke(app, ['check']) - print('OUTPUT WAS:', result.output) # Because clean.py is clean, and ignored.py/dirty.py are excluded, # it should exit 0 assert result.exit_code == 0 @@ -412,6 +414,183 @@ def dirty() -> None: assert 'No python files found' in result2.output +def test_exclude_files_via_pyproject_windows_separator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + _write( + tmp_path, + 'pyproject.toml', + """\ + [tool.douki] + exclude = ["tests\\\\smoke\\\\*"] + """, + ) + + smoke_dir = tmp_path / 'tests' / 'smoke' + smoke_dir.mkdir(parents=True) + _write( + smoke_dir, + 'dirty.py', + '''\ + def dirty(value: int) -> int: + """ + title: dirty + """ + return value + ''', + ) + + result = runner.invoke(app, ['check']) + assert result.exit_code == 0 + assert 'No python files found' in result.output + + +def test_gitignore_files_are_ignored_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + _write( + tmp_path, + '.gitignore', + """\ + ignored.py + """, + ) + + p_ignored = _write( + tmp_path, + 'ignored.py', + '''\ + def bad(value: int) -> int: + """ + title: bad + """ + return value + ''', + ) + + result = runner.invoke(app, ['check']) + assert result.exit_code == 0 + assert 'No python files found' in result.output + + result2 = runner.invoke(app, ['check', str(p_ignored)]) + assert result2.exit_code == 0 + assert 'No python files found' in result2.output + + +def test_nested_gitignore_respected_unless_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + nested = tmp_path / 'pkg' + nested.mkdir() + _write( + nested, + '.gitignore', + """\ + ignored.py + """, + ) + _write( + nested, + 'ignored.py', + '''\ + def bad(value: int) -> int: + """ + title: bad + """ + return value + ''', + ) + + result = runner.invoke(app, ['check', str(tmp_path)]) + assert result.exit_code == 0 + assert 'No python files found' in result.output + + result2 = runner.invoke( + app, + ['check', '--no-respect-gitignore', str(tmp_path)], + ) + assert result2.exit_code == 1 + + +def test_pyproject_can_disable_gitignore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + _write( + tmp_path, + 'pyproject.toml', + """\ + [tool.douki] + respect-gitignore = false + """, + ) + _write( + tmp_path, + '.gitignore', + """\ + ignored.py + """, + ) + _write( + tmp_path, + 'ignored.py', + '''\ + def bad(value: int) -> int: + """ + title: bad + """ + return value + ''', + ) + + result = runner.invoke(app, ['check']) + assert result.exit_code == 1 + + +def test_cli_flag_overrides_pyproject_gitignore_setting( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + _write( + tmp_path, + 'pyproject.toml', + """\ + [tool.douki] + respect-gitignore = false + """, + ) + _write( + tmp_path, + '.gitignore', + """\ + ignored.py + """, + ) + _write( + tmp_path, + 'ignored.py', + '''\ + def bad(value: int) -> int: + """ + title: bad + """ + return value + ''', + ) + + result = runner.invoke(app, ['check', '--respect-gitignore']) + assert result.exit_code == 0 + assert 'No python files found' in result.output + + # ------------------------------------------------------------------- # Coverage: migrate error/unchanged paths # ------------------------------------------------------------------- @@ -510,7 +689,7 @@ def foo() -> None: ) import douki._python.language - def _boom(*a, **kw): + def _boom(*a: object, **kw: object) -> str: raise RuntimeError('boom') monkeypatch.setattr( @@ -545,7 +724,7 @@ def foo() -> None: ) import douki._python.language - def _boom(*a, **kw): + def _boom(*a: object, **kw: object) -> str: raise RuntimeError('boom') monkeypatch.setattr( @@ -580,7 +759,7 @@ def foo() -> None: ) import douki._python.language - def _boom(*a, **kw): + def _boom(*a: object, **kw: object) -> str: raise RuntimeError('boom') monkeypatch.setattr(