# ADR-006: pandas als optionale Dependency

**Status:** Akzeptiert
**Datum:** 2025-10-21
**Kontext:** PoC questra-data Python Package

## Kontext und Problemstellung

Die High-Level API von questra-data gibt Dictionary- und List-basierte Datenstrukturen zurück:

```python
result: dict[int, dict[str, Any]] = client.list_timeseries_values(...)
items: list[dict[str, Any]] = client.list_items(...)
```

Für Data-Science-Workflows wäre pandas DataFrame-Support wünschenswert:

```python
df = client.list_timeseries_values_df(...)  # pd.DataFrame
df = client.list_items_df(...)              # pd.DataFrame
```

Die Frage ist: Wie integrieren wir pandas-Support am besten?

## Entscheidung

Wir verwenden **pandas als optionale Dependency** mit Feature Flag:

```toml
# pyproject.toml
[project.optional-dependencies]
pandas = ["pandas>=2.0.0"]
```

```bash
# Installation
pip install questra-data          # Minimal (ohne pandas)
pip install questra-data[pandas]  # Mit pandas Support
```

```python
# Standard-API (immer verfügbar)
result = client.list_timeseries_values(...)  # dict

# DataFrame-API (nur mit pandas)
df = client.list_timeseries_values_df(...)   # pd.DataFrame
```

## Begründung

### Pro

- **Beste Balance:** Minimale Installation für CRUD, volle Power für Data Science
- **Einfache Wartung:** Ein Package, ein Repo, ein Release-Zyklus
- **Klare API:** Zwei separate Methoden für zwei Use Cases (`list()` vs. `list_df()`)
- **Keine Breaking Changes:** Bestehende Dict-API bleibt unverändert
- **Zukunftssicher:** Später weitere optionale Dependencies möglich (`[polars]`, `[arrow]`)
- **Standard-Pattern:** Viele Python-Packages nutzen optional dependencies (`requests[security]`, `sqlalchemy[asyncio]`)
- **Gute Developer Experience:** Bei installiertem pandas nahtlose Integration
- **Graduelle Migration:** User können schrittweise zu DataFrame-API wechseln
- **Kleine Installation:** Core-Package bleibt schlank (~5 MB), pandas nur bei Bedarf (~15-20 MB zusätzlich)

### Contra

- **Zwei Code-Pfade:** Beide Varianten müssen getestet werden (mit/ohne pandas)
- **Runtime-Check:** Import-Check bei Methoden-Aufruf nötig
- **Etwas komplexere Implementierung:** Try/Except für Import, klare Fehlermeldungen erforderlich
- **Doppelter API-Surface:** Für jede Method zwei Varianten (`list()` und `list_df()`)

## Alternativen

### Option 1: pandas als direkte Dependency

```toml
dependencies = ["pandas>=2.0.0"]
```

- **Pro:** Bessere Developer Experience, keine zusätzlichen Schritte, direkte DataFrame-Rückgabe
- **Contra:** Größere Installation (~15-20 MB zusätzlich), Overhead für minimalistische Use Cases, stärkere Kopplung

**Bewertung:** Zu viel Overhead für User, die nur CRUD-Operationen benötigen

### Option 2: Separates Package (`questra-data-pandas`)

```bash
pip install questra-data          # Core
pip install questra-data-pandas   # Optional: pandas Support
```

- **Pro:** Separation of Concerns, Core bleibt schlank, flexible Installation
- **Contra:** Höhere Entwicklungskomplexität (zwei Repos, zwei CI/CD, zwei Releases), schlechtere Developer Experience, Versionskonflikte, Overhead für ~100-200 Zeilen Code

**Bewertung:** Zu viel Overhead für kleine Utility-Funktionalität. Separates Package macht Sinn für große Features (ML-Models, Dashboards), nicht für Format-Konverter.

### Option 3: Nur Dict-API, User konvertieren selbst

```python
result = client.list_timeseries_values(...)
df = pd.DataFrame(result)  # User macht Konvertierung
```

- **Pro:** Keine pandas-Dependency, maximale Flexibilität
- **Contra:** Schlechte Developer Experience, User müssen komplexe Konvertierung selbst implementieren, Inkonsistente Konvertierungs-Logik in verschiedenen Projekten

**Bewertung:** Verschiebt Komplexität auf den User

## Konsequenzen

### Positiv

- Kleine Installation für minimalistische Use Cases
- Volle pandas-Integration für Data Science ohne Umwege
- Klare API-Trennung (Dict vs. DataFrame)
- Kein Breaking Change für bestehende User
- Erweiterbar für weitere Formate (Polars, Arrow)

### Negativ

- Zusätzlicher Testaufwand (Tests mit/ohne pandas)
- Import-Check bei jeder `_df()` Methode nötig
- Dokumentation muss beide Varianten zeigen

## Implementation

### pyproject.toml

```toml
[project.optional-dependencies]
pandas = ["pandas>=2.0.0"]
```

### Code-Pattern

```python
# src/questra_data/highlevel_client.py

try:
    import pandas as pd
    _PANDAS_AVAILABLE = True
except ImportError:
    _PANDAS_AVAILABLE = False
    pd = None  # type: ignore


class QuestraData:
    # Bestehende Methode (unverändert)
    def list_timeseries_values(
        self,
        inventory_name: str,
        timeseries_properties: str,
        from_time: datetime,
        to_time: datetime,
        **kwargs
    ) -> dict[int, dict[str, Any]]:
        """
        Listet Zeitreihen-Werte für Items auf.

        Returns:
            Dictionary mit Item-ID als Schlüssel
        """
        # Aktuelle Implementierung bleibt unverändert
        ...

    # Neue DataFrame-Methode
    def list_timeseries_values_df(
        self,
        inventory_name: str,
        timeseries_properties: str,
        from_time: datetime,
        to_time: datetime,
        include_metadata: bool = True,
        **kwargs
    ) -> "pd.DataFrame":
        """
        Listet Zeitreihen-Werte als pandas DataFrame auf.

        Note:
            Benötigt pandas: `pip install questra-data[pandas]`

        Args:
            include_metadata: Item-Metadaten als Spalten einbeziehen

        Returns:
            DataFrame mit time als Index und MultiIndex-Spalten

        Raises:
            ImportError: Wenn pandas nicht installiert ist
        """
        if not _PANDAS_AVAILABLE:
            raise ImportError(
                "pandas is required for DataFrame output. "
                "Install with: pip install questra-data[pandas]"
            )

        # Rufe Standard-Methode auf
        result = self.list_timeseries_values(
            inventory_name=inventory_name,
            timeseries_properties=timeseries_properties,
            from_time=from_time,
            to_time=to_time,
            **kwargs
        )

        # Konvertiere zu DataFrame
        return self._convert_timeseries_to_dataframe(result, include_metadata)

    def _convert_timeseries_to_dataframe(
        self,
        result: dict[int, dict],
        include_metadata: bool = True
    ) -> "pd.DataFrame":
        """Konvertiert Zeitreihen-Result zu DataFrame."""
        # Implementierung siehe highlevel_client.py
        ...
```

### Beispiel-Verwendung

```python
from questra_data import QuestraData
from datetime import datetime

client = QuestraData(graphql_url="...", auth_client=auth_client)

# ===== Ohne pandas =====
result = client.list_timeseries_values(
    inventory_name="Stromzaehler",
    timeseries_properties="messwerte_Energie",
    from_time=datetime(2025, 1, 1),
    to_time=datetime(2025, 12, 31)
)
# → dict[int, dict[str, Any]]

# ===== Mit pandas (nach `pip install questra-data[pandas]`) =====
df = client.list_timeseries_values_df(
    inventory_name="Stromzaehler",
    timeseries_properties="messwerte_Energie",
    from_time=datetime(2025, 1, 1),
    to_time=datetime(2025, 12, 31)
)
# → pd.DataFrame
#
#                        messwerte_Energie [kWh]
#                        Item-ID      123      456
# time
# 2025-01-01 12:00:00              100.5    98.3
# 2025-01-02 12:00:00              105.2    99.1

# Direkt weiterverarbeiten
df.resample('1D').mean()
df.plot()
df.describe()
```

## Testing-Strategie

### Tests ohne pandas

```python
def test_list_timeseries_values_dict():
    """Test Dict-API ohne pandas."""
    result = client.list_timeseries_values(...)
    assert isinstance(result, dict)
```

### Tests mit pandas

```python
@pytest.mark.skipif(not _PANDAS_AVAILABLE, reason="pandas not installed")
def test_list_timeseries_values_df():
    """Test DataFrame-API mit pandas."""
    df = client.list_timeseries_values_df(...)
    assert isinstance(df, pd.DataFrame)
```

### Test für ImportError

```python
def test_dataframe_without_pandas(monkeypatch):
    """Test dass ImportError geworfen wird wenn pandas fehlt."""
    monkeypatch.setattr("questra_data.highlevel_client._PANDAS_AVAILABLE", False)

    with pytest.raises(ImportError, match="pandas is required"):
        client.list_timeseries_values_df(...)
```

## Weitere DataFrame-Methoden

Folgende Methoden haben `_df()` Varianten erhalten:

- ✅ `list_timeseries_values_df()`
- ✅ `list_items_df()`
- ✅ `list_inventories_df(explode_properties=False|True)`
- ✅ `list_roles_df()`
- ✅ `list_units_df()`
- ✅ `list_time_zones_df()`

Zukünftige Erweiterungen möglich:

- `create_items_from_df(df)` - Bulk-Create aus DataFrame
- `update_items_from_df(df)` - Bulk-Update aus DataFrame

## Zukünftige Erweiterungen

Optional dependencies können später erweitert werden:

```toml
[project.optional-dependencies]
pandas = ["pandas>=2.0.0"]
polars = ["polars>=0.20.0"]
arrow = ["pyarrow>=15.0.0"]
all = ["pandas>=2.0.0", "polars>=0.20.0", "pyarrow>=15.0.0"]
```

```python
# Polars-Support
df = client.list_timeseries_values_pl(...)  # polars.DataFrame

# Arrow-Support
table = client.list_timeseries_values_arrow(...)  # pyarrow.Table
```

## Referenzen

- [pandas Documentation](https://pandas.pydata.org/)
- [Python Packaging Guide - Optional Dependencies](https://packaging.python.org/guides/distributing-packages-using-setuptools/#optional-dependencies)
- Beispiele für Optional Dependencies in bekannten Packages:
  - `requests[security]`
  - `sqlalchemy[asyncio]`
  - `fastapi[all]`

## Notizen

pandas als optionale Dependency ist das etablierte Pattern im Python-Ecosystem für Format-Konverter und optionale Features. Es bietet die beste Balance zwischen Package-Größe und Developer Experience.

Die Implementierung wurde in der High-Level API (`QuestraData`) vollständig umgesetzt (Stand 2025-11-07).
