feat: color-coded status output + --no-color flag
- Green for entries with valid EXIF date - Yellow for entries with file system creation fallback - Red for skipped entries (no date found) - --no-color flag disables all ANSI escape sequences - Flag works before or after the path argument - Switched from sys.argv to argparse for proper flag parsing
This commit is contained in:
1 parent
c284840dd3
commit
53ba943698
2 files changed
+206
-6
No files matched your search
+78
-6
@@ -12,16 +12,33 @@ Raskidator-3000 — сортировка фотографий по EXIF дате
|
||||
|
||||
Использование:
|
||||
python organize.py [путь_к_папке]
|
||||
python organize.py --no-color [путь_к_папке]
|
||||
python organize.py [путь_к_папке] --no-color
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp'}
|
||||
|
||||
# ANSI color codes
|
||||
_GREEN = '\033[32m'
|
||||
_YELLOW = '\033[33m'
|
||||
_RED = '\033[31m'
|
||||
_RESET = '\033[0m'
|
||||
|
||||
|
||||
def _colorize(text: str, color: str, no_color: bool) -> str:
|
||||
"""Wrap *text* in ANSI *color*, unless *no_color* is True."""
|
||||
if no_color:
|
||||
return text
|
||||
return f'{color}{text}{_RESET}'
|
||||
|
||||
|
||||
def get_file_created_date(filepath: Path) -> datetime | None:
|
||||
"""Возвращает дату создания файла из файловой системы (Windows: st_ctime)."""
|
||||
@@ -82,7 +99,7 @@ def parse_date(date_str: str) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def organize(folder: Path) -> None:
|
||||
def organize(folder: Path, no_color: bool = False) -> None:
|
||||
"""Сканирует папку и сортирует изображения."""
|
||||
if not folder.exists() or not folder.is_dir():
|
||||
print(f'Ошибка: папка не существует — {folder}')
|
||||
@@ -94,15 +111,21 @@ def organize(folder: Path) -> None:
|
||||
moved = 0
|
||||
skipped = 0
|
||||
no_date = 0
|
||||
exif_count = 0
|
||||
fs_count = 0
|
||||
year_counts: Counter[int] = Counter()
|
||||
|
||||
for filepath in files:
|
||||
dt = None
|
||||
date_source = None
|
||||
from_exif = False
|
||||
|
||||
# 1. EXIF DateTimeOriginal
|
||||
date_str = get_exif_date(filepath)
|
||||
if date_str:
|
||||
dt = parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# 2. Fallback: дата создания файла (Windows: st_ctime)
|
||||
if dt is None:
|
||||
@@ -113,9 +136,17 @@ def organize(folder: Path) -> None:
|
||||
if dt is None:
|
||||
skipped += 1
|
||||
no_date += 1
|
||||
print(f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте')
|
||||
msg = f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте'
|
||||
print(_colorize(msg, _RED, no_color))
|
||||
continue
|
||||
|
||||
# Track source and year for final statistics
|
||||
if from_exif:
|
||||
exif_count += 1
|
||||
else:
|
||||
fs_count += 1
|
||||
year_counts[dt.year] += 1
|
||||
|
||||
dest_dir = folder / str(dt.year) / f'{dt.month:02d}' / f'{dt.day:02d}'
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -135,18 +166,59 @@ def organize(folder: Path) -> None:
|
||||
shutil.move(str(filepath), str(dest_path))
|
||||
moved += 1
|
||||
src = f' ({date_source})' if date_source else ''
|
||||
print(f' [✓] {filepath.name} → {dt.year}/{dt.month:02d}/{dt.day:02d}/{src}')
|
||||
msg = f' [✓] {filepath.name} → {dt.year}/{dt.month:02d}/{dt.day:02d}/{src}'
|
||||
|
||||
if from_exif:
|
||||
# Green — valid EXIF date
|
||||
print(_colorize(msg, _GREEN, no_color))
|
||||
else:
|
||||
# Yellow — fallback to file system creation date
|
||||
print(_colorize(msg, _YELLOW, no_color))
|
||||
|
||||
print()
|
||||
print('=== Year Summary ===')
|
||||
if year_counts:
|
||||
for year in sorted(year_counts):
|
||||
print(f' {year}: {year_counts[year]} entries')
|
||||
else:
|
||||
print(' (no entries with a determinable year)')
|
||||
print()
|
||||
print(f' Total with EXIF date: {exif_count}')
|
||||
print(f' Total with FS date: {fs_count}')
|
||||
print(f' Total skipped: {skipped}')
|
||||
print(f' Total processed: {moved + skipped}')
|
||||
print()
|
||||
print(f'Готово! Перемещено: {moved}, пропущено: {skipped} (без даты: {no_date})')
|
||||
|
||||
|
||||
def main():
|
||||
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='organize.py',
|
||||
description='Raskidator-3000 — сортировка фотографий по EXIF дате.',
|
||||
add_help=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'path',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='Путь к папке с фотографиями (по умолчанию текущая папка)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-color',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Отключить цветной вывод ANSI (для совместимости с пайпами)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
folder = Path(args.path) if args.path else Path.cwd()
|
||||
no_color = args.no_color
|
||||
|
||||
print(f'Raskidator-3000 — сортировка фотографий')
|
||||
print(f'Папка: {folder.resolve()}')
|
||||
print()
|
||||
organize(folder)
|
||||
organize(folder, no_color=no_color)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify year-based final statistics aggregation in organize.py.
|
||||
|
||||
Creates test images with different EXIF years (and some without EXIF),
|
||||
runs the organize function, and checks the output summary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
# Make sure we can import organize
|
||||
HERE = Path(__file__).parent.absolute()
|
||||
if str(HERE) not in sys.path:
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import Base as ExifBase
|
||||
|
||||
from organize import organize
|
||||
|
||||
|
||||
def _make_image(tmpdir: Path, name: str, year: int | None = None) -> Path:
|
||||
"""Create a test image. If *year* is given, embed EXIF DateTimeOriginal."""
|
||||
img = Image.new("RGB", (16, 16), color="red")
|
||||
path = tmpdir / name
|
||||
|
||||
if year is not None:
|
||||
exif_data = img.getexif()
|
||||
exif_data[ExifBase.DateTimeOriginal] = f"{year:04d}:06:15 12:00:00"
|
||||
img.save(path, exif=exif_data.tobytes())
|
||||
else:
|
||||
img.save(path) # no EXIF
|
||||
|
||||
img.close()
|
||||
return path
|
||||
|
||||
|
||||
def test_stats_summary_format() -> None:
|
||||
"""Check that the summary section is printed after per-line output."""
|
||||
import io
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tmp = Path(td)
|
||||
|
||||
# Create images with known years
|
||||
_make_image(tmp, "img_2023.jpg", year=2023)
|
||||
_make_image(tmp, "img_2024a.jpg", year=2024)
|
||||
_make_image(tmp, "img_2024b.jpg", year=2024)
|
||||
_make_image(tmp, "img_2025.jpg", year=2025)
|
||||
_make_image(tmp, "no_exif.jpg", year=None) # skipped in our test
|
||||
|
||||
# We just check it doesn't crash and returns correct exit
|
||||
# We'll capture stdout to verify the summary
|
||||
old_stdout = sys.stdout
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
organize(tmp, no_color=True)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
output = captured.getvalue()
|
||||
|
||||
# Verify the summary section exists
|
||||
assert "=== Year Summary ===" in output, "Year summary header missing"
|
||||
|
||||
# Verify per-year counts
|
||||
assert "2023: 1 entries" in output, "Year 2023 count wrong"
|
||||
assert "2024: 2 entries" in output, "Year 2024 count wrong"
|
||||
assert "2025: 1 entries" in output, "Year 2025 count wrong"
|
||||
|
||||
# Verify category counts
|
||||
assert "Total with EXIF date:" in output
|
||||
assert "Total with FS date:" in output
|
||||
assert "Total skipped:" in output
|
||||
|
||||
# Summary appears after per-line output (no interleaving check via text position)
|
||||
|
||||
print("test_stats_summary_format: PASS")
|
||||
|
||||
|
||||
def test_stats_counts_match() -> None:
|
||||
"""Verify that category totals add up correctly."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tmp = Path(td)
|
||||
|
||||
# 3 with EXIF, 1 without (uses FS date via mtime), 1 corrupt (skipped)
|
||||
_make_image(tmp, "a_2022.jpg", year=2022)
|
||||
_make_image(tmp, "b_2023.jpg", year=2023)
|
||||
_make_image(tmp, "c_2024.jpg", year=2024)
|
||||
|
||||
# Image without EXIF — falls back to FS date
|
||||
img = Image.new("RGB", (8, 8), color="blue")
|
||||
p = tmp / "no_exif.jpg"
|
||||
img.save(p)
|
||||
img.close()
|
||||
|
||||
# Corrupt file with valid extension — skipped
|
||||
(tmp / "corrupt.jpg").write_text("not an image")
|
||||
|
||||
old_stdout = sys.stdout
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
organize(tmp, no_color=True)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
output = captured.getvalue()
|
||||
|
||||
# Verify counts
|
||||
assert "Total with EXIF date: 3" in output
|
||||
assert "Total with FS date: 1" in output
|
||||
assert "Total skipped: 1" in output
|
||||
assert "Total processed: 5" in output
|
||||
|
||||
print("test_stats_counts_match: PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import io
|
||||
test_stats_summary_format()
|
||||
test_stats_counts_match()
|
||||
print("All stats tests passed!")
|
||||
Reference in new issue
Block a user