cellarbrain 0.2.2__tar.gz → 0.2.3__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.
- {cellarbrain-0.2.2/src/cellarbrain.egg-info → cellarbrain-0.2.3}/PKG-INFO +1 -1
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/pyproject.toml +1 -1
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/cli.py +3 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/__init__.py +23 -5
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/etl_runner.py +3 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/imap.py +4 -2
- {cellarbrain-0.2.2 → cellarbrain-0.2.3/src/cellarbrain.egg-info}/PKG-INFO +1 -1
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_email_poll.py +220 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_price.py +9 -9
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/LICENSE +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/README.md +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/setup.cfg +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/__init__.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/__main__.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/_query_base.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/backup.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/companion_markdown.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/computed.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/__init__.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/app.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/cellar_queries.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/dossier_render.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/queries.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/static/dashboard.js +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/static/workbench.js +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/base.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/bottles.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/cellar.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/drinking.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/error.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/errors.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/index.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/latency.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/live.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/pairing.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/bottle_rows.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/dossier_section.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/error_detail.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/error_rows.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/event_stream.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/pairing_results.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/session_detail.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/sql_results.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/stats_content.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/tool_rows.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/turn_events.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/wine_rows.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/workbench_response.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/sessions.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/sql.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/stats.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tools.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tracked.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tracked_detail.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/wine_detail.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_batch.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_list.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_tool.html +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/workbench.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/doctor.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dossier_ops.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/credentials.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/grouping.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/placement.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/flat.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/incremental.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/log.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/markdown.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/mcp_server.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/observability.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/pairing.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/parsers.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/price.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/query.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/search.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/settings.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/slugify.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/__init__.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/catalogue.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/engine.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/index.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/model.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/schemas.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/text_builder.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/training.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/transform.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/validate.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/vinocell_parsers.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/vinocell_reader.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/writer.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/SOURCES.txt +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/dependency_links.txt +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/entry_points.txt +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/requires.txt +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/top_level.txt +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_backup.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_catalogue.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_cli.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_companion_markdown.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_computed.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_app.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_cellar.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_dossier.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_pairing.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_queries.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_workbench.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dataset_factory.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_doctor.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dossier_ops.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_flat.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_incremental.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_integration.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_log.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_markdown.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_mcp_server.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_observability.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_pairing.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_parsers.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_query.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_reader.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_search.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_settings.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_data.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_mcp.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_quality.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_training.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_transform.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_validate.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_vinocell_parsers.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_vinocell_reader.py +0 -0
- {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_writer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cellarbrain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports
|
|
5
5
|
Author-email: Urban Busslinger <urbanb@me.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cellarbrain"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
license = "MIT"
|
|
@@ -1349,6 +1349,9 @@ def _cmd_ingest(args: argparse.Namespace, settings: Settings) -> None:
|
|
|
1349
1349
|
|
|
1350
1350
|
if args.once:
|
|
1351
1351
|
count = poll_once(config, settings, dry_run=args.dry_run)
|
|
1352
|
+
if count < 0:
|
|
1353
|
+
print(f"Failed {-count} batch(es) (ETL error — messages left unprocessed).")
|
|
1354
|
+
sys.exit(1)
|
|
1352
1355
|
print(f"Processed {count} batch(es).")
|
|
1353
1356
|
sys.exit(0)
|
|
1354
1357
|
|
|
@@ -58,7 +58,8 @@ def poll_once(
|
|
|
58
58
|
) -> int:
|
|
59
59
|
"""Execute a single poll cycle.
|
|
60
60
|
|
|
61
|
-
Returns the number of batches successfully processed (0 or more)
|
|
61
|
+
Returns the number of batches successfully processed (0 or more),
|
|
62
|
+
or a negative number indicating how many batches failed ETL.
|
|
62
63
|
"""
|
|
63
64
|
from .credentials import resolve_credentials
|
|
64
65
|
from .etl_runner import run_etl
|
|
@@ -111,6 +112,7 @@ def poll_once(
|
|
|
111
112
|
return 0
|
|
112
113
|
|
|
113
114
|
processed = 0
|
|
115
|
+
failed = 0
|
|
114
116
|
for batch in batches:
|
|
115
117
|
logger.info(
|
|
116
118
|
"Batch detected — %s",
|
|
@@ -143,9 +145,15 @@ def poll_once(
|
|
|
143
145
|
expected_files=config.expected_files,
|
|
144
146
|
)
|
|
145
147
|
if exit_code != 0:
|
|
146
|
-
logger.error(
|
|
148
|
+
logger.error(
|
|
149
|
+
"ETL failed (exit %d) — leaving messages unprocessed (UIDs: %s)",
|
|
150
|
+
exit_code,
|
|
151
|
+
list(batch.uids),
|
|
152
|
+
)
|
|
153
|
+
failed += 1
|
|
154
|
+
continue
|
|
147
155
|
|
|
148
|
-
# Mark as processed
|
|
156
|
+
# Mark as processed only on successful ETL
|
|
149
157
|
batch_uids = list(batch.uids)
|
|
150
158
|
if config.processed_action == "move":
|
|
151
159
|
client.move_messages(batch_uids, config.processed_folder)
|
|
@@ -161,6 +169,8 @@ def poll_once(
|
|
|
161
169
|
|
|
162
170
|
processed += 1
|
|
163
171
|
|
|
172
|
+
if failed:
|
|
173
|
+
return -failed
|
|
164
174
|
return processed
|
|
165
175
|
|
|
166
176
|
|
|
@@ -195,9 +205,17 @@ class IngestDaemon:
|
|
|
195
205
|
while True:
|
|
196
206
|
try:
|
|
197
207
|
count = poll_once(self.config, self.settings, dry_run=dry_run)
|
|
198
|
-
if count
|
|
208
|
+
if count < 0:
|
|
209
|
+
logger.error("ETL failed for %d batch(es) — will retry next cycle", -count)
|
|
210
|
+
self._current_interval = min(
|
|
211
|
+
self._current_interval * 2,
|
|
212
|
+
self._max_interval,
|
|
213
|
+
)
|
|
214
|
+
elif count > 0:
|
|
199
215
|
logger.info("Processed %d batch(es)", count)
|
|
200
|
-
|
|
216
|
+
self._current_interval = self._base_interval
|
|
217
|
+
else:
|
|
218
|
+
self._current_interval = self._base_interval
|
|
201
219
|
except ValueError:
|
|
202
220
|
# Credential / config errors — fatal, stop daemon
|
|
203
221
|
raise
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
from pathlib import Path
|
|
@@ -58,11 +59,13 @@ def run_etl(
|
|
|
58
59
|
|
|
59
60
|
logger.info("Running ETL: %s", " ".join(cmd))
|
|
60
61
|
try:
|
|
62
|
+
env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
|
|
61
63
|
result = subprocess.run(
|
|
62
64
|
cmd,
|
|
63
65
|
capture_output=True,
|
|
64
66
|
text=True,
|
|
65
67
|
timeout=_ETL_TIMEOUT,
|
|
68
|
+
env=env,
|
|
66
69
|
)
|
|
67
70
|
output = result.stdout + result.stderr
|
|
68
71
|
if result.returncode == 0:
|
|
@@ -103,14 +103,16 @@ class ImapClient:
|
|
|
103
103
|
return []
|
|
104
104
|
|
|
105
105
|
results: list[tuple[EmailMessage, bytes]] = []
|
|
106
|
-
raw_responses = self._client.fetch(uids, ["
|
|
106
|
+
raw_responses = self._client.fetch(uids, ["BODY.PEEK[]", "INTERNALDATE"])
|
|
107
107
|
|
|
108
108
|
for uid, data in raw_responses.items():
|
|
109
109
|
internal_date = data.get(b"INTERNALDATE")
|
|
110
110
|
if internal_date is None:
|
|
111
111
|
internal_date = datetime.now(UTC)
|
|
112
112
|
|
|
113
|
-
rfc822 = data.get(b"RFC822", b"")
|
|
113
|
+
rfc822 = data.get(b"BODY[]") or data.get(b"RFC822", b"")
|
|
114
|
+
if not rfc822:
|
|
115
|
+
continue
|
|
114
116
|
msg = email.message_from_bytes(rfc822, policy=email.policy.default)
|
|
115
117
|
|
|
116
118
|
attachments = _extract_attachments(msg)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cellarbrain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports
|
|
5
5
|
Author-email: Urban Busslinger <urbanb@me.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -315,6 +315,26 @@ class TestRunEtl:
|
|
|
315
315
|
assert "--config" in cmd
|
|
316
316
|
assert str(config) in cmd
|
|
317
317
|
|
|
318
|
+
def test_utf8_env_forced(self, tmp_path):
|
|
319
|
+
"""ETL subprocess gets PYTHONUTF8=1 and PYTHONIOENCODING=utf-8 (#002)."""
|
|
320
|
+
from cellarbrain.email_poll.etl_runner import run_etl
|
|
321
|
+
|
|
322
|
+
raw_dir = tmp_path / "raw"
|
|
323
|
+
raw_dir.mkdir()
|
|
324
|
+
output_dir = tmp_path / "output"
|
|
325
|
+
output_dir.mkdir()
|
|
326
|
+
|
|
327
|
+
mock_result = MagicMock()
|
|
328
|
+
mock_result.returncode = 0
|
|
329
|
+
mock_result.stdout = ""
|
|
330
|
+
mock_result.stderr = ""
|
|
331
|
+
|
|
332
|
+
with patch("cellarbrain.email_poll.etl_runner.subprocess.run", return_value=mock_result) as mock_run:
|
|
333
|
+
run_etl(raw_dir, output_dir)
|
|
334
|
+
env = mock_run.call_args[1]["env"]
|
|
335
|
+
assert env["PYTHONUTF8"] == "1"
|
|
336
|
+
assert env["PYTHONIOENCODING"] == "utf-8"
|
|
337
|
+
|
|
318
338
|
|
|
319
339
|
# ---------------------------------------------------------------------------
|
|
320
340
|
# TestStateFile
|
|
@@ -341,3 +361,203 @@ class TestStateFile:
|
|
|
341
361
|
loaded = _load_state(tmp_path)
|
|
342
362
|
assert loaded["processed_uids"] == [1, 2, 3]
|
|
343
363
|
assert loaded["last_batch"] == "260428-1435"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# TestImapFetchMessages
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _make_mime_message(filename: str, payload: bytes) -> bytes:
|
|
372
|
+
"""Build a minimal MIME message with one attachment."""
|
|
373
|
+
import email.mime.application
|
|
374
|
+
import email.mime.multipart
|
|
375
|
+
|
|
376
|
+
msg = email.mime.multipart.MIMEMultipart()
|
|
377
|
+
msg["Subject"] = "[VinoCell] CSV file"
|
|
378
|
+
att = email.mime.application.MIMEApplication(payload, Name=filename)
|
|
379
|
+
att.add_header("Content-Disposition", "attachment", filename=filename)
|
|
380
|
+
msg.attach(att)
|
|
381
|
+
return msg.as_bytes()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class TestImapFetchMessages:
|
|
385
|
+
"""Regression tests for iCloud IMAP BODY[] key issue (#001)."""
|
|
386
|
+
|
|
387
|
+
def test_body_bracket_key(self):
|
|
388
|
+
"""iCloud returns body under b'BODY[]' — should still work."""
|
|
389
|
+
from cellarbrain.email_poll.imap import ImapClient
|
|
390
|
+
|
|
391
|
+
mime_bytes = _make_mime_message("export-wines.csv", b"wine,data")
|
|
392
|
+
|
|
393
|
+
mock_client = MagicMock()
|
|
394
|
+
mock_client.fetch.return_value = {
|
|
395
|
+
48: {
|
|
396
|
+
b"BODY[]": mime_bytes,
|
|
397
|
+
b"INTERNALDATE": datetime(2026, 5, 1, 10, 0),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
imap = ImapClient.__new__(ImapClient)
|
|
402
|
+
imap._client = mock_client
|
|
403
|
+
|
|
404
|
+
results = imap.fetch_messages([48], ["export-wines.csv"])
|
|
405
|
+
assert len(results) == 1
|
|
406
|
+
em, data = results[0]
|
|
407
|
+
assert em.uid == 48
|
|
408
|
+
assert em.filename == "export-wines.csv"
|
|
409
|
+
assert data == b"wine,data"
|
|
410
|
+
mock_client.fetch.assert_called_once_with([48], ["BODY.PEEK[]", "INTERNALDATE"])
|
|
411
|
+
|
|
412
|
+
def test_rfc822_key_fallback(self):
|
|
413
|
+
"""Servers returning b'RFC822' should still work (fallback)."""
|
|
414
|
+
from cellarbrain.email_poll.imap import ImapClient
|
|
415
|
+
|
|
416
|
+
mime_bytes = _make_mime_message("export-wines.csv", b"wine,data")
|
|
417
|
+
|
|
418
|
+
mock_client = MagicMock()
|
|
419
|
+
mock_client.fetch.return_value = {
|
|
420
|
+
99: {
|
|
421
|
+
b"RFC822": mime_bytes,
|
|
422
|
+
b"INTERNALDATE": datetime(2026, 5, 1, 10, 0),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
imap = ImapClient.__new__(ImapClient)
|
|
427
|
+
imap._client = mock_client
|
|
428
|
+
|
|
429
|
+
results = imap.fetch_messages([99], ["export-wines.csv"])
|
|
430
|
+
assert len(results) == 1
|
|
431
|
+
em, data = results[0]
|
|
432
|
+
assert em.uid == 99
|
|
433
|
+
assert em.filename == "export-wines.csv"
|
|
434
|
+
|
|
435
|
+
def test_empty_body_skipped(self):
|
|
436
|
+
"""Messages with no body data should be skipped."""
|
|
437
|
+
from cellarbrain.email_poll.imap import ImapClient
|
|
438
|
+
|
|
439
|
+
mock_client = MagicMock()
|
|
440
|
+
mock_client.fetch.return_value = {
|
|
441
|
+
50: {
|
|
442
|
+
b"INTERNALDATE": datetime(2026, 5, 1, 10, 0),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
imap = ImapClient.__new__(ImapClient)
|
|
447
|
+
imap._client = mock_client
|
|
448
|
+
|
|
449
|
+
results = imap.fetch_messages([50], ["export-wines.csv"])
|
|
450
|
+
assert len(results) == 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# TestPollOnceEtlFailure
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class TestPollOnceEtlFailure:
|
|
459
|
+
"""Verify poll_once does NOT mark messages processed on ETL failure (#003)."""
|
|
460
|
+
|
|
461
|
+
def _make_settings(self, tmp_path):
|
|
462
|
+
"""Build minimal Settings and IngestConfig for testing."""
|
|
463
|
+
from cellarbrain.settings import IngestConfig
|
|
464
|
+
|
|
465
|
+
raw_dir = tmp_path / "raw"
|
|
466
|
+
raw_dir.mkdir()
|
|
467
|
+
output_dir = tmp_path / "output"
|
|
468
|
+
output_dir.mkdir()
|
|
469
|
+
|
|
470
|
+
config = IngestConfig()
|
|
471
|
+
|
|
472
|
+
settings = MagicMock()
|
|
473
|
+
settings.paths.raw_dir = str(raw_dir)
|
|
474
|
+
settings.paths.data_dir = str(output_dir)
|
|
475
|
+
settings.config_source = None
|
|
476
|
+
|
|
477
|
+
return config, settings, raw_dir
|
|
478
|
+
|
|
479
|
+
def test_etl_failure_leaves_messages_unprocessed(self, tmp_path):
|
|
480
|
+
"""On ETL failure, messages should NOT be marked as read."""
|
|
481
|
+
from cellarbrain.email_poll import poll_once
|
|
482
|
+
from cellarbrain.email_poll.grouping import Batch
|
|
483
|
+
|
|
484
|
+
config, settings, raw_dir = self._make_settings(tmp_path)
|
|
485
|
+
|
|
486
|
+
t0 = datetime(2026, 5, 1, 10, 0)
|
|
487
|
+
|
|
488
|
+
mock_imap = MagicMock()
|
|
489
|
+
mock_imap.search_unseen.return_value = [48, 49, 50]
|
|
490
|
+
mock_imap.fetch_messages.return_value = [
|
|
491
|
+
(EmailMessage(uid=48, date=t0, filename="export-wines.csv", size=100), b"wines"),
|
|
492
|
+
(EmailMessage(uid=49, date=t0, filename="export-bottles-stored.csv", size=100), b"bottles"),
|
|
493
|
+
(EmailMessage(uid=50, date=t0, filename="export-bottles-gone.csv", size=100), b"gone"),
|
|
494
|
+
]
|
|
495
|
+
mock_imap.__enter__ = MagicMock(return_value=mock_imap)
|
|
496
|
+
mock_imap.__exit__ = MagicMock(return_value=False)
|
|
497
|
+
|
|
498
|
+
batch = Batch(
|
|
499
|
+
messages=[
|
|
500
|
+
EmailMessage(uid=48, date=t0, filename="export-wines.csv", size=100),
|
|
501
|
+
EmailMessage(uid=49, date=t0, filename="export-bottles-stored.csv", size=100),
|
|
502
|
+
EmailMessage(uid=50, date=t0, filename="export-bottles-gone.csv", size=100),
|
|
503
|
+
]
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
with (
|
|
507
|
+
patch("cellarbrain.email_poll.imap.ImapClient", return_value=mock_imap),
|
|
508
|
+
patch("cellarbrain.email_poll.poll_once.__module__", "cellarbrain.email_poll"),
|
|
509
|
+
patch("cellarbrain.email_poll.credentials.resolve_credentials", return_value=("u", "p")),
|
|
510
|
+
patch("cellarbrain.email_poll.grouping.group_messages", return_value=[batch]),
|
|
511
|
+
patch("cellarbrain.email_poll.placement.place_batch", return_value=raw_dir / "260501-1000"),
|
|
512
|
+
patch("cellarbrain.email_poll.etl_runner.run_etl", return_value=(1, "Error: bad data")),
|
|
513
|
+
):
|
|
514
|
+
result = poll_once(config, settings)
|
|
515
|
+
|
|
516
|
+
# ETL failed → should NOT mark messages
|
|
517
|
+
mock_imap.mark_seen.assert_not_called()
|
|
518
|
+
mock_imap.move_messages.assert_not_called()
|
|
519
|
+
# Return value should be negative (indicating failure)
|
|
520
|
+
assert result == -1
|
|
521
|
+
|
|
522
|
+
def test_etl_success_marks_messages(self, tmp_path):
|
|
523
|
+
"""On ETL success, messages should be marked as read."""
|
|
524
|
+
from cellarbrain.email_poll import poll_once
|
|
525
|
+
from cellarbrain.email_poll.grouping import Batch
|
|
526
|
+
|
|
527
|
+
config, settings, raw_dir = self._make_settings(tmp_path)
|
|
528
|
+
|
|
529
|
+
t0 = datetime(2026, 5, 1, 10, 0)
|
|
530
|
+
|
|
531
|
+
mock_imap = MagicMock()
|
|
532
|
+
mock_imap.search_unseen.return_value = [48, 49, 50]
|
|
533
|
+
mock_imap.fetch_messages.return_value = [
|
|
534
|
+
(EmailMessage(uid=48, date=t0, filename="export-wines.csv", size=100), b"wines"),
|
|
535
|
+
(EmailMessage(uid=49, date=t0, filename="export-bottles-stored.csv", size=100), b"bottles"),
|
|
536
|
+
(EmailMessage(uid=50, date=t0, filename="export-bottles-gone.csv", size=100), b"gone"),
|
|
537
|
+
]
|
|
538
|
+
mock_imap.__enter__ = MagicMock(return_value=mock_imap)
|
|
539
|
+
mock_imap.__exit__ = MagicMock(return_value=False)
|
|
540
|
+
|
|
541
|
+
batch = Batch(
|
|
542
|
+
messages=[
|
|
543
|
+
EmailMessage(uid=48, date=t0, filename="export-wines.csv", size=100),
|
|
544
|
+
EmailMessage(uid=49, date=t0, filename="export-bottles-stored.csv", size=100),
|
|
545
|
+
EmailMessage(uid=50, date=t0, filename="export-bottles-gone.csv", size=100),
|
|
546
|
+
]
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
snapshot_dir = raw_dir / "260501-1000"
|
|
550
|
+
snapshot_dir.mkdir(parents=True)
|
|
551
|
+
|
|
552
|
+
with (
|
|
553
|
+
patch("cellarbrain.email_poll.imap.ImapClient", return_value=mock_imap),
|
|
554
|
+
patch("cellarbrain.email_poll.credentials.resolve_credentials", return_value=("u", "p")),
|
|
555
|
+
patch("cellarbrain.email_poll.grouping.group_messages", return_value=[batch]),
|
|
556
|
+
patch("cellarbrain.email_poll.placement.place_batch", return_value=snapshot_dir),
|
|
557
|
+
patch("cellarbrain.email_poll.etl_runner.run_etl", return_value=(0, "OK")),
|
|
558
|
+
):
|
|
559
|
+
result = poll_once(config, settings)
|
|
560
|
+
|
|
561
|
+
# ETL succeeded → messages should be marked
|
|
562
|
+
mock_imap.mark_seen.assert_called_once_with([48, 49, 50])
|
|
563
|
+
assert result == 1
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import date, datetime
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
6
|
from decimal import Decimal
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
@@ -414,7 +414,7 @@ class TestWishlistAlerts:
|
|
|
414
414
|
"price": Decimal("50.00"),
|
|
415
415
|
"currency": "CHF",
|
|
416
416
|
"in_stock": True,
|
|
417
|
-
"observed_at": datetime(
|
|
417
|
+
"observed_at": datetime.now() - timedelta(days=5),
|
|
418
418
|
"observation_source": "agent",
|
|
419
419
|
}
|
|
420
420
|
log_price(price_dir, obs)
|
|
@@ -432,7 +432,7 @@ class TestWishlistAlerts:
|
|
|
432
432
|
"price": Decimal("100.00"),
|
|
433
433
|
"currency": "CHF",
|
|
434
434
|
"in_stock": True,
|
|
435
|
-
"observed_at": datetime(
|
|
435
|
+
"observed_at": datetime.now() - timedelta(days=20),
|
|
436
436
|
"observation_source": "agent",
|
|
437
437
|
}
|
|
438
438
|
log_price(price_dir, old_obs)
|
|
@@ -444,7 +444,7 @@ class TestWishlistAlerts:
|
|
|
444
444
|
"price": Decimal("80.00"),
|
|
445
445
|
"currency": "CHF",
|
|
446
446
|
"in_stock": True,
|
|
447
|
-
"observed_at": datetime(
|
|
447
|
+
"observed_at": datetime.now() - timedelta(days=5),
|
|
448
448
|
"observation_source": "agent",
|
|
449
449
|
}
|
|
450
450
|
log_price(price_dir, new_obs)
|
|
@@ -461,7 +461,7 @@ class TestWishlistAlerts:
|
|
|
461
461
|
"price": Decimal("100.00"),
|
|
462
462
|
"currency": "CHF",
|
|
463
463
|
"in_stock": True,
|
|
464
|
-
"observed_at": datetime(
|
|
464
|
+
"observed_at": datetime.now() - timedelta(days=20),
|
|
465
465
|
"observation_source": "agent",
|
|
466
466
|
}
|
|
467
467
|
log_price(price_dir, old_obs)
|
|
@@ -473,7 +473,7 @@ class TestWishlistAlerts:
|
|
|
473
473
|
"price": Decimal("95.00"),
|
|
474
474
|
"currency": "CHF",
|
|
475
475
|
"in_stock": True,
|
|
476
|
-
"observed_at": datetime(
|
|
476
|
+
"observed_at": datetime.now() - timedelta(days=5),
|
|
477
477
|
"observation_source": "agent",
|
|
478
478
|
}
|
|
479
479
|
log_price(price_dir, new_obs)
|
|
@@ -489,7 +489,7 @@ class TestWishlistAlerts:
|
|
|
489
489
|
"price": Decimal("60.00"),
|
|
490
490
|
"currency": "CHF",
|
|
491
491
|
"in_stock": False,
|
|
492
|
-
"observed_at": datetime(
|
|
492
|
+
"observed_at": datetime.now() - timedelta(days=20),
|
|
493
493
|
"observation_source": "agent",
|
|
494
494
|
}
|
|
495
495
|
log_price(price_dir, out_obs)
|
|
@@ -501,7 +501,7 @@ class TestWishlistAlerts:
|
|
|
501
501
|
"price": Decimal("60.00"),
|
|
502
502
|
"currency": "CHF",
|
|
503
503
|
"in_stock": True,
|
|
504
|
-
"observed_at": datetime(
|
|
504
|
+
"observed_at": datetime.now() - timedelta(days=5),
|
|
505
505
|
"observation_source": "agent",
|
|
506
506
|
}
|
|
507
507
|
log_price(price_dir, in_obs)
|
|
@@ -518,7 +518,7 @@ class TestWishlistAlerts:
|
|
|
518
518
|
"price": Decimal("35.00"),
|
|
519
519
|
"currency": "CHF",
|
|
520
520
|
"in_stock": True,
|
|
521
|
-
"observed_at": datetime(
|
|
521
|
+
"observed_at": datetime.now() - timedelta(days=5),
|
|
522
522
|
"observation_source": "agent",
|
|
523
523
|
"notes": "En primeur release",
|
|
524
524
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/error_rows.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/tool_rows.html
RENAMED
|
File without changes
|
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/wine_rows.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tracked_detail.html
RENAMED
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/wine_detail.html
RENAMED
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_batch.html
RENAMED
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_list.html
RENAMED
|
File without changes
|
{cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_tool.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|