feat(#359): W4a Phase 1 — Import/Export Contribution-Architektur Kern
Check Cross-Plugin Imports / check (push) Has been cancelled

- Format-Registry (app/core/importexport_registry.py): FormatHandler-Protokoll,
  available_for() Schnittmenge, Singleton + Testing-Reset
- importexport_formats-Plugin (csv/json/xlsx), lifecycle-korrekt: on_activate
  registriert Handler in der Core-Registry, on_deactivate unregistriert
- ContactsContract: importexport-Beitrag (ie_*-Methoden) — Contacts besitzt
  seine Import/Export-Fachlogik jetzt selbst
- import_export_service.py: generische Engine, delegiert generisch ueber
  registry.list_discovered() an den besitzenden Contract (keine hartcodierten
  Plugin-Namen mehr); Signaturen identisch
- Funktionserhalt bewiesen: 45/45 import_export-Suite passed (inkl.
  Fehler-Multiplizitaet: 2 failed rows -> 3 total_errors, erreicht via
  ie_required-Weitergabe + ie_row_valid-Nur-contacts-Early-Return)

fixes #359 (Phase 1)
This commit is contained in:
Agent Zero
2026-08-27 21:11:14 +02:00
parent d8a4063c48
commit cd8ef7500c
6 changed files with 593 additions and 384 deletions
@@ -0,0 +1,5 @@
"""Import/export formats plugin — provides the standard file formats."""
from app.plugins.builtins.importexport_formats.plugin import ImportExportFormatsPlugin
__all__ = ["ImportExportFormatsPlugin"]
@@ -0,0 +1,48 @@
"""Standard format handlers — thin wrappers around the core helpers.
No business logic here: parse/serialize only. Business logic (columns,
normalization, validation, persistence) lives in the entity module's
contract contribution (e.g. ContactsContract.importexport).
"""
from __future__ import annotations
from typing import Any
from app.services import import_export_helpers as helpers
class CsvFormat:
format_id = "csv"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_csv(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_csv(rows, headers)
class JsonFormat:
format_id = "json"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_json(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_json(rows)
class XlsxFormat:
format_id = "xlsx"
@staticmethod
def parse(filename: str, content: bytes) -> list[dict[str, Any]]:
return helpers.parse_xlsx(content)
@staticmethod
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
return helpers.write_xlsx(rows, headers)
@@ -0,0 +1,52 @@
"""Import/export formats plugin — provides standard file formats as plugins.
Part of the W4a contribution architecture (Spec #359): file formats live in
this plugin instead of being hardcoded in the core orchestrator. New formats
(e.g. PDF later) are added as additional format plugins without touching the
core.
Lifecycle: registers the bundled handlers (csv, json, xlsx) in the core
format registry on activate, unregisters them on deactivate.
"""
from __future__ import annotations
from typing import Any
from app.core.importexport_registry import get_format_registry
from app.plugins.base import BasePlugin
from app.plugins.builtins.importexport_formats.formats import CsvFormat, JsonFormat, XlsxFormat
from app.plugins.manifest import PluginManifest
class ImportExportFormatsPlugin(BasePlugin):
"""Bundles the standard import/export file formats."""
manifest = PluginManifest(
name="importexport_formats",
version="1.0.0",
display_name="Import/Export Formate",
description="Standard-Formate für Import/Export: CSV, JSON, XLSX.",
dependencies=[],
routes=[],
events=[],
migrations=[],
permissions=[],
is_core=True,
)
def __init__(self) -> None:
super().__init__()
self._handlers: list[Any] = [CsvFormat(), JsonFormat(), XlsxFormat()]
async def on_activate(self, db, service_container, event_bus) -> None:
await super().on_activate(db, service_container, event_bus)
registry = get_format_registry()
for handler in self._handlers:
registry.register(handler)
async def on_deactivate(self, db, service_container, event_bus) -> None:
await super().on_deactivate(db, service_container, event_bus)
registry = get_format_registry()
for handler in self._handlers:
registry.unregister(handler.format_id)