commit ba58bb60ec065a3a21e9654dd48f0f2ff6e63de8 Author: Tianyu Liu Date: Thu Sep 11 18:24:36 2025 +0000 initial commit diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json new file mode 100644 index 0000000..41f94c7 --- /dev/null +++ b/backend/.devcontainer/devcontainer.json @@ -0,0 +1,29 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", + "features": { + "ghcr.io/itsmechlark/features/redis-server:1": {} + }, + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r dev-requirements.txt", + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.debugpy" + ] + } + } + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..51f9037 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,13 @@ +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ + +.pytest_cache/ diff --git a/backend/.vscode/extensions.json b/backend/.vscode/extensions.json new file mode 100644 index 0000000..0cf16fd --- /dev/null +++ b/backend/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "ms-python.vscode-pylance", + "ms-python.python", + "ms-python.debugpy", + "charliermarsh.ruff", + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [] +} \ No newline at end of file diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 0000000..15154cc --- /dev/null +++ b/backend/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: FastAPI", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "main:app", + "--reload", + "--port=5000" + ], + "jinja": true, + "autoStartBrowser": true + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 0000000..96661cd --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..5cfad18 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,33 @@ +# FastAPI Template + +This sample repo contains the recommended structure for a Python FastAPI project. In this sample, we use `fastapi` to build a web application and the `pytest` to run tests. + +For a more in-depth tutorial, see our [Fast API tutorial](https://code.visualstudio.com/docs/python/tutorial-fastapi). + +The code in this repo aims to follow Python style guidelines as outlined in [PEP 8](https://peps.python.org/pep-0008/). + +## Set up instructions + +This sample makes use of Dev Containers, in order to leverage this setup, make sure you have [Docker installed](https://www.docker.com/products/docker-desktop). + +To successfully run this example, we recommend the following VS Code extensions: + +- [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) +- [Python Debugger](https://marketplace.visualstudio.com/items?itemName=ms-python.debugpy) +- [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) + +In addition to these extension there a few settings that are also useful to enable. You can enable to following settings by opening the Settings editor (`Ctrl+,`) and searching for the following settings: + +- Python > Analysis > **Type Checking Mode** : `basic` +- Python > Analysis > Inlay Hints: **Function Return Types** : `enable` +- Python > Analysis > Inlay Hints: **Variable Types** : `enable` + +## Running the sample +- Open the template folder in VS Code (**File** > **Open Folder...**) +- Open the Command Palette in VS Code (**View > Command Palette...**) and run the **Dev Container: Reopen in Container** command. +- Run the app using the Run and Debug view or by pressing `F5` +- `Ctrl + click` on the URL that shows up on the terminal to open the running application +- Test the API functionality by navigating to `/docs` URL to view the Swagger UI +- Configure your Python test in the Test Panel or by triggering the **Python: Configure Tests** command from the Command Palette +- Run tests in the Test Panel or by clicking the Play Button next to the individual tests in the `test_main.py` file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..8c0ae4d --- /dev/null +++ b/backend/app.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI + +from models import MsgPayload + +app = FastAPI() +messages_list: dict[int, MsgPayload] = {} + + +@app.get("/") +def root() -> dict[str, str]: + return {"message": "Hello"} + + +# About page route +@app.get("/about") +def about() -> dict[str, str]: + return {"message": "This is the about page."} + + +# Route to add a message +@app.post("/messages/{msg_name}/") +def add_msg(msg_name: str) -> dict[str, MsgPayload]: + # Generate an ID for the item based on the highest ID in the messages_list + msg_id = max(messages_list.keys()) + 1 if messages_list else 0 + messages_list[msg_id] = MsgPayload(msg_id=msg_id, msg_name=msg_name) + + return {"message": messages_list[msg_id]} + + +# Route to list all messages +@app.get("/messages") +def message_items() -> dict[str, dict[int, MsgPayload]]: + return {"messages:": messages_list} diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt new file mode 100644 index 0000000..f2578c6 --- /dev/null +++ b/backend/dev-requirements.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..bec2de7 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +import os +from typing import Any + +import uvicorn + +from settings import Settings, load_settings + + +def merge_settings(base: Settings, overrides: dict[str, Any]) -> Settings: + base_dict = base.model_dump() if hasattr(base, "model_dump") else {k: v for k, v in vars(base).items() if not k.startswith("_")} + clean_overrides = {k: v for k, v in overrides.items() if v is not None} + return Settings(**{**base_dict, **clean_overrides}) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description="Start FastAPI app with configurable settings") + p.add_argument("--config", "-c", help="Path to YAML config file (overrides env/.env)", default=None) + p.add_argument("--host", help="Host to bind", default=None) + p.add_argument("--port", type=int, help="Port to bind", default=None) + p.add_argument("--workers", type=int, help="Number of workers (uvicorn)", default=None) + p.add_argument("--log-level", help="Log level for uvicorn", default=None) + p.add_argument("--reload", action="store_true", help="Enable reload (development)") + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + + if args.config: + os.environ["CONFIG_FILE"] = args.config + + base = load_settings() + overrides: dict[str, Any] = { + "host": args.host, + "port": args.port, + "workers": args.workers, + "log_level": args.log_level, + } + final_settings = merge_settings(base, overrides) + + uvicorn_kwargs = { + "app": "app:app", + "host": final_settings.host, + "port": int(final_settings.port), + "log_level": final_settings.log_level, + "workers": int(final_settings.workers) if final_settings.workers and final_settings.workers > 0 else None, + "reload": args.reload, + } + uvicorn_kwargs = {k: v for k, v in uvicorn_kwargs.items() if v is not None} + + print("Starting app with settings:", final_settings.model_dump() if hasattr(final_settings, "model_dump") else vars(final_settings)) + uvicorn.run(**uvicorn_kwargs) + + +if __name__ == "__main__": + main() diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..ca1a882 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import BaseModel + + +class MsgPayload(BaseModel): + msg_id: Optional[int] + msg_name: str diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8f1de34 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +httpx +pyyaml +pydantic-settings \ No newline at end of file diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..a4b5c62 --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,10 @@ +target-version = "py39" +line-length = 144 + +[lint] +select = ["ALL"] +fixable = ["UP034", "I001"] +ignore = ["T201", "D", "ANN101", "TD002", "TD003"] + +[lint.extend-per-file-ignores] +"test*.py" = ["S101"] \ No newline at end of file diff --git a/backend/settings.py b/backend/settings.py new file mode 100644 index 0000000..2096af8 --- /dev/null +++ b/backend/settings.py @@ -0,0 +1,28 @@ +import os +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ConfigDict +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + host: str = "0.0.0.0" # noqa: S104 + port: int = 8000 + workers: int = 1 + log_level: str = "info" + + model_config = ConfigDict(env_file=".env", env_file_encoding="utf-8") + + +def load_settings() -> Settings: + cfg_path = os.getenv("CONFIG_FILE") + if cfg_path and Path(cfg_path).exists(): + with Path(cfg_path).open(encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) or {} + return Settings(**data) + return Settings() + + +settings = load_settings() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..b4dd74c --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,22 @@ +import pytest +from fastapi.testclient import TestClient + +from app import app + + +@pytest.fixture +def client(): + with TestClient(app) as client: + yield client + + +def test_home_route(client): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Hello"} + + +def test_about_route(client): + response = client.get("/about") + assert response.status_code == 200 + assert response.json() == {"message": "This is the about page."} diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py new file mode 100644 index 0000000..499b703 --- /dev/null +++ b/backend/tests/test_settings.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import pytest + +from settings import load_settings + + +def test_default_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONFIG_FILE", raising=False) + for k in ("HOST", "PORT", "WORKERS", "LOG_LEVEL"): + monkeypatch.delenv(k, raising=False) + + s = load_settings() + assert s.host == "0.0.0.0" # noqa: S104 + assert s.port == 8000 # noqa: PLR2004 + assert s.workers == 1 + assert s.log_level == "info" + + +def test_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOST", "127.0.0.1") + monkeypatch.setenv("PORT", "9000") + monkeypatch.setenv("WORKERS", "3") + monkeypatch.setenv("LOG_LEVEL", "debug") + monkeypatch.delenv("CONFIG_FILE", raising=False) + + s = load_settings() + assert s.host == "127.0.0.1" + assert s.port == 9000 # noqa: PLR2004 + assert s.workers == 3 # noqa: PLR2004 + assert s.log_level == "debug" + + +def test_yaml_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "config.yaml" + cfg.write_text("host: 10.0.0.5\nport: 8088\nworkers: 5\nlog_level: debug\n") + monkeypatch.setenv("CONFIG_FILE", str(cfg)) + for k in ("HOST", "PORT", "WORKERS", "LOG_LEVEL"): + monkeypatch.delenv(k, raising=False) + + s = load_settings() + assert s.host == "10.0.0.5" + assert s.port == 8088 # noqa: PLR2004 + assert s.workers == 5 # noqa: PLR2004 + assert s.log_level == "debug"