Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/4221.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ListSource and TreeSource now allow mapping-based data to be used without explicitly providing accessors.
39 changes: 26 additions & 13 deletions core/src/toga/sources/list_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
def _find_item(
candidates: Sequence[T],
data: object,
accessors: Sequence[str],
accessors: Sequence[str] | None,
start: T | None,
error: str,
) -> T:
Expand All @@ -29,6 +29,9 @@ def _find_item(
else:
start_index = 0

if accessors is None and not isinstance(data, Mapping):
raise ValueError("Cannot search for non-mapping data without accessors")

for item in candidates[start_index:]:
try:
if isinstance(data, Mapping):
Expand Down Expand Up @@ -103,23 +106,28 @@ def __delattr__(self, attr: str) -> None:

class ListSource(Source):
_data: list[Row]
_accessors: list[str] | None

def __init__(self, accessors: Iterable[str], data: Iterable | None = None):
def __init__(
self, accessors: Iterable[str] | None = None, data: Iterable | None = None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor code style issue - we try to avoid the "all args on one separate line" format, even though it's legal from Ruff's perspective. We prefer to go directly to "one arg per line":

Suggested change
self, accessors: Iterable[str] | None = None, data: Iterable | None = None
self,
accessors: Iterable[str] | None = None,
data: Iterable | None = None

):
"""A data source to store an ordered list of multiple data values.

:param accessors: A list of attribute names for accessing the value
in each column of the row.
in each column of the row. If omitted, only mapping row data can be
converted.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
in each column of the row. If omitted, only mapping row data can be
converted.
in each column of the row. If omitted, row data must be specified as a
mapping.

:param data: The initial list of items in the source. Items are converted as
shown [above][listsource-item].
"""
super().__init__()
if isinstance(accessors, str) or not hasattr(accessors, "__iter__"):
if accessors is None:
self._accessors = None
elif isinstance(accessors, str) or not hasattr(accessors, "__iter__"):
raise ValueError("accessors should be a list of attribute names")

# Copy the list of accessors
self._accessors = list(accessors)
if len(self._accessors) == 0:
raise ValueError("ListSource must be provided a list of accessors")
else:
# Copy the list of accessors
accessors = list(accessors)
self._accessors = accessors if len(accessors) > 0 else None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an edge case; but for consistency, I'd argue an empty list should be interpreted as a list of no accessors, rather than "accessors haven't been provided". That would technically allow construction of "empty" Row objects from list-like data. That isn't really useful, but it's internally consistent (and the implementation is simpler as well).

Suggested change
accessors = list(accessors)
self._accessors = accessors if len(accessors) > 0 else None
self._accessors = list(accessors)


# Convert the data into row objects
if data is not None:
Expand All @@ -128,8 +136,10 @@ def __init__(self, accessors: Iterable[str], data: Iterable | None = None):
self._data = []

@property
def accessors(self) -> list[str]:
def accessors(self) -> list[str] | None:
"""The attribute names for accessing the value in each column of a row."""
if self._accessors is None:
return None
return self._accessors.copy()

######################################################################
Expand Down Expand Up @@ -158,10 +168,13 @@ def __delitem__(self, index: int) -> None:
def _create_row(self, data: object) -> Row:
if isinstance(data, Mapping):
row = Row(**data)
elif hasattr(data, "__iter__") and not isinstance(data, str):
row = Row(**dict(zip(self._accessors, data, strict=False)))
elif self._accessors is not None:
if hasattr(data, "__iter__") and not isinstance(data, str):
row = Row(**dict(zip(self._accessors, data, strict=False)))
else:
row = Row(**{self._accessors[0]: data})
else:
row = Row(**{self._accessors[0]: data})
raise ValueError("ListSource requires accessors for non-mapping row data")
row._source = self
return row

Expand Down
31 changes: 20 additions & 11 deletions core/src/toga/sources/tree_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,25 +204,31 @@ def find(self, data: object, start: Node[T] | None = None) -> Node[T]:

class TreeSource(Source):
_roots: list[Node]
_accessors: list[str] | None

def __init__(self, accessors: Iterable[str], data: object | None = None):
def __init__(
self, accessors: Iterable[str] | None = None, data: object | None = None
):
super().__init__()
if isinstance(accessors, str) or not hasattr(accessors, "__iter__"):
if accessors is None:
self._accessors = None
elif isinstance(accessors, str) or not hasattr(accessors, "__iter__"):
raise ValueError("accessors should be a list of attribute names")

# Copy the list of accessors
self._accessors = list(accessors)
if len(self._accessors) == 0:
raise ValueError("TreeSource must be provided a list of accessors")
else:
# Copy the list of accessors, in case of [] its None.
accessors = list(accessors)
self._accessors = accessors if len(accessors) > 0 else None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with list source; this can be simplified for consistency.


if data is not None:
self._roots = self._create_nodes(parent=None, value=data)
else:
self._roots = []

@property
def accessors(self) -> list[str]:
def accessors(self) -> list[str] | None:
"""The attribute names for accessing the value in each column of a row."""
if self._accessors is None:
return None
return self._accessors.copy()

######################################################################
Expand Down Expand Up @@ -253,10 +259,13 @@ def _create_node(
) -> Node:
if isinstance(data, Mapping):
node = Node(**data)
elif hasattr(data, "__iter__") and not isinstance(data, str):
node = Node(**dict(zip(self._accessors, data, strict=False)))
elif self._accessors is not None:
if hasattr(data, "__iter__") and not isinstance(data, str):
node = Node(**dict(zip(self._accessors, data, strict=False)))
else:
node = Node(**{self._accessors[0]: data})
else:
node = Node(**{self._accessors[0]: data})
raise ValueError("TreeSource requires accessors for non-mapping node data")

node._parent = parent
node._source = self
Expand Down
47 changes: 43 additions & 4 deletions core/tests/sources/test_list_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def source():
@pytest.mark.parametrize(
"value",
[
None,
42,
"not a list",
],
Expand All @@ -34,15 +33,55 @@ def test_invalid_accessors(value):
ListSource(accessors=value)


def test_accessors_required():
"""A list source must specify *some* accessors."""
def test_accessors_optional_for_mapping_data():
"""A list source can omit accessors if rows are mapping-based."""
source = ListSource(data=[{"value": 1}], accessors=[])

assert len(source) == 1
assert source.accessors is None
assert source[0].value == 1


def test_accessors_omitted_for_mapping_data():
"""A list source defaults to no accessors when omitted."""
source = ListSource(data=[{"value": 1}])

assert len(source) == 1
assert source.accessors is None
assert source[0].value == 1


def test_non_mapping_data_requires_accessors():
"""A list source without accessors rejects non-mapping row data."""
with pytest.raises(
ValueError,
match=r"ListSource must be provided a list of accessors",
match=r"ListSource requires accessors for non-mapping row data",
):
ListSource(accessors=[], data=[1, 2, 3])


def test_non_mapping_setitem_requires_accessors():
"""Setting non-mapping row data requires accessors."""
source = ListSource(data=[{"value": 1}])

with pytest.raises(
ValueError,
match=r"ListSource requires accessors for non-mapping row data",
):
source[0] = ("new", 2)


def test_non_mapping_find_requires_accessors():
"""Finding non-mapping row data requires accessors."""
source = ListSource(data=[{"value": 1}])

with pytest.raises(
ValueError,
match=r"Cannot search for non-mapping data without accessors",
):
source.find(1)


def test_accessors_copied():
"""A list source must specify *some* accessors."""
accessors = ["foo", "bar"]
Expand Down
60 changes: 55 additions & 5 deletions core/tests/sources/test_tree_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ def source(listener):
@pytest.mark.parametrize(
"value",
[
None,
42,
"not a list",
],
Expand All @@ -73,13 +72,64 @@ def test_invalid_accessors(value):
TreeSource(accessors=value)


def test_accessors_required():
"""A list source must specify *some* accessors."""
def test_accessors_optional_for_mapping_data():
"""A tree source can omit accessors if node data is mapping-based."""
source = TreeSource(
accessors=[],
data=[
(
{"value": "root"},
[
({"value": "child"}, None),
],
)
],
)

assert len(source) == 1
assert source.accessors is None
assert source[0].value == "root"
assert source[0][0].value == "child"


def test_accessors_omitted_for_mapping_data():
"""A tree source defaults to no accessors when omitted."""
source = TreeSource(data=[({"value": "root"}, None)])

assert len(source) == 1
assert source.accessors is None
assert source[0].value == "root"


def test_non_mapping_data_requires_accessors():
"""A tree source without accessors rejects non-mapping node data."""
with pytest.raises(
ValueError,
match=r"TreeSource requires accessors for non-mapping node data",
):
TreeSource(accessors=[], data=[(("root", 1), None)])


def test_non_mapping_insert_requires_accessors():
"""Inserting non-mapping node data requires accessors."""
source = TreeSource(data=[({"value": "root"}, None)])

with pytest.raises(
ValueError,
match=r"TreeSource requires accessors for non-mapping node data",
):
source.insert(0, ("new", 2))


def test_scalar_insert_requires_accessors():
"""Inserting scalar node data requires accessors."""
source = TreeSource(data=[({"value": "root"}, None)])

with pytest.raises(
ValueError,
match=r"TreeSource must be provided a list of accessors",
match=r"TreeSource requires accessors for non-mapping node data",
):
TreeSource(accessors=[], data=[1, 2, 3])
source.insert(0, "new")


def test_accessors_copied():
Expand Down
4 changes: 2 additions & 2 deletions docs/en/reference/api/data-representation/listsource.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Data sources are abstractions that allow you to define the data being managed by your application independent of the GUI representation of that data. For details on the use of data sources, see the [topic guide](/topics/data-sources.md).

ListSource is an implementation of an ordered list of data. When a ListSource is created, it is given a list of `accessors` - these are the attributes that all items managed by the ListSource will have. The API provided by ListSource is [`list`][]-like; the operations you'd expect on a normal Python list, such as `insert`, `remove`, `index`, and indexing with `[]`, are also possible on a ListSource:
ListSource is an implementation of an ordered list of data. When a ListSource is created, it is given a list of `accessors` - these are the attributes that all items managed by the ListSource will have. If no accessors are provided, or an empty sequence is given, the source stores `None`. The API provided by ListSource is [`list`][]-like; the operations you'd expect on a normal Python list, such as `insert`, `remove`, `index`, and indexing with `[]`, are also possible on a ListSource:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an accurate description of the implementation, but that doesn't describe how or why you'd use a non-accessor ListSource. The docs should be focussed on usage.


```python
from toga.sources import ListSource
Expand Down Expand Up @@ -34,7 +34,7 @@ source.insert(0, {"name": "Bettong", "weight": 1.2})

[](){ #listsource-item }

The ListSource manages a list of [`Row`][toga.sources.Row] objects. Each Row has all the attributes described by the source's `accessors`. A Row object will be constructed for each item that is added to the ListSource, and each item can be:
The ListSource manages a list of [`Row`][toga.sources.Row] objects. Each Row has all the attributes described by the source's `accessors`. If accessors are omitted, or an empty sequence is provided, `accessors` will be `None`. A Row object will be constructed for each item that is added to the ListSource, and each item can be:

- A dictionary, with the accessors mapping to the keys in the dictionary.
- Any other iterable object (except for a string), with the accessors being mapped onto the items in the iterable in order of definition.
Expand Down
8 changes: 4 additions & 4 deletions docs/en/reference/api/data-representation/treesource.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Data sources are abstractions that allow you to define the data being managed by your application independent of the GUI representation of that data. For details on the use of data sources, see the [topic guide](/topics/data-sources.md).

TreeSource is an implementation of an ordered hierarchical tree of values. When a TreeSource is created, it is given a list of `accessors` - these are the attributes that all items managed by the TreeSource will have. The API provided by TreeSource is [`list`][]-like; the operations you'd expect on a normal Python list, such as `insert`, `remove`, `index`, and indexing with `[]`, are also possible on a TreeSource. These methods are available on the TreeSource itself to manipulate root nodes, and also on each node within the tree.
TreeSource is an implementation of an ordered hierarchical tree of values. When a TreeSource is created, it is given a list of `accessors` - these are the attributes that all items managed by the TreeSource will have. If no accessors are provided, or an empty sequence is given, the source stores `None`. The API provided by TreeSource is [`list`][]-like; the operations you'd expect on a normal Python list, such as `insert`, `remove`, `index`, and indexing with `[]`, are also possible on a TreeSource. These methods are available on the TreeSource itself to manipulate root nodes, and also on each node within the tree.

```python
from toga.sources import TreeSource
Expand Down Expand Up @@ -54,9 +54,9 @@ Each Node object in the TreeSource can have children; those children can in turn

When creating a single Node for a TreeSource (e.g., when inserting a new item), the data for the Node can be specified as:

- A dictionary, with the accessors mapping to the keys in the dictionary
- Any iterable object (except for a string), with the accessors being mapped onto the items in the iterable in order of definition.
- Any other object, which will be mapped onto the *first* accessor.
- A dictionary, with the accessors mapping to the keys in the dictionary. If accessors are omitted, or an empty sequence is given, the keys are used directly and `accessors` will be `None`.
- Any iterable object (except for a string), with the accessors being mapped onto the items in the iterable in order of definition. Accessors are required for this type of data.
- Any other object, which will be mapped onto the *first* accessor. Accessors are required for this type of data.

When constructing an entire TreeSource, the data can be specified as:

Expand Down