migrated all pawprint work

This commit is contained in:
buenosairesam
2025-12-31 08:34:18 -03:00
parent fc63e9010c
commit 680969ca42
63 changed files with 4687 additions and 5 deletions

View File

@@ -0,0 +1,8 @@
# Google OAuth2 Configuration
# Get credentials from: https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
GOOGLE_REDIRECT_URI=https://artery.mcrn.ar/google/oauth/callback
GOOGLE_SCOPES=https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly
API_PORT=8003

View File

@@ -0,0 +1,90 @@
# Google Vein
OAuth2-based connector for Google APIs (Sheets, Drive).
## Status: DEVELOPMENT
## Setup
1. Create Google Cloud project and OAuth2 credentials:
- Go to https://console.cloud.google.com/apis/credentials
- Create OAuth 2.0 Client ID (Web application)
- Add authorized redirect URI: `https://artery.mcrn.ar/google/oauth/callback`
- Enable Google Sheets API and Google Drive API
2. Copy `.env.example` to `.env` and fill in credentials:
```bash
cp .env.example .env
# Edit .env with your credentials
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Run standalone:
```bash
python run.py
```
## OAuth Flow
Unlike Jira/Slack (simple token auth), Google uses OAuth2:
1. **Start**: Visit `/google/oauth/start` - redirects to Google login
2. **Callback**: Google redirects to `/google/oauth/callback` with code
3. **Exchange**: Code exchanged for access_token + refresh_token
4. **Storage**: Tokens saved to `storage/tokens_{user_id}.json`
5. **Use**: Subsequent requests use stored tokens
6. **Refresh**: Expired tokens auto-refreshed using refresh_token
## Endpoints
### Authentication
- `GET /google/health` - Check auth status
- `GET /google/oauth/start` - Start OAuth flow
- `GET /google/oauth/callback` - OAuth callback (called by Google)
- `GET /google/oauth/logout` - Clear stored tokens
### Google Sheets
- `GET /google/spreadsheets/{id}` - Get spreadsheet metadata
- `GET /google/spreadsheets/{id}/sheets` - List all sheets
- `GET /google/spreadsheets/{id}/values?range=Sheet1!A1:D10` - Get cell values
All endpoints support `?text=true` for LLM-friendly text output.
## Architecture
```
core/ # Isolated - can run without FastAPI
├── oauth.py # Google OAuth2 client
├── sheets.py # Google Sheets API client
└── config.py # Settings
api/ # FastAPI wrapper
└── routes.py # Endpoints
models/ # Data models
├── spreadsheet.py # Pydantic models
└── formatter.py # Text output
storage/ # Token persistence (gitignored)
```
## Token Storage
For development/demo: File-based storage in `storage/`
For production: Override `TokenStorage` in `vein/oauth.py`:
- Redis for scalability
- Database for audit trail
- Per-user tokens when integrated with auth system
## Future APIs
- Google Drive (file listing, download)
- Gmail (read messages)
- Calendar (events)
Each API gets its own client in `core/` (e.g., `core/drive.py`).

View File

View File

View File

@@ -0,0 +1,194 @@
"""
API routes for Google vein.
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse, RedirectResponse
from typing import Optional
from core.oauth import GoogleOAuth
from core.sheets import GoogleSheetsClient, GoogleSheetsError
from models.spreadsheet import SpreadsheetMetadata, SheetValues
from models.formatter import format_spreadsheet_metadata, format_sheet_values
# Import from parent vein module
import sys
from pathlib import Path
vein_path = Path(__file__).parent.parent.parent
sys.path.insert(0, str(vein_path))
from oauth import TokenStorage
router = APIRouter()
# OAuth client and token storage
oauth_client = GoogleOAuth()
token_storage = TokenStorage(vein_name="google")
# For demo/development, use a default user_id
# In production, this would come from session/auth
DEFAULT_USER_ID = "demo_user"
def _get_sheets_client(user_id: str = DEFAULT_USER_ID) -> GoogleSheetsClient:
"""Get authenticated Sheets client for user."""
tokens = token_storage.load_tokens(user_id)
if not tokens:
raise HTTPException(
status_code=401,
detail="Not authenticated. Visit /google/oauth/start to login.",
)
# Check if expired and refresh if needed
if token_storage.is_expired(tokens):
if "refresh_token" not in tokens:
raise HTTPException(
status_code=401,
detail="Token expired and no refresh token. Re-authenticate at /google/oauth/start",
)
try:
new_tokens = oauth_client.refresh_access_token(tokens["refresh_token"])
token_storage.save_tokens(user_id, new_tokens)
tokens = new_tokens
except Exception as e:
raise HTTPException(
status_code=401,
detail=f"Failed to refresh token: {e}. Re-authenticate at /google/oauth/start",
)
credentials = oauth_client.get_credentials(
access_token=tokens["access_token"],
refresh_token=tokens.get("refresh_token"),
)
return GoogleSheetsClient(credentials)
def _maybe_text(data, text: bool, formatter):
"""Return text or JSON based on query param."""
if not text:
return data
return PlainTextResponse(formatter(data))
@router.get("/health")
async def health():
"""Check if user is authenticated."""
try:
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
if not tokens:
return {
"status": "not_authenticated",
"message": "Visit /google/oauth/start to login",
}
expired = token_storage.is_expired(tokens)
return {
"status": "ok" if not expired else "token_expired",
"has_refresh_token": "refresh_token" in tokens,
"user": DEFAULT_USER_ID,
}
except Exception as e:
raise HTTPException(500, str(e))
@router.get("/oauth/start")
async def start_oauth(state: Optional[str] = None):
"""Start OAuth flow - redirect to Google authorization."""
auth_url = oauth_client.get_authorization_url(state=state)
return RedirectResponse(auth_url)
@router.get("/oauth/callback")
async def oauth_callback(
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
):
"""Handle OAuth callback from Google."""
if error:
raise HTTPException(400, f"OAuth error: {error}")
if not code:
raise HTTPException(400, "Missing authorization code")
try:
tokens = oauth_client.exchange_code_for_tokens(code)
token_storage.save_tokens(DEFAULT_USER_ID, tokens)
return {
"status": "ok",
"message": "Successfully authenticated with Google",
"user": DEFAULT_USER_ID,
}
except Exception as e:
raise HTTPException(500, f"Failed to exchange code: {e}")
@router.get("/oauth/logout")
async def logout():
"""Clear stored tokens."""
token_storage.delete_tokens(DEFAULT_USER_ID)
return {"status": "ok", "message": "Logged out"}
@router.get("/spreadsheets/{spreadsheet_id}")
async def get_spreadsheet(
spreadsheet_id: str,
text: bool = False,
):
"""Get spreadsheet metadata (title, sheets list, etc.)."""
try:
client = _get_sheets_client()
metadata = client.get_spreadsheet_metadata(spreadsheet_id)
result = SpreadsheetMetadata.from_google(metadata)
return _maybe_text(result, text, format_spreadsheet_metadata)
except GoogleSheetsError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@router.get("/spreadsheets/{spreadsheet_id}/values")
async def get_sheet_values(
spreadsheet_id: str,
range: str = Query(..., description="A1 notation range (e.g., 'Sheet1!A1:D10')"),
text: bool = False,
max_rows: int = Query(100, ge=1, le=10000),
):
"""Get values from a sheet range."""
try:
client = _get_sheets_client()
values = client.get_sheet_values(spreadsheet_id, range)
result = SheetValues.from_google(spreadsheet_id, range, values)
if text:
return PlainTextResponse(format_sheet_values(result, max_rows=max_rows))
return result
except GoogleSheetsError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@router.get("/spreadsheets/{spreadsheet_id}/sheets")
async def list_sheets(
spreadsheet_id: str,
text: bool = False,
):
"""List all sheets in a spreadsheet."""
try:
client = _get_sheets_client()
sheets = client.get_all_sheets(spreadsheet_id)
if text:
lines = [f"Sheets in {spreadsheet_id}:", ""]
for sheet in sheets:
lines.append(
f" [{sheet['index']}] {sheet['title']} "
f"({sheet['row_count']} rows x {sheet['column_count']} cols)"
)
return PlainTextResponse("\n".join(lines))
return {"spreadsheet_id": spreadsheet_id, "sheets": sheets}
except GoogleSheetsError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))

View File

View File

@@ -0,0 +1,24 @@
"""
Google OAuth2 configuration loaded from .env file.
"""
from pathlib import Path
from pydantic_settings import BaseSettings
ENV_FILE = Path(__file__).parent.parent / ".env"
class GoogleConfig(BaseSettings):
google_client_id: str
google_client_secret: str
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
api_port: int = 8003
model_config = {
"env_file": ENV_FILE,
"env_file_encoding": "utf-8",
}
settings = GoogleConfig()

View File

@@ -0,0 +1,147 @@
"""
Google OAuth2 flow implementation.
Isolated OAuth2 client that can run without FastAPI.
"""
from typing import Optional
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from .config import settings
class GoogleOAuth:
"""
Google OAuth2 client.
Handles authorization flow, token exchange, and token refresh.
"""
def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
redirect_uri: Optional[str] = None,
scopes: Optional[list[str]] = None,
):
"""
Initialize OAuth client.
Falls back to settings if parameters not provided.
"""
self.client_id = client_id or settings.google_client_id
self.client_secret = client_secret or settings.google_client_secret
self.redirect_uri = redirect_uri or settings.google_redirect_uri
self.scopes = scopes or settings.google_scopes.split()
def _create_flow(self) -> Flow:
"""Create OAuth flow object."""
client_config = {
"web": {
"client_id": self.client_id,
"client_secret": self.client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = Flow.from_client_config(
client_config,
scopes=self.scopes,
redirect_uri=self.redirect_uri,
)
return flow
def get_authorization_url(self, state: Optional[str] = None) -> str:
"""
Generate OAuth2 authorization URL.
Args:
state: Optional state parameter for CSRF protection
Returns:
URL to redirect user for Google authorization
"""
flow = self._create_flow()
auth_url, _ = flow.authorization_url(
access_type="offline", # Request refresh token
include_granted_scopes="true",
state=state,
)
return auth_url
def exchange_code_for_tokens(self, code: str) -> dict:
"""
Exchange authorization code for tokens.
Args:
code: Authorization code from callback
Returns:
Token dict containing:
- access_token
- refresh_token
- expires_in
- scope
- token_type
"""
flow = self._create_flow()
flow.fetch_token(code=code)
credentials = flow.credentials
return {
"access_token": credentials.token,
"refresh_token": credentials.refresh_token,
"expires_in": 3600, # Google tokens typically 1 hour
"scope": " ".join(credentials.scopes or []),
"token_type": "Bearer",
}
def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh an expired access token.
Args:
refresh_token: The refresh token
Returns:
New token dict with fresh access_token
"""
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=self.client_id,
client_secret=self.client_secret,
)
request = Request()
credentials.refresh(request)
return {
"access_token": credentials.token,
"refresh_token": refresh_token, # Keep original refresh token
"expires_in": 3600,
"scope": " ".join(credentials.scopes or []),
"token_type": "Bearer",
}
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
"""
Create Google Credentials object from tokens.
Args:
access_token: OAuth2 access token
refresh_token: Optional refresh token
Returns:
Google Credentials object for API calls
"""
return Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
)

View File

@@ -0,0 +1,130 @@
"""
Google Sheets API client.
Isolated client that can run without FastAPI.
"""
from typing import Optional
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
class GoogleSheetsError(Exception):
"""Sheets API error."""
pass
class GoogleSheetsClient:
"""
Google Sheets API client.
Provides methods to read spreadsheet data.
"""
def __init__(self, credentials: Credentials):
"""
Initialize Sheets client.
Args:
credentials: Google OAuth2 credentials
"""
self.credentials = credentials
self.service = build("sheets", "v4", credentials=credentials)
def get_spreadsheet_metadata(self, spreadsheet_id: str) -> dict:
"""
Get spreadsheet metadata (title, sheets, etc.).
Args:
spreadsheet_id: The spreadsheet ID
Returns:
Spreadsheet metadata
"""
try:
result = self.service.spreadsheets().get(
spreadsheetId=spreadsheet_id
).execute()
return result
except HttpError as e:
raise GoogleSheetsError(f"Failed to get spreadsheet: {e}")
def get_sheet_values(
self,
spreadsheet_id: str,
range_name: str,
value_render_option: str = "FORMATTED_VALUE",
) -> list[list]:
"""
Get values from a sheet range.
Args:
spreadsheet_id: The spreadsheet ID
range_name: A1 notation range (e.g., 'Sheet1!A1:D10')
value_render_option: How values should be rendered
- FORMATTED_VALUE: Values formatted as strings (default)
- UNFORMATTED_VALUE: Values in calculated format
- FORMULA: Formulas
Returns:
List of rows, each row is a list of cell values
"""
try:
result = self.service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=range_name,
valueRenderOption=value_render_option,
).execute()
return result.get("values", [])
except HttpError as e:
raise GoogleSheetsError(f"Failed to get values: {e}")
def get_all_sheets(self, spreadsheet_id: str) -> list[dict]:
"""
Get list of all sheets in a spreadsheet.
Args:
spreadsheet_id: The spreadsheet ID
Returns:
List of sheet metadata (title, id, index, etc.)
"""
metadata = self.get_spreadsheet_metadata(spreadsheet_id)
return [
{
"title": sheet["properties"]["title"],
"sheet_id": sheet["properties"]["sheetId"],
"index": sheet["properties"]["index"],
"row_count": sheet["properties"]["gridProperties"].get("rowCount", 0),
"column_count": sheet["properties"]["gridProperties"].get("columnCount", 0),
}
for sheet in metadata.get("sheets", [])
]
def batch_get_values(
self,
spreadsheet_id: str,
ranges: list[str],
value_render_option: str = "FORMATTED_VALUE",
) -> dict:
"""
Get multiple ranges in a single request.
Args:
spreadsheet_id: The spreadsheet ID
ranges: List of A1 notation ranges
value_render_option: How values should be rendered
Returns:
Dict with spreadsheetId and valueRanges list
"""
try:
result = self.service.spreadsheets().values().batchGet(
spreadsheetId=spreadsheet_id,
ranges=ranges,
valueRenderOption=value_render_option,
).execute()
return result
except HttpError as e:
raise GoogleSheetsError(f"Failed to batch get values: {e}")

View File

@@ -0,0 +1,15 @@
"""
Google Vein - FastAPI app.
"""
from fastapi import FastAPI
from api.routes import router
from core.config import settings
app = FastAPI(title="Google Vein", version="0.1.0")
app.include_router(router, prefix="/google")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)

View File

View File

@@ -0,0 +1,71 @@
"""
Text formatters for spreadsheet data (LLM-friendly output).
"""
from .spreadsheet import SpreadsheetMetadata, SheetValues
def format_spreadsheet_metadata(metadata: SpreadsheetMetadata) -> str:
"""Format spreadsheet metadata as text."""
lines = [
f"Spreadsheet: {metadata.title}",
f"ID: {metadata.spreadsheet_id}",
f"Locale: {metadata.locale or 'N/A'}",
f"Timezone: {metadata.timezone or 'N/A'}",
"",
"Sheets:",
]
for sheet in metadata.sheets:
lines.append(
f" [{sheet.index}] {sheet.title} "
f"({sheet.row_count} rows x {sheet.column_count} cols)"
)
return "\n".join(lines)
def format_sheet_values(values: SheetValues, max_rows: int = 100) -> str:
"""
Format sheet values as text table.
Args:
values: Sheet values
max_rows: Maximum rows to display
"""
lines = [
f"Spreadsheet ID: {values.spreadsheet_id}",
f"Range: {values.range}",
f"Size: {values.row_count} rows x {values.column_count} cols",
"",
]
if not values.values:
lines.append("(empty)")
return "\n".join(lines)
# Display up to max_rows
display_rows = values.values[:max_rows]
# Calculate column widths (for basic alignment)
col_widths = [0] * values.column_count
for row in display_rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], len(str(cell)))
# Format rows
for row_idx, row in enumerate(display_rows):
cells = []
for col_idx, cell in enumerate(row):
width = col_widths[col_idx] if col_idx < len(col_widths) else 0
cells.append(str(cell).ljust(width))
# Pad with empty cells if row is shorter
while len(cells) < values.column_count:
width = col_widths[len(cells)] if len(cells) < len(col_widths) else 0
cells.append("".ljust(width))
lines.append(" | ".join(cells))
if values.row_count > max_rows:
lines.append(f"\n... ({values.row_count - max_rows} more rows)")
return "\n".join(lines)

View File

@@ -0,0 +1,69 @@
"""
Spreadsheet models with self-parsing from Google Sheets API responses.
"""
from pydantic import BaseModel
from typing import Optional, List
class SheetInfo(BaseModel):
"""Individual sheet within a spreadsheet."""
title: str
sheet_id: int
index: int
row_count: int
column_count: int
class SpreadsheetMetadata(BaseModel):
"""Spreadsheet metadata."""
spreadsheet_id: str
title: str
locale: Optional[str] = None
timezone: Optional[str] = None
sheets: List[SheetInfo] = []
@classmethod
def from_google(cls, data: dict) -> "SpreadsheetMetadata":
"""Parse from Google Sheets API response."""
sheets = [
SheetInfo(
title=sheet["properties"]["title"],
sheet_id=sheet["properties"]["sheetId"],
index=sheet["properties"]["index"],
row_count=sheet["properties"]["gridProperties"].get("rowCount", 0),
column_count=sheet["properties"]["gridProperties"].get("columnCount", 0),
)
for sheet in data.get("sheets", [])
]
return cls(
spreadsheet_id=data["spreadsheetId"],
title=data["properties"]["title"],
locale=data["properties"].get("locale"),
timezone=data["properties"].get("timeZone"),
sheets=sheets,
)
class SheetValues(BaseModel):
"""Sheet data values."""
spreadsheet_id: str
range: str
values: List[List[str]] # rows of cells
row_count: int
column_count: int
@classmethod
def from_google(cls, spreadsheet_id: str, range_name: str, values: List[List]) -> "SheetValues":
"""Parse from Google Sheets API values response."""
row_count = len(values)
column_count = max((len(row) for row in values), default=0)
return cls(
spreadsheet_id=spreadsheet_id,
range=range_name,
values=values,
row_count=row_count,
column_count=column_count,
)

View File

@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
google-auth>=2.23.0
google-auth-oauthlib>=1.1.0
google-auth-httplib2>=0.1.1
google-api-python-client>=2.100.0

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""
Standalone runner for Google vein.
"""
if __name__ == "__main__":
import uvicorn
from main import app
from core.config import settings
uvicorn.run(app, host="0.0.0.0", port=settings.api_port, reload=True)

View File

@@ -0,0 +1,5 @@
# Ignore all token files
tokens_*.json
# But keep this directory in git
!.gitignore