# exile

An async-first Python web framework with Flask-like APIs on top of ASGI.

## Install

Install from local source:

```bash
python3 -m pip install -e .
```

After publishing to PyPI:

```bash
python3 -m pip install exile-line
```

Install template support:

```bash
python3 -m pip install -e .[templates]
```

## Quickstart

```python
from exile import Exile

app = Exile(__name__)

@app.get("/")
async def index():
    return {"hello": "exile"}

@app.get("/users/<int:user_id>")
async def user_detail(user_id: int):
    return {"user_id": user_id}
```

Run with uvicorn:

```bash
uvicorn app:app --reload
```

## Core APIs

- Routing: `@app.route`, `@app.get`, `@app.post`, `@app.put`, `@app.patch`, `@app.delete`
- Request context: `request`, `current_app`, `g`
- Response helpers: `Response`, `JSONResponse`, `HTMLResponse`, `RedirectResponse`, `FileResponse`
- Helpers: `abort`, `jsonify`, `make_response`, `url_for`, `render_template`, `render_template_async`
- Middleware:
  - Function middleware: `@app.middleware("http")`
  - ASGI class middleware: `app.add_middleware(...)`
- Blueprint: `Blueprint(...); app.register_blueprint(...)`
- Lifespan: `@app.on_startup`, `@app.on_shutdown`
- Testing: `AsyncTestClient`

## Routing

```python
@app.get("/users/<int:user_id>")
async def user_detail(user_id: int):
    return {"user_id": user_id}

@app.post("/users")
async def create_user():
    data = await request.json()
    return {"created": data}, 201
```

Supported converters:
- `str` (default): `<name>`
- `int`: `<int:user_id>`
- `float`: `<float:amount>`
- `path`: `<path:filepath>`

Static files (`/static/<path:filename>`) include cache headers by default:
- `Cache-Control: public, max-age=3600`
- `ETag`
- `Last-Modified`
- Supports conditional requests (`If-None-Match` / `If-Modified-Since`) and returns `304 Not Modified`
- Supports byte range requests (`Range: bytes=...`) with `206 Partial Content` and `416 Range Not Satisfiable`

Reverse URL build:

```python
path = app.url_for("user_detail", user_id=9, page=2)
# /users/9?page=2
```

## Request And Response

`Request` helpers:
- `await request.body()`
- `await request.text()`
- `await request.json()`
- `request.args` (query string MultiDict)

Response helpers:
- Return `dict/list` directly for JSON
- Return tuple: `(body, status)` or `(body, status, headers)`
- `jsonify(...)`, `make_response(...)`
- `FileResponse`, `StreamingResponse`, `HTMLResponse`, `RedirectResponse`

Template rendering strategy:
- `render_template(...)`: synchronous helper, Flask-like ergonomics
- `await render_template_async(...)`: render in worker thread to avoid blocking event loop in async views

## Error Handling

```python
from exile import abort

@app.errorhandler(404)
def not_found(exc):
    return {"error": "not found"}, 404

@app.get("/must-exist")
def must_exist():
    abort(404)
```

Blueprint-scoped error handler:

```python
api = Blueprint("api", __name__)

@api.errorhandler(404)
def api_not_found(exc):
    return {"api_error": "not found"}, 404
```

## Middleware

Function middleware:

```python
@app.middleware("http")
async def add_header(req, call_next):
    response = await call_next(req)
    response.headers["X-App"] = "exile"
    return response
```

ASGI middleware class:

```python
app.add_middleware(SomeASGIMiddleware, option="value")
```

## Lifespan

```python
@app.on_startup
def startup():
    ...

@app.on_shutdown
async def shutdown():
    ...
```

## Blueprint Example

```python
from exile import Blueprint

api = Blueprint("api", __name__, url_prefix="/v1")

@api.get("/ping")
async def ping():
    return {"ok": True}

app.register_blueprint(api, url_prefix="/api")
```

## Async TestClient Example

```python
import asyncio
from exile import AsyncTestClient

client = AsyncTestClient(app)

async def main():
    resp = await client.get("/users/1")
    assert resp.status_code == 200
    assert resp.json()["user_id"] == 1

asyncio.run(main())
```

## Example App

Run:

```bash
python3 examples/basic_app.py
```

## Run Tests

```bash
python3 -m unittest discover -s tests -v
```

## Build Distribution

```bash
python3 -m pip install --upgrade build
python3 -m build
```

Release precheck:

```bash
./scripts/release_check.sh
```

Publish to TestPyPI (dry run before real release):

```bash
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="<your-testpypi-token>"
./scripts/publish_testpypi.sh
```

Publish to PyPI:

```bash
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="<your-pypi-token>"
./scripts/publish_pypi.sh
```

Changelog content check:

```bash
python3 scripts/check_changelog_content.py
```

Wheel smoke test:

```bash
python3 scripts/smoke_test_wheel.py
```

Dist artifact check:

```bash
python3 scripts/check_dist_artifacts.py
```

Tag/version verification:

```bash
python3 scripts/check_tag_version.py v0.1.0
```

Bump version:

```bash
python3 scripts/bump_version.py 0.1.1
```

Clean build artifacts:

```bash
./scripts/clean.sh
```

Check working tree before publish:

```bash
./scripts/check_worktree_clean.sh
```

## Release

See release steps in:

```text
RELEASE.md
```

Release notes file:

```text
CHANGELOG.md
```

## Make Targets

```bash
make test
make compile
make release-check
make bump-version V=0.1.1
make clean
```
