e-data 2.0.2.dev138__tar.gz → 2.0.2.dev140__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.
- {e_data-2.0.2.dev138/e_data.egg-info → e_data-2.0.2.dev140}/PKG-INFO +1 -1
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140/e_data.egg-info}/PKG-INFO +1 -1
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/migrations/legacy_json_1_3_3.py +46 -20
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/providers/datadis.py +24 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_datadis_connector.py +101 -1
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_migrations.py +24 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/pyproject.toml +1 -1
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/LICENSE +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/MANIFEST.in +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/README.md +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/e_data.egg-info/SOURCES.txt +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/e_data.egg-info/dependency_links.txt +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/e_data.egg-info/requires.txt +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/e_data.egg-info/top_level.txt +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/cli.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/core/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/core/completion.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/core/const.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/core/utils.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/controller.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/migrations/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/migrations/base.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/models.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/queries.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/database/utils.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/models/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/models/bill.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/models/data.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/models/supply.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/providers/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/providers/redata.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/services/bill_service.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/services/data_service.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/__init__.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_completion.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_controller.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_incremental.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_redata_connector.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_services.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/edata/tests/test_utils.py +0 -0
- {e_data-2.0.2.dev138 → e_data-2.0.2.dev140}/setup.cfg +0 -0
|
@@ -10,6 +10,8 @@ Self-contained on purpose: everything it needs lives here, so a future version
|
|
|
10
10
|
can drop this file (and its entry in the registry) once the upgrade window closes.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass
|
|
13
15
|
from datetime import datetime
|
|
14
16
|
import json
|
|
15
17
|
import logging
|
|
@@ -83,44 +85,68 @@ def _to_pvpc(d: dict) -> EnergyPrice:
|
|
|
83
85
|
)
|
|
84
86
|
|
|
85
87
|
|
|
88
|
+
@dataclass
|
|
89
|
+
class _LegacyRecords:
|
|
90
|
+
"""Records parsed from a legacy JSON export."""
|
|
91
|
+
|
|
92
|
+
supplies: list[Supply]
|
|
93
|
+
contracts: list[Contract]
|
|
94
|
+
energy: list[Energy]
|
|
95
|
+
power: list[Power]
|
|
96
|
+
pvpc: list[EnergyPrice]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load(path: str) -> _LegacyRecords | None:
|
|
100
|
+
"""Read and parse the legacy file; ``None`` if it does not exist.
|
|
101
|
+
|
|
102
|
+
Blocking (file I/O plus building tens of thousands of records), so callers
|
|
103
|
+
run it in a worker thread to keep the event loop free.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if not os.path.isfile(path):
|
|
107
|
+
return None
|
|
108
|
+
with open(path, encoding="utf-8") as f:
|
|
109
|
+
raw = json.load(f)
|
|
110
|
+
return _LegacyRecords(
|
|
111
|
+
supplies=[_to_supply(x) for x in raw.get("supplies", [])],
|
|
112
|
+
contracts=[_to_contract(x) for x in raw.get("contracts", [])],
|
|
113
|
+
energy=[_to_energy(x) for x in raw.get("consumptions", [])],
|
|
114
|
+
power=[_to_power(x) for x in raw.get("maximeter", [])],
|
|
115
|
+
pvpc=[_to_pvpc(x) for x in raw.get("pvpc", [])],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
86
119
|
async def apply(db: EdataDB, storage_dir: str, cups: str) -> MigrationResult | None:
|
|
87
120
|
"""Import a 1.3.3 JSON export. Returns None (no-op) if the file is absent."""
|
|
88
121
|
|
|
89
122
|
path = _legacy_path(storage_dir, cups)
|
|
90
|
-
|
|
123
|
+
records = await asyncio.to_thread(_load, path)
|
|
124
|
+
if records is None:
|
|
91
125
|
return None
|
|
92
126
|
|
|
93
127
|
_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
128
|
result = MigrationResult(name=NAME)
|
|
98
129
|
|
|
99
|
-
|
|
100
|
-
for supply in supplies:
|
|
130
|
+
for supply in records.supplies:
|
|
101
131
|
await db.add_supply(supply)
|
|
102
|
-
result.supplies = len(supplies)
|
|
132
|
+
result.supplies = len(records.supplies)
|
|
103
133
|
|
|
104
134
|
# Contracts/energy/power reference a supply by cups; take it from the imported
|
|
105
135
|
# supply record rather than the (lower-cased) filename id.
|
|
106
|
-
target_cups = supplies[0].cups if supplies else cups
|
|
136
|
+
target_cups = records.supplies[0].cups if records.supplies else cups
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
for contract in contracts:
|
|
138
|
+
for contract in records.contracts:
|
|
110
139
|
await db.add_contract(target_cups, contract)
|
|
111
|
-
result.contracts = len(contracts)
|
|
140
|
+
result.contracts = len(records.contracts)
|
|
112
141
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
result.energy = len(energy)
|
|
142
|
+
await db.add_energy_list(target_cups, records.energy)
|
|
143
|
+
result.energy = len(records.energy)
|
|
116
144
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
result.power = len(power)
|
|
145
|
+
await db.add_power_list(target_cups, records.power)
|
|
146
|
+
result.power = len(records.power)
|
|
120
147
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
result.pvpc = len(pvpc)
|
|
148
|
+
await db.add_pvpc_list(records.pvpc)
|
|
149
|
+
result.pvpc = len(records.pvpc)
|
|
124
150
|
|
|
125
151
|
_LOGGER.info(
|
|
126
152
|
"%s: imported %s supplies, %s contracts, %s energy, %s power, %s pvpc",
|
|
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta
|
|
|
12
12
|
|
|
13
13
|
import aiohttp
|
|
14
14
|
import diskcache
|
|
15
|
+
from dateutil.relativedelta import relativedelta
|
|
15
16
|
|
|
16
17
|
from edata.models import Contract, Energy, Power, Supply
|
|
17
18
|
|
|
@@ -57,6 +58,9 @@ GET_MAX_POWER_MANDATORY_FIELDS = ["time", "date", "maxPower"]
|
|
|
57
58
|
# Timing constants
|
|
58
59
|
TIMEOUT = 3 * 60 # requests timeout
|
|
59
60
|
QUERY_LIMIT = timedelta(hours=24) # a datadis limitation, again...
|
|
61
|
+
# Datadis rejects (400) any query starting more than two years ago, which made
|
|
62
|
+
# a first sync of an older supply fail as a whole
|
|
63
|
+
MAX_HISTORY = relativedelta(years=2)
|
|
60
64
|
|
|
61
65
|
|
|
62
66
|
# Cache-related constants
|
|
@@ -70,6 +74,18 @@ def migrate_storage(storage_dir: str) -> None:
|
|
|
70
74
|
os.remove(os.path.join(storage_dir, "edata_recent_queries_cache.json"))
|
|
71
75
|
|
|
72
76
|
|
|
77
|
+
def earliest_query_start(now: datetime | None = None) -> datetime:
|
|
78
|
+
"""Return the oldest start date Datadis accepts.
|
|
79
|
+
|
|
80
|
+
Queries are monthly (``YYYY/MM``), so this is the first month whose first
|
|
81
|
+
day is still within ``MAX_HISTORY`` of ``now``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
limit = (now or datetime.now()) - MAX_HISTORY
|
|
85
|
+
month = limit.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
86
|
+
return month if month >= limit else month + relativedelta(months=1)
|
|
87
|
+
|
|
88
|
+
|
|
73
89
|
def _parse_consumptions(
|
|
74
90
|
response: dict[str, typing.Any], start_date: datetime, end_date: datetime
|
|
75
91
|
) -> list[Energy]:
|
|
@@ -463,6 +479,10 @@ class DatadisConnector:
|
|
|
463
479
|
authorized_nif: str | None = None,
|
|
464
480
|
) -> list[Energy]:
|
|
465
481
|
"""Datadis 'get_consumption_data' query."""
|
|
482
|
+
start_date = max(start_date, earliest_query_start())
|
|
483
|
+
if start_date > end_date:
|
|
484
|
+
_LOGGER.debug("Skipping consumption query older than Datadis history")
|
|
485
|
+
return []
|
|
466
486
|
data = {
|
|
467
487
|
"cups": cups,
|
|
468
488
|
"distributorCode": distributor_code,
|
|
@@ -514,6 +534,10 @@ class DatadisConnector:
|
|
|
514
534
|
authorized_nif: str | None = None,
|
|
515
535
|
) -> list[Power]:
|
|
516
536
|
"""Datadis 'get_max_power' query."""
|
|
537
|
+
start_date = max(start_date, earliest_query_start())
|
|
538
|
+
if start_date > end_date:
|
|
539
|
+
_LOGGER.debug("Skipping max power query older than Datadis history")
|
|
540
|
+
return []
|
|
517
541
|
data = {
|
|
518
542
|
"cups": cups,
|
|
519
543
|
"distributorCode": distributor_code,
|
|
@@ -7,7 +7,17 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from edata.providers.datadis import DatadisConnector
|
|
10
|
+
from edata.providers.datadis import DatadisConnector, earliest_query_start
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _fixture_data_within_history():
|
|
15
|
+
"""Keep the 2022 fixture data inside Datadis's two-year query window."""
|
|
16
|
+
with patch(
|
|
17
|
+
"edata.providers.datadis.earliest_query_start",
|
|
18
|
+
return_value=datetime.datetime(2020, 1, 1),
|
|
19
|
+
):
|
|
20
|
+
yield
|
|
11
21
|
|
|
12
22
|
|
|
13
23
|
def _json_body(payload) -> AsyncMock:
|
|
@@ -377,3 +387,93 @@ async def test_shared_session_is_reused(mock_token, tmp_path, snapshot):
|
|
|
377
387
|
assert supplies == snapshot
|
|
378
388
|
session.get.assert_called_once()
|
|
379
389
|
session.close.assert_not_awaited()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@pytest.mark.parametrize(
|
|
393
|
+
("now", "expected"),
|
|
394
|
+
[
|
|
395
|
+
# 2024-10-01 is already more than two years before the 9th
|
|
396
|
+
(datetime.datetime(2026, 10, 9, 18, 0), datetime.datetime(2024, 11, 1)),
|
|
397
|
+
(datetime.datetime(2026, 10, 1, 0, 0), datetime.datetime(2024, 10, 1)),
|
|
398
|
+
(datetime.datetime(2026, 3, 1, 0, 0, 1), datetime.datetime(2024, 4, 1)),
|
|
399
|
+
],
|
|
400
|
+
)
|
|
401
|
+
def test_earliest_query_start(now, expected):
|
|
402
|
+
"""The oldest accepted start is the first month fully within two years."""
|
|
403
|
+
assert earliest_query_start(now) == expected
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@patch("aiohttp.ClientSession.get")
|
|
407
|
+
@patch.object(
|
|
408
|
+
DatadisConnector, "_async_get_token", new_callable=AsyncMock, return_value=True
|
|
409
|
+
)
|
|
410
|
+
def test_queries_are_clamped_to_datadis_history(mock_token, mock_get, tmp_path):
|
|
411
|
+
"""Older start dates are moved to the oldest month Datadis accepts."""
|
|
412
|
+
mock_response = MagicMock()
|
|
413
|
+
mock_response.status = 200
|
|
414
|
+
mock_response.read = _json_body({"timeCurve": [], "maxPower": []})
|
|
415
|
+
mock_get.return_value.__aenter__.return_value = mock_response
|
|
416
|
+
connector = DatadisConnector(
|
|
417
|
+
MOCK_USERNAME, MOCK_PASSWORD, storage_path=str(tmp_path)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
with patch(
|
|
421
|
+
"edata.providers.datadis.earliest_query_start",
|
|
422
|
+
return_value=datetime.datetime(2022, 10, 1),
|
|
423
|
+
):
|
|
424
|
+
connector.get_consumption_data(
|
|
425
|
+
"ESXXXXXXXXXXXXXXXXTEST",
|
|
426
|
+
"2",
|
|
427
|
+
datetime.datetime(2019, 1, 1),
|
|
428
|
+
datetime.datetime(2022, 10, 31),
|
|
429
|
+
"0",
|
|
430
|
+
5,
|
|
431
|
+
)
|
|
432
|
+
connector.get_max_power(
|
|
433
|
+
"ESXXXXXXXXXXXXXXXXTEST",
|
|
434
|
+
"2",
|
|
435
|
+
datetime.datetime(2019, 1, 1),
|
|
436
|
+
datetime.datetime(2022, 10, 31),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
urls = [call.args[0] for call in mock_get.call_args_list]
|
|
440
|
+
assert len(urls) == 2
|
|
441
|
+
assert all("startDate=2022/10&" in url for url in urls)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@patch("aiohttp.ClientSession.get")
|
|
445
|
+
@patch.object(
|
|
446
|
+
DatadisConnector, "_async_get_token", new_callable=AsyncMock, return_value=True
|
|
447
|
+
)
|
|
448
|
+
def test_queries_entirely_before_history_are_skipped(mock_token, mock_get, tmp_path):
|
|
449
|
+
"""A range Datadis no longer serves is not requested at all."""
|
|
450
|
+
connector = DatadisConnector(
|
|
451
|
+
MOCK_USERNAME, MOCK_PASSWORD, storage_path=str(tmp_path)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
with patch(
|
|
455
|
+
"edata.providers.datadis.earliest_query_start",
|
|
456
|
+
return_value=datetime.datetime(2022, 10, 1),
|
|
457
|
+
):
|
|
458
|
+
assert (
|
|
459
|
+
connector.get_consumption_data(
|
|
460
|
+
"ESXXXXXXXXXXXXXXXXTEST",
|
|
461
|
+
"2",
|
|
462
|
+
datetime.datetime(2019, 1, 1),
|
|
463
|
+
datetime.datetime(2019, 12, 31),
|
|
464
|
+
"0",
|
|
465
|
+
5,
|
|
466
|
+
)
|
|
467
|
+
== []
|
|
468
|
+
)
|
|
469
|
+
assert (
|
|
470
|
+
connector.get_max_power(
|
|
471
|
+
"ESXXXXXXXXXXXXXXXXTEST",
|
|
472
|
+
"2",
|
|
473
|
+
datetime.datetime(2019, 1, 1),
|
|
474
|
+
datetime.datetime(2019, 12, 31),
|
|
475
|
+
)
|
|
476
|
+
== []
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
mock_get.assert_not_called()
|
|
@@ -5,6 +5,7 @@ from datetime import datetime
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
|
+
import threading
|
|
8
9
|
from unittest.mock import patch
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
@@ -175,3 +176,26 @@ async def test_run_migrations_no_legacy_file(data_service: DataService) -> None:
|
|
|
175
176
|
"""run_migrations is a no-op when there is no legacy file."""
|
|
176
177
|
results = await data_service.run_migrations()
|
|
177
178
|
assert results == []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_apply_reads_legacy_file_off_the_event_loop(
|
|
183
|
+
data_service: DataService, tmp_path
|
|
184
|
+
) -> None:
|
|
185
|
+
"""The file is read and parsed in a worker thread, not on the event loop.
|
|
186
|
+
|
|
187
|
+
Home Assistant flags blocking ``open`` calls made on its event loop.
|
|
188
|
+
"""
|
|
189
|
+
_install_legacy_file(str(tmp_path))
|
|
190
|
+
loop_thread = threading.current_thread()
|
|
191
|
+
load_threads = []
|
|
192
|
+
real_load = json.load
|
|
193
|
+
|
|
194
|
+
def recording_load(*args, **kwargs):
|
|
195
|
+
load_threads.append(threading.current_thread())
|
|
196
|
+
return real_load(*args, **kwargs)
|
|
197
|
+
|
|
198
|
+
with patch.object(legacy.json, "load", recording_load):
|
|
199
|
+
assert await legacy.apply(data_service.db, str(tmp_path), CUPS) is not None
|
|
200
|
+
|
|
201
|
+
assert load_threads and all(t is not loop_thread for t in load_threads)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|