e-data 2.0.1.dev131__tar.gz → 2.0.1.dev133__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {e_data-2.0.1.dev131/e_data.egg-info → e_data-2.0.1.dev133}/PKG-INFO +1 -1
  2. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133/e_data.egg-info}/PKG-INFO +1 -1
  3. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/e_data.egg-info/SOURCES.txt +4 -0
  4. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/cli.py +33 -0
  5. e_data-2.0.1.dev133/edata/database/migrations/__init__.py +36 -0
  6. e_data-2.0.1.dev133/edata/database/migrations/base.py +19 -0
  7. e_data-2.0.1.dev133/edata/database/migrations/legacy_json_1_3_3.py +134 -0
  8. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/providers/datadis.py +11 -4
  9. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/services/data_service.py +23 -0
  10. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_datadis_connector.py +72 -0
  11. e_data-2.0.1.dev133/edata/tests/test_migrations.py +185 -0
  12. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/pyproject.toml +1 -1
  13. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/LICENSE +0 -0
  14. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/MANIFEST.in +0 -0
  15. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/README.md +0 -0
  16. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/e_data.egg-info/dependency_links.txt +0 -0
  17. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/e_data.egg-info/requires.txt +0 -0
  18. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/e_data.egg-info/top_level.txt +0 -0
  19. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/__init__.py +0 -0
  20. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/core/__init__.py +0 -0
  21. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/core/completion.py +0 -0
  22. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/core/const.py +0 -0
  23. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/core/utils.py +0 -0
  24. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/database/__init__.py +0 -0
  25. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/database/controller.py +0 -0
  26. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/database/models.py +0 -0
  27. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/database/queries.py +0 -0
  28. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/database/utils.py +0 -0
  29. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/models/__init__.py +0 -0
  30. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/models/bill.py +0 -0
  31. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/models/data.py +0 -0
  32. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/models/supply.py +0 -0
  33. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/providers/__init__.py +0 -0
  34. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/providers/redata.py +0 -0
  35. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/services/bill_service.py +0 -0
  36. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/__init__.py +0 -0
  37. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_completion.py +0 -0
  38. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_incremental.py +0 -0
  39. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_redata_connector.py +0 -0
  40. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_services.py +0 -0
  41. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/edata/tests/test_utils.py +0 -0
  42. {e_data-2.0.1.dev131 → e_data-2.0.1.dev133}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.1.dev131
3
+ Version: 2.0.1.dev133
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Author-email: VMG <vmayorg@outlook.es>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.1.dev131
3
+ Version: 2.0.1.dev133
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Author-email: VMG <vmayorg@outlook.es>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -18,6 +18,9 @@ edata/database/controller.py
18
18
  edata/database/models.py
19
19
  edata/database/queries.py
20
20
  edata/database/utils.py
21
+ edata/database/migrations/__init__.py
22
+ edata/database/migrations/base.py
23
+ edata/database/migrations/legacy_json_1_3_3.py
21
24
  edata/models/__init__.py
22
25
  edata/models/bill.py
23
26
  edata/models/data.py
@@ -31,6 +34,7 @@ edata/tests/__init__.py
31
34
  edata/tests/test_completion.py
32
35
  edata/tests/test_datadis_connector.py
33
36
  edata/tests/test_incremental.py
37
+ edata/tests/test_migrations.py
34
38
  edata/tests/test_redata_connector.py
35
39
  edata/tests/test_services.py
36
40
  edata/tests/test_utils.py
@@ -141,5 +141,38 @@ def update_pvpc_bill(
141
141
  )
142
142
 
143
143
 
144
+ async def _migrate_legacy(
145
+ cups: str, storage_path: str, compile_statistics: bool
146
+ ) -> None:
147
+ """Import a 1.3.3 JSON export into the 2.0 store (no datadis account needed)."""
148
+
149
+ service = DataService(cups, "", "", storage_path=storage_path)
150
+ results = await service.run_migrations(compile_statistics=compile_statistics)
151
+ if not results:
152
+ typer.echo("No legacy storage found to migrate.")
153
+ return
154
+ for result in results:
155
+ typer.echo(
156
+ f"{result.name}: {result.supplies} supplies, {result.contracts} contracts, "
157
+ f"{result.energy} energy, {result.power} power, {result.pvpc} pvpc"
158
+ )
159
+
160
+
161
+ @app.command()
162
+ def migrate_legacy(
163
+ cups: Annotated[str, typer.Option(help="The identifier of the Supply")],
164
+ storage_path: Annotated[
165
+ str,
166
+ typer.Option(help="Root dir holding the legacy edata/ folder and edata.db"),
167
+ ] = "./edata_cli",
168
+ compile_statistics: Annotated[
169
+ bool, typer.Option(help="Compile day/month statistics after import")
170
+ ] = True,
171
+ ) -> None:
172
+ """Import a python-edata 1.3.3 JSON export into the 2.0 SQLite store."""
173
+
174
+ asyncio.run(_migrate_legacy(cups, storage_path, compile_statistics))
175
+
176
+
144
177
  if __name__ == "__main__":
145
178
  app()
@@ -0,0 +1,36 @@
1
+ """Versioned data migrations for the edata SQLite store.
2
+
3
+ Each migration is a self-contained module exposing ``NAME`` and an async
4
+ ``apply(db, storage_dir, cups)`` that returns a :class:`MigrationResult` (or
5
+ ``None`` when it does not apply). ``run_migrations`` runs the ordered registry.
6
+
7
+ To retire a migration in a future version, delete its module and drop it from
8
+ ``MIGRATIONS``.
9
+ """
10
+
11
+ import logging
12
+
13
+ from edata.database.controller import EdataDB
14
+ from edata.database.migrations import legacy_json_1_3_3
15
+ from edata.database.migrations.base import MigrationResult
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+ # Ordered registry of migrations to run.
20
+ MIGRATIONS = [legacy_json_1_3_3]
21
+
22
+ __all__ = ["MIGRATIONS", "MigrationResult", "run_migrations"]
23
+
24
+
25
+ async def run_migrations(
26
+ db: EdataDB, storage_dir: str, cups: str
27
+ ) -> list[MigrationResult]:
28
+ """Run every registered migration; return the results that applied."""
29
+
30
+ results: list[MigrationResult] = []
31
+ for migration in MIGRATIONS:
32
+ result = await migration.apply(db, storage_dir, cups)
33
+ if result is not None:
34
+ _LOGGER.info("Applied migration '%s'", result.name)
35
+ results.append(result)
36
+ return results
@@ -0,0 +1,19 @@
1
+ """Shared types for the migrations package.
2
+
3
+ Kept separate from ``__init__`` and from the individual migration modules so a
4
+ migration file can be deleted in a future version without breaking imports.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class MigrationResult:
12
+ """Summary of what a single migration wrote."""
13
+
14
+ name: str
15
+ supplies: int = 0
16
+ contracts: int = 0
17
+ energy: int = 0
18
+ power: int = 0
19
+ pvpc: int = 0
@@ -0,0 +1,134 @@
1
+ """Migration: import a python-edata 1.3.3 JSON storage export into the 2.0 DB.
2
+
3
+ 1.x persisted everything as a single JSON file per supply at
4
+ ``{storage_dir}/edata/edata_{cups}.json`` (ISO datetimes). 2.0 uses a SQLite
5
+ store. This migration imports the raw records only (supplies, contracts,
6
+ consumptions, maximeter, pvpc); the old ``*_sum`` aggregates are ignored because
7
+ 2.0 recomputes daily/monthly statistics deterministically.
8
+
9
+ Self-contained on purpose: everything it needs lives here, so a future version
10
+ can drop this file (and its entry in the registry) once the upgrade window closes.
11
+ """
12
+
13
+ from datetime import datetime
14
+ import json
15
+ import logging
16
+ import os
17
+
18
+ from edata.database.controller import EdataDB
19
+ from edata.database.migrations.base import MigrationResult
20
+ from edata.models import Contract, Energy, Power, Supply
21
+ from edata.models.bill import EnergyPrice
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+ NAME = "legacy_json_1_3_3"
26
+ LEGACY_SUBDIR = "edata"
27
+
28
+
29
+ def _legacy_path(storage_dir: str, cups: str) -> str:
30
+ """Return the 1.3.3 storage file path for a supply."""
31
+
32
+ return os.path.join(storage_dir, LEGACY_SUBDIR, f"edata_{cups.lower()}.json")
33
+
34
+
35
+ def _to_supply(d: dict) -> Supply:
36
+ return Supply(
37
+ cups=d["cups"],
38
+ date_start=datetime.fromisoformat(d["date_start"]),
39
+ date_end=datetime.fromisoformat(d["date_end"]),
40
+ address=d.get("address"),
41
+ postal_code=d.get("postal_code"),
42
+ province=d.get("province"),
43
+ municipality=d.get("municipality"),
44
+ distributor=d.get("distributor"),
45
+ point_type=d["pointType"],
46
+ distributor_code=d["distributorCode"],
47
+ )
48
+
49
+
50
+ def _to_contract(d: dict) -> Contract:
51
+ power = [p for p in (d.get("power_p1"), d.get("power_p2")) if p is not None]
52
+ return Contract(
53
+ date_start=datetime.fromisoformat(d["date_start"]),
54
+ date_end=datetime.fromisoformat(d["date_end"]),
55
+ marketer=d["marketer"],
56
+ distributor_code=d["distributorCode"],
57
+ power=power,
58
+ )
59
+
60
+
61
+ def _to_energy(d: dict) -> Energy:
62
+ return Energy(
63
+ datetime=datetime.fromisoformat(d["datetime"]),
64
+ delta_h=d["delta_h"],
65
+ consumption_kwh=d["value_kWh"],
66
+ surplus_kwh=d.get("surplus_kWh", 0),
67
+ real=d["real"],
68
+ )
69
+
70
+
71
+ def _to_power(d: dict) -> Power:
72
+ return Power(
73
+ datetime=datetime.fromisoformat(d["datetime"]),
74
+ value_kw=d["value_kW"],
75
+ )
76
+
77
+
78
+ def _to_pvpc(d: dict) -> EnergyPrice:
79
+ return EnergyPrice(
80
+ datetime=datetime.fromisoformat(d["datetime"]),
81
+ value_eur_kwh=d["value_eur_kWh"],
82
+ delta_h=d["delta_h"],
83
+ )
84
+
85
+
86
+ async def apply(db: EdataDB, storage_dir: str, cups: str) -> MigrationResult | None:
87
+ """Import a 1.3.3 JSON export. Returns None (no-op) if the file is absent."""
88
+
89
+ path = _legacy_path(storage_dir, cups)
90
+ if not os.path.isfile(path):
91
+ return None
92
+
93
+ _LOGGER.info("Migrating legacy 1.3.3 storage from %s", path)
94
+ with open(path, encoding="utf-8") as f:
95
+ raw = json.load(f)
96
+
97
+ result = MigrationResult(name=NAME)
98
+
99
+ supplies = [_to_supply(x) for x in raw.get("supplies", [])]
100
+ for supply in supplies:
101
+ await db.add_supply(supply)
102
+ result.supplies = len(supplies)
103
+
104
+ # Contracts/energy/power reference a supply by cups; take it from the imported
105
+ # supply record rather than the (lower-cased) filename id.
106
+ target_cups = supplies[0].cups if supplies else cups
107
+
108
+ contracts = [_to_contract(x) for x in raw.get("contracts", [])]
109
+ for contract in contracts:
110
+ await db.add_contract(target_cups, contract)
111
+ result.contracts = len(contracts)
112
+
113
+ energy = [_to_energy(x) for x in raw.get("consumptions", [])]
114
+ await db.add_energy_list(target_cups, energy)
115
+ result.energy = len(energy)
116
+
117
+ power = [_to_power(x) for x in raw.get("maximeter", [])]
118
+ await db.add_power_list(target_cups, power)
119
+ result.power = len(power)
120
+
121
+ pvpc = [_to_pvpc(x) for x in raw.get("pvpc", [])]
122
+ await db.add_pvpc_list(pvpc)
123
+ result.pvpc = len(pvpc)
124
+
125
+ _LOGGER.info(
126
+ "%s: imported %s supplies, %s contracts, %s energy, %s power, %s pvpc",
127
+ NAME,
128
+ result.supplies,
129
+ result.contracts,
130
+ result.energy,
131
+ result.power,
132
+ result.pvpc,
133
+ )
134
+ return result
@@ -59,7 +59,7 @@ QUERY_LIMIT = timedelta(hours=24) # a datadis limitation, again...
59
59
 
60
60
 
61
61
  # Cache-related constants
62
- RECENT_CACHE_SUBDIR = "cache"
62
+ RECENT_CACHE_SUBDIR = "edata_cache"
63
63
 
64
64
 
65
65
  def migrate_storage(storage_dir: str) -> None:
@@ -403,10 +403,17 @@ class DatadisConnector:
403
403
  for i in response.get("timeCurve", []):
404
404
  if "consumptionKWh" in i:
405
405
  if all(k in i for k in GET_CONSUMPTION_DATA_MANDATORY_FIELDS):
406
- hour = str(int(i["time"].split(":")[0]) - 1)
406
+ raw_hour = int(i["time"].split(":")[0])
407
+ # Datadis delivers hours 1..24 (end-of-interval). A sporadic
408
+ # i-DE glitch emits an extra "00:00" row on a day already
409
+ # carrying its full 24 hours, so drop it -- 01:00..24:00
410
+ # already covers the day -- instead of remapping onto 23:00
411
+ # and overlapping that day's 24:00 slot.
412
+ if raw_hour == 0:
413
+ continue
407
414
  date_as_dt = datetime.strptime(
408
- f"{i['date']} {hour.zfill(2)}:00", "%Y/%m/%d %H:%M"
409
- )
415
+ i["date"], "%Y/%m/%d"
416
+ ) + timedelta(hours=raw_hour - 1)
410
417
  if not (start_date <= date_as_dt <= end_date):
411
418
  continue # skip element if dt is out of range
412
419
 
@@ -19,6 +19,7 @@ from edata.core.utils import (
19
19
  redacted_cups,
20
20
  )
21
21
  from edata.database.controller import EdataDB
22
+ from edata.database.migrations import MigrationResult, run_migrations
22
23
  from edata.models import Contract, Energy, Power, Statistics, Supply
23
24
  from edata.models.bill import EnergyPrice
24
25
  from edata.providers import DatadisConnector, REDataConnector
@@ -55,6 +56,7 @@ class DataService:
55
56
  )
56
57
  self._authorized_nif = None
57
58
 
59
+ self._storage_path = storage_path
58
60
  self.db = EdataDB(get_db_path(storage_path))
59
61
 
60
62
  # data (in-memory cache)
@@ -317,6 +319,27 @@ class DataService:
317
319
  _LOGGER.warning("%s unable to fetch pvpc prices", self._scups)
318
320
  return False
319
321
 
322
+ async def run_migrations(
323
+ self, compile_statistics: bool = True
324
+ ) -> list[MigrationResult]:
325
+ """Run pending data migrations (e.g. import a legacy 1.3.3 JSON export).
326
+
327
+ Migrations import raw records only; when anything is imported and
328
+ ``compile_statistics`` is set, the day/month statistics are compiled from
329
+ the freshly-imported energy so the history is queryable right away. Bills
330
+ are left to the normal billing flow (they need user rules).
331
+ """
332
+
333
+ results = await run_migrations(self.db, self._storage_path, self._cups)
334
+ if not results or not compile_statistics:
335
+ return results
336
+
337
+ supply = await self.get_supply()
338
+ last_energy_dt = await self._get_last_energy_dt()
339
+ if supply is not None and last_energy_dt is not None:
340
+ await self.update_statistics(supply.date_start, last_energy_dt)
341
+ return results
342
+
320
343
  async def update_statistics(self, start: datetime, end: datetime) -> None:
321
344
  """Compile the statistics for a range plus any incomplete backlog."""
322
345
 
@@ -1,6 +1,7 @@
1
1
  """Tests for DatadisConnector (offline)."""
2
2
 
3
3
  import datetime
4
+ import os
4
5
  from unittest.mock import AsyncMock, MagicMock, patch
5
6
 
6
7
  from edata.providers.datadis import DatadisConnector
@@ -8,6 +9,15 @@ from edata.providers.datadis import DatadisConnector
8
9
  MOCK_USERNAME = "USERNAME"
9
10
  MOCK_PASSWORD = "PASSWORD"
10
11
 
12
+
13
+ def test_connector_uses_namespaced_cache_dir(tmp_path) -> None:
14
+ """A storage_path connector creates the namespaced 'edata_cache' dir."""
15
+ connector = DatadisConnector(
16
+ MOCK_USERNAME, MOCK_PASSWORD, storage_path=str(tmp_path)
17
+ )
18
+ assert os.path.basename(connector._recent_cache_dir) == "edata_cache"
19
+ assert (tmp_path / "edata_cache").is_dir()
20
+
11
21
  SUPPLIES_RESPONSE = {
12
22
  "supplies": [
13
23
  {
@@ -56,6 +66,34 @@ CONSUMPTIONS_RESPONSE = {
56
66
  ]
57
67
  }
58
68
 
69
+ CONSUMPTIONS_RESPONSE_WITH_ZERO_HOUR = {
70
+ "timeCurve": [
71
+ {
72
+ "date": "2022/10/22",
73
+ "time": "01:00",
74
+ "consumptionKWh": 0.203,
75
+ "surplusEnergyKWh": 0,
76
+ "obtainMethod": "Real",
77
+ },
78
+ {
79
+ "date": "2022/10/22",
80
+ "time": "02:00",
81
+ "consumptionKWh": 0.163,
82
+ "surplusEnergyKWh": 0,
83
+ "obtainMethod": "Real",
84
+ },
85
+ # Sporadic i-DE glitch: an extra "00:00" row on a day that already
86
+ # carries its full 24 hours. Must be dropped, not remapped to 23:00.
87
+ {
88
+ "date": "2022/10/22",
89
+ "time": "00:00",
90
+ "consumptionKWh": 0.999,
91
+ "surplusEnergyKWh": 0,
92
+ "obtainMethod": "Real",
93
+ },
94
+ ]
95
+ }
96
+
59
97
  MAXIMETER_RESPONSE = {
60
98
  "maxPower": [
61
99
  {
@@ -132,6 +170,40 @@ def test_get_consumption_data(mock_token, mock_get, snapshot):
132
170
  )
133
171
 
134
172
 
173
+ @patch("aiohttp.ClientSession.get")
174
+ @patch.object(
175
+ DatadisConnector, "_async_get_token", new_callable=AsyncMock, return_value=True
176
+ )
177
+ def test_get_consumption_data_skips_zero_hour(mock_token, mock_get):
178
+ """A stray "00:00" row is dropped, not remapped, and never aborts the fetch."""
179
+ mock_response = MagicMock()
180
+ mock_response.status = 200
181
+ mock_response.text = AsyncMock(return_value="text")
182
+ mock_response.json = AsyncMock(
183
+ return_value=CONSUMPTIONS_RESPONSE_WITH_ZERO_HOUR
184
+ )
185
+ mock_get.return_value.__aenter__.return_value = mock_response
186
+ connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
187
+
188
+ # Range spans the previous day, so a remapped "00:00" -> 2022/10/21 23:00
189
+ # would fall inside it; its absence proves the explicit skip, not the filter.
190
+ result = connector.get_consumption_data(
191
+ "ESXXXXXXXXXXXXXXXXTEST",
192
+ "2",
193
+ datetime.datetime(2022, 10, 21, 0, 0, 0),
194
+ datetime.datetime(2022, 10, 22, 23, 59, 59),
195
+ "0",
196
+ 5,
197
+ )
198
+
199
+ datetimes = [x.datetime for x in result]
200
+ assert datetimes == [
201
+ datetime.datetime(2022, 10, 22, 0, 0),
202
+ datetime.datetime(2022, 10, 22, 1, 0),
203
+ ]
204
+ assert datetime.datetime(2022, 10, 21, 23, 0) not in datetimes
205
+
206
+
135
207
  @patch("aiohttp.ClientSession.get")
136
208
  @patch.object(
137
209
  DatadisConnector, "_async_get_token", new_callable=AsyncMock, return_value=True
@@ -0,0 +1,185 @@
1
+ """Tests for the 1.3.3 -> 2.0 legacy JSON migration."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from datetime import datetime
5
+ import json
6
+ import os
7
+ import shutil
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+ import pytest_asyncio
12
+
13
+ from edata.database.controller import EdataDB
14
+ from edata.database.migrations import legacy_json_1_3_3 as legacy
15
+ from edata.services.data_service import DataService
16
+
17
+ ASSETS = os.path.join(os.path.dirname(__file__), "assets")
18
+ CUPS = "ESXXXXXXXXXXXXXXXXTEST"
19
+
20
+
21
+ def _reset_singleton() -> None:
22
+ EdataDB._instance = None
23
+ EdataDB._engine = None
24
+ EdataDB._db_url = None
25
+
26
+
27
+ def _raw() -> dict:
28
+ with open(os.path.join(ASSETS, "legacy_1.3.3.json"), encoding="utf-8") as f:
29
+ return json.load(f)
30
+
31
+
32
+ def _install_legacy_file(storage_dir: str, cups: str = CUPS) -> str:
33
+ """Copy the asset into the 1.3.3 storage location for ``storage_dir``."""
34
+ dest = legacy._legacy_path(storage_dir, cups)
35
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
36
+ shutil.copy(os.path.join(ASSETS, "legacy_1.3.3.json"), dest)
37
+ return dest
38
+
39
+
40
+ @pytest_asyncio.fixture
41
+ async def data_service(tmp_path) -> AsyncIterator[DataService]:
42
+ """A DataService on an isolated on-disk database, singleton reset around it."""
43
+ _reset_singleton()
44
+ with (
45
+ patch("edata.services.data_service.DatadisConnector"),
46
+ patch("edata.services.data_service.REDataConnector"),
47
+ ):
48
+ service = DataService(CUPS, "user", "pwd", storage_path=str(tmp_path))
49
+ yield service
50
+ if EdataDB._engine is not None:
51
+ await EdataDB._engine.dispose()
52
+ _reset_singleton()
53
+
54
+
55
+ # --- pure mappers ---
56
+
57
+
58
+ def test_to_supply_renames_fields() -> None:
59
+ """Supply maps camelCase fields to the 2.0 snake_case model."""
60
+ supply = legacy._to_supply(_raw()["supplies"][0])
61
+ assert supply.cups == CUPS
62
+ assert supply.point_type == 5
63
+ assert supply.distributor_code == "2"
64
+ assert supply.date_start == datetime(2023, 1, 1)
65
+
66
+
67
+ def test_to_contract_builds_power_list_dropping_none() -> None:
68
+ """Contract power list is [p1, p2], dropping a None p2."""
69
+ contracts = [legacy._to_contract(c) for c in _raw()["contracts"]]
70
+ assert contracts[0].power == [4.6, 4.6]
71
+ assert contracts[0].distributor_code == "2"
72
+ # second contract has power_p2=None -> single-element power list
73
+ assert contracts[1].power == [3.3]
74
+
75
+
76
+ def test_to_energy_renames_and_defaults() -> None:
77
+ """Energy renames value_kWh and defaults generation/selfconsumption."""
78
+ energy = legacy._to_energy(_raw()["consumptions"][2])
79
+ assert energy.consumption_kwh == 0.3
80
+ assert energy.surplus_kwh == 0.1
81
+ assert energy.generation_kwh == 0
82
+ assert energy.selfconsumption_kwh == 0
83
+ assert energy.real is True
84
+
85
+
86
+ def test_to_power_and_pvpc_rename() -> None:
87
+ """Power and pvpc rename their value fields to snake_case."""
88
+ power = legacy._to_power(_raw()["maximeter"][0])
89
+ assert power.value_kw == 3.2
90
+ pvpc = legacy._to_pvpc(_raw()["pvpc"][0])
91
+ assert pvpc.value_eur_kwh == 0.12
92
+ assert pvpc.delta_h == 1
93
+
94
+
95
+ # --- migration apply ---
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_apply_imports_raw_records_only(
100
+ data_service: DataService, tmp_path
101
+ ) -> None:
102
+ """Import the raw records and ignores old *_sum aggregates."""
103
+ _install_legacy_file(str(tmp_path))
104
+ result = await legacy.apply(data_service.db, str(tmp_path), CUPS)
105
+
106
+ assert result is not None
107
+ assert result.name == "legacy_json_1_3_3"
108
+ assert (
109
+ result.supplies,
110
+ result.contracts,
111
+ result.energy,
112
+ result.power,
113
+ result.pvpc,
114
+ ) == (1, 2, 5, 2, 2)
115
+
116
+ supply = await data_service.get_supply()
117
+ assert supply is not None and supply.cups == CUPS
118
+ assert len(await data_service.get_contracts()) == 2
119
+ assert len(await data_service.get_energy()) == 5
120
+ assert len(await data_service.get_power()) == 2
121
+ assert len(await data_service.get_pvpc()) == 2
122
+
123
+ # The old *_sum aggregates in the file must be ignored by the migration.
124
+ assert await data_service.get_statistics("day") == []
125
+ assert await data_service.get_statistics("month") == []
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_apply_missing_file_is_noop(data_service: DataService, tmp_path) -> None:
130
+ """Return None and write nothing when no legacy file exists."""
131
+ result = await legacy.apply(data_service.db, str(tmp_path), CUPS)
132
+ assert result is None
133
+ assert await data_service.get_supply() is None
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_apply_is_idempotent(data_service: DataService, tmp_path) -> None:
138
+ """Re-running apply does not duplicate rows (upsert)."""
139
+ _install_legacy_file(str(tmp_path))
140
+ await legacy.apply(data_service.db, str(tmp_path), CUPS)
141
+ await legacy.apply(data_service.db, str(tmp_path), CUPS)
142
+
143
+ assert len(await data_service.get_energy()) == 5
144
+ assert len(await data_service.get_contracts()) == 2
145
+ assert len(await data_service.get_power()) == 2
146
+ assert len(await data_service.get_pvpc()) == 2
147
+
148
+
149
+ # --- orchestrator (DataService.run_migrations) ---
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_run_migrations_compiles_statistics(
154
+ data_service: DataService, tmp_path
155
+ ) -> None:
156
+ """run_migrations imports and compiles day/month statistics."""
157
+ _install_legacy_file(str(tmp_path))
158
+ results = await data_service.run_migrations(compile_statistics=True)
159
+
160
+ assert [r.name for r in results] == ["legacy_json_1_3_3"]
161
+ # Raw records imported and day/month statistics compiled immediately.
162
+ assert len(await data_service.get_energy()) == 5
163
+ assert len(await data_service.get_statistics("day")) > 0
164
+ assert len(await data_service.get_statistics("month")) > 0
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_run_migrations_can_skip_compilation(
169
+ data_service: DataService, tmp_path
170
+ ) -> None:
171
+ """compile_statistics=False imports raw records without statistics."""
172
+ _install_legacy_file(str(tmp_path))
173
+ results = await data_service.run_migrations(compile_statistics=False)
174
+
175
+ assert len(results) == 1
176
+ assert len(await data_service.get_energy()) == 5
177
+ assert await data_service.get_statistics("day") == []
178
+ assert await data_service.get_statistics("month") == []
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_run_migrations_no_legacy_file(data_service: DataService) -> None:
183
+ """run_migrations is a no-op when there is no legacy file."""
184
+ results = await data_service.run_migrations()
185
+ assert results == []
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "e-data"
7
- version = "2.0.1.dev131"
7
+ version = "2.0.1.dev133"
8
8
  description = "Python library for managing spanish energy data from various web providers"
9
9
  readme = "README.md"
10
10
  authors = [
File without changes
File without changes
File without changes
File without changes