toypy 0.1.0__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.
- toypy-0.1.0/LICENSE +21 -0
- toypy-0.1.0/MANIFEST.in +5 -0
- toypy-0.1.0/PKG-INFO +80 -0
- toypy-0.1.0/README.md +57 -0
- toypy-0.1.0/pyproject.toml +40 -0
- toypy-0.1.0/setup.cfg +4 -0
- toypy-0.1.0/src/toy/__init__.py +47 -0
- toypy-0.1.0/src/toy/app.py +347 -0
- toypy-0.1.0/src/toy/bootstrap.py +73 -0
- toypy-0.1.0/src/toy/cli.py +150 -0
- toypy-0.1.0/src/toy/concurrency.py +69 -0
- toypy-0.1.0/src/toy/datafile.py +186 -0
- toypy-0.1.0/src/toy/inject.py +103 -0
- toypy-0.1.0/src/toy/injected.py +53 -0
- toypy-0.1.0/src/toy/jwt_tools.py +146 -0
- toypy-0.1.0/src/toy/loader.py +241 -0
- toypy-0.1.0/src/toy/mongo.py +120 -0
- toypy-0.1.0/src/toy/registry.py +176 -0
- toypy-0.1.0/src/toy/reload_entry.py +42 -0
- toypy-0.1.0/src/toy/response.py +97 -0
- toypy-0.1.0/src/toy/routing_dsl.py +212 -0
- toypy-0.1.0/src/toy/scaffold/GUIDE.md +226 -0
- toypy-0.1.0/src/toy/scaffold/MONGO.md +170 -0
- toypy-0.1.0/src/toy/scaffold/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/_vscode/settings.json +17 -0
- toypy-0.1.0/src/toy/scaffold/_vscode/toy.code-snippets +51 -0
- toypy-0.1.0/src/toy/scaffold/controllers/usersController.py +4 -0
- toypy-0.1.0/src/toy/scaffold/globalVariables.data +7 -0
- toypy-0.1.0/src/toy/scaffold/helpers/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/helpers/example.py +2 -0
- toypy-0.1.0/src/toy/scaffold/main.py +51 -0
- toypy-0.1.0/src/toy/scaffold/middlewares/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/middlewares/example.py +4 -0
- toypy-0.1.0/src/toy/scaffold/models/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/models/example.py +9 -0
- toypy-0.1.0/src/toy/scaffold/pyrightconfig.json +8 -0
- toypy-0.1.0/src/toy/scaffold/routes/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/routes/api.py +10 -0
- toypy-0.1.0/src/toy/scaffold/routes/health.py +5 -0
- toypy-0.1.0/src/toy/scaffold/routes/users.py +11 -0
- toypy-0.1.0/src/toy/scaffold/services/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/services/example.py +4 -0
- toypy-0.1.0/src/toy/scaffold/settings.py +18 -0
- toypy-0.1.0/src/toy/scaffold/sockets/__init__.py +1 -0
- toypy-0.1.0/src/toy/scaffold/sockets/events.py +18 -0
- toypy-0.1.0/src/toy/scaffold/static/.gitkeep +1 -0
- toypy-0.1.0/src/toy/scaffold/typings/__builtins__.pyi +112 -0
- toypy-0.1.0/src/toy/scaffold/uploads/.gitkeep +1 -0
- toypy-0.1.0/src/toy/sockets.py +148 -0
- toypy-0.1.0/src/toy/useable.py +164 -0
- toypy-0.1.0/src/toypy.egg-info/PKG-INFO +80 -0
- toypy-0.1.0/src/toypy.egg-info/SOURCES.txt +64 -0
- toypy-0.1.0/src/toypy.egg-info/dependency_links.txt +1 -0
- toypy-0.1.0/src/toypy.egg-info/entry_points.txt +2 -0
- toypy-0.1.0/src/toypy.egg-info/requires.txt +11 -0
- toypy-0.1.0/src/toypy.egg-info/top_level.txt +1 -0
- toypy-0.1.0/tests/test_app.py +55 -0
- toypy-0.1.0/tests/test_cli.py +36 -0
- toypy-0.1.0/tests/test_concurrency.py +15 -0
- toypy-0.1.0/tests/test_datafile.py +83 -0
- toypy-0.1.0/tests/test_jwt.py +9 -0
- toypy-0.1.0/tests/test_reload.py +46 -0
- toypy-0.1.0/tests/test_response.py +27 -0
- toypy-0.1.0/tests/test_routing_dsl.py +110 -0
- toypy-0.1.0/tests/test_useable.py +102 -0
- toypy-0.1.0/tests/test_useable_loader.py +60 -0
toypy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Toy
|
|
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.
|
toypy-0.1.0/MANIFEST.in
ADDED
toypy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toypy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Toy — Express-like Python backend framework with MongoDB, JWT, and WebSockets
|
|
5
|
+
Author: Toy
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/toypy/
|
|
8
|
+
Keywords: toy,toypy,mongodb,express,jwt,websocket,backend
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
13
|
+
Requires-Dist: starlette>=0.38
|
|
14
|
+
Requires-Dist: motor>=3.5
|
|
15
|
+
Requires-Dist: pymongo>=4.8
|
|
16
|
+
Requires-Dist: PyJWT>=2.8
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Toy
|
|
25
|
+
|
|
26
|
+
Express-like Python backend for beginners — MongoDB, JWT, WebSockets, nested file routing.
|
|
27
|
+
|
|
28
|
+
Default port: **313**.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install toypy
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Create a project
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mkdir my-api
|
|
40
|
+
cd my-api
|
|
41
|
+
toy make
|
|
42
|
+
python main.py
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or: `toy run`
|
|
46
|
+
|
|
47
|
+
## Beginner style
|
|
48
|
+
|
|
49
|
+
Put this one line at the top of files you edit (`main.py`, routes, controllers):
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from toy.injected import * # framework wiring — you can ignore this line
|
|
53
|
+
|
|
54
|
+
db = connect_mongo()
|
|
55
|
+
|
|
56
|
+
use("/api", "routes/api.py")
|
|
57
|
+
|
|
58
|
+
@get("/health")
|
|
59
|
+
def health(req):
|
|
60
|
+
return makeResponse(200, data={"ok": True})
|
|
61
|
+
|
|
62
|
+
app.run(host=settings.HOST, port=settings.PORT, reload=settings.RELOAD)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
With `RELOAD = True` in `settings.py`, saving a file restarts the server automatically. Set `RELOAD = False` in production.
|
|
66
|
+
|
|
67
|
+
Nest routes up to **5 layers** with `use("/prefix", "file.py")`.
|
|
68
|
+
Attach controllers like Node: `get("/", usersController.list)`.
|
|
69
|
+
|
|
70
|
+
See **GUIDE.md** and **MONGO.md** after `toy make`.
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
toy make # scaffold project
|
|
76
|
+
toy make --force # overwrite scaffold files
|
|
77
|
+
toy run # run main.py with the loader
|
|
78
|
+
toy --help
|
|
79
|
+
toy --version
|
|
80
|
+
```
|
toypy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Toy
|
|
2
|
+
|
|
3
|
+
Express-like Python backend for beginners — MongoDB, JWT, WebSockets, nested file routing.
|
|
4
|
+
|
|
5
|
+
Default port: **313**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install toypy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Create a project
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mkdir my-api
|
|
17
|
+
cd my-api
|
|
18
|
+
toy make
|
|
19
|
+
python main.py
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or: `toy run`
|
|
23
|
+
|
|
24
|
+
## Beginner style
|
|
25
|
+
|
|
26
|
+
Put this one line at the top of files you edit (`main.py`, routes, controllers):
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from toy.injected import * # framework wiring — you can ignore this line
|
|
30
|
+
|
|
31
|
+
db = connect_mongo()
|
|
32
|
+
|
|
33
|
+
use("/api", "routes/api.py")
|
|
34
|
+
|
|
35
|
+
@get("/health")
|
|
36
|
+
def health(req):
|
|
37
|
+
return makeResponse(200, data={"ok": True})
|
|
38
|
+
|
|
39
|
+
app.run(host=settings.HOST, port=settings.PORT, reload=settings.RELOAD)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With `RELOAD = True` in `settings.py`, saving a file restarts the server automatically. Set `RELOAD = False` in production.
|
|
43
|
+
|
|
44
|
+
Nest routes up to **5 layers** with `use("/prefix", "file.py")`.
|
|
45
|
+
Attach controllers like Node: `get("/", usersController.list)`.
|
|
46
|
+
|
|
47
|
+
See **GUIDE.md** and **MONGO.md** after `toy make`.
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
toy make # scaffold project
|
|
53
|
+
toy make --force # overwrite scaffold files
|
|
54
|
+
toy run # run main.py with the loader
|
|
55
|
+
toy --help
|
|
56
|
+
toy --version
|
|
57
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toypy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Toy — Express-like Python backend framework with MongoDB, JWT, and WebSockets"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Toy" }]
|
|
13
|
+
keywords = ["toy", "toypy", "mongodb", "express", "jwt", "websocket", "backend"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"uvicorn[standard]>=0.30",
|
|
16
|
+
"starlette>=0.38",
|
|
17
|
+
"motor>=3.5",
|
|
18
|
+
"pymongo>=4.8",
|
|
19
|
+
"PyJWT>=2.8",
|
|
20
|
+
"python-dotenv>=1.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["httpx>=0.27", "pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
toy = "toy.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://pypi.org/project/toypy/"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
toy = ["scaffold/*", "scaffold/**/*"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
testpaths = ["tests"]
|
toypy-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""toy — Express-like Python backend with MongoDB, JWT, and WebSockets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .app import App, BoneRequest
|
|
8
|
+
from .concurrency import map_pool, run_in_pool, run_tasks
|
|
9
|
+
from .jwt_tools import jwt, protect, sign, verify
|
|
10
|
+
from .loader import run_project
|
|
11
|
+
from .mongo import MongoDB, close_mongo, connect_mongo, get_db
|
|
12
|
+
from .response import BoneResponse, makeResponse
|
|
13
|
+
from .routing_dsl import MAX_MOUNT_DEPTH, delete, get, patch, post, put, use
|
|
14
|
+
from .sockets import Socket, SocketManager, sockets
|
|
15
|
+
from .useable import useable, useable_InFolder
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"App",
|
|
19
|
+
"BoneRequest",
|
|
20
|
+
"BoneResponse",
|
|
21
|
+
"MAX_MOUNT_DEPTH",
|
|
22
|
+
"MongoDB",
|
|
23
|
+
"Socket",
|
|
24
|
+
"SocketManager",
|
|
25
|
+
"close_mongo",
|
|
26
|
+
"connect_mongo",
|
|
27
|
+
"delete",
|
|
28
|
+
"get",
|
|
29
|
+
"get_db",
|
|
30
|
+
"jwt",
|
|
31
|
+
"makeResponse",
|
|
32
|
+
"map_pool",
|
|
33
|
+
"patch",
|
|
34
|
+
"post",
|
|
35
|
+
"protect",
|
|
36
|
+
"put",
|
|
37
|
+
"run_in_pool",
|
|
38
|
+
"run_project",
|
|
39
|
+
"run_tasks",
|
|
40
|
+
"sign",
|
|
41
|
+
"sockets",
|
|
42
|
+
"use",
|
|
43
|
+
"useable",
|
|
44
|
+
"useable_InFolder",
|
|
45
|
+
"verify",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""toy App — Express-like HTTP server on Starlette/Uvicorn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.routing import Route, WebSocketRoute
|
|
15
|
+
from starlette.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from .response import as_http_response, makeResponse
|
|
18
|
+
|
|
19
|
+
Handler = Callable[..., Any]
|
|
20
|
+
Middleware = Callable[..., Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BoneRequest:
|
|
24
|
+
"""Thin wrapper around Starlette Request with Express-like helpers."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, request: Request):
|
|
27
|
+
self._request = request
|
|
28
|
+
self.user: Any = None
|
|
29
|
+
self.state: Dict[str, Any] = {}
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def method(self) -> str:
|
|
33
|
+
return self._request.method
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def url(self) -> Any:
|
|
37
|
+
return self._request.url
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def path(self) -> str:
|
|
41
|
+
return self._request.url.path
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def headers(self) -> Any:
|
|
45
|
+
return self._request.headers
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def query(self) -> Dict[str, str]:
|
|
49
|
+
return dict(self._request.query_params)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def params(self) -> Dict[str, Any]:
|
|
53
|
+
return dict(self._request.path_params)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def path_params(self) -> Dict[str, Any]:
|
|
57
|
+
return dict(self._request.path_params)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def cookies(self) -> Dict[str, str]:
|
|
61
|
+
return dict(self._request.cookies)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def client(self) -> Any:
|
|
65
|
+
return self._request.client
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def raw(self) -> Request:
|
|
69
|
+
return self._request
|
|
70
|
+
|
|
71
|
+
async def json(self) -> Any:
|
|
72
|
+
try:
|
|
73
|
+
return await self._request.json()
|
|
74
|
+
except Exception:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
async def body(self) -> bytes:
|
|
78
|
+
return await self._request.body()
|
|
79
|
+
|
|
80
|
+
async def form(self) -> Any:
|
|
81
|
+
return await self._request.form()
|
|
82
|
+
|
|
83
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
84
|
+
return self.query.get(key, self.params.get(key, default))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class App:
|
|
88
|
+
"""
|
|
89
|
+
toy application.
|
|
90
|
+
|
|
91
|
+
Example
|
|
92
|
+
-------
|
|
93
|
+
app = App()
|
|
94
|
+
|
|
95
|
+
@app.get(\"/health\")
|
|
96
|
+
async def health(req):
|
|
97
|
+
return makeResponse(200, data={\"ok\": True})
|
|
98
|
+
|
|
99
|
+
app.run() # port 313
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, title: str = "toy"):
|
|
103
|
+
self.title = title
|
|
104
|
+
self._routes: List[Tuple[str, str, Handler, List[Middleware]]] = []
|
|
105
|
+
self._middlewares: List[Middleware] = []
|
|
106
|
+
self._startup: List[Callable] = []
|
|
107
|
+
self._shutdown: List[Callable] = []
|
|
108
|
+
self._socket_manager = None
|
|
109
|
+
self._static_mounts: List[Tuple[str, str]] = []
|
|
110
|
+
self._starlette: Optional[Starlette] = None
|
|
111
|
+
|
|
112
|
+
def use(self, middleware: Middleware) -> "App":
|
|
113
|
+
"""Register global middleware: ``async def mw(req, next): ...``."""
|
|
114
|
+
self._middlewares.append(middleware)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def on_startup(self, fn: Callable) -> Callable:
|
|
118
|
+
self._startup.append(fn)
|
|
119
|
+
return fn
|
|
120
|
+
|
|
121
|
+
def on_shutdown(self, fn: Callable) -> Callable:
|
|
122
|
+
self._shutdown.append(fn)
|
|
123
|
+
return fn
|
|
124
|
+
|
|
125
|
+
def static(self, url_path: str, directory: str) -> "App":
|
|
126
|
+
self._static_mounts.append((url_path, directory))
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def _add_route(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
handler: Optional[Handler] = None,
|
|
134
|
+
middlewares: Optional[Sequence[Middleware]] = None,
|
|
135
|
+
):
|
|
136
|
+
mws = list(middlewares or [])
|
|
137
|
+
|
|
138
|
+
def decorator(fn: Handler) -> Handler:
|
|
139
|
+
self._routes.append((method.upper(), path, fn, mws))
|
|
140
|
+
return fn
|
|
141
|
+
|
|
142
|
+
if handler is not None:
|
|
143
|
+
return decorator(handler)
|
|
144
|
+
return decorator
|
|
145
|
+
|
|
146
|
+
def get(self, path: str, *middlewares: Middleware):
|
|
147
|
+
return self._add_route("GET", path, middlewares=middlewares)
|
|
148
|
+
|
|
149
|
+
def post(self, path: str, *middlewares: Middleware):
|
|
150
|
+
return self._add_route("POST", path, middlewares=middlewares)
|
|
151
|
+
|
|
152
|
+
def put(self, path: str, *middlewares: Middleware):
|
|
153
|
+
return self._add_route("PUT", path, middlewares=middlewares)
|
|
154
|
+
|
|
155
|
+
def patch(self, path: str, *middlewares: Middleware):
|
|
156
|
+
return self._add_route("PATCH", path, middlewares=middlewares)
|
|
157
|
+
|
|
158
|
+
def delete(self, path: str, *middlewares: Middleware):
|
|
159
|
+
return self._add_route("DELETE", path, middlewares=middlewares)
|
|
160
|
+
|
|
161
|
+
def route(self, path: str, methods: Sequence[str] = ("GET",), *middlewares: Middleware):
|
|
162
|
+
def decorator(fn: Handler) -> Handler:
|
|
163
|
+
for method in methods:
|
|
164
|
+
self._add_route(method, path, fn, middlewares)
|
|
165
|
+
return fn
|
|
166
|
+
|
|
167
|
+
return decorator
|
|
168
|
+
|
|
169
|
+
def _build_handler(self, handler: Handler, route_mws: List[Middleware]) -> Callable:
|
|
170
|
+
chain = list(self._middlewares) + list(route_mws)
|
|
171
|
+
|
|
172
|
+
async def endpoint(request: Request):
|
|
173
|
+
req = BoneRequest(request)
|
|
174
|
+
|
|
175
|
+
async def call_handler(r: BoneRequest):
|
|
176
|
+
result = handler(r)
|
|
177
|
+
if inspect.isawaitable(result):
|
|
178
|
+
result = await result
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
async def invoke(index: int, r: BoneRequest):
|
|
182
|
+
if index >= len(chain):
|
|
183
|
+
return await call_handler(r)
|
|
184
|
+
|
|
185
|
+
mw = chain[index]
|
|
186
|
+
|
|
187
|
+
async def next_handler(next_req: BoneRequest = r):
|
|
188
|
+
return await invoke(index + 1, next_req)
|
|
189
|
+
|
|
190
|
+
result = mw(r, next_handler)
|
|
191
|
+
if inspect.isawaitable(result):
|
|
192
|
+
result = await result
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
value = await invoke(0, req)
|
|
197
|
+
return as_http_response(value)
|
|
198
|
+
except Exception as err:
|
|
199
|
+
return makeResponse(500, error=str(err)).to_starlette()
|
|
200
|
+
|
|
201
|
+
return endpoint
|
|
202
|
+
|
|
203
|
+
def asgi(self) -> Starlette:
|
|
204
|
+
if self._starlette is not None:
|
|
205
|
+
return self._starlette
|
|
206
|
+
|
|
207
|
+
routes = []
|
|
208
|
+
# Group by path for multiple methods
|
|
209
|
+
by_path: Dict[str, Dict[str, Tuple[Handler, List[Middleware]]]] = {}
|
|
210
|
+
for method, path, handler, mws in self._routes:
|
|
211
|
+
by_path.setdefault(path, {})[method] = (handler, mws)
|
|
212
|
+
|
|
213
|
+
for path, methods_map in by_path.items():
|
|
214
|
+
# Starlette Route accepts one endpoint; dispatch by method inside
|
|
215
|
+
async def dispatcher(request: Request, _map=methods_map):
|
|
216
|
+
method = request.method.upper()
|
|
217
|
+
if method == "HEAD" and "GET" in _map:
|
|
218
|
+
method = "GET"
|
|
219
|
+
if method not in _map:
|
|
220
|
+
return makeResponse(405, error=f"Method {request.method} not allowed").to_starlette()
|
|
221
|
+
handler, mws = _map[method]
|
|
222
|
+
endpoint = self._build_handler(handler, mws)
|
|
223
|
+
return await endpoint(request)
|
|
224
|
+
|
|
225
|
+
routes.append(Route(path, dispatcher, methods=list(methods_map.keys()) + (["HEAD"] if "GET" in methods_map else [])))
|
|
226
|
+
|
|
227
|
+
if self._socket_manager is not None:
|
|
228
|
+
routes.append(WebSocketRoute("/ws", self._socket_manager.endpoint))
|
|
229
|
+
routes.append(WebSocketRoute("/socket", self._socket_manager.endpoint))
|
|
230
|
+
|
|
231
|
+
@asynccontextmanager
|
|
232
|
+
async def lifespan(_app):
|
|
233
|
+
for fn in self._startup:
|
|
234
|
+
result = fn()
|
|
235
|
+
if inspect.isawaitable(result):
|
|
236
|
+
await result
|
|
237
|
+
yield
|
|
238
|
+
for fn in self._shutdown:
|
|
239
|
+
result = fn()
|
|
240
|
+
if inspect.isawaitable(result):
|
|
241
|
+
await result
|
|
242
|
+
|
|
243
|
+
app = Starlette(routes=routes, lifespan=lifespan)
|
|
244
|
+
app.add_middleware(
|
|
245
|
+
CORSMiddleware,
|
|
246
|
+
allow_origins=["*"],
|
|
247
|
+
allow_credentials=True,
|
|
248
|
+
allow_methods=["*"],
|
|
249
|
+
allow_headers=["*"],
|
|
250
|
+
)
|
|
251
|
+
for url_path, directory in self._static_mounts:
|
|
252
|
+
if os.path.isdir(directory):
|
|
253
|
+
app.mount(url_path, StaticFiles(directory=directory), name=url_path.strip("/").replace("/", "_") or "static")
|
|
254
|
+
|
|
255
|
+
self._starlette = app
|
|
256
|
+
return app
|
|
257
|
+
|
|
258
|
+
def run(
|
|
259
|
+
self,
|
|
260
|
+
host: Optional[str] = None,
|
|
261
|
+
port: Optional[int] = None,
|
|
262
|
+
reload: Optional[bool] = None,
|
|
263
|
+
**uvicorn_kwargs: Any,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Start the server.
|
|
267
|
+
|
|
268
|
+
Default port is 313 (override with PORT env/settings or port=).
|
|
269
|
+
When ``reload`` is True, Uvicorn watches the project and restarts on file changes.
|
|
270
|
+
"""
|
|
271
|
+
import inspect
|
|
272
|
+
import uvicorn
|
|
273
|
+
|
|
274
|
+
# Allow settings.RELOAD / env when arg omitted
|
|
275
|
+
if reload is None:
|
|
276
|
+
env_reload = os.environ.get("RELOAD") or os.environ.get("TOY_RELOAD")
|
|
277
|
+
if env_reload is not None and env_reload != "":
|
|
278
|
+
reload = str(env_reload).strip().lower() in {"1", "true", "yes", "on"}
|
|
279
|
+
else:
|
|
280
|
+
reload = _setting_bool("RELOAD", False)
|
|
281
|
+
|
|
282
|
+
# Skip starting when reload factory is loading main.py
|
|
283
|
+
if os.environ.get("TOY_SKIP_RUN") == "1":
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
resolved_host = host or os.environ.get("HOST") or _setting("HOST", "0.0.0.0")
|
|
287
|
+
if port is None:
|
|
288
|
+
port_val = os.environ.get("PORT") or _setting("PORT", "313")
|
|
289
|
+
port = int(port_val)
|
|
290
|
+
|
|
291
|
+
if not reload:
|
|
292
|
+
uvicorn.run(self.asgi(), host=resolved_host, port=port, reload=False, **uvicorn_kwargs)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# Resolve project root from caller main.py
|
|
296
|
+
project_root = Path.cwd()
|
|
297
|
+
main_name = "main.py"
|
|
298
|
+
frame = inspect.currentframe()
|
|
299
|
+
try:
|
|
300
|
+
if frame and frame.f_back:
|
|
301
|
+
caller_file = frame.f_back.f_globals.get("__file__")
|
|
302
|
+
if caller_file:
|
|
303
|
+
caller_path = Path(str(caller_file)).resolve()
|
|
304
|
+
project_root = caller_path.parent
|
|
305
|
+
main_name = caller_path.name
|
|
306
|
+
finally:
|
|
307
|
+
del frame
|
|
308
|
+
|
|
309
|
+
os.environ["TOY_PROJECT"] = str(project_root)
|
|
310
|
+
os.environ["TOY_MAIN"] = main_name
|
|
311
|
+
|
|
312
|
+
uvicorn.run(
|
|
313
|
+
"toy.reload_entry:create_app",
|
|
314
|
+
factory=True,
|
|
315
|
+
host=resolved_host,
|
|
316
|
+
port=port,
|
|
317
|
+
reload=True,
|
|
318
|
+
reload_dirs=[str(project_root)],
|
|
319
|
+
**uvicorn_kwargs,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _setting(name: str, default: str) -> str:
|
|
324
|
+
try:
|
|
325
|
+
import settings as user_settings # type: ignore
|
|
326
|
+
|
|
327
|
+
value = getattr(user_settings, name, None)
|
|
328
|
+
if value is not None and value != "":
|
|
329
|
+
return str(value)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
return default
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _setting_bool(name: str, default: bool) -> bool:
|
|
336
|
+
raw = _setting(name, "")
|
|
337
|
+
if raw == "":
|
|
338
|
+
try:
|
|
339
|
+
import settings as user_settings # type: ignore
|
|
340
|
+
|
|
341
|
+
value = getattr(user_settings, name, None)
|
|
342
|
+
if isinstance(value, bool):
|
|
343
|
+
return value
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
return default
|
|
347
|
+
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap Toy project loading.
|
|
3
|
+
|
|
4
|
+
Scaffold ``main.py`` starts with:
|
|
5
|
+
|
|
6
|
+
from toy.injected import * # framework wiring — you can ignore this line
|
|
7
|
+
|
|
8
|
+
That import loads builtins for the editor and re-loads ``main.py`` under the
|
|
9
|
+
project loader at runtime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _caller_info() -> tuple[Path | None, str | None]:
|
|
20
|
+
frame = inspect.currentframe()
|
|
21
|
+
try:
|
|
22
|
+
while frame is not None:
|
|
23
|
+
pkg = frame.f_globals.get("__package__") or ""
|
|
24
|
+
# Skip frames inside the toy package itself
|
|
25
|
+
if pkg == "toy" or pkg.startswith("toy."):
|
|
26
|
+
frame = frame.f_back
|
|
27
|
+
continue
|
|
28
|
+
filename = frame.f_code.co_filename
|
|
29
|
+
norm = filename.replace("\\", "/")
|
|
30
|
+
if filename and "importlib" not in norm:
|
|
31
|
+
path = Path(filename).resolve()
|
|
32
|
+
if path.suffix == ".py":
|
|
33
|
+
name = frame.f_globals.get("__name__")
|
|
34
|
+
return path, name
|
|
35
|
+
frame = frame.f_back
|
|
36
|
+
return None, None
|
|
37
|
+
finally:
|
|
38
|
+
del frame
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bootstrap_from_import(*, exit_after: bool = True) -> None:
|
|
42
|
+
"""Re-exec the importing project file under the Toy loader."""
|
|
43
|
+
if getattr(sys, "_toy_in_loader", False):
|
|
44
|
+
return
|
|
45
|
+
if getattr(sys, "_toy_bootstrapped", False):
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
caller, mod_name = _caller_info()
|
|
49
|
+
if caller is None:
|
|
50
|
+
return
|
|
51
|
+
# Only auto-wire when the user runs the file (python main.py)
|
|
52
|
+
if mod_name != "__main__":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
sys._toy_bootstrapped = True # type: ignore[attr-defined]
|
|
56
|
+
sys._toy_in_loader = True # type: ignore[attr-defined]
|
|
57
|
+
try:
|
|
58
|
+
from .loader import ProjectLoader
|
|
59
|
+
from .routing_dsl import set_context
|
|
60
|
+
|
|
61
|
+
loader = ProjectLoader(caller.parent)
|
|
62
|
+
loader.bootstrap_scan()
|
|
63
|
+
set_context(loader.ctx)
|
|
64
|
+
loader.load_file(caller)
|
|
65
|
+
finally:
|
|
66
|
+
sys._toy_in_loader = False # type: ignore[attr-defined]
|
|
67
|
+
|
|
68
|
+
if exit_after:
|
|
69
|
+
raise SystemExit(0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Auto-run on import when appropriate
|
|
73
|
+
bootstrap_from_import()
|