prune-cli 0.1.1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include src/prune *.py
4
+ recursive-include tests *.py
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: prune-cli
3
+ Version: 0.1.1
4
+ Summary: A CLI tool named prune for cleaning Python code
5
+ Author-email: vincentdeneuf <0189vn@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vincentdeneuf/prune-cli
8
+ Project-URL: Repository, https://github.com/vincentdeneuf/prune-cli
9
+ Project-URL: Issues, https://github.com/vincentdeneuf/prune-cli/issues
10
+ Keywords: cli,code,formatting,refactor,ast,prune
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: libcst
24
+ Dynamic: license-file
25
+
26
+ # prune
27
+
28
+ A CLI tool named prune for cleaning Python code.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install prune-cli
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ prune <command> [options]
40
+ ```
41
+
42
+ ### Commands
43
+
44
+ - `prune comments` - Remove comments from Python files
45
+ - `prune prints` - Remove print statements from Python files
46
+ - `prune docstrings` - Remove docstrings from Python files
47
+ - `prune asserts` - Remove assert statements from Python files
48
+ - `prune logs` - Remove logging statements from Python files
49
+
50
+ ### Examples
51
+
52
+ ```bash
53
+ # Remove all print statements
54
+ prune prints
55
+
56
+ # Remove inline comments only (preserve noqa, type:, pragma)
57
+ prune comments --default
58
+
59
+ # Remove all types of comments
60
+ prune comments --all
61
+
62
+ # Remove specific log levels
63
+ prune logs --debug --info --error
64
+
65
+ # Remove all log levels
66
+ prune logs --all
67
+
68
+ # Show per-file details (verbose is default)
69
+ prune prints
70
+
71
+ # Suppress per-file output
72
+ prune prints --quiet
73
+ ```
74
+
75
+ ## Development
76
+
77
+ This project uses a src-layout packaging structure.
78
+
79
+ ### Requirements
80
+
81
+ - Python >= 3.11
82
+ - LibCST
83
+
84
+ ## License
85
+
86
+ MIT License
@@ -0,0 +1,61 @@
1
+ # prune
2
+
3
+ A CLI tool named prune for cleaning Python code.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install prune-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ prune <command> [options]
15
+ ```
16
+
17
+ ### Commands
18
+
19
+ - `prune comments` - Remove comments from Python files
20
+ - `prune prints` - Remove print statements from Python files
21
+ - `prune docstrings` - Remove docstrings from Python files
22
+ - `prune asserts` - Remove assert statements from Python files
23
+ - `prune logs` - Remove logging statements from Python files
24
+
25
+ ### Examples
26
+
27
+ ```bash
28
+ # Remove all print statements
29
+ prune prints
30
+
31
+ # Remove inline comments only (preserve noqa, type:, pragma)
32
+ prune comments --default
33
+
34
+ # Remove all types of comments
35
+ prune comments --all
36
+
37
+ # Remove specific log levels
38
+ prune logs --debug --info --error
39
+
40
+ # Remove all log levels
41
+ prune logs --all
42
+
43
+ # Show per-file details (verbose is default)
44
+ prune prints
45
+
46
+ # Suppress per-file output
47
+ prune prints --quiet
48
+ ```
49
+
50
+ ## Development
51
+
52
+ This project uses a src-layout packaging structure.
53
+
54
+ ### Requirements
55
+
56
+ - Python >= 3.11
57
+ - LibCST
58
+
59
+ ## License
60
+
61
+ MIT License
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "prune-cli"
7
+ version = "0.1.1"
8
+ description = "A CLI tool named prune for cleaning Python code"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+
13
+ authors = [
14
+ { name = "vincentdeneuf", email = "0189vn@gmail.com" },
15
+ ]
16
+
17
+ dependencies = [
18
+ "libcst",
19
+ ]
20
+
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Topic :: Software Development :: Code Generators",
31
+ ]
32
+
33
+ keywords = ["cli", "code", "formatting", "refactor", "ast", "prune"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/vincentdeneuf/prune-cli"
37
+ Repository = "https://github.com/vincentdeneuf/prune-cli"
38
+ Issues = "https://github.com/vincentdeneuf/prune-cli/issues"
39
+
40
+ [project.scripts]
41
+ prune = "prune.main:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ "build>=1.4.0",
49
+ "ruff>=0.15.0",
50
+ "twine>=6.2.0",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ line-length = 88
55
+ target-version = "py311"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """prune - A CLI tool for cleaning Python code."""
@@ -0,0 +1,418 @@
1
+ """Main entry point for the prune CLI."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ import libcst as cst
8
+
9
+ from prune.transformers import (
10
+ AssertRemover,
11
+ DocstringRemover,
12
+ HeaderCommentRemover,
13
+ InlineCommentRemover,
14
+ LeadingCommentRemover,
15
+ LogRemover,
16
+ PrintRemover,
17
+ )
18
+
19
+
20
+ def find_python_files(start_dir: str) -> list[str]:
21
+ """Recursively find all .py files, excluding specified directories."""
22
+ skip_dirs = {".git", ".venv", "venv", "__pycache__"}
23
+ python_files = []
24
+
25
+ for root, dirs, files in os.walk(start_dir):
26
+ # Remove skip directories from in-place traversal
27
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
28
+
29
+ for file in files:
30
+ if file.endswith(".py"):
31
+ python_files.append(os.path.join(root, file))
32
+
33
+ return python_files
34
+
35
+
36
+ def process_file(
37
+ file_path: str,
38
+ remove_prints: bool = False,
39
+ remove_comments: bool = False,
40
+ remove_docstrings: bool = False,
41
+ remove_asserts: bool = False,
42
+ remove_logs: bool = False,
43
+ comment_options: dict = None,
44
+ log_levels: set[str] = None,
45
+ ) -> tuple[int, int, int, int, int]:
46
+ """Process a single file and return counts of removed items."""
47
+ prints_removed = 0
48
+ comments_removed = 0
49
+ docstrings_removed = 0
50
+ asserts_removed = 0
51
+ logs_removed = 0
52
+
53
+ if comment_options is None:
54
+ comment_options = {}
55
+
56
+ try:
57
+ with open(file_path, encoding="utf-8") as f:
58
+ source_code = f.read()
59
+
60
+ module = cst.parse_module(source_code)
61
+
62
+ if remove_prints:
63
+ print_remover = PrintRemover()
64
+ module = module.visit(print_remover)
65
+ prints_removed = print_remover.removed_count
66
+
67
+ if remove_comments:
68
+ # Handle different comment removal options
69
+ if comment_options.get("all", False):
70
+ # Remove all types of comments
71
+ inline_remover = InlineCommentRemover(remove_all=True)
72
+ leading_remover = LeadingCommentRemover()
73
+ header_remover = HeaderCommentRemover()
74
+
75
+ module = module.visit(inline_remover)
76
+ comments_removed += inline_remover.removed_count
77
+
78
+ module = module.visit(leading_remover)
79
+ comments_removed += leading_remover.removed_count
80
+
81
+ module = module.visit(header_remover)
82
+ comments_removed += header_remover.removed_count
83
+ else:
84
+ # Handle individual comment types
85
+ if comment_options.get("inline", False):
86
+ inline_remover = InlineCommentRemover(remove_all=True)
87
+ module = module.visit(inline_remover)
88
+ comments_removed += inline_remover.removed_count
89
+
90
+ if comment_options.get("leading", False):
91
+ leading_remover = LeadingCommentRemover()
92
+ module = module.visit(leading_remover)
93
+ comments_removed += leading_remover.removed_count
94
+
95
+ if comment_options.get("header", False):
96
+ header_remover = HeaderCommentRemover()
97
+ module = module.visit(header_remover)
98
+ comments_removed += header_remover.removed_count
99
+
100
+ if comment_options.get("default", False):
101
+ inline_remover = InlineCommentRemover(remove_all=False)
102
+ module = module.visit(inline_remover)
103
+ comments_removed += inline_remover.removed_count
104
+
105
+ if remove_docstrings:
106
+ docstring_remover = DocstringRemover()
107
+ module = module.visit(docstring_remover)
108
+ docstrings_removed = docstring_remover.removed_count
109
+
110
+ if remove_asserts:
111
+ assert_remover = AssertRemover()
112
+ module = module.visit(assert_remover)
113
+ asserts_removed = assert_remover.removed_count
114
+
115
+ if remove_logs:
116
+ log_remover = LogRemover(log_levels)
117
+ module = module.visit(log_remover)
118
+ logs_removed = log_remover.removed_count
119
+
120
+ # Write back to file if any changes were made
121
+ if (
122
+ prints_removed > 0
123
+ or comments_removed > 0
124
+ or docstrings_removed > 0
125
+ or asserts_removed > 0
126
+ or logs_removed > 0
127
+ ):
128
+ with open(file_path, "w", encoding="utf-8") as f:
129
+ f.write(module.code)
130
+
131
+ except Exception:
132
+ # Skip files that can't be parsed or written
133
+ pass
134
+
135
+ return (
136
+ prints_removed,
137
+ comments_removed,
138
+ docstrings_removed,
139
+ asserts_removed,
140
+ logs_removed,
141
+ )
142
+
143
+
144
+ def main() -> int:
145
+ """Main entry point for the prune CLI."""
146
+ parser = argparse.ArgumentParser(
147
+ description="Remove print statements, comments, docstrings, asserts, and logs from Python files."
148
+ )
149
+ parser.add_argument(
150
+ "command",
151
+ choices=["comments", "prints", "docstrings", "asserts", "logs"],
152
+ help="What to remove",
153
+ )
154
+ parser.add_argument(
155
+ "--quiet",
156
+ "-q",
157
+ action="store_true",
158
+ help="Suppress per-file output (verbose is default)",
159
+ )
160
+
161
+ # Comment-specific options
162
+ parser.add_argument(
163
+ "--inline",
164
+ action="store_true",
165
+ help="Remove all inline comments, including noqa, type, pragma",
166
+ )
167
+ parser.add_argument(
168
+ "--leading", action="store_true", help="Remove standalone/full-line comments"
169
+ )
170
+ parser.add_argument(
171
+ "--header", action="store_true", help="Remove shebang & coding comments"
172
+ )
173
+ parser.add_argument(
174
+ "--default",
175
+ action="store_true",
176
+ help="Remove inline comments only (preserve noqa, type:, pragma)",
177
+ )
178
+
179
+ # Log-specific options
180
+ parser.add_argument("--trace", action="store_true", help="Remove trace level logs")
181
+ parser.add_argument("--debug", action="store_true", help="Remove debug level logs")
182
+ parser.add_argument("--info", action="store_true", help="Remove info level logs")
183
+ parser.add_argument(
184
+ "--warning", action="store_true", help="Remove warning level logs"
185
+ )
186
+ parser.add_argument(
187
+ "--success", action="store_true", help="Remove success level logs"
188
+ )
189
+ parser.add_argument("--error", action="store_true", help="Remove error level logs")
190
+ parser.add_argument(
191
+ "--exception", action="store_true", help="Remove exception level logs"
192
+ )
193
+ parser.add_argument(
194
+ "--critical", action="store_true", help="Remove critical level logs"
195
+ )
196
+ parser.add_argument(
197
+ "--all",
198
+ action="store_true",
199
+ help="Remove all types (for comments) or all log levels (for logs)",
200
+ )
201
+
202
+ args = parser.parse_args()
203
+
204
+ # Validate comment options
205
+ if args.command == "comments":
206
+ comment_flags = {
207
+ "inline": args.inline,
208
+ "leading": args.leading,
209
+ "header": args.header,
210
+ "default": args.default,
211
+ "all": args.all,
212
+ }
213
+
214
+ # Require at least one flag
215
+ if not any(comment_flags.values()):
216
+ print("Error: prune comments requires at least one flag")
217
+ print("Available flags:")
218
+ print(
219
+ " --default Remove inline comments only (preserve noqa, type:, pragma)"
220
+ )
221
+ print(
222
+ " --inline Remove all inline comments, including noqa, type, pragma"
223
+ )
224
+ print(" --leading Remove standalone/full-line comments")
225
+ print(" --header Remove shebang & coding comments")
226
+ print(" --all Remove all types of comments")
227
+ return 1
228
+
229
+ # Validate log options
230
+ if args.command == "logs":
231
+ log_flags = {
232
+ "trace": args.trace,
233
+ "debug": args.debug,
234
+ "info": args.info,
235
+ "warning": args.warning,
236
+ "success": args.success,
237
+ "error": args.error,
238
+ "exception": args.exception,
239
+ "critical": args.critical,
240
+ "all": args.all,
241
+ }
242
+
243
+ # Require at least one flag
244
+ if not any(log_flags.values()):
245
+ print("Error: prune logs requires at least one flag")
246
+ print("Available flags:")
247
+ print(" --trace Remove trace level logs")
248
+ print(" --debug Remove debug level logs")
249
+ print(" --info Remove info level logs")
250
+ print(" --warning Remove warning level logs")
251
+ print(" --success Remove success level logs")
252
+ print(" --error Remove error level logs")
253
+ print(" --exception Remove exception level logs")
254
+ print(" --critical Remove critical level logs")
255
+ print(" --all Remove all log levels")
256
+ return 1
257
+
258
+ # Determine what to remove
259
+ remove_prints = args.command == "prints"
260
+ remove_comments = args.command == "comments"
261
+ remove_docstrings = args.command == "docstrings"
262
+ remove_asserts = args.command == "asserts"
263
+ remove_logs = args.command == "logs"
264
+
265
+ # Build comment options
266
+ comment_options = {}
267
+ if remove_comments:
268
+ # If --all is specified, ignore other flags
269
+ if args.all:
270
+ comment_options["all"] = True
271
+ else:
272
+ if args.inline:
273
+ comment_options["inline"] = True
274
+ if args.leading:
275
+ comment_options["leading"] = True
276
+ if args.header:
277
+ comment_options["header"] = True
278
+ if args.default:
279
+ comment_options["default"] = True
280
+
281
+ # Build log levels
282
+ log_levels = None
283
+ if remove_logs:
284
+ log_flags = {
285
+ "trace": args.trace,
286
+ "debug": args.debug,
287
+ "info": args.info,
288
+ "warning": args.warning,
289
+ "success": args.success,
290
+ "error": args.error,
291
+ "exception": args.exception,
292
+ "critical": args.critical,
293
+ "all": args.all,
294
+ }
295
+
296
+ # If --all is specified, ignore other flags and use all log levels
297
+ if args.all:
298
+ log_levels = {
299
+ "trace",
300
+ "debug",
301
+ "info",
302
+ "warning",
303
+ "success",
304
+ "error",
305
+ "exception",
306
+ "critical",
307
+ }
308
+ # If any specific log level flags are specified, only remove those levels
309
+ elif any(log_flags.values()):
310
+ log_levels = {
311
+ level
312
+ for level, enabled in log_flags.items()
313
+ if enabled and level != "all"
314
+ }
315
+ # No need for default case since validation requires at least one flag
316
+
317
+ # Find all Python files
318
+ python_files = find_python_files(".")
319
+
320
+ total_prints_removed = 0
321
+ total_comments_removed = 0
322
+ total_docstrings_removed = 0
323
+ total_asserts_removed = 0
324
+ total_logs_removed = 0
325
+ files_with_prints = 0
326
+ files_with_comments = 0
327
+ files_with_docstrings = 0
328
+ files_with_asserts = 0
329
+ files_with_logs = 0
330
+
331
+ # Verbose is default, quiet mode suppresses per-file output
332
+ verbose_mode = not args.quiet
333
+
334
+ # Process each file
335
+ for file_path in python_files:
336
+ (
337
+ prints_removed,
338
+ comments_removed,
339
+ docstrings_removed,
340
+ asserts_removed,
341
+ logs_removed,
342
+ ) = process_file(
343
+ file_path,
344
+ remove_prints,
345
+ remove_comments,
346
+ remove_docstrings,
347
+ remove_asserts,
348
+ remove_logs,
349
+ comment_options,
350
+ log_levels,
351
+ )
352
+
353
+ if prints_removed > 0:
354
+ files_with_prints += 1
355
+ total_prints_removed += prints_removed
356
+ if verbose_mode:
357
+ relative_path = os.path.relpath(file_path, ".")
358
+ print(
359
+ f"{prints_removed} prints removed\t\t\033[90m{relative_path}\033[0m"
360
+ )
361
+
362
+ if comments_removed > 0:
363
+ files_with_comments += 1
364
+ total_comments_removed += comments_removed
365
+ if verbose_mode:
366
+ relative_path = os.path.relpath(file_path, ".")
367
+ print(
368
+ f"{comments_removed} comments removed\t\t\033[90m{relative_path}\033[0m"
369
+ )
370
+
371
+ if docstrings_removed > 0:
372
+ files_with_docstrings += 1
373
+ total_docstrings_removed += docstrings_removed
374
+ if verbose_mode:
375
+ relative_path = os.path.relpath(file_path, ".")
376
+ print(
377
+ f"{docstrings_removed} docstrings removed\t\t\033[90m{relative_path}\033[0m"
378
+ )
379
+
380
+ if asserts_removed > 0:
381
+ files_with_asserts += 1
382
+ total_asserts_removed += asserts_removed
383
+ if verbose_mode:
384
+ relative_path = os.path.relpath(file_path, ".")
385
+ print(
386
+ f"{asserts_removed} asserts removed\t\t\033[90m{relative_path}\033[0m"
387
+ )
388
+
389
+ if logs_removed > 0:
390
+ files_with_logs += 1
391
+ total_logs_removed += logs_removed
392
+ if verbose_mode:
393
+ relative_path = os.path.relpath(file_path, ".")
394
+ print(f"{logs_removed} logs removed\t\t\033[90m{relative_path}\033[0m")
395
+
396
+ # Print summary
397
+ if remove_prints:
398
+ print(f"{total_prints_removed} prints removed from {files_with_prints} files")
399
+ if remove_comments:
400
+ print(
401
+ f"{total_comments_removed} comments removed from {files_with_comments} files"
402
+ )
403
+ if remove_docstrings:
404
+ print(
405
+ f"{total_docstrings_removed} docstrings removed from {files_with_docstrings} files"
406
+ )
407
+ if remove_asserts:
408
+ print(
409
+ f"{total_asserts_removed} asserts removed from {files_with_asserts} files"
410
+ )
411
+ if remove_logs:
412
+ print(f"{total_logs_removed} logs removed from {files_with_logs} files")
413
+
414
+ return 0
415
+
416
+
417
+ if __name__ == "__main__":
418
+ sys.exit(main())
@@ -0,0 +1,264 @@
1
+ """LibCST transformers for removing print statements and comments."""
2
+
3
+ import libcst as cst
4
+ from libcst import matchers as m
5
+
6
+
7
+ def remove_statement_preserve_comments(
8
+ node: cst.SimpleStatementLine,
9
+ ) -> cst.FlattenSentinel:
10
+ """Remove a statement but move its leading comments forward."""
11
+ new_nodes: list[cst.CSTNode] = []
12
+
13
+ for line in node.leading_lines:
14
+ new_nodes.append(line)
15
+
16
+ return cst.FlattenSentinel(new_nodes)
17
+
18
+
19
+ class PrintRemover(cst.CSTTransformer):
20
+ """Transformer to remove standalone print() statements."""
21
+
22
+ def __init__(self):
23
+ self.removed_count = 0
24
+
25
+ def leave_SimpleStatementLine(
26
+ self,
27
+ original_node: cst.SimpleStatementLine,
28
+ updated_node: cst.SimpleStatementLine,
29
+ ):
30
+ if (
31
+ len(updated_node.body) == 1
32
+ and isinstance(updated_node.body[0], cst.Expr)
33
+ and m.matches(
34
+ updated_node.body[0].value,
35
+ m.Call(func=m.Name("print")),
36
+ )
37
+ ):
38
+ self.removed_count += 1
39
+ return remove_statement_preserve_comments(original_node)
40
+
41
+ return updated_node
42
+
43
+
44
+ class InlineCommentRemover(cst.CSTTransformer):
45
+ """Transformer to remove inline (trailing) comments only."""
46
+
47
+ def __init__(self, remove_all: bool = False):
48
+ self.removed_count = 0
49
+ self.remove_all = remove_all
50
+
51
+ def leave_TrailingWhitespace(
52
+ self,
53
+ original_node: cst.TrailingWhitespace,
54
+ updated_node: cst.TrailingWhitespace,
55
+ ) -> cst.TrailingWhitespace:
56
+ if updated_node.comment:
57
+ comment_text = updated_node.comment.value
58
+
59
+ if not self.remove_all and any(
60
+ keyword in comment_text for keyword in ["noqa", "type:", "pragma"]
61
+ ):
62
+ return updated_node
63
+
64
+ self.removed_count += 1
65
+ return updated_node.with_changes(comment=None)
66
+
67
+ return updated_node
68
+
69
+ class LeadingCommentRemover(cst.CSTTransformer):
70
+ """Transformer to remove standalone leading comments everywhere."""
71
+
72
+ def __init__(self):
73
+ self.removed_count = 0
74
+
75
+ def _filter_leading_lines(
76
+ self,
77
+ lines: list[cst.EmptyLine],
78
+ ) -> list[cst.EmptyLine]:
79
+ new_lines: list[cst.EmptyLine] = []
80
+
81
+ for line in lines:
82
+ if line.comment:
83
+ self.removed_count += 1
84
+ continue
85
+ new_lines.append(line)
86
+
87
+ return new_lines
88
+
89
+
90
+ def leave_SimpleStatementLine(
91
+ self,
92
+ original_node: cst.SimpleStatementLine,
93
+ updated_node: cst.SimpleStatementLine,
94
+ ):
95
+ if not updated_node.leading_lines:
96
+ return updated_node
97
+
98
+ new_lines = self._filter_leading_lines(updated_node.leading_lines)
99
+ return updated_node.with_changes(leading_lines=new_lines)
100
+
101
+ def leave_FunctionDef(
102
+ self,
103
+ original_node: cst.FunctionDef,
104
+ updated_node: cst.FunctionDef,
105
+ ):
106
+ if not updated_node.leading_lines:
107
+ return updated_node
108
+
109
+ new_lines = self._filter_leading_lines(updated_node.leading_lines)
110
+ return updated_node.with_changes(leading_lines=new_lines)
111
+
112
+ def leave_ClassDef(
113
+ self,
114
+ original_node: cst.ClassDef,
115
+ updated_node: cst.ClassDef,
116
+ ):
117
+ if not updated_node.leading_lines:
118
+ return updated_node
119
+
120
+ new_lines = self._filter_leading_lines(updated_node.leading_lines)
121
+ return updated_node.with_changes(leading_lines=new_lines)
122
+
123
+ class HeaderCommentRemover(cst.CSTTransformer):
124
+ """Transformer to remove shebang and coding comments."""
125
+
126
+ def __init__(self):
127
+ self.removed_count = 0
128
+
129
+ def leave_Module(
130
+ self,
131
+ original_node: cst.Module,
132
+ updated_node: cst.Module,
133
+ ) -> cst.Module:
134
+ if not updated_node.header:
135
+ return updated_node
136
+
137
+ new_header: list[cst.EmptyLine] = []
138
+
139
+ for line in updated_node.header:
140
+ if line.comment:
141
+ comment = line.comment.value.lower()
142
+
143
+ if (
144
+ comment.startswith("#")
145
+ or "coding" in comment
146
+ or comment.startswith("# vim:")
147
+ ):
148
+ self.removed_count += 1
149
+ continue
150
+
151
+ new_header.append(line)
152
+
153
+ return updated_node.with_changes(header=new_header)
154
+
155
+
156
+ class DocstringRemover(cst.CSTTransformer):
157
+ """Transformer to remove docstrings."""
158
+
159
+ def __init__(self):
160
+ self.removed_count = 0
161
+
162
+ def _strip_docstring(
163
+ self,
164
+ body: list[cst.BaseStatement],
165
+ ) -> list[cst.BaseStatement]:
166
+ if (
167
+ body
168
+ and isinstance(body[0], cst.SimpleStatementLine)
169
+ and len(body[0].body) == 1
170
+ and isinstance(body[0].body[0], cst.Expr)
171
+ and isinstance(body[0].body[0].value, cst.SimpleString)
172
+ ):
173
+ self.removed_count += 1
174
+ return body[1:]
175
+
176
+ return body
177
+
178
+ def leave_Module(
179
+ self,
180
+ original_node: cst.Module,
181
+ updated_node: cst.Module,
182
+ ) -> cst.Module:
183
+ return updated_node.with_changes(
184
+ body=self._strip_docstring(list(updated_node.body)),
185
+ )
186
+
187
+ def leave_ClassDef(
188
+ self,
189
+ original_node: cst.ClassDef,
190
+ updated_node: cst.ClassDef,
191
+ ) -> cst.ClassDef:
192
+ new_body = updated_node.body.with_changes(
193
+ body=self._strip_docstring(list(updated_node.body.body)),
194
+ )
195
+ return updated_node.with_changes(body=new_body)
196
+
197
+ def leave_FunctionDef(
198
+ self,
199
+ original_node: cst.FunctionDef,
200
+ updated_node: cst.FunctionDef,
201
+ ) -> cst.FunctionDef:
202
+ new_body = updated_node.body.with_changes(
203
+ body=self._strip_docstring(list(updated_node.body.body)),
204
+ )
205
+ return updated_node.with_changes(body=new_body)
206
+
207
+
208
+ class AssertRemover(cst.CSTTransformer):
209
+ """Transformer to remove assert statements."""
210
+
211
+ def __init__(self):
212
+ self.removed_count = 0
213
+
214
+ def leave_SimpleStatementLine(
215
+ self,
216
+ original_node: cst.SimpleStatementLine,
217
+ updated_node: cst.SimpleStatementLine,
218
+ ):
219
+ if len(updated_node.body) == 1 and isinstance(updated_node.body[0], cst.Assert):
220
+ self.removed_count += 1
221
+ return remove_statement_preserve_comments(original_node)
222
+
223
+ return updated_node
224
+
225
+
226
+ class LogRemover(cst.CSTTransformer):
227
+ """Transformer to remove logging statements."""
228
+
229
+ def __init__(self, log_levels: set[str] | None = None):
230
+ self.removed_count = 0
231
+ self.log_levels = log_levels or {
232
+ "trace",
233
+ "debug",
234
+ "info",
235
+ "warning",
236
+ "success",
237
+ "error",
238
+ "exception",
239
+ "critical",
240
+ }
241
+
242
+ def leave_SimpleStatementLine(
243
+ self,
244
+ original_node: cst.SimpleStatementLine,
245
+ updated_node: cst.SimpleStatementLine,
246
+ ):
247
+ if (
248
+ len(updated_node.body) == 1
249
+ and isinstance(updated_node.body[0], cst.Expr)
250
+ and isinstance(updated_node.body[0].value, cst.Call)
251
+ and isinstance(updated_node.body[0].value.func, cst.Attribute)
252
+ ):
253
+ attr = updated_node.body[0].value.func
254
+
255
+ if (
256
+ isinstance(attr.attr, cst.Name)
257
+ and attr.attr.value in self.log_levels
258
+ and isinstance(attr.value, cst.Name)
259
+ and attr.value.value in {"log", "logger", "logging"}
260
+ ):
261
+ self.removed_count += 1
262
+ return remove_statement_preserve_comments(original_node)
263
+
264
+ return updated_node
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: prune-cli
3
+ Version: 0.1.1
4
+ Summary: A CLI tool named prune for cleaning Python code
5
+ Author-email: vincentdeneuf <0189vn@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vincentdeneuf/prune-cli
8
+ Project-URL: Repository, https://github.com/vincentdeneuf/prune-cli
9
+ Project-URL: Issues, https://github.com/vincentdeneuf/prune-cli/issues
10
+ Keywords: cli,code,formatting,refactor,ast,prune
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: libcst
24
+ Dynamic: license-file
25
+
26
+ # prune
27
+
28
+ A CLI tool named prune for cleaning Python code.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install prune-cli
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ prune <command> [options]
40
+ ```
41
+
42
+ ### Commands
43
+
44
+ - `prune comments` - Remove comments from Python files
45
+ - `prune prints` - Remove print statements from Python files
46
+ - `prune docstrings` - Remove docstrings from Python files
47
+ - `prune asserts` - Remove assert statements from Python files
48
+ - `prune logs` - Remove logging statements from Python files
49
+
50
+ ### Examples
51
+
52
+ ```bash
53
+ # Remove all print statements
54
+ prune prints
55
+
56
+ # Remove inline comments only (preserve noqa, type:, pragma)
57
+ prune comments --default
58
+
59
+ # Remove all types of comments
60
+ prune comments --all
61
+
62
+ # Remove specific log levels
63
+ prune logs --debug --info --error
64
+
65
+ # Remove all log levels
66
+ prune logs --all
67
+
68
+ # Show per-file details (verbose is default)
69
+ prune prints
70
+
71
+ # Suppress per-file output
72
+ prune prints --quiet
73
+ ```
74
+
75
+ ## Development
76
+
77
+ This project uses a src-layout packaging structure.
78
+
79
+ ### Requirements
80
+
81
+ - Python >= 3.11
82
+ - LibCST
83
+
84
+ ## License
85
+
86
+ MIT License
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/prune/__init__.py
6
+ src/prune/main.py
7
+ src/prune/transformers.py
8
+ src/prune_cli.egg-info/PKG-INFO
9
+ src/prune_cli.egg-info/SOURCES.txt
10
+ src/prune_cli.egg-info/dependency_links.txt
11
+ src/prune_cli.egg-info/entry_points.txt
12
+ src/prune_cli.egg-info/requires.txt
13
+ src/prune_cli.egg-info/top_level.txt
14
+ tests/__init__.py
15
+ tests/test.py
16
+ tests/test_copy.py
17
+ tests/.venv/Lib/site-packages/_virtualenv.py
18
+ tests/.venv/Scripts/activate_this.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prune = prune.main:main
@@ -0,0 +1 @@
1
+ libcst
@@ -0,0 +1,101 @@
1
+ """Patches that are applied at runtime to the virtual environment."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ VIRTUALENV_PATCH_FILE = os.path.join(__file__)
7
+
8
+
9
+ def patch_dist(dist):
10
+ """
11
+ Distutils allows user to configure some arguments via a configuration file:
12
+ https://docs.python.org/3.11/install/index.html#distutils-configuration-files.
13
+
14
+ Some of this arguments though don't make sense in context of the virtual environment files, let's fix them up.
15
+ """ # noqa: D205
16
+ # we cannot allow some install config as that would get packages installed outside of the virtual environment
17
+ old_parse_config_files = dist.Distribution.parse_config_files
18
+
19
+ def parse_config_files(self, *args, **kwargs):
20
+ result = old_parse_config_files(self, *args, **kwargs)
21
+ install = self.get_option_dict("install")
22
+
23
+ if "prefix" in install: # the prefix governs where to install the libraries
24
+ install["prefix"] = VIRTUALENV_PATCH_FILE, os.path.abspath(sys.prefix)
25
+ for base in ("purelib", "platlib", "headers", "scripts", "data"):
26
+ key = f"install_{base}"
27
+ if key in install: # do not allow global configs to hijack venv paths
28
+ install.pop(key, None)
29
+ return result
30
+
31
+ dist.Distribution.parse_config_files = parse_config_files
32
+
33
+
34
+ # Import hook that patches some modules to ignore configuration values that break package installation in case
35
+ # of virtual environments.
36
+ _DISTUTILS_PATCH = "distutils.dist", "setuptools.dist"
37
+ # https://docs.python.org/3/library/importlib.html#setting-up-an-importer
38
+
39
+
40
+ class _Finder:
41
+ """A meta path finder that allows patching the imported distutils modules."""
42
+
43
+ fullname = None
44
+
45
+ # lock[0] is threading.Lock(), but initialized lazily to avoid importing threading very early at startup,
46
+ # because there are gevent-based applications that need to be first to import threading by themselves.
47
+ # See https://github.com/pypa/virtualenv/issues/1895 for details.
48
+ lock = [] # noqa: RUF012
49
+
50
+ def find_spec(self, fullname, path, target=None): # noqa: ARG002
51
+ if fullname in _DISTUTILS_PATCH and self.fullname is None:
52
+ # initialize lock[0] lazily
53
+ if len(self.lock) == 0:
54
+ import threading
55
+
56
+ lock = threading.Lock()
57
+ # there is possibility that two threads T1 and T2 are simultaneously running into find_spec,
58
+ # observing .lock as empty, and further going into hereby initialization. However due to the GIL,
59
+ # list.append() operation is atomic and this way only one of the threads will "win" to put the lock
60
+ # - that every thread will use - into .lock[0].
61
+ # https://docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe
62
+ self.lock.append(lock)
63
+
64
+ from functools import partial
65
+ from importlib.util import find_spec
66
+
67
+ with self.lock[0]:
68
+ self.fullname = fullname
69
+ try:
70
+ spec = find_spec(fullname, path)
71
+ if spec is not None:
72
+ # https://www.python.org/dev/peps/pep-0451/#how-loading-will-work
73
+ is_new_api = hasattr(spec.loader, "exec_module")
74
+ func_name = "exec_module" if is_new_api else "load_module"
75
+ old = getattr(spec.loader, func_name)
76
+ func = self.exec_module if is_new_api else self.load_module
77
+ if old is not func:
78
+ try: # noqa: SIM105
79
+ setattr(spec.loader, func_name, partial(func, old))
80
+ except AttributeError:
81
+ pass # C-Extension loaders are r/o such as zipimporter with <3.7
82
+ return spec
83
+ finally:
84
+ self.fullname = None
85
+ return None
86
+
87
+ @staticmethod
88
+ def exec_module(old, module):
89
+ old(module)
90
+ if module.__name__ in _DISTUTILS_PATCH:
91
+ patch_dist(module)
92
+
93
+ @staticmethod
94
+ def load_module(old, name):
95
+ module = old(name)
96
+ if module.__name__ in _DISTUTILS_PATCH:
97
+ patch_dist(module)
98
+ return module
99
+
100
+
101
+ sys.meta_path.insert(0, _Finder())
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2020-202x The virtualenv developers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ """
23
+ Activate virtualenv for current interpreter:
24
+
25
+ import runpy
26
+ runpy.run_path(this_file)
27
+
28
+ This can be used when you must use an existing Python interpreter, not the virtualenv bin/python.
29
+ """ # noqa: D415
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import site
35
+ import sys
36
+
37
+ try:
38
+ abs_file = os.path.abspath(__file__)
39
+ except NameError as exc:
40
+ msg = "You must use import runpy; runpy.run_path(this_file)"
41
+ raise AssertionError(msg) from exc
42
+
43
+ bin_dir = os.path.dirname(abs_file)
44
+ base = bin_dir[: -len("Scripts") - 1] # strip away the bin part from the __file__, plus the path separator
45
+
46
+ # prepend bin to PATH (this file is inside the bin directory)
47
+ os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
48
+ os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
49
+ os.environ["VIRTUAL_ENV_PROMPT"] = "tests" or os.path.basename(base) # noqa: SIM222
50
+
51
+ # add the virtual environments libraries to the host python import mechanism
52
+ prev_length = len(sys.path)
53
+ for lib in "..\\Lib\\site-packages".split(os.pathsep):
54
+ path = os.path.realpath(os.path.join(bin_dir, lib))
55
+ site.addsitedir(path)
56
+ sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
57
+
58
+ sys.real_prefix = sys.prefix
59
+ sys.prefix = base
@@ -0,0 +1 @@
1
+ """Tests for prune."""
@@ -0,0 +1,41 @@
1
+
2
+ """This is a module docstring that should be removed with tidy docstrings."""
3
+ import logging
4
+ logging.basicConfig(level=logging.DEBUG)
5
+ class LoggerWrapper:
6
+ def __init__(self, logger: logging.Logger) -> None:
7
+ self.logger = logger
8
+ def trace(self, message: str) -> None:
9
+ self.logger.debug(message)
10
+ def success(self, message: str) -> None:
11
+ self.logger.info(message)
12
+ def info(self, message: str) -> None:
13
+ self.logger.info(message)
14
+ def warning(self, message: str) -> None:
15
+ self.logger.warning(message)
16
+ def exception(self, message: str) -> None:
17
+ self.logger.exception(message)
18
+ def critical(self, message: str) -> None:
19
+ self.logger.critical(message)
20
+ log = LoggerWrapper(logging.getLogger("log"))
21
+ logger = logging.getLogger(__name__)
22
+ custom_logger = logging.getLogger("custom")
23
+ class DummyObject:
24
+ def print(self, message: str) -> None:
25
+ pass
26
+ obj = DummyObject()
27
+ def example_function():
28
+ """This is a function docstring that should be removed with tidy docstrings."""
29
+ x = 5
30
+ y = 10
31
+ z = 15
32
+ x = logger.info("This should not be removed")
33
+ logger.debug("This should not be removed").strip()
34
+ custom_logger.info("This should not be removed")
35
+ obj.print("This should not be removed")
36
+ return x + y + z
37
+ class ExampleClass:
38
+ """This is a class docstring that should be removed with tidy docstrings."""
39
+ def method(self):
40
+ """This is a method docstring that should be removed with tidy docstrings."""
41
+ return 42
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3 -> should be removed with --header
2
+ # This is a leading comment that should be removed with --leading
3
+ # This is a leading comment that should be removed with --leading
4
+
5
+ # This is another leading comment that should be removed with --leading
6
+
7
+ """This is a module docstring that should be removed with tidy docstrings."""
8
+
9
+ import logging
10
+
11
+ # Configure basic logging
12
+ logging.basicConfig(level=logging.DEBUG)
13
+
14
+
15
+ # Custom logger wrapper to support trace and success
16
+ class LoggerWrapper:
17
+ def __init__(self, logger: logging.Logger) -> None:
18
+ self.logger = logger
19
+
20
+ def trace(self, message: str) -> None:
21
+ self.logger.debug(message)
22
+
23
+ def success(self, message: str) -> None:
24
+ self.logger.info(message)
25
+
26
+ def info(self, message: str) -> None:
27
+ self.logger.info(message)
28
+
29
+ def warning(self, message: str) -> None:
30
+ self.logger.warning(message)
31
+
32
+ def exception(self, message: str) -> None:
33
+ self.logger.exception(message)
34
+
35
+ def critical(self, message: str) -> None:
36
+ self.logger.critical(message)
37
+
38
+
39
+ log = LoggerWrapper(logging.getLogger("log"))
40
+ logger = logging.getLogger(__name__)
41
+ custom_logger = logging.getLogger("custom")
42
+
43
+
44
+ # Define an object with a print method, as used later
45
+ class DummyObject:
46
+ def print(self, message: str) -> None:
47
+ pass
48
+
49
+
50
+ obj = DummyObject()
51
+
52
+
53
+ # This is a leading comment that should be removed with --leading
54
+ def example_function():
55
+ """This is a function docstring that should be removed with tidy docstrings."""
56
+ print(
57
+ "This should be removed with tidy prints"
58
+ ) # This inline comment should be removed with tidy comments
59
+ x = 5 # type: int # This comment should be preserved by default, removed with --inline
60
+ y = 10 # noqa: E501 # This comment should be preserved by default, removed with --inline
61
+ z = 15 # pragma: no cover # This comment should be preserved by default, removed with --inline
62
+
63
+ # This is another type of leading comment that should be removed with --leading
64
+ assert x > 0, (
65
+ "x must be positive"
66
+ ) # This assert should be removed with tidy asserts
67
+ assert y is not None # This assert should be removed with tidy asserts
68
+
69
+ # This is another type of leading comment that should be removed with --leading
70
+
71
+ log.trace("This is a trace log")
72
+ log.info("This is an info log")
73
+ log.warning("This is a warning log")
74
+ log.success("This is a success log")
75
+ log.exception("This is an exception log")
76
+ log.critical("This is a critical log")
77
+ # This is yet another type of leading comment that should be removed with --leading
78
+
79
+ # More logging with different base names
80
+ logger.info("Logger info message")
81
+ logging.warning("Logging warning message")
82
+
83
+ # These should NOT be removed (not standalone expressions)
84
+ x = logger.info("This should not be removed")
85
+ logger.debug("This should not be removed").strip()
86
+ custom_logger.info("This should not be removed")
87
+
88
+ print("Another print to remove")
89
+ obj.print("This should not be removed") # This comment should be removed
90
+
91
+ return x + y + z
92
+
93
+
94
+ class ExampleClass:
95
+ """This is a class docstring that should be removed with tidy docstrings."""
96
+
97
+ def method(self):
98
+ """This is a method docstring that should be removed with tidy docstrings."""
99
+ assert True # This assert should be removed with tidy asserts
100
+
101
+ # Logging in method
102
+ log.info("Method log message")
103
+
104
+ return 42
105
+
106
+
107
+ # Another leading comment
108
+ print("Third print to remove") # Final inline comment to remove
109
+
110
+ # Another leading comment