email-reply-extractor 1.1.0__py3-none-any.whl

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.
@@ -0,0 +1,49 @@
1
+ """email-reply-extractor: recover the author's newly written text from an email.
2
+
3
+ A two-stage, pure-stdlib pipeline for mailing-list mail:
4
+
5
+ - **Stage 1** (:func:`extract_new_text`) removes quoted text, attribution
6
+ lines, forwarded/quote-header blocks and other reproduced content, keeping
7
+ the author's full novel content — greetings, sign-offs and signatures
8
+ included.
9
+ - **Stage 2** (:func:`clean_for_scoring`) strips that formulaic furniture from
10
+ the stage-1 text, leaving only the author's substantive prose, and reports
11
+ which lines it removed.
12
+
13
+ :data:`EXTRACTION_VERSION` identifies the generation of the routine that
14
+ derives the text; it is independent of the package version and only ever
15
+ increases. See ``CONTRIBUTING.md`` for the contract.
16
+ """
17
+
18
+ from .cleaning import CleanResult, clean_for_scoring
19
+ from .equivalence import texts_equivalent, tolerant_lines
20
+ from .extraction import (
21
+ EXTRACTION_VERSION,
22
+ STATUS_EMPTY,
23
+ STATUS_FAILED,
24
+ STATUS_OK,
25
+ ExtractionResult,
26
+ extract_new_text,
27
+ strip_parent_content,
28
+ )
29
+ from .html_text import HtmlParts, html_to_text, split_html_parts
30
+
31
+ __version__ = "1.1.0"
32
+
33
+ __all__ = [
34
+ "EXTRACTION_VERSION",
35
+ "STATUS_EMPTY",
36
+ "STATUS_FAILED",
37
+ "STATUS_OK",
38
+ "CleanResult",
39
+ "ExtractionResult",
40
+ "HtmlParts",
41
+ "__version__",
42
+ "clean_for_scoring",
43
+ "extract_new_text",
44
+ "html_to_text",
45
+ "split_html_parts",
46
+ "strip_parent_content",
47
+ "texts_equivalent",
48
+ "tolerant_lines",
49
+ ]
@@ -0,0 +1,130 @@
1
+ """Fragment scanner: segment an email body into quoted / unquoted fragments.
2
+
3
+ Vendored and trimmed from ``email_reply_parser`` 0.5.12 (Zapier's Python port
4
+ of GitHub's Email Reply Parser, <https://github.com/zapier/email-reply-parser>,
5
+ MIT License, Copyright (c) 2012 Zapier LLC) when this package absorbed its only
6
+ runtime dependency. Only the ``EmailReplyParser.read()`` path the extractor
7
+ consumes is kept: the bottom-up line scan that groups lines into fragments and
8
+ labels each fragment quoted or not. The upstream ``parse_reply`` entry point,
9
+ the ``reply`` property, the hidden/visible bookkeeping and the dead
10
+ ``quote_header`` method are dropped — none of them influence fragment
11
+ boundaries, fragment content, or the ``quoted`` flag.
12
+
13
+ The scan works bottom-up: lines are visited last-to-first, and a fragment grows
14
+ while successive lines share its quoted/header classification (with the quirks
15
+ encoded in :func:`_scan_line` below). A blank line directly above a
16
+ signature-looking line closes the fragment, which is a boundary effect the
17
+ extractor's output depends on even though the signature label itself is not
18
+ reported.
19
+
20
+ Two upstream defects were preserved byte-for-byte through generation 4 and
21
+ fixed in generation 5 (see ``docs/generation-5.md``):
22
+
23
+ 1. Upstream capped the Outlook-boundary newline fix at 8 replacements per
24
+ message by passing ``re.MULTILINE`` (value 8) as ``re.sub``'s positional
25
+ ``count`` argument; the substitution now applies to every occurrence.
26
+ 2. Upstream stripped each fragment's leading/trailing whitespace, so re-joining
27
+ fragments could glue unrelated lines together — a signature line directly
28
+ above a quote-header block's ``From:`` disguised the block from the
29
+ extractor's detector. Fragment content now keeps its edges, preserving the
30
+ blank lines that separate fragments.
31
+
32
+ Input is expected to be LF-normalized (:func:`.extraction.normalize_body` runs
33
+ first); the upstream CRLF replacement was dropped as unreachable.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass
40
+
41
+ _SIG_RE = re.compile(r"(--|__|-\w)|(^Sent from my (\w+\s*){1,3})")
42
+ _QUOTE_HDR_RE = re.compile("On.*wrote:$")
43
+ _QUOTED_RE = re.compile(r"(>+)")
44
+ _HEADER_RE = re.compile(r"^\*?(From|Sent|To|Subject):\*? .+")
45
+ _MULTI_QUOTE_HDR = r"(?!On.*On\s.+?wrote:)(On\s(.+?)wrote:)"
46
+ #: Upstream's ``MULTI_QUOTE_HDR_REGEX`` (used for the substitution).
47
+ _MULTI_QUOTE_HDR_SUB_RE = re.compile(_MULTI_QUOTE_HDR, re.DOTALL | re.MULTILINE)
48
+ #: Upstream's ``MULTI_QUOTE_HDR_REGEX_MULTILINE`` (used for the search) — the
49
+ #: names are swapped relative to their flags upstream; both are kept verbatim.
50
+ _MULTI_QUOTE_HDR_SEARCH_RE = re.compile(_MULTI_QUOTE_HDR, re.DOTALL)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Fragment:
55
+ """One scanned fragment: its content (edges kept) and whether it is quoted."""
56
+
57
+ content: str
58
+ quoted: bool
59
+
60
+
61
+ class _OpenFragment:
62
+ """A fragment still growing during the bottom-up scan."""
63
+
64
+ __slots__ = ("quoted", "headers", "lines")
65
+
66
+ def __init__(self, quoted: bool, first_line: str, headers: bool) -> None:
67
+ self.quoted = quoted
68
+ self.headers = headers
69
+ self.lines = [first_line]
70
+
71
+ def close(self) -> Fragment:
72
+ # Lines were appended in bottom-up scan order; restore document order.
73
+ # Edges are kept — see fix (2) in the module docstring.
74
+ return Fragment(content="\n".join(reversed(self.lines)), quoted=self.quoted)
75
+
76
+
77
+ def read_fragments(text: str) -> list[Fragment]:
78
+ """Scan ``text`` (LF-normalized) into document-order fragments."""
79
+ match = _MULTI_QUOTE_HDR_SEARCH_RE.search(text)
80
+ if match:
81
+ # Collapse a wrapped multi-line "On … wrote:" header onto one line. The
82
+ # matched text is passed as a replacement *template*, exactly as
83
+ # upstream did — a body containing backslash escapes can make this
84
+ # raise, which the extractor's catch-all reports as a failed extraction.
85
+ text = _MULTI_QUOTE_HDR_SUB_RE.sub(match.groups()[0].replace("\n", ""), text)
86
+
87
+ # Break the reply off an Outlook-style boundary line it sits directly on
88
+ # top of — every occurrence; see fix (1) in the module docstring.
89
+ text = re.sub("([^\n])(?=\n ?[_-]{7,})", "\\1\n", text)
90
+
91
+ fragments: list[Fragment] = []
92
+ open_fragment: _OpenFragment | None = None
93
+
94
+ for line in reversed(text.split("\n")):
95
+ open_fragment = _scan_line(fragments, open_fragment, line)
96
+
97
+ if open_fragment is not None:
98
+ fragments.append(open_fragment.close())
99
+
100
+ fragments.reverse()
101
+ return fragments
102
+
103
+
104
+ def _scan_line(
105
+ fragments: list[Fragment], open_fragment: _OpenFragment | None, line: str
106
+ ) -> _OpenFragment | None:
107
+ """Fold one line (visited bottom-up) into the open fragment, or start a new one."""
108
+ is_quote_header = _QUOTE_HDR_RE.match(line) is not None
109
+ is_quoted = _QUOTED_RE.match(line) is not None
110
+ is_header = is_quote_header or _HEADER_RE.match(line) is not None
111
+ is_blank = len(line.strip()) == 0
112
+
113
+ # A blank line directly above a signature-looking line closes the fragment
114
+ # (the signature label itself was only consumed by the dropped hidden logic;
115
+ # the boundary is what matters here).
116
+ if open_fragment is not None and is_blank:
117
+ if _SIG_RE.match(open_fragment.lines[-1].strip()):
118
+ fragments.append(open_fragment.close())
119
+ open_fragment = None
120
+
121
+ if open_fragment is not None and (
122
+ (open_fragment.headers == is_header and open_fragment.quoted == is_quoted)
123
+ or (open_fragment.quoted and (is_quote_header or is_blank))
124
+ ):
125
+ open_fragment.lines.append(line)
126
+ return open_fragment
127
+
128
+ if open_fragment is not None:
129
+ fragments.append(open_fragment.close())
130
+ return _OpenFragment(quoted=is_quoted, first_line=line, headers=is_header)