Coverage for src/local_deep_research/document_loaders/evernote_loader.py: 96%
51 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Read Evernote exports using the project's existing XML and HTML parsers."""
3from pathlib import Path
4from time import strptime
5from typing import Any, Iterator
7# nosemgrep: python.lang.security.use-defused-xml.use-defused-xml, reason: TreeBuilder only assembles elements; defusedxml's parser reads the XML
8from xml.etree.ElementTree import TreeBuilder
10from bs4 import BeautifulSoup
11from defusedxml.common import DTDForbidden
12from defusedxml.ElementTree import DefusedXMLParser, iterparse
13from langchain_core.document_loaders import BaseLoader
14from langchain_core.documents import Document
17class _EvernoteXMLParser(DefusedXMLParser):
18 """defusedxml's parser, refusing any DOCTYPE with an internal subset.
20 Evernote exports declare ``<!DOCTYPE en-export SYSTEM "...">``, and
21 defusedxml's ``forbid_dtd`` refuses every DOCTYPE, that one included.
22 This parser keeps ``forbid_dtd`` on but refuses only a DOCTYPE with an
23 internal subset (``[...]``), where a file could declare entities or
24 attribute defaults that expat adds to every matching element. Expat
25 reports the subset when it reaches the ``[``, so it is refused before
26 any of its declarations are read.
27 """
29 def __init__(self) -> None:
30 super().__init__(
31 target=TreeBuilder(),
32 forbid_dtd=True,
33 forbid_entities=True,
34 forbid_external=True,
35 )
37 def defused_start_doctype_decl(
38 self, name, sysid, pubid, has_internal_subset
39 ):
40 if has_internal_subset:
41 raise DTDForbidden(name, sysid, pubid)
44class EverNoteLoader(BaseLoader):
45 """Extract notes from an ENEX file without an undeclared parser dependency."""
47 def __init__(
48 self, file_path: str | Path, load_single_document: bool = True
49 ) -> None:
50 self.file_path = str(file_path)
51 self.load_single_document = load_single_document
53 def lazy_load(self) -> Iterator[Document]:
54 notes = self._iter_notes()
55 if self.load_single_document: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 yield Document(
57 page_content="".join(note.page_content for note in notes),
58 metadata={"source": self.file_path},
59 )
60 else:
61 yield from notes
63 def _iter_notes(self) -> Iterator[Document]:
64 # Standard exports declare Evernote's doctype by its external
65 # identifier only, and ElementTree does not fetch that DTD. Any
66 # internal subset is refused (see _EvernoteXMLParser), and entity
67 # declarations and external references stay forbidden. Clearing the
68 # root after each note avoids retaining earlier notes and attachments.
69 with open(self.file_path, "rb") as stream:
70 context = iterparse(
71 stream,
72 events=("start", "end"),
73 parser=_EvernoteXMLParser(),
74 )
75 root = None
76 for event, element in context:
77 if root is None:
78 root = element
79 if event != "end" or element.tag != "note":
80 continue
82 content = element.findtext("content")
83 if content is not None: 83 ↛ 109line 83 didn't jump to line 109 because the condition on line 83 was always true
84 metadata: dict[str, Any] = {"source": self.file_path}
85 for child in element:
86 if child.tag in {"content", "resource"}:
87 continue
88 if child.tag == "note-attributes":
89 for attribute in child:
90 metadata[f"note-attributes.{attribute.tag}"] = (
91 attribute.text
92 )
93 elif child.tag in {"created", "updated"}:
94 metadata[child.tag] = (
95 strptime(child.text, "%Y%m%dT%H%M%SZ")
96 if child.text
97 else None
98 )
99 else:
100 metadata[child.tag] = child.text
102 html = BeautifulSoup(content, "html.parser")
103 for ignored in html(["script", "style"]):
104 ignored.decompose()
105 yield Document(
106 page_content=html.get_text(" ", strip=True),
107 metadata=metadata,
108 )
109 element.clear()
110 root.clear()