wright 0.1.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 (64) hide show
  1. api/__init__.py +0 -0
  2. api/auth.py +165 -0
  3. api/chroma_cache.py +98 -0
  4. api/embedder.py +39 -0
  5. api/main.py +264 -0
  6. api/observability.py +74 -0
  7. api/quota.py +451 -0
  8. api/rate_limit.py +20 -0
  9. api/repo_store.py +67 -0
  10. api/routes/__init__.py +0 -0
  11. api/routes/auth.py +341 -0
  12. api/routes/billing.py +303 -0
  13. api/routes/chat.py +120 -0
  14. api/routes/coverage.py +120 -0
  15. api/routes/drift.py +593 -0
  16. api/routes/fix_pr.py +420 -0
  17. api/routes/generate.py +200 -0
  18. api/routes/internal.py +38 -0
  19. api/routes/llms_txt.py +79 -0
  20. api/routes/repos.py +854 -0
  21. api/routes/usage.py +19 -0
  22. api/routes/webhooks.py +156 -0
  23. api/tasks/__init__.py +0 -0
  24. api/tasks/email_tasks.py +413 -0
  25. api/tasks/ops_alerts.py +198 -0
  26. api/token_store.py +61 -0
  27. api/usage_store.py +215 -0
  28. api/user_store.py +182 -0
  29. cli/__init__.py +0 -0
  30. cli/main.py +1125 -0
  31. core/__init__.py +0 -0
  32. core/config.py +142 -0
  33. core/drift/__init__.py +0 -0
  34. core/drift/drift_detector.py +564 -0
  35. core/embeddings/__init__.py +0 -0
  36. core/embeddings/chroma_store.py +142 -0
  37. core/embeddings/pgvector_store.py +191 -0
  38. core/embeddings/voyage_embeddings.py +74 -0
  39. core/llm/__init__.py +0 -0
  40. core/llm/gateway.py +605 -0
  41. core/llm/graph.py +179 -0
  42. core/llm/prompts.py +450 -0
  43. core/llm/schema.py +31 -0
  44. core/output/__init__.py +0 -0
  45. core/output/injector.py +524 -0
  46. core/output/llms_txt.py +37 -0
  47. core/output/markdown_writer.py +96 -0
  48. core/output/openapi_gen.py +77 -0
  49. core/parser/__init__.py +0 -0
  50. core/parser/ast_chunker.py +131 -0
  51. core/parser/cache.py +480 -0
  52. core/parser/dep_graph.py +89 -0
  53. core/parser/tree_sitter_parser.py +1111 -0
  54. core/retrieval/__init__.py +0 -0
  55. core/retrieval/hybrid_retriever.py +368 -0
  56. mcp_server/__init__.py +0 -0
  57. mcp_server/server.py +374 -0
  58. wright-0.1.0.dist-info/METADATA +557 -0
  59. wright-0.1.0.dist-info/RECORD +64 -0
  60. wright-0.1.0.dist-info/WHEEL +5 -0
  61. wright-0.1.0.dist-info/entry_points.txt +4 -0
  62. wright-0.1.0.dist-info/licenses/LICENSE +661 -0
  63. wright-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL.md +43 -0
  64. wright-0.1.0.dist-info/top_level.txt +4 -0
api/__init__.py ADDED
File without changes
api/auth.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from fastapi import HTTPException, Request, Security
10
+ from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
11
+ from jose import JWTError, jwt
12
+
13
+ _API_KEY_HEADER = APIKeyHeader(name="X-Wright-API-Key", auto_error=False)
14
+ _BEARER = HTTPBearer(auto_error=False)
15
+ _KEY_FILE = Path(os.getenv("WRIGHT_KEY_FILE", Path.home() / ".wright" / "api.key"))
16
+
17
+ WORKOS_CLIENT_ID = os.getenv("WORKOS_CLIENT_ID", "")
18
+ WORKOS_API_KEY = os.getenv("WORKOS_API_KEY", "")
19
+
20
+
21
+ def _load_or_generate_key() -> str:
22
+ """
23
+ Loads or generates a WrightAI API key by checking the environment variable, an existing key file, or creating a new cryptographically secure random key.
24
+
25
+ Attempts to retrieve the API key in the following priority order: (1) from the WRIGHT_API_KEY environment variable, (2) from an existing key file at the path defined by _KEY_FILE, or (3) generates a new cryptographically secure random key using secrets.token_urlsafe(32), saves it to the key file with restricted permissions (0o600), creates parent directories with mode 0o700 if needed, and prints a notification message to stderr on first run.
26
+
27
+ Returns:
28
+ str: The WrightAI API key as a URL-safe string, sourced from the WRIGHT_API_KEY environment variable, an existing key file, or a newly generated secure random token.
29
+
30
+ Example:
31
+ ```
32
+ api_key = _load_or_generate_key()
33
+ print(api_key) # e.g., 'aB3dEfGhIjKlMnOpQrStUvWxYz0123456789-_Ab'
34
+ ```
35
+ """
36
+ env_key = os.getenv("WRIGHT_API_KEY", "")
37
+ if env_key:
38
+ return env_key
39
+ if _KEY_FILE.exists():
40
+ return _KEY_FILE.read_text().strip()
41
+ _KEY_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
42
+ key = secrets.token_urlsafe(32)
43
+ _KEY_FILE.write_text(key)
44
+ _KEY_FILE.chmod(0o600)
45
+ print(
46
+ f"\n WrightAI API key generated (first run).\n"
47
+ f" Saved to: {_KEY_FILE}\n"
48
+ f" Read it with: cat {_KEY_FILE}\n",
49
+ file=sys.stderr,
50
+ )
51
+ return key
52
+
53
+
54
+ _WRIGHT_API_KEY = _load_or_generate_key()
55
+
56
+
57
+ _jwks_cache: dict | None = None
58
+
59
+
60
+ def _get_jwks() -> dict:
61
+ """Fetch and cache the WorkOS JWKS public key set."""
62
+ global _jwks_cache
63
+ if _jwks_cache is None:
64
+ client_id = os.getenv("WORKOS_CLIENT_ID", "")
65
+ resp = httpx.get(
66
+ f"https://api.workos.com/user_management/jwks/{client_id}",
67
+ timeout=5,
68
+ )
69
+ if resp.status_code != 200:
70
+ raise HTTPException(status_code=503, detail="Could not fetch WorkOS JWKS")
71
+ _jwks_cache = resp.json()
72
+ return _jwks_cache
73
+
74
+
75
+ def _verify_workos_token(token: str) -> dict:
76
+ """
77
+ Validates a WorkOS JWT access token by fetching the JWKS public keys and verifying the signature.
78
+
79
+ Fetches the WorkOS JWKS (JSON Web Key Set) for the configured client ID, then decodes and
80
+ verifies the provided JWT against those public keys. Raises HTTP 401 if the token is invalid,
81
+ expired, or the signature doesn't match.
82
+
83
+ Args:
84
+ token (str): The WorkOS JWT access token to validate.
85
+
86
+ Returns:
87
+ dict: The decoded JWT payload containing user identity claims.
88
+
89
+ Raises:
90
+ HTTPException: Raised with HTTP status code 401 when the token is invalid or expired.
91
+ HTTPException: Raised with HTTP status code 503 when the JWKS endpoint is unreachable.
92
+ """
93
+ try:
94
+ jwks = _get_jwks()
95
+ # Extract the key ID from the JWT header to pick the right key from the set
96
+ header = jwt.get_unverified_header(token)
97
+ kid = header.get("kid")
98
+ key = next(
99
+ (k for k in jwks.get("keys", []) if k.get("kid") == kid),
100
+ jwks.get("keys", [None])[0] if jwks.get("keys") else None,
101
+ )
102
+ if key is None:
103
+ raise HTTPException(status_code=401, detail="Invalid WorkOS token: no matching key")
104
+ claims = jwt.decode(
105
+ token,
106
+ key,
107
+ algorithms=["RS256"],
108
+ audience=os.getenv("WORKOS_CLIENT_ID", ""),
109
+ )
110
+ return claims
111
+ except JWTError as exc:
112
+ raise HTTPException(status_code=401, detail=f"Invalid WorkOS token: {exc}") from exc
113
+
114
+
115
+ async def verify_api_key(
116
+ request: Request,
117
+ api_key: str | None = Security(_API_KEY_HEADER),
118
+ bearer: HTTPAuthorizationCredentials | None = Security(_BEARER),
119
+ ) -> None:
120
+ """
121
+ Verifies incoming API authentication credentials against user-issued API keys, a server-level static key, or WorkOS bearer tokens, raising HTTP 401 on any failure.
122
+
123
+ Authenticates requests via one of three ordered methods: (1) user-issued API keys prefixed with 'wai_' are looked up in the Supabase user store via get_user_by_api_key(); (2) a server-level static key is accepted directly for CLI, GitHub Actions, or MCP integrations; (3) a WorkOS bearer token is verified by calling _verify_workos_token(). The function returns silently on the first successful match. If no method succeeds or no credentials are supplied at all, an HTTP 401 exception is raised.
124
+
125
+ Args:
126
+ request (Request): The incoming FastAPI/Starlette HTTP request object, injected by the dependency injection system.
127
+ api_key (str | None): Optional API key extracted from the request headers. Accepts either a user-issued key (prefixed with 'wai_') validated against Supabase, or a server-level static key for CLI/GitHub Actions/MCP.
128
+ bearer (HTTPAuthorizationCredentials | None): Optional bearer token credentials extracted from the Authorization header, used for WorkOS token authentication.
129
+
130
+ Returns:
131
+ None: Returns None implicitly on successful authentication; no value is produced.
132
+
133
+ Raises:
134
+ HTTPException: Raised with status 401 when an API key with the 'wai_' prefix is not found in the Supabase user store.
135
+ HTTPException: Raised with status 401 when WorkOS bearer token verification fails via _verify_workos_token().
136
+ HTTPException: Raised with status 401 when no valid credentials are provided or all authentication methods fail.
137
+
138
+ Example:
139
+ ```
140
+ await verify_api_key(request, api_key='wai_abc123def456', bearer=None)
141
+ ```
142
+ """
143
+ # Accept user API keys issued via WorkOS + Supabase (wai_ prefix)
144
+ if api_key and api_key.startswith("wai_"):
145
+ from api.user_store import get_user_by_api_key
146
+
147
+ if get_user_by_api_key(api_key):
148
+ return
149
+ raise HTTPException(status_code=401, detail="Invalid API key")
150
+
151
+ # Accept server-level static key (CLI / GitHub Action / MCP)
152
+ if api_key and api_key == _WRIGHT_API_KEY:
153
+ return
154
+
155
+ # Accept WorkOS Bearer token directly
156
+ if bearer and bearer.credentials:
157
+ try:
158
+ _verify_workos_token(bearer.credentials)
159
+ return
160
+ except HTTPException:
161
+ raise
162
+ except Exception:
163
+ raise HTTPException(status_code=401, detail="Invalid WorkOS token")
164
+
165
+ raise HTTPException(status_code=401, detail="Invalid or missing credentials")
api/chroma_cache.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shutil
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ _logger = logging.getLogger("wright.chroma_cache")
10
+
11
+ _cache: dict[str, tuple[object, float]] = {}
12
+ _lock = threading.Lock()
13
+ _TTL = 300 # seconds — accept up to 5 min staleness from cross-container writes
14
+ _restored_paths: set[str] = set() # per persist_path, one-time GCS restore
15
+
16
+
17
+ def _restore_chroma_from_gcs(persist_path: str, repo_root: str) -> None:
18
+ """Copy the per-user/repo GCS backup into persist_path on first access."""
19
+ from api.routes.repos import _parse_repo_root # lazy — avoids circular import
20
+
21
+ dst = Path(persist_path)
22
+ if dst.exists():
23
+ return
24
+ parsed = _parse_repo_root(repo_root)
25
+ src = Path("/data/chroma") / parsed[0] / parsed[1] if parsed else Path("/data/chroma")
26
+ if src.exists():
27
+ try:
28
+ shutil.copytree(str(src), str(dst))
29
+ except Exception as e:
30
+ _logger.warning("ChromaDB GCS restore failed for %s: %s", persist_path, e)
31
+
32
+
33
+ def get(persist_path: str, repo_root: str):
34
+ """Return a cached ChromaStore, creating one if missing or TTL-expired.
35
+
36
+ Opening a PersistentClient on GCS Fuse loads SQLite and the HNSW index from
37
+ the network filesystem — ~1-3 s per cold open. Caching the instance means
38
+ only the first request per (path, repo_root) per container pays that cost;
39
+ subsequent requests reuse the warm in-memory index (~10-50 ms).
40
+
41
+ If the resulting store is empty (fresh /tmp on a cold container, with no
42
+ /data/chroma backup either), it's repopulated from the Supabase pgvector
43
+ backup — the durable source of truth for bidirectional sync.
44
+ """
45
+ from core.embeddings.chroma_store import ChromaStore
46
+
47
+ key = f"{persist_path}::{repo_root}"
48
+ now = time.monotonic()
49
+ needs_rebuild = False
50
+ needs_restore = False
51
+ with _lock:
52
+ if persist_path not in _restored_paths:
53
+ _restored_paths.add(persist_path)
54
+ needs_restore = True
55
+ if needs_restore:
56
+ _restore_chroma_from_gcs(persist_path, repo_root)
57
+ with _lock:
58
+ if key in _cache:
59
+ store, ts = _cache[key]
60
+ if now - ts < _TTL:
61
+ return store
62
+ store = ChromaStore(persist_path=persist_path, repo_root=repo_root)
63
+ _cache[key] = (store, now)
64
+ needs_rebuild = store.count() == 0
65
+
66
+ if needs_rebuild:
67
+ _rebuild_from_pgvector(store, repo_root)
68
+ return store
69
+
70
+
71
+ def _rebuild_from_pgvector(store, repo_root: str) -> None:
72
+ """Repopulate a freshly-created, empty ChromaStore from its Supabase
73
+ pgvector backup (e.g. on a cold container start with no /data/chroma)."""
74
+ from api.routes.repos import _parse_repo_root # lazy import, avoids circular import
75
+
76
+ parsed = _parse_repo_root(repo_root)
77
+ if parsed is None:
78
+ return
79
+
80
+ from core.embeddings.pgvector_store import PgVectorStore
81
+
82
+ chunks, embeddings = PgVectorStore(*parsed).get_all_chunks()
83
+ if chunks:
84
+ store.upsert_chunks(chunks, embeddings)
85
+ _logger.info(
86
+ "Rebuilt local chroma for %s from pgvector (%d chunks)", repo_root, len(chunks)
87
+ )
88
+
89
+
90
+ def invalidate(persist_path: str, repo_root: str) -> None:
91
+ """Drop the cached entry so the next request reloads from disk.
92
+
93
+ Call this after a successful upsert to force the cache to reflect newly
94
+ written embeddings on the next request.
95
+ """
96
+ key = f"{persist_path}::{repo_root}"
97
+ with _lock:
98
+ _cache.pop(key, None)
api/embedder.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ _embedder = None
6
+ _gateway = None
7
+
8
+
9
+ def get_embedder():
10
+ """Return the process-level VoyageEmbedder singleton.
11
+
12
+ Creating a new VoyageEmbedder on every request initialises a voyageai.Client
13
+ object each time. This singleton avoids that overhead while keeping the API
14
+ key resolution lazy (read at first call, not at import time).
15
+ """
16
+ global _embedder
17
+ if _embedder is None:
18
+ from core.embeddings.voyage_embeddings import VoyageEmbedder
19
+
20
+ _embedder = VoyageEmbedder(api_key=os.getenv("VOYAGE_API_KEY", ""))
21
+ return _embedder
22
+
23
+
24
+ def get_gateway():
25
+ """Return the process-level LLMGateway singleton.
26
+
27
+ LLMGateway.__init__ creates an anthropic.AsyncAnthropic client on every
28
+ call. Sharing one instance across requests avoids that allocation and keeps
29
+ the underlying httpx connection pool alive between requests.
30
+ """
31
+ global _gateway
32
+ if _gateway is None:
33
+ from core.llm.gateway import LLMGateway
34
+
35
+ _gateway = LLMGateway(
36
+ anthropic_key=os.getenv("ANTHROPIC_API_KEY", ""),
37
+ gemini_key=os.getenv("GEMINI_API_KEY"),
38
+ )
39
+ return _gateway
api/main.py ADDED
@@ -0,0 +1,264 @@
1
+ # WrightAI — AI-powered code documentation tool
2
+ # Copyright (C) 2026 Suraj Sahoo
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # https://github.com/surajs1999/WrightAI
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import time
10
+
11
+ import uvicorn
12
+ from dotenv import load_dotenv
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import JSONResponse
16
+
17
+ load_dotenv()
18
+
19
+ app = FastAPI(
20
+ title="Wright API",
21
+ version="1.0.0",
22
+ description="AI-powered code documentation API",
23
+ )
24
+
25
+ from api.observability import setup_observability # noqa: E402
26
+ from api.rate_limit import limiter # noqa: E402
27
+ from slowapi import _rate_limit_exceeded_handler # noqa: E402
28
+ from slowapi.errors import RateLimitExceeded # noqa: E402
29
+
30
+ setup_observability(app)
31
+ app.state.limiter = limiter
32
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
33
+
34
+ _ALLOWED_ORIGINS = [
35
+ o.strip()
36
+ for o in os.getenv(
37
+ "CORS_ORIGINS",
38
+ "http://localhost:3000,https://www.wrightai.live,https://wrightai.live,https://wrightai-web.netlify.app",
39
+ ).split(",")
40
+ if o.strip()
41
+ ]
42
+
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=_ALLOWED_ORIGINS,
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ _logger = logging.getLogger("wright.api")
52
+
53
+
54
+ @app.middleware("http")
55
+ async def log_requests(request: Request, call_next):
56
+ """
57
+ Logs HTTP request details including method, path, status code, and processing duration.
58
+
59
+ FastAPI middleware that intercepts HTTP requests, measures the time taken to process them, and logs the request method, URL path, response status code, and duration in milliseconds.
60
+
61
+ Args:
62
+ request (Request): The incoming HTTP request object containing request metadata and information.
63
+ call_next (Callable): Middleware chain callable that processes the request and returns the response.
64
+
65
+ Returns:
66
+ Response: The HTTP response object returned from the next middleware or route handler.
67
+
68
+ Example:
69
+ ```
70
+ # Registered automatically via the @app.middleware("http") decorator above.
71
+ # Logs one line per request, e.g.:
72
+ # 2026-01-01T00:00:00 INFO GET /health 200 1.2ms
73
+ ```
74
+
75
+ Complexity: O(1) time, O(1) space
76
+ """
77
+ start = time.perf_counter()
78
+ response = await call_next(request)
79
+ duration_ms = (time.perf_counter() - start) * 1000
80
+ _logger.info(
81
+ "http_request",
82
+ extra={
83
+ "http_method": request.method,
84
+ "http_path": request.url.path,
85
+ "http_status": response.status_code,
86
+ "duration_ms": round(duration_ms, 1),
87
+ },
88
+ )
89
+ return response
90
+
91
+
92
+ from api.routes import ( # noqa: E402
93
+ auth,
94
+ billing,
95
+ chat,
96
+ coverage,
97
+ drift,
98
+ fix_pr,
99
+ generate,
100
+ internal,
101
+ llms_txt,
102
+ repos,
103
+ usage,
104
+ webhooks,
105
+ )
106
+
107
+ app.include_router(auth.router)
108
+ app.include_router(billing.router)
109
+ app.include_router(generate.router)
110
+ app.include_router(coverage.router)
111
+ app.include_router(drift.router)
112
+ app.include_router(chat.router)
113
+ app.include_router(repos.router)
114
+ app.include_router(fix_pr.router)
115
+ app.include_router(llms_txt.router)
116
+ app.include_router(usage.router)
117
+ app.include_router(webhooks.router)
118
+ app.include_router(internal.router)
119
+
120
+
121
+ @app.get("/health")
122
+ async def health() -> dict:
123
+ """Liveness probe — returns immediately. Used by Cloud Run to detect crashes."""
124
+ return {"status": "ok", "version": "1.0.0"}
125
+
126
+
127
+ @app.get("/ready")
128
+ async def ready() -> JSONResponse:
129
+ """Readiness probe — checks live dependencies before accepting traffic."""
130
+ checks: dict[str, str] = {}
131
+
132
+ # Supabase reachability
133
+ try:
134
+ from api.user_store import _db
135
+
136
+ _db().table("plans").select("id").limit(1).execute()
137
+ checks["database"] = "ok"
138
+ except Exception:
139
+ checks["database"] = "error"
140
+
141
+ # Required LLM credentials
142
+ checks["anthropic_key"] = "ok" if os.getenv("ANTHROPIC_API_KEY") else "missing"
143
+ checks["voyage_key"] = "ok" if os.getenv("VOYAGE_API_KEY") else "missing"
144
+
145
+ all_ok = all(v == "ok" for v in checks.values())
146
+ return JSONResponse(
147
+ {"status": "ready" if all_ok else "degraded", "checks": checks},
148
+ status_code=200 if all_ok else 503,
149
+ )
150
+
151
+
152
+ from fastapi import Depends # noqa: E402
153
+ from api.auth import verify_api_key # noqa: E402
154
+
155
+
156
+ @app.post("/user/key/rotate", dependencies=[Depends(verify_api_key)])
157
+ async def rotate_key(request: Request) -> dict:
158
+ """
159
+ Rotates the API key for the authenticated user by invalidating the old key and generating a new one.
160
+
161
+ This FastAPI POST endpoint reads the current API key from the 'X-Wright-API-Key' request header, delegates key rotation to the user store, and returns the newly generated API key. Access is protected by the 'verify_api_key' dependency. If no user is associated with the provided key, a 404 HTTP exception is raised.
162
+
163
+ Args:
164
+ request (Request): The incoming FastAPI HTTP request object, used to extract the current API key from the 'X-Wright-API-Key' header.
165
+
166
+ Returns:
167
+ dict: A dictionary containing the newly generated API key under the key 'api_key', e.g., {'api_key': 'new-generated-key-string'}.
168
+
169
+ Raises:
170
+ HTTPException: Raised with status code 404 and detail 'User not found' when no user is associated with the provided API key.
171
+
172
+ Example:
173
+ ```
174
+ # Using httpx or requests in an async context:
175
+ import httpx
176
+
177
+ response = httpx.post(
178
+ 'http://localhost:8000/user/key/rotate',
179
+ headers={'X-Wright-API-Key': 'existing-api-key-abc123'}
180
+ )
181
+ new_key = response.json() # {'api_key': 'newly-rotated-key-xyz789'}
182
+ ```
183
+ """
184
+ from api.user_store import rotate_api_key
185
+ from fastapi import HTTPException
186
+
187
+ old_key = request.headers.get("X-Wright-API-Key", "")
188
+ user = rotate_api_key(old_key)
189
+ if not user:
190
+ raise HTTPException(status_code=404, detail="User not found")
191
+ return {"api_key": user.api_key}
192
+
193
+
194
+ @app.get("/user/me", dependencies=[Depends(verify_api_key)])
195
+ async def user_me(request: Request) -> dict:
196
+ """
197
+ Retrieves the authenticated user's profile information by looking up the API key from the request headers.
198
+
199
+ An async FastAPI endpoint protected by the verify_api_key dependency. It extracts the X-Wright-API-Key header from the incoming request, resolves the corresponding user via the user store, and returns the user's email, API key, and account creation timestamp. Raises a 401 Unauthorized exception if the key is absent or unrecognized.
200
+
201
+ Args:
202
+ request (Request): The FastAPI Request object used to access HTTP headers, specifically the X-Wright-API-Key header for user lookup.
203
+
204
+ Returns:
205
+ dict: A dictionary containing three keys: 'email' (str) — the user's email address, 'api_key' (str) — the user's API key, and 'created_at' (str or datetime) — the ISO 8601 timestamp of account creation.
206
+
207
+ Raises:
208
+ HTTPException: Raised with status_code=401 when the X-Wright-API-Key header is missing, empty, or does not correspond to any user in the user store.
209
+
210
+ Example:
211
+ ```
212
+ # GET /user/me with Header: X-Wright-API-Key: abc123xyz
213
+ # Response:
214
+ # {
215
+ # "email": "user@example.com",
216
+ # "api_key": "abc123xyz",
217
+ # "created_at": "2024-01-01T00:00:00"
218
+ # }
219
+ ```
220
+ """
221
+ from api.user_store import get_user_by_api_key
222
+
223
+ api_key = request.headers.get("X-Wright-API-Key", "")
224
+ user = get_user_by_api_key(api_key)
225
+ if not user:
226
+ from fastapi import HTTPException
227
+
228
+ raise HTTPException(status_code=401, detail="Invalid API key")
229
+ return {
230
+ "email": user.email,
231
+ "api_key": user.api_key,
232
+ "created_at": user.created_at,
233
+ }
234
+
235
+
236
+ def start() -> None:
237
+ """
238
+ Starts the Uvicorn ASGI server to run the Wright API application on the configured host and port.
239
+
240
+ Initializes and runs the FastAPI application using Uvicorn with the host bound to all network interfaces (0.0.0.0) and the port configured via the WRIGHT_API_PORT environment variable (defaulting to 8765). Auto-reload is disabled for production stability. This function blocks until the server is interrupted.
241
+
242
+ Returns:
243
+ None: Does not return a value; runs the server until interrupted.
244
+
245
+ Raises:
246
+ ValueError: When the WRIGHT_API_PORT environment variable is set to a non-integer value that cannot be converted via int().
247
+
248
+ Example:
249
+ ```
250
+ import os
251
+ os.environ['WRIGHT_API_PORT'] = '8765'
252
+ start() # Starts the Wright API server on http://0.0.0.0:8765
253
+ ```
254
+ """
255
+ uvicorn.run(
256
+ "api.main:app",
257
+ host="0.0.0.0",
258
+ port=int(os.getenv("WRIGHT_API_PORT", "8765")),
259
+ reload=False,
260
+ )
261
+
262
+
263
+ if __name__ == "__main__":
264
+ start()
api/observability.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ Observability setup: structured JSON logging + Sentry error tracking.
3
+ Call setup_observability(app) once, right after FastAPI() is created.
4
+
5
+ Required env vars:
6
+ SENTRY_DSN — enables Sentry (get from sentry.io → Project Settings → DSN)
7
+
8
+ Optional env vars:
9
+ ENVIRONMENT — "production" / "staging" (default "production")
10
+ K_REVISION — Cloud Run revision tag, used as Sentry release identifier
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+
18
+ _logger = logging.getLogger("wright.observability")
19
+
20
+
21
+ def configure_logging() -> None:
22
+ """Replace the plaintext formatter with JSON so Cloud Logging can parse fields."""
23
+ try:
24
+ from pythonjsonlogger import jsonlogger
25
+
26
+ handler = logging.StreamHandler()
27
+ handler.setFormatter(
28
+ jsonlogger.JsonFormatter(
29
+ fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
30
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
31
+ rename_fields={"levelname": "severity", "asctime": "timestamp"},
32
+ )
33
+ )
34
+ logging.root.setLevel(logging.INFO)
35
+ logging.root.handlers = []
36
+ logging.root.addHandler(handler)
37
+ except ImportError:
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
41
+ datefmt="%Y-%m-%dT%H:%M:%S",
42
+ )
43
+
44
+
45
+ def _setup_sentry() -> None:
46
+ dsn = os.getenv("SENTRY_DSN", "")
47
+ if not dsn:
48
+ return
49
+ try:
50
+ import sentry_sdk
51
+ from sentry_sdk.integrations.fastapi import FastApiIntegration
52
+ from sentry_sdk.integrations.logging import LoggingIntegration
53
+
54
+ sentry_sdk.init(
55
+ dsn=dsn,
56
+ integrations=[
57
+ FastApiIntegration(),
58
+ LoggingIntegration(level=logging.ERROR, event_level=logging.ERROR),
59
+ ],
60
+ traces_sample_rate=0.0, # no performance tracing — error tracking only
61
+ environment=os.getenv("ENVIRONMENT", "production"),
62
+ release=os.getenv("K_REVISION", "unknown"),
63
+ )
64
+ _logger.info(
65
+ "Sentry initialised", extra={"environment": os.getenv("ENVIRONMENT", "production")}
66
+ )
67
+ except ImportError:
68
+ _logger.warning("sentry-sdk not installed — error tracking disabled")
69
+
70
+
71
+ def setup_observability(app) -> None: # noqa: ARG001
72
+ """Wire up structured logging and Sentry. Call once after FastAPI() is created."""
73
+ configure_logging()
74
+ _setup_sentry()