youpdated 0.3.0__tar.gz → 0.3.1__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.
- {youpdated-0.3.0 → youpdated-0.3.1}/CHANGELOG.md +34 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/PKG-INFO +1 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/pyproject.toml +1 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/__init__.py +1 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/http.py +20 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/runner.py +21 -7
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/browser.py +4 -2
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/feed.py +5 -3
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/itch.py +18 -6
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/youtube.py +4 -2
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/state.py +43 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/PKG-INFO +1 -1
- {youpdated-0.3.0 → youpdated-0.3.1}/ADDING_SOURCES.md +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/CONTRIBUTING.md +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/LICENSE +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/MANIFEST.in +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/README.md +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/SECURITY.md +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/setup.cfg +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/__main__.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/cleanup.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/cli.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/config.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/crypto.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/models.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/py.typed +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/registry.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/__init__.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/json_out.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/rss_out.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/terminal.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/__init__.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/base.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/generic.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/github.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/npm.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/steam.py +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/SOURCES.txt +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/dependency_links.txt +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/entry_points.txt +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/requires.txt +0 -0
- {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,39 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.1] — 2026-10-10
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **itch builds no longer re-report on unchanged pages.** itch.io shuffles HTML
|
|
12
|
+
attribute order, and the upload list was only read in one
|
|
13
|
+
order, so some fetches saw no files and fingerprinted a "new build". Successive
|
|
14
|
+
runs each reported a different handful of games until every variant had been
|
|
15
|
+
seen. Upload and timestamp parsing no longer depend on attribute order.
|
|
16
|
+
- **YouTube Shorts no longer re-report when the feed falls back.** The official
|
|
17
|
+
feed links Shorts as `/shorts/<id>`, which was not recognised, so Shorts were
|
|
18
|
+
recorded as `yt:video:yt:video:<id>` there and `yt:video:<id>` through Invidious
|
|
19
|
+
or Data API. Each switch reported Shorts again. Existing
|
|
20
|
+
history is migrated on first open, so nothing already seen comes back.
|
|
21
|
+
- **`--no-save` really leaves the state alone.** It still stored each feed's
|
|
22
|
+
ETag/Last-Modified, so the next real run was answered 304 and silently skipped
|
|
23
|
+
whatever the preview had shown. (Whoops)
|
|
24
|
+
- **A target that fails partway no longer hides its new items.** If one of a
|
|
25
|
+
target's fetches failed (e.g. GitHub `watch: [releases, commits]` with commits
|
|
26
|
+
down), the others' validators were still stored while their items were
|
|
27
|
+
discarded, so the next run got a 304 and never reported them. Validators are
|
|
28
|
+
now stored only alongside the items they cover.
|
|
29
|
+
- **Feeds listed oldest-first report new entries.** Entries were cut to the
|
|
30
|
+
limit in document order before sorting, so on an oldest-first feed longer than
|
|
31
|
+
the limit the newest posts never made it in. Affects `feed`, Steam, itch
|
|
32
|
+
devlogs, and GitHub atom feeds.
|
|
33
|
+
- **Chrome no longer errors** when a version is first listed without a rollout
|
|
34
|
+
start time and later with.
|
|
35
|
+
- **Removing an `ignore:` rule shows what it hid.** Ignored items
|
|
36
|
+
are not recorded, but the feed's validators were, so hidden items stayed hidden until it next changed.
|
|
37
|
+
Each target's rules are now recorded, and a target whose rules changed is fetched fully once.
|
|
38
|
+
Every target is fetched in full once after upgrading.
|
|
39
|
+
|
|
7
40
|
## [0.3.0] — 2026-10-07
|
|
8
41
|
|
|
9
42
|
### Added
|
|
@@ -237,6 +270,7 @@ First release.
|
|
|
237
270
|
- Firefox publishes current versions, so it reports one item per channel.
|
|
238
271
|
- Edge exposes release notes only for the stable and beta channels. (But like, it's Edge, why do you want to know when it updates?)
|
|
239
272
|
|
|
273
|
+
[0.3.1]: https://github.com/Void1-1/youpdated/releases/tag/v0.3.1
|
|
240
274
|
[0.3.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.3.0
|
|
241
275
|
[0.2.1]: https://github.com/Void1-1/youpdated/releases/tag/v0.2.1
|
|
242
276
|
[0.2.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.2.0
|
|
@@ -29,6 +29,9 @@ except ImportError: # pragma: no cover
|
|
|
29
29
|
|
|
30
30
|
NETWORK_ERRORS = (httpx.HTTPError, SOCKSError)
|
|
31
31
|
|
|
32
|
+
#: ``(url, etag, last_modified)`` from a 200, see :meth:`Client.deferred_validators`
|
|
33
|
+
Validator = tuple[str, str | None, str | None]
|
|
34
|
+
|
|
32
35
|
# Small pool of current desktop UAs. Rotating inside a plausible set
|
|
33
36
|
# it blends in better, but can be changed here
|
|
34
37
|
USER_AGENTS: tuple[str, ...] = (
|
|
@@ -220,6 +223,17 @@ class Client:
|
|
|
220
223
|
finally:
|
|
221
224
|
self._local.unconditional = previous
|
|
222
225
|
|
|
226
|
+
@contextmanager
|
|
227
|
+
def deferred_validators(self) -> "Iterator[list[Validator]]":
|
|
228
|
+
"""Collect validators this thread receives instead of storing"""
|
|
229
|
+
previous = getattr(self._local, "pending", None)
|
|
230
|
+
pending: list[Validator] = []
|
|
231
|
+
self._local.pending = pending
|
|
232
|
+
try:
|
|
233
|
+
yield pending
|
|
234
|
+
finally:
|
|
235
|
+
self._local.pending = previous
|
|
236
|
+
|
|
223
237
|
# internals
|
|
224
238
|
|
|
225
239
|
def _user_agent(self) -> str:
|
|
@@ -339,11 +353,16 @@ class Client:
|
|
|
339
353
|
if self._run_active:
|
|
340
354
|
self._run_bodies[cache_key] = result
|
|
341
355
|
if conditional and self.state is not None:
|
|
342
|
-
|
|
356
|
+
validator = (
|
|
343
357
|
url,
|
|
344
358
|
response.headers.get("etag"),
|
|
345
359
|
response.headers.get("last-modified"),
|
|
346
360
|
)
|
|
361
|
+
pending = getattr(self._local, "pending", None)
|
|
362
|
+
if pending is not None:
|
|
363
|
+
pending.append(validator)
|
|
364
|
+
else:
|
|
365
|
+
self.state.remember_validators(*validator)
|
|
347
366
|
return result
|
|
348
367
|
|
|
349
368
|
if response.status_code in soft:
|
|
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
9
9
|
from typing import Any, Protocol, runtime_checkable
|
|
10
10
|
|
|
11
11
|
from .config import Config, ConfigError, parse_duration, parse_ignore_list
|
|
12
|
-
from .http import Client
|
|
12
|
+
from .http import Client, Validator
|
|
13
13
|
from .models import RunError, Target, Update
|
|
14
14
|
from .registry import all_sources
|
|
15
15
|
from .state import State
|
|
@@ -118,16 +118,26 @@ def run(
|
|
|
118
118
|
sources = all_sources()
|
|
119
119
|
fetched: list[Update] = []
|
|
120
120
|
succeeded: dict[tuple[str, str], Target] = {}
|
|
121
|
+
# Stored only for targets whose items are recorded, see Client.deferred_validators
|
|
122
|
+
validators: list[Validator] = []
|
|
121
123
|
|
|
122
|
-
def work(
|
|
124
|
+
def work(
|
|
125
|
+
target: Target,
|
|
126
|
+
) -> tuple[Target, list[Update] | Exception, int, list[Validator]]:
|
|
123
127
|
if progress is not None:
|
|
124
128
|
progress.target_started(target)
|
|
125
129
|
outcome: list[Update] | Exception
|
|
126
130
|
dropped = 0
|
|
127
|
-
# A first fetch must see the whole document
|
|
128
|
-
|
|
131
|
+
# A first fetch must see the whole document, same with one thats ignore rules changed
|
|
132
|
+
full = (
|
|
133
|
+
not state.is_baselined(target.source, target.key)
|
|
134
|
+
or state.ignore_rules_changed(target.source, target.key, target.ignore)
|
|
135
|
+
)
|
|
129
136
|
try:
|
|
130
|
-
with
|
|
137
|
+
with (
|
|
138
|
+
client.deferred_validators() as pending,
|
|
139
|
+
client.unconditional() if full else nullcontext(),
|
|
140
|
+
):
|
|
131
141
|
items = list(sources[target.source].fetch(target, client))
|
|
132
142
|
kept = [u for u in items if not target.ignores(u.tags)]
|
|
133
143
|
# Ignored items are not recorded as seen
|
|
@@ -139,7 +149,7 @@ def run(
|
|
|
139
149
|
progress.target_finished(
|
|
140
150
|
target, outcome if isinstance(outcome, Exception) else None
|
|
141
151
|
)
|
|
142
|
-
return target, outcome, dropped
|
|
152
|
+
return target, outcome, dropped, pending
|
|
143
153
|
|
|
144
154
|
if progress is not None:
|
|
145
155
|
progress.run_started(len(targets))
|
|
@@ -147,7 +157,7 @@ def run(
|
|
|
147
157
|
workers = max(1, min(config.privacy.concurrency, len(targets)))
|
|
148
158
|
# run_scope: targets that share an upstream document fetch it once between them
|
|
149
159
|
with client.run_scope(), ThreadPoolExecutor(max_workers=workers) as pool:
|
|
150
|
-
for target, outcome, dropped in pool.map(work, targets):
|
|
160
|
+
for target, outcome, dropped, pending in pool.map(work, targets):
|
|
151
161
|
result.ignored += dropped
|
|
152
162
|
if isinstance(outcome, Exception):
|
|
153
163
|
result.errors.append(
|
|
@@ -159,6 +169,7 @@ def run(
|
|
|
159
169
|
)
|
|
160
170
|
else:
|
|
161
171
|
fetched.extend(outcome)
|
|
172
|
+
validators.extend(pending)
|
|
162
173
|
succeeded.setdefault((target.source, target.key), target)
|
|
163
174
|
|
|
164
175
|
result.total_fetched = len(fetched)
|
|
@@ -181,7 +192,10 @@ def run(
|
|
|
181
192
|
|
|
182
193
|
if save:
|
|
183
194
|
state.mark_seen(fetched)
|
|
195
|
+
for validator in validators:
|
|
196
|
+
state.remember_validators(*validator)
|
|
184
197
|
state.mark_baselined(succeeded)
|
|
198
|
+
state.mark_ignore_rules((s, k, t.ignore) for (s, k), t in succeeded.items())
|
|
185
199
|
state.set_last_run()
|
|
186
200
|
# Prune only after marking. Targets not fetched cleanly (failed, or left out by --source) keep history
|
|
187
201
|
result.pruned = state.prune(
|
|
@@ -164,13 +164,15 @@ class BrowserSource:
|
|
|
164
164
|
return []
|
|
165
165
|
|
|
166
166
|
# One version can appear several times as its rollout fraction grows: keeps the earliest start time per version.
|
|
167
|
-
starts: dict[str, datetime] = {}
|
|
167
|
+
starts: dict[str, datetime | None] = {}
|
|
168
168
|
for release in fetched.json().get("releases", []):
|
|
169
169
|
version = release.get("version")
|
|
170
170
|
started = _parse_iso((release.get("serving") or {}).get("startTime"))
|
|
171
171
|
if not version:
|
|
172
172
|
continue
|
|
173
|
-
if version not in starts
|
|
173
|
+
if version not in starts:
|
|
174
|
+
starts[version] = started
|
|
175
|
+
elif started and (starts[version] is None or started < starts[version]):
|
|
174
176
|
starts[version] = started
|
|
175
177
|
|
|
176
178
|
ordered = sorted(
|
|
@@ -101,8 +101,12 @@ def parse_entries(
|
|
|
101
101
|
) -> list[Update]:
|
|
102
102
|
"""Turn an already-parsed feed into Updates, newest first."""
|
|
103
103
|
updates: list[Update] = []
|
|
104
|
+
# Sort before truncating: some feeds list oldest first. Undated entries sort last
|
|
105
|
+
entries = sorted(
|
|
106
|
+
parsed.entries, key=lambda e: entry_datetime(e) or _UNDATED, reverse=True
|
|
107
|
+
)
|
|
104
108
|
|
|
105
|
-
for entry in
|
|
109
|
+
for entry in entries[: limit or None]:
|
|
106
110
|
link = entry.get("link") or ""
|
|
107
111
|
title = (entry.get("title") or "").strip() or "(untitled)"
|
|
108
112
|
uid = entry.get("id") or link or f"{title}|{entry.get('published', '')}"
|
|
@@ -120,8 +124,6 @@ def parse_entries(
|
|
|
120
124
|
)
|
|
121
125
|
)
|
|
122
126
|
|
|
123
|
-
# Undated entries sort last
|
|
124
|
-
updates.sort(key=lambda u: u.published or _UNDATED, reverse=True)
|
|
125
127
|
return updates
|
|
126
128
|
|
|
127
129
|
|
|
@@ -27,8 +27,11 @@ GAME_URL_RE = re.compile(
|
|
|
27
27
|
|
|
28
28
|
# info panel: <tr><td>Updated</td><td><abbr title="17 August 2026 @ 10:34 UTC">…
|
|
29
29
|
INFO_ROW_RE = re.compile(r"<td>([^<]{1,30})</td><td>(.{0,400}?)</td>", re.S)
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
# itch shuffles attribute order between responses, so never assume any
|
|
31
|
+
ABBR_TITLE_RE = re.compile(r'<abbr\b[^>]*?\stitle="([^"]+)"')
|
|
32
|
+
STRONG_TAG_RE = re.compile(r"<strong\b([^>]*)>")
|
|
33
|
+
NAME_CLASS_RE = re.compile(r'(?:^|\s)class="name"')
|
|
34
|
+
TITLE_ATTR_RE = re.compile(r'(?:^|\s)title="([^"]*)"')
|
|
32
35
|
UPLOAD_SIZE_RE = re.compile(r'class="file_size"><span>([^<]*)</span>')
|
|
33
36
|
ITCH_DATE_RE = re.compile(
|
|
34
37
|
r"(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s*@\s*(\d{1,2}):(\d{2}))?", re.I
|
|
@@ -188,10 +191,19 @@ class ItchSource:
|
|
|
188
191
|
return rows
|
|
189
192
|
|
|
190
193
|
def _uploads(self, html: str) -> list[tuple[str, str]]:
|
|
191
|
-
names =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
names: list[tuple[str, int]] = []
|
|
195
|
+
for tag in STRONG_TAG_RE.finditer(html):
|
|
196
|
+
attrs = tag.group(1)
|
|
197
|
+
title = TITLE_ATTR_RE.search(attrs)
|
|
198
|
+
if title and NAME_CLASS_RE.search(attrs):
|
|
199
|
+
names.append((title.group(1), tag.end()))
|
|
200
|
+
uploads = []
|
|
201
|
+
for i, (name, start) in enumerate(names):
|
|
202
|
+
# Sizes can be missing for some entries; only look up to the next upload
|
|
203
|
+
end = names[i + 1][1] if i + 1 < len(names) else len(html)
|
|
204
|
+
size = UPLOAD_SIZE_RE.search(html, start, end)
|
|
205
|
+
uploads.append((name, size.group(1) if size else ""))
|
|
206
|
+
return uploads
|
|
195
207
|
|
|
196
208
|
|
|
197
209
|
def _parse_itch_date(value: str | None) -> datetime | None:
|
|
@@ -263,7 +263,8 @@ class YouTubeSource:
|
|
|
263
263
|
# Rewrite uids to the video id so the three paths dedupe
|
|
264
264
|
normalized = []
|
|
265
265
|
for update in updates:
|
|
266
|
-
|
|
266
|
+
# Entry ids are already ``yt:video:<id>``
|
|
267
|
+
vid = _video_id(update.url) or update.uid.removeprefix("yt:video:")
|
|
267
268
|
normalized.append(
|
|
268
269
|
Update(
|
|
269
270
|
source=update.source,
|
|
@@ -326,7 +327,8 @@ def _extract_playlist_id(raw: str) -> str:
|
|
|
326
327
|
|
|
327
328
|
|
|
328
329
|
def _video_id(url: str) -> str | None:
|
|
329
|
-
|
|
330
|
+
# Shorts are linked as /shorts/<id> rather than watch?v=<id>
|
|
331
|
+
match = re.search(r"(?:[?&]v=|/shorts/)([A-Za-z0-9_-]{11})", url or "")
|
|
330
332
|
return match.group(1) if match else None
|
|
331
333
|
|
|
332
334
|
|
|
@@ -45,6 +45,10 @@ def _ago(age: timedelta) -> str:
|
|
|
45
45
|
return (datetime.now(timezone.utc) - age).isoformat()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _rules_value(ignore: frozenset[str]) -> str:
|
|
49
|
+
return ",".join(sorted(ignore))
|
|
50
|
+
|
|
51
|
+
|
|
48
52
|
class State:
|
|
49
53
|
"""Thread-safe wrapper over SQLite file."""
|
|
50
54
|
|
|
@@ -90,6 +94,17 @@ class State:
|
|
|
90
94
|
self._conn.execute("ALTER TABLE seen ADD COLUMN last_seen TEXT")
|
|
91
95
|
self._conn.execute("UPDATE seen SET last_seen = first_seen")
|
|
92
96
|
self._dirty = True
|
|
97
|
+
# <= 0.3.0 stored YouTube Shorts from the official feed as yt:video:yt:video:<id>
|
|
98
|
+
fixed = self._conn.execute(
|
|
99
|
+
"UPDATE OR IGNORE seen SET uid = substr(uid, 10) "
|
|
100
|
+
"WHERE source = 'youtube' AND uid LIKE 'yt:video:yt:video:%'"
|
|
101
|
+
).rowcount
|
|
102
|
+
# Rows left over already had the correct id recorded by a fallback path
|
|
103
|
+
fixed += self._conn.execute(
|
|
104
|
+
"DELETE FROM seen WHERE source = 'youtube' AND uid LIKE 'yt:video:yt:video:%'"
|
|
105
|
+
).rowcount
|
|
106
|
+
if fixed:
|
|
107
|
+
self._dirty = True
|
|
93
108
|
|
|
94
109
|
def _read_encrypted(self) -> bytes | None:
|
|
95
110
|
"""Decrypt the state file into a database image, or None if there is no file yet."""
|
|
@@ -247,8 +262,13 @@ class State:
|
|
|
247
262
|
"DELETE FROM kv WHERE namespace='baseline' AND key=?",
|
|
248
263
|
[(key,) for key in stale_marks],
|
|
249
264
|
)
|
|
265
|
+
# Rules are recorded with the baseline mark, and go with it
|
|
266
|
+
removed_rules = self._conn.execute(
|
|
267
|
+
"DELETE FROM kv WHERE namespace='ignore' AND key NOT IN "
|
|
268
|
+
"(SELECT key FROM kv WHERE namespace='baseline')"
|
|
269
|
+
).rowcount
|
|
250
270
|
self._conn.commit()
|
|
251
|
-
if removed or removed_validators or stale_marks:
|
|
271
|
+
if removed or removed_validators or stale_marks or removed_rules:
|
|
252
272
|
# reclaim empty so the file shrinks
|
|
253
273
|
self._conn.execute("VACUUM")
|
|
254
274
|
self._dirty = True
|
|
@@ -301,6 +321,28 @@ class State:
|
|
|
301
321
|
self._conn.commit()
|
|
302
322
|
self._dirty = True
|
|
303
323
|
|
|
324
|
+
# per target ignore rules
|
|
325
|
+
|
|
326
|
+
def ignore_rules_changed(self, source: str, target: str, ignore: frozenset[str]) -> bool:
|
|
327
|
+
"""If ``ignore`` is different from the rules the target's last saved fetch ran under"""
|
|
328
|
+
return self.cache_get("ignore", f"{source}:{target}") != _rules_value(ignore)
|
|
329
|
+
|
|
330
|
+
def mark_ignore_rules(self, targets: Iterable[tuple[str, str, frozenset[str]]]) -> None:
|
|
331
|
+
rows = [
|
|
332
|
+
("ignore", f"{source}:{target}", _rules_value(ignore))
|
|
333
|
+
for source, target, ignore in targets
|
|
334
|
+
]
|
|
335
|
+
if not rows:
|
|
336
|
+
return
|
|
337
|
+
with self._lock:
|
|
338
|
+
self._conn.executemany(
|
|
339
|
+
"INSERT INTO kv (namespace, key, value) VALUES (?,?,?) "
|
|
340
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET value=excluded.value",
|
|
341
|
+
rows,
|
|
342
|
+
)
|
|
343
|
+
self._conn.commit()
|
|
344
|
+
self._dirty = True
|
|
345
|
+
|
|
304
346
|
# upkeeping
|
|
305
347
|
|
|
306
348
|
def last_run(self) -> datetime | None:
|
|
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
|