behave-format 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- behave_format/__init__.py +41 -0
- behave_format/cli/__init__.py +1 -0
- behave_format/cli/main.py +175 -0
- behave_format/config/__init__.py +1 -0
- behave_format/config/settings.py +81 -0
- behave_format/pipeline/__init__.py +1 -0
- behave_format/pipeline/align.py +69 -0
- behave_format/pipeline/formatter.py +96 -0
- behave_format/pipeline/normalize.py +124 -0
- behave_format/pipeline/rules.py +31 -0
- behave_format/pipeline/sort.py +65 -0
- behave_format/printer/__init__.py +1 -0
- behave_format/printer/feature_printer.py +93 -0
- behave_format/printer/scenario_printer.py +117 -0
- behave_format/printer/step_printer.py +46 -0
- behave_format/printer/table_printer.py +47 -0
- behave_format/printer/tag_printer.py +21 -0
- behave_format-1.0.0.dist-info/METADATA +280 -0
- behave_format-1.0.0.dist-info/RECORD +22 -0
- behave_format-1.0.0.dist-info/WHEEL +4 -0
- behave_format-1.0.0.dist-info/entry_points.txt +2 -0
- behave_format-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""behave-format — The opinionated formatter for Behave .feature files.
|
|
2
|
+
|
|
3
|
+
behave-format is the equivalent of Black for Gherkin .feature files.
|
|
4
|
+
It consumes a behave-model Project and produces deterministic,
|
|
5
|
+
beautifully formatted output.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
|
|
9
|
+
from behave_format import format_project, render_project, Settings
|
|
10
|
+
from behave_model import load_project
|
|
11
|
+
|
|
12
|
+
project = load_project("features/")
|
|
13
|
+
format_project(project)
|
|
14
|
+
# or render to text:
|
|
15
|
+
text = render_project(project)
|
|
16
|
+
|
|
17
|
+
CLI:
|
|
18
|
+
|
|
19
|
+
behave-format features/ # format in place
|
|
20
|
+
behave-format --check features/ # check mode (exit 1 if changes needed)
|
|
21
|
+
behave-format --diff features/ # show diff without writing
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from behave_format.config.settings import Settings
|
|
25
|
+
from behave_format.pipeline.formatter import (
|
|
26
|
+
format_feature,
|
|
27
|
+
format_project,
|
|
28
|
+
render_feature,
|
|
29
|
+
render_project,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Settings",
|
|
36
|
+
"format_feature",
|
|
37
|
+
"format_project",
|
|
38
|
+
"render_feature",
|
|
39
|
+
"render_project",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for behave-format."""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""CLI entry point for behave-format.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
behave-format [OPTIONS] PATH...
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
--check Check mode: exit 1 if formatting is needed, don't write.
|
|
8
|
+
--diff Show diffs without writing files.
|
|
9
|
+
--config PATH Path to pyproject.toml (default: auto-discover).
|
|
10
|
+
--quiet Suppress output except errors.
|
|
11
|
+
--help Show help message.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import difflib
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from behave_model import load_feature
|
|
22
|
+
|
|
23
|
+
from behave_format.config.settings import Settings
|
|
24
|
+
from behave_format.pipeline.formatter import format_feature
|
|
25
|
+
from behave_format.printer.feature_printer import print_feature
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
"""CLI entry point.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
argv: Command-line arguments (defaults to sys.argv).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Exit code: 0 on success, 1 if formatting is needed (--check).
|
|
36
|
+
"""
|
|
37
|
+
parser = _build_parser()
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
settings = _load_settings(args.config)
|
|
41
|
+
|
|
42
|
+
paths = [Path(p) for p in args.paths]
|
|
43
|
+
if not paths:
|
|
44
|
+
parser.print_help()
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
needs_formatting = False
|
|
48
|
+
|
|
49
|
+
for path in paths:
|
|
50
|
+
if path.is_dir():
|
|
51
|
+
changed = _process_directory(
|
|
52
|
+
path, settings, check=args.check, diff=args.diff, quiet=args.quiet
|
|
53
|
+
)
|
|
54
|
+
if changed:
|
|
55
|
+
needs_formatting = True
|
|
56
|
+
elif path.is_file() and path.suffix == ".feature":
|
|
57
|
+
changed = _process_file(
|
|
58
|
+
path, settings, check=args.check, diff=args.diff, quiet=args.quiet
|
|
59
|
+
)
|
|
60
|
+
if changed:
|
|
61
|
+
needs_formatting = True
|
|
62
|
+
else:
|
|
63
|
+
print(f"Warning: skipping {path} (not a .feature file or directory)", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
if args.check and needs_formatting:
|
|
66
|
+
if not args.quiet:
|
|
67
|
+
print("Files would be reformatted")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
if not args.quiet and not args.check and not args.diff:
|
|
71
|
+
if needs_formatting:
|
|
72
|
+
print("Formatted files")
|
|
73
|
+
else:
|
|
74
|
+
print("No changes needed")
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
80
|
+
parser = argparse.ArgumentParser(
|
|
81
|
+
prog="behave-format",
|
|
82
|
+
description="The opinionated formatter for Behave .feature files.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"paths",
|
|
86
|
+
nargs="*",
|
|
87
|
+
help="Paths to .feature files or directories containing them.",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--check",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="Check mode: exit 1 if formatting is needed, don't write files.",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--diff",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Show diffs without writing files.",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--config",
|
|
101
|
+
default=None,
|
|
102
|
+
help="Path to pyproject.toml for configuration.",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--quiet",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="Suppress output except errors.",
|
|
108
|
+
)
|
|
109
|
+
return parser
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_settings(config_path: str | None) -> Settings:
|
|
113
|
+
if config_path:
|
|
114
|
+
return Settings.from_pyproject(config_path)
|
|
115
|
+
return Settings.from_pyproject("pyproject.toml")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _process_directory(
|
|
119
|
+
directory: Path,
|
|
120
|
+
settings: Settings,
|
|
121
|
+
*,
|
|
122
|
+
check: bool,
|
|
123
|
+
diff: bool,
|
|
124
|
+
quiet: bool,
|
|
125
|
+
) -> bool:
|
|
126
|
+
feature_files = sorted(directory.rglob("*.feature"))
|
|
127
|
+
changed = False
|
|
128
|
+
for fpath in feature_files:
|
|
129
|
+
if _process_file(fpath, settings, check=check, diff=diff, quiet=quiet):
|
|
130
|
+
changed = True
|
|
131
|
+
return changed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _process_file(
|
|
135
|
+
fpath: Path,
|
|
136
|
+
settings: Settings,
|
|
137
|
+
*,
|
|
138
|
+
check: bool,
|
|
139
|
+
diff: bool,
|
|
140
|
+
quiet: bool,
|
|
141
|
+
) -> bool:
|
|
142
|
+
original = fpath.read_text(encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
feature = load_feature(fpath)
|
|
145
|
+
format_feature(feature, settings)
|
|
146
|
+
formatted = print_feature(feature, indent=settings.indent) + "\n"
|
|
147
|
+
|
|
148
|
+
if formatted == original:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
if diff:
|
|
152
|
+
_show_diff(fpath, original, formatted, quiet=quiet)
|
|
153
|
+
|
|
154
|
+
if not check and not diff:
|
|
155
|
+
fpath.write_text(formatted, encoding="utf-8")
|
|
156
|
+
if not quiet:
|
|
157
|
+
print(f"reformatted {fpath}")
|
|
158
|
+
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _show_diff(fpath: Path, original: str, formatted: str, *, quiet: bool) -> None:
|
|
163
|
+
diff_lines = difflib.unified_diff(
|
|
164
|
+
original.splitlines(keepends=True),
|
|
165
|
+
formatted.splitlines(keepends=True),
|
|
166
|
+
fromfile=str(fpath),
|
|
167
|
+
tofile=str(fpath),
|
|
168
|
+
)
|
|
169
|
+
diff_text = "".join(diff_lines)
|
|
170
|
+
if diff_text and not quiet:
|
|
171
|
+
sys.stdout.write(diff_text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration package for behave-format."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Formatter settings with minimal configuration philosophy.
|
|
2
|
+
|
|
3
|
+
Settings can be loaded from ``pyproject.toml`` under ``[tool.behave-format]``
|
|
4
|
+
or constructed programmatically.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ModuleNotFoundError:
|
|
14
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Settings:
|
|
19
|
+
"""Immutable formatter settings.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
indent: Number of spaces for indentation (default 2).
|
|
23
|
+
sort_tags: Sort tags alphabetically (default True).
|
|
24
|
+
sort_features: Sort features by name (default False).
|
|
25
|
+
sort_scenarios: Sort scenarios by name (default False).
|
|
26
|
+
line_length: Maximum line length for reference (default 120).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
indent: int = 2
|
|
30
|
+
sort_tags: bool = True
|
|
31
|
+
sort_features: bool = False
|
|
32
|
+
sort_scenarios: bool = False
|
|
33
|
+
line_length: int = 120
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_pyproject(cls, path: str = "pyproject.toml") -> Settings:
|
|
37
|
+
"""Load settings from a ``pyproject.toml`` file.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to the ``pyproject.toml`` file.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A Settings instance. If the file or section is missing,
|
|
44
|
+
defaults are returned.
|
|
45
|
+
"""
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
p = Path(path)
|
|
49
|
+
if not p.exists():
|
|
50
|
+
return cls()
|
|
51
|
+
|
|
52
|
+
with p.open("rb") as f:
|
|
53
|
+
data = tomllib.load(f)
|
|
54
|
+
|
|
55
|
+
section = data.get("tool", {}).get("behave-format", {})
|
|
56
|
+
return cls(
|
|
57
|
+
indent=section.get("indent", 2),
|
|
58
|
+
sort_tags=section.get("sort_tags", True),
|
|
59
|
+
sort_features=section.get("sort_features", False),
|
|
60
|
+
sort_scenarios=section.get("sort_scenarios", False),
|
|
61
|
+
line_length=section.get("line_length", 120),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict) -> Settings:
|
|
66
|
+
"""Create settings from a dictionary.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
data: Dictionary with optional keys: indent, sort_tags,
|
|
70
|
+
sort_features, sort_scenarios, line_length.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A Settings instance.
|
|
74
|
+
"""
|
|
75
|
+
return cls(
|
|
76
|
+
indent=data.get("indent", 2),
|
|
77
|
+
sort_tags=data.get("sort_tags", True),
|
|
78
|
+
sort_features=data.get("sort_features", False),
|
|
79
|
+
sort_scenarios=data.get("sort_scenarios", False),
|
|
80
|
+
line_length=data.get("line_length", 120),
|
|
81
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Formatting pipeline package."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Align stage — table alignment and trailing whitespace removal.
|
|
2
|
+
|
|
3
|
+
This stage ensures tables are properly aligned and no trailing
|
|
4
|
+
whitespace remains in any text content. It operates on the model
|
|
5
|
+
before printing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from behave_model.model.feature import Feature
|
|
11
|
+
from behave_model.model.project import Project
|
|
12
|
+
from behave_model.model.scenario_outline import ScenarioOutline
|
|
13
|
+
from behave_model.model.step import Step
|
|
14
|
+
from behave_model.model.table import Table
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def align_project(project: Project) -> Project:
|
|
18
|
+
"""Apply alignment rules to the project.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
project: The project to align (mutated in place).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The same project, aligned.
|
|
25
|
+
"""
|
|
26
|
+
for feature in project.features:
|
|
27
|
+
_align_feature(feature)
|
|
28
|
+
return project
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _align_feature(feature: Feature) -> None:
|
|
32
|
+
if feature.background:
|
|
33
|
+
for step in feature.background.steps:
|
|
34
|
+
_align_step(step)
|
|
35
|
+
for scenario in feature.scenarios:
|
|
36
|
+
for step in scenario.steps:
|
|
37
|
+
_align_step(step)
|
|
38
|
+
if isinstance(scenario, ScenarioOutline):
|
|
39
|
+
for ex in scenario.examples:
|
|
40
|
+
_align_table(examples_table=ex.table)
|
|
41
|
+
for rule in feature.rules:
|
|
42
|
+
if rule.background:
|
|
43
|
+
for step in rule.background.steps:
|
|
44
|
+
_align_step(step)
|
|
45
|
+
for scenario in rule.scenarios:
|
|
46
|
+
for step in scenario.steps:
|
|
47
|
+
_align_step(step)
|
|
48
|
+
if isinstance(scenario, ScenarioOutline):
|
|
49
|
+
for ex in scenario.examples:
|
|
50
|
+
_align_table(examples_table=ex.table)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _align_step(step: Step) -> None:
|
|
54
|
+
if step.data_table:
|
|
55
|
+
_align_table(step.data_table)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _align_table(table: Table, *, examples_table: Table | None = None) -> None:
|
|
59
|
+
target = examples_table if examples_table is not None else table
|
|
60
|
+
_ensure_rectangular(target)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ensure_rectangular(table: Table) -> None:
|
|
64
|
+
num_cols = len(table.headers)
|
|
65
|
+
for row in table.rows:
|
|
66
|
+
while len(row.cells) < num_cols:
|
|
67
|
+
row.cells.append("")
|
|
68
|
+
if len(row.cells) > num_cols:
|
|
69
|
+
row.cells = row.cells[:num_cols]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Formatter — the main orchestrator for the formatting pipeline.
|
|
2
|
+
|
|
3
|
+
The pipeline is:
|
|
4
|
+
1. Normalize — clean whitespace, standardize structure
|
|
5
|
+
2. Sort — order tags, features, scenarios
|
|
6
|
+
3. Align — table alignment, trailing whitespace
|
|
7
|
+
4. Print — convert model to .feature text
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from behave_model.model.feature import Feature
|
|
13
|
+
from behave_model.model.project import Project
|
|
14
|
+
|
|
15
|
+
from behave_format.config.settings import Settings
|
|
16
|
+
from behave_format.pipeline.align import align_project
|
|
17
|
+
from behave_format.pipeline.normalize import normalize_project
|
|
18
|
+
from behave_format.pipeline.sort import sort_project
|
|
19
|
+
from behave_format.printer.feature_printer import print_feature
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_project(project: Project, settings: Settings | None = None) -> Project:
|
|
23
|
+
"""Format a behave-model Project in place.
|
|
24
|
+
|
|
25
|
+
Applies the full pipeline: normalize → sort → align.
|
|
26
|
+
The project is mutated and returned.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
project: The project to format.
|
|
30
|
+
settings: Optional formatter settings. Defaults to Settings().
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The same project, formatted.
|
|
34
|
+
"""
|
|
35
|
+
if settings is None:
|
|
36
|
+
settings = Settings()
|
|
37
|
+
|
|
38
|
+
normalize_project(project)
|
|
39
|
+
sort_project(project, settings)
|
|
40
|
+
align_project(project)
|
|
41
|
+
return project
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_feature(feature: Feature, settings: Settings | None = None) -> Feature:
|
|
45
|
+
"""Format a single Feature in place.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
feature: The feature to format.
|
|
49
|
+
settings: Optional formatter settings. Defaults to Settings().
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The same feature, formatted.
|
|
53
|
+
"""
|
|
54
|
+
if settings is None:
|
|
55
|
+
settings = Settings()
|
|
56
|
+
|
|
57
|
+
from behave_format.pipeline.normalize import _normalize_feature
|
|
58
|
+
from behave_format.pipeline.sort import _sort_feature_tags
|
|
59
|
+
|
|
60
|
+
_normalize_feature(feature)
|
|
61
|
+
if settings.sort_tags:
|
|
62
|
+
_sort_feature_tags(feature)
|
|
63
|
+
# align is handled at print time
|
|
64
|
+
return feature
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_feature(feature: Feature, settings: Settings | None = None) -> str:
|
|
68
|
+
"""Format and render a Feature as .feature text.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
feature: The feature to format and print.
|
|
72
|
+
settings: Optional formatter settings.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The formatted .feature file content as a string.
|
|
76
|
+
"""
|
|
77
|
+
format_feature(feature, settings)
|
|
78
|
+
return print_feature(feature, indent=settings.indent if settings else 2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_project(project: Project, settings: Settings | None = None) -> str:
|
|
82
|
+
"""Format and render an entire Project as .feature text.
|
|
83
|
+
|
|
84
|
+
Features are separated by a single blank line.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
project: The project to format and print.
|
|
88
|
+
settings: Optional formatter settings.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The formatted content for all features, joined by blank lines.
|
|
92
|
+
"""
|
|
93
|
+
format_project(project, settings)
|
|
94
|
+
indent = settings.indent if settings else 2
|
|
95
|
+
parts = [print_feature(f, indent=indent) for f in project.features]
|
|
96
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Normalize stage — clean whitespace and standardize structure.
|
|
2
|
+
|
|
3
|
+
This stage operates on a behave-model Project in place.
|
|
4
|
+
It NEVER changes semantics: only whitespace, indentation, and
|
|
5
|
+
structural consistency are normalized.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from behave_model.model.background import Background
|
|
11
|
+
from behave_model.model.docstring import DocString
|
|
12
|
+
from behave_model.model.examples import Examples
|
|
13
|
+
from behave_model.model.feature import Feature
|
|
14
|
+
from behave_model.model.project import Project
|
|
15
|
+
from behave_model.model.rule import Rule
|
|
16
|
+
from behave_model.model.scenario import Scenario
|
|
17
|
+
from behave_model.model.scenario_outline import ScenarioOutline
|
|
18
|
+
from behave_model.model.step import Step
|
|
19
|
+
from behave_model.model.table import Table
|
|
20
|
+
from behave_model.model.tag import Tag
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_project(project: Project) -> Project:
|
|
24
|
+
"""Normalize whitespace and structure across the entire project.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
project: The project to normalize (mutated in place).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The same project, normalized.
|
|
31
|
+
"""
|
|
32
|
+
for feature in project.features:
|
|
33
|
+
_normalize_feature(feature)
|
|
34
|
+
project.global_tags.sort(key=lambda t: t.name)
|
|
35
|
+
return project
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_feature(feature: Feature) -> None:
|
|
39
|
+
feature.name = " ".join(feature.name.split())
|
|
40
|
+
feature.description = _normalize_description(feature.description)
|
|
41
|
+
_normalize_tags(feature.tags)
|
|
42
|
+
if feature.background:
|
|
43
|
+
_normalize_background(feature.background)
|
|
44
|
+
for scenario in feature.scenarios:
|
|
45
|
+
_normalize_scenario(scenario)
|
|
46
|
+
for rule in feature.rules:
|
|
47
|
+
_normalize_rule(rule)
|
|
48
|
+
for comment in feature.comments:
|
|
49
|
+
comment.text = comment.text.rstrip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_rule(rule: Rule) -> None:
|
|
53
|
+
rule.name = " ".join(rule.name.split())
|
|
54
|
+
rule.description = _normalize_description(rule.description)
|
|
55
|
+
_normalize_tags(rule.tags)
|
|
56
|
+
if rule.background:
|
|
57
|
+
_normalize_background(rule.background)
|
|
58
|
+
for scenario in rule.scenarios:
|
|
59
|
+
_normalize_scenario(scenario)
|
|
60
|
+
for comment in rule.comments:
|
|
61
|
+
comment.text = comment.text.rstrip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normalize_background(background: Background) -> None:
|
|
65
|
+
background.name = " ".join(background.name.split())
|
|
66
|
+
for step in background.steps:
|
|
67
|
+
_normalize_step(step)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_scenario(scenario: Scenario | ScenarioOutline) -> None:
|
|
71
|
+
scenario.name = " ".join(scenario.name.split())
|
|
72
|
+
scenario.description = _normalize_description(scenario.description)
|
|
73
|
+
_normalize_tags(scenario.tags)
|
|
74
|
+
for step in scenario.steps:
|
|
75
|
+
_normalize_step(step)
|
|
76
|
+
if isinstance(scenario, ScenarioOutline):
|
|
77
|
+
for examples in scenario.examples:
|
|
78
|
+
_normalize_examples(examples)
|
|
79
|
+
for comment in scenario.comments:
|
|
80
|
+
comment.text = comment.text.rstrip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normalize_examples(examples: Examples) -> None:
|
|
84
|
+
examples.name = " ".join(examples.name.split())
|
|
85
|
+
_normalize_tags(examples.tags)
|
|
86
|
+
_normalize_table(examples.table)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_step(step: Step) -> None:
|
|
90
|
+
step.keyword = step.keyword.strip()
|
|
91
|
+
step.name = " ".join(step.name.split())
|
|
92
|
+
if step.doc_string:
|
|
93
|
+
_normalize_doc_string(step.doc_string)
|
|
94
|
+
if step.data_table:
|
|
95
|
+
_normalize_table(step.data_table)
|
|
96
|
+
for comment in step.comments:
|
|
97
|
+
comment.text = comment.text.rstrip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_doc_string(doc_string: DocString) -> None:
|
|
101
|
+
doc_string.content_type = doc_string.content_type.strip()
|
|
102
|
+
delimiter = doc_string.delimiter or '"""'
|
|
103
|
+
doc_string.delimiter = delimiter
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalize_table(table: Table) -> None:
|
|
107
|
+
table.headers = [h.strip() for h in table.headers]
|
|
108
|
+
for row in table.rows:
|
|
109
|
+
row.cells = [c.strip() for c in row.cells]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalize_tags(tags: list[Tag]) -> None:
|
|
113
|
+
for tag in tags:
|
|
114
|
+
tag.name = tag.name.strip()
|
|
115
|
+
if not tag.name.startswith("@"):
|
|
116
|
+
tag.name = "@" + tag.name
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_description(description: str) -> str:
|
|
120
|
+
lines = description.splitlines()
|
|
121
|
+
normalized = []
|
|
122
|
+
for line in lines:
|
|
123
|
+
normalized.append(" ".join(line.split()))
|
|
124
|
+
return "\n".join(normalized)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Formatting rules registry.
|
|
2
|
+
|
|
3
|
+
Each rule is a callable that transforms the project at a specific
|
|
4
|
+
stage of the pipeline. Rules are applied in order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from behave_model.model.project import Project
|
|
12
|
+
|
|
13
|
+
from behave_format.config.settings import Settings
|
|
14
|
+
|
|
15
|
+
Rule = Callable[[Project, Settings], Project]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def apply_rules(project: Project, settings: Settings, rules: list[Rule]) -> Project:
|
|
19
|
+
"""Apply a list of formatting rules to the project.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
project: The project to format.
|
|
23
|
+
settings: Formatter settings.
|
|
24
|
+
rules: Ordered list of rule callables.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The formatted project.
|
|
28
|
+
"""
|
|
29
|
+
for rule in rules:
|
|
30
|
+
project = rule(project, settings)
|
|
31
|
+
return project
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Sort stage — order tags, features, and scenarios.
|
|
2
|
+
|
|
3
|
+
Sorting is configurable via Settings. By default only tags are sorted
|
|
4
|
+
alphabetically. Feature and scenario sorting are opt-in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from behave_model.model.feature import Feature
|
|
10
|
+
from behave_model.model.project import Project
|
|
11
|
+
from behave_model.model.rule import Rule
|
|
12
|
+
from behave_model.model.scenario_outline import ScenarioOutline
|
|
13
|
+
|
|
14
|
+
from behave_format.config.settings import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sort_project(project: Project, settings: Settings) -> Project:
|
|
18
|
+
"""Apply sorting rules to the project.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
project: The project to sort (mutated in place).
|
|
22
|
+
settings: Formatter settings controlling sort behavior.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The same project, sorted.
|
|
26
|
+
"""
|
|
27
|
+
if settings.sort_tags:
|
|
28
|
+
_sort_all_tags(project)
|
|
29
|
+
|
|
30
|
+
if settings.sort_features:
|
|
31
|
+
project.features.sort(key=lambda f: f.name)
|
|
32
|
+
|
|
33
|
+
if settings.sort_scenarios:
|
|
34
|
+
for feature in project.features:
|
|
35
|
+
feature.scenarios.sort(key=lambda s: s.name)
|
|
36
|
+
for rule in feature.rules:
|
|
37
|
+
rule.scenarios.sort(key=lambda s: s.name)
|
|
38
|
+
|
|
39
|
+
return project
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sort_all_tags(project: Project) -> None:
|
|
43
|
+
project.global_tags.sort(key=lambda t: t.name)
|
|
44
|
+
for feature in project.features:
|
|
45
|
+
_sort_feature_tags(feature)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sort_feature_tags(feature: Feature) -> None:
|
|
49
|
+
feature.tags.sort(key=lambda t: t.name)
|
|
50
|
+
for scenario in feature.scenarios:
|
|
51
|
+
scenario.tags.sort(key=lambda t: t.name)
|
|
52
|
+
if isinstance(scenario, ScenarioOutline):
|
|
53
|
+
for ex in scenario.examples:
|
|
54
|
+
ex.tags.sort(key=lambda t: t.name)
|
|
55
|
+
for rule in feature.rules:
|
|
56
|
+
_sort_rule_tags(rule)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sort_rule_tags(rule: Rule) -> None:
|
|
60
|
+
rule.tags.sort(key=lambda t: t.name)
|
|
61
|
+
for scenario in rule.scenarios:
|
|
62
|
+
scenario.tags.sort(key=lambda t: t.name)
|
|
63
|
+
if isinstance(scenario, ScenarioOutline):
|
|
64
|
+
for ex in scenario.examples:
|
|
65
|
+
ex.tags.sort(key=lambda t: t.name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Printer package — converts behave-model objects to .feature text."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Feature printer — formats a complete Feature as .feature text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from behave_model.model.feature import Feature
|
|
6
|
+
from behave_model.model.rule import Rule
|
|
7
|
+
from behave_model.model.scenario_outline import ScenarioOutline
|
|
8
|
+
|
|
9
|
+
from behave_format.printer.scenario_printer import (
|
|
10
|
+
print_background,
|
|
11
|
+
print_scenario,
|
|
12
|
+
print_scenario_outline,
|
|
13
|
+
)
|
|
14
|
+
from behave_format.printer.tag_printer import print_tags
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def print_feature(feature: Feature, indent: int = 2) -> str:
|
|
18
|
+
"""Format a Feature as valid, deterministic Gherkin text.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
feature: The Feature to print.
|
|
22
|
+
indent: Base indentation for scenarios and rules.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Multi-line string representing the complete feature file content.
|
|
26
|
+
"""
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
|
|
29
|
+
if feature.tags:
|
|
30
|
+
lines.append(print_tags(feature.tags, indent=0))
|
|
31
|
+
|
|
32
|
+
header = f"Feature: {feature.name}" if feature.name else "Feature:"
|
|
33
|
+
lines.append(header)
|
|
34
|
+
|
|
35
|
+
if feature.description:
|
|
36
|
+
for desc_line in feature.description.splitlines():
|
|
37
|
+
if desc_line:
|
|
38
|
+
lines.append(f"{' ' * indent}{desc_line}")
|
|
39
|
+
else:
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
if feature.background:
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append(print_background(feature.background, indent=indent))
|
|
45
|
+
|
|
46
|
+
for scenario in feature.scenarios:
|
|
47
|
+
lines.append("")
|
|
48
|
+
if isinstance(scenario, ScenarioOutline):
|
|
49
|
+
lines.append(print_scenario_outline(scenario, indent=indent))
|
|
50
|
+
else:
|
|
51
|
+
lines.append(print_scenario(scenario, indent=indent))
|
|
52
|
+
|
|
53
|
+
for rule in feature.rules:
|
|
54
|
+
lines.append("")
|
|
55
|
+
lines.append(_print_rule(rule, indent=indent))
|
|
56
|
+
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_rule(rule: Rule, indent: int = 2) -> str:
|
|
61
|
+
prefix = " " * indent
|
|
62
|
+
lines: list[str] = []
|
|
63
|
+
|
|
64
|
+
if rule.tags:
|
|
65
|
+
lines.append(print_tags(rule.tags, indent=indent))
|
|
66
|
+
|
|
67
|
+
header = f"Rule: {rule.name}" if rule.name else "Rule:"
|
|
68
|
+
lines.append(f"{prefix}{header}")
|
|
69
|
+
|
|
70
|
+
if rule.description:
|
|
71
|
+
for desc_line in rule.description.splitlines():
|
|
72
|
+
if desc_line:
|
|
73
|
+
lines.append(f"{prefix} {desc_line}")
|
|
74
|
+
else:
|
|
75
|
+
lines.append("")
|
|
76
|
+
|
|
77
|
+
first_child = True
|
|
78
|
+
if rule.background:
|
|
79
|
+
if not first_child:
|
|
80
|
+
lines.append("")
|
|
81
|
+
lines.append(print_background(rule.background, indent=indent + 2))
|
|
82
|
+
first_child = False
|
|
83
|
+
|
|
84
|
+
for scenario in rule.scenarios:
|
|
85
|
+
if not first_child:
|
|
86
|
+
lines.append("")
|
|
87
|
+
if isinstance(scenario, ScenarioOutline):
|
|
88
|
+
lines.append(print_scenario_outline(scenario, indent=indent + 2))
|
|
89
|
+
else:
|
|
90
|
+
lines.append(print_scenario(scenario, indent=indent + 2))
|
|
91
|
+
first_child = False
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Scenario printer — formats scenarios and scenario outlines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from behave_model.model.background import Background
|
|
6
|
+
from behave_model.model.examples import Examples
|
|
7
|
+
from behave_model.model.scenario import Scenario
|
|
8
|
+
from behave_model.model.scenario_outline import ScenarioOutline
|
|
9
|
+
|
|
10
|
+
from behave_format.printer.step_printer import print_step
|
|
11
|
+
from behave_format.printer.table_printer import print_table
|
|
12
|
+
from behave_format.printer.tag_printer import print_tags
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_background(background: Background, indent: int = 2) -> str:
|
|
16
|
+
"""Format a Background block.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
background: The Background to print.
|
|
20
|
+
indent: Base indentation level.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Multi-line string with the background and its steps.
|
|
24
|
+
"""
|
|
25
|
+
prefix = " " * indent
|
|
26
|
+
header = "Background:"
|
|
27
|
+
if background.name:
|
|
28
|
+
header = f"Background: {background.name}"
|
|
29
|
+
lines: list[str] = [f"{prefix}{header}"]
|
|
30
|
+
for step in background.steps:
|
|
31
|
+
lines.append(print_step(step, indent=indent + 2))
|
|
32
|
+
return "\n".join(lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_scenario(scenario: Scenario, indent: int = 2) -> str:
|
|
36
|
+
"""Format a Scenario as Gherkin text.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
scenario: The Scenario to print.
|
|
40
|
+
indent: Base indentation level.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Multi-line string with tags, scenario header, and steps.
|
|
44
|
+
"""
|
|
45
|
+
prefix = " " * indent
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
|
|
48
|
+
if scenario.tags:
|
|
49
|
+
lines.append(print_tags(scenario.tags, indent=indent))
|
|
50
|
+
|
|
51
|
+
header = f"Scenario: {scenario.name}" if scenario.name else "Scenario:"
|
|
52
|
+
lines.append(f"{prefix}{header}")
|
|
53
|
+
|
|
54
|
+
if scenario.description:
|
|
55
|
+
for desc_line in scenario.description.splitlines():
|
|
56
|
+
if desc_line:
|
|
57
|
+
lines.append(f"{prefix} {desc_line}")
|
|
58
|
+
else:
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
61
|
+
for step in scenario.steps:
|
|
62
|
+
lines.append(print_step(step, indent=indent + 2))
|
|
63
|
+
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def print_scenario_outline(outline: ScenarioOutline, indent: int = 2) -> str:
|
|
68
|
+
"""Format a ScenarioOutline as Gherkin text.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
outline: The ScenarioOutline to print.
|
|
72
|
+
indent: Base indentation level.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Multi-line string with tags, outline header, steps, and examples.
|
|
76
|
+
"""
|
|
77
|
+
prefix = " " * indent
|
|
78
|
+
lines: list[str] = []
|
|
79
|
+
|
|
80
|
+
if outline.tags:
|
|
81
|
+
lines.append(print_tags(outline.tags, indent=indent))
|
|
82
|
+
|
|
83
|
+
header = f"Scenario Outline: {outline.name}" if outline.name else "Scenario Outline:"
|
|
84
|
+
lines.append(f"{prefix}{header}")
|
|
85
|
+
|
|
86
|
+
if outline.description:
|
|
87
|
+
for desc_line in outline.description.splitlines():
|
|
88
|
+
if desc_line:
|
|
89
|
+
lines.append(f"{prefix} {desc_line}")
|
|
90
|
+
else:
|
|
91
|
+
lines.append("")
|
|
92
|
+
|
|
93
|
+
for step in outline.steps:
|
|
94
|
+
lines.append(print_step(step, indent=indent + 2))
|
|
95
|
+
|
|
96
|
+
for examples in outline.examples:
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append(_print_examples(examples, indent=indent + 2))
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _print_examples(examples: Examples, indent: int = 4) -> str:
|
|
104
|
+
prefix = " " * indent
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
|
|
107
|
+
if examples.tags:
|
|
108
|
+
lines.append(print_tags(examples.tags, indent=indent))
|
|
109
|
+
|
|
110
|
+
header = "Examples:"
|
|
111
|
+
if examples.name:
|
|
112
|
+
header = f"Examples: {examples.name}"
|
|
113
|
+
lines.append(f"{prefix}{header}")
|
|
114
|
+
|
|
115
|
+
lines.append(print_table(examples.table, indent=indent + 2))
|
|
116
|
+
|
|
117
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Step printer — formats steps with proper indentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from behave_model.model.docstring import DocString
|
|
6
|
+
from behave_model.model.step import Step
|
|
7
|
+
|
|
8
|
+
from behave_format.printer.table_printer import print_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_step(step: Step, indent: int = 4) -> str:
|
|
12
|
+
"""Format a single step as Gherkin text.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
step: The Step to print.
|
|
16
|
+
indent: Number of spaces for indentation.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Multi-line string with the step and any attached docstring or table.
|
|
20
|
+
"""
|
|
21
|
+
prefix = " " * indent
|
|
22
|
+
lines: list[str] = [f"{prefix}{step.keyword} {step.name}".rstrip()]
|
|
23
|
+
|
|
24
|
+
if step.doc_string:
|
|
25
|
+
lines.append(_print_doc_string(step.doc_string, indent))
|
|
26
|
+
|
|
27
|
+
if step.data_table:
|
|
28
|
+
lines.append(print_table(step.data_table, indent=indent + 2))
|
|
29
|
+
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _print_doc_string(doc_string: DocString, indent: int) -> str:
|
|
34
|
+
prefix = " " * (indent + 2)
|
|
35
|
+
delimiter = doc_string.delimiter or '"""'
|
|
36
|
+
content_type = doc_string.content_type
|
|
37
|
+
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
header = f"{prefix}{delimiter}"
|
|
40
|
+
if content_type:
|
|
41
|
+
header += content_type
|
|
42
|
+
lines.append(header)
|
|
43
|
+
for content_line in doc_string.lines:
|
|
44
|
+
lines.append(f"{prefix}{content_line}")
|
|
45
|
+
lines.append(f"{prefix}{delimiter}")
|
|
46
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Table printer — formats data tables with aligned columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from behave_model.model.table import Table
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def print_table(table: Table, indent: int = 4) -> str:
|
|
9
|
+
"""Format a Table as aligned Gherkin table text.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
table: The Table to print.
|
|
13
|
+
indent: Number of spaces for indentation.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Multi-line string with aligned table rows.
|
|
17
|
+
"""
|
|
18
|
+
if not table.headers and not table.rows:
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
widths = _compute_widths(table)
|
|
22
|
+
prefix = " " * indent
|
|
23
|
+
lines: list[str] = []
|
|
24
|
+
|
|
25
|
+
header_cells = [
|
|
26
|
+
h.ljust(widths[i]) if i < len(widths) else h for i, h in enumerate(table.headers)
|
|
27
|
+
]
|
|
28
|
+
lines.append(f"{prefix}| {' | '.join(header_cells)} |")
|
|
29
|
+
|
|
30
|
+
for row in table.rows:
|
|
31
|
+
row_cells = [
|
|
32
|
+
cell.ljust(widths[i]) if i < len(widths) else cell for i, cell in enumerate(row.cells)
|
|
33
|
+
]
|
|
34
|
+
lines.append(f"{prefix}| {' | '.join(row_cells)} |")
|
|
35
|
+
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _compute_widths(table: Table) -> list[int]:
|
|
40
|
+
widths = [len(h) for h in table.headers]
|
|
41
|
+
for row in table.rows:
|
|
42
|
+
for i, cell in enumerate(row.cells):
|
|
43
|
+
if i < len(widths):
|
|
44
|
+
widths[i] = max(widths[i], len(cell))
|
|
45
|
+
else:
|
|
46
|
+
widths.append(len(cell))
|
|
47
|
+
return widths
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Tag printer — formats tags as space-separated strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from behave_model.model.tag import Tag
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def print_tags(tags: list[Tag], indent: int = 0) -> str:
|
|
9
|
+
"""Format a list of tags as a single line.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
tags: List of Tag objects.
|
|
13
|
+
indent: Number of spaces to prefix.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A space-separated tag string, or empty string if no tags.
|
|
17
|
+
"""
|
|
18
|
+
if not tags:
|
|
19
|
+
return ""
|
|
20
|
+
prefix = " " * indent
|
|
21
|
+
return prefix + " ".join(t.name for t in tags)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: behave-format
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The opinionated formatter for Behave .feature files
|
|
5
|
+
Project-URL: Homepage, https://github.com/MathiasPaulenko/behave-format
|
|
6
|
+
Project-URL: Repository, https://github.com/MathiasPaulenko/behave-format
|
|
7
|
+
Project-URL: Issues, https://github.com/MathiasPaulenko/behave-format/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/MathiasPaulenko/behave-format/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Mathias Paulenko
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bdd,behave,cucumber,format,formatter,gherkin,testing
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: behave-model>=0.1.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
28
|
+
Provides-Extra: docs
|
|
29
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
30
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# behave-format
|
|
34
|
+
|
|
35
|
+
> The opinionated formatter for Behave `.feature` files.
|
|
36
|
+
|
|
37
|
+
[Black](https://github.com/psf/black) is for Python. [gofmt](https://go.dev/blog/gofmt) is for Go. **behave-format** is for Gherkin.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Overview
|
|
42
|
+
|
|
43
|
+
`behave-format` is a deterministic, opinionated formatter for Behave `.feature` files. It consumes the canonical domain model from [behave-model](https://github.com/MathiasPaulenko/behave-model) and produces clean, consistent, beautifully formatted output.
|
|
44
|
+
|
|
45
|
+
**Key principle:** behave-format does NOT parse Gherkin. It does NOT lint. It does NOT validate. It ONLY transforms a `behave-model.Project` into formatted `.feature` files.
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
.feature files → behave-model (domain model) → behave-format → formatted .feature files
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Opinionated** — minimal configuration, sensible defaults
|
|
54
|
+
- **Deterministic** — same input always produces same output
|
|
55
|
+
- **Idempotent** — `format(format(x)) == format(x)`
|
|
56
|
+
- **Fast** — handles thousands of feature files efficiently
|
|
57
|
+
- **CI-friendly** — `--check` mode with exit code 1 when formatting is needed
|
|
58
|
+
- **Safe** — never changes semantics (names, step text, table values, docstrings)
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install behave-format
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
### CLI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Format files in place (default)
|
|
72
|
+
behave-format features/
|
|
73
|
+
|
|
74
|
+
# Check mode (CI) — exit 1 if formatting is needed
|
|
75
|
+
behave-format --check features/
|
|
76
|
+
|
|
77
|
+
# Diff mode — show differences without writing
|
|
78
|
+
behave-format --diff features/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Python API
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from behave_model import load_project
|
|
85
|
+
from behave_format import format_project, render_project, Settings
|
|
86
|
+
|
|
87
|
+
project = load_project("features/")
|
|
88
|
+
|
|
89
|
+
# Format the project model in place
|
|
90
|
+
format_project(project)
|
|
91
|
+
|
|
92
|
+
# Or render to text
|
|
93
|
+
text = render_project(project, Settings())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Formatting Rules
|
|
97
|
+
|
|
98
|
+
### Tags
|
|
99
|
+
|
|
100
|
+
Tags are sorted alphabetically by default:
|
|
101
|
+
|
|
102
|
+
```gherkin
|
|
103
|
+
@api @smoke
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Features
|
|
107
|
+
|
|
108
|
+
- One blank line before each Feature
|
|
109
|
+
- Clean title formatting
|
|
110
|
+
|
|
111
|
+
### Scenarios
|
|
112
|
+
|
|
113
|
+
- One blank line before each Scenario
|
|
114
|
+
- Two-space indentation for steps
|
|
115
|
+
|
|
116
|
+
```gherkin
|
|
117
|
+
Given user exists
|
|
118
|
+
When user logs in
|
|
119
|
+
Then dashboard is shown
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Tables
|
|
123
|
+
|
|
124
|
+
Tables are always aligned:
|
|
125
|
+
|
|
126
|
+
Before:
|
|
127
|
+
|
|
128
|
+
```gherkin
|
|
129
|
+
|user|password|
|
|
130
|
+
|john|123|
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
After:
|
|
134
|
+
|
|
135
|
+
```gherkin
|
|
136
|
+
| user | password |
|
|
137
|
+
| john | 123 |
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Blank Lines
|
|
141
|
+
|
|
142
|
+
- No trailing blank lines
|
|
143
|
+
- No multiple consecutive empty lines
|
|
144
|
+
- Consistent spacing between blocks
|
|
145
|
+
|
|
146
|
+
### Indentation
|
|
147
|
+
|
|
148
|
+
- Spaces only (no tabs)
|
|
149
|
+
- Default: 2 spaces
|
|
150
|
+
|
|
151
|
+
## Configuration
|
|
152
|
+
|
|
153
|
+
Minimal configuration via `pyproject.toml`:
|
|
154
|
+
|
|
155
|
+
```toml
|
|
156
|
+
[tool.behave-format]
|
|
157
|
+
indent = 2
|
|
158
|
+
sort_tags = true
|
|
159
|
+
sort_features = false
|
|
160
|
+
sort_scenarios = false
|
|
161
|
+
line_length = 120
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
| Option | Default | Description |
|
|
165
|
+
|--------|---------|-------------|
|
|
166
|
+
| `indent` | `2` | Number of spaces for indentation |
|
|
167
|
+
| `sort_tags` | `true` | Sort tags alphabetically |
|
|
168
|
+
| `sort_features` | `false` | Sort features by name |
|
|
169
|
+
| `sort_scenarios` | `false` | Sort scenarios by name |
|
|
170
|
+
| `line_length` | `120` | Maximum line length (reference) |
|
|
171
|
+
|
|
172
|
+
## Before / After
|
|
173
|
+
|
|
174
|
+
### Before
|
|
175
|
+
|
|
176
|
+
```gherkin
|
|
177
|
+
@smoke @auth
|
|
178
|
+
Feature: Login
|
|
179
|
+
As a user
|
|
180
|
+
I want to log in
|
|
181
|
+
|
|
182
|
+
Background:
|
|
183
|
+
Given a database connection
|
|
184
|
+
|
|
185
|
+
@happy
|
|
186
|
+
Scenario: Successful login
|
|
187
|
+
Given the user is on the login page
|
|
188
|
+
When the user enters "admin" and "password"
|
|
189
|
+
Then the user should be logged in
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### After
|
|
193
|
+
|
|
194
|
+
```gherkin
|
|
195
|
+
@auth @smoke
|
|
196
|
+
Feature: Login
|
|
197
|
+
As a user
|
|
198
|
+
I want to log in
|
|
199
|
+
|
|
200
|
+
Background:
|
|
201
|
+
Given a database connection
|
|
202
|
+
|
|
203
|
+
@happy
|
|
204
|
+
Scenario: Successful login
|
|
205
|
+
Given the user is on the login page
|
|
206
|
+
When the user enters "admin" and "password"
|
|
207
|
+
Then the user should be logged in
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Architecture
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
behave_format/
|
|
214
|
+
├── config/
|
|
215
|
+
│ └── settings.py # Settings dataclass + pyproject.toml loader
|
|
216
|
+
├── pipeline/
|
|
217
|
+
│ ├── normalize.py # Whitespace, indentation, tag normalization
|
|
218
|
+
│ ├── sort.py # Sort tags, features, scenarios
|
|
219
|
+
│ ├── align.py # Table alignment, trailing whitespace
|
|
220
|
+
│ ├── rules.py # Formatting rules registry
|
|
221
|
+
│ └── formatter.py # Main orchestrator (format_project)
|
|
222
|
+
├── printer/
|
|
223
|
+
│ ├── feature_printer.py
|
|
224
|
+
│ ├── scenario_printer.py
|
|
225
|
+
│ ├── step_printer.py
|
|
226
|
+
│ ├── table_printer.py
|
|
227
|
+
│ └── tag_printer.py
|
|
228
|
+
└── cli/
|
|
229
|
+
└── main.py # CLI entry point
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Pipeline
|
|
233
|
+
|
|
234
|
+
1. **Normalize** — clean whitespace, standardize indentation, normalize tags
|
|
235
|
+
2. **Sort** — order tags (alphabetically by default), optionally features and scenarios
|
|
236
|
+
3. **Align** — align table columns, remove trailing spaces
|
|
237
|
+
4. **Print** — convert `behave-model` → `.feature` text (deterministic)
|
|
238
|
+
|
|
239
|
+
## Safety
|
|
240
|
+
|
|
241
|
+
The formatter NEVER changes semantics:
|
|
242
|
+
|
|
243
|
+
- Feature names: preserved
|
|
244
|
+
- Scenario names: preserved
|
|
245
|
+
- Step text: preserved (only whitespace normalized)
|
|
246
|
+
- DocString content: preserved
|
|
247
|
+
- Table values: preserved (only alignment changes)
|
|
248
|
+
- Comments content: preserved
|
|
249
|
+
|
|
250
|
+
## Integration
|
|
251
|
+
|
|
252
|
+
`behave-format` integrates naturally with the Behave ecosystem:
|
|
253
|
+
|
|
254
|
+
- [behave-model](https://github.com/MathiasPaulenko/behave-model) — single source of truth
|
|
255
|
+
- [behave-lint](https://github.com/MathiasPaulenko/behave-lint) — linting
|
|
256
|
+
- [behave-modern-json-report](https://github.com/MathiasPaulenko/behave-modern-json-report)
|
|
257
|
+
- [behave-modern-report](https://github.com/MathiasPaulenko/behave-modern-report)
|
|
258
|
+
- [behave-markdown-report](https://github.com/MathiasPaulenko/behave-markdown-report)
|
|
259
|
+
|
|
260
|
+
## Development
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
pip install -e ".[dev]"
|
|
264
|
+
pytest tests/ -v
|
|
265
|
+
ruff check .
|
|
266
|
+
ruff format --check .
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Contributing
|
|
270
|
+
|
|
271
|
+
Contributions are welcome! Please:
|
|
272
|
+
|
|
273
|
+
1. Fork the repository
|
|
274
|
+
2. Create a feature branch
|
|
275
|
+
3. Run `ruff check .` and `pytest tests/` before submitting
|
|
276
|
+
4. Open a Pull Request
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
behave_format/__init__.py,sha256=qa2JDyFPm5NkhhqOK4bpa1V7oq3o1mjwQGYWFszj2Bc,1041
|
|
2
|
+
behave_format/cli/__init__.py,sha256=9ogAdUoeTrMYLl2IZz6MygctP1elHvpfFM777i1cEcs,37
|
|
3
|
+
behave_format/cli/main.py,sha256=DSH0l8L8EayHR3QT_zotK1HNOtJFhnbT05B6BAghELA,4645
|
|
4
|
+
behave_format/config/__init__.py,sha256=uGsMkjI0Hyg0_utGKM3lOsp-cko-hevIQNX7L1VpPI8,47
|
|
5
|
+
behave_format/config/settings.py,sha256=oTxX3BGf-Fyi8jedFRDmvuzX8XE5B2BxZCzNynsXAnA,2439
|
|
6
|
+
behave_format/pipeline/__init__.py,sha256=Gh5e_EKrI36jOEpvfIC0mf_LjZs08_iSzFxlF30ebG8,35
|
|
7
|
+
behave_format/pipeline/align.py,sha256=omZpO9LNGGTTxnWynymNWeIeMwDgV19H-GR31gbEQ0o,2169
|
|
8
|
+
behave_format/pipeline/formatter.py,sha256=FXFYnyrU5sh20wptNtVFg03WHqrtP8BkE2LBE_3y14U,2986
|
|
9
|
+
behave_format/pipeline/normalize.py,sha256=Lf090fIGz9r5R2mJePryGkoEVj5VSAq-moiSUSjfHvg,4095
|
|
10
|
+
behave_format/pipeline/rules.py,sha256=pcdXtvhB7vR5Qh8I6nf43nCajJfaejNYOUE3AiVGYDI,789
|
|
11
|
+
behave_format/pipeline/sort.py,sha256=tHU7tEwv1Mlm1OKG9YzYZhZcxwsHwKxH4vBrREWxs0s,2036
|
|
12
|
+
behave_format/printer/__init__.py,sha256=rMnxwqPHv68zwrq2UicaUE6Dt1Sik6-Ni7GeBjTFfLM,74
|
|
13
|
+
behave_format/printer/feature_printer.py,sha256=1ksgWapnkyhhZFzZ0wFWrwKCmAMfqc6NuppG44_kYd0,2805
|
|
14
|
+
behave_format/printer/scenario_printer.py,sha256=fxnakBkH4O_VPhm33XTqx0vlM0gjHu8GOb8WFmziERY,3445
|
|
15
|
+
behave_format/printer/step_printer.py,sha256=W7gSvn3yuSgcs1hFMLBEcAkGs9-o7ZVTuGUbj62Az9A,1344
|
|
16
|
+
behave_format/printer/table_printer.py,sha256=CFJwkYG-_iambqFCnOEBmXNGbUYQzHMYfu7UqANPvvI,1334
|
|
17
|
+
behave_format/printer/tag_printer.py,sha256=cZQE2tvfkVZ8HDw0U0-BflLEF17eA48kgFCc-YOQ-Sc,535
|
|
18
|
+
behave_format-1.0.0.dist-info/METADATA,sha256=D96PgzdE7WdgI9cfkGpCRptp0a52TBQwbuADgKCSX7M,7222
|
|
19
|
+
behave_format-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
behave_format-1.0.0.dist-info/entry_points.txt,sha256=n9Q-LKVSfdDO-5vez6RQ96hwl3pFFMmR_uDiKjSN52A,62
|
|
21
|
+
behave_format-1.0.0.dist-info/licenses/LICENSE,sha256=LLr8AP65kT83seFEcknE3-76LL7oikrP87U33tZKYro,1073
|
|
22
|
+
behave_format-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mathias Paulenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|