migrated all pawprint work
This commit is contained in:
8
artery/veins/google/.env.example
Normal file
8
artery/veins/google/.env.example
Normal 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
|
||||
90
artery/veins/google/README.md
Normal file
90
artery/veins/google/README.md
Normal 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`).
|
||||
0
artery/veins/google/__init__.py
Normal file
0
artery/veins/google/__init__.py
Normal file
0
artery/veins/google/api/__init__.py
Normal file
0
artery/veins/google/api/__init__.py
Normal file
194
artery/veins/google/api/routes.py
Normal file
194
artery/veins/google/api/routes.py
Normal 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))
|
||||
0
artery/veins/google/core/__init__.py
Normal file
0
artery/veins/google/core/__init__.py
Normal file
24
artery/veins/google/core/config.py
Normal file
24
artery/veins/google/core/config.py
Normal 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()
|
||||
147
artery/veins/google/core/oauth.py
Normal file
147
artery/veins/google/core/oauth.py
Normal 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,
|
||||
)
|
||||
130
artery/veins/google/core/sheets.py
Normal file
130
artery/veins/google/core/sheets.py
Normal 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}")
|
||||
15
artery/veins/google/main.py
Normal file
15
artery/veins/google/main.py
Normal 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)
|
||||
0
artery/veins/google/models/__init__.py
Normal file
0
artery/veins/google/models/__init__.py
Normal file
71
artery/veins/google/models/formatter.py
Normal file
71
artery/veins/google/models/formatter.py
Normal 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)
|
||||
69
artery/veins/google/models/spreadsheet.py
Normal file
69
artery/veins/google/models/spreadsheet.py
Normal 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,
|
||||
)
|
||||
8
artery/veins/google/requirements.txt
Normal file
8
artery/veins/google/requirements.txt
Normal 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
|
||||
11
artery/veins/google/run.py
Normal file
11
artery/veins/google/run.py
Normal 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)
|
||||
5
artery/veins/google/storage/.gitignore
vendored
Normal file
5
artery/veins/google/storage/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Ignore all token files
|
||||
tokens_*.json
|
||||
|
||||
# But keep this directory in git
|
||||
!.gitignore
|
||||
Reference in New Issue
Block a user