code-prune 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ # Gitignore-style patterns for files/directories to exclude from scanning.
2
+ # One pattern per line. Lines starting with # are comments. Empty lines are ignored.
3
+ .git/
4
+ node_modules/
5
+ dist/
6
+ build/
7
+ __pycache__/
8
+ .venv/
9
+ bin/
code_prune/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Package initialization for code_prune.
3
+
4
+ Bootstraps the configuration directory and logging setup.
5
+
6
+ Author: Ron Webb
7
+ Since: 1.0.0
8
+ """
9
+
10
+ from env_dir_bootstrap import EnvDirBootstrap
11
+
12
+ __app_name__ = "code-prune"
13
+ __version__ = "1.0.0"
14
+
15
+ _bootstrapper = EnvDirBootstrap(
16
+ env_var="CODE_PRUNE_CONFIG_DIR",
17
+ resources=["logging.ini", "config.ini", ".codepruneignore", "symbol_ignore"],
18
+ package="code_prune",
19
+ )
20
+
21
+ _bootstrapper.setup()
22
+
23
+ CONF_DIR = str(_bootstrapper.get_dir())
24
+ PATH_IGNORE_PATH = _bootstrapper.resolve(".codepruneignore")
25
+ SYMBOL_IGNORE_PATH = _bootstrapper.resolve("symbol_ignore")
code_prune/cli.py ADDED
@@ -0,0 +1,475 @@
1
+ """Command-line interface for code_prune.
2
+
3
+ Author: Ron Webb
4
+ Since: 1.0.0
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import concurrent.futures
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ import uuid
14
+ from collections.abc import Generator
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from braincraft.ignorefile import IgnoreFile
19
+ from rich.console import Console
20
+ from rich.progress import (
21
+ BarColumn,
22
+ MofNCompleteColumn,
23
+ Progress,
24
+ SpinnerColumn,
25
+ TextColumn,
26
+ TimeElapsedColumn,
27
+ )
28
+
29
+ from logenrich import setup_logger
30
+
31
+ from . import __version__
32
+ from . import __app_name__ as _APP_NAME
33
+ from . import CONF_DIR
34
+ from . import PATH_IGNORE_PATH
35
+ from . import SYMBOL_IGNORE_PATH
36
+ from .config import Config
37
+ from .reporter import write_outputs
38
+ from .scanner import CLI_CATEGORIES, load_name_ignore_patterns, scan_file
39
+ from .store import SessionStore
40
+
41
+ _VALID_FORMATS = ("json", "html", "all")
42
+
43
+
44
+ def _configure_stream_encoding(stream: object) -> None:
45
+ """Force *stream* to UTF-8 with a replacing error handler when possible.
46
+
47
+ Prevents UnicodeEncodeError on legacy Windows code pages or restrictive
48
+ locales. Streams without ``reconfigure`` (e.g. click's test runner) are
49
+ left untouched instead of raising.
50
+
51
+ Author: Ron Webb
52
+ Since: 1.0.0
53
+ """
54
+ reconfigure = getattr(stream, "reconfigure", None)
55
+ if callable(reconfigure):
56
+ reconfigure(encoding="utf-8", errors="replace")
57
+
58
+
59
+ _configure_stream_encoding(sys.stdout)
60
+ _configure_stream_encoding(sys.stderr)
61
+ _console = Console(stderr=True)
62
+ _logger = setup_logger(__name__, conf_dir=CONF_DIR)
63
+ _config = Config()
64
+
65
+
66
+ def _parse_extensions(raw: str) -> list[str]:
67
+ """Parse a comma-separated extension string into a normalised list.
68
+
69
+ Each entry is lowercased and prefixed with a dot when absent.
70
+ Example: ``"py,js, TS"`` -> ``['.py', '.js', '.ts']``
71
+
72
+ Author: Ron Webb
73
+ Since: 1.0.0
74
+ """
75
+ result: list[str] = []
76
+ for part in raw.split(","):
77
+ ext = part.strip().lower()
78
+ if ext and not ext.startswith("."):
79
+ ext = "." + ext
80
+ if ext:
81
+ result.append(ext)
82
+ return result
83
+
84
+
85
+ def _parse_paths(raw: str) -> list[Path]:
86
+ """Parse a semicolon-separated path string into a list of Path objects.
87
+
88
+ Example: ``"/src/a; /src/b"`` -> ``[Path('/src/a'), Path('/src/b')]``
89
+
90
+ Author: Ron Webb
91
+ Since: 1.0.0
92
+ """
93
+ result: list[Path] = []
94
+ for part in raw.split(";"):
95
+ stripped = part.strip()
96
+ if stripped:
97
+ result.append(Path(stripped))
98
+ return result
99
+
100
+
101
+ def _read_target_list(list_file: Path) -> list[Path]:
102
+ """Read one target path per line from *list_file*.
103
+
104
+ Blank lines and lines starting with ``#`` are skipped.
105
+
106
+ Author: Ron Webb
107
+ Since: 1.0.0
108
+ """
109
+ result: list[Path] = []
110
+ for line in list_file.read_text(encoding="utf-8").splitlines():
111
+ stripped = line.strip()
112
+ if stripped and not stripped.startswith("#"):
113
+ result.append(Path(stripped))
114
+ return result
115
+
116
+
117
+ def _parse_categories(raw: str) -> set[str]:
118
+ """Parse a comma-separated category string into a validated set.
119
+
120
+ Raises :class:`click.UsageError` when an unknown category is given.
121
+
122
+ Author: Ron Webb
123
+ Since: 1.0.0
124
+ """
125
+ requested = {part.strip().lower() for part in raw.split(",") if part.strip()}
126
+ unknown = requested - set(CLI_CATEGORIES)
127
+ if unknown:
128
+ raise click.UsageError(
129
+ f"Unknown categories: {', '.join(sorted(unknown))}. "
130
+ f"Valid categories: {', '.join(CLI_CATEGORIES)}."
131
+ )
132
+ return requested
133
+
134
+
135
+ def _scan_and_store(task: tuple[Path, SessionStore, str, set[str]]) -> None:
136
+ """Scan one file and write its results to the session store.
137
+
138
+ Accepts a 4-tuple so the function can be passed directly to
139
+ :meth:`concurrent.futures.Executor.map` without a closure.
140
+
141
+ Author: Ron Webb
142
+ Since: 1.0.0
143
+ """
144
+ file_path, store, session_id, categories = task
145
+ result = scan_file(file_path)
146
+ findings = [f for f in result.findings if _category_enabled(f.category, categories)]
147
+ store.insert_findings(session_id, findings)
148
+ if "unused" in categories:
149
+ store.insert_definitions(session_id, result.definitions)
150
+ store.insert_identifiers(session_id, result.usages)
151
+
152
+
153
+ def _category_enabled(category: str, categories: set[str]) -> bool:
154
+ """Return whether *category* should be reported given selected *categories*.
155
+
156
+ Author: Ron Webb
157
+ Since: 1.0.0
158
+ """
159
+ if category == "unreachable":
160
+ return "unreachable" in categories
161
+ if category in ("unused-import", "unused-local", "unused-symbol"):
162
+ return "unused" in categories
163
+ if category == "obsolete":
164
+ return "obsolete" in categories
165
+ if category == "commented-out":
166
+ return "commented-out" in categories
167
+ return False
168
+
169
+
170
+ def _load_ignore_file(ignore_path: Path, base_dir: Path) -> IgnoreFile | None:
171
+ """Load an :class:`IgnoreFile` from *ignore_path*, logging failures.
172
+
173
+ Returns ``None`` on a missing or non-UTF-8 file instead of raising.
174
+
175
+ Author: Ron Webb
176
+ Since: 1.0.0
177
+ """
178
+ try:
179
+ return IgnoreFile(ignore_path, base_dir=base_dir)
180
+ except FileNotFoundError:
181
+ _logger.warning("Ignore file not found at %s", ignore_path)
182
+ return None
183
+ except UnicodeDecodeError as exc:
184
+ _logger.warning("Ignore file at %s is not valid UTF-8: %s", ignore_path, exc)
185
+ return None
186
+
187
+
188
+ def _build_ignore(base_dir: Path) -> IgnoreFile | None:
189
+ """Build a path-ignore matcher anchored at *base_dir*.
190
+
191
+ The ignore filename is resolved from ``config.ini``'s
192
+ ``[override] ignore-file`` setting under :data:`CONF_DIR`; when that file
193
+ is missing, the bundled default ``.codepruneignore`` is tried instead.
194
+ Returns ``None`` when neither ignore file is usable, so callers can
195
+ proceed without path filtering instead of failing the whole scan.
196
+
197
+ Author: Ron Webb
198
+ Since: 1.0.0
199
+ """
200
+ custom_path = Path(CONF_DIR) / _config.get_ignore_file()
201
+ ignore = _load_ignore_file(custom_path, base_dir)
202
+ _logger.debug("Loaded ignore file from %s: %s", custom_path, ignore)
203
+ if ignore is not None:
204
+ return ignore
205
+ if custom_path == Path(PATH_IGNORE_PATH):
206
+ return None
207
+ return _load_ignore_file(Path(PATH_IGNORE_PATH), base_dir)
208
+
209
+
210
+ def _walk_unignored(
211
+ root: Path, ignore: IgnoreFile | None
212
+ ) -> Generator[Path, None, None]:
213
+ """Recursively yield files under *root*, pruning directories matched by *ignore*.
214
+
215
+ Author: Ron Webb
216
+ Since: 1.0.0
217
+ """
218
+ for entry in root.iterdir():
219
+ if ignore is not None and ignore.is_ignored(entry):
220
+ continue
221
+ if entry.is_dir():
222
+ yield from _walk_unignored(entry, ignore)
223
+ elif entry.is_file():
224
+ yield entry
225
+
226
+
227
+ def discover_files(
228
+ path: Path, extensions: list[str], ignore: IgnoreFile | None = None
229
+ ) -> list[Path]:
230
+ """Discover files under *path* that match the given extensions.
231
+
232
+ When *extensions* is empty every file is included.
233
+ Both files and directories are accepted; for a plain file the extension
234
+ filter still applies. When *ignore* is given, matching files are skipped
235
+ and matching directories are pruned from the walk entirely.
236
+
237
+ Author: Ron Webb
238
+ Since: 1.0.0
239
+ """
240
+ candidates: list[Path]
241
+ if path.is_file():
242
+ candidates = [] if ignore is not None and ignore.is_ignored(path) else [path]
243
+ elif path.is_dir():
244
+ candidates = sorted(_walk_unignored(path, ignore))
245
+ else:
246
+ return []
247
+
248
+ if not extensions:
249
+ return candidates
250
+ return [f for f in candidates if f.suffix.lower() in extensions]
251
+
252
+
253
+ def _run_concurrent_scan(
254
+ files: list[Path],
255
+ store: SessionStore,
256
+ session_id: str,
257
+ workers: int,
258
+ categories: set[str],
259
+ ) -> None:
260
+ """Scan *files* concurrently and store results under *session_id*.
261
+
262
+ Displays a live progress bar via the module-level rich console.
263
+
264
+ Author: Ron Webb
265
+ Since: 1.0.0
266
+ """
267
+ with Progress(
268
+ SpinnerColumn(),
269
+ TextColumn("[progress.description]{task.description}"),
270
+ BarColumn(),
271
+ MofNCompleteColumn(),
272
+ TimeElapsedColumn(),
273
+ console=_console,
274
+ transient=True,
275
+ ) as progress:
276
+ task = progress.add_task("[cyan]Scanning\u2026", total=len(files))
277
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
278
+ futures = {
279
+ executor.submit(_scan_and_store, (f, store, session_id, categories)): f
280
+ for f in files
281
+ }
282
+ for future in concurrent.futures.as_completed(futures):
283
+ try:
284
+ future.result()
285
+ except Exception as exc: # pylint: disable=broad-exception-caught
286
+ _logger.warning("Failed to scan %s: %s", futures[future], exc)
287
+ progress.advance(task)
288
+
289
+
290
+ @click.command()
291
+ @click.version_option(
292
+ version=__version__, prog_name=_APP_NAME, message="%(prog)s v%(version)s"
293
+ )
294
+ @click.argument("path")
295
+ @click.option(
296
+ "--ext",
297
+ default="",
298
+ help=(
299
+ "Comma-separated file extensions to include "
300
+ "(e.g. py,java,js,ts,gs,gsx). Omit to scan all files."
301
+ ),
302
+ )
303
+ @click.option(
304
+ "--output",
305
+ default="code-prune-output",
306
+ help=(
307
+ "Base name (without extension) for the output file(s) "
308
+ "(default: code-prune-output)."
309
+ ),
310
+ )
311
+ @click.option(
312
+ "--output-dir",
313
+ "output_dir",
314
+ default="reports",
315
+ type=click.Path(path_type=Path),
316
+ help=(
317
+ "Directory where the output file will be written "
318
+ "(default: reports). "
319
+ "The filename from --output is placed inside this directory."
320
+ ),
321
+ )
322
+ @click.option(
323
+ "--format",
324
+ "fmt",
325
+ default="json",
326
+ type=click.Choice(_VALID_FORMATS),
327
+ help="Output format: json, html, or all (default: json).",
328
+ )
329
+ @click.option(
330
+ "--workers",
331
+ type=int,
332
+ default=min(32, (os.cpu_count() or 1) + 4),
333
+ help=(
334
+ "Number of parallel worker threads used to scan files "
335
+ "(default: min(32, cpu_count + 4))."
336
+ ),
337
+ )
338
+ @click.option(
339
+ "--db",
340
+ "db_path",
341
+ default=str(Path(tempfile.gettempdir()) / "code_prune.db"),
342
+ type=click.Path(path_type=Path),
343
+ help=(
344
+ "Path to the SQLite scratch database used to store scan results "
345
+ "during a run (default: <system-temp>/code_prune.db). "
346
+ "Session records are removed after the report is written."
347
+ ),
348
+ )
349
+ @click.option(
350
+ "--categories",
351
+ default=",".join(CLI_CATEGORIES),
352
+ help=(
353
+ "Comma-separated categories to report: "
354
+ f"{', '.join(CLI_CATEGORIES)} (default: all). "
355
+ "'unused' covers unused imports, local variables, and "
356
+ "cross-file-unused functions/classes/methods."
357
+ ),
358
+ )
359
+ @click.option(
360
+ "--target-list",
361
+ "target_list",
362
+ is_flag=True,
363
+ default=False,
364
+ help=(
365
+ "Treat PATH as a single existing file listing target paths (files "
366
+ "and/or directories), one per line, instead of a semicolon-separated "
367
+ "path list. Blank lines and lines starting with '#' are skipped."
368
+ ),
369
+ )
370
+ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
371
+ path: str,
372
+ ext: str,
373
+ output: str,
374
+ output_dir: Path,
375
+ fmt: str,
376
+ workers: int,
377
+ db_path: Path,
378
+ categories: str,
379
+ target_list: bool,
380
+ ) -> None:
381
+ """Scan source files for unreachable, unused, and obsolete code.
382
+
383
+ Author: Ron Webb
384
+ Since: 1.0.0
385
+ """
386
+ _header = f"{_APP_NAME} v{__version__}"
387
+ _logger.info(_header)
388
+ _console.print(f"[bold]{_header}[/bold]")
389
+ extensions = _parse_extensions(ext) if ext else []
390
+ selected_categories = _parse_categories(categories)
391
+ paths = _parse_paths(path)
392
+ if target_list:
393
+ if len(paths) != 1 or not paths[0].is_file():
394
+ raise click.UsageError(
395
+ "--target-list requires PATH to be a single existing file."
396
+ )
397
+ paths = _read_target_list(paths[0])
398
+
399
+ files = _discover_all_files(paths, extensions)
400
+ if not files:
401
+ _logger.info("No files found in %s", path)
402
+ _console.print("[yellow]No files found.[/yellow]")
403
+ return
404
+
405
+ _console.print(f"[bold]Scanning[/bold] {len(files)} file(s)\u2026")
406
+
407
+ findings, interrupted = _scan_project(files, db_path, workers, selected_categories)
408
+
409
+ if interrupted:
410
+ raise SystemExit(1)
411
+
412
+ stem = Path(output).stem
413
+ written = write_outputs(
414
+ findings,
415
+ output_dir,
416
+ stem,
417
+ fmt,
418
+ paths_scanned=[str(p) for p in paths],
419
+ categories=sorted(selected_categories),
420
+ )
421
+
422
+ _logger.info(
423
+ "Found %s finding(s) -> %s", len(findings), ", ".join(str(w) for w in written)
424
+ )
425
+ _console.print(
426
+ f"[bold green]Found {len(findings)} finding(s)[/bold green] -> "
427
+ + ", ".join(str(w) for w in written)
428
+ )
429
+
430
+
431
+ def _discover_all_files(paths: list[Path], extensions: list[str]) -> list[Path]:
432
+ """Discover the de-duplicated set of files to scan across *paths*.
433
+
434
+ Author: Ron Webb
435
+ Since: 1.0.0
436
+ """
437
+ seen: set[Path] = set()
438
+ files: list[Path] = []
439
+ with _console.status("[bold cyan]Discovering files\u2026", spinner="dots"):
440
+ for target_path in paths:
441
+ ignore = _build_ignore(target_path)
442
+ for found_file in discover_files(target_path, extensions, ignore):
443
+ if found_file not in seen:
444
+ seen.add(found_file)
445
+ files.append(found_file)
446
+ return files
447
+
448
+
449
+ def _scan_project(
450
+ files: list[Path], db_path: Path, workers: int, categories: set[str]
451
+ ) -> tuple[list[dict[str, object]], bool]:
452
+ """Run the full scan pipeline and return ``(findings, interrupted)``.
453
+
454
+ Author: Ron Webb
455
+ Since: 1.0.0
456
+ """
457
+ session_id = str(uuid.uuid4())
458
+ store = SessionStore(db_path)
459
+ interrupted = False
460
+ findings: list[dict[str, object]] = []
461
+ try:
462
+ _run_concurrent_scan(files, store, session_id, workers, categories)
463
+ findings = store.read_findings(session_id)
464
+ if "unused" in categories:
465
+ name_ignore_patterns = load_name_ignore_patterns(Path(SYMBOL_IGNORE_PATH))
466
+ findings.extend(
467
+ store.compute_unused_symbols(session_id, name_ignore_patterns)
468
+ )
469
+ except KeyboardInterrupt:
470
+ interrupted = True
471
+ _console.print("[yellow]Scan interrupted by user.[/yellow]")
472
+ finally:
473
+ store.delete_session(session_id)
474
+ store.close()
475
+ return findings, interrupted
code_prune/config.ini ADDED
@@ -0,0 +1,2 @@
1
+ [override]
2
+ ignore-file = .codepruneignore
code_prune/config.py ADDED
@@ -0,0 +1,44 @@
1
+ """User-configurable overrides for code_prune, read from ``config.ini``.
2
+
3
+ Author: Ron Webb
4
+ Since: 1.0.0
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import configparser
10
+ from pathlib import Path
11
+
12
+ from . import CONF_DIR
13
+
14
+ _DEFAULT_IGNORE_FILE = ".codepruneignore"
15
+
16
+
17
+ class Config:
18
+ """Reads user-configurable overrides from ``config.ini`` in *conf_dir*.
19
+
20
+ Author: Ron Webb
21
+ Since: 1.0.0
22
+ """
23
+
24
+ def __init__(self, conf_dir: str | None = None) -> None:
25
+ """Load ``config.ini`` from *conf_dir* (defaults to ``CONF_DIR``).
26
+
27
+ Author: Ron Webb
28
+ Since: 1.0.0
29
+ """
30
+ self._config = configparser.ConfigParser()
31
+ config_path = Path(conf_dir or CONF_DIR) / "config.ini"
32
+ self._config.read(config_path)
33
+
34
+ def get_ignore_file(self) -> str:
35
+ """Return the configured ``.codepruneignore`` filename override.
36
+
37
+ Falls back to ``.codepruneignore`` when the section/key is absent.
38
+
39
+ Author: Ron Webb
40
+ Since: 1.0.0
41
+ """
42
+ return self._config.get(
43
+ "override", "ignore-file", fallback=_DEFAULT_IGNORE_FILE
44
+ )
code_prune/logging.ini ADDED
@@ -0,0 +1,28 @@
1
+ [loggers]
2
+ keys=root
3
+
4
+ [handlers]
5
+ keys=consoleHandler,fileHandler
6
+
7
+ [formatters]
8
+ keys=logFormatter,consoleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler,fileHandler
13
+
14
+ [handler_consoleHandler]
15
+ class=StreamHandler
16
+ formatter=consoleFormatter
17
+ args=(sys.stderr,)
18
+
19
+ [handler_fileHandler]
20
+ class=FileHandler
21
+ formatter=logFormatter
22
+ args=('code_prune.log', 'a')
23
+
24
+ [formatter_logFormatter]
25
+ format=%(asctime)s [%(levelname)s] %(name)s - %(message)s
26
+
27
+ [formatter_consoleFormatter]
28
+ format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
code_prune/parser.py ADDED
@@ -0,0 +1,67 @@
1
+ """Tree-sitter language loading and source parsing utilities.
2
+
3
+ Author: Ron Webb
4
+ Since: 1.0.0
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import logging
11
+ from functools import lru_cache
12
+
13
+ from tree_sitter import Language, Parser, Tree
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+ # Maps language name -> (module, callable-name) for dynamic import.
18
+ _LANG_MODULES: dict[str, tuple[str, str]] = {
19
+ "Python": ("tree_sitter_python", "language"),
20
+ "JavaScript": ("tree_sitter_javascript", "language"),
21
+ "TypeScript": ("tree_sitter_typescript", "language_typescript"),
22
+ "Java": ("tree_sitter_java", "language"),
23
+ "Gosu": ("tree_sitter_gosu", "language"),
24
+ }
25
+
26
+
27
+ @lru_cache(maxsize=None)
28
+ def get_language(language_name: str) -> Language | None:
29
+ """Return the tree-sitter :class:`Language` object for *language_name*.
30
+
31
+ Results are cached so the binding is loaded only once per interpreter
32
+ session. Returns ``None`` when the language name is not recognised or
33
+ the corresponding grammar package is unavailable.
34
+
35
+ Author: Ron Webb
36
+ Since: 1.0.0
37
+ """
38
+ spec = _LANG_MODULES.get(language_name)
39
+ if spec is None:
40
+ return None
41
+ module_name, func_name = spec
42
+ try:
43
+ module = importlib.import_module(module_name)
44
+ return Language(getattr(module, func_name)())
45
+ except Exception: # pylint: disable=broad-exception-caught
46
+ _logger.warning("Failed to load tree-sitter language: %s", language_name)
47
+ return None
48
+
49
+
50
+ def parse(source_bytes: bytes, language_name: str) -> Tree | None:
51
+ """Parse *source_bytes* with a fresh :class:`Parser` for *language_name*.
52
+
53
+ A new :class:`Parser` instance is created on every call so that no internal
54
+ C extension state carries over between files (tree-sitter 0.26+ requirement).
55
+ Returns ``None`` when the language cannot be loaded or parsing fails.
56
+
57
+ Author: Ron Webb
58
+ Since: 1.0.0
59
+ """
60
+ language = get_language(language_name)
61
+ if language is None:
62
+ return None
63
+ try:
64
+ return Parser(language).parse(source_bytes)
65
+ except Exception: # pylint: disable=broad-exception-caught
66
+ _logger.warning("Failed to parse source as %s", language_name)
67
+ return None