ushka 0.0.2__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.
- ushka-0.0.2/LICENSE +21 -0
- ushka-0.0.2/PKG-INFO +125 -0
- ushka-0.0.2/README.md +113 -0
- ushka-0.0.2/pyproject.toml +34 -0
- ushka-0.0.2/src/ushka/__init__.py +3 -0
- ushka-0.0.2/src/ushka/application.py +69 -0
- ushka-0.0.2/src/ushka/http/request.py +70 -0
- ushka-0.0.2/src/ushka/http/response.py +18 -0
- ushka-0.0.2/src/ushka/routing/router.py +96 -0
ushka-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [year] [fullname]
|
|
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.
|
ushka-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: ushka
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Ushka is a minimal, experimental Python micro-framework for hobbyists and solo developers. Its goal is to enable rapid API development with **zero boilerplate** by using file-based routing.
|
|
5
|
+
Author-Email: kleber-code <kleber.code@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kleber-code/ushka
|
|
8
|
+
Project-URL: Issues, https://github.com/kleber-code/ushka/issues
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Requires-Dist: uvicorn>=0.38.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Ushka
|
|
14
|
+
|
|
15
|
+
Ushka is a minimal, experimental Python ASGI web framework based on file-based routing.
|
|
16
|
+
|
|
17
|
+
## ⚠️ Alpha Stage: Not Production Ready
|
|
18
|
+
|
|
19
|
+
This is an **early alpha release**. The project is in active, unstable development. The API may change at any time.
|
|
20
|
+
|
|
21
|
+
## Core Concept: File-Based Routing
|
|
22
|
+
|
|
23
|
+
The filesystem is the API. Ushka scans a `routes/` directory and maps the file and directory structure directly to URL paths.
|
|
24
|
+
|
|
25
|
+
* A file like `routes/hello.py` becomes the endpoint `/hello`.
|
|
26
|
+
* A file named `routes/index.py` becomes the root endpoint `/`.
|
|
27
|
+
* Nested directories become nested paths. `routes/api/v1/status.py` becomes `/api/v1/status`.
|
|
28
|
+
* Dynamic paths are created using square brackets in the filename. `routes/users/[id].py` becomes the endpoint `/users/[id]`.
|
|
29
|
+
* The HTTP method is determined by the function name inside the file (e.g., `get()`, `post()`).
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
This example uses **autodiscovery** to create a simple API.
|
|
36
|
+
|
|
37
|
+
### Project Structure
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
examples/
|
|
41
|
+
├── app.py
|
|
42
|
+
└── routes/
|
|
43
|
+
├── hello.py
|
|
44
|
+
├── index.py
|
|
45
|
+
└── hello/
|
|
46
|
+
└── [name].py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 1. The Route Handlers (`examples/routes/`)
|
|
50
|
+
|
|
51
|
+
These files contain the functions that will handle requests.
|
|
52
|
+
|
|
53
|
+
**`examples/routes/index.py`** (handles `GET /`)
|
|
54
|
+
```python
|
|
55
|
+
def get():
|
|
56
|
+
return "Welcome to the autodiscovered example!"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**`examples/routes/hello.py`** (handles `GET /hello`)
|
|
60
|
+
```python
|
|
61
|
+
def get():
|
|
62
|
+
return "Hello, World!"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**`examples/routes/hello/[name].py`** (handles `GET /hello/[name]`)
|
|
66
|
+
```python
|
|
67
|
+
def get(name: str):
|
|
68
|
+
return f"Hello, {name}!"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 2. The Main Application (`examples/app.py`)
|
|
72
|
+
|
|
73
|
+
This file creates the Ushka application and tells it to automatically discover the routes.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from ushka import Ushka
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
app = Ushka()
|
|
80
|
+
|
|
81
|
+
# Autodiscover routes from the 'routes' directory
|
|
82
|
+
routes_path = Path(__file__).parent.joinpath("routes")
|
|
83
|
+
app.router.autodiscover(base_path=routes_path)
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
print("Starting Ushka server on http://127.0.0.1:8000")
|
|
87
|
+
app.run("127.0.0.1", 8000)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### How to Run the Example
|
|
91
|
+
|
|
92
|
+
1. **Navigate to the project root.**
|
|
93
|
+
2. **Run the application:**
|
|
94
|
+
```bash
|
|
95
|
+
python examples/app.py
|
|
96
|
+
```
|
|
97
|
+
3. **Test the routes in your browser or with `curl`:**
|
|
98
|
+
* `curl http://127.0.0.1:8000/`
|
|
99
|
+
* `curl http://127.0.0.1:8000/hello`
|
|
100
|
+
* `curl http://127.0.0.1:8000/hello/Developer`
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Manual Routing
|
|
105
|
+
|
|
106
|
+
For more complex scenarios, you can add routes manually. This is useful if you prefer not to use file-based routing.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from ushka import Ushka
|
|
110
|
+
|
|
111
|
+
app = Ushka()
|
|
112
|
+
|
|
113
|
+
def my_handler():
|
|
114
|
+
return "This was added manually."
|
|
115
|
+
|
|
116
|
+
app.router.add_route("GET", "/manual", my_handler)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Roadmap
|
|
120
|
+
|
|
121
|
+
1. **Stabilize and Enhance the Router:** Improve the routing system and its features.
|
|
122
|
+
2. **Request/Response Objects:** Implement robust `Request` and `Response` objects.
|
|
123
|
+
3. **Middleware:** Add support for middleware.
|
|
124
|
+
4. **CLI:** Create a simple CLI for project scaffolding.
|
|
125
|
+
5. **Dependency Injection:** Explore a simple DI system.
|
ushka-0.0.2/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Ushka
|
|
2
|
+
|
|
3
|
+
Ushka is a minimal, experimental Python ASGI web framework based on file-based routing.
|
|
4
|
+
|
|
5
|
+
## ⚠️ Alpha Stage: Not Production Ready
|
|
6
|
+
|
|
7
|
+
This is an **early alpha release**. The project is in active, unstable development. The API may change at any time.
|
|
8
|
+
|
|
9
|
+
## Core Concept: File-Based Routing
|
|
10
|
+
|
|
11
|
+
The filesystem is the API. Ushka scans a `routes/` directory and maps the file and directory structure directly to URL paths.
|
|
12
|
+
|
|
13
|
+
* A file like `routes/hello.py` becomes the endpoint `/hello`.
|
|
14
|
+
* A file named `routes/index.py` becomes the root endpoint `/`.
|
|
15
|
+
* Nested directories become nested paths. `routes/api/v1/status.py` becomes `/api/v1/status`.
|
|
16
|
+
* Dynamic paths are created using square brackets in the filename. `routes/users/[id].py` becomes the endpoint `/users/[id]`.
|
|
17
|
+
* The HTTP method is determined by the function name inside the file (e.g., `get()`, `post()`).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
This example uses **autodiscovery** to create a simple API.
|
|
24
|
+
|
|
25
|
+
### Project Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
examples/
|
|
29
|
+
├── app.py
|
|
30
|
+
└── routes/
|
|
31
|
+
├── hello.py
|
|
32
|
+
├── index.py
|
|
33
|
+
└── hello/
|
|
34
|
+
└── [name].py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 1. The Route Handlers (`examples/routes/`)
|
|
38
|
+
|
|
39
|
+
These files contain the functions that will handle requests.
|
|
40
|
+
|
|
41
|
+
**`examples/routes/index.py`** (handles `GET /`)
|
|
42
|
+
```python
|
|
43
|
+
def get():
|
|
44
|
+
return "Welcome to the autodiscovered example!"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**`examples/routes/hello.py`** (handles `GET /hello`)
|
|
48
|
+
```python
|
|
49
|
+
def get():
|
|
50
|
+
return "Hello, World!"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**`examples/routes/hello/[name].py`** (handles `GET /hello/[name]`)
|
|
54
|
+
```python
|
|
55
|
+
def get(name: str):
|
|
56
|
+
return f"Hello, {name}!"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. The Main Application (`examples/app.py`)
|
|
60
|
+
|
|
61
|
+
This file creates the Ushka application and tells it to automatically discover the routes.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from ushka import Ushka
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
|
|
67
|
+
app = Ushka()
|
|
68
|
+
|
|
69
|
+
# Autodiscover routes from the 'routes' directory
|
|
70
|
+
routes_path = Path(__file__).parent.joinpath("routes")
|
|
71
|
+
app.router.autodiscover(base_path=routes_path)
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
print("Starting Ushka server on http://127.0.0.1:8000")
|
|
75
|
+
app.run("127.0.0.1", 8000)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### How to Run the Example
|
|
79
|
+
|
|
80
|
+
1. **Navigate to the project root.**
|
|
81
|
+
2. **Run the application:**
|
|
82
|
+
```bash
|
|
83
|
+
python examples/app.py
|
|
84
|
+
```
|
|
85
|
+
3. **Test the routes in your browser or with `curl`:**
|
|
86
|
+
* `curl http://127.0.0.1:8000/`
|
|
87
|
+
* `curl http://127.0.0.1:8000/hello`
|
|
88
|
+
* `curl http://127.0.0.1:8000/hello/Developer`
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Manual Routing
|
|
93
|
+
|
|
94
|
+
For more complex scenarios, you can add routes manually. This is useful if you prefer not to use file-based routing.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from ushka import Ushka
|
|
98
|
+
|
|
99
|
+
app = Ushka()
|
|
100
|
+
|
|
101
|
+
def my_handler():
|
|
102
|
+
return "This was added manually."
|
|
103
|
+
|
|
104
|
+
app.router.add_route("GET", "/manual", my_handler)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Roadmap
|
|
108
|
+
|
|
109
|
+
1. **Stabilize and Enhance the Router:** Improve the routing system and its features.
|
|
110
|
+
2. **Request/Response Objects:** Implement robust `Request` and `Response` objects.
|
|
111
|
+
3. **Middleware:** Add support for middleware.
|
|
112
|
+
4. **CLI:** Create a simple CLI for project scaffolding.
|
|
113
|
+
5. **Dependency Injection:** Explore a simple DI system.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ushka"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Ushka is a minimal, experimental Python micro-framework for hobbyists and solo developers. Its goal is to enable rapid API development with **zero boilerplate** by using file-based routing."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "kleber-code", email = "kleber.code@gmail.com" },
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"uvicorn>=0.38.0",
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
|
|
14
|
+
[project.license]
|
|
15
|
+
text = "MIT"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/kleber-code/ushka"
|
|
19
|
+
Issues = "https://github.com/kleber-code/ushka/issues"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = [
|
|
23
|
+
"pdm-backend",
|
|
24
|
+
]
|
|
25
|
+
build-backend = "pdm.backend"
|
|
26
|
+
|
|
27
|
+
[tool.pdm]
|
|
28
|
+
distribution = true
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"ruff>=0.14.5",
|
|
33
|
+
"twine>=6.2.0",
|
|
34
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from .http.request import Request
|
|
2
|
+
from .http.response import Response
|
|
3
|
+
from .routing.router import Router
|
|
4
|
+
import uvicorn
|
|
5
|
+
from asyncio import iscoroutinefunction
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Ushka:
|
|
10
|
+
def __init__(self, app_path=None) -> None:
|
|
11
|
+
# Get the path of the file that instantiated Ushka
|
|
12
|
+
# stack()[1] refers to the caller's frame
|
|
13
|
+
# .f_globals['__file__'] gets the __file__ attribute from that frame
|
|
14
|
+
if app_path is None:
|
|
15
|
+
app_path = inspect.stack()[1].frame.f_globals["__file__"]
|
|
16
|
+
|
|
17
|
+
self.app_path = app_path
|
|
18
|
+
self.router = Router(app_path)
|
|
19
|
+
self.router.autodiscover()
|
|
20
|
+
|
|
21
|
+
async def handle_http_request(self, scope, receive, send):
|
|
22
|
+
request = Request(scope, receive)
|
|
23
|
+
|
|
24
|
+
func, params = self.router.get_route(request)
|
|
25
|
+
|
|
26
|
+
if callable(func):
|
|
27
|
+
if iscoroutinefunction(func):
|
|
28
|
+
result = await func(**params)
|
|
29
|
+
else:
|
|
30
|
+
result = func(**params)
|
|
31
|
+
|
|
32
|
+
# check result type to auto detect media type (example html, text, json)
|
|
33
|
+
|
|
34
|
+
if "<html>" in result:
|
|
35
|
+
response = Response(result, media_type="text/html")
|
|
36
|
+
else:
|
|
37
|
+
response = Response(result)
|
|
38
|
+
else:
|
|
39
|
+
response = Response("Not Found", 404)
|
|
40
|
+
|
|
41
|
+
await response(send)
|
|
42
|
+
|
|
43
|
+
async def handle_lifespan(self, receive, send):
|
|
44
|
+
while True:
|
|
45
|
+
message = await receive()
|
|
46
|
+
if message["type"] == "lifespan.startup":
|
|
47
|
+
await send({"type": "lifespan.startup.complete"})
|
|
48
|
+
elif message["type"] == "lifespan.shutdown":
|
|
49
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
async def handle_asgi_call(self, scope, receive, send):
|
|
53
|
+
if scope["type"] == "http":
|
|
54
|
+
await self.handle_http_request(scope, receive, send)
|
|
55
|
+
|
|
56
|
+
elif scope["type"] == "lifespan":
|
|
57
|
+
await self.handle_lifespan(receive, send)
|
|
58
|
+
|
|
59
|
+
else:
|
|
60
|
+
response = Response("Not Supported", 501)
|
|
61
|
+
|
|
62
|
+
await response(send)
|
|
63
|
+
|
|
64
|
+
async def __call__(self, scope, receive, send):
|
|
65
|
+
await self.handle_asgi_call(scope, receive, send)
|
|
66
|
+
|
|
67
|
+
def run(self, host="127.0.0.1", port=8000):
|
|
68
|
+
print(f"Starting Ushka server on http://{host}:{port}")
|
|
69
|
+
uvicorn.run(self, host=host, port=port)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.parse import parse_qs
|
|
3
|
+
|
|
4
|
+
MAX_BODY = int(2.5 * 1024 * 1024)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Request:
|
|
8
|
+
def __init__(self, scope, receive):
|
|
9
|
+
self.scope = scope
|
|
10
|
+
self.method = str(scope["method"]).upper()
|
|
11
|
+
self.path = str(scope["path"])
|
|
12
|
+
self.headers = scope["headers"]
|
|
13
|
+
|
|
14
|
+
self._receive = receive
|
|
15
|
+
self._body = None
|
|
16
|
+
self._text = None
|
|
17
|
+
self._json = None
|
|
18
|
+
self._query = None
|
|
19
|
+
self._form = None
|
|
20
|
+
|
|
21
|
+
async def _load_body(self):
|
|
22
|
+
chunks = []
|
|
23
|
+
size = 0
|
|
24
|
+
|
|
25
|
+
while True:
|
|
26
|
+
# TODO Upload Multipart
|
|
27
|
+
msg = await self._receive()
|
|
28
|
+
chunk = msg.get("body", b"")
|
|
29
|
+
size += len(chunk)
|
|
30
|
+
if size > MAX_BODY:
|
|
31
|
+
raise ValueError("body too large")
|
|
32
|
+
|
|
33
|
+
chunks.append(chunk)
|
|
34
|
+
|
|
35
|
+
if not msg.get("more_body", False):
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
self._body = b"".join(chunks)
|
|
39
|
+
return self._body
|
|
40
|
+
|
|
41
|
+
async def body(self) -> bytes:
|
|
42
|
+
if self._body is None:
|
|
43
|
+
return await self._load_body()
|
|
44
|
+
return self._body
|
|
45
|
+
|
|
46
|
+
async def text(self) -> str:
|
|
47
|
+
if self._text is None:
|
|
48
|
+
self._text = (await self.body()).decode()
|
|
49
|
+
return self._text
|
|
50
|
+
|
|
51
|
+
async def json(self):
|
|
52
|
+
if self._json is None:
|
|
53
|
+
self._json = await json.loads(await self.body())
|
|
54
|
+
return self._json
|
|
55
|
+
|
|
56
|
+
async def query(self):
|
|
57
|
+
if self._query is None:
|
|
58
|
+
raw = self.scope.get("query_string", b"")
|
|
59
|
+
parsed = parse_qs(raw.decode())
|
|
60
|
+
|
|
61
|
+
self._query = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
62
|
+
return self._query
|
|
63
|
+
|
|
64
|
+
async def form(self):
|
|
65
|
+
# TODO Multipart form
|
|
66
|
+
if self._form is None:
|
|
67
|
+
body = await self.body()
|
|
68
|
+
parsed = parse_qs(body.decode())
|
|
69
|
+
self._form = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
70
|
+
return self._form
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Response:
|
|
2
|
+
def __init__(self, content, status_code=200, media_type="text/plain") -> None:
|
|
3
|
+
self.content = content
|
|
4
|
+
|
|
5
|
+
self.status_code = status_code
|
|
6
|
+
|
|
7
|
+
self.media_type = media_type
|
|
8
|
+
|
|
9
|
+
async def __call__(self, send):
|
|
10
|
+
await send(
|
|
11
|
+
{
|
|
12
|
+
"type": "http.response.start",
|
|
13
|
+
"status": self.status_code,
|
|
14
|
+
"headers": [[b"content-type", self.media_type.encode()]],
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
await send({"type": "http.response.body", "body": self.content.encode()})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, Callable, Tuple
|
|
3
|
+
|
|
4
|
+
from ..http.request import Request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def normalize_url_path(path: str) -> str:
|
|
8
|
+
parts = [p for p in path.split("/") if p and not p == "."]
|
|
9
|
+
return "/" + "/".join(parts)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Router:
|
|
13
|
+
def __init__(self, app_path: str):
|
|
14
|
+
self.app_path = app_path
|
|
15
|
+
self.static_routes: Dict[str, Dict[str, Callable]] = {}
|
|
16
|
+
self.dynamic_routes: Dict[
|
|
17
|
+
str, list[Tuple[re.Pattern, list[str], Callable]]
|
|
18
|
+
] = {}
|
|
19
|
+
|
|
20
|
+
def add_route(self, method: str, path: str, func: Callable):
|
|
21
|
+
path = normalize_url_path(path)
|
|
22
|
+
|
|
23
|
+
if "[" not in path:
|
|
24
|
+
self.static_routes.setdefault(method, {})[path] = func
|
|
25
|
+
|
|
26
|
+
else:
|
|
27
|
+
param_names = []
|
|
28
|
+
regex_pattern = "^"
|
|
29
|
+
for part in path.strip("/").split("/"):
|
|
30
|
+
if part.startswith("[") and part.endswith("]"):
|
|
31
|
+
param = part[1:-1]
|
|
32
|
+
param_names.append(param)
|
|
33
|
+
regex_pattern += r"/(?P<%s>[^/]+)" % param
|
|
34
|
+
else:
|
|
35
|
+
regex_pattern += "/" + part
|
|
36
|
+
|
|
37
|
+
regex_pattern += "$"
|
|
38
|
+
compiled = re.compile(regex_pattern)
|
|
39
|
+
self.dynamic_routes.setdefault(method, []).append(
|
|
40
|
+
(compiled, param_names, func)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def get_route(self, request: Request) -> Tuple[Callable, Dict] | Tuple[None, Dict]:
|
|
44
|
+
method = request.method
|
|
45
|
+
path = normalize_url_path(request.path)
|
|
46
|
+
|
|
47
|
+
func = self.static_routes.get(method, {}).get(path)
|
|
48
|
+
if func:
|
|
49
|
+
return func, {}
|
|
50
|
+
|
|
51
|
+
for regex, param_names, dynamic_func in self.dynamic_routes.get(method, []):
|
|
52
|
+
# TODO: Add Resolve Route to injects depends like func(request)
|
|
53
|
+
match = regex.match(path)
|
|
54
|
+
if match:
|
|
55
|
+
return dynamic_func, match.groupdict()
|
|
56
|
+
|
|
57
|
+
return None, {}
|
|
58
|
+
|
|
59
|
+
def autodiscover(self, folder="routes"):
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
import importlib.util
|
|
62
|
+
import inspect
|
|
63
|
+
|
|
64
|
+
base_path = Path(self.app_path).parent
|
|
65
|
+
target_path = base_path.joinpath(folder)
|
|
66
|
+
for file in target_path.rglob("*.py"):
|
|
67
|
+
if not file.name.startswith("__"):
|
|
68
|
+
spec = importlib.util.spec_from_file_location("mod", str(file))
|
|
69
|
+
if spec is None or spec.loader is None:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
mod = importlib.util.module_from_spec(spec)
|
|
73
|
+
spec.loader.exec_module(mod)
|
|
74
|
+
|
|
75
|
+
for name, obj in inspect.getmembers(mod):
|
|
76
|
+
if inspect.isfunction(obj):
|
|
77
|
+
# TODO: Add ANY method mode
|
|
78
|
+
if name.upper() in [
|
|
79
|
+
"GET",
|
|
80
|
+
"POST",
|
|
81
|
+
"PUT",
|
|
82
|
+
"UPDATE",
|
|
83
|
+
"DELETE",
|
|
84
|
+
"HEAD",
|
|
85
|
+
]:
|
|
86
|
+
# FIXME: Race condition caused by /foo/index.py and /foo.py It doesn't pose a danger, but it can cause overwriting and make a "bug" difficult to spot.
|
|
87
|
+
|
|
88
|
+
if file.name.lower() == "index.py":
|
|
89
|
+
relative_path = file.parent.relative_to(target_path)
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
relative_path = str(
|
|
93
|
+
file.relative_to(target_path)
|
|
94
|
+
).removesuffix(".py")
|
|
95
|
+
|
|
96
|
+
self.add_route(name.upper(), str(relative_path), obj)
|