Metadata-Version: 2.4
Name: slick-framework
Version: 0.1.0
Summary: A slick, minimal ASGI web framework built on the Python standard library.
Author: nehz
License: MIT
Keywords: asgi,web,framework,http,api,microframework
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: server
Requires-Dist: uvicorn>=0.23; extra == "server"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# slick-framework

**A slick, minimal ASGI web framework for Python, built only on the standard library.**

`slick` covers what most small services need: routing with typed path parameters,
request and response objects, JSON in and out, middleware, lifespan hooks, an
in-process test client and a development server. It has zero runtime dependencies,
and the core is a few hundred lines you can read in one sitting.

```python
from slick import Slick

app = Slick()

@app.get("/hello/{name}")
async def hello(request, name: str):
    return {"message": f"Hello, {name}!"}
```

## Features

- **ASGI 3 app**: runs under uvicorn, hypercorn or any other ASGI server.
- **Routing** with `{name}`, `{id:int}`, `{x:float}` and `{rest:path}` parameters,
  converted values, automatic `HEAD` for `GET` routes, and proper `404`/`405` (with `Allow`) responses.
- **Path params injected as keyword arguments** when the handler's signature names them.
- **Return plain values**: `dict`/`list` becomes JSON, `str` becomes text, `None` becomes `204`,
  and `(body, status[, headers])` tuples set the status and headers.
- **Request object** with headers, query params, cookies, `await request.json()` / `.form()` / `.body()`.
- **Responses**: `Response`, `JSONResponse`, `PlainTextResponse`, `HTMLResponse`, `RedirectResponse`, plus cookies.
- **Middleware**: plain `async def mw(request, call_next)` functions.
- **Errors**: `HTTPException`, plus custom handlers for any exception type.
- **Lifespan hooks**: `@app.on_startup` / `@app.on_shutdown`, and `app.state` / `request.state`.
- **Sub-routers**: `Router()` and `app.include_router(router, prefix="/api")`.
- **`TestClient`**: synchronous and in-process, with no sockets and nothing extra to install.
- **`serve()`**: a stdlib-only development server, so examples run straight away.
- Sync handlers are supported too (they run in a worker thread). Fully type-hinted (`py.typed`).

## Install

```bash
pip install slick-framework            # core, no dependencies
pip install "slick-framework[server]"  # optional: adds uvicorn for production serving
```

Requires Python 3.9+.

## Quickstart

```python
# app.py
from slick import HTTPException, Request, Slick, serve

app = Slick()
BOOKS = {1: {"id": 1, "title": "Dune"}}

@app.get("/books/{book_id:int}")
async def get_book(request: Request, book_id: int):
    if book_id not in BOOKS:
        raise HTTPException(404, "No such book")
    return BOOKS[book_id]

@app.post("/books")
async def add_book(request: Request):
    data = await request.json()          # malformed JSON -> 400 automatically
    book = {"id": len(BOOKS) + 1, "title": data["title"]}
    BOOKS[book["id"]] = book
    return book, 201

if __name__ == "__main__":
    serve(app, port=8000)                # or: uvicorn app:app
```

Test it without a network:

```python
from slick import TestClient
from app import app

with TestClient(app) as client:          # `with` also runs startup/shutdown hooks
    assert client.get("/books/1").json() == {"id": 1, "title": "Dune"}
    assert client.post("/books", json={"title": "Emma"}).status_code == 201
    assert client.get("/books/99").status_code == 404
```

A fuller example (a todo CRUD API with a sub-router, middleware and startup hook) is in
[`examples/todo_app.py`](examples/todo_app.py):

```bash
python3 examples/todo_app.py   # after `pip install -e .`; or prefix with PYTHONPATH=src
```

## API overview

Everything below is importable from `slick`.

### `Slick(*, debug=False)`

The application, and an ASGI callable. It subclasses `Router`, so it has all of the router methods.

| Member | Description |
| --- | --- |
| `@app.get(path, name=None)`, `.post`, `.put`, `.patch`, `.delete` | Register a handler for one method. `GET` also answers `HEAD`. |
| `@app.route(path, methods=None, name=None)` | Register for several methods (default `["GET"]`). |
| `app.add_route(path, handler, methods=None, name=None) -> Route` | Non-decorator form. |
| `app.include_router(router, prefix="")` | Copy a `Router`'s routes in under `prefix`. |
| `app.url_for(name, **params) -> str` | Build a path from a route name. The name defaults to the handler's `__name__`. |
| `@app.middleware` | Register `async def mw(request, call_next) -> Response`. The first registered is outermost. |
| `@app.exception_handler(ExcClass)` | Register `handler(request, exc)`, sync or async, for that class and its subclasses. |
| `@app.on_startup`, `@app.on_shutdown` | Lifespan hooks taking no arguments, sync or async. |
| `app.state` | A `State` attribute bag for app-wide objects. |
| `app.debug` | If true, an unhandled error returns its traceback as a `500` text body. |

**Handlers** are `handler(request, **path_params)`, `async def` or plain `def`. A path
param is passed as a keyword argument only if the signature names it or accepts
`**kwargs`. Otherwise read it from `request.path_params`. A handler may return:

| Return value | Response |
| --- | --- |
| `Response` (or subclass) | sent as-is |
| `dict` / `list` | `JSONResponse` |
| `str` | `PlainTextResponse` |
| `bytes` | `Response` with `application/octet-stream` |
| `None` | empty `204 No Content` |
| `(value, status)` / `(value, status, headers)` | the above, with that status and extra headers |

Middleware may return the same kinds of value.

**Path converters**: `str` (default, one segment), `int` (optionally negative), `float`, `path` (can include `/`).
Matching is exact, with no trailing-slash redirects. Routes are tried in registration order.

**Errors**: `HTTPException` and unmatched routes render as `{"detail": ...}` JSON, and
middleware sees them as normal responses. Any other uncaught exception becomes a
`500 {"detail": "Internal Server Error"}`, is logged on the `slick` logger and is re-raised to the server.

### `Router()`

A collection of routes that has the same `get/post/put/patch/delete/route/add_route/include_router/url_for`
methods as the app, plus `resolve(method, path) -> (Route, params)`. `Route(path, handler, methods=None, name=None)`
provides `.match(path)` and `.url_for(**params)`.

### `Request`

| Member | Description |
| --- | --- |
| `method`, `path`, `url` | Upper-case method, decoded path, and the reconstructed absolute URL. |
| `headers` | Case-insensitive `Headers`. |
| `query_params` | `QueryParams`: `get(key, default)` returns the first value, `getlist(key)` returns all values. |
| `path_params` | `dict` of converted route parameters. |
| `cookies` | `dict` parsed from the `Cookie` header. |
| `client` | `(host, port)` or `None`. |
| `state` | A per-request `State`, for example set by middleware. |
| `app`, `scope` | The `Slick` app and the raw ASGI scope. |
| `await body()`, `await text()` | The raw body (cached) and the body decoded as UTF-8. |
| `await json()` | The parsed body. Raises `HTTPException(400)` on invalid JSON. |
| `await form()` | A `dict` from a urlencoded body. |

### Responses

- `Response(content=None, status_code=200, headers=None, media_type=None)`: `content` is `bytes`, `str` or `None`.
  It has `.status_code`, `.body`, a mutable `.headers` (`Headers`) and `.set_cookie(key, value="", *, max_age=None, path="/", httponly=False, secure=False, samesite="lax")`.
- `JSONResponse(content, status_code=200, headers=None, *, indent=None, default=None)` produces compact UTF-8 JSON by default.
- `PlainTextResponse`, `HTMLResponse` take the same arguments as `Response`.
- `RedirectResponse(url, status_code=307, headers=None)`.
- `to_response(value)` applies the return-value rules above.

### `HTTPException(status_code, detail=None, headers=None)`

When `detail` is omitted it defaults to the standard reason phrase (for example `"Not Found"`).

### `TestClient(app, base_url="http://testserver", raise_server_exceptions=True)`

- `.get/.head/.post/.put/.patch/.delete/.options(url, *, params=None, headers=None, json=None, data=None, cookies=None)`
  and `.request(method, url, ...)` return a `TestResponse`. `data` takes a form mapping, `str` or `bytes`.
- `with TestClient(app) as client:` runs the app's startup and shutdown hooks. `.close()` releases the event loop.
- `Set-Cookie` values are stored in `client.cookies` and sent on later requests.
- With `raise_server_exceptions=False` you get the `500` response instead of the exception.
- `TestResponse` has `.status_code`, `.headers`, `.content`, `.text`, `.json()` and `.ok`.

### `serve(app, host="127.0.0.1", port=8000)`

A threaded development server built on `http.server`. It runs lifespan hooks and stops on Ctrl+C.
It buffers whole bodies and is not meant for production; use `uvicorn yourmodule:app` there.

### Data structures

`Headers` (case-insensitive and multi-valued: `getlist`, `append`, `multi_items`, `raw`, `from_raw`),
`QueryParams` (`getlist`, `multi_items`), and `State` (an attribute bag with `.get(name, default)`).

## Development

```bash
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest                          # or, with no installs at all:
PYTHONPATH=src python3 -m unittest discover -s tests
```

## License

MIT
