cellarbrain 0.2.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.
Files changed (52) hide show
  1. cellarbrain/__init__.py +1 -0
  2. cellarbrain/__main__.py +5 -0
  3. cellarbrain/_query_base.py +16 -0
  4. cellarbrain/backup.py +143 -0
  5. cellarbrain/cli.py +1559 -0
  6. cellarbrain/companion_markdown.py +295 -0
  7. cellarbrain/computed.py +582 -0
  8. cellarbrain/dashboard/__init__.py +38 -0
  9. cellarbrain/dashboard/app.py +1004 -0
  10. cellarbrain/dashboard/cellar_queries.py +509 -0
  11. cellarbrain/dashboard/dossier_render.py +64 -0
  12. cellarbrain/dashboard/queries.py +443 -0
  13. cellarbrain/dashboard/workbench.py +337 -0
  14. cellarbrain/doctor.py +449 -0
  15. cellarbrain/dossier_ops.py +996 -0
  16. cellarbrain/email_poll/__init__.py +215 -0
  17. cellarbrain/email_poll/credentials.py +86 -0
  18. cellarbrain/email_poll/etl_runner.py +75 -0
  19. cellarbrain/email_poll/grouping.py +109 -0
  20. cellarbrain/email_poll/imap.py +165 -0
  21. cellarbrain/email_poll/placement.py +74 -0
  22. cellarbrain/flat.py +316 -0
  23. cellarbrain/incremental.py +1076 -0
  24. cellarbrain/log.py +94 -0
  25. cellarbrain/markdown.py +1048 -0
  26. cellarbrain/mcp_server.py +2097 -0
  27. cellarbrain/observability.py +242 -0
  28. cellarbrain/parsers.py +211 -0
  29. cellarbrain/price.py +460 -0
  30. cellarbrain/query.py +1089 -0
  31. cellarbrain/search.py +577 -0
  32. cellarbrain/settings.py +989 -0
  33. cellarbrain/slugify.py +66 -0
  34. cellarbrain/sommelier/__init__.py +14 -0
  35. cellarbrain/sommelier/catalogue.py +774 -0
  36. cellarbrain/sommelier/engine.py +168 -0
  37. cellarbrain/sommelier/index.py +111 -0
  38. cellarbrain/sommelier/model.py +28 -0
  39. cellarbrain/sommelier/schemas.py +72 -0
  40. cellarbrain/sommelier/text_builder.py +154 -0
  41. cellarbrain/sommelier/training.py +141 -0
  42. cellarbrain/transform.py +846 -0
  43. cellarbrain/validate.py +372 -0
  44. cellarbrain/vinocell_parsers.py +212 -0
  45. cellarbrain/vinocell_reader.py +249 -0
  46. cellarbrain/writer.py +429 -0
  47. cellarbrain-0.2.0.dist-info/METADATA +343 -0
  48. cellarbrain-0.2.0.dist-info/RECORD +52 -0
  49. cellarbrain-0.2.0.dist-info/WHEEL +5 -0
  50. cellarbrain-0.2.0.dist-info/entry_points.txt +2 -0
  51. cellarbrain-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. cellarbrain-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """Cellarbrain — AI sommelier for your wine cellar."""
@@ -0,0 +1,5 @@
1
+ """Allow running as ``python -m cellarbrain``."""
2
+
3
+ from cellarbrain.cli import main
4
+
5
+ main()
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+
6
+ class QueryError(Exception):
7
+ """SQL validation or execution error."""
8
+
9
+
10
+ class DataStaleError(Exception):
11
+ """Parquet files missing or corrupted."""
12
+
13
+
14
+ def _to_md(df: pd.DataFrame) -> str:
15
+ """Render a DataFrame as a Markdown table with NULLs as empty cells."""
16
+ return df.astype(object).where(df.notna(), "").to_markdown(index=False)
cellarbrain/backup.py ADDED
@@ -0,0 +1,143 @@
1
+ """Backup and restore for the cellarbrain data directory.
2
+
3
+ Creates timestamped zip archives of Parquet files, dossiers, and custom
4
+ config before ETL runs. Provides retention pruning and restore from archive.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import pathlib
11
+ import zipfile
12
+ from datetime import UTC, datetime
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Directories excluded from backup by default (rebuildable / diagnostic).
17
+ _DEFAULT_EXCLUDE_DIRS = frozenset({"sommelier", "logs"})
18
+
19
+
20
+ def create_backup(
21
+ data_dir: str | pathlib.Path,
22
+ backup_dir: str | pathlib.Path,
23
+ *,
24
+ include_sommelier: bool = False,
25
+ include_logs: bool = False,
26
+ max_backups: int = 5,
27
+ ) -> pathlib.Path:
28
+ """Create a zip backup of the data directory.
29
+
30
+ Returns the path to the created archive.
31
+ Raises FileNotFoundError if data_dir does not exist.
32
+ """
33
+ data = pathlib.Path(data_dir)
34
+ if not data.exists():
35
+ raise FileNotFoundError(f"Data directory not found: {data}")
36
+
37
+ bkp = pathlib.Path(backup_dir)
38
+ bkp.mkdir(parents=True, exist_ok=True)
39
+
40
+ ts = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%S")
41
+ archive_name = f"cellarbrain-{ts}.zip"
42
+ archive_path = bkp / archive_name
43
+
44
+ exclude_dirs: set[str] = set()
45
+ if not include_sommelier:
46
+ exclude_dirs.add("sommelier")
47
+ if not include_logs:
48
+ exclude_dirs.add("logs")
49
+
50
+ file_count = 0
51
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
52
+ for file in sorted(data.rglob("*")):
53
+ if file.is_dir():
54
+ continue
55
+ rel = file.relative_to(data)
56
+ # Skip excluded directories
57
+ if any(part in exclude_dirs for part in rel.parts):
58
+ continue
59
+ zf.write(file, arcname=str(rel))
60
+ file_count += 1
61
+
62
+ size_mb = archive_path.stat().st_size / (1024 * 1024)
63
+ logger.info(
64
+ "Backup created: %s (%d files, %.1f MB)",
65
+ archive_path,
66
+ file_count,
67
+ size_mb,
68
+ )
69
+
70
+ # Prune old backups
71
+ _prune_backups(bkp, max_backups)
72
+
73
+ return archive_path
74
+
75
+
76
+ def _prune_backups(backup_dir: pathlib.Path, max_backups: int) -> list[pathlib.Path]:
77
+ """Remove oldest backups exceeding max_backups. Returns removed paths."""
78
+ archives = sorted(
79
+ backup_dir.glob("cellarbrain-*.zip"),
80
+ key=lambda p: p.stat().st_mtime,
81
+ reverse=True,
82
+ )
83
+ removed: list[pathlib.Path] = []
84
+ for old in archives[max_backups:]:
85
+ old.unlink()
86
+ removed.append(old)
87
+ logger.info("Pruned old backup: %s", old.name)
88
+ return removed
89
+
90
+
91
+ def list_backups(backup_dir: str | pathlib.Path) -> list[dict]:
92
+ """List available backups with metadata.
93
+
94
+ Returns list of dicts with keys: path, name, size_mb, file_count.
95
+ Sorted newest-first.
96
+ """
97
+ bkp = pathlib.Path(backup_dir)
98
+ if not bkp.exists():
99
+ return []
100
+
101
+ result: list[dict] = []
102
+ for archive in sorted(bkp.glob("cellarbrain-*.zip"), reverse=True):
103
+ with zipfile.ZipFile(archive, "r") as zf:
104
+ file_count = len(zf.namelist())
105
+ result.append(
106
+ {
107
+ "path": archive,
108
+ "name": archive.name,
109
+ "size_mb": round(archive.stat().st_size / (1024 * 1024), 1),
110
+ "file_count": file_count,
111
+ }
112
+ )
113
+ return result
114
+
115
+
116
+ def restore_backup(
117
+ archive_path: str | pathlib.Path,
118
+ data_dir: str | pathlib.Path,
119
+ *,
120
+ dry_run: bool = False,
121
+ ) -> int:
122
+ """Restore a backup archive to the data directory.
123
+
124
+ Overwrites existing files. Does NOT delete files in data_dir that
125
+ aren't in the archive (preserves sommelier indexes, logs, etc.).
126
+
127
+ Returns the number of files restored.
128
+ """
129
+ archive = pathlib.Path(archive_path)
130
+ data = pathlib.Path(data_dir)
131
+
132
+ if not archive.exists():
133
+ raise FileNotFoundError(f"Backup not found: {archive}")
134
+
135
+ with zipfile.ZipFile(archive, "r") as zf:
136
+ members = zf.namelist()
137
+ if dry_run:
138
+ return len(members)
139
+ data.mkdir(parents=True, exist_ok=True)
140
+ zf.extractall(data)
141
+
142
+ logger.info("Restored %d files from %s", len(members), archive.name)
143
+ return len(members)