From 3eae90a9ab83ea63d109f1d556c18f359d38c153 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 3 Apr 2026 07:53:20 +0000 Subject: [PATCH] chore: add docs sync guardrails and spec documents --- .github/workflows/docs-sync-check.yml | 23 ++ .pre-commit-config.yaml | 9 + README.md | 29 ++ docs/api-examples.md | 287 +++++++++++++++++++ docs/db-schema-spec.md | 157 +++++++++++ docs/portfoliohub-spec.md | 380 ++++++++++++++++++++++++++ pyproject.toml | 1 + scripts/check_docs_sync.py | 177 ++++++++++++ 8 files changed, 1063 insertions(+) create mode 100644 .github/workflows/docs-sync-check.yml create mode 100644 .pre-commit-config.yaml create mode 100644 docs/api-examples.md create mode 100644 docs/db-schema-spec.md create mode 100644 docs/portfoliohub-spec.md create mode 100644 scripts/check_docs_sync.py diff --git a/.github/workflows/docs-sync-check.yml b/.github/workflows/docs-sync-check.yml new file mode 100644 index 0000000..920218e --- /dev/null +++ b/.github/workflows/docs-sync-check.yml @@ -0,0 +1,23 @@ +name: Docs Sync Check + +on: + pull_request: + +jobs: + docs-sync-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Verify docs sync + run: | + python3 scripts/check_docs_sync.py --against origin/${{ github.base_ref }} --head ${{ github.sha }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fad8953 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: local + hooks: + - id: docs-sync-check + name: docs-sync-check + entry: python3 scripts/check_docs_sync.py --staged + language: system + pass_filenames: false + stages: [pre-commit] diff --git a/README.md b/README.md index 6c2012a..eee5dea 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ PORTFOLIOHUB_DB_ENABLED=true portfoliohub portfolio list This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`. +## Docs Sync Guardrail + +To keep implementation and specification documents in sync, this repository provides an automated check. + +Rules enforced: + +- If files under `app/` change, `docs/portfoliohub-spec.md` must be updated in the same commit/PR. +- If files under `app/api/` or `app/schemas/` change, `docs/api-examples.md` must be updated in the same commit/PR. +- If files under `migrations/versions/` change, `docs/db-schema-spec.md` must be updated in the same commit/PR. +- Changed migration revision IDs must appear in `docs/db-schema-spec.md`. + +Enable local pre-commit check: + +```bash +pip install -e .[dev] +pre-commit install +``` + +Run manually: + +```bash +python3 scripts/check_docs_sync.py --staged +``` + +CI check: + +- GitHub Actions workflow: `.github/workflows/docs-sync-check.yml` +- Runs automatically on pull requests. + ## Authentication model PortfolioHub currently supports bearer tokens with three permission levels. diff --git a/docs/api-examples.md b/docs/api-examples.md new file mode 100644 index 0000000..fa907df --- /dev/null +++ b/docs/api-examples.md @@ -0,0 +1,287 @@ +# PortfolioHub API Examples + +## 1. Purpose + +This document contains example requests and responses for PortfolioHub HTTP APIs. + +- Sync rule: when files under `app/api/` or `app/schemas/` change, this document must be updated in the same commit/PR. +- Companion spec: `docs/portfoliohub-spec.md` + +## 2. Conventions + +- Base URL example: `http://localhost:8000` +- Auth header format: `Authorization: Bearer ` +- JSON examples are representative and may omit some optional fields. + +## 3. Health + +### 3.1 GET /health + +Request: + +```bash +curl -X GET "http://localhost:8000/health" +``` + +Response 200: + +```json +{ + "status": "ok" +} +``` + +## 4. Portfolios + +### 4.1 GET /portfolios + +Request: + +```bash +curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \ + -H "Authorization: Bearer " +``` + +Response 200: + +```json +[ + { + "portfolio_id": "krw-btc", + "broker": "upbit", + "status": "enabled", + "symbol": "KRW-BTC", + "strategy_name": "target_value", + "strategy_params": {}, + "cash_balance": 1000000.0, + "asset_quantity": 0.1, + "price": 100000000.0, + "profit_buffer": 0.0, + "current_strategy_set_id": "stv-001", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z", + "last_trade_at": "2026-04-03T00:00:00Z", + "total_value": 11000000.0 + } +] +``` + +### 4.2 GET /portfolios/{portfolio_id} + +Request: + +```bash +curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \ + -H "Authorization: Bearer " +``` + +Response 200: + +```json +{ + "portfolio": { + "portfolio_id": "krw-btc", + "broker": "upbit", + "status": "enabled", + "symbol": "KRW-BTC", + "strategy_name": "target_value", + "strategy_params": {}, + "cash_balance": 1000000.0, + "asset_quantity": 0.1, + "price": 100000000.0, + "profit_buffer": 0.0, + "current_strategy_set_id": "stv-001", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z", + "last_trade_at": "2026-04-03T00:00:00Z" + }, + "recent_entries": [ + { + "portfolio_id": "krw-btc", + "recorded_at": "2026-04-03T00:00:00Z", + "action": "buy", + "price": 100000000.0, + "quantity": 0.1, + "fee_amount": 0.0, + "tax_amount": 0.0, + "cash_balance_after": 1000000.0, + "asset_quantity_after": 0.1, + "total_value_after": 11000000.0, + "profit_buffer_after": 0.0, + "strategy_set_id": "stv-001", + "note": "" + } + ], + "strategy_set": { + "id": "stv-001", + "strategy_name": "target_value", + "strategy_config": "{}", + "created_at": "2026-04-03T00:00:00Z" + }, + "live_quantity": null +} +``` + +### 4.3 POST /portfolios + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "portfolio_id": "krw-btc", + "broker": "upbit", + "status": "enabled", + "symbol": "KRW-BTC", + "strategy_name": "target_value", + "strategy_params": {}, + "cash_balance": 1000000, + "asset_quantity": 0, + "asset_price": 0, + "profit_buffer": 0 + }' +``` + +Response 201: + +- Response body shape is `PortfolioDetailResponse` (same as 4.2). + +### 4.4 PATCH /portfolios/{portfolio_id} + +Request: + +```bash +curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "broker": "upbit", + "symbol": "KRW-BTC" + }' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.5 POST /portfolios/{portfolio_id}/buy + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/buy" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.6 POST /portfolios/{portfolio_id}/sell + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/sell" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.7 POST /portfolios/{portfolio_id}/invest + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/invest" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"movement_type": "deposit", "amount": 50000, "note": ""}' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.8 POST /portfolios/{portfolio_id}/transfer + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/transfer" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"direction": "to-buffer", "amount": 10000, "note": ""}' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.9 POST /portfolios/{portfolio_id}/reset + +Request: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"infer_symbol": "KRW-BTC"}' +``` + +Response 200: + +- Response body shape is `PortfolioDetailResponse`. + +### 4.10 DELETE /portfolios/{portfolio_id} + +Request: + +```bash +curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \ + -H "Authorization: Bearer " +``` + +Response 204: + +- No content. + +## 5. Common Error Examples + +### 5.1 Missing token + +Response 401: + +```json +{ + "detail": "Missing bearer token" +} +``` + +### 5.2 Invalid token + +Response 401: + +```json +{ + "detail": "Invalid token" +} +``` + +### 5.3 Insufficient permission + +Response 403: + +```json +{ + "detail": "Insufficient permission" +} +``` diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md new file mode 100644 index 0000000..4a85638 --- /dev/null +++ b/docs/db-schema-spec.md @@ -0,0 +1,157 @@ +# PortfolioHub DB Schema Specification + +## 1. Purpose + +This document defines the database schema in an AI-agent-friendly format. + +- Source of truth: Alembic revisions in `migrations/versions` +- Current baseline revision: `20260403_0001` +- Baseline file: `migrations/versions/20260403_0001_initial_portfolio_schema.py` + +## 2. Scope + +Schema objects covered by baseline revision: + +- `strategy_sets` +- `portfolios` +- `portfolio_ledger` +- secondary indexes +- Timescale hypertable conversion rule for `portfolio_ledger` + +## 3. Conventions + +- Data type names follow SQLAlchemy/Alembic declaration. +- `NULL` means nullable. +- Default means DB-level `server_default` where defined. +- Relationship columns are documented even when no FK constraint exists. + +## 4. Tables + +### 4.1 strategy_sets + +Purpose: + +- Stores normalized strategy configuration snapshots. + +Columns: + +| Column | Type | Null | Default | Constraints / Notes | +|---|---|---|---|---| +| id | Text | NO | - | Primary Key | +| strategy_name | Text | NO | - | Part of unique pair | +| strategy_config | Text | NO | "" | Part of unique pair | +| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | + +Constraints: + +- Primary key: `id` +- Unique: `uq_strategy_sets_name_config` on (`strategy_name`, `strategy_config`) + +### 4.2 portfolios + +Purpose: + +- Stores current portfolio state and strategy linkage. + +Columns: + +| Column | Type | Null | Default | Constraints / Notes | +|---|---|---|---|---| +| portfolio_id | Text | NO | - | Primary Key | +| broker | Text | NO | "kis" | Broker code | +| status | Text | NO | "enabled" | Portfolio status | +| symbol | Text | NO | - | Trading symbol | +| strategy_name | Text | NO | "" | Strategy label | +| strategy_params | JSONB | NO | '{}'::jsonb | Strategy parameters | +| cash_balance | Float | NO | - | Current cash balance | +| asset_quantity | Float | NO | - | Current asset quantity | +| asset_price | Float | NO | 0 | Current price snapshot | +| profit_buffer | Float | NO | 0 | Profit buffer amount | +| current_strategy_set_id | Text | YES | - | Logical reference to `strategy_sets.id` (no FK) | +| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | +| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | +| last_trade_at | DateTime(timezone=True) | YES | - | Last trade time | + +Constraints: + +- Primary key: `portfolio_id` +- Foreign key constraints: none in baseline + +### 4.3 portfolio_ledger + +Purpose: + +- Stores append-like ledger snapshots per portfolio action. + +Columns: + +| Column | Type | Null | Default | Constraints / Notes | +|---|---|---|---|---| +| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` (no FK) | +| recorded_at | DateTime(timezone=True) | NO | - | Event timestamp | +| action | Text | NO | - | Action type | +| price | Float | NO | - | Trade/valuation unit price | +| quantity | Float | NO | - | Quantity delta | +| fee_amount | Float | NO | 0 | Fees | +| tax_amount | Float | NO | 0 | Taxes | +| cash_balance_after | Float | NO | - | Post-action cash | +| asset_quantity_after | Float | NO | - | Post-action asset quantity | +| total_value_after | Float | NO | - | Post-action total value | +| profit_buffer_after | Float | NO | 0 | Post-action profit buffer | +| strategy_set_id | Text | NO | "legacy" | Logical strategy set reference | +| note | Text | NO | "" | Operator/system note | + +Constraints: + +- Primary key: none in baseline +- Foreign key constraints: none in baseline + +## 5. Indexes + +### 5.1 idx_portfolio_ledger_portfolio_time + +- Table: `portfolio_ledger` +- Columns/expressions: (`portfolio_id`, `recorded_at DESC`) +- Unique: no +- Intent: fetch recent ledger entries per portfolio efficiently. + +### 5.2 idx_portfolios_broker_status_updated + +- Table: `portfolios` +- Columns/expressions: (`broker`, `status`, `updated_at DESC`) +- Unique: no +- Intent: speed list/filter queries by broker/status and recency. + +## 6. TimescaleDB Behavior + +Conditional behavior in migration upgrade: + +- Checks whether extension `timescaledb` exists. +- If present, executes: + +`create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)` + +Implication: + +- `portfolio_ledger` acts as a hypertable in environments with TimescaleDB extension enabled. + +## 7. Integrity Notes + +Current baseline intentionally has no DB-level FK constraints. + +- Logical relations are managed by application/repository logic. +- When adding FK constraints in future migrations, update this document and include migration IDs. + +## 8. Change Management Rule + +When schema changes: + +1. Add/modify Alembic revision under `migrations/versions`. +2. Update this document in the same commit. +3. Keep revision mapping table below in sync. + +## 9. Revision Mapping + +| Revision ID | File | Summary | +|---|---|---| +| 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md new file mode 100644 index 0000000..ae46b4b --- /dev/null +++ b/docs/portfoliohub-spec.md @@ -0,0 +1,380 @@ +# PortfolioHub Specification + +## 1. Overview + +PortfolioHub is a FastAPI-based portfolio lifecycle service with a Typer CLI. + +- Runtime: Python 3.11+ +- API framework: FastAPI +- CLI framework: Typer +- DB driver: Psycopg v3 +- Migration: Alembic + +Primary goals: + +- Manage portfolio lifecycle (create/update/delete/reset) +- Record trade and balance-affecting actions (buy/sell/invest/transfer) +- Expose status and ledger snapshots through API and CLI +- Keep API and CLI behavior aligned through a shared service/repository layer + +## 2. Scope and Non-Goals + +In scope: + +- Authenticated portfolio read and write operations +- Role-based access control (read, trade-write, admin) +- Optional PostgreSQL/Timescale-backed repository via feature flag +- Operational commands for DB bootstrap and migration + +Current non-goals / limitations: + +- In-memory fallback repository is not implemented for production usage +- External broker execution is not handled here (service focuses on portfolio state) +- Advanced observability wiring for Redis/Loki is not yet implemented in runtime paths + +## 3. Architecture + +Layered components: + +- API layer: HTTP routes and request validation +- CLI layer: command parsing and output rendering +- Service layer: orchestration and response shaping +- Domain layer: business logic rules +- Repository layer: persistence abstraction and concrete Postgres implementation + +Key design principle: + +- API and CLI call the same `PortfolioService`, so behavior is consistent regardless of entrypoint. + +## 4. Entrypoints + +HTTP service: + +- App factory: `app.main:create_app` +- ASGI app: `app.main:app` +- OpenAPI docs: `/docs` +- ReDoc: `/redoc` + +CLI: + +- Console script: `portfoliohub` +- Entry function: `app.cli.main:main` + +## 5. Configuration Specification + +Configuration precedence (high to low): + +1. Environment variables +2. `.env` file (loaded at startup if present) +3. Code defaults + +Core settings: + +- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins +- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens +- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens +- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens +- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true` + +Database settings (active source): + +- `TIMESCALEDB_HOST` +- `TIMESCALEDB_PORT` +- `TIMESCALEDB_NAME` +- `TIMESCALEDB_USER` +- `TIMESCALEDB_PASSWORD` +- `TIMESCALEDB_SSLMODE` +- `TIMESCALEDB_CONNECT_TIMEOUT_SEC` +- `TIMESCALEDB_APPLICATION_NAME` + +Derived setting: + +- `active_database_url` uses PostgreSQL psycopg URL format with encoded credentials and query options. + +## 6. Authentication and Authorization + +Auth scheme: + +- `Authorization: Bearer ` required for protected API routes + +Permission levels: + +- `read` +- `trade-write` +- `admin` + +Authorization hierarchy: + +- `admin` can access all endpoints +- `trade-write` can access `trade-write` + `read` +- `read` can access only read endpoints + +Failure responses: + +- Missing/invalid bearer header: `401` +- Unknown token: `401` +- Insufficient permission: `403` + +## 7. HTTP API Specification + +Base routes: + +- Health: `/health` +- Portfolio: `/portfolios` + +### 7.1 Health Endpoint + +- `GET /health` +- Auth: none +- Response `200`: + +```json +{"status": "ok"} +``` + +### 7.2 Portfolio Endpoints + +#### List Portfolios + +- `GET /portfolios` +- Auth: `read` +- Query params: + - `broker` (optional string) + - `status_filter` (optional string) +- Response `200`: array of `PortfolioListItem` + +#### Get Portfolio Status + +- `GET /portfolios/{portfolio_id}` +- Auth: `read` +- Query params: + - `recent_trades` (int, default `5`) +- Response `200`: `PortfolioDetailResponse` + +#### Create Portfolio + +- `POST /portfolios` +- Auth: `trade-write` +- Body: `CreatePortfolioRequest` +- Response `201`: `PortfolioDetailResponse` + +#### Update Portfolio + +- `PATCH /portfolios/{portfolio_id}` +- Auth: `trade-write` +- Body: `UpdatePortfolioRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Buy + +- `POST /portfolios/{portfolio_id}/buy` +- Auth: `trade-write` +- Body: `BuyRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Sell + +- `POST /portfolios/{portfolio_id}/sell` +- Auth: `trade-write` +- Body: `SellRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Invest + +- `POST /portfolios/{portfolio_id}/invest` +- Auth: `trade-write` +- Body: `InvestRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Transfer + +- `POST /portfolios/{portfolio_id}/transfer` +- Auth: `trade-write` +- Body: `TransferRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Reset + +- `POST /portfolios/{portfolio_id}/reset` +- Auth: `trade-write` +- Body: `ResetPortfolioRequest` +- Response `200`: `PortfolioDetailResponse` + +#### Delete + +- `DELETE /portfolios/{portfolio_id}` +- Auth: `admin` +- Query params: + - `keep_ledger` (bool, default `false`) +- Response `204`: no content + +### 7.3 Example Requests and Responses + +- See `docs/api-examples.md` for maintained request/response examples. + +## 8. Schema Specification + +### 8.1 Request Models + +`CreatePortfolioRequest` + +- `portfolio_id: str` +- `broker: str = "kis"` +- `status: str = "enabled"` +- `symbol: str` +- `strategy_name: str = "target_value"` +- `strategy_params: object = {}` +- `cash_balance: float >= 0` +- `asset_quantity: float >= 0` +- `asset_price: float >= 0` +- `profit_buffer: float >= 0` + +`UpdatePortfolioRequest` (all fields optional) + +- `broker: str | null` +- `status: str | null` +- `symbol: str | null` +- `strategy_name: str | null` +- `strategy_params: object | null` +- `asset_price: float >= 0 | null` + +`BuyRequest` / `SellRequest` + +- `quantity: float > 0` +- `price: float > 0` +- `fee: float >= 0` +- `tax: float >= 0` +- `note: str = ""` + +`InvestRequest` + +- `movement_type: "deposit" | "profit" | "withdraw"` +- `amount: float > 0` +- `note: str = ""` + +`TransferRequest` + +- `direction: "to-buffer" | "from-buffer"` +- `amount: float > 0` +- `note: str = ""` + +`ResetPortfolioRequest` + +- `infer_symbol: str | null` + +### 8.2 Response Models + +`PortfolioListItem` + +- Includes portfolio summary fields and computed `total_value` + +`PortfolioDetailResponse` + +- `portfolio: PortfolioSummary` +- `recent_entries: LedgerEntryResponse[]` +- `strategy_set: StrategySetResponse | null` +- `live_quantity: float | null` + +`LedgerEntryResponse` + +- Snapshot-oriented ledger response including balances after each action + +`ErrorResponse` + +- `detail: str` + +## 9. CLI Specification + +Global option: + +- `--format [table|json]` (default: `table`) + +Commands: + +- `portfoliohub init-db [--maintenance-db postgres]` +- `portfoliohub migrate-db` +- `portfoliohub health-check` + +Portfolio subcommands: + +- `portfoliohub portfolio list [--broker ...] [--status ...]` +- `portfoliohub portfolio status [--recent 5]` +- `portfoliohub portfolio create --symbol ... [options] [--force]` +- `portfoliohub portfolio update [options] [--force]` +- `portfoliohub portfolio buy --quantity ... --price ... [options] [--force]` +- `portfoliohub portfolio sell --quantity ... --price ... [options] [--force]` +- `portfoliohub portfolio invest --type --amount ... [options] [--force]` +- `portfoliohub portfolio transfer --direction --amount ... [options] [--force]` +- `portfoliohub portfolio reset [--infer-symbol ...] [--force]` +- `portfoliohub portfolio delete [--keep-ledger] [--force]` + +Confirmation behavior: + +- Mutating portfolio commands request confirmation by default. +- `--force` bypasses confirmation prompts. + +## 10. Persistence and Migration Specification + +Repository selection: + +- If `PORTFOLIOHUB_DB_ENABLED=false`: an unimplemented placeholder repository raises `FeatureNotReadyError` on access. +- If `PORTFOLIOHUB_DB_ENABLED=true`: `PostgresPortfolioRepository` is used. + +Migration commands: + +- `init-db`: create target DB if missing, then apply Alembic `head` +- `migrate-db`: apply Alembic `head` to existing target DB + +Migration source of truth: + +- `migrations/versions` + +DB schema specification: + +- Detailed table/column/index constraints are documented in `docs/db-schema-spec.md`. +- The documentation is generated from and must stay consistent with Alembic revisions under `migrations/versions`. + +## 11. Error Handling + +Application-level errors are translated through registered exception handlers. + +Typical error classes include: + +- authorization failures (`401`, `403`) +- domain/repository-level failures surfaced by service operations +- feature-not-ready errors when DB mode is disabled but persistence is required + +## 12. CORS Policy + +CORS middleware is enabled with: + +- `allow_origins` from configured origins list +- `allow_credentials = true` +- `allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]` +- `allow_headers = ["Authorization", "Content-Type"]` + +## 13. Testing Specification + +Test framework: + +- `pytest` + +Configured behavior: + +- test paths: `tests` +- recursion excluded: `temp`, `.git`, `.venv` + +Current test modules include API, auth, CLI, and domain service coverage. + +## 14. Versioning and Compatibility + +- Current package version: `0.1.0` +- API version exposure follows application version setting (`app_version`) +- Minimum Python version: `3.11` + +## 15. Future Extension Points + +- Redis-backed cache/session integration +- Loki logging pipeline integration +- Additional brokers and order-execution adapters +- Enhanced operational/admin endpoints diff --git a/pyproject.toml b/pyproject.toml index 595366c..2ecda84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ portfoliohub = "app.cli.main:main" [project.optional-dependencies] dev = [ "httpx>=0.27.0,<1.0.0", + "pre-commit>=3.8.0,<4.0.0", "pytest>=8.3.0,<9.0.0", ] diff --git a/scripts/check_docs_sync.py b/scripts/check_docs_sync.py new file mode 100644 index 0000000..7e315db --- /dev/null +++ b/scripts/check_docs_sync.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Guardrail check for implementation-to-spec synchronization. + +Rules: +- If files under app/ change, docs/portfoliohub-spec.md must be part of the same change set. +- If files under app/api/ or app/schemas/ change, docs/api-examples.md must be part of the same change set. +- If files under migrations/versions/ change, docs/db-schema-spec.md must be part of the same change set. +- If migrations/versions/ files change, revision IDs found in changed migration files + must appear in docs/db-schema-spec.md. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GENERAL_SPEC = "docs/portfoliohub-spec.md" +DB_SPEC = "docs/db-schema-spec.md" +API_EXAMPLES_SPEC = "docs/api-examples.md" +APP_PREFIX = "app/" +API_PREFIXES = ("app/api/", "app/schemas/") +MIGRATION_PREFIX = "migrations/versions/" + + +class CheckError(Exception): + pass + + +def _run_git(args: list[str]) -> str: + result = subprocess.run( + ["git", *args], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def _ref_exists(ref: str) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", ref], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def _get_changed_files(*, staged: bool, against: str | None, head: str) -> list[str]: + if staged: + output = _run_git(["diff", "--name-only", "--cached", "--diff-filter=ACMR"]) + elif against: + if not _ref_exists(against): + raise CheckError(f"base ref not found: {against}") + if not _ref_exists(head): + raise CheckError(f"head ref not found: {head}") + output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", f"{against}...{head}"]) + else: + if not _ref_exists("HEAD~1"): + # For a repository with a single commit, there is nothing to compare against. + return [] + output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", "HEAD~1", "HEAD"]) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def _extract_revision_id(path: Path) -> str | None: + text = path.read_text(encoding="utf-8") + match = re.search(r'^revision\s*=\s*["\']([^"\']+)["\']', text, flags=re.MULTILINE) + return match.group(1) if match else None + + +def _collect_changed_revision_ids(changed_files: list[str]) -> list[str]: + revision_ids: list[str] = [] + for file in changed_files: + if not file.startswith(MIGRATION_PREFIX) or not file.endswith(".py"): + continue + migration_path = ROOT / file + if not migration_path.exists(): + continue + revision_id = _extract_revision_id(migration_path) + if revision_id: + revision_ids.append(revision_id) + return revision_ids + + +def run_check(*, staged: bool, against: str | None, head: str) -> int: + try: + changed_files = _get_changed_files(staged=staged, against=against, head=head) + except CheckError as exc: + print(f"docs-sync-check: {exc}") + return 2 + except subprocess.CalledProcessError as exc: + print("docs-sync-check: failed to read git diff") + print(exc.stderr.strip()) + return 2 + + if not changed_files: + print("docs-sync-check: no changes detected") + return 0 + + needs_general_spec = any(path.startswith(APP_PREFIX) for path in changed_files) + needs_api_examples = any(path.startswith(prefix) for prefix in API_PREFIXES for path in changed_files) + needs_db_spec = any(path.startswith(MIGRATION_PREFIX) for path in changed_files) + + errors: list[str] = [] + + if needs_general_spec and GENERAL_SPEC not in changed_files: + errors.append( + "Application code changed but docs/portfoliohub-spec.md was not updated in the same commit/PR." + ) + + if needs_api_examples and API_EXAMPLES_SPEC not in changed_files: + errors.append( + "API/schema code changed but docs/api-examples.md was not updated in the same commit/PR." + ) + + if needs_db_spec and DB_SPEC not in changed_files: + errors.append( + "Migration changed but docs/db-schema-spec.md was not updated in the same commit/PR." + ) + + if needs_db_spec: + db_spec_path = ROOT / DB_SPEC + if not db_spec_path.exists(): + errors.append("docs/db-schema-spec.md is missing.") + else: + db_spec_text = db_spec_path.read_text(encoding="utf-8") + changed_revisions = _collect_changed_revision_ids(changed_files) + missing_revisions = [rev for rev in changed_revisions if rev not in db_spec_text] + if missing_revisions: + errors.append( + "Migration revision IDs missing in docs/db-schema-spec.md: " + + ", ".join(sorted(set(missing_revisions))) + ) + + if errors: + print("docs-sync-check: FAILED") + for error in errors: + print(f"- {error}") + print("\nChanged files:") + for file in changed_files: + print(f"- {file}") + return 1 + + print("docs-sync-check: OK") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Verify implementation and docs stay in sync.") + parser.add_argument( + "--staged", + action="store_true", + help="Check staged files (for pre-commit).", + ) + parser.add_argument( + "--against", + default=None, + help="Git base ref/sha to diff against (for CI).", + ) + parser.add_argument( + "--head", + default="HEAD", + help="Git head ref/sha used with --against.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + sys.exit(run_check(staged=args.staged, against=args.against, head=args.head))