reader 3.13__tar.gz → 3.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. {reader-3.13 → reader-3.14}/CHANGES.rst +36 -1
  2. {reader-3.13/src/reader.egg-info → reader-3.14}/PKG-INFO +5 -4
  3. {reader-3.13 → reader-3.14}/docs/dev.rst +2 -0
  4. {reader-3.13 → reader-3.14}/docs/internal.rst +8 -3
  5. reader-3.14/examples/custom_headers.py +50 -0
  6. {reader-3.13 → reader-3.14}/examples/parser_only.py +1 -1
  7. {reader-3.13 → reader-3.14}/pyproject.toml +6 -4
  8. {reader-3.13 → reader-3.14}/src/reader/__init__.py +1 -1
  9. {reader-3.13 → reader-3.14}/src/reader/_app/__init__.py +19 -3
  10. {reader-3.13 → reader-3.14}/src/reader/_parser/_lazy.py +20 -20
  11. {reader-3.13 → reader-3.14}/src/reader/_parser/_url_utils.py +15 -4
  12. {reader-3.13 → reader-3.14}/src/reader/_parser/feedparser.py +1 -1
  13. reader-3.14/src/reader/_plugins/enclosure_tags.py +166 -0
  14. {reader-3.13 → reader-3.14}/src/reader/_storage/_entries.py +3 -4
  15. {reader-3.13 → reader-3.14}/src/reader/_storage/_search.py +4 -4
  16. {reader-3.13 → reader-3.14}/src/reader/_storage/_sqlite_utils.py +2 -1
  17. {reader-3.13 → reader-3.14}/src/reader/_update.py +3 -6
  18. {reader-3.13 → reader-3.14}/src/reader/_utils.py +3 -1
  19. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/__init__.py +29 -10
  20. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/api.py +106 -135
  21. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/__init__.py +3 -2
  22. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/asctime.py +26 -17
  23. reader-3.14/src/reader/_vendor/feedparser/datetimes/greek.py +90 -0
  24. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/hungarian.py +19 -25
  25. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/iso8601.py +62 -52
  26. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/korean.py +35 -24
  27. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/perforce.py +21 -4
  28. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/rfc822.py +52 -23
  29. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +36 -22
  30. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/encodings.py +215 -147
  31. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/exceptions.py +6 -6
  32. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/html.py +93 -84
  33. reader-3.14/src/reader/_vendor/feedparser/http.py +78 -0
  34. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/mixin.py +330 -275
  35. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/_base.py +210 -166
  36. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/admin.py +10 -10
  37. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/cc.py +20 -19
  38. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/dc.py +21 -17
  39. reader-3.14/src/reader/_vendor/feedparser/namespaces/georss.py +683 -0
  40. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/itunes.py +25 -21
  41. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/mediarss.py +37 -36
  42. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/psc.py +13 -13
  43. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/json.py +54 -52
  44. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/loose.py +27 -29
  45. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/strict.py +34 -28
  46. reader-3.14/src/reader/_vendor/feedparser/sanitizer.py +974 -0
  47. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/sgml.py +17 -17
  48. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/urls.py +91 -72
  49. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/util.py +40 -40
  50. {reader-3.13 → reader-3.14}/src/reader/plugins/entry_dedupe.py +41 -8
  51. {reader-3.13 → reader-3.14}/src/reader/types.py +12 -0
  52. {reader-3.13 → reader-3.14/src/reader.egg-info}/PKG-INFO +5 -4
  53. {reader-3.13 → reader-3.14}/src/reader.egg-info/SOURCES.txt +1 -0
  54. {reader-3.13 → reader-3.14}/src/reader.egg-info/requires.txt +2 -2
  55. {reader-3.13 → reader-3.14}/tests/conftest.py +4 -5
  56. {reader-3.13 → reader-3.14}/tests/test_bench.py +2 -6
  57. {reader-3.13 → reader-3.14}/tests/test_plugins_entry_dedupe.py +39 -5
  58. {reader-3.13 → reader-3.14}/tests/test_reader_counts.py +41 -3
  59. {reader-3.13 → reader-3.14}/tests/test_reader_integration.py +21 -1
  60. {reader-3.13 → reader-3.14}/tests/test_reader_search.py +3 -3
  61. {reader-3.13 → reader-3.14}/tests/utils.py +9 -4
  62. {reader-3.13 → reader-3.14}/tox.ini +3 -3
  63. reader-3.13/src/reader/_plugins/enclosure_tags.py +0 -119
  64. reader-3.13/src/reader/_vendor/feedparser/datetimes/greek.py +0 -86
  65. reader-3.13/src/reader/_vendor/feedparser/http.py +0 -227
  66. reader-3.13/src/reader/_vendor/feedparser/namespaces/georss.py +0 -276
  67. reader-3.13/src/reader/_vendor/feedparser/sanitizer.py +0 -950
  68. {reader-3.13 → reader-3.14}/LICENSE +0 -0
  69. {reader-3.13 → reader-3.14}/MANIFEST.in +0 -0
  70. {reader-3.13 → reader-3.14}/README.rst +0 -0
  71. {reader-3.13 → reader-3.14}/docs/Makefile +0 -0
  72. {reader-3.13 → reader-3.14}/docs/api.rst +0 -0
  73. {reader-3.13 → reader-3.14}/docs/app.rst +0 -0
  74. {reader-3.13 → reader-3.14}/docs/changelog.rst +0 -0
  75. {reader-3.13 → reader-3.14}/docs/cli.rst +0 -0
  76. {reader-3.13 → reader-3.14}/docs/conf.py +0 -0
  77. {reader-3.13 → reader-3.14}/docs/config.rst +0 -0
  78. {reader-3.13 → reader-3.14}/docs/contributing.rst +0 -0
  79. {reader-3.13 → reader-3.14}/docs/dev-app.rst +0 -0
  80. {reader-3.13 → reader-3.14}/docs/guide.rst +0 -0
  81. {reader-3.13 → reader-3.14}/docs/images/redesign-01.png +0 -0
  82. {reader-3.13 → reader-3.14}/docs/index.rst +0 -0
  83. {reader-3.13 → reader-3.14}/docs/install.rst +0 -0
  84. {reader-3.13 → reader-3.14}/docs/make.bat +0 -0
  85. {reader-3.13 → reader-3.14}/docs/plugins.rst +0 -0
  86. {reader-3.13 → reader-3.14}/docs/screenshots/dillo.png +0 -0
  87. {reader-3.13 → reader-3.14}/docs/screenshots/entries-feed.png +0 -0
  88. {reader-3.13 → reader-3.14}/docs/screenshots/entries.png +0 -0
  89. {reader-3.13 → reader-3.14}/docs/screenshots/entry-one.png +0 -0
  90. {reader-3.13 → reader-3.14}/docs/screenshots/entry-two.png +0 -0
  91. {reader-3.13 → reader-3.14}/docs/screenshots/feeds.png +0 -0
  92. {reader-3.13 → reader-3.14}/docs/screenshots/lynx.png +0 -0
  93. {reader-3.13 → reader-3.14}/docs/screenshots/search.png +0 -0
  94. {reader-3.13 → reader-3.14}/docs/tutorial.rst +0 -0
  95. {reader-3.13 → reader-3.14}/docs/why.rst +0 -0
  96. {reader-3.13 → reader-3.14}/examples/config.yaml +0 -0
  97. {reader-3.13 → reader-3.14}/examples/podcast.py +0 -0
  98. {reader-3.13 → reader-3.14}/examples/terminal.py +0 -0
  99. {reader-3.13 → reader-3.14}/run.sh +0 -0
  100. {reader-3.13 → reader-3.14}/scripts/.coverage +0 -0
  101. {reader-3.13 → reader-3.14}/scripts/backup.sh +0 -0
  102. {reader-3.13 → reader-3.14}/scripts/bench.py +0 -0
  103. {reader-3.13 → reader-3.14}/scripts/debug_storage_stats.py +0 -0
  104. {reader-3.13 → reader-3.14}/scripts/generate_import_all.py +0 -0
  105. {reader-3.13 → reader-3.14}/scripts/jscontrols.html +0 -0
  106. {reader-3.13 → reader-3.14}/scripts/jscontrols.py +0 -0
  107. {reader-3.13 → reader-3.14}/scripts/lines.sh +0 -0
  108. {reader-3.13 → reader-3.14}/scripts/release.py +0 -0
  109. {reader-3.13 → reader-3.14}/setup.cfg +0 -0
  110. {reader-3.13 → reader-3.14}/src/reader/__main__.py +0 -0
  111. {reader-3.13 → reader-3.14}/src/reader/_app/api_thing.py +0 -0
  112. {reader-3.13 → reader-3.14}/src/reader/_app/cli.py +0 -0
  113. {reader-3.13 → reader-3.14}/src/reader/_app/static/controls.js +0 -0
  114. {reader-3.13 → reader-3.14}/src/reader/_app/static/style.css +0 -0
  115. {reader-3.13 → reader-3.14}/src/reader/_app/templates/add_entry.html +0 -0
  116. {reader-3.13 → reader-3.14}/src/reader/_app/templates/entries.html +0 -0
  117. {reader-3.13 → reader-3.14}/src/reader/_app/templates/entry.html +0 -0
  118. {reader-3.13 → reader-3.14}/src/reader/_app/templates/feeds.html +0 -0
  119. {reader-3.13 → reader-3.14}/src/reader/_app/templates/layout.html +0 -0
  120. {reader-3.13 → reader-3.14}/src/reader/_app/templates/macros.html +0 -0
  121. {reader-3.13 → reader-3.14}/src/reader/_app/templates/metadata.html +0 -0
  122. {reader-3.13 → reader-3.14}/src/reader/_app/templates/tags.html +0 -0
  123. {reader-3.13 → reader-3.14}/src/reader/_app/wsgi.py +0 -0
  124. {reader-3.13 → reader-3.14}/src/reader/_cli.py +0 -0
  125. {reader-3.13 → reader-3.14}/src/reader/_config.py +0 -0
  126. {reader-3.13 → reader-3.14}/src/reader/_hash_utils.py +0 -0
  127. {reader-3.13 → reader-3.14}/src/reader/_parser/__init__.py +0 -0
  128. {reader-3.13 → reader-3.14}/src/reader/_parser/_http_utils.py +0 -0
  129. {reader-3.13 → reader-3.14}/src/reader/_parser/file.py +0 -0
  130. {reader-3.13 → reader-3.14}/src/reader/_parser/http.py +0 -0
  131. {reader-3.13 → reader-3.14}/src/reader/_parser/jsonfeed.py +0 -0
  132. {reader-3.13 → reader-3.14}/src/reader/_parser/requests/__init__.py +0 -0
  133. {reader-3.13 → reader-3.14}/src/reader/_parser/requests/_lazy.py +0 -0
  134. {reader-3.13 → reader-3.14}/src/reader/_plugins/__init__.py +0 -0
  135. {reader-3.13 → reader-3.14}/src/reader/_plugins/cli_status.py +0 -0
  136. {reader-3.13 → reader-3.14}/src/reader/_plugins/preview_feed_list.py +0 -0
  137. {reader-3.13 → reader-3.14}/src/reader/_plugins/share.py +0 -0
  138. {reader-3.13 → reader-3.14}/src/reader/_plugins/sqlite_releases.py +0 -0
  139. {reader-3.13 → reader-3.14}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  140. {reader-3.13 → reader-3.14}/src/reader/_plugins/timer.py +0 -0
  141. {reader-3.13 → reader-3.14}/src/reader/_storage/__init__.py +0 -0
  142. {reader-3.13 → reader-3.14}/src/reader/_storage/_base.py +0 -0
  143. {reader-3.13 → reader-3.14}/src/reader/_storage/_changes.py +0 -0
  144. {reader-3.13 → reader-3.14}/src/reader/_storage/_feeds.py +0 -0
  145. {reader-3.13 → reader-3.14}/src/reader/_storage/_html_utils.py +0 -0
  146. {reader-3.13 → reader-3.14}/src/reader/_storage/_schema.py +0 -0
  147. {reader-3.13 → reader-3.14}/src/reader/_storage/_sql_utils.py +0 -0
  148. {reader-3.13 → reader-3.14}/src/reader/_storage/_tags.py +0 -0
  149. {reader-3.13 → reader-3.14}/src/reader/_types.py +0 -0
  150. {reader-3.13 → reader-3.14}/src/reader/_vendor/__init__.py +0 -0
  151. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  152. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  153. {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/py.typed +0 -0
  154. {reader-3.13 → reader-3.14}/src/reader/core.py +0 -0
  155. {reader-3.13 → reader-3.14}/src/reader/exceptions.py +0 -0
  156. {reader-3.13 → reader-3.14}/src/reader/plugins/__init__.py +0 -0
  157. {reader-3.13 → reader-3.14}/src/reader/plugins/enclosure_dedupe.py +0 -0
  158. {reader-3.13 → reader-3.14}/src/reader/plugins/mark_as_read.py +0 -0
  159. {reader-3.13 → reader-3.14}/src/reader/plugins/readtime.py +0 -0
  160. {reader-3.13 → reader-3.14}/src/reader/plugins/ua_fallback.py +0 -0
  161. {reader-3.13 → reader-3.14}/src/reader/py.typed +0 -0
  162. {reader-3.13 → reader-3.14}/src/reader.egg-info/dependency_links.txt +0 -0
  163. {reader-3.13 → reader-3.14}/src/reader.egg-info/top_level.txt +0 -0
  164. {reader-3.13 → reader-3.14}/tests/data/10.json +0 -0
  165. {reader-3.13 → reader-3.14}/tests/data/10.json.py +0 -0
  166. {reader-3.13 → reader-3.14}/tests/data/custom +0 -0
  167. {reader-3.13 → reader-3.14}/tests/data/empty.atom +0 -0
  168. {reader-3.13 → reader-3.14}/tests/data/empty.atom.py +0 -0
  169. {reader-3.13 → reader-3.14}/tests/data/empty.json +0 -0
  170. {reader-3.13 → reader-3.14}/tests/data/empty.json.py +0 -0
  171. {reader-3.13 → reader-3.14}/tests/data/empty.rss +0 -0
  172. {reader-3.13 → reader-3.14}/tests/data/empty.rss.py +0 -0
  173. {reader-3.13 → reader-3.14}/tests/data/full.atom +0 -0
  174. {reader-3.13 → reader-3.14}/tests/data/full.atom.py +0 -0
  175. {reader-3.13 → reader-3.14}/tests/data/full.json +0 -0
  176. {reader-3.13 → reader-3.14}/tests/data/full.json.py +0 -0
  177. {reader-3.13 → reader-3.14}/tests/data/full.rss +0 -0
  178. {reader-3.13 → reader-3.14}/tests/data/full.rss.py +0 -0
  179. {reader-3.13 → reader-3.14}/tests/data/invalid.json +0 -0
  180. {reader-3.13 → reader-3.14}/tests/data/invalid.json.py +0 -0
  181. {reader-3.13 → reader-3.14}/tests/data/relative.atom +0 -0
  182. {reader-3.13 → reader-3.14}/tests/data/relative.atom.py +0 -0
  183. {reader-3.13 → reader-3.14}/tests/data/relative.rss +0 -0
  184. {reader-3.13 → reader-3.14}/tests/data/relative.rss.py +0 -0
  185. {reader-3.13 → reader-3.14}/tests/data/sqlite_releases.html +0 -0
  186. {reader-3.13 → reader-3.14}/tests/data/unknown.json +0 -0
  187. {reader-3.13 → reader-3.14}/tests/data/unknown.json.py +0 -0
  188. {reader-3.13 → reader-3.14}/tests/fakeparser.py +0 -0
  189. {reader-3.13 → reader-3.14}/tests/reader_methods.py +0 -0
  190. {reader-3.13 → reader-3.14}/tests/reader_test_plugins/__init__.py +0 -0
  191. {reader-3.13 → reader-3.14}/tests/reader_test_plugins/good.py +0 -0
  192. {reader-3.13 → reader-3.14}/tests/reader_test_plugins/init_error.py +0 -0
  193. {reader-3.13 → reader-3.14}/tests/reader_test_plugins/missing_dependency.py +0 -0
  194. {reader-3.13 → reader-3.14}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  195. {reader-3.13 → reader-3.14}/tests/test__types.py +0 -0
  196. {reader-3.13 → reader-3.14}/tests/test_app.py +0 -0
  197. {reader-3.13 → reader-3.14}/tests/test_app_wsgi.py +0 -0
  198. {reader-3.13 → reader-3.14}/tests/test_changes.py +0 -0
  199. {reader-3.13 → reader-3.14}/tests/test_cli.py +0 -0
  200. {reader-3.13 → reader-3.14}/tests/test_config.py +0 -0
  201. {reader-3.13 → reader-3.14}/tests/test_exceptions.py +0 -0
  202. {reader-3.13 → reader-3.14}/tests/test_hash_utils.py +0 -0
  203. {reader-3.13 → reader-3.14}/tests/test_html_utils.py +0 -0
  204. {reader-3.13 → reader-3.14}/tests/test_lazy_imports.py +0 -0
  205. {reader-3.13 → reader-3.14}/tests/test_parser.py +0 -0
  206. {reader-3.13 → reader-3.14}/tests/test_plugins_cli_status.py +0 -0
  207. {reader-3.13 → reader-3.14}/tests/test_plugins_enclosure_dedupe.py +0 -0
  208. {reader-3.13 → reader-3.14}/tests/test_plugins_mark_as_read.py +0 -0
  209. {reader-3.13 → reader-3.14}/tests/test_plugins_preview_feed_list.py +0 -0
  210. {reader-3.13 → reader-3.14}/tests/test_plugins_readtime.py +0 -0
  211. {reader-3.13 → reader-3.14}/tests/test_plugins_sqlite_releases.py +0 -0
  212. {reader-3.13 → reader-3.14}/tests/test_plugins_ua_fallback.py +0 -0
  213. {reader-3.13 → reader-3.14}/tests/test_reader.py +0 -0
  214. {reader-3.13 → reader-3.14}/tests/test_reader_context.py +0 -0
  215. {reader-3.13 → reader-3.14}/tests/test_reader_deprecations.py +0 -0
  216. {reader-3.13 → reader-3.14}/tests/test_reader_filter.py +0 -0
  217. {reader-3.13 → reader-3.14}/tests/test_reader_hooks.py +0 -0
  218. {reader-3.13 → reader-3.14}/tests/test_reader_plugins.py +0 -0
  219. {reader-3.13 → reader-3.14}/tests/test_reader_private.py +0 -0
  220. {reader-3.13 → reader-3.14}/tests/test_reader_sort.py +0 -0
  221. {reader-3.13 → reader-3.14}/tests/test_reader_update.py +0 -0
  222. {reader-3.13 → reader-3.14}/tests/test_search.py +0 -0
  223. {reader-3.13 → reader-3.14}/tests/test_sql_utils.py +0 -0
  224. {reader-3.13 → reader-3.14}/tests/test_sqlite_utils.py +0 -0
  225. {reader-3.13 → reader-3.14}/tests/test_storage.py +0 -0
  226. {reader-3.13 → reader-3.14}/tests/test_tags.py +0 -0
  227. {reader-3.13 → reader-3.14}/tests/test_test_utils.py +0 -0
  228. {reader-3.13 → reader-3.14}/tests/test_types.py +0 -0
  229. {reader-3.13 → reader-3.14}/tests/test_utils.py +0 -0
@@ -6,6 +6,41 @@ Changelog
6
6
  :noindex:
7
7
 
8
8
 
9
+ Version 3.14
10
+ ------------
11
+
12
+ Released 2024-07-23
13
+
14
+ * Add :attr:`~EntryCounts.unimportant` attribute to
15
+ :class:`EntryCounts` and :class:`EntrySearchCounts`.
16
+ Thanks to `chenthur`_ for the pull request.
17
+ (:issue:`283`)
18
+ * Fix bug in the :mod:`~reader.plugins.entry_dedupe` plugin causing entries
19
+ to flip-flop if there were multiple *new* duplicates of the same issue
20
+ (on the first update, one entry remains, on the second update, the other);
21
+ related to the bug fixed in `version 3.2 <Version 3.2_>`_.
22
+ (:issue:`340`)
23
+
24
+ * :mod:`~reader._plugins.enclosure_tags` plugin improvements. (:issue:`344`)
25
+
26
+ * Set genre to `Podcast` if the feed has any tag containing "podcast".
27
+ * Rewrite tags on the fly,
28
+ instead of downloading the entire file before sending it to the user;
29
+ allows browsers to display accurate download progress.
30
+ * Prefer feed user title to feed title if available.
31
+ * Use feed title as artist, instead of author.
32
+
33
+ * Support Python 3.13. (:issue:`341`)
34
+ * Update vendored `feedparser`_ to the ``develop`` branch as of 2024-06-26.
35
+ Needed because upstream removed dependency on stdlib module ``cgi``
36
+ (removed in Python 3.13), but the `version 2.9 <Version 2.9_>`_
37
+ memory usage reduction was not released upstream yet.
38
+ (:issue:`341`)
39
+
40
+ .. _chenthur: https://github.com/chenthur
41
+ .. _feedparser: https://feedparser.readthedocs.io/en/latest/
42
+
43
+
9
44
  Version 3.13
10
45
  ------------
11
46
 
@@ -341,7 +376,7 @@ Released 2022-09-14
341
376
  became optional.
342
377
  (:issue:`96`)
343
378
  * Fix bug in :mod:`~reader.plugins.entry_dedupe` causing updates to fail
344
- if there were multiple *new* duplicates of the same issue.
379
+ if there were multiple *new* duplicates of the same entry.
345
380
  (:issue:`292`)
346
381
  * Fix bug in :mod:`~reader.plugins.readtime`
347
382
  and :mod:`~reader.plugins.mark_as_read` causing updates to fail
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: reader
3
- Version: 3.13
3
+ Version: 3.14
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
22
22
  Classifier: Programming Language :: Python :: 3.10
23
23
  Classifier: Programming Language :: Python :: 3.11
24
24
  Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
25
26
  Classifier: Programming Language :: Python :: Implementation :: CPython
26
27
  Classifier: Programming Language :: Python :: Implementation :: PyPy
27
28
  Classifier: Topic :: Internet
@@ -65,11 +66,11 @@ Requires-Dist: pytest-rerunfailures; extra == "tests"
65
66
  Requires-Dist: coverage; extra == "tests"
66
67
  Requires-Dist: pytest-cov; extra == "tests"
67
68
  Requires-Dist: requests-mock; extra == "tests"
68
- Requires-Dist: mechanicalsoup; (implementation_name != "pypy" or python_version <= "3.9") and extra == "tests"
69
+ Requires-Dist: mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
69
70
  Requires-Dist: requests-wsgi-adapter; extra == "tests"
70
- Requires-Dist: lxml; (implementation_name != "pypy" or python_version <= "3.9") and extra == "tests"
71
+ Requires-Dist: lxml; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
71
72
  Requires-Dist: html5lib; extra == "tests"
72
- Requires-Dist: numpy; (implementation_name != "pypy" and os_name == "posix") and extra == "tests"
73
+ Requires-Dist: numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12") and extra == "tests"
73
74
  Requires-Dist: mypy; implementation_name != "pypy" and extra == "tests"
74
75
  Requires-Dist: types-requests; extra == "tests"
75
76
  Requires-Dist: types-beautifulsoup4; extra == "tests"
@@ -721,6 +721,8 @@ Duplicate entries
721
721
  Duplicate entries are mainly handled by the :mod:`reader.entry_dedupe` plugin.
722
722
 
723
723
  * Using MinHash to speed up similarity checks (maybe): https://gist.github.com/lemon24/b9af5ade919713406bda9603847d32e5
724
+ * Discussion of unifying "on-line" dedupe (after an entry is added/updated),
725
+ and "on-demand" dedupe (backfill): :issue:`340`.
724
726
 
725
727
  However, it is also possible for a feed to have two entries with the same id
726
728
  – yes, even though in most (if not all) formats,
@@ -219,9 +219,14 @@ Type aliases
219
219
  Recipes
220
220
  -------
221
221
 
222
- .. include:: ../examples/parser_only.py
222
+ .. include:: ../examples/custom_headers.py
223
223
  :start-after: """
224
- :end-before: """ # docstring-end
224
+ :end-before: """
225
+ .. literalinclude:: ../examples/custom_headers.py
226
+ :start-at: import
225
227
 
228
+ .. include:: ../examples/parser_only.py
229
+ :start-after: """
230
+ :end-before: """
226
231
  .. literalinclude:: ../examples/parser_only.py
227
- :start-after: """ # docstring-end
232
+ :start-at: import
@@ -0,0 +1,50 @@
1
+ """
2
+ Adding custom headers when retrieving feeds
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Example of adding custom request headers with :attr:`.SessionFactory.request_hooks`:
6
+
7
+ .. code-block:: console
8
+
9
+ $ python examples/custom_headers.py
10
+ updating...
11
+ server: Hello, world!
12
+ updated!
13
+
14
+ """
15
+
16
+ # fmt: off
17
+ # flake8: noqa
18
+
19
+ import http.server
20
+ import threading
21
+ from reader import make_reader
22
+
23
+ # start a background server that logs the received header
24
+
25
+ class Handler(http.server.BaseHTTPRequestHandler):
26
+ def log_message(self, *_): pass
27
+ def do_GET(self):
28
+ print("server:", self.headers.get('my-header'))
29
+ self.send_error(304)
30
+
31
+ server = http.server.HTTPServer(('localhost', 8080), Handler)
32
+ threading.Thread(target=server.handle_request).start()
33
+
34
+ # create a reader object
35
+
36
+ reader = make_reader(':memory:')
37
+ reader.add_feed('http://localhost:8080')
38
+
39
+ # set up a hook that adds the header to each request
40
+
41
+ def hook(session, request, **kwargs):
42
+ request.headers.setdefault('my-header', 'Hello, world!')
43
+
44
+ reader._parser.session_factory.request_hooks.append(hook)
45
+
46
+ # updating the feed sends the modified request to the server
47
+
48
+ print("updating...")
49
+ reader.update_feeds()
50
+ print("updated!")
@@ -11,7 +11,7 @@ retrieved asynchronously with `HTTPX <https://www.python-httpx.org/>`_:
11
11
  death and gravity
12
12
  Has your password been pwned? Or, how I almost failed to search a 37 GB text file in under 1 millisecond (in Python)
13
13
 
14
- """ # docstring-end
14
+ """
15
15
 
16
16
  import asyncio
17
17
  import io
@@ -26,6 +26,7 @@ classifiers = [
26
26
  "Programming Language :: Python :: 3.10",
27
27
  "Programming Language :: Python :: 3.11",
28
28
  "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
29
30
  "Programming Language :: Python :: Implementation :: CPython",
30
31
  "Programming Language :: Python :: Implementation :: PyPy",
31
32
  "Topic :: Internet",
@@ -107,14 +108,15 @@ tests = [
107
108
  "pytest-cov",
108
109
  "requests-mock",
109
110
  # mechanicalsoup hard-depends on lxml (see below)
110
- 'mechanicalsoup; (implementation_name != "pypy" or python_version <= "3.9")',
111
+ 'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12")',
111
112
  "requests-wsgi-adapter",
112
113
  # we want to test search with all known bs4 parsers.
113
- # lxml does not have a PyPy 3.10+ wheel.
114
- 'lxml; (implementation_name != "pypy" or python_version <= "3.9")',
114
+ # lxml usually does not have recent PyPy wheels.
115
+ # lxml usually does not have pre-relase CPython wheels.
116
+ 'lxml; (implementation_name != "pypy" and python_version <= "3.12")',
115
117
  "html5lib",
116
118
  # for bench.py
117
- 'numpy; (implementation_name != "pypy" and os_name == "posix")',
119
+ 'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12")',
118
120
  # mypy does not work on pypy (yet).
119
121
  'mypy; implementation_name != "pypy"',
120
122
  "types-requests",
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.13'
40
+ __version__ = '3.14'
41
41
 
42
42
  # isort: off
43
43
 
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
  from datetime import datetime
9
9
  from datetime import timedelta
10
10
  from datetime import timezone
11
+ from functools import lru_cache
11
12
 
12
13
  import flask.signals
13
14
  import humanize
@@ -418,7 +419,7 @@ def add_entry():
418
419
  FEED_SORT_NATIVE = {'title', 'added'}
419
420
  FEED_SORT_FANCY = {
420
421
  'important': lambda counts: counts.important,
421
- # TODO: an unread/unimportant property would be nice
422
+ 'unimportant': lambda counts: counts.unimportant,
422
423
  'unread': lambda counts: counts.total - counts.read,
423
424
  # TODO: if we keep these average intervals, properties for them might be nice too
424
425
  'avg1m': lambda counts: counts.averages[0],
@@ -435,7 +436,6 @@ def feeds():
435
436
 
436
437
  updates_enabled = request.args.get('updates-enabled')
437
438
  updates_enabled = {None: None, 'no': False, 'yes': True}[updates_enabled]
438
-
439
439
  sort = request.args.get('sort', 'title')
440
440
  assert sort in FEED_SORT_ALL
441
441
 
@@ -801,12 +801,28 @@ def delete_entry(data):
801
801
  get_reader().delete_entry((feed_url, entry_id))
802
802
 
803
803
 
804
+ def get_feed_tag_keys(url):
805
+ assert isinstance(url, str), url
806
+
807
+ if not hasattr(g, 'reader_get_feed_tag_keys'):
808
+
809
+ @lru_cache(128)
810
+ def get(url):
811
+ return list(current_app.reader.get_tag_keys(url))
812
+
813
+ g.reader_get_feed_tag_keys = get
814
+
815
+ return g.reader_get_feed_tag_keys(url)
816
+
817
+
804
818
  # for some reason, @blueprint.app_template_global does not work
805
819
  @blueprint.app_template_global()
806
820
  def additional_enclosure_links(enclosure, entry):
807
821
  funcs = getattr(current_app, 'reader_additional_enclosure_links', ())
822
+ # TODO: this would not be needed if entry.feed could have tags on it
823
+ feed_tags = get_feed_tag_keys(entry.feed.url)
808
824
  for func in funcs:
809
- yield from func(enclosure, entry)
825
+ yield from func(enclosure, entry, feed_tags)
810
826
 
811
827
 
812
828
  @blueprint.app_template_global()
@@ -72,7 +72,7 @@ class Parser:
72
72
  self.parsers_by_mime_type: dict[str, list[tuple[float, ParserType[Any]]]] = {}
73
73
  self.parsers_by_url: dict[str, ParserType[Any]] = {}
74
74
 
75
- #: :class:`~reader._requests_utils.SessionFactory`
75
+ #: :class:`~reader._parser.requests.SessionFactory`
76
76
  #: used to create Requests sessions for retrieving feeds.
77
77
  #:
78
78
  #: Plugins may add request or response hooks to this.
@@ -314,25 +314,25 @@ class Parser:
314
314
  ParseError: No parser matches.
315
315
 
316
316
  """
317
- parser = self.get_parser_by_url(url)
318
- if not parser:
319
- if not mime_type:
320
- mime_type, _ = mimetypes.guess_type(url)
321
-
322
- # https://tools.ietf.org/html/rfc7231#section-3.1.1.5
323
- #
324
- # > If a Content-Type header field is not present, the recipient
325
- # > MAY either assume a media type of "application/octet-stream"
326
- # > ([RFC2046], Section 4.5.1) or examine the data to determine its type.
327
- #
328
- if not mime_type:
329
- mime_type = 'application/octet-stream'
330
-
331
- parser = self.get_parser_by_mime_type(mime_type)
332
- if not parser:
333
- raise ParseError(url, message=f"no parser for MIME type {mime_type!r}")
334
-
335
- return parser, mime_type
317
+ if parser := self.get_parser_by_url(url):
318
+ return parser, mime_type
319
+
320
+ if not mime_type:
321
+ mime_type, _ = mimetypes.guess_type(url)
322
+
323
+ # https://tools.ietf.org/html/rfc7231#section-3.1.1.5
324
+ #
325
+ # > If a Content-Type header field is not present, the recipient
326
+ # > MAY either assume a media type of "application/octet-stream"
327
+ # > ([RFC2046], Section 4.5.1) or examine the data to determine its type.
328
+ #
329
+ if not mime_type:
330
+ mime_type = 'application/octet-stream'
331
+
332
+ if parser := self.get_parser_by_mime_type(mime_type):
333
+ return parser, mime_type
334
+
335
+ raise ParseError(url, message=f"no parser for MIME type {mime_type!r}")
336
336
 
337
337
  def validate_url(self, url: str) -> None:
338
338
  """Check if ``url`` is valid without actually retrieving it.
@@ -93,6 +93,8 @@ def resolve_root(root: str, path: str) -> str:
93
93
 
94
94
  # we normalize the path **before** symlinks are resolved;
95
95
  # i.e. it behaves as realpath -L (logical), not realpath -P (physical).
96
+ # we could probably use pathlib resolve() here, but it is not pure,
97
+ # and we can't mock the Windows version on POSIX.
96
98
  # https://docs.python.org/3/library/os.path.html#os.path.normpath
97
99
  # https://stackoverflow.com/questions/34865153/os-path-normpath-and-symbolic-links
98
100
  path = os.path.normcase(os.path.normpath(os.path.join(root, path)))
@@ -112,8 +114,8 @@ def resolve_root(root: str, path: str) -> str:
112
114
  def is_abs_path(path: str) -> bool:
113
115
  """Return True if path is an absolute pathname.
114
116
 
115
- Unlike os.path.isabs(), return False on Windows if there's no drive
116
- (e.g. "\\path").
117
+ Unlike os.path.isabs(), return False on Windows
118
+ if there's no drive (e.g. "\\path").
117
119
 
118
120
  """
119
121
  is_abs = os.path.isabs(path)
@@ -124,10 +126,19 @@ def is_abs_path(path: str) -> bool:
124
126
  def is_rel_path(path: str) -> bool:
125
127
  """Return True if path is a relative pathname.
126
128
 
127
- Unlike "not os.path.isabs()", return False on windows if there's a drive
128
- (e.g. "C:path").
129
+ Unlike "not os.path.isabs()", return False on Windows
130
+ if there's a drive (e.g. "C:path").
129
131
 
130
132
  """
133
+ # starting with Python 3.13, os.path.isabs('/a') is False on Windows.
134
+ # pathlib is_absolute() doesn't need this.
135
+ path = os.path.normpath(path)
136
+
131
137
  is_abs = os.path.isabs(path)
138
+
139
+ # starting with Python 3.13, os.path.isabs('\\a') is False on Windows.
140
+ if os.name == 'nt' and path.startswith('\\'):
141
+ is_abs = True
142
+
132
143
  has_drive = os.name == 'nt' and os.path.splitdrive(path)[0]
133
144
  return not any([is_abs, has_drive])
@@ -51,7 +51,7 @@ class FeedparserParser:
51
51
  # feedparser content sanitization and relative link resolution should be ON.
52
52
  # https://github.com/lemon24/reader/issues/125
53
53
  # https://github.com/lemon24/reader/issues/157
54
- result = feedparser.parse( # type: ignore[attr-defined]
54
+ result = feedparser.parse(
55
55
  resource,
56
56
  resolve_relative_uris=True,
57
57
  sanitize_html=True,
@@ -0,0 +1,166 @@
1
+ """
2
+ enclosure_tags
3
+ ~~~~~~~~~~~~~~
4
+
5
+ Fix tags for MP3 enclosures (e.g. podcasts).
6
+
7
+ Adds a "with tags" link to a version of the file with tags set as follows:
8
+
9
+ * the entry title as title
10
+ * the feed (user) title as album and artist
11
+ * `Podcast` as genre, if the feed has any tag containing "podcast"
12
+
13
+ This plugin needs additional dependencies, use the ``unstable-plugins`` extra
14
+ to install them:
15
+
16
+ .. code-block:: bash
17
+
18
+ pip install reader[unstable-plugins]
19
+
20
+ To load::
21
+
22
+ READER_APP_PLUGIN='reader._plugins.enclosure_tags:init' \\
23
+ python -m reader serve
24
+
25
+ Implemented for :issue:`50`.
26
+ Became a plugin in :issue:`52`.
27
+ Streaming added in :issue:`344`.
28
+
29
+ """
30
+
31
+ import io
32
+ from urllib.parse import urlparse
33
+
34
+ import mutagen.mp3
35
+ import requests
36
+ from flask import Blueprint
37
+ from flask import request
38
+ from flask import Response
39
+ from flask import stream_with_context
40
+ from flask import url_for
41
+
42
+
43
+ blueprint = Blueprint('enclosure_tags', __name__)
44
+
45
+
46
+ ALL_TAGS = ('album', 'title', 'artist', 'genre')
47
+
48
+
49
+ @blueprint.route('/enclosure-tags', defaults={'filename': None})
50
+ @blueprint.route('/enclosure-tags/<filename>')
51
+ def enclosure_tags(filename):
52
+ tags = {}
53
+ for tag in ALL_TAGS:
54
+ if value := request.args.get(tag):
55
+ tags[tag] = value
56
+
57
+ # TODO: handle raise_for_status() exceptions nicely
58
+ headers, chunks = update_tags_requests(request.args['url'], tags)
59
+
60
+ def iter_chunks():
61
+ # Send the headers as soon as possible.
62
+ # Some browsers wait for the headers before showing the "Save As" dialog.
63
+ yield ''
64
+ yield from chunks
65
+
66
+ return Response(stream_with_context(iter_chunks()), headers=headers)
67
+
68
+
69
+ def update_tags_requests(url, tags, *, session=requests):
70
+ """update_tags_requests(url, ...) -> (headers, iter_chunks())"""
71
+
72
+ response = requests.get(url, stream=True)
73
+ response.raise_for_status()
74
+
75
+ headers = {}
76
+
77
+ if content_disposition := response.headers.get('content-disposition'):
78
+ headers['content_disposition'] = content_disposition
79
+ response.raw.name = content_disposition
80
+ else:
81
+ response.raw.name = urlparse(url).path.split('/')[-1]
82
+
83
+ old_prefix, new_prefix = update_tags(response.raw, tags)
84
+
85
+ try:
86
+ content_length = int(response.headers.get('content-length', ''))
87
+ except ValueError:
88
+ pass
89
+ else:
90
+ content_length = content_length - len(old_prefix) + len(new_prefix)
91
+ headers['content-length'] = str(content_length)
92
+
93
+ if content_type := response.headers.get('content-type'):
94
+ headers['content-type'] = content_type
95
+
96
+ def iter_chunks():
97
+ with response:
98
+ yield new_prefix
99
+ yield from response.iter_content(2**18)
100
+
101
+ return headers, iter_chunks()
102
+
103
+
104
+ def update_tags(file, tags):
105
+ """update_tags(file, ...) -> (old_prefix, new_prefix)
106
+
107
+ Rewrite the prefix of file to update ID3v2 tags.
108
+
109
+ """
110
+ prefix = b''
111
+ easy = None
112
+ for size in [2**17, 2**17, 2**18, 2**19, 2**19, 2**19]:
113
+ chunk = file.read(size)
114
+ if not chunk:
115
+ break
116
+ prefix += chunk
117
+ try:
118
+ easy = mutagen.mp3.EasyMP3(io.BytesIO(prefix))
119
+ except mutagen.MutagenError:
120
+ # TODO: debug logging
121
+ pass
122
+ else:
123
+ break
124
+
125
+ if easy is None:
126
+ return prefix, prefix
127
+
128
+ if easy.info.sketchy:
129
+ return prefix, prefix
130
+
131
+ offset = easy.info.frame_offset
132
+
133
+ for tag, value in tags.items():
134
+ easy[tag] = value
135
+
136
+ out = io.BytesIO()
137
+ easy.save(out)
138
+
139
+ return prefix, out.getvalue() + prefix[offset:]
140
+
141
+
142
+ def enclosure_tags_filter(enclosure, entry, feed_tags):
143
+ filename = urlparse(enclosure.href).path.split('/')[-1]
144
+ if not filename.endswith('.mp3'):
145
+ return []
146
+
147
+ args = {'url': enclosure.href, 'filename': filename}
148
+ if entry.title:
149
+ args['title'] = entry.title
150
+ if album := (entry.feed.user_title or entry.feed.title):
151
+ args['album'] = album
152
+ args['artist'] = album
153
+ elif artist := (entry.author or entry.feed.author):
154
+ args['artist'] = artist
155
+
156
+ for tag in feed_tags:
157
+ if 'podcast' in tag.lower():
158
+ args['genre'] = 'Podcast'
159
+ break
160
+
161
+ return [('with tags', url_for('enclosure_tags.enclosure_tags', **args))]
162
+
163
+
164
+ def init(app):
165
+ app.register_blueprint(blueprint)
166
+ app.reader_additional_enclosure_links.append(enclosure_tags_filter)
@@ -62,7 +62,7 @@ class EntriesMixin(StorageBase):
62
62
  self.paginated_query,
63
63
  partial(get_entries_query, filter, sort),
64
64
  row_factory=entry_factory,
65
- ) # type: ignore[var-annotated]
65
+ )
66
66
  if sort != 'random':
67
67
  last = self.get_entry_last(sort, starting_after) if starting_after else None
68
68
  return paginated_query(limit, last)
@@ -101,8 +101,7 @@ class EntriesMixin(StorageBase):
101
101
  context.update(new_context)
102
102
 
103
103
  row = exactly_one(self.get_db().execute(str(query), context))
104
-
105
- return EntryCounts(*row[:4], row[4:7]) # type: ignore[call-arg]
104
+ return EntryCounts(*row[:5], row[5:8]) # type: ignore[call-arg]
106
105
 
107
106
  @wrap_exceptions()
108
107
  def set_entry_read(
@@ -593,6 +592,7 @@ def get_entry_counts_query(
593
592
  'count(*)',
594
593
  'coalesce(sum(read == 1), 0)',
595
594
  'coalesce(sum(important == 1), 0)',
595
+ 'coalesce(sum(important == 0), 0)',
596
596
  """
597
597
  coalesce(
598
598
  sum(
@@ -606,7 +606,6 @@ def get_entry_counts_query(
606
606
  .FROM("entries_filtered")
607
607
  .JOIN("entries USING (id, feed)")
608
608
  )
609
-
610
609
  # one CTE / period + HAVING in the CTE is a tiny bit faster than
611
610
  # one CTE + WHERE in the SELECT
612
611
 
@@ -349,7 +349,7 @@ class Search:
349
349
  (change.sequence, *change.resource_id),
350
350
  )
351
351
  if cursor.rowcount: # pragma: no cover
352
- log.warn(
352
+ log.warning(
353
353
  "during insert, found and deleted %d rows for %r",
354
354
  cursor.rowcount,
355
355
  change,
@@ -409,7 +409,7 @@ class Search:
409
409
  def pq(
410
410
  limit: int | None, last: tuple[Any, ...] | None = None
411
411
  ) -> Iterable[EntrySearchResult]:
412
- with wrap_exceptions(ENABLED_EXC | QUERY_EXC):
412
+ with wrap_exceptions(ENABLED_EXC | QUERY_EXC): # type: ignore[operator]
413
413
  yield from paginated_query(
414
414
  self.get_db(),
415
415
  make_query,
@@ -455,7 +455,7 @@ class Search:
455
455
  lambda: EntryNotFoundError(feed_url, entry_id),
456
456
  )
457
457
 
458
- @wrap_exceptions(ENABLED_EXC | QUERY_EXC)
458
+ @wrap_exceptions(ENABLED_EXC | QUERY_EXC) # type: ignore[operator]
459
459
  def search_entry_counts(
460
460
  self,
461
461
  query: str,
@@ -486,7 +486,7 @@ class Search:
486
486
 
487
487
  context = dict(query=query, **query_context)
488
488
  row = exactly_one(self.get_db().execute(str(sql_query), context))
489
- return EntrySearchCounts(*row[:4], row[4:7]) # type: ignore[call-arg]
489
+ return EntrySearchCounts(*row[:5], row[5:8]) # type: ignore[call-arg]
490
490
 
491
491
 
492
492
  def make_search_entries_query(
@@ -14,6 +14,7 @@ import traceback
14
14
  import weakref
15
15
  from collections.abc import Callable
16
16
  from collections.abc import Iterator
17
+ from collections.abc import Mapping
17
18
  from collections.abc import Sequence
18
19
  from contextlib import closing
19
20
  from contextlib import contextmanager
@@ -85,7 +86,7 @@ def ddl_transaction(db: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
85
86
  @contextmanager
86
87
  def wrap_exceptions(
87
88
  exc_type: Callable[[str], Exception],
88
- op_exc_types: dict[str, Callable[[str], Exception]] = {}, # noqa: B006
89
+ op_exc_types: Mapping[str, Callable[[str], Exception]] = {}, # noqa: B006
89
90
  message: str = "unexpected error",
90
91
  ) -> Iterator[None]:
91
92
  """Wrap sqlite3 exceptions in a custom exception.
@@ -444,14 +444,14 @@ class Pipeline:
444
444
 
445
445
  try:
446
446
  # assemble pipeline
447
- entry_pairs = self.get_entry_pairs(result)
448
-
449
447
  if result and not isinstance(result, Exception):
448
+ entry_pairs = self.get_entry_pairs(result)
450
449
  entry_pairs = self.reader._parser.process_entry_pairs(
451
450
  feed.url, result.mime_type, entry_pairs
452
451
  )
453
452
  entry_pairs, get_total_count = count_consumed(entry_pairs)
454
453
  else:
454
+ entry_pairs = ()
455
455
  get_total_count = lambda: 0 # noqa: E731
456
456
 
457
457
  intents = make_intents(entry_pairs)
@@ -466,10 +466,7 @@ class Pipeline:
466
466
 
467
467
  return feed.url, UpdatedFeed(feed.url, *counts, total - sum(counts))
468
468
 
469
- def get_entry_pairs(self, result: ParsedFeed | None | ParseError) -> EntryPairs:
470
- if not result or isinstance(result, Exception):
471
- return ()
472
-
469
+ def get_entry_pairs(self, result: ParsedFeed) -> EntryPairs:
473
470
  # give storage a chance to consume entries in a streaming fashion
474
471
  entries1, entries2 = tee(result.entries)
475
472
  entries_for_update = self.reader._storage.get_entries_for_update(
@@ -89,8 +89,10 @@ def count_consumed(it: Iterable[_T]) -> tuple[Iterable[_T], Callable[[], int]]:
89
89
  MapFunction = Callable[[Callable[[_T], _U], Iterable[_T]], Iterator[_U]]
90
90
  MapContextManager = AbstractContextManager[MapFunction[_T, _U]]
91
91
 
92
+ # type ignore because of https://github.com/python/mypy/issues/17551
92
93
 
93
- @contextmanager
94
+
95
+ @contextmanager # type: ignore[arg-type]
94
96
  def make_pool_map(workers: int) -> Iterator[MapFunction[_T, _U]]:
95
97
  # We are using concurrent.futures instead of multiprocessing.dummy
96
98
  # because the latter doesn't work on some environments (e.g. AWS Lambda).