File: table.py

package info (click to toggle)
dupeguru 4.3.1-6
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 3,604 kB
  • sloc: python: 16,846; ansic: 424; makefile: 123
file content (558 lines) | stat: -rw-r--r-- 21,256 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
# Created By: Eric Mc Sween
# Created On: 2008-05-29
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html

from collections.abc import MutableSequence
from collections import namedtuple
from typing import Any, List, Tuple, Union

from hscommon.gui.base import GUIObject
from hscommon.gui.selectable_list import Selectable


# We used to directly subclass list, but it caused problems at some point with deepcopy
class Table(MutableSequence, Selectable):
    """Sortable and selectable sequence of :class:`Row`.

    In fact, the Table is very similar to :class:`.SelectableList` in
    practice and differs mostly in principle. Their difference lies in the nature of their items
    they manage. With the Table, rows usually have many properties, presented in columns, and they
    have to subclass :class:`Row`.

    Usually used with :class:`~hscommon.gui.column.Column`.

    Subclasses :class:`.Selectable`.
    """

    # Should be List[Column], but have circular import...
    COLUMNS: List = []

    def __init__(self) -> None:
        Selectable.__init__(self)
        self._rows: List["Row"] = []
        self._header: Union["Row", None] = None
        self._footer: Union["Row", None] = None

    # TODO type hint for key
    def __delitem__(self, key):
        self._rows.__delitem__(key)
        if self._header is not None and ((not self) or (self[0] is not self._header)):
            self._header = None
        if self._footer is not None and ((not self) or (self[-1] is not self._footer)):
            self._footer = None
        self._check_selection_range()

    # TODO type hint for key
    def __getitem__(self, key) -> Any:
        return self._rows.__getitem__(key)

    def __len__(self) -> int:
        return len(self._rows)

    # TODO type hint for key
    def __setitem__(self, key, value: Any) -> None:
        self._rows.__setitem__(key, value)

    def append(self, item: "Row") -> None:
        """Appends ``item`` at the end of the table.

        If there's a footer, the item is inserted before it.
        """
        if self._footer is not None:
            self._rows.insert(-1, item)
        else:
            self._rows.append(item)

    def insert(self, index: int, item: "Row") -> None:
        """Inserts ``item`` at ``index`` in the table.

        If there's a header, will make sure we don't insert before it, and if there's a footer, will
        make sure that we don't insert after it.
        """
        if (self._header is not None) and (index == 0):
            index = 1
        if (self._footer is not None) and (index >= len(self)):
            index = len(self) - 1
        self._rows.insert(index, item)

    def remove(self, row: "Row") -> None:
        """Removes ``row`` from table.

        If ``row`` is a header or footer, that header or footer will be set to ``None``.
        """
        if row is self._header:
            self._header = None
        if row is self._footer:
            self._footer = None
        self._rows.remove(row)
        self._check_selection_range()

    def sort_by(self, column_name: str, desc: bool = False) -> None:
        """Sort table by ``column_name``.

        Sort key for each row is computed from :meth:`Row.sort_key_for_column`.

        If ``desc`` is ``True``, sort order is reversed.

        If present, header and footer will always be first and last, respectively.
        """
        if self._header is not None:
            self._rows.pop(0)
        if self._footer is not None:
            self._rows.pop()
        self._rows.sort(key=lambda row: row.sort_key_for_column(column_name), reverse=desc)
        if self._header is not None:
            self._rows.insert(0, self._header)
        if self._footer is not None:
            self._rows.append(self._footer)

    # --- Properties
    @property
    def footer(self) -> Union["Row", None]:
        """If set, a row that always stay at the bottom of the table.

        :class:`Row`. *get/set*.

        When set to something else than ``None``, ``header`` and ``footer`` represent rows that will
        always be kept in first and/or last position, regardless of sorting. ``len()`` and indexing
        will include them, which means that if there's a header, ``table[0]`` returns it and if
        there's a footer, ``table[-1]`` returns it. To make things short, all list-like functions
        work with header and footer "on". But things get fuzzy for ``append()`` and ``insert()``
        because these will ensure that no "normal" row gets inserted before the header or after the
        footer.

        Adding and removing footer here and there might seem (and is) hackish, but it's much simpler
        than the alternative (when, of course, you need such a feature), which is to override magic
        methods and adjust the results. When we do that, there the slice stuff that we have to
        implement and it gets quite complex. Moreover, the most frequent operation on a table is
        ``__getitem__``, and making checks to know whether the key is a header or footer at each
        call would make that operation, which is the most used, slower.
        """
        return self._footer

    @footer.setter
    def footer(self, value: Union["Row", None]) -> None:
        if self._footer is not None:
            self._rows.pop()
        if value is not None:
            self._rows.append(value)
        self._footer = value

    @property
    def header(self) -> Union["Row", None]:
        """If set, a row that always stay at the bottom of the table.

        See :attr:`footer` for details.
        """
        return self._header

    @header.setter
    def header(self, value: Union["Row", None]) -> None:
        if self._header is not None:
            self._rows.pop(0)
        if value is not None:
            self._rows.insert(0, value)
        self._header = value

    @property
    def row_count(self) -> int:
        """Number or rows in the table (without counting header and footer).

        *int*. *read-only*.
        """
        result = len(self)
        if self._footer is not None:
            result -= 1
        if self._header is not None:
            result -= 1
        return result

    @property
    def rows(self) -> List["Row"]:
        """List of rows in the table, excluding header and footer.

        List of :class:`Row`. *read-only*.
        """
        start = None
        end = None
        if self._footer is not None:
            end = -1
        if self._header is not None:
            start = 1
        return self[start:end]

    @property
    def selected_row(self) -> "Row":
        """Selected row according to :attr:`Selectable.selected_index`.

        :class:`Row`. *get/set*.

        When setting this attribute, we look up the index of the row and set the selected index from
        there. If the row isn't in the list, selection isn't changed.
        """
        return self[self.selected_index] if self.selected_index is not None else None

    @selected_row.setter
    def selected_row(self, value: int) -> None:
        try:
            self.selected_index = self.index(value)
        except ValueError:
            pass

    @property
    def selected_rows(self) -> List["Row"]:
        """List of selected rows based on :attr:`.selected_indexes`.

        List of :class:`Row`. *read-only*.
        """
        return [self[index] for index in self.selected_indexes]


class GUITableView:
    """Expected interface for :class:`GUITable`'s view.

    *Not actually used in the code. For documentation purposes only.*

    Our view, some kind of table view, is expected to sync with the table's contents by
    appropriately behave to all callbacks in this interface.

    When in edit mode, the content types by the user is expected to be sent as soon as possible
    to the :class:`Row`.

    Whenever the user changes the selection, we expect the view to call :meth:`Table.select`.
    """

    def refresh(self) -> None:
        """Refreshes the contents of the table widget.

        Ensures that the contents of the table widget is synced with the model. This includes
        selection.
        """

    def start_editing(self) -> None:
        """Start editing the currently selected row.

        Begin whatever inline editing support that the view supports.
        """

    def stop_editing(self) -> None:
        """Stop editing if there's an inline editing in effect.

        There's no "aborting" implied in this call, so it's appropriate to send whatever the user
        has typed and might not have been sent down to the :class:`Row` yet. After you've done that,
        stop the editing mechanism.
        """


SortDescriptor = namedtuple("SortDescriptor", "column desc")


class GUITable(Table, GUIObject):
    """Cross-toolkit GUI-enabled table view.

    Represents a UI element presenting the user with a sortable, selectable, possibly editable,
    table view.

    Behaves like the :class:`Table` which it subclasses, but is more focused on being the presenter
    of some model data to its :attr:`.GUIObject.view`. There's a :meth:`refresh`
    mechanism which ensures fresh data while preserving sorting order and selection. There's also an
    editing mechanism which tracks whether (and which) row is being edited (or added) and
    save/cancel edits when appropriate.

    Subclasses :class:`Table` and :class:`.GUIObject`. Expected view:
    :class:`GUITableView`.
    """

    def __init__(self) -> None:
        GUIObject.__init__(self)
        Table.__init__(self)
        #: The row being currently edited by the user. ``None`` if no edit is taking place.
        self.edited: Union["Row", None] = None
        self._sort_descriptor: Union[SortDescriptor, None] = None

    # --- Virtual
    def _do_add(self) -> Tuple["Row", int]:
        """(Virtual) Creates a new row, adds it in the table.

        Returns ``(row, insert_index)``.
        """
        raise NotImplementedError()

    def _do_delete(self) -> None:
        """(Virtual) Delete the selected rows."""
        pass

    def _fill(self) -> None:
        """(Virtual/Required) Fills the table with all the rows that this table is supposed to have.

        Called by :meth:`refresh`. Does nothing by default.
        """
        pass

    def _is_edited_new(self) -> bool:
        """(Virtual) Returns whether the currently edited row should be considered "new".

        This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a
        revert of the row's value or the removal of the row.

        By default, always false.
        """
        return False

    def _restore_selection(self, previous_selection):
        """(Virtual) Restores row selection after a contents-changing operation.

        Before each contents changing operation, we store our previously selected indexes because in
        many cases, such as in :meth:`refresh`, our selection will be lost. After the operation is
        over, we call this method with our previously selected indexes (in ``previous_selection``).

        The default behavior is (if we indeed have an empty :attr:`.selected_indexes`) to re-select
        ``previous_selection``. If it was empty, we select the last row of the table.

        This behavior can, of course, be overriden.
        """
        if not self.selected_indexes:
            if previous_selection:
                self.select(previous_selection)
            else:
                self.select([len(self) - 1])

    # --- Public
    def add(self) -> None:
        """Add a new row in edit mode.

        Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit
        mode.
        """
        self.view.stop_editing()
        if self.edited is not None:
            self.save_edits()
        row, insert_index = self._do_add()
        self.insert(insert_index, row)
        self.select([insert_index])
        self.view.refresh()
        # We have to set "edited" after calling refresh() because some UI are trigger-happy
        # about calling save_edits() and they do so during calls to refresh(). We don't want
        # a call to save_edits() during refresh prematurely mess with our newly added item.
        self.edited = row
        self.view.start_editing()

    def can_edit_cell(self, column_name: str, row_index: int) -> bool:
        """Returns whether the cell at ``row_index`` and ``column_name`` can be edited.

        A row is, by default, editable as soon as it has an attr with the same name as `column`.
        If :meth:`Row.can_edit` returns False, the row is not editable at all. You can set
        editability of rows at the attribute level with can_edit_* properties.

        Mostly just a shortcut to :meth:`Row.can_edit_cell`.
        """
        row = self[row_index]
        return row.can_edit_cell(column_name)

    def cancel_edits(self) -> None:
        """Cancels the current edit operation.

        If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`).
        """
        if self.edited is None:
            return
        self.view.stop_editing()
        if self._is_edited_new():
            previous_selection = self.selected_indexes
            self.remove(self.edited)
            self._restore_selection(previous_selection)
            self._update_selection()
        else:
            self.edited.load()
        self.edited = None
        self.view.refresh()

    def delete(self) -> None:
        """Delete the currently selected rows.

        Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if
        relevant.
        """
        self.view.stop_editing()
        if self.edited is not None:
            self.cancel_edits()
            return
        if self:
            self._do_delete()

    def refresh(self, refresh_view: bool = True) -> None:
        """Empty the table and re-create its rows.

        :meth:`_fill` is called after we emptied the table to create our rows. Previous sort order
        will be preserved, regardless of the order in which the rows were filled. If there was any
        edit operation taking place, it's cancelled.

        :param bool refresh_view: Whether we tell our view to refresh after our refill operation.
                                  Most of the time, it's what we want, but there's some cases where
                                  we don't.
        """
        self.cancel_edits()
        previous_selection = self.selected_indexes
        del self[:]
        self._fill()
        sd = self._sort_descriptor
        if sd is not None:
            Table.sort_by(self, column_name=sd.column, desc=sd.desc)
        self._restore_selection(previous_selection)
        if refresh_view:
            self.view.refresh()

    def save_edits(self) -> None:
        """Commit user edits to the model.

        This is done by calling :meth:`Row.save`.
        """
        if self.edited is None:
            return
        row = self.edited
        self.edited = None
        row.save()

    def sort_by(self, column_name: str, desc: bool = False) -> None:
        """Sort table by ``column_name``.

        Overrides :meth:`Table.sort_by`. After having performed sorting, calls
        :meth:`~.Selectable._update_selection` to give you the chance,
        if appropriate, to update your selected indexes according to, maybe, the selection that you
        have in your model.

        Then, we refresh our view.
        """
        Table.sort_by(self, column_name=column_name, desc=desc)
        self._sort_descriptor = SortDescriptor(column_name, desc)
        self._update_selection()
        self.view.refresh()


class Row:
    """Represents a row in a :class:`Table`.

    It holds multiple values to be represented through columns. It's its role to prepare data
    fetched from model instances into ready-to-present-in-a-table fashion. You will do this in
    :meth:`load`.

    When you do this, you'll put the result into arbitrary attributes, which will later be fetched
    by your table for presentation to the user.

    You can organize your attributes in whatever way you want, but there's a convention you can
    follow if you want to minimize subclassing and use default behavior:

    1. Attribute name = column name. If your attribute is ``foobar``, whenever we refer to
       ``column_name``, you refer to that attribute with the column name ``foobar``.
    2. Public attributes are for *formatted* value, that is, user readable strings.
    3. Underscore prefix is the unformatted (computable) value. For example, you could have
       ``_foobar`` at ``42`` and ``foobar`` at ``"42 seconds"`` (what you present to the user).
    4. Unformatted values are used for sorting.
    5. If your column name is a python keyword, add an underscore suffix (``from_``).

    Of course, this is only default behavior. This can be overriden.
    """

    def __init__(self, table: GUITable) -> None:
        super().__init__()
        self.table = table

    def _edit(self) -> None:
        if self.table.edited is self:
            return
        assert self.table.edited is None
        self.table.edited = self

    # --- Virtual
    def can_edit(self) -> bool:
        """(Virtual) Whether the whole row can be edited.

        By default, always returns ``True``. This is for the *whole* row. For individual cells, it's
        :meth:`can_edit_cell`.
        """
        return True

    def load(self) -> None:
        """(Virtual/Required) Loads up values from the model to be presented in the table.

        Usually, our model instances contain values that are not quite ready for display. If you
        have number formatting, display calculations and other whatnots to perform, you do it here
        and then you put the result in an arbitrary attribute of the row.
        """
        raise NotImplementedError()

    def save(self) -> None:
        """(Virtual/Required) Saves user edits into your model.

        If your table is editable, this is called when the user commits his changes. Usually, these
        are typed up stuff, or selected indexes. You have to do proper parsing and reference
        linking, and save that stuff into your model.
        """
        raise NotImplementedError()

    def sort_key_for_column(self, column_name: str) -> Any:
        """(Virtual) Return the value that is to be used to sort by column ``column_name``.

        By default, looks for an attribute with the same name as ``column_name``, but with an
        underscore prefix ("unformatted value"). If there's none, tries without the underscore. If
        there's none, raises ``AttributeError``.
        """
        try:
            return getattr(self, "_" + column_name)
        except AttributeError:
            return getattr(self, column_name)

    # --- Public
    def can_edit_cell(self, column_name: str) -> bool:
        """Returns whether cell for column ``column_name`` can be edited.

        By the default, the check is done in many steps:

        1. We check whether the whole row can be edited with :meth:`can_edit`. If it can't, the cell
           can't either.
        2. If the column doesn't exist as an attribute, we can't edit.
        3. If we have an attribute ``can_edit_<column_name>``, return that.
        4. Check if our attribute is a property. If it's not, it's not editable.
        5. If our attribute is in fact a property, check whether the property is "settable" (has a
           ``fset`` method). The cell is editable only if the property is "settable".
        """
        if not self.can_edit():
            return False
        # '_' is in case column is a python keyword
        if not hasattr(self, column_name):
            if hasattr(self, column_name + "_"):
                column_name = column_name + "_"
            else:
                return False
        if hasattr(self, "can_edit_" + column_name):
            return getattr(self, "can_edit_" + column_name)
        # If the row has a settable property, we can edit the cell
        rowclass = self.__class__
        prop = getattr(rowclass, column_name, None)
        if prop is None:
            return False
        return bool(getattr(prop, "fset", None))

    def get_cell_value(self, attrname: str) -> Any:
        """Get cell value for ``attrname``.

        By default, does a simple ``getattr()``, but it is used to allow subclasses to have
        alternative value storage mechanisms.
        """
        if attrname == "from":
            attrname = "from_"
        return getattr(self, attrname)

    def set_cell_value(self, attrname: str, value: Any) -> None:
        """Set cell value to ``value`` for ``attrname``.

        By default, does a simple ``setattr()``, but it is used to allow subclasses to have
        alternative value storage mechanisms.
        """
        if attrname == "from":
            attrname = "from_"
        setattr(self, attrname, value)