devbits 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.
devbits/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """devbits: A lightweight CLI toolkit for daily development utilities."""
2
+
3
+ __version__ = "0.1.0"
devbits/cache.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ CACHE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
7
+ PYC_PATTERNS = ["*.pyc", "*.pyo"]
8
+
9
+
10
+ def clear_cache(root: Path, include_extra: bool = False, dry_run: bool = False) -> list[Path]:
11
+ targets: list[Path] = []
12
+ dir_names = set(CACHE_DIR_NAMES if include_extra else {"__pycache__"})
13
+
14
+ for path in root.rglob("*"):
15
+ if path.is_dir() and path.name in dir_names:
16
+ targets.append(path)
17
+
18
+ for pattern in PYC_PATTERNS:
19
+ targets.extend(root.rglob(pattern))
20
+
21
+ unique_targets = sorted(set(targets), key=lambda p: len(p.parts), reverse=True)
22
+ if dry_run:
23
+ return unique_targets
24
+
25
+ for target in unique_targets:
26
+ if not target.exists():
27
+ continue
28
+ if target.is_dir():
29
+ shutil.rmtree(target)
30
+ else:
31
+ target.unlink()
32
+ return unique_targets
devbits/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .cache import clear_cache
8
+ from .image import batch_images, check_images, contact_sheet, image_to_ico, resize_image
9
+ from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
10
+ from .project import print_tree, rename_files, sample_files, top_sizes
11
+ from .utils import ensure_exists
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(prog="devbits", description="Daily development utility CLI toolkit.")
16
+ parser.add_argument("--version", action="version", version="devbits 0.1.0")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ p = sub.add_parser("clearcache", help="Clear Python cache files under a folder.")
20
+ p.add_argument("folder", type=Path)
21
+ p.add_argument("--all", action="store_true", help="Also remove pytest, mypy, and ruff caches.")
22
+ p.add_argument("--dry-run", action="store_true")
23
+ p.set_defaults(func=cmd_clearcache)
24
+
25
+ p = sub.add_parser("images2video", help="Convert image sequence to MP4 video.")
26
+ p.add_argument("folder", type=Path)
27
+ p.add_argument("-o", "--output", type=Path, default=Path("output.mp4"))
28
+ p.add_argument("--fps", type=float, default=30.0)
29
+ p.add_argument("--pattern", default="*")
30
+ p.set_defaults(func=cmd_images2video)
31
+
32
+ p = sub.add_parser("video2images", help="Extract frames from a video.")
33
+ p.add_argument("video", type=Path)
34
+ p.add_argument("-o", "--output", type=Path, default=Path("frames"))
35
+ p.add_argument("--every", type=int, default=1)
36
+ p.add_argument("--prefix", default="frame")
37
+ p.add_argument("--digits", type=int, default=6)
38
+ p.add_argument("--format", default="jpg")
39
+ p.set_defaults(func=cmd_video2images)
40
+
41
+ p = sub.add_parser("images2gif", help="Convert image sequence to GIF.")
42
+ p.add_argument("folder", type=Path)
43
+ p.add_argument("-o", "--output", type=Path, default=Path("output.gif"))
44
+ p.add_argument("--fps", type=float, default=10.0)
45
+ p.add_argument("--pattern", default="*")
46
+ p.set_defaults(func=cmd_images2gif)
47
+
48
+ p = sub.add_parser("video2gif", help="Convert video to GIF.")
49
+ p.add_argument("video", type=Path)
50
+ p.add_argument("-o", "--output", type=Path, default=Path("output.gif"))
51
+ p.add_argument("--fps", type=float, default=10.0)
52
+ p.add_argument("--start", type=float)
53
+ p.add_argument("--end", type=float)
54
+ p.add_argument("--size", help="Output size, e.g. 640,360")
55
+ p.set_defaults(func=cmd_video2gif)
56
+
57
+ p = sub.add_parser("clipvideo", help="Clip video by seconds or frame indices.")
58
+ p.add_argument("video", type=Path)
59
+ p.add_argument("-o", "--output", type=Path, default=Path("clip.mp4"))
60
+ p.add_argument("--start", type=float)
61
+ p.add_argument("--end", type=float)
62
+ p.add_argument("--start-frame", type=int)
63
+ p.add_argument("--end-frame", type=int)
64
+ p.add_argument("--gui", action="store_true", help="Reserved for future GUI clip selection.")
65
+ p.set_defaults(func=cmd_clipvideo)
66
+
67
+ p = sub.add_parser("resizevideo", help="Resize a video.")
68
+ p.add_argument("video", type=Path)
69
+ p.add_argument("-o", "--output", type=Path, default=Path("resized.mp4"))
70
+ p.add_argument("--size", required=True, help="Output size, e.g. 1280,720")
71
+ p.set_defaults(func=cmd_resizevideo)
72
+
73
+ p = sub.add_parser("image2ico", help="Convert an image to ICO.")
74
+ p.add_argument("image", type=Path)
75
+ p.add_argument("-o", "--output", type=Path, default=Path("image.ico"))
76
+ p.add_argument("--sizes", default="16,32,48,64,128,256")
77
+ p.set_defaults(func=cmd_image2ico)
78
+
79
+ p = sub.add_parser("resizeimage", help="Resize an image.")
80
+ p.add_argument("image", type=Path)
81
+ p.add_argument("-o", "--output", type=Path, default=Path("resized.jpg"))
82
+ p.add_argument("--size", required=True)
83
+ p.add_argument("--no-keep-ratio", action="store_true")
84
+ p.set_defaults(func=cmd_resizeimage)
85
+
86
+ p = sub.add_parser("batchimages", help="Batch resize or convert images.")
87
+ p.add_argument("folder", type=Path)
88
+ p.add_argument("-o", "--output", type=Path, required=True)
89
+ p.add_argument("--size")
90
+ p.add_argument("--format")
91
+ p.set_defaults(func=cmd_batchimages)
92
+
93
+ p = sub.add_parser("checkimages", help="Check broken image files.")
94
+ p.add_argument("folder", type=Path)
95
+ p.add_argument("--recursive", action="store_true")
96
+ p.add_argument("--remove-broken", action="store_true")
97
+ p.set_defaults(func=cmd_checkimages)
98
+
99
+ p = sub.add_parser("contactsheet", help="Create a contact sheet from images.")
100
+ p.add_argument("folder", type=Path)
101
+ p.add_argument("-o", "--output", type=Path, default=Path("sheet.jpg"))
102
+ p.add_argument("--cols", type=int, default=5)
103
+ p.add_argument("--thumb-size", default="256,256")
104
+ p.add_argument("--labels", action="store_true")
105
+ p.set_defaults(func=cmd_contactsheet)
106
+
107
+ p = sub.add_parser("tree", help="Print project tree.")
108
+ p.add_argument("folder", type=Path, nargs="?", default=Path("."))
109
+ p.add_argument("--depth", type=int, default=3)
110
+ p.set_defaults(func=cmd_tree)
111
+
112
+ p = sub.add_parser("size", help="Show top-level folder sizes.")
113
+ p.add_argument("folder", type=Path, nargs="?", default=Path("."))
114
+ p.add_argument("--top", type=int, default=20)
115
+ p.set_defaults(func=cmd_size)
116
+
117
+ p = sub.add_parser("renamefiles", help="Batch rename files in a folder.")
118
+ p.add_argument("folder", type=Path)
119
+ p.add_argument("--prefix", default="file")
120
+ p.add_argument("--digits", type=int, default=6)
121
+ p.add_argument("--start", type=int, default=1)
122
+ p.add_argument("--dry-run", action="store_true")
123
+ p.set_defaults(func=cmd_renamefiles)
124
+
125
+ p = sub.add_parser("samplefiles", help="Copy first N files into another folder.")
126
+ p.add_argument("folder", type=Path)
127
+ p.add_argument("-o", "--output", type=Path, required=True)
128
+ p.add_argument("--num", type=int, required=True)
129
+ p.add_argument("--move", action="store_true")
130
+ p.set_defaults(func=cmd_samplefiles)
131
+ return parser
132
+
133
+
134
+ def cmd_clearcache(args: argparse.Namespace) -> None:
135
+ targets = clear_cache(ensure_exists(args.folder), include_extra=args.all, dry_run=args.dry_run)
136
+ for target in targets:
137
+ print(target)
138
+ print(f"{'Found' if args.dry_run else 'Removed'} {len(targets)} cache item(s).")
139
+
140
+
141
+ def cmd_images2video(args: argparse.Namespace) -> None:
142
+ print(images_to_video(ensure_exists(args.folder), args.output, args.fps, args.pattern))
143
+
144
+
145
+ def cmd_video2images(args: argparse.Namespace) -> None:
146
+ outputs = video_to_images(ensure_exists(args.video), args.output, args.every, args.prefix, args.digits, args.format)
147
+ print(f"Saved {len(outputs)} frame(s) to {args.output}")
148
+
149
+
150
+ def cmd_images2gif(args: argparse.Namespace) -> None:
151
+ print(images_to_gif(ensure_exists(args.folder), args.output, args.fps, args.pattern))
152
+
153
+
154
+ def cmd_video2gif(args: argparse.Namespace) -> None:
155
+ print(video_to_gif(ensure_exists(args.video), args.output, args.fps, args.start, args.end, args.size))
156
+
157
+
158
+ def cmd_clipvideo(args: argparse.Namespace) -> None:
159
+ if args.gui:
160
+ print("Warning: --gui is reserved for a future interactive clip selector; using CLI options now.")
161
+ print(clip_video(ensure_exists(args.video), args.output, args.start, args.end, args.start_frame, args.end_frame))
162
+
163
+
164
+ def cmd_resizevideo(args: argparse.Namespace) -> None:
165
+ print(resize_video(ensure_exists(args.video), args.output, args.size))
166
+
167
+
168
+ def cmd_image2ico(args: argparse.Namespace) -> None:
169
+ print(image_to_ico(ensure_exists(args.image), args.output, args.sizes))
170
+
171
+
172
+ def cmd_resizeimage(args: argparse.Namespace) -> None:
173
+ print(resize_image(ensure_exists(args.image), args.output, args.size, not args.no_keep_ratio))
174
+
175
+
176
+ def cmd_batchimages(args: argparse.Namespace) -> None:
177
+ outputs = batch_images(ensure_exists(args.folder), args.output, args.size, args.format)
178
+ print(f"Saved {len(outputs)} image(s) to {args.output}")
179
+
180
+
181
+ def cmd_checkimages(args: argparse.Namespace) -> None:
182
+ broken = check_images(ensure_exists(args.folder), args.recursive, args.remove_broken)
183
+ if broken:
184
+ print("Broken images:")
185
+ for path in broken:
186
+ print(path)
187
+ print(f"Found {len(broken)} broken image(s).")
188
+
189
+
190
+ def cmd_contactsheet(args: argparse.Namespace) -> None:
191
+ print(contact_sheet(ensure_exists(args.folder), args.output, args.cols, args.thumb_size, labels=args.labels))
192
+
193
+
194
+ def cmd_tree(args: argparse.Namespace) -> None:
195
+ for line in print_tree(ensure_exists(args.folder), args.depth):
196
+ print(line)
197
+
198
+
199
+ def cmd_size(args: argparse.Namespace) -> None:
200
+ for path, _, size_text in top_sizes(ensure_exists(args.folder), args.top):
201
+ print(f"{size_text:>10} {path}")
202
+
203
+
204
+ def cmd_renamefiles(args: argparse.Namespace) -> None:
205
+ mappings = rename_files(ensure_exists(args.folder), args.prefix, args.digits, args.start, args.dry_run)
206
+ for old, new in mappings:
207
+ print(f"{old.name} -> {new.name}")
208
+ print(f"{'Planned' if args.dry_run else 'Renamed'} {len(mappings)} file(s).")
209
+
210
+
211
+ def cmd_samplefiles(args: argparse.Namespace) -> None:
212
+ outputs = sample_files(ensure_exists(args.folder), args.output, args.num, copy=not args.move)
213
+ print(f"Saved {len(outputs)} file(s) to {args.output}")
214
+
215
+
216
+ def main(argv: list[str] | None = None) -> int:
217
+ parser = build_parser()
218
+ args = parser.parse_args(argv)
219
+ try:
220
+ args.func(args)
221
+ except Exception as exc:
222
+ print(f"Error: {exc}", file=sys.stderr)
223
+ return 1
224
+ return 0
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
devbits/image.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
6
+
7
+ from .utils import ensure_dir, list_images, parse_size
8
+
9
+
10
+ def image_to_ico(input_path: Path, output_path: Path, sizes: str = "16,32,48,64,128,256") -> Path:
11
+ output_path.parent.mkdir(parents=True, exist_ok=True)
12
+ size_values = [int(item.strip()) for item in sizes.split(",") if item.strip()]
13
+ ico_sizes = [(size, size) for size in size_values]
14
+ with Image.open(input_path) as image:
15
+ image = image.convert("RGBA")
16
+ image.save(output_path, format="ICO", sizes=ico_sizes)
17
+ return output_path
18
+
19
+
20
+ def contact_sheet(
21
+ folder: Path,
22
+ output_path: Path,
23
+ cols: int = 5,
24
+ thumb_size: str = "256,256",
25
+ pattern: str = "*",
26
+ labels: bool = False,
27
+ ) -> Path:
28
+ images = list_images(folder, pattern=pattern)
29
+ if not images:
30
+ raise ValueError(f"No images found in {folder}")
31
+
32
+ thumb_w, thumb_h = parse_size(thumb_size)
33
+ label_h = 24 if labels else 0
34
+ rows = (len(images) + cols - 1) // cols
35
+ sheet = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
36
+ draw = ImageDraw.Draw(sheet)
37
+
38
+ for index, image_path in enumerate(images):
39
+ row, col = divmod(index, cols)
40
+ x = col * thumb_w
41
+ y = row * (thumb_h + label_h)
42
+ try:
43
+ with Image.open(image_path) as image:
44
+ thumb = ImageOps.contain(image.convert("RGB"), (thumb_w, thumb_h))
45
+ except UnidentifiedImageError:
46
+ continue
47
+ paste_x = x + (thumb_w - thumb.width) // 2
48
+ paste_y = y + (thumb_h - thumb.height) // 2
49
+ sheet.paste(thumb, (paste_x, paste_y))
50
+ if labels:
51
+ draw.text((x + 4, y + thumb_h + 4), image_path.name[:40], fill="black")
52
+
53
+ output_path.parent.mkdir(parents=True, exist_ok=True)
54
+ sheet.save(output_path)
55
+ return output_path
56
+
57
+
58
+ def check_images(folder: Path, recursive: bool = False, remove_broken: bool = False) -> list[Path]:
59
+ broken: list[Path] = []
60
+ for image_path in list_images(folder, recursive=recursive):
61
+ try:
62
+ with Image.open(image_path) as image:
63
+ image.verify()
64
+ except Exception:
65
+ broken.append(image_path)
66
+ if remove_broken:
67
+ image_path.unlink(missing_ok=True)
68
+ return broken
69
+
70
+
71
+ def resize_image(input_path: Path, output_path: Path, size: str, keep_ratio: bool = True) -> Path:
72
+ output_path.parent.mkdir(parents=True, exist_ok=True)
73
+ target_size = parse_size(size)
74
+ with Image.open(input_path) as image:
75
+ image = image.convert("RGB")
76
+ if keep_ratio:
77
+ image = ImageOps.contain(image, target_size)
78
+ else:
79
+ image = image.resize(target_size)
80
+ image.save(output_path)
81
+ return output_path
82
+
83
+
84
+ def batch_images(folder: Path, output_folder: Path, size: str | None = None, fmt: str | None = None) -> list[Path]:
85
+ ensure_dir(output_folder)
86
+ outputs: list[Path] = []
87
+ for image_path in list_images(folder):
88
+ with Image.open(image_path) as image:
89
+ image = image.convert("RGB")
90
+ if size:
91
+ image = ImageOps.contain(image, parse_size(size))
92
+ suffix = f".{fmt.lower()}" if fmt else image_path.suffix
93
+ out = output_folder / f"{image_path.stem}{suffix}"
94
+ image.save(out)
95
+ outputs.append(out)
96
+ return outputs
devbits/media.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ from PIL import Image
7
+
8
+ from .utils import ensure_dir, list_images, parse_size
9
+
10
+
11
+ def images_to_video(folder: Path, output_path: Path, fps: float = 30.0, pattern: str = "*") -> Path:
12
+ images = list_images(folder, pattern=pattern)
13
+ if not images:
14
+ raise ValueError(f"No images found in {folder}")
15
+
16
+ first = cv2.imread(str(images[0]))
17
+ if first is None:
18
+ raise ValueError(f"Cannot read image: {images[0]}")
19
+ height, width = first.shape[:2]
20
+ output_path.parent.mkdir(parents=True, exist_ok=True)
21
+ writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
22
+ if not writer.isOpened():
23
+ raise RuntimeError(f"Cannot create video writer: {output_path}")
24
+
25
+ try:
26
+ for image_path in images:
27
+ frame = cv2.imread(str(image_path))
28
+ if frame is None:
29
+ continue
30
+ if frame.shape[:2] != (height, width):
31
+ frame = cv2.resize(frame, (width, height))
32
+ writer.write(frame)
33
+ finally:
34
+ writer.release()
35
+ return output_path
36
+
37
+
38
+ def video_to_images(
39
+ video_path: Path,
40
+ output_folder: Path,
41
+ every: int = 1,
42
+ prefix: str = "frame",
43
+ digits: int = 6,
44
+ fmt: str = "jpg",
45
+ ) -> list[Path]:
46
+ ensure_dir(output_folder)
47
+ cap = cv2.VideoCapture(str(video_path))
48
+ if not cap.isOpened():
49
+ raise RuntimeError(f"Cannot open video: {video_path}")
50
+
51
+ outputs: list[Path] = []
52
+ frame_index = 0
53
+ saved_index = 1
54
+ try:
55
+ while True:
56
+ ok, frame = cap.read()
57
+ if not ok:
58
+ break
59
+ if frame_index % every == 0:
60
+ output = output_folder / f"{prefix}_{saved_index:0{digits}d}.{fmt}"
61
+ cv2.imwrite(str(output), frame)
62
+ outputs.append(output)
63
+ saved_index += 1
64
+ frame_index += 1
65
+ finally:
66
+ cap.release()
67
+ return outputs
68
+
69
+
70
+ def images_to_gif(folder: Path, output_path: Path, fps: float = 10.0, pattern: str = "*") -> Path:
71
+ images = list_images(folder, pattern=pattern)
72
+ if not images:
73
+ raise ValueError(f"No images found in {folder}")
74
+ frames = [Image.open(path).convert("RGB") for path in images]
75
+ duration = int(1000 / fps)
76
+ output_path.parent.mkdir(parents=True, exist_ok=True)
77
+ frames[0].save(output_path, save_all=True, append_images=frames[1:], duration=duration, loop=0)
78
+ for frame in frames:
79
+ frame.close()
80
+ return output_path
81
+
82
+
83
+ def video_to_gif(
84
+ video_path: Path,
85
+ output_path: Path,
86
+ fps: float = 10.0,
87
+ start: float | None = None,
88
+ end: float | None = None,
89
+ size: str | None = None,
90
+ ) -> Path:
91
+ cap = cv2.VideoCapture(str(video_path))
92
+ if not cap.isOpened():
93
+ raise RuntimeError(f"Cannot open video: {video_path}")
94
+
95
+ source_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
96
+ start_frame = int(start * source_fps) if start is not None else 0
97
+ end_frame = int(end * source_fps) if end is not None else None
98
+ sample_every = max(1, int(round(source_fps / fps)))
99
+ target_size = parse_size(size) if size else None
100
+ frames: list[Image.Image] = []
101
+
102
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
103
+ frame_index = start_frame
104
+ try:
105
+ while True:
106
+ if end_frame is not None and frame_index > end_frame:
107
+ break
108
+ ok, frame = cap.read()
109
+ if not ok:
110
+ break
111
+ if (frame_index - start_frame) % sample_every == 0:
112
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
113
+ if target_size:
114
+ frame = cv2.resize(frame, target_size)
115
+ frames.append(Image.fromarray(frame))
116
+ frame_index += 1
117
+ finally:
118
+ cap.release()
119
+
120
+ if not frames:
121
+ raise ValueError("No frames were extracted for GIF")
122
+ output_path.parent.mkdir(parents=True, exist_ok=True)
123
+ frames[0].save(output_path, save_all=True, append_images=frames[1:], duration=int(1000 / fps), loop=0)
124
+ for frame in frames:
125
+ frame.close()
126
+ return output_path
127
+
128
+
129
+ def clip_video(
130
+ video_path: Path,
131
+ output_path: Path,
132
+ start: float | None = None,
133
+ end: float | None = None,
134
+ start_frame: int | None = None,
135
+ end_frame: int | None = None,
136
+ ) -> Path:
137
+ cap = cv2.VideoCapture(str(video_path))
138
+ if not cap.isOpened():
139
+ raise RuntimeError(f"Cannot open video: {video_path}")
140
+
141
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
142
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
143
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
144
+ s_frame = start_frame if start_frame is not None else int((start or 0) * fps)
145
+ e_frame = end_frame if end_frame is not None else (int(end * fps) if end is not None else None)
146
+
147
+ output_path.parent.mkdir(parents=True, exist_ok=True)
148
+ writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
149
+ if not writer.isOpened():
150
+ raise RuntimeError(f"Cannot create video writer: {output_path}")
151
+
152
+ cap.set(cv2.CAP_PROP_POS_FRAMES, s_frame)
153
+ frame_index = s_frame
154
+ try:
155
+ while True:
156
+ if e_frame is not None and frame_index > e_frame:
157
+ break
158
+ ok, frame = cap.read()
159
+ if not ok:
160
+ break
161
+ writer.write(frame)
162
+ frame_index += 1
163
+ finally:
164
+ cap.release()
165
+ writer.release()
166
+ return output_path
167
+
168
+
169
+ def resize_video(video_path: Path, output_path: Path, size: str) -> Path:
170
+ target_size = parse_size(size)
171
+ cap = cv2.VideoCapture(str(video_path))
172
+ if not cap.isOpened():
173
+ raise RuntimeError(f"Cannot open video: {video_path}")
174
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
175
+ output_path.parent.mkdir(parents=True, exist_ok=True)
176
+ writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, target_size)
177
+ try:
178
+ while True:
179
+ ok, frame = cap.read()
180
+ if not ok:
181
+ break
182
+ writer.write(cv2.resize(frame, target_size))
183
+ finally:
184
+ cap.release()
185
+ writer.release()
186
+ return output_path
devbits/project.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from .utils import ensure_dir, iter_project_tree, list_files, readable_size
7
+
8
+ DEFAULT_IGNORES = [".git", "__pycache__", ".venv", "venv", "node_modules", "build", "dist"]
9
+
10
+
11
+ def print_tree(root: Path, depth: int = 3, ignore: list[str] | None = None) -> list[str]:
12
+ return iter_project_tree(root, ignore or DEFAULT_IGNORES, depth)
13
+
14
+
15
+ def folder_size(path: Path) -> int:
16
+ if path.is_file():
17
+ return path.stat().st_size
18
+ total = 0
19
+ for item in path.rglob("*"):
20
+ if item.is_file():
21
+ total += item.stat().st_size
22
+ return total
23
+
24
+
25
+ def top_sizes(root: Path, top: int = 20) -> list[tuple[Path, int, str]]:
26
+ items = [(p, folder_size(p)) for p in root.iterdir()]
27
+ items.sort(key=lambda item: item[1], reverse=True)
28
+ return [(path, size, readable_size(size)) for path, size in items[:top]]
29
+
30
+
31
+ def rename_files(folder: Path, prefix: str = "file", digits: int = 6, start: int = 1, dry_run: bool = False) -> list[tuple[Path, Path]]:
32
+ files = list_files(folder)
33
+ mappings: list[tuple[Path, Path]] = []
34
+ for index, old_path in enumerate(files, start=start):
35
+ new_path = folder / f"{prefix}_{index:0{digits}d}{old_path.suffix.lower()}"
36
+ mappings.append((old_path, new_path))
37
+ if dry_run:
38
+ return mappings
39
+ temp_mappings: list[tuple[Path, Path]] = []
40
+ for index, (old_path, _) in enumerate(mappings):
41
+ temp_path = folder / f".__devbits_tmp_{index}{old_path.suffix}"
42
+ old_path.rename(temp_path)
43
+ temp_mappings.append((temp_path, mappings[index][1]))
44
+ for temp_path, new_path in temp_mappings:
45
+ temp_path.rename(new_path)
46
+ return mappings
47
+
48
+
49
+ def sample_files(folder: Path, output_folder: Path, num: int, copy: bool = True) -> list[Path]:
50
+ files = list_files(folder)
51
+ ensure_dir(output_folder)
52
+ selected = files[:num]
53
+ outputs: list[Path] = []
54
+ for source in selected:
55
+ dest = output_folder / source.name
56
+ if copy:
57
+ shutil.copy2(source, dest)
58
+ else:
59
+ shutil.move(str(source), str(dest))
60
+ outputs.append(dest)
61
+ return outputs
devbits/scripts.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Sequence
5
+
6
+ from .cli import main as devbits_main
7
+
8
+
9
+ def _run(command: str, argv: Sequence[str] | None = None) -> int:
10
+ args = list(sys.argv[1:] if argv is None else argv)
11
+ return devbits_main([command, *args])
12
+
13
+
14
+ def clearcache() -> int:
15
+ return _run("clearcache")
16
+
17
+
18
+ def images2video() -> int:
19
+ return _run("images2video")
20
+
21
+
22
+ def video2images() -> int:
23
+ return _run("video2images")
24
+
25
+
26
+ def images2gif() -> int:
27
+ return _run("images2gif")
28
+
29
+
30
+ def video2gif() -> int:
31
+ return _run("video2gif")
32
+
33
+
34
+ def clipvideo() -> int:
35
+ return _run("clipvideo")
36
+
37
+
38
+ def resizevideo() -> int:
39
+ return _run("resizevideo")
40
+
41
+
42
+ def image2ico() -> int:
43
+ return _run("image2ico")
44
+
45
+
46
+ def resizeimage() -> int:
47
+ return _run("resizeimage")
48
+
49
+
50
+ def batchimages() -> int:
51
+ return _run("batchimages")
52
+
53
+
54
+ def checkimages() -> int:
55
+ return _run("checkimages")
56
+
57
+
58
+ def contactsheet() -> int:
59
+ return _run("contactsheet")
60
+
61
+
62
+ def tree() -> int:
63
+ return _run("tree")
64
+
65
+
66
+ def size() -> int:
67
+ return _run("size")
68
+
69
+
70
+ def renamefiles() -> int:
71
+ return _run("renamefiles")
72
+
73
+
74
+ def samplefiles() -> int:
75
+ return _run("samplefiles")
devbits/utils.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
8
+ VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
9
+
10
+
11
+ def ensure_exists(path: Path) -> Path:
12
+ if not path.exists():
13
+ raise FileNotFoundError(f"Path does not exist: {path}")
14
+ return path
15
+
16
+
17
+ def ensure_dir(path: Path) -> Path:
18
+ if not path.exists():
19
+ path.mkdir(parents=True, exist_ok=True)
20
+ if not path.is_dir():
21
+ raise NotADirectoryError(f"Not a directory: {path}")
22
+ return path
23
+
24
+
25
+ def natural_key(value: str) -> list[object]:
26
+ return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", value)]
27
+
28
+
29
+ def list_files(folder: Path, pattern: str = "*", recursive: bool = False) -> list[Path]:
30
+ iterator = folder.rglob(pattern) if recursive else folder.glob(pattern)
31
+ return sorted([p for p in iterator if p.is_file()], key=lambda p: natural_key(p.name))
32
+
33
+
34
+ def list_images(folder: Path, pattern: str = "*", recursive: bool = False) -> list[Path]:
35
+ return [p for p in list_files(folder, pattern, recursive) if p.suffix.lower() in IMAGE_EXTS]
36
+
37
+
38
+ def parse_size(size: str) -> tuple[int, int]:
39
+ try:
40
+ width, height = size.replace("x", ",").split(",")
41
+ width_i, height_i = int(width), int(height)
42
+ except Exception as exc:
43
+ raise ValueError("Size must be WIDTH,HEIGHT or WIDTHxHEIGHT, e.g. 640,480") from exc
44
+ if width_i <= 0 or height_i <= 0:
45
+ raise ValueError("Width and height must be positive integers")
46
+ return width_i, height_i
47
+
48
+
49
+ def parse_int_tuple(value: str, expected: int, name: str) -> tuple[int, ...]:
50
+ try:
51
+ items = tuple(int(x.strip()) for x in value.split(","))
52
+ except Exception as exc:
53
+ raise ValueError(f"{name} must be comma-separated integers") from exc
54
+ if len(items) != expected:
55
+ raise ValueError(f"{name} must contain {expected} comma-separated integers")
56
+ return items
57
+
58
+
59
+ def readable_size(num_bytes: int) -> str:
60
+ value = float(num_bytes)
61
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
62
+ if value < 1024 or unit == "TB":
63
+ return f"{value:.1f} {unit}"
64
+ value /= 1024
65
+ return f"{num_bytes} B"
66
+
67
+
68
+ def iter_project_tree(root: Path, ignore: Iterable[str], max_depth: int) -> list[str]:
69
+ ignore_set = set(ignore)
70
+ lines: list[str] = []
71
+
72
+ def walk(path: Path, prefix: str = "", depth: int = 0) -> None:
73
+ if depth > max_depth:
74
+ return
75
+ children = [p for p in sorted(path.iterdir(), key=lambda p: (p.is_file(), natural_key(p.name))) if p.name not in ignore_set]
76
+ for index, child in enumerate(children):
77
+ connector = "└── " if index == len(children) - 1 else "├── "
78
+ lines.append(f"{prefix}{connector}{child.name}{'/' if child.is_dir() else ''}")
79
+ if child.is_dir():
80
+ extension = " " if index == len(children) - 1 else "│ "
81
+ walk(child, prefix + extension, depth + 1)
82
+
83
+ lines.append(f"{root.name}/")
84
+ walk(root, max_depth=max_depth)
85
+ return lines
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: devbits
3
+ Version: 0.1.0
4
+ Summary: A lightweight CLI toolkit for daily development utilities.
5
+ Author: Bruce Chuang
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/<your-github-username>/devbits
8
+ Project-URL: Repository, https://github.com/<your-github-username>/devbits
9
+ Project-URL: Issues, https://github.com/<your-github-username>/devbits/issues
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: opencv-python>=4.8.0
14
+ Requires-Dist: pillow>=10.0.0
15
+ Dynamic: license-file
16
+
17
+ # devbits
18
+
19
+ ## CLI style
20
+
21
+ `devbits` provides both a main command and standalone commands installed into your Python environment's PATH.
22
+
23
+ Main command style:
24
+
25
+ ```bash
26
+ devbits clearcache .
27
+ devbits images2video frames/ -o output.mp4 --fps 30
28
+ ```
29
+
30
+ Standalone command style:
31
+
32
+ ```bash
33
+ clearcache .
34
+ images2video frames/ -o output.mp4 --fps 30
35
+ video2gif input.mp4 -o output.gif
36
+ clipvideo input.mp4 --start 3 --end 10 -o clip.mp4
37
+ ```
38
+
39
+ After editable install, reopen the terminal or run `hash -r` if your shell does not immediately find the new commands.
40
+
41
+
42
+ `devbits` is a lightweight CLI toolkit for daily development utilities, including cache cleanup, media conversion, image helpers, dataset-like file operations, and project maintenance tools.
43
+
44
+ The project supports two CLI styles:
45
+
46
+ ```bash
47
+ devbits <command> [options]
48
+ <command> [options]
49
+ ```
50
+
51
+ ## Features
52
+
53
+ ### Project cleanup
54
+
55
+ ```bash
56
+ devbits clearcache .
57
+ devbits clearcache . --all
58
+ devbits clearcache . --dry-run
59
+ ```
60
+
61
+ Removes:
62
+
63
+ - `__pycache__/`
64
+ - `*.pyc`
65
+ - `*.pyo`
66
+ - optionally `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`
67
+
68
+ ### Image and video conversion
69
+
70
+ ```bash
71
+ devbits images2video ./frames -o output.mp4 --fps 30
72
+ devbits video2images input.mp4 -o ./frames --every 1
73
+ devbits images2gif ./frames -o output.gif --fps 10
74
+ devbits video2gif input.mp4 -o output.gif --fps 10 --start 2 --end 8
75
+ devbits clipvideo input.mp4 -o clip.mp4 --start 3 --end 10
76
+ devbits clipvideo input.mp4 -o clip.mp4 --start-frame 100 --end-frame 500
77
+ devbits resizevideo input.mp4 -o resized.mp4 --size 1280,720
78
+ ```
79
+
80
+ ### Image utilities
81
+
82
+ ```bash
83
+ devbits image2ico logo.png -o logo.ico
84
+ devbits resizeimage input.jpg -o output.jpg --size 640,480
85
+ devbits batchimages ./images -o ./resized --size 640,480 --format jpg
86
+ devbits checkimages ./images --recursive
87
+ devbits contactsheet ./images -o sheet.jpg --cols 5 --labels
88
+ ```
89
+
90
+ ### Project utilities
91
+
92
+ ```bash
93
+ devbits tree . --depth 3
94
+ devbits size . --top 20
95
+ devbits renamefiles ./images --prefix frame --digits 6
96
+ devbits renamefiles ./images --prefix frame --digits 6 --dry-run
97
+ devbits samplefiles ./images -o ./sample --num 100
98
+ ```
99
+
100
+ ## Installation for local development
101
+
102
+ ```bash
103
+ git clone https://github.com/yourname/devbits.git
104
+ cd devbits
105
+ python -m venv .venv
106
+ source .venv/bin/activate
107
+ pip install -e ".[dev]"
108
+ ```
109
+
110
+ Check the CLI:
111
+
112
+ ```bash
113
+ devbits --help
114
+ devbits images2video --help
115
+ clearcache --help
116
+ images2video --help
117
+ ```
118
+
119
+ ## Build package
120
+
121
+ ```bash
122
+ python -m pip install --upgrade build twine
123
+ python -m build
124
+ python -m twine check dist/*
125
+ ```
126
+
127
+ ## Upload to TestPyPI
128
+
129
+ ```bash
130
+ python -m twine upload --repository testpypi dist/*
131
+ ```
132
+
133
+ ## Upload to PyPI
134
+
135
+ ```bash
136
+ python -m twine upload dist/*
137
+ ```
138
+
139
+ ## Commands
140
+
141
+ | Command | Description |
142
+ |---|---|
143
+ | `clearcache` | Clear Python cache files. |
144
+ | `images2video` | Convert image sequence to MP4 video. |
145
+ | `video2images` | Extract frames from a video. |
146
+ | `images2gif` | Convert image sequence to GIF. |
147
+ | `video2gif` | Convert video to GIF. |
148
+ | `clipvideo` | Clip video by seconds or frame indices. |
149
+ | `resizevideo` | Resize a video. |
150
+ | `image2ico` | Convert image to `.ico`. |
151
+ | `resizeimage` | Resize an image. |
152
+ | `batchimages` | Batch resize or convert images. |
153
+ | `checkimages` | Check broken image files. |
154
+ | `contactsheet` | Create a contact sheet. |
155
+ | `tree` | Print a compact project tree. |
156
+ | `size` | Show top-level folder/file sizes. |
157
+ | `renamefiles` | Batch rename files. |
158
+ | `samplefiles` | Copy or move first N files. |
159
+
160
+ ## Roadmap
161
+
162
+ - Interactive `clipvideo --gui`
163
+ - `mergevideos`
164
+ - `comparevideos`
165
+ - `concatframes`
166
+ - `annotatevideo`
167
+ - `watermark`
168
+ - `splitdataset`
169
+ - `countdataset`
170
+ - `dedupimages`
171
+ - Optional ROS helpers under `devbits[ros]`
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,14 @@
1
+ devbits/__init__.py,sha256=zqY4XfgveM5MNPiCnaR8ibIN5LNKsJgNgAcXotHkU3w,97
2
+ devbits/cache.py,sha256=B_KL0XjgwpBGX1ZTZ3Mm1ZSKKyN9d1vMuTzc2crp3CM,939
3
+ devbits/cli.py,sha256=vyfjjyktgDJ2uqN4L0qiJ0KnifJTYvn3LfHI7dInU6g,9612
4
+ devbits/image.py,sha256=5U8hejhx7zONa7Hdgh8vzzA_mgnNm8o5EKP_Up9f29g,3436
5
+ devbits/media.py,sha256=jssw4hsoDjRZXDS-jrkLG54GxbU4DFIoFc6vQ-3rnQg,6177
6
+ devbits/project.py,sha256=uZqOrRTlMVhANq2vDQXwZUObuOLIHQ2J3GCGcUPCbjE,2163
7
+ devbits/scripts.py,sha256=EccTlPhLRau5OwX4KyQiiSnRTDsagPDPauki3MNr7BI,1195
8
+ devbits/utils.py,sha256=4MFbn8biH6WW7x2w_GXnHf3lFbcdTxAEGs9WYkGjFpk,3131
9
+ devbits-0.1.0.dist-info/licenses/LICENSE,sha256=c0Rf85t-tlEpHaWcw2PEO-9h-tBhDLKeU_cBpdYJISA,1069
10
+ devbits-0.1.0.dist-info/METADATA,sha256=NsrCuP9tgmg8k_c4F4Mtwqx17UEwOWxKAk6WS0ey16o,4252
11
+ devbits-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ devbits-0.1.0.dist-info/entry_points.txt,sha256=LA9XpAutCCLCS4fFl8m4j8WNOQoxnHgiyjCEeCHypXY,679
13
+ devbits-0.1.0.dist-info/top_level.txt,sha256=4cDyBdxRwcICa4LrpFjN5159MDxI23MeAtM9RYZ5fS0,8
14
+ devbits-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,18 @@
1
+ [console_scripts]
2
+ batchimages = devbits.scripts:batchimages
3
+ checkimages = devbits.scripts:checkimages
4
+ clearcache = devbits.scripts:clearcache
5
+ clipvideo = devbits.scripts:clipvideo
6
+ contactsheet = devbits.scripts:contactsheet
7
+ devbits = devbits.cli:main
8
+ image2ico = devbits.scripts:image2ico
9
+ images2gif = devbits.scripts:images2gif
10
+ images2video = devbits.scripts:images2video
11
+ renamefiles = devbits.scripts:renamefiles
12
+ resizeimage = devbits.scripts:resizeimage
13
+ resizevideo = devbits.scripts:resizevideo
14
+ samplefiles = devbits.scripts:samplefiles
15
+ size = devbits.scripts:size
16
+ tree = devbits.scripts:tree
17
+ video2gif = devbits.scripts:video2gif
18
+ video2images = devbits.scripts:video2images
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruce Chuang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ devbits