From e93ecc18603f70be052abb75fbe4f99406269ff9 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:34:16 +0200 Subject: [PATCH 01/17] Usa lazy evaluation for array fields --- cvat/apps/engine/models.py | 120 +++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index aab67ac13afb..9bd9d31f5b7e 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: MIT from __future__ import annotations +from dataclasses import dataclass, field import os import re @@ -11,7 +12,7 @@ import uuid from enum import Enum from functools import cached_property -from typing import Any, Dict, Optional, Sequence +from typing import Any, Callable, Dict, Iterator, Optional, Sequence, TypeVar, overload from django.conf import settings from django.contrib.auth.models import User @@ -181,6 +182,112 @@ def choices(cls): def __str__(self): return self.value + +T = TypeVar("T") + + +@dataclass(slots=True) +class LazyList(Sequence[T]): + """ + Evaluates elements from the string representation as needed + Initial conversion is ~4 times slower than just splitting the string, but it's more memory efficient + """ + string: str + separator: str + converter: Callable[[str], T] + _computed_elements: list[T] = field(init=False, default_factory=list) + _length: int | None = field(init=False, default=None) + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> list[T]: ... + + def __getitem__(self, index: int | slice) -> T | list[T]: + if isinstance(index, slice): + self._compute_up_to(index.indices(self._compute_length())[1] - 1) + return self._computed_elements[index] + if index < 0: + index += self._compute_length() + if index < 0 or index >= self._compute_length(): + raise IndexError('index out of range') + self._compute_up_to(index) + return self._computed_elements[index] + + def __setitem__(self, index: int, value: T) -> None: + self._compute_up_to(index) + self._computed_elements[index] = value + + def __iter__(self) -> Iterator[T]: + yield from iter(self._computed_elements) + + current_index = len(self._computed_elements) + current_position = self._string_start + string_length = self._string_length + separator_offset = len(self.separator) + + while True: + end = self.string.find(self.separator, current_position, string_length) + if end == -1: + end = string_length + element_str = self.string[current_position:end] + element = self.converter(element_str) + self._computed_elements.append(element) + yield element + if end == string_length: + break + current_position = end + separator_offset + current_index += 1 + + def __len__(self) -> int: + return self._compute_length() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.string!r}, {self.separator!r}, {self.converter})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, LazyList): + return (self.converter, self.separator, self.string) == (other.converter, other.separator, other.string) + if isinstance(other, list): + return list(self) == other + return False + + def _compute_up_to(self, index: int) -> None: + start = len(self._computed_elements) + if start > index: + return + + current_position = self._string_start + string_length = self._string_length + separator_offset = len(self.separator) + + for _ in range(start): + current_position = self.string.find(self.separator, current_position) + separator_offset + + while start <= index: + end = self.string.find(self.separator, current_position, string_length) + if end == -1: + end = string_length + element_str = self.string[current_position:end] + self._computed_elements.append(self.converter(element_str)) + current_position = end + separator_offset + start += 1 + + def _compute_length(self) -> int: + if self._length is None: + self._length = self.string.count(self.separator) + 1 + return self._length + + @property + def _string_start(self) -> int: + return 1 if self.string.startswith('[') else 0 + + @property + def _string_length(self) -> int: + return len(self.string) - 1 if self.string.endswith(']') else len(self.string) + + class AbstractArrayField(models.TextField): separator = "," converter = staticmethod(lambda x: x) @@ -193,19 +300,20 @@ def __init__(self, *args, store_sorted:Optional[bool]=False, unique_values:Optio def from_db_value(self, value, expression, connection): if not value: return [] - if value.startswith('[') and value.endswith(']'): - value = value[1:-1] - return [self.converter(v) for v in value.split(self.separator) if v] + return LazyList(value, self.separator, self.converter) def to_python(self, value): - if isinstance(value, list): + if isinstance(value, list | LazyList): return value return self.from_db_value(value, None, None) def get_prep_value(self, value): + if isinstance(value, LazyList) and not (self._unique_values or self._store_sorted): + return value.string.strip("[]") + if self._unique_values: - value = list(dict.fromkeys(value)) + value = dict.fromkeys(value) if self._store_sorted: value = sorted(value) return self.separator.join(map(str, value)) From 1353ef9e11b8cde584e1e57cc4779a1272dd1a13 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Thu, 25 Jul 2024 22:55:22 +0200 Subject: [PATCH 02/17] Make LazyList subclass of builtins.list Handle list modifications and throw away used up string. --- cvat/apps/engine/models.py | 169 ++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 67 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 9bd9d31f5b7e..61bfebb8cd38 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -5,13 +5,14 @@ from __future__ import annotations from dataclasses import dataclass, field +from itertools import islice import os import re import shutil import uuid from enum import Enum -from functools import cached_property +from functools import cached_property, wraps from typing import Any, Callable, Dict, Iterator, Optional, Sequence, TypeVar, overload from django.conf import settings @@ -186,17 +187,61 @@ def __str__(self): T = TypeVar("T") +def _parse_before_accessing(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" + @wraps(fn) + def decorator(self: 'LazyList', *args, **kwargs) -> 'LazyList': + self._parse_up_to(-1) + return fn(self, *args, **kwargs) + + return decorator + + @dataclass(slots=True) -class LazyList(Sequence[T]): +class LazyList(list[T]): """ - Evaluates elements from the string representation as needed - Initial conversion is ~4 times slower than just splitting the string, but it's more memory efficient + Evaluates elements from the string representation as needed. + Lazy evaluation is supported for __getitem__ and __iter__ methods. + Using any other method will result in parsing the whole string. + Once instance of LazyList is fully parsed (either by accessing list methods + or by iterating over all elements), it will behave just as a regular python list. """ - string: str - separator: str - converter: Callable[[str], T] - _computed_elements: list[T] = field(init=False, default_factory=list) + _string: str + _separator: str + _converter: Callable[[str], T] _length: int | None = field(init=False, default=None) + parsed: bool = field(init=False, default=False) + + for method in [ + "append", + "copy", + "extend", + "insert", + "pop", + "remove", + "reverse", + "sort", + "clear", + "__setitem__", + "__delitem__", + "__eq__", + "__contains__", + "__len__", + "__hash__", + "__add__", + "__iadd__", + "__mul__", + "__rmul__", + "__imul__", + "__contains__", + "__reversed__", + "__gt__", + "__ge__", + "__lt__", + "__le__", + "__eq__", + ]: + locals()[method] = _parse_before_accessing(getattr(list, method)) @overload def __getitem__(self, index: int) -> T: ... @@ -205,88 +250,78 @@ def __getitem__(self, index: int) -> T: ... def __getitem__(self, index: slice) -> list[T]: ... def __getitem__(self, index: int | slice) -> T | list[T]: + if self.parsed: + return list.__getitem__(self, index) + if isinstance(index, slice): - self._compute_up_to(index.indices(self._compute_length())[1] - 1) - return self._computed_elements[index] - if index < 0: - index += self._compute_length() - if index < 0 or index >= self._compute_length(): - raise IndexError('index out of range') - self._compute_up_to(index) - return self._computed_elements[index] + self._parse_up_to(index.indices(self._compute_length())[1] - 1) + return list.__getitem__(self, index) - def __setitem__(self, index: int, value: T) -> None: - self._compute_up_to(index) - self._computed_elements[index] = value + self._parse_up_to(index) + return list.__getitem__(self, index) def __iter__(self) -> Iterator[T]: - yield from iter(self._computed_elements) - - current_index = len(self._computed_elements) - current_position = self._string_start - string_length = self._string_length - separator_offset = len(self.separator) - - while True: - end = self.string.find(self.separator, current_position, string_length) - if end == -1: - end = string_length - element_str = self.string[current_position:end] - element = self.converter(element_str) - self._computed_elements.append(element) - yield element - if end == string_length: - break - current_position = end + separator_offset - current_index += 1 + yield from list.__iter__(self) + yield from self._iter_unparsed() def __len__(self) -> int: + if self.parsed: + return list.__len__(self) return self._compute_length() def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.string!r}, {self.separator!r}, {self.converter})" + if self.parsed: + return list.__repr__(self) + return f"LazyList({self._separator!r}, {self._converter!r}, parsed={list.__len__(self) / self._compute_length() * 100:.02f}%)" - def __eq__(self, other: Any) -> bool: - if isinstance(other, LazyList): - return (self.converter, self.separator, self.string) == (other.converter, other.separator, other.string) - if isinstance(other, list): - return list(self) == other - return False + def _parse_up_to(self, index: int) -> None: + if self.parsed: + return - def _compute_up_to(self, index: int) -> None: - start = len(self._computed_elements) + if index < 0: + index += self._compute_length() + if index < 0 or index >= self._compute_length(): + raise IndexError('Index out of range') + + start = list.__len__(self) if start > index: return - current_position = self._string_start - string_length = self._string_length - separator_offset = len(self.separator) + end = index - start + 1 + for _ in islice(self._iter_unparsed(), end): + pass + + if index == self._compute_length() - 1: + self.parsed = True + self._string = "" # freeing the memory + + def _iter_unparsed(self): + if self.parsed: + return + current_index = list.__len__(self) + current_position = 1 if self._string.startswith('[') else 0 + string_length = len(self._string) - 1 if self._string.endswith(']') else len(self._string) + separator_offset = len(self._separator) - for _ in range(start): - current_position = self.string.find(self.separator, current_position) + separator_offset + for _ in range(current_index): + current_position = self._string.find(self._separator, current_position) + separator_offset - while start <= index: - end = self.string.find(self.separator, current_position, string_length) + while current_index < self._compute_length(): + end = self._string.find(self._separator, current_position, string_length) if end == -1: end = string_length - element_str = self.string[current_position:end] - self._computed_elements.append(self.converter(element_str)) + self.parsed = True + element = self._converter(self._string[current_position:end]) + list.append(self, element) + yield element current_position = end + separator_offset - start += 1 + current_index += 1 def _compute_length(self) -> int: if self._length is None: - self._length = self.string.count(self.separator) + 1 + self._length = self._string.count(self._separator) + 1 return self._length - @property - def _string_start(self) -> int: - return 1 if self.string.startswith('[') else 0 - - @property - def _string_length(self) -> int: - return len(self.string) - 1 if self.string.endswith(']') else len(self.string) - class AbstractArrayField(models.TextField): separator = "," @@ -309,7 +344,7 @@ def to_python(self, value): return self.from_db_value(value, None, None) def get_prep_value(self, value): - if isinstance(value, LazyList) and not (self._unique_values or self._store_sorted): + if isinstance(value, LazyList) and not value.parsed and not (self._unique_values or self._store_sorted): return value.string.strip("[]") if self._unique_values: From 91e1a763f6a14d8752ff8c6db30edc94e53eb5d8 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:25:01 +0200 Subject: [PATCH 03/17] Handle empty elements in LazyList --- cvat/apps/engine/models.py | 39 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 61bfebb8cd38..1637789c2d68 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -209,7 +209,7 @@ class LazyList(list[T]): _string: str _separator: str _converter: Callable[[str], T] - _length: int | None = field(init=False, default=None) + _probable_length: int | None = field(init=False, default=None) parsed: bool = field(init=False, default=False) for method in [ @@ -227,7 +227,6 @@ class LazyList(list[T]): "__eq__", "__contains__", "__len__", - "__hash__", "__add__", "__iadd__", "__mul__", @@ -240,6 +239,8 @@ class LazyList(list[T]): "__lt__", "__le__", "__eq__", + "___repr__", + "__len__", ]: locals()[method] = _parse_before_accessing(getattr(list, method)) @@ -254,7 +255,7 @@ def __getitem__(self, index: int | slice) -> T | list[T]: return list.__getitem__(self, index) if isinstance(index, slice): - self._parse_up_to(index.indices(self._compute_length())[1] - 1) + self._parse_up_to(index.indices(self._compute_max_length())[1] - 1) return list.__getitem__(self, index) self._parse_up_to(index) @@ -264,23 +265,18 @@ def __iter__(self) -> Iterator[T]: yield from list.__iter__(self) yield from self._iter_unparsed() - def __len__(self) -> int: - if self.parsed: - return list.__len__(self) - return self._compute_length() - def __repr__(self) -> str: if self.parsed: return list.__repr__(self) - return f"LazyList({self._separator!r}, {self._converter!r}, parsed={list.__len__(self) / self._compute_length() * 100:.02f}%)" + return f"LazyList({self._separator!r}, {self._converter!r}, parsed={list.__len__(self) / self._compute_max_length() * 100:.02f}%)" def _parse_up_to(self, index: int) -> None: if self.parsed: return if index < 0: - index += self._compute_length() - if index < 0 or index >= self._compute_length(): + index += self._compute_max_length() + if index < 0 or index >= self._compute_max_length(): raise IndexError('Index out of range') start = list.__len__(self) @@ -291,7 +287,7 @@ def _parse_up_to(self, index: int) -> None: for _ in islice(self._iter_unparsed(), end): pass - if index == self._compute_length() - 1: + if index == self._compute_max_length() - 1: self.parsed = True self._string = "" # freeing the memory @@ -306,21 +302,26 @@ def _iter_unparsed(self): for _ in range(current_index): current_position = self._string.find(self._separator, current_position) + separator_offset - while current_index < self._compute_length(): + while current_index < self._compute_max_length(): end = self._string.find(self._separator, current_position, string_length) if end == -1: end = string_length self.parsed = True - element = self._converter(self._string[current_position:end]) + + element_str = self._string[current_position:end] + current_position = end + separator_offset + if not element_str: + self._probable_length -= 1 + continue + element = self._converter(element_str) list.append(self, element) yield element - current_position = end + separator_offset current_index += 1 - def _compute_length(self) -> int: - if self._length is None: - self._length = self._string.count(self._separator) + 1 - return self._length + def _compute_max_length(self) -> int: + if self._probable_length is None: + self._probable_length = self._string.count(self._separator) + 1 + return self._probable_length class AbstractArrayField(models.TextField): From 45111580ec68240f4a03faee6c7adbb3c941eb66 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:48:47 +0200 Subject: [PATCH 04/17] Handle __add__ and __mul__ with numpy.ndarray --- cvat/apps/engine/models.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 1637789c2d68..33513aaa716d 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -189,12 +189,24 @@ def __str__(self): def _parse_before_accessing(fn: Callable[..., Any]) -> Callable[..., Any]: """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" - @wraps(fn) - def decorator(self: 'LazyList', *args, **kwargs) -> 'LazyList': - self._parse_up_to(-1) - return fn(self, *args, **kwargs) - - return decorator + if fn.__name__ in {"__add__", "__mul__"}: + @wraps(fn) + def wrapper(self: 'LazyList', other): + self._parse_up_to(-1) + if not isinstance(other, list): + # explicitly calling list.__add__ with + # np.ndarray raises TypeError instead of it returning NotImplemented + # this prevents python from executing np.ndarray.__radd__ + return NotImplemented + return fn(self, other) + else: + @wraps(fn) + def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': + self._parse_up_to(-1) + + return fn(self, *args, **kwargs) + + return wrapper @dataclass(slots=True) From b0ddf6438d11839d3039388caf59ec9d8aacb9ce Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:15:19 +0200 Subject: [PATCH 05/17] Fix LazyList.__repr__ --- cvat/apps/engine/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 33513aaa716d..be4bd1f9509e 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -251,7 +251,7 @@ class LazyList(list[T]): "__lt__", "__le__", "__eq__", - "___repr__", + "__repr__", "__len__", ]: locals()[method] = _parse_before_accessing(getattr(list, method)) @@ -277,11 +277,6 @@ def __iter__(self) -> Iterator[T]: yield from list.__iter__(self) yield from self._iter_unparsed() - def __repr__(self) -> str: - if self.parsed: - return list.__repr__(self) - return f"LazyList({self._separator!r}, {self._converter!r}, parsed={list.__len__(self) / self._compute_max_length() * 100:.02f}%)" - def _parse_up_to(self, index: int) -> None: if self.parsed: return From 078669e664f9b0a44badbdca017325f445417588 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:29:30 +0200 Subject: [PATCH 06/17] Make LaztList.string public --- cvat/apps/engine/models.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index be4bd1f9509e..1d31ec334c63 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -218,7 +218,7 @@ class LazyList(list[T]): Once instance of LazyList is fully parsed (either by accessing list methods or by iterating over all elements), it will behave just as a regular python list. """ - _string: str + string: str _separator: str _converter: Callable[[str], T] _probable_length: int | None = field(init=False, default=None) @@ -296,26 +296,26 @@ def _parse_up_to(self, index: int) -> None: if index == self._compute_max_length() - 1: self.parsed = True - self._string = "" # freeing the memory + self.string = "" # freeing the memory def _iter_unparsed(self): if self.parsed: return current_index = list.__len__(self) - current_position = 1 if self._string.startswith('[') else 0 - string_length = len(self._string) - 1 if self._string.endswith(']') else len(self._string) + current_position = 1 if self.string.startswith('[') else 0 + string_length = len(self.string) - 1 if self.string.endswith(']') else len(self.string) separator_offset = len(self._separator) for _ in range(current_index): - current_position = self._string.find(self._separator, current_position) + separator_offset + current_position = self.string.find(self._separator, current_position) + separator_offset while current_index < self._compute_max_length(): - end = self._string.find(self._separator, current_position, string_length) + end = self.string.find(self._separator, current_position, string_length) if end == -1: end = string_length self.parsed = True - element_str = self._string[current_position:end] + element_str = self.string[current_position:end] current_position = end + separator_offset if not element_str: self._probable_length -= 1 @@ -327,7 +327,7 @@ def _iter_unparsed(self): def _compute_max_length(self) -> int: if self._probable_length is None: - self._probable_length = self._string.count(self._separator) + 1 + self._probable_length = self.string.count(self._separator) + 1 return self._probable_length From 2a68e50263ad7effe4e9226b1685cc02677848d7 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Mon, 29 Jul 2024 21:32:05 +0200 Subject: [PATCH 07/17] Fix several bugs with LazyList 1. Support pickling 2. Handle cases where LazyList is extended with/added to another LazyList 3. Fix cases when unparsed LazyList was being compared result 4. Fix cases when deep copying resulted in duplicate values --- cvat/apps/engine/models.py | 230 ++++++++++++++++------- cvat/apps/engine/tests/test_lazy_list.py | 166 ++++++++++++++++ 2 files changed, 325 insertions(+), 71 deletions(-) create mode 100644 cvat/apps/engine/tests/test_lazy_list.py diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 1d31ec334c63..227d6abd87e9 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: MIT from __future__ import annotations -from dataclasses import dataclass, field + from itertools import islice import os @@ -187,29 +187,34 @@ def __str__(self): T = TypeVar("T") +def _parse_both_before_accessing(fn): + @wraps(fn) + def wrapper(self: 'LazyList', other: Any) -> 'LazyList': + self._parse_up_to(-1) + if not isinstance(other, list): + # explicitly calling list.__add__ with + # np.ndarray raises TypeError instead of it returning NotImplemented + # this prevents python from executing np.ndarray.__radd__ + return NotImplemented + if isinstance(other, LazyList): + other._parse_up_to(-1) + + return fn(self, other) + + return wrapper + + def _parse_before_accessing(fn: Callable[..., Any]) -> Callable[..., Any]: """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" - if fn.__name__ in {"__add__", "__mul__"}: - @wraps(fn) - def wrapper(self: 'LazyList', other): - self._parse_up_to(-1) - if not isinstance(other, list): - # explicitly calling list.__add__ with - # np.ndarray raises TypeError instead of it returning NotImplemented - # this prevents python from executing np.ndarray.__radd__ - return NotImplemented - return fn(self, other) - else: - @wraps(fn) - def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': - self._parse_up_to(-1) - - return fn(self, *args, **kwargs) + @wraps(fn) + def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': + self._parse_up_to(-1) + + return fn(self, *args, **kwargs) return wrapper -@dataclass(slots=True) class LazyList(list[T]): """ Evaluates elements from the string representation as needed. @@ -218,43 +223,48 @@ class LazyList(list[T]): Once instance of LazyList is fully parsed (either by accessing list methods or by iterating over all elements), it will behave just as a regular python list. """ + __slots__ = ("string", "_separator", "_converter", "_probable_length", "parsed") string: str _separator: str _converter: Callable[[str], T] - _probable_length: int | None = field(init=False, default=None) - parsed: bool = field(init=False, default=False) + _probable_length: int | None - for method in [ - "append", - "copy", - "extend", - "insert", - "pop", - "remove", - "reverse", - "sort", - "clear", - "__setitem__", - "__delitem__", - "__eq__", - "__contains__", - "__len__", - "__add__", - "__iadd__", - "__mul__", - "__rmul__", - "__imul__", - "__contains__", - "__reversed__", - "__gt__", - "__ge__", - "__lt__", - "__le__", - "__eq__", - "__repr__", - "__len__", - ]: - locals()[method] = _parse_before_accessing(getattr(list, method)) + def __init__(self, string: str = "", separator: str = ",", converter: Callable[[str], T] = lambda s: s) -> None: + super().__init__() + self.string = string + self._separator = separator + self._converter = converter + self._probable_length = None + self.parsed = False + + def __repr__(self) -> str: + if self.parsed: + return f"LazyList({list.__repr__(self)})" + current_index = list.__len__(self) + current_position = 1 if self.string.startswith('[') else 0 + separator_offset = len(self._separator) + + for _ in range(current_index): + current_position = self.string.find(self._separator, current_position) + separator_offset + + parsed_elements = list.__repr__(self).removesuffix("]") + unparsed_elements = self.string[current_position:] + return ( + f"LazyList({parsed_elements}... + {unparsed_elements}', " + f"({list.__len__(self) / self._compute_max_length(self.string) * 100:.02f}% parsed))" + ) + + def __deepcopy__(self, memodict: Any = None) -> list[T]: + """ + Since our elements are scalar, this should be sufficient + Without this, deepcopy would copy the state of the object, + then would try to append its elements. + + However, since copy will contain initial string, + it will compute its elements on the first on the first append, + resulting in value duplication. + """ + return list(self) @overload def __getitem__(self, index: int) -> T: ... @@ -267,7 +277,7 @@ def __getitem__(self, index: int | slice) -> T | list[T]: return list.__getitem__(self, index) if isinstance(index, slice): - self._parse_up_to(index.indices(self._compute_max_length())[1] - 1) + self._parse_up_to(index.indices(self._compute_max_length(self.string))[1] - 1) return list.__getitem__(self, index) self._parse_up_to(index) @@ -282,54 +292,132 @@ def _parse_up_to(self, index: int) -> None: return if index < 0: - index += self._compute_max_length() - if index < 0 or index >= self._compute_max_length(): - raise IndexError('Index out of range') + index += self._compute_max_length(self.string) start = list.__len__(self) if start > index: return - end = index - start + 1 - for _ in islice(self._iter_unparsed(), end): + for _ in islice(self._iter_unparsed(), end + 1): pass - if index == self._compute_max_length() - 1: - self.parsed = True - self.string = "" # freeing the memory + if index == self._compute_max_length(self.string) - 1: + self._mark_parsed() + + def _mark_parsed(self): + self.parsed = True + self.string = "" # freeing the memory def _iter_unparsed(self): if self.parsed: return + string = self.string current_index = list.__len__(self) - current_position = 1 if self.string.startswith('[') else 0 - string_length = len(self.string) - 1 if self.string.endswith(']') else len(self.string) + current_position = 1 if string.startswith('[') else 0 + string_length = len(string) - 1 if string.endswith(']') else len(string) separator_offset = len(self._separator) for _ in range(current_index): - current_position = self.string.find(self._separator, current_position) + separator_offset + current_position = string.find(self._separator, current_position) + separator_offset - while current_index < self._compute_max_length(): - end = self.string.find(self._separator, current_position, string_length) + while current_index < self._compute_max_length(string): + end = string.find(self._separator, current_position, string_length) if end == -1: end = string_length - self.parsed = True + self._mark_parsed() - element_str = self.string[current_position:end] + element_str = string[current_position:end] current_position = end + separator_offset if not element_str: self._probable_length -= 1 continue element = self._converter(element_str) - list.append(self, element) + if list.__len__(self) <= current_index: + # We need to handle special case when instance of lazy list becomes parsed after + # this function is called: + # ll = LazyList("1,2,3", _converter=int) + # iterator = iter(ll) + # next(iterator) # > 1 (will generate next element and append to self) + # list(ll) # > [1, 2, 3] + # next(iterator) # > 2 (will generate next element, however will not append it) + # assert list(ll) == [1, 2, 3] + list.append(self, element) yield element current_index += 1 - def _compute_max_length(self) -> int: + def _compute_max_length(self, string) -> int: if self._probable_length is None: - self._probable_length = self.string.count(self._separator) + 1 + if not self.string: + return 0 + self._probable_length = string.count(self._separator) + 1 return self._probable_length + # support pickling + + def __reduce__(self): + return self.__class__, (self.string, self._separator, self._converter), self.__getstate__() + + def __reduce_ex__(self, protocol: int): + return self.__reduce__() + + def __getstate__(self): + return { + 'string': self.string, + '_separator': self._separator, + '_converter': self._converter, + '_probable_length': self._probable_length, + 'parsed': self.parsed, + 'parsed_elements': list(self) if self.parsed else None + } + + def __setstate__(self, state): + self.string = state['string'] + self._separator = state['_separator'] + self._converter = state['_converter'] + self._probable_length = state['_probable_length'] + self.parsed = state['parsed'] + if self.parsed: + self.extend(state['parsed_elements']) + + # add pre-parse for list methods + for method in [ + "append", + "copy", + "insert", + "pop", + "remove", + "reverse", + "sort", + "clear", + "index", + "count", + "__setitem__", + "__delitem__", + "__contains__", + "__len__", + "__contains__", + "__reversed__", + "__mul__", + "__rmul__", + "__imul__", + ]: + locals()[method] = _parse_before_accessing(getattr(list, method)) + + for method in [ + "extend", + "__add__", + "__eq__", + "__iadd__", + "__gt__", + "__ge__", + "__lt__", + "__le__", + "__eq__", + ]: + locals()[method] = _parse_both_before_accessing(getattr(list, method)) + + del method + class AbstractArrayField(models.TextField): separator = "," @@ -343,7 +431,7 @@ def __init__(self, *args, store_sorted:Optional[bool]=False, unique_values:Optio def from_db_value(self, value, expression, connection): if not value: return [] - return LazyList(value, self.separator, self.converter) + return LazyList(string=value, separator=self.separator, converter=self.converter) def to_python(self, value): if isinstance(value, list | LazyList): diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py new file mode 100644 index 000000000000..8e1c486d342c --- /dev/null +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -0,0 +1,166 @@ +import unittest +import copy +import pickle +from typing import TypeVar +from cvat.apps.engine.models import LazyList + + +T = TypeVar('T') + + +class TestLazyList(unittest.TestCase): + + def setUp(self): + self.lazy_list = LazyList(string="1,2,3", converter=int) + + def test_skipped_values(self): + ll = LazyList("1,2,,4", converter=int) + self.assertEqual(len(ll), 3) + self.assertEqual(ll, [1, 2, 4]) + + def test_len(self): + self.assertEqual(len(self.lazy_list), 3) + list(self.lazy_list) + self.assertEqual(len(self.lazy_list), 3) + + def test_repr(self): + self.assertEqual(repr(self.lazy_list), "LazyList([... + 1,2,3', (0.00% parsed))") + next(iter(self.lazy_list)) # Trigger parsing of the first element + self.assertIn("1... + 2,3", repr(self.lazy_list)) + list(self.lazy_list) + self.assertEqual(repr(self.lazy_list), "LazyList([1, 2, 3])") + + def test_deepcopy(self): + copied_list = copy.deepcopy(self.lazy_list) + self.assertEqual(copied_list, [1, 2, 3]) + self.assertNotEquals(id(copied_list), id(self.lazy_list)) + self.assertEqual(len(copied_list), 3) + + def test_getitem(self): + self.assertEqual(self.lazy_list[1], 2) + self.assertEqual(self.lazy_list[:2], [1, 2]) + + def test_iter(self): + iterator = iter(self.lazy_list) + self.assertEqual(next(iterator), 1) + self.assertEqual(next(iterator), 2) + self.assertEqual(list(self.lazy_list), [1, 2, 3]) + self.assertEqual(next(iterator), 3) + self.assertEqual(list(self.lazy_list), [1, 2, 3]) + + def test_append(self): + self.lazy_list.append(4) + self.assertEqual(self.lazy_list, [1, 2, 3, 4]) + + def test_copy(self): + copied_list = self.lazy_list.copy() + self.assertEqual(copied_list, [1, 2, 3]) + + def test_insert(self): + self.lazy_list.insert(0, 0) + self.assertEqual(self.lazy_list, [0, 1, 2, 3]) + + def test_pop(self): + value = self.lazy_list.pop() + self.assertEqual(value, 3) + self.assertEqual(self.lazy_list, [1, 2]) + + def test_remove(self): + self.lazy_list.remove(2) + self.assertEqual(self.lazy_list, [1, 3]) + + def test_reverse(self): + self.lazy_list.reverse() + self.assertEqual(self.lazy_list, [3, 2, 1]) + + def test_sort(self): + unsorted_list = LazyList(string="3,1,2", converter=int) + unsorted_list.sort() + self.assertEqual(unsorted_list, [1, 2, 3]) + + def test_clear(self): + self.lazy_list.clear() + self.assertEqual(len(self.lazy_list), 0) + + def test_index(self): + self.assertEqual(self.lazy_list.index(2), 1) + + def test_count(self): + self.assertEqual(self.lazy_list.count(2), 1) + + def test_setitem(self): + self.lazy_list[0] = 4 + self.assertEqual(self.lazy_list[0], 4) + + def test_delitem(self): + del self.lazy_list[0] + self.assertEqual(self.lazy_list, [2, 3]) + + def test_contains(self): + self.assertIn(2, self.lazy_list) + + def test_reversed(self): + self.assertEqual(list(reversed(self.lazy_list)), [3, 2, 1]) + + def test_mul(self): + self.assertEqual(self.lazy_list * 2, [1, 2, 3, 1, 2, 3]) + + def test_rmul(self): + self.assertEqual(2 * self.lazy_list, [1, 2, 3, 1, 2, 3]) + + def test_imul(self): + self.lazy_list *= 2 + self.assertEqual(self.lazy_list, [1, 2, 3, 1, 2, 3]) + + def test_extend(self): + self.lazy_list.extend([4, 5]) + self.assertEqual(self.lazy_list, [1, 2, 3, 4, 5]) + + def test_add(self): + new_list = self.lazy_list + [4, 5] + self.assertEqual(new_list, [1, 2, 3, 4, 5]) + + def test_eq(self): + self.assertTrue(self.lazy_list == [1, 2, 3]) + + def test_iadd(self): + self.lazy_list += [4, 5] + self.assertEqual(self.lazy_list, [1, 2, 3, 4, 5]) + + def test_gt(self): + self.assertTrue(self.lazy_list > [1, 2]) + + def test_ge(self): + self.assertTrue(self.lazy_list >= [1, 2, 3]) + + def test_lt(self): + self.assertTrue(self.lazy_list < [1, 2, 3, 4]) + + def test_le(self): + self.assertTrue(self.lazy_list <= [1, 2, 3]) + + def test_lazy_list_with_lazy_list(self): + other_lazy_list = LazyList(string="4,5,6", converter=int) + combined_list = self.lazy_list + other_lazy_list + self.assertEqual(combined_list, [1, 2, 3, 4, 5, 6]) + + def test_pickle_support(self): + pickled = pickle.dumps(self.lazy_list) + unpickled = pickle.loads(pickled) + self.assertEqual(unpickled, [1, 2, 3]) + self.assertEqual(unpickled.string, "") + self.assertTrue(unpickled.parsed) + + def test_parse_before_accessing_decorator(self): + lazy_list_copy = LazyList(string="1,2,3", converter=int) + lazy_list_copy.append(4) + self.assertEqual(lazy_list_copy, [1, 2, 3, 4]) + + def test_parse_both_before_accessing_decorator(self): + other_list = LazyList(string="4,5", converter=int) + result = self.lazy_list + other_list + self.assertEqual(result, [1, 2, 3, 4, 5]) + + +if __name__ == "__main__": + unittest.main() From 16dd491c8856dcb703165f37731b547cba6caf20 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:11:23 +0200 Subject: [PATCH 08/17] Bound T to int float or str --- cvat/apps/engine/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 227d6abd87e9..dfa9df95b56e 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -184,7 +184,7 @@ def __str__(self): return self.value -T = TypeVar("T") +T = TypeVar("T", bound=int | float | str) def _parse_both_before_accessing(fn): From 1084fe72109208830289807182217ef29df04376 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:24:11 +0200 Subject: [PATCH 09/17] Avoid using locals() and give list decorators more descriptive names --- cvat/apps/engine/models.py | 106 ++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index dfa9df95b56e..ab8b02b611c9 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -187,35 +187,84 @@ def __str__(self): T = TypeVar("T", bound=int | float | str) -def _parse_both_before_accessing(fn): - @wraps(fn) +def _parse_self_and_other_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: + @wraps(list_method) def wrapper(self: 'LazyList', other: Any) -> 'LazyList': self._parse_up_to(-1) + if isinstance(other, LazyList): + other._parse_up_to(-1) if not isinstance(other, list): # explicitly calling list.__add__ with # np.ndarray raises TypeError instead of it returning NotImplemented # this prevents python from executing np.ndarray.__radd__ return NotImplemented - if isinstance(other, LazyList): - other._parse_up_to(-1) - return fn(self, other) + return list_method(self, other) return wrapper -def _parse_before_accessing(fn: Callable[..., Any]) -> Callable[..., Any]: +def _parse_self_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" - @wraps(fn) + @wraps(list_method) def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': self._parse_up_to(-1) - return fn(self, *args, **kwargs) + return list_method(self, *args, **kwargs) return wrapper -class LazyList(list[T]): +class LazyListMeta(type): + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + ): + # add pre-parse for list methods + for method_name in [ + "append", + "copy", + "insert", + "pop", + "remove", + "reverse", + "sort", + "clear", + "index", + "count", + "__setitem__", + "__delitem__", + "__contains__", + "__len__", + "__reversed__", + "__mul__", + "__rmul__", + "__imul__", + ]: + namespace[method_name] = _parse_self_before_accessing( + getattr(list, method_name) + ) + + for method_name in [ + "extend", + "__add__", + "__iadd__", + "__eq__", + "__gt__", + "__ge__", + "__lt__", + "__le__", + ]: + namespace[method_name] = _parse_self_and_other_before_accessing( + getattr(list, method_name) + ) + + return super().__new__(mcs, name, bases, namespace) + + +class LazyList(list[T], metaclass=LazyListMeta): """ Evaluates elements from the string representation as needed. Lazy evaluation is supported for __getitem__ and __iter__ methods. @@ -379,45 +428,6 @@ def __setstate__(self, state): if self.parsed: self.extend(state['parsed_elements']) - # add pre-parse for list methods - for method in [ - "append", - "copy", - "insert", - "pop", - "remove", - "reverse", - "sort", - "clear", - "index", - "count", - "__setitem__", - "__delitem__", - "__contains__", - "__len__", - "__contains__", - "__reversed__", - "__mul__", - "__rmul__", - "__imul__", - ]: - locals()[method] = _parse_before_accessing(getattr(list, method)) - - for method in [ - "extend", - "__add__", - "__eq__", - "__iadd__", - "__gt__", - "__ge__", - "__lt__", - "__le__", - "__eq__", - ]: - locals()[method] = _parse_both_before_accessing(getattr(list, method)) - - del method - class AbstractArrayField(models.TextField): separator = "," From a176a15297ab2c80c93b44fbd6ca02fe9d4ebfde Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:29:11 +0200 Subject: [PATCH 10/17] Test len() on LazyList during iteration --- cvat/apps/engine/tests/test_lazy_list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index 8e1c486d342c..2e804144806a 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -161,6 +161,14 @@ def test_parse_both_before_accessing_decorator(self): result = self.lazy_list + other_list self.assertEqual(result, [1, 2, 3, 4, 5]) + def test_length_on_iteration(self): + elements = [] + for element in self.lazy_list: + self.assertEqual(len(self.lazy_list), 3) + elements.append(element) + + self.assertEqual(elements, [1, 2, 3]) + if __name__ == "__main__": unittest.main() From cc6d8079e577c34a2ffce06b3e2df4b08b40ddca Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:43:18 +0200 Subject: [PATCH 11/17] Add __str__ method for LazyList --- cvat/apps/engine/models.py | 9 +++++++-- cvat/apps/engine/tests/test_lazy_list.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index ab8b02b611c9..8900d8cd5638 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -336,6 +336,11 @@ def __iter__(self) -> Iterator[T]: yield from list.__iter__(self) yield from self._iter_unparsed() + def __str__(self) -> str: + if not self.parsed: + return self.string.strip("[]") + return self._separator.join(map(str, self)) + def _parse_up_to(self, index: int) -> None: if self.parsed: return @@ -450,8 +455,8 @@ def to_python(self, value): return self.from_db_value(value, None, None) def get_prep_value(self, value): - if isinstance(value, LazyList) and not value.parsed and not (self._unique_values or self._store_sorted): - return value.string.strip("[]") + if isinstance(value, LazyList) and not (self._unique_values or self._store_sorted): + return str(value) if self._unique_values: value = dict.fromkeys(value) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index 2e804144806a..840219cec755 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -169,6 +169,15 @@ def test_length_on_iteration(self): self.assertEqual(elements, [1, 2, 3]) + def test_str(self): + self.assertEqual(str(self.lazy_list), "1,2,3") + self.assertEqual(self.lazy_list, LazyList(str(self.lazy_list), converter=int)) + + def test_str_parsed(self): + list(self.lazy_list) + self.assertEqual(str(self.lazy_list), "1,2,3") + self.assertEqual(self.lazy_list, LazyList(str(self.lazy_list), converter=int)) + if __name__ == "__main__": unittest.main() From a5087987c88c09f8ea318f60362defeb6fc09f88 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:47:51 +0200 Subject: [PATCH 12/17] Make string and parsed private, remove redundant code --- cvat/apps/engine/models.py | 60 +++++++++++------------- cvat/apps/engine/tests/test_lazy_list.py | 4 +- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 8900d8cd5638..b053b85909e9 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -272,35 +272,31 @@ class LazyList(list[T], metaclass=LazyListMeta): Once instance of LazyList is fully parsed (either by accessing list methods or by iterating over all elements), it will behave just as a regular python list. """ - __slots__ = ("string", "_separator", "_converter", "_probable_length", "parsed") - string: str - _separator: str - _converter: Callable[[str], T] - _probable_length: int | None + __slots__ = ("_string", "_separator", "_converter", "_probable_length", "_parsed") def __init__(self, string: str = "", separator: str = ",", converter: Callable[[str], T] = lambda s: s) -> None: super().__init__() - self.string = string + self._string = string self._separator = separator self._converter = converter - self._probable_length = None - self.parsed = False + self._probable_length: int | None = None + self._parsed: bool = False def __repr__(self) -> str: - if self.parsed: + if self._parsed: return f"LazyList({list.__repr__(self)})" current_index = list.__len__(self) - current_position = 1 if self.string.startswith('[') else 0 + current_position = 1 if self._string.startswith('[') else 0 separator_offset = len(self._separator) for _ in range(current_index): - current_position = self.string.find(self._separator, current_position) + separator_offset + current_position = self._string.find(self._separator, current_position) + separator_offset parsed_elements = list.__repr__(self).removesuffix("]") - unparsed_elements = self.string[current_position:] + unparsed_elements = self._string[current_position:] return ( f"LazyList({parsed_elements}... + {unparsed_elements}', " - f"({list.__len__(self) / self._compute_max_length(self.string) * 100:.02f}% parsed))" + f"({list.__len__(self) / self._compute_max_length(self._string) * 100:.02f}% parsed))" ) def __deepcopy__(self, memodict: Any = None) -> list[T]: @@ -322,11 +318,11 @@ def __getitem__(self, index: int) -> T: ... def __getitem__(self, index: slice) -> list[T]: ... def __getitem__(self, index: int | slice) -> T | list[T]: - if self.parsed: + if self._parsed: return list.__getitem__(self, index) if isinstance(index, slice): - self._parse_up_to(index.indices(self._compute_max_length(self.string))[1] - 1) + self._parse_up_to(index.indices(self._compute_max_length(self._string))[1] - 1) return list.__getitem__(self, index) self._parse_up_to(index) @@ -337,16 +333,16 @@ def __iter__(self) -> Iterator[T]: yield from self._iter_unparsed() def __str__(self) -> str: - if not self.parsed: - return self.string.strip("[]") + if not self._parsed: + return self._string.strip("[]") return self._separator.join(map(str, self)) def _parse_up_to(self, index: int) -> None: - if self.parsed: + if self._parsed: return if index < 0: - index += self._compute_max_length(self.string) + index += self._compute_max_length(self._string) start = list.__len__(self) if start > index: @@ -355,17 +351,17 @@ def _parse_up_to(self, index: int) -> None: for _ in islice(self._iter_unparsed(), end + 1): pass - if index == self._compute_max_length(self.string) - 1: + if index == self._compute_max_length(self._string) - 1: self._mark_parsed() def _mark_parsed(self): - self.parsed = True - self.string = "" # freeing the memory + self._parsed = True + self._string = "" # freeing the memory def _iter_unparsed(self): - if self.parsed: + if self._parsed: return - string = self.string + string = self._string current_index = list.__len__(self) current_position = 1 if string.startswith('[') else 0 string_length = len(string) - 1 if string.endswith(']') else len(string) @@ -401,7 +397,7 @@ def _iter_unparsed(self): def _compute_max_length(self, string) -> int: if self._probable_length is None: - if not self.string: + if not self._string: return 0 self._probable_length = string.count(self._separator) + 1 return self._probable_length @@ -409,28 +405,28 @@ def _compute_max_length(self, string) -> int: # support pickling def __reduce__(self): - return self.__class__, (self.string, self._separator, self._converter), self.__getstate__() + return self.__class__, (self._string, self._separator, self._converter), self.__getstate__() def __reduce_ex__(self, protocol: int): return self.__reduce__() def __getstate__(self): return { - 'string': self.string, + 'string': self._string, '_separator': self._separator, '_converter': self._converter, '_probable_length': self._probable_length, - 'parsed': self.parsed, - 'parsed_elements': list(self) if self.parsed else None + 'parsed': self._parsed, + 'parsed_elements': list(self) if self._parsed else None } def __setstate__(self, state): - self.string = state['string'] + self._string = state['string'] self._separator = state['_separator'] self._converter = state['_converter'] self._probable_length = state['_probable_length'] - self.parsed = state['parsed'] - if self.parsed: + self._parsed = state['parsed'] + if self._parsed: self.extend(state['parsed_elements']) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index 840219cec755..ae638d5580ce 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -148,8 +148,8 @@ def test_pickle_support(self): pickled = pickle.dumps(self.lazy_list) unpickled = pickle.loads(pickled) self.assertEqual(unpickled, [1, 2, 3]) - self.assertEqual(unpickled.string, "") - self.assertTrue(unpickled.parsed) + self.assertEqual(unpickled._string, "") + self.assertTrue(unpickled._parsed) def test_parse_before_accessing_decorator(self): lazy_list_copy = LazyList(string="1,2,3", converter=int) From bb67f89dd7feebc825af7cee54ce1f15c50d2561 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:48:12 +0200 Subject: [PATCH 13/17] Fix DeprecationWarning --- cvat/apps/engine/tests/test_lazy_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index ae638d5580ce..62b0b52a07c8 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -33,7 +33,7 @@ def test_repr(self): def test_deepcopy(self): copied_list = copy.deepcopy(self.lazy_list) self.assertEqual(copied_list, [1, 2, 3]) - self.assertNotEquals(id(copied_list), id(self.lazy_list)) + self.assertNotEqual(id(copied_list), id(self.lazy_list)) self.assertEqual(len(copied_list), 3) def test_getitem(self): From 03bd48e8f73652b60119d3e5941ab9f7e69b83b3 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Wed, 31 Jul 2024 19:01:23 +0200 Subject: [PATCH 14/17] Move LazyList to a separate module --- cvat/apps/engine/lazy_list.py | 254 +++++++++++++++++++++++ cvat/apps/engine/models.py | 253 +--------------------- cvat/apps/engine/tests/test_lazy_list.py | 2 +- 3 files changed, 258 insertions(+), 251 deletions(-) create mode 100644 cvat/apps/engine/lazy_list.py diff --git a/cvat/apps/engine/lazy_list.py b/cvat/apps/engine/lazy_list.py new file mode 100644 index 000000000000..743ec2df3752 --- /dev/null +++ b/cvat/apps/engine/lazy_list.py @@ -0,0 +1,254 @@ +from functools import wraps +from itertools import islice +from typing import Any +from typing import Callable +from typing import Iterator + +from typing import TypeVar +from typing import overload + + +T = TypeVar("T", bound=int | float | str) + + +def _parse_self_and_other_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: + @wraps(list_method) + def wrapper(self: 'LazyList', other: Any) -> 'LazyList': + self._parse_up_to(-1) + if isinstance(other, LazyList): + other._parse_up_to(-1) + if not isinstance(other, list): + # explicitly calling list.__add__ with + # np.ndarray raises TypeError instead of it returning NotImplemented + # this prevents python from executing np.ndarray.__radd__ + return NotImplemented + + return list_method(self, other) + + return wrapper + + +def _parse_self_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: + """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" + @wraps(list_method) + def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': + self._parse_up_to(-1) + + return list_method(self, *args, **kwargs) + + return wrapper + + +class LazyListMeta(type): + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + ): + # add pre-parse for list methods + for method_name in [ + "append", + "copy", + "insert", + "pop", + "remove", + "reverse", + "sort", + "clear", + "index", + "count", + "__setitem__", + "__delitem__", + "__contains__", + "__len__", + "__reversed__", + "__mul__", + "__rmul__", + "__imul__", + ]: + namespace[method_name] = _parse_self_before_accessing( + getattr(list, method_name) + ) + + for method_name in [ + "extend", + "__add__", + "__iadd__", + "__eq__", + "__gt__", + "__ge__", + "__lt__", + "__le__", + ]: + namespace[method_name] = _parse_self_and_other_before_accessing( + getattr(list, method_name) + ) + + return super().__new__(mcs, name, bases, namespace) + + +class LazyList(list[T], metaclass=LazyListMeta): + """ + Evaluates elements from the string representation as needed. + Lazy evaluation is supported for __getitem__ and __iter__ methods. + Using any other method will result in parsing the whole string. + Once instance of LazyList is fully parsed (either by accessing list methods + or by iterating over all elements), it will behave just as a regular python list. + """ + __slots__ = ("_string", "_separator", "_converter", "_probable_length", "_parsed") + + def __init__(self, string: str = "", separator: str = ",", converter: Callable[[str], T] = lambda s: s) -> None: + super().__init__() + self._string = string + self._separator = separator + self._converter = converter + self._probable_length: int | None = None + self._parsed: bool = False + + def __repr__(self) -> str: + if self._parsed: + return f"LazyList({list.__repr__(self)})" + current_index = list.__len__(self) + current_position = 1 if self._string.startswith('[') else 0 + separator_offset = len(self._separator) + + for _ in range(current_index): + current_position = self._string.find(self._separator, current_position) + separator_offset + + parsed_elements = list.__repr__(self).removesuffix("]") + unparsed_elements = self._string[current_position:] + return ( + f"LazyList({parsed_elements}... + {unparsed_elements}', " + f"({list.__len__(self) / self._compute_max_length(self._string) * 100:.02f}% parsed))" + ) + + def __deepcopy__(self, memodict: Any = None) -> list[T]: + """ + Since our elements are scalar, this should be sufficient + Without this, deepcopy would copy the state of the object, + then would try to append its elements. + + However, since copy will contain initial string, + it will compute its elements on the first on the first append, + resulting in value duplication. + """ + return list(self) + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> list[T]: ... + + def __getitem__(self, index: int | slice) -> T | list[T]: + if self._parsed: + return list.__getitem__(self, index) + + if isinstance(index, slice): + self._parse_up_to(index.indices(self._compute_max_length(self._string))[1] - 1) + return list.__getitem__(self, index) + + self._parse_up_to(index) + return list.__getitem__(self, index) + + def __iter__(self) -> Iterator[T]: + yield from list.__iter__(self) + yield from self._iter_unparsed() + + def __str__(self) -> str: + if not self._parsed: + return self._string.strip("[]") + return self._separator.join(map(str, self)) + + def _parse_up_to(self, index: int) -> None: + if self._parsed: + return + + if index < 0: + index += self._compute_max_length(self._string) + + start = list.__len__(self) + if start > index: + return + end = index - start + 1 + for _ in islice(self._iter_unparsed(), end + 1): + pass + + if index == self._compute_max_length(self._string) - 1: + self._mark_parsed() + + def _mark_parsed(self): + self._parsed = True + self._string = "" # freeing the memory + + def _iter_unparsed(self): + if self._parsed: + return + string = self._string + current_index = list.__len__(self) + current_position = 1 if string.startswith('[') else 0 + string_length = len(string) - 1 if string.endswith(']') else len(string) + separator_offset = len(self._separator) + + for _ in range(current_index): + current_position = string.find(self._separator, current_position) + separator_offset + + while current_index < self._compute_max_length(string): + end = string.find(self._separator, current_position, string_length) + if end == -1: + end = string_length + self._mark_parsed() + + element_str = string[current_position:end] + current_position = end + separator_offset + if not element_str: + self._probable_length -= 1 + continue + element = self._converter(element_str) + if list.__len__(self) <= current_index: + # We need to handle special case when instance of lazy list becomes parsed after + # this function is called: + # ll = LazyList("1,2,3", _converter=int) + # iterator = iter(ll) + # next(iterator) # > 1 (will generate next element and append to self) + # list(ll) # > [1, 2, 3] + # next(iterator) # > 2 (will generate next element, however will not append it) + # assert list(ll) == [1, 2, 3] + list.append(self, element) + yield element + current_index += 1 + + def _compute_max_length(self, string) -> int: + if self._probable_length is None: + if not self._string: + return 0 + self._probable_length = string.count(self._separator) + 1 + return self._probable_length + + # support pickling + + def __reduce__(self): + return self.__class__, (self._string, self._separator, self._converter), self.__getstate__() + + def __reduce_ex__(self, protocol: int): + return self.__reduce__() + + def __getstate__(self): + return { + 'string': self._string, + '_separator': self._separator, + '_converter': self._converter, + '_probable_length': self._probable_length, + 'parsed': self._parsed, + 'parsed_elements': list(self) if self._parsed else None + } + + def __setstate__(self, state): + self._string = state['string'] + self._separator = state['_separator'] + self._converter = state['_converter'] + self._probable_length = state['_probable_length'] + self._parsed = state['parsed'] + if self._parsed: + self.extend(state['parsed_elements']) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index b053b85909e9..74d6d5247303 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -5,15 +5,13 @@ from __future__ import annotations -from itertools import islice - import os import re import shutil import uuid from enum import Enum -from functools import cached_property, wraps -from typing import Any, Callable, Dict, Iterator, Optional, Sequence, TypeVar, overload +from functools import cached_property +from typing import Any, Dict, Optional, Sequence from django.conf import settings from django.contrib.auth.models import User @@ -24,6 +22,7 @@ from django.db.models import Q from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field +from cvat.apps.engine.lazy_list import LazyList from cvat.apps.engine.utils import parse_specific_attributes from cvat.apps.events.utils import cache_deleted @@ -184,252 +183,6 @@ def __str__(self): return self.value -T = TypeVar("T", bound=int | float | str) - - -def _parse_self_and_other_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: - @wraps(list_method) - def wrapper(self: 'LazyList', other: Any) -> 'LazyList': - self._parse_up_to(-1) - if isinstance(other, LazyList): - other._parse_up_to(-1) - if not isinstance(other, list): - # explicitly calling list.__add__ with - # np.ndarray raises TypeError instead of it returning NotImplemented - # this prevents python from executing np.ndarray.__radd__ - return NotImplemented - - return list_method(self, other) - - return wrapper - - -def _parse_self_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: - """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" - @wraps(list_method) - def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': - self._parse_up_to(-1) - - return list_method(self, *args, **kwargs) - - return wrapper - - -class LazyListMeta(type): - def __new__( - mcs, - name: str, - bases: tuple[type, ...], - namespace: dict[str, Any], - ): - # add pre-parse for list methods - for method_name in [ - "append", - "copy", - "insert", - "pop", - "remove", - "reverse", - "sort", - "clear", - "index", - "count", - "__setitem__", - "__delitem__", - "__contains__", - "__len__", - "__reversed__", - "__mul__", - "__rmul__", - "__imul__", - ]: - namespace[method_name] = _parse_self_before_accessing( - getattr(list, method_name) - ) - - for method_name in [ - "extend", - "__add__", - "__iadd__", - "__eq__", - "__gt__", - "__ge__", - "__lt__", - "__le__", - ]: - namespace[method_name] = _parse_self_and_other_before_accessing( - getattr(list, method_name) - ) - - return super().__new__(mcs, name, bases, namespace) - - -class LazyList(list[T], metaclass=LazyListMeta): - """ - Evaluates elements from the string representation as needed. - Lazy evaluation is supported for __getitem__ and __iter__ methods. - Using any other method will result in parsing the whole string. - Once instance of LazyList is fully parsed (either by accessing list methods - or by iterating over all elements), it will behave just as a regular python list. - """ - __slots__ = ("_string", "_separator", "_converter", "_probable_length", "_parsed") - - def __init__(self, string: str = "", separator: str = ",", converter: Callable[[str], T] = lambda s: s) -> None: - super().__init__() - self._string = string - self._separator = separator - self._converter = converter - self._probable_length: int | None = None - self._parsed: bool = False - - def __repr__(self) -> str: - if self._parsed: - return f"LazyList({list.__repr__(self)})" - current_index = list.__len__(self) - current_position = 1 if self._string.startswith('[') else 0 - separator_offset = len(self._separator) - - for _ in range(current_index): - current_position = self._string.find(self._separator, current_position) + separator_offset - - parsed_elements = list.__repr__(self).removesuffix("]") - unparsed_elements = self._string[current_position:] - return ( - f"LazyList({parsed_elements}... + {unparsed_elements}', " - f"({list.__len__(self) / self._compute_max_length(self._string) * 100:.02f}% parsed))" - ) - - def __deepcopy__(self, memodict: Any = None) -> list[T]: - """ - Since our elements are scalar, this should be sufficient - Without this, deepcopy would copy the state of the object, - then would try to append its elements. - - However, since copy will contain initial string, - it will compute its elements on the first on the first append, - resulting in value duplication. - """ - return list(self) - - @overload - def __getitem__(self, index: int) -> T: ... - - @overload - def __getitem__(self, index: slice) -> list[T]: ... - - def __getitem__(self, index: int | slice) -> T | list[T]: - if self._parsed: - return list.__getitem__(self, index) - - if isinstance(index, slice): - self._parse_up_to(index.indices(self._compute_max_length(self._string))[1] - 1) - return list.__getitem__(self, index) - - self._parse_up_to(index) - return list.__getitem__(self, index) - - def __iter__(self) -> Iterator[T]: - yield from list.__iter__(self) - yield from self._iter_unparsed() - - def __str__(self) -> str: - if not self._parsed: - return self._string.strip("[]") - return self._separator.join(map(str, self)) - - def _parse_up_to(self, index: int) -> None: - if self._parsed: - return - - if index < 0: - index += self._compute_max_length(self._string) - - start = list.__len__(self) - if start > index: - return - end = index - start + 1 - for _ in islice(self._iter_unparsed(), end + 1): - pass - - if index == self._compute_max_length(self._string) - 1: - self._mark_parsed() - - def _mark_parsed(self): - self._parsed = True - self._string = "" # freeing the memory - - def _iter_unparsed(self): - if self._parsed: - return - string = self._string - current_index = list.__len__(self) - current_position = 1 if string.startswith('[') else 0 - string_length = len(string) - 1 if string.endswith(']') else len(string) - separator_offset = len(self._separator) - - for _ in range(current_index): - current_position = string.find(self._separator, current_position) + separator_offset - - while current_index < self._compute_max_length(string): - end = string.find(self._separator, current_position, string_length) - if end == -1: - end = string_length - self._mark_parsed() - - element_str = string[current_position:end] - current_position = end + separator_offset - if not element_str: - self._probable_length -= 1 - continue - element = self._converter(element_str) - if list.__len__(self) <= current_index: - # We need to handle special case when instance of lazy list becomes parsed after - # this function is called: - # ll = LazyList("1,2,3", _converter=int) - # iterator = iter(ll) - # next(iterator) # > 1 (will generate next element and append to self) - # list(ll) # > [1, 2, 3] - # next(iterator) # > 2 (will generate next element, however will not append it) - # assert list(ll) == [1, 2, 3] - list.append(self, element) - yield element - current_index += 1 - - def _compute_max_length(self, string) -> int: - if self._probable_length is None: - if not self._string: - return 0 - self._probable_length = string.count(self._separator) + 1 - return self._probable_length - - # support pickling - - def __reduce__(self): - return self.__class__, (self._string, self._separator, self._converter), self.__getstate__() - - def __reduce_ex__(self, protocol: int): - return self.__reduce__() - - def __getstate__(self): - return { - 'string': self._string, - '_separator': self._separator, - '_converter': self._converter, - '_probable_length': self._probable_length, - 'parsed': self._parsed, - 'parsed_elements': list(self) if self._parsed else None - } - - def __setstate__(self, state): - self._string = state['string'] - self._separator = state['_separator'] - self._converter = state['_converter'] - self._probable_length = state['_probable_length'] - self._parsed = state['parsed'] - if self._parsed: - self.extend(state['parsed_elements']) - - class AbstractArrayField(models.TextField): separator = "," converter = staticmethod(lambda x: x) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index 62b0b52a07c8..52c2f979bf4f 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -2,7 +2,7 @@ import copy import pickle from typing import TypeVar -from cvat.apps.engine.models import LazyList +from cvat.apps.engine.lazy_list import LazyList T = TypeVar('T') From 9cdfe8a56828914b4e6cfd53147ba23151b1a2f4 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Thu, 1 Aug 2024 12:44:27 +0200 Subject: [PATCH 15/17] Add license header --- cvat/apps/engine/lazy_list.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cvat/apps/engine/lazy_list.py b/cvat/apps/engine/lazy_list.py index 743ec2df3752..fcfbe94c525e 100644 --- a/cvat/apps/engine/lazy_list.py +++ b/cvat/apps/engine/lazy_list.py @@ -1,3 +1,7 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + from functools import wraps from itertools import islice from typing import Any From f50b9e4bc166cd56dc1524d2cd5334b41403f84a Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:29:27 +0200 Subject: [PATCH 16/17] Reformat lazy_list.py, use attrs --- cvat/apps/engine/lazy_list.py | 66 ++++++++++++++++------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/cvat/apps/engine/lazy_list.py b/cvat/apps/engine/lazy_list.py index fcfbe94c525e..21b5959c22d5 100644 --- a/cvat/apps/engine/lazy_list.py +++ b/cvat/apps/engine/lazy_list.py @@ -4,20 +4,17 @@ from functools import wraps from itertools import islice -from typing import Any -from typing import Callable -from typing import Iterator - -from typing import TypeVar -from typing import overload +from typing import Any, Callable, Iterator, TypeVar, overload +import attrs +from attr import field T = TypeVar("T", bound=int | float | str) def _parse_self_and_other_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: @wraps(list_method) - def wrapper(self: 'LazyList', other: Any) -> 'LazyList': + def wrapper(self: "LazyList", other: Any) -> "LazyList": self._parse_up_to(-1) if isinstance(other, LazyList): other._parse_up_to(-1) @@ -34,8 +31,9 @@ def wrapper(self: 'LazyList', other: Any) -> 'LazyList': def _parse_self_before_accessing(list_method: Callable[..., Any]) -> Callable[..., Any]: """Wrapper for original list methods. Forces LazyList to parse itself before accessing them.""" + @wraps(list_method) - def wrapper(self: 'LazyList', *args, **kwargs) -> 'LazyList': + def wrapper(self: "LazyList", *args, **kwargs) -> "LazyList": self._parse_up_to(-1) return list_method(self, *args, **kwargs) @@ -71,9 +69,7 @@ def __new__( "__rmul__", "__imul__", ]: - namespace[method_name] = _parse_self_before_accessing( - getattr(list, method_name) - ) + namespace[method_name] = _parse_self_before_accessing(getattr(list, method_name)) for method_name in [ "extend", @@ -92,6 +88,7 @@ def __new__( return super().__new__(mcs, name, bases, namespace) +@attrs.define(slots=True, repr=False) class LazyList(list[T], metaclass=LazyListMeta): """ Evaluates elements from the string representation as needed. @@ -100,25 +97,24 @@ class LazyList(list[T], metaclass=LazyListMeta): Once instance of LazyList is fully parsed (either by accessing list methods or by iterating over all elements), it will behave just as a regular python list. """ - __slots__ = ("_string", "_separator", "_converter", "_probable_length", "_parsed") - def __init__(self, string: str = "", separator: str = ",", converter: Callable[[str], T] = lambda s: s) -> None: - super().__init__() - self._string = string - self._separator = separator - self._converter = converter - self._probable_length: int | None = None - self._parsed: bool = False + _string: str = "" + _separator: str = "," + _converter: Callable[[str], T] = lambda s: s + _probable_length: int | None = field(init=False, default=None) + _parsed: bool = field(init=False, default=False) def __repr__(self) -> str: if self._parsed: return f"LazyList({list.__repr__(self)})" current_index = list.__len__(self) - current_position = 1 if self._string.startswith('[') else 0 + current_position = 1 if self._string.startswith("[") else 0 separator_offset = len(self._separator) for _ in range(current_index): - current_position = self._string.find(self._separator, current_position) + separator_offset + current_position = ( + self._string.find(self._separator, current_position) + separator_offset + ) parsed_elements = list.__repr__(self).removesuffix("]") unparsed_elements = self._string[current_position:] @@ -191,8 +187,8 @@ def _iter_unparsed(self): return string = self._string current_index = list.__len__(self) - current_position = 1 if string.startswith('[') else 0 - string_length = len(string) - 1 if string.endswith(']') else len(string) + current_position = 1 if string.startswith("[") else 0 + string_length = len(string) - 1 if string.endswith("]") else len(string) separator_offset = len(self._separator) for _ in range(current_index): @@ -240,19 +236,19 @@ def __reduce_ex__(self, protocol: int): def __getstate__(self): return { - 'string': self._string, - '_separator': self._separator, - '_converter': self._converter, - '_probable_length': self._probable_length, - 'parsed': self._parsed, - 'parsed_elements': list(self) if self._parsed else None + "string": self._string, + "_separator": self._separator, + "_converter": self._converter, + "_probable_length": self._probable_length, + "parsed": self._parsed, + "parsed_elements": list(self) if self._parsed else None, } def __setstate__(self, state): - self._string = state['string'] - self._separator = state['_separator'] - self._converter = state['_converter'] - self._probable_length = state['_probable_length'] - self._parsed = state['parsed'] + self._string = state["string"] + self._separator = state["_separator"] + self._converter = state["_converter"] + self._probable_length = state["_probable_length"] + self._parsed = state["parsed"] if self._parsed: - self.extend(state['parsed_elements']) + self.extend(state["parsed_elements"]) From dcf4b3dda792b53fe22f027209d7c9b2381fc8fa Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:38:14 +0200 Subject: [PATCH 17/17] Add lazy_list.py to format_python_code.sh --- cvat-cli/pyproject.toml | 6 ------ cvat-sdk/pyproject.toml | 6 ------ cvat/apps/analytics_report/pyproject.toml | 6 ------ cvat/apps/quality_control/pyproject.toml | 6 ------ dev/format_python_code.sh | 1 + pyproject.toml | 9 +++++++++ tests/python/pyproject.toml | 6 ------ 7 files changed, 10 insertions(+), 30 deletions(-) create mode 100644 pyproject.toml diff --git a/cvat-cli/pyproject.toml b/cvat-cli/pyproject.toml index 67280a49cac1..ce8cba3ffba6 100644 --- a/cvat-cli/pyproject.toml +++ b/cvat-cli/pyproject.toml @@ -7,9 +7,3 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38'] diff --git a/cvat-sdk/pyproject.toml b/cvat-sdk/pyproject.toml index 67280a49cac1..ce8cba3ffba6 100644 --- a/cvat-sdk/pyproject.toml +++ b/cvat-sdk/pyproject.toml @@ -7,9 +7,3 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38'] diff --git a/cvat/apps/analytics_report/pyproject.toml b/cvat/apps/analytics_report/pyproject.toml index 567b78362580..b74ee17d74aa 100644 --- a/cvat/apps/analytics_report/pyproject.toml +++ b/cvat/apps/analytics_report/pyproject.toml @@ -4,9 +4,3 @@ forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black known_first_party = ["cvat"] - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38'] diff --git a/cvat/apps/quality_control/pyproject.toml b/cvat/apps/quality_control/pyproject.toml index 567b78362580..b74ee17d74aa 100644 --- a/cvat/apps/quality_control/pyproject.toml +++ b/cvat/apps/quality_control/pyproject.toml @@ -4,9 +4,3 @@ forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black known_first_party = ["cvat"] - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38'] diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index a67bf08572e7..9483c5fbcb2e 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -23,6 +23,7 @@ for paths in \ "tests/python/" \ "cvat/apps/quality_control" \ "cvat/apps/analytics_report" \ + "cvat/apps/engine/lazy_list.py" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000000..581552a67ebc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.isort] +profile = "black" +forced_separate = ["tests"] +line_length = 100 +skip_gitignore = true # align tool behavior with Black + +[tool.black] +line-length = 100 +target-version = ['py38'] diff --git a/tests/python/pyproject.toml b/tests/python/pyproject.toml index e91d405b8e20..ab4db6695977 100644 --- a/tests/python/pyproject.toml +++ b/tests/python/pyproject.toml @@ -3,9 +3,3 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38']