reader 3.19.dev0__tar.gz → 3.21__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reader-3.19.dev0 → reader-3.21}/CHANGES.rst +83 -2
- {reader-3.19.dev0/src/reader.egg-info → reader-3.21}/PKG-INFO +1 -1
- {reader-3.19.dev0 → reader-3.21}/docs/dev.rst +18 -15
- {reader-3.19.dev0 → reader-3.21}/docs/guide.rst +12 -5
- {reader-3.19.dev0 → reader-3.21}/pyproject.toml +7 -5
- {reader-3.19.dev0 → reader-3.21}/run.sh +1 -1
- reader-3.21/scripts/entry_dedupe_backtest.py +113 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/release.py +9 -19
- {reader-3.19.dev0 → reader-3.21}/src/reader/__init__.py +1 -1
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/forms.py +2 -1
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/entries.html +2 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_cli.py +4 -1
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/__init__.py +52 -13
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_http_utils.py +9 -1
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/__init__.py +4 -2
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_base.py +2 -3
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_html_utils.py +16 -2
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_sqlite_utils.py +69 -30
- {reader-3.19.dev0 → reader-3.21}/src/reader/_types.py +15 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_update.py +11 -10
- {reader-3.19.dev0 → reader-3.21}/src/reader/core.py +32 -8
- reader-3.21/src/reader/plugins/entry_dedupe.py +1134 -0
- {reader-3.19.dev0 → reader-3.21/src/reader.egg-info}/PKG-INFO +1 -1
- {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/SOURCES.txt +2 -1
- {reader-3.19.dev0 → reader-3.21}/tests/conftest.py +12 -0
- {reader-3.19.dev0 → reader-3.21}/tests/fakeparser.py +27 -34
- {reader-3.19.dev0 → reader-3.21}/tests/reader_methods.py +45 -10
- {reader-3.19.dev0 → reader-3.21}/tests/test_app.py +24 -19
- {reader-3.19.dev0 → reader-3.21}/tests/test_changes.py +3 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_cli.py +4 -4
- {reader-3.19.dev0 → reader-3.21}/tests/test_html_utils.py +17 -10
- reader-3.21/tests/test_plugins_entry_dedupe.py +833 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_mark_as_read.py +3 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_preview_feed_list.py +1 -1
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_readtime.py +3 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader.py +36 -1298
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_counts.py +1 -1
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_filter.py +103 -109
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_hooks.py +3 -0
- reader-3.21/tests/test_reader_integration.py +207 -0
- reader-3.19.dev0/tests/test_reader_context.py → reader-3.21/tests/test_reader_lifecycle.py +92 -18
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_private.py +3 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_search.py +15 -23
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_sort.py +7 -0
- reader-3.21/tests/test_reader_update.py +1255 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_utils.py +3 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_search.py +1 -1
- {reader-3.19.dev0 → reader-3.21}/tests/test_storage.py +1 -1
- {reader-3.19.dev0 → reader-3.21}/tests/utils.py +20 -0
- {reader-3.19.dev0 → reader-3.21}/tox.ini +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/tests/test_reader_integration.py +0 -209
- reader-3.19.dev0/tests/test_reader_update.py +0 -172
- {reader-3.19.dev0 → reader-3.21}/LICENSE +0 -0
- {reader-3.19.dev0 → reader-3.21}/MANIFEST.in +0 -0
- {reader-3.19.dev0 → reader-3.21}/README.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/api.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/app.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/changelog.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/cli.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/conf.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/config.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/contributing.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/dev-app.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/images/redesign-01.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/index.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/install.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/internal.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/plugins.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/dillo.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-feed.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-v2-dark.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries-v2-filters-light.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entries.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entry-one.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/entry-two.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/feeds.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/lynx.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/screenshots/search.png +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/tutorial.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/docs/why.rst +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/config.yaml +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/custom_headers.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/feed_slugs.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/parser_only.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/podcast.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/examples/terminal.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/backup.sh +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/bench.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/debug_storage_stats.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/jscontrols.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/jscontrols.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/scripts/lines.sh +0 -0
- {reader-3.19.dev0 → reader-3.21}/setup.cfg +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/__main__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/api_thing.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/cli.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/static/controls.js +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/static/style.css +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/add_entry.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/entries.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/entry.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/feeds.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/layout.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/macros.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/metadata.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/templates/tags.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/static/style.css +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/static/theme.js +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/layout.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/v2/templates/v2/macros.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_app/wsgi.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_config.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_hash_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_lazy.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/_url_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/feedparser.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/file.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/http.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/jsonfeed.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/requests/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_parser/requests/_lazy.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/cli_status.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/enclosure_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/preview_feed_list.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/share.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/sqlite_releases.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_plugins/timer.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_changes.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_entries.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_feeds.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_schema.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_search.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_sql_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_storage/_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/api.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/encodings.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/html.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/http.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/mixin.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/py.typed +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/sgml.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/urls.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/_vendor/feedparser/util.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/enclosure_dedupe.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/mark_as_read.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/readtime.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/plugins/ua_fallback.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/py.typed +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/types.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader/utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/dependency_links.txt +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/requires.txt +0 -0
- {reader-3.19.dev0 → reader-3.21}/src/reader.egg-info/top_level.txt +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/10.json +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/10.json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/custom +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.atom +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.json +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.rss +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/empty.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.atom +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.json +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.rss +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/full.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/invalid.json +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/invalid.json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/relative.atom +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/relative.atom.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/relative.rss +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/relative.rss.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/sqlite_releases.html +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/unknown.json +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/data/unknown.json.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/__init__.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/good.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/init_error.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/missing_dependency.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/reader_test_plugins/missing_entry_point.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test__types.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test__utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_app_wsgi.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_bench.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_config.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_exceptions.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_hash_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_lazy_imports.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_parser.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_cli_status.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_enclosure_dedupe.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_sqlite_releases.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_plugins_ua_fallback.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_deprecations.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_reader_plugins.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_sql_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_sqlite_utils.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_tags.py +0 -0
- {reader-3.19.dev0 → reader-3.21}/tests/test_types.py +0 -0
|
@@ -6,18 +6,99 @@ Changelog
|
|
|
6
6
|
:no-index:
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
Version 3.21
|
|
10
|
+
------------
|
|
11
|
+
|
|
12
|
+
Released 2026-01-12
|
|
13
|
+
|
|
14
|
+
* :ref:`Scheduled updates <scheduled>` improvements.
|
|
15
|
+
|
|
16
|
+
* Only update scheduled feeds by default
|
|
17
|
+
(:meth:`update_feeds(scheduled=...) <Reader.update_feeds>`
|
|
18
|
+
defaults to :const:`True`);
|
|
19
|
+
previously, all feeds would be updated.
|
|
20
|
+
(:issue:`379`)
|
|
21
|
+
|
|
22
|
+
.. warning::
|
|
23
|
+
|
|
24
|
+
**This is a minor compatibility break**;
|
|
25
|
+
the previous behavior is a bug –
|
|
26
|
+
*reader* should do the right thing
|
|
27
|
+
even if the user doesn't read the documentation.
|
|
28
|
+
|
|
29
|
+
In the CLI, make ``update --scheduled`` the default.
|
|
30
|
+
|
|
31
|
+
* Honor the Cache-Control (max-age) and Expires HTTP headers.
|
|
32
|
+
(:issue:`376`)
|
|
33
|
+
|
|
34
|
+
* Limit the :attr:`~Feed.update_after` derived from HTTP headers
|
|
35
|
+
to a month in the future.
|
|
36
|
+
(:issue:`384`)
|
|
37
|
+
|
|
38
|
+
* If the Retry-After value is a date,
|
|
39
|
+
make the update interval relative to Date.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Version 3.20
|
|
44
|
+
------------
|
|
45
|
+
|
|
46
|
+
Released 2025-11-26
|
|
47
|
+
|
|
48
|
+
* Add the ``read_only`` :func:`make_reader` argument.
|
|
49
|
+
Thanks to `Roman Milko`_ for the PR.
|
|
50
|
+
(:issue:`319`)
|
|
51
|
+
|
|
52
|
+
* Include the ``alt`` and ``title`` HTML attributes
|
|
53
|
+
in the :ref:`full-text search <fts>` index.
|
|
54
|
+
Also, don't add extra whitespace between tags
|
|
55
|
+
(especially important for inline tags).
|
|
56
|
+
(:issue:`371`)
|
|
57
|
+
|
|
58
|
+
.. note::
|
|
59
|
+
|
|
60
|
+
Old entries will **not** be re-indexed automatically;
|
|
61
|
+
to do so, disable then enable search.
|
|
62
|
+
|
|
63
|
+
* :mod:`~reader.plugins.entry_dedupe` improvements. (:issue:`371`)
|
|
64
|
+
|
|
65
|
+
* Use more heuristics to find potential duplicates
|
|
66
|
+
(in addition to title matching):
|
|
67
|
+
match link, match published timestamp,
|
|
68
|
+
strip common title prefixes for new entries.
|
|
69
|
+
* When comparing entries,
|
|
70
|
+
include the ``alt`` and ``title`` HTML attributes,
|
|
71
|
+
strip accents, and treat dates and versions as single tokens.
|
|
72
|
+
* Increase required minimum content length from 32 to 48 words.
|
|
73
|
+
* Add the ``.reader.dedupe.once.link`` and
|
|
74
|
+
``.reader.dedupe.once.title.prefix`` escape hatches.
|
|
75
|
+
* Clean up documentation, refactor code, and add extensive tests.
|
|
76
|
+
|
|
77
|
+
* Log run times of before/after update hooks.
|
|
78
|
+
|
|
79
|
+
.. _Roman Milko: https://github.com/rastr-0
|
|
80
|
+
|
|
81
|
+
|
|
9
82
|
Version 3.19
|
|
10
83
|
------------
|
|
11
84
|
|
|
12
|
-
|
|
85
|
+
Released 2025-10-01
|
|
13
86
|
|
|
14
87
|
* Support Python 3.14.
|
|
88
|
+
* Remove deprecated empty ``search`` and ``readtime`` extras.
|
|
89
|
+
* Remove undocumented development ``tests``, ``docs``, and ``dev`` extras.
|
|
15
90
|
|
|
16
91
|
* :mod:`~reader._plugins.enclosure_tags` plugin:
|
|
17
92
|
strip HTML tags from values used as MP3 tags
|
|
18
93
|
(for example, ``<b>`` tags used to highlight search result matches).
|
|
19
94
|
|
|
20
|
-
*
|
|
95
|
+
* Project infrastructure improvements.
|
|
96
|
+
|
|
97
|
+
* Clean up :doc:`contributing` documentation and :gh:`run.sh <run.sh>` script.
|
|
98
|
+
* Use GitHub Actions to publish releases to PyPI.
|
|
99
|
+
* Split type checking and testing jobs in the ``build`` GHA workflow.
|
|
100
|
+
* Make tox runs slightly faster.
|
|
101
|
+
* Use dependency groups instead of extras for development dependencies.
|
|
21
102
|
|
|
22
103
|
|
|
23
104
|
Version 3.18
|
|
@@ -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,
|
|
@@ -270,11 +270,12 @@ Scheduled updates
|
|
|
270
270
|
~~~~~~~~~~~~~~~~~
|
|
271
271
|
|
|
272
272
|
Because different feeds need to be updated at different rates,
|
|
273
|
-
*reader* also provides a mechanism for scheduling updates
|
|
273
|
+
*reader* also provides a mechanism for scheduling updates
|
|
274
|
+
that is enabled by default.
|
|
274
275
|
|
|
275
276
|
On every update, *reader* determines when a feed should be updated next
|
|
276
277
|
based on a configurable update interval;
|
|
277
|
-
:meth
|
|
278
|
+
:meth:`~Reader.update_feeds`
|
|
278
279
|
updates only the feeds that should be updated at or before the current time.
|
|
279
280
|
|
|
280
281
|
The interval can be **configured by the user**
|
|
@@ -289,9 +290,9 @@ such that updates don't happen exactly at the the same time;
|
|
|
289
290
|
for example, with a one-hour interval, a jitter of 0.25 means
|
|
290
291
|
updates will occur any time in the first 15 minutes of each hour.
|
|
291
292
|
|
|
292
|
-
If the server
|
|
293
|
-
|
|
294
|
-
:meth:`update_feeds(scheduled=True) <Reader.update_feeds>` will honor
|
|
293
|
+
If the server responds with any of the
|
|
294
|
+
Cache-Control max-age, Expires, or Retry-After HTTP headers,
|
|
295
|
+
:meth:`update_feeds(scheduled=True) <Reader.update_feeds>` will honor them.
|
|
295
296
|
|
|
296
297
|
|
|
297
298
|
.. note::
|
|
@@ -308,6 +309,12 @@ If the server sends the Retry-After HTTP header with
|
|
|
308
309
|
.. versionchanged:: 3.15
|
|
309
310
|
Honor the Retry-After HTTP header.
|
|
310
311
|
|
|
312
|
+
.. versionchanged:: 3.21
|
|
313
|
+
Honor the Cache-Control max-age and Expires HTTP headers.
|
|
314
|
+
|
|
315
|
+
.. versionchanged:: 3.21
|
|
316
|
+
Only update scheduled feeds by default.
|
|
317
|
+
|
|
311
318
|
|
|
312
319
|
Update status
|
|
313
320
|
~~~~~~~~~~~~~
|
|
@@ -92,22 +92,21 @@ unstable-plugins = [
|
|
|
92
92
|
[dependency-groups]
|
|
93
93
|
|
|
94
94
|
tests = [
|
|
95
|
-
"pytest>=
|
|
95
|
+
"pytest>=9",
|
|
96
96
|
"pytest-randomly",
|
|
97
|
-
"pytest-subtests",
|
|
98
97
|
"pytest-rerunfailures",
|
|
99
98
|
"coverage",
|
|
100
99
|
"requests-mock",
|
|
101
100
|
# mechanicalsoup hard-depends on lxml (see below)
|
|
102
|
-
'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.
|
|
101
|
+
'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.14")',
|
|
103
102
|
"requests-wsgi-adapter",
|
|
104
103
|
# we want to test search with all known bs4 parsers.
|
|
105
104
|
# lxml usually does not have recent PyPy wheels.
|
|
106
105
|
# lxml usually does not have pre-relase CPython wheels.
|
|
107
|
-
'lxml; (implementation_name != "pypy" and python_version <= "3.
|
|
106
|
+
'lxml; (implementation_name != "pypy" and python_version <= "3.14")',
|
|
108
107
|
"html5lib",
|
|
109
108
|
# for bench.py
|
|
110
|
-
'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.
|
|
109
|
+
'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.14")',
|
|
111
110
|
]
|
|
112
111
|
|
|
113
112
|
typing = [
|
|
@@ -118,6 +117,8 @@ typing = [
|
|
|
118
117
|
|
|
119
118
|
docs = [
|
|
120
119
|
"sphinx",
|
|
120
|
+
# TODO: remove when https://github.com/sphinx-doc/sphinx/issues/14186 is solved
|
|
121
|
+
"roman_numerals<4.1",
|
|
121
122
|
"sphinx-rtd-theme",
|
|
122
123
|
"click>=7",
|
|
123
124
|
"sphinx-click",
|
|
@@ -149,6 +150,7 @@ markers = [
|
|
|
149
150
|
"slow: mark a test as slow.",
|
|
150
151
|
"requires_lxml: mark a test to only run in places where we have lxml.",
|
|
151
152
|
"apptest: mark a test as a web app test (skipped sometimes).",
|
|
153
|
+
"noscheduled: force update_feeds(scheduled=...) to false.",
|
|
152
154
|
"noautoclose: skip 'autoclose' autouse fixture for a test."
|
|
153
155
|
]
|
|
154
156
|
filterwarnings = [
|
|
@@ -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()
|
|
@@ -116,7 +116,8 @@ ENTRY_SORT_CHOICES = ['recent', 'random']
|
|
|
116
116
|
class EntryFilter(PresetsMixin, Form):
|
|
117
117
|
feed = HiddenField("feed")
|
|
118
118
|
search = SearchField("search", name='Q')
|
|
119
|
-
feed_tags = TagFilterField("tags", name='tags')
|
|
119
|
+
feed_tags = TagFilterField("feed tags", name='tags')
|
|
120
|
+
tags = TagFilterField("entry tags", name='entry-tags')
|
|
120
121
|
read = radio_field("read", choices=BOOL_CHOICES, default='no')
|
|
121
122
|
important = radio_field("important", choices=TRISTATE_CHOICES, default='maybe')
|
|
122
123
|
has_enclosures = radio_field(
|
|
@@ -52,9 +52,11 @@
|
|
|
52
52
|
{# TODO: if data or error #}
|
|
53
53
|
{% if form.search.data %}{{ macros.input(form.search, 'search') }}{% endif %}
|
|
54
54
|
{% if form.feed_tags.data %}{{ macros.input(form.feed_tags, 'tag') }}{% endif %}
|
|
55
|
+
{% if form.feed_tags.data %}{{ macros.input(form.tags, 'tag') }}{% endif %}
|
|
55
56
|
<div class="collapse mb-3" id="nav-more">
|
|
56
57
|
{% if not form.search.data %}{{ macros.input(form.search, 'search') }}{% endif %}
|
|
57
58
|
{% if not form.feed_tags.data %}{{ macros.input(form.feed_tags, 'tag') }}{% endif %}
|
|
59
|
+
{% if not form.feed_tags.data %}{{ macros.input(form.tags, 'tag') }}{% endif %}
|
|
58
60
|
<div class="mb-3">
|
|
59
61
|
{{ macros.radio(form.read) }}
|
|
60
62
|
{{ macros.radio(form.important) }}
|
|
@@ -290,7 +290,10 @@ def iter_update_status(it, length):
|
|
|
290
290
|
help="Only update new (never updated before) feeds.",
|
|
291
291
|
)
|
|
292
292
|
@click.option(
|
|
293
|
-
'--scheduled/--no-scheduled',
|
|
293
|
+
'--scheduled/--no-scheduled',
|
|
294
|
+
default=True,
|
|
295
|
+
show_default=True,
|
|
296
|
+
help="Only update feeds scheduled to be updated.",
|
|
294
297
|
)
|
|
295
298
|
@click.option(
|
|
296
299
|
'--workers',
|
|
@@ -8,6 +8,7 @@ from contextlib import contextmanager
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from datetime import timedelta
|
|
11
|
+
from datetime import timezone
|
|
11
12
|
from typing import Any
|
|
12
13
|
from typing import cast
|
|
13
14
|
from typing import ContextManager
|
|
@@ -34,6 +35,8 @@ from .requests import TimeoutType
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING: # pragma: no cover
|
|
38
|
+
from werkzeug.datastructures import RequestCacheControl
|
|
39
|
+
|
|
37
40
|
from ._lazy import Parser as Parser
|
|
38
41
|
|
|
39
42
|
|
|
@@ -189,28 +192,64 @@ class HTTPInfo(_namedtuple_compat):
|
|
|
189
192
|
#: The HTTP response headers.
|
|
190
193
|
headers: Headers
|
|
191
194
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
def get_update_after(self, now: datetime) -> datetime | None:
|
|
196
|
+
"""Select the best "update after" date from available headers."""
|
|
197
|
+
rv = []
|
|
198
|
+
|
|
199
|
+
if self.status in (429, 503):
|
|
200
|
+
try:
|
|
201
|
+
seconds = int(self.headers.get('retry-after', ''))
|
|
202
|
+
rv.append(now + timedelta(seconds=seconds))
|
|
203
|
+
except ValueError:
|
|
204
|
+
if retry_after := self.parse_date('retry-after', now):
|
|
205
|
+
rv.append(retry_after)
|
|
206
|
+
|
|
207
|
+
# https://httpwg.org/specs/rfc9111.html#calculating.freshness.lifetime
|
|
208
|
+
if cache_control := self.cache_control:
|
|
209
|
+
if not cache_control.no_cache:
|
|
210
|
+
if max_age := cache_control.max_age:
|
|
211
|
+
rv.append(now + timedelta(seconds=max_age))
|
|
212
|
+
elif expires := self.parse_date('expires', now):
|
|
213
|
+
rv.append(expires)
|
|
214
|
+
|
|
215
|
+
# TODO: RFC 9111 specifies a Last-Modified fallback heuristic,
|
|
216
|
+
# but it might be better to implement it in the updater
|
|
217
|
+
# as part of https://github.com/lemon24/reader/issues/382
|
|
218
|
+
|
|
219
|
+
return max(rv, default=None)
|
|
220
|
+
|
|
221
|
+
def parse_date(self, name: str, now: datetime | None = None) -> datetime | None:
|
|
222
|
+
"""Parse an HTTP date header and return a timezone-aware datetime.
|
|
223
|
+
|
|
224
|
+
Return None if missing or if parsing fails.
|
|
195
225
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if timezone information is missing, UTC is assumed.
|
|
226
|
+
If `now` is given and the Date header is set,
|
|
227
|
+
make the returned value relative to `now`.
|
|
199
228
|
|
|
200
229
|
"""
|
|
201
230
|
# lazy import
|
|
202
231
|
from ._http_utils import parse_date
|
|
203
232
|
|
|
204
|
-
value
|
|
233
|
+
if value := parse_date(self.headers.get(name, '')):
|
|
234
|
+
value = value.astimezone(timezone.utc)
|
|
235
|
+
if now and (date := parse_date(self.headers.get('date', ''))):
|
|
236
|
+
value = now + (value - date)
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def cache_control(self) -> RequestCacheControl | None:
|
|
243
|
+
"""Parsed Cache-Control header, or None if missing."""
|
|
244
|
+
|
|
245
|
+
# lazy import
|
|
246
|
+
from ._http_utils import parse_cache_control_header
|
|
247
|
+
|
|
248
|
+
value = self.headers.get('cache-control')
|
|
205
249
|
if not value:
|
|
206
250
|
return None
|
|
207
251
|
|
|
208
|
-
|
|
209
|
-
seconds = int(value)
|
|
210
|
-
except ValueError:
|
|
211
|
-
return parse_date(value)
|
|
212
|
-
|
|
213
|
-
return timedelta(seconds=seconds)
|
|
252
|
+
return parse_cache_control_header(value)
|
|
214
253
|
|
|
215
254
|
|
|
216
255
|
class RetrieveError(ParseError):
|
|
@@ -4,8 +4,11 @@ HTTP utilities. Contains no business logic.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from collections.abc import Iterable
|
|
7
|
+
from functools import partial
|
|
7
8
|
|
|
8
9
|
import werkzeug.http
|
|
10
|
+
from werkzeug.datastructures import MIMEAccept
|
|
11
|
+
from werkzeug.datastructures import ResponseCacheControl
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
parse_options_header = werkzeug.http.parse_options_header
|
|
@@ -14,4 +17,9 @@ parse_date = werkzeug.http.parse_date
|
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def unparse_accept_header(values: Iterable[tuple[str, float]]) -> str:
|
|
17
|
-
return
|
|
20
|
+
return MIMEAccept(values).to_header()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
parse_cache_control_header = partial(
|
|
24
|
+
werkzeug.http.parse_cache_control_header, cls=ResponseCacheControl
|
|
25
|
+
)
|
|
@@ -29,8 +29,10 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
|
|
|
29
29
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
def __init__(
|
|
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])
|