devtidy 0.1.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.
- devtidy/__init__.py +4 -0
- devtidy/__main__.py +4 -0
- devtidy/cli.py +212 -0
- devtidy/models.py +34 -0
- devtidy/rules.py +93 -0
- devtidy/safety.py +60 -0
- devtidy/scanner.py +119 -0
- devtidy/storage.py +155 -0
- devtidy/units.py +49 -0
- devtidy-0.1.0.dist-info/METADATA +140 -0
- devtidy-0.1.0.dist-info/RECORD +15 -0
- devtidy-0.1.0.dist-info/WHEEL +5 -0
- devtidy-0.1.0.dist-info/entry_points.txt +2 -0
- devtidy-0.1.0.dist-info/licenses/LICENSE +22 -0
- devtidy-0.1.0.dist-info/top_level.txt +1 -0
devtidy/__init__.py
ADDED
devtidy/__main__.py
ADDED
devtidy/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devtidy import __version__
|
|
10
|
+
from devtidy.models import Candidate
|
|
11
|
+
from devtidy.rules import RULES
|
|
12
|
+
from devtidy.safety import UnsafePathError
|
|
13
|
+
from devtidy.scanner import scan
|
|
14
|
+
from devtidy.storage import archive, delete, read_history, restore
|
|
15
|
+
from devtidy.units import human_size, parse_duration, parse_size
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _size(value: str) -> int:
|
|
19
|
+
try:
|
|
20
|
+
return parse_size(value)
|
|
21
|
+
except ValueError as error:
|
|
22
|
+
raise argparse.ArgumentTypeError(str(error)) from error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _duration(value: str) -> float:
|
|
26
|
+
try:
|
|
27
|
+
return parse_duration(value)
|
|
28
|
+
except ValueError as error:
|
|
29
|
+
raise argparse.ArgumentTypeError(str(error)) from error
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_scan_arguments(parser: argparse.ArgumentParser) -> None:
|
|
33
|
+
parser.add_argument("paths", nargs="*", type=Path, default=[Path.cwd()])
|
|
34
|
+
parser.add_argument("--older-than", type=_duration, default=0, metavar="30d")
|
|
35
|
+
parser.add_argument("--min-size", type=_size, default=0, metavar="100MB")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--category",
|
|
38
|
+
action="append",
|
|
39
|
+
choices=sorted({rule.category for rule in RULES}),
|
|
40
|
+
dest="categories",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--exclude", action="append", default=[], metavar="GLOB")
|
|
43
|
+
parser.add_argument("--max-depth", type=int)
|
|
44
|
+
parser.add_argument("--json", action="store_true")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="devtidy",
|
|
50
|
+
description="Safely reclaim stale developer artifacts.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
53
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
54
|
+
|
|
55
|
+
scan_parser = subparsers.add_parser("scan", help="find cleanup candidates")
|
|
56
|
+
_add_scan_arguments(scan_parser)
|
|
57
|
+
|
|
58
|
+
clean_parser = subparsers.add_parser("clean", help="archive or delete candidates")
|
|
59
|
+
_add_scan_arguments(clean_parser)
|
|
60
|
+
action = clean_parser.add_mutually_exclusive_group(required=True)
|
|
61
|
+
action.add_argument("--archive", action="store_true")
|
|
62
|
+
action.add_argument("--delete", action="store_true")
|
|
63
|
+
clean_parser.add_argument(
|
|
64
|
+
"--yes",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="confirm the selected cleanup action",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
restore_parser = subparsers.add_parser("restore", help="restore an archive session")
|
|
70
|
+
restore_parser.add_argument("session_id", nargs="?")
|
|
71
|
+
restore_parser.add_argument("--latest", action="store_true")
|
|
72
|
+
restore_parser.add_argument("--overwrite", action="store_true")
|
|
73
|
+
restore_parser.add_argument("--json", action="store_true")
|
|
74
|
+
|
|
75
|
+
history_parser = subparsers.add_parser("history", help="show local cleanup history")
|
|
76
|
+
history_parser.add_argument("--limit", type=int, default=10)
|
|
77
|
+
history_parser.add_argument("--json", action="store_true")
|
|
78
|
+
|
|
79
|
+
rules_parser = subparsers.add_parser("rules", help="explain built-in matching rules")
|
|
80
|
+
rules_parser.add_argument("--json", action="store_true")
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _candidates(args: argparse.Namespace) -> list[Candidate]:
|
|
85
|
+
return scan(
|
|
86
|
+
args.paths or [Path.cwd()],
|
|
87
|
+
older_than_seconds=args.older_than,
|
|
88
|
+
min_size=args.min_size,
|
|
89
|
+
categories=set(args.categories) if args.categories else None,
|
|
90
|
+
exclusions=tuple(args.exclude),
|
|
91
|
+
max_depth=args.max_depth,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_candidates(candidates: list[Candidate]) -> None:
|
|
96
|
+
if not candidates:
|
|
97
|
+
print("No cleanup candidates found.")
|
|
98
|
+
return
|
|
99
|
+
for candidate in candidates:
|
|
100
|
+
activity = datetime.fromtimestamp(candidate.last_activity).strftime("%Y-%m-%d")
|
|
101
|
+
print(
|
|
102
|
+
f"{human_size(candidate.size):>10} "
|
|
103
|
+
f"{candidate.category:<7} {activity} {candidate.path}"
|
|
104
|
+
)
|
|
105
|
+
print(f"\nPotential savings: {human_size(sum(item.size for item in candidates))}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scan_command(args: argparse.Namespace) -> int:
|
|
109
|
+
candidates = _candidates(args)
|
|
110
|
+
if args.json:
|
|
111
|
+
print(json.dumps([item.to_dict() for item in candidates], indent=2))
|
|
112
|
+
else:
|
|
113
|
+
_print_candidates(candidates)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _clean_command(args: argparse.Namespace) -> int:
|
|
118
|
+
candidates = _candidates(args)
|
|
119
|
+
if not candidates:
|
|
120
|
+
print("No cleanup candidates found.")
|
|
121
|
+
return 0
|
|
122
|
+
if not args.yes:
|
|
123
|
+
_print_candidates(candidates)
|
|
124
|
+
action = "archive" if args.archive else "permanently delete"
|
|
125
|
+
print(f"\nRefusing to {action} without --yes.", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
result = archive(candidates) if args.archive else delete(candidates)
|
|
129
|
+
if args.json:
|
|
130
|
+
print(json.dumps(result, indent=2))
|
|
131
|
+
else:
|
|
132
|
+
print(
|
|
133
|
+
f"{result['action'].capitalize()} complete: "
|
|
134
|
+
f"{len(candidates)} item(s), {human_size(int(result['total_size']))}."
|
|
135
|
+
)
|
|
136
|
+
print(f"Session: {result['session_id']}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _restore_command(args: argparse.Namespace) -> int:
|
|
141
|
+
result = restore(args.session_id, latest=args.latest, overwrite=args.overwrite)
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps(result, indent=2))
|
|
144
|
+
else:
|
|
145
|
+
print(f"Restored {len(result['items'])} item(s) from {result['session_id']}.")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _history_command(args: argparse.Namespace) -> int:
|
|
150
|
+
records = read_history(args.limit)
|
|
151
|
+
if args.json:
|
|
152
|
+
print(json.dumps(records, indent=2))
|
|
153
|
+
return 0
|
|
154
|
+
if not records:
|
|
155
|
+
print("No cleanup history found.")
|
|
156
|
+
return 0
|
|
157
|
+
for record in records:
|
|
158
|
+
created = datetime.fromtimestamp(record["created_at"]).strftime("%Y-%m-%d %H:%M")
|
|
159
|
+
print(
|
|
160
|
+
f"{created} {record['action']:<7} "
|
|
161
|
+
f"{human_size(int(record['total_size'])):>10} {record['session_id']}"
|
|
162
|
+
)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rules_command(args: argparse.Namespace) -> int:
|
|
167
|
+
rules = [
|
|
168
|
+
{
|
|
169
|
+
"name": rule.name,
|
|
170
|
+
"category": rule.category,
|
|
171
|
+
"directories": rule.directory_names,
|
|
172
|
+
"description": rule.description,
|
|
173
|
+
}
|
|
174
|
+
for rule in RULES
|
|
175
|
+
]
|
|
176
|
+
if args.json:
|
|
177
|
+
print(json.dumps(rules, indent=2))
|
|
178
|
+
else:
|
|
179
|
+
for rule in rules:
|
|
180
|
+
names = ", ".join(rule["directories"])
|
|
181
|
+
print(f"{rule['name']} ({rule['category']}): {names}")
|
|
182
|
+
print(f" {rule['description']}")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
arguments = list(argv if argv is not None else sys.argv[1:])
|
|
188
|
+
known_commands = {"scan", "clean", "restore", "history", "rules"}
|
|
189
|
+
if not arguments:
|
|
190
|
+
arguments = ["scan", "."]
|
|
191
|
+
elif arguments[0] not in known_commands and arguments[0] not in {"-h", "--help", "--version"}:
|
|
192
|
+
arguments.insert(0, "scan")
|
|
193
|
+
|
|
194
|
+
parser = build_parser()
|
|
195
|
+
args = parser.parse_args(arguments)
|
|
196
|
+
try:
|
|
197
|
+
if args.command == "scan":
|
|
198
|
+
return _scan_command(args)
|
|
199
|
+
if args.command == "clean":
|
|
200
|
+
return _clean_command(args)
|
|
201
|
+
if args.command == "restore":
|
|
202
|
+
return _restore_command(args)
|
|
203
|
+
if args.command == "history":
|
|
204
|
+
return _history_command(args)
|
|
205
|
+
if args.command == "rules":
|
|
206
|
+
return _rules_command(args)
|
|
207
|
+
parser.print_help()
|
|
208
|
+
return 0
|
|
209
|
+
except (UnsafePathError, ValueError, FileExistsError, PermissionError) as error:
|
|
210
|
+
print(f"devtidy: error: {error}", file=sys.stderr)
|
|
211
|
+
return 1
|
|
212
|
+
|
devtidy/models.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Validator = Callable[[Path], bool]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Rule:
|
|
13
|
+
name: str
|
|
14
|
+
category: str
|
|
15
|
+
directory_names: tuple[str, ...]
|
|
16
|
+
description: str
|
|
17
|
+
validator: Validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Candidate:
|
|
22
|
+
path: Path
|
|
23
|
+
root: Path
|
|
24
|
+
rule: str
|
|
25
|
+
category: str
|
|
26
|
+
size: int
|
|
27
|
+
last_activity: float
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, object]:
|
|
30
|
+
result = asdict(self)
|
|
31
|
+
result["path"] = str(self.path)
|
|
32
|
+
result["root"] = str(self.root)
|
|
33
|
+
return result
|
|
34
|
+
|
devtidy/rules.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devtidy.models import Rule
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
NODE_MARKERS = ("package.json",)
|
|
9
|
+
PYTHON_MARKERS = ("pyproject.toml", "setup.py", "setup.cfg", "requirements.txt")
|
|
10
|
+
PROJECT_MARKERS = NODE_MARKERS + PYTHON_MARKERS + (
|
|
11
|
+
".git",
|
|
12
|
+
"Cargo.toml",
|
|
13
|
+
"go.mod",
|
|
14
|
+
"pom.xml",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _has_parent_marker(path: Path, markers: tuple[str, ...]) -> bool:
|
|
19
|
+
return any((path.parent / marker).exists() for marker in markers)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _node_project(path: Path) -> bool:
|
|
23
|
+
return _has_parent_marker(path, NODE_MARKERS)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _python_venv(path: Path) -> bool:
|
|
27
|
+
return (path / "pyvenv.cfg").is_file()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _python_project(path: Path) -> bool:
|
|
31
|
+
return _has_parent_marker(path, PYTHON_MARKERS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _known_project(path: Path) -> bool:
|
|
35
|
+
return _has_parent_marker(path, PROJECT_MARKERS)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _always(_: Path) -> bool:
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
RULES: tuple[Rule, ...] = (
|
|
43
|
+
Rule(
|
|
44
|
+
"node_modules",
|
|
45
|
+
"node",
|
|
46
|
+
("node_modules",),
|
|
47
|
+
"Installed Node.js dependencies in a package.json project",
|
|
48
|
+
_node_project,
|
|
49
|
+
),
|
|
50
|
+
Rule(
|
|
51
|
+
"python_venv",
|
|
52
|
+
"python",
|
|
53
|
+
(".venv", "venv", "env"),
|
|
54
|
+
"Python virtual environment containing pyvenv.cfg",
|
|
55
|
+
_python_venv,
|
|
56
|
+
),
|
|
57
|
+
Rule(
|
|
58
|
+
"python_cache",
|
|
59
|
+
"cache",
|
|
60
|
+
("__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"),
|
|
61
|
+
"Regenerable Python tooling cache",
|
|
62
|
+
_always,
|
|
63
|
+
),
|
|
64
|
+
Rule(
|
|
65
|
+
"tox_nox",
|
|
66
|
+
"python",
|
|
67
|
+
(".tox", ".nox"),
|
|
68
|
+
"Disposable test environments inside a Python project",
|
|
69
|
+
_python_project,
|
|
70
|
+
),
|
|
71
|
+
Rule(
|
|
72
|
+
"web_build",
|
|
73
|
+
"build",
|
|
74
|
+
(".next", ".nuxt", ".svelte-kit", ".parcel-cache"),
|
|
75
|
+
"Regenerable web framework output in a Node.js project",
|
|
76
|
+
_node_project,
|
|
77
|
+
),
|
|
78
|
+
Rule(
|
|
79
|
+
"project_build",
|
|
80
|
+
"build",
|
|
81
|
+
("build", "dist", "coverage", "htmlcov"),
|
|
82
|
+
"Build or coverage output inside a recognized project",
|
|
83
|
+
_known_project,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
RULES_BY_DIRECTORY = {
|
|
89
|
+
directory_name: rule
|
|
90
|
+
for rule in RULES
|
|
91
|
+
for directory_name in rule.directory_names
|
|
92
|
+
}
|
|
93
|
+
|
devtidy/safety.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UnsafePathError(ValueError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolved(path: Path) -> Path:
|
|
12
|
+
return path.expanduser().resolve()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_relative_to(path: Path, parent: Path) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
path.relative_to(parent)
|
|
18
|
+
return True
|
|
19
|
+
except ValueError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def protected_paths() -> set[Path]:
|
|
24
|
+
paths = {resolved(Path.home())}
|
|
25
|
+
anchor = Path(Path.cwd().anchor)
|
|
26
|
+
if anchor:
|
|
27
|
+
paths.add(resolved(anchor))
|
|
28
|
+
|
|
29
|
+
if os.name == "nt":
|
|
30
|
+
for variable in ("WINDIR", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"):
|
|
31
|
+
value = os.environ.get(variable)
|
|
32
|
+
if value:
|
|
33
|
+
paths.add(resolved(Path(value)))
|
|
34
|
+
else:
|
|
35
|
+
paths.update(resolved(Path(path)) for path in ("/boot", "/etc", "/usr", "/var"))
|
|
36
|
+
if Path("/System").exists():
|
|
37
|
+
paths.add(resolved(Path("/System")))
|
|
38
|
+
return paths
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_scan_root(path: Path) -> Path:
|
|
42
|
+
root = resolved(path)
|
|
43
|
+
if not root.exists():
|
|
44
|
+
raise UnsafePathError(f"scan root does not exist: {root}")
|
|
45
|
+
if not root.is_dir():
|
|
46
|
+
raise UnsafePathError(f"scan root is not a directory: {root}")
|
|
47
|
+
if root in protected_paths():
|
|
48
|
+
raise UnsafePathError(f"refusing protected scan root: {root}")
|
|
49
|
+
return root
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_target(path: Path, root: Path) -> Path:
|
|
53
|
+
target = resolved(path)
|
|
54
|
+
safe_root = resolved(root)
|
|
55
|
+
if target == safe_root or not is_relative_to(target, safe_root):
|
|
56
|
+
raise UnsafePathError(f"target escaped scan root: {target}")
|
|
57
|
+
if target in protected_paths():
|
|
58
|
+
raise UnsafePathError(f"refusing protected target: {target}")
|
|
59
|
+
return target
|
|
60
|
+
|
devtidy/scanner.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from devtidy.models import Candidate
|
|
10
|
+
from devtidy.rules import RULES_BY_DIRECTORY
|
|
11
|
+
from devtidy.safety import validate_scan_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SKIP_NAMES = {".git", ".hg", ".svn"}
|
|
15
|
+
ACTIVITY_IGNORES = {
|
|
16
|
+
".DS_Store",
|
|
17
|
+
"Thumbs.db",
|
|
18
|
+
"desktop.ini",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def directory_size(path: Path) -> int:
|
|
23
|
+
total = 0
|
|
24
|
+
stack = [path]
|
|
25
|
+
while stack:
|
|
26
|
+
current = stack.pop()
|
|
27
|
+
try:
|
|
28
|
+
with os.scandir(current) as entries:
|
|
29
|
+
for entry in entries:
|
|
30
|
+
try:
|
|
31
|
+
if entry.is_symlink():
|
|
32
|
+
continue
|
|
33
|
+
if entry.is_dir(follow_symlinks=False):
|
|
34
|
+
stack.append(Path(entry.path))
|
|
35
|
+
elif entry.is_file(follow_symlinks=False):
|
|
36
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
37
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
38
|
+
continue
|
|
39
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
40
|
+
continue
|
|
41
|
+
return total
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def project_activity(target: Path) -> float:
|
|
45
|
+
timestamps = [target.stat().st_mtime]
|
|
46
|
+
try:
|
|
47
|
+
with os.scandir(target.parent) as entries:
|
|
48
|
+
for entry in entries:
|
|
49
|
+
if entry.name == target.name or entry.name in ACTIVITY_IGNORES:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
timestamps.append(entry.stat(follow_symlinks=False).st_mtime)
|
|
53
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
54
|
+
continue
|
|
55
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
56
|
+
pass
|
|
57
|
+
return max(timestamps)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _excluded(path: Path, root: Path, patterns: Iterable[str]) -> bool:
|
|
61
|
+
relative = path.relative_to(root).as_posix()
|
|
62
|
+
return any(
|
|
63
|
+
fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(path.name, pattern)
|
|
64
|
+
for pattern in patterns
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scan(
|
|
69
|
+
roots: Iterable[Path],
|
|
70
|
+
*,
|
|
71
|
+
older_than_seconds: float = 0,
|
|
72
|
+
min_size: int = 0,
|
|
73
|
+
categories: set[str] | None = None,
|
|
74
|
+
exclusions: tuple[str, ...] = (),
|
|
75
|
+
max_depth: int | None = None,
|
|
76
|
+
) -> list[Candidate]:
|
|
77
|
+
candidates: list[Candidate] = []
|
|
78
|
+
cutoff = time.time() - older_than_seconds
|
|
79
|
+
|
|
80
|
+
for supplied_root in roots:
|
|
81
|
+
root = validate_scan_root(supplied_root)
|
|
82
|
+
for current, directories, _ in os.walk(root, topdown=True, followlinks=False):
|
|
83
|
+
current_path = Path(current)
|
|
84
|
+
depth = len(current_path.relative_to(root).parts)
|
|
85
|
+
directories[:] = [
|
|
86
|
+
name
|
|
87
|
+
for name in directories
|
|
88
|
+
if name not in SKIP_NAMES
|
|
89
|
+
and not (current_path / name).is_symlink()
|
|
90
|
+
and not _excluded(current_path / name, root, exclusions)
|
|
91
|
+
]
|
|
92
|
+
if max_depth is not None and depth >= max_depth:
|
|
93
|
+
directories[:] = []
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
matched_names: list[str] = []
|
|
97
|
+
for name in directories:
|
|
98
|
+
rule = RULES_BY_DIRECTORY.get(name)
|
|
99
|
+
if rule is None or (categories and rule.category not in categories):
|
|
100
|
+
continue
|
|
101
|
+
path = current_path / name
|
|
102
|
+
if not rule.validator(path):
|
|
103
|
+
continue
|
|
104
|
+
activity = project_activity(path)
|
|
105
|
+
if older_than_seconds and activity > cutoff:
|
|
106
|
+
continue
|
|
107
|
+
size = directory_size(path)
|
|
108
|
+
if size < min_size:
|
|
109
|
+
continue
|
|
110
|
+
candidates.append(
|
|
111
|
+
Candidate(path, root, rule.name, rule.category, size, activity)
|
|
112
|
+
)
|
|
113
|
+
matched_names.append(name)
|
|
114
|
+
|
|
115
|
+
# A matched artifact owns its subtree; nested caches are not separate savings.
|
|
116
|
+
directories[:] = [name for name in directories if name not in matched_names]
|
|
117
|
+
|
|
118
|
+
return sorted(candidates, key=lambda candidate: candidate.size, reverse=True)
|
|
119
|
+
|
devtidy/storage.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devtidy.models import Candidate
|
|
10
|
+
from devtidy.safety import is_relative_to, resolved, validate_target
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def state_dir() -> Path:
|
|
14
|
+
return Path.home() / ".devtidy"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def archive_root() -> Path:
|
|
18
|
+
return state_dir() / "archives"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def history_file() -> Path:
|
|
22
|
+
return state_dir() / "history.jsonl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _record_history(record: dict[str, object]) -> None:
|
|
26
|
+
path = history_file()
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with path.open("a", encoding="utf-8") as stream:
|
|
29
|
+
stream.write(json.dumps(record, sort_keys=True) + "\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def archive(candidates: list[Candidate]) -> dict[str, object]:
|
|
33
|
+
session_id = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8]
|
|
34
|
+
session_dir = archive_root() / session_id
|
|
35
|
+
items_dir = session_dir / "items"
|
|
36
|
+
items_dir.mkdir(parents=True)
|
|
37
|
+
items: list[dict[str, object]] = []
|
|
38
|
+
|
|
39
|
+
for index, candidate in enumerate(candidates):
|
|
40
|
+
source = validate_target(candidate.path, candidate.root)
|
|
41
|
+
destination = items_dir / f"{index:04d}-{source.name}"
|
|
42
|
+
shutil.move(str(source), str(destination))
|
|
43
|
+
items.append(
|
|
44
|
+
{
|
|
45
|
+
**candidate.to_dict(),
|
|
46
|
+
"archived_path": str(destination),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
manifest: dict[str, object] = {
|
|
51
|
+
"session_id": session_id,
|
|
52
|
+
"created_at": time.time(),
|
|
53
|
+
"action": "archive",
|
|
54
|
+
"total_size": sum(candidate.size for candidate in candidates),
|
|
55
|
+
"items": items,
|
|
56
|
+
}
|
|
57
|
+
(session_dir / "manifest.json").write_text(
|
|
58
|
+
json.dumps(manifest, indent=2, sort_keys=True),
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
)
|
|
61
|
+
_record_history(manifest)
|
|
62
|
+
return manifest
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def delete(candidates: list[Candidate]) -> dict[str, object]:
|
|
66
|
+
removed: list[dict[str, object]] = []
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
target = validate_target(candidate.path, candidate.root)
|
|
69
|
+
if target.is_symlink():
|
|
70
|
+
target.unlink()
|
|
71
|
+
else:
|
|
72
|
+
shutil.rmtree(target)
|
|
73
|
+
removed.append(candidate.to_dict())
|
|
74
|
+
|
|
75
|
+
record: dict[str, object] = {
|
|
76
|
+
"session_id": time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8],
|
|
77
|
+
"created_at": time.time(),
|
|
78
|
+
"action": "delete",
|
|
79
|
+
"total_size": sum(candidate.size for candidate in candidates),
|
|
80
|
+
"items": removed,
|
|
81
|
+
}
|
|
82
|
+
_record_history(record)
|
|
83
|
+
return record
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_sessions() -> list[Path]:
|
|
87
|
+
root = archive_root()
|
|
88
|
+
if not root.exists():
|
|
89
|
+
return []
|
|
90
|
+
return sorted(
|
|
91
|
+
(path for path in root.iterdir() if (path / "manifest.json").is_file()),
|
|
92
|
+
key=lambda path: path.name,
|
|
93
|
+
reverse=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def restore(session_id: str | None, *, latest: bool, overwrite: bool) -> dict[str, object]:
|
|
98
|
+
sessions = list_sessions()
|
|
99
|
+
if latest:
|
|
100
|
+
if not sessions:
|
|
101
|
+
raise ValueError("no archive sessions found")
|
|
102
|
+
session = sessions[0]
|
|
103
|
+
elif session_id:
|
|
104
|
+
session = archive_root() / session_id
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError("provide a session ID or --latest")
|
|
107
|
+
|
|
108
|
+
manifest_path = session / "manifest.json"
|
|
109
|
+
if not manifest_path.is_file():
|
|
110
|
+
raise ValueError(f"archive session not found: {session.name}")
|
|
111
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
112
|
+
|
|
113
|
+
restored: list[str] = []
|
|
114
|
+
for item in reversed(manifest["items"]):
|
|
115
|
+
archived_path = resolved(Path(item["archived_path"]))
|
|
116
|
+
original_path = resolved(Path(item["path"]))
|
|
117
|
+
original_root = resolved(Path(item["root"]))
|
|
118
|
+
if not is_relative_to(archived_path, resolved(session / "items")):
|
|
119
|
+
raise ValueError(f"invalid archived path in manifest: {archived_path}")
|
|
120
|
+
if original_path == original_root or not is_relative_to(original_path, original_root):
|
|
121
|
+
raise ValueError(f"invalid original path in manifest: {original_path}")
|
|
122
|
+
if not archived_path.exists():
|
|
123
|
+
continue
|
|
124
|
+
if original_path.exists():
|
|
125
|
+
if not overwrite:
|
|
126
|
+
raise FileExistsError(f"restore target already exists: {original_path}")
|
|
127
|
+
if original_path.is_dir() and not original_path.is_symlink():
|
|
128
|
+
shutil.rmtree(original_path)
|
|
129
|
+
else:
|
|
130
|
+
original_path.unlink()
|
|
131
|
+
original_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
shutil.move(str(archived_path), str(original_path))
|
|
133
|
+
restored.append(str(original_path))
|
|
134
|
+
|
|
135
|
+
record: dict[str, object] = {
|
|
136
|
+
"session_id": manifest["session_id"],
|
|
137
|
+
"created_at": time.time(),
|
|
138
|
+
"action": "restore",
|
|
139
|
+
"total_size": manifest["total_size"],
|
|
140
|
+
"items": restored,
|
|
141
|
+
}
|
|
142
|
+
_record_history(record)
|
|
143
|
+
return record
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_history(limit: int) -> list[dict[str, object]]:
|
|
147
|
+
path = history_file()
|
|
148
|
+
if not path.exists():
|
|
149
|
+
return []
|
|
150
|
+
records = [
|
|
151
|
+
json.loads(line)
|
|
152
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
153
|
+
if line.strip()
|
|
154
|
+
]
|
|
155
|
+
return records[-limit:][::-1]
|
devtidy/units.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SIZE_UNITS = {
|
|
7
|
+
"B": 1,
|
|
8
|
+
"KB": 1000,
|
|
9
|
+
"MB": 1000**2,
|
|
10
|
+
"GB": 1000**3,
|
|
11
|
+
"TB": 1000**4,
|
|
12
|
+
"KIB": 1024,
|
|
13
|
+
"MIB": 1024**2,
|
|
14
|
+
"GIB": 1024**3,
|
|
15
|
+
"TIB": 1024**4,
|
|
16
|
+
}
|
|
17
|
+
TIME_UNITS = {
|
|
18
|
+
"H": 3600,
|
|
19
|
+
"D": 86400,
|
|
20
|
+
"W": 604800,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_size(value: str) -> int:
|
|
25
|
+
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([kmgt]?i?b)?\s*", value, re.I)
|
|
26
|
+
if not match:
|
|
27
|
+
raise ValueError(f"invalid size: {value}")
|
|
28
|
+
number, unit = match.groups()
|
|
29
|
+
normalized = (unit or "B").upper()
|
|
30
|
+
if normalized not in SIZE_UNITS:
|
|
31
|
+
raise ValueError(f"unsupported size unit: {unit}")
|
|
32
|
+
return int(float(number) * SIZE_UNITS[normalized])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_duration(value: str) -> float:
|
|
36
|
+
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([hdw])\s*", value, re.I)
|
|
37
|
+
if not match:
|
|
38
|
+
raise ValueError(f"invalid duration: {value}; use values such as 24h, 30d, or 8w")
|
|
39
|
+
number, unit = match.groups()
|
|
40
|
+
return float(number) * TIME_UNITS[unit.upper()]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def human_size(value: int) -> str:
|
|
44
|
+
amount = float(value)
|
|
45
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
46
|
+
if amount < 1024 or unit == "TiB":
|
|
47
|
+
return f"{amount:.1f} {unit}"
|
|
48
|
+
amount /= 1024
|
|
49
|
+
return f"{amount:.1f} TiB"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devtidy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safely find and reclaim stale developer build artifacts and environments.
|
|
5
|
+
Author: Harish
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/harish-ai-engineer/devtidy
|
|
8
|
+
Project-URL: Repository, https://github.com/harish-ai-engineer/devtidy.git
|
|
9
|
+
Project-URL: Issues, https://github.com/harish-ai-engineer/devtidy/issues
|
|
10
|
+
Keywords: cleanup,developer-tools,disk-space,node-modules,virtualenv
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# DevTidy
|
|
28
|
+
|
|
29
|
+
DevTidy is a conservative, cross-platform command-line tool for finding stale
|
|
30
|
+
developer artifacts such as `node_modules`, Python virtual environments, build
|
|
31
|
+
outputs, and tool caches.
|
|
32
|
+
|
|
33
|
+
It is an original implementation built around a simple rule: scanning should be
|
|
34
|
+
easy, but removing data should require an explicit decision.
|
|
35
|
+
|
|
36
|
+
## Highlights
|
|
37
|
+
|
|
38
|
+
- Dry-run scanning is the default.
|
|
39
|
+
- Recognizes project context before flagging risky directories.
|
|
40
|
+
- Refuses to operate on protected system and home-directory roots.
|
|
41
|
+
- Never follows directory symlinks.
|
|
42
|
+
- Supports age, size, category, and exclusion filters.
|
|
43
|
+
- Can archive files with a manifest and restore them later.
|
|
44
|
+
- Produces machine-readable JSON for scripts and CI.
|
|
45
|
+
- Stores cleanup history locally. No telemetry or network calls.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```console
|
|
50
|
+
pipx install devtidy
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For local development:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
python -m pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
# Scan the current directory (never deletes)
|
|
63
|
+
devtidy
|
|
64
|
+
|
|
65
|
+
# Scan selected project folders
|
|
66
|
+
devtidy scan ~/Projects ~/work --older-than 30d --min-size 100MB
|
|
67
|
+
|
|
68
|
+
# Archive matches so they can be restored
|
|
69
|
+
devtidy clean ~/Projects --older-than 60d --archive --yes
|
|
70
|
+
|
|
71
|
+
# Permanently delete matches
|
|
72
|
+
devtidy clean ~/Projects --older-than 90d --delete --yes
|
|
73
|
+
|
|
74
|
+
# Restore the most recent archive session
|
|
75
|
+
devtidy restore --latest
|
|
76
|
+
|
|
77
|
+
# JSON output for automation
|
|
78
|
+
devtidy scan . --json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
### `scan`
|
|
84
|
+
|
|
85
|
+
Find candidates without changing the filesystem. This is also the default
|
|
86
|
+
command when no command is supplied.
|
|
87
|
+
|
|
88
|
+
Useful options:
|
|
89
|
+
|
|
90
|
+
- `--older-than 30d`: only include projects inactive for the given duration.
|
|
91
|
+
- `--min-size 100MB`: only include artifacts at least this large.
|
|
92
|
+
- `--category node,python,cache,build`: select rule categories.
|
|
93
|
+
- `--exclude PATTERN`: skip matching paths; may be repeated.
|
|
94
|
+
- `--max-depth N`: limit traversal depth.
|
|
95
|
+
- `--json`: return structured output.
|
|
96
|
+
|
|
97
|
+
### `clean`
|
|
98
|
+
|
|
99
|
+
Uses the same filters as `scan`. Exactly one of `--archive` or `--delete` is
|
|
100
|
+
required. `--yes` is mandatory for non-interactive execution.
|
|
101
|
+
|
|
102
|
+
Archives live in `~/.devtidy/archives` by default. Each session contains a JSON
|
|
103
|
+
manifest recording original paths, sizes, and timestamps.
|
|
104
|
+
|
|
105
|
+
### `restore`
|
|
106
|
+
|
|
107
|
+
Restore an archive by session ID, or use `--latest`. DevTidy will not overwrite
|
|
108
|
+
an existing path unless `--overwrite` is supplied.
|
|
109
|
+
|
|
110
|
+
### `history` and `rules`
|
|
111
|
+
|
|
112
|
+
`history` shows local cleanup sessions. `rules` explains every built-in match
|
|
113
|
+
and the project evidence required for it.
|
|
114
|
+
|
|
115
|
+
## Built-in safety
|
|
116
|
+
|
|
117
|
+
DevTidy refuses filesystem roots, user home directories, and common operating
|
|
118
|
+
system directories as scan roots. It also checks that every cleanup target is
|
|
119
|
+
inside an approved root immediately before acting, which helps protect against
|
|
120
|
+
path changes between scanning and cleanup.
|
|
121
|
+
|
|
122
|
+
Virtual environments must contain `pyvenv.cfg`. A `node_modules` directory must
|
|
123
|
+
belong to a project containing `package.json`. Build directories are only
|
|
124
|
+
matched inside recognized projects. Generic folders named `env`, `build`, or
|
|
125
|
+
`dist` are therefore not removed merely because their name looks familiar.
|
|
126
|
+
|
|
127
|
+
## Name ideas considered
|
|
128
|
+
|
|
129
|
+
- **DevTidy**: clear, friendly, and broad enough for future cleanup rules.
|
|
130
|
+
- **RepoRinse**: memorable, but sounds limited to repositories.
|
|
131
|
+
- **DevSweep**: direct, though more generic.
|
|
132
|
+
- **ByteBroom**: playful, but too close to the inspiration's branding.
|
|
133
|
+
- **ProjectPrune**: descriptive, but may imply source-code deletion.
|
|
134
|
+
|
|
135
|
+
Project source and issue tracking are available at
|
|
136
|
+
<https://github.com/harish-ai-engineer/devtidy>.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
devtidy/__init__.py,sha256=Z4vuUBnVF1p9sjnuw6TSoR3_Nqbz2LsHQQkw34OnaYM,64
|
|
2
|
+
devtidy/__main__.py,sha256=oB9sGihtkKuwRy1YafgI1TkCQCuE-MRqGl3nGcEiTZs,56
|
|
3
|
+
devtidy/cli.py,sha256=M9k9r3DG7w9z1Q0ADxAaRpavmtAqUsBU9iewEY6My_c,7386
|
|
4
|
+
devtidy/models.py,sha256=fcI395AgFDDmjOyjOXIT_ISAlki7-NwGC5Oy39GmGYQ,643
|
|
5
|
+
devtidy/rules.py,sha256=eIvKdQ57aRgP-D9YfUst-uJzpQLl7VX2ANal1YsSG3A,2140
|
|
6
|
+
devtidy/safety.py,sha256=dkTnmk65dHbEDUMKBPMkercvh7YQ0K9COC9fiSn8_bo,1707
|
|
7
|
+
devtidy/scanner.py,sha256=wAT3WEkTym7R1zprPCxMjfJOiEUmauTwKm2964Q4QQo,4079
|
|
8
|
+
devtidy/storage.py,sha256=j4JMzgL30AsdOb8sczSs04RuZqg4Tc0wtCF6hPQP4l8,5053
|
|
9
|
+
devtidy/units.py,sha256=lOSZef2GLsjDK0Hml-DdKB5w0_fDhPH0hZOoKFfda7Y,1267
|
|
10
|
+
devtidy-0.1.0.dist-info/licenses/LICENSE,sha256=Mss4ldA6DvGYUV7jhNEl9t2Mpennj17PGSknGEOW-MI,1078
|
|
11
|
+
devtidy-0.1.0.dist-info/METADATA,sha256=GX8N_XRk2Ae3m-0zu0cOxBDkzplxX_1cfdQ4FDoXB_I,4775
|
|
12
|
+
devtidy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
devtidy-0.1.0.dist-info/entry_points.txt,sha256=TJAHwZUwaFS1ZpSK2CTsYwGSl_qAARkQ8WXEYQs5wiY,45
|
|
14
|
+
devtidy-0.1.0.dist-info/top_level.txt,sha256=WdMDu2bhqgO5u38O5pzzWoOzoonLJXisSFxP9PYW3dk,8
|
|
15
|
+
devtidy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevTidy contributors
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devtidy
|