Metadata-Version: 2.4
Name: deployless
Version: 0.1.2
Summary: Compile Flask/FastAPI apps to AWS SAM serverless
Author-email: Antonio Rodriguez <contact@antoniorodriguez.dev>
License-Expression: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: click>=8.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: flask; extra == "dev"
Requires-Dist: build>=1.4.0; extra == "dev"
Requires-Dist: twine>=6.2.0; extra == "dev"

# deployless

**deployless** is a compiler that converts Flask applications (and in the future FastAPI) into AWS SAM templates ready to deploy as serverless Lambda functions. It does not require rewriting your app: simply add configuration annotations to your `routes.py` files and run `deployless build`.

---

## Table of Contents

1. [What is deployless](#what-is-deployless)
2. [Installation](#installation)
3. [deployless.yaml reference](#deploylessyaml-reference)
4. [pc.configure() in routes.py](#pcconfigure-in-routespy)
5. [AWS Resources](#aws-resources)
   - [DynamoDB](#dynamodb)
   - [S3](#s3)
   - [SQS](#sqs)
   - [KMS](#kms)
   - [SSM Parameter Store](#ssm-parameter-store)
6. [@pc.cron() — Scheduled Lambdas](#pccron--scheduled-lambdas)
7. [@pc.route() — Split Lambdas per route](#pcroute--split-lambdas-per-route)
8. [@pc.lambda_function() — Standalone Lambdas](#pclambda_function--standalone-lambdas)
9. [pc.shared_resource() — Global resources](#pcshared_resource--global-resources)
10. [.env file and secrets](#env-file-and-secrets)
11. [CLI commands](#cli-commands) (`build`, `check`, `validate`, `deploy`, `clean`, `info`, `secrets`)
12. [Project structure](#project-structure)
13. [Full example](#full-example)

---

## What is deployless

deployless takes your Flask project structured by features and generates:

- A `template.yaml` for AWS SAM with one Lambda function per feature (and optionally one per specific route).
- A `.dist/` folder with the packaged code for each Lambda, including an auto-generated `bootstrap.py` and a merged `requirements.txt`.
- CloudWatch Log Groups with configurable retention for each function.

### Mental model

```
app/features/users/routes.py   →   UsersFunction (Lambda)
app/features/auth/routes.py    →   AuthFunction  (Lambda)
app/features/tenant/routes.py  →   TenantFunction (Lambda)
```

Each feature lives in its own Lambda. If a specific endpoint needs a different configuration (more memory, longer timeout), you can "split" it into its own Lambda with `@pc.route()`.

### Compilation flow

```
deployless build
  │
  ├── 1. Reads deployless.yaml
  ├── 2. Discovers app/features/*/routes.py
  ├── 3. Imports each routes.py (extracts Blueprints and routes)
  ├── 4. Reads metadata from pc.configure(), @pc.cron(), @pc.route()
  ├── 5. Validates (memory, timeout, duplicate routes, schedules, etc.)
  ├── 6. Generates .dist/{Feature}Function/ for each Lambda
  └── 7. Writes template.yaml
```

---

## Installation

### From the repository (local development)

```bash
# From the project root
pip install -e ./deployless

# Or with uv
uv add --editable ./deployless
```

### deployless dependencies

```
pyyaml
click
flask         # you should already have it installed
```

### Runtime dependency in each Lambda

Each generated Lambda needs `aws-wsgi` to adapt Flask to the API Gateway event format. deployless adds it automatically to the `requirements.txt` of each `.dist/` package.

```bash
pip install aws-wsgi
```

---

## deployless.yaml reference

Create this file at the project root (at the same level as `requirements.txt`). All fields are optional; default values are indicated.

```yaml
# Project name
name: mi-app

# Cloud provider — only "aws" is supported for now
provider: aws

# Deployment stage. Can be overridden with --stage in the CLI.
stage: dev

# Tags applied to all CloudFormation resources
tags:
  Project: mi-app
  Environment: production

# Paths to the key directories of the project
paths:
  features: app/features    # Directory where features live
  shared: app/shared         # Shared code (copied into each Lambda)

# Global config for all Lambda functions
globals:
  runtime: python3.13        # Lambda runtime
  memory: 256                # MB (128–10240)
  timeout: 30                # Seconds (1–900)
  log_retention: 14          # Retention in CloudWatch (days)
                             # Valid values: 1,3,5,7,14,30,60,90,120,
                             # 150,180,365,400,545,731,1096,1827,3653

# API Gateway configuration
api:
  endpoint_type: REGIONAL    # REGIONAL | EDGE | PRIVATE

  # CORS
  cors:
    allow_origin: "*"          # Or a list: ["https://mi-app.com"]
    allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
    allow_headers: [Content-Type, Authorization, X-API-Key]
    max_age: 3600              # Seconds the browser caches the preflight
    # allow_credentials: true  # Not compatible with allow_origin: "*"

  # Global API Gateway authentication
  # (see "API Gateway Authentication" section for details)
  auth:
    type: cognito              # cognito | lambda | iam
    user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
    name: CognitoAuthorizer    # Optional
    scopes: []                 # Optional

  # API Keys
  api_keys: true               # true = generate a new key | "key-id" = use existing

  # Rate limiting (requires api_keys)
  usage_plan:
    rate: 10000                # Requests/second
    burst: 2000                # Maximum peak
    quota: 1000000             # Optional — total requests
    period: DAY                # DAY | WEEK | MONTH (required if quota is set)

  # Custom domain
  domain:
    domain_name: api.mi-app.com
    certificate_arn: "arn:aws:acm:us-east-1:123456789:certificate/abc-123"
    base_path: /v1             # Optional
    route53:                   # Optional — configures DNS automatically
      hosted_zone_id: Z1234567890ABC

  # MIME types that API Gateway treats as binary (non-UTF-8)
  binary_media_types:
    - image/png
    - image/jpeg
    - application/octet-stream

  # Compress responses larger than N bytes
  minimum_compression_size: 1024

# Global environment variables injected into ALL functions
env:
  APP_ENV: production
  LOG_LEVEL: INFO

# .env file — environment variables and secrets
# Normal variables are injected as env vars in all Lambdas.
# Variables with the SECRET_ prefix are pushed to SSM Parameter Store as SecureString
# and injected as dynamic references {{resolve:ssm-secure:...}}.
env_file: .env.production

# KMS key to encrypt secrets in SSM (optional).
# If not specified, SSM uses the AWS-managed key (aws/ssm).
# Accepts alias ("mi-app/secrets") or key ID / ARN.
secrets_kms: mi-app/secrets
```

---

## API Gateway Authentication

### Cognito User Pool

```yaml
api:
  auth:
    type: cognito
    user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
    name: CognitoAuthorizer    # Optional, default: "CognitoAuthorizer"
    scopes:                    # Optional — required OAuth2 scopes
      - email
      - profile
```

### Lambda Authorizer (custom function)

```yaml
api:
  auth:
    type: lambda
    function_arn: "arn:aws:lambda:us-east-1:123456789:function:my-authorizer"
    name: LambdaAuthorizer     # Optional, default: "LambdaAuthorizer"
    ttl: 300                   # Seconds before re-authorizing (0 = no cache)
    identity:
      header: Authorization    # Header where the token is located
```

### IAM

```yaml
api:
  auth:
    type: iam
```

### Override auth per feature

From `routes.py`, you can override the global auth for an entire feature:

```python
import deployless as pc

# All endpoints in this feature are public (no auth)
pc.configure(auth=None)

# All endpoints in this feature require an API key
pc.configure(auth="api_key")
```

### Override auth per individual route (split Lambda)

```python
@pc.route(memory=512, auth=None)      # This endpoint is public
@bp.route('/health', methods=['GET'])
def health_check():
    return {"status": "ok"}

@pc.route(memory=1024, auth="api_key")  # This endpoint requires an API key
@bp.route('/export', methods=['POST'])
def export_data():
    ...
```

### Auth hierarchy (highest priority first)

```
@pc.route(auth=...)        ← Individual route (split lambdas only)
pc.configure(auth=...)     ← Entire feature
api.auth in deployless.yaml   ← Global
```

---

## API Keys and Rate Limiting

```yaml
api:
  api_keys: true        # Generates a new API key
  usage_plan:
    rate: 10000         # 10k requests/second
    burst: 2000         # Peak of 2k simultaneous
    quota: 1000000      # Maximum 1M requests per day
    period: DAY
```

The generated API Key ID appears in the stack Outputs:

```bash
# View the key value (not shown in Outputs for security)
aws apigateway get-api-key --api-key <ApiKeyId> --include-value
```

To use an existing key instead of creating a new one:

```yaml
api:
  api_keys: "abc123existingkeyid"
```

---

### Validation rules

| Code | Rule |
|--------|-------|
| E00 | Resource validations: DynamoDB (key types, GSI, projection INCLUDE), S3 (bucket name DNS-compliant, 3–63 chars, no underscores), SQS (queue name, visibility_timeout, message_retention, max_receive_count), KMS (alias format, valid key_usage/key_spec, ECC/SIGN_VERIFY incompatibilities), SSMParameter (name starts with /, valid chars, valid type, non-empty value) |
| E01 | `stage` can only contain alphanumeric characters |
| E02 | `api.endpoint_type` must be REGIONAL, EDGE, or PRIVATE |
| E03 | `globals.log_retention` must be a valid CloudWatch value |
| E04 | `allow_credentials: true` is not compatible with `allow_origin: "*"` |
| E11 | `api.auth.type` must be cognito, lambda, or iam |
| E12 | `api.auth` (cognito): `user_pool_arn` is required |
| E13 | `api.auth` (lambda): `function_arn` is required |
| E14 | `api.usage_plan`: `rate` and `burst` are required |
| E15 | `api.usage_plan`: `period` is required if `quota` is set |
| E16 | `api.usage_plan.period` must be DAY, WEEK, or MONTH |
| E17 | `api.domain`: `domain_name` and `certificate_arn` are required |
| E18 | `api.minimum_compression_size` must be an integer >= 0 |
| E19 | `ephemeral_storage` out of range (512–10240 MB) |
| E20 | `reserved_concurrency` must be >= 0 |
| E21 | `provisioned_concurrency` must be >= 1 |
| E22 | `log_retention` per feature must be a valid CloudWatch value |
| E23 | `alarms.sns_topic_arn` must be a valid ARN (starts with `arn:`) |
| E24 | `alarms.duration.threshold_pct` must be between 1 and 100 |
| E25 | `lambda_function` memory out of range (128–10240 MB) |
| E26 | `lambda_function` timeout out of range (1–900 s) |
| E27 | Specified `env_file` does not exist |
| E28 | `SECRET_` variable with empty value |
| E29 | Invalid `secrets_kms` format |

---

## pc.configure() in routes.py

`pc.configure()` is called at module level in `routes.py` to register the Lambda configuration for that feature. It is a **no-op at runtime**: when your Flask app starts normally, this call does nothing visible. Only the deployless compiler reads it.

deployless automatically detects which feature is being called by inspecting the call stack.

### Full parameter reference

```python
import deployless as pc

pc.configure(
    # ── Basic ───────────────────────────────────────────────────────────────
    memory=512,                  # int — MB. Overrides globals.memory (128–10240)
    timeout=30,                  # int — Seconds. Overrides globals.timeout (1–900)
    description="Mi feature",    # str — Description visible in CloudFormation

    # ── Environment ──────────────────────────────────────────────────────────
    env={"FLAG": "true"},        # dict — Additional env vars for this Lambda
    layers=["arn:aws:lambda:..."],# list — Lambda Layer ARNs

    # ── IAM ──────────────────────────────────────────────────────────────────
    policies=[                   # list — Inline IAM policies (SAM format)
        "AmazonDynamoDBReadOnlyAccess",          # Managed policy by name
        {"DynamoDBCrudPolicy": {"TableName": pc.Ref(mi_tabla)}},  # SAM policy
        {"Version": "2012-10-17", "Statement": [...]},            # Inline policy
    ],

    # ── AWS Resources ─────────────────────────────────────────────────────────
    resources={                  # dict — Resources this feature uses
        "users": pc.DynamoDB("users-table", pk="id"),
        "files": pc.S3("uploads-bucket"),
        "jobs":  pc.SQS("jobs-queue", dlq=True),
    },

    # ── Architecture ──────────────────────────────────────────────────────────
    architectures=["arm64"],     # list — ["x86_64"] or ["arm64"] (Graviton, ~20% cheaper)
    tracing=True,                # bool — Enables AWS X-Ray distributed tracing

    # ── Concurrency ───────────────────────────────────────────────────────────
    reserved_concurrency=10,     # int >= 0 — Maximum simultaneous execution limit.
                                 #   0 = full throttle (useful for temporarily disabling)
    provisioned_concurrency=3,   # int >= 1 — Pre-warmed instances (eliminates cold starts).
                                 #   Implies AutoPublishAlias: live in the template.

    # ── Temporary storage ─────────────────────────────────────────────────────
    ephemeral_storage=1024,      # int — Size of /tmp in MB (512–10240, default 512)

    # ── Reliability ───────────────────────────────────────────────────────────
    dlq=True,                    # bool — Creates an SQS Dead Letter Queue for
                                 #   failed asynchronous invocations

    # ── Observability ─────────────────────────────────────────────────────────
    log_retention=30,            # int — Retention days in CloudWatch (overrides global)

    alarms=True,                 # Enables CloudWatch Alarms with default thresholds
    # alarms=False,              # Disables alarms for this feature
    # alarms={...},              # Custom config (see Alarms section)

    # ── Auth (API Gateway) ────────────────────────────────────────────────────
    auth=None,                   # None = public routes | "api_key" = requires API key
                                 # (not specified = inherits global auth from deployless.yaml)
)
```

### Full example

```python
# app/features/user/routes.py
from flask import Blueprint
import deployless as pc

users_table = pc.DynamoDB(
    "users-table",
    pk="tenant_id",
    sk="user_id",
    gsi=[{"name": "EmailIndex", "pk": "email"}],
    ttl_attribute="expires_at",
    deletion_policy="Retain",
)

pc.configure(
    memory=512,
    timeout=30,
    description="User Management API",
    resources={"users": users_table},
    policies=[{"DynamoDBCrudPolicy": {"TableName": pc.Ref(users_table)}}],
    architectures=["arm64"],
    dlq=True,
    alarms=True,
    log_retention=30,
)

user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@user_bp.route("", methods=["GET"])
def list_users():
    ...
```

---

## AWS Resources

Resources are declared inside `pc.configure(resources={...})` in the `routes.py` of each feature. deployless adds them to `template.yaml` and automatically assigns environment variables to them.

### DynamoDB

```python
pc.DynamoDB(
    table_name: str,                      # Table name in AWS
    pk: str = "id",                       # Partition key
    pk_type: str = "S",                   # "S" (String) | "N" (Number) | "B" (Binary)
    sk: str = None,                       # Optional sort key. If defined → AWS::DynamoDB::Table
    sk_type: str = "S",                   # "S" | "N" | "B"
    gsi: list = None,                     # Global Secondary Indexes (see format below)
    billing_mode: str = "PAY_PER_REQUEST",# "PAY_PER_REQUEST" | "PROVISIONED"
    read_capacity: int = None,            # Only for billing_mode="PROVISIONED" (default: 5)
    write_capacity: int = None,           # Only for billing_mode="PROVISIONED" (default: 5)
    ttl_attribute: str = None,            # Time-To-Live attribute (DynamoDB expires it automatically)
    stream: str = None,                   # "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY"
    point_in_time_recovery: bool = False, # Enables PITR (point-in-time recovery)
    sse_enabled: bool = True,             # Encryption at rest with AWS-managed KMS
    deletion_policy: str = "Delete",      # "Delete" | "Retain" | "Snapshot"
    existing: bool = False,               # True = table already exists, do not create (only injects env var)
)
```

#### CloudFormation type

deployless always generates `AWS::DynamoDB::Table` regardless of whether a sort key or GSI is defined. This avoids CloudFormation replacement (and data loss) when you later add a sort key or GSI to a table that started with only a partition key.

#### Auto-generated environment variable

The `-table` / `_table` suffix is removed to avoid redundancy:

| `table_name` | Environment variable |
|---|---|
| `users-table` | `USERS_TABLE` |
| `orders_table` | `ORDERS_TABLE` |
| `sessions` | `SESSIONS_TABLE` |

#### GSI format

Each element of the `gsi` list accepts:

```python
{
    "name": "StatusIndex",           # Required — index name
    "pk": "status",                  # Required — index partition key
    "pk_type": "S",                  # Optional, default "S"
    "sk": "created_at",              # Optional — index sort key
    "sk_type": "S",                  # Optional, default "S"
    "projection": "ALL",             # "ALL" | "KEYS_ONLY" | "INCLUDE" (default "ALL")
    "non_key_attributes": ["email"], # Required only if projection="INCLUDE"
}
```

#### Examples

**Simple table (PK only):**
```python
pc.DynamoDB("sessions-table", pk="session_id", ttl_attribute="expires_at")
# → AWS::DynamoDB::Table
# → Variable: SESSIONS_TABLE
```

**Table with SK and multiple GSIs:**
```python
pc.DynamoDB(
    "orders-table",
    pk="tenant_id",
    sk="order_id",
    gsi=[
        {
            "name": "StatusIndex",
            "pk": "status",
            "sk": "created_at",
        },
        {
            "name": "CustomerIndex",
            "pk": "customer_id",
            "projection": "INCLUDE",
            "non_key_attributes": ["total", "status"],
        },
    ],
    ttl_attribute="expires_at",
    point_in_time_recovery=True,
    deletion_policy="Retain",
)
# → AWS::DynamoDB::Table with SSEEnabled=True
# → Variable: ORDERS_TABLE
```

**Table with provisioned capacity:**
```python
pc.DynamoDB(
    "high-traffic-table",
    pk="pk",
    sk="sk",
    billing_mode="PROVISIONED",
    read_capacity=100,
    write_capacity=50,
)
```

**Table with DynamoDB Streams:**
```python
pc.DynamoDB(
    "events-table",
    pk="event_id",
    stream="NEW_AND_OLD_IMAGES",  # Triggers a Lambda on every change
)
```

**Existing table (do not create, only inject env var):**
```python
pc.DynamoDB("prod-users-table", existing=True)
# Does not generate a CloudFormation resource
# Injects: PROD_USERS_TABLE = "prod-users-table" (literal string)
```

---

### S3

```python
pc.S3(
    bucket_name: str,
    versioning: bool = False,
    encryption: bool = True,        # SSE-S3 (AES256) enabled by default
    cors: list = None,              # List of CORS rules (CloudFormation CorsRule format)
    lifecycle_rules: list = None,   # List of lifecycle rules (CloudFormation format)
    public_access_block: bool = True,  # Blocks public access by default
    deletion_policy: str = "Delete",
    existing: bool = False,
)
```

**Auto-generated environment variable:**
- `uploads-bucket` → `UPLOADS_BUCKET`
- `my_files_bucket` → `MY_FILES_BUCKET` (the `-bucket` / `_bucket` suffix is removed)

**Compile-time validations (E00):**
- `bucket_name` cannot be empty
- Length between 3 and 63 characters
- Cannot contain underscores (S3 is DNS-compliant)
- Lowercase only, digits, hyphens, and dots — starts and ends with alphanumeric

**Basic example:**

```python
pc.S3("user-uploads")
# → SSE-S3 AES256 enabled, public access blocked
# → Variable: UPLOADS_BUCKET
```

**Example with all options:**

```python
pc.S3(
    "user-uploads",
    versioning=True,
    encryption=True,           # AES256 by default — pass False only if using external KMS
    public_access_block=True,
    deletion_policy="Retain",
    cors=[
        {
            "AllowedOrigins": ["https://mi-app.com"],
            "AllowedMethods": ["GET", "PUT"],
            "AllowedHeaders": ["*"],
            "MaxAge": 3600,
        }
    ],
    lifecycle_rules=[
        {
            "Id": "expire-tmp",
            "Status": "Enabled",
            "ExpirationInDays": 7,
            "Prefix": "tmp/",
        }
    ],
)
```

---

### SQS

```python
pc.SQS(
    queue_name: str,
    fifo: bool = False,               # True = FIFO queue. Adds .fifo to the name automatically.
    dlq: bool = False,                # True = also creates a Dead Letter Queue
    visibility_timeout: int = 30,     # seconds (0–43200)
    message_retention: int = 345600,  # seconds (60–1209600, default 4 days)
    max_receive_count: int = 3,       # Attempts before sending to DLQ (1–1000)
    encryption: bool = True,          # SqsManagedSseEnabled — SSE-SQS enabled by default
    deletion_policy: str = "Delete",
    existing: bool = False,
)
```

**Note:** SQS and KMS return **multiple** CloudFormation resources (the main queue + DLQ, or the key + alias). deployless inserts them all correctly into the template.

**Auto-generated environment variable:**
- `notifications-queue` → `NOTIFICATIONS_QUEUE_URL`

**Compile-time validations (E00):**
- `queue_name` cannot be empty or exceed 80 characters
- Alphanumeric only, `-` and `_` (the `.fifo` suffix is excluded from validation)
- `visibility_timeout` must be in range `[0, 43200]`
- `message_retention` must be in range `[60, 1209600]`
- `max_receive_count` must be in range `[1, 1000]`

**Basic example:**

```python
pc.SQS("email-notifications")
# → SSE-SQS enabled, 4-day retention, 30s visibility
# → Variable: EMAIL_NOTIFICATIONS_QUEUE_URL
```

**Example with DLQ:**

```python
pc.SQS(
    "email-notifications",
    dlq=True,
    visibility_timeout=60,
    message_retention=86400,   # 1 day
    max_receive_count=5,
)
# → Main queue + DLQ with 14-day retention
# → Both with SSE-SQS enabled
```

**FIFO example:**

```python
pc.SQS(
    "orders",
    fifo=True,       # → queue_name becomes "orders.fifo" automatically
    dlq=True,        # → DLQ will also be FIFO: "orders-dlq.fifo"
)
```

---

### KMS

```python
pc.KMS(
    alias: str = None,                      # e.g. "alias/mi-app" or simply "mi-app"
    description: str = None,
    key_usage: str = "ENCRYPT_DECRYPT",     # "ENCRYPT_DECRYPT" | "SIGN_VERIFY" | "GENERATE_VERIFY_MAC"
    key_spec: str = "SYMMETRIC_DEFAULT",    # "SYMMETRIC_DEFAULT" | "RSA_2048/3072/4096"
                                            # | "ECC_NIST_P256/P384/P521" | "ECC_SECG_P256K1"
                                            # | "HMAC_224/256/384/512"
    enable_rotation: bool = None,           # None → auto: True for SYMMETRIC_DEFAULT, False otherwise
    deletion_policy: str = "Retain",        # KMS uses Retain by default (security)
    existing_key_id: str = None,            # ID or ARN of an existing key (does not create resource)
    env_var: str = None,                    # Forces the name of the generated env var
)
```

**Auto-generated environment variable:**
- `env_var="MY_KEY"` → `MY_KEY` (takes priority over any automatic derivation)
- `alias="myapp/encryption"` → `MYAPP_ENCRYPTION_KEY_ID`
- No alias or env_var → `KMS_KEY_ID`

**Generated CloudFormation resources:**
- `AWS::KMS::Key` — with `Enabled: True`, `KeyUsage`, `KeySpec`, and a basic key policy (root account)
- `AWS::KMS::Alias` — optional alias to identify the key by name
- `EnableKeyRotation` is only added when `key_spec="SYMMETRIC_DEFAULT"` (asymmetric keys do not support automatic rotation)

**Compile-time validations (E00):**
- `alias` can only contain alphanumeric characters, `-`, `_`, `/`
- `key_usage` must be one of the valid values
- `key_spec` must be one of the valid values
- `enable_rotation=True` is not valid for asymmetric keys (RSA, ECC, HMAC)
- ECC `key_spec` is not compatible with `key_usage="ENCRYPT_DECRYPT"`
- `key_spec="SYMMETRIC_DEFAULT"` is not compatible with `key_usage="SIGN_VERIFY"`

**Note:** The Lambda does NOT have permissions to use the key by default. You must add the IAM policy explicitly with `pc.configure(policies=[...])`.

#### Example with IAM permissions

```python
kms_key = pc.KMS(
    alias="mi-app/datos",
    description="Encryption key for sensitive data",
    enable_rotation=True,
    deletion_policy="Retain",
)

pc.configure(
    resources={"datos_key": kms_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey"],
                    "Resource": pc.Ref(kms_key),
                }
            ],
        }
    ],
)
```

#### How to use the key in app code

The `KMS_KEY_ID` environment variable (or `{ALIAS}_KEY_ID` if using an alias) is automatically injected into the Lambda. Use it in your encryption services:

```python
# app/features/tenant/services/kms_service.py
import boto3
import base64
import os
from botocore.exceptions import ClientError

kms_client = boto3.client('kms')

def encrypt_with_kms(plaintext: str) -> str:
    """Encrypts a string and returns the ciphertext in base64."""
    response = kms_client.encrypt(
        KeyId=os.getenv('KMS_KEY_ID'),
        Plaintext=plaintext.encode('utf-8'),
    )
    return base64.b64encode(response['CiphertextBlob']).decode('utf-8')

def decrypt_with_kms(ciphertext_b64: str) -> str:
    """Decrypts a base64 ciphertext and returns the plaintext."""
    ciphertext_blob = base64.b64decode(ciphertext_b64)
    response = kms_client.decrypt(CiphertextBlob=ciphertext_blob)
    return response['Plaintext'].decode('utf-8')
```

> `kms:Decrypt` does not need to specify `KeyId` because the ciphertext already embeds the ID of the key that encrypted it.

#### Full example — RSA key encryption per tenant

A real pattern used in the reference app: the tenant feature encrypts the RSA private key when creating the tenant, and the auth feature decrypts it on each login.

```python
# app/features/tenant/routes.py
import deployless as pc

tenant_key = pc.KMS(
    alias="ums/tenant-keys",
    description="Encryption of RSA private keys per tenant",
    enable_rotation=True,
    deletion_policy="Retain",
)

tenants_table = pc.DynamoDB("ums-tenants", pk="tenant_id", deletion_policy="Retain")

pc.configure(
    resources={
        "tenants": tenants_table,
        "tenant_key": tenant_key,
    },
    policies=[
        {"DynamoDBCrudPolicy": {"TableName": pc.Ref(tenants_table)}},
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Encrypt"],          # tenant only encrypts
                    "Resource": pc.Ref(tenant_key),
                }
            ],
        },
    ],
)
```

```python
# app/features/auth/routes.py
import deployless as pc

# Reuses the same existing key (does not create it again)
tenant_key = pc.KMS(existing_key_id=os.getenv("UMS_TENANT_KEYS_KEY_ID"))

pc.configure(
    resources={"tenant_key": tenant_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Decrypt"],          # auth only decrypts
                    "Resource": os.getenv("UMS_TENANT_KEYS_KEY_ID"),
                }
            ],
        }
    ],
)
```

**Auto-injected environment variables:**

| Alias | Variable |
|---|---|
| `ums/tenant-keys` | `UMS_TENANT_KEYS_KEY_ID` |
| `mi-app` | `MI_APP_KEY_ID` |
| No alias | `KMS_KEY_ID` |

#### Asymmetric key for digital signing (RSA)

```python
signing_key = pc.KMS(
    alias="mi-app/signing",
    description="RSA key for signing JWTs or documents",
    key_usage="SIGN_VERIFY",
    key_spec="RSA_2048",
    # enable_rotation does not apply — automatically ignored for asymmetric keys
)

pc.configure(
    resources={"signing_key": signing_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Sign", "kms:Verify", "kms:GetPublicKey"],
                    "Resource": pc.Ref(signing_key),
                }
            ],
        }
    ],
)
```

#### Existing key (do not create, only inject env var)

```python
pc.KMS(existing_key_id="arn:aws:kms:us-east-1:123456789:key/abc-123")
# Does not generate a CloudFormation resource
# KMS_KEY_ID = "arn:aws:kms:us-east-1:123456789:key/abc-123"
```

---

### SSM Parameter Store

deployless provides two tools for SSM: `pc.SSMParameter` to **create** a parameter as a CloudFormation resource, and `pc.SSMParam` to **reference** an existing parameter as a dynamic reference in env vars.

#### pc.SSMParameter — create a parameter

```python
pc.SSMParameter(
    name: str,                  # Parameter path, must start with "/"
    value: str,                 # Parameter value
    type: str = "String",       # "String" | "StringList" | "SecureString"
    description: str = None,
    existing: bool = False,     # True = do not create, only inject env var
)
```

**Auto-generated environment variable** — last segment of the path:
- `/myapp/db/host` → `HOST`
- `/myapp/api/secret-key` → `SECRET_KEY`

**Compile-time validations (E00):**
- `name` must start with `/`
- Alphanumeric only, `.`, `-`, `_`, `/`
- `type` must be `String`, `StringList`, or `SecureString`
- `value` cannot be empty (except for `SecureString`)

**Example:**

```python
db_host = pc.SSMParameter(
    "/myapp/db/host",
    value="db.example.com",
    description="RDS endpoint",
)

pc.configure(
    resources={"db_host": db_host},
    policies=["SSMParameterReadPolicy": {"ParameterName": "/myapp/db/host"}],
)
# → Variable: HOST = {"Ref": "MyappDbHostParameter"}
```

#### pc.SSMParam — reference an existing parameter

Does not generate a CloudFormation resource. Produces a CloudFormation **dynamic reference** directly in the env var value.

```python
pc.SSMParam(
    name: str,              # Path of the existing parameter
    secure: bool = False,   # True → "{{resolve:ssm-secure:/path}}" (SecureString)
    version: int = None,    # Optional — pin to a specific version
)
```

**Usage in env vars:**

```python
pc.configure(
    env={
        "DB_HOST":   pc.SSMParam("/prod/db/host"),
        "API_KEY":   pc.SSMParam("/prod/api/key", secure=True),
        "DB_PASS":   pc.SSMParam("/prod/db/password", secure=True, version=3),
    }
)
```

This generates in the template:

```yaml
Environment:
  Variables:
    DB_HOST:  "{{resolve:ssm:/prod/db/host}}"
    API_KEY:  "{{resolve:ssm-secure:/prod/api/key}}"
    DB_PASS:  "{{resolve:ssm-secure:/prod/db/password:3}}"
```

> `{{resolve:ssm-secure:...}}` only works with `SecureString` parameters and requires the Lambda to have `ssm:GetParameter` + `kms:Decrypt` permission on the parameter's KMS key.

---

---

## CloudWatch Alarms

deployless can automatically generate 3 alarms per Lambda: errors, throttles, and duration.

### Activation

```python
# In routes.py — enables alarms with default thresholds
pc.configure(alarms=True)

# With custom thresholds
pc.configure(alarms={
    "errors": {
        "threshold": 1,      # Trigger when Errors >= 1 in the period
        "period": 300,        # Evaluation period in seconds
    },
    "throttles": {
        "threshold": 1,
        "period": 300,
    },
    "duration": {
        "threshold_pct": 80,  # Trigger when Duration > 80% of the configured timeout
        "period": 300,        # (if timeout=30s → alarm at 24000ms)
    },
    "sns_topic_arn": "arn:aws:sns:us-east-1:123456789:my-alerts",  # Optional
})

# Disable alarms for this feature even if globally active
pc.configure(alarms=False)
```

### Global alarms (for all features)

In `deployless.yaml`, you can activate alarms for the entire project:

```yaml
alarms:
  errors:
    threshold: 1
    period: 300
  throttles:
    threshold: 1
    period: 300
  duration:
    threshold_pct: 80
    period: 300
  sns_topic_arn: "arn:aws:sns:us-east-1:123456789:my-alerts"
```

### Generated resources

For each feature with `alarms` active, deployless generates in the template:

```yaml
UserFunctionErrorsAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Errors
    Namespace: AWS/Lambda
    Statistic: Sum
    Period: 300
    Threshold: 1
    ComparisonOperator: GreaterThanOrEqualToThreshold
    TreatMissingData: notBreaching

UserFunctionThrottlesAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Throttles
    # ...

UserFunctionDurationAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Duration
    Statistic: Maximum
    Threshold: 24000    # 80% of 30s = 24000ms
    # ...
```

---

### pc.Ref() and pc.GetAtt() — Referencing resources

Use `pc.Ref(resource)` to get the logical ID of a resource (generates `{"Ref": "LogicalId"}`), and `pc.GetAtt(resource, attr)` to get a specific attribute (generates `{"Fn::GetAtt": ["LogicalId", "Attr"]}`).

```python
tabla = pc.DynamoDB("users-table")
bucket = pc.S3("uploads")

pc.configure(
    resources={"users": tabla, "uploads": bucket},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
                    "Resource": pc.GetAtt(tabla, "Arn"),
                },
                {
                    "Effect": "Allow",
                    "Action": ["s3:GetObject", "s3:PutObject"],
                    "Resource": pc.GetAtt(bucket, "Arn"),
                },
            ]
        }
    ],
)
```

`pc.Ref()` and `pc.GetAtt()` accept both a resource object and a string with the CloudFormation logical ID.

---

## @pc.cron() — Scheduled Lambdas

Decorate any function with `@pc.cron()` to have deployless deploy it as a separate Lambda triggered by EventBridge (CloudWatch Events) on the indicated schedule.

```python
@pc.cron(
    schedule: str,          # Schedule expression (required)
    memory: int = None,     # MB. If None, uses globals.memory
    timeout: int = None,    # Seconds. If None, uses globals.timeout
    env: dict = None,       # Additional environment variables
    description: str = None,
)
```

**Schedule formats:**
- `"rate(5 minutes)"` — every 5 minutes
- `"rate(1 hour)"` — every hour
- `"rate(24 hours)"` — daily
- `"cron(0 9 * * ? *)"` — every day at 9:00 UTC

**The function must have the Lambda signature `(event, context)`.**

**Example:**

```python
# app/features/user/routes.py
import deployless as pc

@pc.cron(
    schedule="rate(24 hours)",
    memory=128,
    timeout=300,
    description="Daily cleanup of expired users",
)
def cleanup_expired_users(event, context):
    # Your logic here
    deleted = delete_expired_users()
    return {"status": "ok", "deleted": deleted}
```

This generates in `template.yaml`:

```yaml
CleanupExpiredUsersFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: .dist/CleanupExpiredUsersFunction/
    Handler: bootstrap.handler
    MemorySize: 128
    Timeout: 300
    Description: Limpieza diaria de usuarios expirados
    Events:
      Schedule:
        Type: Schedule
        Properties:
          Schedule: rate(24 hours)
```

---

## @pc.route() — Split Lambdas per route

By default, all routes in a feature share a single Lambda. With `@pc.route()` you can isolate a specific endpoint into its own Lambda (useful for endpoints that consume many resources or have different timeouts).

```python
@pc.route(
    memory: int = None,
    timeout: int = None,
    description: str = None,
    auth = <not specified>,   # None = public | "api_key" = requires API key
                              # (not specified = inherits auth from feature or global)
)
```

**The `@pc.route()` decorator must go above the Flask decorator.**

```python
# app/features/user/routes.py
import deployless as pc
from flask import Blueprint

user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@pc.route(memory=1024, timeout=120, description="Heavy data export")
@user_bp.route("/export", methods=["POST"])
def export_users():
    # This endpoint will have its own Lambda with 1 GB and 2-minute timeout
    ...

@user_bp.route("", methods=["GET"])
def list_users():
    # This endpoint goes in the feature's shared Lambda
    ...
```

This generates two separate Lambda functions:
- `UserFunction` — contains `GET /users` (and all other endpoints without `@pc.route()`)
- `ExportUsersFunction` — contains only `POST /users/export`

---

## @pc.lambda_function() — Standalone Lambdas

For Lambda functions that have no HTTP routes or schedules — for example, SQS consumers, S3 event handlers, or Step Functions steps — use `@pc.lambda_function()`.

```python
@pc.lambda_function(
    memory: int = None,       # MB. If None, uses globals.memory
    timeout: int = None,      # Seconds. If None, uses globals.timeout
    env: dict = None,         # Additional environment variables
    description: str = None,
)
```

**The function must have the Lambda signature `(event, context)`.**

**Example:**

```python
# app/features/orders/routes.py
import deployless as pc

@pc.lambda_function(memory=512, timeout=60, description="Processes messages from the orders queue")
def process_order_queue(event, context):
    for record in event.get("Records", []):
        body = record["body"]
        print(f"Procesando pedido: {body}")
    return {"processed": len(event.get("Records", []))}
```

This generates in `template.yaml`:

```yaml
ProcessOrderQueueFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: .dist/ProcessOrderQueueFunction/
    Handler: bootstrap.handler
    MemorySize: 512
    Timeout: 60
    Description: Procesa mensajes de la cola de pedidos
```

> **Note:** Unlike HTTP features, standalone lambdas have no API Gateway events. You can connect them to SQS, S3, DynamoDB Streams, etc. manually in the template or via event source mappings.

---

## pc.shared_resource() — Global resources

If a resource (DynamoDB table, S3 bucket, SQS queue, etc.) must be available to **all** features, use `pc.shared_resource()` instead of declaring it inside the `resources` of an individual feature.

```python
pc.shared_resource(key: str, resource)
```

- The resource is included **only once** in the CloudFormation template.
- The resource's environment variables are injected into **all** Lambdas in the project.
- It can be referenced with `pc.Ref()` and `pc.GetAtt()` from any feature.

**Example:**

```python
# app/features/events/routes.py (or any routes.py)
import deployless as pc

# Table shared by all features
pc.shared_resource("audit_log", pc.DynamoDB("audit-log", pk="event_id", sk="timestamp"))

# Shared bucket
pc.shared_resource("shared_assets", pc.S3("app-shared-assets"))
```

From any feature you can use the generated environment variables:

```python
import os

audit_table = os.getenv("AUDIT_LOG_TABLE")       # Injected in ALL Lambdas
assets_bucket = os.getenv("APP_SHARED_ASSETS_BUCKET")
```

---

## .env file and secrets

deployless can read a `.env` file to inject environment variables and manage secrets automatically.

### Configuration in deployless.yaml

```yaml
env_file: .env.production       # Path to the .env file

# Optional — KMS key to encrypt secrets in SSM
secrets_kms: mi-app/secrets     # Alias, key ID, or ARN
```

### .env file format

```env
# Normal variables — injected directly as env vars in all Lambdas
APP_ENV=production
LOG_FORMAT=json

# Secrets — the SECRET_ prefix indicates they are pushed to SSM Parameter Store
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxxx
```

### Behavior

| Type | Example | Destination | Value in Lambda |
|------|---------|---------|-----------------|
| Normal | `APP_ENV=production` | Direct env var | `production` |
| Secret | `SECRET_DB_PASSWORD=xxx` | SSM Parameter Store | `{{resolve:ssm:/mi-app/SECRET_DB_PASSWORD}}` |

**For `SECRET_` variables:**

1. The name is kept in full with the prefix: `SECRET_DB_PASSWORD` → `/mi-app/SECRET_DB_PASSWORD`
2. The value is stored as `String` in SSM Parameter Store under the path `/{app_name}/{VAR_NAME}`
3. The Lambda receives a **dynamic reference** `{{resolve:ssm:...}}` that CloudFormation resolves when creating/updating the stack
4. The env var in the Lambda also keeps the full name: `SECRET_DB_PASSWORD`

> **Note:** `String` (not `SecureString`) is used because CloudFormation does not support `{{resolve:ssm-secure:...}}` in Lambda environment variables. The value is still protected by IAM — only roles with `ssm:GetParameter` permission can read it.

### Validations

| Code | Rule |
|--------|-------|
| E27 | The specified `env_file` does not exist |
| E28 | `SECRET_` variable with empty value |
| E29 | Invalid `secrets_kms` format (alias can only contain alphanumeric characters, `-`, `_`, `/`) |

---

## CLI commands

### `deployless build`

Generates `template.yaml` and builds the `.dist/` packages.

```bash
deployless build

# Options:
deployless build --stage prod            # Overrides the stage
deployless build -o infra/template.yaml  # Template output path
deployless build --dry-run               # Validates without writing files
deployless build --push-secrets          # Also push SECRET_ vars to SSM
deployless build --verbose               # Detailed output
```

### `deployless validate`

Validates the project without generating any files. Equivalent to `build --dry-run` but with cleaner output.

```bash
deployless validate
deployless validate --stage prod
deployless validate --check-existing   # Verifies that resources with existing=True exist in AWS
deployless validate --verbose
```

### `deployless check`

Runs pre-flight checks before deploying: validates env vars, verifies the SAM CLI is installed, checks AWS credentials, and verifies that resources declared with `existing=True` actually exist in AWS.

```bash
deployless check
deployless check --stage prod
deployless check --verbose
```

### `deployless deploy`

Chains `deployless check` + `deployless build` + `sam build` + `sam deploy`. Requires the [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) to be installed.

If `samconfig.toml` does not exist (first deployment), `--guided` is added automatically so SAM prompts for the initial configuration.

```bash
deployless deploy
deployless deploy --stage prod
deployless deploy --guided          # Force wizard mode
deployless deploy --push-secrets    # Push SECRET_ vars to SSM before deploying
```

### `deployless clean`

Removes the generated files (`.dist/` and `template.yaml`).

```bash
deployless clean
deployless clean -o infra/template.yaml  # If you used a different output path
```

### `deployless info`

Shows a summary of the detected project.

```bash
deployless info
```

Example output:

```
Project  : mi-ums-api
Provider : aws
Stage    : dev
Runtime  : python3.13

Features (3):
  - auth    (app/features/auth/routes.py)
  - tenant  (app/features/tenant/routes.py)
  - user    (app/features/user/routes.py)
```

### `deployless secrets push`

Pushes the `SECRET_*` variables from the `.env` file to AWS SSM Parameter Store.

```bash
deployless secrets push
deployless secrets push --stage prod
deployless secrets push --env-file .env.prod   # Overrides the env_file path from deployless.yaml
deployless secrets push --verbose
```

**Process:**
1. Reads the `.env` file (from `deployless.yaml` or `--env-file`)
2. Filters variables with the `SECRET_` prefix
3. Creates/updates SSM parameters: `/{app_name}/{VAR_NAME}` (type `String`)

> **Note:** `deployless build` does **not** push secrets automatically. Use `deployless secrets push` explicitly, or pass `--push-secrets` to `deployless build` / `deployless deploy`.

**Example:**

```env
# .env.prod
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxx
```

```bash
deployless secrets push --env-file .env.prod
# Creates in SSM:
#   /mi-app/SECRET_DB_PASSWORD  (String)
#   /mi-app/SECRET_API_KEY      (String)
```

### `deployless secrets sync`

Push + removes orphaned parameters in SSM. Useful for keeping SSM in sync when secrets are removed from the `.env`.

```bash
deployless secrets sync
deployless secrets sync --stage prod
deployless secrets sync --env-file .env.prod
deployless secrets sync --yes              # Auto-confirms deletion of orphans
deployless secrets sync --verbose
```

**Behavior:**
1. Pushes all `SECRET_*` variables (same as `secrets push`)
2. Lists existing parameters under `/{app_name}/` in SSM
3. Detects parameters that are no longer in the `.env`
4. Asks for confirmation before deleting them (unless `--yes` is used)

---

## Project structure

deployless expects the following directory structure (configurable in `deployless.yaml`):

```
mi-proyecto/
├── deployless.yaml             # deployless configuration
├── requirements.txt         # Global project dependencies
├── app/
│   ├── features/            # One folder per feature
│   │   ├── auth/
│   │   │   ├── routes.py    # REQUIRED — Flask Blueprint + pc.configure()
│   │   │   ├── use_cases/
│   │   │   ├── repositories/
│   │   │   └── schemas/
│   │   ├── user/
│   │   │   ├── routes.py
│   │   │   ├── requirements.txt  # OPTIONAL — extra dependencies for this feature
│   │   │   └── ...
│   │   └── tenant/
│   │       └── routes.py
│   └── shared/              # Shared code — copied into ALL Lambdas
│       ├── decorators/
│       ├── errors/
│       └── config.py
└── .dist/                   # Generated by deployless build (do not commit to git)
    ├── AuthFunction/
    │   ├── app/
    │   │   ├── __init__.py
    │   │   ├── features/
    │   │   │   ├── __init__.py
    │   │   │   └── auth/        # Only this feature's code
    │   │   │       ├── routes.py
    │   │   │       ├── use_cases/
    │   │   │       └── ...
    │   │   └── shared/          # Copy of app/shared/
    │   ├── bootstrap.py         # Auto-generated
    │   ├── deployless.py           # Runtime stub (no-ops)
    │   └── requirements.txt     # Global + feature + aws-wsgi requirements.txt
    ├── UserFunction/
    └── TenantFunction/
```

### Discovery rules

- deployless scans `app/features/` looking for subdirectories that contain a `routes.py` file.
- Directories starting with `_` (e.g. `__pycache__`) are ignored.
- They are processed in alphabetical order.
- Each `routes.py` must define at least one Flask Blueprint with at least one route.

### The generated bootstrap

For each Lambda a `bootstrap.py` is generated that:

1. Registers all Flask Blueprints found in `routes.py`.
2. Creates a temporary Flask app.
3. Wraps the app with `aws_wsgi.response()` to convert API Gateway events into WSGI requests.

```python
# .dist/UserFunction/bootstrap.py — auto-generated, do not edit
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from flask import Flask
import app.features.user.routes as _routes_module  # full app/ namespace
import inspect

flask_app = Flask(__name__)
for _name, _obj in inspect.getmembers(_routes_module):
    _klass = type(_obj)
    if _klass.__name__ == "Blueprint" and "flask" in _klass.__module__:
        flask_app.register_blueprint(_obj)

import awsgi
def handler(event, context):
    return awsgi.response(flask_app, event, context, base64_content_types={"image/png", "image/jpeg"})
```

Each Lambda also includes a `deployless.py` with no-op implementations of all deployless functions (`configure`, `KMS`, `DynamoDB`, etc.), so that `import deployless as pc` statements in `routes.py` do not fail at runtime without needing to install the full package.

---

## Full example

This example uses the real app in this repository (`app/features/auth`, `user`, `tenant`).

### 1. deployless.yaml

```yaml
name: ums-api
provider: aws
stage: dev

paths:
  features: app/features
  shared: app/shared

globals:
  runtime: python3.13
  memory: 256
  timeout: 30
  log_retention: 14

api:
  endpoint_type: REGIONAL
  cors:
    allow_origin: "*"
    allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
    allow_headers: [Content-Type, Authorization, X-Api-Key]

env:
  LOG_LEVEL: INFO
```

### 2. app/features/user/routes.py

```python
from flask import Blueprint, request, g, jsonify
import deployless as pc

from app.features.user.schemas import CreateUserRequest, UpdateUserRequest
from app.features.user.use_cases import create_user, list_users, get_user, update_user, delete_user
from app.shared.decorators import require_auth, require_scopes

# ---- Lambda configuration for the "user" feature ----
pc.configure(
    memory=512,
    timeout=30,
    description="User Management Service",
    resources={
        "users": pc.DynamoDB(
            "ums-users",
            pk="tenant_id",
            pk_type="S",
            sk="user_id",
            sk_type="S",
            gsi=[
                {
                    "name": "EmailIndex",
                    "pk": "email",
                    "pk_type": "S",
                }
            ],
            ttl_attribute="expires_at",
            deletion_policy="Retain",
        ),
        "sessions": pc.DynamoDB(
            "ums-sessions",
            pk="session_id",
            ttl_attribute="expires_at",
        ),
    },
    env={
        "TOKEN_EXPIRY": "3600",
    },
)

# ---- Cron: daily cleanup of expired sessions ----
@pc.cron(
    schedule="rate(24 hours)",
    memory=128,
    timeout=60,
    description="Limpieza de sesiones expiradas",
)
def cleanup_sessions(event, context):
    # Cleanup logic
    return {"status": "ok"}

# ---- Flask Blueprint ----
user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@user_bp.route("", methods=["POST"])
@require_auth
@require_scopes(["ums:users:create"])
def create_user_route():
    data = request.get_json()
    req = CreateUserRequest(
        email=data.get("email"),
        password=data.get("password"),
        scopes=data.get("scopes", []),
    )
    response = create_user(req, g.user["tenant_id"])
    return jsonify(response.to_dict()), 201

@user_bp.route("", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def list_users_route():
    response = list_users(g.user["tenant_id"])
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def get_user_route(user_id):
    response = get_user(g.user["tenant_id"], user_id)
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["PUT"])
@require_auth
@require_scopes(["ums:users:update"])
def update_user_route(user_id):
    data = request.get_json()
    req = UpdateUserRequest(
        email=data.get("email"),
        password=data.get("password"),
        scopes=data.get("scopes"),
    )
    response = update_user(g.user["tenant_id"], user_id, req)
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["DELETE"])
@require_auth
@require_scopes(["ums:users:delete"])
def delete_user_route(user_id):
    delete_user(g.user["tenant_id"], user_id)
    return "", 204

# ---- Split Lambda: heavy export ----
@pc.route(memory=1024, timeout=120, description="Exportación masiva de usuarios")
@user_bp.route("/export", methods=["POST"])
@require_auth
@require_scopes(["ums:users:export"])
def export_users_route():
    # This endpoint will have its own Lambda
    ...
    return jsonify({"url": "https://..."}), 200
```

### 3. Run the build

```bash
deployless build --verbose
```

Expected output:

```
[deployless] Project: ums-api | Stage: dev | Provider: aws
[deployless] Features found: ['auth', 'tenant', 'user']
[deployless]   auth: 3 routes, 0 split
[deployless]   tenant: 2 routes, 0 split
[deployless]   user: 5 routes, 1 split
[deployless] Crons: ['cleanup_sessions']
[deployless] Validation passed.
[deployless]   Built: .dist/AuthFunction
[deployless]   Built: .dist/TenantFunction
[deployless]   Built: .dist/UserFunction
[deployless]   Built split route: .dist/ExportUsersRouteFunction
[deployless]   Built cron: .dist/CleanupSessionsFunction
[deployless] Template generated: /path/to/project/template.yaml
```

### 4. Deploy

```bash
# First time (SAM interactive wizard)
deployless deploy --guided --stage prod

# Subsequent deployments
deployless deploy --stage prod
```

### 5. Generated template.yaml (summary)

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ums-api — Generated by deployless

Globals:
  Function:
    Runtime: python3.13
    MemorySize: 256
    Timeout: 30
    Environment:
      Variables:
        LOG_LEVEL: INFO
        APP_STAGE: dev

Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      EndpointConfiguration: REGIONAL
      Cors:
        AllowOrigin: "'*'"
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Api-Key'"

  UserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .dist/UserFunction/
      Handler: bootstrap.handler
      MemorySize: 512
      Timeout: 30
      Description: User Management Service
      Environment:
        Variables:
          UMS_USERS_TABLE:
            Ref: UmsUsersTable
          UMS_SESSIONS_TABLE:
            Ref: UmsSessionsTable
          TOKEN_EXPIRY: '3600'
      Events:
        UserPostGet:
          Type: Api
          Properties:
            RestApiId:
              Ref: Api
            Path: /users
            Method: get
        # ... more events

  UmsUsersTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    Properties:
      TableName: ums-users
      BillingMode: PAY_PER_REQUEST
      # ... attributes, GSI, TTL

  CleanupSessionsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .dist/CleanupSessionsFunction/
      Handler: bootstrap.handler
      MemorySize: 128
      Timeout: 60
      Events:
        Schedule:
          Type: Schedule
          Properties:
            Schedule: rate(24 hours)

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value:
      Fn::Sub: https://${Api}.execute-api.${AWS::Region}.amazonaws.com/dev
  UserFunctionArn:
    Value:
      Fn::GetAtt: [UserFunction, Arn]
  # ...
```

---

## Known notes and limitations

- **Only Flask is supported** for now. FastAPI support is planned (adapter in `deployless/adapters/fastapi.py`).
- **Feature code is copied flat**: only the `.py` files in the root directory of the feature are included. Subdirectories (use_cases, repositories, etc.) **are not copied**. If your `routes.py` imports from its own subdirectories, you will need to adapt the structure or extend the packager.
- **`app/shared/` is copied in full** into each Lambda under the name `shared/`. Imports like `from app.shared.x import y` will need to be changed to `from shared.x import y` in Lambda production code.
- **Dependencies are not installed** during `deployless build`. `sam build` (run by `deployless deploy`) is what installs the `requirements.txt` of each package.
- **SQS and KMS resources** return multiple CloudFormation entries (queue + DLQ, key + alias). deployless inserts them all correctly into the template.
