Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion docs/contribute.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ If your change touches ingesting, local edits, pushes or a store, run the benchm

python -m calendaring_sync.tests.benchmark

It times a full listing of a large scope, a second listing over local edits and conflicts, a run of single edits, and pushes of those edits, each on a fresh SQLite store. Times differ between machines, so only the comparison means anything. Explain any slowdown beyond run-to-run noise in your pull request. ``--size`` and ``--runs`` change the scope's size and the number of runs.
It times a full listing of a large scope, a second listing over local edits and conflicts, a run of single edits, pushes of those edits, and the longest read in another thread while a listing runs, each on a fresh SQLite store. Times differ between machines, so only the comparison means anything. Explain any slowdown beyond run-to-run noise in your pull request. ``--size`` and ``--runs`` change the scope's size and the number of runs.

Previewing docs
===============
Expand Down Expand Up @@ -125,6 +125,9 @@ No duplication
No hardcoding
A literal that means something (a status value, a format string, a default path) belongs in a named constant defined once, not inlined at every call site. A one-off literal with no reused meaning (a test fixture's arbitrary ID) doesn't need this.

Argument types
The public methods of ``Replica``, ``Session`` and the stores, and the value classes an adapter builds, check their argument types at runtime, from their annotations, so give every parameter of a new public method an annotation. A built-in type such as ``str`` or ``int`` has to match exactly, so ``True`` isn't taken as ``1``. The unit tests build every check and fail on a parameter without an annotation, or on one the checks can't read.

RFC citations
The core doesn't cite RFCs; see :ref:`write-about-sync`. In adapter docstrings (not comments, not test files, see below), cite a real, finalized RFC with Sphinx's ``:rfc:`` role, and always merge the section number into the role itself: ``:rfc:`5545#section-3.6.1```, not ``:rfc:`5545` section 3.6.1``. The latter renders as two disconnected pieces, a link to the RFC's front page and plain text next to it, so a reader has to manually find the section after clicking through. Verify the section number against the actual RFC text before citing it; don't guess from a related section or trust an existing comment's number without checking.

Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/design.rst
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,4 @@ If a calendar or account disappears from the server, the adapter raises an error
Why storage is synchronous
==========================

Storage is a typed interface, ``StateStore``, with an in-memory implementation for tests and a SQLite one for real use. One :term:`store` holds many scopes. A key-value interface couldn't apply a change set and advance a token in one transaction, and an asynchronous store would push synchronous callers through an event loop. Adapters come in synchronous and asynchronous variants that share their logic. The asynchronous ones run each store call in a worker thread, since ingesting a large full listing is one long transaction and would otherwise block the event loop.
Storage is a typed interface, ``StateStore``, with an in-memory implementation for tests and a SQLite one for real use. One :term:`store` holds many scopes. A key-value interface couldn't apply a change set and advance a token in one transaction, and an asynchronous store would push synchronous callers through an event loop. Adapters come in synchronous and asynchronous variants that share their logic. The asynchronous ones run each store call in a worker thread, since ingesting a large full listing is one long transaction and would otherwise block the event loop. Reads from other threads don't wait for that transaction, at most for its commit: until then they see what the store held before it began, so your application can show its records while a sync runs.
4 changes: 2 additions & 2 deletions docs/reference/store.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
====================================

.. meta::
:description: StateStore, the storage interface, and its two implementations, MemoryStore for tests and SQLiteStore for real use.
:description: StateStore, the storage interface, and its two implementations: MemoryStore, for tests or as the base of a store that saves its objects elsewhere, and SQLiteStore for real use.

A :term:`store` keeps every :term:`scope`, its records, and the :term:`change feed`. Use :class:`~calendaring_sync.store.sqlite.SQLiteStore` to keep state between runs, and :class:`~calendaring_sync.store.memory.MemoryStore` in tests.
A :term:`store` keeps every :term:`scope`, its records, and the :term:`change feed`. Use :class:`~calendaring_sync.store.sqlite.SQLiteStore` to keep state between runs, and :class:`~calendaring_sync.store.memory.MemoryStore` in tests. In both, a read in one thread runs while another thread writes. To save the objects themselves somewhere else, such as with :mod:`pickle` or in an object database, subclass ``MemoryStore`` as its documentation shows.

.. automodule:: calendaring_sync.store.base
:members:
Expand Down
1 change: 1 addition & 0 deletions news/24.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read no longer waits for another thread's write: in ``MemoryStore`` and ``SQLiteStore``, a transaction that only reads runs while a write is open in another thread. In SQLite's default journal mode the commit still waits for such reads, and a write keeps its changes in memory until it commits, which for a large listing is about as much again as its change set.
1 change: 1 addition & 0 deletions news/27.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Every public method of ``Replica``, ``Session`` and the stores, and every value an adapter or application builds, now checks its argument types and raises ``TypeError`` for a wrong one the same way on every store; a subclass of ``str`` or ``int``, such as an enum, counts as wrong. Before, a wrong type could be stored as it was by one store and converted by another, such as ``delete_record("1")``, which deleted record 1 only on SQLite.
1 change: 1 addition & 0 deletions news/28.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``MemoryStore`` reads and saves its data through two methods a subclass can override, ``_load()`` and ``_save()``, so a store that keeps the same objects in a file with ``pickle`` or in an object database is a small subclass.
304 changes: 304 additions & 0 deletions src/calendaring_sync/_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
# SPDX-FileCopyrightText: 2026 calendaring-sync contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

"""The checks on what enters the core: argument types, and strings every store can keep.

A value's type is checked where it enters the library, so a wrong type fails
the same way on every store instead of being stored as it is by one and
converted by another. The checks are built from the annotations the first
time they're needed, as one plain function each, since they run on every
call.
"""

from __future__ import annotations

import dataclasses
import inspect
import types
import typing
from collections.abc import Callable, Iterable, Mapping
from functools import wraps
from typing import Any, TypeVar

_T = TypeVar("_T")

#: Built-in types a value must be exactly, not a subclass of: ``True`` is
#: no local key, and a ``str`` subclass could change how two values compare.
_EXACT = (str, int, bytes, bool)

#: Value dataclasses that check their own fields when built. Being frozen,
#: one inside another value only needs to be of its class.
_CHECKED_WHEN_BUILT: set[type] = set()

# Marks an argument the caller left to its default.
_MISSING = object()


def _kind(hint: Any) -> tuple[str, tuple[Any, ...]]:
"""What kind of type a hint asks for, and the hints or class inside it.

Raises:
TypeError: If the checks can't read the hint.
"""
origin = typing.get_origin(hint)
if hint is type(None):
return "none", ()
if origin in (typing.Union, types.UnionType):
return "union", typing.get_args(hint)
if hint in _EXACT:
return "exact", (hint,)
if hint is float:
return "number", ()
if origin is frozenset:
return "frozenset", typing.get_args(hint)
if isinstance(origin, type) and issubclass(origin, Mapping):
return "mapping", typing.get_args(hint)
if origin is not None and getattr(origin, "__name__", "") == "Callable":
return "callable", ()
cls = origin if isinstance(origin, type) else hint
if not isinstance(cls, type):
raise TypeError(f"Can't check a value against {hint!r}")
if dataclasses.is_dataclass(cls) and cls not in _CHECKED_WHEN_BUILT:
return "fields", (cls,)
return "instance", (cls,)


def _describe(hint: Any) -> str:
"""The type a hint asks for, as an error message names it."""
kind, inner = _kind(hint)
match kind:
case "none":
return "None"
case "union":
names = [_describe(part) for part in inner]
return ", ".join(names[:-1]) + " or " + names[-1]
case "number":
return "float or int"
case "frozenset":
return f"frozenset of {_describe(inner[0])}"
case "mapping":
return f"mapping of {_describe(inner[0])} to {_describe(inner[1])}"
case "callable":
return "callable"
# A class, exact or not.
return str(inner[0].__name__)


def _test(hint: Any, value: str, env: dict[str, object]) -> str:
"""Python source that's true when ``value`` matches ``hint``.

Names the source needs go into ``env``.
"""
kind, inner = _kind(hint)
match kind:
case "none":
return f"{value} is None"
case "union":
return "(" + " or ".join(_test(part, value, env) for part in inner) + ")"
case "exact":
return f"type({value}) is {inner[0].__name__}"
case "number":
return f"type({value}) in (float, int)"
case "frozenset":
items = f"all({_test(inner[0], '_i', env)} for _i in {value})"
return f"(type({value}) is frozenset and {items})"
case "mapping":
env["Mapping"] = Mapping
pairs = f"{_test(inner[0], '_k', env)} and {_test(inner[1], '_v', env)}"
return f"(isinstance({value}, Mapping) and all({pairs} for _k, _v in {value}.items()))"
case "callable":
return f"callable({value})"
cls = inner[0]
name = f"_{cls.__module__.replace('.', '_')}_{cls.__qualname__.replace('.', '_')}"
env[name] = cls
if kind == "fields":
env[f"{name}_fields"] = _fields(cls)
return f"(isinstance({value}, {name}) and {name}_fields({value}))"
return f"isinstance({value}, {name})"


def _compile(source: str, env: dict[str, object]) -> Callable[..., Any]:
"""Run the source of one function named ``check`` and return it."""
exec(source, env) # noqa: S102, the source is built above from annotations only
return env["check"] # type: ignore[return-value]


_FIELDS: dict[type, Callable[[object], bool]] = {}


def _fields(cls: type) -> Callable[[object], bool]:
"""A function that's true when every field of a dataclass instance has its declared type."""
if cls not in _FIELDS:
env: dict[str, object] = {}
hints = typing.get_type_hints(cls)
tests = [_test(hints[f.name], f"o.{f.name}", env) for f in dataclasses.fields(cls)]
_FIELDS[cls] = _compile("def check(o):\n return " + " and ".join(tests or ["True"]), env)
return _FIELDS[cls]


def _field_mismatch(cls: type, value: object, prefix: str) -> str | None:
"""The message for the first field of a dataclass instance that has the wrong type."""
hints = typing.get_type_hints(cls)
found = (
_mismatch(prefix + f.name, hints[f.name], getattr(value, f.name))
for f in dataclasses.fields(cls)
)
return next((message for message in found if message is not None), None)


def _inner_mismatch(name: str, hint: Any, value: object) -> str | None:
"""For a value of the kind ``hint`` holds, the message naming what inside it is wrong."""
kind, inner = _kind(hint)
if kind == "fields" and isinstance(value, inner[0]):
return _field_mismatch(inner[0], value, f"{name}.")
if kind == "frozenset" and type(value) is frozenset:
bad = next((each for each in value if _mismatch("item", inner[0], each) is not None), None)
return f"{name} must be {_describe(hint)}, but holds {type(bad).__name__}"
if kind == "mapping" and isinstance(value, Mapping):
key, item = inner
found = (
_mismatch(f"{name}[{k!r}]", item, v) or _mismatch(f"{name} key", key, k)
for k, v in value.items()
)
return next((message for message in found if message is not None), None)
return None


def _mismatch(name: str, hint: Any, value: object) -> str | None:
"""The message for a value that doesn't match its hint, naming the part of it that's wrong."""
env: dict[str, object] = {}
if _compile(f"def check(v):\n return {_test(hint, 'v', env)}", env)(value):
return None
kind, inner_hints = _kind(hint)
parts = inner_hints if kind == "union" else (hint,)
inner = (_inner_mismatch(name, part, value) for part in parts)
found = next((message for message in inner if message is not None), None)
return found or f"{name} must be {_describe(hint)}, not {type(value).__name__}"


def check_types(obj: object) -> None:
"""Raise ``TypeError`` unless every field of a dataclass instance has its declared type.

Fields holding a dataclass are checked in turn, unless that class checks
its own fields when built.
"""
if not _fields(type(obj))(obj):
raise TypeError(_field_mismatch(type(obj), obj, ""))


def checked_when_built(cls: type[_T]) -> type[_T]:
"""Check a value dataclass's field types every time one is built, before its own checks.

The class needs a ``__post_init__`` of its own, which runs after the check.
"""
original = cls.__dict__["__post_init__"]

def __post_init__(self: object) -> None:
check_types(self)
original(self)

cls.__post_init__ = __post_init__ # type: ignore[attr-defined]
_CHECKED_WHEN_BUILT.add(cls)
return cls


#: Checks one call's positional and keyword arguments.
_ArgumentCheck = Callable[[tuple[Any, ...], dict[str, Any]], None]


def argument_check(func: Callable[..., object]) -> _ArgumentCheck:
"""Build the function that checks a call's arguments against ``func``'s annotations."""
hints = typing.get_type_hints(func)
env: dict[str, object] = {"_MISSING": _MISSING, "_fail": _fail}
lines = ["def check(args, kwargs):", " n = len(args)"]
for i, param in enumerate(inspect.signature(func).parameters.values()):
if param.name in ("self", "cls"):
continue
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
raise TypeError(f"{func.__qualname__} takes *{param.name}, which isn't checked")
if param.name not in hints:
raise TypeError(f"{func.__qualname__} takes {param.name} without a type to check")
env[f"_h_{param.name}"] = hints[param.name]
if param.kind is param.KEYWORD_ONLY:
lines.append(f" v = kwargs.get({param.name!r}, _MISSING)")
else:
lines.append(f" v = args[{i}] if n > {i} else kwargs.get({param.name!r}, _MISSING)")
test = _test(hints[param.name], "v", env)
lines.append(f" if v is not _MISSING and not ({test}):")
lines.append(f" _fail({param.name!r}, _h_{param.name}, v)")
return _compile("\n".join(lines), env)


def _fail(name: str, hint: Any, value: object) -> typing.NoReturn:
raise TypeError(_mismatch(name, hint, value))


def check_argument_types(cls: type[_T]) -> type[_T]:
"""Check the argument types of every public method and ``__init__`` a class defines.

Each check is built from the method's annotations on its first call, so
an annotation may name a class defined later in the module.
"""
for name, func in list(vars(cls).items()):
if inspect.isfunction(func) and (not name.startswith("_") or name == "__init__"):
setattr(cls, name, _checking(func))
return cls


def _checking(func: Callable[..., _T]) -> Callable[..., _T]:
check: _ArgumentCheck | None = None

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> _T:
nonlocal check
if check is None:
check = argument_check(func)
check(args, kwargs)
return func(*args, **kwargs)

wrapper._checks_arguments = True # type: ignore[attr-defined]
return wrapper


def check_text(**values: str | Iterable[str] | None) -> None:
"""Raise ``ValueError`` if a string holds a lone surrogate.

Every store keeps any string exactly, empty strings and whitespace
included, except one kind. Python allows a ``str`` to hold a lone
surrogate, half of the pair UTF-16 uses for characters such as emoji,
for example ``"\\ud800"``. It's not a character, and UTF-8 can't encode
it, so a store that writes UTF-8 fails on it while one that keeps
Python objects doesn't. Refusing it where the string comes in keeps
every store the same.

Args:
values: Each value by its argument's name: a string, strings, or
``None``.
"""
for name, value in values.items():
if value is None:
continue
for text in [value] if isinstance(value, str) else value:
try:
text.encode()
except UnicodeEncodeError as exc:
raise ValueError(
f"{name} holds a lone surrogate at {exc.start}, which can't be stored"
) from exc


def check_data(
fingerprint: str | None,
containers: frozenset[str],
logical_key: str | None,
**values: str | None,
) -> None:
"""Check the strings of a copy of an object with :func:`check_text`.

Args:
fingerprint: Its fingerprint.
containers: Its containers.
logical_key: Its logical key.
values: Any other strings that come with it, by name.
"""
check_text(fingerprint=fingerprint, containers=containers, logical_key=logical_key, **values)
Loading
Loading