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.
Files changed (67) hide show
  1. nexom/__init__.py +2 -2
  2. nexom/__main__.py +111 -17
  3. nexom/app/__init__.py +62 -0
  4. nexom/app/auth.py +322 -0
  5. nexom/{web → app}/cookie.py +4 -2
  6. nexom/app/db.py +88 -0
  7. nexom/app/path.py +195 -0
  8. nexom/app/request.py +267 -0
  9. nexom/{web → app}/response.py +13 -3
  10. nexom/{web → app}/template.py +1 -1
  11. nexom/app/user.py +31 -0
  12. nexom/assets/app/__init__.py +0 -0
  13. nexom/assets/app/__pycache__/__init__.cpython-313.pyc +0 -0
  14. nexom/assets/app/config.py +28 -0
  15. nexom/assets/app/gunicorn.conf.py +5 -0
  16. nexom/assets/app/pages/__pycache__/__init__.cpython-313.pyc +0 -0
  17. nexom/assets/app/pages/_templates.py +7 -0
  18. nexom/assets/{server → app}/pages/default.py +2 -2
  19. nexom/assets/{server → app}/pages/document.py +2 -2
  20. nexom/assets/app/router.py +12 -0
  21. nexom/assets/app/wsgi.py +64 -0
  22. nexom/assets/auth/__init__.py +0 -0
  23. nexom/assets/auth/__pycache__/__init__.cpython-313.pyc +0 -0
  24. nexom/assets/auth/config.py +27 -0
  25. nexom/assets/auth/gunicorn.conf.py +5 -0
  26. nexom/assets/auth/wsgi.py +62 -0
  27. nexom/assets/auth_page/login.html +95 -0
  28. nexom/assets/auth_page/signup.html +106 -0
  29. nexom/assets/error_page/error.html +3 -3
  30. nexom/assets/gateway/apache_app.conf +16 -0
  31. nexom/assets/gateway/nginx_app.conf +21 -0
  32. nexom/buildTools/__init__.py +1 -0
  33. nexom/buildTools/build.py +274 -54
  34. nexom/buildTools/run.py +185 -0
  35. nexom/core/__init__.py +2 -1
  36. nexom/core/error.py +81 -3
  37. nexom/core/log.py +111 -0
  38. nexom/{engine → core}/object_html_render.py +4 -1
  39. nexom/templates/__init__.py +0 -0
  40. nexom/templates/auth.py +72 -0
  41. {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/METADATA +75 -50
  42. nexom-1.0.1.dist-info/RECORD +56 -0
  43. {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/WHEEL +1 -1
  44. nexom/assets/server/config.py +0 -27
  45. nexom/assets/server/gunicorn.conf.py +0 -16
  46. nexom/assets/server/pages/__pycache__/__init__.cpython-313.pyc +0 -0
  47. nexom/assets/server/pages/_templates.py +0 -11
  48. nexom/assets/server/router.py +0 -18
  49. nexom/assets/server/wsgi.py +0 -30
  50. nexom/engine/__init__.py +0 -1
  51. nexom/web/__init__.py +0 -5
  52. nexom/web/path.py +0 -125
  53. nexom/web/request.py +0 -62
  54. nexom-0.1.3.dist-info/RECORD +0 -39
  55. /nexom/{web → app}/http_status_codes.py +0 -0
  56. /nexom/{web → app}/middleware.py +0 -0
  57. /nexom/assets/{server → app}/pages/__init__.py +0 -0
  58. /nexom/assets/{server → app}/static/dog.jpeg +0 -0
  59. /nexom/assets/{server → app}/static/style.css +0 -0
  60. /nexom/assets/{server → app}/templates/base.html +0 -0
  61. /nexom/assets/{server → app}/templates/default.html +0 -0
  62. /nexom/assets/{server → app}/templates/document.html +0 -0
  63. /nexom/assets/{server → app}/templates/footer.html +0 -0
  64. /nexom/assets/{server → app}/templates/header.html +0 -0
  65. {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/entry_points.txt +0 -0
  66. {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/licenses/LICENSE +0 -0
  67. {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
@@ -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
- template
144
- .replace("__STATUS__", status_text)
145
- .replace("__MESSAGE__", message)
155
+ ohtml.render("error_page", status=status_str, message=message)
146
156
  )
@@ -5,7 +5,7 @@ from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
- from ..engine.object_html_render import HTMLDoc, HTMLDocLib, ObjectHTML
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
@@ -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"
@@ -0,0 +1,5 @@
1
+ from __app_name__.config import ADDRESS, PORT, WORKERS, RELOAD # noqa: E402
2
+
3
+ bind = f"{ADDRESS}:{PORT}"
4
+ workers = int(WORKERS)
5
+ reload = bool(RELOAD)
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from nexom.app.template import ObjectHTMLTemplates
4
+
5
+ from __app_name__.config import TEMPLATES_DIR
6
+
7
+ templates = ObjectHTMLTemplates(base_dir=TEMPLATES_DIR)
@@ -1,5 +1,5 @@
1
- from nexom.web.request import Request
2
- from nexom.web.response import Response
1
+ from nexom.app.request import Request
2
+ from nexom.app.response import Response
3
3
 
4
4
  from ._templates import templates
5
5
 
@@ -1,5 +1,5 @@
1
- from nexom.web.request import Request
2
- from nexom.web.response import Response
1
+ from nexom.app.request import Request
2
+ from nexom.app.response import Response
3
3
 
4
4
  from ._templates import templates
5
5
 
@@ -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
+ )
@@ -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