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.
- homeguard/__init__.py +10 -0
- homeguard/_vendored.py +13 -0
- homeguard/db.py +281 -0
- homeguard/security.py +905 -0
- penguwatch/__init__.py +3 -0
- penguwatch/activity.py +96 -0
- penguwatch/api/__init__.py +13 -0
- penguwatch/api/catalog.py +53 -0
- penguwatch/api/component.py +13 -0
- penguwatch/api/decide.py +57 -0
- penguwatch/api/deps.py +73 -0
- penguwatch/api/discover.py +203 -0
- penguwatch/api/drivers.py +176 -0
- penguwatch/api/exports.py +107 -0
- penguwatch/api/ical.py +78 -0
- penguwatch/api/insights.py +16 -0
- penguwatch/api/notifications.py +40 -0
- penguwatch/api/planner.py +27 -0
- penguwatch/api/ratings.py +208 -0
- penguwatch/api/router.py +9 -0
- penguwatch/api/schedule.py +86 -0
- penguwatch/api/schemas.py +110 -0
- penguwatch/api/services.py +60 -0
- penguwatch/api/shows.py +267 -0
- penguwatch/api/stable.py +66 -0
- penguwatch/api/sync_review.py +98 -0
- penguwatch/api/system.py +247 -0
- penguwatch/api/titles.py +136 -0
- penguwatch/api/upnext.py +145 -0
- penguwatch/api/webhooks.py +62 -0
- penguwatch/cli.py +346 -0
- penguwatch/clock.py +49 -0
- penguwatch/components.py +139 -0
- penguwatch/config.example.yaml +161 -0
- penguwatch/config.py +339 -0
- penguwatch/db/__init__.py +1 -0
- penguwatch/db/_common.py +7 -0
- penguwatch/db/episodes.py +221 -0
- penguwatch/db/events.py +51 -0
- penguwatch/db/migrations/0001_initial.py +131 -0
- penguwatch/db/migrations/0002_legacy_columns.py +31 -0
- penguwatch/db/migrations/0003_episode_watched_index.py +9 -0
- penguwatch/db/migrations/0004_service_bundles.py +10 -0
- penguwatch/db/migrations/0005_billing_period.py +10 -0
- penguwatch/db/migrations/0006_service_price_history.py +22 -0
- penguwatch/db/migrations/0007_english_titles.py +18 -0
- penguwatch/db/migrations/0008_drop_tmdb_state.py +7 -0
- penguwatch/db/migrations/0009_clear_stale_providers.py +15 -0
- penguwatch/db/migrations/0010_neutral_household_tags.py +11 -0
- penguwatch/db/migrations/0011_drop_rotten_tomatoes.py +9 -0
- penguwatch/db/migrations/0012_service_titles.py +19 -0
- penguwatch/db/migrations/0013_drop_partner_rating.py +10 -0
- penguwatch/db/migrations/0014_rating_sources.py +37 -0
- penguwatch/db/migrations/0015_title_ids.py +122 -0
- penguwatch/db/migrations/0016_outbox_ops.py +87 -0
- penguwatch/db/migrations/0017_driver_state.py +31 -0
- penguwatch/db/migrations/0018_staged_cursor.py +20 -0
- penguwatch/db/migrations/__init__.py +81 -0
- penguwatch/db/outbox.py +242 -0
- penguwatch/db/prices.py +31 -0
- penguwatch/db/ratings.py +311 -0
- penguwatch/db/reports.py +215 -0
- penguwatch/db/repository.py +163 -0
- penguwatch/db/schema.py +22 -0
- penguwatch/db/services.py +126 -0
- penguwatch/db/shows.py +618 -0
- penguwatch/db/state.py +50 -0
- penguwatch/db/storage.py +21 -0
- penguwatch/db/titles.py +88 -0
- penguwatch/db/views.py +310 -0
- penguwatch/doctor.py +140 -0
- penguwatch/drivers/__init__.py +76 -0
- penguwatch/drivers/base.py +476 -0
- penguwatch/drivers/local/__init__.py +111 -0
- penguwatch/drivers/ratelimit.py +33 -0
- penguwatch/drivers/simkl/__init__.py +42 -0
- penguwatch/drivers/simkl/auth.py +208 -0
- penguwatch/drivers/simkl/client.py +689 -0
- penguwatch/drivers/simkl/mapper.py +354 -0
- penguwatch/drivers/simkl/options.py +14 -0
- penguwatch/drivers/simkl/payloads.py +96 -0
- penguwatch/drivers/simkl/service.py +508 -0
- penguwatch/drivers/tvmaze/__init__.py +32 -0
- penguwatch/drivers/tvmaze/client.py +107 -0
- penguwatch/drivers/tvmaze/mapper.py +108 -0
- penguwatch/drivers/tvmaze/options.py +12 -0
- penguwatch/drivers/tvmaze/service.py +151 -0
- penguwatch/envfile.py +88 -0
- penguwatch/events.py +38 -0
- penguwatch/grafana.py +50 -0
- penguwatch/insights.py +75 -0
- penguwatch/interest.py +162 -0
- penguwatch/library.py +184 -0
- penguwatch/main.py +181 -0
- penguwatch/models.py +394 -0
- penguwatch/notify.py +158 -0
- penguwatch/notify_component.py +21 -0
- penguwatch/observability.py +370 -0
- penguwatch/optimizer/__init__.py +1 -0
- penguwatch/optimizer/bundles.py +118 -0
- penguwatch/optimizer/component.py +9 -0
- penguwatch/optimizer/engine.py +425 -0
- penguwatch/optimizer/plannable.py +51 -0
- penguwatch/optimizer/planner.py +326 -0
- penguwatch/optimizer/renewal.py +56 -0
- penguwatch/optimizer/status.py +136 -0
- penguwatch/paths.py +141 -0
- penguwatch/pwa.py +51 -0
- penguwatch/ratings.py +318 -0
- penguwatch/security.py +105 -0
- penguwatch/service.py +920 -0
- penguwatch/servicematch.py +68 -0
- penguwatch/sync/__init__.py +8 -0
- penguwatch/sync/actions.py +203 -0
- penguwatch/sync/active.py +54 -0
- penguwatch/sync/backoff.py +63 -0
- penguwatch/sync/component.py +16 -0
- penguwatch/sync/engine.py +169 -0
- penguwatch/sync/enrich.py +339 -0
- penguwatch/sync/ops.py +78 -0
- penguwatch/sync/pull.py +448 -0
- penguwatch/sync/push.py +326 -0
- penguwatch/sync/titles.py +150 -0
- penguwatch/sync/worker.py +126 -0
- penguwatch/telemetry.py +573 -0
- penguwatch/telemetry_component.py +18 -0
- penguwatch/textsearch.py +93 -0
- penguwatch/ui.py +137 -0
- penguwatch/web/static/components.css +387 -0
- penguwatch/web/static/dialogs/add-title/dialog.css +17 -0
- penguwatch/web/static/dialogs/add-title/dialog.html +86 -0
- penguwatch/web/static/dialogs/add-title/dialog.js +114 -0
- penguwatch/web/static/dialogs/details/dialog.css +69 -0
- penguwatch/web/static/dialogs/details/dialog.html +43 -0
- penguwatch/web/static/dialogs/details/dialog.js +349 -0
- penguwatch/web/static/icons/app-180.png +0 -0
- penguwatch/web/static/icons/app-192.png +0 -0
- penguwatch/web/static/icons/app-512.png +0 -0
- penguwatch/web/static/icons/favicon.ico +0 -0
- penguwatch/web/static/icons/favicon.svg +43 -0
- penguwatch/web/static/js/api.js +162 -0
- penguwatch/web/static/js/bus.js +20 -0
- penguwatch/web/static/js/chrome.js +96 -0
- penguwatch/web/static/js/data.js +91 -0
- penguwatch/web/static/js/dispatch.js +71 -0
- penguwatch/web/static/js/drivers.js +196 -0
- penguwatch/web/static/js/header.js +120 -0
- penguwatch/web/static/js/library-status.js +19 -0
- penguwatch/web/static/js/main.js +51 -0
- penguwatch/web/static/js/mobile.js +92 -0
- penguwatch/web/static/js/modal.js +79 -0
- penguwatch/web/static/js/showchanges.js +84 -0
- penguwatch/web/static/js/state.js +11 -0
- penguwatch/web/static/js/tabs.js +92 -0
- penguwatch/web/static/js/util.js +143 -0
- penguwatch/web/static/login.css +26 -0
- penguwatch/web/static/manifest.webmanifest +14 -0
- penguwatch/web/static/offline.css +6 -0
- penguwatch/web/static/offline.html +7 -0
- penguwatch/web/static/page.css +170 -0
- penguwatch/web/static/panels/calendar/panel.css +22 -0
- penguwatch/web/static/panels/calendar/panel.html +17 -0
- penguwatch/web/static/panels/calendar/panel.js +82 -0
- penguwatch/web/static/panels/calendar/panel.json +1 -0
- penguwatch/web/static/panels/decide/panel.css +23 -0
- penguwatch/web/static/panels/decide/panel.html +87 -0
- penguwatch/web/static/panels/decide/panel.js +62 -0
- penguwatch/web/static/panels/decide/panel.json +1 -0
- penguwatch/web/static/panels/discover/panel.css +21 -0
- penguwatch/web/static/panels/discover/panel.html +34 -0
- penguwatch/web/static/panels/discover/panel.js +135 -0
- penguwatch/web/static/panels/discover/panel.json +1 -0
- penguwatch/web/static/panels/insights/panel.css +17 -0
- penguwatch/web/static/panels/insights/panel.html +16 -0
- penguwatch/web/static/panels/insights/panel.js +118 -0
- penguwatch/web/static/panels/insights/panel.json +1 -0
- penguwatch/web/static/panels/optimizer/panel.css +118 -0
- penguwatch/web/static/panels/optimizer/panel.html +76 -0
- penguwatch/web/static/panels/optimizer/panel.js +302 -0
- penguwatch/web/static/panels/optimizer/panel.json +1 -0
- penguwatch/web/static/panels/optimizer/planner.js +224 -0
- penguwatch/web/static/panels/ratings/csv.js +78 -0
- penguwatch/web/static/panels/ratings/panel.css +14 -0
- penguwatch/web/static/panels/ratings/panel.html +42 -0
- penguwatch/web/static/panels/ratings/panel.js +291 -0
- penguwatch/web/static/panels/ratings/panel.json +1 -0
- penguwatch/web/static/panels/ratings/source.html +50 -0
- penguwatch/web/static/panels/services/addservice.js +95 -0
- penguwatch/web/static/panels/services/library.js +218 -0
- penguwatch/web/static/panels/services/panel.css +75 -0
- penguwatch/web/static/panels/services/panel.html +167 -0
- penguwatch/web/static/panels/services/panel.js +186 -0
- penguwatch/web/static/panels/services/panel.json +1 -0
- penguwatch/web/static/panels/services/pin.html +20 -0
- penguwatch/web/static/panels/services/review.html +41 -0
- penguwatch/web/static/panels/services/review.js +172 -0
- penguwatch/web/static/panels/services/session.js +29 -0
- penguwatch/web/static/panels/system/panel.css +20 -0
- penguwatch/web/static/panels/system/panel.html +15 -0
- penguwatch/web/static/panels/system/panel.js +195 -0
- penguwatch/web/static/panels/system/panel.json +1 -0
- penguwatch/web/static/panels/upnext/panel.css +41 -0
- penguwatch/web/static/panels/upnext/panel.html +14 -0
- penguwatch/web/static/panels/upnext/panel.js +155 -0
- penguwatch/web/static/panels/upnext/panel.json +1 -0
- penguwatch/web/static/panels/watchlist/cards.js +213 -0
- penguwatch/web/static/panels/watchlist/panel.css +121 -0
- penguwatch/web/static/panels/watchlist/panel.html +81 -0
- penguwatch/web/static/panels/watchlist/panel.js +341 -0
- penguwatch/web/static/panels/watchlist/panel.json +1 -0
- penguwatch/web/static/panels/watchlist/tidy.html +61 -0
- penguwatch/web/static/panels/watchlist/tidy.js +172 -0
- penguwatch/web/static/panels/watchlist/tidyassign.js +53 -0
- penguwatch/web/static/style.css +160 -0
- penguwatch/web/static/sw.js +37 -0
- penguwatch/web/static/theme.js +8 -0
- penguwatch/web/static/tokens.css +148 -0
- penguwatch/web/templates/icons.svg +109 -0
- penguwatch/web/templates/index.html +100 -0
- penguwatch/webhooks.py +246 -0
- penguwatch/webhooks_component.py +130 -0
- penguwatch-0.15.0.dist-info/METADATA +259 -0
- penguwatch-0.15.0.dist-info/RECORD +227 -0
- penguwatch-0.15.0.dist-info/WHEEL +5 -0
- penguwatch-0.15.0.dist-info/entry_points.txt +5 -0
- penguwatch-0.15.0.dist-info/licenses/LICENSE +21 -0
- 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()
|