liblore 0.8.2__tar.gz → 0.9.0__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.
- {liblore-0.8.2 → liblore-0.9.0}/PKG-INFO +115 -56
- {liblore-0.8.2 → liblore-0.9.0}/README.md +114 -55
- {liblore-0.8.2 → liblore-0.9.0}/pyproject.toml +1 -1
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore/__init__.py +7 -7
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore/node.py +164 -34
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore/utils.py +10 -7
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore.egg-info/PKG-INFO +115 -56
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_auth_headers.py +6 -4
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_formatting.py +63 -33
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_node.py +144 -29
- {liblore-0.8.2 → liblore-0.9.0}/LICENSES/GPL-2.0-or-later.txt +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/setup.cfg +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore/py.typed +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore.egg-info/SOURCES.txt +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore.egg-info/dependency_links.txt +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore.egg-info/requires.txt +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/src/liblore.egg-info/top_level.txt +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_cache.py +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_email_utils.py +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_mbox.py +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_message.py +0 -0
- {liblore-0.8.2 → liblore-0.9.0}/tests/test_thread.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: liblore
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
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
|
|
@@ -65,13 +65,13 @@ cleaned up automatically:
|
|
|
65
65
|
```python
|
|
66
66
|
from liblore import LoreNode
|
|
67
67
|
|
|
68
|
-
with LoreNode(
|
|
68
|
+
with LoreNode('https://lore.kernel.org/all') as node:
|
|
69
69
|
msgs = node.get_thread_by_msgid(
|
|
70
|
-
|
|
70
|
+
'20250101-example@kernel.org',
|
|
71
71
|
sort=True,
|
|
72
72
|
)
|
|
73
73
|
for msg in msgs:
|
|
74
|
-
print(msg[
|
|
74
|
+
print(msg['Subject'])
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
If you omit the URL, it defaults to `https://lore.kernel.org/all`.
|
|
@@ -83,7 +83,7 @@ If you omit the URL, it defaults to `https://lore.kernel.org/all`.
|
|
|
83
83
|
```python
|
|
84
84
|
from liblore import LoreNode
|
|
85
85
|
|
|
86
|
-
node = LoreNode(url=
|
|
86
|
+
node = LoreNode(url='https://lore.kernel.org/all')
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
#### Git Config Integration
|
|
@@ -96,7 +96,7 @@ maintainer with a local mirror just adds settings to their repo's
|
|
|
96
96
|
|
|
97
97
|
```python
|
|
98
98
|
with LoreNode.from_git_config() as node:
|
|
99
|
-
msgs = node.get_thread_by_msgid(
|
|
99
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
Supported git config keys:
|
|
@@ -132,14 +132,14 @@ automatically:
|
|
|
132
132
|
|
|
133
133
|
```python
|
|
134
134
|
with LoreNode(
|
|
135
|
-
|
|
135
|
+
'https://lore.kernel.org/all',
|
|
136
136
|
fallback_urls=[
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
'http://mymirror.local',
|
|
138
|
+
'https://tor.lore.kernel.org',
|
|
139
|
+
'https://sea.lore.kernel.org',
|
|
140
140
|
],
|
|
141
141
|
) as node:
|
|
142
|
-
msgs = node.get_thread_by_msgid(
|
|
142
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
Each fallback is an **origin prefix** (`scheme://host`). The path from the
|
|
@@ -165,14 +165,14 @@ origin:
|
|
|
165
165
|
|
|
166
166
|
```python
|
|
167
167
|
node = LoreNode(
|
|
168
|
-
|
|
169
|
-
fallback_urls=[
|
|
168
|
+
'https://lore.kernel.org/all',
|
|
169
|
+
fallback_urls=['https://tor.lore.kernel.org', 'https://sea.lore.kernel.org'],
|
|
170
170
|
)
|
|
171
171
|
|
|
172
172
|
# Probe all origins concurrently and reorder by latency
|
|
173
173
|
results = node.probe_origins()
|
|
174
174
|
for origin, elapsed in results:
|
|
175
|
-
print(f
|
|
175
|
+
print(f'{origin}: {elapsed * 1000:.0f}ms')
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
Unreachable origins are moved to the end rather than removed, so they can
|
|
@@ -185,13 +185,13 @@ Set `auto_probe=True` to trigger probing transparently on the first request:
|
|
|
185
185
|
|
|
186
186
|
```python
|
|
187
187
|
with LoreNode(
|
|
188
|
-
|
|
189
|
-
fallback_urls=[
|
|
188
|
+
'https://lore.kernel.org/all',
|
|
189
|
+
fallback_urls=['https://tor.lore.kernel.org'],
|
|
190
190
|
auto_probe=True,
|
|
191
|
-
cache_dir=
|
|
191
|
+
cache_dir='/tmp/liblore-cache',
|
|
192
192
|
) as node:
|
|
193
193
|
# First request probes, reorders, then fetches via the fastest mirror
|
|
194
|
-
msgs = node.get_thread_by_msgid(
|
|
194
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
195
195
|
```
|
|
196
196
|
|
|
197
197
|
#### Caching
|
|
@@ -200,11 +200,11 @@ LoreNode can optionally cache raw mbox bytes on disk. Pass `cache_dir` to
|
|
|
200
200
|
enable it:
|
|
201
201
|
|
|
202
202
|
```python
|
|
203
|
-
with LoreNode(cache_dir=
|
|
203
|
+
with LoreNode(cache_dir='/tmp/liblore-cache', cache_ttl=600) as node:
|
|
204
204
|
# First call fetches from the network and writes a cache file
|
|
205
|
-
msgs = node.get_thread_by_msgid(
|
|
205
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
206
206
|
# Second call reads from cache (if within TTL)
|
|
207
|
-
msgs = node.get_thread_by_msgid(
|
|
207
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
208
208
|
```
|
|
209
209
|
|
|
210
210
|
- `cache_dir` -- directory for cache files (`None` to disable, the default)
|
|
@@ -241,10 +241,10 @@ Returns a `list[EmailMessage]`. Raises `LookupError` if no messages match.
|
|
|
241
241
|
with LoreNode() as node:
|
|
242
242
|
# Fetch a thread, sorted by date, only looking at recent messages
|
|
243
243
|
msgs = node.get_thread_by_msgid(
|
|
244
|
-
|
|
244
|
+
'20250101-example@kernel.org',
|
|
245
245
|
strict=True,
|
|
246
246
|
sort=True,
|
|
247
|
-
since=
|
|
247
|
+
since='20250101',
|
|
248
248
|
)
|
|
249
249
|
```
|
|
250
250
|
|
|
@@ -269,10 +269,11 @@ from datetime import datetime, timedelta, timezone
|
|
|
269
269
|
with LoreNode() as node:
|
|
270
270
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
271
271
|
updates = node.get_thread_updates_since(
|
|
272
|
-
|
|
272
|
+
'20250101-example@kernel.org',
|
|
273
|
+
cutoff,
|
|
273
274
|
)
|
|
274
275
|
if updates:
|
|
275
|
-
print(f
|
|
276
|
+
print(f'{len(updates)} new message(s)')
|
|
276
277
|
```
|
|
277
278
|
|
|
278
279
|
**`node.get_thread_by_query(query, *, full_threads=False)`**
|
|
@@ -291,10 +292,10 @@ the matching messages.
|
|
|
291
292
|
```python
|
|
292
293
|
with LoreNode() as node:
|
|
293
294
|
# Find all messages from a sender in the last month
|
|
294
|
-
msgs = node.get_thread_by_query(
|
|
295
|
+
msgs = node.get_thread_by_query('f:alice@example.com d:last.month..')
|
|
295
296
|
|
|
296
297
|
# Search by patch-id and fetch the full threads
|
|
297
|
-
msgs = node.get_thread_by_query(
|
|
298
|
+
msgs = node.get_thread_by_query('patchid:abc123', full_threads=True)
|
|
298
299
|
```
|
|
299
300
|
|
|
300
301
|
#### Batch Fetching
|
|
@@ -312,12 +313,12 @@ each one with a brief pause between requests. Returns a
|
|
|
312
313
|
```python
|
|
313
314
|
with LoreNode() as node:
|
|
314
315
|
threads = node.batch_get_thread_by_msgid(
|
|
315
|
-
[
|
|
316
|
+
['msg1@example.com', 'msg2@example.com', 'msg3@example.com'],
|
|
316
317
|
sort=True,
|
|
317
|
-
since=
|
|
318
|
+
since='2.weeks.ago',
|
|
318
319
|
)
|
|
319
320
|
for thread in threads:
|
|
320
|
-
print(f
|
|
321
|
+
print(f'Thread with {len(thread)} messages')
|
|
321
322
|
```
|
|
322
323
|
|
|
323
324
|
**`node.batch_get_thread_by_query(queries, *, full_threads=False)`**
|
|
@@ -327,10 +328,12 @@ query with a 100 ms cooldown. Returns a `list[list[EmailMessage]]`.
|
|
|
327
328
|
|
|
328
329
|
```python
|
|
329
330
|
with LoreNode() as node:
|
|
330
|
-
results = node.batch_get_thread_by_query(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
results = node.batch_get_thread_by_query(
|
|
332
|
+
[
|
|
333
|
+
's:fix f:alice@example.com',
|
|
334
|
+
's:feature f:bob@example.com',
|
|
335
|
+
]
|
|
336
|
+
)
|
|
334
337
|
```
|
|
335
338
|
|
|
336
339
|
#### Raw Mbox Access
|
|
@@ -348,8 +351,8 @@ expand results to include full threads.
|
|
|
348
351
|
|
|
349
352
|
```python
|
|
350
353
|
with LoreNode() as node:
|
|
351
|
-
raw = node.get_mbox_by_msgid(
|
|
352
|
-
with open(
|
|
354
|
+
raw = node.get_mbox_by_msgid('20250101-example@kernel.org')
|
|
355
|
+
with open('thread.mbox', 'wb') as f:
|
|
353
356
|
f.write(raw)
|
|
354
357
|
```
|
|
355
358
|
|
|
@@ -366,7 +369,7 @@ entire thread.
|
|
|
366
369
|
identifying your tool:
|
|
367
370
|
|
|
368
371
|
```python
|
|
369
|
-
node.set_user_agent(
|
|
372
|
+
node.set_user_agent('my-tool', '1.0')
|
|
370
373
|
# User-Agent: my-tool/1.0
|
|
371
374
|
```
|
|
372
375
|
|
|
@@ -374,7 +377,7 @@ The optional `plus` argument appends a unique identifier that server operators
|
|
|
374
377
|
can use to identify and prioritize known installations:
|
|
375
378
|
|
|
376
379
|
```python
|
|
377
|
-
node.set_user_agent(
|
|
380
|
+
node.set_user_agent('my-tool', '1.0', plus='550e8400-e29b-41d4')
|
|
378
381
|
# User-Agent: my-tool/1.0+550e8400-e29b-41d4
|
|
379
382
|
```
|
|
380
383
|
|
|
@@ -386,7 +389,7 @@ using liblore:
|
|
|
386
389
|
```python
|
|
387
390
|
# With lore.useragentplus = myuuid in ~/.gitconfig:
|
|
388
391
|
node = LoreNode.from_git_config()
|
|
389
|
-
node.set_user_agent(
|
|
392
|
+
node.set_user_agent('korgalore', '0.7')
|
|
390
393
|
# User-Agent: korgalore/0.7+myuuid
|
|
391
394
|
```
|
|
392
395
|
|
|
@@ -401,6 +404,60 @@ public-inbox server. Raises `RemoteError` if it does not.
|
|
|
401
404
|
**`node.close()`** -- close the HTTP session. Called automatically when
|
|
402
405
|
using `LoreNode` as a context manager.
|
|
403
406
|
|
|
407
|
+
#### Cancellation
|
|
408
|
+
|
|
409
|
+
Fetches can take a while, and interactive applications need a way to bail
|
|
410
|
+
out -- the user pressed Escape, or the whole application is exiting. Both
|
|
411
|
+
methods are thread-safe and are meant to be called from a different thread
|
|
412
|
+
than the one doing the fetching. Cancellation is *operation-scoped*: each
|
|
413
|
+
public method call is one operation, and cancelling affects only the
|
|
414
|
+
operations that are running at that moment.
|
|
415
|
+
|
|
416
|
+
**`node.cancel_active()`** -- cancel every operation currently in flight.
|
|
417
|
+
Each one raises `OperationCancelledError` (from a worker thread, this
|
|
418
|
+
surfaces in whatever way your framework reports worker exceptions) and stays
|
|
419
|
+
cancelled for the rest of its run, so a batch stops fully even when the
|
|
420
|
+
cancel arrives between its requests. Operations started afterwards are
|
|
421
|
+
unaffected -- there is no flag to reset before the next fetch.
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
import threading
|
|
425
|
+
from liblore import LoreNode, OperationCancelledError
|
|
426
|
+
|
|
427
|
+
node = LoreNode()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def fetch() -> None:
|
|
431
|
+
try:
|
|
432
|
+
node.batch_get_thread_by_msgid(lots_of_msgids)
|
|
433
|
+
except OperationCancelledError:
|
|
434
|
+
print('fetch aborted')
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
worker = threading.Thread(target=fetch)
|
|
438
|
+
worker.start()
|
|
439
|
+
# ... user presses Escape:
|
|
440
|
+
node.cancel_active()
|
|
441
|
+
worker.join()
|
|
442
|
+
|
|
443
|
+
# The node is immediately usable again -- no reset needed:
|
|
444
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**`node.shutdown()`** -- cancel everything in flight *and* refuse every
|
|
448
|
+
operation started afterwards. This is the terminal state for application
|
|
449
|
+
exit: a worker racing against shutdown raises `OperationCancelledError`
|
|
450
|
+
immediately instead of opening a new connection and delaying the exit.
|
|
451
|
+
There is no way to undo a shutdown. The **`node.is_shutdown`** property
|
|
452
|
+
reports whether the node has been shut down.
|
|
453
|
+
|
|
454
|
+
Cancelling closes the node-owned HTTP session, so a thread blocked in a
|
|
455
|
+
socket read is interrupted right away rather than waiting for a timeout. A
|
|
456
|
+
session you injected with `set_requests_session()` is left untouched.
|
|
457
|
+
|
|
458
|
+
Versions before 0.9 used a sticky `cancel()`/`reset_cancel()` pair; both
|
|
459
|
+
still work but are deprecated. See `MIGRATIONS.md` for how to move over.
|
|
460
|
+
|
|
404
461
|
#### Message Authentication
|
|
405
462
|
|
|
406
463
|
LoreNode can optionally verify DKIM signatures, DMARC alignment, and ARC
|
|
@@ -409,9 +466,9 @@ chains on every message it retrieves. This requires the `authheaders` package
|
|
|
409
466
|
|
|
410
467
|
```python
|
|
411
468
|
with LoreNode(add_auth_headers=True) as node:
|
|
412
|
-
msgs = node.get_thread_by_msgid(
|
|
469
|
+
msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
|
|
413
470
|
for msg in msgs:
|
|
414
|
-
print(msg[
|
|
471
|
+
print(msg['Authentication-Results'])
|
|
415
472
|
# liblore; dkim=pass header.d=kernel.org; ...
|
|
416
473
|
```
|
|
417
474
|
|
|
@@ -457,11 +514,11 @@ inspecting email messages.
|
|
|
457
514
|
from liblore.utils import clean_header, get_clean_msgid
|
|
458
515
|
|
|
459
516
|
# Decode RFC 2047 encoded headers
|
|
460
|
-
decoded = clean_header(
|
|
517
|
+
decoded = clean_header('=?utf-8?q?Re=3A_Some_Subject?=')
|
|
461
518
|
|
|
462
519
|
# Extract a clean message ID (without angle brackets) from a message
|
|
463
|
-
msgid = get_clean_msgid(msg)
|
|
464
|
-
msgid = get_clean_msgid(msg,
|
|
520
|
+
msgid = get_clean_msgid(msg) # reads Message-Id by default
|
|
521
|
+
msgid = get_clean_msgid(msg, 'In-Reply-To') # or any other header
|
|
465
522
|
```
|
|
466
523
|
|
|
467
524
|
#### Parsing Messages
|
|
@@ -512,18 +569,20 @@ and non-ASCII display names.
|
|
|
512
569
|
from liblore.utils import format_addrs, wrap_header, get_msg_as_bytes
|
|
513
570
|
|
|
514
571
|
# Format (name, email) pairs into an RFC 5322 address string
|
|
515
|
-
formatted = format_addrs(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
572
|
+
formatted = format_addrs(
|
|
573
|
+
[
|
|
574
|
+
('', 'foo@example.com'),
|
|
575
|
+
('Foo Bar', 'bar@example.com'),
|
|
576
|
+
]
|
|
577
|
+
)
|
|
519
578
|
# -> 'foo@example.com, Foo Bar <bar@example.com>'
|
|
520
579
|
|
|
521
580
|
# Wrap and RFC 2047-encode a header for SMTP
|
|
522
|
-
hdr_bytes = wrap_header((
|
|
581
|
+
hdr_bytes = wrap_header(('Subject', 'Hello world'))
|
|
523
582
|
|
|
524
583
|
# Serialize a full message to bytes with proper encoding
|
|
525
|
-
msg_bytes = get_msg_as_bytes(msg)
|
|
526
|
-
msg_bytes = get_msg_as_bytes(msg, nl=
|
|
584
|
+
msg_bytes = get_msg_as_bytes(msg) # \n line endings (dry-run)
|
|
585
|
+
msg_bytes = get_msg_as_bytes(msg, nl='\r\n') # \r\n for SMTP
|
|
527
586
|
```
|
|
528
587
|
|
|
529
588
|
#### Sorting and Threading
|
|
@@ -535,7 +594,7 @@ from liblore.utils import sort_msgs_by_received, get_strict_thread
|
|
|
535
594
|
sorted_msgs = sort_msgs_by_received(msgs)
|
|
536
595
|
|
|
537
596
|
# Filter a list of messages to only those in a specific thread
|
|
538
|
-
thread = get_strict_thread(msgs,
|
|
597
|
+
thread = get_strict_thread(msgs, '20250101-example@kernel.org')
|
|
539
598
|
|
|
540
599
|
# Break the thread at msgid, ignoring its parent references
|
|
541
600
|
thread = get_strict_thread(msgs, msgid, noparent=True)
|
|
@@ -550,7 +609,7 @@ from liblore.utils import minimize_thread
|
|
|
550
609
|
minimized = minimize_thread(msgs)
|
|
551
610
|
|
|
552
611
|
# Customize which headers to keep
|
|
553
|
-
minimized = minimize_thread(msgs, keep_headers=(
|
|
612
|
+
minimized = minimize_thread(msgs, keep_headers=('From', 'Subject', 'Date'))
|
|
554
613
|
|
|
555
614
|
# Aggressively reduce long quotes to just the last paragraph
|
|
556
615
|
minimized = minimize_thread(msgs, reduce_quote_context=True)
|
|
@@ -603,11 +662,11 @@ are thin wrappers that parse the results.
|
|
|
603
662
|
from liblore.utils import get_msgid_from_url
|
|
604
663
|
|
|
605
664
|
# Extract a message ID from a lore URL
|
|
606
|
-
msgid = get_msgid_from_url(
|
|
665
|
+
msgid = get_msgid_from_url('https://lore.kernel.org/all/20250101-example@kernel.org/')
|
|
607
666
|
# -> "20250101-example@kernel.org"
|
|
608
667
|
|
|
609
668
|
# Also works with bare message IDs
|
|
610
|
-
msgid = get_msgid_from_url(
|
|
669
|
+
msgid = get_msgid_from_url('<20250101-example@kernel.org>')
|
|
611
670
|
# -> "20250101-example@kernel.org"
|
|
612
671
|
```
|
|
613
672
|
|
|
@@ -620,7 +679,7 @@ handle specific cases:
|
|
|
620
679
|
from liblore import LibloreError, RemoteError, PublicInboxError
|
|
621
680
|
|
|
622
681
|
try:
|
|
623
|
-
msgs = node.get_thread_by_msgid(
|
|
682
|
+
msgs = node.get_thread_by_msgid('nonexistent@example.com')
|
|
624
683
|
except RemoteError:
|
|
625
684
|
# HTTP request failed (server error, network issue, etc.)
|
|
626
685
|
...
|