Metadata-Version: 2.4
Name: snakeboot
Version: 0.3.0
Summary: Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan, typed configuration with profiles and singleton REST controllers on Django Ninja, wired by wireup.
Keywords: spring-boot,hexagonal-architecture,ddd,django,django-ninja,wireup,dependency-injection,transactional
Author: Wouter Deketelaere
Author-email: Wouter Deketelaere <wouter.deketelaere@kdg.be>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Dist: wireup>=2.12.0
Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'configuration'
Requires-Dist: django>=6.1.1 ; extra == 'django'
Requires-Dist: django>=6.1.1 ; extra == 'ninja'
Requires-Dist: django-ninja>=1.7.1 ; extra == 'ninja'
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/KdG-OCDI/snakeboot
Project-URL: Repository, https://github.com/KdG-OCDI/snakeboot
Project-URL: Issues, https://github.com/KdG-OCDI/snakeboot/issues
Project-URL: Changelog, https://github.com/KdG-OCDI/snakeboot/blob/main/CHANGELOG.md
Provides-Extra: configuration
Provides-Extra: django
Provides-Extra: ninja
Description-Content-Type: text/markdown

# snakeboot

Spring Boot ergonomics for hexagonal Python applications built with Django, Django Ninja and
[wireup](https://github.com/maldoinc/wireup).

snakeboot imitates the basics of Spring Boot, not Spring itself. Each part is a handful of small
classes you can read in an afternoon, and none of them knows anything about your domain.

| snakeboot | Spring Boot | Needs |
|---|---|---|
| `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
| `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
| `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
| `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
| `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
| `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@EnableConfigurationProperties`, profiles | `snakeboot[configuration]` |
| `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
| `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |

The transaction, component scan and testing packages import nothing but the standard library,
so the application layer of a hexagonal application can use `@transactional` without importing Django.

## Install

```bash
uv add "snakeboot[configuration,django,ninja]"
```

## A tour

**The application root.** The component scan starts in the package of the marked class, like
`@SpringBootApplication`. It skips Django's entry points (`wsgi.py`, `asgi.py`, migrations) and
adds the components snakeboot provides itself (the event publisher).

```python
# myapp/myapp_application.py
@application_root
class MyApplication: ...


# settings.py: the setting wireup needs anyway
WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()], auto_inject_views=False)
```

**Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys) is bound to one
table of `application.toml`, like `@ConfigurationProperties`. Settings lists the sections once, like
`@EnableConfigurationProperties`; every section then reaches wireup by its type. The sections Django itself
needs come with snakeboot: `DjangoProperties` (`[django]`), `DatabaseProperties` (`[database]`, PostgreSQL) and
`CacheProperties` (`[cache]`, Redis).

```python
@configuration_properties("chatbot")
class ChatbotProperties(Section):
    model: str  # no default: the value is in application.toml
    llm: LanguageModelProperties  # a nested Section is the table [chatbot.llm]


# settings.py
CONFIGURATION = load_configuration(DjangoProperties, DatabaseProperties, ChatbotProperties, package="myapp")
SECRET_KEY = CONFIGURATION.get(DjangoProperties).secret_key.get_secret_value()
DATABASES = {"default": CONFIGURATION.get(DatabaseProperties).as_django_database()}
WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names(), *CONFIGURATION.injectables()])
```

The values come from three places, a later one overriding an earlier one key by key:

| Source | Holds | Spring Boot |
|---|---|---|
| `application.toml` in the package | every value the application runs with in production | `application.yml` |
| `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
| environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |

Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model has
a default, ignores unknown keys or is mutable; when a file or a value is missing; when a key or a table is
unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced, so put the
variant without keys of its own in `application.toml` when a profile or the environment may choose another.

**Transactions.** A use case declares its boundary; composition chooses the technology.

```python
@injectable
class CancelRegistrationUseCase:
    @transactional
    def execute(self, request: CancelRegistrationRequestDTO) -> None:
        workshop = self._workshops.get(...)
        workshop.cancel_registration(...)
        self._workshops.save(workshop)
        self._events.publish_all(workshop.pull_events())


# composition, at start-up
TransactionManagerRegistry.install(DjangoAtomicTransactionManager())
```

**Application events.** Listeners react to facts after the commit, so a rolled-back change never
reaches anyone, and a failing listener cannot undo a committed one.

```python
@application_event_listener
class ParticipantNotificationListener:
    def __init__(self, notifications: NotificationPort) -> None:
        self._notifications = notifications

    @transactional_event_listener(WaitlistedParticipantPromoted)
    def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
```

**REST controllers.** Singleton classes with constructor injection, found by the component scan.
Two endpoints for the same URL, or two answers for one error, stop the start-up.

```python
@rest_controller("/workshops", tags=["workshops"])
class WorkshopRestController:
    def __init__(self, enrollment: EnrollmentFacadePort) -> None:
        self._enrollment = enrollment

    @route.get("/{workshop_id}", response=WorkshopViewDTO)
    def get_workshop(self, request: HttpRequest, workshop_id: str) -> WorkshopViewDTO: ...


@rest_controller_advice
class EnrollmentRestErrorAdvice:
    @exception_handler(WorkshopNotFoundError, status=404)
    def answer_not_found(self, error: Exception) -> dict[str, str]:
        return error_answer(error)  # {"code": "workshop_not_found", "detail": "..."}


# urls.py
mounted = RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or ())
```

## Design rules

* Imitate the basics, not all of Spring: no AOP, no scheduling, no message brokers.
* Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the application.
* Each package knows as little as possible; `uv run lint-imports` enforces it.

## Develop

```bash
uv sync
uv run pre-commit install
uv run pytest            # tests, including the import contracts
uv run ruff check        # Clean Code rules
uv run mypy              # strict
```
