Imported from psrenergy/quiver (
bindings/python/AGENTS.md). Install upstream withnpx skills add psrenergy/quiver --skill python. Copyright stays with the author.
Python Binding (quiverdb)
Cross-layer naming rules (same snake_case names, @staticmethod factories, kwargs create/update)
and the convenience-method parity tables live in the root AGENTS.md. Local Python runs go
through uv (see root Build & Test).
Layout
src/quiverdb/
__init__.py # Public exports: Database, QuiverError, LuaRunner, CSVOptions, DataType,
# LogLevel, ScalarMetadata, GroupMetadata, version()
database.py # Database class (inherits the CSV mixins below)
database_csv_export.py / database_csv_import.py # export_csv / import_csv mixins
database_options.py # CSVOptions-to-C marshaling
lua_runner.py # LuaRunner class
metadata.py # DataType/LogLevel (IntEnums), CSVOptions, ScalarMetadata, GroupMetadata
element.py # Element builder - INTERNAL ONLY (users pass **kwargs)
exceptions.py # QuiverError
_helpers.py # Shared check()/decode_string helpers
_c_api.py # Hand-written CFFI cdef declarations (kept in sync manually)
_loader.py # Library loading
py.typed # PEP 561 marker
generator/ # generator.py prints current cdecls from headers to stdout — a diff aid
# for hand-updating _c_api.py (it does NOT write the file)
tests/ # Test suite (test_*.py per area) + test.bat
pyproject.toml # Version must match CMakeLists.txt; requires-python >=3.13; deps: cffi>=2.0
ruff.toml # Lint/format config (format.bat runs ruff)
Rules and gotchas
- CFFI ABI-mode — no compiler required at install time;
_c_api.pydeclarations must match the C headers exactly (struct layout mismatches corrupt silently). After C API changes, rungenerator/generator.batand diff its output against_c_api.py. _loader.pypre-loadslibquiver.dllon Windows so the OS resolveslibquiver_c.dll's dependency chain.tests/test.batprependsbuild/bin/to PATH for DLL discovery.- API shape:
create_element/update_elementaccept**kwargs(dict unpacking works:db.create_element("Collection", **my_dict)); theElementclass is internal. Properties are regular methods, not@property(design decision).LogLevelis anIntEnumexported from__init__.py; internal mixin classes are not exported. - A parameter that shadows a column name needs a
/. A method that addresses a row positionally and takes attributes as**kwargsmust mark the positional parameters positional-only, or the kwarg binds to the parameter and raisesTypeError: got multiple values for argument '<name>'before the FFI call.update_element_by_label(collection, label, /, **kwargs)is the acute case — renaming vialabel=is the point of the method, and every collection has alabelcolumn by convention. - A nullable scalar string argument passes
ffi.NULL, neverb""(update_relation/update_relation_by_label) — the C API reads NULL as "clear the relation" and an empty string as a label to look up. - Per-method FFI boilerplate is the house style — don't collapse it into closure-parameterized helpers (root "Do not 'fix'" list).
- Scalar bulk NULLs:
read_scalar_integers/_floatsdecode a paralleluint8_t**mask intolist[T | None](mask[i]falsy →None);read_scalar_stringsalready returnslist[str | None]via theffi.NULLguard, andread_scalar_date_timesmaps that list while preserving itsNoneslots._c_api.pycarries the mask out-param on the two numeric readers plusquiver_database_free_mask. _parse_datetimegates on_DATE_TIME_PATTERNbefore callingfromisoformat.fromisoformatis wider than the core's DATE_TIME grammar — it accepts"20240115", aZsuffix and a UTC offset, none of which Julia's parser reads — so without the gate the same stored bytes read differently per binding. The gate is also what makes the trailing.replace(tzinfo=timezone.utc)correct: it used to overwrite an offset rather than convert it, so"...T10:30:00+03:00"came back as10:30Z, three hours off, with no error. Everything reaching that line is now naive. An out-of-range field that clears the regex ("2024-02-31") falls through to the same rejection so the message still names the column. Keep this parser accepting exactly the same set as Julia'sstring_to_date_timeand Dart'sstringToDateTime. Its@overloadtriple mirrors_integer_to_boolean's — keep the(str) -> datetimevariant, or the vector/set readers' comprehensions widen tolist[list[datetime | None]]against their declaredlist[list[datetime]]. Nothing typechecks this repo (ruff.tomlisselect = ["I"], isort only; no mypy/pyright in CI,pyproject.toml, or the pre-commit hooks), so that note is the only guard against a "remove the redundant overloads" cleanup._integer_to_booleanraisesValueError, notQuiverError— the second documented exception to "messages come from C++", alongside_marshal_group_columns' jagged-column check. The boolean readers are a binding-only convenience with no C++ counterpart, so the core cannot diagnose a stray2; the message names the offendingcollection.attribute(nothing to name forquery_boolean). The@overloadtriple mirrorsbindings/js/src/boolean.ts— keep the(int) -> boolvariant, or the vector/set readers' comprehensions widen tolist[bool | None]against their declaredlist[list[bool]].LuaRunner.runowns its result:quiver_lua_runner_runtakes achar** out_resultand the JSON string must be freed withquiver_lua_runner_free_string— notquiver_database_free_string(both are hand-declared in_c_api.py). The free sits in afinallyso adecode_stringfailure (the JSON is rejected as non-UTF-8 in C++, but be safe) cannot leak the native buffer.- Time-series group NULLs:
read_time_series_groupsurfaces a SQL NULL cell asNonein the column list (decoded via the per-celluint8_t**mask out-param); the dimension column stays dense datetimes._marshal_group_columnsdispatches on the first non-Noneelement, builds a per-column mask, and substitutes0/0.0/ffi.NULLplaceholders forNonecells; an all-Nonecolumn is tagged FLOAT with a zeroed placeholder. _marshal_group_columnsserves every columnar group writer (time series, vector, set, by id and by label) — same name as Dart's_marshalGroupColumn. It raisesValueErrorfor jagged column lists (a pre-FFI marshalling error, the documented exception to "messages come from C++"); everything else is validated in the core and surfaces asQuiverError. Note that the group writers take columns whileread_vector_group_by_idreturns rows, and that reader composes per-column reads, so it drops NULL cells — assert a NULL-cell write in SQL, not through it._marshal_row_columnsis its row-shaped sibling, servingupsert_time_series_rowand its_by_labelform — each kwarg is a scalar wrapped in a 1-element typed array. Kept separate because the row-upsert C signature carries no per-cell mask: the group marshaller's zeroed placeholder for aNonecell would be written as data instead of NULL (aNonekwarg raisesTypeErrorhere).
Packaging
- Wheels build via scikit-build-core (
cmake.source-dir = ../.., Release,-DQUIVER_BUILD_TESTS=OFF; the root CMakeLists detectsSKBUILDand forces the C API ON).wheel.excludestripsbin/lib/include/sharefrom the wheel. - cibuildwheel targets
cp313-win_amd64andcp313-manylinux_x86_64, running pytest as the wheel test. CI publish flow in.github/AGENTS.md. - Local wheel checks:
scripts/test-wheel.bat,scripts/test-wheel-install.bat,scripts/validate_wheel.py,scripts/validate_wheel_install.py.
