Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ Web Framework with focus on python typing, dataclasses and modular design

Package structure and layer rules: [docs/architecture.md](docs/architecture.md).

## Upgrading to Winter 34

An application is composed explicitly in a `winter.application.Application`, which imports every module of
the given packages on construction (by default, those of the directory of the module that creates it), and
adapters build from it. `find_package_routes`, `create_django_urls_from_routes`, `WinterApplication` and the
`setup()` functions of `winter.web`, `winter.django` and `winter.openapi` are gone.

```python
import service_a
import winter.django
from winter.application import Application

app = Application(modules=[service_a], injector=injector) # several applications may share one injector
urlpatterns = winter.django.create_urls(app)
```

- A `@problem` exception gets its handler only when it belongs to an application: define it in a scanned
module or add it, e.g. `app.add(winter.web.problem(status=404)(NotFoundException))`.
- `winter.django.create_urls` installs the Django throttling identity on its first call: replace it with
`winter.web.set_throttling_identity_resolver` after that.
- OpenAPI: `generate_openapi(app, title=..., version=...)` returns the spec and checks `@raises` annotations
of the application (`allow_missing_raises_annotation=True` skips the check).
`winter.openapi.serve_spec(app, spec, url='openapi.json')` and
`winter.openapi.serve_swagger_ui(app, spec_url='openapi.json', url='docs/')` add the routes that serve them;
the served spec and Swagger UI work under whatever prefix the application is included at.

## Upgrading to Winter 33

`winter` is a namespace package now: the `import winter` facade is gone, use `winter.web` instead
Expand Down Expand Up @@ -67,14 +93,15 @@ class HelloWorld:
return 'Hello, world!'
```

To use it with Django:
To use it with Django, in `urls.py`:
```python
from winter.web.autodiscovery import find_package_routes
import some_package
import winter.django
from winter.application import Application

routes = find_package_routes('some_package.sub_package')
app = Application(modules=[some_package])
urlpatterns = [
*winter.django.create_django_urls_from_routes(routes),
*winter.django.create_urls(app),
]
```

Expand Down
25 changes: 18 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,20 @@ at least two of them.

## Current deviations

- **Framework behaviour is still process-global.** `winter.application.get_injector` is a
service locator, and the behavioural registries of `winter.web` and `winter.openapi` are module
singletons. `tests/test_application_scope.py` states the intended behaviour and is marked
`xfail` until an `Application` instance owns that state.
- **Framework behaviour is still process-global.** An `Application` holds the code of an
application and its injector, but the behavioural registries of `winter.web` and `winter.openapi`
are module singletons, and the application installs its injector as the service locator
`winter.application.get_injector`. `tests/test_application_scope.py` states the intended behaviour
and is marked `xfail` until an `Application` instance owns that state.
- **Models are initialised once per process.** `winter.web.initialization.initialize`, the Django
adapter's `_initialize_process` and `winter.openapi.initialization.initialize` fill those module
singletons with the standard resolvers, inspectors and handlers on first use, and add the handlers
of each application's `@problem` exceptions to the same global registry. In the target, each
`Application` owns these registries in an injector of its own, a child of the injector it is given,
and they start with the standard entries. Building from an application, an adapter adds its own
entries (the Django request resolver, the native response type, the throttling identity) to that
application only, and the `@problem` handlers go to the application's registry. The `initialize`
functions then disappear.
- **`winter.wsgi`, `winter.asgi` and `winter.rabbitmq` do not exist yet.** HTTP is served through
`winter.django` only, and `winter.messaging` has no transport adapter.
- **All dependencies are mandatory.** Django, SQLAlchemy, Redis and pydantic are installed with
Expand All @@ -95,8 +105,9 @@ at least two of them.

- **import-linter** (`lint-imports`, configured in `pyproject.toml`) checks the layer rules.
There are no `ignore_imports`, and none may be added.
- **North-star test** `tests/test_application_scope.py` is marked `xfail(strict=True)`. It
creates two `Application` instances with different exception handlers and asserts they do
not see each other. `strict` forces the marker to be removed the moment it passes.
- **Isolation test** `tests/test_application_scope.py` is marked `xfail(strict=True)`. It
builds two applications in one process and asserts that the handler of a `@problem` exception
of one does not handle that exception in the other. `strict` forces the marker to be removed
the moment it passes.
- **This document** describes intent and is updated when the intent changes, not when the code
moves. Deviations are listed above while they exist.
Empty file.
9 changes: 9 additions & 0 deletions tests/application/caller_directory/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from collections import OrderedDict # noqa: F401 imported, not defined here


class API:
pass


def function(): # pragma: no cover
pass
13 changes: 13 additions & 0 deletions tests/application/caller_directory/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from winter.application import Application

print(f'executing {__name__}')


class DefinedBeforeApplication:
pass


app = Application()

if __name__ == '__main__':
print(*(definition.__qualname__ for definition in app.definitions))
1 change: 1 addition & 0 deletions tests/application/caller_directory/not_a_package/broken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise RuntimeError('A directory without __init__.py is not a package and must not be scanned')
Empty file.
2 changes: 2 additions & 0 deletions tests/application/caller_directory/subpackage/deep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class Deep:
pass
129 changes: 113 additions & 16 deletions tests/application/test_application.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,124 @@
import winter.application
import winter.components
import subprocess
import sys
from pathlib import Path

import pytest
from injector import Injector

def test_autodiscover():
from winter.application import Application
from winter.application import get_injector
from winter.application import set_injector

@winter.components.component
class SimpleComponent:
pass

winter_app = winter.application.WinterApplication()
@pytest.fixture(autouse=True)
def restore_injector():
# Application installs its injector globally; the rest of the suite runs with the one from conftest
injector = get_injector()
yield
set_injector(injector)

winter_app.autodiscover()

assert SimpleComponent in winter_app.components
class Defined:
pass


def test_autodiscover_with_pre_add():
def defined(): # pragma: no cover
pass

class SimpleComponent:
pass

winter_app = winter.application.WinterApplication()
winter_app.add_component(SimpleComponent)
winter_app.autodiscover()
def test_definitions_are_classes_and_functions_defined_in_scanned_modules():
import tests.application.caller_directory.api
from tests.application.caller_directory.api import API
from tests.application.caller_directory.api import function

assert SimpleComponent in winter_app.components
# Act
app = Application(modules=[tests.application.caller_directory.api])

# Assert
assert app.definitions == (API, function)


def test_package_is_scanned_with_its_submodules():
import tests.application.caller_directory.subpackage
from tests.application.caller_directory.subpackage.deep import Deep

# Act
app = Application(modules=[tests.application.caller_directory.subpackage])

# Assert
assert app.definitions == (Deep,)


def test_default_modules_are_those_of_caller_directory():
from tests.application.caller_directory.api import API
from tests.application.caller_directory.api import function
from tests.application.caller_directory.subpackage.deep import Deep

# Act
from tests.application.caller_directory.app import DefinedBeforeApplication
from tests.application.caller_directory.app import app

# Assert
assert app.definitions == (API, function, DefinedBeforeApplication, Deep)


def test_caller_run_as_main_is_not_executed_twice():
repository = Path(__file__).parents[2]

# Act
result = subprocess.run(
[sys.executable, '-m', 'tests.application.caller_directory.app'],
cwd=repository,
capture_output=True,
text=True,
check=True,
)

# Assert
assert result.stdout.splitlines() == ['executing __main__', 'API function DefinedBeforeApplication Deep']


def test_unimportable_module_fails_construction():
import tests.application.unimportable

# Act
with pytest.raises(RuntimeError, match='Unimportable') as exception_info:
Application(modules=[tests.application.unimportable])

# Assert
assert exception_info.value.__notes__ == [
'Application imports every module of tests.application.unimportable, so each of them must be importable',
]


def test_add():
app = Application(modules=[])

# Act
app.add(Defined)
app.add(defined)
app.add(Defined)

# Assert
assert app.definitions == (Defined, defined)


def test_new_injector_is_installed():
# Act
app = Application(modules=[])

# Assert
assert isinstance(app.injector, Injector)
assert get_injector() is app.injector


def test_given_injector_is_shared():
injector = Injector()

# Act
app_1 = Application(modules=[], injector=injector)
app_2 = Application(modules=[], injector=injector)

# Assert
assert app_1.injector is injector
assert app_2.injector is injector
Empty file.
1 change: 1 addition & 0 deletions tests/application/unimportable/broken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise RuntimeError('Unimportable')
17 changes: 2 additions & 15 deletions tests/apps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from http import HTTPStatus

from django.apps import AppConfig

from tests.web.interceptors import HelloWorldInterceptor
from winter.ddd import NotFoundException
import winter.web
from winter.web import InMemoryThrottlingStorage
from winter.web import exception_handlers_registry
from winter.web import interceptor_registry
Expand All @@ -14,23 +12,12 @@ class TestAppConfig(AppConfig):
name = 'tests'

def ready(self):
# define this import for force initialization all modules and to register Exceptions
# Importing the URLs creates the application and its URL patterns
from .urls import urlpatterns # noqa: F401
import winter
import winter.django
import winter.openapi

interceptor_registry.add_interceptor(HelloWorldInterceptor())

winter.openapi.setup()

# Mapping a domain exception to an HTTP status is application wiring, not framework behaviour
winter.web.problem(status=HTTPStatus.NOT_FOUND)(NotFoundException)
winter.web.setup()

# The Redis storage is covered in tests/redis; the sample application throttles in memory.
winter.web.set_throttling_storage(InMemoryThrottlingStorage())

winter.django.setup()

exception_handlers_registry.set_default_handler(DefaultExceptionHandler) # for 100% test coverage
18 changes: 14 additions & 4 deletions tests/components/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import winter.components
from winter.components import Component
from winter.application import WinterApplication


@dataclasses.dataclass(frozen=True)
Expand All @@ -16,12 +15,10 @@ def simple_annotation(param: str):


def test_is_component():
winter_app = WinterApplication()

class SimpleComponent:
pass

winter_app.add_component(SimpleComponent)
winter.components.component(SimpleComponent)
assert winter.components.is_component(SimpleComponent)


Expand Down Expand Up @@ -75,3 +72,16 @@ def test_register_with_instance():

assert str(exception.value) == f'Need class. Got: {instance}'
assert exception.value.args == (f'Need class. Got: {instance}',)


def test_register_returns_existing_component():
class SimpleComponent:
pass

component = Component.register(SimpleComponent)

# Act
registered_again = Component.register(SimpleComponent)

# Assert
assert registered_again is component
6 changes: 2 additions & 4 deletions tests/components/test_component_method.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import inspect

from winter.components import Component
from winter.components import ComponentMethod
from winter.components import ComponentMethodArgument
from winter.application import WinterApplication
from winter.components import component_method


def test_all_attributes():
winter_app = WinterApplication()
argument_type = int
argument_name = 'number'
method_name = 'method'
Expand All @@ -18,12 +17,11 @@ class SimpleComponent:
def method(self, number: argument_type):
return self, number

winter_app.add_component(SimpleComponent)
simple_component = SimpleComponent()

cls_method = SimpleComponent.method

assert cls_method.component == winter_app.components[SimpleComponent]
assert cls_method.component == Component.get_by_cls(SimpleComponent)
assert cls_method.name == method_name
assert inspect.ismethod(simple_component.method)
assert cls_method.func == simple_component.method.__func__
Expand Down
3 changes: 3 additions & 0 deletions tests/openapi/inspectors/test_route_parameter_inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from winter.openapi import PathParametersInspector
from winter.openapi import register_route_parameters_inspector
from winter.openapi.initialization import initialize


def test_add_same_inspector_write_warning_log(caplog):
initialize()

with caplog.at_level(logging.WARNING):
register_route_parameters_inspector(PathParametersInspector())
assert 'PathParametersInspector already registered' in caplog.text
4 changes: 2 additions & 2 deletions tests/openapi/test_add_url_segment_as_tag.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import winter.web
from winter.web.routing import get_route
from winter.openapi import generate_openapi
from winter.openapi.generator import generate_openapi_for_routes


def test_add_url_segment_as_tag_false():
Expand All @@ -11,7 +11,7 @@ def get_resource(self): # pragma: no cover

route = get_route(_TestAPI.get_resource)
# Act
result = generate_openapi(
result = generate_openapi_for_routes(
title='title',
version='1.0.0',
description='description',
Expand Down
Loading
Loading