gitcast 1.0.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 (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. web/landing.html +925 -0
publisher/twitter.py ADDED
@@ -0,0 +1,100 @@
1
+ import os
2
+ import tweepy
3
+ from config.settings import (
4
+ TWITTER_API_KEY,
5
+ TWITTER_API_SECRET,
6
+ TWITTER_ACCESS_TOKEN,
7
+ TWITTER_ACCESS_SECRET,
8
+ TWITTER_BEARER_TOKEN,
9
+ )
10
+ from publisher.clipboard import open_x_compose
11
+ from api.analytics import track
12
+
13
+
14
+ def is_configured() -> bool:
15
+ """Return True only if all 5 Twitter API keys are present and non-empty."""
16
+ return all([
17
+ TWITTER_API_KEY,
18
+ TWITTER_API_SECRET,
19
+ TWITTER_ACCESS_TOKEN,
20
+ TWITTER_ACCESS_SECRET,
21
+ TWITTER_BEARER_TOKEN,
22
+ ])
23
+
24
+
25
+ def upload_media(screenshot_path: str) -> str:
26
+ """Upload an image via tweepy v1.1 API and return the media_id string."""
27
+ try:
28
+ auth = tweepy.OAuth1UserHandler(
29
+ TWITTER_API_KEY,
30
+ TWITTER_API_SECRET,
31
+ TWITTER_ACCESS_TOKEN,
32
+ TWITTER_ACCESS_SECRET,
33
+ )
34
+ api = tweepy.API(auth)
35
+ media = api.media_upload(filename=screenshot_path)
36
+ media_id = str(media.media_id)
37
+ print(f"[Publisher] Media uploaded — media_id: {media_id}")
38
+ return media_id
39
+ except Exception as e:
40
+ print(f"[Publisher] Media upload failed: {e}")
41
+ raise
42
+
43
+
44
+ def publish_post(post_text: str, screenshot_path: str = None) -> dict:
45
+ """
46
+ Publish a post to X (Twitter) via API v2.
47
+ Falls back to clipboard if keys are missing or on any error.
48
+ """
49
+ if not is_configured():
50
+ print("[Publisher] Twitter API not configured — falling back to clipboard.")
51
+ result = open_x_compose(post_text)
52
+ result["fallback"] = True
53
+ track("post_published", {"platform": "twitter", "used_fallback": True})
54
+ return result
55
+
56
+ try:
57
+ client = tweepy.Client(
58
+ bearer_token=TWITTER_BEARER_TOKEN,
59
+ consumer_key=TWITTER_API_KEY,
60
+ consumer_secret=TWITTER_API_SECRET,
61
+ access_token=TWITTER_ACCESS_TOKEN,
62
+ access_token_secret=TWITTER_ACCESS_SECRET,
63
+ )
64
+
65
+ media_ids = None
66
+ if screenshot_path and os.path.exists(screenshot_path):
67
+ try:
68
+ media_id = upload_media(screenshot_path)
69
+ media_ids = [media_id]
70
+ except Exception as e:
71
+ print(f"[Publisher] Skipping media attachment: {e}")
72
+
73
+ response = client.create_tweet(text=post_text, media_ids=media_ids)
74
+ tweet_id = str(response.data["id"])
75
+ tweet_url = f"https://twitter.com/i/web/status/{tweet_id}"
76
+
77
+ print(f"[Publisher] Tweet posted — {tweet_url}")
78
+ track("post_published", {"platform": "twitter", "used_fallback": False})
79
+ return {
80
+ "success": True,
81
+ "tweet_url": tweet_url,
82
+ "tweet_id": tweet_id,
83
+ "fallback": False,
84
+ }
85
+
86
+ except Exception as e:
87
+ print(f"[Publisher] Twitter API error: {e}")
88
+ print("[Publisher] Falling back to clipboard.")
89
+ result = open_x_compose(post_text)
90
+ result["fallback"] = True
91
+ track("post_published", {"platform": "twitter", "used_fallback": True})
92
+ return result
93
+
94
+
95
+ if __name__ == "__main__":
96
+ print(f"[Publisher] Twitter configured: {is_configured()}")
97
+ if is_configured():
98
+ print("[Publisher] All 5 API keys are present.")
99
+ else:
100
+ print("[Publisher] Missing one or more Twitter API keys — clipboard fallback will be used.")
storage/__init__.py ADDED
File without changes
storage/cleanup.py ADDED
@@ -0,0 +1,60 @@
1
+ import os
2
+ import time
3
+ from pathlib import Path
4
+ from config.settings import STORAGE_DIR, screenshot_retention_hours
5
+
6
+ # [Cleanup] module for managing screenshot retention policy
7
+
8
+ def run_cleanup():
9
+ """Deletes screenshots older than screenshot_retention_hours."""
10
+ screenshots_dir = STORAGE_DIR / "screenshots"
11
+ if not screenshots_dir.exists():
12
+ print("[Cleanup] No screenshots directory found.")
13
+ return
14
+
15
+ now = time.time()
16
+ retention_sec = screenshot_retention_hours * 3600
17
+ deleted_count = 0
18
+
19
+ for file_path in screenshots_dir.glob("*.png"):
20
+ file_age = now - file_path.stat().st_mtime
21
+ if file_age > retention_sec:
22
+ try:
23
+ # Use secure delete if possible, but for cleanup standard unlink is usually fine
24
+ # unless the user specifically wants secure delete for all old files.
25
+ # Given core/security.py exists, we could use it, but it might be overkill for bulk cleanup.
26
+ file_path.unlink()
27
+ deleted_count += 1
28
+ except Exception as e:
29
+ print(f"[Cleanup] Error deleting {file_path.name}: {e}")
30
+
31
+ if deleted_count > 0:
32
+ print(f"[Cleanup] Deleted {deleted_count} old screenshots.")
33
+ else:
34
+ print("[Cleanup] No old screenshots to delete.")
35
+
36
+ def get_storage_stats():
37
+ """Returns count of screenshots, total size in MB, and oldest file date."""
38
+ screenshots_dir = STORAGE_DIR / "screenshots"
39
+ if not screenshots_dir.exists():
40
+ return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
41
+
42
+ files = list(screenshots_dir.glob("*.png"))
43
+ if not files:
44
+ return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
45
+
46
+ total_size = sum(f.stat().st_size for f in files)
47
+ oldest_time = min(f.stat().st_mtime for f in files)
48
+ oldest_date = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(oldest_time))
49
+
50
+ return {
51
+ "count": len(files),
52
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
53
+ "oldest_file": oldest_date
54
+ }
55
+
56
+ if __name__ == "__main__":
57
+ print("=== STORAGE CLEANUP TEST ===")
58
+ stats = get_storage_stats()
59
+ print(f"Current stats: {stats}")
60
+ run_cleanup()
storage/engagement.py ADDED
@@ -0,0 +1,114 @@
1
+ import json
2
+ import time
3
+ import threading
4
+ from pathlib import Path
5
+ import tweepy
6
+ from config.settings import ENGAGEMENT_LOG, TWITTER_BEARER_TOKEN, POST_LOG
7
+ from storage.tone_memory import update_engagement
8
+
9
+ # [Engagement] module for tracking X metrics 24h after publish
10
+
11
+ def log_pending_fetch(tweet_id: str, post_text: str):
12
+ """Saves a tweet ID to ENGAGEMENT_LOG for background processing."""
13
+ entry = {
14
+ "tweet_id": tweet_id,
15
+ "post_text": post_text,
16
+ "publish_timestamp": time.time(),
17
+ "fetched": False
18
+ }
19
+ try:
20
+ with open(ENGAGEMENT_LOG, "a", encoding="utf-8") as f:
21
+ f.write(json.dumps(entry) + "\n")
22
+ print(f"[Engagement] Logged pending fetch for tweet {tweet_id}")
23
+ except Exception as e:
24
+ print(f"[Engagement] Error logging pending fetch: {e}")
25
+
26
+ def get_pending_fetches():
27
+ """Returns list of entries due for metrics fetch (24h+ old, not fetched)."""
28
+ if not ENGAGEMENT_LOG.exists():
29
+ return []
30
+
31
+ pending = []
32
+ now = time.time()
33
+ try:
34
+ with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
35
+ for line in f:
36
+ if not line.strip(): continue
37
+ entry = json.loads(line)
38
+ # 24 hours = 86400 seconds
39
+ if not entry.get("fetched") and (now - entry["publish_timestamp"] > 86400):
40
+ pending.append(entry)
41
+ except Exception as e:
42
+ print(f"[Engagement] Error reading pending fetches: {e}")
43
+
44
+ return pending
45
+
46
+ def fetch_and_store_metrics(tweet_id: str, post_text: str):
47
+ """Calls X API v2 to get metrics and updates tone_memory."""
48
+ if not TWITTER_BEARER_TOKEN:
49
+ print("[Engagement] No Twitter Bearer Token found, skipping fetch.")
50
+ return False
51
+
52
+ try:
53
+ client = tweepy.Client(bearer_token=TWITTER_BEARER_TOKEN)
54
+ response = client.get_tweet(tweet_id, tweet_fields=["public_metrics"])
55
+
56
+ if response.data:
57
+ metrics = response.data.public_metrics
58
+ likes = metrics.get("like_count", 0)
59
+ retweets = metrics.get("retweet_count", 0)
60
+ replies = metrics.get("reply_count", 0)
61
+
62
+ update_engagement(post_text, likes, retweets, replies)
63
+ return True
64
+ else:
65
+ print(f"[Engagement] Could not find tweet {tweet_id}")
66
+ return False
67
+ except Exception as e:
68
+ print(f"[Engagement] X API error for {tweet_id}: {e}")
69
+ return False
70
+
71
+ def mark_as_fetched(tweet_id: str):
72
+ """Updates ENGAGEMENT_LOG to mark a tweet as fetched."""
73
+ if not ENGAGEMENT_LOG.exists():
74
+ return
75
+
76
+ entries = []
77
+ try:
78
+ with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
79
+ for line in f:
80
+ if not line.strip(): continue
81
+ entry = json.loads(line)
82
+ if entry["tweet_id"] == tweet_id:
83
+ entry["fetched"] = True
84
+ entries.append(entry)
85
+
86
+ with open(ENGAGEMENT_LOG, "w", encoding="utf-8") as f:
87
+ for e in entries:
88
+ f.write(json.dumps(e) + "\n")
89
+ except Exception as e:
90
+ print(f"[Engagement] Error marking as fetched: {e}")
91
+
92
+ def run_engagement_worker():
93
+ """Background thread that runs hourly and processes pending fetches."""
94
+ def worker():
95
+ while True:
96
+ print("[Engagement] Checking for pending metrics fetches...")
97
+ pending = get_pending_fetches()
98
+ for entry in pending:
99
+ success = fetch_and_store_metrics(entry["tweet_id"], entry["post_text"])
100
+ if success:
101
+ mark_as_fetched(entry["tweet_id"])
102
+
103
+ # Sleep for 1 hour
104
+ time.sleep(3600)
105
+
106
+ thread = threading.Thread(target=worker, daemon=True)
107
+ thread.start()
108
+ print("[Engagement] Background worker started.")
109
+
110
+ if __name__ == "__main__":
111
+ print("=== ENGAGEMENT TRACKING TEST ===")
112
+ log_pending_fetch("123456789", "Test post text")
113
+ print(f"Pending: {get_pending_fetches()}")
114
+ # Note: X API call will fail without real token in test
storage/insights.py ADDED
@@ -0,0 +1,203 @@
1
+ from collections import defaultdict
2
+ from datetime import datetime, timedelta
3
+
4
+ from storage.logger import get_streak, load_posts
5
+ from storage.metrics import get_all_metrics
6
+
7
+
8
+ def _parse_dt(value: str):
9
+ try:
10
+ return datetime.fromisoformat(value)
11
+ except Exception:
12
+ return None
13
+
14
+
15
+ def _parse_metric_post_dt(post_id: str):
16
+ value = str(post_id or "")
17
+ try:
18
+ if len(value) >= 15 and value[8] == "_":
19
+ return datetime.strptime(value[:15], "%Y%m%d_%H%M%S")
20
+ if len(value) >= 19 and value[4] == "-" and "T" in value[:19]:
21
+ return datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
22
+ except Exception:
23
+ return None
24
+ return None
25
+
26
+
27
+ def _avg(values: list) -> float:
28
+ return sum(values) / len(values) if values else 0.0
29
+
30
+
31
+ def _engagement_rate(metrics: dict) -> float:
32
+ impressions = max(0, int(metrics.get("impressions", 0)))
33
+ if impressions == 0:
34
+ return 0.0
35
+ engagement = int(metrics.get("likes", 0)) + int(metrics.get("comments", 0)) + int(metrics.get("reposts", 0))
36
+ return round((engagement / impressions) * 100, 2)
37
+
38
+
39
+ def _bucket_chars(count: int) -> str:
40
+ if count <= 140:
41
+ return "0-140"
42
+ if count <= 200:
43
+ return "141-200"
44
+ if count <= 260:
45
+ return "201-260"
46
+ return "261-280"
47
+
48
+
49
+ def _time_window(dt: datetime) -> str:
50
+ hour = dt.hour
51
+ if 6 <= hour < 9:
52
+ return "6am-9am"
53
+ if 9 <= hour < 12:
54
+ return "9am-12pm"
55
+ if 12 <= hour < 15:
56
+ return "12pm-3pm"
57
+ if 15 <= hour < 18:
58
+ return "3pm-6pm"
59
+ if 18 <= hour < 21:
60
+ return "6pm-9pm"
61
+ if 21 <= hour < 23:
62
+ return "9pm-11pm"
63
+ return "late-night"
64
+
65
+
66
+ def _format_from_metric_post_id(post_id: str) -> str:
67
+ value = str(post_id or "")
68
+ if "-" not in value:
69
+ return ""
70
+ suffix = value.rsplit("-", 1)[1]
71
+ return suffix if suffix and not suffix[:2].isdigit() else ""
72
+
73
+
74
+ def _row_from_metric(post_id: str, metrics: dict, post: dict = None) -> dict:
75
+ post = post or {}
76
+ dt = _parse_dt(post.get("timestamp", "")) or _parse_metric_post_dt(post_id) or _parse_dt(metrics.get("measured_at", ""))
77
+ impressions = int(metrics.get("impressions", 0))
78
+ text = post.get("post_text", "")
79
+ return {
80
+ "post_id": post_id,
81
+ "post_text": text,
82
+ "format_key": post.get("format_key", "") or _format_from_metric_post_id(post_id) or metrics.get("platform", ""),
83
+ "timestamp": post.get("timestamp", "") or (dt.isoformat() if dt else metrics.get("measured_at", "")),
84
+ "dt": dt,
85
+ "day": dt.strftime("%A").lower() if dt else "",
86
+ "time_window": _time_window(dt) if dt else "",
87
+ "char_bucket": _bucket_chars(len(text)),
88
+ "impressions": impressions,
89
+ "likes": int(metrics.get("likes", 0)),
90
+ "comments": int(metrics.get("comments", 0)),
91
+ "reposts": int(metrics.get("reposts", 0)),
92
+ "hashtags": metrics.get("hashtags", []),
93
+ "engagement_rate": _engagement_rate(metrics),
94
+ }
95
+
96
+
97
+ def _best_group(rows: list, key: str) -> dict:
98
+ groups = defaultdict(list)
99
+ for row in rows:
100
+ if row.get(key):
101
+ groups[row[key]].append(row["impressions"])
102
+ if not groups:
103
+ return {}
104
+ name, values = max(groups.items(), key=lambda item: _avg(item[1]))
105
+ return {"name": name, "avg": round(_avg(values), 1), "sample_size": len(values)}
106
+
107
+
108
+ def calculate_insights(user_id: str) -> dict:
109
+ posts = load_posts(user_id)
110
+ metrics_entries = get_all_metrics(user_id)
111
+ metrics_by_post = {}
112
+ for metric in metrics_entries:
113
+ metrics_by_post[metric.get("post_id")] = metric
114
+
115
+ rows = []
116
+ matched_metric_ids = set()
117
+ for post in posts:
118
+ post_id = post.get("id") or post.get("timestamp")
119
+ metrics = metrics_by_post.get(post_id) or post.get("metrics")
120
+ if not metrics:
121
+ continue
122
+ matched_metric_ids.add(post_id)
123
+ rows.append(_row_from_metric(post_id, metrics, post))
124
+
125
+ for post_id, metrics in metrics_by_post.items():
126
+ if post_id in matched_metric_ids:
127
+ continue
128
+ rows.append(_row_from_metric(post_id, metrics))
129
+
130
+ if len(rows) < 5:
131
+ return {"insufficient_data": True, "posts_needed": 5 - len(rows), "posts_with_metrics": len(rows)}
132
+
133
+ cutoff = datetime.now() - timedelta(days=30)
134
+ recent = [row for row in rows if row["dt"] and row["dt"] >= cutoff] or rows
135
+ streak = get_streak(user_id)
136
+ total_impressions = sum(row["impressions"] for row in recent)
137
+ best_format = _best_group(rows, "format_key")
138
+ best_day = _best_group(rows, "day")
139
+ best_time = _best_group(rows, "time_window")
140
+ top = max(rows, key=lambda row: row["impressions"])
141
+ overall_avg = _avg([row["impressions"] for row in rows])
142
+
143
+ patterns = []
144
+ by_format = defaultdict(list)
145
+ by_chars = defaultdict(list)
146
+ by_day = defaultdict(list)
147
+ tags = defaultdict(list)
148
+ for row in rows:
149
+ by_format[row["format_key"]].append(row["impressions"])
150
+ by_chars[row["char_bucket"]].append(row["impressions"])
151
+ by_day[row["day"]].append(row["impressions"])
152
+ for tag in row["hashtags"]:
153
+ tags[tag].append(row["impressions"])
154
+
155
+ if len(by_format) >= 2:
156
+ ordered = sorted(by_format.items(), key=lambda item: _avg(item[1]), reverse=True)
157
+ high, low = ordered[0], ordered[-1]
158
+ if _avg(low[1]) > 0:
159
+ patterns.append({
160
+ "pattern": "format",
161
+ "description": f"{high[0].upper()} format gets {round(_avg(high[1]) / _avg(low[1]), 1)}x more impressions than {low[0].upper()}",
162
+ "impact": "high",
163
+ })
164
+ if by_chars:
165
+ bucket, values = max(by_chars.items(), key=lambda item: _avg(item[1]))
166
+ lift = round(((_avg(values) - overall_avg) / overall_avg) * 100, 0) if overall_avg else 0
167
+ patterns.append({"pattern": "length", "description": f"posts in the {bucket} char bucket get {lift}% more engagement", "impact": "medium"})
168
+ if by_day:
169
+ day, values = max(by_day.items(), key=lambda item: _avg(item[1]))
170
+ multiple = round(_avg(values) / overall_avg, 1) if overall_avg else 0
171
+ patterns.append({"pattern": "day", "description": f"{day} posts get {multiple}x your average", "impact": "medium"})
172
+
173
+ hashtag_performance = [
174
+ {"hashtag": tag, "avg_impressions": round(_avg(values), 1), "uses": len(values)}
175
+ for tag, values in sorted(tags.items(), key=lambda item: _avg(item[1]), reverse=True)
176
+ ]
177
+ for tag in hashtag_performance[:3]:
178
+ patterns.append({
179
+ "pattern": "hashtag",
180
+ "description": f"{tag['hashtag']} adds avg {int(tag['avg_impressions'])} impressions",
181
+ "impact": "medium",
182
+ })
183
+
184
+ return {
185
+ "overview": {
186
+ "total_posts": len(recent),
187
+ "total_verified": len([post for post in posts if post.get("posted_verified")]),
188
+ "total_impressions": total_impressions,
189
+ "avg_engagement_rate": round(_avg([row["engagement_rate"] for row in recent]), 2),
190
+ "posting_streak": streak.get("current_streak", 0),
191
+ "best_streak": streak.get("best_streak", streak.get("current_streak", 0)),
192
+ },
193
+ "best_format": {"format_key": best_format.get("name", ""), "avg_impressions": best_format.get("avg", 0.0), "sample_size": best_format.get("sample_size", 0)},
194
+ "best_day": {"day": best_day.get("name", ""), "avg_impressions": best_day.get("avg", 0.0)},
195
+ "best_time": {"window": best_time.get("name", ""), "avg_impressions": best_time.get("avg", 0.0)},
196
+ "top_post": {key: top[key] for key in ["post_text", "impressions", "likes", "format_key", "timestamp"]},
197
+ "viral_patterns": patterns[:8],
198
+ "hashtag_performance": hashtag_performance[:20],
199
+ }
200
+
201
+
202
+ if __name__ == "__main__":
203
+ print("[Insights] Supabase insights module loaded")
storage/key_manager.py ADDED
@@ -0,0 +1,45 @@
1
+ from cryptography.fernet import Fernet, InvalidToken
2
+
3
+ from config.settings import ENCRYPTION_KEY_PATH
4
+
5
+
6
+ def _load_or_create_fernet_key() -> bytes:
7
+ ENCRYPTION_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
8
+ if ENCRYPTION_KEY_PATH.exists():
9
+ key = ENCRYPTION_KEY_PATH.read_bytes().strip()
10
+ if key:
11
+ return key
12
+
13
+ key = Fernet.generate_key()
14
+ ENCRYPTION_KEY_PATH.write_bytes(key)
15
+ return key
16
+
17
+
18
+ def _fernet() -> Fernet:
19
+ return Fernet(_load_or_create_fernet_key())
20
+
21
+
22
+ def encrypt_key(raw_key: str) -> str:
23
+ if not raw_key:
24
+ raise ValueError("raw_key is required")
25
+ return _fernet().encrypt(raw_key.encode("utf-8")).decode("utf-8")
26
+
27
+
28
+ def decrypt_key(encrypted_key: str) -> str:
29
+ if not encrypted_key:
30
+ raise ValueError("encrypted_key is required")
31
+ try:
32
+ return _fernet().decrypt(encrypted_key.encode("utf-8")).decode("utf-8")
33
+ except InvalidToken as exc:
34
+ raise ValueError("Invalid encrypted key") from exc
35
+
36
+
37
+ def mask_key(raw_key: str) -> str:
38
+ suffix = (raw_key or "")[-3:]
39
+ return f"••••••••...{suffix}"
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sample = "test_api_key_abc"
44
+ encrypted = encrypt_key(sample)
45
+ print(f"[KeyManager] Mask: {mask_key(decrypt_key(encrypted))}")