reader 3.19.dev0__py3-none-any.whl → 3.21__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.
- reader/__init__.py +1 -1
- reader/_app/v2/forms.py +2 -1
- reader/_app/v2/templates/v2/entries.html +2 -0
- reader/_cli.py +4 -1
- reader/_parser/__init__.py +52 -13
- reader/_parser/_http_utils.py +9 -1
- reader/_storage/__init__.py +4 -2
- reader/_storage/_base.py +2 -3
- reader/_storage/_html_utils.py +16 -2
- reader/_storage/_sqlite_utils.py +69 -30
- reader/_types.py +15 -0
- reader/_update.py +11 -10
- reader/core.py +32 -8
- reader/plugins/entry_dedupe.py +918 -364
- {reader-3.19.dev0.dist-info → reader-3.21.dist-info}/METADATA +1 -1
- {reader-3.19.dev0.dist-info → reader-3.21.dist-info}/RECORD +19 -19
- {reader-3.19.dev0.dist-info → reader-3.21.dist-info}/WHEEL +0 -0
- {reader-3.19.dev0.dist-info → reader-3.21.dist-info}/licenses/LICENSE +0 -0
- {reader-3.19.dev0.dist-info → reader-3.21.dist-info}/top_level.txt +0 -0
reader/__init__.py
CHANGED
reader/_app/v2/forms.py
CHANGED
|
@@ -116,7 +116,8 @@ ENTRY_SORT_CHOICES = ['recent', 'random']
|
|
|
116
116
|
class EntryFilter(PresetsMixin, Form):
|
|
117
117
|
feed = HiddenField("feed")
|
|
118
118
|
search = SearchField("search", name='Q')
|
|
119
|
-
feed_tags = TagFilterField("tags", name='tags')
|
|
119
|
+
feed_tags = TagFilterField("feed tags", name='tags')
|
|
120
|
+
tags = TagFilterField("entry tags", name='entry-tags')
|
|
120
121
|
read = radio_field("read", choices=BOOL_CHOICES, default='no')
|
|
121
122
|
important = radio_field("important", choices=TRISTATE_CHOICES, default='maybe')
|
|
122
123
|
has_enclosures = radio_field(
|
|
@@ -52,9 +52,11 @@
|
|
|
52
52
|
{# TODO: if data or error #}
|
|
53
53
|
{% if form.search.data %}{{ macros.input(form.search, 'search') }}{% endif %}
|
|
54
54
|
{% if form.feed_tags.data %}{{ macros.input(form.feed_tags, 'tag') }}{% endif %}
|
|
55
|
+
{% if form.feed_tags.data %}{{ macros.input(form.tags, 'tag') }}{% endif %}
|
|
55
56
|
<div class="collapse mb-3" id="nav-more">
|
|
56
57
|
{% if not form.search.data %}{{ macros.input(form.search, 'search') }}{% endif %}
|
|
57
58
|
{% if not form.feed_tags.data %}{{ macros.input(form.feed_tags, 'tag') }}{% endif %}
|
|
59
|
+
{% if not form.feed_tags.data %}{{ macros.input(form.tags, 'tag') }}{% endif %}
|
|
58
60
|
<div class="mb-3">
|
|
59
61
|
{{ macros.radio(form.read) }}
|
|
60
62
|
{{ macros.radio(form.important) }}
|
reader/_cli.py
CHANGED
|
@@ -290,7 +290,10 @@ def iter_update_status(it, length):
|
|
|
290
290
|
help="Only update new (never updated before) feeds.",
|
|
291
291
|
)
|
|
292
292
|
@click.option(
|
|
293
|
-
'--scheduled/--no-scheduled',
|
|
293
|
+
'--scheduled/--no-scheduled',
|
|
294
|
+
default=True,
|
|
295
|
+
show_default=True,
|
|
296
|
+
help="Only update feeds scheduled to be updated.",
|
|
294
297
|
)
|
|
295
298
|
@click.option(
|
|
296
299
|
'--workers',
|
reader/_parser/__init__.py
CHANGED
|
@@ -8,6 +8,7 @@ from contextlib import contextmanager
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from datetime import timedelta
|
|
11
|
+
from datetime import timezone
|
|
11
12
|
from typing import Any
|
|
12
13
|
from typing import cast
|
|
13
14
|
from typing import ContextManager
|
|
@@ -34,6 +35,8 @@ from .requests import TimeoutType
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING: # pragma: no cover
|
|
38
|
+
from werkzeug.datastructures import RequestCacheControl
|
|
39
|
+
|
|
37
40
|
from ._lazy import Parser as Parser
|
|
38
41
|
|
|
39
42
|
|
|
@@ -189,28 +192,64 @@ class HTTPInfo(_namedtuple_compat):
|
|
|
189
192
|
#: The HTTP response headers.
|
|
190
193
|
headers: Headers
|
|
191
194
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
def get_update_after(self, now: datetime) -> datetime | None:
|
|
196
|
+
"""Select the best "update after" date from available headers."""
|
|
197
|
+
rv = []
|
|
198
|
+
|
|
199
|
+
if self.status in (429, 503):
|
|
200
|
+
try:
|
|
201
|
+
seconds = int(self.headers.get('retry-after', ''))
|
|
202
|
+
rv.append(now + timedelta(seconds=seconds))
|
|
203
|
+
except ValueError:
|
|
204
|
+
if retry_after := self.parse_date('retry-after', now):
|
|
205
|
+
rv.append(retry_after)
|
|
206
|
+
|
|
207
|
+
# https://httpwg.org/specs/rfc9111.html#calculating.freshness.lifetime
|
|
208
|
+
if cache_control := self.cache_control:
|
|
209
|
+
if not cache_control.no_cache:
|
|
210
|
+
if max_age := cache_control.max_age:
|
|
211
|
+
rv.append(now + timedelta(seconds=max_age))
|
|
212
|
+
elif expires := self.parse_date('expires', now):
|
|
213
|
+
rv.append(expires)
|
|
214
|
+
|
|
215
|
+
# TODO: RFC 9111 specifies a Last-Modified fallback heuristic,
|
|
216
|
+
# but it might be better to implement it in the updater
|
|
217
|
+
# as part of https://github.com/lemon24/reader/issues/382
|
|
218
|
+
|
|
219
|
+
return max(rv, default=None)
|
|
220
|
+
|
|
221
|
+
def parse_date(self, name: str, now: datetime | None = None) -> datetime | None:
|
|
222
|
+
"""Parse an HTTP date header and return a timezone-aware datetime.
|
|
223
|
+
|
|
224
|
+
Return None if missing or if parsing fails.
|
|
195
225
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if timezone information is missing, UTC is assumed.
|
|
226
|
+
If `now` is given and the Date header is set,
|
|
227
|
+
make the returned value relative to `now`.
|
|
199
228
|
|
|
200
229
|
"""
|
|
201
230
|
# lazy import
|
|
202
231
|
from ._http_utils import parse_date
|
|
203
232
|
|
|
204
|
-
value
|
|
233
|
+
if value := parse_date(self.headers.get(name, '')):
|
|
234
|
+
value = value.astimezone(timezone.utc)
|
|
235
|
+
if now and (date := parse_date(self.headers.get('date', ''))):
|
|
236
|
+
value = now + (value - date)
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def cache_control(self) -> RequestCacheControl | None:
|
|
243
|
+
"""Parsed Cache-Control header, or None if missing."""
|
|
244
|
+
|
|
245
|
+
# lazy import
|
|
246
|
+
from ._http_utils import parse_cache_control_header
|
|
247
|
+
|
|
248
|
+
value = self.headers.get('cache-control')
|
|
205
249
|
if not value:
|
|
206
250
|
return None
|
|
207
251
|
|
|
208
|
-
|
|
209
|
-
seconds = int(value)
|
|
210
|
-
except ValueError:
|
|
211
|
-
return parse_date(value)
|
|
212
|
-
|
|
213
|
-
return timedelta(seconds=seconds)
|
|
252
|
+
return parse_cache_control_header(value)
|
|
214
253
|
|
|
215
254
|
|
|
216
255
|
class RetrieveError(ParseError):
|
reader/_parser/_http_utils.py
CHANGED
|
@@ -4,8 +4,11 @@ HTTP utilities. Contains no business logic.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from collections.abc import Iterable
|
|
7
|
+
from functools import partial
|
|
7
8
|
|
|
8
9
|
import werkzeug.http
|
|
10
|
+
from werkzeug.datastructures import MIMEAccept
|
|
11
|
+
from werkzeug.datastructures import ResponseCacheControl
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
parse_options_header = werkzeug.http.parse_options_header
|
|
@@ -14,4 +17,9 @@ parse_date = werkzeug.http.parse_date
|
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def unparse_accept_header(values: Iterable[tuple[str, float]]) -> str:
|
|
17
|
-
return
|
|
20
|
+
return MIMEAccept(values).to_header()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
parse_cache_control_header = partial(
|
|
24
|
+
werkzeug.http.parse_cache_control_header, cls=ResponseCacheControl
|
|
25
|
+
)
|
reader/_storage/__init__.py
CHANGED
|
@@ -29,8 +29,10 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
|
|
|
29
29
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
def __init__(
|
|
33
|
-
|
|
32
|
+
def __init__(
|
|
33
|
+
self, path: str, read_only: bool = False, timeout: float | None = None
|
|
34
|
+
):
|
|
35
|
+
super().__init__(path, read_only, timeout)
|
|
34
36
|
self.changes: ChangeTrackerType = Changes(self)
|
|
35
37
|
|
|
36
38
|
def make_search(self) -> SearchType:
|
reader/_storage/_base.py
CHANGED
|
@@ -20,7 +20,6 @@ APPLICATION_ID = b'read'
|
|
|
20
20
|
|
|
21
21
|
_T = TypeVar('_T')
|
|
22
22
|
|
|
23
|
-
|
|
24
23
|
# also used by tests
|
|
25
24
|
CONNECTION_CLS = sqlite3.Connection
|
|
26
25
|
|
|
@@ -52,7 +51,7 @@ class StorageBase:
|
|
|
52
51
|
chunk_size = 2**8
|
|
53
52
|
|
|
54
53
|
@wrap_exceptions(message="while opening database")
|
|
55
|
-
def __init__(self, path: str, timeout: float | None = None):
|
|
54
|
+
def __init__(self, path: str, read_only: bool, timeout: float | None = None):
|
|
56
55
|
kwargs: dict[str, Any] = {'factory': CONNECTION_CLS}
|
|
57
56
|
if timeout is not None:
|
|
58
57
|
kwargs['timeout'] = timeout
|
|
@@ -61,7 +60,7 @@ class StorageBase:
|
|
|
61
60
|
# has to run for every connection (in every thread),
|
|
62
61
|
# since it's not persisted across connections
|
|
63
62
|
self.factory = _sqlite_utils.LocalConnectionFactory(
|
|
64
|
-
path, self.setup_db, **kwargs
|
|
63
|
+
path, self.setup_db, read_only, **kwargs
|
|
65
64
|
)
|
|
66
65
|
|
|
67
66
|
def get_db(self) -> sqlite3.Connection:
|
reader/_storage/_html_utils.py
CHANGED
|
@@ -41,7 +41,8 @@ warnings.filterwarnings(
|
|
|
41
41
|
def strip_html(html: str, features: str | None = None) -> str:
|
|
42
42
|
soup = get_soup(html)
|
|
43
43
|
remove_nontext_elements(soup)
|
|
44
|
-
|
|
44
|
+
add_text_attrs_as_elements(soup)
|
|
45
|
+
return soup.get_text()
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
def get_soup(html: str, features: str | None = None) -> bs4.BeautifulSoup:
|
|
@@ -59,4 +60,17 @@ def remove_nontext_elements(soup: bs4.BeautifulSoup) -> None:
|
|
|
59
60
|
# shows any free-floating text it contains, so we should keep it around.
|
|
60
61
|
#
|
|
61
62
|
for e in soup.select('script, noscript, style, title'):
|
|
62
|
-
e.
|
|
63
|
+
e.decompose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_text_attrs_as_elements(soup: bs4.BeautifulSoup) -> None:
|
|
67
|
+
for e in soup.select('img[alt], area[alt], [title]'):
|
|
68
|
+
strings = []
|
|
69
|
+
if e.name == 'img' or e.name == 'area':
|
|
70
|
+
if s := e.attrs.get('alt'):
|
|
71
|
+
strings.append(s)
|
|
72
|
+
if s := e.attrs.get('title'):
|
|
73
|
+
if s not in strings:
|
|
74
|
+
strings.append(s)
|
|
75
|
+
if strings:
|
|
76
|
+
e.insert_after(*[soup.new_tag('span', string=f" {s} ") for s in strings])
|
reader/_storage/_sqlite_utils.py
CHANGED
|
@@ -20,8 +20,10 @@ from contextlib import closing
|
|
|
20
20
|
from contextlib import contextmanager
|
|
21
21
|
from contextlib import nullcontext
|
|
22
22
|
from dataclasses import dataclass
|
|
23
|
+
from dataclasses import field
|
|
23
24
|
from datetime import datetime
|
|
24
25
|
from datetime import timezone
|
|
26
|
+
from pathlib import Path
|
|
25
27
|
from typing import Any
|
|
26
28
|
from typing import cast
|
|
27
29
|
from typing import no_type_check
|
|
@@ -119,6 +121,7 @@ def wrap_exceptions(
|
|
|
119
121
|
for fragment, op_exc_type in op_exc_types.items():
|
|
120
122
|
if fragment.lower() in e_msg:
|
|
121
123
|
raise op_exc_type(str(e)) from None
|
|
124
|
+
|
|
122
125
|
raise exc_type(message) from e
|
|
123
126
|
|
|
124
127
|
except sqlite3.ProgrammingError as e:
|
|
@@ -210,7 +213,6 @@ class IdError(DBError):
|
|
|
210
213
|
|
|
211
214
|
db_errors = [DBError, SchemaVersionError, IntegrityError, RequirementError]
|
|
212
215
|
|
|
213
|
-
|
|
214
216
|
_DBFunction = Callable[[sqlite3.Connection], None]
|
|
215
217
|
|
|
216
218
|
|
|
@@ -411,6 +413,9 @@ class UsageError(DBError):
|
|
|
411
413
|
display_name = "usage error"
|
|
412
414
|
|
|
413
415
|
|
|
416
|
+
TEMPORARY_DB_PATHS = {':memory:', ''}
|
|
417
|
+
|
|
418
|
+
|
|
414
419
|
class LocalConnectionFactory:
|
|
415
420
|
"""Maintain a set of connections to the same database, one per thread.
|
|
416
421
|
|
|
@@ -436,27 +441,45 @@ class LocalConnectionFactory:
|
|
|
436
441
|
|
|
437
442
|
INLINE_OPTIMIZE_TIMEOUT = 0.1
|
|
438
443
|
|
|
444
|
+
@dataclass
|
|
445
|
+
class _State(threading.local):
|
|
446
|
+
class _Sentinel:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
db: sqlite3.Connection | None = None
|
|
450
|
+
finalizer: weakref.finalize[[sqlite3.Connection], _Sentinel] | None = None
|
|
451
|
+
finalizer_sentinel: _Sentinel = field(default_factory=_Sentinel)
|
|
452
|
+
is_creating_thread: bool = False
|
|
453
|
+
context_stack: list[None] = field(default_factory=list)
|
|
454
|
+
call_count: int = 0
|
|
455
|
+
closed: bool = False
|
|
456
|
+
|
|
439
457
|
def __init__(
|
|
440
|
-
self,
|
|
458
|
+
self,
|
|
459
|
+
path: str,
|
|
460
|
+
setup_db: _DBFunction = lambda _: None,
|
|
461
|
+
read_only: bool = False,
|
|
462
|
+
**kwargs: Any,
|
|
441
463
|
):
|
|
442
464
|
self.path = path
|
|
443
465
|
self.setup_db = setup_db
|
|
466
|
+
self.read_only = read_only
|
|
444
467
|
self.kwargs = kwargs
|
|
445
468
|
if kwargs.get('uri'): # pragma: no cover
|
|
446
469
|
raise NotImplementedError("is_private() does not work for uri=True")
|
|
470
|
+
self.kwargs['uri'] = True
|
|
447
471
|
self.attached: dict[str, str] = {}
|
|
448
|
-
self._local =
|
|
472
|
+
self._local = self._State()
|
|
449
473
|
self._local.is_creating_thread = True
|
|
450
474
|
self._other_threads = False
|
|
451
475
|
# connect immediately, so exceptions are raised before the first call
|
|
452
476
|
self.__call__()
|
|
453
477
|
|
|
454
478
|
def __call__(self) -> sqlite3.Connection:
|
|
455
|
-
db
|
|
456
|
-
if db:
|
|
479
|
+
if db := self._local.db:
|
|
457
480
|
if not self._local.context_stack:
|
|
458
481
|
if self._should_optimize(self._local.call_count):
|
|
459
|
-
self._optimize(db, self.INLINE_OPTIMIZE_TIMEOUT)
|
|
482
|
+
self._optimize(db, self.read_only, self.INLINE_OPTIMIZE_TIMEOUT)
|
|
460
483
|
self._local.call_count += 1
|
|
461
484
|
return db
|
|
462
485
|
|
|
@@ -472,10 +495,10 @@ class LocalConnectionFactory:
|
|
|
472
495
|
if not self._local.is_creating_thread:
|
|
473
496
|
self._other_threads = True
|
|
474
497
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
498
|
+
self._local.db = db = cast(
|
|
499
|
+
sqlite3.Connection,
|
|
500
|
+
sqlite3.connect(self._make_uri(self.path), **self.kwargs),
|
|
501
|
+
)
|
|
479
502
|
|
|
480
503
|
try:
|
|
481
504
|
self.setup_db(db)
|
|
@@ -487,9 +510,14 @@ class LocalConnectionFactory:
|
|
|
487
510
|
# works on cpython (finalizer runs in thread),
|
|
488
511
|
# but not on pypy (finalizer runs in main thread);
|
|
489
512
|
# also see https://bugs.python.org/issue14073
|
|
513
|
+
#
|
|
514
|
+
# originally, we were using threading.current_thread() as target,
|
|
515
|
+
# but an unrelated object owned by the thread is more reliable
|
|
516
|
+
# (the thread is collected with a delay, and rarely only atexit)
|
|
517
|
+
#
|
|
490
518
|
self._local.finalizer = weakref.finalize(
|
|
491
|
-
|
|
492
|
-
)
|
|
519
|
+
self._local.finalizer_sentinel, self._close, db, self.read_only
|
|
520
|
+
) # type: ignore[call-arg]
|
|
493
521
|
|
|
494
522
|
for name, path in self.attached.items():
|
|
495
523
|
self._attach(db, name, path)
|
|
@@ -514,10 +542,10 @@ class LocalConnectionFactory:
|
|
|
514
542
|
self._local.closed = True
|
|
515
543
|
|
|
516
544
|
@classmethod
|
|
517
|
-
def _close(cls, db: sqlite3.Connection) -> None:
|
|
545
|
+
def _close(cls, db: sqlite3.Connection, read_only: bool = False) -> None:
|
|
518
546
|
try:
|
|
519
547
|
try:
|
|
520
|
-
cls._optimize(db)
|
|
548
|
+
cls._optimize(db, read_only)
|
|
521
549
|
finally:
|
|
522
550
|
db.close()
|
|
523
551
|
except sqlite3.ProgrammingError as e:
|
|
@@ -527,12 +555,18 @@ class LocalConnectionFactory:
|
|
|
527
555
|
return
|
|
528
556
|
# can't close() a connection from a thread that didn't create it;
|
|
529
557
|
# SQLAlchemy ignores this as well in SingletonThreadPool.dispose()
|
|
530
|
-
if "objects created in a thread" in message:
|
|
558
|
+
if "objects created in a thread" in message: # pragma: no cover
|
|
531
559
|
return
|
|
532
560
|
raise
|
|
533
561
|
|
|
534
562
|
@staticmethod
|
|
535
|
-
def _optimize(
|
|
563
|
+
def _optimize(
|
|
564
|
+
db: sqlite3.Connection, read_only: bool = False, timeout: float = 0
|
|
565
|
+
) -> None:
|
|
566
|
+
# don't optimize the database if it's in a read-only state
|
|
567
|
+
if read_only:
|
|
568
|
+
return
|
|
569
|
+
|
|
536
570
|
# Don't wait too much for a lock, it means the database is busy,
|
|
537
571
|
# and now is likely not a good time to run optimize.
|
|
538
572
|
# Also prevents rare "database is locked" errors in certain conditions
|
|
@@ -552,6 +586,7 @@ class LocalConnectionFactory:
|
|
|
552
586
|
db.execute("PRAGMA optimize;")
|
|
553
587
|
except sqlite3.OperationalError as e: # pragma: no cover
|
|
554
588
|
message = str(e).lower()
|
|
589
|
+
|
|
555
590
|
if "database is locked" not in message:
|
|
556
591
|
raise
|
|
557
592
|
|
|
@@ -581,11 +616,27 @@ class LocalConnectionFactory:
|
|
|
581
616
|
self._attach(db, name, path)
|
|
582
617
|
|
|
583
618
|
def _attach(self, db: sqlite3.Connection, name: str, path: str) -> None:
|
|
584
|
-
db.execute("ATTACH DATABASE ? AS ?;", (path, name))
|
|
619
|
+
db.execute("ATTACH DATABASE ? AS ?;", (self._make_uri(path), name))
|
|
585
620
|
|
|
586
621
|
def is_private(self) -> bool:
|
|
587
622
|
return self._is_private(self.path)
|
|
588
623
|
|
|
624
|
+
def _make_uri(self, path: str) -> str:
|
|
625
|
+
# SQLite allows relative paths by skipping the // after file:,
|
|
626
|
+
# but pathlib.Path.as_uri() does not support this;
|
|
627
|
+
# however, temporary databases *must* be relative
|
|
628
|
+
if path in TEMPORARY_DB_PATHS:
|
|
629
|
+
# arbitrary paths would need to quote ? to avoid qs injection
|
|
630
|
+
uri = f"file:{path}"
|
|
631
|
+
else:
|
|
632
|
+
# bypasses SQLite's resolution of relative paths, should be fine
|
|
633
|
+
uri = Path(path).absolute().as_uri()
|
|
634
|
+
|
|
635
|
+
if self.read_only:
|
|
636
|
+
uri += "?mode=ro"
|
|
637
|
+
|
|
638
|
+
return uri
|
|
639
|
+
|
|
589
640
|
@staticmethod
|
|
590
641
|
def _is_private(path: str) -> bool:
|
|
591
642
|
"""Does connect(path) from another thread connect to a different database?
|
|
@@ -603,19 +654,7 @@ class LocalConnectionFactory:
|
|
|
603
654
|
https://www.sqlite.org/uri.html
|
|
604
655
|
|
|
605
656
|
"""
|
|
606
|
-
return path in
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
class _LocalConnectionFactoryState(threading.local):
|
|
610
|
-
def __init__(self) -> None:
|
|
611
|
-
self.db: sqlite3.Connection | None = None
|
|
612
|
-
self.finalizer: (
|
|
613
|
-
weakref.finalize[[sqlite3.Connection], threading.Thread] | None
|
|
614
|
-
) = None
|
|
615
|
-
self.is_creating_thread: bool = False
|
|
616
|
-
self.context_stack: list[None] = []
|
|
617
|
-
self.call_count: int = 0
|
|
618
|
-
self.closed: bool = False
|
|
657
|
+
return path in TEMPORARY_DB_PATHS
|
|
619
658
|
|
|
620
659
|
|
|
621
660
|
@contextmanager
|
reader/_types.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import time
|
|
4
5
|
from collections import defaultdict
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from collections.abc import Iterable
|
|
@@ -672,6 +673,7 @@ class UpdateHooks:
|
|
|
672
673
|
) -> list[SingleUpdateHookError]:
|
|
673
674
|
rv = []
|
|
674
675
|
for hook in self.hooks[when]:
|
|
676
|
+
start = time.monotonic()
|
|
675
677
|
try:
|
|
676
678
|
hook(self.target, *args)
|
|
677
679
|
except Exception as e:
|
|
@@ -680,6 +682,19 @@ class UpdateHooks:
|
|
|
680
682
|
if not return_exceptions:
|
|
681
683
|
raise wrapper
|
|
682
684
|
rv.append(wrapper)
|
|
685
|
+
finally:
|
|
686
|
+
end = time.monotonic()
|
|
687
|
+
try:
|
|
688
|
+
name = hook.__module__ + ':' + hook.__qualname__
|
|
689
|
+
except AttributeError:
|
|
690
|
+
name = repr(hook)
|
|
691
|
+
log.debug(
|
|
692
|
+
"ran %s hook %s for %r in %f seconds",
|
|
693
|
+
when,
|
|
694
|
+
name,
|
|
695
|
+
resource_id,
|
|
696
|
+
end - start,
|
|
697
|
+
)
|
|
683
698
|
return rv
|
|
684
699
|
|
|
685
700
|
def group(self, message: str) -> _UpdateHookErrorGrouper:
|
reader/_update.py
CHANGED
|
@@ -5,6 +5,7 @@ import random
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from datetime import datetime
|
|
8
|
+
from datetime import timedelta
|
|
8
9
|
from datetime import timezone
|
|
9
10
|
from functools import partial
|
|
10
11
|
from itertools import chain
|
|
@@ -252,17 +253,16 @@ class Decider:
|
|
|
252
253
|
value = self.get_feed_to_update(result.value, bool(entries_to_update))
|
|
253
254
|
|
|
254
255
|
update_after = next_update_after(self.global_now, **self.config)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
else:
|
|
261
|
-
retry_after = self.global_now + http_info.retry_after
|
|
262
|
-
# also accounts for retry_after being in the past / negative
|
|
263
|
-
if retry_after > update_after:
|
|
256
|
+
if result.http_info:
|
|
257
|
+
# TODO (#376): technically this is supposed to be against request end
|
|
258
|
+
http_update_after = result.http_info.get_update_after(self.global_now)
|
|
259
|
+
# also accounts for it being in the past / negative
|
|
260
|
+
if http_update_after and http_update_after > update_after:
|
|
264
261
|
# round up to the next interval
|
|
265
|
-
|
|
262
|
+
# TODO: don't round up if already on the next interval
|
|
263
|
+
update_after = next_update_after(http_update_after, **self.config)
|
|
264
|
+
# limit excessive update_after
|
|
265
|
+
update_after = min(update_after, self.global_now + MAX_UPDATE_AFTER)
|
|
266
266
|
|
|
267
267
|
# We always return a FeedUpdateIntent because
|
|
268
268
|
# we always want to set last_retrieved and update_after,
|
|
@@ -324,6 +324,7 @@ def set_number(name, src, dst, type, min=0, max=float('inf')): # type: ignore
|
|
|
324
324
|
# start on a Monday, so weekly amounts of seconds line up
|
|
325
325
|
UPDATE_AFTER_START = datetime(1970, 1, 5)
|
|
326
326
|
EPOCH_OFFSET = (UPDATE_AFTER_START - datetime(1970, 1, 1)).total_seconds()
|
|
327
|
+
MAX_UPDATE_AFTER = timedelta(31)
|
|
327
328
|
|
|
328
329
|
|
|
329
330
|
def next_update_after(now: datetime, interval: int, jitter: float = 0) -> datetime:
|
reader/core.py
CHANGED
|
@@ -103,6 +103,7 @@ def make_reader(
|
|
|
103
103
|
url: str,
|
|
104
104
|
*,
|
|
105
105
|
feed_root: str | None = None,
|
|
106
|
+
read_only: bool = False,
|
|
106
107
|
plugins: Iterable[PluginInput] = DEFAULT_PLUGINS,
|
|
107
108
|
session_timeout: TimeoutType = DEFAULT_TIMEOUT,
|
|
108
109
|
reserved_name_scheme: Mapping[str, str] = DEFAULT_RESERVED_NAME_SCHEME,
|
|
@@ -166,6 +167,9 @@ def make_reader(
|
|
|
166
167
|
``'/path/to/feed/root'``
|
|
167
168
|
(an absolute path that feed paths are relative to).
|
|
168
169
|
|
|
170
|
+
read_only (bool):
|
|
171
|
+
Only allow read-only storage operations.
|
|
172
|
+
|
|
169
173
|
plugins (iterable(str or callable(Reader)) or None):
|
|
170
174
|
An iterable of built-in plugin names or
|
|
171
175
|
`plugin(reader) --> None` callables.
|
|
@@ -236,6 +240,9 @@ def make_reader(
|
|
|
236
240
|
Wrap exceptions raised during plugin initialization
|
|
237
241
|
in :exc:`PluginInitError` instead of letting them bubble up.
|
|
238
242
|
|
|
243
|
+
.. versionadded:: 3.20
|
|
244
|
+
The ``read_only`` keyword argument.
|
|
245
|
+
|
|
239
246
|
"""
|
|
240
247
|
|
|
241
248
|
# Do as much work as possible before creating the storage.
|
|
@@ -263,12 +270,13 @@ def make_reader(
|
|
|
263
270
|
# See this comment for details on how it should evolve:
|
|
264
271
|
# https://github.com/lemon24/reader/issues/168#issuecomment-642002049
|
|
265
272
|
|
|
266
|
-
storage: StorageType = _storage or Storage(url)
|
|
273
|
+
storage: StorageType = _storage or Storage(url, read_only=read_only)
|
|
267
274
|
|
|
268
275
|
try:
|
|
269
276
|
# For now, we're using a storage-bound search provider.
|
|
270
277
|
if not isinstance(storage, BoundSearchStorageType): # pragma: no cover
|
|
271
278
|
raise TypeError("storage must have a make_search factory")
|
|
279
|
+
|
|
272
280
|
search = storage.make_search()
|
|
273
281
|
|
|
274
282
|
if search_enabled is True:
|
|
@@ -380,6 +388,9 @@ class Reader:
|
|
|
380
388
|
self._enable_search = _enable_search
|
|
381
389
|
self._update_hooks = UpdateHooks(self)
|
|
382
390
|
|
|
391
|
+
#: Override update_feeds(scheduled=...).
|
|
392
|
+
self._scheduled_override = None
|
|
393
|
+
|
|
383
394
|
if _called_directly:
|
|
384
395
|
warnings.warn(
|
|
385
396
|
"Reader objects should be created using make_reader(); the Reader "
|
|
@@ -787,7 +798,7 @@ class Reader:
|
|
|
787
798
|
broken: bool | None = None,
|
|
788
799
|
updates_enabled: bool | None = True,
|
|
789
800
|
new: bool | None = None,
|
|
790
|
-
scheduled: bool =
|
|
801
|
+
scheduled: bool = True,
|
|
791
802
|
workers: int = 1,
|
|
792
803
|
) -> None:
|
|
793
804
|
r"""Update all or some of the feeds.
|
|
@@ -800,7 +811,7 @@ class Reader:
|
|
|
800
811
|
currently, only the exceptions for the first 5 feeds
|
|
801
812
|
with hook failures are collected.
|
|
802
813
|
|
|
803
|
-
By default, update all
|
|
814
|
+
By default, update all scheduled feeds that have updates enabled.
|
|
804
815
|
|
|
805
816
|
Roughly equivalent to ``for _ in reader.update_feeds_iter(...): pass``.
|
|
806
817
|
|
|
@@ -818,6 +829,7 @@ class Reader:
|
|
|
818
829
|
/ have been updated before. Defaults to None.
|
|
819
830
|
scheduled (bool):
|
|
820
831
|
Only update feeds scheduled to be updated.
|
|
832
|
+
Defaults to true.
|
|
821
833
|
workers (int): Number of threads to use when getting the feeds.
|
|
822
834
|
|
|
823
835
|
Raises:
|
|
@@ -868,6 +880,9 @@ class Reader:
|
|
|
868
880
|
``new`` uses :attr:`~Feed.last_retrieved`
|
|
869
881
|
instead of :attr:`~Feed.last_updated`.
|
|
870
882
|
|
|
883
|
+
.. versionchanged:: 3.21
|
|
884
|
+
Only update scheduled feeds by default.
|
|
885
|
+
|
|
871
886
|
"""
|
|
872
887
|
with self._update_hooks.group("some hooks failed") as hook_errors:
|
|
873
888
|
results = self.update_feeds_iter(
|
|
@@ -905,7 +920,7 @@ class Reader:
|
|
|
905
920
|
broken: bool | None = None,
|
|
906
921
|
updates_enabled: bool | None = True,
|
|
907
922
|
new: bool | None = None,
|
|
908
|
-
scheduled: bool =
|
|
923
|
+
scheduled: bool = True,
|
|
909
924
|
workers: int = 1,
|
|
910
925
|
_call_feeds_update_hooks: bool = True,
|
|
911
926
|
) -> Iterable[UpdateResult]:
|
|
@@ -919,7 +934,7 @@ class Reader:
|
|
|
919
934
|
and re-raise them as an :exc:`UpdateHookErrorGroup`
|
|
920
935
|
after updating all the feeds.
|
|
921
936
|
|
|
922
|
-
By default, update all
|
|
937
|
+
By default, update all scheduled feeds that have updates enabled.
|
|
923
938
|
|
|
924
939
|
Args:
|
|
925
940
|
feed (str or tuple(str) or Feed or None): Only update the feed with this URL.
|
|
@@ -935,6 +950,7 @@ class Reader:
|
|
|
935
950
|
/ have been updated before. Defaults to None.
|
|
936
951
|
scheduled (bool):
|
|
937
952
|
Only update feeds scheduled to be updated.
|
|
953
|
+
Defaults to true.
|
|
938
954
|
workers (int): Number of threads to use when getting the feeds.
|
|
939
955
|
|
|
940
956
|
Yields:
|
|
@@ -998,8 +1014,15 @@ class Reader:
|
|
|
998
1014
|
``new`` uses :attr:`~Feed.last_retrieved`
|
|
999
1015
|
instead of :attr:`~Feed.last_updated`.
|
|
1000
1016
|
|
|
1017
|
+
.. versionchanged:: 3.21
|
|
1018
|
+
Only update scheduled feeds by default.
|
|
1019
|
+
|
|
1001
1020
|
"""
|
|
1002
1021
|
now = self._now()
|
|
1022
|
+
|
|
1023
|
+
if self._scheduled_override is not None: # pragma: no cover
|
|
1024
|
+
scheduled = self._scheduled_override
|
|
1025
|
+
|
|
1003
1026
|
filter = FeedFilter.from_args(
|
|
1004
1027
|
now, feed, tags, broken, updates_enabled, new, scheduled
|
|
1005
1028
|
)
|
|
@@ -1068,7 +1091,10 @@ class Reader:
|
|
|
1068
1091
|
"""
|
|
1069
1092
|
_, rv = zero_or_one(
|
|
1070
1093
|
self.update_feeds_iter(
|
|
1071
|
-
feed=feed,
|
|
1094
|
+
feed=feed,
|
|
1095
|
+
updates_enabled=None,
|
|
1096
|
+
scheduled=False,
|
|
1097
|
+
_call_feeds_update_hooks=False,
|
|
1072
1098
|
),
|
|
1073
1099
|
lambda: FeedNotFoundError(_feed_argument(feed)),
|
|
1074
1100
|
)
|
|
@@ -1616,8 +1642,6 @@ class Reader:
|
|
|
1616
1642
|
recent_sort = self._storage.get_entry_recent_sort(src_entry.resource_id)
|
|
1617
1643
|
dst_resource_id = _entry_argument(dst)
|
|
1618
1644
|
|
|
1619
|
-
# FIXME: do not allow copy to the same feed (or at least entry)
|
|
1620
|
-
|
|
1621
1645
|
attrs = dict(src_entry.__dict__)
|
|
1622
1646
|
attrs['feed_url'], attrs['id'] = dst_resource_id
|
|
1623
1647
|
|