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.
@@ -0,0 +1,2 @@
1
+ # gh_formatter package
2
+ __version__ = "0.1.0"
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()