e-data 2.0.2.dev138__py3-none-any.whl → 2.0.2.dev140__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.2.dev138
3
+ Version: 2.0.2.dev140
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,4 +1,4 @@
1
- e_data-2.0.2.dev138.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
1
+ e_data-2.0.2.dev140.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
2
2
  edata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  edata/cli.py,sha256=CsBYDggt0grkLb4cebrckOvJyHr9PaGTnlWqT4yjuFo,5215
4
4
  edata/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -12,26 +12,26 @@ edata/database/queries.py,sha256=XziDSG2V-PVFmvRkj8UrEvgrc9Qc-ShhnxgsPHIsyik,830
12
12
  edata/database/utils.py,sha256=V-FJfyAh8YJBGHB85BbWtG3Ilq09pM9Gl-L86TffB4g,1062
13
13
  edata/database/migrations/__init__.py,sha256=P8VXZzfyceOV3GknYXif2HEMB8Bo4B7f7vKdIC02ZO0,1203
14
14
  edata/database/migrations/base.py,sha256=V7jfMaTl6XPCwM6Wei4bNxfOKSKanIHF30UgH-_CJNM,444
15
- edata/database/migrations/legacy_json_1_3_3.py,sha256=yGWLkhmsHJ9s5rOi9SqdCpDi1koULcnUxo2cWYApVpw,4272
15
+ edata/database/migrations/legacy_json_1_3_3.py,sha256=xTNCELpBuwmtStHi-zHRFvvVs2c8HdhJ5UOyT1o_yE4,5047
16
16
  edata/models/__init__.py,sha256=nMPPPkGjUsF51PBFlChgHyb5C3aQiccTRiVHUdJjNj4,153
17
17
  edata/models/bill.py,sha256=-qZ4l9f8BnPFlykFRoNYnmRe6-ELWmuNh6usks_k4X0,2674
18
18
  edata/models/data.py,sha256=O1aj0H_G92mnJjm6mYhC8fnl8ZOoatGiokRf8b7ziD8,2388
19
19
  edata/models/supply.py,sha256=qM0Q22-7PfBN93-Tu3ljAE58p5gfob0YWw-o5FccjrI,878
20
20
  edata/providers/__init__.py,sha256=5xaRKPttYM6EpVE1I_vhUAF25L9pEM_qK_5Ix6xnQe0,104
21
- edata/providers/datadis.py,sha256=3oSMWpiSHa111X1e0GUkOgnANoFvoUHeGwJN8pduW1Q,21968
21
+ edata/providers/datadis.py,sha256=xzh3xPsAMrTiWN4FH7y9H5PPz6PB5Okb1p0DIqJBYc8,23041
22
22
  edata/providers/redata.py,sha256=i6aBjunyZMb34SGgAgNQvaNu_GFoe5P2rUIY-_vOElw,3727
23
23
  edata/services/bill_service.py,sha256=KAM78u7TpoVF-KTlB7bqj2Kp-rJERiPPoGTJCc-7rM0,15264
24
24
  edata/services/data_service.py,sha256=JJCdivjrwNt3aLhz3uMBWv06y-EL7UiK2G5EBr97l_8,19374
25
25
  edata/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  edata/tests/test_completion.py,sha256=-iyVt-juU3U1HqMuLUJXJthil2-n_4ddWiJq2GwnXTo,3551
27
27
  edata/tests/test_controller.py,sha256=CZmT6TC1vfQsxfBNRq1sKKwD4kIWDWW6OYTYeIpLKpc,4147
28
- edata/tests/test_datadis_connector.py,sha256=yh-cYX6zIU3HaTPimD5Rvm_QiPWJWBIskjKp2M5LpO8,12579
28
+ edata/tests/test_datadis_connector.py,sha256=jsSFo7c30ZL7D4HAwja5-4_NTeEkpySWEV66G_9qV2w,15857
29
29
  edata/tests/test_incremental.py,sha256=MPRc7pPSIUuBSMFzlVTpV_wafeRat4kV3wy8p6NmyFQ,3280
30
- edata/tests/test_migrations.py,sha256=P8Qaas8u1Nwu2qNLSoqUAGL0WFqx9d59gs4Bp6L8RtY,6219
30
+ edata/tests/test_migrations.py,sha256=9VgDYE7vOPCiQlOndgbC1mTkOR2e0ZtEOs-UcS-ZrMw,7024
31
31
  edata/tests/test_redata_connector.py,sha256=SBvxsma5t-qvz2drxNisNM2P1STTurqEs1TSDgwaPJ8,508
32
32
  edata/tests/test_services.py,sha256=DiXA8Y9zZgtAs9HGiMOfystz2shaGAGEs444GJ2AymE,8321
33
33
  edata/tests/test_utils.py,sha256=h6yBzT7j3eA42zbXJ-AK9lXcZkjCMf16wnob1oqGvUE,2307
34
- e_data-2.0.2.dev138.dist-info/METADATA,sha256=-DmL66kwdc79X1tIqaJYUATBtnLaFEfXPJjuCNlq7oo,48041
35
- e_data-2.0.2.dev138.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
- e_data-2.0.2.dev138.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
37
- e_data-2.0.2.dev138.dist-info/RECORD,,
34
+ e_data-2.0.2.dev140.dist-info/METADATA,sha256=005JynX9uvw7f6x46nu-5-OKIAxqUBxHoS7bt_jhn4o,48041
35
+ e_data-2.0.2.dev140.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
+ e_data-2.0.2.dev140.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
37
+ e_data-2.0.2.dev140.dist-info/RECORD,,
@@ -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
- if not os.path.isfile(path):
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
- supplies = [_to_supply(x) for x in raw.get("supplies", [])]
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
- contracts = [_to_contract(x) for x in raw.get("contracts", [])]
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
- energy = [_to_energy(x) for x in raw.get("consumptions", [])]
114
- await db.add_energy_list(target_cups, energy)
115
- result.energy = len(energy)
142
+ await db.add_energy_list(target_cups, records.energy)
143
+ result.energy = len(records.energy)
116
144
 
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)
145
+ await db.add_power_list(target_cups, records.power)
146
+ result.power = len(records.power)
120
147
 
121
- pvpc = [_to_pvpc(x) for x in raw.get("pvpc", [])]
122
- await db.add_pvpc_list(pvpc)
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)