diff --git a/.gitignore b/.gitignore index a9031e8..0caf6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +*.lcov # Translations *.mo diff --git a/Justfile b/Justfile index c6b9ffd..5351f45 100755 --- a/Justfile +++ b/Justfile @@ -1,6 +1,9 @@ SOURCE_PATH := "def_form" TESTS_PATH := "tests" +default: + @just --list + upgrade: uv lock --upgrade @@ -20,5 +23,6 @@ tests: uv run pytest \ --cov=def_form \ --cov-report=lcov:tests.lcov \ + --cov-report=term \ tests/ diff --git a/README.md b/README.md index 67e46ee..b44e2d3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ -# def-form +
+ def-form logo -Python function definition formatter +

Python function definition formatter

+ + [![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) + [![PyPI](https://img.shields.io/pypi/v/def-form.svg)](https://pypi.python.org/pypi/def-form) + [![PyPI](https://img.shields.io/pypi/dm/def-form.svg)](https://pypi.python.org/pypi/def-form) + [![Coverage Status](https://coveralls.io/repos/github/TopNik073/def-form/badge.svg?branch=init)](https://coveralls.io/github/TopNik073/def-form?branch=init) + +
-[![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) -[![PyPI](https://img.shields.io/pypi/v/def-form.svg)](https://pypi.python.org/pypi/def-form) -[![PyPI](https://img.shields.io/pypi/dm/def-form.svg)](https://pypi.python.org/pypi/def-form) -[![Coverage Status](https://coveralls.io/repos/github/TopNik073/def-form/badge.svg?branch=init)](https://coveralls.io/github/TopNik073/def-form?branch=init) ## Overview `def-form` is a code formatting tool that focuses specifically on Python function definitions. It helps maintain consistent formatting of function signatures by automatically organizing arguments vertically when they exceed specified thresholds. @@ -47,38 +51,114 @@ def-form format my_module.py def-form check src/ ``` -### Command line options +### Example + +You can check your code with `check` command and see the result + +```text +(.venv) user@MacBook-Pro def-form % def-form check test_file.py +Checking test_file.py + + Configuration + ───────────────────────────────────────────────────────────────── + Config Path: /Users/user/Documents/def-form/pyproject.toml + Max Inline Args: 2 + Max Def Length: 100 + Indent Size: 4 spaces + Show Skipped: No + Excluded: .venv, tests/cases, build + ───────────────────────────────────────────────────────────────── + +Found 1 errors in 1 files + +/Users/user/Documents/def-form/test_file.py:19 + • Invalid multiline function parameters indentation (expected 4 spaces) + + Summary + ─────────────────────────── + Files processed: 1 + Files with issues: 1 + Total errors: 1 + Success rate: 0.0% + ─────────────────────────── + +Code style violations found +``` + +Or use `format` +```text +(.venv) user@MacBook-Pro def-form % def-form format test_file.py +Formatting test_file.py + + Configuration + ───────────────────────────────────────────────────────────────── + Config Path: /Users/user/Documents/def-form/pyproject.toml + Max Inline Args: 2 + Max Def Length: 100 + Indent Size: 4 spaces + Show Skipped: No + Excluded: build, .venv, tests/cases + ───────────────────────────────────────────────────────────────── + +Found 1 errors in 1 files + +/Users/user/Documents/def-form/test_file.py:19 + • Invalid multiline function parameters indentation (expected 4 spaces) + + Summary + ─────────────────────────── + Files processed: 1 + Files with issues: 1 + Total errors: 1 + Success rate: 0.0% + ─────────────────────────── + +Formatting completed +``` + +## Command line options + +There is global options ```text -def-form format [OPTIONS] [PATH] +Usage: def-form [OPTIONS] COMMAND [ARGS]... Options: - --max-def-length INTEGER Maximum length of function definition - --max-inline-args INTEGER Maximum number of inline arguments - --indent-size INTEGER indent size in spaces (default: 4) - --exclude TEXT Paths or files to exclude from checking/formatting - --show-skipped Show skipped files/directories - --config TEXT Path to pyproject.toml configuration file + --verbose Enable verbose output + --quiet Disable all output + --help Show this message and exit. + +Commands: + check + format +``` + +And specific options for check/format + +```text +Usage: def-form format [OPTIONS] [PATH] + +Options: + --config FILE Path to pyproject.toml configuration file + --show-skipped Show skipped files and directories + --exclude PATH Paths to exclude from processing + --indent-size INTEGER Indent size in spaces (default: 4) + --max-inline-args INTEGER Maximum number of inline arguments + --max-def-length INTEGER Maximum length of function definition + --help Show this message and exit. ``` -### Configuration +## Configuration Create a pyproject.toml file in your project root: ```toml [tool.def-form] -max_def_length = 100 -max_inline_args = 2 -indent_size = 4 -exclude = [ +max_def_length = 100 # Maximum allowed characters in a single-line function definition +max_inline_args = 2 # Maximum number of arguments allowed in inline format +indent_size = 4 # Indent for arguments in spaces +exclude = [ # Files or directories you want to exclude '.venv', 'migrations' ] -``` - -### Configuration options - -* max_def_length: Maximum allowed characters in a single-line function definition -* max_inline_args: Maximum number of arguments allowed in inline format -* indent_size: Indent for arguments in spaces -* exclude: Files or directories you want to exclude \ No newline at end of file +``` \ No newline at end of file diff --git a/assets/logo-transparent.png b/assets/logo-transparent.png new file mode 100644 index 0000000..ee08cbd Binary files /dev/null and b/assets/logo-transparent.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..9628b52 Binary files /dev/null and b/assets/logo.png differ diff --git a/def_form/cli/cli.py b/def_form/cli/cli.py deleted file mode 100644 index 72a96b8..0000000 --- a/def_form/cli/cli.py +++ /dev/null @@ -1,78 +0,0 @@ -import sys - -import click - -from def_form.exceptions.base import BaseDefFormException -from def_form.formatters import DefManager - - -@click.command() -@click.argument('path', type=str, default='src') -@click.option('--max-def-length', type=int, default=None, help='max length of your function definition') -@click.option('--max-inline-args', type=int, default=None, help='max number of inline arguments') -@click.option('--indent-size', type=int, default=None, help='indent size in spaces (default: 4)') -@click.option('--config', type=str, default=None, help='path to pyproject.toml') -@click.option('--exclude', multiple=True, help='paths to exclude from formatting') -@click.option('--show-skipped', is_flag=True, help='show skipped files/directories') -def format( # noqa: PLR0913 - path: str, - max_def_length: int | None, - max_inline_args: int | None, - indent_size: int | None, - config: str | None, - exclude: tuple[str, ...], - show_skipped: bool, -) -> None: - click.echo('Start formatting your code') - try: - DefManager( - path=path, - excluded=exclude, - max_def_length=max_def_length, - max_inline_args=max_inline_args, - indent_size=indent_size, - config=config, - show_skipped=show_skipped, - ).format() - except Exception as e: - click.echo(f'Something went wrong: {e}', err=True) - sys.exit(1) - else: - click.echo('Formatted!') - - -@click.command() -@click.argument('path', type=str, default='src') -@click.option('--max-def-length', type=int, default=None, help='max length of your function definition') -@click.option('--max-inline-args', type=int, default=None, help='max number of inline arguments') -@click.option('--indent-size', type=int, default=None, help='indent size in spaces (default: 4)') -@click.option('--config', type=str, default=None, help='path to pyproject.toml') -@click.option('--exclude', multiple=True, help='paths to exclude from checking') -@click.option('--show-skipped', is_flag=True, help='show skipped files/directories') -def check( # noqa: PLR0913 - path: str, - max_def_length: int | None, - max_inline_args: int | None, - indent_size: int | None, - config: str | None, - exclude: tuple[str, ...], - show_skipped: bool, -) -> None: - click.echo('Start checking your code') - try: - DefManager( - path=path, - excluded=exclude, - max_def_length=max_def_length, - max_inline_args=max_inline_args, - indent_size=indent_size, - config=config, - show_skipped=show_skipped, - ).check() - except BaseDefFormException: - sys.exit(1) - except Exception as e: - click.echo(f'Something went wrong: {e}', err=True) - sys.exit(1) - else: - click.echo('All checks passed!') diff --git a/def_form/cli/commands/__init__.py b/def_form/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/def_form/cli/commands/check.py b/def_form/cli/commands/check.py new file mode 100644 index 0000000..ec7e399 --- /dev/null +++ b/def_form/cli/commands/check.py @@ -0,0 +1,42 @@ +import click + +from def_form.cli.commands.options import common_options +from def_form.cli.console import RichConsole +from def_form.cli.context import context +from def_form.cli.errors import CheckFailedError +from def_form.cli.ui.rich import RichUI +from def_form.exceptions.base import BaseDefFormException +from def_form.core import DefManager + + +@click.command() +@common_options +def check( # noqa: PLR0913 + path: str, + max_def_length: int | None, + max_inline_args: int | None, + indent_size: int | None, + config: str | None, + exclude: tuple[str, ...], + show_skipped: bool, +) -> None: + console = RichConsole(context=context) + console.info(f'Checking [bold]{path}[/bold]') + + try: + DefManager( + path=path, + excluded=exclude, + max_def_length=max_def_length, + max_inline_args=max_inline_args, + indent_size=indent_size, + config=config, + show_skipped=show_skipped, + ui=RichUI(console=console), + ).check() + except BaseDefFormException as exc: + raise CheckFailedError('Code style violations found') from exc + except Exception as exc: + raise CheckFailedError(str(exc)) from exc + + console.success('All checks passed') diff --git a/def_form/cli/commands/format.py b/def_form/cli/commands/format.py new file mode 100644 index 0000000..f592d37 --- /dev/null +++ b/def_form/cli/commands/format.py @@ -0,0 +1,41 @@ +import click + +from def_form.cli.commands.options import common_options +from def_form.cli.console import RichConsole +from def_form.cli.context import context +from def_form.cli.errors import FormatterFailedError +from def_form.cli.ui.rich import RichUI +from def_form.core import DefManager + + +@click.command() +@common_options +def format( # noqa: PLR0913 + path: str, + max_def_length: int | None, + max_inline_args: int | None, + indent_size: int | None, + config: str | None, + exclude: tuple[str, ...], + show_skipped: bool, +) -> None: + context.show_skipped = show_skipped + console = RichConsole(context=context) + console.info(f'Formatting [bold]{path}[/bold]') + console.debug('Initializing formatter') + + try: + DefManager( + path=path, + excluded=exclude, + max_def_length=max_def_length, + max_inline_args=max_inline_args, + indent_size=indent_size, + config=config, + show_skipped=show_skipped, + ui=RichUI(console=console), + ).format() + except Exception as exc: + raise FormatterFailedError(str(exc)) from exc + + console.success('Formatting completed') diff --git a/def_form/cli/commands/options.py b/def_form/cli/commands/options.py new file mode 100644 index 0000000..e9b75c3 --- /dev/null +++ b/def_form/cli/commands/options.py @@ -0,0 +1,46 @@ +from collections.abc import Callable + +import click + + +def path_option(func: Callable) -> Callable: + return click.argument('path', type=click.Path(exists=True), default='.')(func) + + +def max_def_length_option(func: Callable) -> Callable: + return click.option('--max-def-length', type=int, default=None, help='Maximum length of function definition')(func) + + +def max_inline_args_option(func: Callable) -> Callable: + return click.option('--max-inline-args', type=int, default=None, help='Maximum number of inline arguments')(func) + + +def indent_size_option(func: Callable) -> Callable: + return click.option('--indent-size', type=int, default=None, help='Indent size in spaces (default: 4)')(func) + + +def config_option(func: Callable) -> Callable: + return click.option( + '--config', + type=click.Path(exists=True, dir_okay=False), + default=None, + help='Path to pyproject.toml configuration file', + )(func) + + +def exclude_option(func: Callable) -> Callable: + return click.option('--exclude', multiple=True, type=click.Path(), help='Paths to exclude from processing')(func) + + +def show_skipped_option(func: Callable) -> Callable: + return click.option('--show-skipped', is_flag=True, default=False, help='Show skipped files and directories')(func) + + +def common_options(func: Callable) -> Callable: + func = path_option(func) + func = max_def_length_option(func) + func = max_inline_args_option(func) + func = indent_size_option(func) + func = exclude_option(func) + func = show_skipped_option(func) + return config_option(func) diff --git a/def_form/cli/console/__init__.py b/def_form/cli/console/__init__.py new file mode 100644 index 0000000..48bf42e --- /dev/null +++ b/def_form/cli/console/__init__.py @@ -0,0 +1,9 @@ +from def_form.cli.console.base import BaseConsole +from def_form.cli.console.rich import RichConsole +from def_form.cli.console.null import NullConsole + +__all__ = [ + 'BaseConsole', + 'NullConsole', + 'RichConsole', +] diff --git a/def_form/cli/console/base.py b/def_form/cli/console/base.py new file mode 100644 index 0000000..9db0f48 --- /dev/null +++ b/def_form/cli/console/base.py @@ -0,0 +1,26 @@ +from typing import Any + +from rich.console import Console + +from def_form.cli.context import CLIContext + + +class BaseConsole(Console): + def __init__(self, context: CLIContext, *args: Any, **kwargs: Any) -> None: + self.context = context + super().__init__(*args, **kwargs) + + def info(self, message: str) -> None: + raise NotImplementedError + + def success(self, message: str) -> None: + raise NotImplementedError + + def warning(self, message: str) -> None: + raise NotImplementedError + + def error(self, message: str) -> None: + raise NotImplementedError + + def debug(self, message: str) -> None: + raise NotImplementedError diff --git a/def_form/cli/console/null.py b/def_form/cli/console/null.py new file mode 100644 index 0000000..41aec38 --- /dev/null +++ b/def_form/cli/console/null.py @@ -0,0 +1,18 @@ +from def_form.cli.console.base import BaseConsole + + +class NullConsole(BaseConsole): + def info(self, message: str) -> None: + return + + def success(self, message: str) -> None: + return + + def warning(self, message: str) -> None: + return + + def error(self, message: str) -> None: + return + + def debug(self, message: str) -> None: + return diff --git a/def_form/cli/console/rich.py b/def_form/cli/console/rich.py new file mode 100644 index 0000000..d30efbc --- /dev/null +++ b/def_form/cli/console/rich.py @@ -0,0 +1,21 @@ +from def_form.cli.console.base import BaseConsole + + +class RichConsole(BaseConsole): + def info(self, message: str) -> None: + if self.context.should_output: + self.print(message) + + def success(self, message: str) -> None: + if self.context.should_output: + self.print(f'[green]{message}[/green]') + + def warning(self, message: str) -> None: + self.print(f'[yellow]{message}[/yellow]') + + def error(self, message: str) -> None: + self.print(f'[red]{message}[/red]') + + def debug(self, message: str) -> None: + if self.context.verbose: + self.print(f'[dim]{message}[/dim]') diff --git a/def_form/cli/context.py b/def_form/cli/context.py new file mode 100644 index 0000000..59847a6 --- /dev/null +++ b/def_form/cli/context.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass +class CLIContext: + verbose: bool = False + quiet: bool = False + show_skipped: bool = False + config_path: str | None = None + + @property + def should_output(self) -> bool: + return not self.quiet + + +context: CLIContext = CLIContext() diff --git a/def_form/cli/errors/__init__.py b/def_form/cli/errors/__init__.py new file mode 100644 index 0000000..52af20d --- /dev/null +++ b/def_form/cli/errors/__init__.py @@ -0,0 +1,10 @@ +class CLIError(Exception): + pass + + +class FormatterFailedError(CLIError): + pass + + +class CheckFailedError(CLIError): + pass diff --git a/def_form/cli/main.py b/def_form/cli/main.py index 04dbcdc..851b68a 100644 --- a/def_form/cli/main.py +++ b/def_form/cli/main.py @@ -1,16 +1,39 @@ import click +import sys +from def_form.cli.console import RichConsole +from def_form.cli.context import context +from def_form.cli.errors import CLIError +from def_form.cli.commands.check import check +from def_form.cli.commands.format import format -from def_form.cli.cli import check -from def_form.cli.cli import format +console = RichConsole(context=context) -@click.group(name='def-form') -def main() -> None: - click.help_option() +@click.group() +@click.option('--verbose', is_flag=True, help='Enable verbose output') +@click.option('--quiet', is_flag=True, help='Disable all output') +def cli(verbose: bool, quiet: bool) -> None: + console.context.verbose = verbose + console.context.quiet = quiet + + +cli.add_command(check) +cli.add_command(format) -main.add_command(format) -main.add_command(check) +def main() -> None: + try: + cli() + except CLIError as exc: + console.error(str(exc)) + sys.exit(1) + except KeyboardInterrupt: + console.error('Operation cancelled by user') + sys.exit(130) + except Exception as exc: + console.error(f'Unexpected error: {exc}') + sys.exit(1) + if __name__ == '__main__': main() diff --git a/def_form/cli/ui/__init__.py b/def_form/cli/ui/__init__.py new file mode 100644 index 0000000..514ad55 --- /dev/null +++ b/def_form/cli/ui/__init__.py @@ -0,0 +1,5 @@ +from def_form.cli.ui.base import BaseUI +from def_form.cli.ui.null import NullUI +from def_form.cli.ui.rich import RichUI + +__all__ = ['BaseUI', 'NullUI', 'RichUI'] diff --git a/def_form/cli/ui/base.py b/def_form/cli/ui/base.py new file mode 100644 index 0000000..d62ca11 --- /dev/null +++ b/def_form/cli/ui/base.py @@ -0,0 +1,41 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any + +from def_form.cli.console import BaseConsole +from def_form.cli.context import CLIContext +from def_form.exceptions.base import BaseDefFormException + + +class BaseUI(ABC): + def __init__(self, console: BaseConsole) -> None: + self.console: BaseConsole = console + self.context: CLIContext = console.context + + @abstractmethod + def show_config_info(self, **config: Any) -> None: + raise NotImplementedError + + @abstractmethod + def start(self, total: int | None) -> None: + raise NotImplementedError + + @abstractmethod + def processing(self, path: Path) -> None: + raise NotImplementedError + + @abstractmethod + def skipped(self, path: Path) -> None: + raise NotImplementedError + + @abstractmethod + def finish(self, processed: int, issues: list[BaseDefFormException]) -> None: + raise NotImplementedError + + @abstractmethod + def show_issues(self, processed: int, issues: list[BaseDefFormException]) -> None: + raise NotImplementedError + + @abstractmethod + def show_summary(self, processed: int, issues: list[BaseDefFormException]) -> None: + raise NotImplementedError diff --git a/def_form/cli/ui/null.py b/def_form/cli/ui/null.py new file mode 100644 index 0000000..ba3db3d --- /dev/null +++ b/def_form/cli/ui/null.py @@ -0,0 +1,28 @@ +from pathlib import Path +from typing import Any + +from def_form.cli.ui import BaseUI +from def_form.exceptions.base import BaseDefFormException + + +class NullUI(BaseUI): + def show_config_info(self, **config: Any) -> None: + return + + def start(self, total: int | None) -> None: + return + + def processing(self, path: Path) -> None: + return + + def skipped(self, path: Path) -> None: + return + + def finish(self, processed: int, issues: list[BaseDefFormException]) -> None: + return + + def show_issues(self, processed: int, issues: list[BaseDefFormException]) -> None: + return + + def show_summary(self, processed: int, issues: list[BaseDefFormException]) -> None: + return diff --git a/def_form/cli/ui/rich.py b/def_form/cli/ui/rich.py new file mode 100644 index 0000000..e920307 --- /dev/null +++ b/def_form/cli/ui/rich.py @@ -0,0 +1,214 @@ +# ruff: noqa: PLR2004 +from collections import defaultdict +from pathlib import Path +from typing import Any + +from rich import box +from rich.console import Group +from rich.live import Live +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TaskID +from rich.table import Table +from rich.text import Text + +from def_form.cli.console import BaseConsole +from def_form.cli.ui.base import BaseUI +from def_form.exceptions.base import BaseDefFormException + + +class RichUI(BaseUI): + def __init__(self, console: BaseConsole) -> None: + super().__init__( + console=console, + ) + self.progress: Progress | None = None + self._live: Live | None = None + self.task_id: int | None = None + self.current_file: Path | None = None + + def _convert_to_string(self, value: Any) -> str: + if isinstance(value, Path): + return str(value.relative_to(Path.cwd())) + + if isinstance(value, bool): + return 'Yes' if value else 'No' + + return str(value) + + def show_config_info(self, **config: Any) -> None: + if not self.context.should_output: + return + + if not config: + return + + config_table = Table( + title='[yellow]Configuration[/yellow]', box=box.HORIZONTALS, show_header=False, border_style='dim' + ) + config_table.add_column(style='dim') + config_table.add_column(style='cyan') + + for key, value in config.items(): + if value is None or value == '': + continue + + display_key = key.replace('_', ' ').title() + + if key == 'config_path': + config_table.add_row(f'{display_key}:', f'[bold yellow]{value}[/bold yellow]') + else: + if isinstance(value, list | set | tuple): + if not value: + continue + + items = [self._convert_to_string(item) for item in value] + + if len(items) <= 3: + display_value = ', '.join(items) + else: + preview = ', '.join(items[:3]) + display_value = f'{preview} (+{len(items) - 3} more)' + else: + display_value = self._convert_to_string(value) + + config_table.add_row(f'{display_key}:', display_value) + + self.console.print() + self.console.print(config_table) + self.console.print() + + def start(self, total: int | None) -> None: + if not self.context.should_output: + return + + self.progress = Progress( + SpinnerColumn(), + BarColumn(bar_width=None, complete_style='blue', finished_style='green'), + TextColumn('[progress.percentage]{task.percentage:>3.0f}%'), + TextColumn('•'), + TimeElapsedColumn(), + TextColumn('•'), + TextColumn('{task.completed}/{task.total}'), + console=self.console, + expand=True, + ) + + self._progress_display = Group(Text('', style='dim'), self.progress) + + self._live = Live(self._progress_display, console=self.console, refresh_per_second=10) + self._live.start() + + self.task_id = self.progress.add_task( + 'Processing files...', + total=total, + ) + + def processing(self, path: Path) -> None: + if not self.context.should_output: + return + + if not (self.progress and self._live and self.task_id is not None): + return + + self.current_file = path + + file_text = Text(str(path), style='dim') + self._progress_display.renderables[0] = file_text + + self.progress.update( + TaskID(self.task_id), + advance=1, + ) + + self._live.refresh() + + def skipped(self, path: Path) -> None: + if not self.context.should_output: + return + + if not self.context.show_skipped: + return + + self.console.print(f'[yellow]SKIPPED[/yellow] {path}') + + def issue(self, issue: BaseDefFormException) -> None: + pass + + def finish(self, processed: int, issues: list[BaseDefFormException]) -> None: + if not self.context.should_output: + return + + if self._live: + self._live.stop() + self._live = None + + if self.progress: + self.progress.stop() + self.progress = None + + if issues: + self.show_issues(processed, issues) + + def show_issues(self, processed: int, issues: list[BaseDefFormException]) -> None: + if not self.context.should_output: + return + + unique_files = {issue.path.split(':')[0] for issue in issues} + + if issues: + self.console.print('\n') + + self.console.print(f'[bold yellow]Found {len(issues)} errors in {len(unique_files)} files[/bold yellow]') + self.console.print() + + issues_by_def: dict[str, list[BaseDefFormException]] = defaultdict(list) + for issue in issues: + if issue.path not in issues_by_def: + issues_by_def[issue.path] = [] + issues_by_def[issue.path].append(issue) + + for i, (def_path, def_issues) in enumerate(sorted(issues_by_def.items())): + if i > 0: + self.console.print() + + self.console.print(f'[bold cyan link=file://{def_path}]{def_path}[/bold cyan link=file://{def_path}]') + + for _j, issue in enumerate(def_issues): + bullet = '•' + + line_info = '' + if ':' in def_path: + line_info = '' + elif hasattr(issue, 'line') and issue.line: + line_info = f':{issue.line}' + + self.console.print(f' [red]{bullet}[/red] [white]{issue.message}[/white]{line_info}') + + self.console.print() + + self.show_summary(processed, issues) + + def show_summary(self, processed: int, issues: list[BaseDefFormException]) -> None: + if not self.context.should_output: + return + + unique_files = {issue.path.split(':')[0] for issue in issues} + + summary = Table(title='[yellow]Summary[/yellow]', box=box.HORIZONTALS, show_header=False, border_style='dim') + summary.add_column(style='bold') + summary.add_column() + + summary.add_row('Files processed:', f'[cyan]{processed}[/cyan]') + summary.add_row('Files with issues:', f'[yellow]{len(unique_files)}[/yellow]') + summary.add_row('Total errors:', f'[red]{len(issues)}[/red]') + + if processed > 0: + success_rate = (processed - len(unique_files)) / processed * 100 + summary.add_row( + 'Success rate:', + f'[{"green" if success_rate > 90 else "yellow" if success_rate > 70 else "red"}]' + f'{success_rate:.1f}%' + f'[/{"green" if success_rate > 90 else "yellow" if success_rate > 70 else "red"}]', + ) + + self.console.print(summary) + self.console.print() diff --git a/def_form/core/__init__.py b/def_form/core/__init__.py new file mode 100644 index 0000000..72e5374 --- /dev/null +++ b/def_form/core/__init__.py @@ -0,0 +1,5 @@ +from def_form.core.manager import DefManager + +__all__ = [ + 'DefManager', +] diff --git a/def_form/formatters/def_formatter/base.py b/def_form/core/base.py similarity index 69% rename from def_form/formatters/def_formatter/base.py rename to def_form/core/base.py index 7b2f6ba..4af973f 100644 --- a/def_form/formatters/def_formatter/base.py +++ b/def_form/core/base.py @@ -2,7 +2,8 @@ from pathlib import Path from typing import cast -from libcst import FunctionDef, Comma +from libcst import Comma +from libcst import FunctionDef from libcst import MetadataDependent from libcst import Module from libcst import Param @@ -12,10 +13,10 @@ from libcst.metadata import PositionProvider from def_form.exceptions.base import BaseDefFormException -from def_form.exceptions.def_formatter import DefStringTooLongException -from def_form.exceptions.def_formatter import InvalidMultilineParamsIndentException -from def_form.exceptions.def_formatter import TooManyInlineArgumentsException -from def_form.formatters.def_formatter.models import FunctionAnalysis +from def_form.core.models import FunctionAnalysis +from def_form.core.params import get_params_list +from def_form.core.rules import run_rules +from def_form.core.rules.context import RuleContext class DefBase(MetadataDependent): @@ -35,27 +36,6 @@ def __init__( self.indent_size = indent_size if indent_size is not None else 4 self.issues: list[BaseDefFormException] = [] - def _check_issues(self, line_length: int, line_no: int, arg_count: int) -> list[BaseDefFormException]: - issues: list[BaseDefFormException] = [] - - if self.max_def_length and line_length > self.max_def_length: - issues.append( - DefStringTooLongException( - path=f'{self.filepath}:{line_no}', - message=f'Function definition too long ({line_length} > {self.max_def_length})', - ) - ) - - if self.max_inline_args and arg_count > self.max_inline_args: - issues.append( - TooManyInlineArgumentsException( - path=f'{self.filepath}:{line_no}', - message=f'Too many inline args ({arg_count} > {self.max_inline_args})', - ) - ) - - return issues - def is_single_line_function(self, node: FunctionDef) -> bool: func_code = Module([]).code_for_node(node).strip() lines = func_code.split('\n') @@ -87,20 +67,6 @@ def has_skip_comment(self, node: FunctionDef) -> bool: return False - def _get_params_list(self, node: FunctionDef) -> list[Param]: - params: list[Param] = [] - params.extend(node.params.params) - - if isinstance(node.params.star_arg, Param): - params.append(node.params.star_arg) - - params.extend(node.params.kwonly_params) - - if isinstance(node.params.star_kwarg, Param): - params.append(node.params.star_kwarg) - - return params - def has_correct_multiline_params_format(self, node: FunctionDef) -> bool: # noqa: PLR0911, PLR0912 ws = node.whitespace_before_params if not isinstance(ws, ParenthesizedWhitespace): @@ -116,7 +82,7 @@ def has_correct_multiline_params_format(self, node: FunctionDef) -> bool: # noq if ws.last_line.value != expected_indent: return False - all_params = self._get_params_list(node) + all_params = get_params_list(node) if not all_params: return True @@ -195,18 +161,18 @@ def analyze_function(self, node: FunctionDef) -> FunctionAnalysis: pos = self.get_metadata(PositionProvider, node) line_no = pos.start.line - issues: list[BaseDefFormException] = [] - - if self.is_single_line_function(node): - issues = self._check_issues(line_length, line_no, arg_count) - elif not self.has_correct_multiline_params_format(node): - issues.append( - InvalidMultilineParamsIndentException( - path=f'{self.filepath}:{line_no}', - message=f'Invalid multiline function parameters indentation (expected {self.indent_size} spaces)', - ) - ) - + context = RuleContext( + filepath=self.filepath, + line_no=line_no, + line_length=line_length, + arg_count=arg_count, + is_single_line=self.is_single_line_function(node), + has_correct_multiline_format=self.has_correct_multiline_params_format(node), + indent_size=self.indent_size, + max_def_length=self.max_def_length, + max_inline_args=self.max_inline_args, + ) + issues = run_rules(context) has_issues = bool(issues) return FunctionAnalysis( diff --git a/def_form/formatters/def_formatter/checker.py b/def_form/core/checker.py similarity index 72% rename from def_form/formatters/def_formatter/checker.py rename to def_form/core/checker.py index 6f4a248..cc75244 100644 --- a/def_form/formatters/def_formatter/checker.py +++ b/def_form/core/checker.py @@ -1,7 +1,7 @@ from libcst import CSTVisitor from libcst import FunctionDef -from def_form.formatters.def_formatter.base import DefBase +from def_form.core.base import DefBase class DefChecker(DefBase, CSTVisitor): @@ -13,7 +13,10 @@ def __init__( indent_size: int | None, ): super().__init__( - filepath=filepath, max_def_length=max_def_length, max_inline_args=max_inline_args, indent_size=indent_size + filepath=filepath, + max_def_length=max_def_length, + max_inline_args=max_inline_args, + indent_size=indent_size, ) def leave_FunctionDef(self, original_node: FunctionDef) -> None: diff --git a/def_form/core/formatter.py b/def_form/core/formatter.py new file mode 100644 index 0000000..4b1baac --- /dev/null +++ b/def_form/core/formatter.py @@ -0,0 +1,38 @@ +from libcst import CSTTransformer +from libcst import FunctionDef + +from def_form.core.base import DefBase +from def_form.core.node_builder import build_parameters + + +class DefFormatter(DefBase, CSTTransformer): + def __init__( + self, + filepath: str, + max_def_length: int | None, + max_inline_args: int | None, + indent_size: int | None = None, + ): + super().__init__( + filepath=filepath, + max_def_length=max_def_length, + max_inline_args=max_inline_args, + indent_size=indent_size, + ) + + def leave_FunctionDef(self, original_node: FunctionDef, updated_node: FunctionDef) -> FunctionDef: + analysis = self.analyze_function(original_node) + if not analysis.should_process: + return updated_node + if analysis.issues: + self.issues.extend(analysis.issues) + is_single_line = self.is_single_line_function(original_node) + params, whitespace_before_params = build_parameters( + updated_node, + is_single_line=is_single_line, + indent_size=self.indent_size, + ) + return updated_node.with_changes( + params=params, + whitespace_before_params=whitespace_before_params, + ) diff --git a/def_form/core/manager.py b/def_form/core/manager.py new file mode 100644 index 0000000..d68d37d --- /dev/null +++ b/def_form/core/manager.py @@ -0,0 +1,259 @@ +import os +from collections.abc import Generator +from pathlib import Path + +import tomli +import libcst as cst + +from def_form.cli.ui import BaseUI +from def_form.exceptions.base import BaseDefFormException +from def_form.exceptions.def_formatter import CheckCommandFoundAnIssue +from def_form.core.checker import DefChecker +from def_form.core.formatter import DefFormatter +from def_form.utils.find_pyproject import find_pyproject_toml + + +class DefManager: + def __init__( # noqa: PLR0913 + self, + path: str, + ui: BaseUI, + excluded: tuple[str, ...] | None = None, + formatter: type[DefFormatter] = DefFormatter, + checker: type[DefChecker] = DefChecker, + max_def_length: int | None = None, + max_inline_args: int | None = None, + indent_size: int | None = None, + config: str | None = None, + show_skipped: bool = False, + ) -> None: + self.config: str | None = config or find_pyproject_toml() + self.path = Path(path).resolve() + self.ui = ui + + self.issues: list[BaseDefFormException] = [] + + self.formatter_class = formatter + self.checker_class = checker + + self._init_config( + config=self.config, + max_def_length=max_def_length, + max_inline_args=max_inline_args, + indent_size=indent_size, + ) + + self._init_exclusions(excluded or ()) + + self.ui.show_config_info( + config_path=self.config, + max_inline_args=self.max_inline_args, + max_def_length=self.max_def_length, + indent_size=f'{self.indent_size} spaces', + show_skipped=show_skipped, + excluded=self.excluded, + ) + + # --------------------------------------------------------------------- # + # Configuration + # --------------------------------------------------------------------- # + + def _init_config( + self, + config: str | None, + max_def_length: int | None, + max_inline_args: int | None, + indent_size: int | None, + ) -> None: + self.max_def_length = max_def_length + self.max_inline_args = max_inline_args + self.indent_size = indent_size + + self._config_excluded: list[str] = [] + + if not config: + return + + try: + with Path(config).open('rb') as f: + config_data = tomli.load(f) + + config_def = config_data.get('tool', {}).get('def-form', {}) + + self.max_def_length = config_def.get( + 'max_def_length', + self.max_def_length, + ) + self.max_inline_args = config_def.get( + 'max_inline_args', + self.max_inline_args, + ) + self.indent_size = config_def.get( + 'indent_size', + self.indent_size, + ) + self._config_excluded = config_def.get('exclude', []) + + except (FileNotFoundError, tomli.TOMLDecodeError): + self._config_excluded = [] + + # --------------------------------------------------------------------- # + # Exclusions + # --------------------------------------------------------------------- # + + def _init_exclusions(self, cli_excluded: tuple[str, ...]) -> None: + self.excluded: set[Path] = set() + + for p in (*cli_excluded, *self._config_excluded): + try: + self.excluded.add(Path(p).resolve()) + except Exception: + continue + + def _is_excluded(self, path: Path) -> bool: + for excluded in self.excluded: + try: + path.relative_to(excluded) + return True + except ValueError: + pass + + if excluded.name in path.parts: + return True + + return False + + # --------------------------------------------------------------------- # + # File iteration + # --------------------------------------------------------------------- # + + def _iter_py_files(self) -> Generator[Path, None, None]: + if self.path.is_file(): + if self.path.suffix != '.py': + return + + if self._is_excluded(self.path): + self.ui.skipped(self.path) + return + + yield self.path + return + + for root, dirs, files in os.walk(self.path): + root_path = Path(root) + + dirs[:] = [d for d in dirs if not self._is_excluded(root_path / d)] + + for filename in files: + if not filename.endswith('.py'): + continue + + file_path = root_path / filename + + if self._is_excluded(file_path): + self.ui.skipped(file_path) + continue + + yield file_path + + # --------------------------------------------------------------------- # + # Processing + # --------------------------------------------------------------------- # + + def _create_processor( + self, + processor_class: type[DefFormatter] | type[DefChecker], + filepath: str, + ) -> DefFormatter | DefChecker: + return processor_class( + filepath=filepath, + max_def_length=self.max_def_length, + max_inline_args=self.max_inline_args, + indent_size=self.indent_size, + ) + + def _process_file( + self, + filepath: Path, + processor_class: type[DefFormatter] | type[DefChecker], + ) -> tuple[cst.Module | None, list[BaseDefFormException]]: + try: + code = filepath.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None, [] + + try: + tree = cst.parse_module(code) + wrapper = cst.metadata.MetadataWrapper(tree) + processor = self._create_processor(processor_class, str(filepath)) + + if issubclass(processor_class, DefFormatter): + new_tree = wrapper.visit(processor) + return new_tree, processor.issues + + wrapper.visit(processor) + return None, processor.issues + + except cst.ParserSyntaxError: + return None, [] + except Exception: + return None, [] + + def _write( + self, + dest: str | Path, + module: str, + ) -> None: + try: + Path(dest).write_text(module, encoding='utf-8') + except OSError: + self.ui.console.error(f'Exception occurred while writing to {dest}') + + # --------------------------------------------------------------------- # + # Public API + # --------------------------------------------------------------------- # + + def format(self) -> None: + self.issues.clear() + files = list(self._iter_py_files()) + + self.ui.start(total=len(files)) + + for path in files: + self.ui.processing(path) + + new_tree, file_issues = self._process_file( + path, + self.formatter_class, + ) + + self.issues.extend(file_issues) + + if new_tree is not None: + self._write( + dest=path, + module=new_tree.code, + ) + + self.ui.finish(len(files), self.issues) + + def check(self) -> None: + self.issues.clear() + files = list(self._iter_py_files()) + + self.ui.start(total=len(files)) + + for path in files: + self.ui.processing(path) + + _, file_issues = self._process_file( + path, + self.checker_class, + ) + + self.issues.extend(file_issues) + + self.ui.finish(len(files), self.issues) + + if self.issues: + raise CheckCommandFoundAnIssue(str(self.path), 'check command did found an issue') diff --git a/def_form/formatters/def_formatter/models.py b/def_form/core/models.py similarity index 100% rename from def_form/formatters/def_formatter/models.py rename to def_form/core/models.py diff --git a/def_form/core/node_builder.py b/def_form/core/node_builder.py new file mode 100644 index 0000000..e37d76a --- /dev/null +++ b/def_form/core/node_builder.py @@ -0,0 +1,139 @@ +from typing import Any + +from libcst import Comma, MaybeSentinel +from libcst import Comment +from libcst import FunctionDef +from libcst import Param +from libcst import Parameters +from libcst import ParenthesizedWhitespace +from libcst import SimpleWhitespace +from libcst import TrailingWhitespace + +from def_form.core.params import get_params_list + + +def _is_valid_param(param: Any) -> bool: + return isinstance(param, Param) + + +def _extract_comment_from_whitespace(ws: Any) -> TrailingWhitespace | None: + if isinstance(ws, TrailingWhitespace): + return ws + if isinstance(ws, ParenthesizedWhitespace) and isinstance(ws.first_line, TrailingWhitespace): + return ws.first_line + if isinstance(ws, SimpleWhitespace) and '#' in ws.value: + parts = ws.value.split('#', 1) + spaces = parts[0].rstrip() + comment_text = '#' + parts[1].rstrip() + return TrailingWhitespace( + whitespace=SimpleWhitespace(spaces + ' ' if spaces else ''), + comment=Comment(comment_text), + ) + return None + + +def _extract_comment_from_param(param: Param) -> TrailingWhitespace | None: + if isinstance(param.comma, Comma) and param.comma.whitespace_after: + comment = _extract_comment_from_whitespace(param.comma.whitespace_after) + if comment: + return comment + if param.whitespace_after_param: + return _extract_comment_from_whitespace(param.whitespace_after_param) + return None + + +def _create_whitespace( + indent_size: int, + comment: TrailingWhitespace | None, + last_line: SimpleWhitespace, +) -> ParenthesizedWhitespace: + if comment: + return ParenthesizedWhitespace(first_line=comment, empty_lines=[], indent=True, last_line=last_line) + return ParenthesizedWhitespace(last_line=last_line, indent=True) + + +def _create_formatted_param_for_single_line( + indent_size: int, + param: Param, + is_last: bool = False, +) -> Param: + indent = ' ' * indent_size + comment = _extract_comment_from_param(param) + last_line = SimpleWhitespace('' if is_last else indent) + new_ws = _create_whitespace(indent_size, comment, last_line) + need_comma = param.comma is not None if is_last else True + new_comma = Comma(whitespace_after=new_ws) if need_comma else None + return param.with_changes(comma=new_comma, whitespace_after_param=SimpleWhitespace('')) + + +def _create_formatted_param_for_multi_line( + indent_size: int, + param: Param, + is_last: bool = False, +) -> Param: + indent = ' ' * indent_size + last_line = SimpleWhitespace('' if is_last else indent) + original_ws = param.comma.whitespace_after if isinstance(param.comma, Comma) else None + + if isinstance(original_ws, ParenthesizedWhitespace): + comment = original_ws.first_line if isinstance(original_ws.first_line, TrailingWhitespace) else None + new_ws = _create_whitespace(indent_size, comment, last_line) + elif isinstance(original_ws, TrailingWhitespace): + new_ws = _create_whitespace(indent_size, original_ws, last_line) + else: + new_ws = _create_whitespace(indent_size, None, last_line) + + need_comma = param.comma is not None if is_last else True + new_comma = Comma(whitespace_after=new_ws) if need_comma else None + return param.with_changes(comma=new_comma, whitespace_after_param=SimpleWhitespace('')) + + +def _restore_param_groups( + formatted_params: list[Param], + node: FunctionDef, +) -> tuple[list[Param], list[Param], Param | None, Param | None]: + param_count = len(node.params.params) + kwonly_count = len(node.params.kwonly_params) + new_params = formatted_params[:param_count] + remaining = formatted_params[param_count:] + + new_star_arg = None + if _is_valid_param(node.params.star_arg) and remaining: + new_star_arg = remaining[0] + remaining = remaining[1:] + + new_kwonly_params = remaining[:kwonly_count] if kwonly_count else [] + remaining = remaining[kwonly_count:] + + new_star_kwarg = None + if _is_valid_param(node.params.star_kwarg) and remaining: + new_star_kwarg = remaining[0] + + return new_params, new_kwonly_params, new_star_arg, new_star_kwarg + + +def build_parameters( + node: FunctionDef, + is_single_line: bool, + indent_size: int, +) -> tuple[Parameters, ParenthesizedWhitespace]: + all_params = get_params_list(node) + total_params = len(all_params) + if is_single_line: + format_param = lambda p, last: _create_formatted_param_for_single_line(indent_size, p, last) + else: + format_param = lambda p, last: _create_formatted_param_for_multi_line(indent_size, p, last) + formatted_params = [format_param(param, i == total_params - 1) for i, param in enumerate(all_params)] + new_params, new_kwonly_params, new_star_arg, new_star_kwarg = _restore_param_groups(formatted_params, node) + params = Parameters( + params=new_params, + posonly_params=node.params.posonly_params, + kwonly_params=new_kwonly_params, + star_arg=new_star_arg if new_star_arg is not None else MaybeSentinel.DEFAULT, + star_kwarg=new_star_kwarg, + ) + whitespace_before_params = ParenthesizedWhitespace( + last_line=SimpleWhitespace(' ' * indent_size), + indent=True, + ) + return params, whitespace_before_params diff --git a/def_form/core/params.py b/def_form/core/params.py new file mode 100644 index 0000000..c846ef3 --- /dev/null +++ b/def_form/core/params.py @@ -0,0 +1,17 @@ +from libcst import FunctionDef +from libcst import Param + + +def get_params_list(node: FunctionDef) -> list[Param]: + params: list[Param] = [] + params.extend(node.params.params) + + if isinstance(node.params.star_arg, Param): + params.append(node.params.star_arg) + + params.extend(node.params.kwonly_params) + + if isinstance(node.params.star_kwarg, Param): + params.append(node.params.star_kwarg) + + return params diff --git a/def_form/core/rules/__init__.py b/def_form/core/rules/__init__.py new file mode 100644 index 0000000..2491017 --- /dev/null +++ b/def_form/core/rules/__init__.py @@ -0,0 +1,36 @@ +from def_form.exceptions.base import BaseDefFormException + +from def_form.core.rules.base import Rule +from def_form.core.rules.context import RuleContext +from def_form.core.rules.max_def_length import RuleMaxDefLength +from def_form.core.rules.max_inline_args import RuleMaxInlineArgs +from def_form.core.rules.multiline_params_indent import ( + RuleMultilineParamsIndent, +) + +DEFAULT_RULES: tuple[Rule, ...] = ( + RuleMaxDefLength(), + RuleMaxInlineArgs(), + RuleMultilineParamsIndent(), +) + + +def run_rules( + context: RuleContext, + rules: tuple[Rule, ...] | list[Rule] | None = None, +) -> list[BaseDefFormException]: + rule_list = rules if rules is not None else list(DEFAULT_RULES) + issues: list[BaseDefFormException] = [] + for rule in rule_list: + issues.extend(rule.check(context)) + return issues + + +__all__ = [ + 'DEFAULT_RULES', + 'Rule', + 'RuleMaxDefLength', + 'RuleMaxInlineArgs', + 'RuleMultilineParamsIndent', + 'run_rules', +] diff --git a/def_form/core/rules/base.py b/def_form/core/rules/base.py new file mode 100644 index 0000000..9bc32a4 --- /dev/null +++ b/def_form/core/rules/base.py @@ -0,0 +1,11 @@ +from abc import ABC +from abc import abstractmethod + +from def_form.exceptions.base import BaseDefFormException +from def_form.core.rules.context import RuleContext + + +class Rule(ABC): + @abstractmethod + def check(self, context: RuleContext) -> list[BaseDefFormException]: + raise NotImplementedError diff --git a/def_form/core/rules/context.py b/def_form/core/rules/context.py new file mode 100644 index 0000000..9c486d6 --- /dev/null +++ b/def_form/core/rules/context.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RuleContext: + filepath: str + line_no: int + line_length: int + arg_count: int + is_single_line: bool + has_correct_multiline_format: bool + indent_size: int + max_def_length: int | None + max_inline_args: int | None diff --git a/def_form/core/rules/max_def_length.py b/def_form/core/rules/max_def_length.py new file mode 100644 index 0000000..22a1d16 --- /dev/null +++ b/def_form/core/rules/max_def_length.py @@ -0,0 +1,18 @@ +from def_form.exceptions.base import BaseDefFormException +from def_form.exceptions.def_formatter import DefStringTooLongException +from def_form.core.rules.base import Rule +from def_form.core.rules.context import RuleContext + + +class RuleMaxDefLength(Rule): + def check(self, context: RuleContext) -> list[BaseDefFormException]: + if not context.is_single_line: + return [] + if not context.max_def_length or context.line_length <= context.max_def_length: + return [] + return [ + DefStringTooLongException( + path=f'{context.filepath}:{context.line_no}', + message=f'Function definition too long ({context.line_length} > {context.max_def_length})', + ) + ] diff --git a/def_form/core/rules/max_inline_args.py b/def_form/core/rules/max_inline_args.py new file mode 100644 index 0000000..25de0c2 --- /dev/null +++ b/def_form/core/rules/max_inline_args.py @@ -0,0 +1,18 @@ +from def_form.exceptions.base import BaseDefFormException +from def_form.exceptions.def_formatter import TooManyInlineArgumentsException +from def_form.core.rules.base import Rule +from def_form.core.rules.context import RuleContext + + +class RuleMaxInlineArgs(Rule): + def check(self, context: RuleContext) -> list[BaseDefFormException]: + if not context.is_single_line: + return [] + if not context.max_inline_args or context.arg_count <= context.max_inline_args: + return [] + return [ + TooManyInlineArgumentsException( + path=f'{context.filepath}:{context.line_no}', + message=f'Too many inline args ({context.arg_count} > {context.max_inline_args})', + ) + ] diff --git a/def_form/core/rules/multiline_params_indent.py b/def_form/core/rules/multiline_params_indent.py new file mode 100644 index 0000000..bce5470 --- /dev/null +++ b/def_form/core/rules/multiline_params_indent.py @@ -0,0 +1,16 @@ +from def_form.exceptions.base import BaseDefFormException +from def_form.exceptions.def_formatter import InvalidMultilineParamsIndentException +from def_form.core.rules.base import Rule +from def_form.core.rules.context import RuleContext + + +class RuleMultilineParamsIndent(Rule): + def check(self, context: RuleContext) -> list[BaseDefFormException]: + if context.is_single_line or context.has_correct_multiline_format: + return [] + return [ + InvalidMultilineParamsIndentException( + path=f'{context.filepath}:{context.line_no}', + message=f'Invalid multiline function parameters indentation (expected {context.indent_size} spaces)', + ) + ] diff --git a/def_form/exceptions/base.py b/def_form/exceptions/base.py index 86c4143..0bd77ec 100644 --- a/def_form/exceptions/base.py +++ b/def_form/exceptions/base.py @@ -3,6 +3,6 @@ @dataclass class BaseDefFormException(Exception): - path: str | None = None - message: str | None = None + path: str + message: str description: str | None = None diff --git a/def_form/exceptions/def_formatter.py b/def_form/exceptions/def_formatter.py index 3b62b58..1d20423 100644 --- a/def_form/exceptions/def_formatter.py +++ b/def_form/exceptions/def_formatter.py @@ -29,3 +29,10 @@ class InvalidMultilineParamsIndentException(BaseDefFormException): path: str message: str = 'Invalid multiline params indentation' description: str | None = None + + +@dataclass +class CheckCommandFoundAnIssue(BaseDefFormException): + path: str + message: str = 'check command did found an issue' + description: str | None = None diff --git a/def_form/formatters/__init__.py b/def_form/formatters/__init__.py deleted file mode 100644 index 6722c49..0000000 --- a/def_form/formatters/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from def_form.formatters.def_formatter import DefManager - -__all__ = [ - 'DefManager', -] diff --git a/def_form/formatters/def_formatter/__init__.py b/def_form/formatters/def_formatter/__init__.py deleted file mode 100644 index caed506..0000000 --- a/def_form/formatters/def_formatter/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from def_form.formatters.def_formatter.manager import DefManager - -__all__ = [ - 'DefManager', -] diff --git a/def_form/formatters/def_formatter/formatter.py b/def_form/formatters/def_formatter/formatter.py deleted file mode 100644 index bf0dd55..0000000 --- a/def_form/formatters/def_formatter/formatter.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import Any, cast - -from libcst import Comma -from libcst import Comment -from libcst import CSTTransformer -from libcst import FunctionDef -from libcst import Param -from libcst import Parameters -from libcst import ParenthesizedWhitespace -from libcst import SimpleWhitespace -from libcst import TrailingWhitespace - -from def_form.formatters.def_formatter.base import DefBase - - -class DefFormatter(DefBase, CSTTransformer): - def __init__( - self, - filepath: str, - max_def_length: int | None, - max_inline_args: int | None, - indent_size: int | None = None, - ): - super().__init__(filepath, max_def_length, max_inline_args) - self.indent_size = indent_size if indent_size is not None else 4 - - def _is_valid_param(self, param: Any) -> bool: - return isinstance(param, Param) - - def _extract_comment_from_whitespace(self, ws: Any) -> TrailingWhitespace | None: - if isinstance(ws, TrailingWhitespace): - return ws - if isinstance(ws, ParenthesizedWhitespace) and isinstance(ws.first_line, TrailingWhitespace): - return ws.first_line - if isinstance(ws, SimpleWhitespace) and '#' in ws.value: - parts = ws.value.split('#', 1) - spaces = parts[0].rstrip() - comment_text = '#' + parts[1].rstrip() - return TrailingWhitespace( - whitespace=SimpleWhitespace(spaces + ' ' if spaces else ''), comment=Comment(comment_text) - ) - return None - - def _extract_comment_from_param(self, param: Param) -> TrailingWhitespace | None: - if isinstance(param.comma, Comma) and param.comma.whitespace_after: - comment = self._extract_comment_from_whitespace(param.comma.whitespace_after) - if comment: - return comment - if param.whitespace_after_param: - return self._extract_comment_from_whitespace(param.whitespace_after_param) - return None - - def _create_whitespace( - self, comment: TrailingWhitespace | None, last_line: SimpleWhitespace - ) -> ParenthesizedWhitespace: - if comment: - return ParenthesizedWhitespace(first_line=comment, empty_lines=[], indent=True, last_line=last_line) - return ParenthesizedWhitespace(last_line=last_line, indent=True) - - def _create_formatted_param_for_single_line(self, param: Param, is_last: bool = False) -> Param: - indent = ' ' * self.indent_size - comment = self._extract_comment_from_param(param) - last_line = SimpleWhitespace('' if is_last else indent) - new_ws = self._create_whitespace(comment, last_line) - need_comma = param.comma is not None if is_last else True - new_comma = Comma(whitespace_after=new_ws) if need_comma else None - return param.with_changes(comma=new_comma, whitespace_after_param=SimpleWhitespace('')) - - def _create_formatted_param_for_multi_line(self, param: Param, is_last: bool = False) -> Param: - indent = ' ' * self.indent_size - last_line = SimpleWhitespace('' if is_last else indent) - original_ws = param.comma.whitespace_after if isinstance(param.comma, Comma) else None - - if isinstance(original_ws, ParenthesizedWhitespace): - comment = original_ws.first_line if isinstance(original_ws.first_line, TrailingWhitespace) else None - new_ws = self._create_whitespace(comment, last_line) - elif isinstance(original_ws, TrailingWhitespace): - new_ws = self._create_whitespace(original_ws, last_line) - else: - new_ws = self._create_whitespace(None, last_line) - - need_comma = param.comma is not None if is_last else True - new_comma = Comma(whitespace_after=new_ws) if need_comma else None - return param.with_changes(comma=new_comma, whitespace_after_param=SimpleWhitespace('')) - - def _collect_all_params(self, node: FunctionDef) -> list[Param]: - all_params: list[Param] = list(node.params.params) - - if self._is_valid_param(node.params.star_arg): - all_params.append(cast(Param, node.params.star_arg)) - - all_params.extend(node.params.kwonly_params) - - if self._is_valid_param(node.params.star_kwarg): - all_params.append(cast(Param, node.params.star_kwarg)) - - return all_params - - def _restore_param_groups(self, formatted_params: list[Param], node: FunctionDef) -> tuple: - param_count = len(node.params.params) - kwonly_count = len(node.params.kwonly_params) - new_params = formatted_params[:param_count] - remaining = formatted_params[param_count:] - - new_star_arg = None - if self._is_valid_param(node.params.star_arg) and remaining: - new_star_arg = remaining[0] - remaining = remaining[1:] - - new_kwonly_params = remaining[:kwonly_count] if kwonly_count else [] - remaining = remaining[kwonly_count:] - - new_star_kwarg = None - if self._is_valid_param(node.params.star_kwarg) and remaining: - new_star_kwarg = remaining[0] - - return new_params, new_kwonly_params, new_star_arg, new_star_kwarg - - def _process_parameters(self, node: FunctionDef, is_single_line: bool) -> FunctionDef: - all_params = self._collect_all_params(node) - total_params = len(all_params) - format_func = ( - self._create_formatted_param_for_single_line - if is_single_line - else self._create_formatted_param_for_multi_line - ) - formatted_params = [format_func(param, i == total_params - 1) for i, param in enumerate(all_params)] - new_params, new_kwonly_params, new_star_arg, new_star_kwarg = self._restore_param_groups(formatted_params, node) - - return node.with_changes( - params=Parameters( - params=new_params, - posonly_params=node.params.posonly_params, - kwonly_params=new_kwonly_params, - star_arg=new_star_arg, - star_kwarg=new_star_kwarg, - ), - whitespace_before_params=ParenthesizedWhitespace( - last_line=SimpleWhitespace(' ' * self.indent_size), indent=True - ), - ) - - def leave_FunctionDef(self, original_node: FunctionDef, updated_node: FunctionDef) -> FunctionDef: - analysis = self.analyze_function(original_node) - if not analysis.should_process: - return updated_node - if analysis.issues: - self.issues.extend(analysis.issues) - return self._process_parameters(updated_node, self.is_single_line_function(original_node)) diff --git a/def_form/formatters/def_formatter/manager.py b/def_form/formatters/def_formatter/manager.py deleted file mode 100644 index fa6bb3e..0000000 --- a/def_form/formatters/def_formatter/manager.py +++ /dev/null @@ -1,232 +0,0 @@ -import os -import tomli -from collections.abc import Generator -from pathlib import Path - -import click -import libcst as cst - -from def_form.exceptions.base import BaseDefFormException -from def_form.formatters.def_formatter.checker import DefChecker -from def_form.formatters.def_formatter.formatter import DefFormatter -from def_form.utils.find_pyproject import find_pyproject_toml - - -class DefManager: - def __init__( # noqa: PLR0913 - self, - path: str, - excluded: tuple[str, ...] | None = None, - formatter: type[DefFormatter] = DefFormatter, - checker: type[DefChecker] = DefChecker, - max_def_length: int | None = None, - max_inline_args: int | None = None, - indent_size: int | None = None, - config: str | None = None, - show_skipped: bool = False, - ): - self.path = Path(path).resolve() - self.show_skipped = show_skipped - self.issues: list[BaseDefFormException] = [] - - self._init_config( - config=config, - max_def_length=max_def_length, - max_inline_args=max_inline_args, - indent_size=indent_size, - ) - - self._init_exclusions(excluded or ()) - - self.formatter_class = formatter - self.checker_class = checker - - def _init_config( - self, - config: str | None, - max_def_length: int | None, - max_inline_args: int | None, - indent_size: int | None, - ) -> None: - self.max_def_length = max_def_length - self.max_inline_args = max_inline_args - self.indent_size = indent_size - - if not config: - config = find_pyproject_toml() - - config_excluded: list[str] = [] - - if config: - try: - with Path.open(Path(config), 'rb') as f: - click.secho(f'Using config: {config}', fg='yellow') - config_data = tomli.load(f) - - config_def = config_data.get('tool', {}).get('def-form', {}) - - self.max_def_length = config_def.get( - 'max_def_length', - self.max_def_length, - ) - self.max_inline_args = config_def.get( - 'max_inline_args', - self.max_inline_args, - ) - - self.indent_size = config_def.get( - 'indent_size', - self.indent_size, - ) - - config_excluded = config_def.get('exclude', []) - except (FileNotFoundError, tomli.TOMLDecodeError) as e: - click.secho(f'Error loading config {config}: {e}', fg='red') - self.is_config_found = False - - self._config_excluded = config_excluded - - def _init_exclusions(self, cli_excluded: tuple[str, ...]) -> None: - self.excluded: set[Path] = set() - - for p in (*cli_excluded, *self._config_excluded): - try: - excluded_path = Path(p).resolve() - self.excluded.add(excluded_path) - except Exception: - click.secho(f'Warning: invalid excluded path: {p}', fg='yellow') - - def _iter_py_files(self) -> Generator[str, None, None]: - if self.path.is_file(): - if self.path.suffix != '.py': - return - - if self._is_excluded(self.path): - if self.show_skipped: - click.secho(f'SKIPPED {self.path}', fg='yellow') - return - - click.secho(f'Processing: {self.path}', fg='green') - yield str(self.path) - return - - for root, dirs, files in os.walk(self.path): - root_path = Path(root) - - dirs[:] = [d for d in dirs if not self._is_excluded(root_path / d)] - - for filename in files: - if not filename.endswith('.py'): - continue - - file_path = root_path / filename - - if self._is_excluded(file_path): - if self.show_skipped: - click.secho(f'SKIPPED {file_path}', fg='yellow') - continue - - click.secho(f'Processing: {file_path}', fg='green') - yield str(file_path) - - def _is_excluded(self, path: Path) -> bool: - for excluded in self.excluded: - try: - path.relative_to(excluded) - return True - except ValueError: - pass - - if excluded.name in path.parts: - return True - - return False - - def _create_processor( - self, processor_class: type[DefFormatter] | type[DefChecker], filepath: str - ) -> DefFormatter | DefChecker: - return processor_class( - filepath=filepath, - max_def_length=self.max_def_length, - max_inline_args=self.max_inline_args, - indent_size=self.indent_size, - ) - - def _process_file( - self, - filepath: str, - processor_class: type[DefFormatter] | type[DefChecker], - ) -> tuple[cst.Module | None, list[BaseDefFormException]]: - try: - with Path.open(Path(filepath), encoding='utf-8') as f: - code = f.read() - except (OSError, UnicodeDecodeError) as e: - click.secho(f'Error reading {filepath}: {e}', fg='red') - return None, [] - - try: - tree = cst.parse_module(code) - wrapper = cst.metadata.MetadataWrapper(tree) - processor = self._create_processor(processor_class, filepath) - - if issubclass(processor_class, DefFormatter): - new_tree = wrapper.visit(processor) - return new_tree, processor.issues - wrapper.visit(processor) - return None, processor.issues - - except cst.ParserSyntaxError as e: - click.secho(f'Syntax error in {filepath}: {e}', fg='red') - return None, [] - except Exception as e: - click.secho(f'Unexpected error processing {filepath}: {e}', fg='red') - return None, [] - - def format(self, write_to: str | None = None) -> None: - processed_count = 0 - self.issues.clear() - - for filepath in self._iter_py_files(): - processed_count += 1 - new_tree, file_issues = self._process_file(filepath, self.formatter_class) - - self.issues.extend(file_issues) - - if new_tree is not None: - try: - with Path.open(Path(write_to or filepath), 'w', encoding='utf-8') as f: - f.write(new_tree.code) - except OSError as e: - click.secho(f'Error writing {filepath}: {e}', fg='red') - - self._echo_summary('format', processed_count) - - def check(self) -> None: - processed_count = 0 - self.issues.clear() - - for filepath in self._iter_py_files(): - processed_count += 1 - _, file_issues = self._process_file(filepath, self.checker_class) - self.issues.extend(file_issues) - - self._echo_summary('check', processed_count) - - if self.issues: - raise BaseDefFormException - - def _echo_summary(self, mode: str, processed_count: int) -> None: - click.echo('\n') - - if self.issues: - click.secho('Issues:', fg='yellow', bold=True) - for i, issue in enumerate(self.issues, 1): - click.secho(f'{issue.path}', color=True) - click.secho(f' {issue.message}', fg='white') - if i != len(self.issues): - click.echo('') - - click.echo('') - click.secho(f'{mode.capitalize()} Summary:', fg='cyan', bold=True) - click.echo(f'Processed files: {processed_count}') - click.echo(f'Issues found: {len(self.issues)}') diff --git a/pyproject.toml b/pyproject.toml index 2b6e829..163f78f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "def-form" -version = "0.1.0" +version = "0.2.0" description = "Formatter for functions" readme = "README.md" requires-python = ">=3.10" @@ -33,6 +33,7 @@ classifiers = [ dependencies = [ "click>=8.2.1", "libcst>=1.8.2", + "rich>=14.3.1", "tomli>=2.4.0", ] @@ -63,6 +64,7 @@ indent_size = 4 exclude = [ ".venv/", "build/", + "tests/cases/" ] [tool.mypy] @@ -78,8 +80,7 @@ warn_unused_ignores = true no_implicit_reexport = true exclude = [ ".venv/", - "tests/mock_data/example.py", - "tests/mock_data/expected.py", + "tests/cases/", ] [tool.ruff] @@ -87,8 +88,7 @@ target-version = "py310" line-length = 120 exclude = [ ".venv/", - "tests/mock_data/example.py", - "tests/mock_data/expected.py", + "tests/cases/", ] lint.flake8-tidy-imports.ban-relative-imports = "all" lint.mccabe.max-complexity = 20 diff --git a/tests/cases/__init__.py b/tests/cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/async_def/__init__.py b/tests/cases/async_def/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/async_def/source.py b/tests/cases/async_def/source.py new file mode 100644 index 0000000..415e2f9 --- /dev/null +++ b/tests/cases/async_def/source.py @@ -0,0 +1,2 @@ +async def async_def(): + return diff --git a/tests/cases/async_def_with_args/__init__.py b/tests/cases/async_def_with_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/async_def_with_args/expected.py b/tests/cases/async_def_with_args/expected.py new file mode 100644 index 0000000..11cb779 --- /dev/null +++ b/tests/cases/async_def_with_args/expected.py @@ -0,0 +1,6 @@ +async def async_def_with_args( + a, + b, + c, +): + return diff --git a/tests/cases/async_def_with_args/expected_issues.json b/tests/cases/async_def_with_args/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/async_def_with_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/async_def_with_args/source.py b/tests/cases/async_def_with_args/source.py new file mode 100644 index 0000000..241c959 --- /dev/null +++ b/tests/cases/async_def_with_args/source.py @@ -0,0 +1,2 @@ +async def async_def_with_args(a, b, c): + return diff --git a/tests/cases/async_multiline_bad_comment/__init__.py b/tests/cases/async_multiline_bad_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/async_multiline_bad_comment/expected.py b/tests/cases/async_multiline_bad_comment/expected.py new file mode 100644 index 0000000..08b3daa --- /dev/null +++ b/tests/cases/async_multiline_bad_comment/expected.py @@ -0,0 +1,6 @@ +async def async_single_line_with_comment( + a, # async with comment + b, + c, +): + return diff --git a/tests/cases/async_multiline_bad_comment/expected_issues.json b/tests/cases/async_multiline_bad_comment/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/async_multiline_bad_comment/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/async_multiline_bad_comment/source.py b/tests/cases/async_multiline_bad_comment/source.py new file mode 100644 index 0000000..a13d222 --- /dev/null +++ b/tests/cases/async_multiline_bad_comment/source.py @@ -0,0 +1,3 @@ +async def async_single_line_with_comment(a, # async with comment + b, c): + return diff --git a/tests/cases/class_async_def_with_args/__init__.py b/tests/cases/class_async_def_with_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_async_def_with_args/expected.py b/tests/cases/class_async_def_with_args/expected.py new file mode 100644 index 0000000..771400f --- /dev/null +++ b/tests/cases/class_async_def_with_args/expected.py @@ -0,0 +1,8 @@ +class ClassWithFunctions: + async def async_def_with_args( + self, + a, + b, + c, + ): + return diff --git a/tests/cases/class_async_def_with_args/expected_issues.json b/tests/cases/class_async_def_with_args/expected_issues.json new file mode 100644 index 0000000..44ec0e2 --- /dev/null +++ b/tests/cases/class_async_def_with_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/class_async_def_with_args/source.py b/tests/cases/class_async_def_with_args/source.py new file mode 100644 index 0000000..0494510 --- /dev/null +++ b/tests/cases/class_async_def_with_args/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + async def async_def_with_args(self, a, b, c): + return diff --git a/tests/cases/class_def_with_kw_args/__init__.py b/tests/cases/class_def_with_kw_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_def_with_kw_args/expected.py b/tests/cases/class_def_with_kw_args/expected.py new file mode 100644 index 0000000..4ab1e83 --- /dev/null +++ b/tests/cases/class_def_with_kw_args/expected.py @@ -0,0 +1,10 @@ +class ClassWithFunctions: + def def_with_kw_args( + self, + a, + b, + c, + *args, + **kwargs, + ): + return diff --git a/tests/cases/class_def_with_kw_args/expected_issues.json b/tests/cases/class_def_with_kw_args/expected_issues.json new file mode 100644 index 0000000..44ec0e2 --- /dev/null +++ b/tests/cases/class_def_with_kw_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/class_def_with_kw_args/source.py b/tests/cases/class_def_with_kw_args/source.py new file mode 100644 index 0000000..379ee44 --- /dev/null +++ b/tests/cases/class_def_with_kw_args/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def def_with_kw_args(self, a, b, c, *args, **kwargs): + return diff --git a/tests/cases/class_def_with_kw_only_args/__init__.py b/tests/cases/class_def_with_kw_only_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_def_with_kw_only_args/expected.py b/tests/cases/class_def_with_kw_only_args/expected.py new file mode 100644 index 0000000..20c9fae --- /dev/null +++ b/tests/cases/class_def_with_kw_only_args/expected.py @@ -0,0 +1,9 @@ +class ClassWithFunctions: + def def_with_kw_only_args( + self, + *args, + b: str, + s: str, + **kwargs, + ): + return diff --git a/tests/cases/class_def_with_kw_only_args/expected_issues.json b/tests/cases/class_def_with_kw_only_args/expected_issues.json new file mode 100644 index 0000000..44ec0e2 --- /dev/null +++ b/tests/cases/class_def_with_kw_only_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/class_def_with_kw_only_args/source.py b/tests/cases/class_def_with_kw_only_args/source.py new file mode 100644 index 0000000..3c2f79b --- /dev/null +++ b/tests/cases/class_def_with_kw_only_args/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def def_with_kw_only_args(self, *args, b: str, s: str, **kwargs): + return diff --git a/tests/cases/class_method/__init__.py b/tests/cases/class_method/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method/expected.py b/tests/cases/class_method/expected.py new file mode 100644 index 0000000..caf6e76 --- /dev/null +++ b/tests/cases/class_method/expected.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def class_method(self): + return diff --git a/tests/cases/class_method/source.py b/tests/cases/class_method/source.py new file mode 100644 index 0000000..caf6e76 --- /dev/null +++ b/tests/cases/class_method/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def class_method(self): + return diff --git a/tests/cases/class_method_kw_only_with_comments/__init__.py b/tests/cases/class_method_kw_only_with_comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_kw_only_with_comments/expected.py b/tests/cases/class_method_kw_only_with_comments/expected.py new file mode 100644 index 0000000..88c4489 --- /dev/null +++ b/tests/cases/class_method_kw_only_with_comments/expected.py @@ -0,0 +1,9 @@ +class ClassWithFunctions: + def class_method_kw_only_with_comments( + self, + *args, # varargs in class + b: str, # keyword-only in class + s: str, # another keyword-only + **kwargs, + ): # kwargs in class + return diff --git a/tests/cases/class_method_kw_only_with_comments/expected_issues.json b/tests/cases/class_method_kw_only_with_comments/expected_issues.json new file mode 100644 index 0000000..fec236f --- /dev/null +++ b/tests/cases/class_method_kw_only_with_comments/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/class_method_kw_only_with_comments/source.py b/tests/cases/class_method_kw_only_with_comments/source.py new file mode 100644 index 0000000..2a78228 --- /dev/null +++ b/tests/cases/class_method_kw_only_with_comments/source.py @@ -0,0 +1,6 @@ +class ClassWithFunctions: + def class_method_kw_only_with_comments(self, *args, # varargs in class + b: str, # keyword-only in class + s: str, # another keyword-only + **kwargs): # kwargs in class + return diff --git a/tests/cases/class_method_long_name/__init__.py b/tests/cases/class_method_long_name/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_long_name/expected.py b/tests/cases/class_method_long_name/expected.py new file mode 100644 index 0000000..8c92e3b --- /dev/null +++ b/tests/cases/class_method_long_name/expected.py @@ -0,0 +1,5 @@ +class ClassWithFunctions: + def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name( + self, + ): + return diff --git a/tests/cases/class_method_long_name/expected_issues.json b/tests/cases/class_method_long_name/expected_issues.json new file mode 100644 index 0000000..940735b --- /dev/null +++ b/tests/cases/class_method_long_name/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "DefStringTooLongException"}] diff --git a/tests/cases/class_method_long_name/source.py b/tests/cases/class_method_long_name/source.py new file mode 100644 index 0000000..bd47826 --- /dev/null +++ b/tests/cases/class_method_long_name/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name(self): + return diff --git a/tests/cases/class_method_multiline_bad/__init__.py b/tests/cases/class_method_multiline_bad/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_multiline_bad/expected.py b/tests/cases/class_method_multiline_bad/expected.py new file mode 100644 index 0000000..6e9aee8 --- /dev/null +++ b/tests/cases/class_method_multiline_bad/expected.py @@ -0,0 +1,8 @@ +class ClassWithFunctions: + def class_method_single_line_with_comment( + self, + a, # class method comment + b, + c, + ): + return diff --git a/tests/cases/class_method_multiline_bad/expected_issues.json b/tests/cases/class_method_multiline_bad/expected_issues.json new file mode 100644 index 0000000..fec236f --- /dev/null +++ b/tests/cases/class_method_multiline_bad/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/class_method_multiline_bad/source.py b/tests/cases/class_method_multiline_bad/source.py new file mode 100644 index 0000000..8940b3b --- /dev/null +++ b/tests/cases/class_method_multiline_bad/source.py @@ -0,0 +1,4 @@ +class ClassWithFunctions: + def class_method_single_line_with_comment(self, a, # class method comment + b, c): + return diff --git a/tests/cases/class_method_skipped/__init__.py b/tests/cases/class_method_skipped/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_skipped/source.py b/tests/cases/class_method_skipped/source.py new file mode 100644 index 0000000..981a4a5 --- /dev/null +++ b/tests/cases/class_method_skipped/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def skipped_with_right_comment(self, a, b, c): # def-form: skip + return diff --git a/tests/cases/class_method_with_args/__init__.py b/tests/cases/class_method_with_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_with_args/expected.py b/tests/cases/class_method_with_args/expected.py new file mode 100644 index 0000000..98bcab3 --- /dev/null +++ b/tests/cases/class_method_with_args/expected.py @@ -0,0 +1,7 @@ +class ClassWithFunctions: + def class_method_with_args( + self, + a, + b, + ): + return diff --git a/tests/cases/class_method_with_args/expected_issues.json b/tests/cases/class_method_with_args/expected_issues.json new file mode 100644 index 0000000..44ec0e2 --- /dev/null +++ b/tests/cases/class_method_with_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/class_method_with_args/source.py b/tests/cases/class_method_with_args/source.py new file mode 100644 index 0000000..08d90a9 --- /dev/null +++ b/tests/cases/class_method_with_args/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def class_method_with_args(self, a, b): + return diff --git a/tests/cases/class_method_with_args_with_typehints/__init__.py b/tests/cases/class_method_with_args_with_typehints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_method_with_args_with_typehints/expected.py b/tests/cases/class_method_with_args_with_typehints/expected.py new file mode 100644 index 0000000..b3c7ecf --- /dev/null +++ b/tests/cases/class_method_with_args_with_typehints/expected.py @@ -0,0 +1,11 @@ +from typing import Optional + + +class ClassWithFunctions: + def class_method_with_args_with_typehints( + self, + a: Optional[int], + b: int, + c, + ): + return diff --git a/tests/cases/class_method_with_args_with_typehints/expected_issues.json b/tests/cases/class_method_with_args_with_typehints/expected_issues.json new file mode 100644 index 0000000..3360185 --- /dev/null +++ b/tests/cases/class_method_with_args_with_typehints/expected_issues.json @@ -0,0 +1 @@ +[{"line": 5, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/class_method_with_args_with_typehints/source.py b/tests/cases/class_method_with_args_with_typehints/source.py new file mode 100644 index 0000000..328cf4d --- /dev/null +++ b/tests/cases/class_method_with_args_with_typehints/source.py @@ -0,0 +1,6 @@ +from typing import Optional + + +class ClassWithFunctions: + def class_method_with_args_with_typehints(self, a: Optional[int], b: int, c): + return diff --git a/tests/cases/class_static_method/__init__.py b/tests/cases/class_static_method/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/class_static_method/expected.py b/tests/cases/class_static_method/expected.py new file mode 100644 index 0000000..6753512 --- /dev/null +++ b/tests/cases/class_static_method/expected.py @@ -0,0 +1,4 @@ +class ClassWithFunctions: + @staticmethod + def static_method(): + return diff --git a/tests/cases/class_static_method/source.py b/tests/cases/class_static_method/source.py new file mode 100644 index 0000000..6753512 --- /dev/null +++ b/tests/cases/class_static_method/source.py @@ -0,0 +1,4 @@ +class ClassWithFunctions: + @staticmethod + def static_method(): + return diff --git a/tests/cases/decorated_multiline_bad/__init__.py b/tests/cases/decorated_multiline_bad/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/decorated_multiline_bad/expected.py b/tests/cases/decorated_multiline_bad/expected.py new file mode 100644 index 0000000..4b355a8 --- /dev/null +++ b/tests/cases/decorated_multiline_bad/expected.py @@ -0,0 +1,16 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def single_line_decorated_with_comment( + a, # decorated with comment + b, + c: Optional[str], +): + return diff --git a/tests/cases/decorated_multiline_bad/expected_issues.json b/tests/cases/decorated_multiline_bad/expected_issues.json new file mode 100644 index 0000000..1aadd0d --- /dev/null +++ b/tests/cases/decorated_multiline_bad/expected_issues.json @@ -0,0 +1 @@ +[{"line": 11, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/decorated_multiline_bad/source.py b/tests/cases/decorated_multiline_bad/source.py new file mode 100644 index 0000000..87ea85d --- /dev/null +++ b/tests/cases/decorated_multiline_bad/source.py @@ -0,0 +1,13 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def single_line_decorated_with_comment(a, # decorated with comment + b, c: Optional[str]): + return diff --git a/tests/cases/def_kw_args_first/__init__.py b/tests/cases/def_kw_args_first/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/def_kw_args_first/expected.py b/tests/cases/def_kw_args_first/expected.py new file mode 100644 index 0000000..4060bb6 --- /dev/null +++ b/tests/cases/def_kw_args_first/expected.py @@ -0,0 +1,7 @@ +def def_kw_args_first( + a, + *args, + b, + **kwargs, +): + return diff --git a/tests/cases/def_kw_args_first/expected_issues.json b/tests/cases/def_kw_args_first/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/def_kw_args_first/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/def_kw_args_first/source.py b/tests/cases/def_kw_args_first/source.py new file mode 100644 index 0000000..4b83ab7 --- /dev/null +++ b/tests/cases/def_kw_args_first/source.py @@ -0,0 +1,2 @@ +def def_kw_args_first(a, *args, b, **kwargs): + return diff --git a/tests/cases/def_too_long/__init__.py b/tests/cases/def_too_long/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/def_too_long/expected.py b/tests/cases/def_too_long/expected.py new file mode 100644 index 0000000..d407cbe --- /dev/null +++ b/tests/cases/def_too_long/expected.py @@ -0,0 +1,4 @@ +def loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_def_name( + a, +): + return diff --git a/tests/cases/def_too_long/expected_issues.json b/tests/cases/def_too_long/expected_issues.json new file mode 100644 index 0000000..ab60a89 --- /dev/null +++ b/tests/cases/def_too_long/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "DefStringTooLongException"}] diff --git a/tests/cases/def_too_long/source.py b/tests/cases/def_too_long/source.py new file mode 100644 index 0000000..fbbce42 --- /dev/null +++ b/tests/cases/def_too_long/source.py @@ -0,0 +1,2 @@ +def loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_def_name(a): + return diff --git a/tests/cases/def_with_decorator/__init__.py b/tests/cases/def_with_decorator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/def_with_decorator/expected.py b/tests/cases/def_with_decorator/expected.py new file mode 100644 index 0000000..33c928e --- /dev/null +++ b/tests/cases/def_with_decorator/expected.py @@ -0,0 +1,12 @@ +from typing import Callable + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def def_with_decorator(): + return diff --git a/tests/cases/def_with_decorator/source.py b/tests/cases/def_with_decorator/source.py new file mode 100644 index 0000000..33c928e --- /dev/null +++ b/tests/cases/def_with_decorator/source.py @@ -0,0 +1,12 @@ +from typing import Callable + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def def_with_decorator(): + return diff --git a/tests/cases/def_with_decorator_and_args/__init__.py b/tests/cases/def_with_decorator_and_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/def_with_decorator_and_args/expected.py b/tests/cases/def_with_decorator_and_args/expected.py new file mode 100644 index 0000000..b801399 --- /dev/null +++ b/tests/cases/def_with_decorator_and_args/expected.py @@ -0,0 +1,16 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def def_with_decorator_and_args( + a, + b, + c: Optional[str], +): + return diff --git a/tests/cases/def_with_decorator_and_args/expected_issues.json b/tests/cases/def_with_decorator_and_args/expected_issues.json new file mode 100644 index 0000000..9c8b7a3 --- /dev/null +++ b/tests/cases/def_with_decorator_and_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 11, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/def_with_decorator_and_args/source.py b/tests/cases/def_with_decorator_and_args/source.py new file mode 100644 index 0000000..cfcdc03 --- /dev/null +++ b/tests/cases/def_with_decorator_and_args/source.py @@ -0,0 +1,12 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def def_with_decorator_and_args(a, b, c: Optional[str]): + return diff --git a/tests/cases/kw_args/__init__.py b/tests/cases/kw_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/kw_args/expected.py b/tests/cases/kw_args/expected.py new file mode 100644 index 0000000..d26259f --- /dev/null +++ b/tests/cases/kw_args/expected.py @@ -0,0 +1,7 @@ +def kw_args( + def_, + with_, + *args, + **kwargs, +): + return diff --git a/tests/cases/kw_args/expected_issues.json b/tests/cases/kw_args/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/kw_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/kw_args/source.py b/tests/cases/kw_args/source.py new file mode 100644 index 0000000..42a3345 --- /dev/null +++ b/tests/cases/kw_args/source.py @@ -0,0 +1,2 @@ +def kw_args(def_, with_, *args, **kwargs): + return diff --git a/tests/cases/long_typehint_no_args/__init__.py b/tests/cases/long_typehint_no_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/long_typehint_no_args/source.py b/tests/cases/long_typehint_no_args/source.py new file mode 100644 index 0000000..5264356 --- /dev/null +++ b/tests/cases/long_typehint_no_args/source.py @@ -0,0 +1,5 @@ +from typing import Optional, Union + + +def long_typehint_without_args() -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: + return diff --git a/tests/cases/long_typehint_with_args/__init__.py b/tests/cases/long_typehint_with_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/long_typehint_with_args/expected.py b/tests/cases/long_typehint_with_args/expected.py new file mode 100644 index 0000000..28abb93 --- /dev/null +++ b/tests/cases/long_typehint_with_args/expected.py @@ -0,0 +1,7 @@ +from typing import Optional, Union + + +def long_typehint_with_args( + a, +) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: + return diff --git a/tests/cases/long_typehint_with_args/expected_issues.json b/tests/cases/long_typehint_with_args/expected_issues.json new file mode 100644 index 0000000..fd1c0ff --- /dev/null +++ b/tests/cases/long_typehint_with_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 4, "type": "DefStringTooLongException"}] diff --git a/tests/cases/long_typehint_with_args/source.py b/tests/cases/long_typehint_with_args/source.py new file mode 100644 index 0000000..260af8e --- /dev/null +++ b/tests/cases/long_typehint_with_args/source.py @@ -0,0 +1,5 @@ +from typing import Optional, Union + + +def long_typehint_with_args(a) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: + return diff --git a/tests/cases/multiline_bad_indent/__init__.py b/tests/cases/multiline_bad_indent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/multiline_bad_indent/expected.py b/tests/cases/multiline_bad_indent/expected.py new file mode 100644 index 0000000..e50c061 --- /dev/null +++ b/tests/cases/multiline_bad_indent/expected.py @@ -0,0 +1,5 @@ +def wrong_formatted_def( + a, + b: int | None = None, +): + return diff --git a/tests/cases/multiline_bad_indent/expected_issues.json b/tests/cases/multiline_bad_indent/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/multiline_bad_indent/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/multiline_bad_indent/source.py b/tests/cases/multiline_bad_indent/source.py new file mode 100644 index 0000000..77a87e3 --- /dev/null +++ b/tests/cases/multiline_bad_indent/source.py @@ -0,0 +1,5 @@ +def wrong_formatted_def( + a, + b: int | None = None, +): + return diff --git a/tests/cases/no_issues_already_formatted/__init__.py b/tests/cases/no_issues_already_formatted/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/no_issues_already_formatted/expected.py b/tests/cases/no_issues_already_formatted/expected.py new file mode 100644 index 0000000..dccc6d8 --- /dev/null +++ b/tests/cases/no_issues_already_formatted/expected.py @@ -0,0 +1,6 @@ +def formatted_def( + a, + b: int | None = None, + c: str = "", +): + return diff --git a/tests/cases/no_issues_already_formatted/source.py b/tests/cases/no_issues_already_formatted/source.py new file mode 100644 index 0000000..dccc6d8 --- /dev/null +++ b/tests/cases/no_issues_already_formatted/source.py @@ -0,0 +1,6 @@ +def formatted_def( + a, + b: int | None = None, + c: str = "", +): + return diff --git a/tests/cases/no_issues_empty_def/__init__.py b/tests/cases/no_issues_empty_def/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/no_issues_empty_def/source.py b/tests/cases/no_issues_empty_def/source.py new file mode 100644 index 0000000..3698a47 --- /dev/null +++ b/tests/cases/no_issues_empty_def/source.py @@ -0,0 +1,2 @@ +def clear_def(): + return diff --git a/tests/cases/single_line_comment_first/__init__.py b/tests/cases/single_line_comment_first/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_comment_first/expected.py b/tests/cases/single_line_comment_first/expected.py new file mode 100644 index 0000000..d08ef51 --- /dev/null +++ b/tests/cases/single_line_comment_first/expected.py @@ -0,0 +1,6 @@ +def single_line_with_comment_first( + a, # first param comment + b, + c, +): + return diff --git a/tests/cases/single_line_comment_first/expected_issues.json b/tests/cases/single_line_comment_first/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/single_line_comment_first/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/single_line_comment_first/source.py b/tests/cases/single_line_comment_first/source.py new file mode 100644 index 0000000..80dd9ff --- /dev/null +++ b/tests/cases/single_line_comment_first/source.py @@ -0,0 +1,3 @@ +def single_line_with_comment_first(a, # first param comment + b, c): + return diff --git a/tests/cases/single_line_comment_last/__init__.py b/tests/cases/single_line_comment_last/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_comment_last/expected.py b/tests/cases/single_line_comment_last/expected.py new file mode 100644 index 0000000..7382201 --- /dev/null +++ b/tests/cases/single_line_comment_last/expected.py @@ -0,0 +1,6 @@ +def single_line_with_comment_last( + a, + b, + c, +): # end comment + return diff --git a/tests/cases/single_line_comment_last/expected_issues.json b/tests/cases/single_line_comment_last/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/single_line_comment_last/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/single_line_comment_last/source.py b/tests/cases/single_line_comment_last/source.py new file mode 100644 index 0000000..4beb87a --- /dev/null +++ b/tests/cases/single_line_comment_last/source.py @@ -0,0 +1,2 @@ +def single_line_with_comment_last(a, b, c): # end comment + return diff --git a/tests/cases/single_line_comment_middle/__init__.py b/tests/cases/single_line_comment_middle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_comment_middle/expected.py b/tests/cases/single_line_comment_middle/expected.py new file mode 100644 index 0000000..4e0c557 --- /dev/null +++ b/tests/cases/single_line_comment_middle/expected.py @@ -0,0 +1,6 @@ +def single_line_with_comment_middle( + a, + b, # middle param comment + c, +): + return diff --git a/tests/cases/single_line_comment_middle/expected_issues.json b/tests/cases/single_line_comment_middle/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/single_line_comment_middle/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/single_line_comment_middle/source.py b/tests/cases/single_line_comment_middle/source.py new file mode 100644 index 0000000..3e53c6e --- /dev/null +++ b/tests/cases/single_line_comment_middle/source.py @@ -0,0 +1,3 @@ +def single_line_with_comment_middle(a, b, # middle param comment + c): + return diff --git a/tests/cases/single_line_kw_only_with_comments/__init__.py b/tests/cases/single_line_kw_only_with_comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_kw_only_with_comments/expected.py b/tests/cases/single_line_kw_only_with_comments/expected.py new file mode 100644 index 0000000..756267e --- /dev/null +++ b/tests/cases/single_line_kw_only_with_comments/expected.py @@ -0,0 +1,8 @@ +def single_line_kw_only_with_comments( + a, + *args, # varargs + b: str, # keyword-only + c: int = 10, # keyword-only with default + **kwargs, +): # kwargs + return diff --git a/tests/cases/single_line_kw_only_with_comments/expected_issues.json b/tests/cases/single_line_kw_only_with_comments/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/single_line_kw_only_with_comments/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/single_line_kw_only_with_comments/source.py b/tests/cases/single_line_kw_only_with_comments/source.py new file mode 100644 index 0000000..c7c39af --- /dev/null +++ b/tests/cases/single_line_kw_only_with_comments/source.py @@ -0,0 +1,5 @@ +def single_line_kw_only_with_comments(a, *args, # varargs + b: str, # keyword-only + c: int = 10, # keyword-only with default + **kwargs): # kwargs + return diff --git a/tests/cases/single_line_needs_format/__init__.py b/tests/cases/single_line_needs_format/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_needs_format/expected.py b/tests/cases/single_line_needs_format/expected.py new file mode 100644 index 0000000..cfb4147 --- /dev/null +++ b/tests/cases/single_line_needs_format/expected.py @@ -0,0 +1,6 @@ +def single_line_with_comment( + a, + b, + c, +): # first + return diff --git a/tests/cases/single_line_needs_format/expected_issues.json b/tests/cases/single_line_needs_format/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/single_line_needs_format/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/single_line_needs_format/source.py b/tests/cases/single_line_needs_format/source.py new file mode 100644 index 0000000..cea48e8 --- /dev/null +++ b/tests/cases/single_line_needs_format/source.py @@ -0,0 +1,2 @@ +def single_line_with_comment(a, b, c): # first + return diff --git a/tests/cases/single_line_ok_no_format/__init__.py b/tests/cases/single_line_ok_no_format/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_ok_no_format/expected.py b/tests/cases/single_line_ok_no_format/expected.py new file mode 100644 index 0000000..51f1aee --- /dev/null +++ b/tests/cases/single_line_ok_no_format/expected.py @@ -0,0 +1,2 @@ +def ok_two_args(a, b): + return diff --git a/tests/cases/single_line_ok_no_format/source.py b/tests/cases/single_line_ok_no_format/source.py new file mode 100644 index 0000000..51f1aee --- /dev/null +++ b/tests/cases/single_line_ok_no_format/source.py @@ -0,0 +1,2 @@ +def ok_two_args(a, b): + return diff --git a/tests/cases/single_line_return_type_comment/__init__.py b/tests/cases/single_line_return_type_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_return_type_comment/expected.py b/tests/cases/single_line_return_type_comment/expected.py new file mode 100644 index 0000000..419fc8f --- /dev/null +++ b/tests/cases/single_line_return_type_comment/expected.py @@ -0,0 +1,2 @@ +def single_line_with_return_type_comment(a: int, b: str) -> bool: # return type comment + return True diff --git a/tests/cases/single_line_return_type_comment/source.py b/tests/cases/single_line_return_type_comment/source.py new file mode 100644 index 0000000..419fc8f --- /dev/null +++ b/tests/cases/single_line_return_type_comment/source.py @@ -0,0 +1,2 @@ +def single_line_with_return_type_comment(a: int, b: str) -> bool: # return type comment + return True diff --git a/tests/cases/single_line_with_all_features/__init__.py b/tests/cases/single_line_with_all_features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/single_line_with_all_features/expected.py b/tests/cases/single_line_with_all_features/expected.py new file mode 100644 index 0000000..c177bf8 --- /dev/null +++ b/tests/cases/single_line_with_all_features/expected.py @@ -0,0 +1,8 @@ +def single_line_with_all_features( + a: int, # typed with comment + b: str = "default", # default with comment + *args, # varargs with comment + c: int = 10, # keyword-only with comment + **kwargs, +) -> bool: # kwargs and return type + return True diff --git a/tests/cases/single_line_with_all_features/expected_issues.json b/tests/cases/single_line_with_all_features/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/single_line_with_all_features/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/single_line_with_all_features/source.py b/tests/cases/single_line_with_all_features/source.py new file mode 100644 index 0000000..d7dbf7b --- /dev/null +++ b/tests/cases/single_line_with_all_features/source.py @@ -0,0 +1,6 @@ +def single_line_with_all_features(a: int, # typed with comment + b: str = "default", # default with comment + *args, # varargs with comment + c: int = 10, # keyword-only with comment + **kwargs) -> bool: # kwargs and return type + return True diff --git a/tests/cases/skip_comment/__init__.py b/tests/cases/skip_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/skip_comment/source.py b/tests/cases/skip_comment/source.py new file mode 100644 index 0000000..944287f --- /dev/null +++ b/tests/cases/skip_comment/source.py @@ -0,0 +1,3 @@ +# def-form: skip +def skipped_with_up_comment(a, b, c): + return diff --git a/tests/cases/skipped_with_decorator/__init__.py b/tests/cases/skipped_with_decorator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/skipped_with_decorator/source.py b/tests/cases/skipped_with_decorator/source.py new file mode 100644 index 0000000..9d904bf --- /dev/null +++ b/tests/cases/skipped_with_decorator/source.py @@ -0,0 +1,12 @@ +from typing import Callable + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def skipped_with_up_comment_and_decorator(a, b, c): # def-form: skip + return diff --git a/tests/cases/skipped_with_right_comment/__init__.py b/tests/cases/skipped_with_right_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/skipped_with_right_comment/source.py b/tests/cases/skipped_with_right_comment/source.py new file mode 100644 index 0000000..ee316be --- /dev/null +++ b/tests/cases/skipped_with_right_comment/source.py @@ -0,0 +1,2 @@ +def skipped_with_right_comment(a, b, c): # def-form: skip + return diff --git a/tests/cases/to_much_inline_args_with_typehints/__init__.py b/tests/cases/to_much_inline_args_with_typehints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/to_much_inline_args_with_typehints/expected.py b/tests/cases/to_much_inline_args_with_typehints/expected.py new file mode 100644 index 0000000..3ef3b8b --- /dev/null +++ b/tests/cases/to_much_inline_args_with_typehints/expected.py @@ -0,0 +1,7 @@ +def to_much_inline_args_with_typehints( + to: str, + much, + inline: int, + arguments, +): + return diff --git a/tests/cases/to_much_inline_args_with_typehints/expected_issues.json b/tests/cases/to_much_inline_args_with_typehints/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/to_much_inline_args_with_typehints/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/to_much_inline_args_with_typehints/source.py b/tests/cases/to_much_inline_args_with_typehints/source.py new file mode 100644 index 0000000..dbd7993 --- /dev/null +++ b/tests/cases/to_much_inline_args_with_typehints/source.py @@ -0,0 +1,2 @@ +def to_much_inline_args_with_typehints(to: str, much, inline: int, arguments): + return diff --git a/tests/cases/too_many_inline_args/__init__.py b/tests/cases/too_many_inline_args/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/too_many_inline_args/expected.py b/tests/cases/too_many_inline_args/expected.py new file mode 100644 index 0000000..be36c0e --- /dev/null +++ b/tests/cases/too_many_inline_args/expected.py @@ -0,0 +1,7 @@ +def to_much_inline_args( + to, + much, + inline, + arguments, +): + return diff --git a/tests/cases/too_many_inline_args/expected_issues.json b/tests/cases/too_many_inline_args/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/too_many_inline_args/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/too_many_inline_args/source.py b/tests/cases/too_many_inline_args/source.py new file mode 100644 index 0000000..1918d7f --- /dev/null +++ b/tests/cases/too_many_inline_args/source.py @@ -0,0 +1,2 @@ +def to_much_inline_args(to, much, inline, arguments): + return diff --git a/tests/cases/truly_async_single_line_with_comment/__init__.py b/tests/cases/truly_async_single_line_with_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_async_single_line_with_comment/expected.py b/tests/cases/truly_async_single_line_with_comment/expected.py new file mode 100644 index 0000000..21064c9 --- /dev/null +++ b/tests/cases/truly_async_single_line_with_comment/expected.py @@ -0,0 +1,6 @@ +async def truly_async_single_line_with_comment( + a, + b, + c, +): # async single line + return diff --git a/tests/cases/truly_async_single_line_with_comment/expected_issues.json b/tests/cases/truly_async_single_line_with_comment/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/truly_async_single_line_with_comment/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_async_single_line_with_comment/source.py b/tests/cases/truly_async_single_line_with_comment/source.py new file mode 100644 index 0000000..d3923f5 --- /dev/null +++ b/tests/cases/truly_async_single_line_with_comment/source.py @@ -0,0 +1,2 @@ +async def truly_async_single_line_with_comment(a, b, c): # async single line + return diff --git a/tests/cases/truly_class_method_kw_only/__init__.py b/tests/cases/truly_class_method_kw_only/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_class_method_kw_only/expected.py b/tests/cases/truly_class_method_kw_only/expected.py new file mode 100644 index 0000000..ccee8b0 --- /dev/null +++ b/tests/cases/truly_class_method_kw_only/expected.py @@ -0,0 +1,9 @@ +class ClassWithFunctions: + def truly_class_method_kw_only( + self, + *args, + b: str, + s: str, + **kwargs, + ): # kw-only in class single line + return diff --git a/tests/cases/truly_class_method_kw_only/expected_issues.json b/tests/cases/truly_class_method_kw_only/expected_issues.json new file mode 100644 index 0000000..6c4f368 --- /dev/null +++ b/tests/cases/truly_class_method_kw_only/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "DefStringTooLongException"}, {"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_class_method_kw_only/source.py b/tests/cases/truly_class_method_kw_only/source.py new file mode 100644 index 0000000..7d1116b --- /dev/null +++ b/tests/cases/truly_class_method_kw_only/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def truly_class_method_kw_only(self, *args, b: str, s: str, **kwargs): # kw-only in class single line + return diff --git a/tests/cases/truly_class_method_single_line/__init__.py b/tests/cases/truly_class_method_single_line/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_class_method_single_line/expected.py b/tests/cases/truly_class_method_single_line/expected.py new file mode 100644 index 0000000..42c66dd --- /dev/null +++ b/tests/cases/truly_class_method_single_line/expected.py @@ -0,0 +1,8 @@ +class ClassWithFunctions: + def truly_class_method_single_line( + self, + a, + b, + c, + ): # class method single line + return diff --git a/tests/cases/truly_class_method_single_line/expected_issues.json b/tests/cases/truly_class_method_single_line/expected_issues.json new file mode 100644 index 0000000..44ec0e2 --- /dev/null +++ b/tests/cases/truly_class_method_single_line/expected_issues.json @@ -0,0 +1 @@ +[{"line": 2, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_class_method_single_line/source.py b/tests/cases/truly_class_method_single_line/source.py new file mode 100644 index 0000000..bd5af3d --- /dev/null +++ b/tests/cases/truly_class_method_single_line/source.py @@ -0,0 +1,3 @@ +class ClassWithFunctions: + def truly_class_method_single_line(self, a, b, c): # class method single line + return diff --git a/tests/cases/truly_single_line_all_features/__init__.py b/tests/cases/truly_single_line_all_features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_all_features/expected.py b/tests/cases/truly_single_line_all_features/expected.py new file mode 100644 index 0000000..48e0e04 --- /dev/null +++ b/tests/cases/truly_single_line_all_features/expected.py @@ -0,0 +1,8 @@ +def truly_single_line_all_features( + a: int, + b: str = "default", + *args, + c: int = 10, + **kwargs, +) -> bool: # all features in one line + return True diff --git a/tests/cases/truly_single_line_all_features/expected_issues.json b/tests/cases/truly_single_line_all_features/expected_issues.json new file mode 100644 index 0000000..7f132d8 --- /dev/null +++ b/tests/cases/truly_single_line_all_features/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "DefStringTooLongException"}, {"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_single_line_all_features/source.py b/tests/cases/truly_single_line_all_features/source.py new file mode 100644 index 0000000..29a9031 --- /dev/null +++ b/tests/cases/truly_single_line_all_features/source.py @@ -0,0 +1,2 @@ +def truly_single_line_all_features(a: int, b: str = "default", *args, c: int = 10, **kwargs) -> bool: # all features in one line + return True diff --git a/tests/cases/truly_single_line_decorated/__init__.py b/tests/cases/truly_single_line_decorated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_decorated/expected.py b/tests/cases/truly_single_line_decorated/expected.py new file mode 100644 index 0000000..d02e4f9 --- /dev/null +++ b/tests/cases/truly_single_line_decorated/expected.py @@ -0,0 +1,16 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def truly_single_line_decorated( + a, + b, + c: Optional[str], +): # decorated single line + return diff --git a/tests/cases/truly_single_line_decorated/expected_issues.json b/tests/cases/truly_single_line_decorated/expected_issues.json new file mode 100644 index 0000000..9c8b7a3 --- /dev/null +++ b/tests/cases/truly_single_line_decorated/expected_issues.json @@ -0,0 +1 @@ +[{"line": 11, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_single_line_decorated/source.py b/tests/cases/truly_single_line_decorated/source.py new file mode 100644 index 0000000..b1e52a0 --- /dev/null +++ b/tests/cases/truly_single_line_decorated/source.py @@ -0,0 +1,12 @@ +from typing import Callable, Optional + + +def example_of_decorator(f: Callable) -> Callable: + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +@example_of_decorator +def truly_single_line_decorated(a, b, c: Optional[str]): # decorated single line + return diff --git a/tests/cases/truly_single_line_kw_only/__init__.py b/tests/cases/truly_single_line_kw_only/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_kw_only/expected.py b/tests/cases/truly_single_line_kw_only/expected.py new file mode 100644 index 0000000..65a696c --- /dev/null +++ b/tests/cases/truly_single_line_kw_only/expected.py @@ -0,0 +1,8 @@ +def truly_single_line_kw_only( + a, + *args, + b: str, + c: int = 10, + **kwargs, +): # kw-only in one line + return diff --git a/tests/cases/truly_single_line_kw_only/expected_issues.json b/tests/cases/truly_single_line_kw_only/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/truly_single_line_kw_only/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_single_line_kw_only/source.py b/tests/cases/truly_single_line_kw_only/source.py new file mode 100644 index 0000000..1c1c9d6 --- /dev/null +++ b/tests/cases/truly_single_line_kw_only/source.py @@ -0,0 +1,2 @@ +def truly_single_line_kw_only(a, *args, b: str, c: int = 10, **kwargs): # kw-only in one line + return diff --git a/tests/cases/truly_single_line_with_args_comment/__init__.py b/tests/cases/truly_single_line_with_args_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_with_args_comment/expected.py b/tests/cases/truly_single_line_with_args_comment/expected.py new file mode 100644 index 0000000..e90ad8f --- /dev/null +++ b/tests/cases/truly_single_line_with_args_comment/expected.py @@ -0,0 +1,6 @@ +def truly_single_line_with_args_comment( + a, + *args, + **kwargs, +): # args and kwargs + return diff --git a/tests/cases/truly_single_line_with_args_comment/expected_issues.json b/tests/cases/truly_single_line_with_args_comment/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/truly_single_line_with_args_comment/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_single_line_with_args_comment/source.py b/tests/cases/truly_single_line_with_args_comment/source.py new file mode 100644 index 0000000..e5d726f --- /dev/null +++ b/tests/cases/truly_single_line_with_args_comment/source.py @@ -0,0 +1,2 @@ +def truly_single_line_with_args_comment(a, *args, **kwargs): # args and kwargs + return diff --git a/tests/cases/truly_single_line_with_comment/__init__.py b/tests/cases/truly_single_line_with_comment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_with_comment/expected.py b/tests/cases/truly_single_line_with_comment/expected.py new file mode 100644 index 0000000..6b163d3 --- /dev/null +++ b/tests/cases/truly_single_line_with_comment/expected.py @@ -0,0 +1,6 @@ +def truly_single_line_with_comment( + a, + b, + c, +): # truly single line + return diff --git a/tests/cases/truly_single_line_with_comment/expected_issues.json b/tests/cases/truly_single_line_with_comment/expected_issues.json new file mode 100644 index 0000000..375ebee --- /dev/null +++ b/tests/cases/truly_single_line_with_comment/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "TooManyInlineArgumentsException"}] diff --git a/tests/cases/truly_single_line_with_comment/source.py b/tests/cases/truly_single_line_with_comment/source.py new file mode 100644 index 0000000..35bbec7 --- /dev/null +++ b/tests/cases/truly_single_line_with_comment/source.py @@ -0,0 +1,2 @@ +def truly_single_line_with_comment(a, b, c): # truly single line + return diff --git a/tests/cases/truly_single_line_with_return_type/__init__.py b/tests/cases/truly_single_line_with_return_type/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/truly_single_line_with_return_type/expected.py b/tests/cases/truly_single_line_with_return_type/expected.py new file mode 100644 index 0000000..3550e06 --- /dev/null +++ b/tests/cases/truly_single_line_with_return_type/expected.py @@ -0,0 +1,2 @@ +def truly_single_line_with_return_type(a: int, b: str) -> bool: # return type in one line + return True diff --git a/tests/cases/truly_single_line_with_return_type/source.py b/tests/cases/truly_single_line_with_return_type/source.py new file mode 100644 index 0000000..3550e06 --- /dev/null +++ b/tests/cases/truly_single_line_with_return_type/source.py @@ -0,0 +1,2 @@ +def truly_single_line_with_return_type(a: int, b: str) -> bool: # return type in one line + return True diff --git a/tests/cases/two_issues_same_line/__init__.py b/tests/cases/two_issues_same_line/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/two_issues_same_line/expected.py b/tests/cases/two_issues_same_line/expected.py new file mode 100644 index 0000000..5987131 --- /dev/null +++ b/tests/cases/two_issues_same_line/expected.py @@ -0,0 +1,6 @@ +def truly_single_line_with_type_and_comment( + a: int, + b: str, + c: int | None = None, +): # all in one line + return diff --git a/tests/cases/two_issues_same_line/expected_issues.json b/tests/cases/two_issues_same_line/expected_issues.json new file mode 100644 index 0000000..4b65c72 --- /dev/null +++ b/tests/cases/two_issues_same_line/expected_issues.json @@ -0,0 +1,4 @@ +[ + {"line": 1, "type": "DefStringTooLongException"}, + {"line": 1, "type": "TooManyInlineArgumentsException"} +] diff --git a/tests/cases/two_issues_same_line/source.py b/tests/cases/two_issues_same_line/source.py new file mode 100644 index 0000000..f885196 --- /dev/null +++ b/tests/cases/two_issues_same_line/source.py @@ -0,0 +1,2 @@ +def truly_single_line_with_type_and_comment(a: int, b: str, c: int | None = None): # all in one line + return diff --git a/tests/cases/with_comments/__init__.py b/tests/cases/with_comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/with_comments/expected.py b/tests/cases/with_comments/expected.py new file mode 100644 index 0000000..f06d037 --- /dev/null +++ b/tests/cases/with_comments/expected.py @@ -0,0 +1,6 @@ +def with_comments( + a, # this is an argument + b: int | None = None, + c: str = "", +): + return diff --git a/tests/cases/with_comments/expected_issues.json b/tests/cases/with_comments/expected_issues.json new file mode 100644 index 0000000..fa9621c --- /dev/null +++ b/tests/cases/with_comments/expected_issues.json @@ -0,0 +1 @@ +[{"line": 1, "type": "InvalidMultilineParamsIndentException"}] diff --git a/tests/cases/with_comments/source.py b/tests/cases/with_comments/source.py new file mode 100644 index 0000000..276b19e --- /dev/null +++ b/tests/cases/with_comments/source.py @@ -0,0 +1,6 @@ +def with_comments( + a, # this is an argument + b: int | None = None, + c: str = "", +): + return diff --git a/tests/conftest.py b/tests/conftest.py index 468cacb..50d05e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,45 +1,58 @@ -from collections.abc import Callable - import pytest -from def_form.formatters.def_formatter import DefManager -from tests.constants import EXAMPLE_PATH -from tests.constants import EXPECTED_PATH -from tests.constants import FORMATTED_PATH -from tests.constants import MAX_DEF_LENGTH -from tests.constants import MAX_INLINE_ARGS +from def_form.cli.console import NullConsole +from def_form.cli.context import context +from def_form.cli.ui import NullUI +from def_form.core import DefManager + +from tests.helpers import discover_cases +from tests.helpers import load_expected_content +from tests.helpers import load_expected_issues +from tests.helpers import MAX_DEF_LENGTH +from tests.helpers import MAX_INLINE_ARGS + + +_cases = discover_cases() +CASE_IDS = [name for name, _ in _cases] +CASE_DIR_BY_ID = {name: path for name, path in _cases} + + +def _manager_kwargs(path: str) -> dict: + return { + 'config': '/nonexistent', + 'excluded': (), + 'max_def_length': MAX_DEF_LENGTH, + 'max_inline_args': MAX_INLINE_ARGS, + 'path': path, + 'ui': NullUI(console=NullConsole(context=context)), + } @pytest.fixture -def get_def_manager() -> DefManager: - return DefManager( - excluded=(), - max_def_length=MAX_DEF_LENGTH, - max_inline_args=MAX_INLINE_ARGS, - path=EXAMPLE_PATH, - ) +def case_id(request: pytest.FixtureRequest) -> str: + return request.param @pytest.fixture -def get_correct_def_manager() -> DefManager: - return DefManager( - excluded=(), - max_def_length=MAX_DEF_LENGTH, - max_inline_args=MAX_INLINE_ARGS, - path=EXPECTED_PATH, - ) +def case_dir(case_id: str): + return CASE_DIR_BY_ID[case_id] @pytest.fixture -def get_expected() -> str: - with open(EXPECTED_PATH, encoding='utf-8') as f: - return f.read() +def case_source_path(case_dir) -> str: + return str(case_dir / 'source.py') @pytest.fixture -def get_formatted() -> Callable[[], str]: - def read() -> str: - with open(FORMATTED_PATH, encoding='utf-8') as f: - return f.read() +def case_expected_issues(case_dir): + return load_expected_issues(case_dir) - return read + +@pytest.fixture +def case_expected_content(case_dir) -> str | None: + return load_expected_content(case_dir) + + +@pytest.fixture +def case_manager(case_source_path: str) -> DefManager: + return DefManager(**_manager_kwargs(case_source_path)) diff --git a/tests/constants.py b/tests/constants.py deleted file mode 100644 index 5acff22..0000000 --- a/tests/constants.py +++ /dev/null @@ -1,11 +0,0 @@ -from pathlib import Path - -MAX_DEF_LENGTH = 100 -MAX_INLINE_ARGS = 2 -EXPECTED_TOTAL_ISSUES = 62 - -src_path = Path(__file__).resolve().parent - -EXAMPLE_PATH: str = str(src_path / 'mock_data/example.py') -EXPECTED_PATH: str = str(src_path / 'mock_data/expected.py') -FORMATTED_PATH: str = str(src_path / 'mock_data/formatted.py') diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..4d9faf1 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,54 @@ +import json +from pathlib import Path + +from def_form.exceptions.base import BaseDefFormException + + +MAX_DEF_LENGTH = 100 +MAX_INLINE_ARGS = 2 + + +def get_case_dir() -> Path: + return Path(__file__).resolve().parent / 'cases' + + +def discover_cases() -> list[tuple[str, Path]]: + cases_dir = get_case_dir() + if not cases_dir.is_dir(): + return [] + result: list[tuple[str, Path]] = [] + for path in sorted(cases_dir.iterdir()): + if path.is_dir() and (path / 'source.py').is_file(): + result.append((path.name, path)) + return result + + +def normalize_issues(issues: list[BaseDefFormException]) -> list[tuple[int, str]]: + out: list[tuple[int, str]] = [] + for exc in issues: + path_str = getattr(exc, 'path', '') + if ':' in path_str: + line_str = path_str.rsplit(':', 1)[-1] + try: + line_no = int(line_str) + except ValueError: + line_no = 0 + else: + line_no = 0 + out.append((line_no, type(exc).__name__)) + return sorted(out) + + +def load_expected_issues(case_dir: Path) -> list[tuple[int, str]]: + path = case_dir / 'expected_issues.json' + if not path.is_file(): + return [] + data = json.loads(path.read_text(encoding='utf-8')) + return [(_item['line'], _item['type']) for _item in data] + + +def load_expected_content(case_dir: Path) -> str | None: + path = case_dir / 'expected.py' + if not path.is_file(): + return None + return path.read_text(encoding='utf-8') diff --git a/tests/mock_data/__init__.py b/tests/mock_data/__init__.py deleted file mode 100644 index 7ab28e7..0000000 --- a/tests/mock_data/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -from def_form.exceptions.def_formatter import DefStringTooLongException, InvalidMultilineParamsIndentException -from def_form.exceptions.def_formatter import TooManyInlineArgumentsException -from tests.constants import EXAMPLE_PATH - -EXPECTED_ISSUES: list[DefStringTooLongException | TooManyInlineArgumentsException | InvalidMultilineParamsIndentException] = [ - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:19", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:25", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:32", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:35", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:39", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:43", message="Too many inline args (3 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:46", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:51", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:55", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:59", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:64", message="Too many inline args (3 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:67", message="Too many inline args (3 > 2)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:70", message="Function definition too long (104 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:70", message="Too many inline args (3 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:73", message="Too many inline args (3 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:79", message="Too many inline args (3 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:82", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:86", message="Too many inline args (3 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:89", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:92", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:96", message="Function definition too long (142 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:96", message="Too many inline args (3 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:99", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:102", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:105", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:111", message="Too many inline args (5 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:119", message="Too many inline args (3 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:123", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:128", message="Too many inline args (3 > 2)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:149", message="Function definition too long (163 > 100)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:152", message="Function definition too long (126 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:160", message="Too many inline args (3 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:163", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:167", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:170", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:173", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:177", message="Function definition too long (108 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:177", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:183", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:186", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:190", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:194", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:198", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:203", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:218", message="Too many inline args (4 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:225", message="Too many inline args (6 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:228", message="Too many inline args (6 > 2)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:231", message="Too many inline args (5 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:234", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:240", message="Function definition too long (102 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:240", message="Too many inline args (5 > 2)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:243", message="Function definition too long (174 > 100)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:246", message="Function definition too long (199 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:246", message="Too many inline args (4 > 2)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:249", message="Function definition too long (127 > 100)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:252", message="Function definition too long (138 > 100)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:255", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:260", message="Function definition too long (118 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:260", message="Too many inline args (4 > 2)", description=None), - InvalidMultilineParamsIndentException(path=f"{EXAMPLE_PATH}:269", message="Invalid multiline function parameters indentation (expected 4 spaces)", description=None), - DefStringTooLongException(path=f"{EXAMPLE_PATH}:276", message="Function definition too long (129 > 100)", description=None), - TooManyInlineArgumentsException(path=f"{EXAMPLE_PATH}:276", message="Too many inline args (5 > 2)", description=None), -] \ No newline at end of file diff --git a/tests/mock_data/example.py b/tests/mock_data/example.py deleted file mode 100644 index 5a5e25e..0000000 --- a/tests/mock_data/example.py +++ /dev/null @@ -1,277 +0,0 @@ -from typing import Optional, Union, Callable - - -def example_of_decorator(f: Callable) -> Callable: - def wrapper(*args, **kwargs): - return f(*args, **kwargs) - return wrapper - -def clear_def(): - return - -def formatted_def( - a, - b: Optional[Union[int, str]] = None, - c: int | str | None = None, -): - return - -def wrong_formatted_def( - a, - b: Optional[Union[int, str]] = None, -): - return - -def with_comments( - a, # this is an argument - b: Optional[Union[int, str]] = None, - c: int | str | None = None, -): - return - -def to_much_inline_args(to, much, inline, arguments): - return - -def single_line_with_comment_first(a, # first param comment - b, c): - return - -def single_line_with_comment_middle(a, b, # middle param comment - c): - return - -def single_line_with_comment_last(a, b, c): # end comment - return - -def single_line_with_multiple_comments(a, # first comment - b, # second comment - c): # third comment - return - -def single_line_with_comment_and_default(a, # param with default - b: int = 42, c: str = "test"): - return - -def single_line_with_comment_and_typehint(a: int, # typed param - b: str, c: Optional[int] = None): - return - -def single_line_with_comment_and_args(a, # regular param - *args, # varargs comment - **kwargs): # keyword args comment - return - -def truly_single_line_with_comment(a, b, c): # truly single line - return - -def truly_single_line_with_comment_first(a, b, c): # comment at end of signature - return - -def truly_single_line_with_type_and_comment(a: int, b: str, c: Optional[int] = None): # all in one line - return - -def truly_single_line_with_args_comment(a, *args, **kwargs): # args and kwargs - return - -async def async_def(): - return - -async def async_def_with_args(a, b, c): - return - -async def async_single_line_with_comment(a, # async with comment - b, c): - return - -async def truly_async_single_line_with_comment(a, b, c): # async single line - return - -def to_much_inline_args_with_typehints(to: str, much, inline: int, arguments): - return - -def single_line_with_comment_and_complex_type(a: Optional[Union[int, str]], # complex type - b: dict[str, int] = {}, c: list[str] = []): - return - -def truly_single_line_with_complex_type(a: Optional[Union[int, str]], b: dict[str, int] = {}, c: list[str] = []): # complex types in one line - return - -def kw_args(def_, with_, *args, **kwargs): - return - -def def_kw_args_first(a, *args, b, **kwargs): - return - -def single_line_kw_only_with_comments(a, *args, # varargs - b: str, # keyword-only - c: int = 10, # keyword-only with default - **kwargs): # kwargs - return - -def truly_single_line_kw_only(a, *args, b: str, c: int = 10, **kwargs): # kw-only in one line - return - -@example_of_decorator -def def_with_decorator(): - return - -@example_of_decorator -def def_with_decorator_and_args(a, b, c: Optional[str]): - return - -@example_of_decorator -def single_line_decorated_with_comment(a, # decorated with comment - b, c: Optional[str]): - return - -@example_of_decorator -def truly_single_line_decorated(a, b, c: Optional[str]): # decorated single line - return - -def skipped_with_right_comment(a, b, c): # def-form: skip - return - -# def-form: skip -def skipped_with_up_comment(a, b, c): - return - -@example_of_decorator -def skipped_with_right_comment_and_decorator(a, b, c): # def-form: skip - return - -@example_of_decorator -def skipped_with_up_comment_and_decorator(a, b, c): # def-form: skip - return - -def long_typehint_without_args() -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - -def long_typehint_with_args(a) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - -def loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_def_name(a): - return - - -class ClassWithFunctions: - def class_method(self): - return - - def class_method_with_args(self, a, b): - return - - def class_method_single_line_with_comment(self, a, # class method comment - b, c): - return - - def truly_class_method_single_line(self, a, b, c): # class method single line - return - - def class_method_with_args_with_typehints(self, a: Optional[int], b: int, c): - return - - def class_method_single_line_with_typehint_comment(self, a: int, # typed comment - b: str, c: Optional[int] = None): - return - - def truly_class_method_with_types(self, a: int, b: str, c: Optional[int] = None): # class method with types - return - - async def async_def(self): - return - - async def async_def_with_args(self, a, b, c): - return - - async def async_class_method_single_line(self, a, # async class method - b, c): - return - - async def truly_async_class_method(self, a, b, c): # async class method single line - return - - @example_of_decorator - async def async_def_with_args_and_decorator(self, a, b, c): - return - - @example_of_decorator - async def async_class_method_with_comment(self, a, # decorated async - b, c): - return - - @example_of_decorator - async def truly_async_decorated_class_method(self, a, b, c): # decorated async class method - return - - def skipped_with_right_comment(self, a, b, c): # def-form: skip - return - - # def-form: skip - def skipped_with_up_comment(self, a, b, c): - return - - @example_of_decorator - def def_with_decorator(self): - return - - @example_of_decorator - def def_with_decorator_and_args(self, a, b, c): - return - - @staticmethod - def static_method(): - return - - def def_with_kw_args(self, a, b, c, *args, **kwargs): - return - - def def_with_kw_args_first(self, *args, a, b, c, **kwargs): - return - - def def_with_kw_only_args(self, *args, b: str, s: str, **kwargs): - return - - def class_method_kw_only_with_comments(self, *args, # varargs in class - b: str, # keyword-only in class - s: str, # another keyword-only - **kwargs): # kwargs in class - return - - def truly_class_method_kw_only(self, *args, b: str, s: str, **kwargs): # kw-only in class single line - return - - def class_method_with_long_typehint(self) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - - def class_method_with_long_typehint_with_args(self, a, b: int, c) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - - def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name(self): - return - - def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name_and_arg(self, a): - return - - def class_method_single_line_with_defaults(self, a: int = 1, # default value - b: str = "test", # another default - c: Optional[int] = None): # optional default - return - - def truly_class_method_with_defaults(self, a: int = 1, b: str = "test", c: Optional[int] = None): # defaults in class - return - -def single_line_with_return_type_comment(a: int, b: str) -> bool: # return type comment - return True - -def truly_single_line_with_return_type(a: int, b: str) -> bool: # return type in one line - return True - -def single_line_with_all_features(a: int, # typed with comment - b: str = "default", # default with comment - *args, # varargs with comment - c: int = 10, # keyword-only with comment - **kwargs) -> bool: # kwargs and return type - return True - -def truly_single_line_all_features(a: int, b: str = "default", *args, c: int = 10, **kwargs) -> bool: # all features in one line - return True diff --git a/tests/mock_data/expected.py b/tests/mock_data/expected.py deleted file mode 100644 index febc558..0000000 --- a/tests/mock_data/expected.py +++ /dev/null @@ -1,492 +0,0 @@ -from typing import Optional, Union, Callable - - -def example_of_decorator(f: Callable) -> Callable: - def wrapper(*args, **kwargs): - return f(*args, **kwargs) - return wrapper - -def clear_def(): - return - -def formatted_def( - a, - b: Optional[Union[int, str]] = None, - c: int | str | None = None, -): - return - -def wrong_formatted_def( - a, - b: Optional[Union[int, str]] = None, -): - return - -def with_comments( - a, # this is an argument - b: Optional[Union[int, str]] = None, - c: int | str | None = None, -): - return - -def to_much_inline_args( - to, - much, - inline, - arguments, -): - return - -def single_line_with_comment_first( - a, # first param comment - b, - c, -): - return - -def single_line_with_comment_middle( - a, - b, # middle param comment - c, -): - return - -def single_line_with_comment_last( - a, - b, - c, -): # end comment - return - -def single_line_with_multiple_comments( - a, # first comment - b, # second comment - c, -): # third comment - return - -def single_line_with_comment_and_default( - a, # param with default - b: int = 42, - c: str = "test", -): - return - -def single_line_with_comment_and_typehint( - a: int, # typed param - b: str, - c: Optional[int] = None, -): - return - -def single_line_with_comment_and_args( - a, # regular param - *args, # varargs comment - **kwargs, -): # keyword args comment - return - -def truly_single_line_with_comment( - a, - b, - c, -): # truly single line - return - -def truly_single_line_with_comment_first( - a, - b, - c, -): # comment at end of signature - return - -def truly_single_line_with_type_and_comment( - a: int, - b: str, - c: Optional[int] = None, -): # all in one line - return - -def truly_single_line_with_args_comment( - a, - *args, - **kwargs, -): # args and kwargs - return - -async def async_def(): - return - -async def async_def_with_args( - a, - b, - c, -): - return - -async def async_single_line_with_comment( - a, # async with comment - b, - c, -): - return - -async def truly_async_single_line_with_comment( - a, - b, - c, -): # async single line - return - -def to_much_inline_args_with_typehints( - to: str, - much, - inline: int, - arguments, -): - return - -def single_line_with_comment_and_complex_type( - a: Optional[Union[int, str]], # complex type - b: dict[str, int] = {}, - c: list[str] = [], -): - return - -def truly_single_line_with_complex_type( - a: Optional[Union[int, str]], - b: dict[str, int] = {}, - c: list[str] = [], -): # complex types in one line - return - -def kw_args( - def_, - with_, - *args, - **kwargs, -): - return - -def def_kw_args_first( - a, - *args, - b, - **kwargs, -): - return - -def single_line_kw_only_with_comments( - a, - *args, # varargs - b: str, # keyword-only - c: int = 10, # keyword-only with default - **kwargs, -): # kwargs - return - -def truly_single_line_kw_only( - a, - *args, - b: str, - c: int = 10, - **kwargs, -): # kw-only in one line - return - -@example_of_decorator -def def_with_decorator(): - return - -@example_of_decorator -def def_with_decorator_and_args( - a, - b, - c: Optional[str], -): - return - -@example_of_decorator -def single_line_decorated_with_comment( - a, # decorated with comment - b, - c: Optional[str], -): - return - -@example_of_decorator -def truly_single_line_decorated( - a, - b, - c: Optional[str], -): # decorated single line - return - -def skipped_with_right_comment(a, b, c): # def-form: skip - return - -# def-form: skip -def skipped_with_up_comment(a, b, c): - return - -@example_of_decorator -def skipped_with_right_comment_and_decorator(a, b, c): # def-form: skip - return - -@example_of_decorator -def skipped_with_up_comment_and_decorator(a, b, c): # def-form: skip - return - -def long_typehint_without_args() -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - -def long_typehint_with_args( - a, -) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - -def loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_def_name( - a, -): - return - - -class ClassWithFunctions: - def class_method(self): - return - - def class_method_with_args( - self, - a, - b, - ): - return - - def class_method_single_line_with_comment( - self, - a, # class method comment - b, - c, - ): - return - - def truly_class_method_single_line( - self, - a, - b, - c, - ): # class method single line - return - - def class_method_with_args_with_typehints( - self, - a: Optional[int], - b: int, - c, - ): - return - - def class_method_single_line_with_typehint_comment( - self, - a: int, # typed comment - b: str, - c: Optional[int] = None, - ): - return - - def truly_class_method_with_types( - self, - a: int, - b: str, - c: Optional[int] = None, - ): # class method with types - return - - async def async_def(self): - return - - async def async_def_with_args( - self, - a, - b, - c, - ): - return - - async def async_class_method_single_line( - self, - a, # async class method - b, - c, - ): - return - - async def truly_async_class_method( - self, - a, - b, - c, - ): # async class method single line - return - - @example_of_decorator - async def async_def_with_args_and_decorator( - self, - a, - b, - c, - ): - return - - @example_of_decorator - async def async_class_method_with_comment( - self, - a, # decorated async - b, - c, - ): - return - - @example_of_decorator - async def truly_async_decorated_class_method( - self, - a, - b, - c, - ): # decorated async class method - return - - def skipped_with_right_comment(self, a, b, c): # def-form: skip - return - - # def-form: skip - def skipped_with_up_comment(self, a, b, c): - return - - @example_of_decorator - def def_with_decorator(self): - return - - @example_of_decorator - def def_with_decorator_and_args( - self, - a, - b, - c, - ): - return - - @staticmethod - def static_method(): - return - - def def_with_kw_args( - self, - a, - b, - c, - *args, - **kwargs, - ): - return - - def def_with_kw_args_first( - self, - *args, - a, - b, - c, - **kwargs, - ): - return - - def def_with_kw_only_args( - self, - *args, - b: str, - s: str, - **kwargs, - ): - return - - def class_method_kw_only_with_comments( - self, - *args, # varargs in class - b: str, # keyword-only in class - s: str, # another keyword-only - **kwargs, - ): # kwargs in class - return - - def truly_class_method_kw_only( - self, - *args, - b: str, - s: str, - **kwargs, - ): # kw-only in class single line - return - - def class_method_with_long_typehint( - self, - ) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - - def class_method_with_long_typehint_with_args( - self, - a, - b: int, - c, - ) -> Optional[Union[int, str, list, dict[str, str], tuple[str], None, dict[str | int, list[str] | tuple[str, str, int, int] | None]]]: - return - - def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name( - self, - ): - return - - def class_method_with_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_name_and_arg( - self, - a, - ): - return - - def class_method_single_line_with_defaults( - self, - a: int = 1, # default value - b: str = "test", # another default - c: Optional[int] = None, - ): # optional default - return - - def truly_class_method_with_defaults( - self, - a: int = 1, - b: str = "test", - c: Optional[int] = None, - ): # defaults in class - return - -def single_line_with_return_type_comment(a: int, b: str) -> bool: # return type comment - return True - -def truly_single_line_with_return_type(a: int, b: str) -> bool: # return type in one line - return True - -def single_line_with_all_features( - a: int, # typed with comment - b: str = "default", # default with comment - *args, # varargs with comment - c: int = 10, # keyword-only with comment - **kwargs, -) -> bool: # kwargs and return type - return True - -def truly_single_line_all_features( - a: int, - b: str = "default", - *args, - c: int = 10, - **kwargs, -) -> bool: # all features in one line - return True diff --git a/tests/test_checker/test_checker.py b/tests/test_checker/test_checker.py index e4d3d72..0122af4 100644 --- a/tests/test_checker/test_checker.py +++ b/tests/test_checker/test_checker.py @@ -1,19 +1,23 @@ import pytest from def_form.exceptions.base import BaseDefFormException -from def_form.formatters.def_formatter import DefManager -from tests.constants import EXPECTED_TOTAL_ISSUES -from tests.mock_data import EXPECTED_ISSUES +from def_form.exceptions.def_formatter import CheckCommandFoundAnIssue +from tests.helpers import normalize_issues +from tests.conftest import CASE_IDS -def test_failed_check(get_def_manager: DefManager): - with pytest.raises(BaseDefFormException): - get_def_manager.check() - assert get_def_manager.issues == EXPECTED_ISSUES - assert len(get_def_manager.issues) == EXPECTED_TOTAL_ISSUES +@pytest.mark.parametrize('case_id', CASE_IDS, indirect=True) +def test_check_case( + case_id: str, + case_manager, + case_expected_issues: list[tuple[int, str]], +): + if case_expected_issues: + with pytest.raises(CheckCommandFoundAnIssue): + case_manager.check() + else: + case_manager.check() - -def test_successful_check(get_correct_def_manager: DefManager): - get_correct_def_manager.check() - assert len(get_correct_def_manager.issues) == 0 + got = normalize_issues(case_manager.issues) + assert got == case_expected_issues diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli/test_cli_invoke.py b/tests/test_cli/test_cli_invoke.py new file mode 100644 index 0000000..4b833f6 --- /dev/null +++ b/tests/test_cli/test_cli_invoke.py @@ -0,0 +1,44 @@ +from click.testing import CliRunner + +from def_form.cli.main import cli + + +runner = CliRunner() + + +def test_cli_help_exit_zero() -> None: + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'format' in result.output + assert 'check' in result.output + assert '--verbose' in result.output + assert '--quiet' in result.output + + +def test_format_help_exit_zero() -> None: + result = runner.invoke(cli, ['format', '--help']) + assert result.exit_code == 0 + assert 'path' in result.output.lower() + assert '--max-def-length' in result.output + assert '--max-inline-args' in result.output + assert '--indent-size' in result.output + assert '--config' in result.output + assert '--exclude' in result.output + assert '--show-skipped' in result.output + + +def test_check_help_exit_zero() -> None: + result = runner.invoke(cli, ['check', '--help']) + assert result.exit_code == 0 + assert 'path' in result.output.lower() + assert '--max-def-length' in result.output + assert '--max-inline-args' in result.output + assert '--config' in result.output + assert '--exclude' in result.output + + +def test_cli_verbose_quiet_flags() -> None: + result = runner.invoke(cli, ['--verbose', '--help']) + assert result.exit_code == 0 + result = runner.invoke(cli, ['--quiet', '--help']) + assert result.exit_code == 0 diff --git a/tests/test_cli/test_commands.py b/tests/test_cli/test_commands.py new file mode 100644 index 0000000..521a5f6 --- /dev/null +++ b/tests/test_cli/test_commands.py @@ -0,0 +1,156 @@ +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +from click.testing import CliRunner + +from def_form.cli.main import cli + + +runner = CliRunner() + + +def test_format_default_path_is_current_dir() -> None: + with patch('def_form.cli.commands.format.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['format']) + + assert result.exit_code == 0 + call_kw = mock_manager_class.call_args[1] + assert call_kw['path'] == '.' + + +def test_format_invokes_def_manager_and_format(tmp_path: Path) -> None: + with patch('def_form.cli.commands.format.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['format', str(tmp_path)]) + + assert result.exit_code == 0 + mock_manager_class.assert_called_once() + call_kw = mock_manager_class.call_args[1] + assert call_kw['path'] == str(tmp_path) + assert call_kw['excluded'] == () + assert call_kw['max_def_length'] is None + assert call_kw['max_inline_args'] is None + assert call_kw['indent_size'] is None + assert call_kw['config'] is None + assert call_kw['show_skipped'] is False + assert 'ui' in call_kw + mock_instance.format.assert_called_once() + + +def test_format_passes_options_to_def_manager(tmp_path: Path) -> None: + with patch('def_form.cli.commands.format.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_manager_class.return_value = mock_instance + + result = runner.invoke( + cli, + [ + 'format', + str(tmp_path), + '--max-def-length', '88', + '--max-inline-args', '3', + '--indent-size', '2', + '--exclude', 'foo', + '--exclude', 'bar', + '--show-skipped', + ], + ) + + assert result.exit_code == 0 + call_kw = mock_manager_class.call_args[1] + assert call_kw['max_def_length'] == 88 + assert call_kw['max_inline_args'] == 3 + assert call_kw['indent_size'] == 2 + assert call_kw['excluded'] == ('foo', 'bar') + assert call_kw['show_skipped'] is True + + +def test_check_invokes_def_manager_and_check(tmp_path: Path) -> None: + with patch('def_form.cli.commands.check.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['check', str(tmp_path)]) + + assert result.exit_code == 0 + mock_manager_class.assert_called_once() + call_kw = mock_manager_class.call_args[1] + assert call_kw['path'] == str(tmp_path) + assert call_kw['excluded'] == () + mock_instance.check.assert_called_once() + + +def test_check_passes_options_to_def_manager(tmp_path: Path) -> None: + with patch('def_form.cli.commands.check.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_manager_class.return_value = mock_instance + + result = runner.invoke( + cli, + [ + 'check', + str(tmp_path), + '--max-def-length', '100', + '--max-inline-args', '2', + '--exclude', 'build', + ], + ) + + assert result.exit_code == 0 + call_kw = mock_manager_class.call_args[1] + assert call_kw['max_def_length'] == 100 + assert call_kw['max_inline_args'] == 2 + assert call_kw['excluded'] == ('build',) + + +def test_format_raises_cli_error_on_exception(tmp_path: Path) -> None: + with patch('def_form.cli.commands.format.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_instance.format.side_effect = RuntimeError('formatter broke') + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['format', str(tmp_path)]) + + assert result.exit_code != 0 + assert result.exc_info is not None + from def_form.cli.errors import FormatterFailedError + assert isinstance(result.exc_info[1], FormatterFailedError) + + +def test_check_raises_cli_error_on_generic_exception(tmp_path: Path) -> None: + with patch('def_form.cli.commands.check.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_instance.check.side_effect = RuntimeError('something broke') + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['check', str(tmp_path)]) + + assert result.exit_code != 0 + assert result.exc_info is not None + from def_form.cli.errors import CheckFailedError + assert isinstance(result.exc_info[1], CheckFailedError) + assert 'something broke' in str(result.exc_info[1]) + + +def test_check_raises_cli_error_on_base_def_form_exception(tmp_path: Path) -> None: + from def_form.exceptions.def_formatter import TooManyInlineArgumentsException + + with patch('def_form.cli.commands.check.DefManager') as mock_manager_class: + mock_instance = MagicMock() + mock_instance.check.side_effect = TooManyInlineArgumentsException( + path='file.py:1', message='too many args' + ) + mock_manager_class.return_value = mock_instance + + result = runner.invoke(cli, ['check', str(tmp_path)]) + + assert result.exit_code != 0 + assert result.exc_info is not None + from def_form.cli.errors import CheckFailedError + assert isinstance(result.exc_info[1], CheckFailedError) diff --git a/tests/test_cli/test_console.py b/tests/test_cli/test_console.py new file mode 100644 index 0000000..6fec563 --- /dev/null +++ b/tests/test_cli/test_console.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +import pytest + +from def_form.cli.context import CLIContext +from def_form.cli.console.base import BaseConsole +from def_form.cli.console.null import NullConsole +from def_form.cli.console.rich import RichConsole + + +def test_base_console_info_raises_not_implemented() -> None: + ctx = CLIContext() + console = BaseConsole(context=ctx) + with pytest.raises(NotImplementedError): + console.info('x') + + +def test_base_console_success_raises_not_implemented() -> None: + ctx = CLIContext() + console = BaseConsole(context=ctx) + with pytest.raises(NotImplementedError): + console.success('x') + + +def test_base_console_warning_raises_not_implemented() -> None: + ctx = CLIContext() + console = BaseConsole(context=ctx) + with pytest.raises(NotImplementedError): + console.warning('x') + + +def test_base_console_error_raises_not_implemented() -> None: + ctx = CLIContext() + console = BaseConsole(context=ctx) + with pytest.raises(NotImplementedError): + console.error('x') + + +def test_base_console_debug_raises_not_implemented() -> None: + ctx = CLIContext() + console = BaseConsole(context=ctx) + with pytest.raises(NotImplementedError): + console.debug('x') + + +def test_null_console_all_methods_no_op() -> None: + ctx = CLIContext() + console = NullConsole(context=ctx) + console.info('x') + console.success('x') + console.warning('x') + console.error('x') + console.debug('x') + + +def test_rich_console_info_respects_should_output() -> None: + ctx = CLIContext() + ctx.quiet = True + console = RichConsole(context=ctx) + console.print = MagicMock() + console.info('hi') + console.print.assert_not_called() + ctx.quiet = False + console.info('hi') + console.print.assert_called_once_with('hi') + + +def test_rich_console_success_respects_should_output() -> None: + ctx = CLIContext() + console = RichConsole(context=ctx) + console.print = MagicMock() + console.success('ok') + console.print.assert_called_once() + assert '[green]' in str(console.print.call_args[0][0]) + + +def test_rich_console_warning_error_always_print() -> None: + ctx = CLIContext() + console = RichConsole(context=ctx) + console.print = MagicMock() + console.warning('w') + console.print.assert_called_once() + console.error('e') + assert console.print.call_count == 2 + + +def test_rich_console_debug_only_when_verbose() -> None: + ctx = CLIContext() + ctx.verbose = False + console = RichConsole(context=ctx) + console.print = MagicMock() + console.debug('d') + console.print.assert_not_called() + ctx.verbose = True + console.debug('d') + console.print.assert_called_once() diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py new file mode 100644 index 0000000..e60a366 --- /dev/null +++ b/tests/test_cli/test_main.py @@ -0,0 +1,26 @@ +import pytest +from unittest.mock import patch + +from def_form.cli.main import main +from def_form.cli.errors import CLIError + + +def test_main_exits_1_on_cli_error() -> None: + with patch('def_form.cli.main.cli', side_effect=CLIError('test error')): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_main_exits_130_on_keyboard_interrupt() -> None: + with patch('def_form.cli.main.cli', side_effect=KeyboardInterrupt): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 130 + + +def test_main_exits_1_on_generic_exception() -> None: + with patch('def_form.cli.main.cli', side_effect=RuntimeError('unexpected')): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 diff --git a/tests/test_cli/test_ui.py b/tests/test_cli/test_ui.py new file mode 100644 index 0000000..b5b90d1 --- /dev/null +++ b/tests/test_cli/test_ui.py @@ -0,0 +1,302 @@ +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from def_form.cli.context import CLIContext +from def_form.cli.console.base import BaseConsole +from def_form.cli.console.rich import RichConsole +from def_form.cli.ui.base import BaseUI +from def_form.cli.ui.null import NullUI +from def_form.cli.ui.rich import RichUI +from def_form.exceptions.base import BaseDefFormException +from def_form.exceptions.def_formatter import TooManyInlineArgumentsException + + +def _make_base_ui_console() -> MagicMock: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + return console + + +def test_base_ui_abstract_methods_raise_not_implemented() -> None: + class StubUI(BaseUI): + def show_config_info(self, **kwargs: object) -> None: + super().show_config_info(**kwargs) + + def start(self, total: int | None) -> None: + super().start(total) + + def processing(self, path: Path) -> None: + super().processing(path) + + def skipped(self, path: Path) -> None: + super().skipped(path) + + def finish(self, processed: int, issues: list[BaseDefFormException]) -> None: + super().finish(processed, issues) + + def show_issues(self, processed: int, issues: list[BaseDefFormException]) -> None: + super().show_issues(processed, issues) + + def show_summary(self, processed: int, issues: list[BaseDefFormException]) -> None: + super().show_summary(processed, issues) + + console = _make_base_ui_console() + ui = StubUI(console=console) + + with pytest.raises(NotImplementedError): + ui.show_config_info() + with pytest.raises(NotImplementedError): + ui.start(1) + with pytest.raises(NotImplementedError): + ui.processing(Path('x.py')) + with pytest.raises(NotImplementedError): + ui.skipped(Path('y')) + with pytest.raises(NotImplementedError): + ui.finish(1, []) + with pytest.raises(NotImplementedError): + ui.show_issues(1, []) + with pytest.raises(NotImplementedError): + ui.show_summary(1, []) + + +def test_null_ui_all_methods_no_op() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = NullUI(console=console) + ui.show_config_info() + ui.start(1) + ui.processing(Path('x.py')) + ui.skipped(Path('y')) + ui.finish(1, []) + ui.show_issues(1, []) + ui.show_summary(1, []) + + +def test_rich_ui_show_config_info_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info(config_path='x', max_def_length=100) + console.print.assert_not_called() + + +def test_rich_ui_show_config_info_skips_when_empty_config() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info() + console.print.assert_not_called() + + +def test_rich_ui_show_config_info_prints_table_when_output_on() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info(config_path='/cfg', max_def_length=100, max_inline_args=2) + assert console.print.call_count >= 1 + + +def test_rich_ui_start_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.start(5) + assert ui.progress is None + + +def test_rich_ui_processing_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.processing(Path('a.py')) + assert ui.current_file is None + + +def test_rich_ui_skipped_skips_when_show_skipped_false() -> None: + ctx = CLIContext() + ctx.show_skipped = False + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.skipped(Path('x')) + console.print.assert_not_called() + + +def test_rich_ui_finish_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.finish(0, []) + + +def test_rich_ui_show_issues_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_issues(1, [TooManyInlineArgumentsException(path='f:1', message='m')]) + console.print.assert_not_called() + + +def test_rich_ui_show_issues_prints_when_issues_and_output_on() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_issues(1, [TooManyInlineArgumentsException(path='f:1', message='msg')]) + assert console.print.call_count >= 1 + + +def test_rich_ui_show_summary_skips_when_quiet() -> None: + ctx = CLIContext() + ctx.quiet = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_summary(1, []) + console.print.assert_not_called() + + +def test_rich_ui_convert_to_string_path(tmp_path: Path) -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + with patch.object(Path, 'cwd', return_value=tmp_path): + s = ui._convert_to_string(tmp_path / 'sub' / 'file.py') + assert s == 'sub/file.py' or 'sub' in s + + +def test_rich_ui_convert_to_string_bool() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + assert ui._convert_to_string(True) == 'Yes' + assert ui._convert_to_string(False) == 'No' + + +def test_rich_ui_show_config_info_skips_none_and_empty_values() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info(config_path='/x', max_def_length=None, indent_size='') + assert console.print.call_count >= 1 + + +def test_rich_ui_show_config_info_config_path_bold_yellow() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info(config_path='/project/pyproject.toml') + assert console.print.call_count >= 1 + + +def test_rich_ui_show_config_info_list_value_more_than_three() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_config_info(excluded=['a', 'b', 'c', 'd']) + assert console.print.call_count >= 1 + + +def test_rich_ui_start_creates_progress_when_output_on() -> None: + ctx = CLIContext() + console = RichConsole(context=ctx, file=StringIO()) + ui = RichUI(console=console) + ui.start(10) + assert ui.progress is not None + assert ui._live is not None + assert ui.task_id is not None + + +def test_rich_ui_processing_updates_display_when_started() -> None: + ctx = CLIContext() + console = RichConsole(context=ctx, file=StringIO()) + ui = RichUI(console=console) + ui.start(5) + ui.processing(Path('foo/bar.py')) + assert ui.current_file == Path('foo/bar.py') + assert ui._progress_display.renderables[0] is not None + + +def test_rich_ui_skipped_prints_when_show_skipped_true() -> None: + ctx = CLIContext() + ctx.show_skipped = True + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.skipped(Path('skipped.py')) + console.print.assert_called_once() + assert 'SKIPPED' in str(console.print.call_args[0][0]) + + +def test_rich_ui_issue_no_op() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.issue(TooManyInlineArgumentsException(path='f:1', message='m')) + + +def test_rich_ui_finish_stops_live_and_shows_issues_when_present() -> None: + ctx = CLIContext() + console = RichConsole(context=ctx, file=StringIO()) + ui = RichUI(console=console) + ui.start(2) + ui.finish(2, [TooManyInlineArgumentsException(path='f:1', message='err')]) + assert ui._live is None + assert ui.progress is None + + +def test_rich_ui_show_issues_line_info_when_colon_in_path() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_issues(1, [TooManyInlineArgumentsException(path='f.py:10', message='m')]) + assert console.print.call_count >= 1 + + +def test_rich_ui_show_issues_line_info_when_issue_has_line() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + exc = TooManyInlineArgumentsException(path='f.py', message='m') + exc.line = 5 + ui.show_issues(1, [exc]) + assert console.print.call_count >= 1 + out = str(console.print.call_args_list) + assert ':5' in out or '5' in out + + +def test_rich_ui_show_summary_success_rate_branches() -> None: + ctx = CLIContext() + console = MagicMock(spec=BaseConsole) + console.context = ctx + ui = RichUI(console=console) + ui.show_summary(100, [TooManyInlineArgumentsException(path='a.py:1', message='x')]) + assert console.print.call_count >= 1 + ui.show_summary(10, [TooManyInlineArgumentsException(path=f'f{i}.py:1', message='x') for i in range(10)]) + assert console.print.call_count >= 2 diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core/test_base.py b/tests/test_core/test_base.py new file mode 100644 index 0000000..5dbd79a --- /dev/null +++ b/tests/test_core/test_base.py @@ -0,0 +1,92 @@ +from pathlib import Path +from unittest.mock import patch + +import libcst as cst + +from def_form.core.base import DefBase +from def_form.core.checker import DefChecker + + +def _parse_and_get_function(code: str) -> cst.FunctionDef: + tree = cst.parse_module(code) + for node in tree.body: + if isinstance(node, cst.FunctionDef): + return node + raise AssertionError('No FunctionDef in code') + + +def test_is_single_line_function_returns_false_when_def_line_has_no_paren_colon() -> None: + code = 'def f(\n x,\n):\n pass\n' + node = _parse_and_get_function(code) + base = DefBase(filepath='x.py', max_def_length=None, max_inline_args=None) + result = base.is_single_line_function(node) + assert result is False + + +def test_has_skip_comment_returns_false_on_file_open_error(tmp_path: Path) -> None: + py_file = tmp_path / 'f.py' + py_file.write_text('def f(): pass\n', encoding='utf-8') + code = py_file.read_text() + tree = cst.parse_module(code) + wrapper = cst.metadata.MetadataWrapper(tree) + checker = DefChecker( + filepath=str(py_file), + max_def_length=None, + max_inline_args=None, + indent_size=4, + ) + + def open_side_effect(self: Path, *args: object, **kwargs: object) -> object: + if str(self) == str(py_file): + raise OSError('permission denied') + return Path.open(self, *args, **kwargs) + + with patch.object(Path, 'open', open_side_effect): + wrapper.visit(checker) + assert checker.issues == [] + + +def test_has_skip_comment_returns_false_on_unicode_decode_error(tmp_path: Path) -> None: + py_file = tmp_path / 'f.py' + py_file.write_text('def f(): pass\n', encoding='utf-8') + tree = cst.parse_module(py_file.read_text()) + wrapper = cst.metadata.MetadataWrapper(tree) + checker = DefChecker( + filepath=str(py_file), + max_def_length=None, + max_inline_args=None, + indent_size=4, + ) + + def open_side_effect(self: Path, *args: object, **kwargs: object) -> object: + if str(self) == str(py_file): + raise UnicodeDecodeError('utf-8', b'', 0, 1, 'invalid') + return Path.open(self, *args, **kwargs) + + with patch.object(Path, 'open', open_side_effect): + wrapper.visit(checker) + assert checker.issues == [] + + +def test_has_correct_multiline_params_format_false_when_not_parenthesized_whitespace() -> None: + code = 'def f(x): pass\n' + node = _parse_and_get_function(code) + base = DefBase(filepath='x.py', max_def_length=None, max_inline_args=None) + result = base.has_correct_multiline_params_format(node) + assert result is False + + +def test_count_arguments_includes_star_arg() -> None: + code = 'def f(a, *args): pass\n' + node = _parse_and_get_function(code) + base = DefBase(filepath='x.py', max_def_length=None, max_inline_args=None) + count = base._count_arguments(node) + assert count == 2 + + +def test_count_arguments_includes_star_kwarg() -> None: + code = 'def f(a, **kwargs): pass\n' + node = _parse_and_get_function(code) + base = DefBase(filepath='x.py', max_def_length=None, max_inline_args=None) + count = base._count_arguments(node) + assert count == 2 diff --git a/tests/test_core/test_manager.py b/tests/test_core/test_manager.py new file mode 100644 index 0000000..1956488 --- /dev/null +++ b/tests/test_core/test_manager.py @@ -0,0 +1,152 @@ +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +from def_form.cli.context import CLIContext +from def_form.cli.console.null import NullConsole +from def_form.cli.ui.null import NullUI +from def_form.core.manager import DefManager + + +def _make_manager(path: str = '.', config: str | None = None, excluded: tuple[str, ...] = ()) -> DefManager: + ctx = CLIContext() + ui = NullUI(console=NullConsole(context=ctx)) + return DefManager(path=path, ui=ui, config=config, excluded=excluded or ()) + + +def test_init_config_with_no_config_keeps_defaults(tmp_path: Path) -> None: + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=None) + assert m.max_def_length is None + assert m.max_inline_args is None + assert m.indent_size is None + assert m._config_excluded == [] + + +def test_init_config_loads_from_pyproject(tmp_path: Path) -> None: + pyproject = tmp_path / 'pyproject.toml' + pyproject.write_text( + '[tool.def-form]\n' + 'max_def_length = 88\n' + 'max_inline_args = 3\n' + 'indent_size = 2\n' + 'exclude = ["venv", "build"]\n', + encoding='utf-8', + ) + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=str(pyproject)) + assert m.max_def_length == 88 + assert m.max_inline_args == 3 + assert m.indent_size == 2 + assert m._config_excluded == ['venv', 'build'] + + +def test_init_config_with_missing_file_sets_excluded_empty(tmp_path: Path) -> None: + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config='/nonexistent/pyproject.toml') + assert m._config_excluded == [] + + +def test_init_exclusions_adds_resolved_paths(tmp_path: Path) -> None: + sub = tmp_path / 'sub' + sub.mkdir(exist_ok=True) + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=None, excluded=(str(sub),)) + assert len(m.excluded) >= 1 + + +def test_is_excluded_true_when_path_under_excluded(tmp_path: Path) -> None: + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=None) + m.excluded = {tmp_path} + assert m._is_excluded(tmp_path / 'sub' / 'file.py') is True + + +def test_is_excluded_true_when_excluded_name_in_parts(tmp_path: Path) -> None: + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=None) + other = tmp_path.parent / 'other_dir' + other.mkdir(exist_ok=True) + m.excluded = {other} + p = Path('/any/other_dir/file.py') + assert m._is_excluded(p) is True + + +def test_is_excluded_false_when_not_under_and_name_not_in_parts(tmp_path: Path) -> None: + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path), config=None) + m.excluded = {tmp_path / 'x'} + assert m._is_excluded(tmp_path / 'file.py') is False + + +def test_iter_py_files_file_not_py_returns_nothing(tmp_path: Path) -> None: + f = tmp_path / 'readme.txt' + f.write_text('x') + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(f)) + m.excluded = set() + assert list(m._iter_py_files()) == [] + + +def test_iter_py_files_file_excluded_calls_ui_skipped(tmp_path: Path) -> None: + py = tmp_path / 'f.py' + py.write_text('x = 1') + ui = MagicMock() + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = DefManager(path=str(py), ui=ui, config=None) + m.excluded = {tmp_path} + assert list(m._iter_py_files()) == [] + ui.skipped.assert_called_once() + + +def test_process_file_read_error_returns_empty(tmp_path: Path) -> None: + f = tmp_path / 'x.py' + f.write_text('def f(): pass') + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path)) + with patch.object(Path, 'read_text', side_effect=OSError): + tree, issues = m._process_file(f, m.checker_class) + assert tree is None + assert issues == [] + + +def test_process_file_syntax_error_returns_empty(tmp_path: Path) -> None: + import libcst as cst + + f = tmp_path / 'x.py' + f.write_text('x = 1') + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path)) + err = cst.ParserSyntaxError('syntax error', lines=(), raw_line=1, raw_column=0) + with patch('def_form.core.manager.cst.parse_module', side_effect=err): + tree, issues = m._process_file(f, m.checker_class) + assert tree is None + assert issues == [] + + +def test_process_file_generic_exception_returns_empty(tmp_path: Path) -> None: + f = tmp_path / 'x.py' + f.write_text('def f(): pass') + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = _make_manager(path=str(tmp_path)) + + def raise_in_visit(*args: object, **kwargs: object) -> None: + raise RuntimeError('visit failed') + + with patch('def_form.core.manager.cst.metadata.MetadataWrapper') as MockWrapper: + mock_wrapper = MagicMock() + MockWrapper.return_value = mock_wrapper + mock_wrapper.visit.side_effect = raise_in_visit + tree, issues = m._process_file(f, m.checker_class) + assert tree is None + assert issues == [] + + +def test_write_on_os_error_calls_ui_error(tmp_path: Path) -> None: + ui = MagicMock() + with patch('def_form.core.manager.find_pyproject_toml', return_value=None): + m = DefManager(path=str(tmp_path), ui=ui, config=None) + with patch.object(Path, 'write_text', side_effect=OSError('permission')): + m._write(tmp_path / 'out.py', 'code') + ui.console.error.assert_called_once() + assert 'Exception occurred' in str(ui.console.error.call_args[0][0]) diff --git a/tests/test_core/test_rules_base.py b/tests/test_core/test_rules_base.py new file mode 100644 index 0000000..0ea1ff1 --- /dev/null +++ b/tests/test_core/test_rules_base.py @@ -0,0 +1,25 @@ +import pytest + +from def_form.core.rules.base import Rule +from def_form.core.rules.context import RuleContext + + +def test_rule_check_raises_not_implemented() -> None: + class StubRule(Rule): + def check(self, context: RuleContext): + return super().check(context) + + rule = StubRule() + ctx = RuleContext( + filepath='x.py', + line_no=1, + line_length=50, + arg_count=2, + is_single_line=True, + has_correct_multiline_format=True, + indent_size=4, + max_def_length=None, + max_inline_args=None, + ) + with pytest.raises(NotImplementedError): + rule.check(ctx) diff --git a/tests/test_formatter/test_formatter.py b/tests/test_formatter/test_formatter.py index 150dc06..b01eb73 100644 --- a/tests/test_formatter/test_formatter.py +++ b/tests/test_formatter/test_formatter.py @@ -1,21 +1,32 @@ -import os -from collections.abc import Callable +from pathlib import Path +from unittest.mock import patch -from def_form.formatters.def_formatter import DefManager -from tests.constants import EXPECTED_TOTAL_ISSUES -from tests.constants import FORMATTED_PATH -from tests.mock_data import EXPECTED_ISSUES +import pytest +from def_form.core import DefManager +from tests.helpers import normalize_issues +from tests.conftest import CASE_IDS -def test_successful_format(get_def_manager: DefManager, get_expected: str, get_formatted: Callable[[], str]): - get_def_manager.format(write_to=FORMATTED_PATH) - assert len(get_def_manager.issues) == EXPECTED_TOTAL_ISSUES - assert get_def_manager.issues == EXPECTED_ISSUES - assert get_expected == get_formatted() - os.remove(FORMATTED_PATH) +@pytest.mark.parametrize('case_id', CASE_IDS, indirect=True) +def test_format_case( + case_id: str, + case_dir: Path, + case_manager: DefManager, + case_expected_issues: list[tuple[int, str]], + case_expected_content: str | None, +): + def capture_write(dest, module: str): + pass + with patch.object(case_manager, '_write', side_effect=capture_write) as mocked_write: + case_manager.format() -def test_no_need_format(get_correct_def_manager: DefManager): - get_correct_def_manager.format() - assert len(get_correct_def_manager.issues) == 0 + got_issues = normalize_issues(case_manager.issues) + assert got_issues == case_expected_issues, ( + f'case_id={case_id}: expected issues {case_expected_issues}, got {got_issues}' + ) + + if case_expected_content is not None: + mocked_write.assert_called_once() + assert mocked_write.call_args[1]['module'].strip() == case_expected_content.strip() diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils/test_find_pyproject.py b/tests/test_utils/test_find_pyproject.py new file mode 100644 index 0000000..97375da --- /dev/null +++ b/tests/test_utils/test_find_pyproject.py @@ -0,0 +1,43 @@ +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +from def_form.utils.find_pyproject import find_pyproject_toml + + +def test_find_pyproject_returns_path_when_in_cwd(tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "x"') + with patch.object(Path, 'cwd', return_value=tmp_path): + result = find_pyproject_toml() + assert result == str(tmp_path / 'pyproject.toml') + + +def test_find_pyproject_returns_none_when_no_file_in_any_parent() -> None: + cwd = MagicMock(spec=Path) + cwd.__truediv__ = lambda self, other: cwd._child if other == 'pyproject.toml' else MagicMock() + cwd._child = MagicMock() + cwd._child.is_file.return_value = False + parent1 = MagicMock(spec=Path) + parent1.__truediv__ = lambda self, other: parent1._child if other == 'pyproject.toml' else MagicMock() + parent1._child = MagicMock() + parent1._child.is_file.return_value = False + cwd.parents = [parent1] + with patch.object(Path, 'cwd', return_value=cwd): + result = find_pyproject_toml() + assert result is None + + +def test_find_pyproject_returns_path_when_in_parent() -> None: + cwd = MagicMock(spec=Path) + child_cwd = MagicMock() + child_cwd.is_file.return_value = False + cwd.__truediv__ = lambda self, other: child_cwd if other == 'pyproject.toml' else MagicMock() + parent = MagicMock(spec=Path) + parent_file = MagicMock() + parent_file.is_file.return_value = True + parent_file.__str__ = lambda self: '/fake/parent/pyproject.toml' + parent.__truediv__ = lambda self, other: parent_file if other == 'pyproject.toml' else MagicMock() + cwd.parents = [parent] + with patch.object(Path, 'cwd', return_value=cwd): + result = find_pyproject_toml() + assert result == '/fake/parent/pyproject.toml' diff --git a/uv.lock b/uv.lock index a96900f..3927bcc 100644 --- a/uv.lock +++ b/uv.lock @@ -134,11 +134,12 @@ toml = [ [[package]] name = "def-form" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" }, { name = "libcst" }, + { name = "rich" }, { name = "tomli" }, ] @@ -154,6 +155,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.2.1" }, { name = "libcst", specifier = ">=1.8.2" }, + { name = "rich", specifier = ">=14.3.1" }, { name = "tomli", specifier = ">=2.4.0" }, ] @@ -327,6 +329,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -384,20 +407,20 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pathspec" -version = "1.0.3" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -538,6 +561,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, ] +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + [[package]] name = "ruff" version = "0.11.13"