ushka 0.0.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .application import Ushka
2
+ from .http.request import Request
3
+ from .http.response import Response
ushka/application.py ADDED
@@ -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)
ushka/http/request.py ADDED
@@ -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
ushka/http/response.py ADDED
@@ -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)
@@ -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.
@@ -0,0 +1,10 @@
1
+ ushka-0.0.2.dist-info/METADATA,sha256=QjeGhhfeNikMONbopDHTpnEreZEpK4pQ1lyCwFKqhRM,3614
2
+ ushka-0.0.2.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ ushka-0.0.2.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ ushka-0.0.2.dist-info/licenses/LICENSE,sha256=ACwmltkrXIz5VsEQcrqljq-fat6ZXAMepjXGoe40KtE,1069
5
+ ushka/__init__.py,sha256=4FLXs85EI3XcbNYOdntXr5Uqsp8LmDdqgmFFp0iZ5sk,101
6
+ ushka/application.py,sha256=M0LFVUhzH_kt6pFTilFZQ9cyPj73Brlqi5OvueH_y2s,2319
7
+ ushka/http/request.py,sha256=2wVyQwR_72339U0FUks_vNOlTRIFkgMkvZatxLyEOHk,1928
8
+ ushka/http/response.py,sha256=fTnYhyM5BE-3nokLZXFiGNzl0EI6UQzKJkKR0Tbu1Ck,551
9
+ ushka/routing/router.py,sha256=N3-uATTL7_zLgCHL7AQcS41lk0ie7YZjO4ZVQH1CXtQ,3549
10
+ ushka-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.6)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -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.