Metadata-Version: 2.4
Name: naspy-ioc
Version: 0.7.1
Summary: NestJS-like IoC container, decorators and project scaffolding for FastAPI
Project-URL: Homepage, https://github.com/Leocap11/naspy-ioc
Project-URL: Repository, https://github.com/Leocap11/naspy-ioc
Project-URL: Issues, https://github.com/Leocap11/naspy-ioc/issues
License: MIT
Keywords: cli,decorators,dependency-injection,fastapi,ioc,nestjs,scaffolding
Requires-Python: >=3.12
Requires-Dist: asyncpg>=0.27.0
Requires-Dist: fastapi>=0.100.0
Requires-Dist: pydantic-settings>=2.0.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: sqlalchemy>=2.0.0
Requires-Dist: starlette>=0.27.0
Description-Content-Type: text/markdown

# NasPy 🐍


<p align="center">
  <img src="https://raw.githubusercontent.com/Leocap11/naspy-ioc/main/assets/naspy-logo.png" width="160"/>
</p>

<h1 align="center">NasPy 🐍</h1>

<p align="center">
  NestJS-inspired IoC container for FastAPI
</p>

<p align="center">
  <img src="https://img.shields.io/pypi/v/naspy-ioc.svg">
  <img src="https://img.shields.io/badge/python-3.12+-blue.svg">
  <img src="https://img.shields.io/badge/license-MIT-green.svg">
</p>

**NasPy** is a NestJS-inspired IoC (Inversion of Control) container and decorator library for FastAPI. It brings familiar patterns like `@Injectable`, `@Controller`, `@UseGuards`, dependency injection, and lifecycle management to Python.

---

## Installation

> ### ⚠️ Read this first: `pip install naspy-ioc` will not work globally
>
> On most Linux and macOS setups, installing anything with `pip` **outside a
> virtualenv is refused by the operating system**. Since PEP 668 the system
> Python answers:
>
> ```
> error: externally-managed-environment
> × This environment is externally managed
> ```
>
> **This is not something NasPy can fix.** It is the OS protecting its own Python
> installation, and it applies to every package, not just this one. So the `naspy`
> command has to live *somewhere*, and there are exactly two sensible places.
> Pick the row that matches your machine — both are fully supported:

| | Where the `naspy` command lives | You need | Virtualenvs you create by hand |
|---|---|---|---|
| **A — recommended** | globally, in its own isolated environment | `uv` or `pipx` | none |
| **B — only Python** | inside the project's own virtualenv | nothing extra | one |

### A. With `uv` or `pipx` — nothing to create by hand

`naspy new` builds the project's virtualenv and installs its dependencies, so
once the command is installed a project takes two commands and no virtualenv
work at all:

```bash
uv tool install naspy-ioc        # once, or: pipx install naspy-ioc

naspy new my-api
cd my-api
.venv/bin/uvicorn main:app --reload
```

Or run the scaffolder without installing even that:

```bash
uvx --from naspy-ioc naspy new my-api
# or: pipx run --spec naspy-ioc naspy new my-api
```

Don't have `uv`? Its official installer is one command, and it needs no Python
packaging at all:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

> `uv` caches package metadata, so right after a release it may still hand you the
> previous version — and `--force --refresh` is not always enough to notice.
> `uv tool install --force --refresh --reinstall naspy-ioc` is the one that
> reliably upgrades; check with `naspy --version`.

### B. With only Python — one virtualenv, no global install

If you would rather not add `uv` or `pipx`, install NasPy into a virtualenv and
scaffold into that same directory with `naspy init`, which leaves everything
already there — the virtualenv included — alone:

```bash
mkdir my-api && cd my-api
python -m venv .venv && source .venv/bin/activate
pip install naspy-ioc
naspy init
pip install -r requirements.txt
uvicorn main:app --reload
```

One `pip install naspy-ioc` covers both roles here: the command that scaffolds,
and the library the project imports at runtime. Prefer not to activate? Every
command works by path — `.venv/bin/pip`, `.venv/bin/naspy`, `.venv/bin/uvicorn` —
or through `python -m naspy` when the script is not on PATH.

On Debian and Ubuntu, `python -m venv` needs one system package: if it fails with
`ensurepip is not available`, run `sudo apt install python3-venv`.

> The distribution is named `naspy-ioc` on PyPI; the import name is `naspy`.
> (`pip install naspy` installs an unrelated package.)

---

## Create a project

```bash
naspy new my-api
cd my-api
.venv/bin/uvicorn main:app --reload
```

`GET http://127.0.0.1:8000` answers `"Welcome to Naspy Framework"`.

`naspy new` writes the files, creates `.venv` and installs `requirements.txt` into
it. If either step fails — no network, or `python3-venv` missing on Debian — the
project is still written and the command tells you what to run to finish. Pass
`--no-venv` to only write the files.

The generated project is a runnable skeleton — no database needed to start:

```
my-api/
├── main.py                       app factory: middleware, filters, routers
├── .env                          DATABASE_URL and ENVIRONMENT
├── .gitignore
├── requirements.txt              naspy-ioc pinned, plus uvicorn and alembic
├── README.md
├── docker-compose.yml            postgres 16, credentials matching .env
├── alembic.ini                   no credentials: env.py reads .env
├── migrations/
│   ├── env.py                    async engine, target_metadata = naspy Base
│   ├── script.py.mako
│   └── versions/
└── app/
    ├── core/
    │   ├── settings.py           Settings, extends NaspySettings
    │   └── app_module.py         providers: register_value / register_factory
    ├── guards/
    │   └── api_key_guard.py      IGuard checking X-API-Key
    ├── controller/
    │   └── welcome/
    │       ├── welcome_controller.py     the three routes below
    │       ├── dto/
    │       │   ├── request.py            GreetRequestDTO
    │       │   └── response.py           GreetResponseDTO
    │       └── mapper/
    │           └── mapper.py             domain model → response DTO
    ├── domain/
    │   └── welcome/
    │       ├── model/
    │       │   └── welcome_model.py      WelcomeModel
    │       └── usecase/
    │           ├── get_welcome/
    │           │   └── get_welcome_usecase.py
    │           └── greet/
    │               ├── greet_command.py  GreetCommand
    │               └── greet_usecase.py  GreetUseCase
    └── repository/
        └── item/
            ├── item_schema.py            example table on naspy Base
            └── item_repository.py        example BaseRepository[ItemSchema]
```

Three example routes, one per level of detail:

- `GET /` — the short path: controller → use case → string.
- `POST /greet` with `{"name": "Ada"}` → `{"message": "Welcome to Naspy Framework, Ada"}`,
  walking the full chain: request DTO → command → use case → domain model →
  mapper → response DTO. The controller imports from the domain, never the
  reverse, so the HTTP contract and the domain evolve independently.
- `GET /protected` — guarded with `@UseGuards(ApiKeyGuard)`: 401 in the standard
  error shape without `X-API-Key`, 200 with it.

Migrations are ready to run but not run for you:

```bash
docker compose up -d
alembic revision --autogenerate -m "create item"
alembic upgrade head
```

`app/repository/item/` stays inert until you inject `ItemRepository` into a use
case — it is declared and registered, but nothing resolves it, so no engine is
created and `naspy new` needs no database to start.

| Command | Description |
|---|---|
| `naspy new <name>` | Create `./<name>`, build its `.venv`, install requirements |
| `naspy new <name> --no-venv` | Only write the files |
| `naspy new <name> -p DIR` | Create it in `DIR/<name>` |
| `naspy init` | Scaffold into the current directory, named after it |
| `naspy init <name>` | Same, with an explicit project name |
| `naspy init -p DIR` | Scaffold into `DIR`, creating it if needed |
| `naspy --version` | Print the installed version |
| `python -m naspy ...` | Same commands, when `naspy` is not on PATH |

`init` refuses only files it would overwrite, listing them, so it is safe to run
next to a virtualenv, a `.git` or an editor folder — but it will not clobber an
existing project.

---

## Quick Start

```python
from naspy import Injectable, Controller, Injected, IoC, Lifetime
from fastapi import FastAPI

@Injectable()
class GreetingService:
    def greet(self, name: str) -> str:
        return f"Hello, {name}!"

@Controller("/hello")
class HelloController:
    greetingService = Injected(GreetingService)

    def _register_routes(self):
        self.router.add_api_route("/{name}", self.get_hello, methods=["GET"])

    async def get_hello(self, name: str):
        return self.greetingService.greet(name)

app = FastAPI()
app.include_router(IoC.resolve(HelloController).router)
```

---

## Project Structure

NasPy is opinionated — here is the recommended project structure:

```
app/
├── main.py
├── .env
├── core/
│   └── app_module.py
├── domain/
│   └── user/
│       ├── usecase/
│       │   └── get_user/
│       │       ├── get_user_command.py
│       │       └── get_user_usecase.py
│       └── model/
│           └── user_model.py
├── repository/
│   └── user/
│       ├── user_repository.py
│       └── user_schema.py
├── controller/
│   └── user/
│       ├── user_controller.py
│       └── user_dto.py
└── guards/
    └── auth_guard.py
```

---

## Configuration

NasPy provides a base `NaspySettings` class built on top of `pydantic-settings`. It automatically reads from your `.env` file.

### `.env`

```dotenv
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/mydb
ENVIRONMENT=development
```

### Extending Settings

```python
from naspy.config import NaspySettings

class Settings(NaspySettings):
    SECRET_KEY: str
    DEBUG: bool = False

settings = Settings()
```

`NaspySettings` provides two required fields out of the box:

| Field | Type | Required |
|---|---|---|
| `DATABASE_URL` | `str` | ✅ |
| `ENVIRONMENT` | `str` | ✅ |

Settings are read lazily, on first use — importing `naspy.database` does not require
the environment to be loaded yet.

If you want NasPy's internals (e.g. `Database`) to use *your* subclass instead of a
plain `NaspySettings`, register it before the first `IoC.resolve(Database)`:

```python
from naspy.config import set_settings

settings = Settings()
set_settings(settings)
```

---

## Database

NasPy provides a `Database` class and a `BaseRepository` for PostgreSQL using SQLAlchemy async.

### Setup

No setup required: `Database` builds its own `AsyncEngine` from `DATABASE_URL` the
first time it is resolved. Just make sure the variable is in your `.env`:

```dotenv
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/mydb
```

### `Database`

NasPy exports `Database` and `Base` (SQLAlchemy `DeclarativeBase`) directly:

```python
from naspy.database import Database, Base
```

`Database` is a singleton that holds the `async_sessionmaker`. It is injected automatically into `BaseRepository`.

### Defining a Schema

```python
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String
from naspy.database import Base

class UserSchema(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String)
    email: Mapped[str] = mapped_column(String, unique=True)
```

---

## BaseRepository

`BaseRepository` provides CRUD operations out of the box. Extend it and set the `model` attribute.

```python
from naspy.database import BaseRepository
from naspy import Injectable
from app.repository.user.user_schema import UserSchema

@Injectable()
class UserRepository(BaseRepository[UserSchema]):
    model = UserSchema
```

### Available methods

| Method | Signature | Description |
|---|---|---|
| `find_all` | `() -> list[T]` | Returns all records |
| `find_by_unique_id` | `(id: str) -> T \| None` | Returns a single record by id |
| `save` | `(entity: T) -> T` | Inserts a record |
| `update_entity` | `(id: str, data: T) -> T` | Copies the non-`None` fields of `data` onto the stored record |
| `delete` | `(id: str) -> bool` | Deletes a record by id, `False` if not found |

Each method opens its own session from the connection pool and closes it automatically — no manual session management needed.

```python
@Injectable()
class GetUserUseCase:
    repository = Injected(UserRepository)

    async def run(self, id: int):
        return await self.repository.find_by_unique_id(id)
```

---

## Core Concepts

### `@Injectable`

Marks a class as injectable and registers it in the IoC container.

```python
@Injectable()
class MyService:
    def do_something(self):
        return "done"
```

Supports all three lifetimes:

```python
@Injectable()                        # SINGLETON (default)
@Injectable(Lifetime.SINGLETON)      # one instance for the entire app lifetime
@Injectable(Lifetime.REQUEST)        # one instance per request
@Injectable(Lifetime.TRANSIENT)      # new instance every time it is resolved
```

| Lifetime | NestJS equivalent | Behavior |
|---|---|---|
| `Lifetime.SINGLETON` | `Scope.DEFAULT` | One instance for the entire app lifetime |
| `Lifetime.REQUEST` | `Scope.REQUEST` | New instance per request, shared within the same request |
| `Lifetime.TRANSIENT` | `Scope.TRANSIENT` | New instance every time it is resolved |

---

### `@Controller`

Registers a class as a controller and sets up an `APIRouter`.

```python
@Controller("/users")
class UserController:

    def _register_routes(self):
        self.router.add_api_route("", self.get_all, methods=["GET"])
        self.router.add_api_route("/{id}", self.get_by_id, methods=["GET"])

    async def get_all(self):
        ...

    async def get_by_id(self, id: int):
        ...
```

Register the router in your FastAPI app:

```python
app.include_router(IoC.resolve(UserController).router)
```

---

### `Injected`

Marks a class attribute as an injectable dependency.

```python
@Injectable()
class UserService:
    repository = Injected(UserRepository)

    async def find(self, id: int):
        return await self.repository.find_by_unique_id(id)
```

---

### `InjectedValue`

Injects a registered value (non-class) by token.

```python
# registration
IoC.register_value("MAX_RETRIES", 3)
IoC.register_value("SUPPORTED_CURRENCIES", ["USD", "EUR", "GBP"])

# injection
@Injectable()
class PaymentService:
    max_retries = InjectedValue("MAX_RETRIES")
    currencies = InjectedValue("SUPPORTED_CURRENCIES")
```

---

### `IoC`

The IoC container. Manages registration and resolution of dependencies.

```python
# register a factory
IoC.register_factory(IMailService, lambda: MailService(), Lifetime.SINGLETON)

# register a value
IoC.register_value("CONFIG", {"timeout": 30})

# resolve a dependency
service = IoC.resolve(MyService)

# resolve a value
config = IoC.resolve_value("CONFIG")
```

---

### Factory providers

Equivalent to NestJS `useFactory` — useful for conditional or dynamic instantiation:

```python
class AppModule:
    def register():
        IoC.register_factory(
            IMailService,
            lambda: IoC.resolve(MailService) if os.getenv("ENVIRONMENT") == "production"
                    else IoC.resolve(FakeMailService),
            Lifetime.SINGLETON
        )
```

Call `register()` before creating the FastAPI app:

```python
AppModule.register()
app = create_app()
```

---

### Guards

Guards control access to routes, equivalent to NestJS `@UseGuards`.

#### Define a guard

```python
from naspy import IGuard, Injectable
from fastapi import Request

@Injectable()
class AuthGuard(IGuard):
    async def can_activate(self, request: Request) -> bool:
        token = request.headers.get("Authorization")
        return token is not None
```

> **Deprecated:** `from naspy import AuthGuard` still works but raises a
> `DeprecationWarning` and will be removed in 1.0.0. It only checked that the
> header was present, which is application policy — and a placeholder one at
> that. Write your own `IGuard`, as above; `naspy new` scaffolds one in
> `app/guards/`.

#### Apply guards at different levels

```python
from naspy import UseGuards, SkipGuards, set_global_guards

# 1. Global — applies to all routes in the app
set_global_guards(AuthGuard)

# 2. Controller — applies to all routes in the controller
@Controller("/users", guards=[AuthGuard])
class UserController:
    ...

# 3. Method — applies to a single route
@UseGuards(RolesGuard)
async def get_by_id(self, id: int):
    ...

# 4. Skip all guards on a specific route
@SkipGuards()
async def get_public(self):
    ...
```

Guard resolution priority:

```
Global guards
    └── Controller guards
            ├── @SkipGuards    → skip all guards
            ├── @UseGuards     → global + controller + method guards
            └── (no decorator) → global + controller guards
```

---

### Exception Filters

Register a global exception filter to standardize all error responses:

```python
from naspy import register_exception_filters

def create_app() -> FastAPI:
    app = FastAPI()
    register_exception_filters(app)
    ...
```

All errors will follow this format:

```json
{
    "outcome": false,
    "error": {
        "code": 401,
        "status": "Unauthorized",
        "message": "Invalid or missing token"
    }
}
```

This covers `HTTPException` raised by your code, request validation errors
(`422`), any uncaught exception (`500`), and the `404` / `405` the router raises
on its own.

---

### Scoped Middleware

Add `ScopeMiddleware` to enable `Lifetime.REQUEST` dependencies:

```python
from naspy import ScopeMiddleware

def create_app() -> FastAPI:
    app = FastAPI()
    app.add_middleware(ScopeMiddleware)
    ...
```

Scoped dependencies must be resolved inside request methods using `IoC.resolve()`:

```python
async def get_by_id(self, id: int):
    logger = IoC.resolve(RequestLogger)  # new instance per request
    logger.log(f"Fetching id={id}")
    ...
```

---

## Full Example

```python
# main.py
from fastapi import FastAPI
from naspy import IoC, set_global_guards, register_exception_filters, ScopeMiddleware
from app.guards.auth_guard import AuthGuard
from app.controller.user.user_controller import UserController
from app.core.app_module import AppModule

def create_app() -> FastAPI:
    app = FastAPI()
    app.add_middleware(ScopeMiddleware)
    register_exception_filters(app)
    set_global_guards(AuthGuard)
    app.include_router(IoC.resolve(UserController).router)
    return app

AppModule.register()
app = create_app()
```

```python
# app/core/app_module.py
from naspy import IoC, Lifetime
from naspy.config import NaspySettings, set_settings
from app.services.mail.i_mail_service import IMailService
from app.services.mail.mail_service import MailService
from app.services.mail.fake_mail_service import FakeMailService

class Settings(NaspySettings):
    SECRET_KEY: str

settings = Settings()
set_settings(settings)

class AppModule:
    def register():
        IoC.register_value("MAX_RETRIES", 3)
        IoC.register_factory(
            IMailService,
            lambda: IoC.resolve(MailService) if settings.ENVIRONMENT == "production"
                    else IoC.resolve(FakeMailService),
            Lifetime.SINGLETON
        )
```

```python
# app/repository/user/user_schema.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String
from naspy.database import Base

class UserSchema(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String)
    email: Mapped[str] = mapped_column(String, unique=True)
```

```python
# app/repository/user/user_repository.py
from naspy import Injectable
from naspy.database import BaseRepository
from app.repository.user.user_schema import UserSchema

@Injectable()
class UserRepository(BaseRepository[UserSchema]):
    model = UserSchema
```

```python
# app/domain/user/usecase/get_user/get_user_usecase.py
from naspy import Injectable, Injected
from app.repository.user.user_repository import UserRepository

@Injectable()
class GetUserUseCase:
    repository = Injected(UserRepository)

    async def run(self, id: int):
        return await self.repository.find_by_unique_id(id)
```

```python
# app/controller/user/user_controller.py
from naspy import Controller, Injected, SkipGuards
from app.domain.user.usecase.get_user.get_user_usecase import GetUserUseCase

@Controller("/users")
class UserController:
    getUserUseCase = Injected(GetUserUseCase)

    def _register_routes(self):
        self.router.add_api_route("/public", self.get_public, methods=["GET"])
        self.router.add_api_route("/{id}", self.get_by_id, methods=["GET"])

    @SkipGuards()
    async def get_public(self):
        return {"public": True}

    async def get_by_id(self, id: int):
        return await self.getUserUseCase.run(id)
```

---

## Comparison with NestJS

| NestJS | NasPy |
|---|---|
| `@Injectable()` | `@Injectable()` |
| `@Controller('/path')` | `@Controller('/path')` |
| `@UseGuards(Guard)` | `@UseGuards(Guard)` |
| `app.useGlobalGuards()` | `set_global_guards(Guard)` |
| `Scope.DEFAULT` | `Lifetime.SINGLETON` |
| `Scope.REQUEST` | `Lifetime.REQUEST` |
| `Scope.TRANSIENT` | `Lifetime.TRANSIENT` |
| `useFactory` | `IoC.register_factory()` |
| `useValue` | `IoC.register_value()` |
| `AppModule` | `AppModule.register()` |
| `ModuleRef.resolve()` | `IoC.resolve()` |
| `TypeOrmModule` | `BaseRepository` |
| `ConfigService` | `NaspySettings` |

---

## Requirements

- Python >= 3.12
- FastAPI >= 0.100.0
- SQLAlchemy >= 2.0.0
- Starlette >= 0.27.0
- Pydantic >= 2.0.0
- pydantic-settings >= 2.0.0
- asyncpg >= 0.27.0

---

## License

MIT