Wauplin HF Staff commited on
Commit
3ca3e6a
·
verified ·
1 Parent(s): c93db8e

Database backup

Browse files
Dockerfile CHANGED
@@ -77,6 +77,9 @@ RUN chown -R appuser:appuser /app/tmp_data
77
  # Set frontend path for FastAPI
78
  ENV FRONTEND_PATH=/app/frontend/dist
79
 
 
 
 
80
  # Switch to non-root user
81
  USER appuser
82
 
 
77
  # Set frontend path for FastAPI
78
  ENV FRONTEND_PATH=/app/frontend/dist
79
 
80
+ # Set HF_HUB_DISABLE_EXPERIMENTAL_WARNING to avoid warnings
81
+ ENV HF_HUB_DISABLE_EXPERIMENTAL_WARNING=1
82
+
83
  # Switch to non-root user
84
  USER appuser
85
 
README.md CHANGED
@@ -21,6 +21,10 @@ Spaces are very well suited to vibe-code demos using Cursor, Lovable, Claude Cod
21
 
22
  The backend (Python + FastAPI) and the frontend (TS + React + Tailwind) are located respectively in the `backend/` and `frontend/` folder.
23
 
 
 
 
 
24
  ### Backend
25
 
26
  To run the backend, you'll need `uv` and Python3.12 installed.
@@ -67,8 +71,8 @@ Configuration should be automatically done if you clone the on the Hugging Face
67
  If you want to run it on your own, you can use Docker:
68
 
69
  ```bash
70
- docker build -t fastapi-react-space .
71
- docker run -e HF_TOKEN -p 9481:9481 fastapi-react-space
72
  ```
73
 
74
- Note that when running in Docker, the app runs in production mode without hot-reloading.
 
21
 
22
  The backend (Python + FastAPI) and the frontend (TS + React + Tailwind) are located respectively in the `backend/` and `frontend/` folder.
23
 
24
+ ### Prerequisite
25
+
26
+ To run this app locally, you MUST have `HF_TOKEN` set as environment variable with a valid token. You can generate a new one from your [user settings](https://huggingface.co/settings/tokens). This token is necessary to fake the OAuth workflow. If you click on "Sign in with Huggingface" in your dev environment, you will be authenticated as yourself and your local `HF_TOKEN` will be used. This is a mock to allow local testing while avoiding more complex configuration.
27
+
28
  ### Backend
29
 
30
  To run the backend, you'll need `uv` and Python3.12 installed.
 
71
  If you want to run it on your own, you can use Docker:
72
 
73
  ```bash
74
+ make docker-build
75
+ make docker-run
76
  ```
77
 
78
+ When running in Docker, the app runs in production mode without hot-reloading.
backend/Makefile CHANGED
@@ -1,7 +1,7 @@
1
  .PHONY: quality style
2
 
3
  dev:
4
- uv run uvicorn src.app:app --reload
5
 
6
  quality:
7
  ruff check src
@@ -13,4 +13,4 @@ style:
13
  ruff check --fix src
14
 
15
  preview:
16
- FRONTEND_PATH=../frontend/dist/ uv run uvicorn src.app:app
 
1
  .PHONY: quality style
2
 
3
  dev:
4
+ HF_HUB_DISABLE_EXPERIMENTAL_WARNING=1 uv run uvicorn src.app:app --reload
5
 
6
  quality:
7
  ruff check src
 
13
  ruff check --fix src
14
 
15
  preview:
16
+ HF_HUB_DISABLE_EXPERIMENTAL_WARNING=1 FRONTEND_PATH=../frontend/dist/ uv run uvicorn src.app:app
backend/src/app.py CHANGED
@@ -1,79 +1,10 @@
1
- from contextlib import asynccontextmanager
2
-
3
- from fastapi import Depends, FastAPI, HTTPException, Request
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from fastapi.responses import FileResponse, RedirectResponse
6
- from fastapi.staticfiles import StaticFiles
7
- from huggingface_hub import OAuthInfo, attach_huggingface_oauth, parse_huggingface_oauth
8
  from sqlmodel import select
9
 
10
- from . import constants
11
- from .database import get_session, init_db
12
  from .schemas import UserCount
13
 
14
-
15
- # Initialize database on startup
16
- @asynccontextmanager
17
- async def lifespan(app: FastAPI):
18
- init_db()
19
- yield
20
-
21
-
22
- # FastAPI app
23
- app = FastAPI(lifespan=lifespan)
24
-
25
-
26
- # Set CORS headers
27
- app.add_middleware(
28
- CORSMiddleware,
29
- allow_origins=[
30
- # Can't use "*" because frontend doesn't like it with "credentials: true"
31
- "http://localhost:5173",
32
- "http://0.0.0.0:9481",
33
- "http://localhost:9481",
34
- "http://127.0.0.1:9481",
35
- ],
36
- allow_credentials=True,
37
- allow_methods=["*"],
38
- allow_headers=["*"],
39
- )
40
-
41
- # Mount frontend from dist directory (if configured)
42
- if constants.SERVE_FRONTEND:
43
- # Production => Serve frontend from dist directory
44
- app.mount(
45
- "/assets",
46
- StaticFiles(directory=constants.FRONTEND_ASSETS_PATH),
47
- name="assets",
48
- )
49
-
50
- @app.get("/")
51
- async def serve_frontend():
52
- return FileResponse(constants.FRONTEND_INDEX_PATH)
53
-
54
- else:
55
- # Development => Redirect to dev frontend
56
- @app.get("/")
57
- async def redirect_to_frontend():
58
- return RedirectResponse("http://localhost:5173/")
59
-
60
-
61
- # Set up Hugging Face OAuth
62
- # To get OAuthInfo in an endpoint
63
- attach_huggingface_oauth(app)
64
-
65
-
66
- async def oauth_info_optional(request: Request) -> OAuthInfo | None:
67
- return parse_huggingface_oauth(request)
68
-
69
-
70
- async def oauth_info_required(request: Request) -> OAuthInfo:
71
- oauth_info = parse_huggingface_oauth(request)
72
- if oauth_info is None:
73
- raise HTTPException(
74
- status_code=401, detail="Unauthorized. Please Sign in with Hugging Face."
75
- )
76
- return oauth_info
77
 
78
 
79
  # Health check endpoint
@@ -85,7 +16,7 @@ async def health():
85
 
86
  # User endpoints
87
  @app.get("/api/user")
88
- async def get_user(oauth_info: OAuthInfo | None = Depends(oauth_info_optional)):
89
  """Get user information."""
90
  return {
91
  "connected": oauth_info is not None,
@@ -94,9 +25,7 @@ async def get_user(oauth_info: OAuthInfo | None = Depends(oauth_info_optional)):
94
 
95
 
96
  @app.get("/api/user/count")
97
- async def get_user_count(
98
- oauth_info: OAuthInfo = Depends(oauth_info_required),
99
- ) -> UserCount:
100
  """Get user count."""
101
  with get_session() as session:
102
  statement = select(UserCount).where(UserCount.name == oauth_info.user_info.name)
@@ -107,9 +36,7 @@ async def get_user_count(
107
 
108
 
109
  @app.post("/api/user/count/increment")
110
- async def increment_user_count(
111
- oauth_info: OAuthInfo = Depends(oauth_info_required),
112
- ) -> UserCount:
113
  """Increment user count."""
114
  with get_session() as session:
115
  statement = select(UserCount).where(UserCount.name == oauth_info.user_info.name)
 
 
 
 
 
 
 
 
1
  from sqlmodel import select
2
 
3
+ from .app_factory import OptionalOAuth, RequiredOAuth, create_app, get_session
 
4
  from .schemas import UserCount
5
 
6
+ # Configure FastAPI app + database
7
+ app = create_app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  # Health check endpoint
 
16
 
17
  # User endpoints
18
  @app.get("/api/user")
19
+ async def get_user(oauth_info: OptionalOAuth):
20
  """Get user information."""
21
  return {
22
  "connected": oauth_info is not None,
 
25
 
26
 
27
  @app.get("/api/user/count")
28
+ async def get_user_count(oauth_info: RequiredOAuth) -> UserCount:
 
 
29
  """Get user count."""
30
  with get_session() as session:
31
  statement = select(UserCount).where(UserCount.name == oauth_info.user_info.name)
 
36
 
37
 
38
  @app.post("/api/user/count/increment")
39
+ async def increment_user_count(oauth_info: RequiredOAuth) -> UserCount:
 
 
40
  """Increment user count."""
41
  with get_session() as session:
42
  statement = select(UserCount).where(UserCount.name == oauth_info.user_info.name)
backend/src/app_factory.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from contextlib import asynccontextmanager, contextmanager
3
+ from typing import Annotated, Generator
4
+
5
+ from fastapi import Depends, FastAPI, HTTPException, Request
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse, RedirectResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ from huggingface_hub import OAuthInfo, attach_huggingface_oauth, parse_huggingface_oauth
10
+ from sqlalchemy.engine import Engine
11
+ from sqlmodel import Session, SQLModel, create_engine
12
+
13
+ from . import constants
14
+
15
+ _ENGINE_SINGLETON: Engine | None = None
16
+
17
+ # OAuth utilities
18
+
19
+
20
+ async def _oauth_info_optional(request: Request) -> OAuthInfo | None:
21
+ return parse_huggingface_oauth(request)
22
+
23
+
24
+ async def _oauth_info_required(request: Request) -> OAuthInfo:
25
+ oauth_info = parse_huggingface_oauth(request)
26
+ if oauth_info is None:
27
+ raise HTTPException(
28
+ status_code=401, detail="Unauthorized. Please Sign in with Hugging Face."
29
+ )
30
+ return oauth_info
31
+
32
+
33
+ OptionalOAuth = Annotated[OAuthInfo | None, Depends(_oauth_info_optional)]
34
+ RequiredOAuth = Annotated[OAuthInfo, Depends(_oauth_info_required)]
35
+
36
+
37
+ # Database utilities
38
+ def _get_engine() -> Engine:
39
+ """Get the engine."""
40
+ global _ENGINE_SINGLETON
41
+ if _ENGINE_SINGLETON is None:
42
+ _ENGINE_SINGLETON = create_engine(constants.DATABASE_URL)
43
+ return _ENGINE_SINGLETON
44
+
45
+
46
+ @contextmanager
47
+ def get_session() -> Generator[Session, None, None]:
48
+ """Get a session from the engine."""
49
+ engine = _get_engine()
50
+ with Session(engine) as session:
51
+ yield session
52
+
53
+
54
+ @asynccontextmanager
55
+ async def _database_lifespan(app: FastAPI):
56
+ """Handle database lifespan.
57
+
58
+ 1. If backup is enabled enabled,
59
+ a. Try to load backup from remote dataset. If it fails, delete local database for a fresh start.
60
+ b. Start back-up scheduler.
61
+ 2. If disabled, create local database file or reuse existing one.
62
+ 3. Initialize database.
63
+ 4. Yield control to FastAPI app.
64
+ 5. Close database + force push backup to remote dataset.
65
+ """
66
+ if constants.BACKUP_DB:
67
+ from huggingface_hub import CommitScheduler, hf_hub_download
68
+
69
+ print("Back-up database is enabled")
70
+
71
+ # Try to load backup from remote dataset
72
+ print("Trying to load backup from remote dataset...")
73
+ try:
74
+ hf_hub_download(
75
+ repo_id=constants.BACKUP_DATASET_ID, # type: ignore[arg-type]
76
+ repo_type="dataset",
77
+ filename="database.db",
78
+ token=constants.HF_TOKEN,
79
+ local_dir=constants.DATABASE_PATH,
80
+ force_download=True,
81
+ )
82
+ except Exception:
83
+ # If backup is enabled but no backup is found, delete local database to prevent confusion.
84
+ print("Couldn't find backup in remote dataset.")
85
+ print("Deleting local database for a fresh start.")
86
+ os.remove(constants.DATABASE_FILE)
87
+
88
+ # Start back-up scheduler
89
+ print("Starting back-up scheduler (save every 5 minutes)...")
90
+ scheduler = CommitScheduler(
91
+ repo_id=constants.BACKUP_DATASET_ID, # type: ignore[arg-type]
92
+ folder_path=constants.DATABASE_PATH,
93
+ allow_patterns="database.db",
94
+ token=constants.HF_TOKEN,
95
+ private=True,
96
+ repo_type="dataset",
97
+ every=5,
98
+ )
99
+
100
+ engine = _get_engine()
101
+ SQLModel.metadata.create_all(engine)
102
+
103
+ yield
104
+
105
+ print("Closing database...")
106
+ global _ENGINE_SINGLETON
107
+ if _ENGINE_SINGLETON is not None:
108
+ _ENGINE_SINGLETON.dispose()
109
+ _ENGINE_SINGLETON = None
110
+
111
+ if constants.BACKUP_DB:
112
+ print("Pushing backup to remote dataset...")
113
+ scheduler.push_to_hub()
114
+
115
+
116
+ def create_app() -> FastAPI:
117
+ # FastAPI app
118
+ app = FastAPI(lifespan=_database_lifespan)
119
+
120
+ # Set CORS headers
121
+ app.add_middleware(
122
+ CORSMiddleware,
123
+ allow_origins=[
124
+ # Can't use "*" because frontend doesn't like it with "credentials: true"
125
+ "http://localhost:5173",
126
+ "http://0.0.0.0:9481",
127
+ "http://localhost:9481",
128
+ "http://127.0.0.1:9481",
129
+ ],
130
+ allow_credentials=True,
131
+ allow_methods=["*"],
132
+ allow_headers=["*"],
133
+ )
134
+
135
+ # Mount frontend from dist directory (if configured)
136
+ if constants.SERVE_FRONTEND:
137
+ # Production => Serve frontend from dist directory
138
+ app.mount(
139
+ "/assets",
140
+ StaticFiles(directory=constants.FRONTEND_ASSETS_PATH), # type: ignore[invalid-argument-type]
141
+ name="assets",
142
+ )
143
+
144
+ @app.get("/")
145
+ async def serve_frontend():
146
+ return FileResponse(constants.FRONTEND_INDEX_PATH) # type: ignore[invalid-argument-type]
147
+
148
+ else:
149
+ # Development => Redirect to dev frontend
150
+ @app.get("/")
151
+ async def redirect_to_frontend():
152
+ return RedirectResponse("http://localhost:5173/")
153
+
154
+ # Set up Hugging Face OAuth
155
+ # To get OAuthInfo in an endpoint
156
+ attach_huggingface_oauth(app)
157
+
158
+ return app
backend/src/constants.py CHANGED
@@ -13,9 +13,11 @@ DATABASE_URL = f"sqlite:///{DATABASE_FILE}"
13
  # In practice: is it running in a production environment?
14
  FRONTEND_PATH = os.getenv("FRONTEND_PATH")
15
  SERVE_FRONTEND = os.getenv("FRONTEND_PATH") is not None
16
- FRONTEND_ASSETS_PATH = os.path.join(FRONTEND_PATH, "assets") if SERVE_FRONTEND else None
 
 
17
  FRONTEND_INDEX_PATH = (
18
- os.path.join(FRONTEND_PATH, "index.html") if SERVE_FRONTEND else None
19
  )
20
 
21
 
@@ -28,3 +30,9 @@ if SERVE_FRONTEND and (
28
  f"FRONTEND_PATH {FRONTEND_PATH} has not been built correctly. Please build the frontend first by running `pnpm build` from the 'frontend/' directory."
29
  " If you want to run the server in development mode, run `make dev` from the 'backend/' directory and `pnpm dev` from the 'frontend/' directory."
30
  )
 
 
 
 
 
 
 
13
  # In practice: is it running in a production environment?
14
  FRONTEND_PATH = os.getenv("FRONTEND_PATH")
15
  SERVE_FRONTEND = os.getenv("FRONTEND_PATH") is not None
16
+ FRONTEND_ASSETS_PATH = (
17
+ os.path.join(FRONTEND_PATH, "assets") if FRONTEND_PATH is not None else None
18
+ )
19
  FRONTEND_INDEX_PATH = (
20
+ os.path.join(FRONTEND_PATH, "index.html") if FRONTEND_PATH is not None else None
21
  )
22
 
23
 
 
30
  f"FRONTEND_PATH {FRONTEND_PATH} has not been built correctly. Please build the frontend first by running `pnpm build` from the 'frontend/' directory."
31
  " If you want to run the server in development mode, run `make dev` from the 'backend/' directory and `pnpm dev` from the 'frontend/' directory."
32
  )
33
+
34
+
35
+ # Back-up Hub dataset (optional)
36
+ BACKUP_DATASET_ID = os.getenv("BACKUP_DATASET_ID")
37
+ HF_TOKEN = os.getenv("HF_TOKEN")
38
+ BACKUP_DB = BACKUP_DATASET_ID is not None and HF_TOKEN is not None
backend/src/database.py DELETED
@@ -1,31 +0,0 @@
1
- from contextlib import contextmanager
2
- from typing import Generator
3
-
4
- from sqlalchemy.engine import Engine
5
- from sqlmodel import Session, SQLModel, create_engine
6
-
7
- from . import constants
8
-
9
- _ENGINE_SINGLETON: Engine | None = None
10
-
11
-
12
- def get_engine() -> Engine:
13
- """Get the engine."""
14
- global _ENGINE_SINGLETON
15
- if _ENGINE_SINGLETON is None:
16
- _ENGINE_SINGLETON = create_engine(constants.DATABASE_URL)
17
- return _ENGINE_SINGLETON
18
-
19
-
20
- @contextmanager
21
- def get_session() -> Generator[Session, None, None]:
22
- """Get a session from the engine."""
23
- engine = get_engine()
24
- with Session(engine) as session:
25
- yield session
26
-
27
-
28
- def init_db() -> None:
29
- """Initialize the database."""
30
- engine = get_engine()
31
- SQLModel.metadata.create_all(engine)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/.env DELETED
@@ -1 +0,0 @@
1
- VITE_BACKEND_URL=http://127.0.0.1:8000
 
 
frontend/index.html CHANGED
@@ -2,9 +2,9 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Vite + React + TS</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/src/assets/huggingface.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>FastAPI + React Space Template</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>