liblore 0.8.0__tar.gz → 0.8.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liblore
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Shared library for public-inbox / lore.kernel.org access
5
5
  Author-email: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
6
6
  License-Expression: GPL-2.0-or-later
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "liblore"
8
- version = "0.8.0"
8
+ version = "0.8.2"
9
9
  description = "Shared library for public-inbox / lore.kernel.org access"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.9"
@@ -6,7 +6,7 @@ import email.charset
6
6
  import email.policy
7
7
  from email.message import EmailMessage
8
8
 
9
- __version__ = '0.8.0'
9
+ __version__ = '0.8.2'
10
10
 
11
11
  # Email policy used for parsing and serialising messages
12
12
  emlpolicy = email.policy.EmailPolicy(
@@ -79,10 +79,21 @@ def clean_header(hdrval: str | None) -> str:
79
79
 
80
80
 
81
81
  def get_clean_msgid(msg: EmailMessage, header: str = 'Message-Id') -> str | None:
82
- """Extract a clean message ID (without angle brackets) from a header."""
82
+ """Extract a clean message ID (without angle brackets) from a header.
83
+
84
+ Message-IDs are always ASCII and enclosed in angle brackets, and
85
+ RFC 2047 forbids encoded words in msg-id headers (Message-Id,
86
+ In-Reply-To, References). We therefore extract straight from the
87
+ raw value and deliberately avoid :func:`clean_header`, whose
88
+ address-parsing path would mangle a bare message-ID when the header
89
+ also carries an encoded-word comment (e.g. Gnus-style In-Reply-To).
90
+
91
+ This mirrors :func:`_clean_msgid_raw`, which does the same on raw
92
+ message bytes.
93
+ """
83
94
  raw = msg.get(header)
84
95
  if raw:
85
- matches = re.search(r'<([^>]+)>', clean_header(raw))
96
+ matches = re.search(r'<([^>]+)>', str(raw))
86
97
  if matches:
87
98
  return matches.groups()[0]
88
99
  return None
@@ -383,6 +394,13 @@ def get_strict_thread(
383
394
  for hval in msg.get_all(hdr_name, []):
384
395
  raw_refs += re.findall(r'<([^>]+)>', str(hval))
385
396
 
397
+ # Some tooling emits messages that reference their own Message-ID.
398
+ # A self-reference is never meaningful for threading, so drop it
399
+ # before it can be mistaken for a parent.
400
+ if c_msgid in raw_refs:
401
+ logger.debug('Ignoring self-reference in %s', c_msgid)
402
+ raw_refs = [ref for ref in raw_refs if ref != c_msgid]
403
+
386
404
  # If noparent, pretend our target message has no references
387
405
  if noparent and msgid == c_msgid:
388
406
  logger.info('Breaking thread to remove parents of %s', msgid)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liblore
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Shared library for public-inbox / lore.kernel.org access
5
5
  Author-email: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
6
6
  License-Expression: GPL-2.0-or-later
@@ -0,0 +1,149 @@
1
+ # SPDX-License-Identifier: GPL-2.0-or-later
2
+ """Tests for liblore.message."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from email.message import EmailMessage
7
+
8
+ from conftest import MsgFactory
9
+
10
+ from liblore.utils import clean_header, get_clean_msgid, parse_message
11
+
12
+
13
+ class TestCleanHeader:
14
+ def test_plain_header(self) -> None:
15
+ assert clean_header('Hello World') == 'Hello World'
16
+
17
+ def test_none_returns_empty(self) -> None:
18
+ assert clean_header(None) == ''
19
+
20
+ def test_rfc2047_encoded(self) -> None:
21
+ encoded = '=?utf-8?q?Caf=C3=A9?='
22
+ assert clean_header(encoded) == 'Caf\u00e9'
23
+
24
+ def test_folded_whitespace(self) -> None:
25
+ assert clean_header('Hello\n World') == 'Hello World'
26
+
27
+ def test_encoded_address(self) -> None:
28
+ # formataddr re-encodes Unicode names, so we just check
29
+ # the address is preserved and the result is valid
30
+ hdr = '=?utf-8?q?=C3=89ric?= <eric@example.com>'
31
+ result = clean_header(hdr)
32
+ assert 'eric@example.com' in result
33
+
34
+ def test_special_chars_in_name(self) -> None:
35
+ hdr = '=?utf-8?q?O=27Brien=2C_Joe?= <joe@example.com>'
36
+ result = clean_header(hdr)
37
+ assert 'joe@example.com' in result
38
+
39
+
40
+ class TestGetCleanMsgid:
41
+ def test_extracts_msgid(self, make_msg: MsgFactory) -> None:
42
+ msg = make_msg(msgid='test123@example.com')
43
+ assert get_clean_msgid(msg) == 'test123@example.com'
44
+
45
+ def test_missing_header(self) -> None:
46
+ msg = EmailMessage()
47
+ assert get_clean_msgid(msg) is None
48
+
49
+ def test_custom_header(self, make_msg: MsgFactory) -> None:
50
+ msg = make_msg(in_reply_to='parent@example.com')
51
+ assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
52
+
53
+ def test_in_reply_to_with_encoded_comment(self) -> None:
54
+ """Gnus appends an RFC 2047 encoded comment after the message-ID.
55
+
56
+ The encoded-word comment must not send us down clean_header's
57
+ address-parsing path, which would destroy the message-ID.
58
+ """
59
+ msg = EmailMessage()
60
+ msg['In-Reply-To'] = (
61
+ '<patch-v1-1-abc123@example.com>'
62
+ ' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s'
63
+ ' message of "Mon, 20 Jul 2026 11:55:30 +0400")'
64
+ )
65
+ assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com'
66
+
67
+ def test_in_reply_to_with_encoded_comment_parsed(self) -> None:
68
+ """Same case, but via a message parsed under the modern policy."""
69
+ raw = (
70
+ b'From: x@example.com\r\n'
71
+ b'Message-Id: <child@example.com>\r\n'
72
+ b'In-Reply-To: <patch-v1-1-abc123@example.com>'
73
+ b' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s'
74
+ b' message of "Mon, 20 Jul 2026 11:55:30 +0400")\r\n'
75
+ b'\r\nbody\r\n'
76
+ )
77
+ msg = parse_message(raw)
78
+ assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com'
79
+
80
+ # --- Non-compliant / malformed headers ---------------------------------
81
+ # These document how extraction copes with headers that violate
82
+ # RFC 5322. The contract is deliberately simple: we return the
83
+ # contents of the first ``<...>`` pair, or None when there isn't one.
84
+
85
+ def test_missing_angle_brackets(self) -> None:
86
+ """A bare address without angle brackets yields no message-ID."""
87
+ msg = EmailMessage()
88
+ msg['Message-Id'] = 'bare@example.com'
89
+ assert get_clean_msgid(msg) is None
90
+
91
+ def test_empty_angle_brackets(self) -> None:
92
+ """An empty ``<>`` has nothing to extract."""
93
+ msg = EmailMessage()
94
+ msg['In-Reply-To'] = '<>'
95
+ assert get_clean_msgid(msg, 'In-Reply-To') is None
96
+
97
+ def test_no_at_sign_inside_brackets(self) -> None:
98
+ """We trust the angle brackets and do not require an ``@``."""
99
+ msg = EmailMessage()
100
+ msg['Message-Id'] = '<garbage-no-at>'
101
+ assert get_clean_msgid(msg) == 'garbage-no-at'
102
+
103
+ def test_leading_comment_before_id(self) -> None:
104
+ """A comment preceding the message-ID must not confuse extraction."""
105
+ msg = EmailMessage()
106
+ msg['In-Reply-To'] = '(sent earlier) <parent@example.com>'
107
+ assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
108
+
109
+ def test_multiple_ids_returns_first(self) -> None:
110
+ """Some broken clients cram several IDs into In-Reply-To."""
111
+ msg = EmailMessage()
112
+ msg['In-Reply-To'] = '<first@example.com> <second@example.com>'
113
+ assert get_clean_msgid(msg, 'In-Reply-To') == 'first@example.com'
114
+
115
+ def test_rfc2047_encoded_msgid(self) -> None:
116
+ """A nonconformant fully RFC 2047-encoded ``<foo@bar>``.
117
+
118
+ RFC 2047 forbids encoded words in msg-id headers, but if one
119
+ shows up the modern EmailPolicy decodes it on read, so we still
120
+ recover the ID -- no clean_header fallback required.
121
+ """
122
+ msg = EmailMessage()
123
+ msg['Message-Id'] = '=?utf-8?q?=3Cfoo=40bar=3E?='
124
+ assert get_clean_msgid(msg) == 'foo@bar'
125
+
126
+ def test_rfc2047_encoded_msgid_parsed(self) -> None:
127
+ """Same nonconformant case, parsed under the real policy."""
128
+ raw = (
129
+ b'From: x@example.com\r\n'
130
+ b'Message-Id: =?utf-8?q?=3Cfoo=40bar=3E?=\r\n'
131
+ b'\r\nbody\r\n'
132
+ )
133
+ msg = parse_message(raw)
134
+ assert get_clean_msgid(msg) == 'foo@bar'
135
+
136
+
137
+ class TestParseMessage:
138
+ def test_roundtrip(self) -> None:
139
+ raw = (
140
+ b'From: test@example.com\r\n'
141
+ b'Subject: Test\r\n'
142
+ b'Message-Id: <test@example.com>\r\n'
143
+ b'\r\n'
144
+ b'Body text\r\n'
145
+ )
146
+ msg = parse_message(raw)
147
+ assert isinstance(msg, EmailMessage)
148
+ assert msg['Subject'] == 'Test'
149
+ assert msg['From'] == 'test@example.com'
@@ -47,6 +47,57 @@ class TestGetStrictThread:
47
47
  assert 'child@x.com' in ids
48
48
  assert 'grandchild@x.com' in ids
49
49
 
50
+ def test_noparent_self_referencing_cover(self, make_msg: MsgFactory) -> None:
51
+ # Some tooling emits a cover letter whose In-Reply-To/References point
52
+ # at its own Message-ID. Breaking the thread there must not treat the
53
+ # cover's own msgid as a reference to ignore, or every patch that
54
+ # legitimately replies to the cover gets dropped.
55
+ cover = make_msg(
56
+ msgid='cover@x.com',
57
+ subject='[PATCH 0/2] Something',
58
+ in_reply_to='cover@x.com',
59
+ references=['cover@x.com'],
60
+ )
61
+ patch1 = make_msg(
62
+ msgid='p1@x.com',
63
+ subject='[PATCH 1/2] First',
64
+ in_reply_to='cover@x.com',
65
+ references=['cover@x.com'],
66
+ )
67
+ patch2 = make_msg(
68
+ msgid='p2@x.com',
69
+ subject='[PATCH 2/2] Second',
70
+ in_reply_to='cover@x.com',
71
+ references=['cover@x.com'],
72
+ )
73
+ review = make_msg(
74
+ msgid='review@x.com',
75
+ subject='Re: [PATCH 2/2] Second',
76
+ in_reply_to='p2@x.com',
77
+ references=['cover@x.com', 'p2@x.com'],
78
+ )
79
+ want = {'cover@x.com', 'p1@x.com', 'p2@x.com', 'review@x.com'}
80
+ # Cover first in the list is the worst case: without the fix only the
81
+ # cover survives. Ordering must not matter, so check both.
82
+ for msgs in ([cover, patch1, patch2, review], [patch1, cover, patch2, review]):
83
+ result = get_strict_thread(msgs, 'cover@x.com', noparent=True)
84
+ assert result is not None
85
+ assert {get_clean_msgid(m) for m in result} == want
86
+
87
+ def test_self_reference_is_not_a_parent(self, make_msg: MsgFactory) -> None:
88
+ # A self-referencing message must not be treated as a reply to itself,
89
+ # even without noparent: it is a thread root, and starting the walk at
90
+ # its child still has to pull it in.
91
+ root = make_msg(
92
+ msgid='root@x.com',
93
+ in_reply_to='root@x.com',
94
+ references=['root@x.com'],
95
+ )
96
+ reply = make_msg(msgid='reply@x.com', in_reply_to='root@x.com')
97
+ result = get_strict_thread([root, reply], 'reply@x.com')
98
+ assert result is not None
99
+ assert {get_clean_msgid(m) for m in result} == {'root@x.com', 'reply@x.com'}
100
+
50
101
  def test_references_chain(self, make_msg: MsgFactory) -> None:
51
102
  msg1 = make_msg(msgid='a@x.com')
52
103
  msg2 = make_msg(msgid='b@x.com', references=['a@x.com'])
@@ -1,66 +0,0 @@
1
- # SPDX-License-Identifier: GPL-2.0-or-later
2
- """Tests for liblore.message."""
3
-
4
- from __future__ import annotations
5
-
6
- from email.message import EmailMessage
7
-
8
- from conftest import MsgFactory
9
-
10
- from liblore.utils import clean_header, get_clean_msgid, parse_message
11
-
12
-
13
- class TestCleanHeader:
14
- def test_plain_header(self) -> None:
15
- assert clean_header('Hello World') == 'Hello World'
16
-
17
- def test_none_returns_empty(self) -> None:
18
- assert clean_header(None) == ''
19
-
20
- def test_rfc2047_encoded(self) -> None:
21
- encoded = '=?utf-8?q?Caf=C3=A9?='
22
- assert clean_header(encoded) == 'Caf\u00e9'
23
-
24
- def test_folded_whitespace(self) -> None:
25
- assert clean_header('Hello\n World') == 'Hello World'
26
-
27
- def test_encoded_address(self) -> None:
28
- # formataddr re-encodes Unicode names, so we just check
29
- # the address is preserved and the result is valid
30
- hdr = '=?utf-8?q?=C3=89ric?= <eric@example.com>'
31
- result = clean_header(hdr)
32
- assert 'eric@example.com' in result
33
-
34
- def test_special_chars_in_name(self) -> None:
35
- hdr = '=?utf-8?q?O=27Brien=2C_Joe?= <joe@example.com>'
36
- result = clean_header(hdr)
37
- assert 'joe@example.com' in result
38
-
39
-
40
- class TestGetCleanMsgid:
41
- def test_extracts_msgid(self, make_msg: MsgFactory) -> None:
42
- msg = make_msg(msgid='test123@example.com')
43
- assert get_clean_msgid(msg) == 'test123@example.com'
44
-
45
- def test_missing_header(self) -> None:
46
- msg = EmailMessage()
47
- assert get_clean_msgid(msg) is None
48
-
49
- def test_custom_header(self, make_msg: MsgFactory) -> None:
50
- msg = make_msg(in_reply_to='parent@example.com')
51
- assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
52
-
53
-
54
- class TestParseMessage:
55
- def test_roundtrip(self) -> None:
56
- raw = (
57
- b'From: test@example.com\r\n'
58
- b'Subject: Test\r\n'
59
- b'Message-Id: <test@example.com>\r\n'
60
- b'\r\n'
61
- b'Body text\r\n'
62
- )
63
- msg = parse_message(raw)
64
- assert isinstance(msg, EmailMessage)
65
- assert msg['Subject'] == 'Test'
66
- assert msg['From'] == 'test@example.com'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes