Skip to content

Commit 78db1e2

Browse files
Copilotks6088ts
andcommitted
Implement complete file CRUD service with Azure Blob Storage
Co-authored-by: ks6088ts <[email protected]>
1 parent 2eb0b0c commit 78db1e2

File tree

8 files changed

+601
-1
lines changed

8 files changed

+601
-1
lines changed

.env.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ AZURE_OPENAI_MODEL_EMBEDDING="text-embedding-3-small"
99
AZURE_COSMOSDB_CONNECTION_STRING="AccountEndpoint=https://<YOUR_COSMOSDB_NAME>.documents.azure.com:443/;AccountKey=<ACCOUNT_KEY>;"
1010
AZURE_COSMOSDB_DATABASE_NAME="template_fastapi"
1111
AZURE_COSMOSDB_CONTAINER_NAME="items"
12+
13+
# Azure Blob Storage
14+
AZURE_BLOB_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=<YOUR_STORAGE_ACCOUNT>;AccountKey=<YOUR_ACCOUNT_KEY>;EndpointSuffix=core.windows.net"
15+
AZURE_BLOB_STORAGE_CONTAINER_NAME="files"

docs/index.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,49 @@ uv run python scripts/foodies_restaurants.py find-nearby --latitude 35.681167 --
3030
make dev
3131
```
3232

33+
### Files Service
34+
35+
```shell
36+
# Help
37+
uv run python scripts/files.py --help
38+
39+
# List all files
40+
uv run python scripts/files.py list-files
41+
42+
# List files with prefix
43+
uv run python scripts/files.py list-files --prefix "images/"
44+
45+
# Upload a single file
46+
uv run python scripts/files.py upload-file ./path/to/file.txt
47+
48+
# Upload a single file with custom blob name
49+
uv run python scripts/files.py upload-file ./path/to/file.txt --name "custom-name.txt"
50+
51+
# Upload multiple files
52+
uv run python scripts/files.py upload-multiple-files ./file1.txt ./file2.jpg ./file3.pdf
53+
54+
# Download a file
55+
uv run python scripts/files.py download-file "file.txt"
56+
57+
# Download a file to specific location
58+
uv run python scripts/files.py download-file "file.txt" --output "./downloads/file.txt"
59+
60+
# Get file information
61+
uv run python scripts/files.py get-file-info "file.txt"
62+
63+
# Delete a file (with confirmation)
64+
uv run python scripts/files.py delete-file "file.txt"
65+
66+
# Delete a file (without confirmation)
67+
uv run python scripts/files.py delete-file "file.txt" --force
68+
69+
# Delete multiple files
70+
uv run python scripts/files.py delete-multiple-files "file1.txt" "file2.jpg" "file3.pdf"
71+
72+
# Delete multiple files (without confirmation)
73+
uv run python scripts/files.py delete-multiple-files "file1.txt" "file2.jpg" "file3.pdf" --force
74+
```
75+
3376
## MCP
3477

3578
- [FastAPI-MCP](https://github.com/tadata-org/fastapi_mcp)

scripts/files.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#!/usr/bin/env python
2+
# filepath: /home/runner/work/template-fastapi/template-fastapi/scripts/files.py
3+
4+
import os
5+
from pathlib import Path
6+
from typing import List, Optional
7+
8+
import typer
9+
from rich.console import Console
10+
from rich.table import Table
11+
12+
from template_fastapi.repositories.files import FileRepository
13+
14+
app = typer.Typer()
15+
console = Console()
16+
file_repo = FileRepository()
17+
18+
19+
@app.command()
20+
def list_files(
21+
prefix: Optional[str] = typer.Option(None, "--prefix", "-p", help="ファイル名のプレフィックス"),
22+
):
23+
"""ファイル一覧を取得する"""
24+
console.print(f"[bold green]ファイル一覧[/bold green]を取得します")
25+
26+
if prefix:
27+
console.print(f"プレフィックス: {prefix}")
28+
29+
try:
30+
files = file_repo.list_files(prefix=prefix)
31+
32+
if not files:
33+
console.print("[yellow]ファイルが見つかりませんでした[/yellow]")
34+
return
35+
36+
# テーブルで表示
37+
table = Table(title="ファイル一覧")
38+
table.add_column("ファイル名", style="cyan")
39+
table.add_column("サイズ (bytes)", style="green")
40+
table.add_column("コンテンツタイプ", style="yellow")
41+
table.add_column("最終更新日時", style="magenta")
42+
43+
for file in files:
44+
table.add_row(
45+
file.name,
46+
str(file.size) if file.size else "N/A",
47+
file.content_type or "N/A",
48+
file.last_modified.strftime("%Y-%m-%d %H:%M:%S") if file.last_modified else "N/A"
49+
)
50+
51+
console.print(table)
52+
console.print(f"[bold blue]合計: {len(files)}件[/bold blue]")
53+
54+
except Exception as e:
55+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
56+
57+
58+
@app.command()
59+
def upload_file(
60+
file_path: str = typer.Argument(..., help="アップロードするファイルのパス"),
61+
blob_name: Optional[str] = typer.Option(None, "--name", "-n", help="Blob名(指定しない場合はファイル名を使用)"),
62+
):
63+
"""単一のファイルをアップロードする"""
64+
file_path_obj = Path(file_path)
65+
66+
if not file_path_obj.exists():
67+
console.print(f"[bold red]エラー[/bold red]: ファイル '{file_path}' が見つかりません")
68+
return
69+
70+
if not file_path_obj.is_file():
71+
console.print(f"[bold red]エラー[/bold red]: '{file_path}' はファイルではありません")
72+
return
73+
74+
blob_name = blob_name or file_path_obj.name
75+
console.print(f"[bold green]ファイル[/bold green]: {file_path} -> {blob_name}")
76+
77+
try:
78+
with open(file_path_obj, "rb") as f:
79+
file_data = f.read()
80+
81+
uploaded_file = file_repo.upload_file(
82+
file_name=blob_name,
83+
file_data=file_data,
84+
content_type=None # Let Azure detect the content type
85+
)
86+
87+
console.print(f"[bold green]アップロード成功[/bold green]")
88+
console.print(f" ファイル名: {uploaded_file.name}")
89+
console.print(f" サイズ: {uploaded_file.size} bytes")
90+
console.print(f" コンテンツタイプ: {uploaded_file.content_type}")
91+
console.print(f" URL: {uploaded_file.url}")
92+
93+
except Exception as e:
94+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
95+
96+
97+
@app.command()
98+
def upload_multiple_files(
99+
file_paths: List[str] = typer.Argument(..., help="アップロードするファイルのパス(複数指定可能)"),
100+
):
101+
"""複数のファイルを同時にアップロードする"""
102+
console.print(f"[bold green]複数ファイル[/bold green]をアップロードします")
103+
104+
# ファイルの存在チェック
105+
valid_files = []
106+
for file_path in file_paths:
107+
file_path_obj = Path(file_path)
108+
if not file_path_obj.exists():
109+
console.print(f"[bold red]警告[/bold red]: ファイル '{file_path}' が見つかりません - スキップします")
110+
continue
111+
if not file_path_obj.is_file():
112+
console.print(f"[bold red]警告[/bold red]: '{file_path}' はファイルではありません - スキップします")
113+
continue
114+
valid_files.append(file_path_obj)
115+
116+
if not valid_files:
117+
console.print("[bold red]エラー[/bold red]: 有効なファイルが見つかりませんでした")
118+
return
119+
120+
try:
121+
file_data_list = []
122+
for file_path_obj in valid_files:
123+
with open(file_path_obj, "rb") as f:
124+
file_data = f.read()
125+
file_data_list.append((file_path_obj.name, file_data, None))
126+
127+
uploaded_files = file_repo.upload_files(file_data_list)
128+
129+
console.print(f"[bold green]アップロード成功[/bold green]: {len(uploaded_files)}件")
130+
for uploaded_file in uploaded_files:
131+
console.print(f" - {uploaded_file.name} ({uploaded_file.size} bytes)")
132+
133+
except Exception as e:
134+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
135+
136+
137+
@app.command()
138+
def download_file(
139+
blob_name: str = typer.Argument(..., help="ダウンロードするBlobの名前"),
140+
output_path: Optional[str] = typer.Option(None, "--output", "-o", help="出力ファイルのパス"),
141+
):
142+
"""ファイルをダウンロードする"""
143+
console.print(f"[bold green]ファイル[/bold green]: {blob_name} をダウンロードします")
144+
145+
try:
146+
file_data = file_repo.download_file(blob_name)
147+
148+
output_path = output_path or blob_name
149+
output_path_obj = Path(output_path)
150+
151+
with open(output_path_obj, "wb") as f:
152+
f.write(file_data)
153+
154+
console.print(f"[bold green]ダウンロード成功[/bold green]")
155+
console.print(f" 出力先: {output_path_obj.absolute()}")
156+
console.print(f" サイズ: {len(file_data)} bytes")
157+
158+
except Exception as e:
159+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
160+
161+
162+
@app.command()
163+
def get_file_info(
164+
blob_name: str = typer.Argument(..., help="情報を取得するBlobの名前"),
165+
):
166+
"""ファイル情報を取得する"""
167+
console.print(f"[bold green]ファイル情報[/bold green]: {blob_name}")
168+
169+
try:
170+
file_info = file_repo.get_file_info(blob_name)
171+
172+
console.print(f" ファイル名: {file_info.name}")
173+
console.print(f" サイズ: {file_info.size} bytes")
174+
console.print(f" コンテンツタイプ: {file_info.content_type}")
175+
console.print(f" 最終更新日時: {file_info.last_modified}")
176+
console.print(f" URL: {file_info.url}")
177+
178+
except Exception as e:
179+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
180+
181+
182+
@app.command()
183+
def delete_file(
184+
blob_name: str = typer.Argument(..., help="削除するBlobの名前"),
185+
force: bool = typer.Option(False, "--force", "-f", help="確認なしで削除する"),
186+
):
187+
"""ファイルを削除する"""
188+
console.print(f"[bold green]ファイル削除[/bold green]: {blob_name}")
189+
190+
try:
191+
# 削除前に確認
192+
if not force:
193+
file_info = file_repo.get_file_info(blob_name)
194+
console.print("以下のファイルを削除しようとしています:")
195+
console.print(f" ファイル名: {file_info.name}")
196+
console.print(f" サイズ: {file_info.size} bytes")
197+
console.print(f" 最終更新日時: {file_info.last_modified}")
198+
199+
if not typer.confirm("削除してもよろしいですか?"):
200+
console.print("[yellow]削除をキャンセルしました[/yellow]")
201+
return
202+
203+
file_repo.delete_file(blob_name)
204+
console.print(f"[bold green]削除成功[/bold green]: {blob_name}")
205+
206+
except Exception as e:
207+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
208+
209+
210+
@app.command()
211+
def delete_multiple_files(
212+
blob_names: List[str] = typer.Argument(..., help="削除するBlobの名前(複数指定可能)"),
213+
force: bool = typer.Option(False, "--force", "-f", help="確認なしで削除する"),
214+
):
215+
"""複数のファイルを同時に削除する"""
216+
console.print(f"[bold green]複数ファイル削除[/bold green]: {len(blob_names)}件")
217+
218+
try:
219+
# 削除前に確認
220+
if not force:
221+
console.print("以下のファイルを削除しようとしています:")
222+
for blob_name in blob_names:
223+
console.print(f" - {blob_name}")
224+
225+
if not typer.confirm("削除してもよろしいですか?"):
226+
console.print("[yellow]削除をキャンセルしました[/yellow]")
227+
return
228+
229+
deleted_files = file_repo.delete_files(blob_names)
230+
console.print(f"[bold green]削除成功[/bold green]: {len(deleted_files)}件")
231+
for deleted_file in deleted_files:
232+
console.print(f" - {deleted_file}")
233+
234+
except Exception as e:
235+
console.print(f"[bold red]エラー[/bold red]: {str(e)}")
236+
237+
238+
if __name__ == "__main__":
239+
app()

template_fastapi/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
1212
from opentelemetry.trace import Span
1313

14-
from template_fastapi.routers import demos, foodies, games, items
14+
from template_fastapi.routers import demos, files, foodies, games, items
1515

1616
app = FastAPI()
1717

@@ -40,3 +40,4 @@ def server_request_hook(span: Span, scope: dict):
4040
app.include_router(demos.router)
4141
app.include_router(games.router)
4242
app.include_router(foodies.router)
43+
app.include_router(files.router)

template_fastapi/models/file.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
4+
from pydantic import BaseModel, ConfigDict
5+
6+
7+
class File(BaseModel):
8+
"""ファイル情報を表すモデル"""
9+
10+
model_config = ConfigDict(extra="ignore")
11+
12+
name: str
13+
size: Optional[int] = None
14+
content_type: Optional[str] = None
15+
last_modified: Optional[datetime] = None
16+
url: Optional[str] = None

0 commit comments

Comments
 (0)