reader 3.20__tar.gz → 3.21__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 (234) hide show
  1. {reader-3.20 → reader-3.21}/CHANGES.rst +34 -0
  2. {reader-3.20/src/reader.egg-info → reader-3.21}/PKG-INFO +1 -1
  3. {reader-3.20 → reader-3.21}/docs/guide.rst +12 -5
  4. {reader-3.20 → reader-3.21}/pyproject.toml +4 -2
  5. {reader-3.20 → reader-3.21}/run.sh +1 -1
  6. {reader-3.20 → reader-3.21}/src/reader/__init__.py +1 -1
  7. {reader-3.20 → reader-3.21}/src/reader/_app/v2/forms.py +2 -1
  8. {reader-3.20 → reader-3.21}/src/reader/_app/v2/templates/v2/entries.html +2 -0
  9. {reader-3.20 → reader-3.21}/src/reader/_cli.py +4 -1
  10. {reader-3.20 → reader-3.21}/src/reader/_parser/__init__.py +52 -13
  11. {reader-3.20 → reader-3.21}/src/reader/_parser/_http_utils.py +9 -1
  12. {reader-3.20 → reader-3.21}/src/reader/_storage/_sqlite_utils.py +24 -22
  13. {reader-3.20 → reader-3.21}/src/reader/_update.py +11 -10
  14. {reader-3.20 → reader-3.21}/src/reader/core.py +23 -7
  15. {reader-3.20 → reader-3.21}/src/reader/plugins/entry_dedupe.py +5 -6
  16. {reader-3.20 → reader-3.21/src/reader.egg-info}/PKG-INFO +1 -1
  17. {reader-3.20 → reader-3.21}/tests/conftest.py +12 -0
  18. {reader-3.20 → reader-3.21}/tests/fakeparser.py +27 -36
  19. {reader-3.20 → reader-3.21}/tests/reader_methods.py +45 -10
  20. {reader-3.20 → reader-3.21}/tests/test_app.py +6 -3
  21. {reader-3.20 → reader-3.21}/tests/test_changes.py +3 -0
  22. {reader-3.20 → reader-3.21}/tests/test_cli.py +4 -4
  23. {reader-3.20 → reader-3.21}/tests/test_plugins_entry_dedupe.py +21 -3
  24. {reader-3.20 → reader-3.21}/tests/test_plugins_mark_as_read.py +3 -0
  25. {reader-3.20 → reader-3.21}/tests/test_plugins_readtime.py +3 -0
  26. {reader-3.20 → reader-3.21}/tests/test_reader.py +36 -1298
  27. {reader-3.20 → reader-3.21}/tests/test_reader_counts.py +1 -1
  28. {reader-3.20 → reader-3.21}/tests/test_reader_filter.py +103 -109
  29. {reader-3.20 → reader-3.21}/tests/test_reader_hooks.py +3 -0
  30. reader-3.21/tests/test_reader_integration.py +207 -0
  31. {reader-3.20 → reader-3.21}/tests/test_reader_lifecycle.py +6 -8
  32. {reader-3.20 → reader-3.21}/tests/test_reader_private.py +3 -0
  33. {reader-3.20 → reader-3.21}/tests/test_reader_search.py +15 -23
  34. {reader-3.20 → reader-3.21}/tests/test_reader_sort.py +7 -0
  35. reader-3.21/tests/test_reader_update.py +1255 -0
  36. {reader-3.20 → reader-3.21}/tests/test_reader_utils.py +3 -0
  37. {reader-3.20 → reader-3.21}/tests/utils.py +20 -0
  38. {reader-3.20 → reader-3.21}/tox.ini +1 -1
  39. reader-3.20/tests/test_reader_integration.py +0 -209
  40. reader-3.20/tests/test_reader_update.py +0 -172
  41. {reader-3.20 → reader-3.21}/LICENSE +0 -0
  42. {reader-3.20 → reader-3.21}/MANIFEST.in +0 -0
  43. {reader-3.20 → reader-3.21}/README.rst +0 -0
  44. {reader-3.20 → reader-3.21}/docs/api.rst +0 -0
  45. {reader-3.20 → reader-3.21}/docs/app.rst +0 -0
  46. {reader-3.20 → reader-3.21}/docs/changelog.rst +0 -0
  47. {reader-3.20 → reader-3.21}/docs/cli.rst +0 -0
  48. {reader-3.20 → reader-3.21}/docs/conf.py +0 -0
  49. {reader-3.20 → reader-3.21}/docs/config.rst +0 -0
  50. {reader-3.20 → reader-3.21}/docs/contributing.rst +0 -0
  51. {reader-3.20 → reader-3.21}/docs/dev-app.rst +0 -0
  52. {reader-3.20 → reader-3.21}/docs/dev.rst +0 -0
  53. {reader-3.20 → reader-3.21}/docs/images/redesign-01.png +0 -0
  54. {reader-3.20 → reader-3.21}/docs/index.rst +0 -0
  55. {reader-3.20 → reader-3.21}/docs/install.rst +0 -0
  56. {reader-3.20 → reader-3.21}/docs/internal.rst +0 -0
  57. {reader-3.20 → reader-3.21}/docs/plugins.rst +0 -0
  58. {reader-3.20 → reader-3.21}/docs/screenshots/dillo.png +0 -0
  59. {reader-3.20 → reader-3.21}/docs/screenshots/entries-feed.png +0 -0
  60. {reader-3.20 → reader-3.21}/docs/screenshots/entries-v2-dark.png +0 -0
  61. {reader-3.20 → reader-3.21}/docs/screenshots/entries-v2-filters-light.png +0 -0
  62. {reader-3.20 → reader-3.21}/docs/screenshots/entries.png +0 -0
  63. {reader-3.20 → reader-3.21}/docs/screenshots/entry-one.png +0 -0
  64. {reader-3.20 → reader-3.21}/docs/screenshots/entry-two.png +0 -0
  65. {reader-3.20 → reader-3.21}/docs/screenshots/feeds.png +0 -0
  66. {reader-3.20 → reader-3.21}/docs/screenshots/lynx.png +0 -0
  67. {reader-3.20 → reader-3.21}/docs/screenshots/search.png +0 -0
  68. {reader-3.20 → reader-3.21}/docs/tutorial.rst +0 -0
  69. {reader-3.20 → reader-3.21}/docs/why.rst +0 -0
  70. {reader-3.20 → reader-3.21}/examples/config.yaml +0 -0
  71. {reader-3.20 → reader-3.21}/examples/custom_headers.py +0 -0
  72. {reader-3.20 → reader-3.21}/examples/feed_slugs.py +0 -0
  73. {reader-3.20 → reader-3.21}/examples/parser_only.py +0 -0
  74. {reader-3.20 → reader-3.21}/examples/podcast.py +0 -0
  75. {reader-3.20 → reader-3.21}/examples/terminal.py +0 -0
  76. {reader-3.20 → reader-3.21}/scripts/backup.sh +0 -0
  77. {reader-3.20 → reader-3.21}/scripts/bench.py +0 -0
  78. {reader-3.20 → reader-3.21}/scripts/debug_storage_stats.py +0 -0
  79. {reader-3.20 → reader-3.21}/scripts/entry_dedupe_backtest.py +0 -0
  80. {reader-3.20 → reader-3.21}/scripts/jscontrols.html +0 -0
  81. {reader-3.20 → reader-3.21}/scripts/jscontrols.py +0 -0
  82. {reader-3.20 → reader-3.21}/scripts/lines.sh +0 -0
  83. {reader-3.20 → reader-3.21}/scripts/release.py +0 -0
  84. {reader-3.20 → reader-3.21}/setup.cfg +0 -0
  85. {reader-3.20 → reader-3.21}/src/reader/__main__.py +0 -0
  86. {reader-3.20 → reader-3.21}/src/reader/_app/__init__.py +0 -0
  87. {reader-3.20 → reader-3.21}/src/reader/_app/api_thing.py +0 -0
  88. {reader-3.20 → reader-3.21}/src/reader/_app/cli.py +0 -0
  89. {reader-3.20 → reader-3.21}/src/reader/_app/static/controls.js +0 -0
  90. {reader-3.20 → reader-3.21}/src/reader/_app/static/style.css +0 -0
  91. {reader-3.20 → reader-3.21}/src/reader/_app/templates/add_entry.html +0 -0
  92. {reader-3.20 → reader-3.21}/src/reader/_app/templates/entries.html +0 -0
  93. {reader-3.20 → reader-3.21}/src/reader/_app/templates/entry.html +0 -0
  94. {reader-3.20 → reader-3.21}/src/reader/_app/templates/feeds.html +0 -0
  95. {reader-3.20 → reader-3.21}/src/reader/_app/templates/layout.html +0 -0
  96. {reader-3.20 → reader-3.21}/src/reader/_app/templates/macros.html +0 -0
  97. {reader-3.20 → reader-3.21}/src/reader/_app/templates/metadata.html +0 -0
  98. {reader-3.20 → reader-3.21}/src/reader/_app/templates/tags.html +0 -0
  99. {reader-3.20 → reader-3.21}/src/reader/_app/v2/__init__.py +0 -0
  100. {reader-3.20 → reader-3.21}/src/reader/_app/v2/static/style.css +0 -0
  101. {reader-3.20 → reader-3.21}/src/reader/_app/v2/static/theme.js +0 -0
  102. {reader-3.20 → reader-3.21}/src/reader/_app/v2/templates/v2/layout.html +0 -0
  103. {reader-3.20 → reader-3.21}/src/reader/_app/v2/templates/v2/macros.html +0 -0
  104. {reader-3.20 → reader-3.21}/src/reader/_app/wsgi.py +0 -0
  105. {reader-3.20 → reader-3.21}/src/reader/_config.py +0 -0
  106. {reader-3.20 → reader-3.21}/src/reader/_hash_utils.py +0 -0
  107. {reader-3.20 → reader-3.21}/src/reader/_parser/_lazy.py +0 -0
  108. {reader-3.20 → reader-3.21}/src/reader/_parser/_url_utils.py +0 -0
  109. {reader-3.20 → reader-3.21}/src/reader/_parser/feedparser.py +0 -0
  110. {reader-3.20 → reader-3.21}/src/reader/_parser/file.py +0 -0
  111. {reader-3.20 → reader-3.21}/src/reader/_parser/http.py +0 -0
  112. {reader-3.20 → reader-3.21}/src/reader/_parser/jsonfeed.py +0 -0
  113. {reader-3.20 → reader-3.21}/src/reader/_parser/requests/__init__.py +0 -0
  114. {reader-3.20 → reader-3.21}/src/reader/_parser/requests/_lazy.py +0 -0
  115. {reader-3.20 → reader-3.21}/src/reader/_plugins/__init__.py +0 -0
  116. {reader-3.20 → reader-3.21}/src/reader/_plugins/cli_status.py +0 -0
  117. {reader-3.20 → reader-3.21}/src/reader/_plugins/enclosure_tags.py +0 -0
  118. {reader-3.20 → reader-3.21}/src/reader/_plugins/preview_feed_list.py +0 -0
  119. {reader-3.20 → reader-3.21}/src/reader/_plugins/share.py +0 -0
  120. {reader-3.20 → reader-3.21}/src/reader/_plugins/sqlite_releases.py +0 -0
  121. {reader-3.20 → reader-3.21}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  122. {reader-3.20 → reader-3.21}/src/reader/_plugins/timer.py +0 -0
  123. {reader-3.20 → reader-3.21}/src/reader/_storage/__init__.py +0 -0
  124. {reader-3.20 → reader-3.21}/src/reader/_storage/_base.py +0 -0
  125. {reader-3.20 → reader-3.21}/src/reader/_storage/_changes.py +0 -0
  126. {reader-3.20 → reader-3.21}/src/reader/_storage/_entries.py +0 -0
  127. {reader-3.20 → reader-3.21}/src/reader/_storage/_feeds.py +0 -0
  128. {reader-3.20 → reader-3.21}/src/reader/_storage/_html_utils.py +0 -0
  129. {reader-3.20 → reader-3.21}/src/reader/_storage/_schema.py +0 -0
  130. {reader-3.20 → reader-3.21}/src/reader/_storage/_search.py +0 -0
  131. {reader-3.20 → reader-3.21}/src/reader/_storage/_sql_utils.py +0 -0
  132. {reader-3.20 → reader-3.21}/src/reader/_storage/_tags.py +0 -0
  133. {reader-3.20 → reader-3.21}/src/reader/_types.py +0 -0
  134. {reader-3.20 → reader-3.21}/src/reader/_utils.py +0 -0
  135. {reader-3.20 → reader-3.21}/src/reader/_vendor/__init__.py +0 -0
  136. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/__init__.py +0 -0
  137. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/api.py +0 -0
  138. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  139. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  140. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  141. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  142. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  143. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  144. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  145. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  146. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  147. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/encodings.py +0 -0
  148. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  149. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/html.py +0 -0
  150. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/http.py +0 -0
  151. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/mixin.py +0 -0
  152. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  153. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  154. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  155. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  156. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  157. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  158. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  159. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  160. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  161. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  162. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  163. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  164. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  165. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/py.typed +0 -0
  166. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  167. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/sgml.py +0 -0
  168. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/urls.py +0 -0
  169. {reader-3.20 → reader-3.21}/src/reader/_vendor/feedparser/util.py +0 -0
  170. {reader-3.20 → reader-3.21}/src/reader/exceptions.py +0 -0
  171. {reader-3.20 → reader-3.21}/src/reader/plugins/__init__.py +0 -0
  172. {reader-3.20 → reader-3.21}/src/reader/plugins/enclosure_dedupe.py +0 -0
  173. {reader-3.20 → reader-3.21}/src/reader/plugins/mark_as_read.py +0 -0
  174. {reader-3.20 → reader-3.21}/src/reader/plugins/readtime.py +0 -0
  175. {reader-3.20 → reader-3.21}/src/reader/plugins/ua_fallback.py +0 -0
  176. {reader-3.20 → reader-3.21}/src/reader/py.typed +0 -0
  177. {reader-3.20 → reader-3.21}/src/reader/types.py +0 -0
  178. {reader-3.20 → reader-3.21}/src/reader/utils.py +0 -0
  179. {reader-3.20 → reader-3.21}/src/reader.egg-info/SOURCES.txt +0 -0
  180. {reader-3.20 → reader-3.21}/src/reader.egg-info/dependency_links.txt +0 -0
  181. {reader-3.20 → reader-3.21}/src/reader.egg-info/requires.txt +0 -0
  182. {reader-3.20 → reader-3.21}/src/reader.egg-info/top_level.txt +0 -0
  183. {reader-3.20 → reader-3.21}/tests/data/10.json +0 -0
  184. {reader-3.20 → reader-3.21}/tests/data/10.json.py +0 -0
  185. {reader-3.20 → reader-3.21}/tests/data/custom +0 -0
  186. {reader-3.20 → reader-3.21}/tests/data/empty.atom +0 -0
  187. {reader-3.20 → reader-3.21}/tests/data/empty.atom.py +0 -0
  188. {reader-3.20 → reader-3.21}/tests/data/empty.json +0 -0
  189. {reader-3.20 → reader-3.21}/tests/data/empty.json.py +0 -0
  190. {reader-3.20 → reader-3.21}/tests/data/empty.rss +0 -0
  191. {reader-3.20 → reader-3.21}/tests/data/empty.rss.py +0 -0
  192. {reader-3.20 → reader-3.21}/tests/data/full.atom +0 -0
  193. {reader-3.20 → reader-3.21}/tests/data/full.atom.py +0 -0
  194. {reader-3.20 → reader-3.21}/tests/data/full.json +0 -0
  195. {reader-3.20 → reader-3.21}/tests/data/full.json.py +0 -0
  196. {reader-3.20 → reader-3.21}/tests/data/full.rss +0 -0
  197. {reader-3.20 → reader-3.21}/tests/data/full.rss.py +0 -0
  198. {reader-3.20 → reader-3.21}/tests/data/invalid.json +0 -0
  199. {reader-3.20 → reader-3.21}/tests/data/invalid.json.py +0 -0
  200. {reader-3.20 → reader-3.21}/tests/data/relative.atom +0 -0
  201. {reader-3.20 → reader-3.21}/tests/data/relative.atom.py +0 -0
  202. {reader-3.20 → reader-3.21}/tests/data/relative.rss +0 -0
  203. {reader-3.20 → reader-3.21}/tests/data/relative.rss.py +0 -0
  204. {reader-3.20 → reader-3.21}/tests/data/sqlite_releases.html +0 -0
  205. {reader-3.20 → reader-3.21}/tests/data/unknown.json +0 -0
  206. {reader-3.20 → reader-3.21}/tests/data/unknown.json.py +0 -0
  207. {reader-3.20 → reader-3.21}/tests/reader_test_plugins/__init__.py +0 -0
  208. {reader-3.20 → reader-3.21}/tests/reader_test_plugins/good.py +0 -0
  209. {reader-3.20 → reader-3.21}/tests/reader_test_plugins/init_error.py +0 -0
  210. {reader-3.20 → reader-3.21}/tests/reader_test_plugins/missing_dependency.py +0 -0
  211. {reader-3.20 → reader-3.21}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  212. {reader-3.20 → reader-3.21}/tests/test__types.py +0 -0
  213. {reader-3.20 → reader-3.21}/tests/test__utils.py +0 -0
  214. {reader-3.20 → reader-3.21}/tests/test_app_wsgi.py +0 -0
  215. {reader-3.20 → reader-3.21}/tests/test_bench.py +0 -0
  216. {reader-3.20 → reader-3.21}/tests/test_config.py +0 -0
  217. {reader-3.20 → reader-3.21}/tests/test_exceptions.py +0 -0
  218. {reader-3.20 → reader-3.21}/tests/test_hash_utils.py +0 -0
  219. {reader-3.20 → reader-3.21}/tests/test_html_utils.py +0 -0
  220. {reader-3.20 → reader-3.21}/tests/test_lazy_imports.py +0 -0
  221. {reader-3.20 → reader-3.21}/tests/test_parser.py +0 -0
  222. {reader-3.20 → reader-3.21}/tests/test_plugins_cli_status.py +0 -0
  223. {reader-3.20 → reader-3.21}/tests/test_plugins_enclosure_dedupe.py +0 -0
  224. {reader-3.20 → reader-3.21}/tests/test_plugins_preview_feed_list.py +0 -0
  225. {reader-3.20 → reader-3.21}/tests/test_plugins_sqlite_releases.py +0 -0
  226. {reader-3.20 → reader-3.21}/tests/test_plugins_ua_fallback.py +0 -0
  227. {reader-3.20 → reader-3.21}/tests/test_reader_deprecations.py +0 -0
  228. {reader-3.20 → reader-3.21}/tests/test_reader_plugins.py +0 -0
  229. {reader-3.20 → reader-3.21}/tests/test_search.py +0 -0
  230. {reader-3.20 → reader-3.21}/tests/test_sql_utils.py +0 -0
  231. {reader-3.20 → reader-3.21}/tests/test_sqlite_utils.py +0 -0
  232. {reader-3.20 → reader-3.21}/tests/test_storage.py +0 -0
  233. {reader-3.20 → reader-3.21}/tests/test_tags.py +0 -0
  234. {reader-3.20 → reader-3.21}/tests/test_types.py +0 -0
@@ -6,6 +6,40 @@ Changelog
6
6
  :no-index:
7
7
 
8
8
 
9
+ Version 3.21
10
+ ------------
11
+
12
+ Released 2026-01-12
13
+
14
+ * :ref:`Scheduled updates <scheduled>` improvements.
15
+
16
+ * Only update scheduled feeds by default
17
+ (:meth:`update_feeds(scheduled=...) <Reader.update_feeds>`
18
+ defaults to :const:`True`);
19
+ previously, all feeds would be updated.
20
+ (:issue:`379`)
21
+
22
+ .. warning::
23
+
24
+ **This is a minor compatibility break**;
25
+ the previous behavior is a bug –
26
+ *reader* should do the right thing
27
+ even if the user doesn't read the documentation.
28
+
29
+ In the CLI, make ``update --scheduled`` the default.
30
+
31
+ * Honor the Cache-Control (max-age) and Expires HTTP headers.
32
+ (:issue:`376`)
33
+
34
+ * Limit the :attr:`~Feed.update_after` derived from HTTP headers
35
+ to a month in the future.
36
+ (:issue:`384`)
37
+
38
+ * If the Retry-After value is a date,
39
+ make the update interval relative to Date.
40
+
41
+
42
+
9
43
  Version 3.20
10
44
  ------------
11
45
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reader
3
- Version: 3.20
3
+ Version: 3.21
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -270,11 +270,12 @@ Scheduled updates
270
270
  ~~~~~~~~~~~~~~~~~
271
271
 
272
272
  Because different feeds need to be updated at different rates,
273
- *reader* also provides a mechanism for scheduling updates.
273
+ *reader* also provides a mechanism for scheduling updates
274
+ that is enabled by default.
274
275
 
275
276
  On every update, *reader* determines when a feed should be updated next
276
277
  based on a configurable update interval;
277
- :meth:`update_feeds(scheduled=True) <Reader.update_feeds>`
278
+ :meth:`~Reader.update_feeds`
278
279
  updates only the feeds that should be updated at or before the current time.
279
280
 
280
281
  The interval can be **configured by the user**
@@ -289,9 +290,9 @@ such that updates don't happen exactly at the the same time;
289
290
  for example, with a one-hour interval, a jitter of 0.25 means
290
291
  updates will occur any time in the first 15 minutes of each hour.
291
292
 
292
- If the server sends the Retry-After HTTP header with
293
- 429 Too Many Requests or 503 Service Unavailable responses,
294
- :meth:`update_feeds(scheduled=True) <Reader.update_feeds>` will honor it.
293
+ If the server responds with any of the
294
+ Cache-Control max-age, Expires, or Retry-After HTTP headers,
295
+ :meth:`update_feeds(scheduled=True) <Reader.update_feeds>` will honor them.
295
296
 
296
297
 
297
298
  .. note::
@@ -308,6 +309,12 @@ If the server sends the Retry-After HTTP header with
308
309
  .. versionchanged:: 3.15
309
310
  Honor the Retry-After HTTP header.
310
311
 
312
+ .. versionchanged:: 3.21
313
+ Honor the Cache-Control max-age and Expires HTTP headers.
314
+
315
+ .. versionchanged:: 3.21
316
+ Only update scheduled feeds by default.
317
+
311
318
 
312
319
  Update status
313
320
  ~~~~~~~~~~~~~
@@ -92,9 +92,8 @@ unstable-plugins = [
92
92
  [dependency-groups]
93
93
 
94
94
  tests = [
95
- "pytest>=4",
95
+ "pytest>=9",
96
96
  "pytest-randomly",
97
- "pytest-subtests",
98
97
  "pytest-rerunfailures",
99
98
  "coverage",
100
99
  "requests-mock",
@@ -118,6 +117,8 @@ typing = [
118
117
 
119
118
  docs = [
120
119
  "sphinx",
120
+ # TODO: remove when https://github.com/sphinx-doc/sphinx/issues/14186 is solved
121
+ "roman_numerals<4.1",
121
122
  "sphinx-rtd-theme",
122
123
  "click>=7",
123
124
  "sphinx-click",
@@ -149,6 +150,7 @@ markers = [
149
150
  "slow: mark a test as slow.",
150
151
  "requires_lxml: mark a test to only run in places where we have lxml.",
151
152
  "apptest: mark a test as a web app test (skipped sometimes).",
153
+ "noscheduled: force update_feeds(scheduled=...) to false.",
152
154
  "noautoclose: skip 'autoclose' autouse fixture for a test."
153
155
  ]
154
156
  filterwarnings = [
@@ -63,7 +63,7 @@ function typing-dev {
63
63
  }
64
64
 
65
65
  function docs-dev {
66
- rm -r docs/_build/html
66
+ rm -rf docs/_build/html
67
67
  watch sphinx-build -W docs docs/_build/html "$@"
68
68
  }
69
69
 
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.20'
40
+ __version__ = '3.21'
41
41
 
42
42
  # isort: off
43
43
 
@@ -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) }}
@@ -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
+ )
@@ -20,6 +20,7 @@ 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
25
26
  from pathlib import Path
@@ -440,6 +441,19 @@ class LocalConnectionFactory:
440
441
 
441
442
  INLINE_OPTIMIZE_TIMEOUT = 0.1
442
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
+
443
457
  def __init__(
444
458
  self,
445
459
  path: str,
@@ -455,15 +469,14 @@ class LocalConnectionFactory:
455
469
  raise NotImplementedError("is_private() does not work for uri=True")
456
470
  self.kwargs['uri'] = True
457
471
  self.attached: dict[str, str] = {}
458
- self._local = _LocalConnectionFactoryState()
472
+ self._local = self._State()
459
473
  self._local.is_creating_thread = True
460
474
  self._other_threads = False
461
475
  # connect immediately, so exceptions are raised before the first call
462
476
  self.__call__()
463
477
 
464
478
  def __call__(self) -> sqlite3.Connection:
465
- db = self._local.db
466
- if db:
479
+ if db := self._local.db:
467
480
  if not self._local.context_stack:
468
481
  if self._should_optimize(self._local.call_count):
469
482
  self._optimize(db, self.read_only, self.INLINE_OPTIMIZE_TIMEOUT)
@@ -482,15 +495,11 @@ class LocalConnectionFactory:
482
495
  if not self._local.is_creating_thread:
483
496
  self._other_threads = True
484
497
 
485
- # TODO: remove needless cast (needed after mypy 1.16.0 update)
486
- db = cast(
498
+ self._local.db = db = cast(
487
499
  sqlite3.Connection,
488
500
  sqlite3.connect(self._make_uri(self.path), **self.kwargs),
489
501
  )
490
502
 
491
- self._local.db = db
492
- self._local.call_count = 0
493
-
494
503
  try:
495
504
  self.setup_db(db)
496
505
  except BaseException:
@@ -501,8 +510,13 @@ class LocalConnectionFactory:
501
510
  # works on cpython (finalizer runs in thread),
502
511
  # but not on pypy (finalizer runs in main thread);
503
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
+ #
504
518
  self._local.finalizer = weakref.finalize(
505
- threading.current_thread(), self._close, db, self.read_only
519
+ self._local.finalizer_sentinel, self._close, db, self.read_only
506
520
  ) # type: ignore[call-arg]
507
521
 
508
522
  for name, path in self.attached.items():
@@ -541,7 +555,7 @@ class LocalConnectionFactory:
541
555
  return
542
556
  # can't close() a connection from a thread that didn't create it;
543
557
  # SQLAlchemy ignores this as well in SingletonThreadPool.dispose()
544
- if "objects created in a thread" in message:
558
+ if "objects created in a thread" in message: # pragma: no cover
545
559
  return
546
560
  raise
547
561
 
@@ -643,18 +657,6 @@ class LocalConnectionFactory:
643
657
  return path in TEMPORARY_DB_PATHS
644
658
 
645
659
 
646
- class _LocalConnectionFactoryState(threading.local):
647
- def __init__(self) -> None:
648
- self.db: sqlite3.Connection | None = None
649
- self.finalizer: (
650
- weakref.finalize[[sqlite3.Connection], threading.Thread] | None
651
- ) = None
652
- self.is_creating_thread: bool = False
653
- self.context_stack: list[None] = []
654
- self.call_count: int = 0
655
- self.closed: bool = False
656
-
657
-
658
660
  @contextmanager
659
661
  def busy_timeout(
660
662
  db: sqlite3.Connection, seconds: float
@@ -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:
@@ -388,6 +388,9 @@ class Reader:
388
388
  self._enable_search = _enable_search
389
389
  self._update_hooks = UpdateHooks(self)
390
390
 
391
+ #: Override update_feeds(scheduled=...).
392
+ self._scheduled_override = None
393
+
391
394
  if _called_directly:
392
395
  warnings.warn(
393
396
  "Reader objects should be created using make_reader(); the Reader "
@@ -795,7 +798,7 @@ class Reader:
795
798
  broken: bool | None = None,
796
799
  updates_enabled: bool | None = True,
797
800
  new: bool | None = None,
798
- scheduled: bool = False,
801
+ scheduled: bool = True,
799
802
  workers: int = 1,
800
803
  ) -> None:
801
804
  r"""Update all or some of the feeds.
@@ -808,7 +811,7 @@ class Reader:
808
811
  currently, only the exceptions for the first 5 feeds
809
812
  with hook failures are collected.
810
813
 
811
- By default, update all the feeds that have updates enabled.
814
+ By default, update all scheduled feeds that have updates enabled.
812
815
 
813
816
  Roughly equivalent to ``for _ in reader.update_feeds_iter(...): pass``.
814
817
 
@@ -826,6 +829,7 @@ class Reader:
826
829
  / have been updated before. Defaults to None.
827
830
  scheduled (bool):
828
831
  Only update feeds scheduled to be updated.
832
+ Defaults to true.
829
833
  workers (int): Number of threads to use when getting the feeds.
830
834
 
831
835
  Raises:
@@ -876,6 +880,9 @@ class Reader:
876
880
  ``new`` uses :attr:`~Feed.last_retrieved`
877
881
  instead of :attr:`~Feed.last_updated`.
878
882
 
883
+ .. versionchanged:: 3.21
884
+ Only update scheduled feeds by default.
885
+
879
886
  """
880
887
  with self._update_hooks.group("some hooks failed") as hook_errors:
881
888
  results = self.update_feeds_iter(
@@ -913,7 +920,7 @@ class Reader:
913
920
  broken: bool | None = None,
914
921
  updates_enabled: bool | None = True,
915
922
  new: bool | None = None,
916
- scheduled: bool = False,
923
+ scheduled: bool = True,
917
924
  workers: int = 1,
918
925
  _call_feeds_update_hooks: bool = True,
919
926
  ) -> Iterable[UpdateResult]:
@@ -927,7 +934,7 @@ class Reader:
927
934
  and re-raise them as an :exc:`UpdateHookErrorGroup`
928
935
  after updating all the feeds.
929
936
 
930
- By default, update all the feeds that have updates enabled.
937
+ By default, update all scheduled feeds that have updates enabled.
931
938
 
932
939
  Args:
933
940
  feed (str or tuple(str) or Feed or None): Only update the feed with this URL.
@@ -943,6 +950,7 @@ class Reader:
943
950
  / have been updated before. Defaults to None.
944
951
  scheduled (bool):
945
952
  Only update feeds scheduled to be updated.
953
+ Defaults to true.
946
954
  workers (int): Number of threads to use when getting the feeds.
947
955
 
948
956
  Yields:
@@ -1006,8 +1014,15 @@ class Reader:
1006
1014
  ``new`` uses :attr:`~Feed.last_retrieved`
1007
1015
  instead of :attr:`~Feed.last_updated`.
1008
1016
 
1017
+ .. versionchanged:: 3.21
1018
+ Only update scheduled feeds by default.
1019
+
1009
1020
  """
1010
1021
  now = self._now()
1022
+
1023
+ if self._scheduled_override is not None: # pragma: no cover
1024
+ scheduled = self._scheduled_override
1025
+
1011
1026
  filter = FeedFilter.from_args(
1012
1027
  now, feed, tags, broken, updates_enabled, new, scheduled
1013
1028
  )
@@ -1076,7 +1091,10 @@ class Reader:
1076
1091
  """
1077
1092
  _, rv = zero_or_one(
1078
1093
  self.update_feeds_iter(
1079
- 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,
1080
1098
  ),
1081
1099
  lambda: FeedNotFoundError(_feed_argument(feed)),
1082
1100
  )
@@ -1624,8 +1642,6 @@ class Reader:
1624
1642
  recent_sort = self._storage.get_entry_recent_sort(src_entry.resource_id)
1625
1643
  dst_resource_id = _entry_argument(dst)
1626
1644
 
1627
- # FIXME: do not allow copy to the same feed (or at least entry)
1628
-
1629
1645
  attrs = dict(src_entry.__dict__)
1630
1646
  attrs['feed_url'], attrs['id'] = dst_resource_id
1631
1647
 
@@ -7,13 +7,12 @@ reader.entry_dedupe
7
7
 
8
8
  Deduplicate the entries of a feed.
9
9
 
10
- Sometimes, the entry id of some or all the entries in a feed changes
10
+ Sometimes, the id of some or all the entries in a feed changes
11
11
  (e.g. from ``example.com/123`` to ``example.com/entry-title``),
12
12
  causing each entry to appear twice.
13
- :mod:`~reader.plugins.entry_dedupe` addresses this
14
- by copying entry user attributes
15
- like *read* or *important* to the new entry,
16
- and **deleting** the old entries.
13
+ :mod:`~reader.plugins.entry_dedupe` fixes this
14
+ by copying user attributes to the new entry
15
+ and deleting the old one.
17
16
 
18
17
 
19
18
  User attributes
@@ -605,7 +604,7 @@ def normalize_url(url):
605
604
 
606
605
  try:
607
606
  url = urlparse(url)
608
- except ValueError: # pragma: no cover
607
+ except ValueError:
609
608
  return None
610
609
 
611
610
  scheme = url.scheme.lower()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reader
3
- Version: 3.20
3
+ Version: 3.21
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -11,6 +11,7 @@ import reader_methods
11
11
  from fakeparser import Parser
12
12
  from reader import make_reader as original_make_reader
13
13
  from reader._storage import Storage
14
+ from utils import monkeypatch_datetime
14
15
  from utils import monkeypatch_os
15
16
  from utils import monkeypatch_tz
16
17
  from utils import reload_module
@@ -74,6 +75,9 @@ def make_reader(request):
74
75
  if 'parser' in request.fixturenames:
75
76
  reader._parser = request.getfixturevalue('parser')
76
77
 
78
+ if 'noscheduled' in request.keywords:
79
+ reader._scheduled_override = False
80
+
77
81
  return reader
78
82
 
79
83
  return make_reader
@@ -86,6 +90,9 @@ def reader(request):
86
90
  if 'parser' in request.fixturenames:
87
91
  reader._parser = request.getfixturevalue('parser')
88
92
 
93
+ if 'noscheduled' in request.keywords:
94
+ reader._scheduled_override = False
95
+
89
96
  yield reader
90
97
 
91
98
 
@@ -124,6 +131,11 @@ def update_feeds_iter(request):
124
131
  return request.param
125
132
 
126
133
 
134
+ @pytest.fixture(params=reader_methods.update_feeds_methods)
135
+ def update_feeds(request):
136
+ return request.param
137
+
138
+
127
139
  def feed_arg_as_str(feed):
128
140
  return feed.url
129
141