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.
- cellarbrain/__init__.py +1 -0
- cellarbrain/__main__.py +5 -0
- cellarbrain/_query_base.py +16 -0
- cellarbrain/backup.py +143 -0
- cellarbrain/cli.py +1559 -0
- cellarbrain/companion_markdown.py +295 -0
- cellarbrain/computed.py +582 -0
- cellarbrain/dashboard/__init__.py +38 -0
- cellarbrain/dashboard/app.py +1004 -0
- cellarbrain/dashboard/cellar_queries.py +509 -0
- cellarbrain/dashboard/dossier_render.py +64 -0
- cellarbrain/dashboard/queries.py +443 -0
- cellarbrain/dashboard/workbench.py +337 -0
- cellarbrain/doctor.py +449 -0
- cellarbrain/dossier_ops.py +996 -0
- cellarbrain/email_poll/__init__.py +215 -0
- cellarbrain/email_poll/credentials.py +86 -0
- cellarbrain/email_poll/etl_runner.py +75 -0
- cellarbrain/email_poll/grouping.py +109 -0
- cellarbrain/email_poll/imap.py +165 -0
- cellarbrain/email_poll/placement.py +74 -0
- cellarbrain/flat.py +316 -0
- cellarbrain/incremental.py +1076 -0
- cellarbrain/log.py +94 -0
- cellarbrain/markdown.py +1048 -0
- cellarbrain/mcp_server.py +2097 -0
- cellarbrain/observability.py +242 -0
- cellarbrain/parsers.py +211 -0
- cellarbrain/price.py +460 -0
- cellarbrain/query.py +1089 -0
- cellarbrain/search.py +577 -0
- cellarbrain/settings.py +989 -0
- cellarbrain/slugify.py +66 -0
- cellarbrain/sommelier/__init__.py +14 -0
- cellarbrain/sommelier/catalogue.py +774 -0
- cellarbrain/sommelier/engine.py +168 -0
- cellarbrain/sommelier/index.py +111 -0
- cellarbrain/sommelier/model.py +28 -0
- cellarbrain/sommelier/schemas.py +72 -0
- cellarbrain/sommelier/text_builder.py +154 -0
- cellarbrain/sommelier/training.py +141 -0
- cellarbrain/transform.py +846 -0
- cellarbrain/validate.py +372 -0
- cellarbrain/vinocell_parsers.py +212 -0
- cellarbrain/vinocell_reader.py +249 -0
- cellarbrain/writer.py +429 -0
- cellarbrain-0.2.0.dist-info/METADATA +343 -0
- cellarbrain-0.2.0.dist-info/RECORD +52 -0
- cellarbrain-0.2.0.dist-info/WHEEL +5 -0
- cellarbrain-0.2.0.dist-info/entry_points.txt +2 -0
- cellarbrain-0.2.0.dist-info/licenses/LICENSE +21 -0
- cellarbrain-0.2.0.dist-info/top_level.txt +1 -0
cellarbrain/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Cellarbrain — AI sommelier for your wine cellar."""
|
cellarbrain/__main__.py
ADDED
|
@@ -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)
|