penguwatch 0.15.0__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.
Files changed (227) hide show
  1. homeguard/__init__.py +10 -0
  2. homeguard/_vendored.py +13 -0
  3. homeguard/db.py +281 -0
  4. homeguard/security.py +905 -0
  5. penguwatch/__init__.py +3 -0
  6. penguwatch/activity.py +96 -0
  7. penguwatch/api/__init__.py +13 -0
  8. penguwatch/api/catalog.py +53 -0
  9. penguwatch/api/component.py +13 -0
  10. penguwatch/api/decide.py +57 -0
  11. penguwatch/api/deps.py +73 -0
  12. penguwatch/api/discover.py +203 -0
  13. penguwatch/api/drivers.py +176 -0
  14. penguwatch/api/exports.py +107 -0
  15. penguwatch/api/ical.py +78 -0
  16. penguwatch/api/insights.py +16 -0
  17. penguwatch/api/notifications.py +40 -0
  18. penguwatch/api/planner.py +27 -0
  19. penguwatch/api/ratings.py +208 -0
  20. penguwatch/api/router.py +9 -0
  21. penguwatch/api/schedule.py +86 -0
  22. penguwatch/api/schemas.py +110 -0
  23. penguwatch/api/services.py +60 -0
  24. penguwatch/api/shows.py +267 -0
  25. penguwatch/api/stable.py +66 -0
  26. penguwatch/api/sync_review.py +98 -0
  27. penguwatch/api/system.py +247 -0
  28. penguwatch/api/titles.py +136 -0
  29. penguwatch/api/upnext.py +145 -0
  30. penguwatch/api/webhooks.py +62 -0
  31. penguwatch/cli.py +346 -0
  32. penguwatch/clock.py +49 -0
  33. penguwatch/components.py +139 -0
  34. penguwatch/config.example.yaml +161 -0
  35. penguwatch/config.py +339 -0
  36. penguwatch/db/__init__.py +1 -0
  37. penguwatch/db/_common.py +7 -0
  38. penguwatch/db/episodes.py +221 -0
  39. penguwatch/db/events.py +51 -0
  40. penguwatch/db/migrations/0001_initial.py +131 -0
  41. penguwatch/db/migrations/0002_legacy_columns.py +31 -0
  42. penguwatch/db/migrations/0003_episode_watched_index.py +9 -0
  43. penguwatch/db/migrations/0004_service_bundles.py +10 -0
  44. penguwatch/db/migrations/0005_billing_period.py +10 -0
  45. penguwatch/db/migrations/0006_service_price_history.py +22 -0
  46. penguwatch/db/migrations/0007_english_titles.py +18 -0
  47. penguwatch/db/migrations/0008_drop_tmdb_state.py +7 -0
  48. penguwatch/db/migrations/0009_clear_stale_providers.py +15 -0
  49. penguwatch/db/migrations/0010_neutral_household_tags.py +11 -0
  50. penguwatch/db/migrations/0011_drop_rotten_tomatoes.py +9 -0
  51. penguwatch/db/migrations/0012_service_titles.py +19 -0
  52. penguwatch/db/migrations/0013_drop_partner_rating.py +10 -0
  53. penguwatch/db/migrations/0014_rating_sources.py +37 -0
  54. penguwatch/db/migrations/0015_title_ids.py +122 -0
  55. penguwatch/db/migrations/0016_outbox_ops.py +87 -0
  56. penguwatch/db/migrations/0017_driver_state.py +31 -0
  57. penguwatch/db/migrations/0018_staged_cursor.py +20 -0
  58. penguwatch/db/migrations/__init__.py +81 -0
  59. penguwatch/db/outbox.py +242 -0
  60. penguwatch/db/prices.py +31 -0
  61. penguwatch/db/ratings.py +311 -0
  62. penguwatch/db/reports.py +215 -0
  63. penguwatch/db/repository.py +163 -0
  64. penguwatch/db/schema.py +22 -0
  65. penguwatch/db/services.py +126 -0
  66. penguwatch/db/shows.py +618 -0
  67. penguwatch/db/state.py +50 -0
  68. penguwatch/db/storage.py +21 -0
  69. penguwatch/db/titles.py +88 -0
  70. penguwatch/db/views.py +310 -0
  71. penguwatch/doctor.py +140 -0
  72. penguwatch/drivers/__init__.py +76 -0
  73. penguwatch/drivers/base.py +476 -0
  74. penguwatch/drivers/local/__init__.py +111 -0
  75. penguwatch/drivers/ratelimit.py +33 -0
  76. penguwatch/drivers/simkl/__init__.py +42 -0
  77. penguwatch/drivers/simkl/auth.py +208 -0
  78. penguwatch/drivers/simkl/client.py +689 -0
  79. penguwatch/drivers/simkl/mapper.py +354 -0
  80. penguwatch/drivers/simkl/options.py +14 -0
  81. penguwatch/drivers/simkl/payloads.py +96 -0
  82. penguwatch/drivers/simkl/service.py +508 -0
  83. penguwatch/drivers/tvmaze/__init__.py +32 -0
  84. penguwatch/drivers/tvmaze/client.py +107 -0
  85. penguwatch/drivers/tvmaze/mapper.py +108 -0
  86. penguwatch/drivers/tvmaze/options.py +12 -0
  87. penguwatch/drivers/tvmaze/service.py +151 -0
  88. penguwatch/envfile.py +88 -0
  89. penguwatch/events.py +38 -0
  90. penguwatch/grafana.py +50 -0
  91. penguwatch/insights.py +75 -0
  92. penguwatch/interest.py +162 -0
  93. penguwatch/library.py +184 -0
  94. penguwatch/main.py +181 -0
  95. penguwatch/models.py +394 -0
  96. penguwatch/notify.py +158 -0
  97. penguwatch/notify_component.py +21 -0
  98. penguwatch/observability.py +370 -0
  99. penguwatch/optimizer/__init__.py +1 -0
  100. penguwatch/optimizer/bundles.py +118 -0
  101. penguwatch/optimizer/component.py +9 -0
  102. penguwatch/optimizer/engine.py +425 -0
  103. penguwatch/optimizer/plannable.py +51 -0
  104. penguwatch/optimizer/planner.py +326 -0
  105. penguwatch/optimizer/renewal.py +56 -0
  106. penguwatch/optimizer/status.py +136 -0
  107. penguwatch/paths.py +141 -0
  108. penguwatch/pwa.py +51 -0
  109. penguwatch/ratings.py +318 -0
  110. penguwatch/security.py +105 -0
  111. penguwatch/service.py +920 -0
  112. penguwatch/servicematch.py +68 -0
  113. penguwatch/sync/__init__.py +8 -0
  114. penguwatch/sync/actions.py +203 -0
  115. penguwatch/sync/active.py +54 -0
  116. penguwatch/sync/backoff.py +63 -0
  117. penguwatch/sync/component.py +16 -0
  118. penguwatch/sync/engine.py +169 -0
  119. penguwatch/sync/enrich.py +339 -0
  120. penguwatch/sync/ops.py +78 -0
  121. penguwatch/sync/pull.py +448 -0
  122. penguwatch/sync/push.py +326 -0
  123. penguwatch/sync/titles.py +150 -0
  124. penguwatch/sync/worker.py +126 -0
  125. penguwatch/telemetry.py +573 -0
  126. penguwatch/telemetry_component.py +18 -0
  127. penguwatch/textsearch.py +93 -0
  128. penguwatch/ui.py +137 -0
  129. penguwatch/web/static/components.css +387 -0
  130. penguwatch/web/static/dialogs/add-title/dialog.css +17 -0
  131. penguwatch/web/static/dialogs/add-title/dialog.html +86 -0
  132. penguwatch/web/static/dialogs/add-title/dialog.js +114 -0
  133. penguwatch/web/static/dialogs/details/dialog.css +69 -0
  134. penguwatch/web/static/dialogs/details/dialog.html +43 -0
  135. penguwatch/web/static/dialogs/details/dialog.js +349 -0
  136. penguwatch/web/static/icons/app-180.png +0 -0
  137. penguwatch/web/static/icons/app-192.png +0 -0
  138. penguwatch/web/static/icons/app-512.png +0 -0
  139. penguwatch/web/static/icons/favicon.ico +0 -0
  140. penguwatch/web/static/icons/favicon.svg +43 -0
  141. penguwatch/web/static/js/api.js +162 -0
  142. penguwatch/web/static/js/bus.js +20 -0
  143. penguwatch/web/static/js/chrome.js +96 -0
  144. penguwatch/web/static/js/data.js +91 -0
  145. penguwatch/web/static/js/dispatch.js +71 -0
  146. penguwatch/web/static/js/drivers.js +196 -0
  147. penguwatch/web/static/js/header.js +120 -0
  148. penguwatch/web/static/js/library-status.js +19 -0
  149. penguwatch/web/static/js/main.js +51 -0
  150. penguwatch/web/static/js/mobile.js +92 -0
  151. penguwatch/web/static/js/modal.js +79 -0
  152. penguwatch/web/static/js/showchanges.js +84 -0
  153. penguwatch/web/static/js/state.js +11 -0
  154. penguwatch/web/static/js/tabs.js +92 -0
  155. penguwatch/web/static/js/util.js +143 -0
  156. penguwatch/web/static/login.css +26 -0
  157. penguwatch/web/static/manifest.webmanifest +14 -0
  158. penguwatch/web/static/offline.css +6 -0
  159. penguwatch/web/static/offline.html +7 -0
  160. penguwatch/web/static/page.css +170 -0
  161. penguwatch/web/static/panels/calendar/panel.css +22 -0
  162. penguwatch/web/static/panels/calendar/panel.html +17 -0
  163. penguwatch/web/static/panels/calendar/panel.js +82 -0
  164. penguwatch/web/static/panels/calendar/panel.json +1 -0
  165. penguwatch/web/static/panels/decide/panel.css +23 -0
  166. penguwatch/web/static/panels/decide/panel.html +87 -0
  167. penguwatch/web/static/panels/decide/panel.js +62 -0
  168. penguwatch/web/static/panels/decide/panel.json +1 -0
  169. penguwatch/web/static/panels/discover/panel.css +21 -0
  170. penguwatch/web/static/panels/discover/panel.html +34 -0
  171. penguwatch/web/static/panels/discover/panel.js +135 -0
  172. penguwatch/web/static/panels/discover/panel.json +1 -0
  173. penguwatch/web/static/panels/insights/panel.css +17 -0
  174. penguwatch/web/static/panels/insights/panel.html +16 -0
  175. penguwatch/web/static/panels/insights/panel.js +118 -0
  176. penguwatch/web/static/panels/insights/panel.json +1 -0
  177. penguwatch/web/static/panels/optimizer/panel.css +118 -0
  178. penguwatch/web/static/panels/optimizer/panel.html +76 -0
  179. penguwatch/web/static/panels/optimizer/panel.js +302 -0
  180. penguwatch/web/static/panels/optimizer/panel.json +1 -0
  181. penguwatch/web/static/panels/optimizer/planner.js +224 -0
  182. penguwatch/web/static/panels/ratings/csv.js +78 -0
  183. penguwatch/web/static/panels/ratings/panel.css +14 -0
  184. penguwatch/web/static/panels/ratings/panel.html +42 -0
  185. penguwatch/web/static/panels/ratings/panel.js +291 -0
  186. penguwatch/web/static/panels/ratings/panel.json +1 -0
  187. penguwatch/web/static/panels/ratings/source.html +50 -0
  188. penguwatch/web/static/panels/services/addservice.js +95 -0
  189. penguwatch/web/static/panels/services/library.js +218 -0
  190. penguwatch/web/static/panels/services/panel.css +75 -0
  191. penguwatch/web/static/panels/services/panel.html +167 -0
  192. penguwatch/web/static/panels/services/panel.js +186 -0
  193. penguwatch/web/static/panels/services/panel.json +1 -0
  194. penguwatch/web/static/panels/services/pin.html +20 -0
  195. penguwatch/web/static/panels/services/review.html +41 -0
  196. penguwatch/web/static/panels/services/review.js +172 -0
  197. penguwatch/web/static/panels/services/session.js +29 -0
  198. penguwatch/web/static/panels/system/panel.css +20 -0
  199. penguwatch/web/static/panels/system/panel.html +15 -0
  200. penguwatch/web/static/panels/system/panel.js +195 -0
  201. penguwatch/web/static/panels/system/panel.json +1 -0
  202. penguwatch/web/static/panels/upnext/panel.css +41 -0
  203. penguwatch/web/static/panels/upnext/panel.html +14 -0
  204. penguwatch/web/static/panels/upnext/panel.js +155 -0
  205. penguwatch/web/static/panels/upnext/panel.json +1 -0
  206. penguwatch/web/static/panels/watchlist/cards.js +213 -0
  207. penguwatch/web/static/panels/watchlist/panel.css +121 -0
  208. penguwatch/web/static/panels/watchlist/panel.html +81 -0
  209. penguwatch/web/static/panels/watchlist/panel.js +341 -0
  210. penguwatch/web/static/panels/watchlist/panel.json +1 -0
  211. penguwatch/web/static/panels/watchlist/tidy.html +61 -0
  212. penguwatch/web/static/panels/watchlist/tidy.js +172 -0
  213. penguwatch/web/static/panels/watchlist/tidyassign.js +53 -0
  214. penguwatch/web/static/style.css +160 -0
  215. penguwatch/web/static/sw.js +37 -0
  216. penguwatch/web/static/theme.js +8 -0
  217. penguwatch/web/static/tokens.css +148 -0
  218. penguwatch/web/templates/icons.svg +109 -0
  219. penguwatch/web/templates/index.html +100 -0
  220. penguwatch/webhooks.py +246 -0
  221. penguwatch/webhooks_component.py +130 -0
  222. penguwatch-0.15.0.dist-info/METADATA +259 -0
  223. penguwatch-0.15.0.dist-info/RECORD +227 -0
  224. penguwatch-0.15.0.dist-info/WHEEL +5 -0
  225. penguwatch-0.15.0.dist-info/entry_points.txt +5 -0
  226. penguwatch-0.15.0.dist-info/licenses/LICENSE +21 -0
  227. penguwatch-0.15.0.dist-info/top_level.txt +2 -0
homeguard/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """homeguard: request guards shared by the home apps that are reachable on the local network.
2
+
3
+ This package is vendored: scripts/vendor.py copies it verbatim into each app's src/ directory. Never edit
4
+ the copy inside an app; change it here, commit, and re-vendor.
5
+
6
+ The version constant is deliberately not called __version__: `depguard sbom` takes an app's version from
7
+ the first src/*/__init__.py that defines __version__, and "homeguard" sorts before the app packages.
8
+ """
9
+
10
+ HOMEGUARD_VERSION = "0.1.0"
homeguard/_vendored.py ADDED
@@ -0,0 +1,13 @@
1
+ """Generated by homeguard/scripts/vendor.py - do not edit.
2
+
3
+ Records which homeguard commit this copy came from and the SHA-256 of each vendored file (LF bytes).
4
+ FILES are the Python files in this package, SCRIPTS the PowerShell modules copied to <app>/scripts/.
5
+ """
6
+
7
+ COMMIT = "82f1127"
8
+ FILES = {
9
+ "__init__.py": "035b4c21661fbffe3d455b70580a68bed3fb54f460d837d090ba2da6f45dcdc7",
10
+ "db.py": "7df9c8dc66acb331a330cedf568d4dc059c3e99dd34870d8eb4f9d8b8ec0e19a",
11
+ "security.py": "d859214340681b4743f83544fad594135db5e00dbb1b92f36c3c19ccdf45f1c6",
12
+ }
13
+ SCRIPTS = {}
homeguard/db.py ADDED
@@ -0,0 +1,281 @@
1
+ """homeguard.db: one writer and one read-only reader connection for an aiosqlite database.
2
+
3
+ The usual alternatives are a connection per call, or one connection shared by reads and writes (where a reader can
4
+ see another task's uncommitted rows). `Database` keeps exactly two
5
+ long-lived connections:
6
+
7
+ * the writer, used through `write()`: one transaction at a time (an asyncio.Lock), committed when the block
8
+ ends and rolled back on any exception;
9
+ * the reader (`PRAGMA query_only=ON`), used through `read()` / `reader()`: it never sees uncommitted rows of
10
+ the writer, because it is a different connection.
11
+
12
+ A read session finishes every statement before it returns: the rows are fetched and the cursor is closed
13
+ right away. An unfinished SELECT would keep a read transaction open on the long-lived reader, which would
14
+ pin an old snapshot (stale reads) and stop WAL checkpoints.
15
+
16
+ In-memory databases (":memory:" or a "mode=memory" URI) cannot have a second connection (it would be a
17
+ different, empty database), so there the reader *is* the writer. That is the only mode in which a read can
18
+ see uncommitted rows; it exists for tests.
19
+
20
+ Use `write()` for every statement that changes data. `writer()` hands out the raw connection without the
21
+ lock, for migrations, maintenance and tests. Do not call `write()` from inside a `write()` block (it raises
22
+ instead of deadlocking), and do not start child tasks inside the block that call it either.
23
+ """
24
+
25
+ import asyncio
26
+ import logging
27
+ import re
28
+ import sqlite3
29
+ from contextlib import asynccontextmanager
30
+ from pathlib import Path
31
+ from typing import Any, AsyncIterator, Callable, Iterable, List, Optional, Sequence, Union
32
+
33
+ import aiosqlite
34
+
35
+ logger = logging.getLogger("homeguard.db")
36
+
37
+ _PRAGMA = re.compile(r"^[A-Za-z_]+\s*=\s*[A-Za-z0-9_+-]+$")
38
+
39
+
40
+ class Prefetched:
41
+ """The result of a read statement: every row already fetched, the cursor already closed."""
42
+
43
+ def __init__(self, rows: List[Any], description: Any, rowcount: int, lastrowid: Optional[int]):
44
+ self._rows = rows
45
+ self._pos = 0
46
+ self.description = description
47
+ self.rowcount = rowcount
48
+ self.lastrowid = lastrowid
49
+
50
+ async def fetchone(self) -> Any:
51
+ if self._pos >= len(self._rows):
52
+ return None
53
+ row = self._rows[self._pos]
54
+ self._pos += 1
55
+ return row
56
+
57
+ async def fetchall(self) -> List[Any]:
58
+ rows, self._pos = self._rows[self._pos:], len(self._rows)
59
+ return rows
60
+
61
+ async def fetchmany(self, size: Optional[int] = None) -> List[Any]:
62
+ count = 1 if size is None else size
63
+ rows = self._rows[self._pos:self._pos + count]
64
+ self._pos += len(rows)
65
+ return rows
66
+
67
+ async def close(self) -> None:
68
+ """Nothing to release; here so the object can stand in for a cursor."""
69
+
70
+ async def __aiter__(self) -> AsyncIterator[Any]:
71
+ while self._pos < len(self._rows):
72
+ row = self._rows[self._pos]
73
+ self._pos += 1
74
+ yield row
75
+
76
+
77
+ class _Statement:
78
+ """Awaitable and async context manager, like aiosqlite's execute(): `await s.execute(..)` and
79
+ `async with s.execute(..) as cursor` both give the `Prefetched`."""
80
+
81
+ def __init__(self, coro):
82
+ self._coro = coro
83
+ self._result: Optional[Prefetched] = None
84
+
85
+ def __await__(self):
86
+ return self._coro.__await__()
87
+
88
+ async def __aenter__(self) -> Prefetched:
89
+ self._result = await self._coro
90
+ return self._result
91
+
92
+ async def __aexit__(self, *exc) -> None:
93
+ self._result = None
94
+
95
+
96
+ class ReadSession:
97
+ """Read access to the reader connection that looks like an aiosqlite connection.
98
+
99
+ `row_factory` belongs to the session: set it on a session from `Database.read()` and no other session
100
+ sees it. (The session from `Database.reader()` is shared; leave its row_factory alone.) Anything else is
101
+ forwarded to the underlying connection.
102
+ """
103
+
104
+ def __init__(self, connection: aiosqlite.Connection):
105
+ self._connection = connection
106
+ self.row_factory: Optional[Callable] = None # None: the connection's own (the Database's row_factory)
107
+
108
+ def execute(self, sql: str, parameters: Optional[Iterable[Any]] = None) -> _Statement:
109
+ return _Statement(self._run("execute", sql, parameters))
110
+
111
+ def executemany(self, sql: str, parameters: Iterable[Iterable[Any]]) -> _Statement:
112
+ return _Statement(self._run("executemany", sql, parameters))
113
+
114
+ async def _run(self, method: str, sql: str, parameters: Any) -> Prefetched:
115
+ connection = self._connection
116
+ if method == "execute":
117
+ cursor = await connection.execute(sql, () if parameters is None else parameters)
118
+ else:
119
+ cursor = await connection.executemany(sql, parameters)
120
+ try:
121
+ if self.row_factory is not None:
122
+ cursor.row_factory = self.row_factory
123
+ rows = await cursor.fetchall()
124
+ return Prefetched(rows, cursor.description, cursor.rowcount, cursor.lastrowid)
125
+ finally:
126
+ await cursor.close()
127
+
128
+ def __getattr__(self, name: str) -> Any:
129
+ if name.startswith("_"):
130
+ raise AttributeError(name)
131
+ return getattr(self._connection, name)
132
+
133
+
134
+ class Database:
135
+ def __init__(
136
+ self,
137
+ path: Union[str, Path],
138
+ *,
139
+ pragmas: Sequence[str] = ("synchronous=NORMAL",),
140
+ wal: bool = True,
141
+ row_factory: Optional[Callable] = None,
142
+ foreign_keys: bool = False,
143
+ ):
144
+ """`pragmas` ("name=value") are applied to both connections; `wal` switches file databases to
145
+ journal_mode=WAL (in WAL, synchronous=NORMAL cannot corrupt the file on a crash); `row_factory` is set
146
+ on both connections; `foreign_keys` is for the writer. The default 5 s busy timeout is kept."""
147
+ for pragma in pragmas:
148
+ if not _PRAGMA.match(pragma):
149
+ raise ValueError(f"not a 'name=value' pragma: {pragma!r}")
150
+ self.path = str(path)
151
+ self._pragmas = tuple(pragmas)
152
+ self._wal = wal
153
+ self._row_factory = row_factory
154
+ self._foreign_keys = foreign_keys
155
+ self._writer: Optional[aiosqlite.Connection] = None
156
+ self._reader: Optional[aiosqlite.Connection] = None
157
+ self._session: Optional[ReadSession] = None
158
+ self._open_lock = asyncio.Lock()
159
+ self._write_lock = asyncio.Lock()
160
+ self._owner: Optional[asyncio.Task] = None
161
+ self._closed = False
162
+
163
+ @property
164
+ def in_memory(self) -> bool:
165
+ return self.path == ":memory:" or "mode=memory" in self.path
166
+
167
+ @property
168
+ def is_open(self) -> bool:
169
+ return self._writer is not None
170
+
171
+ @property
172
+ def is_closed(self) -> bool:
173
+ """True once `close()` has run: a closed Database is final and raises RuntimeError on any later use."""
174
+ return self._closed
175
+
176
+ def _check_not_closed(self) -> None:
177
+ if self._closed:
178
+ raise RuntimeError(f"Database {self.path} is closed; create a new Database to use it again")
179
+
180
+ async def _connect(self, *, read_only: bool) -> aiosqlite.Connection:
181
+ connection = await aiosqlite.connect(self.path, uri=self.path.startswith("file:"))
182
+ try:
183
+ if not read_only:
184
+ if self._wal and not self.in_memory:
185
+ try:
186
+ await connection.execute("PRAGMA journal_mode=WAL")
187
+ except sqlite3.Error:
188
+ logger.warning("could not switch %s to WAL; continuing with the default journal", self.path)
189
+ if self._foreign_keys:
190
+ await connection.execute("PRAGMA foreign_keys=ON")
191
+ for pragma in self._pragmas:
192
+ await connection.execute(f"PRAGMA {pragma}")
193
+ if read_only:
194
+ await connection.execute("PRAGMA query_only=ON")
195
+ if self._row_factory is not None:
196
+ connection.row_factory = self._row_factory
197
+ except BaseException:
198
+ await connection.close()
199
+ raise
200
+ return connection
201
+
202
+ async def open(self) -> None:
203
+ """Opens the writer (which creates a file database), then the reader. Idempotent; every other method
204
+ calls it when needed. Raises RuntimeError after `close()`."""
205
+ self._check_not_closed()
206
+ if self._writer is not None:
207
+ return
208
+ async with self._open_lock:
209
+ self._check_not_closed()
210
+ if self._writer is not None:
211
+ return
212
+ writer = await self._connect(read_only=False)
213
+ try:
214
+ reader = writer if self.in_memory else await self._connect(read_only=True)
215
+ except BaseException:
216
+ await writer.close()
217
+ raise
218
+ self._reader = reader
219
+ self._session = ReadSession(reader)
220
+ self._writer = writer
221
+
222
+ async def writer(self) -> aiosqlite.Connection:
223
+ """The raw writer connection, without the lock or the commit/rollback: for migrations, maintenance
224
+ and tests. Application writes go through `write()`."""
225
+ await self.open()
226
+ assert self._writer is not None
227
+ return self._writer
228
+
229
+ async def reader(self) -> ReadSession:
230
+ """The shared, long-lived read session."""
231
+ await self.open()
232
+ assert self._session is not None
233
+ return self._session
234
+
235
+ @asynccontextmanager
236
+ async def write(self) -> AsyncIterator[aiosqlite.Connection]:
237
+ """One write transaction: exclusive, committed when the block ends, rolled back on any exception
238
+ (including cancellation). An explicit `commit()` inside the block is allowed. Not re-entrant."""
239
+ task = asyncio.current_task()
240
+ if task is not None and self._owner is task:
241
+ raise RuntimeError("Database.write() is not re-entrant")
242
+ async with self._write_lock:
243
+ self._owner = task
244
+ try:
245
+ db = await self.writer()
246
+ try:
247
+ yield db
248
+ await db.commit()
249
+ except BaseException:
250
+ try:
251
+ await db.rollback()
252
+ except Exception:
253
+ logger.warning("rollback failed", exc_info=True)
254
+ raise
255
+ finally:
256
+ self._owner = None
257
+
258
+ @asynccontextmanager
259
+ async def read(self) -> AsyncIterator[ReadSession]:
260
+ """A fresh read session over the shared reader (its own row_factory)."""
261
+ await self.open()
262
+ assert self._reader is not None
263
+ yield ReadSession(self._reader)
264
+
265
+ async def close(self) -> None:
266
+ """Waits for the transaction in flight, then closes the reader and the writer (the last connection
267
+ to close checkpoints the WAL and removes it). Closing is final: any later use raises RuntimeError, so a
268
+ task that outlives shutdown cannot silently reopen the database. Closing twice is fine."""
269
+ if self._owner is not None and self._owner is asyncio.current_task():
270
+ raise RuntimeError("Database.close() cannot be called inside a write() block")
271
+ async with self._write_lock:
272
+ async with self._open_lock:
273
+ self._closed = True
274
+ reader, writer = self._reader, self._writer
275
+ self._reader = self._writer = self._session = None
276
+ try:
277
+ if reader is not None and reader is not writer:
278
+ await reader.close()
279
+ finally:
280
+ if writer is not None:
281
+ await writer.close()