liametahi 0.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.
liametahi/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Liametahi: a local, cron-friendly IMAP mailbox cleanup CLI that uses
2
+ a model only as a constrained classifier."""
3
+
4
+ __version__ = "0.1.0"
liametahi/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m liametahi`` entry point."""
2
+
3
+ from liametahi.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
liametahi/backup.py ADDED
@@ -0,0 +1,400 @@
1
+ """Verified `.eml` backup and restore.
2
+
3
+ `MailboxAdapter`, `MailboxStatus`, `RawMetadata`, `UnsupportedCapability`,
4
+ and `MessageVanished` were originally reproduced locally in this module,
5
+ exactly as `tests/fakes/fake_mailbox.py` used to: `imap_adapter.py`
6
+ (Unit 2) was being built concurrently and this unit could not import or
7
+ create it (a work-unit boundary). Now that
8
+ `imap_adapter.py` has landed, this module imports the real definitions
9
+ per the explicit instruction ("Units 2 and 3 must move the
10
+ real definitions into `imap_adapter.py` ... and change the fakes to
11
+ import them, deleting the local copies") -- the same cleanup already
12
+ applied to `tests/fakes/fake_mailbox.py`. `MailboxAdapter` is a
13
+ `Protocol`, so any object shaped like a real mailbox adapter --
14
+ `FakeMailbox` included -- still satisfies it without change.
15
+
16
+ `is_vanished_error`/`is_unsupported_error` below still match by
17
+ exception *name* rather than `isinstance`. That was originally a
18
+ necessary bridge (a `MessageVanished` raised by a fake and one raised by
19
+ the not-yet-existing real adapter were different Python objects); now
20
+ that both sides import the same classes it is merely a harmless
21
+ belt-and-braces choice, kept as-is to minimise this cleanup's diff.
22
+ """
23
+
24
+ import contextlib
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import sqlite3
29
+ import tempfile
30
+ from collections.abc import Collection
31
+ from dataclasses import dataclass
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+
35
+ from liametahi import state
36
+ from liametahi.domain import MessageKey
37
+ from liametahi.imap_adapter import (
38
+ MailboxAdapter,
39
+ MailboxStatus,
40
+ MessageVanished,
41
+ RawMetadata,
42
+ UnsupportedCapability,
43
+ )
44
+
45
+ __all__ = [
46
+ "BackupError",
47
+ "MailboxAdapter",
48
+ "MailboxStatus",
49
+ "MessageVanished",
50
+ "RawMetadata",
51
+ "UnsupportedCapability",
52
+ "is_unsupported_error",
53
+ "is_vanished_error",
54
+ ]
55
+
56
+
57
+ def is_vanished_error(exc: Exception) -> bool:
58
+ """True for a `MessageVanished` (by name, see module docstring) or
59
+ any `LookupError` (e.g. the `KeyError` `FakeMailbox.fetch_raw` and
60
+ `fetch_metadata` raise for an absent UID) -- both mean "the message
61
+ is not there for us to act on"."""
62
+ return isinstance(exc, LookupError) or type(exc).__name__ == "MessageVanished"
63
+
64
+
65
+ def is_unsupported_error(exc: Exception) -> bool:
66
+ return type(exc).__name__ == "UnsupportedCapability"
67
+
68
+
69
+ # --- Verified backup write --------------------------------------------
70
+
71
+
72
+ class BackupError(Exception):
73
+ """Backup could not be written and verified. Per the specification:
74
+ 'If backup verification fails, record the failure and do not modify
75
+ the mailbox' -- callers must not proceed to a mutation when this is
76
+ raised."""
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class BackupResult:
81
+ backup_id: str
82
+ sha256: str
83
+ byte_count: int
84
+ relative_path: str
85
+
86
+
87
+ def backup_relative_path(sha256: str) -> Path:
88
+ """Content-addressed path, fanned out by the first four hex
89
+ characters so the backup directory never holds a huge flat listing:
90
+ `<aa>/<bb>/<sha256>.eml`."""
91
+ return Path(sha256[0:2]) / sha256[2:4] / f"{sha256}.eml"
92
+
93
+
94
+ def ensure_backup_dir(backup_dir: Path) -> Path:
95
+ """Create the backup directory (and its `.tmp` staging
96
+ subdirectory) with mode 0700."""
97
+ backup_dir = backup_dir.expanduser()
98
+ backup_dir.mkdir(parents=True, exist_ok=True)
99
+ backup_dir.chmod(0o700)
100
+ tmp_dir = backup_dir / ".tmp"
101
+ tmp_dir.mkdir(parents=True, exist_ok=True)
102
+ tmp_dir.chmod(0o700)
103
+ return backup_dir
104
+
105
+
106
+ def _sha256_of_file(path: Path) -> str:
107
+ digest = hashlib.sha256()
108
+ with path.open("rb") as fh:
109
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
110
+ digest.update(chunk)
111
+ return digest.hexdigest()
112
+
113
+
114
+ def _iso(value: datetime) -> str:
115
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
116
+
117
+
118
+ def _from_iso(value: str) -> datetime:
119
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
120
+
121
+
122
+ def write_verified_backup(
123
+ conn: sqlite3.Connection,
124
+ *,
125
+ mailbox: MailboxAdapter,
126
+ backup_dir: Path,
127
+ key: MessageKey,
128
+ fingerprint: str,
129
+ message_id: str | None,
130
+ original_flags: Collection[str],
131
+ internaldate: datetime,
132
+ run_id: str,
133
+ raw: bytes | None = None,
134
+ ) -> BackupResult:
135
+ """Fetch raw RFC 822 bytes with `BODY.PEEK[]` (the adapter's job --
136
+ this function only calls `fetch_raw`, never touching `\\Seen`),
137
+ write to a unique temporary file in the backup filesystem, fsync,
138
+ checksum the bytes actually on disk, atomically rename to the
139
+ SHA-256 content-addressed final path, then commit the manifest row.
140
+
141
+ Idempotent under a retry with the same key: if a manifest row for
142
+ this exact `(account_id, mailbox, uidvalidity, uid, sha256)`
143
+ already exists (the manifest's own uniqueness constraint), that
144
+ existing row's `backup_id` is returned rather than raising a
145
+ `sqlite3.IntegrityError` or writing a duplicate.
146
+
147
+ Raises `BackupError` -- without ever calling a mutating mailbox
148
+ method -- if the fetch, write, or verification fails. The caller
149
+ must treat `BackupError` as "the mailbox was not touched, do not
150
+ proceed to the mutation."
151
+
152
+ `raw`, when given, is the message's bytes already in hand from an
153
+ earlier fetch of this same key -- the execute phase pulls metadata
154
+ and body in one round trip -- and skips the `fetch_raw` below. It
155
+ changes nothing downstream: the bytes still go through the same
156
+ write, fsync, and read-back-and-checksum verification, so a caller
157
+ passing corrupt bytes is caught exactly as a corrupt fetch would be.
158
+ """
159
+ if raw is None:
160
+ try:
161
+ raw = mailbox.fetch_raw(key.uid)
162
+ except Exception as exc:
163
+ raise BackupError(
164
+ f"could not fetch raw message for backup ({key.render()}): {exc}"
165
+ ) from exc
166
+
167
+ try:
168
+ backup_dir = ensure_backup_dir(backup_dir)
169
+ tmp_dir = backup_dir / ".tmp"
170
+ fd, tmp_name = tempfile.mkstemp(dir=tmp_dir, suffix=".eml.tmp")
171
+ except OSError as exc:
172
+ raise BackupError(
173
+ f"could not prepare backup directory for {key.render()}: {exc}"
174
+ ) from exc
175
+ tmp_path = Path(tmp_name)
176
+ try:
177
+ with os.fdopen(fd, "wb") as fh:
178
+ fh.write(raw)
179
+ fh.flush()
180
+ os.fsync(fh.fileno())
181
+ tmp_path.chmod(0o600)
182
+ # Checksum the bytes actually on disk, not the in-memory
183
+ # buffer, so a write-time corruption is caught rather than
184
+ # silently backed up ("fsync, checksum" as a
185
+ # distinct step after the write).
186
+ sha256 = _sha256_of_file(tmp_path)
187
+ byte_count = tmp_path.stat().st_size
188
+ if byte_count != len(raw):
189
+ raise BackupError(
190
+ f"backup write size mismatch for {key.render()}: "
191
+ f"wrote {len(raw)} bytes, found {byte_count} on disk"
192
+ )
193
+
194
+ relative_path = backup_relative_path(sha256)
195
+ final_path = backup_dir / relative_path
196
+ final_path.parent.mkdir(parents=True, exist_ok=True)
197
+ final_path.parent.chmod(0o700)
198
+
199
+ if final_path.exists():
200
+ # Content-addressed dedup: identical
201
+ # bytes already backed up (possibly for a different
202
+ # message). Verify the existing file still matches before
203
+ # trusting it.
204
+ if _sha256_of_file(final_path) != sha256:
205
+ raise BackupError(
206
+ f"backup dedup path {final_path} exists with unexpected "
207
+ "content; refusing to overwrite"
208
+ )
209
+ else:
210
+ os.replace(tmp_path, final_path)
211
+ final_path.chmod(0o600)
212
+ except BackupError:
213
+ raise
214
+ except OSError as exc:
215
+ raise BackupError(f"backup write failed for {key.render()}: {exc}") from exc
216
+ finally:
217
+ with contextlib.suppress(FileNotFoundError):
218
+ tmp_path.unlink()
219
+
220
+ existing = _find_existing_backup(
221
+ conn,
222
+ account_id=key.account_id,
223
+ mailbox=key.mailbox,
224
+ uidvalidity=key.uidvalidity,
225
+ uid=key.uid,
226
+ sha256=sha256,
227
+ )
228
+ if existing is not None:
229
+ return BackupResult(
230
+ backup_id=str(existing["backup_id"]),
231
+ sha256=sha256,
232
+ byte_count=byte_count,
233
+ relative_path=str(relative_path),
234
+ )
235
+
236
+ backup_id = state.new_backup_id()
237
+ try:
238
+ state.insert_backup(
239
+ conn,
240
+ backup_id=backup_id,
241
+ account_id=key.account_id,
242
+ mailbox=key.mailbox,
243
+ uidvalidity=key.uidvalidity,
244
+ uid=key.uid,
245
+ fingerprint=fingerprint,
246
+ message_id=message_id,
247
+ sha256=sha256,
248
+ byte_count=byte_count,
249
+ relative_path=str(relative_path),
250
+ original_mailbox=key.mailbox,
251
+ original_flags=sorted(original_flags),
252
+ internaldate=_iso(internaldate),
253
+ run_id=run_id,
254
+ )
255
+ except sqlite3.IntegrityError:
256
+ # Lost a race against a concurrent insert of the same natural
257
+ # key: re-read rather than fail.
258
+ existing = _find_existing_backup(
259
+ conn,
260
+ account_id=key.account_id,
261
+ mailbox=key.mailbox,
262
+ uidvalidity=key.uidvalidity,
263
+ uid=key.uid,
264
+ sha256=sha256,
265
+ )
266
+ if existing is None:
267
+ raise
268
+ backup_id = str(existing["backup_id"])
269
+
270
+ return BackupResult(
271
+ backup_id=backup_id,
272
+ sha256=sha256,
273
+ byte_count=byte_count,
274
+ relative_path=str(relative_path),
275
+ )
276
+
277
+
278
+ def _find_existing_backup(
279
+ conn: sqlite3.Connection,
280
+ *,
281
+ account_id: int,
282
+ mailbox: str,
283
+ uidvalidity: int,
284
+ uid: int,
285
+ sha256: str,
286
+ ) -> sqlite3.Row | None:
287
+ """A natural-key lookup `state.py` does not expose (it only offers
288
+ `get_backup(backup_id)`; its insert-only surface for backups was not
289
+ extended with this query). This is the one read this module cannot
290
+ route through `state.py`'s typed surface without a change to a file
291
+ this unit does not own; it is read-only, scoped to exactly the
292
+ manifest's own uniqueness constraint -- a known gap `state.py` should
293
+ close by adding this query to its own typed surface.
294
+ """
295
+ return conn.execute( # type: ignore[no-any-return]
296
+ """
297
+ SELECT * FROM backups
298
+ WHERE account_id = ? AND mailbox = ? AND uidvalidity = ? AND uid = ?
299
+ AND sha256 = ?
300
+ """,
301
+ (account_id, mailbox, uidvalidity, uid, sha256),
302
+ ).fetchone()
303
+
304
+
305
+ def find_backups_by_key(
306
+ conn: sqlite3.Connection, *, key: MessageKey
307
+ ) -> list[sqlite3.Row]:
308
+ """All backup manifest rows recorded for a message key, newest
309
+ first. Used by reconcile (`execute.py`); not exposed by `state.py`
310
+ today (see `_find_existing_backup`'s docstring)."""
311
+ return conn.execute(
312
+ """
313
+ SELECT * FROM backups
314
+ WHERE account_id = ? AND mailbox = ? AND uidvalidity = ? AND uid = ?
315
+ ORDER BY backed_up_at DESC
316
+ """,
317
+ (key.account_id, key.mailbox, key.uidvalidity, key.uid),
318
+ ).fetchall()
319
+
320
+
321
+ # --- Restore -----------------------------------------------------------
322
+
323
+
324
+ class RestoreError(Exception):
325
+ """The backup could not be verified or restored."""
326
+
327
+
328
+ @dataclass(frozen=True, slots=True)
329
+ class RestoreResult:
330
+ backup_id: str
331
+ mailbox: str
332
+ byte_count: int
333
+ sha256: str
334
+ dry_run: bool
335
+ appended: bool
336
+
337
+
338
+ def restore_backup(
339
+ conn: sqlite3.Connection,
340
+ *,
341
+ mailbox: MailboxAdapter,
342
+ backup_dir: Path,
343
+ backup_id: str,
344
+ destination_mailbox: str,
345
+ dry_run: bool,
346
+ ) -> RestoreResult:
347
+ """Verify the backup's checksum against its file,
348
+ then `APPEND` it to `destination_mailbox`, preserving the original
349
+ `INTERNALDATE` and flags minus `\\Deleted` and `\\Recent`. Records
350
+ the new server identity on the manifest row. `--dry-run` verifies
351
+ the checksum and reports what would be appended without calling
352
+ `append`.
353
+
354
+ Restore is best-effort: it does not detect that
355
+ the message already exists in the destination, so restoring an
356
+ already-present message produces a duplicate.
357
+ """
358
+ row = state.get_backup(conn, backup_id)
359
+ if row is None:
360
+ raise RestoreError(f"no backup found with id {backup_id!r}")
361
+
362
+ file_path = backup_dir.expanduser() / str(row["relative_path"])
363
+ if not file_path.is_file():
364
+ raise RestoreError(
365
+ f"backup file missing for {backup_id}: expected at {file_path}"
366
+ )
367
+ actual_sha256 = _sha256_of_file(file_path)
368
+ expected_sha256 = str(row["sha256"])
369
+ if actual_sha256 != expected_sha256:
370
+ raise RestoreError(
371
+ f"backup {backup_id} failed checksum verification: "
372
+ f"expected {expected_sha256}, found {actual_sha256}"
373
+ )
374
+ raw = file_path.read_bytes()
375
+ byte_count = len(raw)
376
+
377
+ original_flags = set(json.loads(str(row["original_flags"])))
378
+ restore_flags = original_flags - {"\\Deleted", "\\Recent"}
379
+ internaldate = _from_iso(str(row["internaldate"]))
380
+
381
+ if dry_run:
382
+ return RestoreResult(
383
+ backup_id=backup_id,
384
+ mailbox=destination_mailbox,
385
+ byte_count=byte_count,
386
+ sha256=expected_sha256,
387
+ dry_run=True,
388
+ appended=False,
389
+ )
390
+
391
+ mailbox.append(destination_mailbox, raw, restore_flags, internaldate)
392
+ state.record_restore(conn, backup_id=backup_id, restored_to=destination_mailbox)
393
+ return RestoreResult(
394
+ backup_id=backup_id,
395
+ mailbox=destination_mailbox,
396
+ byte_count=byte_count,
397
+ sha256=expected_sha256,
398
+ dry_run=False,
399
+ appended=True,
400
+ )
@@ -0,0 +1,117 @@
1
+ """Classifier protocol and payload/response models.
2
+
3
+ This is the fixed cross-unit interface between the evaluate phase (this
4
+ unit) and the provider adapters (`openai_compatible.py`, `anthropic.py`,
5
+ `jev.py`, also this unit). It was originally reproduced verbatim inside
6
+ `tests/fakes/fake_classifier.py` because this module did not yet exist
7
+ when Unit 1 landed; per the explicit instruction that fake now imports
8
+ these definitions from here instead of declaring local copies.
9
+
10
+ **Validation against the offered processor and candidate vocabulary
11
+ happens in the caller (see `liametahi.evaluate`), never in an adapter.**
12
+ An adapter's `classify()` only has to do transport, structured-output
13
+ negotiation, and JSON parsing; it is free to hand back a `Classification`
14
+ that names a processor or candidate id it was never offered, or an
15
+ answer value outside a processor's declared criteria; the model is
16
+ untrusted and the architecture assumes every adapter response is hostile
17
+ until validated -- an adapter must never let a model response widen what
18
+ an action may do.
19
+
20
+ This retires the old single-`llm`-atom,
21
+ yes/no/unsure vocabulary (`OfferedRule`/`Classification.matches`/
22
+ `needs_content`) in favour of a per-processor answer map: each candidate
23
+ may be asked about several independently-named processors in one batch,
24
+ and each processor answers with a resolved `value` (a probability in
25
+ `[0, 1]` for `noul`, a choice string for `choice`, a level string for
26
+ `score`) plus an optional `confidence` (jev always populates it for
27
+ `choice`/`score`, never for `noul`; a chat processor only if its own
28
+ declared schema asked for it -- which it never does for `noul`, since a
29
+ `noul` processor's `.confidence` is always `None` regardless of backend).
30
+ """
31
+
32
+ from collections.abc import Mapping, Sequence
33
+ from dataclasses import dataclass
34
+ from typing import Protocol
35
+
36
+ from liametahi.rules import ProcessorAnswer
37
+
38
+ __all__ = [
39
+ "CandidatePayload",
40
+ "Classification",
41
+ "Classifier",
42
+ "ClassifyOutcome",
43
+ "OfferedProcessor",
44
+ "ProcessorAnswer",
45
+ ]
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class OfferedProcessor:
50
+ """One processor offered to the model for a batch: its name and
51
+ enough of its declared shape (`type`, its required `instructions`,
52
+ plus its optional/required `criteria`) for an adapter to compile a
53
+ request/schema from (`jev.py` maps these straight onto its
54
+ `noul`/`choice`/`score` request shape -- jev's own schema, not a
55
+ project invention; `prompt.py` compiles the same fields into a chat
56
+ prompt + JSON schema for the other two providers). `criteria`'s shape
57
+ depends on `type`: a `{"true": ..., "false": ...}` mapping (optional)
58
+ for `noul`, an `{option: description}` mapping (required) for
59
+ `choice`, or an ordered sequence of level names (required) for
60
+ `score`."""
61
+
62
+ name: str
63
+ type: str # "noul" | "choice" | "score"
64
+ instructions: str
65
+ criteria: Mapping[str, str] | Sequence[str] | None = None
66
+ include_body: bool = False
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class CandidatePayload:
71
+ """One candidate as sent to the model: a batch-local id and its
72
+ already capped-and-sanitised metadata fields."""
73
+
74
+ payload_id: str # batch-local, "c1".."cN"
75
+ fields: Mapping[str, object] # already capped and sanitised by prompt.py
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Classification:
80
+ """One raw, unvalidated per-candidate model response item.
81
+
82
+ `answers` maps a processor name to its raw, not-yet-validated
83
+ `ProcessorAnswer` -- only for the processors this response actually
84
+ resolved for this candidate this round; a processor name absent from
85
+ the map means "not answered this round" and the caller must treat it
86
+ the same as invalid/needing retry, never as silently unknown-forever.
87
+ `value` may name an option/level outside what was offered, or the
88
+ wrong type for the processor's declared `type` -- none of that has
89
+ been validated yet.
90
+ """
91
+
92
+ payload_id: str
93
+ answers: Mapping[str, ProcessorAnswer]
94
+ reason: str | None
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class ClassifyOutcome:
99
+ """The full result of one `classify()` call: parsed items, the
100
+ payload ids that failed structural parsing, the payload ids simply
101
+ absent from the response, and per-call metadata for the audit trail."""
102
+
103
+ results: tuple[Classification, ...] # validated items only
104
+ invalid: tuple[str, ...] # payload_ids that failed validation
105
+ missing: tuple[str, ...] # payload_ids absent from the response
106
+ structured_output_level: str # json_schema|json_object|none
107
+ input_tokens: int | None
108
+ output_tokens: int | None
109
+ latency_ms: int
110
+
111
+
112
+ class Classifier(Protocol):
113
+ def classify(
114
+ self,
115
+ candidates: Sequence[CandidatePayload],
116
+ processors: Sequence[OfferedProcessor],
117
+ ) -> ClassifyOutcome: ...