Skip to content

Commit

Permalink
Add setting to change default image allocation limit.
Browse files Browse the repository at this point in the history
  • Loading branch information
rbreu committed May 8, 2024
1 parent 65942c0 commit 1319298
Show file tree
Hide file tree
Showing 12 changed files with 163 additions and 20 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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
==================

Expand Down
1 change: 1 addition & 0 deletions beeref/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 28 additions & 1 deletion beeref/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -132,6 +139,26 @@ def __init__(self):
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.
Expand Down
6 changes: 6 additions & 0 deletions beeref/fileio/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions beeref/fileio/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

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


Expand All @@ -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
Expand Down Expand Up @@ -210,8 +210,15 @@ def read(self):
}

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)

Expand Down
6 changes: 3 additions & 3 deletions beeref/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']))

Expand Down
2 changes: 1 addition & 1 deletion beeref/main_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def dragEnterEvent(self, event):
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)

Expand Down
10 changes: 5 additions & 5 deletions beeref/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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
Expand Down Expand Up @@ -532,13 +533,12 @@ def on_insert_images_finished(self, new_scene, filename, errors):
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()
Expand Down Expand Up @@ -631,7 +631,7 @@ def on_action_paste(self):
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)

Expand Down
10 changes: 10 additions & 0 deletions beeref/widgets/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -147,6 +156,7 @@ def __init__(self, parent):
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()
Expand Down
51 changes: 50 additions & 1 deletion tests/config/test_settings.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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'

Expand Down
33 changes: 30 additions & 3 deletions tests/fileio/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

0 comments on commit 1319298

Please sign in to comment.