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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,16 @@ Consequences for any code that mutates a `MorphingBIndex`:
- Destructive shortcuts (destroying and rebuilding the whole bindex, or erasing it entirely from its parent) avoid the issue since no stale reference remains.

When adding a new mutating path that operates on a `MorphingBIndex`, treat re-syncing any externally held `{address, type}` as mandatory, not an optimization. Collection-specific handling (where these pairs live, which paths must re-sync) is documented at the top of the relevant `.cpp` files.

## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.

Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
36 changes: 35 additions & 1 deletion dbzero/dbzero/dbzero.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Type stubs for dbzero module.
"""

from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence
from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence, overload
from .interfaces import (
Memo, MemoWeakProxy, QueryObject, Tag, TagSet, EnumValue,
ListObject, IndexObject, TupleObject, SetObject, DictObject, ByteArrayObject,
Expand All @@ -11,6 +11,28 @@ from .interfaces import (

# Core workspace management functions

class FieldRef:
...

class FieldNamespace:
def __getattr__(self, field_name: str) -> FieldRef:
...

def __getitem__(self, field_name: str) -> FieldRef:
...

def tag_fields(*field_names: str) -> Callable[[type], type]:
"""Declare memo fields that should later be tag-backed."""
...

def indexed_fields(*field_names: str) -> Callable[[type], type]:
"""Declare memo fields that should be backed by managed indexes."""
...

def fields_of(memo_type: type) -> FieldNamespace:
"""Return a namespace for first-class memo field references."""
...

def open(prefix_name: str, open_mode: str = "rw", **kwargs: Any) -> None:
"""Open a data prefix and set it as the current working context.

Expand Down Expand Up @@ -780,6 +802,18 @@ def index() -> IndexObject:
"""
...

@overload
def index_of(memo_type: type, field_name: str, *, prefix: Optional[str] = None) -> IndexObject:
...

@overload
def index_of(field: FieldRef, *, prefix: Optional[str] = None) -> IndexObject:
...

def index_of(*args: Any, prefix: Optional[str] = None) -> IndexObject:
"""Return the managed query index for an indexed memo field."""
...

def tuple(iterable: Iterable[Any] = (), /) -> TupleObject:
"""Create a persistent, immutable sequence object.

Expand Down
45 changes: 33 additions & 12 deletions dbzero/dbzero/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,56 @@


__DBZERO_TAG_FIELDS_ATTR = "__DBZERO_TAG_FIELDS_ATTR"
__DBZERO_INDEXED_FIELDS_ATTR = "__DBZERO_INDEXED_FIELDS_ATTR"


def _normalize_tag_fields(field_names):
tag_fields = []
def _normalize_field_names(field_names, decorator_name):
fields = []
seen = set()
for field_name in field_names:
if not isinstance(field_name, str):
raise TypeError("tag_fields arguments must be strings")
raise TypeError(f"{decorator_name} arguments must be strings")
if field_name not in seen:
seen.add(field_name)
tag_fields.append(field_name)
return tuple(tag_fields)
fields.append(field_name)
return tuple(fields)


def _merge_tag_field_declarations(*declarations):
tag_fields = []
def _merge_field_declarations(*declarations):
fields = []
seen = set()
for declaration in declarations:
for field_name in declaration:
if field_name not in seen:
seen.add(field_name)
tag_fields.append(field_name)
return tuple(tag_fields)
fields.append(field_name)
return tuple(fields)


def tag_fields(*field_names):
new_fields = _normalize_tag_fields(field_names)
new_fields = _normalize_field_names(field_names, "tag_fields")

def wrap(cls):
existing_fields = getattr(cls, __DBZERO_TAG_FIELDS_ATTR, ())
merged_fields = _merge_tag_field_declarations(existing_fields, new_fields)
merged_fields = _merge_field_declarations(existing_fields, new_fields)
setattr(cls, __DBZERO_TAG_FIELDS_ATTR, merged_fields)
return cls

return wrap


def indexed_fields(*field_names):
new_fields = _normalize_field_names(field_names, "indexed_fields")

def wrap(cls):
existing_fields = cls.__dict__.get(__DBZERO_INDEXED_FIELDS_ATTR, ())
merged_fields = _merge_field_declarations(existing_fields, new_fields)
setattr(cls, __DBZERO_INDEXED_FIELDS_ATTR, merged_fields)
return cls

return wrap


def migration(func: Callable) -> Callable:
"""Decorator for marking a function as a migration function"""
func._db0_migration = None
Expand Down Expand Up @@ -270,6 +283,11 @@ def _is_self_load_instruction(inst):
if inst.opname == "LOAD_FAST" and inst.arg == 0:
# Traditional single load of first argument (self)
return True
elif inst.opname == "LOAD_FAST_LOAD_FAST":
# Python 3.13+ dual local load
# argval is a tuple; self is the second value loaded for STORE_ATTR.
if isinstance(inst.argval, tuple) and len(inst.argval) == 2:
return inst.argval[1] == 'self'
elif inst.opname == "LOAD_FAST_BORROW" and inst.arg == 0:
# Python 3.14+ borrowed reference load of first argument (self)
return True
Expand Down Expand Up @@ -310,11 +328,14 @@ def wrap(cls_):
init_vars = []

tag_field_names = list(getattr(cls_, __DBZERO_TAG_FIELDS_ATTR, ()))
indexed_field_names = list(cls_.__dict__.get(__DBZERO_INDEXED_FIELDS_ATTR, ()))

wrapped = _wrap_memo_type(cls_, py_file = getfile(cls_), py_init_vars = init_vars, py_dyn_prefix = dyn_prefix, \
py_migrations = list(find_migrations(cls_)) if is_singleton else None, py_tag_fields = tag_field_names, **kwargs
py_migrations = list(find_migrations(cls_)) if is_singleton else None, py_tag_fields = tag_field_names,
py_indexed_fields = indexed_field_names, **kwargs
)
setattr(wrapped, __DBZERO_TAG_FIELDS_ATTR, tuple(tag_field_names))
setattr(wrapped, __DBZERO_INDEXED_FIELDS_ATTR, tuple(indexed_field_names))

# Call __init_subclass__ on the wrapped class for any base that defines it.
# Python normally calls __init_subclass__ before the decorator runs, so the
Expand Down
2 changes: 1 addition & 1 deletion dbzero/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

setup(
name='dbzero',
version='0.5.2',
version='0.6.0',
description='DBZero community edition',
packages=['dbzero'],
python_requires='>=3.9',
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ requires = ['meson-python']

[project]
name = 'dbzero'
version = '0.5.2'
version = '0.6.0'
description = 'A state management system for Python 3.x that unifies your applications business logic, data persistence, and caching into a single, efficient layer.'
readme = 'README.md'
requires-python = '>=3.9'
Expand Down
99 changes: 99 additions & 0 deletions python_tests/test_fields_of.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# Copyright (c) 2026 DBZero Software sp. z o.o.

from dataclasses import dataclass
import pickle

import pytest
import dbzero as db0


def test_fields_of_rejects_non_memo_type():
with pytest.raises(TypeError, match="memo type"):
db0.fields_of(object)


def test_fields_of_preserves_dataclass_class_attributes(db0_fixture):
@db0.memo
@dataclass
class FieldRefDataclassTask:
title: str
priority: int = 0

assert FieldRefDataclassTask.priority == 0
assert not hasattr(FieldRefDataclassTask, "__fields__")

ns = db0.fields_of(FieldRefDataclassTask)
assert ns is db0.fields_of(FieldRefDataclassTask)
assert ns.priority is ns.priority
assert repr(ns.priority).endswith(".FieldRefDataclassTask.priority>")
assert "priority" in dir(ns)

with pytest.raises(AttributeError):
ns.missing


def test_fields_of_inherits_declared_field_identity(db0_fixture):
@db0.memo
class FieldRefBase:
def __init__(self, created_at):
self.created_at = created_at

@db0.memo
class FieldRefDerived(FieldRefBase):
def __init__(self, created_at, priority):
super().__init__(created_at)
self.priority = priority

assert db0.fields_of(FieldRefDerived).created_at is db0.fields_of(FieldRefBase).created_at
assert db0.fields_of(FieldRefDerived).priority is db0.fields_of(FieldRefDerived).priority


def test_fields_of_supports_explicit_dynamic_names(db0_fixture):
@db0.memo
class FieldRefDynamic:
pass

dynamic = db0.fields_of(FieldRefDynamic)["from"]
assert dynamic == db0.fields_of(FieldRefDynamic)["from"]
assert dynamic is db0.fields_of(FieldRefDynamic)["from"]

with pytest.raises(TypeError):
db0.fields_of(FieldRefDynamic)[123]

with pytest.raises(Exception, match="Invalid persistent field name"):
db0.fields_of(FieldRefDynamic)["_X__hidden"]


def test_field_ref_and_namespace_are_not_picklable(db0_fixture):
@db0.memo
class FieldRefPickle:
def __init__(self, value):
self.value = value

ns = db0.fields_of(FieldRefPickle)
with pytest.raises(TypeError):
pickle.dumps(ns)
with pytest.raises(TypeError):
pickle.dumps(ns.value)


def test_index_of_accepts_field_ref(db0_fixture):
@db0.memo
@db0.indexed_fields("priority")
class FieldRefIndexedTask:
def __init__(self, name):
self.name = name

by_string = db0.index_of(FieldRefIndexedTask, "priority")
by_ref = db0.index_of(db0.fields_of(FieldRefIndexedTask).priority)
low = FieldRefIndexedTask("low")
high = FieldRefIndexedTask("high")
low.priority = 1
high.priority = 5

assert [item.name for item in db0.find(FieldRefIndexedTask, by_string.select(1, 1))] == ["low"]
assert [item.name for item in db0.find(FieldRefIndexedTask, by_ref.select(5, 5))] == ["high"]

with pytest.raises(Exception, match="does not accept a field_name"):
db0.index_of(db0.fields_of(FieldRefIndexedTask).priority, "priority")
Loading
Loading