refactor-cli 0.1.2__py3-none-any.whl → 0.2.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.
refactor/cli.py CHANGED
@@ -1,51 +1,59 @@
1
1
  import typer
2
- from refactor.tree import print_tree
3
2
  from importlib.metadata import version as get_version
4
- from refactor.validation import validate_path
5
- from refactor.scanner import scan_directory
6
- from refactor.metadata import get_metadata
3
+
4
+ from refactor.context_store import DEFAULT_CACHE_FILE, save_context
7
5
  from refactor.ignore import load_ignore_patterns
6
+ from refactor.image_scanner import scan_images
7
+ from refactor.metadata import get_metadata
8
+ from refactor.organizer import (
9
+ execute_moves,
10
+ managed_category_dirs,
11
+ plan_moves,
12
+ save_managed_category_dirs,
13
+ )
14
+ from refactor.scanner import scan_directory
15
+ from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
16
+ from refactor.shim import build_image_classifier, choose_category_folders
17
+ from refactor.tree import print_tree
18
+ from refactor.validation import validate_path
8
19
 
9
20
  app = typer.Typer(no_args_is_help=True)
10
21
 
22
+
23
+ @app.callback()
24
+ def bootstrap() -> None:
25
+ maybe_run_first_time_setup()
26
+
27
+
11
28
  @app.command()
12
- def version():
29
+ def version() -> None:
13
30
  """Print version information"""
14
31
  print(get_version("refactor-cli"))
15
32
 
16
- # @app.command()
17
- # def tree(path: str = "."):
18
- # """Print folder structure"""
19
- # print_tree(path)
20
33
 
21
- if __name__ == "__main__":
22
- app()
34
+ @app.command()
35
+ def setup() -> None:
36
+ """Configure hosted or local multimodal backend."""
37
+ command_setup()
38
+
23
39
 
24
- # validates folder
25
40
  @app.command()
26
- def tree(path: str = "."):
41
+ def tree(path: str = ".") -> None:
27
42
  """Display folder structure of given path"""
28
43
  try:
29
44
  validate_path(path)
30
45
  print_tree(path)
31
-
32
46
  except ValueError as e:
33
47
  typer.echo(f"Error: {e}")
34
- raise typer.Exit(code=1)
48
+ raise typer.Exit(code=1)
35
49
 
36
- # scanner
37
- # files = scan_directory(".")
38
- # print(len(files))
39
50
 
40
51
  @app.command()
41
- def scan(path: str = "."):
52
+ def scan(path: str = ".") -> None:
42
53
  """Scan directory and display metadata"""
43
-
44
54
  try:
45
55
  validate_path(path)
46
-
47
56
  patterns = load_ignore_patterns()
48
-
49
57
  items = scan_directory(path, patterns)
50
58
 
51
59
  for item in items:
@@ -53,4 +61,231 @@ def scan(path: str = "."):
53
61
 
54
62
  except ValueError as e:
55
63
  typer.echo(f"Error: {e}")
56
- raise typer.Exit(code=1)
64
+ raise typer.Exit(code=1)
65
+
66
+
67
+ @app.command(name="get-image-context")
68
+ def get_image_context(
69
+ path: str = typer.Argument(".", help="Directory to scan for images"),
70
+ output: str = typer.Option(
71
+ ".refactor_image_context.json",
72
+ "--output",
73
+ "-o",
74
+ help="Path to write the JSON context cache",
75
+ ),
76
+ min_confidence: float = typer.Option(
77
+ 40.0,
78
+ "--min-confidence",
79
+ help="Minimum tag confidence to include (hosted backend only)",
80
+ ),
81
+ verbose: bool = typer.Option(
82
+ False,
83
+ "--verbose",
84
+ "-v",
85
+ help="Print per-image tag results to stdout",
86
+ ),
87
+ category_root: str = typer.Option(
88
+ ".",
89
+ "--category-root",
90
+ help="Directory whose existing folders Gemini may reuse",
91
+ ),
92
+ write_context: bool = typer.Option(
93
+ True,
94
+ "--write-context/--no-write-context",
95
+ hidden=True,
96
+ ),
97
+ ) -> list:
98
+ """
99
+ Scan directory images using configured backend shim and save
100
+ tags/captions to JSON context cache.
101
+ """
102
+ from pathlib import Path
103
+
104
+ try:
105
+ validate_path(path)
106
+ if not Path(category_root).exists() or not Path(category_root).is_dir():
107
+ raise ValueError(f"Category root is not a directory: {category_root}")
108
+ config = ensure_backend_configured()
109
+ except (ValueError, EnvironmentError) as e:
110
+ typer.echo(f"Error: {e}", err=True)
111
+ raise typer.Exit(code=1)
112
+
113
+ patterns = load_ignore_patterns()
114
+ source_root = Path(path).resolve()
115
+ destination_root = Path(category_root).resolve()
116
+ excluded_dirs = (
117
+ managed_category_dirs(destination_root)
118
+ if source_root == destination_root else set()
119
+ )
120
+ images = scan_images(path, patterns, excluded_dirs)
121
+
122
+ if not images:
123
+ typer.echo("No image files found in the specified directory.")
124
+ raise typer.Exit(code=0)
125
+
126
+ typer.echo(f"Found {len(images)} image(s) in '{path}'")
127
+
128
+ try:
129
+ classifier = build_image_classifier(config, min_confidence=min_confidence)
130
+ except EnvironmentError as e:
131
+ typer.echo(f"Error: {e}", err=True)
132
+ raise typer.Exit(code=1)
133
+
134
+ results = []
135
+ failed = 0
136
+
137
+ with typer.progressbar(images, label="Analysing images") as progress:
138
+ for img_path in progress:
139
+ try:
140
+ ctx = classifier.classify_image(img_path)
141
+ results.append(ctx)
142
+
143
+ except Exception as exc:
144
+ typer.echo(f"\n [!] Skipping {img_path.name}: {exc}", err=True)
145
+ failed += 1
146
+
147
+ if results:
148
+ try:
149
+ results = choose_category_folders(config, results, Path(category_root))
150
+ except Exception as exc:
151
+ # Folder selection is an essential part of the hosted workflow; do
152
+ # not silently fall back to an Imagga top-tag directory.
153
+ typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
154
+ raise typer.Exit(code=1)
155
+
156
+ if verbose:
157
+ for ctx in results:
158
+ typer.echo(f"\n {Path(ctx.file_path).name}")
159
+ typer.echo(f" Folder : {ctx.primary_folder}")
160
+ typer.echo(f" Tags : {', '.join(ctx.tags[:5])}")
161
+ typer.echo(f" Caption : {ctx.caption}")
162
+
163
+ output_path = Path(output)
164
+ if write_context:
165
+ save_context(results, path, output_path)
166
+
167
+ typer.echo(f"\n{'─' * 52}")
168
+ typer.echo(f" Processed : {len(results)} image(s)")
169
+ if failed:
170
+ typer.echo(f" Failed : {failed} image(s)")
171
+ if write_context:
172
+ typer.echo(f" Cache : {output_path.resolve()}")
173
+ typer.echo(f"{'─' * 52}")
174
+
175
+ if results:
176
+ typer.echo(f"\n{'Filename':<30} {'Folder':<20} {'Top tags'}")
177
+ typer.echo("─" * 80)
178
+ for ctx in results:
179
+ fname = Path(ctx.file_path).name
180
+ top_tags = ", ".join(ctx.tags[:3])
181
+ typer.echo(f" {fname:<28} {ctx.primary_folder:<20} {top_tags}")
182
+
183
+ return results
184
+
185
+
186
+ @app.command(name="refactor-images")
187
+ def refactor_images(
188
+ path: str = typer.Argument(".", help="Directory containing images to organize"),
189
+ context: str = typer.Option(
190
+ ".refactor_image_context.json",
191
+ "--context",
192
+ "-c",
193
+ help="Path to context cache (defaults to the source directory)",
194
+ ),
195
+ output_dir: str = typer.Option(
196
+ ".",
197
+ "--output-dir",
198
+ "-d",
199
+ help="Root directory where tag-named folders will be created",
200
+ ),
201
+ dry_run: bool = typer.Option(
202
+ False,
203
+ "--dry-run",
204
+ help="Print the planned moves without executing them",
205
+ ),
206
+ move: bool = typer.Option(
207
+ True,
208
+ "--move/--copy",
209
+ help="Move files into categories. Use --copy to preserve source files.",
210
+ ),
211
+ min_confidence: float = typer.Option(
212
+ 40.0,
213
+ "--min-confidence",
214
+ help="Minimum Imagga tag confidence to retain",
215
+ ),
216
+ ) -> None:
217
+ """
218
+ Generate fresh Imagga and Gemini context, then organise images into
219
+ Gemini-selected subdirectories. The context cache is refreshed on every run.
220
+
221
+ By default, images are MOVED. Use --copy to preserve source files.
222
+ """
223
+ from pathlib import Path
224
+ try:
225
+ validate_path(path)
226
+ out = Path(output_dir)
227
+ if not out.exists() or not out.is_dir():
228
+ raise ValueError(f"Output directory is not a directory: {output_dir}")
229
+ except ValueError as e:
230
+ typer.echo(f"Error: {e}", err=True)
231
+ raise typer.Exit(code=1)
232
+
233
+ context_path = Path(context)
234
+ if context == DEFAULT_CACHE_FILE:
235
+ context_path = Path(path) / DEFAULT_CACHE_FILE
236
+
237
+ excluded = managed_category_dirs(out) if Path(path).resolve() == out.resolve() else set()
238
+ if excluded:
239
+ typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
240
+
241
+ typer.echo("Generating image context with Imagga and Gemini...")
242
+ contexts = get_image_context(
243
+ path=path,
244
+ output=str(context_path),
245
+ min_confidence=min_confidence,
246
+ verbose=False,
247
+ category_root=output_dir,
248
+ write_context=not dry_run,
249
+ )
250
+
251
+ if not contexts:
252
+ typer.echo("Context cache is empty. Nothing to organize.")
253
+ raise typer.Exit(code=0)
254
+
255
+ moves = plan_moves(contexts, out)
256
+
257
+ action_word = "Move" if move else "Copy"
258
+
259
+ if dry_run:
260
+ verb_plural = "moves" if move else "copies"
261
+ verb_past = "moved" if move else "copied"
262
+ typer.echo(f"\n[Dry Run] Planned {verb_plural} ({len(moves)} files):\n")
263
+ typer.echo(f" {'Source':<40} {'Destination'}")
264
+ typer.echo(" " + "─" * 76)
265
+ for src, dst in moves:
266
+ typer.echo(f" {src.name:<40} {dst}")
267
+ typer.echo(f"\n No files were {verb_past}.")
268
+ raise typer.Exit(code=0)
269
+
270
+ folders = {dst.parent.name for _, dst in moves}
271
+ typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
272
+ for folder in sorted(folders):
273
+ count = sum(1 for _, dst in moves if dst.parent.name == folder)
274
+ typer.echo(f" {out / folder} ({count} file(s))")
275
+
276
+ summary = execute_moves(moves, copy=not move)
277
+ if not summary["failed"]:
278
+ save_managed_category_dirs(out, folders)
279
+
280
+ typer.echo(f"\n{'─' * 52}")
281
+ count = summary["moved"] if move else summary["copied"]
282
+ verb_past = "moved" if move else "copied"
283
+ typer.echo(f" {verb_past.capitalize():<8}: {count} file(s)")
284
+ if summary["failed"]:
285
+ typer.echo(f" Failed : {summary['failed']} file(s)")
286
+ typer.echo(f" Output : {out.resolve()}")
287
+ typer.echo(f"{'─' * 52}")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ app()
refactor/config.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ CONFIG_DIR = Path.home() / ".refactor"
8
+ CONFIG_FILE = CONFIG_DIR / "config.json"
9
+ ENV_FILE = CONFIG_DIR / ".env"
10
+
11
+
12
+ def _ensure_config_dir() -> None:
13
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def default_config() -> dict[str, Any]:
17
+ return {
18
+ "version": 1,
19
+ "backend": "unconfigured",
20
+ "setup_skipped": False,
21
+ "hosted": {
22
+ "provider": "imagga_gemini",
23
+ "imagga_api_key_env": "IMAGGA_API_KEY",
24
+ "imagga_api_secret_env": "IMAGGA_API_SECRET",
25
+ "gemini_api_key_env": "GEMINI_API_KEY",
26
+ },
27
+ "local": {
28
+ "runtime": "ollama",
29
+ "recommended_model": "",
30
+ "status": "stub",
31
+ },
32
+ }
33
+
34
+
35
+ def config_exists() -> bool:
36
+ return CONFIG_FILE.exists()
37
+
38
+
39
+ def load_config() -> dict[str, Any]:
40
+ if not CONFIG_FILE.exists():
41
+ return default_config()
42
+
43
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
44
+ payload = json.load(f)
45
+
46
+ merged = default_config()
47
+ merged.update(payload)
48
+ return merged
49
+
50
+
51
+ def save_config(config: dict[str, Any]) -> None:
52
+ _ensure_config_dir()
53
+
54
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
55
+ json.dump(config, f, indent=2, ensure_ascii=False)
56
+
57
+
58
+ def save_env_vars(values: dict[str, str]) -> None:
59
+ _ensure_config_dir()
60
+
61
+ existing: dict[str, str] = {}
62
+ if ENV_FILE.exists():
63
+ for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
64
+ if "=" not in line or line.strip().startswith("#"):
65
+ continue
66
+ key, value = line.split("=", 1)
67
+ existing[key.strip()] = value.strip()
68
+
69
+ existing.update(values)
70
+
71
+ lines = [f"{k}={v}" for k, v in existing.items()]
72
+ ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
73
+
74
+ try:
75
+ ENV_FILE.chmod(0o600)
76
+ except OSError:
77
+ pass
@@ -0,0 +1,48 @@
1
+ """
2
+ refactor/context_store.py
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Manages reading and writing the JSON context cache produced by get-image-context.
5
+
6
+ Default cache filename: .refactor_image_context.json
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import json
11
+ from dataclasses import asdict
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from refactor.imagga import ImageContext
16
+
17
+ DEFAULT_CACHE_FILE = ".refactor_image_context.json"
18
+
19
+
20
+ def save_context(
21
+ results: list[ImageContext],
22
+ source_dir: str,
23
+ output_path: Path,
24
+ ) -> None:
25
+ """Serialise a list of ImageContext objects to a JSON cache file."""
26
+ payload = {
27
+ "generated_at": datetime.now(timezone.utc).isoformat(),
28
+ "source_dir": str(Path(source_dir).resolve()),
29
+ "images": [asdict(ctx) for ctx in results],
30
+ }
31
+
32
+ output_path.parent.mkdir(parents=True, exist_ok=True)
33
+ with open(output_path, "w", encoding="utf-8") as f:
34
+ json.dump(payload, f, indent=2, ensure_ascii=False)
35
+
36
+
37
+ def load_context(input_path: Path) -> list[ImageContext]:
38
+ """Load ImageContext objects from a JSON cache file."""
39
+ if not input_path.exists():
40
+ raise FileNotFoundError(
41
+ f"Context cache not found: {input_path}\n"
42
+ "Run 'refactor get-image-context <path>' first."
43
+ )
44
+
45
+ with open(input_path, "r", encoding="utf-8") as f:
46
+ payload = json.load(f)
47
+
48
+ return [ImageContext(**img) for img in payload.get("images", [])]
refactor/gemini.py ADDED
@@ -0,0 +1,103 @@
1
+ """Gemini-backed folder selection for Imagga image context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+
10
+ import requests
11
+ from dotenv import load_dotenv
12
+
13
+ from refactor.config import ENV_FILE
14
+ from refactor.imagga import ImageContext
15
+
16
+ load_dotenv()
17
+ if ENV_FILE.exists():
18
+ load_dotenv(ENV_FILE)
19
+
20
+ # Current Gemini API default. Users may set GEMINI_MODEL to override it.
21
+ DEFAULT_MODEL = "gemini-3.8-flash"
22
+
23
+
24
+ def _safe_folder_name(value: str) -> str:
25
+ """Return one relative directory name, never a path supplied by the model."""
26
+ value = value.strip().lower()
27
+ value = re.sub(r"[^\w\s-]", "", value)
28
+ value = re.sub(r"\s+", "_", value).strip("_-")
29
+ return value or "uncategorized"
30
+
31
+
32
+ class GeminiFolderClassifier:
33
+ """Ask Gemini to select consistent folders from Imagga tags and captions."""
34
+
35
+ def __init__(self, api_key: str | None = None, model: str | None = None) -> None:
36
+ self.api_key = api_key or os.environ.get("GEMINI_API_KEY", "")
37
+ self.model = model or os.environ.get("GEMINI_MODEL", DEFAULT_MODEL)
38
+ if not self.api_key:
39
+ raise EnvironmentError(
40
+ "Gemini credentials not found. Set GEMINI_API_KEY in ~/.refactor/.env."
41
+ )
42
+
43
+ def choose_folders(
44
+ self, contexts: list[ImageContext], category_root: Path
45
+ ) -> list[ImageContext]:
46
+ if not contexts:
47
+ return contexts
48
+
49
+ existing = sorted(
50
+ entry.name for entry in category_root.iterdir() if entry.is_dir()
51
+ ) if category_root.exists() else []
52
+ images = [
53
+ {"id": index, "filename": Path(ctx.file_path).name,
54
+ "tags": ctx.tags, "caption": ctx.caption}
55
+ for index, ctx in enumerate(contexts)
56
+ ]
57
+ prompt = (
58
+ "Choose exactly one concise, lowercase category folder for every image. "
59
+ "Prefer an existing category when it accurately fits; otherwise create a "
60
+ "new, broad and reusable category. Folder names must be a single relative "
61
+ "directory name (letters, numbers, spaces, underscores, or hyphens only). "
62
+ "Do not use filenames, paths, or explanations. Return JSON only in this "
63
+ "shape: {\"folders\":[{\"id\":0,\"folder\":\"example\"}]}.\n\n"
64
+ f"Existing categories: {json.dumps(existing)}\n"
65
+ f"Images and Imagga semantic context: {json.dumps(images, ensure_ascii=False)}"
66
+ )
67
+ payload = {
68
+ "contents": [{"parts": [{"text": prompt}]}],
69
+ "generationConfig": {
70
+ "responseMimeType": "application/json",
71
+ "temperature": 0,
72
+ },
73
+ }
74
+ url = (
75
+ "https://generativelanguage.googleapis.com/v1beta/models/"
76
+ f"{self.model}:generateContent"
77
+ )
78
+ response = requests.post(
79
+ url, headers={"x-goog-api-key": self.api_key}, json=payload, timeout=60
80
+ )
81
+ if not response.ok:
82
+ raise requests.HTTPError(
83
+ f"Gemini folder selection returned {response.status_code}: {response.text}",
84
+ response=response,
85
+ )
86
+
87
+ try:
88
+ text = response.json()["candidates"][0]["content"]["parts"][0]["text"]
89
+ choices = json.loads(text)["folders"]
90
+ except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
91
+ raise ValueError("Gemini returned an invalid folder-selection response") from exc
92
+
93
+ folders = {
94
+ choice.get("id"): _safe_folder_name(str(choice.get("folder", "")))
95
+ for choice in choices if isinstance(choice, dict) and isinstance(choice.get("id"), int)
96
+ }
97
+ missing = [str(index) for index in range(len(contexts)) if index not in folders]
98
+ if missing:
99
+ raise ValueError("Gemini did not choose a folder for image id(s): " + ", ".join(missing))
100
+
101
+ for index, context in enumerate(contexts):
102
+ context.primary_folder = folders[index]
103
+ return contexts
refactor/hardware.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class HardwareSpec:
11
+ cpu_cores: int
12
+ memory_gb: float
13
+ has_nvidia_gpu: bool
14
+ gpu_vram_gb: float | None = None
15
+
16
+
17
+ def _memory_gb() -> float:
18
+ pagesize = os.sysconf("SC_PAGE_SIZE")
19
+ pages = os.sysconf("SC_PHYS_PAGES")
20
+ return round((pagesize * pages) / (1024**3), 2)
21
+
22
+
23
+ def _nvidia_vram_gb() -> float | None:
24
+ if not shutil.which("nvidia-smi"):
25
+ return None
26
+
27
+ try:
28
+ result = subprocess.run(
29
+ [
30
+ "nvidia-smi",
31
+ "--query-gpu=memory.total",
32
+ "--format=csv,noheader,nounits",
33
+ ],
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ )
38
+ except Exception:
39
+ return None
40
+
41
+ values = []
42
+ for line in result.stdout.splitlines():
43
+ line = line.strip()
44
+ if not line:
45
+ continue
46
+ try:
47
+ values.append(float(line) / 1024)
48
+ except ValueError:
49
+ continue
50
+
51
+ return round(max(values), 2) if values else None
52
+
53
+
54
+ def detect_hardware() -> HardwareSpec:
55
+ vram = _nvidia_vram_gb()
56
+ return HardwareSpec(
57
+ cpu_cores=os.cpu_count() or 1,
58
+ memory_gb=_memory_gb(),
59
+ has_nvidia_gpu=vram is not None,
60
+ gpu_vram_gb=vram,
61
+ )
62
+
63
+
64
+ def recommend_local_model(spec: HardwareSpec) -> tuple[str, str]:
65
+ if spec.has_nvidia_gpu and (spec.gpu_vram_gb or 0) >= 8:
66
+ return (
67
+ "llava:7b",
68
+ "Good fit for GPU-backed image understanding with balanced quality/speed.",
69
+ )
70
+
71
+ if spec.memory_gb >= 16:
72
+ return (
73
+ "llava:7b-q4",
74
+ "Reasonable CPU-only option with quantization for 16GB+ RAM systems.",
75
+ )
76
+
77
+ if spec.memory_gb >= 8:
78
+ return (
79
+ "moondream",
80
+ "Lightweight option for lower-memory systems focused on image captioning.",
81
+ )
82
+
83
+ return (
84
+ "nanollava",
85
+ "Very small fallback model for constrained hardware; expect limited accuracy.",
86
+ )
@@ -0,0 +1,39 @@
1
+ """
2
+ refactor/image_scanner.py
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Scans a directory for image files only.
5
+ Extends the base scanner with image-extension filtering.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from refactor.ignore import should_ignore
11
+
12
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"}
13
+
14
+
15
+ def scan_images(
16
+ path: str, ignore_patterns: list[str], excluded_dirs: set[Path] | None = None
17
+ ) -> list[Path]:
18
+ """
19
+ Recursively scan *path* and return all image files.
20
+
21
+ Files matching any pattern in *ignore_patterns* are skipped.
22
+ """
23
+ images: list[Path] = []
24
+ excluded_dirs = {directory.resolve() for directory in (excluded_dirs or set())}
25
+
26
+ for item in Path(path).rglob("*"):
27
+ if item.is_dir():
28
+ continue
29
+
30
+ if any(directory in item.resolve().parents for directory in excluded_dirs):
31
+ continue
32
+
33
+ if should_ignore(item.name, ignore_patterns):
34
+ continue
35
+
36
+ if item.suffix.lower() in IMAGE_EXTENSIONS:
37
+ images.append(item)
38
+
39
+ return images