Metadata-Version: 2.4
Name: hammer-framework
Version: 0.1.0
Summary: Hammer — a Laravel-style web framework built on FastAPI and SQLAlchemy.
Author: Sopheak Lim
License: MIT
Project-URL: Homepage, https://github.com/sopheak8888/hammer
Project-URL: Repository, https://github.com/sopheak8888/hammer
Keywords: laravel,fastapi,framework,hammer,eloquent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: fastapi>=0.110
Requires-Dist: uvicorn[standard]>=0.29
Requires-Dist: sqlalchemy[asyncio]>=2.0
Requires-Dist: aiosqlite>=0.20
Requires-Dist: jinja2>=3.1
Requires-Dist: mistune>=3.0
Requires-Dist: python-dotenv>=1.0
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: click>=8.1
Requires-Dist: pydantic>=2.6
Provides-Extra: postgres
Requires-Dist: asyncpg>=0.29; extra == "postgres"
Provides-Extra: mysql
Requires-Dist: aiomysql>=0.2; extra == "mysql"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"

# 🔨 Hammer

**Laravel ergonomics. FastAPI speed. Python clarity.**

Hammer is a full-stack web framework for Python that ports the architecture and
developer experience of [Laravel](https://laravel.com) onto
[FastAPI](https://fastapi.tiangolo.com) and async SQLAlchemy 2.

```python
# app/controllers/post_controller.py
from hammer.http import Request
from hammer.support.helpers import response

from app.models import Post

class PostController:
    async def index(self, request: Request):
        posts = await Post.query().with_("author").latest().paginate(10)
        return response([p.to_dict() for p in posts])

    async def store(self, request: Request):
        data = await request.validate({
            "title": "required|string|max:120",
            "slug": "required|unique:posts,slug",
        })
        return response(await Post.create(**data), 201)
```

## Feature map (Laravel → Hammer)

| Laravel | Hammer |
| --- | --- |
| Service Container (`Illuminate\Container`) | `hammer.container.Container` — bind / singleton / instance / alias / tag / extend / call |
| Service Providers | `ServiceProvider` with `register()` → `boot()` lifecycle |
| Facades | `DB`, `Schema`, `Config` metaclass facades backed by the container |
| Router (`Illuminate\Routing`) | `Router` with verbs, groups, prefixes, named routes, `resource()` / `apiResource()`, middleware aliases + groups, `whereNumber()` constraints, `fallback()`, decorator syntax |
| Eloquent ORM | `Model` active-record base on SQLAlchemy Core — auto table/PK/timestamps, `$fillable`/`$guarded`, casts, accessors & mutators, hidden/visible |
| Query Builder | `EloquentBuilder`: `where` (all operators), `orWhere*`, `orderBy`, `latest/oldest`, `when/unless`, `get/first/findOrFail/sole`, `count/exists/max/min/sum/avg/pluck/value`, `increment/decrement`, bulk `update/delete/insert`, `chunk/each`, `paginate` |
| Relationships | `hasOne`, `hasMany`, `belongsTo` (+ `associate/dissociate`), `belongsToMany` (+ `attach/detach/sync/toggle`), lazy **awaitable** relations, eager loading via `with_()` |
| Schema Builder | `Blueprint` fluent columns (`id, string, text, integer, decimal, json, uuid, foreign().constrained(), timestamps(), softDeletes()` …), `Schema.create/table/drop/hasTable/rename` |
| Migrations | Timestamped migration files, `migrator` repository table, `migrate / rollback --steps / reset / refresh / status` |
| Validation | Pipe-rule strings (`"required\|email\|max:255"`), 30+ rules incl. `unique:` / `exists:` DB rules, custom messages, Laravel error bags |
| Blade views | Jinja2 engine with `config()`, `route()` globals |
| Events & Observers | App-level `Event` dispatcher (wildcards, halting) + model lifecycle events (`eloquent.creating: User`…) and `Model.observe(Observer)` |
| Soft Deletes | `class Post(SoftDeletes, Model)` — `deleted_at` scope, `with_trashed()`, `only_trashed()`, `restore()`, `force_delete()` |
| Auth | PBKDF2 hashing, `Auth.attempt()`, `HasApiTokens` bearer tokens (`create_token/revoke_tokens`), `auth` middleware |
| Rate limiting | `ThrottleRequests` middleware with `X-RateLimit-*` headers |
| Queues | Database queue: `Queue.dispatch(job, delay=…)`, retries with backoff, failed-job table, `hammer queue:work/--once/--stop-when-empty`, `queue:failed`, `queue:retry` |
| Gates | Ability definitions, before/after hooks, `Gate.for_user()`, `authorize()` raising 403, `Can` middleware |
| Policies | `class PostPolicy(Policy)` + **auto-discovery** from `app/policies/` by naming convention |
| Notifications | `Notifiable` trait + database channel: `await user.notify(Note())`, unread tracking, `mark_read()` |
| Mail | Log driver + **SMTP driver** (stdlib smtplib, threaded), async `Mail.send()`, mail channel |
| Tinker | `hammer tinker` — booted-app REPL with models & helpers preloaded |
| WebSockets & Broadcasting | `@router.websocket(...)` routes, `Broadcast` channel pub/sub with a ready-made `/broadcast` endpoint |
| Factories | `Factory` classes with a built-in fake-data generator, `make()/create()/count()/state()` |
| Testing kit | `HammerTestCase` — booted app, HTTP verb helpers (`self.get/post/...`), `acting_as()`, `refresh_database()` |
| Form Requests | `class StoreDocForm(FormRequest)` — rules + authorize resolved by type-hint in controller actions |
| Signed URLs | `signed_url(path, expires_in=300)`, `ValidateSignature` middleware, tamper-proof HMAC |
| Maintenance mode | `hammer down --message` / `hammer up` — 503 JSON or HTML via middleware |
| Pruning | `hammer model:prune [--model X] [--hours N]` permanently deletes old soft-deleted rows |
| Artisan CLI | `hammer` CLI (see below) |
| Exception Handler | Central handler rendering JSON (API) or HTML, `abort()` helper |

## Install

```bash
pip install -e ".[dev]"        # from a clone
pip install -e ".[postgres]"   # optional asyncpg driver
```

Python 3.11+ required.

## Quick start

```bash
hammer new myblog          # scaffold a fresh application
cd myblog
hammer serve               # http://127.0.0.1:8000
```

### The basics

```python
from pathlib import Path
from hammer import build_http, create_application

application = create_application(Path(__file__).parent)   # boots config + providers
router = application.make("router")

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

app = build_http(application)   # bridges routes onto FastAPI (OpenAPI docs included)
```

### Models

```python
from hammer.database import Model, columns as col
from hammer.database.relations import has_many, belongs_to

class User(Model):                    # table "users", id PK + timestamps auto-added
    name = col.string()
    email = col.string(unique=True)
    age = col.integer(nullable=True)

    posts = has_many("Post")          # awaitable + chainable

user = await User.create(name="Sopheak", email="s@hammer.dev")
adults = await User.where("age", ">=", 18).order_by("name").limit(10).get()
await user.posts                       # lazy load -> list[Post]
users = await User.with_("posts").get()   # eager load (no N+1)
```

Relations are plain attribute descriptors; awaiting one runs the query, chaining
one builds it:

```python
recent = await user.posts.where("published", True).latest().take(5).get()
pivot_result = await post.tags.sync([1, 2, 3])
```

### Migrations

```bash
hammer make:model Post -m     # model + timestamped migration stub
hammer migrate                # run pending migrations
hammer migrate:status         # ✓ Ran / • Pending
hammer migrate:rollback       # revert last batch
```

```python
class Migration(Migration):
    async def up(self):
        await Schema.create("posts", lambda t: [
            t.id(),
            t.string("title"),
            t.string("slug").unique(),
            t.foreign_id("user_id"),
            t.timestamps(),
        ])

    async def down(self):
        await Schema.drop("posts")
```

### Validation

```python
data = await request.validate({
    "email": "required|email|unique:users,email",
    "age": "nullable|integer|min:18",
})
# raises ValidationException -> 422 {"message": ..., "errors": {field: [...]}}
request.validated   # the validated subset
```

### Events & observers

```python
from app.models import User

class UserObserver:
    def created(self, user): send_welcome_email(user)
    def deleting(self, user): return False   # returning False halts the operation

User.observe(UserObserver())

# or listen globally:
from hammer.events.dispatcher import dispatcher
dispatcher().listen("eloquent.created: User", lambda user, event: log(event))
```

Events fired: `saving/creating/creating…/saved/created`, `updating/updated`,
`deleting/deleted`, `restoring/restored`, `force_deleted`.

### Soft deletes

```python
from hammer.database.model import Model, SoftDeletes

class Invoice(SoftDeletes, Model):
    ...

await invoice.delete()                    # sets deleted_at (fires trashed event)
invoices = await Invoice.all()            # excludes trashed automatically
await Invoice.with_trashed().count()
await Invoice.only_trashed().get()
await invoice.restore()
await invoice.force_delete()
```

### Authentication

```python
from hammer.auth import Authenticate, HasApiTokens

class User(HasApiTokens, Model): ...

router.alias_middleware("auth", Authenticate)

user = await auth.attempt({"email": ..., "password": ...})   # PBKDF2 verified
token = await user.create_token("cli")                       # store hash, return plain
client.get("/api/me", headers={"Authorization": f"Bearer {token}"})
await user.revoke_tokens()
```

Requires `users.password` and a `personal_access_tokens` table
(`hammer make:migration` + `Schema.create` — see `tests/test_auth.py` for the exact schema).

### Rate limiting

```python
from hammer.auth import ThrottleRequests

router.get("/search", handler).middleware(ThrottleRequests(max_attempts=10, decay_seconds=60))
```

### Queues

```bash
hammer make:job SendInvoice
hammer queue:work --queue=default          # long-running worker
hammer queue:work --once                   # process a single job
hammer queue:failed && hammer queue:retry <id>
```

```python
# app/jobs/send_invoice.py
from hammer.database.queues import Queueable

class SendInvoice(Queueable):
    queue = "default"
    tries = 3
    backoff = 10

    def __init__(self, invoice_id: int) -> None:
        self.invoice_id = invoice_id

    async def handle(self) -> None:
        ...  # heavy work happens on the worker, not the web request

# anywhere in your app:
from hammer.support.facades import Queue

await Queue.dispatch(SendInvoice(invoice_id=42))
await Queue.dispatch(SendInvoice(43), delay=300)   # available in 5 minutes
```

Jobs are serialized to a `jobs` table (auto-created on first dispatch) with
optimistic claiming, attempt tracking, exponential release via `backoff`, and a
`failed_jobs` table once `tries` is exhausted. Job classes must live in
importable modules (`app/jobs/…`), never `__main__`.

### Gates (authorization)

```python
from hammer.auth.gate import Gate

gate = app.make("gate")
gate.define("update-post", lambda user, post: post.user_id == user.get_key())
gate.before(lambda user, ability, arguments: True if user.is_super else None)

await gate.authorize(user, "update-post", post)   # raises 403 AuthorizationException

# or as middleware:
route.middleware(Can("update-post"))
```

### Policies (auto-discovered)

Drop a policy in `app/policies/` — the naming convention wires it up:

```python
# app/policies/PostPolicy.py
from hammer.auth import Policy

class PostPolicy(Policy):
    def update(self, user, post): return post.user_id == user.get_key()
    def delete(self, user, post): return bool(user.get_attribute("is_admin"))
```

```python
await gate.authorize(user, "update", post)   # resolves PostPolicy.update
```

### SMTP mail

```python
# config/mail.py
DRIVER = "smtp"
HOST = "smtp.example.com"
PORT = 587
USERNAME = "..."
PASSWORD = "..."
STARTTLS = True
FROM = "blog@hammer.dev"
```

```python
from hammer.support.facades import Mail

await Mail.send(to="user@x.dev", subject="Hello", body="Welcome!")  # works for log + smtp
```

### Factories & the testing kit

```python
# database/factories/note_factory.py
from hammer.database.factories import Factory, fake

class NoteFactory(Factory):
    model = Note

    def definition(self):
        return {"title": fake.sentence(4), "body": fake.paragraph(), "published": fake.boolean()}

await NoteFactory().count(5).create()               # persisted
draft = await NoteFactory().state(published=False).create()
unsaved = NoteFactory().make()
```

```python
from hammer.testing import HammerTestCase

class AuthFlowTest(HammerTestCase):
    use_migrations = True

    @staticmethod
    def register_routes(router): ...

    def test_login(self):
        response = self.post("/api/auth/login", json={...})
        self.assertEqual(response.status_code, 200)

    def test_me(self):
        user = asyncio.run(User.create(...))
        self.acting_as(user)
        self.assertEqual(self.get("/api/auth/me").status_code, 200)
```

### Form requests

```python
# app/http/requests/store_doc_form.py
from hammer.http import FormRequest

class StoreDocForm(FormRequest):
    def rules(self) -> dict:
        return {"title": "required|string|max:120"}

    def authorize(self) -> bool:      # 403 when False
        return True
```

```python
async def store(self, form: StoreDocForm):   # validated before your code runs
    return response(await Doc.create(**form.validated), 201)
```

### Signed URLs & maintenance mode

```python
from hammer.http.signed_urls import signed_url, ValidateSignature

url = signed_url("/download/report.pdf", expires_in_seconds=300)
route.get("/download/{path}", handler).middleware(ValidateSignature())
```

```bash
hammer down --message "Back in 10 minutes"   # 503s every request
hammer up                                    # back to normal
hammer model:prune --model Invoice --hours 72  # purge old soft-deleted rows
```

### Tinker & about

```bash
hammer tinker   # REPL: models, DB, Schema, Queue, Gate, helpers preloaded
hammer about    # environment / driver / route summary
```

### Notifications (database channel)

```python
from hammer.notifications import Notifiable, Notification

class User(Notifiable, Model): ...

class InvoicePaid(Notification):
    def __init__(self, invoice_id): self.invoice_id = invoice_id
    def title(self): return "Invoice paid"
    def body(self): return f"Invoice #{self.invoice_id} was paid."

await user.notify(InvoicePaid(42))
unread = await user.unread_notifications()
await unread[0].mark_read()          # persists read_at
```

Requires a `notifications` table (`type`, `notifiable_type`, `notifiable_id`,
`data` json, `read_at`) — see `tests/test_phase4.py`.

### Sessions & CORS

```python
from hammer.http.site_middleware import SessionMiddleware, CorsMiddleware

session_mw = SessionMiddleware(secret="change-me")
router.alias_middleware("sessions", lambda: session_mw)
router.alias_middleware("cors", lambda: CorsMiddleware(["https://myapp.com"]))

@router.post("/cart/add").middleware(session_mw)
async def add(request):
    cart = request.session.get("cart", [])
    cart.append(request.input("item"))
    request.session.put("cart", cart)
```

Signed cookies are tamper-proof (HMAC-SHA256); CORS handles OPTIONS preflights.

### Scheduling

```python
# anywhere during boot, e.g. routes/console.py
from hammer.support.facades import Schedule
from app.jobs import SendDigest

Schedule.call(cleanup_temp_files).daily_at("03:00")
Schedule.job(SendDigest()).every_fifteen_minutes().weekdays()
```

```bash
hammer schedule:run     # single pass — wire into cron: * * * * *
hammer schedule:work    # long-running loop (60s default)
```

Frequencies fire once per window (`every_five_minutes` runs at :00/:05/…; a poll
inside the same window is deduped via `storage/framework/schedule.json`).

### Mail

```bash
# config/mail.py
DRIVER = "log"                       # dev driver writes to storage/logs/mail.log
FROM = "blog@hammer.dev"
```

```python
from hammer.support.facades import Mail

Mail.raw(to="user@x.dev", subject="Hello", body="Welcome!")
```

Notifications with `channels = ["mail"]` route through the same mailer — define
`to_mail(notifiable)` returning `{"subject": ..., "body": ...}`.

### Starter kit

```bash
hammer new myapp --auth
cd myapp && hammer migrate && hammer serve
# POST /api/auth/register  {name, email, password, password_confirmation}
# POST /api/auth/login     {email, password}          -> {user, token}
# GET  /api/auth/me        Authorization: Bearer <token>
# POST /api/auth/logout    revokes all tokens
```

### WebSockets & broadcasting

```python
# custom socket handler
@router.websocket("/echo/{room}")
async def echo(websocket, room: str):
    await websocket.accept()
    data = await websocket.receive_text()
    await websocket.send_text(f"echo[{room}]: {data}")
    await websocket.close()

# broadcasting: register the endpoint once
router.broadcast_ws("/broadcast")
```

```js
// client
const ws = new WebSocket("ws://localhost:8000/broadcast?channels=chat");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
```

```python
from hammer.support.facades import Broadcast

Broadcast.publish("chat", {"user": "sopheak", "msg": "hi all"})
```

### Queue monitoring

```bash
hammer queue:monitor --interval 5   # live pending/reserved/failed table
```

```python
stats = await app.make("queue").stats()
# {"queues": {"default": 2}, "reserved": 0, "failed": 0}
```

## The CLI

```
hammer new <project>         Scaffold a new application
hammer serve [--port]        Run the dev server
hammer route:list            Method | URI | Name | Action table
hammer make:model/controller/job/factory/migration/middleware/seeder
hammer db:show
hammer migrate [--step] / migrate:rollback / migrate:reset / migrate:refresh / migrate:status
hammer db:seed [Seeder]
hammer queue:work [--once] [--stop-when-empty] / queue:failed / queue:retry <id>
hammer queue:monitor
hammer schedule:run / schedule:work
hammer tinker / about
```

## Configuration

Drop Python modules in `config/`; every UPPERCASE constant becomes a key:

```python
# config/database.py
DEFAULT = "sqlite"
CONNECTIONS = {
    "sqlite":  {"driver": "sqlite", "database": "database/app.sqlite"},
    "pg":      {"driver": "postgres", "host": "...", "database": "..."},
}
```

Read anywhere with `config("database.default")` or the `env()` helper (.env supported).

## Project layout

```
src/hammer/
├── container/       IoC container (bind, singleton, resolve, extend…)
├── foundation/      Application, providers, bootstrap
├── config/          Repository with dot-notation
├── support/         Collection, Facades, helpers
├── http/            Request, Response, Middleware pipeline
├── routing/         Router, Route, URL generator
├── database/        Model, Builder, Relations, Schema, Migrations, Paginator
├── validation/      Rules + Validator
├── broadcast/       Channel pub/sub for WebSockets
├── auth/            Guard, hashing, API tokens, policies, throttle middleware
├── events/          Event dispatcher
├── notifications/   Database-channel notifications
├── views/           Jinja2 engine
├── mail/            Log + SMTP drivers, notification mail channel
├── console/         `hammer` CLI, scheduler, tinker, generator stubs
└── exceptions/      Handler + typed exceptions
examples/blog        Blog demo: views, seeds, basic CRUD
examples/shop        **Full ecommerce system** — real-world acceptance test (auth, catalog, cart, checkout, queues, notifications, policies, signed invoices)
tests/               69 pytest tests covering every subsystem
_reference/          Shallow clone of laravel/framework used as porting reference
```

## Design notes

- **Async everywhere.** Every DB operation is awaited; sessions are short-lived per
  operation (`expire_on_commit=False`) so detached Active Record instances behave
  like Eloquent's.
- **FastAPI underneath.** Routes are bridged onto FastAPI's router, so you keep
  OpenAPI/Swagger, Starlette middleware compatibility, and can drop to raw FastAPI
  any time.
- **SQLAlchemy underneath the ORM.** Column helpers emit real SQLAlchemy
  constructs; you can mix in `Mapped[...]` annotations when you want types.
- **Mass assignment** defaults to unguarded for ergonomics; set
  `__fillable__` / `__guarded__` per model to lock down `fill()` / `update()`.

## Tests

```bash
pytest tests/ -q      # 100 tests across every subsystem
```

## Roadmap

Broadcasting (websockets), Redis queue driver, policy auto-discovery caching,
queue monitoring dashboard.
