reader 3.13__tar.gz → 3.14__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.13 → reader-3.14}/CHANGES.rst +36 -1
- {reader-3.13/src/reader.egg-info → reader-3.14}/PKG-INFO +5 -4
- {reader-3.13 → reader-3.14}/docs/dev.rst +2 -0
- {reader-3.13 → reader-3.14}/docs/internal.rst +8 -3
- reader-3.14/examples/custom_headers.py +50 -0
- {reader-3.13 → reader-3.14}/examples/parser_only.py +1 -1
- {reader-3.13 → reader-3.14}/pyproject.toml +6 -4
- {reader-3.13 → reader-3.14}/src/reader/__init__.py +1 -1
- {reader-3.13 → reader-3.14}/src/reader/_app/__init__.py +19 -3
- {reader-3.13 → reader-3.14}/src/reader/_parser/_lazy.py +20 -20
- {reader-3.13 → reader-3.14}/src/reader/_parser/_url_utils.py +15 -4
- {reader-3.13 → reader-3.14}/src/reader/_parser/feedparser.py +1 -1
- reader-3.14/src/reader/_plugins/enclosure_tags.py +166 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_entries.py +3 -4
- {reader-3.13 → reader-3.14}/src/reader/_storage/_search.py +4 -4
- {reader-3.13 → reader-3.14}/src/reader/_storage/_sqlite_utils.py +2 -1
- {reader-3.13 → reader-3.14}/src/reader/_update.py +3 -6
- {reader-3.13 → reader-3.14}/src/reader/_utils.py +3 -1
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/__init__.py +29 -10
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/api.py +106 -135
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/__init__.py +3 -2
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/asctime.py +26 -17
- reader-3.14/src/reader/_vendor/feedparser/datetimes/greek.py +90 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/hungarian.py +19 -25
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/iso8601.py +62 -52
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/korean.py +35 -24
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/perforce.py +21 -4
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/rfc822.py +52 -23
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +36 -22
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/encodings.py +215 -147
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/exceptions.py +6 -6
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/html.py +93 -84
- reader-3.14/src/reader/_vendor/feedparser/http.py +78 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/mixin.py +330 -275
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/_base.py +210 -166
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/admin.py +10 -10
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/cc.py +20 -19
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/dc.py +21 -17
- reader-3.14/src/reader/_vendor/feedparser/namespaces/georss.py +683 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/itunes.py +25 -21
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/mediarss.py +37 -36
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/psc.py +13 -13
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/json.py +54 -52
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/loose.py +27 -29
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/strict.py +34 -28
- reader-3.14/src/reader/_vendor/feedparser/sanitizer.py +974 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/sgml.py +17 -17
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/urls.py +91 -72
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/util.py +40 -40
- {reader-3.13 → reader-3.14}/src/reader/plugins/entry_dedupe.py +41 -8
- {reader-3.13 → reader-3.14}/src/reader/types.py +12 -0
- {reader-3.13 → reader-3.14/src/reader.egg-info}/PKG-INFO +5 -4
- {reader-3.13 → reader-3.14}/src/reader.egg-info/SOURCES.txt +1 -0
- {reader-3.13 → reader-3.14}/src/reader.egg-info/requires.txt +2 -2
- {reader-3.13 → reader-3.14}/tests/conftest.py +4 -5
- {reader-3.13 → reader-3.14}/tests/test_bench.py +2 -6
- {reader-3.13 → reader-3.14}/tests/test_plugins_entry_dedupe.py +39 -5
- {reader-3.13 → reader-3.14}/tests/test_reader_counts.py +41 -3
- {reader-3.13 → reader-3.14}/tests/test_reader_integration.py +21 -1
- {reader-3.13 → reader-3.14}/tests/test_reader_search.py +3 -3
- {reader-3.13 → reader-3.14}/tests/utils.py +9 -4
- {reader-3.13 → reader-3.14}/tox.ini +3 -3
- reader-3.13/src/reader/_plugins/enclosure_tags.py +0 -119
- reader-3.13/src/reader/_vendor/feedparser/datetimes/greek.py +0 -86
- reader-3.13/src/reader/_vendor/feedparser/http.py +0 -227
- reader-3.13/src/reader/_vendor/feedparser/namespaces/georss.py +0 -276
- reader-3.13/src/reader/_vendor/feedparser/sanitizer.py +0 -950
- {reader-3.13 → reader-3.14}/LICENSE +0 -0
- {reader-3.13 → reader-3.14}/MANIFEST.in +0 -0
- {reader-3.13 → reader-3.14}/README.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/Makefile +0 -0
- {reader-3.13 → reader-3.14}/docs/api.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/app.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/changelog.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/cli.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/conf.py +0 -0
- {reader-3.13 → reader-3.14}/docs/config.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/contributing.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/dev-app.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/guide.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/images/redesign-01.png +0 -0
- {reader-3.13 → reader-3.14}/docs/index.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/install.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/make.bat +0 -0
- {reader-3.13 → reader-3.14}/docs/plugins.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/dillo.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/entries-feed.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/entries.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/entry-one.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/entry-two.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/feeds.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/lynx.png +0 -0
- {reader-3.13 → reader-3.14}/docs/screenshots/search.png +0 -0
- {reader-3.13 → reader-3.14}/docs/tutorial.rst +0 -0
- {reader-3.13 → reader-3.14}/docs/why.rst +0 -0
- {reader-3.13 → reader-3.14}/examples/config.yaml +0 -0
- {reader-3.13 → reader-3.14}/examples/podcast.py +0 -0
- {reader-3.13 → reader-3.14}/examples/terminal.py +0 -0
- {reader-3.13 → reader-3.14}/run.sh +0 -0
- {reader-3.13 → reader-3.14}/scripts/.coverage +0 -0
- {reader-3.13 → reader-3.14}/scripts/backup.sh +0 -0
- {reader-3.13 → reader-3.14}/scripts/bench.py +0 -0
- {reader-3.13 → reader-3.14}/scripts/debug_storage_stats.py +0 -0
- {reader-3.13 → reader-3.14}/scripts/generate_import_all.py +0 -0
- {reader-3.13 → reader-3.14}/scripts/jscontrols.html +0 -0
- {reader-3.13 → reader-3.14}/scripts/jscontrols.py +0 -0
- {reader-3.13 → reader-3.14}/scripts/lines.sh +0 -0
- {reader-3.13 → reader-3.14}/scripts/release.py +0 -0
- {reader-3.13 → reader-3.14}/setup.cfg +0 -0
- {reader-3.13 → reader-3.14}/src/reader/__main__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/api_thing.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/cli.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/static/controls.js +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/static/style.css +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/add_entry.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/entries.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/entry.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/feeds.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/layout.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/macros.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/metadata.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/templates/tags.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_app/wsgi.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_cli.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_config.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_hash_utils.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/_http_utils.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/file.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/http.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/jsonfeed.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/requests/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_parser/requests/_lazy.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/cli_status.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/preview_feed_list.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/share.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/sqlite_releases.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_plugins/timer.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_base.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_changes.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_feeds.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_html_utils.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_schema.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_sql_utils.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_storage/_tags.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_types.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/_vendor/feedparser/py.typed +0 -0
- {reader-3.13 → reader-3.14}/src/reader/core.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/exceptions.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/plugins/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/plugins/enclosure_dedupe.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/plugins/mark_as_read.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/plugins/readtime.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/plugins/ua_fallback.py +0 -0
- {reader-3.13 → reader-3.14}/src/reader/py.typed +0 -0
- {reader-3.13 → reader-3.14}/src/reader.egg-info/dependency_links.txt +0 -0
- {reader-3.13 → reader-3.14}/src/reader.egg-info/top_level.txt +0 -0
- {reader-3.13 → reader-3.14}/tests/data/10.json +0 -0
- {reader-3.13 → reader-3.14}/tests/data/10.json.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/custom +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.atom +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.atom.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.json +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.json.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.rss +0 -0
- {reader-3.13 → reader-3.14}/tests/data/empty.rss.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.atom +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.atom.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.json +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.json.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.rss +0 -0
- {reader-3.13 → reader-3.14}/tests/data/full.rss.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/invalid.json +0 -0
- {reader-3.13 → reader-3.14}/tests/data/invalid.json.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/relative.atom +0 -0
- {reader-3.13 → reader-3.14}/tests/data/relative.atom.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/relative.rss +0 -0
- {reader-3.13 → reader-3.14}/tests/data/relative.rss.py +0 -0
- {reader-3.13 → reader-3.14}/tests/data/sqlite_releases.html +0 -0
- {reader-3.13 → reader-3.14}/tests/data/unknown.json +0 -0
- {reader-3.13 → reader-3.14}/tests/data/unknown.json.py +0 -0
- {reader-3.13 → reader-3.14}/tests/fakeparser.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_methods.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_test_plugins/__init__.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_test_plugins/good.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_test_plugins/init_error.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_test_plugins/missing_dependency.py +0 -0
- {reader-3.13 → reader-3.14}/tests/reader_test_plugins/missing_entry_point.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test__types.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_app.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_app_wsgi.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_changes.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_cli.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_config.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_exceptions.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_hash_utils.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_html_utils.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_lazy_imports.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_parser.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_cli_status.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_enclosure_dedupe.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_mark_as_read.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_preview_feed_list.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_readtime.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_sqlite_releases.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_plugins_ua_fallback.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_context.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_deprecations.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_filter.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_hooks.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_plugins.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_private.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_sort.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_reader_update.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_search.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_sql_utils.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_sqlite_utils.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_storage.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_tags.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_test_utils.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_types.py +0 -0
- {reader-3.13 → reader-3.14}/tests/test_utils.py +0 -0
|
@@ -6,6 +6,41 @@ Changelog
|
|
|
6
6
|
:noindex:
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
Version 3.14
|
|
10
|
+
------------
|
|
11
|
+
|
|
12
|
+
Released 2024-07-23
|
|
13
|
+
|
|
14
|
+
* Add :attr:`~EntryCounts.unimportant` attribute to
|
|
15
|
+
:class:`EntryCounts` and :class:`EntrySearchCounts`.
|
|
16
|
+
Thanks to `chenthur`_ for the pull request.
|
|
17
|
+
(:issue:`283`)
|
|
18
|
+
* Fix bug in the :mod:`~reader.plugins.entry_dedupe` plugin causing entries
|
|
19
|
+
to flip-flop if there were multiple *new* duplicates of the same issue
|
|
20
|
+
(on the first update, one entry remains, on the second update, the other);
|
|
21
|
+
related to the bug fixed in `version 3.2 <Version 3.2_>`_.
|
|
22
|
+
(:issue:`340`)
|
|
23
|
+
|
|
24
|
+
* :mod:`~reader._plugins.enclosure_tags` plugin improvements. (:issue:`344`)
|
|
25
|
+
|
|
26
|
+
* Set genre to `Podcast` if the feed has any tag containing "podcast".
|
|
27
|
+
* Rewrite tags on the fly,
|
|
28
|
+
instead of downloading the entire file before sending it to the user;
|
|
29
|
+
allows browsers to display accurate download progress.
|
|
30
|
+
* Prefer feed user title to feed title if available.
|
|
31
|
+
* Use feed title as artist, instead of author.
|
|
32
|
+
|
|
33
|
+
* Support Python 3.13. (:issue:`341`)
|
|
34
|
+
* Update vendored `feedparser`_ to the ``develop`` branch as of 2024-06-26.
|
|
35
|
+
Needed because upstream removed dependency on stdlib module ``cgi``
|
|
36
|
+
(removed in Python 3.13), but the `version 2.9 <Version 2.9_>`_
|
|
37
|
+
memory usage reduction was not released upstream yet.
|
|
38
|
+
(:issue:`341`)
|
|
39
|
+
|
|
40
|
+
.. _chenthur: https://github.com/chenthur
|
|
41
|
+
.. _feedparser: https://feedparser.readthedocs.io/en/latest/
|
|
42
|
+
|
|
43
|
+
|
|
9
44
|
Version 3.13
|
|
10
45
|
------------
|
|
11
46
|
|
|
@@ -341,7 +376,7 @@ Released 2022-09-14
|
|
|
341
376
|
became optional.
|
|
342
377
|
(:issue:`96`)
|
|
343
378
|
* Fix bug in :mod:`~reader.plugins.entry_dedupe` causing updates to fail
|
|
344
|
-
if there were multiple *new* duplicates of the same
|
|
379
|
+
if there were multiple *new* duplicates of the same entry.
|
|
345
380
|
(:issue:`292`)
|
|
346
381
|
* Fix bug in :mod:`~reader.plugins.readtime`
|
|
347
382
|
and :mod:`~reader.plugins.mark_as_read` causing updates to fail
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: reader
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.14
|
|
4
4
|
Summary: A Python feed reader library.
|
|
5
5
|
Author: lemon24
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.10
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.11
|
|
24
24
|
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
26
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
26
27
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
27
28
|
Classifier: Topic :: Internet
|
|
@@ -65,11 +66,11 @@ Requires-Dist: pytest-rerunfailures; extra == "tests"
|
|
|
65
66
|
Requires-Dist: coverage; extra == "tests"
|
|
66
67
|
Requires-Dist: pytest-cov; extra == "tests"
|
|
67
68
|
Requires-Dist: requests-mock; extra == "tests"
|
|
68
|
-
Requires-Dist: mechanicalsoup; (implementation_name != "pypy"
|
|
69
|
+
Requires-Dist: mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
|
|
69
70
|
Requires-Dist: requests-wsgi-adapter; extra == "tests"
|
|
70
|
-
Requires-Dist: lxml; (implementation_name != "pypy"
|
|
71
|
+
Requires-Dist: lxml; (implementation_name != "pypy" and python_version <= "3.12") and extra == "tests"
|
|
71
72
|
Requires-Dist: html5lib; extra == "tests"
|
|
72
|
-
Requires-Dist: numpy; (implementation_name != "pypy" and os_name == "posix") and extra == "tests"
|
|
73
|
+
Requires-Dist: numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12") and extra == "tests"
|
|
73
74
|
Requires-Dist: mypy; implementation_name != "pypy" and extra == "tests"
|
|
74
75
|
Requires-Dist: types-requests; extra == "tests"
|
|
75
76
|
Requires-Dist: types-beautifulsoup4; extra == "tests"
|
|
@@ -721,6 +721,8 @@ Duplicate entries
|
|
|
721
721
|
Duplicate entries are mainly handled by the :mod:`reader.entry_dedupe` plugin.
|
|
722
722
|
|
|
723
723
|
* Using MinHash to speed up similarity checks (maybe): https://gist.github.com/lemon24/b9af5ade919713406bda9603847d32e5
|
|
724
|
+
* Discussion of unifying "on-line" dedupe (after an entry is added/updated),
|
|
725
|
+
and "on-demand" dedupe (backfill): :issue:`340`.
|
|
724
726
|
|
|
725
727
|
However, it is also possible for a feed to have two entries with the same id
|
|
726
728
|
– yes, even though in most (if not all) formats,
|
|
@@ -219,9 +219,14 @@ Type aliases
|
|
|
219
219
|
Recipes
|
|
220
220
|
-------
|
|
221
221
|
|
|
222
|
-
.. include:: ../examples/
|
|
222
|
+
.. include:: ../examples/custom_headers.py
|
|
223
223
|
:start-after: """
|
|
224
|
-
:end-before: """
|
|
224
|
+
:end-before: """
|
|
225
|
+
.. literalinclude:: ../examples/custom_headers.py
|
|
226
|
+
:start-at: import
|
|
225
227
|
|
|
228
|
+
.. include:: ../examples/parser_only.py
|
|
229
|
+
:start-after: """
|
|
230
|
+
:end-before: """
|
|
226
231
|
.. literalinclude:: ../examples/parser_only.py
|
|
227
|
-
:start-
|
|
232
|
+
:start-at: import
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adding custom headers when retrieving feeds
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Example of adding custom request headers with :attr:`.SessionFactory.request_hooks`:
|
|
6
|
+
|
|
7
|
+
.. code-block:: console
|
|
8
|
+
|
|
9
|
+
$ python examples/custom_headers.py
|
|
10
|
+
updating...
|
|
11
|
+
server: Hello, world!
|
|
12
|
+
updated!
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# fmt: off
|
|
17
|
+
# flake8: noqa
|
|
18
|
+
|
|
19
|
+
import http.server
|
|
20
|
+
import threading
|
|
21
|
+
from reader import make_reader
|
|
22
|
+
|
|
23
|
+
# start a background server that logs the received header
|
|
24
|
+
|
|
25
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
26
|
+
def log_message(self, *_): pass
|
|
27
|
+
def do_GET(self):
|
|
28
|
+
print("server:", self.headers.get('my-header'))
|
|
29
|
+
self.send_error(304)
|
|
30
|
+
|
|
31
|
+
server = http.server.HTTPServer(('localhost', 8080), Handler)
|
|
32
|
+
threading.Thread(target=server.handle_request).start()
|
|
33
|
+
|
|
34
|
+
# create a reader object
|
|
35
|
+
|
|
36
|
+
reader = make_reader(':memory:')
|
|
37
|
+
reader.add_feed('http://localhost:8080')
|
|
38
|
+
|
|
39
|
+
# set up a hook that adds the header to each request
|
|
40
|
+
|
|
41
|
+
def hook(session, request, **kwargs):
|
|
42
|
+
request.headers.setdefault('my-header', 'Hello, world!')
|
|
43
|
+
|
|
44
|
+
reader._parser.session_factory.request_hooks.append(hook)
|
|
45
|
+
|
|
46
|
+
# updating the feed sends the modified request to the server
|
|
47
|
+
|
|
48
|
+
print("updating...")
|
|
49
|
+
reader.update_feeds()
|
|
50
|
+
print("updated!")
|
|
@@ -11,7 +11,7 @@ retrieved asynchronously with `HTTPX <https://www.python-httpx.org/>`_:
|
|
|
11
11
|
death and gravity
|
|
12
12
|
Has your password been pwned? Or, how I almost failed to search a 37 GB text file in under 1 millisecond (in Python)
|
|
13
13
|
|
|
14
|
-
"""
|
|
14
|
+
"""
|
|
15
15
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import io
|
|
@@ -26,6 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Programming Language :: Python :: 3.10",
|
|
27
27
|
"Programming Language :: Python :: 3.11",
|
|
28
28
|
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
29
30
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
30
31
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
31
32
|
"Topic :: Internet",
|
|
@@ -107,14 +108,15 @@ tests = [
|
|
|
107
108
|
"pytest-cov",
|
|
108
109
|
"requests-mock",
|
|
109
110
|
# mechanicalsoup hard-depends on lxml (see below)
|
|
110
|
-
'mechanicalsoup; (implementation_name != "pypy"
|
|
111
|
+
'mechanicalsoup; (implementation_name != "pypy" and python_version <= "3.12")',
|
|
111
112
|
"requests-wsgi-adapter",
|
|
112
113
|
# we want to test search with all known bs4 parsers.
|
|
113
|
-
# lxml does not have
|
|
114
|
-
|
|
114
|
+
# lxml usually does not have recent PyPy wheels.
|
|
115
|
+
# lxml usually does not have pre-relase CPython wheels.
|
|
116
|
+
'lxml; (implementation_name != "pypy" and python_version <= "3.12")',
|
|
115
117
|
"html5lib",
|
|
116
118
|
# for bench.py
|
|
117
|
-
'numpy; (implementation_name != "pypy" and os_name == "posix")',
|
|
119
|
+
'numpy; (implementation_name != "pypy" and os_name == "posix" and python_version <= "3.12")',
|
|
118
120
|
# mypy does not work on pypy (yet).
|
|
119
121
|
'mypy; implementation_name != "pypy"',
|
|
120
122
|
"types-requests",
|
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
from datetime import timedelta
|
|
10
10
|
from datetime import timezone
|
|
11
|
+
from functools import lru_cache
|
|
11
12
|
|
|
12
13
|
import flask.signals
|
|
13
14
|
import humanize
|
|
@@ -418,7 +419,7 @@ def add_entry():
|
|
|
418
419
|
FEED_SORT_NATIVE = {'title', 'added'}
|
|
419
420
|
FEED_SORT_FANCY = {
|
|
420
421
|
'important': lambda counts: counts.important,
|
|
421
|
-
|
|
422
|
+
'unimportant': lambda counts: counts.unimportant,
|
|
422
423
|
'unread': lambda counts: counts.total - counts.read,
|
|
423
424
|
# TODO: if we keep these average intervals, properties for them might be nice too
|
|
424
425
|
'avg1m': lambda counts: counts.averages[0],
|
|
@@ -435,7 +436,6 @@ def feeds():
|
|
|
435
436
|
|
|
436
437
|
updates_enabled = request.args.get('updates-enabled')
|
|
437
438
|
updates_enabled = {None: None, 'no': False, 'yes': True}[updates_enabled]
|
|
438
|
-
|
|
439
439
|
sort = request.args.get('sort', 'title')
|
|
440
440
|
assert sort in FEED_SORT_ALL
|
|
441
441
|
|
|
@@ -801,12 +801,28 @@ def delete_entry(data):
|
|
|
801
801
|
get_reader().delete_entry((feed_url, entry_id))
|
|
802
802
|
|
|
803
803
|
|
|
804
|
+
def get_feed_tag_keys(url):
|
|
805
|
+
assert isinstance(url, str), url
|
|
806
|
+
|
|
807
|
+
if not hasattr(g, 'reader_get_feed_tag_keys'):
|
|
808
|
+
|
|
809
|
+
@lru_cache(128)
|
|
810
|
+
def get(url):
|
|
811
|
+
return list(current_app.reader.get_tag_keys(url))
|
|
812
|
+
|
|
813
|
+
g.reader_get_feed_tag_keys = get
|
|
814
|
+
|
|
815
|
+
return g.reader_get_feed_tag_keys(url)
|
|
816
|
+
|
|
817
|
+
|
|
804
818
|
# for some reason, @blueprint.app_template_global does not work
|
|
805
819
|
@blueprint.app_template_global()
|
|
806
820
|
def additional_enclosure_links(enclosure, entry):
|
|
807
821
|
funcs = getattr(current_app, 'reader_additional_enclosure_links', ())
|
|
822
|
+
# TODO: this would not be needed if entry.feed could have tags on it
|
|
823
|
+
feed_tags = get_feed_tag_keys(entry.feed.url)
|
|
808
824
|
for func in funcs:
|
|
809
|
-
yield from func(enclosure, entry)
|
|
825
|
+
yield from func(enclosure, entry, feed_tags)
|
|
810
826
|
|
|
811
827
|
|
|
812
828
|
@blueprint.app_template_global()
|
|
@@ -72,7 +72,7 @@ class Parser:
|
|
|
72
72
|
self.parsers_by_mime_type: dict[str, list[tuple[float, ParserType[Any]]]] = {}
|
|
73
73
|
self.parsers_by_url: dict[str, ParserType[Any]] = {}
|
|
74
74
|
|
|
75
|
-
#: :class:`~reader.
|
|
75
|
+
#: :class:`~reader._parser.requests.SessionFactory`
|
|
76
76
|
#: used to create Requests sessions for retrieving feeds.
|
|
77
77
|
#:
|
|
78
78
|
#: Plugins may add request or response hooks to this.
|
|
@@ -314,25 +314,25 @@ class Parser:
|
|
|
314
314
|
ParseError: No parser matches.
|
|
315
315
|
|
|
316
316
|
"""
|
|
317
|
-
parser
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
317
|
+
if parser := self.get_parser_by_url(url):
|
|
318
|
+
return parser, mime_type
|
|
319
|
+
|
|
320
|
+
if not mime_type:
|
|
321
|
+
mime_type, _ = mimetypes.guess_type(url)
|
|
322
|
+
|
|
323
|
+
# https://tools.ietf.org/html/rfc7231#section-3.1.1.5
|
|
324
|
+
#
|
|
325
|
+
# > If a Content-Type header field is not present, the recipient
|
|
326
|
+
# > MAY either assume a media type of "application/octet-stream"
|
|
327
|
+
# > ([RFC2046], Section 4.5.1) or examine the data to determine its type.
|
|
328
|
+
#
|
|
329
|
+
if not mime_type:
|
|
330
|
+
mime_type = 'application/octet-stream'
|
|
331
|
+
|
|
332
|
+
if parser := self.get_parser_by_mime_type(mime_type):
|
|
333
|
+
return parser, mime_type
|
|
334
|
+
|
|
335
|
+
raise ParseError(url, message=f"no parser for MIME type {mime_type!r}")
|
|
336
336
|
|
|
337
337
|
def validate_url(self, url: str) -> None:
|
|
338
338
|
"""Check if ``url`` is valid without actually retrieving it.
|
|
@@ -93,6 +93,8 @@ def resolve_root(root: str, path: str) -> str:
|
|
|
93
93
|
|
|
94
94
|
# we normalize the path **before** symlinks are resolved;
|
|
95
95
|
# i.e. it behaves as realpath -L (logical), not realpath -P (physical).
|
|
96
|
+
# we could probably use pathlib resolve() here, but it is not pure,
|
|
97
|
+
# and we can't mock the Windows version on POSIX.
|
|
96
98
|
# https://docs.python.org/3/library/os.path.html#os.path.normpath
|
|
97
99
|
# https://stackoverflow.com/questions/34865153/os-path-normpath-and-symbolic-links
|
|
98
100
|
path = os.path.normcase(os.path.normpath(os.path.join(root, path)))
|
|
@@ -112,8 +114,8 @@ def resolve_root(root: str, path: str) -> str:
|
|
|
112
114
|
def is_abs_path(path: str) -> bool:
|
|
113
115
|
"""Return True if path is an absolute pathname.
|
|
114
116
|
|
|
115
|
-
Unlike os.path.isabs(), return False on Windows
|
|
116
|
-
(e.g. "\\path").
|
|
117
|
+
Unlike os.path.isabs(), return False on Windows
|
|
118
|
+
if there's no drive (e.g. "\\path").
|
|
117
119
|
|
|
118
120
|
"""
|
|
119
121
|
is_abs = os.path.isabs(path)
|
|
@@ -124,10 +126,19 @@ def is_abs_path(path: str) -> bool:
|
|
|
124
126
|
def is_rel_path(path: str) -> bool:
|
|
125
127
|
"""Return True if path is a relative pathname.
|
|
126
128
|
|
|
127
|
-
Unlike "not os.path.isabs()", return False on
|
|
128
|
-
(e.g. "C:path").
|
|
129
|
+
Unlike "not os.path.isabs()", return False on Windows
|
|
130
|
+
if there's a drive (e.g. "C:path").
|
|
129
131
|
|
|
130
132
|
"""
|
|
133
|
+
# starting with Python 3.13, os.path.isabs('/a') is False on Windows.
|
|
134
|
+
# pathlib is_absolute() doesn't need this.
|
|
135
|
+
path = os.path.normpath(path)
|
|
136
|
+
|
|
131
137
|
is_abs = os.path.isabs(path)
|
|
138
|
+
|
|
139
|
+
# starting with Python 3.13, os.path.isabs('\\a') is False on Windows.
|
|
140
|
+
if os.name == 'nt' and path.startswith('\\'):
|
|
141
|
+
is_abs = True
|
|
142
|
+
|
|
132
143
|
has_drive = os.name == 'nt' and os.path.splitdrive(path)[0]
|
|
133
144
|
return not any([is_abs, has_drive])
|
|
@@ -51,7 +51,7 @@ class FeedparserParser:
|
|
|
51
51
|
# feedparser content sanitization and relative link resolution should be ON.
|
|
52
52
|
# https://github.com/lemon24/reader/issues/125
|
|
53
53
|
# https://github.com/lemon24/reader/issues/157
|
|
54
|
-
result = feedparser.parse(
|
|
54
|
+
result = feedparser.parse(
|
|
55
55
|
resource,
|
|
56
56
|
resolve_relative_uris=True,
|
|
57
57
|
sanitize_html=True,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
enclosure_tags
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Fix tags for MP3 enclosures (e.g. podcasts).
|
|
6
|
+
|
|
7
|
+
Adds a "with tags" link to a version of the file with tags set as follows:
|
|
8
|
+
|
|
9
|
+
* the entry title as title
|
|
10
|
+
* the feed (user) title as album and artist
|
|
11
|
+
* `Podcast` as genre, if the feed has any tag containing "podcast"
|
|
12
|
+
|
|
13
|
+
This plugin needs additional dependencies, use the ``unstable-plugins`` extra
|
|
14
|
+
to install them:
|
|
15
|
+
|
|
16
|
+
.. code-block:: bash
|
|
17
|
+
|
|
18
|
+
pip install reader[unstable-plugins]
|
|
19
|
+
|
|
20
|
+
To load::
|
|
21
|
+
|
|
22
|
+
READER_APP_PLUGIN='reader._plugins.enclosure_tags:init' \\
|
|
23
|
+
python -m reader serve
|
|
24
|
+
|
|
25
|
+
Implemented for :issue:`50`.
|
|
26
|
+
Became a plugin in :issue:`52`.
|
|
27
|
+
Streaming added in :issue:`344`.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import io
|
|
32
|
+
from urllib.parse import urlparse
|
|
33
|
+
|
|
34
|
+
import mutagen.mp3
|
|
35
|
+
import requests
|
|
36
|
+
from flask import Blueprint
|
|
37
|
+
from flask import request
|
|
38
|
+
from flask import Response
|
|
39
|
+
from flask import stream_with_context
|
|
40
|
+
from flask import url_for
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
blueprint = Blueprint('enclosure_tags', __name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ALL_TAGS = ('album', 'title', 'artist', 'genre')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@blueprint.route('/enclosure-tags', defaults={'filename': None})
|
|
50
|
+
@blueprint.route('/enclosure-tags/<filename>')
|
|
51
|
+
def enclosure_tags(filename):
|
|
52
|
+
tags = {}
|
|
53
|
+
for tag in ALL_TAGS:
|
|
54
|
+
if value := request.args.get(tag):
|
|
55
|
+
tags[tag] = value
|
|
56
|
+
|
|
57
|
+
# TODO: handle raise_for_status() exceptions nicely
|
|
58
|
+
headers, chunks = update_tags_requests(request.args['url'], tags)
|
|
59
|
+
|
|
60
|
+
def iter_chunks():
|
|
61
|
+
# Send the headers as soon as possible.
|
|
62
|
+
# Some browsers wait for the headers before showing the "Save As" dialog.
|
|
63
|
+
yield ''
|
|
64
|
+
yield from chunks
|
|
65
|
+
|
|
66
|
+
return Response(stream_with_context(iter_chunks()), headers=headers)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def update_tags_requests(url, tags, *, session=requests):
|
|
70
|
+
"""update_tags_requests(url, ...) -> (headers, iter_chunks())"""
|
|
71
|
+
|
|
72
|
+
response = requests.get(url, stream=True)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
|
|
75
|
+
headers = {}
|
|
76
|
+
|
|
77
|
+
if content_disposition := response.headers.get('content-disposition'):
|
|
78
|
+
headers['content_disposition'] = content_disposition
|
|
79
|
+
response.raw.name = content_disposition
|
|
80
|
+
else:
|
|
81
|
+
response.raw.name = urlparse(url).path.split('/')[-1]
|
|
82
|
+
|
|
83
|
+
old_prefix, new_prefix = update_tags(response.raw, tags)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
content_length = int(response.headers.get('content-length', ''))
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
89
|
+
else:
|
|
90
|
+
content_length = content_length - len(old_prefix) + len(new_prefix)
|
|
91
|
+
headers['content-length'] = str(content_length)
|
|
92
|
+
|
|
93
|
+
if content_type := response.headers.get('content-type'):
|
|
94
|
+
headers['content-type'] = content_type
|
|
95
|
+
|
|
96
|
+
def iter_chunks():
|
|
97
|
+
with response:
|
|
98
|
+
yield new_prefix
|
|
99
|
+
yield from response.iter_content(2**18)
|
|
100
|
+
|
|
101
|
+
return headers, iter_chunks()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def update_tags(file, tags):
|
|
105
|
+
"""update_tags(file, ...) -> (old_prefix, new_prefix)
|
|
106
|
+
|
|
107
|
+
Rewrite the prefix of file to update ID3v2 tags.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
prefix = b''
|
|
111
|
+
easy = None
|
|
112
|
+
for size in [2**17, 2**17, 2**18, 2**19, 2**19, 2**19]:
|
|
113
|
+
chunk = file.read(size)
|
|
114
|
+
if not chunk:
|
|
115
|
+
break
|
|
116
|
+
prefix += chunk
|
|
117
|
+
try:
|
|
118
|
+
easy = mutagen.mp3.EasyMP3(io.BytesIO(prefix))
|
|
119
|
+
except mutagen.MutagenError:
|
|
120
|
+
# TODO: debug logging
|
|
121
|
+
pass
|
|
122
|
+
else:
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if easy is None:
|
|
126
|
+
return prefix, prefix
|
|
127
|
+
|
|
128
|
+
if easy.info.sketchy:
|
|
129
|
+
return prefix, prefix
|
|
130
|
+
|
|
131
|
+
offset = easy.info.frame_offset
|
|
132
|
+
|
|
133
|
+
for tag, value in tags.items():
|
|
134
|
+
easy[tag] = value
|
|
135
|
+
|
|
136
|
+
out = io.BytesIO()
|
|
137
|
+
easy.save(out)
|
|
138
|
+
|
|
139
|
+
return prefix, out.getvalue() + prefix[offset:]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def enclosure_tags_filter(enclosure, entry, feed_tags):
|
|
143
|
+
filename = urlparse(enclosure.href).path.split('/')[-1]
|
|
144
|
+
if not filename.endswith('.mp3'):
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
args = {'url': enclosure.href, 'filename': filename}
|
|
148
|
+
if entry.title:
|
|
149
|
+
args['title'] = entry.title
|
|
150
|
+
if album := (entry.feed.user_title or entry.feed.title):
|
|
151
|
+
args['album'] = album
|
|
152
|
+
args['artist'] = album
|
|
153
|
+
elif artist := (entry.author or entry.feed.author):
|
|
154
|
+
args['artist'] = artist
|
|
155
|
+
|
|
156
|
+
for tag in feed_tags:
|
|
157
|
+
if 'podcast' in tag.lower():
|
|
158
|
+
args['genre'] = 'Podcast'
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
return [('with tags', url_for('enclosure_tags.enclosure_tags', **args))]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def init(app):
|
|
165
|
+
app.register_blueprint(blueprint)
|
|
166
|
+
app.reader_additional_enclosure_links.append(enclosure_tags_filter)
|
|
@@ -62,7 +62,7 @@ class EntriesMixin(StorageBase):
|
|
|
62
62
|
self.paginated_query,
|
|
63
63
|
partial(get_entries_query, filter, sort),
|
|
64
64
|
row_factory=entry_factory,
|
|
65
|
-
)
|
|
65
|
+
)
|
|
66
66
|
if sort != 'random':
|
|
67
67
|
last = self.get_entry_last(sort, starting_after) if starting_after else None
|
|
68
68
|
return paginated_query(limit, last)
|
|
@@ -101,8 +101,7 @@ class EntriesMixin(StorageBase):
|
|
|
101
101
|
context.update(new_context)
|
|
102
102
|
|
|
103
103
|
row = exactly_one(self.get_db().execute(str(query), context))
|
|
104
|
-
|
|
105
|
-
return EntryCounts(*row[:4], row[4:7]) # type: ignore[call-arg]
|
|
104
|
+
return EntryCounts(*row[:5], row[5:8]) # type: ignore[call-arg]
|
|
106
105
|
|
|
107
106
|
@wrap_exceptions()
|
|
108
107
|
def set_entry_read(
|
|
@@ -593,6 +592,7 @@ def get_entry_counts_query(
|
|
|
593
592
|
'count(*)',
|
|
594
593
|
'coalesce(sum(read == 1), 0)',
|
|
595
594
|
'coalesce(sum(important == 1), 0)',
|
|
595
|
+
'coalesce(sum(important == 0), 0)',
|
|
596
596
|
"""
|
|
597
597
|
coalesce(
|
|
598
598
|
sum(
|
|
@@ -606,7 +606,6 @@ def get_entry_counts_query(
|
|
|
606
606
|
.FROM("entries_filtered")
|
|
607
607
|
.JOIN("entries USING (id, feed)")
|
|
608
608
|
)
|
|
609
|
-
|
|
610
609
|
# one CTE / period + HAVING in the CTE is a tiny bit faster than
|
|
611
610
|
# one CTE + WHERE in the SELECT
|
|
612
611
|
|
|
@@ -349,7 +349,7 @@ class Search:
|
|
|
349
349
|
(change.sequence, *change.resource_id),
|
|
350
350
|
)
|
|
351
351
|
if cursor.rowcount: # pragma: no cover
|
|
352
|
-
log.
|
|
352
|
+
log.warning(
|
|
353
353
|
"during insert, found and deleted %d rows for %r",
|
|
354
354
|
cursor.rowcount,
|
|
355
355
|
change,
|
|
@@ -409,7 +409,7 @@ class Search:
|
|
|
409
409
|
def pq(
|
|
410
410
|
limit: int | None, last: tuple[Any, ...] | None = None
|
|
411
411
|
) -> Iterable[EntrySearchResult]:
|
|
412
|
-
with wrap_exceptions(ENABLED_EXC | QUERY_EXC):
|
|
412
|
+
with wrap_exceptions(ENABLED_EXC | QUERY_EXC): # type: ignore[operator]
|
|
413
413
|
yield from paginated_query(
|
|
414
414
|
self.get_db(),
|
|
415
415
|
make_query,
|
|
@@ -455,7 +455,7 @@ class Search:
|
|
|
455
455
|
lambda: EntryNotFoundError(feed_url, entry_id),
|
|
456
456
|
)
|
|
457
457
|
|
|
458
|
-
@wrap_exceptions(ENABLED_EXC | QUERY_EXC)
|
|
458
|
+
@wrap_exceptions(ENABLED_EXC | QUERY_EXC) # type: ignore[operator]
|
|
459
459
|
def search_entry_counts(
|
|
460
460
|
self,
|
|
461
461
|
query: str,
|
|
@@ -486,7 +486,7 @@ class Search:
|
|
|
486
486
|
|
|
487
487
|
context = dict(query=query, **query_context)
|
|
488
488
|
row = exactly_one(self.get_db().execute(str(sql_query), context))
|
|
489
|
-
return EntrySearchCounts(*row[:
|
|
489
|
+
return EntrySearchCounts(*row[:5], row[5:8]) # type: ignore[call-arg]
|
|
490
490
|
|
|
491
491
|
|
|
492
492
|
def make_search_entries_query(
|
|
@@ -14,6 +14,7 @@ import traceback
|
|
|
14
14
|
import weakref
|
|
15
15
|
from collections.abc import Callable
|
|
16
16
|
from collections.abc import Iterator
|
|
17
|
+
from collections.abc import Mapping
|
|
17
18
|
from collections.abc import Sequence
|
|
18
19
|
from contextlib import closing
|
|
19
20
|
from contextlib import contextmanager
|
|
@@ -85,7 +86,7 @@ def ddl_transaction(db: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
|
|
85
86
|
@contextmanager
|
|
86
87
|
def wrap_exceptions(
|
|
87
88
|
exc_type: Callable[[str], Exception],
|
|
88
|
-
op_exc_types:
|
|
89
|
+
op_exc_types: Mapping[str, Callable[[str], Exception]] = {}, # noqa: B006
|
|
89
90
|
message: str = "unexpected error",
|
|
90
91
|
) -> Iterator[None]:
|
|
91
92
|
"""Wrap sqlite3 exceptions in a custom exception.
|
|
@@ -444,14 +444,14 @@ class Pipeline:
|
|
|
444
444
|
|
|
445
445
|
try:
|
|
446
446
|
# assemble pipeline
|
|
447
|
-
entry_pairs = self.get_entry_pairs(result)
|
|
448
|
-
|
|
449
447
|
if result and not isinstance(result, Exception):
|
|
448
|
+
entry_pairs = self.get_entry_pairs(result)
|
|
450
449
|
entry_pairs = self.reader._parser.process_entry_pairs(
|
|
451
450
|
feed.url, result.mime_type, entry_pairs
|
|
452
451
|
)
|
|
453
452
|
entry_pairs, get_total_count = count_consumed(entry_pairs)
|
|
454
453
|
else:
|
|
454
|
+
entry_pairs = ()
|
|
455
455
|
get_total_count = lambda: 0 # noqa: E731
|
|
456
456
|
|
|
457
457
|
intents = make_intents(entry_pairs)
|
|
@@ -466,10 +466,7 @@ class Pipeline:
|
|
|
466
466
|
|
|
467
467
|
return feed.url, UpdatedFeed(feed.url, *counts, total - sum(counts))
|
|
468
468
|
|
|
469
|
-
def get_entry_pairs(self, result: ParsedFeed
|
|
470
|
-
if not result or isinstance(result, Exception):
|
|
471
|
-
return ()
|
|
472
|
-
|
|
469
|
+
def get_entry_pairs(self, result: ParsedFeed) -> EntryPairs:
|
|
473
470
|
# give storage a chance to consume entries in a streaming fashion
|
|
474
471
|
entries1, entries2 = tee(result.entries)
|
|
475
472
|
entries_for_update = self.reader._storage.get_entries_for_update(
|
|
@@ -89,8 +89,10 @@ def count_consumed(it: Iterable[_T]) -> tuple[Iterable[_T], Callable[[], int]]:
|
|
|
89
89
|
MapFunction = Callable[[Callable[[_T], _U], Iterable[_T]], Iterator[_U]]
|
|
90
90
|
MapContextManager = AbstractContextManager[MapFunction[_T, _U]]
|
|
91
91
|
|
|
92
|
+
# type ignore because of https://github.com/python/mypy/issues/17551
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
|
|
95
|
+
@contextmanager # type: ignore[arg-type]
|
|
94
96
|
def make_pool_map(workers: int) -> Iterator[MapFunction[_T, _U]]:
|
|
95
97
|
# We are using concurrent.futures instead of multiprocessing.dummy
|
|
96
98
|
# because the latter doesn't work on some environments (e.g. AWS Lambda).
|