Skip to content

xnano.state

xnano.state

xnano.state

The state of a canvas, a placement and a hook, stored in the core state store.

Classes:

  • StateNamespace –

    A branch of a canvas's state that hooks and your code read with get and write with push.

Functions:

  • parse_path –

    Split a dotted state path into the keys and indexes it names.

Attributes:

  • StatePath (TypeAlias) –

    A path in the state store: object keys and array indexes, outermost first.

StatePath module-attribute

StatePath: TypeAlias = tuple[str | int, ...]

A path in the state store: object keys and array indexes, outermost first.

StateNamespace

StateNamespace(canvas: Canvas[object, object], base: StatePath, *, fields: Mapping[str, object] | None = None)

A branch of a canvas's state that hooks and your code read with get and write with push.

A canvas has one state tree. canvas.state (and context.root.state) is its root, placement.state (and context.buffer.state) is a branch that belongs to one placement, and context.plugin.state is a branch that belongs to one hook. Each is an independent namespace, so two hooks can both keep a 'count' without meeting.

Paths are dotted: 'player.score' is the score of player, and a part made only of digits is an index into a list ('items.0.name'). '' is the namespace itself. Keep small values here, such as numbers, flags, names and short lists; the state is not a place for per-cell data.

Inside a hook, a read sees the state as it was when the hook started plus whatever the hook pushed, and every push is saved together when the hook returns. A hook declared with on='state' runs again when a path it read changes; other hooks do not.

On the root of a canvas created with new(state=Settings()), the fields of the dataclass are also attributes with their declared types, and a write is checked against the field's type. A field that holds a nested dataclass reads as a new instance of that dataclass, so changing it changes nothing: assign the field or push a path to save a change. A whole number written to a float field is stored and read as a float.

Attributes:

  • <field> –

    Every field of the state dataclass, on the root namespace only.

Examples:

import xnano

with xnano.new(width=8, height=1) as canvas:
    canvas.state.push('player', {'name': 'Ada'})
    canvas.state.push('player.score', 3)
    assert canvas.state.get('player.score') == 3
    assert canvas.state.get('player.lives', default=0) == 0

    @canvas.hook(on='key')
    def count(context: xnano.Context) -> None:
        state = context.root.state
        state.push('player.score', state.get('player.score') + 1)

Create a namespace. Use canvas.state, placement.state or context.plugin.state.

Parameters:

  • canvas (Canvas[object, object]) –

    The root canvas that owns the state store.

  • base (StatePath) –

    Where the namespace starts in the store.

  • fields (Mapping[str, object] | None, default: None ) –

    The declared type of every state dataclass field by name, for the root namespace.

Methods:

  • get –

    Read the value at path.

  • push –

    Write value at path, creating the objects on the way.

Source code in xnano/state.py
def __init__(
    self,
    canvas: Canvas[object, object],
    base: StatePath,
    *,
    fields: Mapping[str, object] | None = None,
) -> None:
    """
    Create a namespace. Use ``canvas.state``, ``placement.state`` or ``context.plugin.state``.

    Args:
        canvas: The root canvas that owns the state store.
        base: Where the namespace starts in the store.
        fields: The declared type of every state dataclass field by name, for the root
            namespace.
    """
    object.__setattr__(self, '_canvas', canvas)
    object.__setattr__(self, '_base', base)
    object.__setattr__(self, '_fields', {} if fields is None else fields)
    object.__setattr__(self, '_is_removed', False)

get

get(path: str, default: JSONValue = None) -> JSONValue

Read the value at path.

Parameters:

  • path (str) –

    A dotted path such as 'player.score', or '' for the whole namespace.

  • default (JSONValue, default: None ) –

    What to return when nothing is stored at path.

Raises:

Examples:

import xnano

with xnano.new(width=8, height=1) as canvas:
    canvas.state.push('level', 4)
    assert canvas.state.get('level') == 4
    assert canvas.state.get('player', default='nobody') == 'nobody'
Source code in xnano/state.py
def get(self, path: str, default: JSONValue = None) -> JSONValue:
    """
    Read the value at ``path``.

    Args:
        path: A dotted path such as ``'player.score'``, or ``''`` for the whole namespace.
        default: What to return when nothing is stored at ``path``.

    Raises:
        ValueError: ``path`` is malformed.

    Examples:
        ```python
        import xnano

        with xnano.new(width=8, height=1) as canvas:
            canvas.state.push('level', 4)
            assert canvas.state.get('level') == 4
            assert canvas.state.get('player', default='nobody') == 'nobody'
        ```
    """
    parsed = parse_path(path)
    base = self._base
    absolute = base + parsed
    if not base:
        if parsed[:1] == (_RESERVED_ROOT_KEY,):
            raise ValueError(_reserved_message(path))
        if not parsed:
            value = _read_state(self._canvas, absolute, default)
            if isinstance(value, dict):
                return {
                    key: item
                    for key, item in value.items()
                    if key != _RESERVED_ROOT_KEY
                }
            return value
    return _read_state(self._canvas, absolute, default)

push

push(path: str, value: JSONValue) -> None

Write value at path, creating the objects on the way.

A name at the root that is not a declared field is added as a new value, so check the spelling of a root path. Inside a declared object, a name that is not one of its fields is refused.

Inside a hook the write is saved when the hook returns; elsewhere it is saved at once. The canvas draws a frame afterwards.

Parameters:

  • path (str) –

    A dotted path such as 'player.score'. '' replaces a whole placement or hook namespace with an object, and is not allowed on the root.

  • value (JSONValue) –

    A number, string, flag, None, list or dictionary of those.

Raises:

  • ValueError –

    path is malformed or names the reserved xnano key.

  • TerminalError –

    The value does not fit a declared field, or the namespace belongs to a placement or hook that was removed.

Examples:

import xnano

with xnano.new(width=8, height=1) as canvas:
    canvas.state.push('score', 1)
    canvas.state.push('score', 2)
    assert canvas.state.get('score') == 2
    canvas.state.push('player', {'name': 'Ada'})
    canvas.state.push('player.score', 5)
    assert canvas.state.get('player') == {'name': 'Ada', 'score': 5}
Source code in xnano/state.py
def push(self, path: str, value: JSONValue) -> None:
    """
    Write ``value`` at ``path``, creating the objects on the way.

    A name at the root that is not a declared field is added as a new value, so check the
    spelling of a root path. Inside a declared object, a name that is not one of its fields
    is refused.

    Inside a hook the write is saved when the hook returns; elsewhere it is saved at once.
    The canvas draws a frame afterwards.

    Args:
        path: A dotted path such as ``'player.score'``. ``''`` replaces a whole placement or
            hook namespace with an object, and is not allowed on the root.
        value: A number, string, flag, ``None``, list or dictionary of those.

    Raises:
        ValueError: ``path`` is malformed or names the reserved ``xnano`` key.
        TerminalError: The value does not fit a declared field, or the namespace belongs
            to a placement or hook that was removed.

    Examples:
        ```python
        import xnano

        with xnano.new(width=8, height=1) as canvas:
            canvas.state.push('score', 1)
            canvas.state.push('score', 2)
            assert canvas.state.get('score') == 2
            canvas.state.push('player', {'name': 'Ada'})
            canvas.state.push('player.score', 5)
            assert canvas.state.get('player') == {'name': 'Ada', 'score': 5}
        ```
    """
    parsed = parse_path(path)
    base = self._base
    if not base:
        if parsed[:1] == (_RESERVED_ROOT_KEY,):
            raise ValueError(_reserved_message(path))
        if not parsed:
            raise ValueError(
                "push('') would replace the whole root state. Push one path at a "
                "time, such as push('score', 1)."
            )
    elif not parsed and not isinstance(value, dict):
        raise ValueError(
            f"push('') replaces the whole namespace and needs an object, but was given "
            f'{value!r}. Push a dictionary, or push one path such as push("score", 1).'
        )
    if self._is_removed:
        raise TerminalError(
            f'Could not push {path!r}: this state belongs to a placement or hook that '
            f'was removed. Push to the state of one that is still on the canvas.'
        )
    _write_state(self._canvas, base + parsed, self._declared_form(parsed, value))

parse_path cached

parse_path(path: str) -> StatePath

Split a dotted state path into the keys and indexes it names.

Parameters:

  • path (str) –

    Parts joined by dots, such as 'player.score' or 'items.0.name'. A part made only of digits is an array index. '' names the whole namespace.

Raises:

  • ValueError –

    path is not a string or has an empty part.

Source code in xnano/state.py
@functools.lru_cache(maxsize=2048)
def parse_path(path: str) -> StatePath:
    """
    Split a dotted state path into the keys and indexes it names.

    Args:
        path: Parts joined by dots, such as ``'player.score'`` or ``'items.0.name'``. A part
            made only of digits is an array index. ``''`` names the whole namespace.

    Raises:
        ValueError: ``path`` is not a string or has an empty part.
    """
    if not isinstance(path, str):
        raise ValueError(
            f'A state path must be a string such as "player.score", but was {path!r}.'
        )
    if not path:
        return ()
    parsed: list[str | int] = []
    for part in path.split('.'):
        if not part:
            raise ValueError(
                f'The state path {path!r} has an empty part. Join the parts with single '
                f"dots, such as 'player.score' or 'items.0.name'."
            )
        parsed.append(int(part) if part.isascii() and part.isdigit() else part)
    return tuple(parsed)