gitcast 1.0.4__tar.gz → 1.0.6__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 (86) hide show
  1. {gitcast-1.0.4/gitcast.egg-info → gitcast-1.0.6}/PKG-INFO +1 -1
  2. gitcast-1.0.6/api/ratelimit.py +18 -0
  3. {gitcast-1.0.4 → gitcast-1.0.6}/api/routes.py +24 -42
  4. {gitcast-1.0.4 → gitcast-1.0.6}/cli/gitcast.py +2 -2
  5. {gitcast-1.0.4 → gitcast-1.0.6}/config/settings.py +59 -4
  6. gitcast-1.0.6/docs/pypi-errors.md +68 -0
  7. {gitcast-1.0.4 → gitcast-1.0.6/gitcast.egg-info}/PKG-INFO +1 -1
  8. {gitcast-1.0.4 → gitcast-1.0.6}/gitcast.egg-info/SOURCES.txt +1 -0
  9. {gitcast-1.0.4 → gitcast-1.0.6}/gitcast.egg-info/dependency_links.txt +0 -0
  10. {gitcast-1.0.4 → gitcast-1.0.6}/gitcast.egg-info/entry_points.txt +0 -0
  11. {gitcast-1.0.4 → gitcast-1.0.6}/gitcast.egg-info/requires.txt +0 -0
  12. {gitcast-1.0.4 → gitcast-1.0.6}/gitcast.egg-info/top_level.txt +0 -0
  13. {gitcast-1.0.4 → gitcast-1.0.6}/setup.cfg +0 -0
  14. {gitcast-1.0.4 → gitcast-1.0.6}/setup.py +33 -1
  15. {gitcast-1.0.4 → gitcast-1.0.6}/storage/supabase_client.py +14 -4
  16. gitcast-1.0.4/api/ratelimit.py +0 -9
  17. {gitcast-1.0.4 → gitcast-1.0.6}/.env.example +0 -0
  18. {gitcast-1.0.4 → gitcast-1.0.6}/LICENSE +0 -0
  19. {gitcast-1.0.4 → gitcast-1.0.6}/MANIFEST.in +0 -0
  20. {gitcast-1.0.4 → gitcast-1.0.6}/README.md +0 -0
  21. {gitcast-1.0.4 → gitcast-1.0.6}/ai/__init__.py +0 -0
  22. {gitcast-1.0.4 → gitcast-1.0.6}/ai/formatter.py +0 -0
  23. {gitcast-1.0.4 → gitcast-1.0.6}/ai/generator.py +0 -0
  24. {gitcast-1.0.4 → gitcast-1.0.6}/ai/prompts.py +0 -0
  25. {gitcast-1.0.4 → gitcast-1.0.6}/ai/viral_patterns.py +0 -0
  26. {gitcast-1.0.4 → gitcast-1.0.6}/api/__init__.py +0 -0
  27. {gitcast-1.0.4 → gitcast-1.0.6}/api/analytics.py +0 -0
  28. {gitcast-1.0.4 → gitcast-1.0.6}/api/auth.py +0 -0
  29. {gitcast-1.0.4 → gitcast-1.0.6}/api/auth_middleware.py +0 -0
  30. {gitcast-1.0.4 → gitcast-1.0.6}/api/auth_routes.py +0 -0
  31. {gitcast-1.0.4 → gitcast-1.0.6}/api/monitoring.py +0 -0
  32. {gitcast-1.0.4 → gitcast-1.0.6}/api/payload.py +0 -0
  33. {gitcast-1.0.4 → gitcast-1.0.6}/api/server.py +0 -0
  34. {gitcast-1.0.4 → gitcast-1.0.6}/api/validators.py +0 -0
  35. {gitcast-1.0.4 → gitcast-1.0.6}/assets/__init__.py +0 -0
  36. {gitcast-1.0.4 → gitcast-1.0.6}/assets/favicon-16x16.png +0 -0
  37. {gitcast-1.0.4 → gitcast-1.0.6}/assets/favicon-32x32.png +0 -0
  38. {gitcast-1.0.4 → gitcast-1.0.6}/assets/favicon-64x64.png +0 -0
  39. {gitcast-1.0.4 → gitcast-1.0.6}/assets/favicon.ico +0 -0
  40. {gitcast-1.0.4 → gitcast-1.0.6}/assets/icon.png +0 -0
  41. {gitcast-1.0.4 → gitcast-1.0.6}/cli/.env.example +0 -0
  42. {gitcast-1.0.4 → gitcast-1.0.6}/cli/__init__.py +0 -0
  43. {gitcast-1.0.4 → gitcast-1.0.6}/config/__init__.py +0 -0
  44. {gitcast-1.0.4 → gitcast-1.0.6}/core/__init__.py +0 -0
  45. {gitcast-1.0.4 → gitcast-1.0.6}/core/capture.py +0 -0
  46. {gitcast-1.0.4 → gitcast-1.0.6}/core/codebase_reader.py +0 -0
  47. {gitcast-1.0.4 → gitcast-1.0.6}/core/framing.py +0 -0
  48. {gitcast-1.0.4 → gitcast-1.0.6}/core/hotkey.py +0 -0
  49. {gitcast-1.0.4 → gitcast-1.0.6}/core/log_stream.py +0 -0
  50. {gitcast-1.0.4 → gitcast-1.0.6}/core/ocr.py +0 -0
  51. {gitcast-1.0.4 → gitcast-1.0.6}/core/screenshot_session.py +0 -0
  52. {gitcast-1.0.4 → gitcast-1.0.6}/core/security.py +0 -0
  53. {gitcast-1.0.4 → gitcast-1.0.6}/core/tray.py +0 -0
  54. {gitcast-1.0.4 → gitcast-1.0.6}/docs/api-keys.md +0 -0
  55. {gitcast-1.0.4 → gitcast-1.0.6}/docs/article-generator.md +0 -0
  56. {gitcast-1.0.4 → gitcast-1.0.6}/docs/capture-flow.md +0 -0
  57. {gitcast-1.0.4 → gitcast-1.0.6}/docs/changelog.md +0 -0
  58. {gitcast-1.0.4 → gitcast-1.0.6}/docs/contributing.md +0 -0
  59. {gitcast-1.0.4 → gitcast-1.0.6}/docs/dashboard.md +0 -0
  60. {gitcast-1.0.4 → gitcast-1.0.6}/docs/insights.md +0 -0
  61. {gitcast-1.0.4 → gitcast-1.0.6}/docs/introduction.md +0 -0
  62. {gitcast-1.0.4 → gitcast-1.0.6}/docs/post-formats.md +0 -0
  63. {gitcast-1.0.4 → gitcast-1.0.6}/docs/providers.md +0 -0
  64. {gitcast-1.0.4 → gitcast-1.0.6}/docs/quickstart.md +0 -0
  65. {gitcast-1.0.4 → gitcast-1.0.6}/docs/sprint-mode.md +0 -0
  66. {gitcast-1.0.4 → gitcast-1.0.6}/publisher/__init__.py +0 -0
  67. {gitcast-1.0.4 → gitcast-1.0.6}/publisher/clipboard.py +0 -0
  68. {gitcast-1.0.4 → gitcast-1.0.6}/publisher/twitter.py +0 -0
  69. {gitcast-1.0.4 → gitcast-1.0.6}/pyproject.toml +0 -0
  70. {gitcast-1.0.4 → gitcast-1.0.6}/requirements.txt +0 -0
  71. {gitcast-1.0.4 → gitcast-1.0.6}/storage/__init__.py +0 -0
  72. {gitcast-1.0.4 → gitcast-1.0.6}/storage/cleanup.py +0 -0
  73. {gitcast-1.0.4 → gitcast-1.0.6}/storage/engagement.py +0 -0
  74. {gitcast-1.0.4 → gitcast-1.0.6}/storage/insights.py +0 -0
  75. {gitcast-1.0.4 → gitcast-1.0.6}/storage/key_manager.py +0 -0
  76. {gitcast-1.0.4 → gitcast-1.0.6}/storage/logger.py +0 -0
  77. {gitcast-1.0.4 → gitcast-1.0.6}/storage/metrics.py +0 -0
  78. {gitcast-1.0.4 → gitcast-1.0.6}/storage/sprint.py +0 -0
  79. {gitcast-1.0.4 → gitcast-1.0.6}/storage/streak.py +0 -0
  80. {gitcast-1.0.4 → gitcast-1.0.6}/storage/tone_memory.py +0 -0
  81. {gitcast-1.0.4 → gitcast-1.0.6}/tests/test_keys.py +0 -0
  82. {gitcast-1.0.4 → gitcast-1.0.6}/tests/test_ocr_fix.py +0 -0
  83. {gitcast-1.0.4 → gitcast-1.0.6}/ui/__init__.py +0 -0
  84. {gitcast-1.0.4 → gitcast-1.0.6}/web/__init__.py +0 -0
  85. {gitcast-1.0.4 → gitcast-1.0.6}/web/index.html +0 -0
  86. {gitcast-1.0.4 → gitcast-1.0.6}/web/landing.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -0,0 +1,18 @@
1
+ from slowapi import Limiter
2
+ from slowapi.util import get_remote_address
3
+ from fastapi import Request
4
+
5
+
6
+ def get_client_ip(request: Request) -> str:
7
+ client_host = request.client.host if request.client else None
8
+ if client_host in ["127.0.0.1", "localhost", "::1"]:
9
+ return None
10
+ return get_remote_address(request)
11
+
12
+
13
+ limiter = Limiter(key_func=get_client_ip, default_limits=["60/minute"], headers_enabled=False)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ print("[RateLimit] Default limit: 60/minute (bypassed for localhost)")
18
+
@@ -445,7 +445,8 @@ KEY_GUIDE = {
445
445
 
446
446
 
447
447
  def _env_path():
448
- path = BASE_DIR / ".env"
448
+ from config.settings import get_active_env_path
449
+ path = get_active_env_path(for_write=True)
449
450
  path.touch(exist_ok=True)
450
451
  return path
451
452
 
@@ -540,8 +541,7 @@ def _update_settings_for_user(user_id: str, values: dict) -> dict:
540
541
 
541
542
 
542
543
  @router.get("/keys/status")
543
- def get_ai_keys_status():
544
- user_id = LOCAL_USER_ID
544
+ def get_ai_keys_status(user_id: str = Depends(get_current_user)):
545
545
  if _is_local_user(user_id):
546
546
  env_values = dotenv_values(_env_path())
547
547
  return {
@@ -667,9 +667,8 @@ def add_to_waitlist(request: WaitlistRequest):
667
667
 
668
668
  @router.patch("/settings")
669
669
  @router.post("/settings")
670
- def update_settings(update: SettingsUpdate):
670
+ def update_settings(update: SettingsUpdate, user_id: str = Depends(get_current_user)):
671
671
  """Updates user project settings."""
672
- user_id = LOCAL_USER_ID
673
672
  values = update.dict(exclude_unset=True)
674
673
  if "project_narrative" in values and values["project_narrative"] is not None:
675
674
  values["project_narrative"] = sanitize_text(values["project_narrative"])
@@ -679,9 +678,8 @@ def update_settings(update: SettingsUpdate):
679
678
 
680
679
  @router.post("/publish")
681
680
  @limiter.limit("10/minute")
682
- async def publish(request: Request, body: PublishRequest):
681
+ async def publish(request: Request, body: PublishRequest, user_id: str = Depends(get_current_user)):
683
682
  """Publishes a post to X (Twitter)."""
684
- user_id = LOCAL_USER_ID
685
683
  from publisher.twitter import publish_post
686
684
  try:
687
685
  post_text = sanitize_text(body.post_text)
@@ -782,8 +780,7 @@ async def get_framed_screenshot(filename: str):
782
780
 
783
781
  @router.post("/generate", response_model=GenerateResponse)
784
782
  @limiter.limit("10/minute")
785
- async def generate(request: Request, body: GenerateRequest):
786
- user_id = LOCAL_USER_ID
783
+ async def generate(request: Request, body: GenerateRequest, user_id: str = Depends(get_current_user)):
787
784
  payload = body.dict()
788
785
  for key, value in list(payload.items()):
789
786
  if isinstance(value, str):
@@ -865,9 +862,8 @@ def get_current_draft():
865
862
 
866
863
  @router.post("/chat")
867
864
  @limiter.limit("20/minute")
868
- async def chat_refine(request: Request, body: ChatRequest):
865
+ async def chat_refine(request: Request, body: ChatRequest, user_id: str = Depends(get_current_user)):
869
866
  """Refines a post variation using AI chat."""
870
- user_id = LOCAL_USER_ID
871
867
  if not CURRENT_DRAFT.exists():
872
868
  raise HTTPException(status_code=400, detail="No active draft to refine.")
873
869
 
@@ -925,9 +921,8 @@ async def chat_refine(request: Request, body: ChatRequest):
925
921
 
926
922
 
927
923
  @router.post("/cli/trigger")
928
- async def cli_trigger(request: CliTriggerRequest):
924
+ async def cli_trigger(request: CliTriggerRequest, user_id: str = Depends(get_current_user)):
929
925
  """Triggers a capture workflow with a specific thought from the CLI."""
930
- user_id = LOCAL_USER_ID
931
926
  from core.capture import run_capture
932
927
  from core.ocr import run_ocr
933
928
  from api.payload import build_payload
@@ -963,9 +958,8 @@ async def cli_trigger(request: CliTriggerRequest):
963
958
 
964
959
 
965
960
  @router.post("/capture/trigger")
966
- async def ui_trigger_capture(request: CaptureTriggerRequest):
961
+ async def ui_trigger_capture(request: CaptureTriggerRequest, user_id: str = Depends(get_current_user)):
967
962
  """Triggers a capture workflow from the UI."""
968
- user_id = LOCAL_USER_ID
969
963
  from core.capture import run_capture
970
964
  from core.ocr import run_ocr
971
965
  from api.payload import build_payload
@@ -1044,9 +1038,8 @@ def list_screenshots():
1044
1038
 
1045
1039
 
1046
1040
  @router.post("/screenshot/recommend")
1047
- async def recommend_screenshot(request: RecommendRequest):
1041
+ async def recommend_screenshot(request: RecommendRequest, user_id: str = Depends(get_current_user)):
1048
1042
  """Uses AI to recommend where to post a specific screenshot."""
1049
- user_id = LOCAL_USER_ID
1050
1043
  from config.settings import STORAGE_DIR
1051
1044
  path = STORAGE_DIR / "screenshots" / sanitize_text(request.filename)
1052
1045
  if not path.exists():
@@ -1136,16 +1129,14 @@ def get_formats():
1136
1129
 
1137
1130
 
1138
1131
  @router.get("/settings/plan")
1139
- def get_plan():
1140
- user_id = LOCAL_USER_ID
1132
+ def get_plan(user_id: str = Depends(get_current_user)):
1141
1133
  settings = _settings_for_user(user_id)
1142
1134
  preferred = settings.get("preferred_providers") or {}
1143
1135
  return {"plan": preferred.get("twitter_plan", get_twitter_plan())}
1144
1136
 
1145
1137
 
1146
1138
  @router.put("/settings/plan")
1147
- def update_plan(update: PlanUpdate):
1148
- user_id = LOCAL_USER_ID
1139
+ def update_plan(update: PlanUpdate, user_id: str = Depends(get_current_user)):
1149
1140
  plan = sanitize_text(update.plan).lower()
1150
1141
  settings = _settings_for_user(user_id)
1151
1142
  preferred = settings.get("preferred_providers") or {}
@@ -1155,14 +1146,12 @@ def update_plan(update: PlanUpdate):
1155
1146
 
1156
1147
 
1157
1148
  @router.get("/settings")
1158
- def get_all_settings():
1159
- user_id = LOCAL_USER_ID
1149
+ def get_all_settings(user_id: str = Depends(get_current_user)):
1160
1150
  return _settings_for_user(user_id)
1161
1151
 
1162
1152
 
1163
1153
  @router.post("/settings/sprint/toggle")
1164
- def sprint_toggle():
1165
- user_id = LOCAL_USER_ID
1154
+ def sprint_toggle(user_id: str = Depends(get_current_user)):
1166
1155
  settings = _settings_for_user(user_id)
1167
1156
  new_state = not bool(settings.get("sprint_mode"))
1168
1157
  _update_settings_for_user(user_id, {"sprint_mode": new_state})
@@ -1170,15 +1159,13 @@ def sprint_toggle():
1170
1159
 
1171
1160
 
1172
1161
  @router.get("/settings/keys")
1173
- def get_keys_status():
1174
- user_id = LOCAL_USER_ID
1175
- return get_ai_keys_status()
1162
+ def get_keys_status(user_id: str = Depends(get_current_user)):
1163
+ return get_ai_keys_status(user_id=user_id)
1176
1164
 
1177
1165
 
1178
1166
  @router.get("/diagnose")
1179
- async def diagnose_connectivity():
1167
+ async def diagnose_connectivity(user_id: str = Depends(get_current_user)):
1180
1168
  """Diagnoses network and authentication status of all configured AI providers."""
1181
- user_id = LOCAL_USER_ID
1182
1169
  import ai.generator
1183
1170
  import httpx
1184
1171
 
@@ -1247,9 +1234,8 @@ async def diagnose_connectivity():
1247
1234
 
1248
1235
  @router.post("/article/generate")
1249
1236
  @limiter.limit("5/minute")
1250
- async def generate_article(request: Request, body: ArticleGenerateRequest):
1237
+ async def generate_article(request: Request, body: ArticleGenerateRequest, user_id: str = Depends(get_current_user)):
1251
1238
  """Generates a full technical article from sprint context."""
1252
- user_id = LOCAL_USER_ID
1253
1239
  if not CURRENT_DRAFT.exists():
1254
1240
  raise HTTPException(status_code=400, detail="No active draft to generate article from.")
1255
1241
 
@@ -1285,9 +1271,8 @@ async def generate_article(request: Request, body: ArticleGenerateRequest):
1285
1271
 
1286
1272
 
1287
1273
  @router.post("/article/refine")
1288
- async def refine_article(request: ArticleRefineRequest):
1274
+ async def refine_article(request: ArticleRefineRequest, user_id: str = Depends(get_current_user)):
1289
1275
  """Refines an article draft based on user instructions."""
1290
- user_id = LOCAL_USER_ID
1291
1276
  current_article = sanitize_text(request.current_article)
1292
1277
  instruction = sanitize_text(request.instruction)
1293
1278
  injection = check_prompt_injection(instruction)
@@ -1315,8 +1300,7 @@ def thread_split(request: ThreadSplitRequest):
1315
1300
 
1316
1301
 
1317
1302
  @router.post("/posts/verify")
1318
- async def verify_logged_post(request: PostVerifyRequest):
1319
- user_id = LOCAL_USER_ID
1303
+ async def verify_logged_post(request: PostVerifyRequest, user_id: str = Depends(get_current_user)):
1320
1304
  post_id = sanitize_text(request.post_id)
1321
1305
  post_url = sanitize_text(request.post_url or "")
1322
1306
  result = await safe_update_post(post_id, {
@@ -1332,8 +1316,7 @@ async def verify_logged_post(request: PostVerifyRequest):
1332
1316
 
1333
1317
 
1334
1318
  @router.post("/posts/decline")
1335
- async def decline_logged_post(request: PostDeclineRequest):
1336
- user_id = LOCAL_USER_ID
1319
+ async def decline_logged_post(request: PostDeclineRequest, user_id: str = Depends(get_current_user)):
1337
1320
  result = await safe_update_post(sanitize_text(request.post_id), {"declined": True})
1338
1321
  if not result:
1339
1322
  raise HTTPException(status_code=404, detail="post not found")
@@ -1346,8 +1329,7 @@ async def unverified_posts():
1346
1329
 
1347
1330
 
1348
1331
  @router.post("/metrics/save")
1349
- async def save_post_metrics(request: MetricsSaveRequest):
1350
- user_id = LOCAL_USER_ID
1332
+ async def save_post_metrics(request: MetricsSaveRequest, user_id: str = Depends(get_current_user)):
1351
1333
  values = {
1352
1334
  "impressions": request.impressions,
1353
1335
  "likes": request.likes,
@@ -1422,9 +1404,9 @@ async def get_post_metrics(post_id: str):
1422
1404
 
1423
1405
 
1424
1406
  @router.get("/insights")
1425
- async def get_insights():
1407
+ async def get_insights(user_id: str = Depends(get_current_user)):
1426
1408
  now = time.time()
1427
- cache_key = f"data:{LOCAL_USER_ID}"
1409
+ cache_key = f"data:{user_id}"
1428
1410
  if _INSIGHTS_CACHE.get(cache_key) is not None and now - _INSIGHTS_CACHE["timestamp"] < 3600:
1429
1411
  return _INSIGHTS_CACHE[cache_key]
1430
1412
 
@@ -7,8 +7,8 @@ import webbrowser
7
7
 
8
8
  def main():
9
9
  if "--setup" in sys.argv:
10
- env_path = os.path.join(
11
- os.path.dirname(__file__), '..', '.env')
10
+ from config.settings import get_active_env_path
11
+ env_path = str(get_active_env_path(for_write=True))
12
12
  example_path = os.path.join(
13
13
  os.path.dirname(__file__), '..', '.env.example')
14
14
  if not os.path.exists(example_path):
@@ -5,11 +5,55 @@ from pathlib import Path
5
5
  from dotenv import load_dotenv
6
6
 
7
7
 
8
+ import sys
9
+
8
10
  BASE_DIR = Path(__file__).resolve().parent.parent
9
11
  CONFIG_DIR = BASE_DIR / "config"
10
- load_dotenv(BASE_DIR / ".env")
11
- if not os.getenv("SUPABASE_URL"):
12
- load_dotenv(Path(os.getcwd()) / ".env")
12
+
13
+ def get_env_paths() -> list:
14
+ paths = []
15
+ # 1. Custom env path
16
+ custom_path = os.getenv("GITCAST_ENV_PATH")
17
+ if custom_path:
18
+ paths.append(Path(custom_path))
19
+ # 2. Current working directory
20
+ paths.append(Path(os.getcwd()) / ".env")
21
+ # 3. Virtual environment parent
22
+ if hasattr(sys, "prefix") and sys.prefix:
23
+ paths.append(Path(sys.prefix).parent / ".env")
24
+ # 4. User home directories
25
+ paths.append(Path.home() / ".gitcast" / ".env")
26
+ paths.append(Path.home() / ".gitcast.env")
27
+ # 5. Base directory
28
+ paths.append(BASE_DIR / ".env")
29
+ return [p for p in paths if p]
30
+
31
+ def load_all_dotenvs(override: bool = False) -> None:
32
+ paths = get_env_paths()
33
+ if override:
34
+ for p in reversed(paths):
35
+ if p.exists() and p.is_file():
36
+ load_dotenv(p, override=True)
37
+ else:
38
+ for p in paths:
39
+ if p.exists() and p.is_file():
40
+ load_dotenv(p, override=False)
41
+
42
+ def get_active_env_path(for_write: bool = False) -> Path:
43
+ paths = get_env_paths()
44
+ for p in paths:
45
+ if p.exists() and p.is_file():
46
+ return p
47
+ if for_write:
48
+ home_env = Path.home() / ".gitcast" / ".env"
49
+ try:
50
+ home_env.parent.mkdir(parents=True, exist_ok=True)
51
+ return home_env
52
+ except Exception:
53
+ pass
54
+ return BASE_DIR / ".env"
55
+
56
+ load_all_dotenvs()
13
57
  STORAGE_DIR = BASE_DIR / "storage" / "data"
14
58
  POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY", "")
15
59
  SENTRY_DSN = os.getenv("SENTRY_DSN", "")
@@ -65,8 +109,13 @@ def reload_api_keys() -> None:
65
109
  global GEMINI_MODEL
66
110
  global CEREBRAS_MODEL
67
111
  global OPENROUTER_MODEL
112
+ global SUPABASE_URL
113
+ global SUPABASE_SERVICE_KEY
114
+ global SUPABASE_ANON_KEY
115
+ global SUPABASE_JWT_SECRET
116
+ global SUPABASE_JWT_AUDIENCE
68
117
 
69
- load_dotenv(BASE_DIR / ".env", override=True)
118
+ load_all_dotenvs(override=True)
70
119
  GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
71
120
  MOONSHOT_API_KEY = os.getenv("MOONSHOT_API_KEY", "")
72
121
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
@@ -77,6 +126,12 @@ def reload_api_keys() -> None:
77
126
  GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
78
127
  CEREBRAS_MODEL = os.getenv("CEREBRAS_MODEL", "gpt-oss-120b")
79
128
  OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "qwen/qwen3-coder:free")
129
+
130
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
131
+ SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
132
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
133
+ SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")
134
+ SUPABASE_JWT_AUDIENCE = os.getenv("SUPABASE_JWT_AUDIENCE", "authenticated")
80
135
 
81
136
 
82
137
  reload_api_keys()
@@ -0,0 +1,68 @@
1
+ # PyPI Release & Execution Issues Log
2
+
3
+ This document records the issues faced during the testing, packaging, and execution of `gitcast` on PyPI, along with their resolutions and current active status.
4
+
5
+ ---
6
+
7
+ ## 1. Resolved Issues
8
+
9
+ ### Fixed in `gitcast==1.0.6`
10
+
11
+ #### Issue 1.4: FastAPI Server Rate Limiting on Localhost
12
+ * **Symptom**: Browser console logs API call failures when calling `api/article/generate` or other endpoints via the dashboard UI with a 429 Too Many Requests status code.
13
+ * **Root Cause**: The FastAPI server used slowapi's default `get_remote_address` rate-limiting key, which rate-limited local requests when they exceeded the threshold (e.g. 5/minute for article generation).
14
+ * **Resolution**: Replaced the default `get_remote_address` with a custom `get_client_ip` function in `api/ratelimit.py`. This function returns `None` for any localhost client (`127.0.0.1`, `localhost`, `::1`), which tells slowapi to bypass rate limiting completely for local executions.
15
+
16
+ #### Issue 1.5: BYOK (Bring Your Own Key) Feature Ignored Authenticated Users
17
+ * **Symptom**: When a user logged in via GitHub/Supabase and configured their own provider keys (BYOK), the server would still hit rate limits or token exhaustion on the developer's default keys.
18
+ * **Root Cause**: In `api/routes.py`, 19 routes hardcoded `user_id = LOCAL_USER_ID`. This caused the server to completely ignore the user ID of logged-in users, preventing their custom BYOK keys (stored in the Supabase DB) from being resolved and loaded.
19
+ * **Resolution**: Updated all 19 endpoints in `api/routes.py` to accept and use the dynamically resolved user ID using FastAPI's dependency injection (`user_id: str = Depends(get_current_user)`). This allows logged-in users to load their custom keys dynamically.
20
+
21
+ ### Fixed in `gitcast==1.0.5`
22
+
23
+ #### Issue 1.1: Supabase Configuration Load Failure
24
+ * **Symptom**: Running `gitcast` from an external directory (e.g. `C:\Users\USER\Documents\snakegame`) outputs:
25
+ ```text
26
+ [STORAGE] Supabase get_posts failed: SUPABASE_URL and SUPABASE_SERVICE_KEY must be configured. Falling back to local JSON.
27
+ ```
28
+ * **Root Cause**: The dynamic resolution of `BASE_DIR = Path(__file__).resolve().parent.parent` in `settings.py` evaluated to Python's virtualenv `site-packages/gitcast/` directory rather than the active workspace directory. As a result, the `.env` file containing the Supabase keys was not found.
29
+ * **Resolution**: Replaced the direct loading of `BASE_DIR / ".env"` with a multi-path environment file loader. It now searches the following locations in order of priority:
30
+ 1. A custom path defined in `GITCAST_ENV_PATH`
31
+ 2. The current working directory (`os.getcwd() / ".env"`)
32
+ 3. The parent directory of the active virtual environment (`sys.prefix / ".." / ".env"`), resolving to `context-engine/.env`
33
+ 4. Standard user directories (`~/.gitcast/.env` and `~/.gitcast.env`)
34
+ 5. The fallback package directory (`BASE_DIR / ".env"`)
35
+
36
+ #### Issue 1.2: UI Settings/BYOK Writes to Package Internals
37
+ * **Symptom**: Saving API keys via the dashboard (BYOK feature) or running `gitcast --setup` would write to `site-packages/gitcast/.env`, which was volatile (deleted on package updates) and sometimes generated permission access errors.
38
+ * **Resolution**: Updated `api/routes.py` and `cli/gitcast.py` to write to the resolved active `.env` file, falling back to a persistent user-home folder `~/.gitcast/.env` if no local env file existed.
39
+
40
+ #### Issue 1.3: Package Build Failure (`bdist_wheel` Invalid Command)
41
+ * **Symptom**: Attempting to package the build output using `python setup.py sdist bdist_wheel` generated the error:
42
+ ```text
43
+ error: invalid command 'bdist_wheel'
44
+ ```
45
+ * **Root Cause**: The `wheel` package was not installed in the python environment, causing `setuptools` to not register the command.
46
+ * **Resolution**: Installed `wheel` package (`pip install wheel`) prior to building, enabling setuptools to build the binary wheel package successfully.
47
+
48
+ ---
49
+
50
+ ## 2. Active Issues (To be Addressed)
51
+
52
+ ### Issue 2.1: Post Generation Fails silently (gitcast>=1.0.5)
53
+ * **Symptom**: `gitcast` starts up successfully, the FastAPI server listens on `http://127.0.0.1:8000`, the pygame modules load, and the CLI runs, but the application does not generate posts.
54
+ * **Console Logs**:
55
+ ```text
56
+ (venv) PS C:\Users\USER\Documents\snakegame> gitcast
57
+ [Monitoring] Sentry not configured - skipping
58
+
59
+ [Auth] Session Token: 549cfdc2-c987-416d-8a70-8b0dc5f2315b
60
+ [Server] Starting Gitcast API on http://127.0.0.1:8000
61
+
62
+ [Auth] Session Token: 549cfdc2-c987-416d-8a70-8b0dc5f2315b
63
+ [Server] Starting Gitcast API on http://127.0.0.1:8000
64
+ pygame 2.6.1 (SDL 2.28.4, Python 3.10.0)
65
+ Hello from the pygame community. https://www.pygame.org/contribute.html
66
+ ```
67
+ * **Status**: Logged and waiting for future triage. The application starts, but key bindings or endpoints triggers are failing to generate the actual post output.
68
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -52,6 +52,7 @@ docs/insights.md
52
52
  docs/introduction.md
53
53
  docs/post-formats.md
54
54
  docs/providers.md
55
+ docs/pypi-errors.md
55
56
  docs/quickstart.md
56
57
  docs/sprint-mode.md
57
58
  gitcast.egg-info/PKG-INFO
File without changes
@@ -1,6 +1,38 @@
1
1
  import os
2
+ import shutil
2
3
  from setuptools import setup, find_packages
3
4
 
5
+ # Monkeypatch copystat, os.link, chmod, and utime to avoid "Operation not permitted" errors under WSL mounts
6
+ try:
7
+ if hasattr(os, 'link'):
8
+ del os.link
9
+
10
+ orig_chmod = os.chmod
11
+ def patched_chmod(path, mode, *args, **kwargs):
12
+ try:
13
+ orig_chmod(path, mode, *args, **kwargs)
14
+ except OSError:
15
+ pass
16
+ os.chmod = patched_chmod
17
+
18
+ orig_utime = os.utime
19
+ def patched_utime(path, times=None, *args, **kwargs):
20
+ try:
21
+ orig_utime(path, times, *args, **kwargs)
22
+ except OSError:
23
+ pass
24
+ os.utime = patched_utime
25
+
26
+ orig_copystat = shutil.copystat
27
+ def patched_copystat(src, dst, *args, **kwargs):
28
+ try:
29
+ orig_copystat(src, dst, *args, **kwargs)
30
+ except OSError:
31
+ pass
32
+ shutil.copystat = patched_copystat
33
+ except Exception:
34
+ pass
35
+
4
36
  # Read the contents of README.md
5
37
  this_directory = os.path.abspath(os.path.dirname(__file__))
6
38
  with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
@@ -19,7 +51,7 @@ if os.path.exists(requirements_path):
19
51
 
20
52
  setup(
21
53
  name="gitcast",
22
- version="1.0.4",
54
+ version="1.0.6",
23
55
  description="git diff → published post. under 60 seconds.",
24
56
  long_description=long_description,
25
57
  long_description_content_type="text/markdown",
@@ -1,22 +1,32 @@
1
1
  import threading
2
2
  from supabase import Client, create_client, ClientOptions
3
- from config.settings import SUPABASE_SERVICE_KEY, SUPABASE_URL
3
+ import config.settings
4
4
 
5
5
  _thread_local = threading.local()
6
6
 
7
7
  def get_client() -> Client:
8
- if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
8
+ url = getattr(config.settings, "SUPABASE_URL", "")
9
+ key = getattr(config.settings, "SUPABASE_SERVICE_KEY", "")
10
+ if not url or not key:
9
11
  raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_KEY must be configured")
10
12
 
13
+ # Check if config changed and recreate if needed
14
+ if hasattr(_thread_local, "client"):
15
+ if getattr(_thread_local, "client_url", None) != url or getattr(_thread_local, "client_key", None) != key:
16
+ if hasattr(_thread_local, "client"):
17
+ delattr(_thread_local, "client")
18
+
11
19
  if not hasattr(_thread_local, "client"):
12
20
  _thread_local.client = create_client(
13
- SUPABASE_URL,
14
- SUPABASE_SERVICE_KEY,
21
+ url,
22
+ key,
15
23
  options=ClientOptions(
16
24
  postgrest_client_timeout=10,
17
25
  storage_client_timeout=10
18
26
  )
19
27
  )
28
+ _thread_local.client_url = url
29
+ _thread_local.client_key = key
20
30
  return _thread_local.client
21
31
 
22
32
 
@@ -1,9 +0,0 @@
1
- from slowapi import Limiter
2
- from slowapi.util import get_remote_address
3
-
4
-
5
- limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"], headers_enabled=False)
6
-
7
-
8
- if __name__ == "__main__":
9
- print("[RateLimit] Default limit: 60/minute")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes