details:   https://code.tryton.org/tryton/commit/a284570bb708
branch:    default
user:      Cédric Krier <[email protected]>
date:      Fri Apr 24 23:21:41 2026 +0200
description:
        Add BrowseList for Model instances

        Closes #14808
diffstat:

 trytond/CHANGELOG                          |    1 +
 trytond/doc/ref/models.rst                 |   19 +-
 trytond/trytond/model/__init__.py          |    3 +-
 trytond/trytond/model/fields/function.py   |    6 +-
 trytond/trytond/model/fields/many2many.py  |    2 +-
 trytond/trytond/model/fields/one2many.py   |    2 +-
 trytond/trytond/model/modelstorage.py      |  208 ++++++++++++++++++-
 trytond/trytond/tests/test_modelstorage.py |  297 ++++++++++++++++++++++++++++-
 8 files changed, 513 insertions(+), 25 deletions(-)

diffs (704 lines):

diff -r 570ed1f51f26 -r a284570bb708 trytond/CHANGELOG
--- a/trytond/CHANGELOG Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/CHANGELOG Fri Apr 24 23:21:41 2026 +0200
@@ -1,3 +1,4 @@
+* Add BrowseList for Model instances
 * Remove cursor_dict
 * Add row factory to cursor
 * Add filename extension to Binary field
diff -r 570ed1f51f26 -r a284570bb708 trytond/doc/ref/models.rst
--- a/trytond/doc/ref/models.rst        Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/doc/ref/models.rst        Fri Apr 24 23:21:41 2026 +0200
@@ -539,7 +539,7 @@
 
 .. classmethod:: ModelStorage.browse(ids)
 
-   Return a list of record instance for the ``ids``.
+   Return a :class:`BrowseList` of record instance for the ``ids``.
 
 .. classmethod:: ModelStorage.export_data(records, fields_names[, header])
 
@@ -881,6 +881,23 @@
    * ``begin``: optimize for constant pattern and anchored to the beginning of
      the string
 
+BrowseList
+==========
+
+.. class:: BrowseList(Model, ids)
+
+   A class inheriting from :py:class:`list <list>` that lazily stores
+   :class:`ModelStorage` instances together.
+   The instances are created and stored when accessed.
+   The mutation methods from the list accept :class:`ModelStorage` instances or
+   :py:class:`integer <int>` and always store a new instance.
+
+Instance attributes:
+
+.. method:: BrowseList.ids
+
+   The list of ids of the instances.
+
 
 convert_from
 ------------
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/model/__init__.py
--- a/trytond/trytond/model/__init__.py Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/trytond/model/__init__.py Fri Apr 24 23:21:41 2026 +0200
@@ -10,7 +10,7 @@
 from .model import Model
 from .modelsingleton import ModelSingleton
 from .modelsql import Check, Exclude, Index, ModelSQL, Unique, convert_from
-from .modelstorage import EvalEnvironment, ModelStorage
+from .modelstorage import BrowseList, EvalEnvironment, ModelStorage
 from .modelview import ModelView
 from .multivalue import MultiValueMixin, ValueMixin
 from .order import sequence_ordered, sequence_reorder, sort
@@ -20,6 +20,7 @@
 from .workflow import Workflow
 
 __all__ = [
+    BrowseList,
     ChatMixin,
     Check,
     DeactivableMixin,
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/model/fields/function.py
--- a/trytond/trytond/model/fields/function.py  Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/trytond/model/fields/function.py  Fri Apr 24 23:21:41 2026 +0200
@@ -132,14 +132,14 @@
         multiple = self.getter_multiple(method)
 
         records = Model.browse(ids)
-        for record, value in zip(records, values):
-            assert record.id == value['id']
+        for id, value in zip(records.ids, values):
+            assert id == value['id']
             for fname, val in value.items():
                 field = Model._fields.get(fname)
                 if field and field._type not in {
                         'many2one', 'reference',
                         'one2many', 'many2many', 'one2one'}:
-                    record._local_cache[record.id][fname] = val
+                    records._local_cache[id][fname] = val
 
         def call(name):
             if not instance_method:
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/model/fields/many2many.py
--- a/trytond/trytond/model/fields/many2many.py Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/trytond/model/fields/many2many.py Fri Apr 24 23:21:41 2026 +0200
@@ -154,7 +154,7 @@
         clause += [(self.target, '!=', None)]
         if self.filter:
             clause.append((self.target, 'where', self.filter))
-        to_read = [r.id for r in Relation.search(clause, order=order)]
+        to_read = Relation.search(clause, order=order).ids
         relations = {t['id']: t
             for t in Relation.read(to_read, [self.origin, self.target])}
 
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/model/fields/one2many.py
--- a/trytond/trytond/model/fields/one2many.py  Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/trytond/model/fields/one2many.py  Fri Apr 24 23:21:41 2026 +0200
@@ -162,7 +162,7 @@
             clause = [(self.field, 'in', ids)]
         if self.filter:
             clause.append(self.filter)
-        to_read = [r.id for r in Target.search(clause, order=order)]
+        to_read = Target.search(clause, order=order).ids
         targets = {t['id']: t
             for t in Target.read(to_read, ['id', self.field])}
 
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/model/modelstorage.py
--- a/trytond/trytond/model/modelstorage.py     Wed Jun 03 18:24:08 2026 +0200
+++ b/trytond/trytond/model/modelstorage.py     Fri Apr 24 23:21:41 2026 +0200
@@ -8,13 +8,14 @@
 import json
 import math
 import random
+import sys
 import time
 import warnings
 from collections import defaultdict
 from decimal import Decimal
 from functools import lru_cache
 from io import StringIO
-from itertools import chain, groupby, islice
+from itertools import chain, groupby, islice, tee
 from operator import itemgetter
 
 import trytond.config as config
@@ -36,7 +37,7 @@
 from .descriptors import dualmethod
 from .model import Model
 
-__all__ = ['ModelStorage', 'EvalEnvironment']
+__all__ = ['ModelStorage', 'BrowseList', 'EvalEnvironment']
 
 
 def local_cache(Model, transaction=None):
@@ -106,6 +107,13 @@
         return args, kwargs, context, timestamp
 
 
+def _rpc_record_ids(lst):
+    if isinstance(lst, BrowseList):
+        return lst.ids
+    else:
+        return list(map(int, lst))
+
+
 class ModelStorage(Model):
     """
     Define a model with storage capability in Tryton.
@@ -146,7 +154,7 @@
                         size_limits={
                             0: request_records_limit,
                             },
-                        result=lambda r: list(map(int, r))),
+                        result=_rpc_record_ids),
                     'read': RPC(
                         timeout=request_timeout,
                         size_limits={
@@ -171,9 +179,9 @@
                         size_limits={
                             0: request_records_limit,
                             },
-                        result=lambda r: list(map(int, r))),
+                        result=_rpc_record_ids),
                     'search': _search_RPC(
-                        result=lambda r: list(map(int, r)),
+                        result=_rpc_record_ids,
                         size_limits={
                             2: request_records_limit,
                             },
@@ -895,14 +903,7 @@
         '''
         Return a list of instance for the ids
         '''
-        transaction = Transaction()
-        ids = list(map(int, ids))
-        _local_cache = local_cache(cls, transaction)
-        transaction_cache = transaction.get_cache()
-        return [cls(x, _ids=ids,
-                _local_cache=_local_cache,
-                _transaction_cache=transaction_cache,
-                _transaction=transaction) for x in ids]
+        return BrowseList(cls, map(int, ids))
 
     def __export_row(self, fields_names):
         pool = Pool()
@@ -1835,7 +1836,7 @@
             transaction = Transaction()
         self._transaction = transaction
         self._user = transaction.user
-        self._context = transaction.context
+        self._context = kwargs.pop('_context', transaction.context)
         if id is not None:
             id = int(id)
         if _ids is not None:
@@ -1991,9 +1992,13 @@
                     yield id_
         read_size = max(1, min(
                 self._cache.size_limit, self._local_cache.size_limit))
-        index = self._ids.index(self.id)
-        ids = islice(self._ids, index, index + read_size)
-        ids = unique(filter(filter_, ids))
+        try:
+            index = self._ids.index(self.id)
+        except ValueError:
+            ids = [self.id]
+        else:
+            ids = islice(self._ids, index, index + read_size)
+            ids = unique(filter(filter_, ids))
 
         kwargs_cache = {}
         pysoned_ctx = {}
@@ -2265,6 +2270,175 @@
             records = latter
 
 
+class BrowseList(list):
+    __slots__ = (
+        '_Model', '_ids',
+        '_transaction', '_context', '_local_cache', '_transaction_cache',
+        )
+
+    def __init__(self, Model, ids):
+        super().__init__()
+        assert issubclass(Model, ModelStorage)
+        self._Model = Model
+        self._ids = list(ids)
+        self._transaction = Transaction()
+        self._context = self._transaction.context
+        self._local_cache = local_cache(Model, self._transaction)
+        self._transaction_cache = self._transaction.get_cache()
+
+    @property
+    def ids(self):
+        return self._ids.copy()
+
+    def __instantiates_idx(self, start, end):
+        return (self.__instantiate_idx(i) for i in range(start, end))
+
+    def __instantiate_idx(self, index):
+        id = self._ids[index]
+        return self.__instantiate(id)
+
+    def __instantiates(self, ids):
+        return (self.__instantiate(id) for id in ids)
+
+    def __instantiate(self, id):
+        return self._Model(
+            id,
+            _ids=self._ids,
+            _local_cache=self._local_cache,
+            _transaction_cache=self._transaction_cache,
+            _transaction=self._transaction,
+            _context=self._context)
+
+    def __check_size(self, index):
+        if index >= len(self._ids):
+            raise IndexError("list index out of range")
+
+    def __fill(self, end=None):
+        if end is None:
+            end = len(self._ids) - 1
+        else:
+            end = min(end, len(self._ids) - 1)
+        length = list.__len__(self)
+        assert end < len(self._ids)
+        if end == length:
+            list.append(self, self.__instantiate_idx(end))
+        elif end > length:
+            list.extend(self, self.__instantiates_idx(length, end + 1))
+
+    def __contains__(self, item):
+        self.__fill()
+        return super().__contains__(item)
+
+    def __delitem__(self, index):
+        self.__check_size(index)
+        self.__fill(index + 1)
+        super().__delitem__(index)
+        del self._ids[index]
+
+    def __eq__(self, value):
+        return list(self) == value
+
+    def __getitem__(self, index):
+        if isinstance(index, slice):
+            size = len(self._ids)
+            end = min(index.stop or size, size) - 1
+        else:
+            end = index
+        self.__check_size(end)
+        self.__fill(end)
+        return super().__getitem__(index)
+
+    def __iter__(self):
+        for i in range(len(self._ids)):
+            if not i % 100 and i + 100 < len(self._ids):
+                self[i:i + 100]
+            yield self[i]
+
+    def __len__(self):
+        return len(self._ids)
+
+    def __repr__(self):
+        return f'{self.__class__.__name__}({self._Model!r}, {self._ids!r})'
+
+    def __setitem__(self, index, value):
+        if isinstance(index, slice):
+            size = len(self._ids)
+            end = min(index.stop or size, size) - 1
+            id = map(int, value)
+        else:
+            end = index
+            id = int(value)
+        self.__check_size(end)
+        self.__fill(end + 1)
+        self._ids[index] = id
+        if isinstance(index, slice):
+            instance = self.__instantiates(value)
+        else:
+            instance = self.__instantiate(value)
+        super().__setitem__(index, instance)
+
+    def append(self, value):
+        self.__fill()
+        self._ids.append(int(value))
+        instance = self.__instantiate(value)
+        super().append(instance)
+
+    def clear(self):
+        super().clear()
+        self._ids = []
+
+    def copy(self):
+        self.__fill()
+        return super().copy()
+
+    def count(self, value):
+        self.__fill()
+        return super().count(value)
+
+    def extend(self, iterable):
+        iterable, iterable_ids = tee(iterable)
+        self.__fill()
+        self._ids.extend(map(int, iterable_ids))
+        super().extend(self.__instantiates(iterable))
+
+    def index(self, value, start=0, stop=sys.maxsize):
+        self.__fill(min(stop, len(self._ids)) - 1)
+        return super().index(value, start, stop)
+
+    def insert(self, index, value):
+        self.__fill(min(index, len(self._ids)))
+        self._ids.insert(index, int(value))
+        instance = self.__instantiate(value)
+        super().insert(index, instance)
+
+    def pop(self, index=-1):
+        if index < 0:
+            end = len(self.ids)
+        else:
+            end = index + 1
+        self.__fill(end)
+        super().pop(index)
+        self._ids.pop(index)
+
+    def remove(self, value):
+        if not isinstance(value, self._Model):
+            raise ValueError(f"unsupported value {value!r}")
+        self.__fill(self._ids.index(value.id) + 1)
+        super().remove(value)
+        self._ids.remove(value.id)
+
+    def reverse(self):
+        self.__fill()
+        super().reverse()
+        self._ids.reverse()
+
+    def sort(self, /, *, key=None, reverse=False):
+        self.__fill()
+        super().sort(key=key, reverse=reverse)
+        self._ids.clear()
+        self._ids.extend(map(int, list.__iter__(self)))
+
+
 class EvalEnvironment(dict):
     __slots__ = ('_record', '_model')
 
diff -r 570ed1f51f26 -r a284570bb708 trytond/trytond/tests/test_modelstorage.py
--- a/trytond/trytond/tests/test_modelstorage.py        Wed Jun 03 18:24:08 
2026 +0200
+++ b/trytond/trytond/tests/test_modelstorage.py        Fri Apr 24 23:21:41 
2026 +0200
@@ -3,7 +3,7 @@
 
 import warnings
 
-from trytond.model import EvalEnvironment
+from trytond.model import BrowseList, EvalEnvironment
 from trytond.model.exceptions import (
     AccessError, DomainValidationError, RequiredValidationError)
 from trytond.model.modelstorage import _UnsavedRecordError
@@ -768,6 +768,301 @@
         self.assertEqual(notification.description, "Long description")
 
 
+class BrowseListTestCase(TestCase):
+    "Test BrowseList"
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        activate_module('tests')
+
+    @with_transaction()
+    def test_contains(self):
+        "Test __contains__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertIn(Model(2), blist)
+
+    @with_transaction()
+    def test_delitem(self):
+        "Test __delitem__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        del blist[1]
+
+        self.assertEqual(len(blist), 2)
+        self.assertEqual(blist.ids, [1, 3])
+        self.assertEqual(blist[0]._ids, blist.ids)
+
+    @with_transaction()
+    def test_eq(self):
+        "Test __eq__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(blist, [Model(1), Model(2), Model(3)])
+
+    @with_transaction()
+    def test_getitem(self):
+        "Test __getitem__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(blist[0], Model(1))
+        self.assertEqual(blist[1], Model(2))
+        self.assertEqual(blist[2], Model(3))
+        with self.assertRaises(IndexError):
+            blist[42]
+
+    @with_transaction()
+    def test_getitem_slice(self):
+        "Test __getitem__ with slice"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(blist[:0], [])
+        self.assertEqual(blist[:10], [Model(1), Model(2), Model(3)])
+        self.assertEqual(blist[1:3], [Model(2), Model(3)])
+        self.assertEqual(blist[10:20], [])
+
+    @with_transaction()
+    def test_iter(self):
+        "Test __iter__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(blist, [Model(i) for i in range(1, 4)])
+
+    @with_transaction()
+    def test_len(self):
+        "Test __len__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(list.__len__(blist), 0)
+        self.assertEqual(len(blist), 3)
+
+        list(blist)
+        self.assertEqual(list.__len__(blist), 3)
+        self.assertEqual(len(blist), 3)
+
+    @with_transaction()
+    def test_repr(self):
+        "Test __repr__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2])
+
+        self.assertEqual(
+            repr(blist),
+            "BrowseList(<class 'trytond.pool.test.modelstorage'>, [1, 2])")
+
+    @with_transaction()
+    def test_setitem(self):
+        "Test __setitem__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        blist[1] = Model(42)
+
+        self.assertEqual(blist[1], Model(42))
+        self.assertEqual(blist.ids, [1, 42, 3])
+        self.assertEqual(blist[0]._ids, blist.ids)
+
+    @with_transaction()
+    def test_setitem_slice(self):
+        "Test __setitem__ slice"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3, 4, 5])
+
+        blist[1:4] = [Model(42)]
+
+        self.assertEqual(blist, [Model(1), Model(42), Model(5)])
+        self.assertEqual(blist.ids, [1, 42, 5])
+
+    @with_transaction()
+    def test_not_contains(self):
+        "Test not __contains__"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertNotIn(Model(42), blist)
+
+    @with_transaction()
+    def test_append(self):
+        "Test append"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1])
+        blist.append(Model(2))
+
+        self.assertEqual(blist, [Model(1), Model(2)])
+
+    @with_transaction()
+    def test_clear(self):
+        "Test clear"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        blist.clear()
+        self.assertEqual(blist, [])
+        self.assertEqual(blist.ids, [])
+
+    @with_transaction()
+    def test_copy(self):
+        "Test copy"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        blist_copy = blist.copy()
+        self.assertEqual(blist, blist_copy)
+        self.assertIsInstance(blist_copy, list)
+
+    @with_transaction()
+    def test_count(self):
+        "Test count"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3, 1])
+
+        self.assertEqual(blist.count(Model(1)), 2)
+
+    @with_transaction()
+    def test_extend(self):
+        "Test extend"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1])
+        blist.extend([Model(2), Model(3)])
+
+        self.assertEqual(blist, [Model(1), Model(2), Model(3)])
+        self.assertEqual(blist.ids, [1, 2, 3])
+
+    @with_transaction()
+    def test_index(self):
+        "Test index"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+
+        self.assertEqual(blist.index(Model(2)), 1)
+
+    @with_transaction()
+    def test_index_stop(self):
+        "Test index with stop"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3, 4, 5])
+
+        with self.assertRaises(ValueError):
+            blist.index(Model(4), 0, 2)
+        self.assertEqual(list.__len__(blist), 2)
+
+    @with_transaction()
+    def test_insert(self):
+        "Test insert"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+        blist.insert(2, Model(42))
+
+        self.assertEqual(blist, [Model(1), Model(2), Model(42), Model(3)])
+        self.assertEqual(blist.ids, [1, 2, 42, 3])
+
+    @with_transaction()
+    def test_pop(self):
+        "Test pop"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+        blist.pop(1)
+
+        self.assertEqual(blist, [Model(1), Model(3)])
+        self.assertEqual(blist.ids, [1, 3])
+
+    @with_transaction()
+    def test_pop_negative_index(self):
+        "Test pop negative index"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+        blist.pop(-2)
+
+        self.assertEqual(blist, [Model(1), Model(3)])
+        self.assertEqual(blist.ids, [1, 3])
+
+    @with_transaction()
+    def test_remove(self):
+        "Test remove"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+        blist.remove(Model(2))
+
+        self.assertEqual(blist, [Model(1), Model(3)])
+        self.assertEqual(blist.ids, [1, 3])
+
+    @with_transaction()
+    def test_reverse(self):
+        "Test reverse"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [1, 2, 3])
+        blist.reverse()
+
+        self.assertEqual(blist, [Model(3), Model(2), Model(1)])
+        self.assertEqual(blist.ids, [3, 2, 1])
+
+    @with_transaction()
+    def test_sort(self):
+        "Test sort"
+        pool = Pool()
+        Model = pool.get('test.modelstorage')
+
+        blist = BrowseList(Model, [2, 3, 1])
+        blist.sort()
+
+        self.assertEqual(blist, [Model(1), Model(2), Model(3)])
+        self.assertEqual(blist.ids, [1, 2, 3])
+
+
 class EvalEnvironmentTestCase(TestCase):
     "Test EvalEnvironment"
 

Reply via email to