fixes
This commit is contained in:
1 parent
6a821c7512
commit
594d9e1df3
5 files changed
+173
-5
No files matched your search
@@ -87,6 +87,25 @@ python main.py \
|
|||||||
|
|
||||||
Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu.
|
Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu.
|
||||||
|
|
||||||
|
### Duze skany: DecompressionBombWarning / DecompressionBombError
|
||||||
|
|
||||||
|
Pillow standardowo ostrzega powyzej ok. **89,5 mln pikseli (89,5 MP)**, a powyzej ok. **179 mln** przerywa otwieranie z bledem `DecompressionBombError`. To limit liczby pikseli, **nie rozmiaru pliku w MB**. Uczciwe skany dokumentow moga miec 100-200 MP.
|
||||||
|
|
||||||
|
W tej wersji prog jest konfigurowalny: domyslnie `--max-image-mpixels 220`, dzieki czemu skany o **199 756 800 pikselach** z podanego logu nie wywoluja juz ostrzezen ani automatycznego bledu Pillow. Narzedzie **nie wylacza globalnie ochrony**: otwiera naglowek, sprawdza liczbe pikseli **przed dekodowaniem**, a przekraczajace limit obrazy wypisuje jako `[SKIP-LIMIT]` z przyczyna. Pozostale rodzaje ostrzezen nie sa filtrowane. Zbyt duzy obraz nie zostaje zmieniony na dysku ani w bazie.
|
||||||
|
|
||||||
|
Uruchom ponownie swoj test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py \
|
||||||
|
--min-size-mb 5 --optimize --dry-run \
|
||||||
|
--max-width 4000 --max-height 6000 --max-file-mb 3 \
|
||||||
|
--max-image-mpixels 220 --report-csv plan.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Jesli serwer ma malo RAM, mozna obnizyc limit, np. do `--max-image-mpixels 120`: obrazy powyzej 120 MP zostana **pominiete**, a nie bezwarunkowo dekodowane. Skany JPEG wymagajace skalowania korzystaja z `draft()` do zmniejszenia pamieci przy dekodowaniu. Bardzo duze PNG moga nadal potrzebowac **ponad 1 GB RAM** na dekodowanie i skalowanie; domyslny limit MP nie jest gwarancja bezpiecznego zuzycia pamieci. Uruchamiaj najpierw `--dry-run`, a `--apply` testuj na pojedynczych plikach z backupem.
|
||||||
|
|
||||||
|
Nie ustawiaj `Image.MAX_IMAGE_PIXELS = None` ani nie ignoruj wszystkich ostrzezen Pillow: takie ustawienia usuwaja mechanizm ostrzegania przed niebezpiecznymi obrazami.
|
||||||
|
|
||||||
## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę
|
## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę
|
||||||
|
|
||||||
**Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie:
|
**Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie:
|
||||||
|
|||||||
+14
-1
@@ -10,6 +10,7 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageOps, PngImagePlugin
|
from PIL import Image, ImageOps, PngImagePlugin
|
||||||
|
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, ImagePixelLimitExceeded, safe_image_open
|
||||||
|
|
||||||
|
|
||||||
class ImageOptimizationError(Exception):
|
class ImageOptimizationError(Exception):
|
||||||
@@ -43,6 +44,7 @@ def create_optimized_candidate(
|
|||||||
min_jpeg_quality=85,
|
min_jpeg_quality=85,
|
||||||
min_gain_percent=5.0,
|
min_gain_percent=5.0,
|
||||||
temp_dir=None,
|
temp_dir=None,
|
||||||
|
max_pixels=DEFAULT_MAX_IMAGE_PIXELS,
|
||||||
):
|
):
|
||||||
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}.
|
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}.
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ def create_optimized_candidate(
|
|||||||
}
|
}
|
||||||
temp_path = None
|
temp_path = None
|
||||||
try:
|
try:
|
||||||
with Image.open(path) as source:
|
with safe_image_open(path, max_pixels=max_pixels) as source:
|
||||||
fmt = source.format
|
fmt = source.format
|
||||||
ext = Path(path).suffix.lower()
|
ext = Path(path).suffix.lower()
|
||||||
if fmt not in ('JPEG', 'PNG'):
|
if fmt not in ('JPEG', 'PNG'):
|
||||||
@@ -79,6 +81,13 @@ def create_optimized_candidate(
|
|||||||
result['reason'] = 'obraz juz spelnia limity'
|
result['reason'] = 'obraz juz spelnia limity'
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Duze JPEG-i dekodujemy w mozliwie mniejszej rozdzielczosci.
|
||||||
|
# JPEG draft korzysta z dekodera DCT i zapobiega alokacji
|
||||||
|
# kilkuset MB / kilku GB przy wejsciu rzedu 200 MP.
|
||||||
|
# Nie stosujemy draft do JPEG bez planowanego skalowania.
|
||||||
|
if fmt == 'JPEG' and (original_width > max_width or original_height > max_height):
|
||||||
|
source.draft(None, (max_width, max_height))
|
||||||
|
|
||||||
# EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
|
# EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
|
||||||
working = ImageOps.exif_transpose(source)
|
working = ImageOps.exif_transpose(source)
|
||||||
if (fmt == 'PNG' and working.mode == 'P' and
|
if (fmt == 'PNG' and working.mode == 'P' and
|
||||||
@@ -144,6 +153,10 @@ def create_optimized_candidate(
|
|||||||
result['temp_path'] = temp_path
|
result['temp_path'] = temp_path
|
||||||
temp_path = None # przekazujemy plik wywolujacemu
|
temp_path = None # przekazujemy plik wywolujacemu
|
||||||
return result
|
return result
|
||||||
|
except ImagePixelLimitExceeded as exc:
|
||||||
|
result['status'] = 'skip'
|
||||||
|
result['reason'] = str(exc)
|
||||||
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result['status'] = 'error'
|
result['status'] = 'error'
|
||||||
result['reason'] = f'{type(exc).__name__}: {exc}'
|
result['reason'] = f'{type(exc).__name__}: {exc}'
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Ograniczona obsluga duzych obrazow Pillow.
|
||||||
|
|
||||||
|
Pillow domyslnie ostrzega powyzej ~89 MP i zglasza blad powyzej ~179 MP.
|
||||||
|
Dla skanow dopuszczamy konfigurowalny limit, ale PRZED dekodowaniem
|
||||||
|
odrzucamy wszystko, co ten limit przekracza. Nie wylaczamy ochrony globalnie.
|
||||||
|
|
||||||
|
Narzędzie CLI wykonuje operacje obrazow sekwencyjnie; prog PIL jest globalny,
|
||||||
|
ale zmieniamy go tylko na czas kontrolowanego otwarcia i przywracamy po nim.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAX_IMAGE_PIXELS = 220_000_000 # 220 MP; obejmuje skany ~200 MP
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePixelLimitExceeded(ValueError):
|
||||||
|
"""Obraz nie zostal zdekodowany, bo przekracza ustalony limit."""
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def safe_image_open(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS, check_pixels=True):
|
||||||
|
"""Otworz naglowek obrazu z limitem, zanim Pillow zdekoduje piksele.
|
||||||
|
|
||||||
|
Ostrzezenie Pillow dot. progu zostaje zastapione JAWNYM sprawdzeniem
|
||||||
|
wielkosci (i statusem SKIP w callerze). Pozostale ostrzezenia pozostaja
|
||||||
|
widoczne. Dla samego odczytu wymiarow check_pixels=False nie dekoduje.
|
||||||
|
"""
|
||||||
|
max_pixels = int(max_pixels)
|
||||||
|
if max_pixels <= 0:
|
||||||
|
raise ValueError('max_pixels musi byc wieksze od zera')
|
||||||
|
old_limit = Image.MAX_IMAGE_PIXELS
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings('ignore', category=Image.DecompressionBombWarning)
|
||||||
|
Image.MAX_IMAGE_PIXELS = max_pixels
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
pixels = image.width * image.height
|
||||||
|
if check_pixels and pixels > max_pixels:
|
||||||
|
raise ImagePixelLimitExceeded(
|
||||||
|
f'{image.width}x{image.height} ({pixels / 1_000_000:.1f} MP) '
|
||||||
|
f'przekracza --max-image-mpixels={max_pixels / 1_000_000:g}; '
|
||||||
|
'pominieto dla ochrony pamieci RAM'
|
||||||
|
)
|
||||||
|
yield image
|
||||||
|
except Image.DecompressionBombError as exc:
|
||||||
|
# Bardzo duze obrazy Pillow moze odrzucic juz przy Image.open.
|
||||||
|
raise ImagePixelLimitExceeded(
|
||||||
|
f'Pillow odrzucil obraz przekraczajacy limit '
|
||||||
|
f'--max-image-mpixels={max_pixels / 1_000_000:g}: {exc}'
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
Image.MAX_IMAGE_PIXELS = old_limit
|
||||||
@@ -26,6 +26,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from drupal_lookup import DrupalReferenceLookup, node_url, search_url
|
from drupal_lookup import DrupalReferenceLookup, node_url, search_url
|
||||||
from image_optimizer import create_optimized_candidate
|
from image_optimizer import create_optimized_candidate
|
||||||
|
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, safe_image_open
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
def build_parser():
|
||||||
@@ -75,6 +76,8 @@ def build_parser():
|
|||||||
change.add_argument('--jpeg-quality', type=int, default=92, help='Poczatkowa jakosc JPEG')
|
change.add_argument('--jpeg-quality', type=int, default=92, help='Poczatkowa jakosc JPEG')
|
||||||
change.add_argument('--min-jpeg-quality', type=int, default=85, help='Minimalna dopuszczalna jakosc JPEG')
|
change.add_argument('--min-jpeg-quality', type=int, default=85, help='Minimalna dopuszczalna jakosc JPEG')
|
||||||
change.add_argument('--min-gain-percent', type=float, default=5.0, help='Minimalne zmniejszenie objetosci w procentach')
|
change.add_argument('--min-gain-percent', type=float, default=5.0, help='Minimalne zmniejszenie objetosci w procentach')
|
||||||
|
change.add_argument('--max-image-mpixels', type=float, default=DEFAULT_MAX_IMAGE_PIXELS / 1_000_000,
|
||||||
|
help='Limit megapikseli oryginalnego obrazu; powyzej tego progu plik bedzie pominiety (ochrona RAM)')
|
||||||
|
|
||||||
report = parser.add_argument_group('Raporty')
|
report = parser.add_argument_group('Raporty')
|
||||||
report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)')
|
report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)')
|
||||||
@@ -93,6 +96,8 @@ def validate_args(args, parser):
|
|||||||
parser.error('--min-gain-percent musi byc w przedziale 0..100')
|
parser.error('--min-gain-percent musi byc w przedziale 0..100')
|
||||||
if args.max_changes < 0 or args.optimize_top < 0:
|
if args.max_changes < 0 or args.optimize_top < 0:
|
||||||
parser.error('--max-changes i --optimize-top nie moga byc ujemne')
|
parser.error('--max-changes i --optimize-top nie moga byc ujemne')
|
||||||
|
if not (0 < args.max_image_mpixels <= 500):
|
||||||
|
parser.error('--max-image-mpixels musi byc w zakresie (0, 500]')
|
||||||
if args.apply and not (args.optimize or args.update_db):
|
if args.apply and not (args.optimize or args.update_db):
|
||||||
parser.error('--apply wymaga --optimize lub --update-db')
|
parser.error('--apply wymaga --optimize lub --update-db')
|
||||||
if args.apply and args.optimize and not args.backup_dir:
|
if args.apply and args.optimize and not args.backup_dir:
|
||||||
@@ -203,9 +208,12 @@ def sha256_file(path):
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def file_resolution(path):
|
def file_resolution(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS):
|
||||||
try:
|
try:
|
||||||
with Image.open(path) as im:
|
# W metadanych mozemy odczytac wymiary nawet wtedy, gdy uzytkownik
|
||||||
|
# ustawi niski prog optymalizacji. Nie dekodujemy pikseli.
|
||||||
|
with safe_image_open(path, max_pixels=max(max_pixels, DEFAULT_MAX_IMAGE_PIXELS),
|
||||||
|
check_pixels=False) as im:
|
||||||
return im.size
|
return im.size
|
||||||
except Exception:
|
except Exception:
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
@@ -257,7 +265,7 @@ def collect_images(connection, config, args):
|
|||||||
if stat_result.st_size < args.min_size_mb * 1024 * 1024:
|
if stat_result.st_size < args.min_size_mb * 1024 * 1024:
|
||||||
continue
|
continue
|
||||||
if full_path not in groups:
|
if full_path not in groups:
|
||||||
width, height = file_resolution(full_path)
|
width, height = file_resolution(full_path, max_pixels=int(args.max_image_mpixels * 1_000_000))
|
||||||
groups[full_path] = {
|
groups[full_path] = {
|
||||||
'path': full_path,
|
'path': full_path,
|
||||||
'rel_path': rel_path,
|
'rel_path': rel_path,
|
||||||
@@ -286,6 +294,7 @@ def collect_images(connection, config, args):
|
|||||||
|
|
||||||
def is_candidate(image, args):
|
def is_candidate(image, args):
|
||||||
return (
|
return (
|
||||||
|
not image['width'] or not image['height'] or
|
||||||
image['width'] > args.max_width or
|
image['width'] > args.max_width or
|
||||||
image['height'] > args.max_height or
|
image['height'] > args.max_height or
|
||||||
image['before_size'] > args.max_file_mb * 1024 * 1024
|
image['before_size'] > args.max_file_mb * 1024 * 1024
|
||||||
@@ -465,6 +474,7 @@ def process_changes(connection, config, args, images, table_prefix):
|
|||||||
min_jpeg_quality=args.min_jpeg_quality,
|
min_jpeg_quality=args.min_jpeg_quality,
|
||||||
min_gain_percent=args.min_gain_percent,
|
min_gain_percent=args.min_gain_percent,
|
||||||
temp_dir=os.path.dirname(image['path']) if args.apply else None,
|
temp_dir=os.path.dirname(image['path']) if args.apply else None,
|
||||||
|
max_pixels=int(args.max_image_mpixels * 1_000_000),
|
||||||
)
|
)
|
||||||
image['optimization_reason'] = result['reason']
|
image['optimization_reason'] = result['reason']
|
||||||
image['optimize_status'] = result['status']
|
image['optimize_status'] = result['status']
|
||||||
@@ -475,7 +485,9 @@ def process_changes(connection, config, args, images, table_prefix):
|
|||||||
print(f'[ERROR] {image["rel_path"]}: {result["reason"]}')
|
print(f'[ERROR] {image["rel_path"]}: {result["reason"]}')
|
||||||
continue
|
continue
|
||||||
if result['status'] == 'skip':
|
if result['status'] == 'skip':
|
||||||
if args.debug:
|
if 'przekracza --max-image-mpixels' in result['reason'] or 'Pillow odrzucil' in result['reason']:
|
||||||
|
print(f'[SKIP-LIMIT] {image["rel_path"]}: {result["reason"]}')
|
||||||
|
elif args.debug:
|
||||||
print(f'[SKIP] {image["rel_path"]}: {result["reason"]}')
|
print(f'[SKIP] {image["rel_path"]}: {result["reason"]}')
|
||||||
# Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala.
|
# Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala.
|
||||||
if args.update_db and mismatched_rows:
|
if args.update_db and mismatched_rows:
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
|
import warnings
|
||||||
|
import zlib
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -15,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|||||||
import main
|
import main
|
||||||
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
|
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
|
||||||
from image_optimizer import create_optimized_candidate
|
from image_optimizer import create_optimized_candidate
|
||||||
|
from image_safety import safe_image_open
|
||||||
|
|
||||||
|
|
||||||
class FakeCursor:
|
class FakeCursor:
|
||||||
@@ -346,6 +350,68 @@ class TestFiles(unittest.TestCase):
|
|||||||
Image.new('RGB', (300, 500), 'red').save(gif)
|
Image.new('RGB', (300, 500), 'red').save(gif)
|
||||||
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip')
|
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip')
|
||||||
|
|
||||||
|
def test_image_limit_replaces_pillow_warning_without_disabling_safety(self):
|
||||||
|
# Symulujemy niski prog Pillow: nasz konfigurowalny limit nie
|
||||||
|
# powinien powodowac ostrzezen ani wychodzic poza scope.
|
||||||
|
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
|
||||||
|
with warnings.catch_warnings(record=True) as emitted:
|
||||||
|
warnings.simplefilter('always', Image.DecompressionBombWarning)
|
||||||
|
self.assertEqual(main.file_resolution(str(self.image), max_pixels=2_000_000),
|
||||||
|
(950, 1250))
|
||||||
|
result = create_optimized_candidate(
|
||||||
|
str(self.image), max_width=550, max_height=700,
|
||||||
|
max_bytes=100_000, max_pixels=2_000_000,
|
||||||
|
)
|
||||||
|
self.assertEqual(result['status'], 'ready', result['reason'])
|
||||||
|
if result['temp_path']:
|
||||||
|
os.unlink(result['temp_path'])
|
||||||
|
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
|
||||||
|
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
|
||||||
|
for item in emitted))
|
||||||
|
|
||||||
|
def test_giant_image_header_is_readable_but_skipped_above_limit(self):
|
||||||
|
# Header PNG deklaruje tyle pikseli co trzy problematyczne pliki
|
||||||
|
# z logu: 14400 * 13872 = 199 756 800. Nie generujemy tych pikseli.
|
||||||
|
fake = self.folder / 'scans' / '200mp.png'
|
||||||
|
with io.BytesIO() as buffer:
|
||||||
|
Image.new('RGB', (2, 2), 'red').save(buffer, format='PNG')
|
||||||
|
content = bytearray(buffer.getvalue())
|
||||||
|
content[16:24] = struct.pack('>II', 14400, 13872)
|
||||||
|
content[29:33] = struct.pack('>I', zlib.crc32(content[12:29]))
|
||||||
|
fake.write_bytes(content)
|
||||||
|
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
|
||||||
|
with warnings.catch_warnings(record=True) as emitted:
|
||||||
|
warnings.simplefilter('always', Image.DecompressionBombWarning)
|
||||||
|
self.assertEqual(main.file_resolution(str(fake), max_pixels=220_000_000),
|
||||||
|
(14400, 13872))
|
||||||
|
with safe_image_open(str(fake), max_pixels=220_000_000) as source:
|
||||||
|
self.assertEqual(source.width * source.height, 199_756_800)
|
||||||
|
# Obrazu nie nalezy dekodowac bez limitu RAM.
|
||||||
|
rejected = create_optimized_candidate(str(fake), max_pixels=150_000_000)
|
||||||
|
self.assertEqual(rejected['status'], 'skip')
|
||||||
|
self.assertIn('--max-image-mpixels=150', rejected['reason'])
|
||||||
|
# Przypadek > 2 * limitu Pillow rowniez jest SKIP, nie ERROR.
|
||||||
|
rejected_strict = create_optimized_candidate(str(fake), max_pixels=60_000_000)
|
||||||
|
self.assertEqual(rejected_strict['status'], 'skip')
|
||||||
|
self.assertIn('--max-image-mpixels=60', rejected_strict['reason'])
|
||||||
|
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
|
||||||
|
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
|
||||||
|
for item in emitted))
|
||||||
|
|
||||||
|
def test_oversize_images_are_reported_as_skipped_not_errors(self):
|
||||||
|
args = self.args('--optimize', '--dry-run', '--max-image-mpixels', '0.5',
|
||||||
|
'--max-width', '500')
|
||||||
|
original = self.image.read_bytes()
|
||||||
|
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
|
||||||
|
with redirect_stdout(io.StringIO()) as output:
|
||||||
|
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
|
||||||
|
self.assertEqual(stats['errors'], 0)
|
||||||
|
self.assertEqual(stats['planned'], 0)
|
||||||
|
self.assertEqual(images[0]['optimize_status'], 'skip')
|
||||||
|
self.assertIn('[SKIP-LIMIT]', output.getvalue())
|
||||||
|
self.assertEqual(self.image.read_bytes(), original)
|
||||||
|
self.assertEqual(self.conn.files[0]['filesize'], 12345)
|
||||||
|
|
||||||
def test_cli_end_to_end_read_only_generates_reports(self):
|
def test_cli_end_to_end_read_only_generates_reports(self):
|
||||||
csv_path = self.root / 'end.csv'
|
csv_path = self.root / 'end.csv'
|
||||||
json_path = self.root / 'end.json'
|
json_path = self.root / 'end.json'
|
||||||
|
|||||||
Reference in new issue
Block a user