reader 3.17__py3-none-any.whl → 3.19.dev0__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.17'
40
+ __version__ = '3.19.dev0'
41
41
 
42
42
  # isort: off
43
43
 
@@ -58,6 +58,9 @@ from .types import (
58
58
  FeedCounts as FeedCounts,
59
59
  EntryCounts as EntryCounts,
60
60
  EntrySearchCounts as EntrySearchCounts,
61
+ FeedSort as FeedSort,
62
+ EntrySort as EntrySort,
63
+ EntrySearchSort as EntrySearchSort,
61
64
  UpdateResult as UpdateResult,
62
65
  UpdatedFeed as UpdatedFeed,
63
66
  EntryUpdateStatus as EntryUpdateStatus,
@@ -239,13 +239,9 @@ class NotModified(RetrieveError):
239
239
  _default_message = "not modified"
240
240
 
241
241
 
242
- @dataclass(frozen=True)
243
- class RetrieveResult(_namedtuple_compat, Generic[F, T, E]):
242
+ class RetrieveResult(NamedTuple, Generic[F, T, E]):
244
243
  """The result of retrieving a feed, regardless of the outcome."""
245
244
 
246
- # should be a NamedTuple, but the typing one became generic only in 3.11,
247
- # and we don't want to depend on typing_extensions at runtime
248
-
249
245
  #: The feed (a :class:`FeedArgument`, usually a :class:`FeedForUpdate`).
250
246
  feed: F
251
247
 
@@ -339,8 +335,7 @@ class FeedForUpdateRetrieverType(RetrieverType[T_co], Protocol): # pragma: no c
339
335
  """
340
336
 
341
337
 
342
- @dataclass(frozen=True)
343
- class ParseResult(_namedtuple_compat, Generic[F, E]):
338
+ class ParseResult(NamedTuple, Generic[F, E]):
344
339
  """The result of retrieving and parsing a feed, regardless of the outcome."""
345
340
 
346
341
  #: The feed (a :class:`FeedArgument`, usually a :class:`.FeedForUpdate`).
reader/_parser/_lazy.py CHANGED
@@ -259,8 +259,7 @@ class Parser:
259
259
  Takes one argument and does not raise exceptions.
260
260
 
261
261
  """
262
- feed = result.feed
263
- context = result.value
262
+ feed, context = result
264
263
 
265
264
  http_info = None
266
265
  value: ParsedFeed | None | Exception
@@ -4,6 +4,7 @@ from collections.abc import Sequence
4
4
  from dataclasses import dataclass
5
5
  from dataclasses import field
6
6
  from typing import Any
7
+ from typing import Self
7
8
  from typing import TYPE_CHECKING
8
9
  from typing import TypeVar
9
10
 
@@ -14,8 +15,6 @@ from . import TimeoutType
14
15
 
15
16
 
16
17
  if TYPE_CHECKING: # pragma: no cover
17
- from typing_extensions import Self
18
-
19
18
  from . import Headers
20
19
  from . import RequestHook
21
20
  from . import ResponseHook
@@ -73,7 +73,6 @@ def save_output(reader, now, config, command_path, output):
73
73
 
74
74
  try:
75
75
  old_content = reader.get_entry((feed_url, entry_id)).content[0].value
76
- reader.delete_entry((feed_url, entry_id))
77
76
  except EntryNotFoundError:
78
77
  old_content = ''
79
78
 
@@ -91,7 +90,8 @@ def save_output(reader, now, config, command_path, output):
91
90
  title=title,
92
91
  updated=now,
93
92
  content=[dict(type='text/plain', value=''.join(parts))],
94
- )
93
+ ),
94
+ overwrite=True,
95
95
  )
96
96
  reader.mark_entry_as_read((feed_url, entry_id))
97
97
 
@@ -38,6 +38,7 @@ from flask import request
38
38
  from flask import Response
39
39
  from flask import stream_with_context
40
40
  from flask import url_for
41
+ from jinja2.filters import do_striptags as striptags
41
42
 
42
43
 
43
44
  blueprint = Blueprint('enclosure_tags', __name__)
@@ -146,12 +147,13 @@ def enclosure_tags_filter(enclosure, entry, feed_tags):
146
147
 
147
148
  args = {'url': enclosure.href, 'filename': filename}
148
149
  if entry.title:
149
- args['title'] = entry.title
150
+ args['title'] = striptags(entry.title)
150
151
  if album := (entry.feed.user_title or entry.feed.title):
152
+ album = striptags(album)
151
153
  args['album'] = album
152
154
  args['artist'] = album
153
155
  elif artist := (entry.author or entry.feed.author):
154
- args['artist'] = artist
156
+ args['artist'] = striptags(artist)
155
157
 
156
158
  for tag in feed_tags:
157
159
  if 'podcast' in tag.lower():
reader/_storage/_base.py CHANGED
@@ -41,7 +41,7 @@ if debug: # pragma: no cover
41
41
  stmt = data.pop('stmt', None)
42
42
  print('STORAGE_DEBUG', json.dumps(data), file=sys.stderr)
43
43
  if stmt:
44
- print(stmt)
44
+ print(stmt, file=sys.stderr)
45
45
 
46
46
 
47
47
  wrap_exceptions = partial(_sqlite_utils.wrap_exceptions, StorageError)
@@ -55,7 +55,7 @@ class EntriesMixin(StorageBase):
55
55
  def get_entries(
56
56
  self,
57
57
  filter: EntryFilter = EntryFilter(), # noqa: B008
58
- sort: EntrySort = 'recent',
58
+ sort: EntrySort = EntrySort.RECENT,
59
59
  limit: int | None = None,
60
60
  starting_after: tuple[str, str] | None = None,
61
61
  ) -> Iterable[Entry]:
@@ -64,7 +64,7 @@ class EntriesMixin(StorageBase):
64
64
  partial(get_entries_query, filter, sort),
65
65
  row_factory=entry_factory,
66
66
  )
67
- if sort != 'random':
67
+ if sort != EntrySort.RANDOM:
68
68
  last = self.get_entry_last(sort, starting_after) if starting_after else None
69
69
  return paginated_query(limit, last)
70
70
  else:
@@ -571,7 +571,7 @@ RECENT_SORT_KEY = SortKey(
571
571
  desc=True,
572
572
  )
573
573
 
574
- ENTRY_SORT_KEYS = {'recent': RECENT_SORT_KEY}
574
+ ENTRY_SORT_KEYS = {EntrySort.RECENT: RECENT_SORT_KEY}
575
575
 
576
576
 
577
577
  def entries_recent_sort(
@@ -597,9 +597,9 @@ def entries_random_sort(query: Query) -> None:
597
597
  query.ORDER_BY("random()")
598
598
 
599
599
 
600
- ENTRIES_SORT: dict[str, Callable[[Query], None]] = {
601
- 'recent': entries_recent_sort,
602
- 'random': entries_random_sort,
600
+ ENTRIES_SORT: dict[EntrySort, Callable[[Query], None]] = {
601
+ EntrySort.RECENT: entries_recent_sort,
602
+ EntrySort.RANDOM: entries_random_sort,
603
603
  }
604
604
 
605
605
 
reader/_storage/_feeds.py CHANGED
@@ -107,7 +107,7 @@ class FeedsMixin(StorageBase):
107
107
  def get_feeds(
108
108
  self,
109
109
  filter: FeedFilter = FeedFilter(), # noqa: B008
110
- sort: FeedSort = 'title',
110
+ sort: FeedSort = FeedSort.TITLE,
111
111
  limit: int | None = None,
112
112
  starting_after: str | None = None,
113
113
  ) -> Iterable[Feed]:
@@ -335,8 +335,10 @@ def feed_factory(row: tuple[Any, ...]) -> Feed:
335
335
  FEED_SORT_KEYS = {
336
336
  # values must be non-null, see #203 for details.
337
337
  # url at the end makes the order deterministic.
338
- 'title': SortKey(("kinda_title", "lower(coalesce(user_title, title, ''))"), "url"),
339
- 'added': SortKey("added", "url", desc=True),
338
+ FeedSort.TITLE: SortKey(
339
+ ("kinda_title", "lower(coalesce(user_title, title, ''))"), "url"
340
+ ),
341
+ FeedSort.ADDED: SortKey("added", "url", desc=True),
340
342
  }
341
343
 
342
344
 
@@ -358,7 +360,7 @@ def feed_filter(query: Query, filter: FeedFilter) -> dict[str, Any]:
358
360
  if new is not None:
359
361
  query.WHERE(f"last_retrieved is {'' if new else 'NOT'} NULL")
360
362
  if update_after is not None:
361
- query.WHERE("update_after is NULL or update_after <= :update_after")
363
+ query.WHERE("(update_after is NULL or update_after <= :update_after)")
362
364
  context.update(update_after=adapt_datetime(update_after))
363
365
 
364
366
  return context
@@ -28,8 +28,9 @@ from ..exceptions import SearchError
28
28
  from ..exceptions import SearchNotEnabledError
29
29
  from ..types import EntrySearchCounts
30
30
  from ..types import EntrySearchResult
31
+ from ..types import EntrySearchSort
32
+ from ..types import EntrySort
31
33
  from ..types import HighlightedString
32
- from ..types import SearchSortOrder
33
34
  from . import _entries
34
35
  from . import _html_utils
35
36
  from . import _sqlite_utils
@@ -425,7 +426,7 @@ class Search:
425
426
  self,
426
427
  query: str,
427
428
  filter: EntryFilter = EntryFilter(), # noqa: B008
428
- sort: SearchSortOrder = 'relevant',
429
+ sort: EntrySearchSort = EntrySearchSort.RELEVANT,
429
430
  limit: int | None = None,
430
431
  starting_after: tuple[str, str] | None = None,
431
432
  ) -> Iterable[EntrySearchResult]:
@@ -457,13 +458,13 @@ class Search:
457
458
  )
458
459
 
459
460
  # TODO: dupe of at least Storage.get_entries(), maybe deduplicate
460
- if sort != 'random':
461
+ if sort != EntrySearchSort.RANDOM:
461
462
  last = None
462
463
  if starting_after:
463
- if sort == 'relevant':
464
+ if sort == EntrySearchSort.RELEVANT:
464
465
  last = self.search_entry_last(query, starting_after)
465
- elif sort == 'recent':
466
- last = self.storage.get_entry_last(sort, starting_after)
466
+ elif sort == EntrySearchSort.RECENT:
467
+ last = self.storage.get_entry_last(EntrySort.RECENT, starting_after)
467
468
  else:
468
469
  assert False, "shouldn't get here" # noqa: B011; # pragma: no cover
469
470
 
@@ -527,7 +528,7 @@ class Search:
527
528
 
528
529
 
529
530
  def make_search_entries_query(
530
- filter: EntryFilter, sort: SearchSortOrder
531
+ filter: EntryFilter, sort: EntrySearchSort
531
532
  ) -> tuple[Query, dict[str, Any]]:
532
533
  search = (
533
534
  Query()
@@ -585,12 +586,12 @@ def relevant_sort(query: Query) -> None:
585
586
  )
586
587
 
587
588
 
588
- SEARCH_ENTRIES_SORT: dict[str, Callable[[Query], None]] = {
589
- 'relevant': relevant_sort,
590
- 'recent': partial(
589
+ SEARCH_ENTRIES_SORT: dict[EntrySearchSort, Callable[[Query], None]] = {
590
+ EntrySearchSort.RELEVANT: relevant_sort,
591
+ EntrySearchSort.RECENT: partial(
591
592
  _entries.entries_recent_sort, keyword='HAVING', id_prefix='search._'
592
593
  ),
593
- 'random': _entries.entries_random_sort,
594
+ EntrySearchSort.RANDOM: _entries.entries_random_sort,
594
595
  }
595
596
 
596
597
 
@@ -34,6 +34,7 @@ from collections.abc import Iterable
34
34
  from dataclasses import dataclass
35
35
  from typing import Any
36
36
  from typing import NamedTuple
37
+ from typing import Self
37
38
  from typing import TYPE_CHECKING
38
39
  from typing import TypeVar
39
40
  from typing import Union
@@ -42,8 +43,6 @@ from typing import Union
42
43
  if TYPE_CHECKING: # pragma: no cover
43
44
  import sqlite3
44
45
 
45
- from typing_extensions import Self
46
-
47
46
 
48
47
  _T = TypeVar('_T')
49
48
  _U = TypeVar('_U')
@@ -197,7 +196,7 @@ class BaseQuery:
197
196
 
198
197
  yield '\n'
199
198
 
200
- _indent = functools.partial(textwrap.indent, prefix=' ')
199
+ _indent = staticmethod(functools.partial(textwrap.indent, prefix=' '))
201
200
 
202
201
 
203
202
  if TYPE_CHECKING: # pragma: no cover
@@ -23,6 +23,7 @@ from dataclasses import dataclass
23
23
  from datetime import datetime
24
24
  from datetime import timezone
25
25
  from typing import Any
26
+ from typing import cast
26
27
  from typing import no_type_check
27
28
  from typing import TypeVar
28
29
 
@@ -95,8 +96,17 @@ def wrap_exceptions(
95
96
  can only be fixed by the user (e.g. access permission denied), and aren't
96
97
  domain-related (those should have other custom exceptions).
97
98
 
98
- This is an imprecise science, since the DB-API exceptions are somewhat
99
- fuzzy in their meaning and we can't access the SQLite result code.
99
+ We intentionally rely on the error message, because:
100
+
101
+ * the DB-API exceptions are somewhat fuzzy in their meaning
102
+ * we can't access the SQLite result code prior to Python 3.11
103
+ * even having the primary result code (added in 3.11):
104
+ * it's generic SQLITE_ERROR for a lot of errors we care about
105
+ ("no such ...", "... already exists", invalid FTS5 queries)
106
+ * we need the table name so we don't accidentally shadow bugs
107
+ * we need the extended result code for the same reason
108
+ (e.g. SQLITE_CONSTRAINT vs SQLITE_CONSTRAINT_FOREIGNKEY)
109
+ * ProgrammingError usually does not have an error code
100
110
 
101
111
  Full discussion at https://github.com/lemon24/reader/issues/21
102
112
 
@@ -462,8 +472,9 @@ class LocalConnectionFactory:
462
472
  if not self._local.is_creating_thread:
463
473
  self._other_threads = True
464
474
 
465
- self._local.db = db = sqlite3.connect(self.path, **self.kwargs)
466
- assert db is not None, "for mypy"
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
467
478
  self._local.call_count = 0
468
479
 
469
480
  try:
reader/_types.py CHANGED
@@ -20,7 +20,7 @@ from typing import NamedTuple
20
20
  from typing import overload
21
21
  from typing import Protocol
22
22
  from typing import runtime_checkable
23
- from typing import TYPE_CHECKING
23
+ from typing import Self
24
24
  from typing import TypeVar
25
25
  from typing import Union
26
26
 
@@ -40,6 +40,7 @@ from .types import EntryCounts
40
40
  from .types import EntryInput
41
41
  from .types import EntrySearchCounts
42
42
  from .types import EntrySearchResult
43
+ from .types import EntrySearchSort
43
44
  from .types import EntrySort
44
45
  from .types import EntrySource
45
46
  from .types import ExceptionInfo
@@ -51,15 +52,10 @@ from .types import JSONType
51
52
  from .types import MISSING
52
53
  from .types import MissingType
53
54
  from .types import ResourceId
54
- from .types import SearchSortOrder
55
55
  from .types import TagFilterInput
56
56
  from .types import TristateFilterInput
57
57
 
58
58
 
59
- if TYPE_CHECKING: # pragma: no cover
60
- from typing_extensions import Self
61
-
62
-
63
59
  log = logging.getLogger("reader")
64
60
 
65
61
  # Private API
@@ -1268,7 +1264,7 @@ class SearchType(Protocol): # pragma: no cover
1268
1264
  query: str,
1269
1265
  /,
1270
1266
  filter: EntryFilter,
1271
- sort: SearchSortOrder,
1267
+ sort: EntrySearchSort,
1272
1268
  limit: int | None,
1273
1269
  starting_after: tuple[str, str] | None,
1274
1270
  ) -> Iterable[EntrySearchResult]:
reader/_update.py CHANGED
@@ -430,8 +430,7 @@ class Pipeline:
430
430
  config: UpdateConfig,
431
431
  result: ParseResult[FeedForUpdate, ParseError],
432
432
  ) -> tuple[str, UpdatedFeed | None | Exception]:
433
- feed = result.feed
434
- value = result.value
433
+ feed, value, _ = result
435
434
 
436
435
  # TODO: don't duplicate code from update()
437
436
  # TODO: the feed tag value should come from get_feeds_for_update()
reader/core.py CHANGED
@@ -15,6 +15,7 @@ from types import MappingProxyType
15
15
  from typing import Any
16
16
  from typing import Literal
17
17
  from typing import overload
18
+ from typing import Self
18
19
  from typing import TYPE_CHECKING
19
20
  from typing import TypeVar
20
21
 
@@ -58,6 +59,7 @@ from .types import EntryCounts
58
59
  from .types import EntryInput
59
60
  from .types import EntrySearchCounts
60
61
  from .types import EntrySearchResult
62
+ from .types import EntrySearchSort
61
63
  from .types import EntrySort
62
64
  from .types import EntryUpdateStatus
63
65
  from .types import Feed
@@ -68,7 +70,6 @@ from .types import JSONType
68
70
  from .types import MISSING
69
71
  from .types import MissingType
70
72
  from .types import ResourceInput
71
- from .types import SearchSortOrder
72
73
  from .types import TagFilterInput
73
74
  from .types import TristateFilterInput
74
75
  from .types import UpdatedFeed
@@ -76,8 +77,6 @@ from .types import UpdateResult
76
77
 
77
78
 
78
79
  if TYPE_CHECKING: # pragma: no cover
79
- from typing_extensions import Self
80
-
81
80
  from ._parser import Parser
82
81
 
83
82
 
@@ -557,7 +556,7 @@ class Reader:
557
556
  updates_enabled: bool | None = None,
558
557
  new: bool | None = None,
559
558
  scheduled: bool = False,
560
- sort: FeedSort = 'title',
559
+ sort: FeedSort = FeedSort.TITLE,
561
560
  limit: int | None = None,
562
561
  starting_after: FeedInput | None = None,
563
562
  ) -> Iterable[Feed]:
@@ -576,9 +575,8 @@ class Reader:
576
575
  / have been updated before.
577
576
  scheduled (bool):
578
577
  Only return feeds scheduled to be updated.
579
- sort (str): How to order feeds; one of ``'title'`` (by
580
- :attr:`~Feed.user_title` or :attr:`~Feed.title`, case
581
- insensitive; default), or ``'added'`` (last added first).
578
+ sort (FeedSort):
579
+ How to order feeds; see :class:`FeedSort` for details.
582
580
  limit (int or None): A limit on the number of feeds to be returned;
583
581
  by default, all feeds are returned.
584
582
  starting_after (str or tuple(str) or Feed or None):
@@ -617,9 +615,7 @@ class Reader:
617
615
  filter = FeedFilter.from_args(
618
616
  self._now(), feed, tags, broken, updates_enabled, new, scheduled
619
617
  )
620
-
621
- if sort not in ('title', 'added'):
622
- raise ValueError("sort should be one of ('title', 'added')")
618
+ sort = FeedSort(sort)
623
619
 
624
620
  if limit is not None:
625
621
  if not isinstance(limit, numbers.Integral) or limit < 1:
@@ -1095,43 +1091,12 @@ class Reader:
1095
1091
  source: FeedInput | None = None,
1096
1092
  tags: TagFilterInput = None,
1097
1093
  feed_tags: TagFilterInput = None,
1098
- sort: EntrySort = 'recent',
1094
+ sort: EntrySort = EntrySort.RECENT,
1099
1095
  limit: int | None = None,
1100
1096
  starting_after: EntryInput | None = None,
1101
1097
  ) -> Iterable[Entry]:
1102
1098
  """Get all or some of the entries.
1103
1099
 
1104
- Entries are sorted according to ``sort``. Possible values:
1105
-
1106
- ``'recent'``
1107
-
1108
- Most recent first. That is:
1109
-
1110
- * by published date for entries imported on the first update
1111
- (if an entry does not have :attr:`~Entry.published`,
1112
- :attr:`~Entry.updated` is used)
1113
- * by added date for entries imported after that
1114
-
1115
- This is to make sure newly imported entries appear at the top
1116
- regardless of when the feed says they were published,
1117
- while not having all the old entries at the top for new feeds.
1118
-
1119
- .. note::
1120
-
1121
- The algorithm for "recent" is a heuristic and may change over time.
1122
-
1123
- .. versionchanged:: 3.1
1124
- Sort entries by added date most of the time,
1125
- with the exception of those imported on the first update.
1126
- Previously, entries would be sorted by added
1127
- only if they were published less than 7 days ago.
1128
-
1129
- ``'random'``
1130
-
1131
- Random order (shuffled). At at most 256 entries will be returned.
1132
-
1133
- .. versionadded:: 1.2
1134
-
1135
1100
  Args:
1136
1101
  feed (str or tuple(str) or Feed or None):
1137
1102
  Only return the entries for this feed.
@@ -1152,13 +1117,13 @@ class Reader:
1152
1117
  feed_tags (None or bool or list(str or bool or list(str or bool))):
1153
1118
  Only return entries from feeds matching these tags;
1154
1119
  see :data:`~reader.types.TagFilterInput` for details.
1155
- sort (str): How to order entries; one of ``'recent'`` (default)
1156
- or ``'random'``.
1120
+ sort (EntrySort):
1121
+ How to order entries; see :class:`EntrySort` for details.
1157
1122
  limit (int or None): A limit on the number of entries to be returned;
1158
1123
  by default, all entries are returned.
1159
1124
  starting_after (tuple(str, str) or Entry or None):
1160
1125
  Return entries after this entry; a cursor for use in pagination.
1161
- Using ``starting_after`` with ``sort='random'`` is not supported.
1126
+ Using ``starting_after`` with ``sort=RANDOM`` is not supported.
1162
1127
 
1163
1128
  Yields:
1164
1129
  :class:`Entry`: Sorted according to ``sort``.
@@ -1193,17 +1158,15 @@ class Reader:
1193
1158
  filter = EntryFilter.from_args(
1194
1159
  feed, entry, read, important, has_enclosures, source, tags, feed_tags
1195
1160
  )
1196
-
1197
- if sort not in ('recent', 'random'):
1198
- raise ValueError("sort should be one of ('recent', 'random')")
1161
+ sort = EntrySort(sort)
1199
1162
 
1200
1163
  if limit is not None:
1201
1164
  if not isinstance(limit, numbers.Integral) or limit < 1:
1202
1165
  raise ValueError("limit should be a positive integer")
1203
1166
  starting_after = _entry_argument(starting_after) if starting_after else None
1204
1167
 
1205
- if starting_after and sort == 'random':
1206
- raise ValueError("using starting_after with sort='random' not supported")
1168
+ if starting_after and sort == EntrySort.RANDOM:
1169
+ raise ValueError("using starting_after with sort=RANDOM not supported")
1207
1170
 
1208
1171
  return self._storage.get_entries(filter, sort, limit, starting_after)
1209
1172
 
@@ -1495,7 +1458,7 @@ class Reader:
1495
1458
  """
1496
1459
  return self.set_entry_important(entry, False)
1497
1460
 
1498
- def add_entry(self, entry: Any, /) -> None:
1461
+ def add_entry(self, entry: Any, /, *, overwrite: bool = False) -> None:
1499
1462
  """Add a new entry to an existing feed.
1500
1463
 
1501
1464
  ``entry`` can be any :class:`Entry`-like object,
@@ -1537,6 +1500,9 @@ class Reader:
1537
1500
 
1538
1501
  Args:
1539
1502
  entry (Entry or dict): An entry-like object or equivalent mapping.
1503
+ overwrite (bool):
1504
+ If true and the entry already exists,
1505
+ overwrite it instead of raising :exc:`EntryExistsError`.
1540
1506
 
1541
1507
  Raises:
1542
1508
  EntryExistsError: If an entry with the same id already exists.
@@ -1551,6 +1517,9 @@ class Reader:
1551
1517
  .. versionchanged:: 3.16
1552
1518
  Allow setting :attr:`~Entry.source`.
1553
1519
 
1520
+ .. versionadded:: 3.18
1521
+ The ``overwrite`` argument.
1522
+
1554
1523
  """
1555
1524
 
1556
1525
  # `entry` is of type Union[EntryDataLikeProtocol, EntryDataTypedDict],
@@ -1570,9 +1539,19 @@ class Reader:
1570
1539
  added_by='user',
1571
1540
  )
1572
1541
 
1573
- self._storage.add_entry(intent)
1542
+ status = EntryUpdateStatus.NEW
1543
+ if overwrite:
1544
+ (old_entry,) = self._storage.get_entries_for_update(
1545
+ [entry_data.resource_id]
1546
+ )
1547
+ if old_entry:
1548
+ status = EntryUpdateStatus.MODIFIED
1549
+ self._storage.add_or_update_entries([intent])
1550
+ else:
1551
+ self._storage.add_entry(intent)
1552
+
1574
1553
  for entry_hook in self.after_entry_update_hooks:
1575
- entry_hook(self, intent.entry, EntryUpdateStatus.NEW)
1554
+ entry_hook(self, intent.entry, status)
1576
1555
 
1577
1556
  def delete_entry(self, entry: EntryInput, /, missing_ok: bool = False) -> None:
1578
1557
  """Delete an entry.
@@ -1732,37 +1711,12 @@ class Reader:
1732
1711
  source: FeedInput | None = None,
1733
1712
  tags: TagFilterInput = None,
1734
1713
  feed_tags: TagFilterInput = None,
1735
- sort: SearchSortOrder = 'relevant',
1714
+ sort: EntrySearchSort = EntrySearchSort.RELEVANT,
1736
1715
  limit: int | None = None,
1737
1716
  starting_after: EntryInput | None = None,
1738
1717
  ) -> Iterable[EntrySearchResult]:
1739
1718
  """Get entries matching a full-text search query.
1740
1719
 
1741
- Entries are sorted according to ``sort``. Possible values:
1742
-
1743
- ``'relevant'``
1744
-
1745
- Most relevant first.
1746
-
1747
- ``'recent'``
1748
-
1749
- Most recent first. See :meth:`~Reader.get_entries()`
1750
- for details on what *recent* means.
1751
-
1752
- .. versionadded:: 1.4
1753
-
1754
- .. versionchanged:: 3.1
1755
- Sort entries by added date most of the time,
1756
- with the exception of those imported on the first update.
1757
- Previously, entries would be sorted by added
1758
- only if they were published less than 7 days ago.
1759
-
1760
- ``'random'``
1761
-
1762
- Random order (shuffled). At at most 256 entries will be returned.
1763
-
1764
- .. versionadded:: 1.10
1765
-
1766
1720
  Note:
1767
1721
  The query syntax is dependent on the search provider.
1768
1722
 
@@ -1815,13 +1769,13 @@ class Reader:
1815
1769
  feed_tags (None or bool or list(str or bool or list(str or bool))):
1816
1770
  Only search entries from feeds matching these tags;
1817
1771
  see :data:`~reader.types.TagFilterInput` for details.
1818
- sort (str): How to order results; one of ``'relevant'`` (default),
1819
- ``'recent'``, or ``'random'``.
1772
+ sort (EntrySearchSort):
1773
+ How to order results; see :class:`EntrySearchSort` for details.
1820
1774
  limit (int or None): A limit on the number of results to be returned;
1821
1775
  by default, all results are returned.
1822
1776
  starting_after (tuple(str, str) or EntrySearchResult or None):
1823
1777
  Return results after this result; a cursor for use in pagination.
1824
- Using ``starting_after`` with ``sort='random'`` is not supported.
1778
+ Using ``starting_after`` with ``sort=RANDOM`` is not supported.
1825
1779
 
1826
1780
  Yields:
1827
1781
  :class:`EntrySearchResult`: Sorted according to ``sort``.
@@ -1858,17 +1812,15 @@ class Reader:
1858
1812
  filter = EntryFilter.from_args(
1859
1813
  feed, entry, read, important, has_enclosures, source, tags, feed_tags
1860
1814
  )
1861
-
1862
- if sort not in ('relevant', 'recent', 'random'):
1863
- raise ValueError("sort should be one of ('relevant', 'recent', 'random')")
1815
+ sort = EntrySearchSort(sort)
1864
1816
 
1865
1817
  if limit is not None:
1866
1818
  if not isinstance(limit, numbers.Integral) or limit < 1:
1867
1819
  raise ValueError("limit should be a positive integer")
1868
1820
  starting_after = _entry_argument(starting_after) if starting_after else None
1869
1821
 
1870
- if starting_after and sort == 'random':
1871
- raise ValueError("using starting_after with sort='random' not supported")
1822
+ if starting_after and sort == EntrySearchSort.RANDOM:
1823
+ raise ValueError("using starting_after with sort=RANDOM not supported")
1872
1824
 
1873
1825
  return self._search.search_entries(query, filter, sort, limit, starting_after)
1874
1826
 
reader/exceptions.py CHANGED
@@ -1,10 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Iterable
4
3
  from collections.abc import Sequence
5
4
  from functools import cached_property
6
- from traceback import format_exception
7
5
  from typing import Any
6
+ from typing import TypeVar
8
7
 
9
8
 
10
9
  class _FancyExceptionBase(Exception):
@@ -63,79 +62,6 @@ class _FancyExceptionBase(Exception):
63
62
  return ': '.join(filter(None, parts))
64
63
 
65
64
 
66
- class _ExceptionGroup(Exception): # pragma: no cover
67
- """ExceptionGroup shim for Python 3.10.
68
-
69
- Always includes a traceback in str(exc),
70
- so no traceback.* monkeypatching is needed,
71
- at the expense of the traceback not being in the right place,
72
- and a slightly non-standard traceback.
73
-
74
- Avoids dependency on https://pypi.org/project/exceptiongroup/
75
-
76
- >>> raise _ExceptionGroup('message', [
77
- ... NameError('one'),
78
- ... _ExceptionGroup('another', [
79
- ... AttributeError('two'),
80
- ... ]),
81
- ... ])
82
- Traceback (most recent call last):
83
- File "<stdin>", line 1, in <module>
84
- reader.exceptions._ExceptionGroup: message (2 sub-exceptions)
85
- +---------------- 1 -----------------
86
- | NameError: one
87
- +---------------- 2 -----------------
88
- | reader.exceptions._ExceptionGroup: another (1 sub-exception)
89
- | +---------------- 1 -----------------
90
- | | AttributeError: two
91
- | +------------------------------------
92
- +------------------------------------
93
-
94
- TODO: Remove this once we drop Python 3.10.
95
-
96
- """
97
-
98
- def __init__(self, msg: str, excs: Sequence[Exception], /):
99
- if not isinstance(msg, str): # pragma: no cover
100
- raise TypeError(f"first argument must be str, not {type(msg).__name__}")
101
- excs = tuple(excs)
102
- if not excs: # pragma: no cover
103
- raise ValueError("second argument must be a non-empty sequence")
104
- for i, e in enumerate(excs): # pragma: no cover
105
- if not isinstance(e, Exception):
106
- raise ValueError(f"item {i} of second argument is not an exception")
107
- self._message = msg
108
- self._exceptions = excs
109
-
110
- @property
111
- def message(self) -> str:
112
- return self._message
113
-
114
- @property
115
- def exceptions(self) -> tuple[Exception, ...]:
116
- return self._exceptions
117
-
118
- def _format_lines(self) -> Iterable[str]:
119
- count = len(self.exceptions)
120
- s = 's' if count != 1 else ''
121
- yield f"{self.message} ({count} sub-exception{s})\n"
122
- for i, exc in enumerate(self.exceptions, 1):
123
- yield f"+{f' {i} '.center(36, '-')}\n"
124
- for line in format_exception(exc):
125
- for subline in line.rstrip().splitlines():
126
- yield f"| {subline}\n"
127
- yield f"+{'-' * 36}\n"
128
-
129
- def __str__(self) -> str:
130
- return ''.join(self._format_lines()).rstrip()
131
-
132
-
133
- try:
134
- ExceptionGroup # type: ignore[used-before-def,unused-ignore]
135
- except NameError: # pragma: no cover
136
- ExceptionGroup = _ExceptionGroup
137
-
138
-
139
65
  class ReaderError(_FancyExceptionBase):
140
66
  """Base for all public exceptions."""
141
67
 
@@ -351,21 +277,29 @@ class SingleUpdateHookError(UpdateHookError):
351
277
  return ': '.join(parts)
352
278
 
353
279
 
354
- class UpdateHookErrorGroup(ExceptionGroup, UpdateHookError):
280
+ _UpdateHookErrorT = TypeVar('_UpdateHookErrorT', bound=UpdateHookError)
281
+
282
+
283
+ class UpdateHookErrorGroup(ExceptionGroup[_UpdateHookErrorT], UpdateHookError):
355
284
  r"""A (possibly nested) :exc:`ExceptionGroup` of :exc:`UpdateHookError`\s.
356
285
 
357
286
  .. versionadded:: 3.8
358
287
 
359
288
  """
360
289
 
361
- def __init__(self, msg: str, excs: Sequence[Exception], /):
290
+ def __init__(self, msg: str, excs: Sequence[_UpdateHookErrorT], /):
362
291
  super().__init__(msg, excs)
363
292
  for e in self.exceptions:
364
293
  if not isinstance(e, UpdateHookError):
365
- msg = f"cannot nest {type(e).__name__} in an UpdateHookErrorGroup"
366
- raise TypeError(msg)
367
-
368
- def derive(self, excs: Sequence[Exception]) -> UpdateHookErrorGroup:
294
+ raise TypeError(
295
+ "UpdateHookErrorGroup can only contain UpdateHookError; "
296
+ f"got {type(e).__name__}"
297
+ )
298
+
299
+ # https://github.com/python/typeshed/issues/9922
300
+ def derive( # type: ignore[override]
301
+ self, excs: Sequence[_UpdateHookErrorT], /
302
+ ) -> ExceptionGroup[_UpdateHookErrorT]:
369
303
  return UpdateHookErrorGroup(self.message, excs)
370
304
 
371
305
 
reader/types.py CHANGED
@@ -18,7 +18,7 @@ from typing import Literal
18
18
  from typing import NamedTuple
19
19
  from typing import overload
20
20
  from typing import Protocol
21
- from typing import TYPE_CHECKING
21
+ from typing import Self
22
22
  from typing import TypedDict
23
23
  from typing import Union
24
24
 
@@ -29,9 +29,6 @@ from reader.exceptions import UpdateError
29
29
  from reader._utils import MISSING as MISSING # isort: skip # noqa: F401
30
30
  from reader._utils import MissingType as MissingType # isort: skip # noqa: F401
31
31
 
32
- if TYPE_CHECKING: # pragma: no cover
33
- from typing_extensions import Self
34
-
35
32
 
36
33
  class _namedtuple_compat:
37
34
  """Add namedtuple-like methods to a dataclass."""
@@ -726,26 +723,81 @@ class EntryUpdateStatus(enum.Enum):
726
723
  MODIFIED = 'modified'
727
724
 
728
725
 
729
- # Semi-public API (typing support)
726
+ class FeedSort(enum.StrEnum):
727
+ """How to order feeds.
728
+
729
+ .. versionadded:: 3.18
730
+
731
+ """
732
+
733
+ #: By :attr:`~Feed.resolved_title`, case insensitive.
734
+ TITLE = 'title'
735
+
736
+ #: By :attr:`~Feed.added`, last added first.
737
+ ADDED = 'added'
738
+
739
+
740
+ class EntrySort(enum.StrEnum):
741
+ """How to order entries.
730
742
 
743
+ .. versionadded:: 3.18
744
+
745
+ """
746
+
747
+ #: Most recent first. That is:
748
+ #:
749
+ #: * by published date for entries imported on the first update
750
+ #: (if an entry does not have :attr:`~Entry.published`,
751
+ #: :attr:`~Entry.updated` is used)
752
+ #: * by added date for entries imported after that
753
+ #:
754
+ #: This is to make sure newly imported entries appear at the top
755
+ #: regardless of when the feed says they were published,
756
+ #: while not having all the old entries at the top for new feeds.
757
+ #:
758
+ #: .. note::
759
+ #:
760
+ #: The algorithm for "recent" is a heuristic and may change over time.
761
+ #:
762
+ #: .. versionchanged:: 3.1
763
+ #: Sort entries by added date most of the time,
764
+ #: with the exception of those imported on the first update.
765
+ #: Previously, entries would be sorted by added
766
+ #: only if they were published less than 7 days ago.
767
+ #:
768
+ RECENT = 'recent'
731
769
 
732
- # TODO: Could we use some kind of str-compatible enum here?
733
- #
734
- # Yes:
735
- #
736
- # class Order(enum.Enum):
737
- # TITLE = 'title'
738
- # ADDED = 'added'
739
- #
740
- # The public methods should then take Union[str, Order],
741
- # and use the value _only_ as Order(arg) for validation.
742
- # Having the arguments typed as Literals is silly anyway,
743
- # because we sometimes get them dynamically, e.g.
744
- # https://github.com/lemon24/reader/blob/1.18/src/reader/_app/__init__.py#L151
745
- #
746
- FeedSort = Literal['title', 'added']
747
- EntrySort = Literal['recent', 'random']
748
- SearchSortOrder = Literal['relevant', 'recent', 'random']
770
+ #: Random (shuffle). Return at most 256 entries.
771
+ #:
772
+ #: .. versionadded:: 1.2
773
+ #:
774
+ RANDOM = 'random'
775
+
776
+
777
+ class EntrySearchSort(enum.StrEnum):
778
+ """How to order entry search results.
779
+
780
+ .. versionadded:: 3.18
781
+
782
+ """
783
+
784
+ #: Most relevant first.
785
+ RELEVANT = 'relevant'
786
+
787
+ #: Most recent first. See :data:`EntrySort.RECENT` for details.
788
+ #:
789
+ #: .. versionadded:: 1.4
790
+ #:
791
+ RECENT = 'recent'
792
+
793
+ #: Random (shuffle). See :data:`EntrySort.RANDOM` for details.
794
+ #:
795
+ #: .. versionadded:: 1.10
796
+ #:
797
+ RANDOM = 'random'
798
+
799
+
800
+ # Semi-public API (typing support)
749
801
 
750
802
 
751
803
  # https://github.com/python/typing/issues/182
@@ -1,13 +1,13 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: reader
3
- Version: 3.17
3
+ Version: 3.19.dev0
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
7
7
  Project-URL: Documentation, https://reader.readthedocs.io/
8
- Project-URL: Changes, https://reader.readthedocs.io/en/latest/changelog.html
9
- Project-URL: Source Code, https://github.com/lemon24/reader
10
- Project-URL: Issue tracker, https://github.com/lemon24/reader/issues
8
+ Project-URL: Repository, https://github.com/lemon24/reader
9
+ Project-URL: Issues, https://github.com/lemon24/reader/issues
10
+ Project-URL: Changelog, https://reader.readthedocs.io/en/latest/changelog.html
11
11
  Keywords: atom,cdf,feed,rdf,rss,json feed,web feed,podcast,feed reader,feed aggregator
12
12
  Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Environment :: Console
@@ -19,10 +19,10 @@ Classifier: Operating System :: OS Independent
19
19
  Classifier: Programming Language :: Python
20
20
  Classifier: Programming Language :: Python :: 3
21
21
  Classifier: Programming Language :: Python :: 3 :: Only
22
- Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
26
  Classifier: Programming Language :: Python :: Implementation :: CPython
27
27
  Classifier: Programming Language :: Python :: Implementation :: PyPy
28
28
  Classifier: Topic :: Internet
@@ -32,7 +32,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
32
32
  Classifier: Topic :: Software Development :: Libraries
33
33
  Classifier: Topic :: Utilities
34
34
  Classifier: Typing :: Typed
35
- Requires-Python: >=3.10
35
+ Requires-Python: >=3.11
36
36
  Description-Content-Type: text/x-rst
37
37
  License-File: LICENSE
38
38
  Requires-Dist: typing-extensions>=4
@@ -41,14 +41,14 @@ Requires-Dist: requests>=2.18
41
41
  Requires-Dist: werkzeug>2
42
42
  Requires-Dist: iso8601>=1
43
43
  Requires-Dist: beautifulsoup4>=4.5
44
- Provides-Extra: search
45
- Provides-Extra: readtime
44
+ Provides-Extra: all
45
+ Requires-Dist: reader[app,cli,unstable-plugins]; extra == "all"
46
46
  Provides-Extra: cli
47
47
  Requires-Dist: click>=7; extra == "cli"
48
48
  Requires-Dist: PyYAML; extra == "cli"
49
49
  Provides-Extra: app
50
50
  Requires-Dist: flask>=0.10; extra == "app"
51
- Requires-Dist: humanize!=4.7.*,>=4; extra == "app"
51
+ Requires-Dist: humanize>=4; extra == "app"
52
52
  Requires-Dist: PyYAML; extra == "app"
53
53
  Requires-Dist: WTForms; extra == "app"
54
54
  Requires-Dist: jinja2-fragments; extra == "app"
@@ -60,36 +60,7 @@ Requires-Dist: beautifulsoup4; extra == "unstable-plugins"
60
60
  Requires-Dist: blinker>=1.4; extra == "unstable-plugins"
61
61
  Requires-Dist: beautifulsoup4; extra == "unstable-plugins"
62
62
  Requires-Dist: tabulate; extra == "unstable-plugins"
63
- Provides-Extra: tests
64
- Requires-Dist: pytest>=4; extra == "tests"
65
- Requires-Dist: pytest-randomly; extra == "tests"
66
- Requires-Dist: pytest-subtests; extra == "tests"
67
- Requires-Dist: pytest-rerunfailures; extra == "tests"
68
- Requires-Dist: coverage; extra == "tests"
69
- Requires-Dist: pytest-cov; extra == "tests"
70
- Requires-Dist: requests-mock; extra == "tests"
71
- Requires-Dist: mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
72
- Requires-Dist: requests-wsgi-adapter; extra == "tests"
73
- Requires-Dist: lxml; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
74
- Requires-Dist: html5lib; extra == "tests"
75
- Requires-Dist: numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12") and extra == "tests"
76
- Requires-Dist: mypy; implementation_name != "pypy" and extra == "tests"
77
- Requires-Dist: types-requests; extra == "tests"
78
- Requires-Dist: types-beautifulsoup4; extra == "tests"
79
- Provides-Extra: docs
80
- Requires-Dist: sphinx; extra == "docs"
81
- Requires-Dist: sphinx-rtd-theme>=1.3.0rc1; extra == "docs"
82
- Requires-Dist: click>=7; extra == "docs"
83
- Requires-Dist: sphinx-click; extra == "docs"
84
- Requires-Dist: sphinx-hoverxref; extra == "docs"
85
- Requires-Dist: sphinxcontrib-log-cabinet; extra == "docs"
86
- Requires-Dist: setuptools; extra == "docs"
87
- Provides-Extra: dev
88
- Requires-Dist: reader[app,cli,docs,tests,unstable-plugins]; extra == "dev"
89
- Requires-Dist: tox; extra == "dev"
90
- Requires-Dist: pre-commit; extra == "dev"
91
- Requires-Dist: build; extra == "dev"
92
- Requires-Dist: twine; extra == "dev"
63
+ Dynamic: license-file
93
64
 
94
65
  .. begin-intro
95
66
 
@@ -1,15 +1,15 @@
1
- reader/__init__.py,sha256=-nQvYmaiSiWjBHVtC4y5dD5pgMLwBnEJdDD1FZmkXvs,2753
1
+ reader/__init__.py,sha256=_9p_wEikp-LHY2B-Vq2TGYZj5g12NJJpP2QzJqdUYj8,2852
2
2
  reader/__main__.py,sha256=gNiypbMW5x2B5Pn3xzMSVl7zldI824eEBg-WmzeD1vM,382
3
3
  reader/_cli.py,sha256=gCh_D166fLeMHS4vWWagCI9H-3EJ4WxhmJ96RwS8PEY,12371
4
4
  reader/_config.py,sha256=zQT1XMauermsQONVGuboeNjV7I03Aq8QsyuOBcaqAtM,4587
5
5
  reader/_hash_utils.py,sha256=X28ufDQrWcoXRm4x0JCLc4Cpoyi1UKb2geiwFebpTYM,2981
6
- reader/_types.py,sha256=6zo06Fgyu_X_QRzEje5f9BH3rW5L3jn14xMBvR8mvTI,41295
7
- reader/_update.py,sha256=PBvasVh8zzrA841K3g4Hnc2Sq8Tef98a5n9EKWHnqxw,18281
6
+ reader/_types.py,sha256=1WPcoVd6mUtkwon-fJ0z6pXLc6Y4d3tAIHlBsjDReDc,41207
7
+ reader/_update.py,sha256=mnZCy8C1VbQ-5phK6o7JPLZid8OW069pwRTnbxd5P5Q,18257
8
8
  reader/_utils.py,sha256=PwpqOSj1l3cG8k11z_9rO9gqVYp0CyYTorIIT856t-4,7740
9
- reader/core.py,sha256=TKZ198w4LimaUVbi2zXDM0CZlqSMYYY_meBY8SPRy9c,81494
10
- reader/exceptions.py,sha256=zlCzka9JNnaZTFxxoX65Q30hKIHE_9lafffFU774tIk,12294
9
+ reader/core.py,sha256=PCdzZUGRlmGoJfF7AfGHV7STwEWWrYAKCX6RT_fhuQg,79818
10
+ reader/exceptions.py,sha256=REAWBsWrV22RfaFWxqkGAbAwz-COkp-yjHWvIYk7w1E,9961
11
11
  reader/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- reader/types.py,sha256=f1lsEjvujbOPTAcDhWA-8rqnkinqslXdymadddeTRBg,33089
12
+ reader/types.py,sha256=Od_-4SxehrsLyjIfASrjA7AD_w_VeHtnLymmyJwCvag,34205
13
13
  reader/utils.py,sha256=UjIjdNgI7APluejh7nMkGN91sT4JFZxWrcYOAG3J52Y,2535
14
14
  reader/_app/__init__.py,sha256=nH1sfDP0gG5kBPaMCp-g55E0B2UZ9F-a8VQZHktVYHE,24192
15
15
  reader/_app/api_thing.py,sha256=y88NNCGzQe8jND-2IYLhCXtD9LhshTU266iPiQbb7lU,4330
@@ -32,34 +32,34 @@ reader/_app/v2/static/theme.js,sha256=aboAfzLaiaHqpsNV6YzjxZks5chCqx-cIpQHGgMdNb
32
32
  reader/_app/v2/templates/v2/entries.html,sha256=u0rbPrzLZPRTMkgSdbViHJhjiNJ_QeQlz4Z-ARDYR0U,7170
33
33
  reader/_app/v2/templates/v2/layout.html,sha256=5f_azghWBOrxSYkBF-k0QKyh9hFz88SjAKyjqzrnfgw,4247
34
34
  reader/_app/v2/templates/v2/macros.html,sha256=fU5HBfVCABKWuz8z6ml53yIjChxvkvX9r9EY6eaC9g0,1367
35
- reader/_parser/__init__.py,sha256=0RG5csG9ixaQt3-Yw1xEkOjgSynWVBCH0IoYzmo2_-o,13615
35
+ reader/_parser/__init__.py,sha256=j4k-nbFqgqv82zQJJYTB6c2Ev4fAvb6kwD8LT5OFzqo,13406
36
36
  reader/_parser/_http_utils.py,sha256=jy0kJMhbQagpNnZ_cF53pynd43VijsqIN7Q35fNs84c,406
37
- reader/_parser/_lazy.py,sha256=g8EbwSH7Svdzcn31WoXQziRrin9Y5w5rFibRVlxiNvw,18585
37
+ reader/_parser/_lazy.py,sha256=yLLX-8bYXBX2cxzq0G8a1KHKsL1_1kWk9UX_OM_WYts,18558
38
38
  reader/_parser/_url_utils.py,sha256=gpDQl2MDM_VCcHGjrIZFyGrjU9JrzAKrMvKfwp2iOWo,4470
39
39
  reader/_parser/feedparser.py,sha256=2oVQGKPGNNBvRF5QlLPQXtKPiGzi_WHjDYxjhsM-1Eg,6288
40
40
  reader/_parser/file.py,sha256=M-iqeoQkcFpAehP4sTNzoyjFGwxLZZO04Iz5ABah5lc,1501
41
41
  reader/_parser/http.py,sha256=H1UVvGPLRFIo2fBXvw_-5DbHef8QEFMoZ__GpeTbZic,3865
42
42
  reader/_parser/jsonfeed.py,sha256=qFLVifbkhOkW3pvMMLB2Dwz_iibah1hZlcyhnHdci8k,5642
43
43
  reader/_parser/requests/__init__.py,sha256=zEvj3jda9_bFdNtW4KFL3AGWReFUhKXCPDs80LB556M,5107
44
- reader/_parser/requests/_lazy.py,sha256=BR9lGQDa-yiiJ5HoLL9JCk2n7YJ-NtHbbs7vh_WITlc,5002
44
+ reader/_parser/requests/_lazy.py,sha256=zSnNWQnlKi7ChKG7tI3pOQ16K5vc6beZOMadLDej_pw,4986
45
45
  reader/_plugins/__init__.py,sha256=C7U9M_pTe9Qul3IVaxrm411222AElnPgAQBhad21MME,1617
46
- reader/_plugins/cli_status.py,sha256=lonASansk2XQ76KWRKe-OWLZworX7ZohhH2TBVP-k_8,4220
47
- reader/_plugins/enclosure_tags.py,sha256=S83QHKcAXDoox2G3WXh5nuRv0kwRJhgE28hxlNHQMVE,4379
46
+ reader/_plugins/cli_status.py,sha256=cW8fwPKdYa80ve1Nv6XUKXtWW0gXMwyenIZFkSz5XX8,4195
47
+ reader/_plugins/enclosure_tags.py,sha256=70sQ-8YkjLKCsMCAtcJHc-jI-cYB4G6fOFUkliJvRmY,4487
48
48
  reader/_plugins/preview_feed_list.py,sha256=u9_c0EMj0uwWKlsOXSuGM554J4rqEyskuUOG8u306MY,4562
49
49
  reader/_plugins/share.py,sha256=02GBA9XXKkRrtfyyUk9mr8hx9jhLdbES-NrAHvs6M4I,1227
50
50
  reader/_plugins/sqlite_releases.py,sha256=R14lNGmsk-LuyOueaScYk6Qf_sjVILlTKaMDuE-1c6o,3036
51
51
  reader/_plugins/timer.py,sha256=1cjxVPjwTJDV6N0TA9-1RGlVNI0D70_aAAIfQy2lEqI,5962
52
52
  reader/_plugins/templates/preview_feed_list.html,sha256=aFfcbXLuf3bHtkYlH4tgwK7ip1Ukx93WS6X6fntYDwU,815
53
53
  reader/_storage/__init__.py,sha256=1WbFjzSqflwuSo8AWpAlDiLshkQ3zDAuasWfMKkmaow,1132
54
- reader/_storage/_base.py,sha256=dOMGEaEZuyi39ipOCYvmh9Af7cNcIQSwHc_4Onwucmk,3215
54
+ reader/_storage/_base.py,sha256=-_7vyKa6xEbYqkhaOKMw2MdgyHzsmoJHJVSkE00qbVA,3232
55
55
  reader/_storage/_changes.py,sha256=PrOgZrkmQLbI4iOKQpDMta0xgswBeqQq0_sNfqbKZ0A,6298
56
- reader/_storage/_entries.py,sha256=LwQ6YI412e84zev_TpVOxMatvICmh_vUZRRbUcB9zb4,23448
57
- reader/_storage/_feeds.py,sha256=aNUV-wva9WONqsOJq63FjZDO3lZ1raYk2wn4rAov43k,11850
56
+ reader/_storage/_entries.py,sha256=YlPvupxRKHfPuUvoZy2uJT7Sl1f2Hi7UD872xAZ6MIM,23494
57
+ reader/_storage/_feeds.py,sha256=GFr1OGG4j86ZEtmVzfOlgXK_n1BthBkp0ExYmEkx784,11887
58
58
  reader/_storage/_html_utils.py,sha256=qwVAfPk3VAkG6vWWna5js20vW5bc2Oe3xv-QYrDiJLE,2061
59
59
  reader/_storage/_schema.py,sha256=sEGEhF8Atc6AKOVHHaeNYrmum0GB9SNkNpNIspT5-9o,10274
60
- reader/_storage/_search.py,sha256=a_tmZi8aFZmb72ER2_LyFZOm5icD6OMrQFIRscJ_OXk,21865
61
- reader/_storage/_sql_utils.py,sha256=Ymbg2xahVyJ7Nr-1LX7js_lla09d4EtQ5I087d4lNrs,10332
62
- reader/_storage/_sqlite_utils.py,sha256=9miTSy3tAMTfYn6D89ZNs_fuT2qLuD_q_0uqAg2E0IY,25215
60
+ reader/_storage/_search.py,sha256=yEUCzfH6O-zNCNX1nI-Ewzy706eoQ2Fr5eZ1feHwchE,22017
61
+ reader/_storage/_sql_utils.py,sha256=rJJN4XckLK6UyvVl2-yEMejuCNyYIZ0-nJNt_3QlwAU,10330
62
+ reader/_storage/_sqlite_utils.py,sha256=y7yP-JEeDApvdjufHOM0E8NLxPCe4srQqUXBTXQa7gQ,25797
63
63
  reader/_storage/_tags.py,sha256=0Qll-WEuu8BeKlzcAp7SadjMK46pqDzsUOzwCa1wWD0,10736
64
64
  reader/_vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
65
  reader/_vendor/feedparser/__init__.py,sha256=eg2_Fs6sPwVY3Pat68RoqgfDyAkP0ydyv6wRzGM96nE,3133
@@ -102,8 +102,8 @@ reader/plugins/entry_dedupe.py,sha256=hSNB0NZ7CPx34md3073j9Hyv5E49iuDHjn-yVDkQb5
102
102
  reader/plugins/mark_as_read.py,sha256=j_GG3zmQtd6icr5iSLmiu7CXqcQmODLgNR91c23lxvs,3709
103
103
  reader/plugins/readtime.py,sha256=gWlO8MOLDlOddMhc9bgdwrthV-o13_DQA2qbKGD53qw,4699
104
104
  reader/plugins/ua_fallback.py,sha256=bwDcgKfJmTPiBa52Vb6063zAQ8Pnh9GJlxBE0ZrWMG0,1630
105
- reader-3.17.dist-info/LICENSE,sha256=ndUPB_e5gQU8fDYOwbx5utcbbokCD1Joi94v_LCDBLA,1475
106
- reader-3.17.dist-info/METADATA,sha256=JifBkayTm5czR1bt00ASm-CIxWjIjxR9YtplptoGUEY,7735
107
- reader-3.17.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
108
- reader-3.17.dist-info/top_level.txt,sha256=zVRv6FulXZWKf9Gycz9vjxul5OCZ3fCq2Attdwsu7Z4,7
109
- reader-3.17.dist-info/RECORD,,
105
+ reader-3.19.dev0.dist-info/licenses/LICENSE,sha256=ndUPB_e5gQU8fDYOwbx5utcbbokCD1Joi94v_LCDBLA,1475
106
+ reader-3.19.dev0.dist-info/METADATA,sha256=9ArGAYa0oQ26zOA0vAfE8XdzjSaRxgsffUoWPrFUzM8,6219
107
+ reader-3.19.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
108
+ reader-3.19.dev0.dist-info/top_level.txt,sha256=zVRv6FulXZWKf9Gycz9vjxul5OCZ3fCq2Attdwsu7Z4,7
109
+ reader-3.19.dev0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.2)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5