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.
- {reader-3.19.dev0 → reader-3.20}/CHANGES.rst +49 -2
- {reader-3.19.dev0/src/reader.egg-info → reader-3.20}/PKG-INFO +1 -1
- {reader-3.19.dev0 → reader-3.20}/docs/dev.rst +18 -15
- {reader-3.19.dev0 → reader-3.20}/pyproject.toml +3 -3
- reader-3.20/scripts/entry_dedupe_backtest.py +113 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/release.py +9 -19
- {reader-3.19.dev0 → reader-3.20}/src/reader/__init__.py +1 -1
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/__init__.py +4 -2
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_base.py +2 -3
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_html_utils.py +16 -2
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_sqlite_utils.py +48 -11
- {reader-3.19.dev0 → reader-3.20}/src/reader/_types.py +15 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/core.py +9 -1
- reader-3.20/src/reader/plugins/entry_dedupe.py +1135 -0
- {reader-3.19.dev0 → reader-3.20/src/reader.egg-info}/PKG-INFO +1 -1
- {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/SOURCES.txt +2 -1
- {reader-3.19.dev0 → reader-3.20}/tests/fakeparser.py +2 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_app.py +18 -16
- {reader-3.19.dev0 → reader-3.20}/tests/test_html_utils.py +17 -10
- reader-3.20/tests/test_plugins_entry_dedupe.py +815 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_preview_feed_list.py +1 -1
- reader-3.19.dev0/tests/test_reader_context.py → reader-3.20/tests/test_reader_lifecycle.py +86 -10
- {reader-3.19.dev0 → reader-3.20}/tests/test_search.py +1 -1
- {reader-3.19.dev0 → reader-3.20}/tests/test_storage.py +1 -1
- reader-3.19.dev0/src/reader/plugins/entry_dedupe.py +0 -580
- reader-3.19.dev0/tests/test_plugins_entry_dedupe.py +0 -805
- {reader-3.19.dev0 → reader-3.20}/LICENSE +0 -0
- {reader-3.19.dev0 → reader-3.20}/MANIFEST.in +0 -0
- {reader-3.19.dev0 → reader-3.20}/README.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/api.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/app.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/changelog.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/cli.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/conf.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/config.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/contributing.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/dev-app.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/guide.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/images/redesign-01.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/index.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/install.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/internal.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/plugins.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/dillo.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-feed.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-v2-dark.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries-v2-filters-light.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entries.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entry-one.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/entry-two.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/feeds.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/lynx.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/screenshots/search.png +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/tutorial.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/docs/why.rst +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/config.yaml +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/custom_headers.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/feed_slugs.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/parser_only.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/podcast.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/examples/terminal.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/run.sh +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/backup.sh +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/bench.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/debug_storage_stats.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/jscontrols.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/jscontrols.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/scripts/lines.sh +0 -0
- {reader-3.19.dev0 → reader-3.20}/setup.cfg +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/__main__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/api_thing.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/cli.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/static/controls.js +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/static/style.css +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/add_entry.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/entries.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/entry.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/feeds.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/layout.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/macros.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/metadata.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/templates/tags.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/forms.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/static/style.css +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/static/theme.js +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/entries.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/layout.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/v2/templates/v2/macros.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_app/wsgi.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_cli.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_config.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_hash_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_http_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_lazy.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/_url_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/feedparser.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/file.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/http.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/jsonfeed.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/requests/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_parser/requests/_lazy.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/cli_status.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/enclosure_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/preview_feed_list.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/share.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/sqlite_releases.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_plugins/timer.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_changes.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_entries.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_feeds.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_schema.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_search.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_sql_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_storage/_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_update.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/api.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/encodings.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/html.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/http.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/mixin.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/py.typed +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/sgml.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/urls.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/_vendor/feedparser/util.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/enclosure_dedupe.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/mark_as_read.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/readtime.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/plugins/ua_fallback.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/py.typed +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/types.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader/utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/dependency_links.txt +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/requires.txt +0 -0
- {reader-3.19.dev0 → reader-3.20}/src/reader.egg-info/top_level.txt +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/conftest.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/10.json +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/10.json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/custom +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.atom +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.json +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.rss +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/empty.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.atom +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.json +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.rss +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/full.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/invalid.json +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/invalid.json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/relative.atom +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/relative.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/relative.rss +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/relative.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/sqlite_releases.html +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/unknown.json +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/data/unknown.json.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_methods.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/good.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/init_error.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/missing_dependency.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/reader_test_plugins/missing_entry_point.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test__types.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test__utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_app_wsgi.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_bench.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_changes.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_cli.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_config.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_hash_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_lazy_imports.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_parser.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_cli_status.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_enclosure_dedupe.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_mark_as_read.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_readtime.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_sqlite_releases.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_plugins_ua_fallback.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_counts.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_deprecations.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_filter.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_hooks.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_integration.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_plugins.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_private.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_search.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_sort.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_update.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_reader_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_sql_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_sqlite_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/test_types.py +0 -0
- {reader-3.19.dev0 → reader-3.20}/tests/utils.py +0 -0
- {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
|
-
|
|
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
|
-
*
|
|
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
|
|
@@ -226,25 +226,31 @@ Making a release
|
|
|
226
226
|
|
|
227
227
|
.. note::
|
|
228
228
|
|
|
229
|
-
:gh:`
|
|
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 /
|
|
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)
|
|
245
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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()
|
|
@@ -29,8 +29,10 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
|
|
|
29
29
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
def __init__(
|
|
33
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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:
|