Add setting to change default image allocation limit.

This commit is contained in:
Rebecca Breu 2024-05-08 21:35:11 +02:00
parent 65942c08cf
commit 13192987c6
12 changed files with 163 additions and 20 deletions

View file

@ -1,3 +1,15 @@
0.3.4-dev (unreleased)
======================
Added
-----
* Added a setting to change the default memory limit for individual
images. If a big image won't load, increase this limit. This
setting can be overridden by Qt's default environment variable
QT_IMAGEIO_MAXALLOC
0.3.3 - 2024-05-05
==================

View file

@ -107,6 +107,7 @@ def main():
settings = BeeSettings()
logger.info(f'Using settings: {settings.fileName()}')
logger.info(f'Logging to: {logfile_name()}')
settings.on_startup()
args = CommandlineArgs(with_check=True) # Force checking
assert not args.debug_raise_error, args.debug_raise_error

View file

@ -15,9 +15,10 @@
import argparse
import logging
import os
import os.path
from PyQt6 import QtCore
from PyQt6 import QtCore, QtGui
from beeref import constants
@ -117,6 +118,12 @@ class BeeSettings(QtCore.QSettings):
'cast': int,
'validate': lambda x: 0 <= x <= 200,
},
'Items/image_allocation_limit': {
'default': QtGui.QImageReader.allocationLimit(),
'cast': int,
'validate': lambda x: x >= 0,
'post_save_callback': QtGui.QImageReader.setAllocationLimit,
}
}
def __init__(self):
@ -132,6 +139,26 @@ class BeeSettings(QtCore.QSettings):
constants.APPNAME,
constants.APPNAME)
def on_startup(self):
"""Settings to be applied on application startup."""
if os.environ.get('QT_IMAGEIO_MAXALLOC'):
alloc = int(os.environ['QT_IMAGEIO_MAXALLOC'])
else:
alloc = self.valueOrDefault('Items/image_allocation_limit')
QtGui.QImageReader.setAllocationLimit(alloc)
def setValue(self, key, value):
super().setValue(key, value)
if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]:
self.FIELDS[key]['post_save_callback'](value)
def remove(self, key):
super().remove(key)
if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]:
value = self.valueOrDefault(key)
self.FIELDS[key]['post_save_callback'](value)
def valueOrDefault(self, key):
"""Get the value for key, or the default value specified in FIELDS.

View file

@ -13,6 +13,12 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
IMG_LOADING_ERROR_MSG = (
'Unknown format or too big?\n'
'Check Settings -> Miscellaneous -> Maximum Image Size')
class BeeFileIOError(Exception):
def __init__(self, msg, filename):
self.msg = msg

View file

@ -35,7 +35,7 @@ from PyQt6 import QtGui
from beeref import constants
from beeref.items import BeePixmapItem
from .errors import BeeFileIOError
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
@ -52,7 +52,7 @@ def handle_sqlite_errors(func):
def wrapper(self, *args, **kwargs):
try:
func(self, *args, **kwargs)
except sqlite3.Error as e:
except (sqlite3.Error, BeeFileIOError) as e:
logger.exception(f'Error while reading/writing {self.filename}')
try:
# Try to roll back transaction if there is any
@ -210,8 +210,15 @@ class SQLiteIO:
}
if data['type'] == 'pixmap':
data['item'] = BeePixmapItem(QtGui.QImage())
data['item'].pixmap_from_bytes(row[9])
item = BeePixmapItem(QtGui.QImage())
item.pixmap_from_bytes(row[9])
if item.pixmap().isNull():
item = data['data']['text'] = (
f'Image could not be loaded: {item.filename}\n'
+ IMG_LOADING_ERROR_MSG)
data['type'] = 'text'
data['item'] = item
data['data']['is_editable'] = False
self.scene.add_item_later(data)

View file

@ -85,7 +85,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
TYPE = 'pixmap'
CROP_HANDLE_SIZE = 15
def __init__(self, image, filename=None):
def __init__(self, image, filename=None, **kwargs):
super().__init__(QtGui.QPixmap.fromImage(image))
self.save_id = None
self.filename = filename
@ -602,13 +602,13 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
TYPE = 'text'
def __init__(self, text=None):
def __init__(self, text=None, is_editable=True, **kwargs):
super().__init__(text or "Text")
self.save_id = None
logger.debug(f'Initialized {self}')
self.is_image = False
self.init_selectable()
self.is_editable = True
self.is_editable = is_editable
self.edit_mode = False
self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text']))

View file

@ -80,7 +80,7 @@ class MainControlsMixin:
elif mimedata.hasImage():
event.acceptProposedAction()
else:
msg = 'Attempted drop not an image'
msg = 'Attempted drop not an image or image too big'
logger.info(msg)
widgets.BeeNotification(self.control_target, msg)

View file

@ -26,6 +26,7 @@ from beeref import commands
from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings
from beeref import constants
from beeref import fileio
from beeref.fileio.errors import IMG_LOADING_ERROR_MSG
from beeref.fileio.export import exporter_registry
from beeref import widgets
from beeref.items import BeePixmapItem, BeeTextItem
@ -532,13 +533,12 @@ class BeeGraphicsView(MainControlsMixin,
errornames = [
f'<li>{fn}</li>' for fn in errors]
errornames = '<ul>%s</ul>' % '\n'.join(errornames)
msg = ('{errors} image(s) out of {total} '
'could not be opened:'.format(
errors=len(errors), total=len(errors)))
num = len(errors)
msg = f'{num} image(s) could not be opened.<br/>'
QtWidgets.QMessageBox.warning(
self,
'Problem loading images',
msg + errornames)
msg + IMG_LOADING_ERROR_MSG + errornames)
self.scene.add_queued_items()
self.scene.arrange_optimal()
self.undo_stack.endMacro()
@ -631,7 +631,7 @@ class BeeGraphicsView(MainControlsMixin,
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
msg = 'No image data or text in clipboard'
msg = 'No image data or text in clipboard or image too big'
logger.info(msg)
widgets.BeeNotification(self, msg)

View file

@ -135,6 +135,15 @@ class ArrangeGapWidget(IntegerGroup):
MAX = 200
class AllocationLimitWidget(IntegerGroup):
TITLE = 'Maximum Image Size:'
HELPTEXT = ('The maximum image size that can be loaded (in megabytes). '
'Set to 0 for no limitation.')
KEY = 'Items/image_allocation_limit'
MIN = 0
MAX = 10000
class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
@ -147,6 +156,7 @@ class SettingsDialog(QtWidgets.QDialog):
misc.setLayout(misc_layout)
misc_layout.addWidget(ImageStorageFormatWidget(), 0, 0)
misc_layout.addWidget(ArrangeGapWidget(), 0, 1)
misc_layout.addWidget(AllocationLimitWidget(), 1, 0)
tabs.addTab(misc, '&Miscellaneous')
layout = QtWidgets.QVBoxLayout()

View file

@ -1,9 +1,12 @@
import os
import os.path
import tempfile
from unittest.mock import patch
from unittest.mock import patch, MagicMock
import pytest
from PyQt6 import QtGui
from beeref.config.settings import CommandlineArgs
@ -35,6 +38,52 @@ def test_command_line_args_get_unknown():
CommandlineArgs._instance = None
def test_settings_on_startup_sets_alloc_from_settings(settings):
settings.setValue('Items/image_allocation_limit', 66)
QtGui.QImageReader.setAllocationLimit(100)
settings.on_startup()
assert QtGui.QImageReader.allocationLimit() == 66
def test_settings_on_startup_sets_alloc_from_environment(settings):
settings.setValue('Items/image_allocation_limit', 66)
os.environ['QT_IMAGEIO_MAXALLOC'] = '42'
QtGui.QImageReader.setAllocationLimit(100)
settings.on_startup()
assert QtGui.QImageReader.allocationLimit() == 42
def test_settings_set_value_without_callback(settings):
settings.FIELDS = {'foo/bar': {}}
settings.setValue('foo/bar', 100)
assert settings.value('foo/bar') == 100
def test_settings_set_value_with_callback(settings):
foo_callback = MagicMock()
settings.FIELDS = {'foo/bar': {'post_save_callback': foo_callback}}
settings.setValue('foo/bar', 100)
foo_callback.assert_called_once_with(100)
assert settings.value('foo/bar') == 100
def test_settings_remove_without_callback(settings):
settings.FIELDS = {'foo/bar': {}}
settings.remove('foo/bar')
assert settings.value('foo/bar') is None
def test_settings_remove_with_callback(settings):
foo_callback = MagicMock()
settings.FIELDS = {
'foo/bar': {'default': 66,
'post_save_callback': foo_callback}
}
settings.remove('foo/bar')
foo_callback.assert_called_once_with(66)
assert settings.value('foo/bar') is None
def test_settings_value_or_default_gets_default(settings):
assert settings.valueOrDefault('Items/image_storage_format') == 'best'

View file

@ -120,21 +120,21 @@ def test_all_migrations(tmpfile):
assert result[3] == b'bla'
def test_sqliteio_rite_meta_application_id(tmpfile):
def test_sqliteio_write_meta_application_id(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA application_id')
assert result[0] == schema.APPLICATION_ID
def test_sqliteio_rite_meta_user_version(tmpfile):
def test_sqliteio_write_meta_user_version(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA user_version')
assert result[0] == schema.USER_VERSION
def test_sqliteio_rite_meta_foreign_keys(tmpfile):
def test_sqliteio_write_meta_foreign_keys(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA foreign_keys')
@ -463,6 +463,8 @@ def test_sqliteio_read_reads_readonly_text_item(tmpfile, view):
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeeTextItem)
assert item.is_editable is True
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
@ -493,6 +495,7 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeePixmapItem)
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
@ -510,6 +513,30 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
assert view.scene.items_to_add.empty() is True
def test_sqliteio_read_reads_readonly_pixmap_item_error(tmpfile, view):
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items '
'(type, x, y, z, scale, rotation, flip, data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1,
json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)',
(1, b'not an image'))
io.connection.commit()
del io
io = SQLiteIO(tmpfile, view.scene, readonly=True)
io.read()
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeeTextItem)
assert item.is_editable is False
item.toPlainText().startswith('Unknown')
assert view.scene.items_to_add.empty() is True
def test_sqliteio_read_updates_progress(tmpfile, view):
worker = MagicMock(canceled=False)
io = SQLiteIO(tmpfile, view.scene, create_new=True,

View file

@ -29,12 +29,16 @@ def test_beerefapplication_fileopenevent(open_mock, qapp, main_window):
@patch('beeref.__main__.BeeRefApplication')
@patch('beeref.__main__.CommandlineArgs')
def test_main(args_mock, app_mock, qapp):
@patch('beeref.config.BeeSettings.on_startup')
def test_main(startup_mock, args_mock, app_mock, qapp):
app_mock.return_value = qapp
args_mock.return_value.filename = None
args_mock.return_value.loglevel = 'WARN'
args_mock.return_value.debug_raise_error = ''
with patch.object(qapp, 'exec') as exec_mock:
main()
args_mock.assert_called_once_with(with_check=True)
exec_mock.assert_called_once_with()
args_mock.assert_called_once_with(with_check=True)
startup_mock.assert_called()