Nexom 0.1.3__py3-none-any.whl → 1.0.1__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.
- nexom/__init__.py +2 -2
- nexom/__main__.py +111 -17
- nexom/app/__init__.py +62 -0
- nexom/app/auth.py +322 -0
- nexom/{web → app}/cookie.py +4 -2
- nexom/app/db.py +88 -0
- nexom/app/path.py +195 -0
- nexom/app/request.py +267 -0
- nexom/{web → app}/response.py +13 -3
- nexom/{web → app}/template.py +1 -1
- nexom/app/user.py +31 -0
- nexom/assets/app/__init__.py +0 -0
- nexom/assets/app/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/app/config.py +28 -0
- nexom/assets/app/gunicorn.conf.py +5 -0
- nexom/assets/app/pages/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/app/pages/_templates.py +7 -0
- nexom/assets/{server → app}/pages/default.py +2 -2
- nexom/assets/{server → app}/pages/document.py +2 -2
- nexom/assets/app/router.py +12 -0
- nexom/assets/app/wsgi.py +64 -0
- nexom/assets/auth/__init__.py +0 -0
- nexom/assets/auth/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/auth/config.py +27 -0
- nexom/assets/auth/gunicorn.conf.py +5 -0
- nexom/assets/auth/wsgi.py +62 -0
- nexom/assets/auth_page/login.html +95 -0
- nexom/assets/auth_page/signup.html +106 -0
- nexom/assets/error_page/error.html +3 -3
- nexom/assets/gateway/apache_app.conf +16 -0
- nexom/assets/gateway/nginx_app.conf +21 -0
- nexom/buildTools/__init__.py +1 -0
- nexom/buildTools/build.py +274 -54
- nexom/buildTools/run.py +185 -0
- nexom/core/__init__.py +2 -1
- nexom/core/error.py +81 -3
- nexom/core/log.py +111 -0
- nexom/{engine → core}/object_html_render.py +4 -1
- nexom/templates/__init__.py +0 -0
- nexom/templates/auth.py +72 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/METADATA +75 -50
- nexom-1.0.1.dist-info/RECORD +56 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/WHEEL +1 -1
- nexom/assets/server/config.py +0 -27
- nexom/assets/server/gunicorn.conf.py +0 -16
- nexom/assets/server/pages/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/server/pages/_templates.py +0 -11
- nexom/assets/server/router.py +0 -18
- nexom/assets/server/wsgi.py +0 -30
- nexom/engine/__init__.py +0 -1
- nexom/web/__init__.py +0 -5
- nexom/web/path.py +0 -125
- nexom/web/request.py +0 -62
- nexom-0.1.3.dist-info/RECORD +0 -39
- /nexom/{web → app}/http_status_codes.py +0 -0
- /nexom/{web → app}/middleware.py +0 -0
- /nexom/assets/{server → app}/pages/__init__.py +0 -0
- /nexom/assets/{server → app}/static/dog.jpeg +0 -0
- /nexom/assets/{server → app}/static/style.css +0 -0
- /nexom/assets/{server → app}/templates/base.html +0 -0
- /nexom/assets/{server → app}/templates/default.html +0 -0
- /nexom/assets/{server → app}/templates/document.html +0 -0
- /nexom/assets/{server → app}/templates/footer.html +0 -0
- /nexom/assets/{server → app}/templates/header.html +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/entry_points.txt +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/top_level.txt +0 -0
nexom/app/path.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from mimetypes import guess_type
|
|
6
|
+
from pathlib import Path as _Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..core.error import (
|
|
10
|
+
PathNotFoundError,
|
|
11
|
+
PathlibTypeError,
|
|
12
|
+
PathInvalidHandlerTypeError,
|
|
13
|
+
PathHandlerMissingArgError,
|
|
14
|
+
)
|
|
15
|
+
from .request import Request
|
|
16
|
+
from .response import Response, JsonResponse
|
|
17
|
+
from .middleware import Middleware, MiddlewareChain, Handler
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ====================
|
|
21
|
+
# Path (base)
|
|
22
|
+
# ====================
|
|
23
|
+
|
|
24
|
+
class Path:
|
|
25
|
+
"""Represents a route with optional path arguments and its handler."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
path: str,
|
|
30
|
+
handler: Handler,
|
|
31
|
+
name: str,
|
|
32
|
+
*,
|
|
33
|
+
methods: set[str] | None = None, # None = any method
|
|
34
|
+
):
|
|
35
|
+
self.handler = handler
|
|
36
|
+
self.name: str = name
|
|
37
|
+
self.methods: set[str] | None = {m.upper() for m in methods} if methods else None
|
|
38
|
+
|
|
39
|
+
path_segments = path.strip("/").split("/") if path.strip("/") else [""]
|
|
40
|
+
self.path_args: dict[int, str] = {}
|
|
41
|
+
|
|
42
|
+
detection_index = 0
|
|
43
|
+
for idx, segment in enumerate(path_segments):
|
|
44
|
+
m = re.match(r"{(.*?)}", segment)
|
|
45
|
+
if m:
|
|
46
|
+
if detection_index == 0:
|
|
47
|
+
detection_index = idx
|
|
48
|
+
self.path_args[idx] = m.group(1)
|
|
49
|
+
|
|
50
|
+
if detection_index == 0:
|
|
51
|
+
detection_index = len(path_segments)
|
|
52
|
+
|
|
53
|
+
self.path: str = "/".join(path_segments[:detection_index])
|
|
54
|
+
self.detection_range: int = detection_index
|
|
55
|
+
|
|
56
|
+
def _read_args(self, request_path: str) -> dict[str, Optional[str]]:
|
|
57
|
+
"""Build args for this request. (No shared state)"""
|
|
58
|
+
args: dict[str, Optional[str]] = {}
|
|
59
|
+
segments = request_path.strip("/").split("/") if request_path.strip("/") else [""]
|
|
60
|
+
for idx, arg_name in self.path_args.items():
|
|
61
|
+
args[arg_name] = segments[idx] if idx < len(segments) else None
|
|
62
|
+
return args
|
|
63
|
+
|
|
64
|
+
def call_handler(
|
|
65
|
+
self,
|
|
66
|
+
request: Request,
|
|
67
|
+
middlewares: tuple[Middleware, ...] = (),
|
|
68
|
+
) -> Response:
|
|
69
|
+
try:
|
|
70
|
+
args = self._read_args(request.path)
|
|
71
|
+
|
|
72
|
+
handler = self.handler
|
|
73
|
+
if middlewares:
|
|
74
|
+
handler = MiddlewareChain(middlewares).wrap(handler)
|
|
75
|
+
|
|
76
|
+
res = handler(request, args)
|
|
77
|
+
|
|
78
|
+
if isinstance(res, dict):
|
|
79
|
+
return JsonResponse(res)
|
|
80
|
+
|
|
81
|
+
if not isinstance(res, Response):
|
|
82
|
+
raise PathInvalidHandlerTypeError(self.handler)
|
|
83
|
+
|
|
84
|
+
return res
|
|
85
|
+
|
|
86
|
+
except TypeError as e:
|
|
87
|
+
# handler の引数不足だけは明示的に
|
|
88
|
+
if re.search(r"takes \d+ positional arguments? but \d+ were given", str(e)):
|
|
89
|
+
raise PathHandlerMissingArgError()
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ====================
|
|
94
|
+
# Method specific paths
|
|
95
|
+
# ====================
|
|
96
|
+
|
|
97
|
+
class Get(Path):
|
|
98
|
+
def __init__(self, path: str, handler: Handler, name: str):
|
|
99
|
+
super().__init__(path, handler, name, methods={"GET"})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Post(Path):
|
|
103
|
+
def __init__(self, path: str, handler: Handler, name: str):
|
|
104
|
+
super().__init__(path, handler, name, methods={"POST"})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ====================
|
|
108
|
+
# Static files
|
|
109
|
+
# ====================
|
|
110
|
+
|
|
111
|
+
class Static(Path):
|
|
112
|
+
"""Represents a static file route."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, path: str, static_directory: str, name: str) -> None:
|
|
115
|
+
self._root = _Path(static_directory).resolve()
|
|
116
|
+
super().__init__(path, self._access, name)
|
|
117
|
+
|
|
118
|
+
def _access(self, request: Request, args: dict[str, Optional[str]]) -> Response:
|
|
119
|
+
segments = request.path.strip("/").split("/") if request.path.strip("/") else [""]
|
|
120
|
+
relative_parts = segments[self.detection_range :] if len(segments) > self.detection_range else []
|
|
121
|
+
rel = _Path(*relative_parts) if relative_parts else _Path("")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
target = (self._root / rel).resolve()
|
|
125
|
+
except Exception:
|
|
126
|
+
raise PathNotFoundError(request.path)
|
|
127
|
+
|
|
128
|
+
if not str(target).startswith(str(self._root) + os.sep) and target != self._root:
|
|
129
|
+
raise PathNotFoundError(request.path)
|
|
130
|
+
|
|
131
|
+
if target.is_dir():
|
|
132
|
+
target = (target / "index.html").resolve()
|
|
133
|
+
|
|
134
|
+
if not target.exists() or not target.is_file():
|
|
135
|
+
raise PathNotFoundError(request.path)
|
|
136
|
+
|
|
137
|
+
data = target.read_bytes()
|
|
138
|
+
mime_type, _ = guess_type(str(target))
|
|
139
|
+
|
|
140
|
+
headers = [
|
|
141
|
+
("Content-Type", mime_type or "application/octet-stream"),
|
|
142
|
+
("Content-Length", str(len(data))),
|
|
143
|
+
]
|
|
144
|
+
return Response(data, headers=headers)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ====================
|
|
148
|
+
# Pathlib
|
|
149
|
+
# ====================
|
|
150
|
+
|
|
151
|
+
class Pathlib(list[Path]):
|
|
152
|
+
"""Collection of Path objects with middleware support."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, *paths: Path) -> None:
|
|
155
|
+
for p in paths:
|
|
156
|
+
self._check(p)
|
|
157
|
+
super().__init__(paths)
|
|
158
|
+
|
|
159
|
+
self.raise_if_not_exist: bool = True
|
|
160
|
+
self.middlewares: list[Middleware] = []
|
|
161
|
+
|
|
162
|
+
def _check(self, arg: object) -> None:
|
|
163
|
+
if not isinstance(arg, Path):
|
|
164
|
+
raise PathlibTypeError
|
|
165
|
+
|
|
166
|
+
def add_middleware(self, *middlewares: Middleware) -> None:
|
|
167
|
+
self.middlewares.extend(middlewares)
|
|
168
|
+
|
|
169
|
+
def get(self, request_path: str, *, method: str | None = None) -> Path | None:
|
|
170
|
+
segments = request_path.rstrip("/").split("/")
|
|
171
|
+
method_u = method.upper() if method else None
|
|
172
|
+
|
|
173
|
+
fallback: Path | None = None
|
|
174
|
+
|
|
175
|
+
for p in self:
|
|
176
|
+
detection_path = "/".join(segments[: p.detection_range])
|
|
177
|
+
if detection_path != p.path:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Method-specific Path has priority
|
|
181
|
+
if method_u and p.methods is not None:
|
|
182
|
+
if method_u in p.methods:
|
|
183
|
+
return p
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Method-agnostic Path as fallback
|
|
187
|
+
if fallback is None:
|
|
188
|
+
fallback = p
|
|
189
|
+
|
|
190
|
+
if fallback is not None:
|
|
191
|
+
return fallback
|
|
192
|
+
|
|
193
|
+
if self.raise_if_not_exist:
|
|
194
|
+
raise PathNotFoundError(request_path)
|
|
195
|
+
return None
|
nexom/app/request.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
from http.cookies import SimpleCookie
|
|
5
|
+
from urllib.parse import parse_qs
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .cookie import RequestCookies
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
WSGIEnviron = Mapping[str, Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Request:
|
|
15
|
+
"""
|
|
16
|
+
Represents an HTTP request constructed from a WSGI environ.
|
|
17
|
+
|
|
18
|
+
Notes:
|
|
19
|
+
- headers keys are normalized to lower-case
|
|
20
|
+
- wsgi.input is readable only once; this class caches parsed body per request
|
|
21
|
+
- .json() / .form() use cached raw body (bytes)
|
|
22
|
+
- .files() parses multipart/form-data using python-multipart (external dependency)
|
|
23
|
+
and cannot be used together with .read_body()/.json()/.form() after reading the stream
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, environ: WSGIEnviron) -> None:
|
|
27
|
+
self.environ: WSGIEnviron = environ
|
|
28
|
+
|
|
29
|
+
self.method: str = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
|
30
|
+
self.path: str = str(environ.get("PATH_INFO", "")).lstrip("/")
|
|
31
|
+
self.query: dict[str, list[str]] = parse_qs(str(environ.get("QUERY_STRING", "")))
|
|
32
|
+
|
|
33
|
+
# normalize header keys to lower-case
|
|
34
|
+
self.headers: dict[str, str] = {
|
|
35
|
+
k[5:].replace("_", "-").lower(): v
|
|
36
|
+
for k, v in environ.items()
|
|
37
|
+
if k.startswith("HTTP_") and isinstance(v, str)
|
|
38
|
+
}
|
|
39
|
+
ct = environ.get("CONTENT_TYPE")
|
|
40
|
+
if isinstance(ct, str) and ct:
|
|
41
|
+
self.headers["content-type"] = ct
|
|
42
|
+
cl = environ.get("CONTENT_LENGTH")
|
|
43
|
+
if isinstance(cl, str) and cl:
|
|
44
|
+
self.headers["content-length"] = cl
|
|
45
|
+
|
|
46
|
+
self.cookie: RequestCookies | None = self._parse_cookies()
|
|
47
|
+
|
|
48
|
+
self._body: bytes | None = None
|
|
49
|
+
self._json_cache: Any | None = None
|
|
50
|
+
self._form_cache: dict[str, list[str]] | None = None
|
|
51
|
+
self._files_cache: dict[str, Any] | None = None
|
|
52
|
+
self._multipart_consumed: bool = False
|
|
53
|
+
|
|
54
|
+
# -------------------------
|
|
55
|
+
# basic helpers
|
|
56
|
+
# -------------------------
|
|
57
|
+
|
|
58
|
+
def _parse_cookies(self) -> RequestCookies | None:
|
|
59
|
+
cookie_header = self.environ.get("HTTP_COOKIE")
|
|
60
|
+
if not cookie_header:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
simple_cookie = SimpleCookie()
|
|
64
|
+
simple_cookie.load(cookie_header)
|
|
65
|
+
|
|
66
|
+
cookies = {key: morsel.value for key, morsel in simple_cookie.items()}
|
|
67
|
+
return RequestCookies(**cookies)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def content_type(self) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Lower-cased mime type without parameters (no charset/boundary).
|
|
73
|
+
Example:
|
|
74
|
+
"application/json; charset=utf-8" -> "application/json"
|
|
75
|
+
"""
|
|
76
|
+
return (self.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
|
|
77
|
+
|
|
78
|
+
def _content_length(self) -> int:
|
|
79
|
+
raw = self.environ.get("CONTENT_LENGTH")
|
|
80
|
+
try:
|
|
81
|
+
return int(raw) if raw else 0
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
# -------------------------
|
|
86
|
+
# body
|
|
87
|
+
# -------------------------
|
|
88
|
+
|
|
89
|
+
def read_body(self) -> bytes:
|
|
90
|
+
"""
|
|
91
|
+
Read and cache request body bytes.
|
|
92
|
+
|
|
93
|
+
WARNING:
|
|
94
|
+
- If multipart parsing (.files()) already consumed the stream, body will be empty.
|
|
95
|
+
"""
|
|
96
|
+
if self._body is not None:
|
|
97
|
+
return self._body
|
|
98
|
+
|
|
99
|
+
if self._multipart_consumed:
|
|
100
|
+
self._body = b""
|
|
101
|
+
return self._body
|
|
102
|
+
|
|
103
|
+
length = self._content_length()
|
|
104
|
+
if length <= 0:
|
|
105
|
+
self._body = b""
|
|
106
|
+
return self._body
|
|
107
|
+
|
|
108
|
+
self._body = self.environ["wsgi.input"].read(length)
|
|
109
|
+
return self._body
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def body(self) -> bytes:
|
|
113
|
+
return self.read_body()
|
|
114
|
+
|
|
115
|
+
# -------------------------
|
|
116
|
+
# POST parsers
|
|
117
|
+
# -------------------------
|
|
118
|
+
|
|
119
|
+
def json(self) -> Any | None:
|
|
120
|
+
"""
|
|
121
|
+
Parse application/json body.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Parsed JSON (dict/list/...) or None if not JSON or empty body.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
json.JSONDecodeError: If Content-Type is JSON but body is invalid.
|
|
128
|
+
"""
|
|
129
|
+
if self._json_cache is not None:
|
|
130
|
+
return self._json_cache
|
|
131
|
+
|
|
132
|
+
if self.content_type != "application/json":
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
raw = self.body
|
|
136
|
+
if not raw:
|
|
137
|
+
self._json_cache = None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
self._json_cache = json.loads(raw.decode("utf-8"))
|
|
141
|
+
return self._json_cache
|
|
142
|
+
|
|
143
|
+
def form(self) -> dict[str, list[str]] | None:
|
|
144
|
+
"""
|
|
145
|
+
Parse application/x-www-form-urlencoded body.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
dict[str, list[str]] or None if not urlencoded form.
|
|
149
|
+
"""
|
|
150
|
+
if self._form_cache is not None:
|
|
151
|
+
return self._form_cache
|
|
152
|
+
|
|
153
|
+
if self.content_type != "application/x-www-form-urlencoded":
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
raw = self.body
|
|
157
|
+
if not raw:
|
|
158
|
+
self._form_cache = {}
|
|
159
|
+
return self._form_cache
|
|
160
|
+
|
|
161
|
+
self._form_cache = parse_qs(raw.decode("utf-8"))
|
|
162
|
+
return self._form_cache
|
|
163
|
+
|
|
164
|
+
def files(self) -> dict[str, Any] | None:
|
|
165
|
+
"""
|
|
166
|
+
Parse multipart/form-data using python-multipart.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
dict[str, Any] mapping field name to either:
|
|
170
|
+
- str for normal form fields
|
|
171
|
+
- dict for file fields:
|
|
172
|
+
{
|
|
173
|
+
"filename": str,
|
|
174
|
+
"content_type": str | None,
|
|
175
|
+
"size": int | None,
|
|
176
|
+
"file": <file-like object or bytes depending on backend>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ModuleNotFoundError: if python-multipart is not installed.
|
|
181
|
+
ValueError: if Content-Type is multipart but parsing fails.
|
|
182
|
+
|
|
183
|
+
IMPORTANT:
|
|
184
|
+
multipart parsing consumes wsgi.input. Do not call .read_body()/.json()/.form()
|
|
185
|
+
after calling this method.
|
|
186
|
+
"""
|
|
187
|
+
if self._files_cache is not None:
|
|
188
|
+
return self._files_cache
|
|
189
|
+
|
|
190
|
+
if self.content_type != "multipart/form-data":
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# Lazy import (optional dependency)
|
|
194
|
+
# python-multipart package provides "multipart" module.
|
|
195
|
+
try:
|
|
196
|
+
from multipart import MultipartParser # type: ignore
|
|
197
|
+
except Exception as e:
|
|
198
|
+
raise ModuleNotFoundError(
|
|
199
|
+
"python-multipart is required for multipart/form-data parsing. "
|
|
200
|
+
"Install with: pip install python-multipart"
|
|
201
|
+
) from e
|
|
202
|
+
|
|
203
|
+
# Prevent mixing with body-based parsing
|
|
204
|
+
if self._body is not None and self._body != b"":
|
|
205
|
+
raise ValueError("Body was already read. multipart parsing must be done first.")
|
|
206
|
+
|
|
207
|
+
self._multipart_consumed = True
|
|
208
|
+
|
|
209
|
+
# Extract boundary from Content-Type header
|
|
210
|
+
ctype_full = self.headers.get("content-type", "")
|
|
211
|
+
boundary = None
|
|
212
|
+
for part in ctype_full.split(";")[1:]:
|
|
213
|
+
part = part.strip()
|
|
214
|
+
if part.startswith("boundary="):
|
|
215
|
+
boundary = part.split("=", 1)[1].strip().strip('"')
|
|
216
|
+
break
|
|
217
|
+
if not boundary:
|
|
218
|
+
raise ValueError("multipart/form-data boundary not found")
|
|
219
|
+
|
|
220
|
+
# Parse stream
|
|
221
|
+
stream = self.environ["wsgi.input"]
|
|
222
|
+
|
|
223
|
+
parser = MultipartParser(stream, boundary.encode("utf-8"))
|
|
224
|
+
|
|
225
|
+
out: dict[str, Any] = {}
|
|
226
|
+
|
|
227
|
+
# MultipartParser yields parts; API differs slightly by version.
|
|
228
|
+
# We handle common attributes: name, filename, headers, raw, file.
|
|
229
|
+
for p in parser: # type: ignore
|
|
230
|
+
name = getattr(p, "name", None)
|
|
231
|
+
if not name:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
filename = getattr(p, "filename", None)
|
|
235
|
+
if filename:
|
|
236
|
+
# file part
|
|
237
|
+
content_type = None
|
|
238
|
+
headers = getattr(p, "headers", None)
|
|
239
|
+
if isinstance(headers, dict):
|
|
240
|
+
# some versions use bytes keys/values
|
|
241
|
+
ct = headers.get(b"Content-Type") or headers.get("Content-Type")
|
|
242
|
+
if ct:
|
|
243
|
+
content_type = ct.decode() if isinstance(ct, (bytes, bytearray)) else str(ct)
|
|
244
|
+
|
|
245
|
+
# Try to expose a stream if available, else raw bytes
|
|
246
|
+
fileobj = getattr(p, "file", None)
|
|
247
|
+
raw = getattr(p, "raw", None)
|
|
248
|
+
|
|
249
|
+
out[name] = {
|
|
250
|
+
"filename": filename,
|
|
251
|
+
"content_type": content_type,
|
|
252
|
+
"size": None,
|
|
253
|
+
"file": fileobj if fileobj is not None else raw,
|
|
254
|
+
}
|
|
255
|
+
else:
|
|
256
|
+
# normal field
|
|
257
|
+
value = getattr(p, "value", None)
|
|
258
|
+
if value is None:
|
|
259
|
+
raw = getattr(p, "raw", b"")
|
|
260
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
261
|
+
value = raw.decode("utf-8", errors="replace")
|
|
262
|
+
else:
|
|
263
|
+
value = str(raw)
|
|
264
|
+
out[name] = value
|
|
265
|
+
|
|
266
|
+
self._files_cache = out
|
|
267
|
+
return self._files_cache
|
nexom/{web → app}/response.py
RENAMED
|
@@ -4,6 +4,8 @@ from typing import Iterable, Optional
|
|
|
4
4
|
from importlib import resources
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
+
from ..core.object_html_render import HTMLDoc, ObjectHTML
|
|
8
|
+
|
|
7
9
|
from .http_status_codes import http_status_codes
|
|
8
10
|
|
|
9
11
|
|
|
@@ -49,6 +51,11 @@ class Response:
|
|
|
49
51
|
if cookie:
|
|
50
52
|
self.headers.append(("Set-Cookie", cookie))
|
|
51
53
|
|
|
54
|
+
# ---- auto Content-Length (if not already present) ----
|
|
55
|
+
has_len = any(k.lower() == "content-length" for k, _ in self.headers)
|
|
56
|
+
if not has_len and isinstance(self.body, (bytes, bytearray)):
|
|
57
|
+
self.headers.append(("Content-Length", str(len(self.body))))
|
|
58
|
+
|
|
52
59
|
def __iter__(self):
|
|
53
60
|
"""
|
|
54
61
|
Allow Response to be returned directly from WSGI apps.
|
|
@@ -127,6 +134,7 @@ class ErrorResponse(Response):
|
|
|
127
134
|
|
|
128
135
|
def __init__(self, status: int, message: str) -> None:
|
|
129
136
|
html = self._render(status, message)
|
|
137
|
+
|
|
130
138
|
super().__init__(html, status=status)
|
|
131
139
|
|
|
132
140
|
@staticmethod
|
|
@@ -139,8 +147,10 @@ class ErrorResponse(Response):
|
|
|
139
147
|
.read_text(encoding="utf-8")
|
|
140
148
|
)
|
|
141
149
|
|
|
150
|
+
ohtml = ObjectHTML(HTMLDoc("error_page", template))
|
|
151
|
+
|
|
152
|
+
status_str = f"{status} - {http_status_codes.get(status)}"
|
|
153
|
+
|
|
142
154
|
return (
|
|
143
|
-
|
|
144
|
-
.replace("__STATUS__", status_text)
|
|
145
|
-
.replace("__MESSAGE__", message)
|
|
155
|
+
ohtml.render("error_page", status=status_str, message=message)
|
|
146
156
|
)
|
nexom/{web → app}/template.py
RENAMED
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from ..
|
|
8
|
+
from ..core.object_html_render import HTMLDoc, HTMLDocLib, ObjectHTML
|
|
9
9
|
from ..core.error import TemplateNotFoundError, TemplateInvalidNameError, TemplatesNotDirError
|
|
10
10
|
|
|
11
11
|
_SEG_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
nexom/app/user.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import pathlib as plb
|
|
6
|
+
|
|
7
|
+
from .db import DatabaseManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# --------------------
|
|
11
|
+
# models
|
|
12
|
+
# --------------------
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class User:
|
|
16
|
+
uid: str
|
|
17
|
+
user_id: str
|
|
18
|
+
public_name: str
|
|
19
|
+
password_hash: str
|
|
20
|
+
password_salt: str
|
|
21
|
+
is_active: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --------------------
|
|
25
|
+
# UserDatabaseManager
|
|
26
|
+
# --------------------
|
|
27
|
+
|
|
28
|
+
class UserDatabaseManager(DatabaseManager):
|
|
29
|
+
def __init__(self, users_dir:str, user: User, auto_commit: bool = True):
|
|
30
|
+
db_file = str(plb.Path(users_dir) / f"{user.uid}.db")
|
|
31
|
+
super().__init__(db_file, auto_commit)
|
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nexom server settings.
|
|
3
|
+
|
|
4
|
+
This file is generated by Nexom buildTools.
|
|
5
|
+
"""
|
|
6
|
+
# ======== project ========
|
|
7
|
+
APP_NAME: str = "__app_name__"
|
|
8
|
+
|
|
9
|
+
PROJECT_DIR: str = "__prj_dir__"
|
|
10
|
+
APP_DIR: str = "__app_dir__"
|
|
11
|
+
TEMPLATES_DIR: str = "__app_dir__/templates"
|
|
12
|
+
|
|
13
|
+
DATA_DIR: str = "__prj_dir__/data"
|
|
14
|
+
|
|
15
|
+
# ======== gunicorn ========
|
|
16
|
+
ADDRESS: str = "__g_address__"
|
|
17
|
+
PORT: int = __g_port__
|
|
18
|
+
WORKERS: int = __g_workers__
|
|
19
|
+
RELOAD: bool = __g_reload__
|
|
20
|
+
|
|
21
|
+
# ======== auth ========
|
|
22
|
+
AUTH_SERVER: str = "http://127.0.0.1:7070"
|
|
23
|
+
|
|
24
|
+
# ======== logger ========
|
|
25
|
+
INFO_LOG: str = "__prj_dir__/data/log/__app_name__/info.log"
|
|
26
|
+
WARN_LOG: str = "__prj_dir__/data/log/__app_name__/warning.log"
|
|
27
|
+
ERR_LOG: str = "__prj_dir__/data/log/__app_name__/error.log"
|
|
28
|
+
ACES_LOG: str = "__prj_dir__/data/log/__app_name__/access.log"
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexom.app.path import Get, Static, Pathlib
|
|
4
|
+
|
|
5
|
+
from .config import APP_DIR
|
|
6
|
+
from .pages import default, document
|
|
7
|
+
|
|
8
|
+
routing = Pathlib(
|
|
9
|
+
Get("", default.main, "DefaultPage"),
|
|
10
|
+
Get("doc/", document.main, "DocumentPage"),
|
|
11
|
+
Static("static/", APP_DIR + "/static", "StaticFiles"),
|
|
12
|
+
)
|
nexom/assets/app/wsgi.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Iterable
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from nexom.app.request import Request
|
|
7
|
+
from nexom.app.response import Response, ErrorResponse
|
|
8
|
+
from nexom.core.error import PathNotFoundError
|
|
9
|
+
from nexom.core.log import AppLogger, AuthLogger
|
|
10
|
+
|
|
11
|
+
from __app_name__.config import INFO_LOG, WARN_LOG, ERR_LOG, ACES_LOG
|
|
12
|
+
from .router import routing
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Logger (global)
|
|
16
|
+
logger = AppLogger(
|
|
17
|
+
info=INFO_LOG,
|
|
18
|
+
warn=WARN_LOG,
|
|
19
|
+
error=ERR_LOG,
|
|
20
|
+
access=ACES_LOG,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ip(environ: dict) -> str:
|
|
25
|
+
xff = environ.get("HTTP_X_FORWARDED_FOR")
|
|
26
|
+
if isinstance(xff, str) and xff.strip():
|
|
27
|
+
return xff.split(",")[0].strip()
|
|
28
|
+
return str(environ.get("REMOTE_ADDR") or "-")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def app(environ: dict, start_response: Callable) -> Iterable[bytes]:
|
|
32
|
+
t0 = time.time()
|
|
33
|
+
|
|
34
|
+
req: Request | None = None
|
|
35
|
+
res: Response
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
req = Request(environ)
|
|
39
|
+
path = req.path
|
|
40
|
+
method = req.method
|
|
41
|
+
|
|
42
|
+
p = routing.get(path, method=method)
|
|
43
|
+
res = p.call_handler(req)
|
|
44
|
+
|
|
45
|
+
except PathNotFoundError as e:
|
|
46
|
+
logger.warn(str(e))
|
|
47
|
+
res = ErrorResponse(404, "Not Found")
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(e)
|
|
51
|
+
res = ErrorResponse(500, "Internal Server Error")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
dt_ms = int((time.time() - t0) * 1000)
|
|
55
|
+
method = req.method if req else str(environ.get("REQUEST_METHOD") or "-")
|
|
56
|
+
path = (req.path if req else str(environ.get("PATH_INFO") or "")).lstrip("/")
|
|
57
|
+
ip = _ip(environ)
|
|
58
|
+
ua = str(environ.get("HTTP_USER_AGENT") or "-")
|
|
59
|
+
logger.access(f'{ip} "{method} /{path}" {res.status_code} {dt_ms}ms "{ua}"')
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
start_response(res.status_text, res.headers)
|
|
64
|
+
return [res.body]
|
|
File without changes
|