Bootstrap Python rewrite skeleton

This commit is contained in:
2026-04-19 20:19:58 +02:00
parent 7818a3fb44
commit 31390882ef
72 changed files with 2273 additions and 62 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Application package for the Python rewrite skeleton."""
+2
View File
@@ -0,0 +1,2 @@
"""API package."""
+2
View File
@@ -0,0 +1,2 @@
"""Route modules."""
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.config import Settings
from app.dependencies import get_app_settings
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
@router.get("/", response_class=HTMLResponse)
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"notion_status": "Legacy scope, removed from the Python rewrite target.",
}
return templates.TemplateResponse(request, "home.html", context)
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.schemas.health import StatusResponse
router = APIRouter(tags=["system"])
@router.get("/status", response_model=StatusResponse)
def get_status() -> StatusResponse:
return StatusResponse(status="ok")
+50
View File
@@ -0,0 +1,50 @@
from functools import lru_cache
from pathlib import Path
from pydantic import computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Home Automation Backend (Python)"
app_env: str = "development"
app_debug: bool = False
app_host: str = "0.0.0.0"
app_port: int = 8000
database_url: str = "sqlite:///./data/app.db"
ticktick_client_id: str = ""
ticktick_client_secret: str = ""
ticktick_redirect_uri: str = ""
ticktick_token: str = ""
home_assistant_base_url: str = ""
home_assistant_auth_token: str = ""
home_assistant_action_task_project_id: str = ""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
@computed_field
@property
def is_development(self) -> bool:
return self.app_env.lower() == "development"
@computed_field
@property
def sqlite_path(self) -> Path | None:
prefix = "sqlite:///"
if not self.database_url.startswith(prefix):
return None
raw_path = self.database_url[len(prefix) :]
return Path(raw_path)
@lru_cache
def get_settings() -> Settings:
return Settings()
+29
View File
@@ -0,0 +1,29 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
connect_args: dict[str, object] = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
+15
View File
@@ -0,0 +1,15 @@
from collections.abc import Generator
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import get_db_session
def get_app_settings() -> Settings:
return get_settings()
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()
+2
View File
@@ -0,0 +1,2 @@
"""External integration placeholders for future migration."""
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass
from app.config import Settings
@dataclass(slots=True)
class HomeAssistantClient:
settings: Settings
def is_configured(self) -> bool:
return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token)
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass
from app.config import Settings
@dataclass(slots=True)
class TickTickClient:
settings: Settings
def is_configured(self) -> bool:
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret)
+45
View File
@@ -0,0 +1,45 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.api.routes import pages, status
from app.config import get_settings
def ensure_runtime_dirs() -> None:
settings = get_settings()
if settings.sqlite_path is not None:
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_runtime_dirs()
yield
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
lifespan=lifespan,
description=(
"Python rewrite skeleton for the home automation backend. "
"This stage provides only the foundation for future module migration."
),
)
static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router)
app.include_router(pages.router)
return app
app = create_app()
+2
View File
@@ -0,0 +1,2 @@
"""SQLAlchemy models package."""
+4
View File
@@ -0,0 +1,4 @@
from app.db import Base
__all__ = ["Base"]
+2
View File
@@ -0,0 +1,2 @@
"""Pydantic schemas package."""
+6
View File
@@ -0,0 +1,6 @@
from pydantic import BaseModel
class StatusResponse(BaseModel):
status: str
+2
View File
@@ -0,0 +1,2 @@
"""Service layer package."""
+6
View File
@@ -0,0 +1,6 @@
from app.config import Settings
def build_status_payload(settings: Settings) -> dict[str, str]:
return {"status": "ok", "environment": settings.app_env}
+95
View File
@@ -0,0 +1,95 @@
:root {
--bg: #f4f1ea;
--panel: rgba(255, 255, 255, 0.88);
--text: #1f2933;
--muted: #5b6875;
--accent: #2d6a4f;
--border: rgba(31, 41, 51, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(45, 106, 79, 0.18), transparent 28%),
linear-gradient(160deg, #f7f4ee 0%, #ece6d8 100%);
}
.shell {
width: min(880px, calc(100% - 32px));
margin: 48px auto;
}
.panel {
padding: 32px;
border: 1px solid var(--border);
border-radius: 24px;
background: var(--panel);
backdrop-filter: blur(12px);
box-shadow: 0 20px 60px rgba(31, 41, 51, 0.12);
}
.eyebrow {
margin: 0 0 8px;
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0 0 16px;
font-size: clamp(2rem, 4vw, 3.2rem);
}
.lead {
margin: 0 0 24px;
line-height: 1.7;
color: var(--muted);
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin: 0;
}
.meta div {
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(31, 41, 51, 0.06);
}
.meta dt {
margin-bottom: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.meta dd {
margin: 0;
font-size: 1.05rem;
}
a {
color: var(--accent);
}
@media (max-width: 640px) {
.shell {
margin: 24px auto;
}
.panel {
padding: 24px;
}
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
</head>
<body>
<main class="shell">
{% block content %}{% endblock %}
</main>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Python Rewrite Skeleton</p>
<h1>{{ app_name }}</h1>
<p class="lead">
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
测试、模板和容器化基础,不包含业务逻辑迁移。
</p>
<dl class="meta">
<div>
<dt>运行环境</dt>
<dd>{{ app_env }}</dd>
</div>
<div>
<dt>健康检查</dt>
<dd><a href="/status">/status</a></dd>
</div>
<div>
<dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd>
</div>
<div>
<dt>Notion</dt>
<dd>{{ notion_status }}</dd>
</div>
</dl>
</section>
{% endblock %}