Skip to content

Commit 16022e1

Browse files
authored
Merge pull request #17 from ks6088ts-labs/copilot/fix-16
Implement file CRUD service for Azure Blob Storage
2 parents d1bfe04 + 63ceefb commit 16022e1

File tree

10 files changed

+612
-2
lines changed

10 files changed

+612
-2
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: 47 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)
@@ -71,6 +114,10 @@ az resource update \
71114
--set properties.disableLocalAuth=false
72115
```
73116

117+
### Azure Blob Storage
118+
119+
- [クイック スタート: Python 用 Azure Blob Storage クライアント ライブラリ](https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-quickstart-blobs-python?tabs=connection-string%2Croles-azure-portal%2Csign-in-azure-cli&pivots=blob-storage-quickstart-scratch)
120+
74121
### Application Insights
75122

76123
- [Application Insights の概要 - OpenTelemetry の可観測性](https://learn.microsoft.com/ja-jp/azure/azure-monitor/app/app-insights-overview)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"azure-cosmos>=4.9.0",
99
"azure-functions>=1.23.0",
1010
"azure-monitor-opentelemetry>=1.6.10",
11+
"azure-storage-blob>=12.25.1",
1112
"fastapi-mcp>=0.3.4",
1213
"fastapi[standard]>=0.115.12",
1314
"langchain-community>=0.3.27",

scripts/files.py

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

0 commit comments

Comments
 (0)