e-data 2.0.2.dev135__py3-none-any.whl → 2.0.2.dev137__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.dev135
3
+ Version: 2.0.2.dev137
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
@@ -697,7 +697,7 @@ Requires-Dist: pydantic<3,>=2.10
697
697
  Requires-Dist: python_dateutil<3,>=2.8
698
698
  Requires-Dist: Requests<3,>=2.31
699
699
  Requires-Dist: SQLAlchemy[asyncio]<3,>=2.0
700
- Requires-Dist: sqlmodel<0.0.45,>=0.0.22
700
+ Requires-Dist: sqlmodel<0.1,>=0.0.45
701
701
  Requires-Dist: typer<1,>=0.12
702
702
  Requires-Dist: aiosqlite<1,>=0.21
703
703
  Dynamic: license-file
@@ -1,4 +1,4 @@
1
- e_data-2.0.2.dev135.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
1
+ e_data-2.0.2.dev137.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
@@ -6,8 +6,8 @@ edata/core/completion.py,sha256=A0ekfu7eCjaLblUYO5DrvaWuDPAO9k1buzy2qdfjdNc,3914
6
6
  edata/core/const.py,sha256=X9J2f7lt9jXJnghqz0JFi-J7USa3xlYvbuiki70eHNE,43
7
7
  edata/core/utils.py,sha256=uQX8GmNjuhof56jiXXoV-Fk-d590OuIoBJbM9ymZyV8,3194
8
8
  edata/database/__init__.py,sha256=HP_pDbGLzeMY9Ufm_Dps5cxzW_3YTonT8w8dstG558c,46
9
- edata/database/controller.py,sha256=xKBNwv1PYfEkuaunz3Qh5zLAPI_6sDkxiohUGEP0jN4,22831
10
- edata/database/models.py,sha256=tSkxf2OKgT1m2F1td3j3GtYcboUtSuYbhnHWQtpvJuY,5525
9
+ edata/database/controller.py,sha256=bI3cE1Q4SK9U1Eep_61EhlmyVjgYH87F5lfBLhwSpmg,23211
10
+ edata/database/models.py,sha256=WWZHYgFBgSDqHORxw5z4Vip7HvorMcQbt9oCpV61NJA,5780
11
11
  edata/database/queries.py,sha256=XziDSG2V-PVFmvRkj8UrEvgrc9Qc-ShhnxgsPHIsyik,8304
12
12
  edata/database/utils.py,sha256=V-FJfyAh8YJBGHB85BbWtG3Ilq09pM9Gl-L86TffB4g,1062
13
13
  edata/database/migrations/__init__.py,sha256=P8VXZzfyceOV3GknYXif2HEMB8Bo4B7f7vKdIC02ZO0,1203
@@ -24,14 +24,14 @@ edata/services/bill_service.py,sha256=KAM78u7TpoVF-KTlB7bqj2Kp-rJERiPPoGTJCc-7rM
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
- edata/tests/test_controller.py,sha256=7sqDR3_Vrxy4S084Eh6VYkLQNuhfYAYzJFCtMSCudd8,2815
27
+ edata/tests/test_controller.py,sha256=YvilJ9cIpjhdGGtVJtlEevhRL7IHAVnU0At7AmxM5_8,3983
28
28
  edata/tests/test_datadis_connector.py,sha256=8Xet2M2eG-kMhiRDTCSH0R5nYO2oIpQTPOuTZKMKoLo,12538
29
29
  edata/tests/test_incremental.py,sha256=dn1LRgjBi2HuNYELPKDVdZQlS01hk4lsP9L-vyvtEHo,3479
30
30
  edata/tests/test_migrations.py,sha256=3Io2nokZVdv2xU9fCZCMIvwAwPVtGc0UivClSouP3pw,6418
31
31
  edata/tests/test_redata_connector.py,sha256=SBvxsma5t-qvz2drxNisNM2P1STTurqEs1TSDgwaPJ8,508
32
32
  edata/tests/test_services.py,sha256=Ya5cbADHj1Qsffb_IuMeDD0BViz8nqrgwazUWK0-hRc,8297
33
33
  edata/tests/test_utils.py,sha256=h6yBzT7j3eA42zbXJ-AK9lXcZkjCMf16wnob1oqGvUE,2307
34
- e_data-2.0.2.dev135.dist-info/METADATA,sha256=FWIkMXs3931Au8RSJa5QzAEaFUXJzIboaKHzLt7U0x0,48087
35
- e_data-2.0.2.dev135.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
- e_data-2.0.2.dev135.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
37
- e_data-2.0.2.dev135.dist-info/RECORD,,
34
+ e_data-2.0.2.dev137.dist-info/METADATA,sha256=-7nOw3zSVBn4BVOgnmEXPt44pUE_qauv2ZZ2yjDjXt4,48084
35
+ e_data-2.0.2.dev137.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
+ e_data-2.0.2.dev137.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
37
+ e_data-2.0.2.dev137.dist-info/RECORD,,
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  import os
3
4
  import typing
@@ -93,6 +94,7 @@ class EdataDB:
93
94
  cls._engine = create_async_engine(db_url, future=True)
94
95
  event.listen(cls._engine.sync_engine, "connect", _set_sqlite_pragmas)
95
96
  cls._instance._tables_initialized = False
97
+ cls._instance._tables_lock = asyncio.Lock()
96
98
  elif db_url != cls._db_url:
97
99
  raise ValueError("EdataDB already initialized with a different db_url")
98
100
  return cls._instance
@@ -108,7 +110,12 @@ class EdataDB:
108
110
 
109
111
  if self._tables_initialized:
110
112
  return
111
- if self.engine:
113
+ # concurrent first calls (e.g. several websocket requests right after
114
+ # startup) would otherwise all see the index missing and race to create
115
+ # it, failing with "index ... already exists"
116
+ async with self._tables_lock:
117
+ if self._tables_initialized or not self.engine:
118
+ return
112
119
  async with self.engine.begin() as conn:
113
120
  await conn.run_sync(SQLModel.metadata.create_all)
114
121
  await conn.run_sync(_create_missing_indexes)
edata/database/models.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import typing
2
2
  from datetime import datetime as dt
3
3
 
4
+ from pydantic import NaiveDatetime
4
5
  from sqlmodel import AutoString, Column, Field, Index, SQLModel, UniqueConstraint
5
6
 
6
7
  from edata.database.utils import PydanticJSON
@@ -27,8 +28,8 @@ class SupplyModel(SQLModel, table=True):
27
28
  cups: str = Field(default=None, primary_key=True)
28
29
  data: Supply = Field(sa_column=Column(PydanticJSON(Supply)))
29
30
  version: int = Field(default=1)
30
- created_at: dt = Field(default_factory=_now, nullable=False)
31
- updated_at: dt = Field(
31
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
32
+ updated_at: NaiveDatetime = Field(
32
33
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
33
34
  )
34
35
 
@@ -44,12 +45,12 @@ class ContractModel(SQLModel, table=True):
44
45
 
45
46
  id: int | None = Field(default=None, primary_key=True)
46
47
  cups: str = Field(foreign_key="supply.cups", index=True)
47
- date_start: dt = Field(index=True)
48
+ date_start: NaiveDatetime = Field(index=True)
48
49
  data: Contract = Field(sa_column=Column(PydanticJSON(Contract)))
49
50
 
50
51
  version: int = Field(default=1)
51
- created_at: dt = Field(default_factory=_now, nullable=False)
52
- updated_at: dt = Field(
52
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
53
+ updated_at: NaiveDatetime = Field(
53
54
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
54
55
  )
55
56
 
@@ -71,13 +72,13 @@ class EnergyModel(SQLModel, table=True):
71
72
  id: int | None = Field(default=None, primary_key=True)
72
73
  cups: str = Field(foreign_key="supply.cups", index=True)
73
74
  delta_h: float
74
- datetime: dt = Field(index=True)
75
+ datetime: NaiveDatetime = Field(index=True)
75
76
 
76
77
  data: Energy = Field(sa_column=Column(PydanticJSON(Energy)))
77
78
 
78
79
  version: int = Field(default=1)
79
- created_at: dt = Field(default_factory=_now, nullable=False)
80
- updated_at: dt = Field(
80
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
81
+ updated_at: NaiveDatetime = Field(
81
82
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
82
83
  )
83
84
 
@@ -93,13 +94,13 @@ class PowerModel(SQLModel, table=True):
93
94
 
94
95
  id: int | None = Field(default=None, primary_key=True)
95
96
  cups: str = Field(foreign_key="supply.cups", index=True)
96
- datetime: dt = Field(index=True)
97
+ datetime: NaiveDatetime = Field(index=True)
97
98
 
98
99
  data: Power = Field(sa_column=Column(PydanticJSON(Power)))
99
100
 
100
101
  version: int = Field(default=1)
101
- created_at: dt = Field(default_factory=_now, nullable=False)
102
- updated_at: dt = Field(
102
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
103
+ updated_at: NaiveDatetime = Field(
103
104
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
104
105
  )
105
106
 
@@ -120,14 +121,14 @@ class StatisticsModel(SQLModel, table=True):
120
121
 
121
122
  id: int | None = Field(default=None, primary_key=True)
122
123
  cups: str = Field(foreign_key="supply.cups", index=True)
123
- datetime: dt = Field(index=True)
124
+ datetime: NaiveDatetime = Field(index=True)
124
125
  type: typing.Literal["day", "month"] = Field(index=True, sa_type=AutoString)
125
126
  complete: bool = Field(False)
126
127
  data: Statistics = Field(sa_column=Column(PydanticJSON(Statistics)))
127
128
 
128
129
  version: int = Field(default=1)
129
- created_at: dt = Field(default_factory=_now, nullable=False)
130
- updated_at: dt = Field(
130
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
131
+ updated_at: NaiveDatetime = Field(
131
132
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
132
133
  )
133
134
 
@@ -137,12 +138,12 @@ class PVPCModel(SQLModel, table=True):
137
138
  __tablename__ = "pvpc" # type: ignore
138
139
 
139
140
  id: int | None = Field(default=None, primary_key=True)
140
- datetime: dt = Field(index=True, unique=True)
141
+ datetime: NaiveDatetime = Field(index=True, unique=True)
141
142
  data: EnergyPrice = Field(sa_column=Column(PydanticJSON(EnergyPrice)))
142
143
 
143
144
  version: int = Field(default=1)
144
- created_at: dt = Field(default_factory=_now, nullable=False)
145
- updated_at: dt = Field(
145
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
146
+ updated_at: NaiveDatetime = Field(
146
147
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
147
148
  )
148
149
 
@@ -163,14 +164,14 @@ class BillModel(SQLModel, table=True):
163
164
 
164
165
  id: int | None = Field(default=None, primary_key=True)
165
166
  cups: str = Field(foreign_key="supply.cups", index=True)
166
- datetime: dt = Field(index=True)
167
+ datetime: NaiveDatetime = Field(index=True)
167
168
  type: typing.Literal["hour", "day", "month"] = Field(index=True, sa_type=AutoString)
168
169
  complete: bool = Field(False)
169
170
  confhash: str
170
171
  data: Bill = Field(sa_column=Column(PydanticJSON(Bill)))
171
172
 
172
173
  version: int = Field(default=1)
173
- created_at: dt = Field(default_factory=_now, nullable=False)
174
- updated_at: dt = Field(
174
+ created_at: NaiveDatetime = Field(default_factory=_now, nullable=False)
175
+ updated_at: NaiveDatetime = Field(
175
176
  default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
176
177
  )
@@ -1,10 +1,13 @@
1
- """Bulk upsert tests for the database controller."""
1
+ """Tests for the database controller."""
2
2
 
3
+ import asyncio
3
4
  from collections.abc import AsyncIterator
4
5
  from datetime import datetime, timedelta
5
6
 
6
7
  import pytest
7
8
  import pytest_asyncio
9
+ from sqlalchemy import DateTime
10
+ from sqlmodel import SQLModel
8
11
 
9
12
  from edata.database.controller import EdataDB
10
13
  from edata.models import Bill, Energy, Supply
@@ -89,3 +92,32 @@ async def test_add_bill_list_applies_overrides(db: EdataDB) -> None:
89
92
  stored = await db.list_bill(CUPS, "hour")
90
93
  assert len(stored) == 3
91
94
  assert all(x.complete and x.confhash == "hash-b" for x in stored)
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_concurrent_first_calls_do_not_race_index_creation(db: EdataDB) -> None:
99
+ # simulate a database created before the index existed, opened fresh
100
+ async with db.engine.begin() as conn:
101
+ await conn.exec_driver_sql("DROP INDEX ix_energy_cups_datetime")
102
+ db._tables_initialized = False
103
+
104
+ await asyncio.gather(*(db.get_last_energy(CUPS) for _ in range(10)))
105
+
106
+ async with db.engine.connect() as conn:
107
+ result = await conn.exec_driver_sql(
108
+ "SELECT name FROM sqlite_master WHERE name='ix_energy_cups_datetime'"
109
+ )
110
+ assert result.first() is not None
111
+
112
+
113
+ def test_datetime_columns_are_naive() -> None:
114
+ # the supply timezone is unknown, so datetimes are stored as-is (naive);
115
+ # plain ``datetime`` fields map to UTC-aware columns on sqlmodel>=0.0.45
116
+ columns = [
117
+ column
118
+ for table in SQLModel.metadata.sorted_tables
119
+ for column in table.columns
120
+ if isinstance(column.type, DateTime)
121
+ ]
122
+ assert len(columns) == 20
123
+ assert all(column.type.timezone is False for column in columns)