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:
admin committed 2026-06-28 17:40:32 +02:00
1 parent c284840dd3
commit 53ba943698
2 files changed
+206 -6

No files matched your search

+78 -6
View File
@@ -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
View File
@@ -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!")