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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liblore
3
- Version: 0.8.2
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("https://lore.kernel.org/all") as node:
68
+ with LoreNode('https://lore.kernel.org/all') as node:
69
69
  msgs = node.get_thread_by_msgid(
70
- "20250101-example@kernel.org",
70
+ '20250101-example@kernel.org',
71
71
  sort=True,
72
72
  )
73
73
  for msg in msgs:
74
- print(msg["Subject"])
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="https://lore.kernel.org/all")
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("20250101-example@kernel.org")
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
- "https://lore.kernel.org/all",
135
+ 'https://lore.kernel.org/all',
136
136
  fallback_urls=[
137
- "http://mymirror.local",
138
- "https://tor.lore.kernel.org",
139
- "https://sea.lore.kernel.org",
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("20250101-example@kernel.org")
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
- "https://lore.kernel.org/all",
169
- fallback_urls=["https://tor.lore.kernel.org", "https://sea.lore.kernel.org"],
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"{origin}: {elapsed*1000:.0f}ms")
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
- "https://lore.kernel.org/all",
189
- fallback_urls=["https://tor.lore.kernel.org"],
188
+ 'https://lore.kernel.org/all',
189
+ fallback_urls=['https://tor.lore.kernel.org'],
190
190
  auto_probe=True,
191
- cache_dir="/tmp/liblore-cache",
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("20250101-example@kernel.org")
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="/tmp/liblore-cache", cache_ttl=600) as node:
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("20250101-example@kernel.org")
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("20250101-example@kernel.org")
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
- "20250101-example@kernel.org",
244
+ '20250101-example@kernel.org',
245
245
  strict=True,
246
246
  sort=True,
247
- since="20250101",
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
- "20250101-example@kernel.org", cutoff,
272
+ '20250101-example@kernel.org',
273
+ cutoff,
273
274
  )
274
275
  if updates:
275
- print(f"{len(updates)} new message(s)")
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("f:alice@example.com d:last.month..")
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("patchid:abc123", full_threads=True)
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
- ["msg1@example.com", "msg2@example.com", "msg3@example.com"],
316
+ ['msg1@example.com', 'msg2@example.com', 'msg3@example.com'],
316
317
  sort=True,
317
- since="2.weeks.ago",
318
+ since='2.weeks.ago',
318
319
  )
319
320
  for thread in threads:
320
- print(f"Thread with {len(thread)} messages")
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
- "s:fix f:alice@example.com",
332
- "s:feature f:bob@example.com",
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("20250101-example@kernel.org")
352
- with open("thread.mbox", "wb") as f:
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("my-tool", "1.0")
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("my-tool", "1.0", plus="550e8400-e29b-41d4")
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("korgalore", "0.7")
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("20250101-example@kernel.org")
469
+ msgs = node.get_thread_by_msgid('20250101-example@kernel.org')
413
470
  for msg in msgs:
414
- print(msg["Authentication-Results"])
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("=?utf-8?q?Re=3A_Some_Subject?=")
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) # reads Message-Id by default
464
- msgid = get_clean_msgid(msg, "In-Reply-To") # or any other header
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
- ("", "foo@example.com"),
517
- ("Foo Bar", "bar@example.com"),
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(("Subject", "Hello world"))
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) # \n line endings (dry-run)
526
- msg_bytes = get_msg_as_bytes(msg, nl="\r\n") # \r\n for SMTP
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, "20250101-example@kernel.org")
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=("From", "Subject", "Date"))
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("https://lore.kernel.org/all/20250101-example@kernel.org/")
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("<20250101-example@kernel.org>")
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("nonexistent@example.com")
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
  ...