gh-formatter 0.1.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.
- gh_formatter/__init__.py +2 -0
- gh_formatter/casing.py +80 -0
- gh_formatter/cli.py +289 -0
- gh_formatter/comments.py +358 -0
- gh_formatter/config.py +341 -0
- gh_formatter/context.py +89 -0
- gh_formatter/directives.py +110 -0
- gh_formatter/discovery.py +62 -0
- gh_formatter/engine.py +108 -0
- gh_formatter/postprocess.py +184 -0
- gh_formatter/project.py +109 -0
- gh_formatter/references.py +60 -0
- gh_formatter/rules/__init__.py +27 -0
- gh_formatter/rules/alphabetize.py +58 -0
- gh_formatter/rules/base.py +29 -0
- gh_formatter/rules/callers.py +157 -0
- gh_formatter/rules/if_expressions.py +68 -0
- gh_formatter/rules/inputs.py +130 -0
- gh_formatter/rules/jobs.py +84 -0
- gh_formatter/rules/keys.py +137 -0
- gh_formatter/rules/lists.py +73 -0
- gh_formatter/rules/names.py +63 -0
- gh_formatter/rules/quotes.py +51 -0
- gh_formatter/rules/style.py +57 -0
- gh_formatter/utils.py +133 -0
- gh_formatter/yamllint_sync.py +78 -0
- gh_formatter-0.1.0.dist-info/METADATA +501 -0
- gh_formatter-0.1.0.dist-info/RECORD +31 -0
- gh_formatter-0.1.0.dist-info/WHEEL +4 -0
- gh_formatter-0.1.0.dist-info/entry_points.txt +2 -0
- gh_formatter-0.1.0.dist-info/licenses/LICENSE +21 -0
gh_formatter/__init__.py
ADDED
gh_formatter/casing.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Identifier casing conversion and collision-safe rename planning."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from gh_formatter.context import Context
|
|
6
|
+
|
|
7
|
+
# Split camelCase/PascalCase at word boundaries; keeps acronyms intact
|
|
8
|
+
# (myURLInput -> my-URL-Input, not my-U-R-L-Input).
|
|
9
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
10
|
+
_SEPARATORS = re.compile(r"[\s_]+")
|
|
11
|
+
_DASH_RUNS = re.compile(r"-{2,}")
|
|
12
|
+
|
|
13
|
+
# Entirely uppercase identifiers (env-var style, e.g. SERVER_IMAGE)
|
|
14
|
+
_ALL_UPPERCASE = re.compile(r"^[A-Z0-9_-]+$")
|
|
15
|
+
_UPPER_SEPARATORS = re.compile(r"[\s_-]+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_uppercase_name(name: str) -> bool:
|
|
19
|
+
"""Returns True for env-var style names like SERVER_IMAGE or MM_ENV."""
|
|
20
|
+
return bool(_ALL_UPPERCASE.match(name)) and any(c.isalpha() for c in name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_uppercase(name: str) -> str:
|
|
24
|
+
"""Normalizes an uppercase identifier to underscore separators.
|
|
25
|
+
|
|
26
|
+
SERVER-IMAGE -> SERVER_IMAGE; SERVER_IMAGE stays unchanged.
|
|
27
|
+
"""
|
|
28
|
+
cleaned = _UPPER_SEPARATORS.sub("_", name).strip("_")
|
|
29
|
+
return cleaned or name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_casing(name: str, target: str) -> str:
|
|
33
|
+
"""Converts an identifier to dash-case or snake_case."""
|
|
34
|
+
cleaned = _CAMEL_BOUNDARY.sub("-", name)
|
|
35
|
+
cleaned = _SEPARATORS.sub("-", cleaned).lower()
|
|
36
|
+
cleaned = _DASH_RUNS.sub("-", cleaned).strip("-")
|
|
37
|
+
if not cleaned:
|
|
38
|
+
# Name consists only of separators; leave it untouched.
|
|
39
|
+
return name
|
|
40
|
+
if target == "snake_case":
|
|
41
|
+
return cleaned.replace("-", "_")
|
|
42
|
+
return cleaned
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_safe_renames(
|
|
46
|
+
keys: list[str],
|
|
47
|
+
target_casing: str,
|
|
48
|
+
context: Context,
|
|
49
|
+
kind: str,
|
|
50
|
+
preserve_uppercase: bool = False,
|
|
51
|
+
) -> dict[str, str]:
|
|
52
|
+
"""Plans renames to the target casing, skipping any that would collide.
|
|
53
|
+
|
|
54
|
+
A rename is skipped (with a warning) when the formatted name already
|
|
55
|
+
exists among the keys or is produced by another rename — applying it
|
|
56
|
+
would silently overwrite a sibling definition. With
|
|
57
|
+
``preserve_uppercase``, env-var style names (SERVER_IMAGE) keep their
|
|
58
|
+
uppercase form and only have separators normalized to underscores.
|
|
59
|
+
"""
|
|
60
|
+
existing = set(keys)
|
|
61
|
+
renames: dict[str, str] = {}
|
|
62
|
+
targets: set[str] = set()
|
|
63
|
+
|
|
64
|
+
for key in keys:
|
|
65
|
+
if preserve_uppercase and is_uppercase_name(key):
|
|
66
|
+
formatted = normalize_uppercase(key)
|
|
67
|
+
else:
|
|
68
|
+
formatted = format_casing(key, target_casing)
|
|
69
|
+
if formatted == key:
|
|
70
|
+
continue
|
|
71
|
+
if formatted in existing or formatted in targets:
|
|
72
|
+
context.add_warning(
|
|
73
|
+
f"Skipped renaming {kind} '{key}' to '{formatted}': "
|
|
74
|
+
"the target name is already in use"
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
renames[key] = formatted
|
|
78
|
+
targets.add(formatted)
|
|
79
|
+
|
|
80
|
+
return renames
|
gh_formatter/cli.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Command-line interface: argument parsing and orchestration."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import difflib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from gh_formatter import __version__
|
|
12
|
+
from gh_formatter.config import Config, ConfigError
|
|
13
|
+
from gh_formatter.context import Context
|
|
14
|
+
from gh_formatter.discovery import find_yaml_files
|
|
15
|
+
from gh_formatter.engine import Engine
|
|
16
|
+
from gh_formatter.project import ProjectPlan, build_project_plan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileStatus(Enum):
|
|
20
|
+
UNCHANGED = "unchanged"
|
|
21
|
+
CHANGED = "changed"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileResult:
|
|
27
|
+
status: FileStatus
|
|
28
|
+
message: str
|
|
29
|
+
warnings: list[str] = field(default_factory=list)
|
|
30
|
+
errors: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def process_file(
|
|
34
|
+
file_path: Path,
|
|
35
|
+
engine: Engine,
|
|
36
|
+
config: Config,
|
|
37
|
+
check: bool,
|
|
38
|
+
show_diff: bool,
|
|
39
|
+
plan: ProjectPlan | None = None,
|
|
40
|
+
) -> FileResult:
|
|
41
|
+
"""Processes a single file, formatting it or checking for changes."""
|
|
42
|
+
try:
|
|
43
|
+
raw = file_path.read_bytes()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return FileResult(FileStatus.ERROR, f"Failed to read file: {e}")
|
|
46
|
+
|
|
47
|
+
# "preserve" keeps the file's existing line endings instead of
|
|
48
|
+
# letting Python translate to the platform default on write.
|
|
49
|
+
if config.line_endings == "lf":
|
|
50
|
+
newline = "\n"
|
|
51
|
+
else:
|
|
52
|
+
newline = "\r\n" if b"\r\n" in raw else "\n"
|
|
53
|
+
needs_newline_fix = config.line_endings == "lf" and b"\r\n" in raw
|
|
54
|
+
content = raw.decode("utf-8").replace("\r\n", "\n")
|
|
55
|
+
|
|
56
|
+
context = Context(str(file_path), config)
|
|
57
|
+
context.project_plan = plan
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
formatted_content = engine.format_string(content, context)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return FileResult(
|
|
63
|
+
FileStatus.ERROR,
|
|
64
|
+
f"Failed to parse/format file: {e}",
|
|
65
|
+
context.warnings,
|
|
66
|
+
context.errors,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if content == formatted_content and not needs_newline_fix:
|
|
70
|
+
return FileResult(
|
|
71
|
+
FileStatus.UNCHANGED,
|
|
72
|
+
"Already formatted",
|
|
73
|
+
context.warnings,
|
|
74
|
+
context.errors,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if show_diff:
|
|
78
|
+
diff = difflib.unified_diff(
|
|
79
|
+
content.splitlines(keepends=True),
|
|
80
|
+
formatted_content.splitlines(keepends=True),
|
|
81
|
+
fromfile=f"a/{file_path.name}",
|
|
82
|
+
tofile=f"b/{file_path.name}",
|
|
83
|
+
)
|
|
84
|
+
return FileResult(
|
|
85
|
+
FileStatus.CHANGED, "".join(diff), context.warnings, context.errors
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if check:
|
|
89
|
+
return FileResult(
|
|
90
|
+
FileStatus.CHANGED,
|
|
91
|
+
"Needs formatting",
|
|
92
|
+
context.warnings,
|
|
93
|
+
context.errors,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with open(file_path, "w", encoding="utf-8", newline=newline) as f:
|
|
98
|
+
f.write(formatted_content)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return FileResult(
|
|
101
|
+
FileStatus.ERROR,
|
|
102
|
+
f"Failed to write file: {e}",
|
|
103
|
+
context.warnings,
|
|
104
|
+
context.errors,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return FileResult(
|
|
108
|
+
FileStatus.CHANGED,
|
|
109
|
+
"Formatted successfully",
|
|
110
|
+
context.warnings,
|
|
111
|
+
context.errors,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
description=(
|
|
118
|
+
"Format GitHub Actions and Workflows with custom stylesheets"
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--version",
|
|
123
|
+
action="version",
|
|
124
|
+
version=f"%(prog)s {__version__}",
|
|
125
|
+
help="Show the version and exit",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"paths", nargs="*", help="Paths to files or directories to format"
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--check",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help=(
|
|
134
|
+
"Dry run: Check if files are formatted, exit with 1 "
|
|
135
|
+
"if changes needed, 0 otherwise"
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--diff",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help=(
|
|
142
|
+
"Show unified diff of changes, exit with 1 if changes needed, "
|
|
143
|
+
"0 otherwise"
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--config", dest="config_path", help="Path to custom configuration file"
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--list-rules",
|
|
151
|
+
action="store_true",
|
|
152
|
+
help="List all available rules and post-processors, then exit",
|
|
153
|
+
)
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _list_rules(engine: Engine) -> None:
|
|
158
|
+
print("Rules:")
|
|
159
|
+
for rule in engine.rules:
|
|
160
|
+
print(f" {rule.id:<20} {rule.description}")
|
|
161
|
+
print("\nPost-processors:")
|
|
162
|
+
for processor in engine.postprocessors:
|
|
163
|
+
print(f" {processor.id:<20} {processor.description}")
|
|
164
|
+
print(
|
|
165
|
+
"\nDisable any of these via config, e.g.:\n"
|
|
166
|
+
" rules:\n capitalize-names: false"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main() -> None:
|
|
171
|
+
parser = _build_parser()
|
|
172
|
+
args = parser.parse_args()
|
|
173
|
+
engine = Engine()
|
|
174
|
+
|
|
175
|
+
if args.list_rules:
|
|
176
|
+
_list_rules(engine)
|
|
177
|
+
sys.exit(0)
|
|
178
|
+
if not args.paths:
|
|
179
|
+
parser.error("paths is required unless --list-rules is given")
|
|
180
|
+
|
|
181
|
+
config = _load_config(args.config_path)
|
|
182
|
+
files = find_yaml_files(args.paths)
|
|
183
|
+
if not files:
|
|
184
|
+
print("No GitHub action or workflow files found.")
|
|
185
|
+
sys.exit(0)
|
|
186
|
+
|
|
187
|
+
counts = _format_files(files, engine, config, args)
|
|
188
|
+
_print_summary(len(files), counts, args)
|
|
189
|
+
sys.exit(_exit_code(counts, args))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_config(config_path: str | None) -> Config:
|
|
193
|
+
"""Loads the config, exiting with code 2 on a configuration error."""
|
|
194
|
+
try:
|
|
195
|
+
return Config.load(config_path)
|
|
196
|
+
except ConfigError as e:
|
|
197
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
198
|
+
sys.exit(2)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class _Counts:
|
|
203
|
+
"""Tallies across a run, used for the summary and exit code."""
|
|
204
|
+
|
|
205
|
+
changed: int = 0
|
|
206
|
+
errors: int = 0 # files that failed to read/parse/write
|
|
207
|
+
lint_errors: int = 0 # caller-input (and similar) errors that must be fixed
|
|
208
|
+
warnings: int = 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _format_files(
|
|
212
|
+
files: list[Path],
|
|
213
|
+
engine: Engine,
|
|
214
|
+
config: Config,
|
|
215
|
+
args: argparse.Namespace,
|
|
216
|
+
) -> _Counts:
|
|
217
|
+
"""Processes each file, prints output, and returns the run tallies."""
|
|
218
|
+
# Plan the canonical inputs of local targets so callers can be checked.
|
|
219
|
+
plan = build_project_plan(files, config)
|
|
220
|
+
|
|
221
|
+
counts = _Counts()
|
|
222
|
+
print(f"Checking {len(files)} files...")
|
|
223
|
+
for f in files:
|
|
224
|
+
result = process_file(
|
|
225
|
+
f, engine, config, check=args.check, show_diff=args.diff, plan=plan
|
|
226
|
+
)
|
|
227
|
+
rel_path = os.path.relpath(f, os.getcwd())
|
|
228
|
+
_report_file(result, rel_path, args)
|
|
229
|
+
if result.status is FileStatus.ERROR:
|
|
230
|
+
counts.errors += 1
|
|
231
|
+
elif result.status is FileStatus.CHANGED:
|
|
232
|
+
counts.changed += 1
|
|
233
|
+
for error in result.errors:
|
|
234
|
+
counts.lint_errors += 1
|
|
235
|
+
print(f"[error] {rel_path} - {error}")
|
|
236
|
+
for warning in result.warnings:
|
|
237
|
+
counts.warnings += 1
|
|
238
|
+
print(f"[warn] {rel_path} - {warning}")
|
|
239
|
+
return counts
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _report_file(
|
|
243
|
+
result: FileResult, rel_path: str, args: argparse.Namespace
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Prints the one-line status for a processed file."""
|
|
246
|
+
if result.status is FileStatus.ERROR:
|
|
247
|
+
print(f"[ERROR] {rel_path} - {result.message}")
|
|
248
|
+
elif result.status is FileStatus.CHANGED:
|
|
249
|
+
if args.diff:
|
|
250
|
+
print(f"\n--- Diff for {rel_path} ---")
|
|
251
|
+
print(result.message)
|
|
252
|
+
elif args.check:
|
|
253
|
+
print(f"[X] {rel_path} - Needs formatting")
|
|
254
|
+
else:
|
|
255
|
+
print(f"[Fixed] {rel_path} - Formatted in-place")
|
|
256
|
+
elif args.check:
|
|
257
|
+
print(f"[ok] {rel_path}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _print_summary(
|
|
261
|
+
total: int, counts: _Counts, args: argparse.Namespace
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Prints the run summary."""
|
|
264
|
+
print("\nSummary:")
|
|
265
|
+
if args.check or args.diff:
|
|
266
|
+
print(f" {counts.changed} files would be formatted.")
|
|
267
|
+
else:
|
|
268
|
+
print(f" {counts.changed} files formatted.")
|
|
269
|
+
unchanged = total - counts.changed - counts.errors
|
|
270
|
+
print(f" {unchanged} files left unchanged.")
|
|
271
|
+
if counts.warnings:
|
|
272
|
+
print(f" {counts.warnings} warnings.")
|
|
273
|
+
if counts.lint_errors:
|
|
274
|
+
print(f" {counts.lint_errors} errors must be fixed.")
|
|
275
|
+
if counts.errors:
|
|
276
|
+
print(f" {counts.errors} files could not be processed.")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _exit_code(counts: _Counts, args: argparse.Namespace) -> int:
|
|
280
|
+
"""Returns the process exit code from the run outcome."""
|
|
281
|
+
if counts.changed > 0 and (args.check or args.diff):
|
|
282
|
+
return 1
|
|
283
|
+
if counts.errors or counts.lint_errors:
|
|
284
|
+
return 1
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|