yaafcli 2026.2.4.180929__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yaafcli-2026.2.4.180929/LICENSE +21 -0
- yaafcli-2026.2.4.180929/PKG-INFO +206 -0
- yaafcli-2026.2.4.180929/README.md +164 -0
- yaafcli-2026.2.4.180929/pyproject.toml +40 -0
- yaafcli-2026.2.4.180929/setup.cfg +4 -0
- yaafcli-2026.2.4.180929/tests/test_app_integration.py +108 -0
- yaafcli-2026.2.4.180929/tests/test_di.py +44 -0
- yaafcli-2026.2.4.180929/tests/test_loader.py +39 -0
- yaafcli-2026.2.4.180929/tests/test_responses.py +34 -0
- yaafcli-2026.2.4.180929/yaaf/__init__.py +23 -0
- yaafcli-2026.2.4.180929/yaaf/__main__.py +4 -0
- yaafcli-2026.2.4.180929/yaaf/app.py +109 -0
- yaafcli-2026.2.4.180929/yaaf/cli.py +45 -0
- yaafcli-2026.2.4.180929/yaaf/di.py +65 -0
- yaafcli-2026.2.4.180929/yaaf/gen_services.py +80 -0
- yaafcli-2026.2.4.180929/yaaf/loader.py +193 -0
- yaafcli-2026.2.4.180929/yaaf/py.typed +0 -0
- yaafcli-2026.2.4.180929/yaaf/responses.py +69 -0
- yaafcli-2026.2.4.180929/yaaf/types.py +44 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/PKG-INFO +206 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/SOURCES.txt +23 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/dependency_links.txt +1 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/entry_points.txt +2 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/requires.txt +5 -0
- yaafcli-2026.2.4.180929/yaafcli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yaafcli
|
|
3
|
+
Version: 2026.2.4.180929
|
|
4
|
+
Summary: Minimal ASGI app scaffold
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Framework :: AsyncIO
|
|
32
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
34
|
+
Requires-Python: >=3.13
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: uvicorn>=0.23
|
|
38
|
+
Provides-Extra: test
|
|
39
|
+
Requires-Dist: pytest>=7.4; extra == "test"
|
|
40
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# yaaf
|
|
44
|
+
|
|
45
|
+
YAAF stands for "Yet Another ASGI Framework".
|
|
46
|
+
|
|
47
|
+
A minimal Python ASGI app scaffold that discovers routes from the filesystem. It includes a tiny router and a CLI wrapper around `uvicorn`.
|
|
48
|
+
|
|
49
|
+
## Design Goals and Opinions
|
|
50
|
+
|
|
51
|
+
- **Filesystem-first routing.** Routes are inferred from the directory structure under `consumers/**/api` rather than declared with decorators. This keeps routing discoverable by looking at the tree.
|
|
52
|
+
- **Explicit endpoint files.** Each route has `_server.py` and `_service.py` to separate request handling from domain logic.
|
|
53
|
+
- **Dependency injection without wiring.** Services are registered automatically and injected by name/type, so handlers and services focus on behavior, not setup.
|
|
54
|
+
- **Static-first routing precedence.** Static routes always win over dynamic segments, with warnings when a dynamic route would overlap a static route.
|
|
55
|
+
- **Minimal core.** The framework is intentionally small and opinionated, leaving room for you to add auth, middleware, validation, etc.
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m venv .venv
|
|
61
|
+
source .venv/bin/activate
|
|
62
|
+
pip install -e .
|
|
63
|
+
|
|
64
|
+
# Run the built-in example routes
|
|
65
|
+
yaaf --reload
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Example routes:
|
|
69
|
+
|
|
70
|
+
- `GET /api/hello`
|
|
71
|
+
- `GET /api/<name>` (dynamic segment)
|
|
72
|
+
|
|
73
|
+
## Routing Model
|
|
74
|
+
|
|
75
|
+
Routes are inferred from the directory structure under any `consumers/**/api` directory.
|
|
76
|
+
|
|
77
|
+
- Every route directory must contain `_server.py` and `_service.py`.
|
|
78
|
+
- The route path is `/api/...` plus the sub-path after `api/`.
|
|
79
|
+
- Dynamic segments use `[param]` directory names and are exposed as `params`/`path_params`.
|
|
80
|
+
|
|
81
|
+
Example layout:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
consumers/
|
|
85
|
+
api/
|
|
86
|
+
users/
|
|
87
|
+
_server.py
|
|
88
|
+
_service.py
|
|
89
|
+
hello/
|
|
90
|
+
_server.py
|
|
91
|
+
_service.py
|
|
92
|
+
[name]/
|
|
93
|
+
_server.py
|
|
94
|
+
_service.py
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Handlers and Services
|
|
98
|
+
|
|
99
|
+
In `_server.py`, export functions named after HTTP methods (lowercase): `get`, `post`, etc. The function signature is resolved via dependency injection:
|
|
100
|
+
|
|
101
|
+
- `request` gives you the `yaaf.Request` object.
|
|
102
|
+
- `params` or `path_params` provides dynamic route parameters.
|
|
103
|
+
- Services are injected by type annotations.
|
|
104
|
+
|
|
105
|
+
Example `_server.py`:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from consumers.services import HelloService
|
|
109
|
+
from yaaf import Request
|
|
110
|
+
from yaaf.types import Params
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def get(request: Request, service: HelloService, params: Params):
|
|
114
|
+
return {"message": service.message(), "path": request.path, "params": params}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
In `_service.py`, expose a module-level `service` instance (or a callable like `Service` or `get_service`). Services are registered and can be injected into other services or handlers:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from consumers.services import UsersService
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Service:
|
|
124
|
+
def __init__(self, users: UsersService) -> None:
|
|
125
|
+
self._users = users
|
|
126
|
+
|
|
127
|
+
def message(self) -> str:
|
|
128
|
+
user = self._users.get_user("1")
|
|
129
|
+
return f"Hello from yaaf, {user['name']}"
|
|
130
|
+
|
|
131
|
+
service = Service
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Service-to-Service Injection
|
|
135
|
+
|
|
136
|
+
Services can depend on other services via type annotations. Example layout:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
consumers/
|
|
140
|
+
api/
|
|
141
|
+
users/
|
|
142
|
+
_service.py
|
|
143
|
+
_server.py
|
|
144
|
+
hello/
|
|
145
|
+
_service.py
|
|
146
|
+
_server.py
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`consumers/api/users/_service.py`
|
|
150
|
+
```python
|
|
151
|
+
class Service:
|
|
152
|
+
def get_user(self, user_id: str) -> dict:
|
|
153
|
+
return {"id": user_id, "name": "Austin"}
|
|
154
|
+
|
|
155
|
+
service = Service()
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`consumers/api/hello/_service.py`
|
|
159
|
+
```python
|
|
160
|
+
from yaaf.services import UsersService
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Service:
|
|
164
|
+
def __init__(self, users: UsersService) -> None:
|
|
165
|
+
self._users = users
|
|
166
|
+
|
|
167
|
+
def message(self) -> str:
|
|
168
|
+
user = self._users.get_user("1")
|
|
169
|
+
return f"Hello from yaaf, {user['name']}"
|
|
170
|
+
|
|
171
|
+
service = Service
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`consumers/api/hello/_server.py`
|
|
175
|
+
```python
|
|
176
|
+
from consumers.services import HelloService
|
|
177
|
+
from yaaf import Request
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def get(request: Request, service: HelloService):
|
|
181
|
+
return {"message": service.message(), "path": request.path}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Running Another App
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
yaaf --app your_package.app:app
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Versioning
|
|
191
|
+
|
|
192
|
+
This project uses calendar-based versions with a timestamp (UTC). To bump the version:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
python scripts/bump_version.py
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Service Type Generation
|
|
199
|
+
|
|
200
|
+
Every `yaaf` command regenerates `consumers/services.py` for type-checking. You can also run it explicitly:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
yaaf gen-services
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Dynamic route segments like `[name]` get Protocol stubs in the generated file since they are not valid import paths.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# yaaf
|
|
2
|
+
|
|
3
|
+
YAAF stands for "Yet Another ASGI Framework".
|
|
4
|
+
|
|
5
|
+
A minimal Python ASGI app scaffold that discovers routes from the filesystem. It includes a tiny router and a CLI wrapper around `uvicorn`.
|
|
6
|
+
|
|
7
|
+
## Design Goals and Opinions
|
|
8
|
+
|
|
9
|
+
- **Filesystem-first routing.** Routes are inferred from the directory structure under `consumers/**/api` rather than declared with decorators. This keeps routing discoverable by looking at the tree.
|
|
10
|
+
- **Explicit endpoint files.** Each route has `_server.py` and `_service.py` to separate request handling from domain logic.
|
|
11
|
+
- **Dependency injection without wiring.** Services are registered automatically and injected by name/type, so handlers and services focus on behavior, not setup.
|
|
12
|
+
- **Static-first routing precedence.** Static routes always win over dynamic segments, with warnings when a dynamic route would overlap a static route.
|
|
13
|
+
- **Minimal core.** The framework is intentionally small and opinionated, leaving room for you to add auth, middleware, validation, etc.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m venv .venv
|
|
19
|
+
source .venv/bin/activate
|
|
20
|
+
pip install -e .
|
|
21
|
+
|
|
22
|
+
# Run the built-in example routes
|
|
23
|
+
yaaf --reload
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Example routes:
|
|
27
|
+
|
|
28
|
+
- `GET /api/hello`
|
|
29
|
+
- `GET /api/<name>` (dynamic segment)
|
|
30
|
+
|
|
31
|
+
## Routing Model
|
|
32
|
+
|
|
33
|
+
Routes are inferred from the directory structure under any `consumers/**/api` directory.
|
|
34
|
+
|
|
35
|
+
- Every route directory must contain `_server.py` and `_service.py`.
|
|
36
|
+
- The route path is `/api/...` plus the sub-path after `api/`.
|
|
37
|
+
- Dynamic segments use `[param]` directory names and are exposed as `params`/`path_params`.
|
|
38
|
+
|
|
39
|
+
Example layout:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
consumers/
|
|
43
|
+
api/
|
|
44
|
+
users/
|
|
45
|
+
_server.py
|
|
46
|
+
_service.py
|
|
47
|
+
hello/
|
|
48
|
+
_server.py
|
|
49
|
+
_service.py
|
|
50
|
+
[name]/
|
|
51
|
+
_server.py
|
|
52
|
+
_service.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Handlers and Services
|
|
56
|
+
|
|
57
|
+
In `_server.py`, export functions named after HTTP methods (lowercase): `get`, `post`, etc. The function signature is resolved via dependency injection:
|
|
58
|
+
|
|
59
|
+
- `request` gives you the `yaaf.Request` object.
|
|
60
|
+
- `params` or `path_params` provides dynamic route parameters.
|
|
61
|
+
- Services are injected by type annotations.
|
|
62
|
+
|
|
63
|
+
Example `_server.py`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from consumers.services import HelloService
|
|
67
|
+
from yaaf import Request
|
|
68
|
+
from yaaf.types import Params
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def get(request: Request, service: HelloService, params: Params):
|
|
72
|
+
return {"message": service.message(), "path": request.path, "params": params}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
In `_service.py`, expose a module-level `service` instance (or a callable like `Service` or `get_service`). Services are registered and can be injected into other services or handlers:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from consumers.services import UsersService
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Service:
|
|
82
|
+
def __init__(self, users: UsersService) -> None:
|
|
83
|
+
self._users = users
|
|
84
|
+
|
|
85
|
+
def message(self) -> str:
|
|
86
|
+
user = self._users.get_user("1")
|
|
87
|
+
return f"Hello from yaaf, {user['name']}"
|
|
88
|
+
|
|
89
|
+
service = Service
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Service-to-Service Injection
|
|
93
|
+
|
|
94
|
+
Services can depend on other services via type annotations. Example layout:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
consumers/
|
|
98
|
+
api/
|
|
99
|
+
users/
|
|
100
|
+
_service.py
|
|
101
|
+
_server.py
|
|
102
|
+
hello/
|
|
103
|
+
_service.py
|
|
104
|
+
_server.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`consumers/api/users/_service.py`
|
|
108
|
+
```python
|
|
109
|
+
class Service:
|
|
110
|
+
def get_user(self, user_id: str) -> dict:
|
|
111
|
+
return {"id": user_id, "name": "Austin"}
|
|
112
|
+
|
|
113
|
+
service = Service()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`consumers/api/hello/_service.py`
|
|
117
|
+
```python
|
|
118
|
+
from yaaf.services import UsersService
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Service:
|
|
122
|
+
def __init__(self, users: UsersService) -> None:
|
|
123
|
+
self._users = users
|
|
124
|
+
|
|
125
|
+
def message(self) -> str:
|
|
126
|
+
user = self._users.get_user("1")
|
|
127
|
+
return f"Hello from yaaf, {user['name']}"
|
|
128
|
+
|
|
129
|
+
service = Service
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`consumers/api/hello/_server.py`
|
|
133
|
+
```python
|
|
134
|
+
from consumers.services import HelloService
|
|
135
|
+
from yaaf import Request
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def get(request: Request, service: HelloService):
|
|
139
|
+
return {"message": service.message(), "path": request.path}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Running Another App
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
yaaf --app your_package.app:app
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Versioning
|
|
149
|
+
|
|
150
|
+
This project uses calendar-based versions with a timestamp (UTC). To bump the version:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
python scripts/bump_version.py
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Service Type Generation
|
|
157
|
+
|
|
158
|
+
Every `yaaf` command regenerates `consumers/services.py` for type-checking. You can also run it explicitly:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
yaaf gen-services
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Dynamic route segments like `[name]` get Protocol stubs in the generated file since they are not valid import paths.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yaafcli"
|
|
7
|
+
version = "2026.02.04.180929"
|
|
8
|
+
description = "Minimal ASGI app scaffold"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = ["uvicorn>=0.23"]
|
|
12
|
+
license = { file = "LICENSE" }
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest>=7.4", "pytest-asyncio>=0.23"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
yaaf = "yaaf.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["yaaf"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
yaaf = ["py.typed"]
|
|
34
|
+
|
|
35
|
+
[tool.mypy]
|
|
36
|
+
python_version = "3.13"
|
|
37
|
+
warn_return_any = true
|
|
38
|
+
warn_unused_configs = true
|
|
39
|
+
no_implicit_optional = true
|
|
40
|
+
check_untyped_defs = true
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from yaaf.app import App
|
|
8
|
+
from yaaf.gen_services import generate_services
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DummySend:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.messages: list[dict] = []
|
|
14
|
+
|
|
15
|
+
async def __call__(self, message: dict) -> None:
|
|
16
|
+
self.messages.append(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DummyReceive:
|
|
20
|
+
def __init__(self, body: bytes = b"") -> None:
|
|
21
|
+
self.body = body
|
|
22
|
+
self.sent = False
|
|
23
|
+
|
|
24
|
+
async def __call__(self) -> dict:
|
|
25
|
+
if self.sent:
|
|
26
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
27
|
+
self.sent = True
|
|
28
|
+
return {"type": "http.request", "body": self.body, "more_body": False}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_service(path: Path, code: str) -> None:
|
|
32
|
+
(path / "_service.py").write_text(code)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_server(path: Path, code: str) -> None:
|
|
36
|
+
(path / "_server.py").write_text(code)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_app_routes_and_params(tmp_path: Path) -> None:
|
|
41
|
+
base = tmp_path / "consumers" / "api"
|
|
42
|
+
hello = base / "hello"
|
|
43
|
+
dynamic = base / "[name]"
|
|
44
|
+
hello.mkdir(parents=True)
|
|
45
|
+
dynamic.mkdir(parents=True)
|
|
46
|
+
(tmp_path / "consumers" / "__init__.py").write_text("# package\n")
|
|
47
|
+
|
|
48
|
+
_write_service(
|
|
49
|
+
hello,
|
|
50
|
+
"class Service:\n"
|
|
51
|
+
" def message(self) -> str:\n"
|
|
52
|
+
" return 'hi'\n\n"
|
|
53
|
+
"service = Service()\n",
|
|
54
|
+
)
|
|
55
|
+
_write_server(
|
|
56
|
+
hello,
|
|
57
|
+
"from consumers.services import HelloService\n"
|
|
58
|
+
"from yaaf import Request\n\n"
|
|
59
|
+
"async def get(request: Request, service: HelloService):\n"
|
|
60
|
+
" return {'message': service.message(), 'path': request.path}\n",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_write_service(
|
|
64
|
+
dynamic,
|
|
65
|
+
"class Service:\n"
|
|
66
|
+
" def greet(self, name: str) -> str:\n"
|
|
67
|
+
" return f'hello {name}'\n\n"
|
|
68
|
+
"service = Service()\n",
|
|
69
|
+
)
|
|
70
|
+
_write_server(
|
|
71
|
+
dynamic,
|
|
72
|
+
"from consumers.services import NameService\n"
|
|
73
|
+
"from yaaf.types import Params\n\n"
|
|
74
|
+
"async def get(params: Params, service: NameService):\n"
|
|
75
|
+
" return {'message': service.greet(params['name'])}\n",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
generate_services(consumers_dir=str(tmp_path / "consumers"))
|
|
79
|
+
app = App(consumers_dir=str(tmp_path / "consumers"))
|
|
80
|
+
|
|
81
|
+
send = DummySend()
|
|
82
|
+
scope = {"type": "http", "method": "GET", "path": "/api/hello", "headers": []}
|
|
83
|
+
await app(scope, DummyReceive(), send)
|
|
84
|
+
assert send.messages[0]["status"] == 200
|
|
85
|
+
assert b"hi" in send.messages[1]["body"]
|
|
86
|
+
|
|
87
|
+
send = DummySend()
|
|
88
|
+
scope = {"type": "http", "method": "GET", "path": "/api/austin", "headers": []}
|
|
89
|
+
await app(scope, DummyReceive(), send)
|
|
90
|
+
assert send.messages[0]["status"] == 200
|
|
91
|
+
assert b"austin" in send.messages[1]["body"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_app_not_found(tmp_path: Path) -> None:
|
|
96
|
+
base = tmp_path / "consumers" / "api" / "hello"
|
|
97
|
+
base.mkdir(parents=True)
|
|
98
|
+
(tmp_path / "consumers" / "__init__.py").write_text("# package\n")
|
|
99
|
+
_write_service(base, "class Service:...\nservice = Service()\n")
|
|
100
|
+
_write_server(base, "async def get():...\n")
|
|
101
|
+
|
|
102
|
+
generate_services(consumers_dir=str(tmp_path / "consumers"))
|
|
103
|
+
app = App(consumers_dir=str(tmp_path / "consumers"))
|
|
104
|
+
|
|
105
|
+
send = DummySend()
|
|
106
|
+
scope = {"type": "http", "method": "GET", "path": "/api/missing", "headers": []}
|
|
107
|
+
await app(scope, DummyReceive(), send)
|
|
108
|
+
assert send.messages[0]["status"] == 404
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from yaaf.di import DependencyResolver, ServiceRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlphaService:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.value = "alpha"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_registry_resolves_by_type_and_name() -> None:
|
|
14
|
+
class AlphaAlias:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
registry = ServiceRegistry(by_type={}, by_alias={})
|
|
18
|
+
alpha = registry.register(AlphaService(), aliases=["AlphaAlias"])
|
|
19
|
+
|
|
20
|
+
assert registry.resolve(AlphaService) is alpha
|
|
21
|
+
assert registry.resolve(AlphaAlias) is alpha
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dependency_resolver_injects_context_and_services() -> None:
|
|
25
|
+
registry = ServiceRegistry(by_type={}, by_alias={})
|
|
26
|
+
alpha = registry.register(AlphaService(), aliases=[])
|
|
27
|
+
resolver = DependencyResolver(registry)
|
|
28
|
+
|
|
29
|
+
def handler(alpha: AlphaService, extra: str) -> tuple[str, str]:
|
|
30
|
+
return alpha.value, extra
|
|
31
|
+
|
|
32
|
+
result = resolver.call(handler, {"extra": "context"})
|
|
33
|
+
assert result == ("alpha", "context")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_dependency_resolver_errors_on_missing_dependency() -> None:
|
|
37
|
+
registry = ServiceRegistry(by_type={}, by_alias={})
|
|
38
|
+
resolver = DependencyResolver(registry)
|
|
39
|
+
|
|
40
|
+
def handler(missing: AlphaService) -> str:
|
|
41
|
+
return missing.value
|
|
42
|
+
|
|
43
|
+
with pytest.raises(TypeError):
|
|
44
|
+
resolver.call(handler, {})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from yaaf.loader import build_pattern, discover_routes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_build_pattern_static_and_dynamic() -> None:
|
|
11
|
+
pattern, params, static_count, segment_count = build_pattern(["users", "[id]"] , prefix="api")
|
|
12
|
+
assert params == ["id"]
|
|
13
|
+
assert static_count == 1
|
|
14
|
+
assert segment_count == 2
|
|
15
|
+
assert pattern.startswith("^/api/")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_discover_routes_missing_dir(tmp_path: Path) -> None:
|
|
19
|
+
routes, registry = discover_routes(str(tmp_path / "missing"))
|
|
20
|
+
assert routes == []
|
|
21
|
+
assert registry.by_type == {}
|
|
22
|
+
assert registry.by_alias == {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_discover_routes_dynamic_shadow_warning(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
26
|
+
base = tmp_path / "consumers" / "api"
|
|
27
|
+
hello = base / "hello"
|
|
28
|
+
dynamic = base / "[name]"
|
|
29
|
+
hello.mkdir(parents=True)
|
|
30
|
+
dynamic.mkdir(parents=True)
|
|
31
|
+
|
|
32
|
+
(hello / "_service.py").write_text("class Service:...\nservice = Service()\n")
|
|
33
|
+
(hello / "_server.py").write_text("async def get():...\n")
|
|
34
|
+
(dynamic / "_service.py").write_text("class Service:...\nservice = Service()\n")
|
|
35
|
+
(dynamic / "_server.py").write_text("async def get():...\n")
|
|
36
|
+
|
|
37
|
+
discover_routes(str(tmp_path / "consumers"))
|
|
38
|
+
captured = capsys.readouterr()
|
|
39
|
+
assert "dynamic route /api/[name] matches static route /api/hello" in captured.out
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from yaaf.responses import Response, as_response
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_as_response_string() -> None:
|
|
7
|
+
response = as_response("hello")
|
|
8
|
+
assert response.body == b"hello"
|
|
9
|
+
assert response.status == 200
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_as_response_bytes() -> None:
|
|
13
|
+
response = as_response(b"data")
|
|
14
|
+
assert response.body == b"data"
|
|
15
|
+
assert response.status == 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_as_response_dict_json() -> None:
|
|
19
|
+
response = as_response({"ok": True})
|
|
20
|
+
assert response.status == 200
|
|
21
|
+
assert b"\"ok\"" in response.body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_as_response_tuple_status() -> None:
|
|
25
|
+
response = as_response(("nope", 404))
|
|
26
|
+
assert response.status == 404
|
|
27
|
+
assert response.body == b"nope"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_response_with_status() -> None:
|
|
31
|
+
base = Response.text("hi")
|
|
32
|
+
updated = base.with_status(201)
|
|
33
|
+
assert updated.status == 201
|
|
34
|
+
assert updated.body == base.body
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""yaaf package exports with lazy loading to avoid circular imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = ["App", "Request", "Response", "app"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
if name in {"App", "Request", "app"}:
|
|
13
|
+
app_module = importlib.import_module(f"{__name__}.app")
|
|
14
|
+
return getattr(app_module, name)
|
|
15
|
+
if name == "Response":
|
|
16
|
+
from .responses import Response
|
|
17
|
+
|
|
18
|
+
return Response
|
|
19
|
+
raise AttributeError(f"module {__name__} has no attribute {name}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __dir__() -> list[str]:
|
|
23
|
+
return sorted(__all__)
|