Schnellstart

Diese Seite führt ein Projekt vom leeren Verzeichnis bis zum Skript, das Objekte liest und schreibt: Installation, Entscheidung zwischen der sync- und der async-Variante, Verbindungsaufbau, typisierte Modelle, ein select(), eine direkte SQL-Abfrage, ein upsert(), ein Dokument samt Datei, ein Kontextwechsel auf einen anderen Benutzer und die Fehlerbehandlung.

Jedes Codebeispiel liegt in beiden Varianten vor. Der zum Projekt passende Tab wird einmal gewählt und bleibt für den Rest der Seite ausgewählt.

Voraussetzungen
  • Python >= 3.12

  • uv oder pip

  • Hostname, Port (Standard 4000) und Zugangsdaten des enaio-Servers

  • TCP-Zugang vom ausführenden Rechner zu diesem Server

1. Installation

  • uv

  • pip

uv add ecmind-blue-client
pip install ecmind-blue-client

1.1. Neues Projekt von Null aufsetzen

uv init erzeugt das Projektgerüst, uv add löst die Abhängigkeit auf und schreibt sie in pyproject.toml und uv.lock:

uv init invoice-import
cd invoice-import
uv add ecmind-blue-client

Verzeichnis danach:

invoice-import/
├── main.py
├── pyproject.toml
├── README.md
└── uv.lock

Die virtuelle Umgebung wird vom ersten uv-Kommando erzeugt, das sie benötigt, es muss also nichts manuell aktiviert werden. Ausgeführt wird das Projekt mit uv run:

uv run main.py
uv add schreibt die aufgelösten Versionen nach uv.lock. Diese Datei gehört in die Versionsverwaltung, damit auf jedem Rechner und in jedem Build exakt dieselben Versionen installiert werden.

2. Sync oder Async?

Die Bibliothek bietet zwei API-Varianten, die sich in der Ausführungsweise unterscheiden:

Sync (SyncPoolClient) Async (AsyncPoolClient)

Blockiert den aufrufenden Thread bis die Antwort vorliegt.

Gibt die Kontrolle an den Event Loop zurück, während auf die Antwort gewartet wird.

Einfacher, linearer Code ohne async/await.

Erfordert async def-Funktionen und await.

Geeignet für Skripte, Batch-Prozesse und Importer.

Geeignet für Web-Anwendungen und andere Event-Loop-basierte Systeme.

Sync eignet sich überall dort, wo der Code sequenziell läuft und keine parallele Verarbeitung nötig ist — zum Beispiel in Importskripten, Migrationen oder Kommandozeilen-Werkzeugen.

Async ist die richtige Wahl, wenn der Client in einen bestehenden Event Loop eingebunden wird, etwa in FastAPI-Endpunkten. In diesem Fall würde ein blockierender Sync-Client den gesamten Event Loop blockieren und damit alle parallelen Anfragen verzögern.

  • Sync

  • Async

# Importskript: linearer Code, kein Event Loop
ecm = ECM(SyncPoolClient(servers="<host>:4000:1", username="<user>", password="<pass>"))

for folder in ecm.dms.select(InvoiceFolder).stream():
    print(folder.system.id, folder.Title)
# FastAPI-Endpunkt: in einem bestehenden Event Loop
ecm = ECM(AsyncPoolClient(servers="<host>:4000:1", username="<user>", password="<pass>"))

@app.get("/folders")
async def list_folders():
    return [
        {"id": folder.system.id, "title": folder.Title}
        async for folder in ecm.dms.select(InvoiceFolder).stream()
    ]

3. Verbindung herstellen

  • Sync

  • Async

from ecmind_blue_client.ecm import ECM
from ecmind_blue_client.pool import SyncPoolClient

client = SyncPoolClient(
    servers="<host>:4000:1",
    username="<username>",
    password="<password>",
)
ecm = ECM(client)
from ecmind_blue_client.ecm import ECM
from ecmind_blue_client.pool import AsyncPoolClient

client = AsyncPoolClient(
    servers="<host>:4000:1",
    username="<username>",
    password="<password>",
)
ecm = ECM(client)

Das Format für servers ist <host>:<port>:<gewichtung>, mehrere Server werden durch getrennt. Die Gewichtung bestimmt die Verteilung der Verbindungen, "<host1>:4000:2<host2>:4000:1" schickt also doppelt so viele Verbindungen an den ersten wie an den zweiten Server.

Verschlüsselung (use_ssl)

SyncPoolClient und AsyncPoolClient haben den Parameter use_ssl=True als Default — der enaio®-Server erwartet seit vielen Versionen verschlüsselte TCP-Verbindungen. Diesen Default unverändert lassen.

use_ssl=False ist ausschließlich für historische enaio-Versionen ohne TLS-Support vorgesehen und gilt als deprecated. In aktuellen Produktionsumgebungen muss use_ssl immer True sein.

Der Client wird einmal erzeugt und über die gesamte Laufzeit wiederverwendet, nicht pro Operation neu. Bei einem kurzen Skript kann der Pool der Garbage Collection überlassen werden. Ein langlaufender Prozess sollte ihn explizit mit client.close() (sync) bzw. await client.aclose() (async) herunterfahren, was die Hintergrund-Worker stoppt und alle unbenutzten Verbindungen schliesst. Das Gegenstück in einem Web-Service zeigt FastAPI-Integration, das Offenhalten unbenutzter Verbindungen durch Firewalls und Load-Balancer hindurch KeepAlive im Pool.

4. Modelle erstellen

Eine Modellklasse beschreibt einen Objekttyp und liefert typisierten Feldzugriff, Code-Completion und statische Typprüfung. ecm-generate-models ist Teil der Bibliothek und schreibt diese Klassen direkt aus der Objektdefinition des Servers, eine Datei pro Schrank:

uv run ecm-generate-models \
    --host enaio.example.com \
    --username admin \
    --password secret \
    --cabinet Invoices \
    --output-dir ./models
Written: models/Invoices.py

Die generierte Datei enthält eine Klasse pro Objekttyp, ein ECMField pro Indexfeld und ein Enum pro Listenfeld:

class InvoiceFolder(ECMFolderModel):
    _internal_name_ = "InvoiceFolder"

    Title: ECMField[str] = ECMField(str, mandatory=True)
    Year: ECMField[int] = ECMField(int, default=None)
    Status: ECMField[InvoiceFolder_StatusEnum] = ECMField(InvoiceFolder_StatusEnum, default=None)

Die Datei wird neu generiert, sobald sich das Schema auf dem Server ändert, und nie manuell bearbeitet. Jedes Argument des Kommandos, inklusive Generierung aus einer exportierten asobjdef-XML anstelle eines laufenden Servers, beschreibt ecm-generate-models.

Wo Generierung nicht in Frage kommt, lässt sich eine Modellklasse auch von Hand schreiben oder zur Laufzeit allein aus dem internen Namen mit make_folder_model() und seinen Geschwistern erzeugen. Beides beschreibt ECM-Modell.

5. Objekte abfragen

ecm.dms.select() gibt einen Query-Builder zurück: .where() filtert, .order_by() sortiert, ausgeführt wird die Abfrage mit .stream() oder .execute().

stream()

Liest die Ergebnisse seitenweise vom Server und liefert jedes Objekt einzeln. Empfohlen für große Ergebnismengen, da nie alle Objekte gleichzeitig im Speicher gehalten werden.

execute()

Ruft alle Ergebnisse vollständig ab und gibt eine Liste zurück. Praktisch bei kleinen Ergebnismengen oder wenn die Gesamtanzahl vorab benötigt wird.

execute() lädt alle Objekte vollständig in den Speicher, bevor die Verarbeitung beginnt. Bei großen Ergebnismengen kann dies zu Speicherproblemen führen.

stream() hingegen arbeitet seitenbasiert — das Paging in enaio ist nicht transaktional. Werden während des Iterierens Objekte verändert, kann es vorkommen, dass Seiten inkonsistente Zustände liefern oder Objekte doppelt erscheinen. In diesem Fall sollte execute() bevorzugt werden.

  • Sync

  • Async

from models.Invoices import InvoiceFolder

for folder in (
    ecm.dms.select(InvoiceFolder)
    .where(InvoiceFolder.Year >= 2024)
    .order_by(InvoiceFolder.Year.DESC)
    .stream()
):
    print(folder.system.id, folder.Title, folder.Year)
from models.Invoices import InvoiceFolder

async for folder in (
    ecm.dms.select(InvoiceFolder)
    .where(InvoiceFolder.Year >= 2024)
    .order_by(InvoiceFolder.Year.DESC)
    .stream()
):
    print(folder.system.id, folder.Title, folder.Year)

.execute() liefert dieselben Treffer stattdessen als Liste, praktisch wenn die Gesamtzahl vorab benötigt wird: folders = ecm.dms.select(InvoiceFolder).where(InvoiceFolder.Year == 2024).execute(), in der async-Variante mit await vor der Kette.

Mehrere Bedingungen in einem .where()-Aufruf werden mit AND verknüpft, & und | verknüpfen sie explizit. Beide Operatoren binden in Python stärker als ==, jeder Vergleich braucht daher eigene Klammern, und or / and sind kein Ersatz: sie würden eine Bedingung verwerfen und werden deshalb mit TypeError abgelehnt.

query = ecm.dms.select(InvoiceFolder)

query.where((InvoiceFolder.Year == 2024) | (InvoiceFolder.Year == 2025))   # OR-Gruppe
query.where(InvoiceFolder.Year == 2024 | InvoiceFolder.Year == 2025)       # TypeError
query.where((InvoiceFolder.Year == 2024) or (InvoiceFolder.Year == 2025))  # TypeError

Alle Builder-Methoden inklusive Paging, Feldbeschränkung und Volltextsuche: select().

6. Ein einzelnes Objekt lesen und ändern

select() sucht, get() holt ein bekanntes Objekt über seine ID:

  • Sync

  • Async

folder = ecm.dms.get(InvoiceFolder, 4711)
print(folder.Title)
folder = await ecm.dms.get(InvoiceFolder, 4711)
print(folder.Title)

Eine nicht existierende ID führt zu einer ECMNotFoundException.

Eine geladene Instanz verfolgt ihre Änderungen selbst, update() schickt daher nur die Felder, die sich wirklich geändert haben:

  • Sync

  • Async

folder.Title = "Rechnung 2024 (korrigiert)"
print(folder.system.is_modified)       # True
print(folder.system.modified_fields)   # {'Title': 'Rechnung 2024 (korrigiert)'}

ecm.dms.update(folder)
folder.Title = "Rechnung 2024 (korrigiert)"
print(folder.system.is_modified)       # True
print(folder.system.modified_fields)   # {'Title': 'Rechnung 2024 (korrigiert)'}

await ecm.dms.update(folder)

Hat sich nichts geändert, unterlässt update() den Serveraufruf ganz, mit force=True wird er trotzdem gesendet. update_and_get() aktualisiert und gibt die frische Instanz in einem Aufruf zurück. Wie die Änderungsverfolgung im Detail arbeitet: ECM-Modell.

7. Direkt per SQL abfragen

ecm.dms deckt die Archivobjekte ab, ecm.security die Benutzer und Gruppen. Alles darüber hinaus, vor allem Aggregate, Joins und Verwaltungstabellen, ist per SQL über ado.ExecuteSQL erreichbar:

  • Sync

  • Async

result = ecm.db.select(
    "SELECT id, benutzer FROM benutzer WHERE benutzer = %s",
    "admin",
)
for row in result:
    print(row["benutzer"])
result = await ecm.db.select(
    "SELECT id, benutzer FROM benutzer WHERE benutzer = %s",
    "admin",
)
for row in result:
    print(row["benutzer"])

Werte gehören in Platzhalter (%s für Strings, %d für Ganzzahlen, %u für Bezeichner) und nie in einen f-String, denn der Server setzt sie quotiert ein und verhindert damit SQL-Injection. Eine Zeile wird als Rohstring mit row["name"] gelesen oder mit row.typed("name", int) konvertiert. Alle Platzhalter, die Spaltenmetadaten und der Rückgabetyp: db.select().

8. Objekte anlegen und aktualisieren

8.1. Anlegen

insert_and_get() legt das Objekt an und gibt es vollständig gefüllt zurück, inklusive der zugewiesenen ID. Wohin das neue Objekt kommt, hängt von seiner Ebene ab:

Ebene Basisklasse Ablage über

Ordner

ECMFolderModel

nichts, ein Ordner ist die oberste Ebene

Register

ECMRegisterModel

folder_id, zusätzlich register_id in einem anderen Register

Dokument

ECMDocumentModel

folder_id, zusätzlich register_id, wenn es in einem Register liegt

  • Sync

  • Async

from datetime import date

folder = ecm.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024", Year=2024))
document = ecm.dms.insert_and_get(
    InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
    folder_id=folder,
)
print(folder.system.id, document.system.id)
from datetime import date

folder = await ecm.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024", Year=2024))
document = await ecm.dms.insert_and_get(
    InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
    folder_id=folder,
)
print(folder.system.id, document.system.id)

Ein Dokument kann direkt in einem Ordner liegen, wie hier, oder über register_id in einem seiner Register. insert() und insert_and_get() akzeptieren für beide entweder eine numerische ID oder die Modellinstanz selbst, ein frisches Objekt kann also direkt als übergeordnetes Objekt des nächsten dienen. upsert() nimmt ausschliesslich die numerischen IDs, daher folder.system.id weiter unten.

8.2. Upsert

upsert() überlässt die Entscheidung zwischen Anlegen und Aktualisieren dem Server: .search() definiert die Bedingungen der Dublettenprüfung, der Server legt bei keinem Treffer neu an und aktualisiert bei genau einem Treffer. Genau das macht ein Importskript gefahrlos zweimal ausführbar.

.execute() gibt die Objekt-ID, die Objekttyp-ID, die Trefferzahl und die tatsächlich ausgeführte Aktion zurück:

  • Sync

  • Async

object_id, type_id, hits, action = (
    ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
    .search(InvoiceFolder.Title == "Rechnung 2024")
    .execute()
)
print(object_id, action)  # z. B. 4711 INSERT
object_id, type_id, hits, action = await (
    ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
    .search(InvoiceFolder.Title == "Rechnung 2024")
    .execute()
)
print(object_id, action)  # z. B. 4711 INSERT

.execute_and_get() liest das entstandene Objekt vom Server zurück und gibt das gefüllte Modell anstelle des Tupels zurück:

  • Sync

  • Async

folder = (
    ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
    .search(InvoiceFolder.Title == "Rechnung 2024")
    .execute_and_get()
)
print(folder.system.id, folder.Title)
folder = await (
    ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
    .search(InvoiceFolder.Title == "Rechnung 2024")
    .execute_and_get()
)
print(folder.system.id, folder.Title)
.search() verwendet nur Feldname und Wert jeder Bedingung. Der Server vergleicht immer auf Gleichheit, andere Operatoren als == haben dort keine Wirkung.

Die Aktion je Trefferzahl ist konfigurierbar, zum Beispiel nur Anlegen mit .action1("NONE"), und Dokumente können ihre Datei über .files() mitschicken. Alle Optionen: upsert().

Zwei clientseitige Regeln greifen beim ersten Schreiben auf einen echten Objekttyp:

Pflichtfelder

Ein mit mandatory=True deklariertes Feld muss einen Wert tragen, sonst lösen insert() und .execute() einen ValueError aus, bevor überhaupt etwas den Server erreicht. Mit check_mandatory=False wird die Prüfung abgeschaltet.

Schreibgeschützte Felder

Ein in der Objektdefinition schreibgeschütztes Feld kann nicht geschrieben werden, der Versuch führt ebenfalls zu einem ValueError. Beide Regeln beschreibt ECM-Modell.

Bei Datums- und Zeitfeldern kommt eine Entscheidung hinzu. enaio speichert jeden Zeitpunkt als Epoch-Zahl, die es in der Zeitzone des Servers berechnet, und das Protokoll überträgt diese Zeitzone nicht. Sie muss dem Client daher einmal beim Start mitgeteilt werden, sonst verschieben sich Zeitstempel unbemerkt, sobald Server und Hostprozess in unterschiedlichen Zonen liegen:

from ecmind_blue_client import set_server_timezone

set_server_timezone("Europe/Zurich")  # oder ECMIND_BLUE_SERVER_TIMEZONE in der Umgebung setzen

Details und die Konvertierungsfunktionen: Zeitzone der Installation.

9. Dokumente mit Datei ablegen

Nur Dokumente tragen Dateien. Eine Datei wird als JobRequestFile übergeben, in einer von drei Ausprägungen:

Klasse Für

JobRequestFileFromPath(path)

eine Datei auf der Festplatte

JobRequestFileFromBytes(data, extension)

Inhalt, der bereits im Speicher liegt

JobRequestFileFromReader(reader, size, extension)

einen Stream, etwa einen Upload, ohne ihn vollständig zu puffern

insert_and_get() nimmt die Dateien als zweites Argument, upsert() über .files(), wo zusätzlich entschieden wird, ob die übergebenen Dateien die vorhandenen ersetzen oder ergänzen:

  • Sync

  • Async

from ecmind_blue_client.rpc import JobRequestFileFromPath

object_id, type_id, hits, action = (
    ecm.dms.upsert(
        InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
        folder_id=folder.system.id,
    )
    .search(InvoiceDocument.Title == "Rechnung 4711")
    .files([JobRequestFileFromPath("rechnung.pdf")], replace=True)
    .execute()
)
from ecmind_blue_client.rpc import JobRequestFileFromPath

object_id, type_id, hits, action = await (
    ecm.dms.upsert(
        InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
        folder_id=folder.system.id,
    )
    .search(InvoiceDocument.Title == "Rechnung 4711")
    .files([JobRequestFileFromPath("rechnung.pdf")], replace=True)
    .execute()
)

Beim Zurücklesen liefert files() pro Datei ein JobResponseFile. Kleine Dateien bleiben im Speicher, größere landen in einer temporären Datei, gelesen werden beide gleich:

  • Sync

  • Async

for response_file in ecm.dms.files(document):
    print(response_file.name, response_file.size())
    response_file.store("/tmp/" + response_file.name)  # auf die Festplatte schreiben
    content = response_file.bytes()                    # oder die Bytes übernehmen
for response_file in await ecm.dms.files(document):
    print(response_file.name, response_file.size())
    response_file.store("/tmp/" + response_file.name)  # auf die Festplatte schreiben
    content = response_file.bytes()                    # oder die Bytes übernehmen

Dateien, die nicht in den Speicher passen, werden mit document_stream() in Blöcken gelesen oder mit files_streaming() direkt durchgereicht.

10. Im Namen eines anderen Benutzers arbeiten

Ein Importer meldet sich in der Regel mit einem technischen Konto an, während die abgelegten Objekte den zuständigen Benutzer als Ersteller tragen sollen, auch in der Objekthistorie. impersonate() gibt dazu eine zweite ECM-Instanz auf demselben Pool zurück, die jeder Anfrage den Kontextwechsel mitgibt, ohne zweite Anmeldung und ohne zweite Verbindung. Der ausführende Benutzer benötigt dafür die Systemrolle Kontextwechsel.

Der Zielbenutzer kann über username, user_guid oder user_id angegeben werden — genau eines davon. Ein str ist immer ein Benutzername, niemals eine GUID.

  • Sync

  • Async

# Als Context-Manager
with ecm.impersonate("john") as ecm_john:
    folder = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))

# Oder direkt ohne with-Block
ecm_john = ecm.impersonate("john")
folder = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))

# Oder über GUID bzw. numerische ID des Benutzers
ecm_john = ecm.impersonate(user_guid="8A1D1F2E4C7B4A9E8F0D3C5B7A9E1D2F")
ecm_john = ecm.impersonate(user_id=42)
# Als Context-Manager
async with ecm.impersonate("john") as ecm_john:
    folder = await ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))

# Oder direkt ohne with-Block
ecm_john = ecm.impersonate("john")
folder = await ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))

Als Context-Manager bleibt der Wechsel auf den Block begrenzt, womit im Code sichtbar bleibt, welche Operationen unter welchem Benutzer laufen. Vollständige Beschreibung: impersonate() in der API-Referenz.

11. Wenn etwas schiefgeht

Jeder Fehler, den der Server meldet, kommt als Exception aus einer kleinen Hierarchie an. ECMException ist die Basisklasse, wer sie fängt, fängt alles, was die ECM-API auslöst:

Exception Wird ausgelöst, wenn

ECMNotFoundException

das Objekt, der Objekttyp, der Schrank, das Register oder die Datei nicht existiert, etwa get() mit einer unbekannten ID

ECMAccessDeniedException

dem angemeldeten Benutzer die Rechte für die Operation fehlen

ECMWrongStateException

die Operation nicht zum Zustand des Objekts passt, etwa die aktive Variante eines Dokuments ohne Varianten

ECMMissingArgumentException

eine erforderliche Kennung fehlt, etwa ein Dokument ohne Ordner oder Register

ECMException

alles Übrige, was der Server meldet, darunter nicht gefüllte Pflichtfelder und unzulässige Feldwerte

Zwei Fehler entstehen clientseitig, noch vor dem Senden: ein ValueError bei einem fehlenden Pflichtfeld, einem geschriebenen schreibgeschützten Feld oder einem unentschiedenen Leeren eines Tabellenfelds, und ein TypeError bei Dateien an einem Modell, das kein Dokument ist.

from ecmind_blue_client.ecm import ECMException, ECMNotFoundException

try:
    folder = ecm.dms.get(InvoiceFolder, 4711)
except ECMNotFoundException:
    print("kein Ordner mit der ID 4711")
except ECMException as error:
    print("der Server hat die Anfrage abgelehnt:", error)

In der async-Variante wartet derselbe Block mit await auf ecm.dms.get(…​), sonst ändert sich nichts.

Die Meldung enthält den Text des Servers samt einer Beschreibung des dahinterliegenden Result-Codes, sie gehört also unverändert ins Protokoll und nicht durch eine eigene Meldung ersetzt.

11.1. Sehen, was der Client tut

Die Bibliothek protokolliert über das Standardmodul logging, mit einem Logger pro Modul unterhalb von ecmind_blue_client, und bleibt still, solange die Anwendung kein Logging konfiguriert. Ein höherer Level zeigt Verbindungs- und Pool-Aktivität, was in der Regel genügt, um einen hängenden Server von einem hängenden Skript zu unterscheiden:

import logging

logging.basicConfig(level=logging.INFO)
logging.getLogger("ecmind_blue_client").setLevel(logging.DEBUG)

Wenn überhaupt nichts funktioniert, gehört die Prüfung vor den Code: Servererreichbarkeit prüfen testet jeden konfigurierten Server auf Erreichbarkeit, Anmeldung und Version.

12. Das vollständige Skript

Die Schritte von oben in einer lauffähigen main.py. Die Zugangsdaten kommen aus der Umgebung, die Zeitzone des Servers wird vor dem ersten Job gesetzt, und jeder Serverfehler wird an einer Stelle gefangen:

  • Sync

  • Async

import logging
import os
from datetime import date

from ecmind_blue_client import set_server_timezone
from ecmind_blue_client.ecm import ECM, ECMException
from ecmind_blue_client.pool import SyncPoolClient
from ecmind_blue_client.rpc import JobRequestFileFromPath

from models.Invoices import InvoiceDocument, InvoiceFolder, InvoiceRegister

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("invoice-import")


def main() -> None:
    set_server_timezone("Europe/Zurich")

    client = SyncPoolClient(
        servers=os.environ["ECM_SERVERS"],
        username=os.environ["ECM_USERNAME"],
        password=os.environ["ECM_PASSWORD"],
    )
    ecm = ECM(client)

    try:
        # 1. SQL: eine Auswertungszahl, die die Objekt-API nicht ausdrücken kann
        result = ecm.db.select("SELECT COUNT(*) AS anzahl FROM benutzer WHERE aktiv = %d", 1)
        log.info("aktive Benutzer: %s", result.rows[0].typed("anzahl", int))

        # 2. Ordner: anlegen oder aktualisieren, falls er bereits existiert
        folder = (
            ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
            .search(InvoiceFolder.Title == "Rechnung 2024")
            .execute_and_get()
        )
        log.info("Ordner %s", folder.system.id)

        # 3. Register in diesem Ordner, Dokument samt Datei in diesem Register
        register = ecm.dms.insert_and_get(InvoiceRegister(Name="März"), folder_id=folder)
        document = ecm.dms.insert_and_get(
            InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
            [JobRequestFileFromPath("rechnung.pdf")],
            folder_id=folder,
            register_id=register,
        )
        log.info("Dokument %s mit %s Datei(en)", document.system.id, len(ecm.dms.files(document)))

        # 4. Lesen: alle Ordner ab 2024, neueste zuerst
        for hit in (
            ecm.dms.select(InvoiceFolder)
            .where(InvoiceFolder.Year >= 2024)
            .order_by(InvoiceFolder.Year.DESC)
            .stream()
        ):
            log.info("%s %s %s", hit.system.id, hit.Title, hit.Year)

        # 5. Ein Feld ändern, nur dieses Feld geht an den Server
        document.Title = "Rechnung 4711 (geprüft)"
        ecm.dms.update(document)

        # 6. Im Namen von john ablegen, damit das Objekt ihn als Ersteller trägt
        #    (benötigt die Systemrolle Kontextwechsel)
        with ecm.impersonate("john") as ecm_john:
            filed = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024 (john)", Year=2024))
        log.info("abgelegt als john: %s", filed.system.id)

    except ECMException as error:
        log.error("der Server hat die Anfrage abgelehnt: %s", error)
    finally:
        client.close()


if __name__ == "__main__":
    main()
import asyncio
import logging
import os
from datetime import date

from ecmind_blue_client import set_server_timezone
from ecmind_blue_client.ecm import ECM, ECMException
from ecmind_blue_client.pool import AsyncPoolClient
from ecmind_blue_client.rpc import JobRequestFileFromPath

from models.Invoices import InvoiceDocument, InvoiceFolder, InvoiceRegister

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("invoice-import")


async def main() -> None:
    set_server_timezone("Europe/Zurich")

    client = AsyncPoolClient(
        servers=os.environ["ECM_SERVERS"],
        username=os.environ["ECM_USERNAME"],
        password=os.environ["ECM_PASSWORD"],
    )
    ecm = ECM(client)

    try:
        # 1. SQL: eine Auswertungszahl, die die Objekt-API nicht ausdrücken kann
        result = await ecm.db.select("SELECT COUNT(*) AS anzahl FROM benutzer WHERE aktiv = %d", 1)
        log.info("aktive Benutzer: %s", result.rows[0].typed("anzahl", int))

        # 2. Ordner: anlegen oder aktualisieren, falls er bereits existiert
        folder = await (
            ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
            .search(InvoiceFolder.Title == "Rechnung 2024")
            .execute_and_get()
        )
        log.info("Ordner %s", folder.system.id)

        # 3. Register in diesem Ordner, Dokument samt Datei in diesem Register
        register = await ecm.dms.insert_and_get(InvoiceRegister(Name="März"), folder_id=folder)
        document = await ecm.dms.insert_and_get(
            InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
            [JobRequestFileFromPath("rechnung.pdf")],
            folder_id=folder,
            register_id=register,
        )
        log.info("Dokument %s mit %s Datei(en)", document.system.id, len(await ecm.dms.files(document)))

        # 4. Lesen: alle Ordner ab 2024, neueste zuerst
        async for hit in (
            ecm.dms.select(InvoiceFolder)
            .where(InvoiceFolder.Year >= 2024)
            .order_by(InvoiceFolder.Year.DESC)
            .stream()
        ):
            log.info("%s %s %s", hit.system.id, hit.Title, hit.Year)

        # 5. Ein Feld ändern, nur dieses Feld geht an den Server
        document.Title = "Rechnung 4711 (geprüft)"
        await ecm.dms.update(document)

        # 6. Im Namen von john ablegen, damit das Objekt ihn als Ersteller trägt
        #    (benötigt die Systemrolle Kontextwechsel)
        async with ecm.impersonate("john") as ecm_john:
            filed = await ecm_john.dms.insert_and_get(
                InvoiceFolder(Title="Rechnung 2024 (john)", Year=2024)
            )
        log.info("abgelegt als john: %s", filed.system.id)

    except ECMException as error:
        log.error("der Server hat die Anfrage abgelehnt: %s", error)
    finally:
        await client.aclose()


if __name__ == "__main__":
    asyncio.run(main())
export ECM_SERVERS=enaio.example.com:4000:1 ECM_USERNAME=admin ECM_PASSWORD=secret
uv run main.py

Das Repository liefert beide Varianten lauffähig mit, als examples/quickstart_sync.py und examples/quickstart_async.py.

13. Nächste Schritte

  • ecm-generate-models generiert Modelle für alle Schränke, inklusive Tabellenfelder und Listenfelder

  • Servererreichbarkeit prüfen prüft Host, Port und Zugangsdaten vor dem ersten Job

  • Batch-Import macht aus diesen Schritten einen vollständigen Importer: CSV als Eingabe, idempotente Upserts, Fehlerbehandlung pro Datensatz

  • FastAPI-Integration baut einen vollständigen REST-Service auf dem async-Client

  • KeepAlive im Pool hält unbenutzte Verbindungen in langlaufenden Prozessen offen

  • API-Referenz dokumentiert alle Namespaces und Methoden