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.
Files changed (132) hide show
  1. {cellarbrain-0.2.2/src/cellarbrain.egg-info → cellarbrain-0.2.3}/PKG-INFO +1 -1
  2. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/pyproject.toml +1 -1
  3. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/cli.py +3 -0
  4. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/__init__.py +23 -5
  5. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/etl_runner.py +3 -0
  6. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/imap.py +4 -2
  7. {cellarbrain-0.2.2 → cellarbrain-0.2.3/src/cellarbrain.egg-info}/PKG-INFO +1 -1
  8. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_email_poll.py +220 -0
  9. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_price.py +9 -9
  10. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/LICENSE +0 -0
  11. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/README.md +0 -0
  12. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/setup.cfg +0 -0
  13. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/__init__.py +0 -0
  14. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/__main__.py +0 -0
  15. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/_query_base.py +0 -0
  16. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/backup.py +0 -0
  17. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/companion_markdown.py +0 -0
  18. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/computed.py +0 -0
  19. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/__init__.py +0 -0
  20. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/app.py +0 -0
  21. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/cellar_queries.py +0 -0
  22. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/dossier_render.py +0 -0
  23. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/queries.py +0 -0
  24. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/static/dashboard.js +0 -0
  25. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/static/workbench.js +0 -0
  26. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/base.html +0 -0
  27. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/bottles.html +0 -0
  28. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/cellar.html +0 -0
  29. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/drinking.html +0 -0
  30. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/error.html +0 -0
  31. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/errors.html +0 -0
  32. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/index.html +0 -0
  33. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/latency.html +0 -0
  34. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/live.html +0 -0
  35. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/pairing.html +0 -0
  36. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/bottle_rows.html +0 -0
  37. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/dossier_section.html +0 -0
  38. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/error_detail.html +0 -0
  39. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/error_rows.html +0 -0
  40. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/event_stream.html +0 -0
  41. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/pairing_results.html +0 -0
  42. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/session_detail.html +0 -0
  43. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/sql_results.html +0 -0
  44. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/stats_content.html +0 -0
  45. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/tool_rows.html +0 -0
  46. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/turn_events.html +0 -0
  47. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/wine_rows.html +0 -0
  48. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/partials/workbench_response.html +0 -0
  49. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/sessions.html +0 -0
  50. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/sql.html +0 -0
  51. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/stats.html +0 -0
  52. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tools.html +0 -0
  53. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tracked.html +0 -0
  54. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/tracked_detail.html +0 -0
  55. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/wine_detail.html +0 -0
  56. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_batch.html +0 -0
  57. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_list.html +0 -0
  58. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/templates/workbench_tool.html +0 -0
  59. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dashboard/workbench.py +0 -0
  60. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/doctor.py +0 -0
  61. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/dossier_ops.py +0 -0
  62. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/credentials.py +0 -0
  63. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/grouping.py +0 -0
  64. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/email_poll/placement.py +0 -0
  65. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/flat.py +0 -0
  66. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/incremental.py +0 -0
  67. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/log.py +0 -0
  68. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/markdown.py +0 -0
  69. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/mcp_server.py +0 -0
  70. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/observability.py +0 -0
  71. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/pairing.py +0 -0
  72. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/parsers.py +0 -0
  73. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/price.py +0 -0
  74. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/query.py +0 -0
  75. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/search.py +0 -0
  76. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/settings.py +0 -0
  77. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/slugify.py +0 -0
  78. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/__init__.py +0 -0
  79. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/catalogue.py +0 -0
  80. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/engine.py +0 -0
  81. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/index.py +0 -0
  82. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/model.py +0 -0
  83. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/schemas.py +0 -0
  84. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/text_builder.py +0 -0
  85. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/sommelier/training.py +0 -0
  86. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/transform.py +0 -0
  87. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/validate.py +0 -0
  88. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/vinocell_parsers.py +0 -0
  89. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/vinocell_reader.py +0 -0
  90. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain/writer.py +0 -0
  91. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/SOURCES.txt +0 -0
  92. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/dependency_links.txt +0 -0
  93. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/entry_points.txt +0 -0
  94. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/requires.txt +0 -0
  95. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/src/cellarbrain.egg-info/top_level.txt +0 -0
  96. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_backup.py +0 -0
  97. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_catalogue.py +0 -0
  98. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_cli.py +0 -0
  99. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_companion_markdown.py +0 -0
  100. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_computed.py +0 -0
  101. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_app.py +0 -0
  102. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_cellar.py +0 -0
  103. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_dossier.py +0 -0
  104. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_pairing.py +0 -0
  105. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_queries.py +0 -0
  106. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dashboard_workbench.py +0 -0
  107. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dataset_factory.py +0 -0
  108. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_doctor.py +0 -0
  109. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_dossier_ops.py +0 -0
  110. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_flat.py +0 -0
  111. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_incremental.py +0 -0
  112. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_integration.py +0 -0
  113. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_log.py +0 -0
  114. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_markdown.py +0 -0
  115. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_mcp_server.py +0 -0
  116. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_observability.py +0 -0
  117. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_pairing.py +0 -0
  118. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_parsers.py +0 -0
  119. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_query.py +0 -0
  120. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_reader.py +0 -0
  121. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_search.py +0 -0
  122. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_settings.py +0 -0
  123. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier.py +0 -0
  124. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_data.py +0 -0
  125. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_mcp.py +0 -0
  126. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_quality.py +0 -0
  127. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_sommelier_training.py +0 -0
  128. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_transform.py +0 -0
  129. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_validate.py +0 -0
  130. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_vinocell_parsers.py +0 -0
  131. {cellarbrain-0.2.2 → cellarbrain-0.2.3}/tests/test_vinocell_reader.py +0 -0
  132. {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.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.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("ETL failed (exit %d)", exit_code)
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 (regardless of ETL outcome)
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 > 0:
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
- self._current_interval = self._base_interval
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, ["RFC822", "INTERNALDATE"])
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.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(2026, 4, 7, 12, 0),
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(2026, 3, 1, 12, 0),
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(2026, 4, 5, 12, 0),
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(2026, 3, 1, 12, 0),
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(2026, 4, 5, 12, 0),
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(2026, 3, 1, 12, 0),
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(2026, 4, 5, 12, 0),
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(2026, 4, 7, 12, 0),
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