Metadata-Version: 2.4
Name: hawdb
Version: 0.25.0
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Dist: pydantic>=2,<3 ; extra == 'pydantic'
Provides-Extra: pydantic
Summary: HawDB Python bindings
License-Expression: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/nowledge-co/hawdb

# HawDB Python bindings

Embed the HawDB database directly from a Python host — same engine, no
separate server process. The binding is a thin translation layer over the
existing embedded Rust API; all query semantics, budgets, and durability
behavior stay in HawDB itself.

## Status

Development phase. The binding exposes the embedded engine directly while it
proves out against real host workloads. The PyPI distribution and Python import
are both named `hawdb`.

## Install a published release

```bash
python -m pip install hawdb
```

Requires CPython 3.11 or newer. Native wheels cover Linux x86-64/AArch64
(manylinux 2.28), macOS x86-64/AArch64, and Windows x86-64. Other environments
can build the source distribution with the repository-pinned Rust toolchain.
The default wheel includes the engine's default features; `lite` remains an
explicit source build. Pydantic stays optional: `pip install 'hawdb[pydantic]'`.

## Publishing

[Publish Python to PyPI](../../.github/workflows/pypi.yaml) runs whenever a
GitHub Release is published, including prereleases. Drafts do not publish.
The release must use `v<Cargo version>` and point to a commit in `main` history;
the facade and binding package versions must agree. Version changes still
follow the ordinary issue, review, CI and merge workflow.

The binding's `Cargo.lock` is committed and ordinary pip CI builds with
`--locked`. `check_lock.py` rejects any shared dependency whose version, source
or checksum differs from the root lock. When changing dependencies or bumping
versions, refresh the binding lock from the reviewed root graph and review the
additional Python-only dependency changes:

```bash
cp Cargo.lock bindings/python/Cargo.lock
cargo metadata --manifest-path bindings/python/Cargo.toml --format-version 1 > /dev/null
python bindings/python/check_lock.py
```

The workflow builds one sdist with that reviewed lock, then builds all
five wheels from that same archive using the repository compilation profile.
For historical tags without a binding lock, it seeds from that tag's root lock,
resolves the binding additions, and applies the same shared-identity gate.
The validated lock must survive sdist creation byte-for-byte, and its SHA-256
is printed beside the distribution hashes.
Each wheel is installed and tested before the complete artifact set is passed
to the publisher. Only the publisher job receives `id-token: write` and uses
the `pypi` environment. Configure PyPI's GitHub Trusted Publisher with owner
`nowledge-co`, repository `hawdb`, workflow `pypi.yaml`, environment `pypi`.

The GitHub `pypi` environment must allow only the `main` branch and `v*` tags.
The active release-tag ruleset restricts `v*` creation, update and deletion to
the release owner and organization administrators; the existing main/Tide
ruleset remains in force. These are GitHub environment/ruleset settings, not
checks that a branch can remove from its workflow. Publication stays automatic
after the release builds and tests complete. Actions in the publisher job are
pinned to commit SHAs.

For an existing release, dispatch the workflow with its tag:

```bash
gh workflow run pypi.yaml --ref main -f tag=v0.19.0
```

If a publisher fails, retain the original workflow artifacts and rerun only
the failed publisher job. Existing uploads are skipped only after their hashes
match those original artifacts; rebuilding different bytes for a published
filename is refused. The final job compares every uploaded file's SHA-256 with
PyPI, then installs the public wheel and runs an embedded SQL query.
That final public install runs on Linux x86-64 and retries transient index/CDN
misses. All five wheels have their own native installation tests before upload.

## Read budgets and query plans

`open`, `connect` and `Database` accept the keyword-only options
`max_read_result_rows` and `max_read_result_payload_bytes`. They configure the
engine's read ceilings for in-memory, file-backed and read-only databases.
Exceeding a ceiling raises `hawdb.exceptions.ExecutionError`; the binding does
not return a shortened result. A later read can still succeed within the budget.

```python
with hawdb.open(max_read_result_rows=1000, max_read_result_payload_bytes=1048576) as db:
    plan = db.explain(
        "MATCH (m:Memory) WHERE m.id = $id RETURN m.title",
        {"id": "memory-1"},
    )
    print(plan)
```

Omitted options and `None` preserve engine defaults: no host row ceiling and a
64 MiB result payload ceiling. Zero rows follows `DatabaseConfig` semantics and
disables the host row ceiling; zero payload bytes remains a zero-byte ceiling.
Other engine memory and session limits still apply. These options do not expose
the rest of `DatabaseConfig` or change durability.

`db.explain(cypher, params=None)` returns the existing physical plan text without
executing the statement. Parameters use the same conversion as `execute`.
Closed handles and an active write transaction reject this database-level call.
SQL `EXPLAIN` continues through `execute_sql`.

## Experimental retained numeric buffers

`execute_retained` is an opt-in numeric query surface over the embedded Rust
cursor. It supports catalog-declared integer/float comparisons, projections of
the same property or unsigned `id(n)`, and optional SKIP/LIMIT. Declare the node
table and property type before creating a cursor. Other plans/types, source
reuse and copying requests refuse explicitly.

```python
options = hawdb.RetainedOptions(batch_rows=1024)
query = db.execute_retained(
    "MATCH (n:Item) WHERE n.score >= $min RETURN n.score AS score",
    {"min": 0}, options=options,
)
try:
    while (batch := query.next_batch()) is not None:
        column = batch.column(0)
        selection = batch.selection()
        try:
            with memoryview(column) as values, memoryview(selection) as indices:
                for physical in indices:
                    consume(values[physical])
        finally:
            selection.close()
            column.close()
            batch.close()
finally:
    query.close()
```

Numeric data has one element per physical row; ordered selection identifies the
result rows without gathering. `column.validity()` returns None for all-valid
data, otherwise a separate read-only native u64 bitmap with least-significant
bit order. Schema formats q/d/Q distinguish Int64/Float64/UInt64 identity values;
selection uses I and validity uses Q. Nullable raw values do not imply an ordinary
non-null NumPy array. `schema_copy` preserves schema even for empty completion.

Defaults remain two payload slots, no prefetch, 1,024 inspected records and 1 MiB
per batch, under the existing database/shared byte and handle limits. Positive
batch/slot options cannot bypass those limits; `max_result_rows` can impose a
lower cumulative budget. This per-cursor option is a low-level request budget:
None inherits the host policy, while explicit zero allows no matching rows.
The Rust session setting max_result_rows=0 separately means unlimited.
Python requires at least four shared handles for the
control, batch, exporter and native buffer lease.

`BackpressureError` is retryable: release held views and retry the same cursor.
It never means successful EOF, never waits for the caller, and does not advance
the source. Other `RetainedError` outcomes expose `kind` and `retryable` and remain
terminal. Every batch is provisional until successful completion; live exporter
status observes late failures. `profile_copy` distinguishes native emissions
from successfully delivered Python batches. `retained_snapshot_copy` observes
the shared resource owner.

`profile_copy` also exposes `source_pinned_capacity_bytes`, the conservative
source capacity admitted to query/runtime memory before cursor creation,
`source_preflight_rows`, the cold capacity-walk work, and `query_peak_bytes`.
The source bound includes unrelated labels and unrequested property capacities;
close or EOF releases it while already produced numeric views remain readable.
The peak is historical and can grow when Arrow descriptors are admitted without
advancing the cursor. Source capacity is charged per cursor, including shared
pages; an unchanged source can reuse its cached bound without rescanning rows.

Close batches/exporters explicitly and release each memoryview when finished.
Each native buffer acquisition has a separately admitted lease; existing views
remain readable after parent or database closure. A derived view shares the
managed lease and a small slice retains the full native capacity. Closed
high-level owners reject new access/export. `retain` creates an independent
admitted owner. GC prevents abandoned-owner leaks, while explicit release is the
way to unblock a stopped same-thread consumer. Owners keep the code module,
not the database or source iterator.

The strict payload path builds no Python row list, result JSON or IPC envelope.
`value_copy` and metadata/diagnostic copy methods are explicit object
materialization. Writable buffer requests and dtype changes refuse before
ownership transfer. The extension imports without NumPy or PyArrow.

Compatible batches expose standard `__arrow_c_array__` capsules and both
batches/cursors expose schema-only `__arrow_c_schema__`. Cursor
`__arrow_c_stream__` exports a demand-driven stream, adopting the cursor;
the original cursor then reports closed. Exporting a capsule does not pull.
Only empty/contiguous selections are eligible; sparse selections and requested
schema arguments refuse explicitly. Capsules are consumed once, and abandoned
capsules release their owners. Arrays already handed to a consumer remain
readable after stream or database close.

Selection contiguity is checked per demanded batch. An Arrow stream may return
earlier arrays and then terminate with `SelectionRequiresMaterialization`
(errno 22) when matching/nonmatching rows interleave. Earlier arrays stay
readable but remain an incomplete result. Known plan/schema incompatibility is
checked at creation; export does not pre-scan, gather or copy the source. Native
retained batches preserve sparse selection for selection-aware consumers.
Arrow data/stream export currently requires a little-endian host. Errno 12
can mean retryable pressure or a terminal capacity/result-budget failure;
inspect the diagnostic rather than retrying solely on errno. Generic Arrow
consumers may stop after any stream error.

Cursor pull, close and Arrow adoption are serialized. `close()` waits for an
in-flight pull while releasing the GIL; it is idempotent. Batches already
delivered by that pull remain independently readable. For point queries and
row consumers, see the [layout and measurement discussion](../../docs/RETAINED_NUMERIC_FOUNDATION.md#point-lookups-row-reads-and-columnar-delivery).
Indexed complete-node views use `Database.execute_retained_node()` with a unique
constraint on the seek property; see [the indexed-node API](../../docs/PYTHON_RETAINED_NODE_VIEWS.md)
and [its ownership contract](../../docs/PYTHON_RETAINED_NODE_OWNERSHIP.md).

With optional PyArrow installed, use its public protocol APIs:

```python
reader = pyarrow.RecordBatchReader.from_stream(query)
try:
    for batch in reader:
        consume(batch)  # release each batch before retaining more than two
finally:
    reader.close()
```

`pyarrow.record_batch(batch)` imports an eligible retained batch. Collecting all
stream batches, including `read_all()`, still obeys shared limits and may raise
an Arrow resource error; it does not enlarge the native pool. EOF confirmation
after the immutable source is exhausted needs no new slot/descriptor.

The optional public-consumer tests are skipped without PyArrow. After building
the default test target, they can be run in a separate environment containing
pytest and PyArrow (locally verified with 26.0.0):

```bash
bazel test //bindings/python:hawdb_python_tests
PYTHONPATH="$PWD/bazel-bin/bindings/python/hawdb_python_tests.runfiles/_main/bindings/python/python" \
  /path/to/consumer-venv/bin/python -m pytest --import-mode=importlib \
  --rootdir="$PWD" bindings/python/tests/test_arrow_consumer.py -v
```

This is experimental. Whole-operation source/planning qualification, opaque derived-view
and allocator/RSS accounting, platform and performance qualification remain
open. Current native result-buffer evidence does not bound whole-operation or
interpreter RSS. See [the Arrow contract](../../docs/RETAINED_ARROW.md) and
[the full scope and remaining gates](../../docs/RETAINED_NUMERIC_FOUNDATION.md).

## Build from source

From the repository root, Bazel builds the native extension and runs the same
Python smoke tests with a hermetic CPython 3.11 interpreter and locked pytest
dependencies. No virtual environment, maturin, or preinstalled HawDB wheel is
needed:

```bash
bazel build //bindings/python:hawdb
bazel test //bindings/python:hawdb_python_tests
```

The Python package is also available as a `py_library` dependency at
`//bindings/python:hawdb`. Its native module is built from the existing Rust
binding against `//:hawdb`, with the same Python 3.11 stable ABI as the maturin
build. Bazel outputs stay under `bazel-bin/bindings/python/python/hawdb/`.

For wheel packaging or an editable installation, use maturin:

Requires the repo-pinned Rust toolchain and Python 3.11+ (abi3 wheels).

```bash
cd bindings/python
uv venv .venv && source .venv/bin/activate
uv pip install maturin pytest
maturin develop
```

### Notebooks

In Jupyter or IPython a `QueryResult` renders as an HTML table (values escaped
as text, at most 100 rows, with a note on how many were omitted); terminals keep
the plain `repr`. `import hawdb.magic` (or `%load_ext hawdb.magic`) registers
`%%cypher` and `%%sql` when IPython is running; `hawdb` itself never imports
IPython.

```python
%load_ext hawdb.magic
%hawdb open ./graph          # or: %hawdb use NAME / set a `hawdb_db` variable
params = {"code": "603122"}  # optional; a list for %%sql
```

```cypher
%%cypher
MATCH (s:Stock {code: $code}) RETURN s.code
```

Every `%%cypher` and `%%sql` cell reads the notebook's `params` variable, so
set `params = None` once a query no longer needs it. The magic line takes no
options. The cell body is sent unchanged. `%%sql` returns the `QueryResult`;
`%%cypher` returns it wrapped so that, when the displayed rows hold nodes or
relationships (`MATCH (a)-[r]->(b) RETURN a, r, b`), they are drawn as an
inline SVG graph above the table. The wrapper forwards everything else to the
`QueryResult`, which its `result` attribute holds. `%hawdb close` closes the
selected database.

### Lite profile

Constrained hosts — including the Pyodide/JupyterLite build — can link the
extension against the minimal engine instead of the default feature set:

```bash
maturin build --no-default-features --features lite
# or inside the venv: maturin develop --no-default-features --features lite
```

Lite is a build profile of the same package, not a second API: `open`,
`execute`, `execute_sql`, and `QueryResult` behave the same. Statements that
need a capability the minimal engine does not carry (full-text search, vector
search, graph analytics, background maintenance) raise
`hawdb.exceptions.CapabilityError` instead of silently returning an empty
result. `hawdb.capabilities()` reports which capability flags were compiled
into the extension as a read-only mapping:

```python
import hawdb

hawdb.capabilities()["full_text_search"]   # False on a lite build
```

## Usage

```python
import hawdb

db = hawdb.open("./graph")
db.execute("CREATE (s:Stock {code: $code})", {"code": "603122"})

result = db.execute("MATCH (s:Stock) RETURN s.code")
print(result.columns)      # ['s.code']
print(result.fetchall())   # [{'s.code': '603122'}]

db.execute_sql("SELECT 1")
db.close()
```

`db.transaction()` opens the engine's single user transaction: statements
inside the `with` block see their own writes, a clean exit publishes them as
one durable commit, and an exception rolls back and re-raises. A nested
`db.transaction()` — and any `db.execute` / `db.execute_sql` on the parent —
fails while a transaction is open.

```python
with db.transaction() as tx:
    tx.execute("CREATE (s:Stock {code: $code})", {"code": "603122"})
    tx.execute_sql("INSERT INTO trades (id) VALUES ($1)", [7])
    tx.rollback()      # abandons both statements; omit it to commit
```

`db.read_transaction()` pins the committed state for a stable read scope;
write statements on it raise the engine's read-transaction error.

`db.executemany(cypher, rows)` accepts a sequence of parameter dicts;
`db.executemany_sql(sql, rows)` accepts a sequence of positional parameter
sequences (for example, lists or tuples). Each nonempty batch uses one
transaction and commits after the last row. A conversion or statement failure
rolls back the entire batch, including any earlier rows. Parameters use the same
value mapping as `execute` and `execute_sql`, converted one row at a time.
Both methods return `None` and discard statement results. An empty sequence is
a no-op that neither opens a transaction nor executes the statement.

```python
db.executemany(
    "CREATE (s:Stock {code: $code})",
    [{"code": "603122"}, {"code": "600519"}],
)
db.executemany_sql("INSERT INTO trades (id) VALUES ($1)", [(1,), (2,)])
```

`tx.executemany` and `tx.executemany_sql` use an existing transaction without
committing it. If batch processing fails, they roll back and close the **whole
transaction**, including earlier statements outside the batch; there are no
savepoints. Catching the exception does not leave a partial batch that can later
be committed. A successful batch stays staged until the caller commits or exits
the transaction context normally.

`hawdb.open()` without a path returns an empty in-memory database for tests,
notebooks, and other hosts that already hold their data. It answers the same
`execute` / `execute_sql` calls through the same embedded admission path;
`close()` discards everything and a later `open()` does not see it.
`db.path` is `None` for in-memory handles. `open`, `Database` and `connect`
share the same path validation: omit the path or pass `None` for an in-memory
database; `""` and `":memory:"` raise `ValueError` (including read-only opens).
Unlike `sqlite3`, `":memory:"` does not select an in-memory database.

Durable paths accept strings and `os.PathLike[str]`. A bare relative name
creates or opens a project in the current directory. `Database.path` and its
`repr` report an absolute path without resolving symlinks, so the saved path
can reopen the same project after `os.chdir`. A regular file is refused with
a `StorageError` naming the path and is left untouched. Writable opens refuse
known missing parents with `StorageError`; permission and other filesystem
errors retain their underlying cause instead of reporting a missing parent.
Read-only opens require an existing project. DB-API `connect` preserves these
native exception types while also mapping them into its PEP 249 hierarchy.
The parent check is advisory: removal between validation and engine open can
still race with the engine's parent creation. It is not an atomic
no-parent-creation guarantee.

```python
db = hawdb.open()
db.execute("CREATE (:Memory {id: $id})", {"id": 1})
db.close()
```

Parameters accept `None`, `bool`, `int`, `float`, `str`, `bytes`,
`decimal.Decimal`, `uuid.UUID`, lists or tuples, and string-keyed dicts, nested up to 64 levels.
They are stored exactly or rejected before the statement runs. Integers,
including NumPy integer scalars, must fit the signed 64-bit range or raise
`OverflowError`. NumPy `float16`/`float32` scalars widen to `float` exactly
and are accepted. Finite `Decimal` values use the native exact representation
with at most 38 coefficient digits and scale 0 through 38; invalid or
out-of-range decimals raise `ValueError`. Results return `decimal.Decimal`
without conversion through a Python float, retaining stored scale. Decimal
does not have an experimental retained numeric buffer representation.
Any other type, including `Fraction` or NumPy
`longdouble`, raises `TypeError`; convert such values explicitly, for example
with `float(x)`. Deeper nesting, including a list or dict that contains
itself, raises `ValueError`. These errors name where the value sits in the
parameters: `$price`, `$2` for an `execute_sql` parameter, `$rows[3].price`
inside a list of dicts, or `$m["first name"]` for a key that is not an
identifier. A `str` that is not valid UTF-8, such as one with a lone
surrogate, raises `UnicodeEncodeError`.

Errors raise `hawdb.exceptions` subclasses (`ParseError`, `SemanticError`,
`StorageError`, `ExecutionError`, `ConflictError`, ...), mapped from
`HawDBError` kinds.

Memory-ledger and spill-quota refusals raise `exceptions.ResourceBudgetError`,
a subclass of `ExecutionError`, with `resource`, `scope`, `owner`,
`memory_class`, `requested`, `used`, `budget`, and `budget_option` attributes.
The message retains the existing execution diagnostic. `requested` is the
additional charge (the net increase for a query-level ownership transfer),
and `budget` is the effective limit at the refusing boundary. `budget_option`
identifies a host setting, runtime grant, or constructor argument; increasing
a configured maximum does not widen a smaller runtime grant. For filesystem
free-space refusals, `budget` is the usable space after preserving
`min_spill_free_bytes`; that option is a minimum reserve, not a maximum to raise.
File-descriptor failures retain `DescriptorError`. Other execution checks may
still raise the general `ExecutionError`. The [resource metadata contract](../../docs/EXECUTION_REQUEST_CONTRACT.md#resource-refusal-metadata)
lists built-in budget origins and explains how to handle caller-defined origins.

## DB-API (PEP 249)

`hawdb.connect()` returns a PEP 249 connection for SQL. It takes the same
arguments as `hawdb.open`, plus `autocommit`, and owns the database it opens.
`connect` used to be another name for `open`; code that relied on that should
call `open`, which keeps Cypher, named parameters and graph values. A
connection holds its path like any handle, so close it before opening the same
path with `open`.

```python
with hawdb.connect() as connection:
    cursor = connection.cursor()
    cursor.execute("CREATE TABLE trades (id BIGINT PRIMARY KEY, price DOUBLE PRECISION)")
    cursor.executemany("INSERT INTO trades (id, price) VALUES ($1, $2)", [(1, 9.5), (2, 7.25)])
    cursor.execute("SELECT id, price FROM trades WHERE id >= $1", [2])
    print(cursor.description[1][:2])  # ('price', <class 'float'>)
    print(cursor.fetchall())          # [(2, 7.25)]
```

Parameters bind by position to `$1`, `$2`, ... (`paramstyle` is
`"numeric_dollar"`). Autocommit is off: statements, queries included, run in a
transaction that `commit()` publishes and that `rollback()`, `close()` or an
exception leaving the `with` block discards. A transaction that only reads
still pins its snapshot until it ends, so end it promptly, or turn autocommit on
for long-lived reading. A failed `executemany` rolls back
the whole transaction, and the connection refuses further work until
`rollback()`. Branch commands need autocommit, which
`connect(..., autocommit=True)` turns on and `connection.autocommit` changes
between transactions; read-only connections always autocommit. Errors follow
the PEP 249 hierarchy, and engine errors keep their `hawdb.exceptions` types
as well: a syntax error is both a `ProgrammingError` and a
`hawdb.exceptions.ParseError`. Constraint violations such as a duplicate
primary key are plain `DatabaseError`s, because the engine does not set them
apart yet. Use and close a connection on the thread that opened it.

Rows are tuples in SELECT-list order, and `description` lists the columns even
when nothing matches, including queries on `information_schema`, `pg_catalog`
and `system`. Catalog wildcards expand in the catalog's declared column order.
SQL `EXPLAIN` reports `id, estRows, task, access object, operator info`;
`EXPLAIN ANALYZE` reports `id, estRows, actRows, task, access object, execution info,
operator info, memory, disk`. `pandas.read_sql` works, with a
warning that pandas only tests SQLAlchemy and sqlite3 connections.
`help(hawdb.dbapi)` gives the full contract.

Cypher results from Python's database execution API retain RETURN-list order,
including empty matches and LIMIT 0. Repeated names retain the existing
collision suffixes (for example, coalesce, coalesce#2, coalesce#3).
Cypher EXPLAIN keeps its structured diagnostic row, with mode, statement kind
and plan first; EXPLAIN ANALYZE appends execution diagnostics.

## Pydantic rows (optional)

The `pydantic` extra types the rows a statement returned and the parameters a
statement takes with Pydantic v2 models. It does not generate Cypher or SQL.
`import hawdb` does not import Pydantic; `hawdb.pydantic` loads on first use.

```bash
pip install 'hawdb[pydantic]'
```

```python
from pydantic import BaseModel, Field

import hawdb

class Stock(BaseModel):
    code: str
    price: float

class StockColumns(BaseModel):
    code: str = Field(validation_alias="s.code")

db = hawdb.open("./graph")
db.execute(
    "CREATE (s:Stock {code: $code, price: $price})",
    hawdb.pydantic.params(Stock(code="603122", price=12.5)),
)

result = db.execute("MATCH (s:Stock) RETURN s.code AS code, s.price AS price")
stocks = hawdb.pydantic.parse(result, Stock)

# Columns that are not identifiers bind through a validation alias.
codes = hawdb.pydantic.parse(db.execute("MATCH (s:Stock) RETURN s.code"), StockColumns)

# A batch is a list of parameter dicts, loaded by one statement.
listed = [Stock(code="000001", price=9.0), Stock(code="600000", price=7.2)]
db.execute(
    "UNWIND $rows AS row CREATE (:Stock {code: row.code, price: row.price})",
    {"rows": [hawdb.pydantic.params(stock) for stock in listed]},
)
```

`parse` consumes the remaining rows of a `QueryResult`. Unknown columns and
missing fields follow the model's config. Invalid rows raise one
`pydantic.ValidationError` located by row index, and no models are returned.
Rows hold the binding's values: a UUID comes back as `str` and a tuple as a
list, so a strict model needs `Field(strict=False)` on those fields.

`params` returns `model_dump()`, whose keys become parameter names. Its
values follow the parameter rules above and are checked when the statement
runs, not by `params`: a `datetime` or plain `Enum` field raises
`TypeError` there, named by its path, such as `$price` or `$rows[3].price` in
a batch. Run statements that must all apply or none in `db.transaction()`.
Engine errors stay `hawdb.exceptions`.

## Test

```bash
pytest tests/
```

`conftest.py` parametrizes statement-level tests over both backends: a
file-backed project under `tmp_path` and the in-memory `hawdb.open()`.
`test_stubs.py` keeps the hand-written `_hawdb.pyi` signatures in sync with
the compiled module's runtime signatures.

Without Pydantic installed, the extra's tests skip and the rest prove that
`import hawdb` does not need it. Install `pydantic` to run them. Bazel runs the
two environments as `//bindings/python:hawdb_python_tests` and
`//bindings/python:hawdb_python_pydantic_tests`.

The default unified target uses the explicit deadline declared by upstream
main. It includes every ordinary and retained/Arrow test with per-case database
isolation and unchanged durability settings.

To refresh the Bazel test dependency lock:

```bash
bazel run //bindings/python:requirements.update
```

The separate `Cargo.Bazel.lock` locks the PyO3 dependencies for Bazel; the
binding stays outside the library's Cargo workspace. To update it deliberately,
run `CARGO_BAZEL_REPIN=1 CARGO_BAZEL_REPIN_ONLY=python_crates bazel build //bindings/python:hawdb`.

## Scope and follow-ups

Not included yet: async calls, general-query Arrow output, schema introspection helpers.
These are tracked for follow-up once the base surface settles.

