Skip to content

Collections

API Reference

syncwave.SyncCollection

This page introduces the reactive collections: SyncDict, SyncList, and SyncSet. You'll see the lifecycle of their instances, how they nest into each other, and how each of the three works.

Why Reactive Collections

Skip

This is just a reminder of the previous page. Feel free to skip if you just read it.

A store holding a plain list is limited:

main.py
from syncwave import Syncwave

syncwave = Syncwave()

numbers = syncwave.create_store(list[int], name="numbers")

numbers.append(1)  # changes a copy, not the store
syncwave["numbers"].append(2)  # also a copy
print(syncwave["numbers"])  # still []

Reads hand out copies, and in-place changes never reach the store. With plain types, the only way to change the store is to assign a whole new value, and you see the current data by reading fresh through the instance.

Now declare the same store with SyncList, the reactive counterpart of list:

main.py
from syncwave import SyncList, Syncwave

syncwave = Syncwave()

numbers = syncwave.create_store(SyncList[int], name="numbers")

numbers.append(1)
syncwave["numbers"].append(2)

print(syncwave["numbers"])  # [1, 2]
print(numbers)  # [1, 2]

Both append calls reached the store and its JSON file, and numbers is a live object that follows every change, including edits made to the file while the program runs.

The SyncCollection Types

SyncList is one of three reactive collections, the counterparts of Python's built-in containers:

  • SyncDict for dict
  • SyncList for list
  • SyncSet for set

They behave like the originals, except that every change is validated against the declared type and synced to the store's JSON file, in both directions.

The three of them share a common (virtual) base class, SyncCollection, which is mostly intended for static typing and for runtime checks, such as:

isinstance(numbers, SyncCollection)  # True
issubclass(SyncList, SyncCollection)  # True
issubclass(SyncCollection, Reactive)  # True

See the discussion at the end of The Reactive Types if you think there should be more collection types.

Instance Lifecycle

A reactive object is always in one of three states: inert, live, or dead. The sync_state property tells you which one.

Inert

You can create a reactive collection with the same arguments as its regular counterpart:

main.py
from syncwave import SyncList

numbers = SyncList([1, 2, 3])
numbers.append(4)

print(numbers)  # [1, 2, 3, 4]
print(numbers.sync_state)  # SyncState.INERT

An object created this way is inert. It isn't connected to any store, and it behaves exactly like a regular list. Nothing is validated either, so type arguments aren't enforced: SyncList[int](["a"]) works.

Live

A live object belongs to a store. The value returned by create_store is one:

main.py
from syncwave import SyncList, Syncwave

syncwave = Syncwave()

numbers = syncwave.create_store(SyncList[int], name="numbers")
print(numbers.sync_state)  # SyncState.LIVE

When you call create_store, an initial value (coming from the file or default) is loaded, and when that value enters the store it becomes a live SyncList. Mutating numbers updates the store and its JSON file, and changes to the file update numbers. Reading the store, like syncwave["numbers"], gives you that same live object.

If you want to change the whole store, just assign a regular list where a reactive list is expected, and Syncwave knows what to do with it:

syncwave["numbers"] = [1, 2, 3]  # assign a regular list

Here syncwave["numbers"] was already a SyncList, so the assignment didn't create a new one: Syncwave updated the existing instance in place with the new values. That's how the variable numbers stays connected; more formally, numbers is syncwave["numbers"] is still True. See Updates Happen In Place for more details.

You can assign an inert SyncList too, but just like a regular list, it's copied on the way in. The store keeps its own live object, and yours stays inert:

inert_list = SyncList([1, 2, 3])
syncwave["numbers"] = inert_list

inert_list.append(4)  # doesn't reach the store
print(syncwave["numbers"])  # [1, 2, 3]
print(inert_list.sync_state)  # SyncState.INERT

An inert object never becomes live. To get a live object, read it from the store.

Dead

A live object becomes dead when it's removed from the store. Deleting the whole store is the simplest example:

numbers = syncwave["numbers"]
del syncwave["numbers"]

print(numbers.sync_state)  # SyncState.DEAD
numbers.append(4)
syncwave.errors.DeadReferenceError: Operation attempted on a dead reference: <[1, 2, 3] (dead)>

A dead object is permanently disconnected, and using it raises a DeadReferenceError. If it kept accepting changes, it would silently drift away from the file, and that kind of bug is hard to track down.

To check whether an object is connected, use the sync_live property. It's True for a live object, and False for an inert or a dead one.

This section used a SyncList, but the same is true for SyncDict and SyncSet. Since an inert collection is copied on the way in anyway, regular values (dict, list, set) do the job just as well when you insert data.

Nesting

Reactive collections compose. A store can be a dictionary of lists, a list of sets, and so on, and reactivity reaches all the way down:

main.py
from syncwave import SyncDict, SyncList, Syncwave

syncwave = Syncwave()

playlists = syncwave.create_store(SyncDict[str, SyncList[str]], name="playlists")

playlists["synthwave"] = []  # a regular list, converted to a SyncList
playlists["synthwave"].append("Nightcall — Kavinsky")  # reactive, synced

print(playlists)  # {'synthwave': <['Nightcall — Kavinsky'] (live)>}
syncstores/playlists.json
{
  "synthwave": ["Nightcall — Kavinsky"]
}

This is what's generally referred to as "deep reactivity". The store's type is SyncDict[str, SyncList[str]]: the outer SyncDict holding the playlists is reactive, and so is each individual playlist. That's what lets the append call reach the store; it wouldn't have if the store had been declared as SyncDict[str, list[str]].

One rule to keep in mind: the chain of reactive types must be unbroken from the store root down. A reactive collection cannot live inside a plain one, because the plain container would swallow the changes. Syncwave rejects such types upfront:

syncwave.create_store(list[SyncList[int]], name="bad")
TypeError: `SyncList` cannot be used here: `list` is not a reactive container.

The reverse is fine: a plain type inside a reactive collection (like SyncList[list[int]]) is valid; the chain simply ends there. The inner lists are ordinary Python objects with the usual restriction: change them by assignment, not in place.

That restriction should look familiar. The copy behavior described in the previous page was never specific to the syncwave instance. Every reactive container treats the non-reactive values it holds the same way: a read hands you a copy, a write keeps one. In a SyncList[list[int]] store, reading store[0] gives you a copy of the inner list, and assigning a list to store[0] keeps one. The syncwave instance was simply the first reactive container you met. Wherever the reactive chain ends, the same pattern holds: change plain values by assigning through their closest reactive parent, and read them fresh when you need the current data.

Deep Instance Creation

Note how in the above example, the plain [] assigned to playlists["synthwave"] became a live SyncList on the way in. What you saw in Live happens at every level, not just at the store root.

This works at any depth, and with more complex data too; the appropriate reactive objects are always constructed on the way in.

Say you receive some plain data from elsewhere in your program; you can insert it as is:

incoming_data = {"road_trip": ["Sweet Virginia — The Rolling Stones"]}
playlists.update(incoming_data)

Just like that, the new playlist was added to the store as an actual SyncList, so it's reactive:

playlists["road_trip"].append("Dreams — Fleetwood Mac")

Assuming syncstores/playlists.json contained the data from the previous example, it should now be:

syncstores/playlists.json
{
  "synthwave": ["Nightcall — Kavinsky"],
  "road_trip": ["Sweet Virginia — The Rolling Stones", "Dreams — Fleetwood Mac"]
}

Keeping Deep References

You already saw that you can keep a whole store in a variable (a reference), like playlists above: mutating it updates the file, and it stays synchronized with the file in return.

The same is true for reactive values deeper in the store: with nesting, a store becomes a tree of reactive objects, and any of them can be held in a variable, no matter how deep it sits:

road_trip = playlists["road_trip"]
road_trip.append("Bones — The Killers")  # synced, same as through `playlists`

Just like a store-level variable, road_trip stays connected in both directions for as long as the value lives in the store. Test it: edit the "road_trip" songs in syncstores/playlists.json and the road_trip variable follows, not just playlists or syncwave["playlists"].

This is all pretty intuitive for the items under a SyncDict, but it's a bit less so in the case of SyncList and SyncSet. Make sure you read Position-Based Identity and Hashable Items to understand their less obvious behaviors.

If you delete the whole entry, either with del playlists["road_trip"] or by removing it from the file, road_trip becomes dead, and so does every reactive object inside it.

There would be more to say about which objects die and when, but these concepts apply to all reactive types, not just reactive collections, so let's leave it at that. Reactivity covers everything.

SyncDict

SyncDict is the reactive counterpart of dict. It can be parameterized with a key type and a value type, e.g. SyncDict[str, int], or it can be used bare. Here's how to use it, for example to hold some simple settings:

main.py
from syncwave import SyncDict, Syncwave

syncwave = Syncwave()

settings = syncwave.create_store(SyncDict[str, int], name="settings")
settings["volume"] = 8
settings["brightness"] = 5

print(settings)  # {'volume': 8, 'brightness': 5}

Run the program, then open the file:

syncstores/settings.json
{
  "volume": 8,
  "brightness": 5
}

The usual dict operations (get, items, update, deletions, iteration, and so on) all work, and every mutation is synced.

Key Types

The keys of a JSON object are always strings. That puts three requirements on the key type of anything that serializes to a JSON object: SyncDict, a regular dict, or a subclass of either. The key type must:

  1. produce a valid JSON object key when serialized to a string,
  2. deserialize back to an equal value (it must round-trip through a string),
  3. be hashable, like any Python dict key.

Syncwave checks the key type when the store is created. str qualifies trivially, but so do int, float, UUID, datetime, etc.

What is accepted

The following types are all accepted:

str, int, float, bool, bytes, Decimal, Pattern, Path, date, datetime, time, timedelta, UUID, IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network, ByteSize.

Literal is accepted if all its members are strings (members of a StrEnum, or of an Enum subclassing str, count as strings). For instance, Literal["light", "dark"] is fine, but Literal[1, 2] and Literal[1, "other"] aren't.

Enums are accepted with a few more conditions: the enum must subclass str, int, or float (like IntEnum), or have all string values (values taken from a different StrEnum, or from an Enum subclassing str, count as strings). Flag enums are always rejected.

The conversion is transparent. With a SyncDict[int, list[int]] you use integer keys in Python, while the file stores strings. Here's an example of that with a directed acyclic graph (DAG):

dag = syncwave.create_store(SyncDict[int, list[int]], name="dag")
dag[1] = [2, 3]
print(dag[1])  # [2, 3]
syncstores/dag.json
{
  "1": [2, 3]
}

The round-trip requirement is also what rules out some types. Take a union:

syncwave.create_store(SyncDict[int | str, int], name="ambiguous")

int and str are both valid key types on their own, but together they make every key ambiguous: when Syncwave reads "1" from the file, should it become store[1] or store["1"]? There's no way to tell, so the type is rejected when the store is created.

typing.Any is excluded as a key type for the same reason. This means that a bare SyncDict (without type parameters) is interpreted as SyncDict[str, Any]. The values can be anything, but the keys stay strings. For example:

any_dict = syncwave.create_store(SyncDict, name="any_dict")

any_dict["str_key"] = "str_value"  # good
any_dict["1"] = 123  # also good
any_dict[1] = 2  # will raise an error: the store is implicitly `SyncDict[str, Any]`

To be clear, these restrictions only apply to the key type, not to the values of the SyncDict. For example, SyncDict[int | str, int] is not valid, but SyncDict[int, str | int] is perfectly fine.

SyncList

SyncList is the reactive counterpart of list. It can be parameterized with the type of its items, e.g. SyncList[str], or it can be used bare. Here's how to use it, for example to hold some tags:

main.py
from syncwave import SyncList, Syncwave

syncwave = Syncwave()

tags = syncwave.create_store(SyncList[str], name="tags")
tags.append("python")
tags.extend(["json", "reactive"])
print(tags)  # ['python', 'json', 'reactive']

del tags[0]
print(tags)  # ['json', 'reactive']

Every mutation lands in the file, so after the del it reads:

syncstores/tags.json
["json", "reactive"]

Slices are not supported yet

Slice operations like tags[1:3] are not supported for now.

Position-Based Identity

In Python, the items of a list are identified by position. Nothing surprising when stated like that, but it has a consequence for SyncList that you may find counterintuitive: when the items of a SyncList are themselves reactive, a reference kept to an item points to a position in the list, regardless of its content.

Let's bring back our music theme to demonstrate:

main.py
from syncwave import SyncDict, SyncList, Syncwave

syncwave = Syncwave()

songs = syncwave.create_store(SyncList[SyncDict[str, str]], name="songs")
songs.extend([
    {"title": "Sweet Virginia", "artist": "The Rolling Stones"},
    {"title": "Dreams", "artist": "Fleetwood Mac"},
])

sweet_virginia = songs[0]  # reference to the song at position 0
songs.insert(0, {"title": "Bones", "artist": "The Killers"})  # takes position 0

print(sweet_virginia)  # wrong song! {'title': 'Bones', 'artist': 'The Killers'}

As soon as another song took the first spot, Syncwave updated the reference based on the position, ignoring the content. There's nothing mechanically wrong with this example, except a poor choice of variable name.

If you end up in a situation like this and are surprised to see your items get metamorphosed, that's a good hint that a SyncList isn't the right data structure. Instead use a SyncDict so you have a key to identify your items and keep their identities stable.

A SyncList (especially one holding reactive items) is the appropriate data structure when the items are truly defined by their position. The same example with a slightly different mental model makes much more sense:

main.py
from syncwave import SyncDict, SyncList, Syncwave

syncwave = Syncwave()

top_songs = syncwave.create_store(SyncList[SyncDict[str, str]], name="top_songs")
top_songs.extend([
    {"title": "Sweet Virginia", "artist": "The Rolling Stones"},
    {"title": "Dreams", "artist": "Fleetwood Mac"},
])

most_popular_song = top_songs[0]
top_songs.insert(0, {"title": "Bones", "artist": "The Killers"})  # new hit!

print(most_popular_song)  # {'title': 'Bones', 'artist': 'The Killers'}

SyncSet

SyncSet is the reactive counterpart of set: items are unique and unordered. It can be parameterized with the type of its items, e.g. SyncSet[int], or it can be used bare. JSON has no set type, so the file representation is an array. Here's how to use it, for example to hold some IDs:

main.py
from syncwave import SyncSet, Syncwave

syncwave = Syncwave()

ids = syncwave.create_store(SyncSet[int], name="ids")
ids.add(1)
ids.add(2)
ids.add(1)  # already present, no effect

print(ids)  # {1, 2}
syncstores/ids.json
[1, 2]

If you write duplicate values into the file by hand, they collapse into one item: as soon as you save if the program is running, or the next time the store is loaded otherwise.

Hashable Items

Like a regular set, a SyncSet requires its items to be hashable. Syncwave checks the item type of set, frozenset, and SyncSet when they're used in a store:

syncwave.create_store(SyncSet[list[int]], name="bad")
TypeError: `SyncSet` must hold hashable elements, got `list`.

The check also covers the less obvious cases: tuple and frozenset are hashable only if their element types are, and an Enum only if its member values are.

Notably, this means other reactive types are excluded as items of a SyncSet: hashable values are immutable, while a reactive object must be mutable so it can be updated in place; no type can be both. This makes SyncSet the exception to nesting: it can hold int, str, UUID, and other hashable values, but never another reactive type, so it always ends the reactive chain.

What's Next

Let's bring back the settings example from above:

from syncwave import SyncDict, Syncwave

syncwave = Syncwave()

settings = syncwave.create_store(SyncDict[str, int], name="settings")
settings["volume"] = 8
settings["brightness"] = 5

This has an obvious weakness: SyncDict[str, int] works while every setting is an int, but settings rarely stay that uniform. Say you add a theme that must be one of "light", "dark", or "system": no dict type fits anymore. The best you can do is loosen the store to SyncDict[str, Any], which accepts your theme along with everything else, e.g. settings["volume"] = "loud!". Even worse, typos like settings["brigthness"] are accepted. It would also be nice to have more features, like having a default value for some settings, adding constraints (e.g. volume between 0 and 10), etc.

What that store really needs is a fixed set of named fields, each with its own type. That's exactly what a Pydantic model is. Define your model by subclassing SyncModel instead of Pydantic's BaseModel, and it becomes reactive. Just like the collections on this page, that means assigning a field is validated and written to the file, and fields can themselves hold reactive collections, so the whole structure stays reactive all the way down. The Models page covers it.

One last thing before you move on: much of what this page introduced is not specific to collections; it applies to all reactive types. The Reactivity page presents the system as a whole.

For the complete API of the types presented on this page, see the API Reference.