Intial Deployment
Browse files- Dockerfile +35 -0
- README.md +1 -0
- main.py +186 -0
- requirements.txt +5 -0
Dockerfile
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use an official Python runtime as a parent image
|
2 |
+
FROM python:3.10-slim
|
3 |
+
|
4 |
+
# Set the working directory in the container
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Copy the requirements file into the container at /app
|
8 |
+
COPY requirements.txt .
|
9 |
+
|
10 |
+
# Install any needed packages specified in requirements.txt
|
11 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
12 |
+
pip install --no-cache-dir -r requirements.txt
|
13 |
+
|
14 |
+
# Copy the rest of the application code into the container at /app
|
15 |
+
COPY main.py .
|
16 |
+
|
17 |
+
# --- Define Volumes ---
|
18 |
+
# This tells Docker to manage these directories as volumes if not explicitly mounted.
|
19 |
+
# Data written here will persist in anonymous volumes by default.
|
20 |
+
VOLUME /app/data
|
21 |
+
VOLUME /root/.duckdb
|
22 |
+
# --- End Define Volumes ---
|
23 |
+
|
24 |
+
# Make API port 8000 available
|
25 |
+
EXPOSE 8000
|
26 |
+
# Make DuckDB UI port 8080 available (default)
|
27 |
+
EXPOSE 8080
|
28 |
+
|
29 |
+
# Define environment variables
|
30 |
+
ENV PYTHONUNBUFFERED=1
|
31 |
+
ENV UI_EXPECTED_PORT=8080
|
32 |
+
|
33 |
+
# Command to run the FastAPI application using Uvicorn
|
34 |
+
# The startup event in main.py will handle starting the DuckDB UI
|
35 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
@@ -7,6 +7,7 @@ sdk: docker
|
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
short_description: DuckDB Hosting with UI & FastAPI 4 SQL Calls & DB Downloads
|
|
|
10 |
---
|
11 |
|
12 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
short_description: DuckDB Hosting with UI & FastAPI 4 SQL Calls & DB Downloads
|
10 |
+
port:
|
11 |
---
|
12 |
|
13 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
main.py
ADDED
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import duckdb
|
3 |
+
from fastapi import FastAPI, HTTPException, Body
|
4 |
+
from fastapi.responses import FileResponse, JSONResponse
|
5 |
+
from pydantic import BaseModel, Field
|
6 |
+
from pathlib import Path
|
7 |
+
import logging
|
8 |
+
import time # Import time for potential startup delays
|
9 |
+
import asyncio
|
10 |
+
|
11 |
+
# --- Configuration ---
|
12 |
+
DB_DIR = Path("data")
|
13 |
+
DB_FILENAME = "mydatabase.db"
|
14 |
+
DB_FILE = DB_DIR / DB_FILENAME
|
15 |
+
UI_EXPECTED_PORT = 8080 # Default port DuckDB UI often tries first
|
16 |
+
|
17 |
+
# Ensure the data directory exists
|
18 |
+
DB_DIR.mkdir(parents=True, exist_ok=True)
|
19 |
+
|
20 |
+
# --- Logging Setup ---
|
21 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
22 |
+
logger = logging.getLogger(__name__)
|
23 |
+
|
24 |
+
# --- FastAPI App ---
|
25 |
+
app = FastAPI(
|
26 |
+
title="DuckDB API & UI Host",
|
27 |
+
description="Interact with DuckDB via API (/query, /download) and access the official DuckDB Web UI.",
|
28 |
+
version="1.0.0"
|
29 |
+
)
|
30 |
+
|
31 |
+
# --- Pydantic Models ---
|
32 |
+
class QueryRequest(BaseModel):
|
33 |
+
sql: str = Field(..., description="The SQL query to execute against DuckDB.")
|
34 |
+
|
35 |
+
class QueryResponse(BaseModel):
|
36 |
+
columns: list[str] | None = None
|
37 |
+
rows: list[dict] | None = None
|
38 |
+
message: str | None = None
|
39 |
+
error: str | None = None
|
40 |
+
|
41 |
+
# --- Helper Function ---
|
42 |
+
def execute_duckdb_query(sql_query: str, db_path: str = str(DB_FILE)):
|
43 |
+
"""Connects to DuckDB, executes a query, and returns results or error."""
|
44 |
+
con = None
|
45 |
+
try:
|
46 |
+
logger.info(f"Connecting to database: {db_path}")
|
47 |
+
con = duckdb.connect(database=db_path, read_only=False)
|
48 |
+
logger.info(f"Executing SQL: {sql_query[:200]}{'...' if len(sql_query) > 200 else ''}")
|
49 |
+
|
50 |
+
con.begin()
|
51 |
+
result_relation = con.execute(sql_query)
|
52 |
+
response_data = {"columns": None, "rows": None, "message": None, "error": None}
|
53 |
+
|
54 |
+
if result_relation.description:
|
55 |
+
columns = [desc[0] for desc in result_relation.description]
|
56 |
+
rows_raw = result_relation.fetchall()
|
57 |
+
rows_dict = [dict(zip(columns, row)) for row in rows_raw]
|
58 |
+
response_data["columns"] = columns
|
59 |
+
response_data["rows"] = rows_dict
|
60 |
+
response_data["message"] = f"Query executed successfully. Fetched {len(rows_dict)} row(s)."
|
61 |
+
logger.info(f"Query successful, returned {len(rows_dict)} rows.")
|
62 |
+
else:
|
63 |
+
response_data["message"] = "Query executed successfully (no data returned)."
|
64 |
+
logger.info("Query successful (no data returned).")
|
65 |
+
|
66 |
+
con.commit()
|
67 |
+
return response_data
|
68 |
+
|
69 |
+
except duckdb.Error as e:
|
70 |
+
logger.error(f"DuckDB Error: {e}")
|
71 |
+
if con: con.rollback()
|
72 |
+
return {"columns": None, "rows": None, "message": None, "error": str(e)}
|
73 |
+
except Exception as e:
|
74 |
+
logger.error(f"General Error: {e}")
|
75 |
+
if con: con.rollback()
|
76 |
+
return {"columns": None, "rows": None, "message": None, "error": f"An unexpected error occurred: {e}"}
|
77 |
+
finally:
|
78 |
+
if con:
|
79 |
+
con.close()
|
80 |
+
logger.info("Database connection closed.")
|
81 |
+
|
82 |
+
|
83 |
+
# --- FastAPI Startup Event ---
|
84 |
+
@app.on_event("startup")
|
85 |
+
async def startup_event():
|
86 |
+
logger.info("Application startup: Initializing DuckDB UI...")
|
87 |
+
con = None
|
88 |
+
try:
|
89 |
+
# Connect to the main DB file to execute initialization commands
|
90 |
+
# Use a temporary in-memory DB for UI start if main DB doesn't exist yet?
|
91 |
+
# No, start_ui seems to need the target DB. Ensure DB file path exists.
|
92 |
+
if not DB_FILE.parent.exists():
|
93 |
+
DB_FILE.parent.mkdir(parents=True, exist_ok=True)
|
94 |
+
|
95 |
+
# It's crucial the UI extension can write its state.
|
96 |
+
# By default it uses ~/.duckdb/ which will be /root/.duckdb in the container.
|
97 |
+
# Ensure this is writable or mount a volume there.
|
98 |
+
logger.info(f"Attempting to connect to {DB_FILE} for UI setup.")
|
99 |
+
con = duckdb.connect(database=str(DB_FILE), read_only=False)
|
100 |
+
|
101 |
+
logger.info("Installing and loading 'ui' extension...")
|
102 |
+
con.execute("INSTALL ui;")
|
103 |
+
con.execute("LOAD ui;")
|
104 |
+
|
105 |
+
logger.info("Calling start_ui()... This will start a separate web server.")
|
106 |
+
# CALL start_ui() starts the server in the background (usually)
|
107 |
+
# It might print the URL/port it's using to stderr/stdout of the main process
|
108 |
+
con.execute("CALL start_ui();")
|
109 |
+
|
110 |
+
# Give the UI server a moment to start up. This is a guess.
|
111 |
+
# A more robust solution might involve checking if the port is listening.
|
112 |
+
await asyncio.sleep(2)
|
113 |
+
|
114 |
+
logger.info(f"DuckDB UI server startup initiated. It usually listens on port {UI_EXPECTED_PORT}.")
|
115 |
+
logger.info("Check container logs for the exact URL if it differs.")
|
116 |
+
logger.info("API server (FastAPI/Uvicorn) is running on port 8000.")
|
117 |
+
|
118 |
+
except duckdb.Error as e:
|
119 |
+
logger.error(f"CRITICAL: Failed to install/load/start DuckDB UI extension: {e}")
|
120 |
+
logger.error("The DuckDB UI will likely not be available.")
|
121 |
+
except Exception as e:
|
122 |
+
logger.error(f"CRITICAL: An unexpected error occurred during UI startup: {e}")
|
123 |
+
logger.error("The DuckDB UI will likely not be available.")
|
124 |
+
finally:
|
125 |
+
if con:
|
126 |
+
con.close()
|
127 |
+
logger.info("UI setup connection closed.")
|
128 |
+
|
129 |
+
# --- API Endpoints ---
|
130 |
+
@app.get("/", summary="Root Endpoint / Info", tags=["General"])
|
131 |
+
async def read_root():
|
132 |
+
"""Provides links to the API docs and the DuckDB UI."""
|
133 |
+
# Assumes UI is running on localhost from the container's perspective
|
134 |
+
# User needs to map the port correctly
|
135 |
+
return JSONResponse({
|
136 |
+
"message": "DuckDB API and UI Host",
|
137 |
+
"api_details": {
|
138 |
+
"docs": "/docs",
|
139 |
+
"query_endpoint": "/query (POST)",
|
140 |
+
"download_endpoint": "/download (GET)"
|
141 |
+
},
|
142 |
+
"duckdb_ui": {
|
143 |
+
"message": f"Access the official DuckDB Web UI. It should be running on port {UI_EXPECTED_PORT} inside the container.",
|
144 |
+
"typical_access_url": f"http://localhost:{UI_EXPECTED_PORT}",
|
145 |
+
"notes": f"Ensure you have mapped port {UI_EXPECTED_PORT} from the container when running `docker run` (e.g., -p {UI_EXPECTED_PORT}:{UI_EXPECTED_PORT})."
|
146 |
+
},
|
147 |
+
"database_file_container_path": str(DB_FILE)
|
148 |
+
})
|
149 |
+
|
150 |
+
@app.post("/query", response_model=QueryResponse, summary="Execute SQL Query", tags=["Database API"])
|
151 |
+
async def execute_query_endpoint(query_request: QueryRequest):
|
152 |
+
"""
|
153 |
+
Executes a given SQL query against the DuckDB database via the API.
|
154 |
+
Handles SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc.
|
155 |
+
"""
|
156 |
+
result = execute_duckdb_query(query_request.sql)
|
157 |
+
if result["error"]:
|
158 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
159 |
+
return JSONResponse(content=result)
|
160 |
+
|
161 |
+
|
162 |
+
@app.get("/download", summary="Download Database File", tags=["Database API"])
|
163 |
+
async def download_database_file():
|
164 |
+
"""
|
165 |
+
Allows downloading the current DuckDB database file via the API.
|
166 |
+
"""
|
167 |
+
if not DB_FILE.is_file():
|
168 |
+
logger.error(f"Download request failed: Database file not found at {DB_FILE}")
|
169 |
+
raise HTTPException(status_code=404, detail="Database file not found.")
|
170 |
+
|
171 |
+
logger.info(f"Serving database file for download: {DB_FILE}")
|
172 |
+
return FileResponse(
|
173 |
+
path=str(DB_FILE),
|
174 |
+
filename=DB_FILENAME,
|
175 |
+
media_type='application/octet-stream'
|
176 |
+
)
|
177 |
+
|
178 |
+
# Need asyncio for sleep in startup
|
179 |
+
# import asyncio
|
180 |
+
|
181 |
+
# --- Run with Uvicorn (for local testing - doesn't handle UI startup well here) ---
|
182 |
+
# if __name__ == "__main__":
|
183 |
+
# # Note: Running directly with python main.py won't trigger the startup
|
184 |
+
# # event correctly in the same way uvicorn command does.
|
185 |
+
# # Use `uvicorn main:app --reload --port 8000` for local dev testing.
|
186 |
+
# print("Run using: uvicorn main:app --host 0.0.0.0 --port 8000")
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi
|
2 |
+
uvicorn[standard]
|
3 |
+
duckdb>=1.0.0 # Ensure version compatibility with UI extension
|
4 |
+
pydantic
|
5 |
+
python-multipart
|