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.
- prune_cli-0.1.1/LICENSE +21 -0
- prune_cli-0.1.1/MANIFEST.in +4 -0
- prune_cli-0.1.1/PKG-INFO +86 -0
- prune_cli-0.1.1/README.md +61 -0
- prune_cli-0.1.1/pyproject.toml +58 -0
- prune_cli-0.1.1/setup.cfg +4 -0
- prune_cli-0.1.1/src/prune/__init__.py +1 -0
- prune_cli-0.1.1/src/prune/main.py +418 -0
- prune_cli-0.1.1/src/prune/transformers.py +264 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/PKG-INFO +86 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/SOURCES.txt +18 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/dependency_links.txt +1 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/entry_points.txt +2 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/requires.txt +1 -0
- prune_cli-0.1.1/src/prune_cli.egg-info/top_level.txt +1 -0
- prune_cli-0.1.1/tests/.venv/Lib/site-packages/_virtualenv.py +101 -0
- prune_cli-0.1.1/tests/.venv/Scripts/activate_this.py +59 -0
- prune_cli-0.1.1/tests/__init__.py +1 -0
- prune_cli-0.1.1/tests/test.py +41 -0
- prune_cli-0.1.1/tests/test_copy.py +110 -0
prune_cli-0.1.1/LICENSE
ADDED
|
@@ -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.
|
prune_cli-0.1.1/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
libcst
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prune
|
|
@@ -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
|