reader 3.11__tar.gz → 3.12__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 (225) hide show
  1. {reader-3.11 → reader-3.12}/CHANGES.rst +22 -0
  2. {reader-3.11/src/reader.egg-info → reader-3.12}/PKG-INFO +1 -1
  3. {reader-3.11 → reader-3.12}/docs/dev.rst +7 -2
  4. {reader-3.11 → reader-3.12}/docs/guide.rst +23 -10
  5. {reader-3.11 → reader-3.12}/docs/internal.rst +26 -3
  6. {reader-3.11 → reader-3.12}/src/reader/__init__.py +1 -1
  7. {reader-3.11 → reader-3.12}/src/reader/_app/cli.py +0 -3
  8. {reader-3.11 → reader-3.12}/src/reader/_cli.py +3 -30
  9. reader-3.12/src/reader/_storage/__init__.py +39 -0
  10. reader-3.12/src/reader/_storage/_base.py +110 -0
  11. reader-3.12/src/reader/_storage/_changes.py +213 -0
  12. reader-3.12/src/reader/_storage/_entries.py +654 -0
  13. reader-3.12/src/reader/_storage/_feeds.py +404 -0
  14. {reader-3.11/src/reader → reader-3.12/src/reader/_storage}/_html_utils.py +3 -1
  15. reader-3.12/src/reader/_storage/_schema.py +284 -0
  16. reader-3.12/src/reader/_storage/_search.py +643 -0
  17. {reader-3.11/src/reader → reader-3.12/src/reader/_storage}/_sql_utils.py +94 -26
  18. {reader-3.11/src/reader → reader-3.12/src/reader/_storage}/_sqlite_utils.py +123 -104
  19. reader-3.12/src/reader/_storage/_tags.py +250 -0
  20. {reader-3.11 → reader-3.12}/src/reader/_types.py +152 -0
  21. {reader-3.11 → reader-3.12}/src/reader/_utils.py +0 -60
  22. {reader-3.11 → reader-3.12}/src/reader/core.py +1 -2
  23. {reader-3.11 → reader-3.12}/src/reader/exceptions.py +12 -0
  24. {reader-3.11 → reader-3.12}/src/reader/plugins/readtime.py +2 -2
  25. {reader-3.11 → reader-3.12}/src/reader/types.py +10 -0
  26. {reader-3.11 → reader-3.12/src/reader.egg-info}/PKG-INFO +1 -1
  27. {reader-3.11 → reader-3.12}/src/reader.egg-info/SOURCES.txt +12 -5
  28. {reader-3.11 → reader-3.12}/tests/conftest.py +0 -2
  29. reader-3.12/tests/test_changes.py +427 -0
  30. {reader-3.11 → reader-3.12}/tests/test_html_utils.py +1 -1
  31. {reader-3.11 → reader-3.12}/tests/test_reader.py +1 -3
  32. {reader-3.11 → reader-3.12}/tests/test_reader_context.py +4 -2
  33. {reader-3.11 → reader-3.12}/tests/test_reader_search.py +112 -276
  34. reader-3.12/tests/test_search.py +218 -0
  35. {reader-3.11 → reader-3.12}/tests/test_sql_utils.py +2 -2
  36. {reader-3.11 → reader-3.12}/tests/test_sqlite_utils.py +124 -89
  37. {reader-3.11 → reader-3.12}/tests/test_storage.py +41 -60
  38. {reader-3.11 → reader-3.12}/tox.ini +1 -1
  39. reader-3.11/src/reader/_search.py +0 -988
  40. reader-3.11/src/reader/_storage.py +0 -1808
  41. reader-3.11/tests/test_search.py +0 -204
  42. {reader-3.11 → reader-3.12}/LICENSE +0 -0
  43. {reader-3.11 → reader-3.12}/MANIFEST.in +0 -0
  44. {reader-3.11 → reader-3.12}/README.rst +0 -0
  45. {reader-3.11 → reader-3.12}/docs/Makefile +0 -0
  46. {reader-3.11 → reader-3.12}/docs/api.rst +0 -0
  47. {reader-3.11 → reader-3.12}/docs/app.rst +0 -0
  48. {reader-3.11 → reader-3.12}/docs/changelog.rst +0 -0
  49. {reader-3.11 → reader-3.12}/docs/cli.rst +0 -0
  50. {reader-3.11 → reader-3.12}/docs/conf.py +0 -0
  51. {reader-3.11 → reader-3.12}/docs/config.rst +0 -0
  52. {reader-3.11 → reader-3.12}/docs/contributing.rst +0 -0
  53. {reader-3.11 → reader-3.12}/docs/dev-app.rst +0 -0
  54. {reader-3.11 → reader-3.12}/docs/images/redesign-01.png +0 -0
  55. {reader-3.11 → reader-3.12}/docs/index.rst +0 -0
  56. {reader-3.11 → reader-3.12}/docs/install.rst +0 -0
  57. {reader-3.11 → reader-3.12}/docs/make.bat +0 -0
  58. {reader-3.11 → reader-3.12}/docs/plugins.rst +0 -0
  59. {reader-3.11 → reader-3.12}/docs/screenshots/dillo.png +0 -0
  60. {reader-3.11 → reader-3.12}/docs/screenshots/entries-feed.png +0 -0
  61. {reader-3.11 → reader-3.12}/docs/screenshots/entries.png +0 -0
  62. {reader-3.11 → reader-3.12}/docs/screenshots/entry-one.png +0 -0
  63. {reader-3.11 → reader-3.12}/docs/screenshots/entry-two.png +0 -0
  64. {reader-3.11 → reader-3.12}/docs/screenshots/feeds.png +0 -0
  65. {reader-3.11 → reader-3.12}/docs/screenshots/lynx.png +0 -0
  66. {reader-3.11 → reader-3.12}/docs/screenshots/search.png +0 -0
  67. {reader-3.11 → reader-3.12}/docs/tutorial.rst +0 -0
  68. {reader-3.11 → reader-3.12}/docs/why.rst +0 -0
  69. {reader-3.11 → reader-3.12}/examples/config.yaml +0 -0
  70. {reader-3.11 → reader-3.12}/examples/parser_only.py +0 -0
  71. {reader-3.11 → reader-3.12}/examples/podcast.py +0 -0
  72. {reader-3.11 → reader-3.12}/examples/terminal.py +0 -0
  73. {reader-3.11 → reader-3.12}/pyproject.toml +0 -0
  74. {reader-3.11 → reader-3.12}/run.sh +0 -0
  75. {reader-3.11 → reader-3.12}/scripts/.coverage +0 -0
  76. {reader-3.11 → reader-3.12}/scripts/backup.sh +0 -0
  77. {reader-3.11 → reader-3.12}/scripts/bench.py +0 -0
  78. {reader-3.11 → reader-3.12}/scripts/debug_storage_stats.py +0 -0
  79. {reader-3.11 → reader-3.12}/scripts/generate_import_all.py +0 -0
  80. {reader-3.11 → reader-3.12}/scripts/jscontrols.html +0 -0
  81. {reader-3.11 → reader-3.12}/scripts/jscontrols.py +0 -0
  82. {reader-3.11 → reader-3.12}/scripts/lines.sh +0 -0
  83. {reader-3.11 → reader-3.12}/scripts/release.py +0 -0
  84. {reader-3.11 → reader-3.12}/setup.cfg +0 -0
  85. {reader-3.11 → reader-3.12}/src/reader/__main__.py +0 -0
  86. {reader-3.11 → reader-3.12}/src/reader/_app/__init__.py +0 -0
  87. {reader-3.11 → reader-3.12}/src/reader/_app/api_thing.py +0 -0
  88. {reader-3.11 → reader-3.12}/src/reader/_app/static/controls.js +0 -0
  89. {reader-3.11 → reader-3.12}/src/reader/_app/static/style.css +0 -0
  90. {reader-3.11 → reader-3.12}/src/reader/_app/templates/add_entry.html +0 -0
  91. {reader-3.11 → reader-3.12}/src/reader/_app/templates/entries.html +0 -0
  92. {reader-3.11 → reader-3.12}/src/reader/_app/templates/entry.html +0 -0
  93. {reader-3.11 → reader-3.12}/src/reader/_app/templates/feeds.html +0 -0
  94. {reader-3.11 → reader-3.12}/src/reader/_app/templates/layout.html +0 -0
  95. {reader-3.11 → reader-3.12}/src/reader/_app/templates/macros.html +0 -0
  96. {reader-3.11 → reader-3.12}/src/reader/_app/templates/metadata.html +0 -0
  97. {reader-3.11 → reader-3.12}/src/reader/_app/templates/tags.html +0 -0
  98. {reader-3.11 → reader-3.12}/src/reader/_app/wsgi.py +0 -0
  99. {reader-3.11 → reader-3.12}/src/reader/_config.py +0 -0
  100. {reader-3.11 → reader-3.12}/src/reader/_hash_utils.py +0 -0
  101. {reader-3.11 → reader-3.12}/src/reader/_parser/__init__.py +0 -0
  102. {reader-3.11 → reader-3.12}/src/reader/_parser/_http_utils.py +0 -0
  103. {reader-3.11 → reader-3.12}/src/reader/_parser/_lazy.py +0 -0
  104. {reader-3.11 → reader-3.12}/src/reader/_parser/_url_utils.py +0 -0
  105. {reader-3.11 → reader-3.12}/src/reader/_parser/feedparser.py +0 -0
  106. {reader-3.11 → reader-3.12}/src/reader/_parser/file.py +0 -0
  107. {reader-3.11 → reader-3.12}/src/reader/_parser/http.py +0 -0
  108. {reader-3.11 → reader-3.12}/src/reader/_parser/jsonfeed.py +0 -0
  109. {reader-3.11 → reader-3.12}/src/reader/_parser/requests/__init__.py +0 -0
  110. {reader-3.11 → reader-3.12}/src/reader/_parser/requests/_lazy.py +0 -0
  111. {reader-3.11 → reader-3.12}/src/reader/_plugins/__init__.py +0 -0
  112. {reader-3.11 → reader-3.12}/src/reader/_plugins/cli_status.py +0 -0
  113. {reader-3.11 → reader-3.12}/src/reader/_plugins/enclosure_tags.py +0 -0
  114. {reader-3.11 → reader-3.12}/src/reader/_plugins/preview_feed_list.py +0 -0
  115. {reader-3.11 → reader-3.12}/src/reader/_plugins/share.py +0 -0
  116. {reader-3.11 → reader-3.12}/src/reader/_plugins/sqlite_releases.py +0 -0
  117. {reader-3.11 → reader-3.12}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  118. {reader-3.11 → reader-3.12}/src/reader/_plugins/timer.py +0 -0
  119. {reader-3.11 → reader-3.12}/src/reader/_update.py +0 -0
  120. {reader-3.11 → reader-3.12}/src/reader/_vendor/__init__.py +0 -0
  121. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/__init__.py +0 -0
  122. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/api.py +0 -0
  123. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  124. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  125. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  126. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  127. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  128. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  129. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  130. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  131. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  132. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/encodings.py +0 -0
  133. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  134. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/html.py +0 -0
  135. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/http.py +0 -0
  136. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/mixin.py +0 -0
  137. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  138. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  139. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  140. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  141. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  142. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  143. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  144. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  145. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  146. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  147. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  148. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  149. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  150. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/py.typed +0 -0
  151. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  152. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/sgml.py +0 -0
  153. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/urls.py +0 -0
  154. {reader-3.11 → reader-3.12}/src/reader/_vendor/feedparser/util.py +0 -0
  155. {reader-3.11 → reader-3.12}/src/reader/plugins/__init__.py +0 -0
  156. {reader-3.11 → reader-3.12}/src/reader/plugins/enclosure_dedupe.py +0 -0
  157. {reader-3.11 → reader-3.12}/src/reader/plugins/entry_dedupe.py +0 -0
  158. {reader-3.11 → reader-3.12}/src/reader/plugins/mark_as_read.py +0 -0
  159. {reader-3.11 → reader-3.12}/src/reader/plugins/ua_fallback.py +0 -0
  160. {reader-3.11 → reader-3.12}/src/reader/py.typed +0 -0
  161. {reader-3.11 → reader-3.12}/src/reader.egg-info/dependency_links.txt +0 -0
  162. {reader-3.11 → reader-3.12}/src/reader.egg-info/requires.txt +0 -0
  163. {reader-3.11 → reader-3.12}/src/reader.egg-info/top_level.txt +0 -0
  164. {reader-3.11 → reader-3.12}/tests/data/10.json +0 -0
  165. {reader-3.11 → reader-3.12}/tests/data/10.json.py +0 -0
  166. {reader-3.11 → reader-3.12}/tests/data/custom +0 -0
  167. {reader-3.11 → reader-3.12}/tests/data/empty.atom +0 -0
  168. {reader-3.11 → reader-3.12}/tests/data/empty.atom.py +0 -0
  169. {reader-3.11 → reader-3.12}/tests/data/empty.json +0 -0
  170. {reader-3.11 → reader-3.12}/tests/data/empty.json.py +0 -0
  171. {reader-3.11 → reader-3.12}/tests/data/empty.rss +0 -0
  172. {reader-3.11 → reader-3.12}/tests/data/empty.rss.py +0 -0
  173. {reader-3.11 → reader-3.12}/tests/data/full.atom +0 -0
  174. {reader-3.11 → reader-3.12}/tests/data/full.atom.py +0 -0
  175. {reader-3.11 → reader-3.12}/tests/data/full.json +0 -0
  176. {reader-3.11 → reader-3.12}/tests/data/full.json.py +0 -0
  177. {reader-3.11 → reader-3.12}/tests/data/full.rss +0 -0
  178. {reader-3.11 → reader-3.12}/tests/data/full.rss.py +0 -0
  179. {reader-3.11 → reader-3.12}/tests/data/invalid.json +0 -0
  180. {reader-3.11 → reader-3.12}/tests/data/invalid.json.py +0 -0
  181. {reader-3.11 → reader-3.12}/tests/data/relative.atom +0 -0
  182. {reader-3.11 → reader-3.12}/tests/data/relative.atom.py +0 -0
  183. {reader-3.11 → reader-3.12}/tests/data/relative.rss +0 -0
  184. {reader-3.11 → reader-3.12}/tests/data/relative.rss.py +0 -0
  185. {reader-3.11 → reader-3.12}/tests/data/sqlite_releases.html +0 -0
  186. {reader-3.11 → reader-3.12}/tests/data/unknown.json +0 -0
  187. {reader-3.11 → reader-3.12}/tests/data/unknown.json.py +0 -0
  188. {reader-3.11 → reader-3.12}/tests/fakeparser.py +0 -0
  189. {reader-3.11 → reader-3.12}/tests/reader_methods.py +0 -0
  190. {reader-3.11 → reader-3.12}/tests/reader_test_plugins/__init__.py +0 -0
  191. {reader-3.11 → reader-3.12}/tests/reader_test_plugins/good.py +0 -0
  192. {reader-3.11 → reader-3.12}/tests/reader_test_plugins/init_error.py +0 -0
  193. {reader-3.11 → reader-3.12}/tests/reader_test_plugins/missing_dependency.py +0 -0
  194. {reader-3.11 → reader-3.12}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  195. {reader-3.11 → reader-3.12}/tests/test__types.py +0 -0
  196. {reader-3.11 → reader-3.12}/tests/test_app.py +0 -0
  197. {reader-3.11 → reader-3.12}/tests/test_app_wsgi.py +0 -0
  198. {reader-3.11 → reader-3.12}/tests/test_bench.py +0 -0
  199. {reader-3.11 → reader-3.12}/tests/test_cli.py +0 -0
  200. {reader-3.11 → reader-3.12}/tests/test_config.py +0 -0
  201. {reader-3.11 → reader-3.12}/tests/test_exceptions.py +0 -0
  202. {reader-3.11 → reader-3.12}/tests/test_hash_utils.py +0 -0
  203. {reader-3.11 → reader-3.12}/tests/test_lazy_imports.py +0 -0
  204. {reader-3.11 → reader-3.12}/tests/test_parser.py +0 -0
  205. {reader-3.11 → reader-3.12}/tests/test_plugins_cli_status.py +0 -0
  206. {reader-3.11 → reader-3.12}/tests/test_plugins_enclosure_dedupe.py +0 -0
  207. {reader-3.11 → reader-3.12}/tests/test_plugins_entry_dedupe.py +0 -0
  208. {reader-3.11 → reader-3.12}/tests/test_plugins_mark_as_read.py +0 -0
  209. {reader-3.11 → reader-3.12}/tests/test_plugins_preview_feed_list.py +0 -0
  210. {reader-3.11 → reader-3.12}/tests/test_plugins_readtime.py +0 -0
  211. {reader-3.11 → reader-3.12}/tests/test_plugins_sqlite_releases.py +0 -0
  212. {reader-3.11 → reader-3.12}/tests/test_plugins_ua_fallback.py +0 -0
  213. {reader-3.11 → reader-3.12}/tests/test_reader_counts.py +0 -0
  214. {reader-3.11 → reader-3.12}/tests/test_reader_deprecations.py +0 -0
  215. {reader-3.11 → reader-3.12}/tests/test_reader_filter.py +0 -0
  216. {reader-3.11 → reader-3.12}/tests/test_reader_hooks.py +0 -0
  217. {reader-3.11 → reader-3.12}/tests/test_reader_integration.py +0 -0
  218. {reader-3.11 → reader-3.12}/tests/test_reader_plugins.py +0 -0
  219. {reader-3.11 → reader-3.12}/tests/test_reader_private.py +0 -0
  220. {reader-3.11 → reader-3.12}/tests/test_reader_sort.py +0 -0
  221. {reader-3.11 → reader-3.12}/tests/test_tags.py +0 -0
  222. {reader-3.11 → reader-3.12}/tests/test_test_utils.py +0 -0
  223. {reader-3.11 → reader-3.12}/tests/test_types.py +0 -0
  224. {reader-3.11 → reader-3.12}/tests/test_utils.py +0 -0
  225. {reader-3.11 → reader-3.12}/tests/utils.py +0 -0
@@ -6,6 +6,28 @@ Changelog
6
6
  :noindex:
7
7
 
8
8
 
9
+ Version 3.12
10
+ ------------
11
+
12
+ Released 2024-03-05
13
+
14
+ * Split the :ref:`full-text search <fts>` index into a separate,
15
+ attached database.
16
+ (:issue:`323`)
17
+ * Require at least SQLite 3.18.
18
+ Previously, *reader* core required 3.15,
19
+ and only :meth:`~Reader.update_search` required 3.18.
20
+ (:issue:`323`)
21
+ * Enable `write-ahead logging`_ only once, when the database is created,
22
+ instead of every time it is opened.
23
+ (:issue:`323`)
24
+ * Vacuum the main database after migrations. (:issue:`323`)
25
+ * Add an internal :ref:`change tracking API <changes>`
26
+ to formalize how search keeps in sync with storage.
27
+ (:issue:`323`)
28
+ * Refactor storage internals. (:issue:`323`)
29
+
30
+
9
31
  Version 3.11
10
32
  ------------
11
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: reader
3
- Version: 3.11
3
+ Version: 3.12
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -57,7 +57,6 @@ but I will prioritize supporting :doc:`contributors <contributing>`
57
57
 
58
58
  * :ref:`searchable tag values <searchable tags>`, e.g. for comments
59
59
  * :ref:`unification with entry.read/important <entry flag unification>`
60
- * filter entries by entry tags, :issue:`328`
61
60
  * optimistic locking, :issue:`308`
62
61
  * filter tags by prefix, :issue:`309`
63
62
 
@@ -345,6 +344,8 @@ Storage internal API documented in version 3.10 (November 2023) in :issue:`325`.
345
344
  Database optimization
346
345
  ~~~~~~~~~~~~~~~~~~~~~
347
346
 
347
+ Optimization lessons learned while fixing "database is locked" errors: :issue:`175#issuecomment-657495233`.
348
+
348
349
  Some general guidance on schema/index design: :issue:`327#issuecomment-1859147186`.
349
350
 
350
351
  Speeding up ``get_entries(sort='recent')``:
@@ -449,7 +450,8 @@ https://death.andgravity.com/query-builder
449
450
  Pagination for methods that return iterators
450
451
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
451
452
 
452
- Why do it for the private implementation: :issue:`167#issuecomment-626753299` (also a comment in storage code).
453
+ Why do it for the private implementation: :issue:`167#issuecomment-626753299`;
454
+
453
455
 
454
456
  Detailed requirements and API discussion for public pagination: :issue:`196#issuecomment-706038363`.
455
457
 
@@ -464,6 +466,9 @@ From the initial issue:
464
466
 
465
467
  Enabling search by default, and alternative search APIs: :issue:`252`.
466
468
 
469
+ Change tracking API: :issue:`323#issuecomment-1930756417`, model validated in
470
+ `this gist <https://gist.github.com/lemon24/558955ad82ba2e4f50c0184c630c668c>`_.
471
+
467
472
  External resources:
468
473
 
469
474
  * Comprehensive, although a bit old (2017): `What every software engineer should know about search <https://medium.com/startup-grind/what-every-software-engineer-should-know-about-search-27d1df99f80d>`_ (`full version <http://webcache.googleusercontent.com/search?q=cache:https://medium.com/startup-grind/what-every-software-engineer-should-know-about-search-27d1df99f80d&sca_esv=570067020&prmd=ivn&strip=1&vwsrc=0>`_)
@@ -127,6 +127,8 @@ since each connection would be to a *different* database::
127
127
  reader.exceptions.StorageError: usage error: cannot use a private database from threads other than the creating thread
128
128
 
129
129
 
130
+ .. _backups:
131
+
130
132
  Back-ups
131
133
  ~~~~~~~~
132
134
 
@@ -439,16 +441,27 @@ the entries that changed since the last call,
439
441
  so it is OK to call it relatively often.
440
442
 
441
443
 
442
- Because search adds minor overhead to other :class:`Reader` methods
443
- and can almost double the size of the database,
444
- it can be turned on/off through the
445
- :meth:`~Reader.enable_search()` / :meth:`~Reader.disable_search()` methods.
446
- This is persistent across instances using the same database,
447
- and only needs to be done once.
448
- You can also use the ``search_enabled`` :func:`make_reader` argument
449
- for the same purpose.
450
- By default, search is disabled,
451
- and enabled automatically on the first :meth:`~Reader.update_search()` call.
444
+ Search can be turned on/off through the
445
+ :meth:`~Reader.enable_search()` / :meth:`~Reader.disable_search()` methods
446
+ (persistent across instances using the same database),
447
+ or the ``search_enabled`` argument of :func:`make_reader`;
448
+ by default, search is enabled automatically
449
+ on the first :meth:`~Reader.update_search()` call.
450
+ If search is enabled,
451
+ you should call :meth:`~Reader.update_search()` regularly
452
+ to prevent unprocesses changes from accumulating over time.
453
+
454
+
455
+ Because the search index can be almost as large as the main database,
456
+ the default implementation splits it into a separate, attached database,
457
+ which allows :ref:`backing up <backups>` the main database separately;
458
+ for a reader created with ``make_reader('db.sqlite')``,
459
+ the search index will be in ``db.sqlite.search``.
460
+
461
+
462
+ .. versionchanged:: 3.12
463
+ Split the full-text search index into a separate database.
464
+
452
465
 
453
466
 
454
467
 
@@ -143,9 +143,11 @@ but at least one other implementation needs to exists before that.
143
143
 
144
144
  Currently, search is tightly-bound to a storage implementation
145
145
  (see :meth:`~.BoundSearchStorageType.make_search`).
146
- In the future, it may be possible to mix and match
147
- main storage and search implementations,
148
- but :class:`StorageType` will likely need
146
+ While the :ref:`change tracking API <changes>` allows
147
+ search implementations to keep in sync with text content changes,
148
+ there is no convenient way for :meth:`SearchType.search_entries`
149
+ to filter/sort results without storage cooperation;
150
+ :class:`StorageType` will need
149
151
  additional capabilities to support this.
150
152
 
151
153
 
@@ -167,6 +169,27 @@ but at least one other implementation needs to exists before that.
167
169
  :members:
168
170
 
169
171
 
172
+ .. _changes:
173
+
174
+ .. autoclass:: ChangeTrackingStorageType()
175
+ :members:
176
+ :show-inheritance:
177
+
178
+ .. autoclass:: ChangeTrackerType()
179
+ :members:
180
+
181
+ .. autoclass:: Change
182
+ :members:
183
+
184
+ .. autoclass:: Action
185
+ :members:
186
+
187
+ .. autoattribute:: reader.Entry._sequence
188
+
189
+ .. autoexception:: reader.exceptions.ChangeTrackingNotEnabledError
190
+ :show-inheritance:
191
+
192
+
170
193
  Data objects
171
194
  ~~~~~~~~~~~~
172
195
 
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.11'
40
+ __version__ = '3.12'
41
41
 
42
42
  from .core import (
43
43
  Reader as Reader,
@@ -35,9 +35,6 @@ def serve(config, host, port, plugin, verbose):
35
35
  if plugin:
36
36
  config['app']['plugins'] = dict.fromkeys(plugin)
37
37
 
38
- # TODO: remove this once we make debug_storage a storage_arg
39
- config['default']['reader'].pop('debug_storage', None)
40
-
41
38
  app = create_app(config)
42
39
  app.wsgi_app = make_add_response_headers_middleware(
43
40
  app.wsgi_app,
@@ -1,5 +1,4 @@
1
1
  import functools
2
- import json
3
2
  import logging
4
3
  import os.path
5
4
  import shutil
@@ -17,7 +16,6 @@ from ._config import make_reader_config
17
16
  from ._config import make_reader_from_config
18
17
  from ._plugins import Loader
19
18
  from ._plugins import LoaderError
20
- from ._sqlite_utils import DebugConnection
21
19
 
22
20
 
23
21
  APP_NAME = reader.__name__
@@ -45,23 +43,7 @@ def abort(message, *args, **kwargs):
45
43
  raise click.ClickException(message.format(*args, **kwargs))
46
44
 
47
45
 
48
- def make_reader_with_plugins(*, debug_storage=False, **kwargs):
49
- if debug_storage:
50
- # TODO: the web app should be able to do this too
51
-
52
- log_debug = logging.getLogger('reader._storage').debug
53
- pid = os.getpid()
54
-
55
- class Connection(DebugConnection):
56
- _io_counters = True
57
-
58
- @staticmethod
59
- def _log_method(data):
60
- data['pid'] = pid
61
- log_debug(json.dumps(data))
62
-
63
- kwargs['_storage_factory'] = Connection
64
-
46
+ def make_reader_with_plugins(**kwargs):
65
47
  try:
66
48
  return make_reader_from_config(**kwargs)
67
49
  except StorageError as e:
@@ -170,7 +152,7 @@ def pass_reader(fn):
170
152
  @functools.wraps(fn)
171
153
  def wrapper(*args, **kwargs):
172
154
  ctx = click.get_current_context().find_root()
173
- # TODO: replace with ctx.obj.make_reader('cli') when we get rid of debug_storage
155
+ # TODO: replace with ctx.obj.make_reader('cli')
174
156
  reader = make_reader_with_plugins(**ctx.obj.merged('cli').get('reader', {}))
175
157
  ctx.call_on_close(reader.close)
176
158
  return fn(reader, *args, **kwargs)
@@ -215,14 +197,9 @@ def pass_reader(fn):
215
197
  "If not provided, don't open local feeds."
216
198
  ),
217
199
  )
218
- @click.option(
219
- '--debug-storage/--no-debug-storage',
220
- hidden=True,
221
- help="NOT TESTED. With -vv, log storage database calls.",
222
- )
223
200
  @click.version_option(reader.__version__, message='%(prog)s %(version)s')
224
201
  @click.pass_obj
225
- def cli(config, db, plugin, cli_plugin, feed_root, debug_storage):
202
+ def cli(config, db, plugin, cli_plugin, feed_root):
226
203
  # TODO: mention in docs that --db/--plugin/envvars ALWAYS override the config
227
204
  # (same for wsgi envvars)
228
205
  # NOTE: we can never use click defaults for --db/--plugin, because they would override the config always
@@ -247,10 +224,6 @@ def cli(config, db, plugin, cli_plugin, feed_root, debug_storage):
247
224
  if feed_root is not None:
248
225
  config['default']['reader']['feed_root'] = feed_root
249
226
 
250
- # until we make debug_storage a proper make_reader argument,
251
- # and we get rid of make_reader_with_plugins
252
- config['default']['reader']['debug_storage'] = debug_storage
253
-
254
227
  try:
255
228
  loader = Loader()
256
229
  loader.init(config, config.merged('cli').get('plugins', {}))
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._types import ChangeTrackerType
6
+ from .._types import SearchType
7
+ from ._base import StorageBase
8
+ from ._changes import Changes
9
+ from ._entries import EntriesMixin
10
+ from ._feeds import FeedsMixin
11
+ from ._tags import TagsMixin
12
+
13
+
14
+ # Row value support was added in 3.15.
15
+ # pragma_*() tabled-valued functions were added in 3.16.
16
+ # last_insert_rowid() support for FTS5 was added in 3.18.
17
+ MINIMUM_SQLITE_VERSION = (3, 18)
18
+
19
+ # Both storage and search use the JSON1 extension.
20
+ REQUIRED_SQLITE_FUNCTIONS = ['json']
21
+
22
+
23
+ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
24
+ """Data access object used for all storage (except search).
25
+
26
+ This class is split into per-domain mixins, add new methods accordingly.
27
+
28
+ Add a test_storage.py::test_errors_locked test for each new public method.
29
+
30
+ """
31
+
32
+ def __init__(self, path: str, timeout: float | None = None):
33
+ super().__init__(path, timeout)
34
+ self.changes: ChangeTrackerType = Changes(self)
35
+
36
+ def make_search(self) -> SearchType:
37
+ from ._search import Search
38
+
39
+ return Search(self)
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import sys
7
+ from collections.abc import Callable
8
+ from collections.abc import Iterable
9
+ from functools import partial
10
+ from typing import Any
11
+ from typing import TypeVar
12
+
13
+ from . import _sqlite_utils
14
+ from ..exceptions import StorageError
15
+ from ._sql_utils import paginated_query
16
+ from ._sql_utils import Query
17
+
18
+
19
+ APPLICATION_ID = b'read'
20
+
21
+ _T = TypeVar('_T')
22
+
23
+
24
+ # also used by tests
25
+ CONNECTION_CLS = sqlite3.Connection
26
+
27
+ debug = os.environ.get('READER_DEBUG_STORAGE', '')
28
+ assert set(debug) <= {'m', 't', 'i'}, f"invalid READER_DEBUG_STORAGE={debug}"
29
+
30
+ if debug: # pragma: no cover
31
+
32
+ class CONNECTION_CLS(_sqlite_utils.DebugConnection): # type: ignore # noqa: F811
33
+ _set_trace = 't' in debug
34
+ _io_counters = 'i' in debug
35
+ _pid = os.getpid()
36
+
37
+ def _log_method(self, data): # type: ignore
38
+ data['pid'] = self._pid
39
+ print('STORAGE_DEBUG', json.dumps(data), file=sys.stderr)
40
+
41
+
42
+ wrap_exceptions = partial(_sqlite_utils.wrap_exceptions, StorageError)
43
+
44
+
45
+ class StorageBase:
46
+ #: Private storage API.
47
+ chunk_size = 2**8
48
+
49
+ @wrap_exceptions(message="while opening database")
50
+ def __init__(self, path: str, timeout: float | None = None):
51
+ kwargs: dict[str, Any] = {'factory': CONNECTION_CLS}
52
+ if timeout is not None:
53
+ kwargs['timeout'] = timeout
54
+
55
+ self.factory = _sqlite_utils.LocalConnectionFactory(path, **kwargs)
56
+ db = self.factory()
57
+ try:
58
+ self.setup_db(db)
59
+ except BaseException:
60
+ db.close()
61
+ raise
62
+
63
+ self.path = path
64
+ self.timeout = timeout
65
+
66
+ def get_db(self) -> sqlite3.Connection:
67
+ return self.factory()
68
+
69
+ @staticmethod
70
+ def setup_db(db: sqlite3.Connection) -> None:
71
+ """Private storage API (used by tests)."""
72
+ from ._schema import MIGRATION
73
+ from . import MINIMUM_SQLITE_VERSION, REQUIRED_SQLITE_FUNCTIONS
74
+
75
+ return _sqlite_utils.setup_db(
76
+ db,
77
+ migration=MIGRATION,
78
+ id=APPLICATION_ID,
79
+ minimum_sqlite_version=MINIMUM_SQLITE_VERSION,
80
+ required_sqlite_functions=REQUIRED_SQLITE_FUNCTIONS,
81
+ )
82
+
83
+ @wrap_exceptions()
84
+ def __enter__(self) -> None:
85
+ self.factory.__enter__()
86
+
87
+ @wrap_exceptions()
88
+ def __exit__(self, *_: Any) -> None:
89
+ self.factory.__exit__()
90
+
91
+ @wrap_exceptions()
92
+ def close(self) -> None:
93
+ self.factory.close()
94
+
95
+ def paginated_query(
96
+ self,
97
+ make_query: Callable[[], tuple[Query, dict[str, Any]]],
98
+ limit: int | None = None,
99
+ last: tuple[Any, ...] | None = None,
100
+ row_factory: Callable[[tuple[Any, ...]], _T] | None = None,
101
+ ) -> Iterable[_T]:
102
+ with wrap_exceptions():
103
+ yield from paginated_query(
104
+ self.get_db(),
105
+ make_query,
106
+ self.chunk_size,
107
+ limit or 0,
108
+ last,
109
+ row_factory,
110
+ )
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from typing import Any
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._types import Action
8
+ from .._types import Change
9
+ from ..exceptions import ChangeTrackingNotEnabledError
10
+ from ._base import wrap_exceptions
11
+ from ._sql_utils import parse_schema
12
+ from ._sql_utils import Query
13
+ from ._sqlite_utils import ddl_transaction
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from ._base import StorageBase
17
+
18
+
19
+ ENABLED_EXC = {'no such table': lambda _: ChangeTrackingNotEnabledError()}
20
+
21
+
22
+ class Changes:
23
+ def __init__(self, storage: StorageBase):
24
+ self.storage = storage
25
+
26
+ @wrap_exceptions()
27
+ def enable(self) -> None:
28
+ with ddl_transaction(self.storage.get_db()) as db:
29
+ try:
30
+ self._enable(db)
31
+ except sqlite3.OperationalError as e:
32
+ if "table changes already exists" in str(e).lower():
33
+ return
34
+ raise # pragma: no cover
35
+
36
+ @classmethod
37
+ def _enable(cls, db: sqlite3.Connection) -> None:
38
+ assert db.in_transaction
39
+ for objects in SCHEMA.values():
40
+ for object in objects.values():
41
+ object.create(db)
42
+ db.execute("UPDATE entries SET sequence = randomblob(16)")
43
+ db.execute(
44
+ """
45
+ INSERT INTO changes
46
+ SELECT sequence, feed, id, '', 1 FROM entries
47
+ """
48
+ )
49
+
50
+ @wrap_exceptions()
51
+ def disable(self) -> None:
52
+ with ddl_transaction(self.storage.get_db()) as db:
53
+ self._disable(db)
54
+
55
+ @classmethod
56
+ def _disable(cls, db: sqlite3.Connection) -> None:
57
+ assert db.in_transaction
58
+ for objects in SCHEMA.values():
59
+ for object in objects.values():
60
+ db.execute(f"DROP {object.type} IF EXISTS {object.name}")
61
+ db.execute("UPDATE entries SET sequence = NULL")
62
+
63
+ @wrap_exceptions(ENABLED_EXC)
64
+ def get(
65
+ self, action: Action | None = None, limit: int | None = None
66
+ ) -> list[Change]:
67
+ if not limit or limit > self.storage.chunk_size:
68
+ limit = self.storage.chunk_size
69
+ context = {'limit': limit}
70
+ # the ORDER_BY is only used for testing; should this return a set instead?
71
+ query = Query().SELECT('*').FROM('changes').ORDER_BY('rowid').LIMIT(':limit')
72
+ if action:
73
+ query.WHERE('action = :action')
74
+ context['action'] = action.value
75
+ rows = self.storage.get_db().execute(str(query), context)
76
+ return list(map(change_factory, rows))
77
+
78
+ @wrap_exceptions(ENABLED_EXC)
79
+ def done(self, changes: list[Change]) -> None:
80
+ if len(changes) > self.storage.chunk_size:
81
+ raise ValueError(f"too many changes, expected <= {self.storage.chunk_size}")
82
+ with self.storage.get_db() as db:
83
+ for change in changes:
84
+ db.execute(
85
+ """
86
+ DELETE FROM changes
87
+ WHERE (sequence, feed, id, key, action)
88
+ = (:sequence, :feed, :id, :key, :action)
89
+ """,
90
+ change_to_dict(change),
91
+ )
92
+
93
+
94
+ def change_factory(row: tuple[Any, ...]) -> Change:
95
+ sequence, feed, id, key, action = row
96
+ resource = tuple(filter(bool, (feed, id)))
97
+ return Change(Action(action), sequence, resource, key or None)
98
+
99
+
100
+ def change_to_dict(change: Change) -> dict[str, Any]:
101
+ resource = change.resource_id + ('', '')
102
+ return dict(
103
+ sequence=change.sequence,
104
+ feed=resource[0],
105
+ id=resource[1],
106
+ key=change.tag_key or '',
107
+ action=change.action.value,
108
+ )
109
+
110
+
111
+ SCHEMA = parse_schema("""
112
+
113
+ CREATE TABLE changes (
114
+ sequence BLOB NOT NULL,
115
+ feed TEXT NOT NULL,
116
+ id TEXT NOT NULL,
117
+ key TEXT NOT NULL,
118
+ action INTEGER NOT NULL,
119
+
120
+ PRIMARY KEY (sequence, feed, id, key)
121
+ );
122
+
123
+
124
+ CREATE TRIGGER changes_entry_insert
125
+ AFTER INSERT
126
+ ON entries
127
+ BEGIN
128
+ -- SELECT print(' entry_insert', new.feed, new.id);
129
+
130
+ UPDATE entries
131
+ SET sequence = randomblob(16)
132
+ WHERE (new.id, new.feed) = (id, feed);
133
+
134
+ INSERT OR REPLACE INTO changes
135
+ SELECT sequence, feed, id, '', 1
136
+ FROM entries
137
+ WHERE (feed, id) = (new.feed, new.id);
138
+ END;
139
+
140
+
141
+ -- Can't handle feed URL changes in changes_entry_update because
142
+ -- those entry updates are a consequence of ON UPDATE CASCADE,
143
+ -- which overrides the INSERT OR REPLACE used in the trigger,
144
+ -- because "conflict handling policy of the outer statement"
145
+ -- takes precedence per https://sqlite.org/lang_createtrigger.html.
146
+ -- Instead, we handle feed URL changes in changes_feed_changed.
147
+
148
+ CREATE TRIGGER changes_entry_update
149
+ AFTER UPDATE
150
+ OF title, summary, content
151
+ ON entries
152
+ WHEN
153
+ new.id = old.id AND new.feed = old.feed AND (
154
+ coalesce(new.title, '') != coalesce(old.title, '')
155
+ OR coalesce(new.summary, '') != coalesce(old.summary, '')
156
+ OR coalesce(new.content, '') != coalesce(old.content, '')
157
+ )
158
+ BEGIN
159
+ -- SELECT print(' entry_update', old.feed, old.id, '->', new.feed, new.id);
160
+
161
+ INSERT OR REPLACE INTO changes
162
+ VALUES (old.sequence, old.feed, old.id, '', 2);
163
+
164
+ UPDATE entries
165
+ SET sequence = randomblob(16)
166
+ WHERE (new.id, new.feed) = (id, feed);
167
+
168
+ INSERT OR REPLACE INTO changes
169
+ SELECT sequence, feed, id, '', 1
170
+ FROM entries
171
+ WHERE (feed, id) = (new.feed, new.id);
172
+ END;
173
+
174
+
175
+ CREATE TRIGGER changes_entry_delete
176
+ AFTER DELETE
177
+ ON entries
178
+ BEGIN
179
+ -- SELECT print(' entry_delete', old.feed, old.id);
180
+
181
+ INSERT OR REPLACE INTO changes
182
+ VALUES (old.sequence, old.feed, old.id, '', 2);
183
+ END;
184
+
185
+
186
+ CREATE TRIGGER changes_feed_changed
187
+ AFTER UPDATE
188
+ OF url, title, user_title
189
+ ON feeds
190
+ WHEN
191
+ new.url != old.url
192
+ OR coalesce(new.title, '') != coalesce(old.title, '')
193
+ OR coalesce(new.user_title, '') != coalesce(old.user_title, '')
194
+ BEGIN
195
+ -- SELECT print(' feed_url_change', old.url, '->', new.url);
196
+
197
+ INSERT OR REPLACE INTO changes
198
+ SELECT sequence, old.url, id, '', 2
199
+ FROM entries
200
+ WHERE feed = new.url;
201
+
202
+ UPDATE entries
203
+ SET sequence = randomblob(16)
204
+ WHERE feed = new.url;
205
+
206
+ INSERT OR REPLACE INTO changes
207
+ SELECT sequence, feed, id, '', 1
208
+ FROM entries
209
+ WHERE feed = new.url;
210
+ END;
211
+
212
+
213
+ """) # fmt: skip