@holdyourvoice/hyv 2.7.1 → 2.8.0

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.
@@ -1,194 +0,0 @@
1
- #!/usr/bin/env python3
2
- #!/usr/bin/env python3
3
- """Push voice profile, meta, and voice.md to Cloudflare R2 for backup.
4
-
5
- Cost design: R2 has no egress fees. This script uses S3-compatible PUT only
6
- (no Workers, no compute). Typical payload: 5-20KB. At $0.015/GB stored with
7
- 1 sync/day, annual cost is essentially zero.
8
-
9
- Requires: pip install boto3
10
-
11
- Env vars (set once):
12
- HYV_R2_ACCESS_KEY_ID
13
- HYV_R2_SECRET_ACCESS_KEY
14
- HYV_R2_ENDPOINT (e.g. https://<account>.r2.cloudflarestorage.com)
15
- HYV_R2_BUCKET (e.g. hyv-voice-profiles)
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import json
21
- import os
22
- import sys
23
- from datetime import datetime, timezone
24
- from pathlib import Path
25
- from typing import Any
26
-
27
- SCRIPT_DIR = Path(__file__).resolve().parent
28
-
29
-
30
- def _now_iso() -> str:
31
- return datetime.now(timezone.utc).isoformat()
32
-
33
-
34
- def sync_profile(
35
- profile_path: Path,
36
- meta_path: Path | None = None,
37
- voice_md_path: Path | None = None,
38
- dry_run: bool = False,
39
- ) -> dict[str, Any]:
40
- """push files to R2. returns summary dict."""
41
- try:
42
- import boto3
43
- except ImportError:
44
- return {"synced": False, "error": "boto3 not installed. pip install boto3"}
45
-
46
- required = ["HYV_R2_ACCESS_KEY_ID", "HYV_R2_SECRET_ACCESS_KEY", "HYV_R2_ENDPOINT", "HYV_R2_BUCKET"]
47
- missing = [v for v in required if not os.environ.get(v)]
48
- if missing:
49
- return {"synced": False, "error": f"missing env vars: {', '.join(missing)}"}
50
-
51
- if not profile_path.exists():
52
- return {"synced": False, "error": f"profile not found: {profile_path}"}
53
-
54
- s3 = boto3.client(
55
- "s3",
56
- endpoint_url=os.environ["HYV_R2_ENDPOINT"],
57
- aws_access_key_id=os.environ["HYV_R2_ACCESS_KEY_ID"],
58
- aws_secret_access_key=os.environ["HYV_R2_SECRET_ACCESS_KEY"],
59
- region_name="auto",
60
- )
61
- bucket = os.environ["HYV_R2_BUCKET"]
62
- profile_name = profile_path.stem # e.g. "voice-profile"
63
-
64
- files_to_sync: list[tuple[Path, str]] = [
65
- (profile_path, f"{profile_name}/{profile_path.name}"),
66
- ]
67
- if meta_path and meta_path.exists():
68
- files_to_sync.append((meta_path, f"{profile_name}/{meta_path.name}"))
69
- if voice_md_path and voice_md_path.exists():
70
- files_to_sync.append((voice_md_path, f"{profile_name}/{voice_md_path.name}"))
71
-
72
- total_size = sum(f.stat().st_size for f, _ in files_to_sync if f.exists())
73
- if total_size > 1_000_000: # 1MB safety cap
74
- return {"synced": False, "error": f"payload too large: {total_size} bytes"}
75
-
76
- if dry_run:
77
- return {"synced": False, "dry_run": True, "would_sync": [k for _, k in files_to_sync], "size": total_size}
78
-
79
- sync_time = _now_iso()
80
- uploaded = []
81
- for local_path, remote_key in files_to_sync:
82
- if not local_path.exists():
83
- continue
84
- s3.upload_file(
85
- str(local_path),
86
- bucket,
87
- remote_key,
88
- ExtraArgs={"ContentType": "application/json" if local_path.suffix == ".json" else "text/markdown"},
89
- )
90
- uploaded.append(remote_key)
91
-
92
- return {"synced": True, "uploaded": uploaded, "size": total_size, "time": sync_time}
93
-
94
-
95
- def was_synced_recently(meta: dict[str, Any], max_hours: int = 24) -> bool:
96
- """check if a sync happened within max_hours."""
97
- last = meta.get("last_sync")
98
- if not last:
99
- return False
100
- try:
101
- last_dt = datetime.fromisoformat(last)
102
- hours_since = (datetime.now(timezone.utc) - last_dt.replace(tzinfo=timezone.utc)).total_seconds() / 3600
103
- return hours_since < max_hours
104
- except (ValueError, TypeError):
105
- return False
106
-
107
-
108
- def try_auto_sync(
109
- profile_path: str,
110
- meta_path: str | None = None,
111
- voice_md_path: str | None = None,
112
- ) -> bool:
113
- """called by profile-evolve after saving. syncs if env is configured and
114
- last sync was > 24h ago. returns True if sync happened."""
115
- p = Path(profile_path).expanduser()
116
- if not p.exists():
117
- return False
118
-
119
- m = Path(meta_path).expanduser() if meta_path else p.with_suffix(".meta.json")
120
- meta: dict[str, Any] = {}
121
- if m.exists():
122
- try:
123
- meta = json.loads(m.read_text(encoding="utf-8", errors="ignore"))
124
- except (json.JSONDecodeError, OSError):
125
- meta = {}
126
-
127
- if was_synced_recently(meta, max_hours=23):
128
- return False
129
-
130
- vm = Path(voice_md_path).expanduser() if voice_md_path else p.with_suffix(".voice.md")
131
- result = sync_profile(p, meta_path=m, voice_md_path=vm)
132
-
133
- if result.get("synced"):
134
- meta["last_sync"] = result.get("time", _now_iso())
135
- m.parent.mkdir(parents=True, exist_ok=True)
136
- m.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
137
- return True
138
- return False
139
-
140
-
141
- def main() -> int:
142
- import argparse
143
-
144
- parser = argparse.ArgumentParser(description="Sync voice profile to Cloudflare R2")
145
- parser.add_argument("--profile", required=True, help="voice profile JSON file")
146
- parser.add_argument("--meta", help="meta JSON file (default: profile path with .meta.json)")
147
- parser.add_argument("--voice-md", help="voice.md file (default: profile path with .voice.md)")
148
- parser.add_argument("--dry-run", action="store_true", help="print what would be synced without uploading")
149
- parser.add_argument("--force", action="store_true", help="sync even if recently synced")
150
- args = parser.parse_args()
151
-
152
- profile_path = Path(args.profile).expanduser()
153
- meta_path = Path(args.meta).expanduser() if args.meta else profile_path.with_suffix(".meta.json")
154
- voice_md_path = Path(args.voice_md).expanduser() if args.voice_md else profile_path.with_suffix(".voice.md")
155
-
156
- if not args.force:
157
- meta: dict[str, Any] = {}
158
- if meta_path.exists():
159
- try:
160
- meta = json.loads(meta_path.read_text(encoding="utf-8", errors="ignore"))
161
- except (json.JSONDecodeError, OSError):
162
- pass
163
- if was_synced_recently(meta):
164
- print("sync skipped — already synced within 24h (use --force to override)")
165
- return 0
166
-
167
- result = sync_profile(profile_path, meta_path=meta_path, voice_md_path=voice_md_path, dry_run=args.dry_run)
168
-
169
- if args.dry_run:
170
- print(json.dumps(result, indent=2))
171
- return 0
172
-
173
- if result.get("synced"):
174
- meta: dict[str, Any] = {}
175
- if meta_path.exists():
176
- try:
177
- meta = json.loads(meta_path.read_text(encoding="utf-8", errors="ignore"))
178
- except (json.JSONDecodeError, OSError):
179
- meta = {}
180
- meta["last_sync"] = result.get("time", _now_iso())
181
- meta_path.parent.mkdir(parents=True, exist_ok=True)
182
- meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
183
-
184
- print(f"synced {len(result.get('uploaded', []))} files ({result.get('size', 0)} bytes)")
185
- for f in result.get("uploaded", []):
186
- print(f" {f}")
187
- return 0
188
-
189
- print(f"sync failed: {result.get('error', 'unknown')}", file=sys.stderr)
190
- return 1
191
-
192
-
193
- if __name__ == "__main__":
194
- raise SystemExit(main())