Initial Release
iris_persistence is a Python object persistence layer for InterSystems IRIS, inspired by %Persistent. It provides a Python-first model class, brownfield scaffolding, and typed storage metadata using IRIS APIs rather than SQL as its persistence model.
Status:
0.1.0public preview. The API is experimental and may change before a stable1.0release. Python 3.10 or newer is required.
Model as the primary base classname: str = Field(...) and Annotated[..., Field(...)] declarationsclass Meta for model configurationpersistent=True and serial=True class flagsField(index=True|unique=True|primary_key=True)extend, replace, and observe schema sync modes%Persistent and %SerialObject modelsModel inheritancedict and dataclass DTO conversion helpersStorageDefinition metadatairis_persistence.testing.InMemoryAdapter for unit teststo_iris() and from_iris()from __future__ import annotationsfrom typing import Annotated
import iris_persistence from iris_persistence import Field, Model
Embedded Python (running inside IRIS) — no argument needed.
iris_persistence.configure()
Remote connection — pass the iris native-API object.
import iris
conn = iris.connect(host, port, ns, user, pw)
iris_persistence.configure(conn)
class Product(Model, persistent=True): name: str = Field(required=True, max_length=200, unique=True) price: Annotated[float, Field(default=0.0)] in_stock: bool = True
class Meta: classname = "Demo.Product" mode = "replace"
product = Product(name="Widget", price=12.5, in_stock=True) Product.sync_schema() product.save() same = Product.get(product.pk) rows = Product.where(name="Widget").order_by("name").all()
None is an explicit value. When a model field is nullable, assigning None
and saving clears that IRIS property. Fields that are absent from a partially
constructed model are not written, so existing IRIS values are left unchanged.
Use Model inheritance for shared persistence fields:
class NamedRecord(Model): name: str
class Product(NamedRecord, persistent=True): price: float = 0.0
Use explicit conversion helpers for API or application DTOs:
from dataclasses import dataclass@dataclass class ProductDTO: name: str price: float
product = Product.from_dict({"name": "Widget", "price": 12.5}) payload = product.to_dict() dto = product.to_dataclass(ProductDTO) same = Product.from_dataclass(dto)
Dataclasses are supported as DTOs, not as persistence base classes.
Fields can be declared either with Field(...) defaults or with Annotated metadata:
from typing import Annotated from iris_persistence import Field, Modelclass Article(Model, persistent=True): title: str = Field(required=True, max_length=500) views: Annotated[int, Field(default=0)]
class Meta: classname = "Demo.Article"
If you need to force the underlying IRIS property type instead of using the Python type mapping,
set Field(iris_type="..."):
class Event(Model, persistent=True):
payload: bytes = Field(iris_type="%Stream.GlobalBinary")
created_at: str = Field(iris_type="%Library.TimeStamp")
Model configuration lives in an optional inner Meta class:
class Meta:
classname = "Demo.Article"
mode = "extend" # "extend" | "replace" | "observe" (default: "extend")
storage = StorageDefinition(data_location="^Demo.ArticleD")
indexes = [Index("TitleIdx", properties="Title", unique=True)]
parameters = {"DEFAULTGLOBAL": "^Demo.ArticleD"}
Meta.parameters is written into IRIS class parameters during sync_schema().
When scaffolding with extract_meta=True, iris_persistence reads parameters from
%Dictionary.CompiledParameter and falls back to the live
%Dictionary.ClassDefinition.Parameters collection if the SQL dictionary view is empty.
Python and IRIS share ownership. Safe starting point for brownfield classes.
class Product(Model, persistent=True): name: str = Field(required=True)class Meta: classname = "Demo.Product" # mode = "extend" ← default, can be omitted
Behavior:
Model.sync_schema() is calledPython is fully authoritative. Use for greenfield classes owned entirely by Python.
class Meta:
classname = "Demo.Product"
mode = "replace"
Behavior:
Model.sync_schema() is calledModel types are synced first so related classes exist before parent compilationIRIS is authoritative. Use to bind to existing classes without touching their schema.
class Article(Model):
class Meta:
classname = "Demo.Article"
mode = "observe"
Behavior:
Storage uses typed dataclasses instead of raw nested dicts.
from iris_persistence import StorageData, StorageDefinition, StorageProperty, StorageSQLMapclass Product(Model, persistent=True): name: str = Field(required=True)
class Meta: classname = "Demo.Product" mode = "replace" storage = StorageDefinition( data_location="^Demo.ProductD", default_data="ProductDefaultData", type="%Storage.Persistent", data=( StorageData( name="ProductDefaultData", structure="listnode", values={"1": "%%CLASSNAME", "2": "Name"}, ), ), properties=( StorageProperty(name="Name", average_field_size="8"), ), sql_maps=( StorageSQLMap(name="IDKEY", block_count="-4"), ), )
Plain dicts are accepted, but StorageDefinition(...) is the intended API.
iris_persistence supports nested model references:
%Persistent models can reference other %Persistent models%Persistent models can embed %SerialObject modelsfrom typing import Annotated from iris_persistence import Field, Modelclass Address(Model, serial=True): street: str = Field(required=True, max_length=120)
class Meta: classname = "Demo.Address" mode = "replace"class Customer(Model, persistent=True): name: str = Field(required=True, max_length=120)
class Meta: classname = "Demo.Customer" mode = "replace"class Order(Model, persistent=True): number: str = Field(required=True, max_length=32) customer: Customer | None = None ship_to: Address | None = None
class Meta: classname = "Demo.Order" mode = "replace"
Use to_iris() when you need the underlying IRIS object handle without saving a row:
product = Product(name="Widget", price=12.5) iris_obj = product.to_iris()
assert product.pk is None
to_iris() populates the object graph in memory. It may create unsaved IRIS object
handles for related models, but it does not call %Save() and does not persist
%Persistent rows. A later save() reuses those materialized handles and
persists related %Persistent models through the normal save path. For pure
transient object-body creation, disable persistence-oriented conveniences:
iris_obj = product.to_iris(auto_sync=False, validate=False)
Use from_iris() when you already have an IRIS object handle and want a typed
Python model wrapper:
iris_obj = iris.cls("Demo.Product")._OpenId("1")
product = Product.from_iris(iris_obj, known_pk="1")
iris_persistence uses iris-embedded-python-wrapper as its unified runtime facade for embedded, embedded-local, and native remote access.
Embedded Python (running inside IRIS — no argument needed):
import iris_persistence
iris_persistence.configure()
Remote (running externally via the Native API):
import iris import iris_persistence
conn = iris.connect(host, port, namespace, user, password) iris_persistence.configure(conn)
If configure() is never called, iris_persistence reads the current iris.runtime state without mutating it. Configure embedded mode with IRISINSTALLDIR or iris.connect(path=...), or configure native mode with iris_persistence.configure(conn).
If you already have a DB-API connection that should be reused for queries and scaffolding, bind it explicitly:
iris_persistence.configure(dbapi_connection=dbapi_conn)
InMemoryAdapter is available for model tests without a live IRIS instance.
It is intentionally limited to CRUD/query tests and does not emulate %Dictionary or schema compilation.
from iris_persistence.testing import InMemoryAdapter from iris_persistence.runtime import configure_default_runtime
adapter = InMemoryAdapter() configure_default_runtime(runtime=adapter)
Run unit tests inside the IRIS Docker container:
./scripts/test-unit.sh
Run the live IRIS round-trip coverage inside Docker:
./scripts/test-docker.sh
The Docker E2E runner uses docker-compose-test.yml and defaults to
containers.intersystems.com/intersystems/iris-community:latest-cd.
Override the image tag when needed:
IRIS_IMAGE_TAG=latest-preview ./scripts/test-docker.sh
test-unit.sh and test-docker.sh use the same local container runner. test-unit.sh
selects pytest -m "not integration"; test-docker.sh selects pytest -m integration.
You can still run integration tests directly against a configured local IRIS runtime:
.venv/bin/pytest -m integration
Integration tests use checked-in fixtures under tests/fixtures/:
tests/fixtures/objectscript/: one-class-per-.cls IRIS source fixtures plus Python fallback sidecarstests/fixtures/python/: Python-first fixture models for round-trip coverageThat fixture set covers:
%PersistentEns.Request%SerialObject%Persistent referencing %Persistent and %SerialObject)Run the local checks:
.venv/bin/python -m ruff check iris_persistence tests examples benchmarks
.venv/bin/python -m mypy iris_persistence
.venv/bin/python -m pytest -m "not integration"
Run live IRIS integration coverage against the community image:
IRIS_IMAGE_TAG=latest-cd ./scripts/test-docker.sh
Latest verification, 2026-05-11:
10 source files checked91 passed, 14 deselectedlatest-cd: 12 passed, 2 skipped, 91 deselectedRun the simple benchmark in Docker:
./scripts/benchmark-simple.sh --rows 500 --repeats 3
Run it from a local virtualenv:
.venv/bin/python benchmarks/simple_suite.py --rows 500 --repeats 3
On macOS, do not export DYLD_LIBRARY_PATH to the IRIS install bin directory for
local benchmark runs. That can force the Native API wheel to bind to incompatible
IRIS dylibs. If your shell exports it globally, unset it for the benchmark process:
env -u DYLD_LIBRARY_PATH .venv/bin/python benchmarks/simple_suite.py --rows 500 --repeats 3
Use --modes to run a subset, and --require-remote when remote modes must fail
instead of being skipped:
.venv/bin/python benchmarks/simple_suite.py --modes embedded_persistence,objectscript
Generate typed models from live IRIS:
from iris_persistence import ScaffoldResult, scaffold_from_irisscaffold_from_iris("Demo.*", "./generated_models")
result: ScaffoldResult = scaffold_from_iris( "Demo.*", "./generated_models", extract_meta=True, scaffold_selectivity=True, return_result=True, ) for warning in result.warnings: print(warning.message)
Scaffold rules:
mode="observe" is the defaultAnnotated[..., Field(...)]class MetaStorageDefinition(...)scaffold_selectivity=True enriches StorageProperty(..., selectivity=...) from %Dictionary.StoragePropertyDefinitionmode="extend" preserves indexes and parameters in Metareturn_result=True returns generated file paths plus any metadata extraction warningsRunnable examples:
ModelFieldIndexStorageDefinitionStorageDataStoragePropertyStorageSQLMapconfigurescaffold_from_irisiris_persistence.testing.InMemoryAdapterAdvanced:
Model.sync_schema()iris_persistence.scaffold.scaffold_from_cls() for exported .cls files. It is intentionallyNotImplementedError.