Skip to content

Commit d17a055

Browse files
committed
Add high level OPML import/export API. #165
1 parent 19ec534 commit d17a055

10 files changed

Lines changed: 228 additions & 38 deletions

File tree

docs/api.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ Data objects
6767
.. autoclass:: EntryUpdateStatus
6868
:members:
6969

70+
.. autoclass:: FeedImportResult
71+
:members:
72+
73+
.. autoclass:: FeedExport
74+
:members:
75+
76+
7077

7178
Exceptions
7279
----------
@@ -159,6 +166,9 @@ Exceptions
159166
.. autoexception:: ReaderWarning
160167
:show-inheritance:
161168

169+
.. autoexception:: FeedImportError
170+
:show-inheritance:
171+
162172

163173
.. _exctree:
164174

docs/conf.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
sys.modules[name] = unittest.mock.Mock()
2424

2525
import reader
26-
import reader.opml
2726

2827
extensions = [
2928
'sphinx_rtd_theme',

src/reader/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
UpdateResult as UpdateResult,
6565
UpdatedFeed as UpdatedFeed,
6666
EntryUpdateStatus as EntryUpdateStatus,
67+
FeedImportResult as FeedImportResult,
68+
FeedExport as FeedExport,
6769
)
6870

6971
from .exceptions import (
@@ -91,6 +93,7 @@
9193
InvalidPluginError as InvalidPluginError,
9294
PluginInitError as PluginInitError,
9395
ReaderWarning as ReaderWarning,
96+
FeedImportError as FeedImportError,
9497
)
9598

9699
# Constants.

src/reader/_app/__init__.py

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -167,18 +167,8 @@ def feeds():
167167
feeds = reader.get_feeds(**kwargs)
168168

169169
if request.args.get('format', '').lower() == 'opml':
170-
# TODO: good candidate for a Reader method
171-
feeds = (f for f in feeds if not f.url.startswith('reader:'))
172-
173-
now = reader._now()
174-
# TODO: title should contain branding
175-
title = 'reader feeds'
176-
filename = f"{title.replace(' ', '-')}-{now:%Y-%m-%d-%H-%M-%S}.opml"
177-
178-
return opml.unparse(feeds, title=title, created=now), {
179-
'Content-Type': 'application/xml',
180-
'Content-Disposition': f'attachment; filename="{filename}"',
181-
}
170+
export = reader.export_feeds(feeds)
171+
return export.content, export.headers
182172

183173
return stream_template(
184174
'feeds.html',
@@ -296,26 +286,12 @@ def import_feeds():
296286
if request.method == 'POST':
297287
if file := request.files.get('file'):
298288
try:
299-
feeds = opml.parse(file.stream)
289+
parsed_feeds = [asdict(f) for f in opml.parse(file.stream)]
300290
except opml.OPMLError as e:
301291
error = str(e)
302-
else:
303-
# TODO: good candidate for a Reader method
304-
feeds = (f for f in feeds if not f.url.startswith('reader:'))
305-
parsed_feeds = [asdict(f) for f in feeds]
306292
else:
307-
feeds = [opml.Feed(**json.loads(f)) for f in request.form.getlist('feed')]
308-
# TODO: good candidate for a Reader method
309-
imported_feeds = []
310-
for feed in feeds:
311-
try:
312-
reader.add_feed(feed)
313-
except InvalidFeedURLError as e:
314-
imported_feeds.append((feed, False, f"invalid feed: {e}"))
315-
except FeedExistsError:
316-
imported_feeds.append((feed, False, None))
317-
else:
318-
imported_feeds.append((feed, True, None))
293+
feeds = (opml.Feed(**json.loads(f)) for f in request.form.getlist('feed'))
294+
imported_feeds = list(reader.import_feeds_iter(feeds))
319295
# TODO: out of band update (unlike add, there's too many to do here)
320296

321297
return render_template(

src/reader/_app/templates/import_feeds.html

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,23 +89,24 @@ <h2 class="h5 mb-1" style="font-size: 1.125rem">
8989

9090
<p>Imported feeds:</p>
9191

92-
{% for feed, added, error in imported_feeds %}
92+
{% for result in imported_feeds %}
93+
{%- set feed = result.feed %}
9394
<div class="mb-3">
9495

9596
{#- NOTE similarity to feeds page feeds #}
9697
<h2 class="h5 mb-1" style="font-size: 1.125rem">
9798
{{ macros.title_href(
9899
feed.title or feed.url,
99-
url_for('.entries', feed=feed.url) if not error else None,
100+
url_for('.entries', feed=feed.url) if not result.error else None,
100101
external=feed.link,
101102
feed_url=feed.url,
102103
) }}
103104
</h2>
104105

105-
{% if added %}
106+
{% if result.added %}
106107
{{ macros.subtitle("feed added", 'text-success-emphasis') }}
107-
{% elif error %}
108-
{{ macros.subtitle(error, 'text-danger-emphasis') }}
108+
{% elif result.error %}
109+
{{ macros.subtitle("invalid feed: {}".format(result.error), 'text-danger-emphasis') }}
109110
{% else %}
110111
{{ macros.subtitle("feed already exists", 'text-secondary') }}
111112
{% endif %}

src/reader/core.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from datetime import timezone
1414
from types import MappingProxyType
1515
from typing import Any
16+
from typing import IO
1617
from typing import Literal
1718
from typing import overload
1819
from typing import Self
@@ -40,6 +41,7 @@
4041
from ._utils import MapContextManager
4142
from ._utils import zero_or_one
4243
from .exceptions import EntryNotFoundError
44+
from .exceptions import FeedError
4345
from .exceptions import FeedExistsError
4446
from .exceptions import FeedNotFoundError
4547
from .exceptions import ParseError
@@ -64,6 +66,8 @@
6466
from .types import EntryUpdateStatus
6567
from .types import Feed
6668
from .types import FeedCounts
69+
from .types import FeedExport
70+
from .types import FeedImportResult
6771
from .types import FeedInput
6872
from .types import FeedSort
6973
from .types import JSONType
@@ -76,6 +80,7 @@
7680
from .types import UpdateResult
7781

7882
if TYPE_CHECKING: # pragma: no cover
83+
from . import opml
7984
from ._parser import Parser
8085

8186

@@ -2181,6 +2186,80 @@ def delete_tag(
21812186
if not missing_ok:
21822187
raise
21832188

2189+
def import_feeds(self, file: IO[bytes], /) -> None:
2190+
"""Import feeds from an OPML subscription list.
2191+
2192+
Existing and unsupported feeds are silently skipped.
2193+
2194+
Args:
2195+
file (file): A binary file.
2196+
2197+
Raises:
2198+
FeedImportError: If the file could not be parsed.
2199+
StorageError
2200+
2201+
.. versionadded:: 3.23
2202+
2203+
"""
2204+
from . import opml
2205+
2206+
for _ in self.import_feeds_iter(opml.parse(file)):
2207+
pass
2208+
2209+
def import_feeds_iter(
2210+
self, feeds: Iterable[opml.Feed], /
2211+
) -> Iterable[FeedImportResult]:
2212+
"""Import feeds returned by :func:`reader.opml.parse`.
2213+
2214+
Args:
2215+
feeds (iterable(reader.opml.Feed)): The feeds to import.
2216+
2217+
Yields:
2218+
:class:`FeedImportResult`: The feed and whether it was added.
2219+
2220+
Raises:
2221+
StorageError
2222+
2223+
.. versionadded:: 3.23
2224+
2225+
"""
2226+
for feed in feeds:
2227+
try:
2228+
self.add_feed(feed)
2229+
except FeedError as e:
2230+
yield FeedImportResult(feed, e)
2231+
else:
2232+
yield FeedImportResult(feed)
2233+
2234+
def export_feeds(self, feeds: Iterable[Feed] | None = None, /) -> FeedExport:
2235+
"""Export all or some feeds as an OPML subscription list.
2236+
2237+
Args:
2238+
feeds (iterable(Feed)): The feeds to export; if None, export all feeds.
2239+
2240+
Returns:
2241+
FeedExport: The OPML export.
2242+
2243+
Raises:
2244+
StorageError
2245+
2246+
.. versionadded:: 3.23
2247+
2248+
"""
2249+
from . import opml
2250+
2251+
if feeds is None:
2252+
feeds = self.get_feeds()
2253+
2254+
feeds = (f for f in feeds if not f.url.startswith('reader:'))
2255+
2256+
now = self._now()
2257+
title = 'reader feeds'
2258+
filename = f"{title.replace(' ', '-')}-{now:%Y-%m-%d-%H-%M-%S}.opml"
2259+
content = opml.unparse(feeds, title=title, created=now)
2260+
2261+
return FeedExport(content, filename)
2262+
21842263
def make_reader_reserved_name(self, key: str, /) -> str:
21852264
"""Create a *reader*-reserved tag name.
21862265
See :ref:`reserved names` for details.

src/reader/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,11 @@ class ChangeTrackingNotEnabledError(StorageError):
407407
"""
408408

409409
_default_message = "operation not supported with change tracking disabled"
410+
411+
412+
class FeedImportError(ReaderError):
413+
"""An error occured while parsing a feed import.
414+
415+
.. versionadded:: 3.23
416+
417+
"""

src/reader/opml.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class Feed:
5858
subtitle: str | None = None
5959

6060

61-
class OPMLError(reader.ReaderError):
61+
class OPMLError(reader.FeedImportError):
6262
"""An error occurred while parsing an OPML subscription list."""
6363

6464

@@ -82,7 +82,7 @@ def parse(file: IO[bytes], max_depth: int = 10) -> list[Feed]:
8282
try:
8383
tree = etree.parse(file)
8484
except (etree.ParseError, LookupError) as e:
85-
raise OPMLError(f"XML error: {e}") from e
85+
raise OPMLError("XML error") from e
8686

8787
root = tree.getroot()
8888
if root.tag.lower() != 'opml':

src/reader/types.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@
1919
from typing import overload
2020
from typing import Protocol
2121
from typing import Self
22+
from typing import TYPE_CHECKING
2223
from typing import TypedDict
2324
from typing import Union
2425

26+
from reader.exceptions import FeedError
27+
from reader.exceptions import FeedExistsError
2528
from reader.exceptions import UpdateError
2629

2730
# can't be defined here because of circular imports
2831
from reader._utils import MISSING as MISSING # isort: skip # noqa: F401
2932
from reader._utils import MissingType as MissingType # isort: skip # noqa: F401
3033

34+
if TYPE_CHECKING:
35+
from . import opml
36+
3137

3238
class _namedtuple_compat:
3339
"""Add namedtuple-like methods to a dataclass."""
@@ -1234,3 +1240,53 @@ class UpdateConfig(TypedDict, total=False):
12341240

12351241
#: Update jitter, as a ratio of :attr:`interval`, between 0.0 and 1.0.
12361242
jitter: float
1243+
1244+
1245+
@dataclass(frozen=True)
1246+
class FeedImportResult:
1247+
"""The result of importing a single feed.
1248+
1249+
.. versionadded:: 3.23
1250+
1251+
"""
1252+
1253+
#: The feed parsed from the import file.
1254+
feed: opml.Feed
1255+
1256+
#: Exception raised by :meth:`~add_feed`, if any.
1257+
exception: FeedError | None = None
1258+
1259+
@property
1260+
def added(self) -> bool:
1261+
"""Whether the feed was added."""
1262+
return not self.exception
1263+
1264+
@property
1265+
def error(self) -> FeedError | None:
1266+
"""Any error adding the feed (excluding already existing feed)."""
1267+
if not self.exception or isinstance(self.exception, FeedExistsError):
1268+
return None
1269+
return self.exception
1270+
1271+
1272+
@dataclass(frozen=True)
1273+
class FeedExport:
1274+
"""A feed export.
1275+
1276+
.. versionadded:: 3.23
1277+
1278+
"""
1279+
1280+
#: The export content.
1281+
content: bytes
1282+
1283+
#: Suggested filename (derived from the title and date in the content).
1284+
filename: str
1285+
1286+
@property
1287+
def headers(self) -> dict[str, str]:
1288+
"""Content-* HTTP headers describing the content."""
1289+
return {
1290+
'Content-Type': 'application/xml; charset=utf-8',
1291+
'Content-Disposition': f'attachment; filename="{self.filename}"',
1292+
}

0 commit comments

Comments
 (0)