reader 3.19.dev0__tar.gz → 3.20__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.19.dev0 → reader-3.20}/CHANGES.rst +49 -2
  2. {reader-3.19.dev0/src/reader.egg-info → reader-3.20}/PKG-INFO +1 -1
  3. {reader-3.19.dev0 → reader-3.20}/docs/dev.rst +18 -15
  4. {reader-3.19.dev0 → reader-3.20}/pyproject.toml +3 -3
  5. reader-3.20/scripts/entry_dedupe_backtest.py +113 -0
  6. {reader-3.19.dev0 → reader-3.20}/scripts/release.py +9 -19
  7. {reader-3.19.dev0 → reader-3.20}/src/reader/__init__.py +1 -1
  8. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/__init__.py +4 -2
  9. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_base.py +2 -3
  10. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_html_utils.py +16 -2
  11. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_sqlite_utils.py +48 -11
  12. {reader-3.19.dev0 → reader-3.20}/src/reader/_types.py +15 -0
  13. {reader-3.19.dev0 → reader-3.20}/src/reader/core.py +9 -1
  14. reader-3.20/src/reader/plugins/entry_dedupe.py +1135 -0
  15. {reader-3.19.dev0 → reader-3.20/src/reader.egg-info}/PKG-INFO +1 -1
  16. {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/SOURCES.txt +2 -1
  17. {reader-3.19.dev0 → reader-3.20}/tests/fakeparser.py +2 -0
  18. {reader-3.19.dev0 → reader-3.20}/tests/test_app.py +18 -16
  19. {reader-3.19.dev0 → reader-3.20}/tests/test_html_utils.py +17 -10
  20. reader-3.20/tests/test_plugins_entry_dedupe.py +815 -0
  21. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_preview_feed_list.py +1 -1
  22. reader-3.19.dev0/tests/test_reader_context.py → reader-3.20/tests/test_reader_lifecycle.py +86 -10
  23. {reader-3.19.dev0 → reader-3.20}/tests/test_search.py +1 -1
  24. {reader-3.19.dev0 → reader-3.20}/tests/test_storage.py +1 -1
  25. reader-3.19.dev0/src/reader/plugins/entry_dedupe.py +0 -580
  26. reader-3.19.dev0/tests/test_plugins_entry_dedupe.py +0 -805
  27. {reader-3.19.dev0 → reader-3.20}/LICENSE +0 -0
  28. {reader-3.19.dev0 → reader-3.20}/MANIFEST.in +0 -0
  29. {reader-3.19.dev0 → reader-3.20}/README.rst +0 -0
  30. {reader-3.19.dev0 → reader-3.20}/docs/api.rst +0 -0
  31. {reader-3.19.dev0 → reader-3.20}/docs/app.rst +0 -0
  32. {reader-3.19.dev0 → reader-3.20}/docs/changelog.rst +0 -0
  33. {reader-3.19.dev0 → reader-3.20}/docs/cli.rst +0 -0
  34. {reader-3.19.dev0 → reader-3.20}/docs/conf.py +0 -0
  35. {reader-3.19.dev0 → reader-3.20}/docs/config.rst +0 -0
  36. {reader-3.19.dev0 → reader-3.20}/docs/contributing.rst +0 -0
  37. {reader-3.19.dev0 → reader-3.20}/docs/dev-app.rst +0 -0
  38. {reader-3.19.dev0 → reader-3.20}/docs/guide.rst +0 -0
  39. {reader-3.19.dev0 → reader-3.20}/docs/images/redesign-01.png +0 -0
  40. {reader-3.19.dev0 → reader-3.20}/docs/index.rst +0 -0
  41. {reader-3.19.dev0 → reader-3.20}/docs/install.rst +0 -0
  42. {reader-3.19.dev0 → reader-3.20}/docs/internal.rst +0 -0
  43. {reader-3.19.dev0 → reader-3.20}/docs/plugins.rst +0 -0
  44. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/dillo.png +0 -0
  45. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-feed.png +0 -0
  46. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-v2-dark.png +0 -0
  47. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-v2-filters-light.png +0 -0
  48. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries.png +0 -0
  49. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entry-one.png +0 -0
  50. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entry-two.png +0 -0
  51. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/feeds.png +0 -0
  52. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/lynx.png +0 -0
  53. {reader-3.19.dev0 → reader-3.20}/docs/screenshots/search.png +0 -0
  54. {reader-3.19.dev0 → reader-3.20}/docs/tutorial.rst +0 -0
  55. {reader-3.19.dev0 → reader-3.20}/docs/why.rst +0 -0
  56. {reader-3.19.dev0 → reader-3.20}/examples/config.yaml +0 -0
  57. {reader-3.19.dev0 → reader-3.20}/examples/custom_headers.py +0 -0
  58. {reader-3.19.dev0 → reader-3.20}/examples/feed_slugs.py +0 -0
  59. {reader-3.19.dev0 → reader-3.20}/examples/parser_only.py +0 -0
  60. {reader-3.19.dev0 → reader-3.20}/examples/podcast.py +0 -0
  61. {reader-3.19.dev0 → reader-3.20}/examples/terminal.py +0 -0
  62. {reader-3.19.dev0 → reader-3.20}/run.sh +0 -0
  63. {reader-3.19.dev0 → reader-3.20}/scripts/backup.sh +0 -0
  64. {reader-3.19.dev0 → reader-3.20}/scripts/bench.py +0 -0
  65. {reader-3.19.dev0 → reader-3.20}/scripts/debug_storage_stats.py +0 -0
  66. {reader-3.19.dev0 → reader-3.20}/scripts/jscontrols.html +0 -0
  67. {reader-3.19.dev0 → reader-3.20}/scripts/jscontrols.py +0 -0
  68. {reader-3.19.dev0 → reader-3.20}/scripts/lines.sh +0 -0
  69. {reader-3.19.dev0 → reader-3.20}/setup.cfg +0 -0
  70. {reader-3.19.dev0 → reader-3.20}/src/reader/__main__.py +0 -0
  71. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/__init__.py +0 -0
  72. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/api_thing.py +0 -0
  73. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/cli.py +0 -0
  74. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/static/controls.js +0 -0
  75. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/static/style.css +0 -0
  76. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/add_entry.html +0 -0
  77. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/entries.html +0 -0
  78. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/entry.html +0 -0
  79. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/feeds.html +0 -0
  80. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/layout.html +0 -0
  81. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/macros.html +0 -0
  82. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/metadata.html +0 -0
  83. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/tags.html +0 -0
  84. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/__init__.py +0 -0
  85. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/forms.py +0 -0
  86. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/static/style.css +0 -0
  87. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/static/theme.js +0 -0
  88. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/entries.html +0 -0
  89. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/layout.html +0 -0
  90. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/macros.html +0 -0
  91. {reader-3.19.dev0 → reader-3.20}/src/reader/_app/wsgi.py +0 -0
  92. {reader-3.19.dev0 → reader-3.20}/src/reader/_cli.py +0 -0
  93. {reader-3.19.dev0 → reader-3.20}/src/reader/_config.py +0 -0
  94. {reader-3.19.dev0 → reader-3.20}/src/reader/_hash_utils.py +0 -0
  95. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/__init__.py +0 -0
  96. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_http_utils.py +0 -0
  97. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_lazy.py +0 -0
  98. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_url_utils.py +0 -0
  99. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/feedparser.py +0 -0
  100. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/file.py +0 -0
  101. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/http.py +0 -0
  102. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/jsonfeed.py +0 -0
  103. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/requests/__init__.py +0 -0
  104. {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/requests/_lazy.py +0 -0
  105. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/__init__.py +0 -0
  106. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/cli_status.py +0 -0
  107. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/enclosure_tags.py +0 -0
  108. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/preview_feed_list.py +0 -0
  109. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/share.py +0 -0
  110. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/sqlite_releases.py +0 -0
  111. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  112. {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/timer.py +0 -0
  113. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_changes.py +0 -0
  114. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_entries.py +0 -0
  115. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_feeds.py +0 -0
  116. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_schema.py +0 -0
  117. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_search.py +0 -0
  118. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_sql_utils.py +0 -0
  119. {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_tags.py +0 -0
  120. {reader-3.19.dev0 → reader-3.20}/src/reader/_update.py +0 -0
  121. {reader-3.19.dev0 → reader-3.20}/src/reader/_utils.py +0 -0
  122. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/__init__.py +0 -0
  123. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/__init__.py +0 -0
  124. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/api.py +0 -0
  125. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  126. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  127. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  128. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  129. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  130. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  131. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  132. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  133. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  134. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/encodings.py +0 -0
  135. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  136. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/html.py +0 -0
  137. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/http.py +0 -0
  138. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/mixin.py +0 -0
  139. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  140. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  141. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  142. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  143. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  144. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  145. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  146. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  147. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  148. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  149. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  150. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  151. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  152. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/py.typed +0 -0
  153. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  154. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/sgml.py +0 -0
  155. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/urls.py +0 -0
  156. {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/util.py +0 -0
  157. {reader-3.19.dev0 → reader-3.20}/src/reader/exceptions.py +0 -0
  158. {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/__init__.py +0 -0
  159. {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/enclosure_dedupe.py +0 -0
  160. {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/mark_as_read.py +0 -0
  161. {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/readtime.py +0 -0
  162. {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/ua_fallback.py +0 -0
  163. {reader-3.19.dev0 → reader-3.20}/src/reader/py.typed +0 -0
  164. {reader-3.19.dev0 → reader-3.20}/src/reader/types.py +0 -0
  165. {reader-3.19.dev0 → reader-3.20}/src/reader/utils.py +0 -0
  166. {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/dependency_links.txt +0 -0
  167. {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/requires.txt +0 -0
  168. {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/top_level.txt +0 -0
  169. {reader-3.19.dev0 → reader-3.20}/tests/conftest.py +0 -0
  170. {reader-3.19.dev0 → reader-3.20}/tests/data/10.json +0 -0
  171. {reader-3.19.dev0 → reader-3.20}/tests/data/10.json.py +0 -0
  172. {reader-3.19.dev0 → reader-3.20}/tests/data/custom +0 -0
  173. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.atom +0 -0
  174. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.atom.py +0 -0
  175. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.json +0 -0
  176. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.json.py +0 -0
  177. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.rss +0 -0
  178. {reader-3.19.dev0 → reader-3.20}/tests/data/empty.rss.py +0 -0
  179. {reader-3.19.dev0 → reader-3.20}/tests/data/full.atom +0 -0
  180. {reader-3.19.dev0 → reader-3.20}/tests/data/full.atom.py +0 -0
  181. {reader-3.19.dev0 → reader-3.20}/tests/data/full.json +0 -0
  182. {reader-3.19.dev0 → reader-3.20}/tests/data/full.json.py +0 -0
  183. {reader-3.19.dev0 → reader-3.20}/tests/data/full.rss +0 -0
  184. {reader-3.19.dev0 → reader-3.20}/tests/data/full.rss.py +0 -0
  185. {reader-3.19.dev0 → reader-3.20}/tests/data/invalid.json +0 -0
  186. {reader-3.19.dev0 → reader-3.20}/tests/data/invalid.json.py +0 -0
  187. {reader-3.19.dev0 → reader-3.20}/tests/data/relative.atom +0 -0
  188. {reader-3.19.dev0 → reader-3.20}/tests/data/relative.atom.py +0 -0
  189. {reader-3.19.dev0 → reader-3.20}/tests/data/relative.rss +0 -0
  190. {reader-3.19.dev0 → reader-3.20}/tests/data/relative.rss.py +0 -0
  191. {reader-3.19.dev0 → reader-3.20}/tests/data/sqlite_releases.html +0 -0
  192. {reader-3.19.dev0 → reader-3.20}/tests/data/unknown.json +0 -0
  193. {reader-3.19.dev0 → reader-3.20}/tests/data/unknown.json.py +0 -0
  194. {reader-3.19.dev0 → reader-3.20}/tests/reader_methods.py +0 -0
  195. {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/__init__.py +0 -0
  196. {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/good.py +0 -0
  197. {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/init_error.py +0 -0
  198. {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/missing_dependency.py +0 -0
  199. {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  200. {reader-3.19.dev0 → reader-3.20}/tests/test__types.py +0 -0
  201. {reader-3.19.dev0 → reader-3.20}/tests/test__utils.py +0 -0
  202. {reader-3.19.dev0 → reader-3.20}/tests/test_app_wsgi.py +0 -0
  203. {reader-3.19.dev0 → reader-3.20}/tests/test_bench.py +0 -0
  204. {reader-3.19.dev0 → reader-3.20}/tests/test_changes.py +0 -0
  205. {reader-3.19.dev0 → reader-3.20}/tests/test_cli.py +0 -0
  206. {reader-3.19.dev0 → reader-3.20}/tests/test_config.py +0 -0
  207. {reader-3.19.dev0 → reader-3.20}/tests/test_exceptions.py +0 -0
  208. {reader-3.19.dev0 → reader-3.20}/tests/test_hash_utils.py +0 -0
  209. {reader-3.19.dev0 → reader-3.20}/tests/test_lazy_imports.py +0 -0
  210. {reader-3.19.dev0 → reader-3.20}/tests/test_parser.py +0 -0
  211. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_cli_status.py +0 -0
  212. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_enclosure_dedupe.py +0 -0
  213. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_mark_as_read.py +0 -0
  214. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_readtime.py +0 -0
  215. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_sqlite_releases.py +0 -0
  216. {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_ua_fallback.py +0 -0
  217. {reader-3.19.dev0 → reader-3.20}/tests/test_reader.py +0 -0
  218. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_counts.py +0 -0
  219. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_deprecations.py +0 -0
  220. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_filter.py +0 -0
  221. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_hooks.py +0 -0
  222. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_integration.py +0 -0
  223. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_plugins.py +0 -0
  224. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_private.py +0 -0
  225. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_search.py +0 -0
  226. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_sort.py +0 -0
  227. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_update.py +0 -0
  228. {reader-3.19.dev0 → reader-3.20}/tests/test_reader_utils.py +0 -0
  229. {reader-3.19.dev0 → reader-3.20}/tests/test_sql_utils.py +0 -0
  230. {reader-3.19.dev0 → reader-3.20}/tests/test_sqlite_utils.py +0 -0
  231. {reader-3.19.dev0 → reader-3.20}/tests/test_tags.py +0 -0
  232. {reader-3.19.dev0 → reader-3.20}/tests/test_types.py +0 -0
  233. {reader-3.19.dev0 → reader-3.20}/tests/utils.py +0 -0
  234. {reader-3.19.dev0 → reader-3.20}/tox.ini +0 -0
@@ -6,18 +6,65 @@ Changelog
6
6
  :no-index:
7
7
 
8
8
 
9
+ Version 3.20
10
+ ------------
11
+
12
+ Released 2025-11-26
13
+
14
+ * Add the ``read_only`` :func:`make_reader` argument.
15
+ Thanks to `Roman Milko`_ for the PR.
16
+ (:issue:`319`)
17
+
18
+ * Include the ``alt`` and ``title`` HTML attributes
19
+ in the :ref:`full-text search <fts>` index.
20
+ Also, don't add extra whitespace between tags
21
+ (especially important for inline tags).
22
+ (:issue:`371`)
23
+
24
+ .. note::
25
+
26
+ Old entries will **not** be re-indexed automatically;
27
+ to do so, disable then enable search.
28
+
29
+ * :mod:`~reader.plugins.entry_dedupe` improvements. (:issue:`371`)
30
+
31
+ * Use more heuristics to find potential duplicates
32
+ (in addition to title matching):
33
+ match link, match published timestamp,
34
+ strip common title prefixes for new entries.
35
+ * When comparing entries,
36
+ include the ``alt`` and ``title`` HTML attributes,
37
+ strip accents, and treat dates and versions as single tokens.
38
+ * Increase required minimum content length from 32 to 48 words.
39
+ * Add the ``.reader.dedupe.once.link`` and
40
+ ``.reader.dedupe.once.title.prefix`` escape hatches.
41
+ * Clean up documentation, refactor code, and add extensive tests.
42
+
43
+ * Log run times of before/after update hooks.
44
+
45
+ .. _Roman Milko: https://github.com/rastr-0
46
+
47
+
9
48
  Version 3.19
10
49
  ------------
11
50
 
12
- Unreleased
51
+ Released 2025-10-01
13
52
 
14
53
  * Support Python 3.14.
54
+ * Remove deprecated empty ``search`` and ``readtime`` extras.
55
+ * Remove undocumented development ``tests``, ``docs``, and ``dev`` extras.
15
56
 
16
57
  * :mod:`~reader._plugins.enclosure_tags` plugin:
17
58
  strip HTML tags from values used as MP3 tags
18
59
  (for example, ``<b>`` tags used to highlight search result matches).
19
60
 
20
- * Remove long-deprecated, empty ``search`` and ``readtime`` extras.
61
+ * Project infrastructure improvements.
62
+
63
+ * Clean up :doc:`contributing` documentation and :gh:`run.sh <run.sh>` script.
64
+ * Use GitHub Actions to publish releases to PyPI.
65
+ * Split type checking and testing jobs in the ``build`` GHA workflow.
66
+ * Make tox runs slightly faster.
67
+ * Use dependency groups instead of extras for development dependencies.
21
68
 
22
69
 
23
70
  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.20
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,
@@ -99,15 +99,15 @@ tests = [
99
99
  "coverage",
100
100
  "requests-mock",
101
101
  # mechanicalsoup hard-depends on lxml (see below)
102
- 'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12")',
102
+ 'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.14")',
103
103
  "requests-wsgi-adapter",
104
104
  # we want to test search with all known bs4 parsers.
105
105
  # lxml usually does not have recent PyPy wheels.
106
106
  # lxml usually does not have pre-relase CPython wheels.
107
- 'lxml; (implementation_name != "pypy" and python_version <= "3.12")',
107
+ 'lxml; (implementation_name != "pypy" and python_version <= "3.14")',
108
108
  "html5lib",
109
109
  # for bench.py
110
- 'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12")',
110
+ 'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.14")',
111
111
  ]
112
112
 
113
113
  typing = [
@@ -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.20'
41
41
 
42
42
  # isort: off
43
43
 
@@ -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])
@@ -22,6 +22,7 @@ from contextlib import nullcontext
22
22
  from dataclasses import dataclass
23
23
  from datetime import datetime
24
24
  from datetime import timezone
25
+ from pathlib import Path
25
26
  from typing import Any
26
27
  from typing import cast
27
28
  from typing import no_type_check
@@ -119,6 +120,7 @@ def wrap_exceptions(
119
120
  for fragment, op_exc_type in op_exc_types.items():
120
121
  if fragment.lower() in e_msg:
121
122
  raise op_exc_type(str(e)) from None
123
+
122
124
  raise exc_type(message) from e
123
125
 
124
126
  except sqlite3.ProgrammingError as e:
@@ -210,7 +212,6 @@ class IdError(DBError):
210
212
 
211
213
  db_errors = [DBError, SchemaVersionError, IntegrityError, RequirementError]
212
214
 
213
-
214
215
  _DBFunction = Callable[[sqlite3.Connection], None]
215
216
 
216
217
 
@@ -411,6 +412,9 @@ class UsageError(DBError):
411
412
  display_name = "usage error"
412
413
 
413
414
 
415
+ TEMPORARY_DB_PATHS = {':memory:', ''}
416
+
417
+
414
418
  class LocalConnectionFactory:
415
419
  """Maintain a set of connections to the same database, one per thread.
416
420
 
@@ -437,13 +441,19 @@ class LocalConnectionFactory:
437
441
  INLINE_OPTIMIZE_TIMEOUT = 0.1
438
442
 
439
443
  def __init__(
440
- self, path: str, setup_db: _DBFunction = lambda _: None, **kwargs: Any
444
+ self,
445
+ path: str,
446
+ setup_db: _DBFunction = lambda _: None,
447
+ read_only: bool = False,
448
+ **kwargs: Any,
441
449
  ):
442
450
  self.path = path
443
451
  self.setup_db = setup_db
452
+ self.read_only = read_only
444
453
  self.kwargs = kwargs
445
454
  if kwargs.get('uri'): # pragma: no cover
446
455
  raise NotImplementedError("is_private() does not work for uri=True")
456
+ self.kwargs['uri'] = True
447
457
  self.attached: dict[str, str] = {}
448
458
  self._local = _LocalConnectionFactoryState()
449
459
  self._local.is_creating_thread = True
@@ -456,7 +466,7 @@ class LocalConnectionFactory:
456
466
  if db:
457
467
  if not self._local.context_stack:
458
468
  if self._should_optimize(self._local.call_count):
459
- self._optimize(db, self.INLINE_OPTIMIZE_TIMEOUT)
469
+ self._optimize(db, self.read_only, self.INLINE_OPTIMIZE_TIMEOUT)
460
470
  self._local.call_count += 1
461
471
  return db
462
472
 
@@ -473,7 +483,11 @@ class LocalConnectionFactory:
473
483
  self._other_threads = True
474
484
 
475
485
  # TODO: remove needless cast (needed after mypy 1.16.0 update)
476
- db = cast(sqlite3.Connection, sqlite3.connect(self.path, **self.kwargs))
486
+ db = cast(
487
+ sqlite3.Connection,
488
+ sqlite3.connect(self._make_uri(self.path), **self.kwargs),
489
+ )
490
+
477
491
  self._local.db = db
478
492
  self._local.call_count = 0
479
493
 
@@ -488,8 +502,8 @@ class LocalConnectionFactory:
488
502
  # but not on pypy (finalizer runs in main thread);
489
503
  # also see https://bugs.python.org/issue14073
490
504
  self._local.finalizer = weakref.finalize(
491
- threading.current_thread(), self._close, db
492
- )
505
+ threading.current_thread(), self._close, db, self.read_only
506
+ ) # type: ignore[call-arg]
493
507
 
494
508
  for name, path in self.attached.items():
495
509
  self._attach(db, name, path)
@@ -514,10 +528,10 @@ class LocalConnectionFactory:
514
528
  self._local.closed = True
515
529
 
516
530
  @classmethod
517
- def _close(cls, db: sqlite3.Connection) -> None:
531
+ def _close(cls, db: sqlite3.Connection, read_only: bool = False) -> None:
518
532
  try:
519
533
  try:
520
- cls._optimize(db)
534
+ cls._optimize(db, read_only)
521
535
  finally:
522
536
  db.close()
523
537
  except sqlite3.ProgrammingError as e:
@@ -532,7 +546,13 @@ class LocalConnectionFactory:
532
546
  raise
533
547
 
534
548
  @staticmethod
535
- def _optimize(db: sqlite3.Connection, timeout: float = 0) -> None:
549
+ def _optimize(
550
+ db: sqlite3.Connection, read_only: bool = False, timeout: float = 0
551
+ ) -> None:
552
+ # don't optimize the database if it's in a read-only state
553
+ if read_only:
554
+ return
555
+
536
556
  # Don't wait too much for a lock, it means the database is busy,
537
557
  # and now is likely not a good time to run optimize.
538
558
  # Also prevents rare "database is locked" errors in certain conditions
@@ -552,6 +572,7 @@ class LocalConnectionFactory:
552
572
  db.execute("PRAGMA optimize;")
553
573
  except sqlite3.OperationalError as e: # pragma: no cover
554
574
  message = str(e).lower()
575
+
555
576
  if "database is locked" not in message:
556
577
  raise
557
578
 
@@ -581,11 +602,27 @@ class LocalConnectionFactory:
581
602
  self._attach(db, name, path)
582
603
 
583
604
  def _attach(self, db: sqlite3.Connection, name: str, path: str) -> None:
584
- db.execute("ATTACH DATABASE ? AS ?;", (path, name))
605
+ db.execute("ATTACH DATABASE ? AS ?;", (self._make_uri(path), name))
585
606
 
586
607
  def is_private(self) -> bool:
587
608
  return self._is_private(self.path)
588
609
 
610
+ def _make_uri(self, path: str) -> str:
611
+ # SQLite allows relative paths by skipping the // after file:,
612
+ # but pathlib.Path.as_uri() does not support this;
613
+ # however, temporary databases *must* be relative
614
+ if path in TEMPORARY_DB_PATHS:
615
+ # arbitrary paths would need to quote ? to avoid qs injection
616
+ uri = f"file:{path}"
617
+ else:
618
+ # bypasses SQLite's resolution of relative paths, should be fine
619
+ uri = Path(path).absolute().as_uri()
620
+
621
+ if self.read_only:
622
+ uri += "?mode=ro"
623
+
624
+ return uri
625
+
589
626
  @staticmethod
590
627
  def _is_private(path: str) -> bool:
591
628
  """Does connect(path) from another thread connect to a different database?
@@ -603,7 +640,7 @@ class LocalConnectionFactory:
603
640
  https://www.sqlite.org/uri.html
604
641
 
605
642
  """
606
- return path in [':memory:', '']
643
+ return path in TEMPORARY_DB_PATHS
607
644
 
608
645
 
609
646
  class _LocalConnectionFactoryState(threading.local):
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import time
4
5
  from collections import defaultdict
5
6
  from collections.abc import Callable
6
7
  from collections.abc import Iterable
@@ -672,6 +673,7 @@ class UpdateHooks:
672
673
  ) -> list[SingleUpdateHookError]:
673
674
  rv = []
674
675
  for hook in self.hooks[when]:
676
+ start = time.monotonic()
675
677
  try:
676
678
  hook(self.target, *args)
677
679
  except Exception as e:
@@ -680,6 +682,19 @@ class UpdateHooks:
680
682
  if not return_exceptions:
681
683
  raise wrapper
682
684
  rv.append(wrapper)
685
+ finally:
686
+ end = time.monotonic()
687
+ try:
688
+ name = hook.__module__ + ':' + hook.__qualname__
689
+ except AttributeError:
690
+ name = repr(hook)
691
+ log.debug(
692
+ "ran %s hook %s for %r in %f seconds",
693
+ when,
694
+ name,
695
+ resource_id,
696
+ end - start,
697
+ )
683
698
  return rv
684
699
 
685
700
  def group(self, message: str) -> _UpdateHookErrorGrouper:
@@ -103,6 +103,7 @@ def make_reader(
103
103
  url: str,
104
104
  *,
105
105
  feed_root: str | None = None,
106
+ read_only: bool = False,
106
107
  plugins: Iterable[PluginInput] = DEFAULT_PLUGINS,
107
108
  session_timeout: TimeoutType = DEFAULT_TIMEOUT,
108
109
  reserved_name_scheme: Mapping[str, str] = DEFAULT_RESERVED_NAME_SCHEME,
@@ -166,6 +167,9 @@ def make_reader(
166
167
  ``'/path/to/feed/root'``
167
168
  (an absolute path that feed paths are relative to).
168
169
 
170
+ read_only (bool):
171
+ Only allow read-only storage operations.
172
+
169
173
  plugins (iterable(str or callable(Reader)) or None):
170
174
  An iterable of built-in plugin names or
171
175
  `plugin(reader) --> None` callables.
@@ -236,6 +240,9 @@ def make_reader(
236
240
  Wrap exceptions raised during plugin initialization
237
241
  in :exc:`PluginInitError` instead of letting them bubble up.
238
242
 
243
+ .. versionadded:: 3.20
244
+ The ``read_only`` keyword argument.
245
+
239
246
  """
240
247
 
241
248
  # Do as much work as possible before creating the storage.
@@ -263,12 +270,13 @@ def make_reader(
263
270
  # See this comment for details on how it should evolve:
264
271
  # https://github.com/lemon24/reader/issues/168#issuecomment-642002049
265
272
 
266
- storage: StorageType = _storage or Storage(url)
273
+ storage: StorageType = _storage or Storage(url, read_only=read_only)
267
274
 
268
275
  try:
269
276
  # For now, we're using a storage-bound search provider.
270
277
  if not isinstance(storage, BoundSearchStorageType): # pragma: no cover
271
278
  raise TypeError("storage must have a make_search factory")
279
+
272
280
  search = storage.make_search()
273
281
 
274
282
  if search_enabled is True: