• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-MSS / MSS / 10653123390

01 Sep 2024 10:12AM UTC coverage: 69.967% (-0.07%) from 70.037%
10653123390

Pull #2495

github

web-flow
Merge d3a10b8f0 into 0b95679f6
Pull Request #2495: remove the conda/mamba based updater.

24 of 41 new or added lines in 5 files covered. (58.54%)

92 existing lines in 6 files now uncovered.

13843 of 19785 relevant lines covered (69.97%)

0.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.14
/mslib/utils/qt.py
1
# -*- coding: utf-8 -*-
2
"""
3

4
    mslib.utils.msui_qt
5
    ~~~~~~~~~~~~~~~~~~~
6

7
    This module helps with qt
8

9
    This file is part of MSS.
10

11
    :copyright: Copyright 2017-2018 Joern Ungermann, Reimar Bauer
12
    :copyright: Copyright 2017-2024 by the MSS team, see AUTHORS.
13
    :license: APACHE-2.0, see LICENSE for details.
14

15
    Licensed under the Apache License, Version 2.0 (the "License");
16
    you may not use this file except in compliance with the License.
17
    You may obtain a copy of the License at
18

19
       http://www.apache.org/licenses/LICENSE-2.0
20

21
    Unless required by applicable law or agreed to in writing, software
22
    distributed under the License is distributed on an "AS IS" BASIS,
23
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24
    See the License for the specific language governing permissions and
25
    limitations under the License.
26
"""
27

28
import logging
1✔
29
import os
1✔
30
import re
1✔
31
import platform
1✔
32
import sys
1✔
33
import traceback
1✔
34

35
from fslib.fs_filepicker import getSaveFileName, getOpenFileName, getExistingDirectory
1✔
36
from PyQt5 import QtCore, QtWidgets, QtGui  # noqa
1✔
37

38
from mslib.utils.config import config_loader
1✔
39
from mslib.utils import FatalUserError
1✔
40

41

42
def get_open_filename_qt(*args):
1✔
43
    filename = QtWidgets.QFileDialog.getOpenFileName(*args)
1✔
44
    return filename[0] if isinstance(filename, tuple) else str(filename)
1✔
45

46

47
def get_open_filenames_qt(*args):
1✔
48
    """
49
    To select multiple files simultaneously
50
    """
51
    filenames = QtWidgets.QFileDialog.getOpenFileNames(*args)
1✔
52
    return filenames[0] if isinstance(filenames, tuple) else str(filenames)
1✔
53

54

55
def get_save_filename_qt(*args):
1✔
56
    _filename = QtWidgets.QFileDialog.getSaveFileName(*args)
1✔
57
    if isinstance(_filename, tuple):
1✔
58
        # ToDo when can this be only a str
59
        extension = re.sub(r'\w.*\(\*', '', _filename[1][:-1])
1✔
60
        filename = _filename[0]
1✔
61
        if not filename.endswith(extension):
1✔
62
            filename = f'{filename}{extension}'
×
63
        return filename
1✔
64
    return _filename
×
65

66

67
def get_existing_directory_qt(*args):
1✔
68
    dirname = QtWidgets.QFileDialog.getExistingDirectory(*args)
1✔
69
    return dirname[0] if isinstance(dirname, tuple) else str(dirname)
1✔
70

71

72
def get_pickertype(pickertype=None):
1✔
73
    if pickertype is None:
1✔
74
        return config_loader(dataset="filepicker_default")
1✔
75
    if pickertype not in ["qt", "default", "fs"]:
1✔
76
        raise FatalUserError(f"Unknown file picker type '{pickertype}'.")
1✔
77
    return pickertype
1✔
78

79

80
def get_open_filename(parent, title, dirname, filt, pickertype=None):
1✔
81
    pickertype = get_pickertype(pickertype)
1✔
82
    if pickertype == "fs":
1✔
83
        # fs filepicker takes file filters as a list
84
        if not isinstance(filt, list):
1✔
85
            filt = filt.split(';;')
1✔
86
        filename = getOpenFileName(parent, dirname, filt, title="Import Flight Track")
1✔
87
    elif pickertype in ["qt", "default"]:
1✔
88
        # qt filepicker takes file filters separated by ';;'
89
        filename = get_open_filename_qt(parent, title, os.path.expanduser(dirname), filt)
1✔
90
    else:
91
        raise FatalUserError(f"Unknown file picker type '{pickertype}'.")
×
92
    logging.debug("Selected '%s'", filename)
1✔
93
    if filename == "":
1✔
94
        filename = None
1✔
95
    return filename
1✔
96

97

98
def get_open_filenames(parent, title, dirname, filt, pickertype=None):
1✔
99
    """
100
    Opens multiple files simultaneously
101
    Currently implemented only in kmloverlay_dockwidget.py
102
    """
103
    pickertype = get_pickertype(pickertype)
1✔
104
    if pickertype in ["qt", "default"]:
1✔
105
        filename = get_open_filenames_qt(parent, title, os.path.expanduser(dirname), filt)
1✔
106
    else:
107
        raise FatalUserError(f"Unknown file picker type '{pickertype}'.")
×
108
    logging.debug("Selected '%s'", filename)
1✔
109
    if filename == []:
1✔
110
        filename = None
1✔
111
    return filename
1✔
112

113

114
def get_save_filename(parent, title, filename, filt, pickertype=None):
1✔
115
    pickertype = get_pickertype(pickertype)
1✔
116
    if pickertype == "fs":
1✔
117
        dirname, filename = os.path.split(filename)
1✔
118
        filename = getSaveFileName(
1✔
119
            parent, dirname, filt, title=title, default_filename=filename, show_save_action=True)
120
    elif pickertype in ["qt", "default"]:
1✔
121
        filename = get_save_filename_qt(parent, title, os.path.expanduser(filename), filt)
1✔
122
    else:
123
        raise FatalUserError(f"Unknown file picker type '{pickertype}'.")
×
124
    logging.debug("Selected '%s'", filename)
1✔
125
    if filename == "":
1✔
126
        filename = None
1✔
127
    return filename
1✔
128

129

130
def get_existing_directory(parent, title, defaultdir, pickertype=None):
1✔
131
    pickertype = get_pickertype(pickertype)
1✔
132
    if pickertype == "fs":
1✔
133
        dirname = getExistingDirectory(parent, title=title, fs_url=defaultdir)[0]
1✔
134
    elif pickertype in ["qt", "default"]:
1✔
135
        dirname = get_existing_directory_qt(parent, title, defaultdir)
1✔
136
    else:
137
        raise FatalUserError(f"Unknown file picker type '{pickertype}'.")
×
138
    logging.debug("Selected '%s'", dirname)
1✔
139
    if dirname == "":
1✔
140
        dirname = None
1✔
141
    return dirname
1✔
142

143

144
def variant_to_string(variant):
1✔
145
    if isinstance(variant, QtCore.QVariant):
1✔
146
        return str(variant.value())
1✔
147
    return str(variant)
×
148

149

150
def variant_to_float(variant, locale=QtCore.QLocale()):
1✔
151
    if isinstance(variant, QtCore.QVariant):
1✔
152
        value = variant.value()
1✔
153
    else:
154
        value = variant
×
155

156
    if isinstance(value, (int, float)):
1✔
157
        return value
1✔
158
    try:
1✔
159
        float_value, ok = locale.toDouble(value)
1✔
160
        if not ok:
1✔
161
            raise ValueError
×
162
    except TypeError:  # neither float nor string, try Python conversion
×
163
        logging.error("Unexpected type in float conversion: %s=%s",
×
164
                      type(value), value)
165
        float_value = float(value)
×
166
    return float_value
1✔
167

168

169
# to store config by QSettings
170
QtCore.QCoreApplication.setOrganizationName("msui")
1✔
171

172

173
# PyQt5 silently aborts on a Python Exception
174
def excepthook(type_, value, traceback_):
1✔
175
    """
176
    This dumps the error to console, logging (i.e. logfile), and tries to open a MessageBox for GUI users.
177
    """
178
    import mslib
×
179
    import mslib.utils
×
180
    tb = "".join(traceback.format_exception(type_, value, traceback_))
×
181
    traceback.print_exception(type_, value, traceback_)
×
182
    logging.critical("MSS Version: %s", mslib.__version__)
×
183
    logging.critical("Python Version: %s", sys.version)
×
184
    logging.critical("Platform: %s (%s)", platform.platform(), platform.architecture())
×
185
    logging.critical("Fatal error: %s", tb)
×
186

187
    if type_ is mslib.utils.FatalUserError:
×
188
        QtWidgets.QMessageBox.critical(
×
189
            None, "fatal error",
190
            f"Fatal user error in MSS {mslib.__version__} on {platform.platform()}\n"
191
            f"Python {sys.version}\n"
192
            f"\n"
193
            f"{value}")
194
    else:
195
        QtWidgets.QMessageBox.critical(
×
196
            None, "fatal error",
197
            f"Fatal error in MSS {mslib.__version__} on {platform.platform()}\n"
198
            f"Python {sys.version}\n"
199
            f"\n"
200
            f"Please report bugs in MSS to https://github.com/Open-MSS/MSS\n"
201
            f"\n"
202
            f"Information about the fatal error:\n"
203
            f"\n"
204
            f"{tb}")
205

206

207
def show_popup(parent, title, message, icon=0):
1✔
208
    """
209
        title: Title of message box
210
        message: Display Message
211
        icon: 0 = Error Icon, 1 = Information Icon
212
    """
213
    if icon == 0:
1✔
214
        QtWidgets.QMessageBox.critical(parent, title, message)
1✔
215
    elif icon == 1:
1✔
216
        QtWidgets.QMessageBox.information(parent, title, message)
1✔
217

218

219
# TableView drag and drop
220
def dropEvent(self, event):
1✔
221
    target_row = self.indexAt(event.pos()).row()
×
222
    if target_row == -1:
×
223
        target_row = self.model().rowCount() - 1
×
224
    source_row = event.source().currentIndex().row()
×
225
    wps = [self.model().waypoints[source_row]]
×
226
    if target_row > source_row:
×
227
        self.model().insertRows(target_row + 1, 1, waypoints=wps)
×
228
        self.model().removeRows(source_row)
×
229
    elif target_row < source_row:
×
230
        self.model().removeRows(source_row)
×
231
        self.model().insertRows(target_row, 1, waypoints=wps)
×
232
    event.accept()
×
233

234

235
def dragEnterEvent(self, event):
1✔
236
    event.accept()
×
237

238

239
class CheckableComboBox(QtWidgets.QComboBox):
1✔
240
    """
241
    Multiple Choice ComboBox taken from QGIS
242
    """
243

244
    # Subclass Delegate to increase item height
245
    class Delegate(QtWidgets.QStyledItemDelegate):
1✔
246
        def sizeHint(self, option, index):
1✔
247
            size = super().sizeHint(option, index)
×
248
            size.setHeight(20)
×
249
            return size
×
250

251
    def __init__(self, *args, **kwargs):
1✔
252
        super().__init__(*args, **kwargs)
×
253

254
        # Make the combo editable to set a custom text, but readonly
255
        self.setEditable(True)
×
256
        self.lineEdit().setReadOnly(True)
×
257
        # Make the lineedit the same color as QPushButton
258
        palette = QtWidgets.QApplication.palette()
×
259
        palette.setBrush(QtGui.QPalette.Base, palette.button())
×
260
        self.lineEdit().setPalette(palette)
×
261

262
        # Use custom delegate
263
        self.setItemDelegate(CheckableComboBox.Delegate())
×
264

265
        # Update the text when an item is toggled
266
        self.model().dataChanged.connect(self.updateText)
×
267

268
        # Hide and show popup when clicking the line edit
269
        self.lineEdit().installEventFilter(self)
×
270
        self.closeOnLineEditClick = False
×
271

272
        # Prevent popup from closing when clicking on an item
273
        self.view().viewport().installEventFilter(self)
×
274

275
        self.empty_text = ""
×
276

277
    def resizeEvent(self, event):
1✔
278
        # Recompute text to elide as needed
279
        self.updateText()
×
280
        super().resizeEvent(event)
×
281

282
    def eventFilter(self, obj, event):
1✔
283
        if obj == self.lineEdit():
×
284
            if event.type() == QtCore.QEvent.MouseButtonRelease:
×
285
                if self.closeOnLineEditClick:
×
286
                    self.hidePopup()
×
287
                else:
288
                    self.showPopup()
×
289
                return True
×
290
            return False
×
291

292
        if obj == self.view().viewport():
×
293
            if event.type() == QtCore.QEvent.MouseButtonRelease:
×
294
                index = self.view().indexAt(event.pos())
×
295
                item = self.model().item(index.row())
×
296

297
                if item.checkState() == QtCore.Qt.Checked:
×
298
                    item.setCheckState(QtCore.Qt.Unchecked)
×
299
                else:
300
                    item.setCheckState(QtCore.Qt.Checked)
×
301
                return True
×
302
        return False
×
303

304
    def showPopup(self):
1✔
305
        super().showPopup()
×
306
        # When the popup is displayed, a click on the lineedit should close it
307
        self.closeOnLineEditClick = True
×
308

309
    def hidePopup(self):
1✔
310
        super().hidePopup()
×
311
        # Used to prevent immediate reopening when clicking on the lineEdit
312
        self.startTimer(100)
×
313
        # Refresh the display text when closing
314
        self.updateText()
×
315

316
    def timerEvent(self, event):
1✔
317
        # After timeout, kill timer, and re-enable click on line edit
318
        self.killTimer(event.timerId())
×
319
        self.closeOnLineEditClick = False
×
320

321
    def updateText(self):
1✔
322
        texts = []
×
323
        for i in range(self.model().rowCount()):
×
324
            if self.model().item(i).checkState() == QtCore.Qt.Checked:
×
325
                texts.append(self.model().item(i).text())
×
326
        text = ", ".join(texts)
×
327
        if len(text) == 0:
×
328
            text = self.empty_text
×
329

330
        self.lineEdit().setText(text)
×
331

332
    def addItem(self, text, data=None):
1✔
333
        item = QtGui.QStandardItem()
×
334
        item.setText(text)
×
335
        if data is None:
×
336
            item.setData(text)
×
337
        else:
338
            item.setData(data)
×
339
        item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsUserCheckable)
×
340
        item.setData(QtCore.Qt.Unchecked, QtCore.Qt.CheckStateRole)
×
341
        self.model().appendRow(item)
×
342

343
    def addItems(self, texts, datalist=None):
1✔
344
        for i, text in enumerate(texts):
×
345
            try:
×
346
                data = datalist[i]
×
347
            except (TypeError, IndexError):
×
348
                data = None
×
349
            self.addItem(text, data)
×
350

351
    def currentData(self):
1✔
352
        # Return the list of selected items data
353
        res = []
×
354
        for i in range(self.model().rowCount()):
×
355
            if self.model().item(i).checkState() == QtCore.Qt.Checked:
×
356
                res.append(self.model().item(i).data())
×
357
        return res
×
358

359

360
class NoLayersError(Exception):
1✔
361
    def __init__(self, message="No Layers found in WMS get capabilities"):
1✔
362
        self.message = message
×
363
        super().__init__(self.message)
×
364

365

366
class Worker(QtCore.QThread):
1✔
367
    """
368
    Can be used to run a function through a QThread without much struggle,
369
    and receive the return value or exception through signals.
370
    Beware not to modify the parents connections through the function.
371
    You may change the GUI, but it may sometimes not update until the Worker is done.
372
    """
373
    # Static set of all workers to avoid segfaults
374
    workers = set()
1✔
375
    finished = QtCore.pyqtSignal(object)
1✔
376
    failed = QtCore.pyqtSignal(Exception)
1✔
377

378
    def __init__(self, function):
1✔
379
        Worker.workers.add(self)
1✔
380
        super().__init__()
1✔
381
        self.function = function
1✔
382
        # pyqtSignals don't work without an application eventloop running
383
        if QtCore.QCoreApplication.startingUp():
1✔
384
            self.finished = NonQtCallback()
×
385
            self.failed = NonQtCallback()
×
386

387
    def run(self):
1✔
UNCOV
388
        try:
×
UNCOV
389
            result = self.function()
×
390
            # ToDo the capbilities worker member needs the possibility to terminate itself.
391
            # ToDo refactoring needed
UNCOV
392
            if "MSUIWebMapService" in repr(result) and not result.contents:
×
393
                raise NoLayersError
×
394
            else:
UNCOV
395
                self.finished.emit(result)
×
UNCOV
396
        except Exception as e:
×
UNCOV
397
            self.failed.emit(e)
×
398
        finally:
UNCOV
399
            try:
×
UNCOV
400
                Worker.workers.remove(self)
×
401
            except KeyError:
×
402
                pass
×
403

404
    @staticmethod
1✔
405
    def create(function, on_success=None, on_failure=None, start=True):
1✔
406
        """
407
        Create, connect and directly execute a Worker in a single line.
408
        Inspired by QThread.create only available in C++17.
409
        """
410
        worker = Worker(function)
1✔
411
        if on_success:
1✔
412
            worker.finished.connect(on_success)
1✔
413
        if on_failure:
1✔
414
            worker.failed.connect(on_failure)
1✔
415
        if start:
1✔
416
            worker.start()
1✔
417
        return worker
1✔
418

419
    def _restart_msui(self):
1✔
420
        """
421
        Restart msui with all the same parameters, not entirely
422
        safe in case parameters change in higher versions, or while debugging
423
        """
424
        command = [sys.executable.split(os.sep)[-1]] + sys.argv
×
425
        if os.name == "nt" and not command[1].endswith(".py"):
×
426
            command[1] += "-script.py"
×
427
        os.execv(sys.executable, command)
×
428

429

430
class NonQtCallback:
1✔
431
    """
432
    Small mock of pyqtSignal to work without the QT eventloop.
433
    Callbacks are run on the same thread as the caller of emit, as opposed to the caller of connect.
434
    Keep in mind if this causes issues.
435
    """
436

437
    def __init__(self):
1✔
438
        self.callbacks = []
×
439

440
    def connect(self, function):
1✔
441
        self.callbacks.append(function)
×
442

443
    def emit(self, *args):
1✔
444
        for cb in self.callbacks:
×
445
            try:
×
446
                cb(*args)
×
447
            except Exception:
×
448
                pass
×
449

450

451
sys.excepthook = excepthook
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc