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 CHANGED
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.19.dev0'
40
+ __version__ = '3.21'
41
41
 
42
42
  # isort: off
43
43
 
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', help="Only update feeds scheduled to be updated."
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',
@@ -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
- @property
193
- def retry_after(self) -> datetime | timedelta | None:
194
- """Parsed Retry-After header.
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
- None if parsing fails (instead of raising an exception).
197
- Always a timezone-aware datetime object;
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 = self.headers.get('retry-after')
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
- try:
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):
@@ -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 werkzeug.datastructures.MIMEAccept(values).to_header()
20
+ return MIMEAccept(values).to_header()
21
+
22
+
23
+ parse_cache_control_header = partial(
24
+ werkzeug.http.parse_cache_control_header, cls=ResponseCacheControl
25
+ )
@@ -29,8 +29,10 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
29
29
 
30
30
  """
31
31
 
32
- def __init__(self, path: str, timeout: float | None = None):
33
- super().__init__(path, timeout)
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:
@@ -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
- return soup.get_text(separator=' ')
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.replace_with('\n')
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])
@@ -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, path: str, setup_db: _DBFunction = lambda _: None, **kwargs: Any
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 = _LocalConnectionFactoryState()
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 = self._local.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
- # TODO: remove needless cast (needed after mypy 1.16.0 update)
476
- db = cast(sqlite3.Connection, sqlite3.connect(self.path, **self.kwargs))
477
- self._local.db = db
478
- self._local.call_count = 0
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
- threading.current_thread(), self._close, db
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(db: sqlite3.Connection, timeout: float = 0) -> None:
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 [':memory:', '']
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
- http_info = result.http_info
257
- if http_info and http_info.status in (429, 503) and http_info.retry_after:
258
- if isinstance(http_info.retry_after, datetime):
259
- retry_after = http_info.retry_after.astimezone(timezone.utc)
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
- update_after = next_update_after(retry_after, **self.config)
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 = False,
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 the feeds that have updates enabled.
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 = False,
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 the feeds that have updates enabled.
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, updates_enabled=None, _call_feeds_update_hooks=False
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