devcleaner-cli 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 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from dev_cleaner.main import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ # __init__.py
@@ -0,0 +1,8 @@
1
+ from pathlib import Path, PurePosixPath
2
+ from typing import Protocol
3
+
4
+
5
+ class FileSystemCleaner(Protocol):
6
+ def remove(self, path: Path | PurePosixPath) -> None:
7
+ """Remove a directory from the filesystem."""
8
+ ...
@@ -0,0 +1,18 @@
1
+ import os
2
+ import shutil
3
+ import stat
4
+ from pathlib import Path
5
+
6
+
7
+ def _handle_remove_error(function, path, exc_info) -> None:
8
+ # Read-only files (common inside node_modules on Windows) block rmtree.
9
+ os.chmod(path, stat.S_IWRITE)
10
+ function(path)
11
+
12
+
13
+ class NativeCleaner:
14
+ def remove(self, path: Path) -> None:
15
+ if not path.is_dir():
16
+ return
17
+
18
+ shutil.rmtree(path, onexc=_handle_remove_error)
@@ -0,0 +1,37 @@
1
+ import shutil
2
+ import subprocess
3
+ from pathlib import PurePosixPath
4
+
5
+
6
+ class WslCleaner:
7
+ def __init__(self, distribution: str = "Ubuntu"):
8
+ if shutil.which("wsl") is None:
9
+ raise ValueError(
10
+ "The wsl backend requires Windows with WSL installed. "
11
+ "Use the native backend on Linux."
12
+ )
13
+
14
+ self.distribution = distribution
15
+
16
+ def remove(self, path: PurePosixPath) -> None:
17
+ result = subprocess.run(
18
+ [
19
+ "wsl",
20
+ "-d",
21
+ self.distribution,
22
+ "--",
23
+ "rm",
24
+ "-rf",
25
+ "--",
26
+ str(path),
27
+ ],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+
32
+ if result.returncode != 0:
33
+ message = result.stderr.strip() or result.stdout.strip()
34
+
35
+ raise RuntimeError(
36
+ message or f"wsl rm failed with exit code {result.returncode}"
37
+ )
@@ -0,0 +1 @@
1
+ # __init__.py
@@ -0,0 +1,272 @@
1
+ import os
2
+ import sys
3
+ import threading
4
+ import time
5
+ from typing import TextIO
6
+
7
+ from dev_cleaner import __version__
8
+ from dev_cleaner.core.cleaner import Cleaner, CleanupReport
9
+
10
+ GREEN = "32"
11
+ RED = "31"
12
+ YELLOW = "33"
13
+ CYAN = "36"
14
+ DIM = "2"
15
+
16
+ LABEL_WIDTH = 12
17
+ SPINNER_FRAMES = "|/-\\"
18
+ SPINNER_INTERVAL = 0.1
19
+ SPINNER_DELAY = 0.2
20
+ STATUS_PROJECT_WIDTH = 20
21
+
22
+
23
+ def _enable_windows_ansi(stream: TextIO) -> None:
24
+ if os.name != "nt":
25
+ return
26
+
27
+ try:
28
+ import ctypes
29
+
30
+ kernel32 = ctypes.windll.kernel32
31
+ handle_id = -12 if stream is sys.stderr else -11
32
+ handle = kernel32.GetStdHandle(handle_id)
33
+ mode = ctypes.c_uint32()
34
+
35
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
36
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def supports_color(stream: TextIO) -> bool:
42
+ if os.environ.get("NO_COLOR"):
43
+ return False
44
+
45
+ if os.environ.get("TERM") == "dumb":
46
+ return False
47
+
48
+ if not (hasattr(stream, "isatty") and stream.isatty()):
49
+ return False
50
+
51
+ _enable_windows_ansi(stream)
52
+
53
+ return True
54
+
55
+
56
+ def backend_label(backend: str, distribution: str | None) -> str:
57
+ if backend == "wsl":
58
+ return f"wsl:{distribution}"
59
+
60
+ return "native"
61
+
62
+
63
+ def plural(count: int, word: str) -> str:
64
+ suffix = "" if count == 1 else "s"
65
+
66
+ return f"{count} {word}{suffix}"
67
+
68
+
69
+ def supports_animation(stream: TextIO) -> bool:
70
+ if os.environ.get("TERM") == "dumb":
71
+ return False
72
+
73
+ return hasattr(stream, "isatty") and stream.isatty()
74
+
75
+
76
+ def format_elapsed(seconds: float) -> str:
77
+ total = int(seconds)
78
+ minutes, secs = divmod(total, 60)
79
+ hours, minutes = divmod(minutes, 60)
80
+
81
+ if hours:
82
+ return f"{hours}:{minutes:02d}:{secs:02d}"
83
+
84
+ return f"{minutes}:{secs:02d}"
85
+
86
+
87
+ def shorten(text: str, width: int = STATUS_PROJECT_WIDTH) -> str:
88
+ if len(text) <= width:
89
+ return text
90
+
91
+ return text[: width - 3] + "..."
92
+
93
+
94
+ class Spinner:
95
+ """Single-line spinner with an elapsed timer, rendered only on a TTY."""
96
+
97
+ def __init__(self, stream: TextIO, enabled: bool):
98
+ self.stream = stream
99
+ self.enabled = enabled
100
+ self._stop = threading.Event()
101
+ self._thread: threading.Thread | None = None
102
+ self._status = ""
103
+ self._start = 0.0
104
+ self._width = 0
105
+
106
+ def __enter__(self) -> "Spinner":
107
+ self._start = time.monotonic()
108
+
109
+ if self.enabled:
110
+ self._thread = threading.Thread(target=self._spin, daemon=True)
111
+ self._thread.start()
112
+
113
+ return self
114
+
115
+ def __exit__(self, exc_type, exc, traceback) -> None:
116
+ self._stop.set()
117
+
118
+ if self._thread is not None:
119
+ self._thread.join()
120
+
121
+ if self._width:
122
+ self.stream.write("\r" + " " * self._width + "\r")
123
+ self.stream.flush()
124
+
125
+ def update(self, status: str) -> None:
126
+ self._status = status
127
+
128
+ def _spin(self) -> None:
129
+ index = 0
130
+
131
+ while not self._stop.wait(SPINNER_INTERVAL):
132
+ if time.monotonic() - self._start < SPINNER_DELAY:
133
+ continue
134
+
135
+ elapsed = format_elapsed(time.monotonic() - self._start)
136
+ frame = SPINNER_FRAMES[index % len(SPINNER_FRAMES)]
137
+ line = f"{frame} {self._status} {elapsed}"
138
+
139
+ self._width = max(self._width, len(line))
140
+ self.stream.write("\r" + line.ljust(self._width))
141
+ self.stream.flush()
142
+ index += 1
143
+
144
+
145
+ class CLI:
146
+ def __init__(self, out: TextIO | None = None, err: TextIO | None = None):
147
+ self.out = out if out is not None else sys.stdout
148
+ self.err = err if err is not None else sys.stderr
149
+ self.out_color = supports_color(self.out)
150
+ self.err_color = supports_color(self.err)
151
+
152
+ def style(self, text: str, code: str, *, err: bool = False) -> str:
153
+ enabled = self.err_color if err else self.out_color
154
+
155
+ if not enabled:
156
+ return text
157
+
158
+ return f"\033[{code}m{text}\033[0m"
159
+
160
+ def show_header(
161
+ self,
162
+ workspace: str,
163
+ backend: str,
164
+ distribution: str | None,
165
+ ) -> None:
166
+ version = self.style(f"dev-cleaner {__version__}", DIM)
167
+ label = backend_label(backend, distribution)
168
+
169
+ print(f"{version} | {label} | {workspace}", file=self.out)
170
+
171
+ def confirm(self, targets: list[str]) -> bool:
172
+ names = ", ".join(targets)
173
+
174
+ self.out.write(f"Remove {names} from every project? [y/N] ")
175
+ self.out.flush()
176
+
177
+ try:
178
+ answer = sys.stdin.readline()
179
+ except KeyboardInterrupt:
180
+ self.out.write("\n")
181
+ return False
182
+
183
+ if not answer:
184
+ self.out.write("\n")
185
+ return False
186
+
187
+ return answer.strip().lower() in {"y", "yes"}
188
+
189
+ def show_results(self, report: CleanupReport, dry_run: bool = False) -> None:
190
+ for result in report.results:
191
+ path = f"{result.project}/{result.target}"
192
+
193
+ if not result.success:
194
+ label = self.style(f"{'failed':<{LABEL_WIDTH}}", RED, err=True)
195
+ print(f"{label} {path} {result.error}", file=self.err)
196
+ continue
197
+
198
+ if dry_run:
199
+ label = self.style(f"{'would remove':<{LABEL_WIDTH}}", CYAN)
200
+ else:
201
+ label = self.style(f"{'removed':<{LABEL_WIDTH}}", GREEN)
202
+
203
+ print(f"{label} {path}", file=self.out)
204
+
205
+ def show_summary(
206
+ self,
207
+ report: CleanupReport,
208
+ dry_run: bool = False,
209
+ elapsed: float | None = None,
210
+ ) -> None:
211
+ projects = plural(report.projects_scanned, "project")
212
+ removed = len(report.removed)
213
+ failed = len(report.failed)
214
+
215
+ if dry_run:
216
+ summary = f"{removed} matched ({projects}) [dry run]"
217
+ elif failed:
218
+ summary = f"{removed} removed, {failed} failed ({projects})"
219
+ else:
220
+ summary = f"{removed} removed ({projects})"
221
+
222
+ if elapsed is not None and not dry_run and elapsed >= 1.0:
223
+ summary += f" in {format_elapsed(elapsed)}"
224
+
225
+ code = YELLOW if failed else GREEN
226
+
227
+ print(self.style(summary, code), file=self.out)
228
+
229
+ def run(
230
+ self,
231
+ cleaner: Cleaner,
232
+ workspace: str,
233
+ backend: str,
234
+ distribution: str | None = None,
235
+ assume_yes: bool = False,
236
+ ) -> int:
237
+ self.show_header(workspace, backend, distribution)
238
+ print(file=self.out)
239
+
240
+ if not cleaner.dry_run and not assume_yes:
241
+ if not self.confirm(cleaner.target_folders):
242
+ print(self.style("cancelled", YELLOW), file=self.out)
243
+ return 0
244
+
245
+ print(file=self.out)
246
+
247
+ action = "scanning" if cleaner.dry_run else "cleaning"
248
+ start = time.monotonic()
249
+
250
+ with Spinner(
251
+ self.out,
252
+ enabled=supports_animation(self.out),
253
+ ) as spinner:
254
+ spinner.update(f"{action}...")
255
+
256
+ def on_target(current: int, total: int, project, target: str) -> None:
257
+ name = f"{shorten(project.name)}/{target}"
258
+ spinner.update(f"{action} {name} ({current}/{total})")
259
+
260
+ report = cleaner.run(on_target=on_target)
261
+
262
+ elapsed = time.monotonic() - start
263
+
264
+ if not report.results:
265
+ print("no matching directories", file=self.out)
266
+ return 0
267
+
268
+ self.show_results(report, dry_run=cleaner.dry_run)
269
+ print(file=self.out)
270
+ self.show_summary(report, dry_run=cleaner.dry_run, elapsed=elapsed)
271
+
272
+ return 1 if report.failed else 0
@@ -0,0 +1,81 @@
1
+ import argparse
2
+
3
+ from dev_cleaner import __version__
4
+
5
+ DEFAULT_TARGETS = [
6
+ "node_modules",
7
+ ".next",
8
+ "dist",
9
+ ]
10
+
11
+
12
+ def create_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="dev-cleaner",
15
+ description="Remove generated directories from every project in a workspace.",
16
+ epilog=(
17
+ "examples:\n"
18
+ " python -m src.main -w C:/Users/me/workspace\n"
19
+ " python -m src.main -w /home/me/workspace -n\n"
20
+ " python -m src.main -w /home/me/workspace -t node_modules -t .venv -y\n"
21
+ ),
22
+ formatter_class=argparse.RawDescriptionHelpFormatter,
23
+ )
24
+
25
+ parser.add_argument(
26
+ "-w",
27
+ "--workspace",
28
+ required=True,
29
+ metavar="PATH",
30
+ help="Workspace directory to scan.",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "-t",
35
+ "--target",
36
+ action="append",
37
+ dest="targets",
38
+ metavar="DIR",
39
+ help=(
40
+ "Directory to remove. Repeatable. "
41
+ f"Defaults to: {', '.join(DEFAULT_TARGETS)}."
42
+ ),
43
+ )
44
+
45
+ parser.add_argument(
46
+ "-b",
47
+ "--backend",
48
+ type=str.lower,
49
+ choices=["native", "wsl"],
50
+ metavar="NAME",
51
+ help="Force native or wsl. Defaults to auto-detection.",
52
+ )
53
+
54
+ parser.add_argument(
55
+ "-d",
56
+ "--distribution",
57
+ metavar="DISTRO",
58
+ help="WSL distribution. Defaults to the detected default, then Ubuntu.",
59
+ )
60
+
61
+ parser.add_argument(
62
+ "-n",
63
+ "--dry-run",
64
+ action="store_true",
65
+ help="Show what would be removed without deleting anything.",
66
+ )
67
+
68
+ parser.add_argument(
69
+ "-y",
70
+ "--yes",
71
+ action="store_true",
72
+ help="Do not ask for confirmation.",
73
+ )
74
+
75
+ parser.add_argument(
76
+ "--version",
77
+ action="version",
78
+ version=f"%(prog)s {__version__}",
79
+ )
80
+
81
+ return parser
@@ -0,0 +1 @@
1
+ # __init__.py
@@ -0,0 +1,171 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from dev_cleaner.cleaners.base import FileSystemCleaner
7
+
8
+
9
+ def is_link(path: Path) -> bool:
10
+ """True for symlinks and, on Windows, directory junctions."""
11
+
12
+ if path.is_symlink():
13
+ return True
14
+
15
+ is_junction = getattr(os.path, "isjunction", None)
16
+
17
+ return bool(is_junction and is_junction(path))
18
+
19
+
20
+ @dataclass
21
+ class CleanupResult:
22
+ project: str
23
+ target: str
24
+ path: Path | PurePosixPath
25
+ success: bool
26
+ error: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class CleanupReport:
31
+ projects_scanned: int = 0
32
+ results: list[CleanupResult] = field(default_factory=list)
33
+
34
+ @property
35
+ def removed(self) -> list[CleanupResult]:
36
+ return [result for result in self.results if result.success]
37
+
38
+ @property
39
+ def failed(self) -> list[CleanupResult]:
40
+ return [result for result in self.results if not result.success]
41
+
42
+
43
+ class Cleaner:
44
+ def __init__(
45
+ self,
46
+ workspace_path: Path,
47
+ target_folders: list[str],
48
+ filesystem: FileSystemCleaner,
49
+ wsl_workspace_path: str | None = None,
50
+ dry_run: bool = False,
51
+ ):
52
+ self.workspace_path = workspace_path
53
+ self.target_folders = target_folders
54
+ self.filesystem = filesystem
55
+ self.wsl_workspace_path = wsl_workspace_path
56
+ self.dry_run = dry_run
57
+
58
+ self._validate_workspace()
59
+ self._validate_targets()
60
+
61
+ def _validate_workspace(self) -> None:
62
+ anchor = self.workspace_path.anchor
63
+
64
+ if anchor and self.workspace_path == Path(anchor):
65
+ raise ValueError(
66
+ f"Refusing to scan a filesystem root: {self.workspace_path}"
67
+ )
68
+
69
+ if not self.workspace_path.is_dir():
70
+ raise NotADirectoryError(f"Workspace not found: {self.workspace_path}")
71
+
72
+ def _validate_targets(self) -> None:
73
+ for target in self.target_folders:
74
+ target_path = Path(target)
75
+
76
+ if (
77
+ target_path.is_absolute()
78
+ or len(target_path.parts) != 1
79
+ or target in {".", ".."}
80
+ ):
81
+ raise ValueError(f"Invalid target directory: {target}")
82
+
83
+ def _removal_path(self, target_path: Path) -> Path | PurePosixPath:
84
+ if self.wsl_workspace_path is None:
85
+ return target_path
86
+
87
+ relative_path = target_path.relative_to(self.workspace_path)
88
+
89
+ return PurePosixPath(self.wsl_workspace_path, *relative_path.parts)
90
+
91
+ def get_projects(self) -> list[Path]:
92
+ if not self.workspace_path.is_dir():
93
+ raise NotADirectoryError(f"Workspace not found: {self.workspace_path}")
94
+
95
+ return [
96
+ item
97
+ for item in self.workspace_path.iterdir()
98
+ if item.is_dir() and not is_link(item)
99
+ ]
100
+
101
+ def clean_project(
102
+ self,
103
+ project: Path,
104
+ on_target: Callable[[Path, str], None] | None = None,
105
+ ) -> list[CleanupResult]:
106
+ results = []
107
+
108
+ for target in self.target_folders:
109
+ target_path = project / target
110
+
111
+ if is_link(target_path) or not target_path.is_dir():
112
+ continue
113
+
114
+ if on_target:
115
+ on_target(project, target)
116
+
117
+ try:
118
+ removal_path = self._removal_path(target_path)
119
+
120
+ if not self.dry_run:
121
+ self.filesystem.remove(removal_path)
122
+
123
+ results.append(
124
+ CleanupResult(
125
+ project=project.name,
126
+ target=target,
127
+ path=removal_path,
128
+ success=True,
129
+ )
130
+ )
131
+
132
+ except Exception as error:
133
+ results.append(
134
+ CleanupResult(
135
+ project=project.name,
136
+ target=target,
137
+ path=target_path,
138
+ success=False,
139
+ error=str(error),
140
+ )
141
+ )
142
+
143
+ return results
144
+
145
+ def run(
146
+ self,
147
+ on_progress: Callable[[int, int, Path], None] | None = None,
148
+ on_target: Callable[[int, int, Path, str], None] | None = None,
149
+ ) -> CleanupReport:
150
+ projects = self.get_projects()
151
+
152
+ report = CleanupReport(
153
+ projects_scanned=len(projects),
154
+ )
155
+
156
+ for index, project in enumerate(projects, start=1):
157
+
158
+ def report_target(
159
+ target_project: Path,
160
+ target: str,
161
+ index: int = index,
162
+ ) -> None:
163
+ if on_target is not None:
164
+ on_target(index, len(projects), target_project, target)
165
+
166
+ report.results.extend(self.clean_project(project, on_target=report_target))
167
+
168
+ if on_progress:
169
+ on_progress(index, len(projects), project)
170
+
171
+ return report
dev_cleaner/main.py ADDED
@@ -0,0 +1,180 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from dev_cleaner.cleaners.base import FileSystemCleaner
7
+ from dev_cleaner.cleaners.native import NativeCleaner
8
+ from dev_cleaner.cleaners.wsl import WslCleaner
9
+ from dev_cleaner.cli.interface import CLI
10
+ from dev_cleaner.cli.parser import DEFAULT_TARGETS, create_parser
11
+ from dev_cleaner.core.cleaner import Cleaner
12
+
13
+ DEFAULT_DISTRIBUTION = "Ubuntu"
14
+ WSL_UNC_HOST = "wsl.localhost"
15
+ WSL_UNC_HOSTS = {WSL_UNC_HOST, "wsl$"}
16
+
17
+
18
+ def is_wsl_unc_path(workspace: str) -> bool:
19
+ parts = PurePosixPath(workspace.replace("\\", "/")).parts
20
+
21
+ return len(parts) >= 3 and parts[0] == "//" and parts[1].lower() in WSL_UNC_HOSTS
22
+
23
+
24
+ def split_wsl_unc_path(workspace: str) -> tuple[str, PurePosixPath] | None:
25
+ if not is_wsl_unc_path(workspace):
26
+ return None
27
+
28
+ parts = PurePosixPath(workspace.replace("\\", "/")).parts
29
+
30
+ if len(parts) < 4:
31
+ raise ValueError(
32
+ "Workspace must point to a directory inside the WSL distribution, "
33
+ "not to the distribution root."
34
+ )
35
+
36
+ distribution = parts[2]
37
+ linux_path = PurePosixPath("/", *parts[3:])
38
+
39
+ return distribution, linux_path
40
+
41
+
42
+ def detect_backend(workspace: str) -> str:
43
+ """Pick a backend from the shape of the workspace path.
44
+
45
+ WSL is only considered when running on Windows, because Linux already
46
+ sees WSL paths natively and has no ``wsl`` command to delegate to.
47
+ """
48
+
49
+ if os.name != "nt":
50
+ return "native"
51
+
52
+ if is_wsl_unc_path(workspace):
53
+ return "wsl"
54
+
55
+ # A POSIX path such as /home/user/workspace is not a valid Windows path,
56
+ # so it must refer to a filesystem inside WSL.
57
+ if PurePosixPath(workspace).is_absolute() and not Path(workspace).is_absolute():
58
+ return "wsl"
59
+
60
+ return "native"
61
+
62
+
63
+ def decode_wsl_output(data: bytes) -> str:
64
+ # wsl.exe writes UTF-16LE to pipes, which looks like ASCII with NUL bytes.
65
+ if b"\x00" in data:
66
+ text = data.decode("utf-16-le", errors="replace")
67
+ else:
68
+ text = data.decode("utf-8", errors="replace")
69
+
70
+ return text.replace("\ufeff", "").replace("\x00", "")
71
+
72
+
73
+ def detect_default_distribution() -> str | None:
74
+ """Return the default WSL distribution, or None when WSL is unavailable."""
75
+
76
+ try:
77
+ result = subprocess.run(
78
+ ["wsl", "-l", "-q"],
79
+ capture_output=True,
80
+ timeout=15,
81
+ )
82
+ except (OSError, subprocess.SubprocessError):
83
+ return None
84
+
85
+ if result.returncode != 0:
86
+ return None
87
+
88
+ for line in decode_wsl_output(result.stdout).splitlines():
89
+ name = line.strip()
90
+
91
+ if name:
92
+ return name
93
+
94
+ return None
95
+
96
+
97
+ def create_cleaner(backend: str, distribution: str) -> FileSystemCleaner:
98
+ if backend == "native":
99
+ return NativeCleaner()
100
+
101
+ return WslCleaner(distribution)
102
+
103
+
104
+ def resolve_workspace(
105
+ workspace: str,
106
+ backend: str,
107
+ distribution: str | None,
108
+ ) -> tuple[Path, str | None, str]:
109
+ """Return the path to scan, the Linux path for WSL, and the distribution."""
110
+
111
+ if backend == "native":
112
+ return (
113
+ Path(workspace).expanduser(),
114
+ None,
115
+ distribution or DEFAULT_DISTRIBUTION,
116
+ )
117
+
118
+ unc = split_wsl_unc_path(workspace)
119
+
120
+ if unc is not None:
121
+ unc_distribution, linux_path = unc
122
+ distribution = distribution or unc_distribution
123
+ else:
124
+ linux_path = PurePosixPath(workspace)
125
+
126
+ if not linux_path.is_absolute():
127
+ raise ValueError(
128
+ "WSL workspace must be an absolute Linux path, "
129
+ "for example /home/user/workspace."
130
+ )
131
+
132
+ if ".." in linux_path.parts:
133
+ raise ValueError("WSL workspace cannot contain '..'.")
134
+
135
+ distribution = distribution or detect_default_distribution() or DEFAULT_DISTRIBUTION
136
+
137
+ scan_path = Path(f"//{WSL_UNC_HOST}/{distribution}{linux_path.as_posix()}")
138
+
139
+ return scan_path, linux_path.as_posix(), distribution
140
+
141
+
142
+ def main() -> int:
143
+ args = create_parser().parse_args()
144
+
145
+ try:
146
+ backend = args.backend or detect_backend(args.workspace)
147
+
148
+ workspace_path, wsl_workspace_path, distribution = resolve_workspace(
149
+ workspace=args.workspace,
150
+ backend=backend,
151
+ distribution=args.distribution,
152
+ )
153
+
154
+ cleaner = Cleaner(
155
+ workspace_path=workspace_path,
156
+ target_folders=args.targets or DEFAULT_TARGETS,
157
+ filesystem=create_cleaner(backend, distribution),
158
+ wsl_workspace_path=wsl_workspace_path,
159
+ dry_run=args.dry_run,
160
+ )
161
+
162
+ return CLI().run(
163
+ cleaner=cleaner,
164
+ workspace=args.workspace,
165
+ backend=backend,
166
+ distribution=distribution if backend == "wsl" else None,
167
+ assume_yes=args.yes,
168
+ )
169
+
170
+ except KeyboardInterrupt:
171
+ print("\nCancelled.", file=sys.stderr)
172
+ return 130
173
+
174
+ except (NotADirectoryError, ValueError, OSError) as error:
175
+ print(f"Error: {error}", file=sys.stderr)
176
+ return 1
177
+
178
+
179
+ if __name__ == "__main__":
180
+ raise SystemExit(main())
@@ -0,0 +1,468 @@
1
+ Metadata-Version: 2.5
2
+ Name: devcleaner-cli
3
+ Version: 1.0.0
4
+ Summary: Remove generated directories such as node_modules, .next, and dist from every project in a workspace.
5
+ Project-URL: Homepage, https://github.com/adonesbalica/dev-cleaner
6
+ Project-URL: Repository, https://github.com/adonesbalica/dev-cleaner
7
+ Project-URL: Issues, https://github.com/adonesbalica/dev-cleaner/issues
8
+ Author-email: Adones Balica <adonesbalica@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cleanup,cli,developer-tools,disk-space,node-modules,wsl
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: System :: Filesystems
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.12
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.2; extra == 'dev'
26
+ Requires-Dist: ruff>=0.17; extra == 'dev'
27
+ Requires-Dist: twine>=5; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # dev-cleaner
31
+
32
+ A lightweight, cross-platform CLI tool to clean unnecessary directories from development projects.
33
+
34
+ `dev-cleaner` helps developers keep their workspaces clean by automatically detecting projects and removing generated or dependency directories such as `node_modules`, `.next`, and `dist`.
35
+
36
+ ## Table of contents
37
+
38
+ - [Features](#features)
39
+ - [Supported environments](#supported-environments)
40
+ - [Requirements](#requirements)
41
+ - [Installation](#installation)
42
+ - [Tutorial](#tutorial)
43
+ - [Usage](#usage)
44
+ - [Development](#development)
45
+ - [Project structure](#project-structure)
46
+ - [Architecture](#architecture)
47
+ - [Safety](#safety)
48
+ - [Troubleshooting](#troubleshooting)
49
+ - [License](#license)
50
+
51
+ ## Features
52
+
53
+ - **Cross-platform:** runs natively on Windows, Linux, and inside WSL.
54
+ - **Automatic backend selection:** the right filesystem implementation is picked from the workspace path.
55
+ - **Windows → WSL support:** clean Linux filesystems from Windows without leaving the terminal.
56
+ - **Configurable targets:** defaults to `node_modules`, `.next`, and `dist`, and accepts custom targets.
57
+ - **Safe by default:** asks for confirmation, supports `--dry-run`, skips symlinks and junctions, and refuses to scan a filesystem root.
58
+ - **Per-operation reporting:** failures are reported per project instead of aborting the whole run.
59
+ - **Live progress:** a spinner and elapsed timer show that a long cleanup is still running.
60
+ - **Installable:** published on PyPI as `devcleaner-cli` and exposes the `dev-cleaner` command.
61
+ - **No dependencies:** the CLI uses only the Python standard library.
62
+
63
+ ## Supported environments
64
+
65
+ | Where you run it | Workspace path | Backend |
66
+ |---|---|---|
67
+ | Windows | `C:\Users\me\workspace` | `native` (Windows filesystem) |
68
+ | Windows | `/home/me/workspace` | `wsl` (default distribution) |
69
+ | Windows | `//wsl.localhost/Ubuntu/home/me/workspace` | `wsl` (distribution taken from the path) |
70
+ | Linux | `/home/me/workspace` | `native` (Linux filesystem) |
71
+ | Inside WSL | `/home/me/workspace` or `/mnt/c/...` | `native` (Linux filesystem) |
72
+
73
+ The backend is detected automatically. Use `--backend` only to override the detection.
74
+
75
+ ## Requirements
76
+
77
+ - Python 3.12 or higher.
78
+ - WSL 2 with a Linux distribution such as Ubuntu (only when cleaning a Linux filesystem from Windows).
79
+
80
+ No third-party packages are required.
81
+
82
+ ## Installation
83
+
84
+ The package is published on PyPI as [`devcleaner-cli`](https://pypi.org/project/devcleaner-cli/) and installs the `dev-cleaner` command.
85
+
86
+ **pipx (recommended for CLI tools):**
87
+
88
+ ```bash
89
+ pipx install devcleaner-cli
90
+ ```
91
+
92
+ **uv:**
93
+
94
+ ```bash
95
+ uv tool install devcleaner-cli
96
+ ```
97
+
98
+ **pip:**
99
+
100
+ ```bash
101
+ python -m pip install --user devcleaner-cli
102
+ ```
103
+
104
+ On Windows, install pipx with `py -m pip install --user pipx` and run `py -m pipx ensurepath`, then reopen the terminal.
105
+
106
+ **From source:**
107
+
108
+ ```bash
109
+ git clone https://github.com/adonesbalica/dev-cleaner.git
110
+ cd dev-cleaner
111
+ pipx install .
112
+ ```
113
+
114
+ After installing, `dev-cleaner` works from any directory:
115
+
116
+ ```bash
117
+ dev-cleaner --version
118
+ ```
119
+
120
+ If the command is not found, reopen the terminal so the updated `PATH` is loaded, or run `python -m dev_cleaner`. The package has no runtime dependencies.
121
+
122
+ ## Tutorial
123
+
124
+ This walkthrough creates a throwaway workspace, so nothing real is deleted. It takes about two minutes.
125
+
126
+ ### 1. Create a practice workspace
127
+
128
+ **Windows (PowerShell):**
129
+
130
+ ```powershell
131
+ $demo = Join-Path $env:TEMP "dev-cleaner-demo"
132
+ New-Item -ItemType Directory -Force "$demo\app-a\node_modules\react" | Out-Null
133
+ New-Item -ItemType Directory -Force "$demo\app-a\dist" | Out-Null
134
+ New-Item -ItemType Directory -Force "$demo\app-b\.next" | Out-Null
135
+ New-Item -ItemType Directory -Force "$demo\app-c\src" | Out-Null
136
+ ```
137
+
138
+ **Linux or WSL:**
139
+
140
+ ```bash
141
+ demo="$(mktemp -d)"
142
+ mkdir -p "$demo/app-a/node_modules/react" "$demo/app-a/dist"
143
+ mkdir -p "$demo/app-b/.next" "$demo/app-c/src"
144
+ ```
145
+
146
+ You now have three projects: `app-a` and `app-b` contain generated directories, while `app-c` contains only source code.
147
+
148
+ ### 2. Preview the cleanup with a dry run
149
+
150
+ **Windows:**
151
+
152
+ ```powershell
153
+ dev-cleaner -w $demo -n
154
+ ```
155
+
156
+ **Linux or WSL:**
157
+
158
+ ```bash
159
+ dev-cleaner -w "$demo" -n
160
+ ```
161
+
162
+ Expected output:
163
+
164
+ ```text
165
+ dev-cleaner 1.0.0 | native | <workspace>
166
+
167
+ would remove app-a/node_modules
168
+ would remove app-a/dist
169
+ would remove app-b/.next
170
+
171
+ 3 matched (3 projects) [dry run]
172
+ ```
173
+
174
+ The first line shows the tool version, the selected backend, and the workspace you passed. Nothing has been deleted; the `[dry run]` marker confirms it. The order of the lines depends on the filesystem; the counts are what matter.
175
+
176
+ ### 3. Run the cleanup for real
177
+
178
+ Drop `-n` and add `-y` to skip the confirmation prompt:
179
+
180
+ ```bash
181
+ dev-cleaner -w "$demo" -y
182
+ ```
183
+
184
+ Expected output:
185
+
186
+ ```text
187
+ dev-cleaner 1.0.0 | native | <workspace>
188
+
189
+ removed app-a/node_modules
190
+ removed app-a/dist
191
+ removed app-b/.next
192
+
193
+ 3 removed (3 projects)
194
+ ```
195
+
196
+ On a real terminal, a single-line spinner and timer appear while the directories are removed, so a slow cleanup never looks frozen:
197
+
198
+ ```text
199
+ | cleaning app-a/node_modules (1/3) 0:04
200
+ ```
201
+
202
+ The line is erased when the run finishes, and the summary appends the total time when it took at least one second (for example `3 removed (3 projects) in 0:04`). When the output is piped or redirected, the spinner is skipped and only the final lines are written, which keeps the output easy to parse.
203
+
204
+ Without `-y`, the tool asks first:
205
+
206
+ ```text
207
+ Remove node_modules, .next, dist from every project? [y/N]
208
+ ```
209
+
210
+ Press `y` and Enter to continue, or Enter to cancel. The default is always **No**.
211
+
212
+ ### 4. Verify the result
213
+
214
+ **Windows:**
215
+
216
+ ```powershell
217
+ Get-ChildItem -Directory -Recurse $demo | Select-Object -ExpandProperty FullName
218
+ ```
219
+
220
+ **Linux or WSL:**
221
+
222
+ ```bash
223
+ find "$demo" -type d
224
+ ```
225
+
226
+ Only `app-a`, `app-b`, and `app-c` (plus `app-c/src`) should remain. The generated directories are gone.
227
+
228
+ ### 5. Remove the practice workspace
229
+
230
+ **Windows:**
231
+
232
+ ```powershell
233
+ Remove-Item $demo -Recurse -Force
234
+ ```
235
+
236
+ **Linux or WSL:**
237
+
238
+ ```bash
239
+ rm -rf "$demo"
240
+ ```
241
+
242
+ ### Tutorial: clean a WSL workspace from Windows
243
+
244
+ Use the Linux path of the workspace. The tool detects that it is not a Windows path and switches to the WSL backend automatically.
245
+
246
+ ```powershell
247
+ # See which distributions are available
248
+ wsl -l -q
249
+
250
+ # Preview the cleanup
251
+ dev-cleaner -w /home/me/workspace -n
252
+
253
+ # Use a specific distribution if the default is not the right one
254
+ dev-cleaner -w /home/me/workspace -d Ubuntu -n
255
+
256
+ # Run it
257
+ dev-cleaner -w /home/me/workspace -y
258
+ ```
259
+
260
+ The header shows which backend and distribution were selected:
261
+
262
+ ```text
263
+ dev-cleaner 1.0.0 | wsl:Ubuntu | /home/me/workspace
264
+ ```
265
+
266
+ You can also pass a Windows UNC path such as `//wsl.localhost/Ubuntu/home/me/workspace`; the distribution is read from the path.
267
+
268
+ ### Tutorial: use custom targets
269
+
270
+ Passing `-t` **replaces** the default target list, so list every directory you want to remove:
271
+
272
+ ```bash
273
+ dev-cleaner -w /home/me/workspace -t node_modules -t .venv -t target -n
274
+ ```
275
+
276
+ This removes only `node_modules`, `.venv`, and `target`; it does not also remove `.next` or `dist`.
277
+
278
+ ### Tutorial: use it in a script
279
+
280
+ Use `-y` so the command never waits for input, and check the exit code:
281
+
282
+ **Bash:**
283
+
284
+ ```bash
285
+ dev-cleaner -w /home/me/workspace -y
286
+ if [ $? -ne 0 ]; then
287
+ echo "cleanup failed" >&2
288
+ fi
289
+ ```
290
+
291
+ **PowerShell:**
292
+
293
+ ```powershell
294
+ dev-cleaner -w C:/Users/me/workspace -y
295
+ if ($LASTEXITCODE -ne 0) {
296
+ Write-Error "cleanup failed"
297
+ }
298
+ ```
299
+
300
+ ## Usage
301
+
302
+ ```bash
303
+ dev-cleaner -w <workspace> [options]
304
+ ```
305
+
306
+ ### Options
307
+
308
+ | Option | Description |
309
+ |---|---|
310
+ | `-w`, `--workspace` | Workspace directory to scan. Required. |
311
+ | `-t`, `--target` | Directory to remove. Repeatable; replaces the defaults. Defaults to `node_modules`, `.next`, `dist`. |
312
+ | `-b`, `--backend` | Force `native` or `wsl`. Auto-detected by default. |
313
+ | `-d`, `--distribution` | WSL distribution to use. Defaults to the WSL default distribution, or `Ubuntu`. |
314
+ | `-n`, `--dry-run` | Show what would be removed without deleting anything. |
315
+ | `-y`, `--yes` | Do not ask for confirmation. |
316
+ | `--version` | Show the version and exit. |
317
+
318
+ ### Progress indicator
319
+
320
+ While scanning or deleting, the CLI shows one animated line with the directory being removed and an elapsed timer:
321
+
322
+ ```text
323
+ / cleaning app-a/node_modules (2/7) 0:12
324
+ ```
325
+
326
+ - The spinner uses plain ASCII frames (`|`, `/`, `-`, `\`), so it works in any terminal.
327
+ - The status updates before **each directory** is removed, so the name changes even when a single project takes a long time.
328
+ - The timer uses `m:ss`, switching to `h:mm:ss` after one hour.
329
+ - The line is cleared before the per-directory results are printed.
330
+ - The summary appends `in m:ss` when the run took at least one second.
331
+ - The spinner is **TTY-only**. Piped or redirected output never contains the animation, so logs and scripts stay clean.
332
+
333
+ ### Exit codes
334
+
335
+ | Code | Meaning |
336
+ |---|---|
337
+ | `0` | Success, including "nothing to remove" and a cancelled prompt. |
338
+ | `1` | Error: invalid workspace, invalid target, failed removal, or WSL unavailable. |
339
+ | `2` | Invalid command-line arguments. |
340
+ | `130` | Interrupted with Ctrl+C. |
341
+
342
+ ### How it works
343
+
344
+ 1. Detects the backend from the workspace path (unless `--backend` is given).
345
+ 2. Maps the workspace to a scan path and, for WSL, to the equivalent Linux path.
346
+ 3. Lists the direct children of the workspace as projects.
347
+ 4. Checks each project for the configured target directories.
348
+ 5. Removes matching directories using the selected backend, reporting every operation.
349
+
350
+ Only direct children of the workspace are treated as projects, and only direct children of each project are removed. Symlinked projects and targets, as well as Windows directory junctions, are skipped.
351
+
352
+ ## Development
353
+
354
+ Clone the repository and create a virtual environment:
355
+
356
+ ```bash
357
+ git clone https://github.com/adonesbalica/dev-cleaner.git
358
+ cd dev-cleaner
359
+ python -m venv .venv
360
+ ```
361
+
362
+ Activate it:
363
+
364
+ **Windows (PowerShell):**
365
+
366
+ ```powershell
367
+ .venv\Scripts\Activate.ps1
368
+ ```
369
+
370
+ **Linux / WSL:**
371
+
372
+ ```bash
373
+ source .venv/bin/activate
374
+ ```
375
+
376
+ Install the package in editable mode with the development tools:
377
+
378
+ ```bash
379
+ python -m pip install -e ".[dev]"
380
+ ```
381
+
382
+ Run the CLI without the console script:
383
+
384
+ ```bash
385
+ python -m dev_cleaner --help
386
+ ```
387
+
388
+ Run the checks and build the distribution:
389
+
390
+ ```bash
391
+ ruff check src
392
+ ruff format --check src
393
+ python -m build
394
+ ```
395
+
396
+ The build writes `dist/devcleaner_cli-<version>-py3-none-any.whl` and a source archive. See [RELEASING.md](https://github.com/adonesbalica/dev-cleaner/blob/main/RELEASING.md) for the publish steps.
397
+
398
+ ## Project structure
399
+
400
+ ```text
401
+ dev-cleaner/
402
+ ├── src/
403
+ │ └── dev_cleaner/
404
+ │ ├── __init__.py
405
+ │ ├── __main__.py
406
+ │ ├── main.py
407
+ │ ├── core/
408
+ │ │ ├── __init__.py
409
+ │ │ └── cleaner.py
410
+ │ ├── cleaners/
411
+ │ │ ├── __init__.py
412
+ │ │ ├── base.py
413
+ │ │ ├── native.py
414
+ │ │ └── wsl.py
415
+ │ └── cli/
416
+ │ ├── __init__.py
417
+ │ ├── interface.py
418
+ │ └── parser.py
419
+ ├── pyproject.toml
420
+ ├── README.md
421
+ ├── RELEASING.md
422
+ ├── LICENSE
423
+ └── .gitignore
424
+ ```
425
+
426
+ ## Architecture
427
+
428
+ The project uses a strategy-based architecture to separate cleanup orchestration from filesystem operations.
429
+
430
+ - **`FileSystemCleaner`:** defines the contract for filesystem cleanup implementations.
431
+ - **`NativeCleaner`:** uses `shutil.rmtree` on the filesystem the application runs on (Windows or Linux), including retries for read-only files.
432
+ - **`WslCleaner`:** runs `wsl -d <distribution> -- rm -rf` to remove directories inside a Linux filesystem from Windows.
433
+ - **`Cleaner`:** handles workspace scanning, project detection, WSL path mapping, dry runs, and cleanup orchestration.
434
+ - **`main.py`:** detects the backend, resolves the workspace paths, and wires everything together.
435
+ - **`cli/`:** parses arguments, shows the TTY-only spinner and timer, and prints plain-text results, a summary, and the confirmation prompt. No third-party dependencies.
436
+
437
+ This design allows new filesystem cleanup implementations to be added without changing the core cleanup logic.
438
+
439
+ ## Safety
440
+
441
+ Directory removal is permanent.
442
+
443
+ Before running the application, make sure that:
444
+
445
+ - The workspace path is correct.
446
+ - The target directories are the ones you intend to remove.
447
+ - The selected backend matches your environment.
448
+
449
+ Use `--dry-run` first to review what will be removed. Symlinked projects and targets are skipped, and the tool refuses to scan a filesystem root. Only direct children of the workspace are considered, so pointing `-w` at a folder that contains projects is safer than pointing it at a single project.
450
+
451
+ ## Troubleshooting
452
+
453
+ | Problem | Cause and fix |
454
+ |---|---|
455
+ | `Error: Workspace not found: ...` | The path is wrong, or the WSL distribution is not running. Check the path and run `wsl -l -q` to see available distributions. |
456
+ | `Error: The wsl backend requires Windows with WSL installed.` | You forced `-b wsl` on Linux. Remove `-b wsl`; Linux paths already work with the native backend. |
457
+ | `Error: Refusing to scan a filesystem root: ...` | Pass the folder that contains your projects, not `/`, `C:\`, or a WSL distribution root. |
458
+ | `Error: Invalid target directory: ...` | Targets must be a single directory name. Do not use paths such as `src/dist` or `..`. |
459
+ | `failed ... [WinError 32]` or `Permission denied` | A file is open or read-only. Close editors, dev servers, and terminals using the directory, then retry. |
460
+ | Nothing is removed | Only direct children of the workspace are scanned. Point `-w` at the folder containing your project folders. |
461
+ | Paths with spaces do not work | Quote the path: `-w "C:/Users/me/My Projects"`. |
462
+ | Colors look wrong or appear as escape codes | Set `NO_COLOR=1`, or use a terminal with ANSI support. |
463
+ | No spinner or timer appears | The spinner is TTY-only. It is hidden when output is piped or redirected, and when `TERM=dumb`. Run the tool directly in a terminal to see it. |
464
+ | `dev-cleaner` is not recognized | Install it with `pipx install devcleaner-cli` or `uv tool install devcleaner-cli`, then reopen the terminal so `PATH` is updated. You can always run `python -m dev_cleaner` instead. |
465
+
466
+ ## License
467
+
468
+ This project is licensed under the MIT License.
@@ -0,0 +1,17 @@
1
+ dev_cleaner/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ dev_cleaner/__main__.py,sha256=IuIEzeQW7UXTs6IfuP-K7IC8RCKRcAu-n74mLVE5zi0,95
3
+ dev_cleaner/main.py,sha256=cc2SEtRxBftfk9TxWPgpV_u9POP0wS9qk3gzBQZTiVQ,5108
4
+ dev_cleaner/cleaners/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
5
+ dev_cleaner/cleaners/base.py,sha256=ez3K4ptQfKTQBoKwO7-EOhYfmijzhslypQ8mB1cVedM,229
6
+ dev_cleaner/cleaners/native.py,sha256=-FBXjTNCBa66QMeaMkvzkyJgeHulku2Ji3gW-TJvWdo,423
7
+ dev_cleaner/cleaners/wsl.py,sha256=HtbQoQe2_vTPtIaeqnFWfiC5lK9EqOejDi8DUhEjxCg,996
8
+ dev_cleaner/cli/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
9
+ dev_cleaner/cli/interface.py,sha256=gG4CEyCujGmAluImSMFgdj_EKPEOvYdMBvQcvjdFhfI,7593
10
+ dev_cleaner/cli/parser.py,sha256=MU6HyZlMh5WDC4eKltcBhMwfBn8PqQoyb_6N0vF7x0I,1926
11
+ dev_cleaner/core/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
12
+ dev_cleaner/core/cleaner.py,sha256=9tdpYNtunSFTitKDoc0CjIqV8EsNeXjWhoMX09oTu1U,4976
13
+ devcleaner_cli-1.0.0.dist-info/METADATA,sha256=V8inQgEtDMW4zTV_9evWRq71pfwmspEzxVoiBYr2uxI,15578
14
+ devcleaner_cli-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
15
+ devcleaner_cli-1.0.0.dist-info/entry_points.txt,sha256=weNwCHbPFbggrX7BEHfpZlh0oRib4jxKfQU71csaHoI,54
16
+ devcleaner_cli-1.0.0.dist-info/licenses/LICENSE,sha256=GjwWVWb-7ncF_RZHaRzniNX6hDPkOkWqKqRw_O5i-Lk,1069
17
+ devcleaner_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dev-cleaner = dev_cleaner.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 adonesbalica
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.