diff --git a/Dockerfile b/Dockerfile index 8c416a7..59351a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,51 @@ FROM python:3.12-slim-bookworm -WORKDIR /app +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# copy/create bare minimum files needed to install dependencies -COPY pyproject.toml README.md uv.lock /app/ -RUN mkdir -p /app/src/files_api/ -RUN touch /app/src/files_api/__init__.py +WORKDIR /app -# Set environment variables for uv to use the system Python environment -ENV UV_SYSTEM_PYTHON=true -ENV VIRTUAL_ENV=/usr/local/ -ENV PATH="/usr/local/bin:$PATH" +ENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1 +ENV UV_SYSTEM_PYTHON=false +ENV UV_PROJECT_ENVIRONMENT=/app/.venv +# ^^^https://docs.astral.sh/uv/guides/integration/docker/#optimizations +# ^^^https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode +# ^^^https://docs.astral.sh/uv/guides/integration/docker/#caching -# install dependencies from pyproject.toml -RUN pip install --upgrade pip uv -RUN uv sync --no-cache --group=docker --frozen --active --project=/app/ -# RUN source /app/.venv/bin/activate -# RUN uv pip install --no-cache --group=docker --editable "/app/" -# RUN pip install --editable "/app/[docker]" +# ENV UV_NO_CACHE=1 +# If you're not mounting the cache, image size can be reduced by using the --no-cache flag or setting UV_NO_CACHE. +# Install dependencies without installing the project itself +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --group docker -# copy the rest of the source code +# Copy project files +COPY pyproject.toml README.md uv.lock /app/ COPY ./src/ /app/src/ +COPY ./tests/mocks /app/tests/mocks + +# Sync the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --group docker + +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="/app/.venv/bin:$PATH" # create the s3 bucket if desired(if false then using real S3 bucket), then start the fastapi app CMD (\ if [ "$CREATE_BUCKET_ON_STARTUP" = "true" ]; then \ - uv run --active -- python -c "import boto3; boto3.client('s3').create_bucket(Bucket='${S3_BUCKET_NAME}')"; \ + uv run -- python -c "import boto3; boto3.client('s3').create_bucket(Bucket='${S3_BUCKET_NAME}')"; \ fi \ ) \ - && uv run --active -- uvicorn files_api.main:create_app --factory --host 0.0.0.0 --port 8000 --reload \ No newline at end of file + && uv run -- uvicorn files_api.main:create_app --factory --host 0.0.0.0 --port 8000 --reload + +# """ +# Ref: +# - https://github.com/astral-sh/uv-docker-example/tree/main +# - https://docs.astral.sh/uv/guides/integration/docker/#getting-started +# - https://docs.astral.sh/uv/guides/integration/fastapi/#migrating-an-existing-fastapi-project +# - https://docs.astral.sh/uv/guides/integration/aws-lambda/#using-uv-with-aws-lambda +# - https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path +# """ \ No newline at end of file diff --git a/README.md b/README.md index 1449ec9..e08d62f 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ This project is a more polished version of the [cloud-engineering-project](https - [x] Added Secret Manager to store OpenAI API key securely. - [x] Used AWS SSM Parameter Store to store the OpenAI API key instead of Secrets Manager. [ref](docs/Secrets-Manager-and-SSM-Parameter-Store.md) - This is free to use and has no additional cost unlike Secrets Manager $0.40 per secret per month. -- [ ] Setup the Dockerfile with the recommended way of using [uv in Docker](https://docs.astral.sh/uv/guides/integration/docker/). - - [ ] CDK rebuilds the Lambda Layer Docker image on every deployment. Is it possible to cache it locally and only rebuild when there are changes to files like `pyproject.toml` or `uv.lock`? +- [x] Setup the Dockerfile with the recommended way of using [uv in Docker](https://docs.astral.sh/uv/guides/integration/docker/). + - [x] CDK rebuilds the Lambda Layer Docker image on every deployment. Is it possible to cache it locally and only rebuild when there are changes to files like `pyproject.toml` or `uv.lock`? + - [ ] Try Docker multi-stage builds and configure [watch](https://docs.astral.sh/uv/guides/integration/docker/#configuring-watch-with-docker-compose) with docker compose. - [ ] Implement API versioning strategy (like v1 in the path). - [ ] Setup CI/CD pipeline to deploy the API to AWS using GitHub Actions. - [ ] Deployment Stratgies like Blue-Green, Canary deployments, etc. diff --git a/docker-compose.yaml b/docker-compose.yaml index a922f12..ec440ab 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,6 @@ # ship the fastapi metrics, traces, and logs to AWS services: - fastapi: # logs to stdout (including metrics in EMF json format); pushes xray traces to the xray-daemon build: @@ -11,7 +10,7 @@ services: - "8000:8000" environment: # app config - CREATE_BUCKET_ON_STARTUP: "true" # in Dockerfile + CREATE_BUCKET_ON_STARTUP: "true" # in Dockerfile S3_BUCKET_NAME: "mock-bucket" LOGURU_LEVEL: INFO # openai mock (comment these out if you want to use real openai) @@ -43,7 +42,8 @@ services: # - .env # - openai.env # create this file if you want to use real OpenAI volumes: - - ./:/app + - ./:/app # Mount local directory + - /app/.venv # Anonymous volume: preserve this directory's contents in container aws-mock: # mock aws' endpoints on port 5000 (with moto) @@ -58,13 +58,14 @@ services: image: openai-mock build: dockerfile: Dockerfile - entrypoint: python ./tests/mocks/openai_fastapi_mock_app.py + entrypoint: ["uv", "run", "./tests/mocks/openai_fastapi_mock_app.py"] environment: OPENAI_MOCK_PORT: "1080" ports: - "1080:1080" volumes: - - ./:/app + - ./:/app # Mount local directory + - /app/.venv # Anonymous volume: preserve this directory's contents in container logspout: # Logs: uses the docker daemon to collect logs from fastapi's stdout and push to cloudwatch diff --git a/docs/CDK-Asset-Hash.md b/docs/CDK-Asset-Hash.md new file mode 100644 index 0000000..06d9a61 --- /dev/null +++ b/docs/CDK-Asset-Hash.md @@ -0,0 +1,46 @@ +In the AWS CDK, the `asset_hash` is a property used to determine if local assets (like your AWS Lambda function's source code directory) have changed, which is crucial for efficient deployments. + +**How AWS CDK Uses Asset Hashes** + +When you define a Lambda function using `lambda.Code.fromAsset(path)` or similar methods, the CDK performs the following steps during synthesis and deployment: + +1. **Calculate Hash:** The CDK calculates a hash of the local asset (e.g., the contents of your Lambda function's directory). +2. **Generate Cloud Assembly:** This hash is included in the cloud assembly (the `cdk.out` directory by default) metadata. +3. **Deployment Check:** During deployment (`cdk deploy`), the CLI uses this hash to check if the asset already exists in the S3 bucket or ECR repository created during the CDK bootstrapping process. +4. **Optimization:** If the hash matches an existing asset, the CDK skips re-uploading the content, optimizing the deployment time. If the hash is different, it means the content has changed, and the new asset is uploaded. + +**Customizing the Asset Hash** + +By default, the CDK automatically calculates a hash based on the source code content (`AssetHashType.SOURCE`). However, you can manually control the hashing behavior using the `assetHash` and `assetHashType` properties within the asset options. + +This can be useful if the automatic hashing is non-deterministic (e.g., due to temporary files in a build process) or if you want to force an update. + +You can use the `assetHashType` property in the `AssetOptions` to specify how the hash should be calculated: + +- **`AssetHashType.SOURCE` (Default):** The hash is calculated based on the contents of the source directory or file. +- **`AssetHashType.BUNDLE`:** The hash is calculated on the output of a bundling command (useful when using asset bundling with Docker). +- **`AssetHashType.CUSTOM`:** Allows you to provide a specific, custom hash string using the `assetHash` property. + +**Example (Python)** + +When creating a Lambda function in Python, you can specify custom asset options: + +```python +import aws_cdk as cdk +from aws_cdk import aws_lambda as lambda_ + +# ... inside your stack definition + +my_lambda_function = lambda_.Function(self, "MyLambdaFunction", + runtime=lambda_.Runtime.PYTHON_3_11, + handler="index.handler", + code=lambda_.Code.from_asset("path/to/your/lambda/code", + asset_hash_type=cdk.AssetHashType.CUSTOM, # Set hash type to CUSTOM + asset_hash="my-specific-hash-v1" # Provide a custom hash string + ) +) +``` + +**Important:** If you use `AssetHashType.CUSTOM`, you are responsible for updating the hash string every time the asset content changes; otherwise, deployments might not invalidate and upload the new code. + +ref: https://docs.aws.amazon.com/cdk/v2/guide/assets.html \ No newline at end of file diff --git a/docs/Docker-Volumes-and-Virtual-Environments.md b/docs/Docker-Volumes-and-Virtual-Environments.md new file mode 100644 index 0000000..4e0c7bf --- /dev/null +++ b/docs/Docker-Volumes-and-Virtual-Environments.md @@ -0,0 +1,456 @@ +# Docker Volumes and Virtual Environments: A Deep Dive + +## The Problem We Encountered + +When running our FastAPI application with Docker Compose, we encountered the following error: + +``` +fastapi-1 | error: Failed to spawn: `uvicorn` + | Caused by: No such file or directory (os error 2) + +openai-mock-1 | ModuleNotFoundError: No module named 'uvicorn' +``` + +This happened even though: +- The Dockerfile correctly installed all dependencies including `uvicorn` +- The `uv sync --locked --group docker` command completed successfully during build +- The `.venv` directory was created at `/app/.venv` during the Docker image build + +**Why did this happen?** The answer lies in understanding how Docker volumes work. + +--- + +## Fundamental Concepts + +### What is a Docker Volume? + +A **volume** is a way to persist and share data between: +- Your host machine (your laptop/computer) and a container +- Multiple containers +- Across container restarts + +Think of volumes as "bridges" between different filesystems. + +**Types of volumes:** + +1. **Named volumes**: Managed by Docker, stored in Docker's storage area + ```yaml + volumes: + - my_data:/app/data + ``` + +2. **Anonymous volumes**: Temporary volumes with random IDs, cleaned up with container + ```yaml + volumes: + - /app/.venv + ``` + +3. **Bind mounts**: Direct mapping from host directory to container directory + ```yaml + volumes: + - ./src:/app/src + ``` + +### What Does "Mounting a Volume" Mean? + +**Mounting** means attaching a storage location to a specific path in the container's filesystem. + +**Real-world analogy**: Think of your container's filesystem as a wall with hooks. Mounting is like hanging a picture frame on one of those hooks. The picture (your data) can be changed, but the hook location (`/app`) stays the same. + +**Technical explanation**: When you mount `./:/app`, Docker creates a link so that: +- Anything you read from `/app` in the container comes from `./` on your host +- Anything you write to `/app` in the container goes to `./` on your host +- Changes on either side are immediately visible on the other side + +``` +Your Computer (Host) Docker Container +───────────────────── ───────────────── +./files-api/ /app/ +├── src/ → ├── src/ (same files!) +├── tests/ → ├── tests/ (same files!) +└── pyproject.toml → └── pyproject.toml +``` + +--- + +## Docker Build vs Runtime: Two Critical Phases + +Understanding the difference between these phases is crucial to solving our problem. + +### Phase 1: Build Time (Creating the Image) + +**What happens:** +```dockerfile +FROM python:3.12-slim-bookworm +WORKDIR /app +COPY pyproject.toml README.md uv.lock /app/ +COPY ./src/ /app/src/ +RUN uv sync --locked --group docker +``` + +**Result**: A Docker **image** is created with: +- All dependencies installed in `/app/.venv/` +- `uvicorn` executable at `/app/.venv/bin/uvicorn` +- Your source code in `/app/src/` +- Everything "frozen" into layers like a cake + +**Analogy**: Building a house from blueprints. The house (image) is complete with all furniture (dependencies) installed. + +### Phase 2: Runtime (Starting the Container) + +**What happens:** +```yaml +services: + fastapi: + build: . + volumes: + - ./:/app +``` + +**Result**: A **container** is created from the image and: +- The container starts with the filesystem from the image +- **THEN** volume mounts are applied +- Volume mounts **overlay** on top of the image filesystem + +**Analogy**: Someone moves into the house (container) and brings their own furniture (volume mount), which **replaces** what was already there. + +--- + +## The Volume Mount Overlay Problem + +This is where our bug originated. + +### Step-by-Step Breakdown + +**1. After Docker Build (Image Created):** +``` +Image Filesystem: +/app/ +├── .venv/ ← Contains uvicorn! +│ ├── bin/ +│ │ ├── uvicorn ← Executable we need +│ │ └── python +│ └── lib/ +│ └── python3.12/ +│ └── site-packages/ +├── src/ +│ └── files_api/ +├── pyproject.toml +└── uv.lock +``` + +**2. During Docker Run (Container Started):** +```yaml +volumes: + - ./:/app # Mount host directory to /app +``` + +This mounts your **local directory** on top of `/app`: + +``` +Local Directory Structure: +./ +├── src/ +├── pyproject.toml +├── uv.lock +├── Dockerfile +└── (no .venv/ because .dockerignore excludes it) +``` + +**3. The Overlay Effect:** + +``` +┌─────────────────────────────────────┐ +│ Container Filesystem View │ +├─────────────────────────────────────┤ +│ │ +│ Volume Mount: ./→/app (Top layer) │ +│ ┌─────────────────────────────────┤ +│ │ /app/src/ ← from host │ +│ │ /app/pyproject.toml ← from host │ +│ │ /app/Dockerfile ← from host │ +│ └─────────────────────────────────┤ +│ │ +│ Image Filesystem (Bottom layer) │ +│ ┌─────────────────────────────────┤ +│ │ /app/.venv/ ← HIDDEN! ❌ │ +│ │ /app/src/ ← HIDDEN! ❌ │ +│ └─────────────────────────────────┘ +│ │ +└─────────────────────────────────────┘ +``` + +**The Problem**: The volume mount **hides** the `/app/.venv/` directory from the image! + +When the application tries to run: +```bash +uvicorn files_api.main:create_app +``` + +It looks for `uvicorn` in the PATH, which includes `/app/.venv/bin`, but that directory is now hidden by the volume mount. The host's local directory doesn't have a `.venv` (thanks to `.dockerignore`), so there's nothing there! + +**Result**: `ModuleNotFoundError: No module named 'uvicorn'` + +--- + +## The Solution: Anonymous Volumes + +### The Fix + +```yaml +volumes: + - ./:/app # Mount local directory for live code updates + - /app/.venv # Anonymous volume: preserve container's .venv +``` + +### How Anonymous Volumes Work + +**Volume Priority System**: Docker applies volumes from **least specific** to **most specific**. + +- `/app` is less specific (matches everything in /app) +- `/app/.venv` is more specific (matches only the .venv directory) +- **More specific wins!** + +**Visual Representation:** + +``` +┌─────────────────────────────────────────────┐ +│ Container Filesystem with Anonymous Volume │ +├─────────────────────────────────────────────┤ +│ │ +│ Anonymous Volume: /app/.venv (Highest) │ +│ ┌──────────────────────────────────────┐ │ +│ │ /app/.venv/ ← from IMAGE ✅ │ │ +│ │ ├── bin/uvicorn │ │ +│ │ └── lib/python3.12/site-packages/ │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ Bind Mount: ./→/app (Middle) │ +│ ┌──────────────────────────────────────┐ │ +│ │ /app/src/ ← from HOST │ │ +│ │ /app/pyproject.toml ← from HOST │ │ +│ │ /app/tests/ ← from HOST │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ Image Filesystem (Lowest) │ +│ ┌──────────────────────────────────────┐ │ +│ │ (other files from image) │ │ +│ └──────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**What happens:** +1. Container starts with image filesystem +2. `./:/app` mounts, overlaying most of `/app` +3. `/app/.venv` mounts, punching a "hole" through the bind mount +4. `/app/.venv` now shows the **original directory from the image** +5. `uvicorn` is accessible! ✅ + +### Why Is It Called "Anonymous"? + +```yaml +# Named volume (has a name before the colon) +- my_venv_volume:/app/.venv + +# Anonymous volume (no name before the colon) +- /app/.venv +``` + +**Anonymous volumes:** +- Docker generates a random ID: `a8f7d9e2b3c1...` +- Automatically cleaned up when container is removed (with `docker compose down` or `--rm` flag) +- Ephemeral by nature (good for build artifacts you can recreate) + +**Named volumes:** +- Persist across container restarts and removals +- Need explicit `docker volume rm` to delete +- Good for databases or data you want to keep + +--- + +## Complete Solution Applied + +### In `docker-compose.yaml`: + +```yaml +services: + fastapi: + build: + context: . + dockerfile: Dockerfile + volumes: + - ./:/app # Live code updates + - /app/.venv # Preserve virtual environment + # ... rest of config + + openai-mock: + build: + dockerfile: Dockerfile + volumes: + - ./:/app # Live code updates + - /app/.venv # Preserve virtual environment + # ... rest of config +``` + +### Why This Pattern Is Powerful + +**Development Workflow Benefits:** + +1. **Fast iteration**: Edit code locally, see changes immediately in container + ```bash + # Edit src/files_api/main.py on your laptop + # FastAPI auto-reload detects change + # No rebuild needed! 🚀 + ``` + +2. **Consistent environment**: Dependencies from image, code from host + ``` + Dependencies: From Docker image (consistent) + Source code: From your laptop (editable) + ``` + +3. **No rebuild for code changes**: Only rebuild when dependencies change + ```bash + # Changed a .py file? No rebuild needed. + # Changed pyproject.toml? Need rebuild. + ``` + +--- + +## Alternative Solutions + +### Option A: Mount Only Source Directories (Not Root) + +```yaml +volumes: + - ./src:/app/src + - ./tests:/app/tests + # No /app/.venv conflict because we're not mounting ./:/app +``` + +**Pros:** +- Explicit about what's mounted +- No risk of hiding directories + +**Cons:** +- Need to list every directory +- Can't easily edit `pyproject.toml` without rebuild + +### Option B: Don't Mount Anything (Rebuild for Changes) + +```yaml +# No volumes section +``` + +**Pros:** +- Simplest configuration +- No mount confusion + +**Cons:** +- Must rebuild image for every code change +- Slow development cycle + +### Option C: Named Volume (Overkill for This Case) + +```yaml +volumes: + - ./:/app + - venv_data:/app/.venv + +volumes: + venv_data: +``` + +**Pros:** +- `.venv` persists across `docker compose down` + +**Cons:** +- Unnecessary complexity +- Volume doesn't update when you change dependencies +- Need to manually `docker volume rm venv_data` to update + +--- + +## Key Takeaways + +1. **Docker Build** creates an image with all dependencies installed +2. **Volume Mounts** happen at runtime and overlay the image filesystem +3. **Mount Priority**: More specific paths override less specific ones +4. **Anonymous Volumes** preserve specific directories from being hidden by bind mounts +5. **Pattern**: `./:/app` + `/app/.venv` gives you live code updates + working dependencies + +## Common Pitfalls to Avoid + +❌ **Mounting without preserving .venv** +```yaml +volumes: + - ./:/app # Breaks virtual environment! +``` + +❌ **Including .venv in Docker context** +```dockerignore +# .dockerignore should exclude .venv +.venv/ +``` + +❌ **Using uv run without proper environment** +```dockerfile +# If PATH isn't set correctly: +CMD ["uv", "run", "uvicorn", "..."] # May fail +``` + +✅ **Correct pattern:** +```yaml +volumes: + - ./:/app + - /app/.venv +``` + +```dockerfile +ENV PATH="/app/.venv/bin:$PATH" +CMD ["uvicorn", "files_api.main:create_app", "--factory"] +``` + +--- + +## Related Documentation + +- [uv Docker Integration Guide](https://docs.astral.sh/uv/guides/integration/docker/) +- [Docker Compose Volumes Reference](https://docs.docker.com/compose/compose-file/07-volumes/) +- [Docker Development Best Practices](https://docs.docker.com/develop/dev-best-practices/) + +--- + +## Debugging Tips + +If you encounter similar issues: + +1. **Check if .venv exists in the running container:** + ```bash + docker compose exec fastapi ls -la /app/.venv + ``` + +2. **Check which Python is being used:** + ```bash + docker compose exec fastapi which python + ``` + +3. **Check if uvicorn is installed:** + ```bash + docker compose exec fastapi /app/.venv/bin/python -m pip list | grep uvicorn + ``` + +4. **Inspect volume mounts:** + ```bash + docker inspect | jq '.[0].Mounts' + ``` + +5. **Compare image vs container filesystem:** + ```bash + # In image (during build) + docker build -t test . && docker run --rm test ls -la /app/ + + # In container (at runtime) + docker compose run --rm fastapi ls -la /app/ + ``` diff --git a/infra.py b/infra.py index 4d21eb1..79222c2 100644 --- a/infra.py +++ b/infra.py @@ -1,3 +1,4 @@ +import hashlib import os from pathlib import Path @@ -15,6 +16,27 @@ S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"] +_assets_to_exclude: list[str] = [ + "scripts/*", + "tests/*", + "docs/*", + ".vscode", + "*.env", + ".venv", + "*.pyc", + "__pycache__", + "*cache*", + ".DS_Store", + ".git", + ".github", +] + +# Create a Lambda function & Lambda Layer +# ref: https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html#configuration-layers-path +# ref: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.LayerVersion.html +# ref: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_assets.AssetOptions.html +# ref: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.BundlingOptions.html + class FilesApiCdkStack(Stack): """Files API CDK Stack""" @@ -55,9 +77,17 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: compatible_runtimes=[_lambda.Runtime.PYTHON_3_12], code=_lambda.Code.from_asset( path=THIS_DIR.as_posix(), - bundling={ - "image": _lambda.Runtime.PYTHON_3_12.bundling_image, - "command": [ + display_name="files-api-lambda-layer", + deploy_time=True, # delete S3 asset after deployment + # Only re-build and re-deploy the layer if the dependency files change + # ref: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.AssetOptions.html + asset_hash_type=cdk.AssetHashType.CUSTOM, + asset_hash=hashlib.sha256( + (THIS_DIR / "pyproject.toml").read_bytes() + (THIS_DIR / "uv.lock").read_bytes() + ).hexdigest(), # Custom hash based on dependency files + bundling=cdk.BundlingOptions( + image=_lambda.Runtime.PYTHON_3_12.bundling_image, + command=[ "bash", "-c", # 0. Upgrade pip to the latest version @@ -65,11 +95,16 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: # 1. Install uv "pip install uv && " # 2. Use uv to install the 'aws-lambda' group into /asset-output/python - "uv pip install --editable . --group aws-lambda --target /asset-output/python", + "uv pip install --no-cache --link-mode=copy --requirements pyproject.toml --group aws-lambda --target /asset-output/python", ], - "user": "root", # `user` override to be able to install uv and upgrade pip - }, - exclude=["tests/*", ".venv", "*.pyc", "__pycache__", ".git"], + user="root", # `user` override to be able to install uv and upgrade pip + ), + # bundling={ + # "image": _lambda.Runtime.PYTHON_3_12.bundling_image, + # "command": [...], + # "user": "root", # `user` override to be able to install uv and upgrade pip + # }, + exclude=_assets_to_exclude, ), removal_policy=cdk.RemovalPolicy.DESTROY, ) @@ -97,7 +132,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: timeout=cdk.Duration.seconds(60), code=_lambda.Code.from_asset( path=(THIS_DIR / "src").as_posix(), - exclude=["tests/*", ".venv", "*.pyc", "__pycache__", ".git"], + exclude=_assets_to_exclude, ), # Add Lambda Layers for dependencies and AWS Secrets Manager extension layers=[files_api_lambda_layer, secrets_manager_lambda_extension_layer], diff --git a/pyproject.toml b/pyproject.toml index c5ad135..64e4e70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "files-api" -version = "0.1.4" +version = "0.1.5" description = "Files API to upload and download files from S3 using FastAPI." readme = "README.md" authors = [ diff --git a/run b/run index b94904c..d5305f7 100755 --- a/run +++ b/run @@ -20,7 +20,6 @@ else fi - THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" diff --git a/src/files_api/generate_files/gemini.py b/src/files_api/generate_files/gemini.py new file mode 100644 index 0000000..785775f --- /dev/null +++ b/src/files_api/generate_files/gemini.py @@ -0,0 +1,135 @@ +# """Generate text, images, and audio from prompts using Google Gemini's API.""" + +# from typing import ( +# Literal, +# Optional, +# Tuple, +# ) + +# from aws_embedded_metrics import MetricsLogger +# from aws_embedded_metrics.storage_resolution import StorageResolution +# from google import genai +# from google.genai import types + +# from files_api.monitoring.metrics import metrics_ctx + +# SYSTEM_PROMPT = "You are an autocompletion tool that produces text files given constraints." + + +# async def get_text_chat_completion(prompt: str, gemini_client: Optional[genai.Client] = None) -> str: +# """Generate a text chat completion from a given prompt.""" +# # get the Gemini client +# client = gemini_client or genai.Client() + +# # get the completion +# response = await client.aio.models.generate_content( +# model="gemini-2.5-flash", +# contents=prompt, +# config=types.GenerateContentConfig( +# system_instruction=SYSTEM_PROMPT, +# max_output_tokens=100, # avoid burning your credits +# temperature=1.0, +# ), +# ) + +# metrics: MetricsLogger | None = metrics_ctx.get() +# if metrics: +# # Gemini response has usage_metadata with total_token_count +# if response.usage_metadata and response.usage_metadata.total_token_count: +# metrics.put_metric( +# key="GeminiTokensUsage", +# value=float(response.usage_metadata.total_token_count), +# unit="Count", +# storage_resolution=StorageResolution.STANDARD, +# ) +# return response.text or "" + + +# async def generate_image(prompt: str, gemini_client: Optional[genai.Client] = None) -> bytes | None: +# """Generate an image from a given prompt.""" +# # get the Gemini client +# client = gemini_client or genai.Client() + +# # get image response from Gemini +# response = await client.aio.models.generate_content( +# model="gemini-2.5-flash-image", +# contents=prompt, +# config=types.GenerateContentConfig( +# response_modalities=["IMAGE"], +# image_config=types.ImageConfig( +# aspect_ratio="1:1", +# ), +# ), +# ) + +# metrics: MetricsLogger | None = metrics_ctx.get() +# if metrics: +# metrics.put_metric(key="GeminiImageGeneratedCount", value=1, unit="Count") + +# # Extract the image data from the response +# # Gemini returns inline_data with the image bytes +# if response.parts: +# for part in response.parts: +# if part.inline_data is not None and part.inline_data.data: +# # Return the raw bytes +# return part.inline_data.data + +# return None + + +# async def generate_text_to_speech( +# prompt: str, +# gemini_client: Optional[genai.Client] = None, +# response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "mp3", +# ) -> Tuple[bytes, str]: +# """ +# Generate text-to-speech audio from a given prompt. + +# Returns the audio content as bytes and the MIME type as a string. +# """ +# # get the Gemini client +# client = gemini_client or genai.Client() + +# # Map response format to MIME type +# mime_type_map = { +# "mp3": "audio/mpeg", +# "opus": "audio/opus", +# "aac": "audio/aac", +# "flac": "audio/flac", +# "wav": "audio/wav", +# "pcm": "audio/pcm", +# } + +# # get audio response from Gemini +# response = await client.aio.models.generate_content( +# model="gemini-2.5-flash-preview-tts", +# contents=f"Say: {prompt}", +# config=types.GenerateContentConfig( +# response_modalities=["AUDIO"], +# speech_config=types.SpeechConfig( +# voice_config=types.VoiceConfig( +# prebuilt_voice_config=types.PrebuiltVoiceConfig( +# voice_name="Kore", +# ) +# ) +# ), +# ), +# ) + +# # Get the audio content as bytes +# file_content_bytes: bytes = b"" +# file_mime_type: str = mime_type_map.get(response_format, "audio/wav") + +# # Extract audio data from response +# if response.parts: +# for part in response.parts: +# if part.inline_data is not None and part.inline_data.data: +# file_content_bytes = bytes(part.inline_data.data) +# file_mime_type = part.inline_data.mime_type or file_mime_type +# break + +# metrics: MetricsLogger | None = metrics_ctx.get() +# if metrics: +# metrics.put_metric(key="GeminiTextToSpeechGeneratedCount", value=1, unit="Count") + +# return file_content_bytes, file_mime_type diff --git a/src/files_api/utils.py b/src/files_api/utils.py index 4430e9a..9e90198 100644 --- a/src/files_api/utils.py +++ b/src/files_api/utils.py @@ -8,6 +8,8 @@ from loguru import logger +# TODO: Could replace urllib with httpx + # Use the AWS-Parameters-and-Secrets-Lambda-Extension to retrieve secrets from Secrets Manager # ref: https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html diff --git a/tests/mocks/openai_fastapi_mock_app.py b/tests/mocks/openai_fastapi_mock_app.py index 18a2dd9..a3a45f8 100644 --- a/tests/mocks/openai_fastapi_mock_app.py +++ b/tests/mocks/openai_fastapi_mock_app.py @@ -10,7 +10,6 @@ from io import BytesIO from pathlib import Path -import uvicorn from fastapi import FastAPI from fastapi.responses import ( JSONResponse, @@ -100,4 +99,6 @@ async def create_speech(): if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=MOCK_PORT) diff --git a/uv.lock b/uv.lock index d08309f..ac36dc6 100644 --- a/uv.lock +++ b/uv.lock @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "files-api" -version = "0.1.4" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "aws-embedded-metrics" },