-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathpyck.py
More file actions
executable file
·353 lines (324 loc) · 15.3 KB
/
Copy pathpyck.py
File metadata and controls
executable file
·353 lines (324 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
#!/usr/bin/env python
########################################################################
# pyck.py: Comprehensive Python Code Formatter and Linter
#
# Description:
# This script performs Python code auto-fixing and lint checking. It uses
# autoflake to remove unused imports, autopep8 to apply formatting fixes,
# isort to organize imports, and flake8 to detect lint issues.
#
# pyck deliberately separates changes that its automatic fixers can apply
# from lint findings that may require human judgment. "Would clean:",
# "Would format:", and "Would sort imports in:" are reserved for changes
# that pyck -i directly applies through autoflake, autopep8, and isort.
# flake8 findings are reported separately because flake8 can detect real
# defects that the configured automatic fixers cannot safely rewrite.
#
# Without -i, pyck performs a non-destructive dry run. flake8 findings are
# reported as "Lint issue (manual review candidate):". They are described
# as candidates at this stage because a later autoflake, autopep8, or isort
# change may indirectly eliminate the condition.
#
# With -i, pyck first runs autoflake, autopep8, and isort for each file,
# then runs flake8 against the resulting file. Any lint findings that remain
# are reported as "Manual fix required:". This means the configured
# automatic fixers have completed and the remaining finding requires human
# review.
#
# Remaining lint findings are advisory diagnostics, not pyck execution
# failures. A normally completed pyck run returns status 0 even when
# "Manual fix required:" findings remain. When a formatter or linter
# process exits with a status other than the success or finding status
# expected for that invocation, pyck treats it as an execution failure and
# returns status 1, while still processing the remaining stages and files.
#
# pyck uses its own formatter and linter settings and ignores user-level and
# project-local configuration files. The same file is therefore checked and
# formatted with the same pyck policy regardless of the current working
# directory or configuration files surrounding the target.
#
# Author: id774 (More info: https://id774.net)
# Source Code: https://github.com/id774/scripts
# License: The GPL version 3, or LGPL version 3 (Dual License).
# Contact: idnanashi@gmail.com
#
# Usage:
# Without -i (Dry-run mode):
# pyck.py [file(s) or directory(ies)]
# Example:
# pyck.py ./my_python_project *.py
# This mode never modifies files. "Would clean:", "Would format:", and
# "Would sort imports in:" identify changes that -i would directly apply.
# flake8 findings are shown separately as
# "Lint issue (manual review candidate):" because they are not guaranteed
# to require manual correction until auto-fix has been applied.
#
# With -i (Actual formatting mode):
# pyck.py -i [file(s) or directory(ies)]
# Example:
# pyck.py -i ./my_python_project *.py
# For each file, this mode runs autoflake, autopep8, and isort, then runs
# flake8 on the resulting file. Remaining lint findings are reported as
# "Manual fix required:". These findings require human review but do not
# change the normal exit status from 0.
#
# Requirements:
# - Python Version: 3.2 or later
# - Dependencies: autopep8, flake8, autoflake, isort
#
# Exit Status:
# 0. Processing completed; lint findings or dry-run change candidates may remain.
# 1. A formatter or linter command failed during processing.
# 9. The Python interpreter is older than the supported minimum version.
# 126. A required command exists but is not executable.
# 127. A required command is not found.
#
# pyck uses separate ignore policies for autopep8 and Flake8. autopep8
# ignores E302, E402, and E501. Flake8 ignores E302, E402, E501, W503, and
# W504. W503 and W504 are mutually exclusive operator line-break
# conventions, so pyck excludes both from Flake8 rather than enforcing
# either one. Other Flake8 rules are not added to the explicit ignore
# list and remain enforced.
#
# Version History:
# v3.2 2026-09-06
# Return failure for formatter or linter execution errors while keeping
# lint findings and dry-run change candidates advisory.
# v3.1 2026-09-05
# Separate autopep8 and Flake8 ignore policies, keeping E302/E402/E501
# for autopep8 and additionally ignoring W503/W504 in Flake8.
# v3.0 2026-08-23
# Distinguish auto-fixable changes from lint findings, report unresolved lint
# issues after auto-fix, and use isolated formatter and linter configuration.
# v2.7 2026-07-15
# Quote file and directory paths before interpolating them into
# shell commands, to support paths containing spaces.
# v2.6 2025-07-01
# Standardized termination behavior for consistent script execution.
# v2.5 2025-06-23
# Unified usage output to display full script header and support common help/version options.
# v2.4 2025-04-14
# Unify error and info message formatting with stderr and prefix tags.
# v2.3 2024-01-28
# Replaced shutil.which with a custom which function to ensure compatibility
# with Python versions prior to 3.3.
# v2.2 2024-01-20
# Refactored to include a main function and separate argument parser setup function.
# v2.1 2024-01-18
# Added isort integration for organizing imports.
# Fixed TypeError in run_command function by decoding stdout to string.
# v2.0 2024-01-13
# Ported from shell script (pyck.sh) to Python (pyck.py) for enhanced portability and functionality. Integrated functionality
# of autopyck.sh, including dry-run mode. Added support for multiple files and directories, including wildcard usage.
# v1.4 2024-01-07
# Updated command existence and execution permission checks
# using a common function for enhanced reliability and maintainability.
# v1.3 2023-12-20
# Replaced 'which' with 'command -v' for command existence check.
# v1.2 2023-12-07
# Removed dependency on specific Python path.
# v1.1 2023-12-06
# Refactored for clarity, added detailed comments, and documentation.
# v1.0 2014-08-12
# Initial release.
#
########################################################################
import argparse
import glob
import os
import shlex
import subprocess
import sys
import tempfile
AUTOPEP8_IGNORE_ERRORS = "E302,E402,E501"
FLAKE8_IGNORE_ERRORS = "E302,E402,E501,W503,W504"
def usage():
""" Display the script header as usage information and exit. """
script_path = os.path.abspath(__file__)
in_header = False
try:
with open(script_path, 'r', encoding='utf-8') as f:
for line in f:
if line.strip().startswith('#' * 10):
if not in_header:
in_header = True
continue
else:
break
if in_header and line.startswith('#'):
if line.startswith('# '):
print(line[2:], end='')
else:
print(line[1:], end='')
except Exception as e:
print("Error reading usage information: %s" % str(e), file=sys.stderr)
sys.exit(1)
sys.exit(0)
def setup_argument_parser():
""" Initialize and return an argument parser for command-line options. """
parser = argparse.ArgumentParser(
description="Python Code Formatter and Linter")
parser.add_argument("paths", nargs='+', type=str,
help="Directories or files to format and lint")
parser.add_argument("-i", "--auto-fix",
action="store_true", help="Auto-fix code issues")
return parser
def find_quality_tool_candidate(cmd):
""" Check if a given command exists in the system's PATH. """
for path in os.environ["PATH"].split(os.pathsep):
full_path = os.path.join(path, cmd)
if os.path.isfile(full_path):
return full_path
return None
def check_quality_tool(cmd):
""" Verify if a command is available and executable in the system's PATH. """
cmd_path = find_quality_tool_candidate(cmd)
if not cmd_path:
# If the command is not found
print("[ERROR] Command '{}' is not installed. Please install {} and try again.".format(cmd, cmd), file=sys.stderr)
sys.exit(127)
elif not os.access(cmd_path, os.X_OK):
# If the command is found but not executable
print("[ERROR] Command '{}' is not executable. Please check the permissions.".format(cmd), file=sys.stderr)
sys.exit(126)
def create_isolated_config(directory):
""" Create an isolated shared configuration for formatter and linter tools. """
config_path = os.path.join(directory, 'pyck.cfg')
with open(config_path, 'w', encoding='utf-8') as f:
f.write(
"[autoflake]\n"
"quiet = false\n\n"
"[pycodestyle]\n\n"
"[isort]\n"
"lines_between_sections = 1\n"
)
return config_path
def format_imports(file_path, config_path):
""" Format and organize imports in a Python file using 'isort'. """
command = "isort --settings-path={} {}".format(
shlex.quote(config_path), shlex.quote(file_path))
return subprocess.Popen(command, shell=True).wait()
def resolve_target_files(paths):
""" Resolve the given files/directories into the concrete list of .py files to process. """
target_files = []
for path in paths:
actual_path = path[0] if isinstance(path, list) else path
if os.path.isdir(actual_path):
for root, dirs, files in os.walk(actual_path):
for name in files:
if name.endswith('.py'):
target_files.append(os.path.join(root, name))
elif os.path.isfile(actual_path):
target_files.append(actual_path)
else:
print("[ERROR] The specified path '{}' is neither a file nor a directory.".format(
actual_path), file=sys.stderr)
return target_files
def dry_run_formatting(paths, autopep8_ignore_errors, config_path):
""" Perform a dry run to show which files auto-fix would change, without making actual changes. """
print("[INFO] DRY RUN: No files will be modified. Use -i to auto-fix.")
overall_status = 0
for file_path in resolve_target_files(paths):
if run_quality_check(
"flake8 --isolated --ignore={} {}".format(
FLAKE8_IGNORE_ERRORS, shlex.quote(file_path)),
show_files="Lint issue (manual review candidate):",
expected_nonzero=(1,)) != 0:
overall_status = 1
if run_quality_check("autoflake --config={} --imports=django,requests,urllib3 --check {}".format(
shlex.quote(config_path), shlex.quote(file_path)),
show_files="Would clean: {}".format(file_path), literal_message=True,
expected_nonzero=(1,)) != 0:
overall_status = 1
if run_quality_check("autopep8 --global-config={} --ignore-local-config --ignore={} --diff --exit-code {}".format(
shlex.quote(config_path), autopep8_ignore_errors, shlex.quote(file_path)),
show_files="Would format: {}".format(file_path), literal_message=True,
expected_nonzero=(2,)) != 0:
overall_status = 1
if run_quality_check("isort --settings-path={} --check-only {}".format(
shlex.quote(config_path), shlex.quote(file_path)),
show_files="Would sort imports in: {}".format(file_path), literal_message=True,
expected_nonzero=(1,)) != 0:
overall_status = 1
return overall_status
def execute_formatting(paths, autopep8_ignore_errors, config_path):
""" Execute auto-formatting and report lint issues that remain afterward. """
overall_status = 0
for file_path in resolve_target_files(paths):
if format_file(file_path, autopep8_ignore_errors, config_path) != 0:
overall_status = 1
if run_quality_check(
"flake8 --isolated --ignore={} {}".format(
FLAKE8_IGNORE_ERRORS, shlex.quote(file_path)),
show_files="Manual fix required:",
expected_nonzero=(1,)) != 0:
overall_status = 1
return overall_status
def format_file(file_path, autopep8_ignore_errors, config_path):
""" Format a single Python file by cleaning up imports, and applying 'autopep8' and 'isort'. """
overall_status = 0
command = "autoflake --config={} --imports=django,requests,urllib3 -i {}".format(
shlex.quote(config_path), shlex.quote(file_path))
status = subprocess.Popen(command, shell=True).wait()
if status != 0:
print("[ERROR] autoflake failed for {} with exit status {}.".format(
file_path, status), file=sys.stderr)
overall_status = 1
command = "autopep8 --global-config={} --ignore-local-config --ignore={} -v -i {}".format(
shlex.quote(config_path), autopep8_ignore_errors, shlex.quote(file_path))
status = subprocess.Popen(command, shell=True).wait()
if status != 0:
print("[ERROR] autopep8 failed for {} with exit status {}.".format(
file_path, status), file=sys.stderr)
overall_status = 1
status = format_imports(file_path, config_path)
if status != 0:
print("[ERROR] isort failed for {} with exit status {}.".format(
file_path, status), file=sys.stderr)
overall_status = 1
return overall_status
def run_quality_check(command, show_files=None, literal_message=False, expected_nonzero=()):
""" Execute a shell command, reporting expected non-zero statuses as findings and any other non-zero status as an execution failure. """
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
stdout, _ = process.communicate()
if isinstance(stdout, bytes):
stdout = stdout.decode('utf-8')
if process.returncode == 0:
return 0
if process.returncode in expected_nonzero:
if show_files:
if literal_message:
print(show_files)
else:
for line in stdout.split('\n'):
if line:
print("{} {}".format(show_files, line))
return 0
print("[ERROR] Command failed with exit status {}: {}".format(
process.returncode, command), file=sys.stderr)
return 1
def main():
""" Parse command-line arguments and perform formatting or dry-run based on the input. """
parser = setup_argument_parser()
args = parser.parse_args()
expanded_paths = []
for path in args.paths:
expanded_paths.extend(glob.glob(path) or [path])
check_quality_tool("autopep8")
check_quality_tool("flake8")
check_quality_tool("autoflake")
check_quality_tool("isort")
with tempfile.TemporaryDirectory() as temp_dir:
config_path = create_isolated_config(temp_dir)
if args.auto_fix:
status = execute_formatting(expanded_paths, AUTOPEP8_IGNORE_ERRORS, config_path)
else:
status = dry_run_formatting(expanded_paths, AUTOPEP8_IGNORE_ERRORS, config_path)
return status
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', '-v', '--version'):
usage()
if sys.version_info < (3, 2):
print("[ERROR] This script requires Python 3.2 or later.", file=sys.stderr)
sys.exit(9)
sys.exit(main())