Metadata-Version: 2.5
Name: edutap.data_models
Version: 0.3.0
Summary: Shared vocabularies, message contract, Kafka runtime and settings building blocks for eduTAP
Project-URL: Source, https://github.com/edutap-collective/edutap.data_models
Project-URL: Issues, https://github.com/edutap-collective/edutap.data_models/issues
Author: eduTAP
License-Expression: EUPL-1.2
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: pydantic-settings>=2.4
Requires-Dist: pydantic>=2.8
Requires-Dist: structlog>=25
Provides-Extra: dev
Requires-Dist: anyio>=4.4; extra == 'dev'
Requires-Dist: cryptography>=44; extra == 'dev'
Requires-Dist: pdbp; extra == 'dev'
Requires-Dist: pytest>=8.2; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
Requires-Dist: ty; extra == 'dev'
Provides-Extra: docs
Requires-Dist: myst-parser; extra == 'docs'
Requires-Dist: sphinx>=8; extra == 'docs'
Description-Content-Type: text/markdown

# edutap.data_models

Shared contracts of the eduTAP packages: controlled vocabularies, the Kafka message
contract, and settings building blocks whose field names are meant to be identical
in every container.

## Why this exists

Three duplicates, all of them found in the field.

**Vocabularies in three diverging copies.** `PassLifecycleState` existed with eight
values in `lmu_edutap_common` and with six in both `lmu_edutap_full_view` and
`edutap.data_provider`. `WalletType` existed in four spellings.

**The message contract.** Header block, key rule and dead letter envelope have to be
identical in six packages. Six copies diverge, and the divergence is invisible until
a consumer quietly stops recognising a message.

**Settings that should mean the same thing everywhere.** `environment` is the clearest
case: the same field name in every container, populated per service by the Swarm
compose file, and read by the error tracker and the trace exporter alike.

## What belongs here

| Module | Contents |
|---|---|
| `vocabulary` | `WalletType`, `IssuanceState`, `HolderState`, `InstanceState`, `FieldKind`, `Provider` |
| `messaging` | header names and construction, logical topic names, schema and action names, the `pass.command` body, DLQ naming |
| `settings` | `ServiceSettings`, `KafkaSettings` — mixins, not a finished class |
| `runtime` | the shared Kafka runtime: `consume()`, `publish()`, `Unprocessable`, `DeadLetterQueue`, `serve()` |

The `runtime` package is the odd one out and says so. It is behaviour rather than a
contract in the way a vocabulary is, and it is here because the behaviour is what
several services have to agree on: the commit order, the dead letter naming, what a
stop signal does. It is written against protocols, so the Kafka driver is **not** a
dependency of this package — `build_consumer()` and `build_dead_letter_producer()`
stay in the service, with its settings class. See the design record of 2026-08-11.

The pass lifecycle is spelled on **three** axes. `IssuanceState` is what the issuer
did or wants and exists with no exemplar at all; `InstanceState` is what one exemplar
at the holder is doing; `HolderState` is the summary of the second and is derived,
never set. `PassLifecycleState` conflated the first two and is superseded — it is
still reachable under `edutap.data_models.vocabulary` and warns when used.

Error tracking and trace export live in `edutap.observability_settings`, not here.
The options that decide what may leave a process were chosen against measurements
and are worth nothing apart from the `sentry_sdk.init()` call that applies them.

## What does not

Table definitions of individual packages. `edutap.db_definitions` collects those
through entry points, and that collection is what makes one package answerable for
one schema. Move them here and nobody owns a schema any more.

Anything LMU-specific. `lmu_edutap_common` stays what it is; the `edutap.*` packages
must not depend on it.

## Dependency direction

This package depends on nothing from the eduTAP estate — only on `pydantic`,
`pydantic-settings` and `structlog`. Everything else may depend on it. A library that
knows about services is not a library.

`structlog` is the third and had to be argued for: the runtime loop's log records are
structured — `topic`, `partition`, `offset`, `reason` — and that structure is their
entire value in operation. Every consumer already uses it.

## Usage

```python
from edutap.data_models import WalletType
from edutap.data_models.messaging import TOPIC_PASS_STATE, build_headers
from edutap.data_models.settings import KafkaSettings


class MySettings(KafkaSettings):
    model_config = {"env_prefix": "EDUTAP_MY_SERVICE_"}


settings = MySettings()                      # aborts without EDUTAP_MY_SERVICE_TOPIC_PREFIX
topic = settings.topic(TOPIC_PASS_STATE)     # edutap.production.pass.state
```

Reaching a broker that requires mTLS is the same three settings fields everywhere —
`ca_file`, `cert_file`, `key_file`, plus `password` for an encrypted key — and one
function that turns them into driver options:

```python
from edutap.data_models.runtime import transport_options

AIOKafkaConsumer(topic, bootstrap_servers=..., **transport_options(settings))
```

It returns `{}` when nothing is configured, which is the development case against a
broker without TLS. Configuring only *some* of the three raises instead of falling
back: against an SSL-only broker a silent fallback does not produce an unencrypted
connection, it produces one that fails during the handshake — and that error names
the broker rather than the secret nobody mounted.

The context is built from the standard library, so the Kafka driver stays out of this
package's dependencies.

Sending is one call, and it builds the envelope rather than accepting one:

```python
from edutap.data_models import messaging
from edutap.data_models.runtime import producer_options, publish

producer = AIOKafkaProducer(**producer_options(settings.bootstrap_servers),
                            **transport_options(settings))

await publish(
    producer,
    settings.topic(messaging.TOPIC_PASS_COMMAND),
    key=command.pass_id.encode(),
    value=command.model_dump_json().encode(),
    producer_name="my_service",
    schema=messaging.SCHEMA_PASS_COMMAND,
    event_id=str(uuid4()),
    occurred_at=datetime.now(tz=UTC),
    action=messaging.ACTION_CREATE,
)
```

There is deliberately no way to publish without the header block. It was available as
a separate call for two minor versions and the estate's one producer never made it —
a body the broker accepts and every consumer parks, with success reported at the
sending end. A rule that cannot be broken beats a rule that is written down.

`acks="all"` and idempotence are not options in `producer_options()` either. An HTTP
endpoint answers its caller on the strength of that write.

## Development

```shell
make venv
make lint
make test-local
```

`tox` runs the suite across every supported Python version.

## Design records

The design is written down under [`docs/superpowers/specs/`](docs/superpowers/specs/).
