reader 3.19.dev0__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 (236) hide show
  1. {reader-3.19.dev0 → reader-3.21}/CHANGES.rst +83 -2
  2. {reader-3.19.dev0/src/reader.egg-info → reader-3.21}/PKG-INFO +1 -1
  3. {reader-3.19.dev0 → reader-3.21}/docs/dev.rst +18 -15
  4. {reader-3.19.dev0 → reader-3.21}/docs/guide.rst +12 -5
  5. {reader-3.19.dev0 → reader-3.21}/pyproject.toml +7 -5
  6. {reader-3.19.dev0 → reader-3.21}/run.sh +1 -1
  7. reader-3.21/scripts/entry_dedupe_backtest.py +113 -0
  8. {reader-3.19.dev0 → reader-3.21}/scripts/release.py +9 -19
  9. {reader-3.19.dev0 → reader-3.21}/src/reader/__init__.py +1 -1
  10. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/forms.py +2 -1
  11. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/entries.html +2 -0
  12. {reader-3.19.dev0 → reader-3.21}/src/reader/_cli.py +4 -1
  13. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/__init__.py +52 -13
  14. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_http_utils.py +9 -1
  15. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/__init__.py +4 -2
  16. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_base.py +2 -3
  17. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_html_utils.py +16 -2
  18. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_sqlite_utils.py +69 -30
  19. {reader-3.19.dev0 → reader-3.21}/src/reader/_types.py +15 -0
  20. {reader-3.19.dev0 → reader-3.21}/src/reader/_update.py +11 -10
  21. {reader-3.19.dev0 → reader-3.21}/src/reader/core.py +32 -8
  22. reader-3.21/src/reader/plugins/entry_dedupe.py +1134 -0
  23. {reader-3.19.dev0 → reader-3.21/src/reader.egg-info}/PKG-INFO +1 -1
  24. {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/SOURCES.txt +2 -1
  25. {reader-3.19.dev0 → reader-3.21}/tests/conftest.py +12 -0
  26. {reader-3.19.dev0 → reader-3.21}/tests/fakeparser.py +27 -34
  27. {reader-3.19.dev0 → reader-3.21}/tests/reader_methods.py +45 -10
  28. {reader-3.19.dev0 → reader-3.21}/tests/test_app.py +24 -19
  29. {reader-3.19.dev0 → reader-3.21}/tests/test_changes.py +3 -0
  30. {reader-3.19.dev0 → reader-3.21}/tests/test_cli.py +4 -4
  31. {reader-3.19.dev0 → reader-3.21}/tests/test_html_utils.py +17 -10
  32. reader-3.21/tests/test_plugins_entry_dedupe.py +833 -0
  33. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_mark_as_read.py +3 -0
  34. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_preview_feed_list.py +1 -1
  35. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_readtime.py +3 -0
  36. {reader-3.19.dev0 → reader-3.21}/tests/test_reader.py +36 -1298
  37. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_counts.py +1 -1
  38. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_filter.py +103 -109
  39. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_hooks.py +3 -0
  40. reader-3.21/tests/test_reader_integration.py +207 -0
  41. reader-3.19.dev0/tests/test_reader_context.py → reader-3.21/tests/test_reader_lifecycle.py +92 -18
  42. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_private.py +3 -0
  43. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_search.py +15 -23
  44. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_sort.py +7 -0
  45. reader-3.21/tests/test_reader_update.py +1255 -0
  46. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_utils.py +3 -0
  47. {reader-3.19.dev0 → reader-3.21}/tests/test_search.py +1 -1
  48. {reader-3.19.dev0 → reader-3.21}/tests/test_storage.py +1 -1
  49. {reader-3.19.dev0 → reader-3.21}/tests/utils.py +20 -0
  50. {reader-3.19.dev0 → reader-3.21}/tox.ini +1 -1
  51. reader-3.19.dev0/src/reader/plugins/entry_dedupe.py +0 -580
  52. reader-3.19.dev0/tests/test_plugins_entry_dedupe.py +0 -805
  53. reader-3.19.dev0/tests/test_reader_integration.py +0 -209
  54. reader-3.19.dev0/tests/test_reader_update.py +0 -172
  55. {reader-3.19.dev0 → reader-3.21}/LICENSE +0 -0
  56. {reader-3.19.dev0 → reader-3.21}/MANIFEST.in +0 -0
  57. {reader-3.19.dev0 → reader-3.21}/README.rst +0 -0
  58. {reader-3.19.dev0 → reader-3.21}/docs/api.rst +0 -0
  59. {reader-3.19.dev0 → reader-3.21}/docs/app.rst +0 -0
  60. {reader-3.19.dev0 → reader-3.21}/docs/changelog.rst +0 -0
  61. {reader-3.19.dev0 → reader-3.21}/docs/cli.rst +0 -0
  62. {reader-3.19.dev0 → reader-3.21}/docs/conf.py +0 -0
  63. {reader-3.19.dev0 → reader-3.21}/docs/config.rst +0 -0
  64. {reader-3.19.dev0 → reader-3.21}/docs/contributing.rst +0 -0
  65. {reader-3.19.dev0 → reader-3.21}/docs/dev-app.rst +0 -0
  66. {reader-3.19.dev0 → reader-3.21}/docs/images/redesign-01.png +0 -0
  67. {reader-3.19.dev0 → reader-3.21}/docs/index.rst +0 -0
  68. {reader-3.19.dev0 → reader-3.21}/docs/install.rst +0 -0
  69. {reader-3.19.dev0 → reader-3.21}/docs/internal.rst +0 -0
  70. {reader-3.19.dev0 → reader-3.21}/docs/plugins.rst +0 -0
  71. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/dillo.png +0 -0
  72. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-feed.png +0 -0
  73. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-v2-dark.png +0 -0
  74. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-v2-filters-light.png +0 -0
  75. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries.png +0 -0
  76. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entry-one.png +0 -0
  77. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entry-two.png +0 -0
  78. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/feeds.png +0 -0
  79. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/lynx.png +0 -0
  80. {reader-3.19.dev0 → reader-3.21}/docs/screenshots/search.png +0 -0
  81. {reader-3.19.dev0 → reader-3.21}/docs/tutorial.rst +0 -0
  82. {reader-3.19.dev0 → reader-3.21}/docs/why.rst +0 -0
  83. {reader-3.19.dev0 → reader-3.21}/examples/config.yaml +0 -0
  84. {reader-3.19.dev0 → reader-3.21}/examples/custom_headers.py +0 -0
  85. {reader-3.19.dev0 → reader-3.21}/examples/feed_slugs.py +0 -0
  86. {reader-3.19.dev0 → reader-3.21}/examples/parser_only.py +0 -0
  87. {reader-3.19.dev0 → reader-3.21}/examples/podcast.py +0 -0
  88. {reader-3.19.dev0 → reader-3.21}/examples/terminal.py +0 -0
  89. {reader-3.19.dev0 → reader-3.21}/scripts/backup.sh +0 -0
  90. {reader-3.19.dev0 → reader-3.21}/scripts/bench.py +0 -0
  91. {reader-3.19.dev0 → reader-3.21}/scripts/debug_storage_stats.py +0 -0
  92. {reader-3.19.dev0 → reader-3.21}/scripts/jscontrols.html +0 -0
  93. {reader-3.19.dev0 → reader-3.21}/scripts/jscontrols.py +0 -0
  94. {reader-3.19.dev0 → reader-3.21}/scripts/lines.sh +0 -0
  95. {reader-3.19.dev0 → reader-3.21}/setup.cfg +0 -0
  96. {reader-3.19.dev0 → reader-3.21}/src/reader/__main__.py +0 -0
  97. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/__init__.py +0 -0
  98. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/api_thing.py +0 -0
  99. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/cli.py +0 -0
  100. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/static/controls.js +0 -0
  101. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/static/style.css +0 -0
  102. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/add_entry.html +0 -0
  103. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/entries.html +0 -0
  104. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/entry.html +0 -0
  105. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/feeds.html +0 -0
  106. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/layout.html +0 -0
  107. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/macros.html +0 -0
  108. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/metadata.html +0 -0
  109. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/tags.html +0 -0
  110. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/__init__.py +0 -0
  111. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/static/style.css +0 -0
  112. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/static/theme.js +0 -0
  113. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/layout.html +0 -0
  114. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/macros.html +0 -0
  115. {reader-3.19.dev0 → reader-3.21}/src/reader/_app/wsgi.py +0 -0
  116. {reader-3.19.dev0 → reader-3.21}/src/reader/_config.py +0 -0
  117. {reader-3.19.dev0 → reader-3.21}/src/reader/_hash_utils.py +0 -0
  118. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_lazy.py +0 -0
  119. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_url_utils.py +0 -0
  120. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/feedparser.py +0 -0
  121. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/file.py +0 -0
  122. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/http.py +0 -0
  123. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/jsonfeed.py +0 -0
  124. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/requests/__init__.py +0 -0
  125. {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/requests/_lazy.py +0 -0
  126. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/__init__.py +0 -0
  127. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/cli_status.py +0 -0
  128. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/enclosure_tags.py +0 -0
  129. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/preview_feed_list.py +0 -0
  130. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/share.py +0 -0
  131. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/sqlite_releases.py +0 -0
  132. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  133. {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/timer.py +0 -0
  134. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_changes.py +0 -0
  135. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_entries.py +0 -0
  136. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_feeds.py +0 -0
  137. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_schema.py +0 -0
  138. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_search.py +0 -0
  139. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_sql_utils.py +0 -0
  140. {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_tags.py +0 -0
  141. {reader-3.19.dev0 → reader-3.21}/src/reader/_utils.py +0 -0
  142. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/__init__.py +0 -0
  143. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/__init__.py +0 -0
  144. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/api.py +0 -0
  145. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  146. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  147. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  148. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  149. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  150. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  151. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  152. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  153. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  154. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/encodings.py +0 -0
  155. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  156. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/html.py +0 -0
  157. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/http.py +0 -0
  158. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/mixin.py +0 -0
  159. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  160. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  161. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  162. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  163. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  164. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  165. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  166. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  167. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  168. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  169. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  170. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  171. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  172. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/py.typed +0 -0
  173. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  174. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/sgml.py +0 -0
  175. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/urls.py +0 -0
  176. {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/util.py +0 -0
  177. {reader-3.19.dev0 → reader-3.21}/src/reader/exceptions.py +0 -0
  178. {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/__init__.py +0 -0
  179. {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/enclosure_dedupe.py +0 -0
  180. {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/mark_as_read.py +0 -0
  181. {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/readtime.py +0 -0
  182. {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/ua_fallback.py +0 -0
  183. {reader-3.19.dev0 → reader-3.21}/src/reader/py.typed +0 -0
  184. {reader-3.19.dev0 → reader-3.21}/src/reader/types.py +0 -0
  185. {reader-3.19.dev0 → reader-3.21}/src/reader/utils.py +0 -0
  186. {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/dependency_links.txt +0 -0
  187. {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/requires.txt +0 -0
  188. {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/top_level.txt +0 -0
  189. {reader-3.19.dev0 → reader-3.21}/tests/data/10.json +0 -0
  190. {reader-3.19.dev0 → reader-3.21}/tests/data/10.json.py +0 -0
  191. {reader-3.19.dev0 → reader-3.21}/tests/data/custom +0 -0
  192. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.atom +0 -0
  193. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.atom.py +0 -0
  194. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.json +0 -0
  195. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.json.py +0 -0
  196. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.rss +0 -0
  197. {reader-3.19.dev0 → reader-3.21}/tests/data/empty.rss.py +0 -0
  198. {reader-3.19.dev0 → reader-3.21}/tests/data/full.atom +0 -0
  199. {reader-3.19.dev0 → reader-3.21}/tests/data/full.atom.py +0 -0
  200. {reader-3.19.dev0 → reader-3.21}/tests/data/full.json +0 -0
  201. {reader-3.19.dev0 → reader-3.21}/tests/data/full.json.py +0 -0
  202. {reader-3.19.dev0 → reader-3.21}/tests/data/full.rss +0 -0
  203. {reader-3.19.dev0 → reader-3.21}/tests/data/full.rss.py +0 -0
  204. {reader-3.19.dev0 → reader-3.21}/tests/data/invalid.json +0 -0
  205. {reader-3.19.dev0 → reader-3.21}/tests/data/invalid.json.py +0 -0
  206. {reader-3.19.dev0 → reader-3.21}/tests/data/relative.atom +0 -0
  207. {reader-3.19.dev0 → reader-3.21}/tests/data/relative.atom.py +0 -0
  208. {reader-3.19.dev0 → reader-3.21}/tests/data/relative.rss +0 -0
  209. {reader-3.19.dev0 → reader-3.21}/tests/data/relative.rss.py +0 -0
  210. {reader-3.19.dev0 → reader-3.21}/tests/data/sqlite_releases.html +0 -0
  211. {reader-3.19.dev0 → reader-3.21}/tests/data/unknown.json +0 -0
  212. {reader-3.19.dev0 → reader-3.21}/tests/data/unknown.json.py +0 -0
  213. {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/__init__.py +0 -0
  214. {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/good.py +0 -0
  215. {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/init_error.py +0 -0
  216. {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/missing_dependency.py +0 -0
  217. {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  218. {reader-3.19.dev0 → reader-3.21}/tests/test__types.py +0 -0
  219. {reader-3.19.dev0 → reader-3.21}/tests/test__utils.py +0 -0
  220. {reader-3.19.dev0 → reader-3.21}/tests/test_app_wsgi.py +0 -0
  221. {reader-3.19.dev0 → reader-3.21}/tests/test_bench.py +0 -0
  222. {reader-3.19.dev0 → reader-3.21}/tests/test_config.py +0 -0
  223. {reader-3.19.dev0 → reader-3.21}/tests/test_exceptions.py +0 -0
  224. {reader-3.19.dev0 → reader-3.21}/tests/test_hash_utils.py +0 -0
  225. {reader-3.19.dev0 → reader-3.21}/tests/test_lazy_imports.py +0 -0
  226. {reader-3.19.dev0 → reader-3.21}/tests/test_parser.py +0 -0
  227. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_cli_status.py +0 -0
  228. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_enclosure_dedupe.py +0 -0
  229. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_sqlite_releases.py +0 -0
  230. {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_ua_fallback.py +0 -0
  231. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_deprecations.py +0 -0
  232. {reader-3.19.dev0 → reader-3.21}/tests/test_reader_plugins.py +0 -0
  233. {reader-3.19.dev0 → reader-3.21}/tests/test_sql_utils.py +0 -0
  234. {reader-3.19.dev0 → reader-3.21}/tests/test_sqlite_utils.py +0 -0
  235. {reader-3.19.dev0 → reader-3.21}/tests/test_tags.py +0 -0
  236. {reader-3.19.dev0 → reader-3.21}/tests/test_types.py +0 -0
@@ -6,18 +6,99 @@ 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
+
43
+ Version 3.20
44
+ ------------
45
+
46
+ Released 2025-11-26
47
+
48
+ * Add the ``read_only`` :func:`make_reader` argument.
49
+ Thanks to `Roman Milko`_ for the PR.
50
+ (:issue:`319`)
51
+
52
+ * Include the ``alt`` and ``title`` HTML attributes
53
+ in the :ref:`full-text search <fts>` index.
54
+ Also, don't add extra whitespace between tags
55
+ (especially important for inline tags).
56
+ (:issue:`371`)
57
+
58
+ .. note::
59
+
60
+ Old entries will **not** be re-indexed automatically;
61
+ to do so, disable then enable search.
62
+
63
+ * :mod:`~reader.plugins.entry_dedupe` improvements. (:issue:`371`)
64
+
65
+ * Use more heuristics to find potential duplicates
66
+ (in addition to title matching):
67
+ match link, match published timestamp,
68
+ strip common title prefixes for new entries.
69
+ * When comparing entries,
70
+ include the ``alt`` and ``title`` HTML attributes,
71
+ strip accents, and treat dates and versions as single tokens.
72
+ * Increase required minimum content length from 32 to 48 words.
73
+ * Add the ``.reader.dedupe.once.link`` and
74
+ ``.reader.dedupe.once.title.prefix`` escape hatches.
75
+ * Clean up documentation, refactor code, and add extensive tests.
76
+
77
+ * Log run times of before/after update hooks.
78
+
79
+ .. _Roman Milko: https://github.com/rastr-0
80
+
81
+
9
82
  Version 3.19
10
83
  ------------
11
84
 
12
- Unreleased
85
+ Released 2025-10-01
13
86
 
14
87
  * Support Python 3.14.
88
+ * Remove deprecated empty ``search`` and ``readtime`` extras.
89
+ * Remove undocumented development ``tests``, ``docs``, and ``dev`` extras.
15
90
 
16
91
  * :mod:`~reader._plugins.enclosure_tags` plugin:
17
92
  strip HTML tags from values used as MP3 tags
18
93
  (for example, ``<b>`` tags used to highlight search result matches).
19
94
 
20
- * Remove long-deprecated, empty ``search`` and ``readtime`` extras.
95
+ * Project infrastructure improvements.
96
+
97
+ * Clean up :doc:`contributing` documentation and :gh:`run.sh <run.sh>` script.
98
+ * Use GitHub Actions to publish releases to PyPI.
99
+ * Split type checking and testing jobs in the ``build`` GHA workflow.
100
+ * Make tox runs slightly faster.
101
+ * Use dependency groups instead of extras for development dependencies.
21
102
 
22
103
 
23
104
  Version 3.18
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reader
3
- Version: 3.19.dev0
3
+ Version: 3.21
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -226,25 +226,31 @@ Making a release
226
226
 
227
227
  .. note::
228
228
 
229
- :gh:`scripts/release.py <scripts/release.py>` already does most of these.
229
+ The :gh:`release.py <scripts/release.py>` script together with
230
+ the :gh:`publish <.github/workflows/publish.yaml>` GitHub Actions workflow
231
+ already do most of these.
230
232
 
231
233
  Making a release (from ``x`` to ``y`` == ``x + 1``):
232
234
 
233
235
  * (release.py) bump version in ``src/reader/__init__.py`` to ``y``
234
236
  * (release.py) update changelog with release version and date
235
237
  * (release.py) make sure tests pass / docs build
236
- * (release.py) clean up dist/: ``rm -rf dist/``
237
- * (release.py) build tarball and wheel: ``python -m build``
238
238
  * (release.py) push to GitHub
239
- * (release.py prompts) wait for GitHub Actions / Codecov / Read the Docs builds to pass
240
- * upload to test PyPI and check: ``twine upload --repository-url https://test.pypi.org/legacy/ dist/*``
241
- * (release.py) upload to PyPI: ``twine upload dist/*``
239
+ * (release.py prompts) wait for GitHub Actions / Read the Docs builds to pass
242
240
  * (release.py) tag current commit with `<major>.<minor>` and `<major>.x`
243
- (e.g. when releasing `1.20`: `1.20` and `1.x`)
244
- * (release.py prompts) create release in GitHub
245
- * build docs from latest and enable ``y`` docs version (should happen automatically after the first time)
241
+ (e.g. when releasing `1.20`: `1.20` and `1.x`) and push tags to GitHub
242
+ * (release.py prompts) approve publish workflow
243
+ * (publish workflow) build and upload to PyPI
244
+ * alternatively, to make a manual release (should not be needed)
245
+
246
+ * clean up dist/: ``rm -rf dist/``
247
+ * build tarball and wheel: ``python -m build``
248
+ * upload to test PyPI and check: ``twine upload --repository-url https://test.pypi.org/legacy/ dist/*``
249
+ * upload to PyPI: ``twine upload dist/*``
250
+
251
+ * (publish workflow) create draft release in GitHub
252
+ * (release.py prompts) publish release in GitHub
246
253
  * (release.py) bump versions from ``y`` to ``(y + 1).dev0``, add ``(y + 1)`` changelog section
247
- * (release.py prompts) trigger Read the Docs build for `<major>.x` (doesn't happen automatically)
248
254
 
249
255
 
250
256
 
@@ -750,11 +756,8 @@ Thoughts on dynamic lists of feeds: :issue:`165#issuecomment-905893420`.
750
756
  Duplicate entries
751
757
  ~~~~~~~~~~~~~~~~~
752
758
 
753
- Duplicate entries are mainly handled by the :mod:`reader.entry_dedupe` plugin.
754
-
755
- * Using MinHash to speed up similarity checks (maybe): https://gist.github.com/lemon24/b9af5ade919713406bda9603847d32e5
756
- * Discussion of unifying "on-line" dedupe (after an entry is added/updated),
757
- and "on-demand" dedupe (backfill): :issue:`340`.
759
+ Duplicate entries are handled by :mod:`~reader.plugins.entry_dedupe`
760
+ (extensive notes in the :gh:`docstring <src/reader/plugins/entry_dedupe.py>`).
758
761
 
759
762
  However, it is also possible for a feed to have two entries with the same id
760
763
  – yes, even though in most (if not all) formats,
@@ -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,22 +92,21 @@ 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",
101
100
  # mechanicalsoup hard-depends on lxml (see below)
102
- 'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12")',
101
+ 'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.14")',
103
102
  "requests-wsgi-adapter",
104
103
  # we want to test search with all known bs4 parsers.
105
104
  # lxml usually does not have recent PyPy wheels.
106
105
  # lxml usually does not have pre-relase CPython wheels.
107
- 'lxml; (implementation_name != "pypy" and python_version <= "3.12")',
106
+ 'lxml; (implementation_name != "pypy" and python_version <= "3.14")',
108
107
  "html5lib",
109
108
  # for bench.py
110
- 'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12")',
109
+ 'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.14")',
111
110
  ]
112
111
 
113
112
  typing = [
@@ -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
 
@@ -0,0 +1,113 @@
1
+ """
2
+ Run entry_dedupe .dedupe.once for an entire database,
3
+ and parse the logs into a form that makes backtesting easier.
4
+
5
+ Run dedupe and pretty-print logs:
6
+
7
+ $ python scripts/entry_dedupe_backtest.py dedupe.log run
8
+
9
+ Pretty-print existing logs:
10
+
11
+ $ python scripts/entry_dedupe_backtest.py dedupe.log
12
+
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ import time
20
+
21
+ from reader import make_reader
22
+ from reader.plugins import entry_dedupe
23
+
24
+
25
+ DB_ARCHIVE = "_backups/reader.sqlite.2025-10-04.gz"
26
+
27
+ file = sys.argv[1]
28
+ run = len(sys.argv) > 2 and sys.argv[2] == 'run'
29
+ only = len(sys.argv) > 3 and sys.argv[3]
30
+
31
+ if run:
32
+ os.system(f"rm db.*; gzip -dc {DB_ARCHIVE} > db.sqlite")
33
+ reader = make_reader('db.sqlite')
34
+ logging.basicConfig(filename=file, filemode='w', level=logging.DEBUG)
35
+ start = time.monotonic()
36
+ for feed in reader.get_feeds():
37
+ if only and not re.search(only, feed.title or '', re.I):
38
+ continue
39
+ reader.set_tag(feed, '.reader.dedupe.once')
40
+ entry_dedupe.after_feed_update(reader, feed)
41
+ end = time.monotonic()
42
+ print(f"TIME: {end-start:.3f} seconds", file=sys.stderr)
43
+ reader.close()
44
+ os.system(f"rm db.*; gzip -dc {DB_ARCHIVE} > db.sqlite")
45
+
46
+ reader = make_reader('db.sqlite')
47
+
48
+
49
+ def shorten(s, n=48):
50
+ if not s:
51
+ return s
52
+ s = ' '.join(s.split())
53
+ if len(s) < n:
54
+ return s
55
+ return s[:n] + '...'
56
+
57
+
58
+ feed = None
59
+ entries = None
60
+ grouper = None
61
+ prev = ''
62
+
63
+ for l in open(file):
64
+ if 'skipping:' in prev and 'skipping:' not in l:
65
+ print()
66
+ if 'skipping:' not in prev and 'skipping:' in l:
67
+ print(' HUGE\n')
68
+ prev = l
69
+
70
+ if m := re.search(r"for feed '([^']+)'", l):
71
+ feed = reader.get_feed(m[1])
72
+ entries = {e.id: e for e in reader.get_entries(feed=feed)}
73
+ grouper = None
74
+ print(f"{feed.title} {feed.url}\n")
75
+ continue
76
+
77
+ if m := re.search("grouper ([^:]+): all=.*", l):
78
+ grouper = m[1]
79
+ print(f" grouper {grouper}\n")
80
+ continue
81
+
82
+ # if m := re.search(r"found mass duplication pairs \d+ < \d+, skipping", l):
83
+ # print(" TOO FEW PAIRS\n")
84
+ # continue
85
+
86
+ if m := re.search(r"found group of size \d+ > \d+, skipping: (.*)", l):
87
+ ids = eval(m[1])
88
+ e = entries[ids[0]]
89
+ fragment = ''
90
+ if 'link' in grouper:
91
+ fragment = e.link
92
+ elif 'published' in grouper:
93
+ fragment = e.published or e.updated
94
+ print(f" {len(ids):>3} {shorten(e.title)} {fragment}")
95
+ continue
96
+
97
+ if m := re.search(r"grouper \S+: found (\[.*)", l):
98
+ ids = eval(m[1])
99
+ if ids:
100
+ print(' DUPLICATES\n')
101
+ for ids in ids:
102
+ for i, id in enumerate(ids):
103
+ e = entries[id]
104
+ print(f" {shorten(e.title, 64)} {e.id}")
105
+ if 'link' in grouper:
106
+ print(f" link = {e.link}")
107
+ if 'published' in grouper:
108
+ print(f" published = {e.published or e.updated}")
109
+ print()
110
+ continue
111
+
112
+ if m := re.search(r"grouper \S+: group count by size (.*)", l):
113
+ print(f" summary {m[1]}\n")
@@ -34,11 +34,6 @@ def run_tox():
34
34
  run_no_venv('tox -p all', shell=True)
35
35
 
36
36
 
37
- def build():
38
- run('rm -rf dist/', shell=True)
39
- run('python -m build', shell=True)
40
-
41
-
42
37
  def path_sub(pattern, repl, path):
43
38
  with open(path) as f:
44
39
  text = f.read()
@@ -112,10 +107,6 @@ def check_unpushed():
112
107
  abort("unpushed changes\n\n{}\n", p.stdout.strip('\n'))
113
108
 
114
109
 
115
- def upload_to_pypi():
116
- run('twine upload dist/*', shell=True)
117
-
118
-
119
110
  def add_and_push_tags(tags):
120
111
  for tag in tags:
121
112
  run(['git', 'tag', '--force', tag])
@@ -150,10 +141,6 @@ def main(version, new_version, date, tox, changelog):
150
141
 
151
142
  confirm("Wait for GitHub Actions / Read the Docs builds to pass.")
152
143
 
153
- confirm("Upload to PyPI?")
154
- build()
155
- upload_to_pypi()
156
-
157
144
  tags = [version]
158
145
  version_x = version.partition('.')[0] + '.x'
159
146
  if new_version_is_final:
@@ -162,7 +149,15 @@ def main(version, new_version, date, tox, changelog):
162
149
  confirm(f"Add and push tags ({', '.join(tags)})?")
163
150
  add_and_push_tags(tags)
164
151
 
165
- confirm("Create release {} in GitHub (doesn't happen automatically).", version)
152
+ confirm(
153
+ "Approve publish workflow to upload to PyPI.\n {}\nDone?",
154
+ "https://github.com/lemon24/reader/actions/workflows/publish.yaml",
155
+ )
156
+ confirm(
157
+ "Publish draft release {} in GitHub.\n {}\nDone?",
158
+ version,
159
+ "https://github.com/lemon24/reader/releases",
160
+ )
166
161
 
167
162
  new_version_full = f"{new_version}.dev0"
168
163
  update_init_version(new_version_full)
@@ -173,11 +168,6 @@ def main(version, new_version, date, tox, changelog):
173
168
  confirm("Push version {}?", new_version_full)
174
169
  push()
175
170
 
176
- # TODO: I just enabled branch or tag creation/deletion for the RtD webhook in GitHub, this might not be needed next time.
177
- confirm(
178
- f"Trigger Read the Docs build for {version_x} (doesn't happen automatically)."
179
- )
180
-
181
171
 
182
172
  if __name__ == '__main__':
183
173
  main()
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.19.dev0'
40
+ __version__ = '3.21'
41
41
 
42
42
  # isort: off
43
43
 
@@ -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
+ )
@@ -29,8 +29,10 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
29
29
 
30
30
  """
31
31
 
32
- def __init__(self, path: str, timeout: float | None = None):
33
- super().__init__(path, timeout)
32
+ def __init__(
33
+ self, path: str, read_only: bool = False, timeout: float | None = None
34
+ ):
35
+ super().__init__(path, read_only, timeout)
34
36
  self.changes: ChangeTrackerType = Changes(self)
35
37
 
36
38
  def make_search(self) -> SearchType:
@@ -20,7 +20,6 @@ APPLICATION_ID = b'read'
20
20
 
21
21
  _T = TypeVar('_T')
22
22
 
23
-
24
23
  # also used by tests
25
24
  CONNECTION_CLS = sqlite3.Connection
26
25
 
@@ -52,7 +51,7 @@ class StorageBase:
52
51
  chunk_size = 2**8
53
52
 
54
53
  @wrap_exceptions(message="while opening database")
55
- def __init__(self, path: str, timeout: float | None = None):
54
+ def __init__(self, path: str, read_only: bool, timeout: float | None = None):
56
55
  kwargs: dict[str, Any] = {'factory': CONNECTION_CLS}
57
56
  if timeout is not None:
58
57
  kwargs['timeout'] = timeout
@@ -61,7 +60,7 @@ class StorageBase:
61
60
  # has to run for every connection (in every thread),
62
61
  # since it's not persisted across connections
63
62
  self.factory = _sqlite_utils.LocalConnectionFactory(
64
- path, self.setup_db, **kwargs
63
+ path, self.setup_db, read_only, **kwargs
65
64
  )
66
65
 
67
66
  def get_db(self) -> sqlite3.Connection:
@@ -41,7 +41,8 @@ warnings.filterwarnings(
41
41
  def strip_html(html: str, features: str | None = None) -> str:
42
42
  soup = get_soup(html)
43
43
  remove_nontext_elements(soup)
44
- return soup.get_text(separator=' ')
44
+ add_text_attrs_as_elements(soup)
45
+ return soup.get_text()
45
46
 
46
47
 
47
48
  def get_soup(html: str, features: str | None = None) -> bs4.BeautifulSoup:
@@ -59,4 +60,17 @@ def remove_nontext_elements(soup: bs4.BeautifulSoup) -> None:
59
60
  # shows any free-floating text it contains, so we should keep it around.
60
61
  #
61
62
  for e in soup.select('script, noscript, style, title'):
62
- e.replace_with('\n')
63
+ e.decompose()
64
+
65
+
66
+ def add_text_attrs_as_elements(soup: bs4.BeautifulSoup) -> None:
67
+ for e in soup.select('img[alt], area[alt], [title]'):
68
+ strings = []
69
+ if e.name == 'img' or e.name == 'area':
70
+ if s := e.attrs.get('alt'):
71
+ strings.append(s)
72
+ if s := e.attrs.get('title'):
73
+ if s not in strings:
74
+ strings.append(s)
75
+ if strings:
76
+ e.insert_after(*[soup.new_tag('span', string=f" {s} ") for s in strings])