Metadata-Version: 2.4
Name: pytisan-framework
Version: 0.6.2
Summary: Async-first web framework for Python — elegant, productive, fast.
Project-URL: Homepage, https://github.com/pytisan/framework
Project-URL: Repository, https://github.com/pytisan/framework
Project-URL: Documentation, https://github.com/pytisan/framework#readme
Project-URL: Issues, https://github.com/pytisan/framework/issues
Author: Pytisan Contributors
License: MIT
Keywords: api,asgi,async,framework,web
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: aiosqlite>=0.20
Requires-Dist: bcrypt>=4.0
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pyright>=1.1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: uvicorn>=0.30; extra == 'dev'
Description-Content-Type: text/markdown

# Pytisan

Async-first web framework for Python — productive, opinionated where it matters, no unnecessary magic.

Inspired by the Laravel ecosystem: declarative routes, Active Record, Form Requests, migrations, session-based Auth, and the `anvil` CLI.

> **Português (Brasil):** [README.pt-BR.md](README.pt-BR.md)

---

## Table of contents

1. [Create a project](#create-a-project)
2. [Application structure](#application-structure)
3. [Configuration](#configuration)
4. [Tutorial: Posts CRUD](#tutorial-posts-crud)
5. [Validation (FormRequest)](#validation-formrequest)
6. [ORM relationships](#orm-relationships)
7. [Authentication](#authentication)
8. [CLI reference](#cli-reference)
9. [Framework development](#framework-development)

---

## Create a project

Use **uv** / **uvx** (nothing to install upfront):

```bash
uvx pytisan new my-blog
cd my-blog
cp .env.example .env
uv run anvil migrate
uv run anvil serve
```

The API runs at `http://127.0.0.1:8000`. Starter routes are prefixed with `/api` (e.g. `GET /api/`).

Day-to-day commands:

```bash
uv run anvil serve              # dev server (reload enabled)
uv run anvil route:list         # list registered routes
uv run anvil migrate            # run pending migrations
uv run anvil migrate:status     # migration status
uv run anvil migrate:rollback   # rollback last batch
uv run anvil migrate:fresh      # drop and re-run all migrations
```

---

## Application structure

The starter scaffolded by `pytisan new` looks like this:

```txt
my-blog/
├── main.py                     # ASGI entrypoint (uvicorn main:asgi)
├── bootstrap/app.py            # Application.configure().with_routing(...)
├── config/
│   ├── app.py                  # middleware, debug, auth
│   ├── auth.py                 # Auth.configure(model=User)
│   └── database.py             # Database.configure() from .env
├── routes/
│   └── api.py                  # HTTP routes
├── app/
│   ├── models/                 # Active Record models
│   └── http/
│       ├── controllers/
│       └── requests/           # FormRequest classes
├── database/
│   ├── database.sqlite         # created after migrate (SQLite)
│   └── migrations/
├── .env
└── pyproject.toml              # depends on pytisan-framework
```

Boot flow:

```txt
main.py  →  bootstrap/app.py (routes)  →  config/app.py (DB, session, auth)
         →  asgi = app.asgi()
```

---

## Configuration

### `.env`

```env
APP_DEBUG=true

DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
```

The connection is **lazy**: the database connects on the first query. You do not need to call `Database.connect()` manually in your app.

### Database

`config/database.py` reads `.env` and registers the global configuration:

```python
from pytisan import Database
from pytisan.database.environment import database_config_from_env

Database.configure(database_config_from_env(root=Path(__file__).parent.parent))
```

The starter ships with a `User` model (`users` table) and migration — name, email (unique), password (hashed), timestamps.

---

## Tutorial: Posts CRUD

This guide builds a complete REST CRUD for posts, from scratch to working endpoints.

### 1. Migration

Generate the migration and edit the file under `database/migrations/`:

```bash
uv run anvil make:migration create_posts_table
```

```python
from pytisan.database.migrations import Migration, schema
from pytisan.database.schema import Blueprint


class CreatePostsTable(Migration):
    async def up(self) -> None:
        await schema.create("posts", self._define)

    async def down(self) -> None:
        await schema.drop("posts")

    @staticmethod
    def _define(table: Blueprint) -> None:
        table.id()
        table.string("title").not_null()
        table.text("body").not_null()
        table.foreign_id("user_id", references="users")
        table.timestamps()
```

Run the migration:

```bash
uv run anvil migrate
```

#### Blueprint — available columns

| Method | Description |
|--------|-------------|
| `table.id()` | INTEGER PRIMARY KEY AUTOINCREMENT |
| `table.string("name")` | TEXT |
| `table.text("body")` | TEXT |
| `table.integer("count")` | INTEGER |
| `table.boolean("active")` | INTEGER (0/1) |
| `table.foreign_id("user_id", references="users")` | INTEGER + REFERENCES |
| `table.timestamps()` | `created_at`, `updated_at` |
| `.not_null()`, `.unique()`, `.nullable()` | chainable modifiers |

---

### 2. Model

```bash
uv run anvil make:model Post
```

Edit `app/models/post.py`:

```python
from pytisan import Model, belongs_to

from app.models.user import User


class Post(Model):
    __table__ = "posts"
    __fillable__ = ("user_id", "title", "body")

    id: int | None
    user_id: int
    title: str
    body: str
    created_at: str | None
    updated_at: str | None

    user = belongs_to(User)
```

#### Model — common options

| Attribute | Purpose |
|-----------|---------|
| `__table__` | Table name (default: pluralized class name) |
| `__fillable__` | Fields allowed in `create()` / `save()` |
| `__hidden__` | Fields omitted from `to_dict()` |
| `__casts__` | Type casting (`"bool"`, `"hashed"`, `"datetime"`, `"json"`) |
| `__guarded__` | Blocked fields (default: `("id",)`) |

#### Model API

```python
post = await Post.create(title="Hello", body="...", user_id=1)
post = await Post.find(1)
posts = await Post.query().where("user_id", 1).order_by("-id").get()
post.title = "New title"
await post.save()
await post.delete()
await post.refresh()
data = post.to_dict()
```

---

### 3. Form Requests

Laravel-style validation — declarative rules, automatic 422 responses.

```bash
uv run anvil make:request post/create_post_request
uv run anvil make:request post/update_post_request
```

`app/http/requests/post/create_post_request.py`:

```python
from pytisan import FormRequest, Rule


class CreatePostRequest(FormRequest):
    async def authorize(self) -> bool:
        return True

    def rules(self) -> dict:
        return {
            "title": "required|string|min:3|max:255",
            "body": "required|string|min:10",
        }

    def messages(self) -> dict[str, str]:
        return {
            "title.required": "The title field is required.",
        }
```

`app/http/requests/post/update_post_request.py`:

```python
from pytisan import FormRequest


class UpdatePostRequest(FormRequest):
    def rules(self) -> dict:
        return {
            "title": "sometimes|required|string|min:3",
            "body": "sometimes|required|string|min:10",
        }
```

#### Available rules

| Rule | Example |
|------|---------|
| `required` | `"email": "required\|email"` |
| `sometimes` | Only validates when the field is present (PATCH) |
| `string`, `integer`, `boolean`, `email` | Basic types |
| `min`, `max` | String length, numeric value, or array size |
| `in` | `"status": "in:draft,published"` |
| `array` | `"items": "required\|array"` |
| `items.*.qty` | Nested validation (wildcard) |
| `unique` | `"email": "unique:users,email"` |
| `exists` | `"user_id": "exists:users,id"` |

`unique` with ignore (update):

```python
Rule.unique("users", "email", ignore=user.id)
# or string: f"unique:users,email,{user.id},id"
```

The router validates the FormRequest **before** calling the handler. In the controller:

```python
data = await request.validated()
```

---

### 4. Controller

```bash
uv run anvil make:controller post/post_controller
```

`app/http/controllers/post/post_controller.py`:

```python
from pytisan import Auth, json

from app.http.requests.post.create_post_request import CreatePostRequest
from app.http.requests.post.update_post_request import UpdatePostRequest
from app.models.post import Post


class PostController:
    async def index(self):
        posts = await Post.query().with_("user").order_by("-id").get()
        return json({
            "posts": [
                {**post.to_dict(), "author": (await post.user).to_dict() if await post.user else None}
                for post in posts
            ],
        })

    async def show(self, post: Post):
        await post.load("user")
        author = await post.user
        return json({
            "post": post.to_dict(),
            "author": author.to_dict() if author else None,
        })

    async def store(self, request: CreatePostRequest):
        data = await request.validated()
        data["user_id"] = Auth.id()
        post = await Post.create(**data)
        return json({"post": post.to_dict()}, status_code=201)

    async def update(self, request: UpdatePostRequest, post: Post):
        for key, value in (await request.validated()).items():
            post._set_attribute(key, value, mark_dirty=True)
        await post.save()
        return json({"post": post.to_dict()})

    async def destroy(self, post: Post):
        await post.delete()
        return json({"message": "Post deleted."})
```

**Route model binding:** when a route parameter is type-hinted with a `Model`, the framework loads the record automatically. Missing records return **404**.

---

### 5. Routes

Edit `routes/api.py`:

```python
from pytisan import Route

from app.http.controllers.post.post_controller import PostController


@Route.get("/")
async def index():
    from pytisan import json
    return json({"message": "API online"})


with Route.group(middleware=["auth"]):
    with Route.prefix("/posts").group():
        Route.get("/", [PostController, "index"])
        Route.post("/", [PostController, "store"])
        Route.get("/{id:int}", [PostController, "show"])
        Route.put("/{id:int}", [PostController, "update"])
        Route.patch("/{id:int}", [PostController, "update"])
        Route.delete("/{id:int}", [PostController, "destroy"])
```

Verify routes:

```bash
uv run anvil route:list
```

#### Route patterns

| Pattern | Description |
|---------|-------------|
| `Route.get("/users")` | GET |
| `Route.post("/users")` | POST |
| `Route.put("/users/{id:int}")` | PUT |
| `Route.patch("/users/{id:int}")` | PATCH |
| `Route.delete("/users/{id:int}")` | DELETE |
| `Route.prefix("/posts").group():` | Prefix group |
| `Route.group(middleware=["auth"]):` | Auth-protected group |

Parameter converters: `{id:int}`, `{slug:str}`, `{uuid:uuid}`.

---

### 6. Test the CRUD

```bash
uv run anvil serve
```

**Login** (create a user via script/seed first; example assumes a login endpoint exists):

```bash
curl -X POST http://127.0.0.1:8000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ana@example.com","password":"secret"}' \
  -c cookies.txt

# List posts (authenticated)
curl http://127.0.0.1:8000/api/posts -b cookies.txt

# Create post
curl -X POST http://127.0.0.1:8000/api/posts \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"title":"My post","body":"Content with more than ten characters."}'

# Show post
curl http://127.0.0.1:8000/api/posts/1 -b cookies.txt

# Update
curl -X PATCH http://127.0.0.1:8000/api/posts/1 \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"title":"Updated title"}'

# Delete
curl -X DELETE http://127.0.0.1:8000/api/posts/1 -b cookies.txt
```

Validation failures return **422** with `{ "message": "...", "errors": { ... } }`.

---

## Validation (FormRequest)

Automatic injection via type hint on the handler:

```python
async def store(self, request: CreatePostRequest):
    data = await request.validated()
```

- `authorize()` returns `False` → **403 Forbidden**
- Rules fail → **422 ValidationException** with per-field `errors`
- `await request.validated()` is cached for the same request

Correct import in your app (starter includes `pyrightconfig.json`):

```python
from app.http.requests.post.create_post_request import CreatePostRequest
```

---

## ORM relationships

Define on the model with class-level descriptors:

```python
from pytisan import belongs_to, has_many, has_one

class User(Model):
    # ...

class Post(Model):
    user = belongs_to(User)

class Comment(Model):
    post = belongs_to(Post)

# When Post did not exist yet when User was defined:
User.posts = has_many(Post)
Post.comments = has_many(Comment)
```

Usage:

```python
author = await post.user              # belongs_to
posts = await user.posts              # has_many
post = await user.posts.create(title="...", body="...")
profile = await user.has_one(Profile).get()  # has_one
```

Eager loading (avoids N+1):

```python
posts = await Post.query().with_("user", "comments").get()
await post.load("user")
```

Foreign keys in migrations:

```python
table.foreign_id("user_id", references="users")
table.foreign_id("post_id", references="posts", nullable=True)
```

---

## Authentication

The starter includes `User`, session handling, and middleware in `config/app.py`.

### Setup

`config/auth.py`:

```python
from pytisan import Auth
from app.models.user import User

Auth.configure(model=User)
```

Global middleware (already registered in the starter):

- `start_session` — `pytisan_session` cookie, `sessions` table
- `authenticate` — restores the user from the session on each request

### Login controller

```python
from pytisan import Auth, json


class AuthController:
    async def login(self, request: LoginRequest):
        credentials = await request.validated()
        if not await Auth.attempt(credentials):
            return json({"message": "Invalid credentials."}, status_code=422)
        return json({"message": "Logged in."})

    async def logout(self):
        await Auth.logout()
        return json({"message": "Logged out."})

    async def me(self):
        return json({"user": Auth.user().to_dict()})
```

Routes:

```python
Route.post("/login", [AuthController, "login"])
Route.post("/logout", [AuthController, "logout"])

with Route.group(middleware=["auth"]):
    Route.get("/me", [AuthController, "me"])
```

### Auth API

| Method | Description |
|--------|-------------|
| `await Auth.attempt(credentials, remember=False)` | Validate email/password and log in |
| `await Auth.login(user)` | Manual login (called by `attempt`) |
| `await Auth.logout()` | End session |
| `Auth.check()` | `True` when authenticated |
| `Auth.id()` | User ID (requires `auth` middleware on the route) |
| `Auth.user()` | User instance (requires `auth` middleware) |

`Auth.user()` and `Auth.id()` **require** the route to be inside `Route.group(middleware=["auth"])`. Otherwise a **500** is returned with a message explaining correct usage.

Unauthenticated access to protected routes returns **401 Unauthorized**.

### Hash

```python
from pytisan import Hash

hashed = Hash.make("secret-password")
Hash.check("secret-password", hashed)  # True
```

On the model, use `__casts__ = {"password": "hashed"}` — passwords are hashed automatically on `create()` / `save()`.

---

## CLI reference

### Installer (new projects)

```bash
uvx pytisan new my-app
uvx pytisan new my-app --path /tmp
uvx pytisan new my-app --force
```

### Anvil (inside a project)

| Command | Description |
|---------|-------------|
| `anvil serve` | ASGI dev server (default `main:asgi`) |
| `anvil serve --host 0.0.0.0 --port 8080` | Host/port options |
| `anvil migrate` | Run migrations |
| `anvil migrate:status` | Status |
| `anvil migrate:rollback` | Rollback last batch |
| `anvil migrate:fresh` | Recreate database |
| `anvil route:list` | List routes |
| `anvil make:model Post -m` | Model + migration |
| `anvil make:migration create_x_table` | Empty migration |
| `anvil make:controller post/post_controller` | Controller |
| `anvil make:request post/create_post_request` | FormRequest |
| `anvil make:resource PostResource --model Post` | API Resource |

Laravel-style paths in generators:

```bash
anvil make:request Post/CreatePost
anvil make:request post/create_post_request
anvil make:controller post/post_controller
```

---

## Query Builder

Direct access without Model:

```python
from pytisan import Database

rows = await Database.table("users").where("active", 1).order_by("name").get()
user = await Database.table("users").where("email", email).first()
await Database.table("users").where("id", 1).update({"name": "Ana"})
await Database.table("users").where("id", 1).delete()
count = await Database.table("users").count()
```

---

## Framework development

PyPI package `pytisan-framework` (import `pytisan`):

```bash
cd framework
uv sync --group dev
uv run python -m pytest
uv run ruff check .
```

Install locally into a test app:

```bash
cd ../my-blog
uv sync --reinstall-package pytisan-framework
```

### Package layout

```txt
framework/
├── core/           # Application, ApplicationBuilder
├── web/            # Request, Response, ASGI, middleware
├── routing/        # Router, Route, model binding
├── orm/            # Model, relations (belongs_to, has_many, …)
├── database/       # SQLite async, migrations, schema blueprint
├── validation/     # FormRequest, Rule
├── auth/           # Auth, Hash, session, middleware
├── query/          # Query builder
├── console/        # anvil CLI, generators, migrate
└── tests/
```

---

## License

MIT
