Nexom 0.1.3__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 +19 -0
- nexom/__main__.py +61 -0
- nexom/assets/error_page/error.html +44 -0
- nexom/assets/server/config.py +27 -0
- nexom/assets/server/gunicorn.conf.py +16 -0
- nexom/assets/server/pages/__init__.py +3 -0
- nexom/assets/server/pages/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/server/pages/_templates.py +11 -0
- nexom/assets/server/pages/default.py +10 -0
- nexom/assets/server/pages/document.py +10 -0
- nexom/assets/server/router.py +18 -0
- nexom/assets/server/static/dog.jpeg +0 -0
- nexom/assets/server/static/style.css +39 -0
- nexom/assets/server/templates/base.html +18 -0
- nexom/assets/server/templates/default.html +7 -0
- nexom/assets/server/templates/document.html +169 -0
- nexom/assets/server/templates/footer.html +3 -0
- nexom/assets/server/templates/header.html +3 -0
- nexom/assets/server/wsgi.py +30 -0
- nexom/buildTools/__init__.py +0 -0
- nexom/buildTools/build.py +99 -0
- nexom/core/__init__.py +1 -0
- nexom/core/error.py +149 -0
- nexom/engine/__init__.py +1 -0
- nexom/engine/object_html_render.py +224 -0
- nexom/web/__init__.py +5 -0
- nexom/web/cookie.py +73 -0
- nexom/web/http_status_codes.py +72 -0
- nexom/web/middleware.py +51 -0
- nexom/web/path.py +125 -0
- nexom/web/request.py +62 -0
- nexom/web/response.py +146 -0
- nexom/web/template.py +115 -0
- nexom-0.1.3.dist-info/METADATA +168 -0
- nexom-0.1.3.dist-info/RECORD +39 -0
- nexom-0.1.3.dist-info/WHEEL +5 -0
- nexom-0.1.3.dist-info/entry_points.txt +2 -0
- nexom-0.1.3.dist-info/licenses/LICENSE +21 -0
- nexom-0.1.3.dist-info/top_level.txt +1 -0
nexom/core/error.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NexomError(Exception):
|
|
6
|
+
"""
|
|
7
|
+
Base exception class for all Nexom errors.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
code: Stable error code for programmatic handling.
|
|
11
|
+
message: Human-readable error message.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, code: str, message: str) -> None:
|
|
15
|
+
self.code: str = code
|
|
16
|
+
self.message: str = message
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
return f"{self.code} -> {self.message}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# =========================
|
|
24
|
+
# Command / CLI
|
|
25
|
+
# =========================
|
|
26
|
+
|
|
27
|
+
class CommandArgumentsError(NexomError):
|
|
28
|
+
"""Raised when required CLI arguments are missing."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__("CS01", "Missing command arguments.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# =========================
|
|
35
|
+
# Path / Routing
|
|
36
|
+
# =========================
|
|
37
|
+
|
|
38
|
+
class PathNotFoundError(NexomError):
|
|
39
|
+
"""Raised when no matching route is found."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: str) -> None:
|
|
42
|
+
super().__init__("P01", f"This path is not found. '{path}'")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PathInvalidHandlerTypeError(NexomError):
|
|
46
|
+
"""Raised when a handler returns an invalid response type."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, handler: Any) -> None:
|
|
49
|
+
name = getattr(handler, "__name__", repr(handler))
|
|
50
|
+
super().__init__(
|
|
51
|
+
"P02",
|
|
52
|
+
"This handler returns an invalid type. "
|
|
53
|
+
f"Return value must be Response or dict. '{name}'",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PathlibTypeError(NexomError):
|
|
58
|
+
"""Raised when a non-Path object is added to Pathlib."""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__("P03", "This list only accepts Path objects.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PathHandlerMissingArgError(NexomError):
|
|
65
|
+
"""Raised when a handler signature is invalid."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
"P04",
|
|
70
|
+
"Handler must accept 'request' and 'args' as parameters.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# =========================
|
|
75
|
+
# Cookie
|
|
76
|
+
# =========================
|
|
77
|
+
|
|
78
|
+
class CookieInvalidValueError(NexomError):
|
|
79
|
+
"""Raised when a cookie value is invalid."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, value: str) -> None:
|
|
82
|
+
super().__init__("C01", f"This value is invalid. '{value}'")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# =========================
|
|
86
|
+
# Template
|
|
87
|
+
# =========================
|
|
88
|
+
|
|
89
|
+
class TemplateNotFoundError(NexomError):
|
|
90
|
+
"""Raised when a template file cannot be found."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, name: str) -> None:
|
|
93
|
+
super().__init__("T01", f"This template is not found. '{name}'")
|
|
94
|
+
|
|
95
|
+
class TemplateInvalidNameError(NexomError):
|
|
96
|
+
"""Raised when a template file/dir name violates Nexom template naming rules."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, key: str) -> None:
|
|
99
|
+
super().__init__(
|
|
100
|
+
"T02",
|
|
101
|
+
f"This template name is invalid. '{key}'",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
class TemplatesNotDirError(NexomError):
|
|
105
|
+
"""Raised when the base templates directory is not a directory."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, path: str) -> None:
|
|
108
|
+
super().__init__(
|
|
109
|
+
"T03",
|
|
110
|
+
f"This base path is not a directory. '{path}'"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# =========================
|
|
115
|
+
# ObjectHTML
|
|
116
|
+
# =========================
|
|
117
|
+
class HTMLDocLibNotFoundError(NexomError):
|
|
118
|
+
"""Raised when an HTML document is not found in the library."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, name: str) -> None:
|
|
121
|
+
super().__init__(
|
|
122
|
+
"HD01",
|
|
123
|
+
f"This HTML document is not found in the library. '{name}'",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
class ObjectHTMLInsertValueError(NexomError):
|
|
127
|
+
"""Raised when an insert value for ObjectHTML is invalid."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, name: str) -> None:
|
|
130
|
+
super().__init__(
|
|
131
|
+
"OH01",
|
|
132
|
+
f"This insert value is invalid. '{name}'",
|
|
133
|
+
)
|
|
134
|
+
class ObjectHTMLExtendsError(NexomError):
|
|
135
|
+
"""Raised when an extends for ObjectHTML is invalid."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, name: str) -> None:
|
|
138
|
+
super().__init__(
|
|
139
|
+
"OH02",
|
|
140
|
+
f"This extends is invalid. '{name}'",
|
|
141
|
+
)
|
|
142
|
+
class ObjectHTMLImportError(NexomError):
|
|
143
|
+
"""Raised when an import for ObjectHTML is invalid."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, name: str) -> None:
|
|
146
|
+
super().__init__(
|
|
147
|
+
"OH03",
|
|
148
|
+
f"This import is invalid. '{name}'",
|
|
149
|
+
)
|
nexom/engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import object_html_render
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nexom Object HTML (OHTML)
|
|
3
|
+
|
|
4
|
+
A lightweight HTML composition system that extends plain HTML with:
|
|
5
|
+
- <Extends ... />
|
|
6
|
+
- <Insert ...>...</Insert>
|
|
7
|
+
- <Import ... />
|
|
8
|
+
- {{slot}}
|
|
9
|
+
|
|
10
|
+
This renderer also preserves indentation when importing blocks or inserting
|
|
11
|
+
multi-line slot values.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections import UserList
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
from ..core.error import (
|
|
21
|
+
HTMLDocLibNotFoundError,
|
|
22
|
+
ObjectHTMLImportError,
|
|
23
|
+
ObjectHTMLInsertValueError,
|
|
24
|
+
ObjectHTMLExtendsError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Slots: {{ key }}
|
|
29
|
+
_SLOT_RE: Final = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
30
|
+
|
|
31
|
+
# Extends: <Extends a.b />
|
|
32
|
+
_EXTENDS_RE: Final = re.compile(r"<Extends\s+([\w\.]+)\s*/>")
|
|
33
|
+
|
|
34
|
+
# Insert: <Insert key>...</Insert>
|
|
35
|
+
_INSERT_RE: Final = re.compile(r"<Insert\s+([\w\.]+)>(.*?)</Insert>", flags=re.DOTALL)
|
|
36
|
+
|
|
37
|
+
# Import: line-based (captures indent + name)
|
|
38
|
+
# Example:
|
|
39
|
+
# <Import components.header />
|
|
40
|
+
_IMPORT_LINE_RE: Final = re.compile(r"(?m)^([ \t]*)<Import\s+([\w\.]+)\s*/>\s*$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HTMLDoc:
|
|
44
|
+
"""Raw HTML document container (no rendering)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, name: str, html: str) -> None:
|
|
47
|
+
self.name = name.rsplit(".", 1)[0] if name.endswith(".html") else name
|
|
48
|
+
self.html = html
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return self.name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HTMLDocLib(UserList[HTMLDoc]):
|
|
55
|
+
"""A list of HTML documents with name lookup."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, docs: list[HTMLDoc] | None = None) -> None:
|
|
58
|
+
super().__init__(docs or [])
|
|
59
|
+
|
|
60
|
+
def get(self, name: str, raise_error: bool = False) -> HTMLDoc | None:
|
|
61
|
+
for doc in self.data:
|
|
62
|
+
if doc.name == name:
|
|
63
|
+
return doc
|
|
64
|
+
if raise_error:
|
|
65
|
+
raise HTMLDocLibNotFoundError(name)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ObjectHTML:
|
|
70
|
+
"""
|
|
71
|
+
Object HTML renderer.
|
|
72
|
+
|
|
73
|
+
Provides dynamic callable access:
|
|
74
|
+
engine.default(title="x")
|
|
75
|
+
engine.layout.base(title="x")
|
|
76
|
+
|
|
77
|
+
Internals:
|
|
78
|
+
- Extends/Insert are applied first (non-strict slots for inserts)
|
|
79
|
+
- Imports are expanded with indentation preserved
|
|
80
|
+
- Final {{slot}} replacement is applied (strict)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, *docs: HTMLDoc, lib: HTMLDocLib | None = None) -> None:
|
|
84
|
+
self.lib = lib or HTMLDocLib()
|
|
85
|
+
for doc in docs:
|
|
86
|
+
self.lib.append(doc)
|
|
87
|
+
|
|
88
|
+
# Build dynamic callables for each doc name
|
|
89
|
+
for doc in self.lib:
|
|
90
|
+
self._set_doc(doc)
|
|
91
|
+
|
|
92
|
+
def _set_doc(self, doc: HTMLDoc) -> None:
|
|
93
|
+
def _call(**kwargs: str) -> str:
|
|
94
|
+
return self.render(doc.name, **kwargs)
|
|
95
|
+
|
|
96
|
+
setattr(self, doc.name, _call)
|
|
97
|
+
|
|
98
|
+
def render(self, name: str, **kwargs: str) -> str:
|
|
99
|
+
"""Render a template by name."""
|
|
100
|
+
html = self._render_structure(name)
|
|
101
|
+
# Final strict slot fill (indent-aware)
|
|
102
|
+
return self._apply_slots_strict(html, kwargs)
|
|
103
|
+
|
|
104
|
+
# -------------------------
|
|
105
|
+
# phases
|
|
106
|
+
# -------------------------
|
|
107
|
+
|
|
108
|
+
def _render_structure(self, name: str) -> str:
|
|
109
|
+
"""Resolve Extends/Insert and Import. Leaves {{slots}} unresolved."""
|
|
110
|
+
doc = self.lib.get(name, raise_error=True)
|
|
111
|
+
html = self._apply_extends(doc.html)
|
|
112
|
+
html = self._apply_imports(html)
|
|
113
|
+
return html
|
|
114
|
+
|
|
115
|
+
def _apply_extends(self, html: str) -> str:
|
|
116
|
+
m = _EXTENDS_RE.search(html)
|
|
117
|
+
if not m:
|
|
118
|
+
return html
|
|
119
|
+
|
|
120
|
+
extends_name = m.group(1)
|
|
121
|
+
base = self.lib.get(extends_name)
|
|
122
|
+
if not base:
|
|
123
|
+
raise ObjectHTMLExtendsError(extends_name)
|
|
124
|
+
|
|
125
|
+
inserts = {t: c.strip() for t, c in _INSERT_RE.findall(html)}
|
|
126
|
+
|
|
127
|
+
# Replace only specified slots in base (non-strict, indent-aware)
|
|
128
|
+
return self._apply_slots_non_strict(base.html, inserts)
|
|
129
|
+
|
|
130
|
+
def _apply_imports(self, html: str) -> str:
|
|
131
|
+
import_map = {d.name: d.html for d in self.lib}
|
|
132
|
+
|
|
133
|
+
def indent_block(block: str, indent: str) -> str:
|
|
134
|
+
# Import replaces the whole line, so indent ALL non-empty lines.
|
|
135
|
+
lines = block.splitlines(True) # keep line breaks
|
|
136
|
+
out: list[str] = []
|
|
137
|
+
for line in lines:
|
|
138
|
+
if line.strip() == "":
|
|
139
|
+
out.append(line)
|
|
140
|
+
else:
|
|
141
|
+
out.append(indent + line)
|
|
142
|
+
return "".join(out)
|
|
143
|
+
|
|
144
|
+
def repl(m: re.Match) -> str:
|
|
145
|
+
indent = m.group(1)
|
|
146
|
+
name = m.group(2)
|
|
147
|
+
|
|
148
|
+
if name not in import_map:
|
|
149
|
+
raise ObjectHTMLImportError(name)
|
|
150
|
+
|
|
151
|
+
imported = import_map[name]
|
|
152
|
+
return indent_block(imported, indent)
|
|
153
|
+
|
|
154
|
+
return _IMPORT_LINE_RE.sub(repl, html)
|
|
155
|
+
|
|
156
|
+
# -------------------------
|
|
157
|
+
# slot replacement (indent-aware)
|
|
158
|
+
# -------------------------
|
|
159
|
+
|
|
160
|
+
def _line_indent_before(self, html: str, pos: int) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Return whitespace indent from the start of the line up to pos.
|
|
163
|
+
|
|
164
|
+
If the substring from line start to pos contains only whitespace,
|
|
165
|
+
that whitespace is returned. Otherwise returns empty string.
|
|
166
|
+
"""
|
|
167
|
+
line_start = html.rfind("\n", 0, pos)
|
|
168
|
+
line_start = 0 if line_start == -1 else line_start + 1
|
|
169
|
+
prefix = html[line_start:pos]
|
|
170
|
+
|
|
171
|
+
m = re.match(r"[ \t]*", prefix)
|
|
172
|
+
indent = m.group(0) if m else ""
|
|
173
|
+
# If there is any non-whitespace before the slot, don't indent-inject.
|
|
174
|
+
return indent if prefix == indent else ""
|
|
175
|
+
|
|
176
|
+
def _indent_multiline_slot_value(self, value: str, indent: str) -> str:
|
|
177
|
+
"""
|
|
178
|
+
Indent multi-line slot values for {{slot}} replacement.
|
|
179
|
+
|
|
180
|
+
IMPORTANT: The indent before {{slot}} already remains in the output,
|
|
181
|
+
so we indent ONLY lines after the first line.
|
|
182
|
+
"""
|
|
183
|
+
if "\n" not in value:
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
lines = value.splitlines(True) # keepends
|
|
187
|
+
if not lines:
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
out = [lines[0]]
|
|
191
|
+
for line in lines[1:]:
|
|
192
|
+
if line.strip() == "":
|
|
193
|
+
out.append(line)
|
|
194
|
+
else:
|
|
195
|
+
out.append(indent + line)
|
|
196
|
+
return "".join(out)
|
|
197
|
+
|
|
198
|
+
def _apply_slots_non_strict(self, html: str, values: dict[str, str]) -> str:
|
|
199
|
+
def repl(m: re.Match) -> str:
|
|
200
|
+
key = m.group(1)
|
|
201
|
+
if key not in values:
|
|
202
|
+
return m.group(0)
|
|
203
|
+
|
|
204
|
+
raw = str(values[key])
|
|
205
|
+
indent = self._line_indent_before(html, m.start())
|
|
206
|
+
if indent:
|
|
207
|
+
return self._indent_multiline_slot_value(raw, indent)
|
|
208
|
+
return raw
|
|
209
|
+
|
|
210
|
+
return _SLOT_RE.sub(repl, html)
|
|
211
|
+
|
|
212
|
+
def _apply_slots_strict(self, html: str, values: dict[str, str]) -> str:
|
|
213
|
+
def repl(m: re.Match) -> str:
|
|
214
|
+
key = m.group(1)
|
|
215
|
+
if key not in values:
|
|
216
|
+
raise ObjectHTMLInsertValueError(key)
|
|
217
|
+
|
|
218
|
+
raw = str(values[key])
|
|
219
|
+
indent = self._line_indent_before(html, m.start())
|
|
220
|
+
if indent:
|
|
221
|
+
return self._indent_multiline_slot_value(raw, indent)
|
|
222
|
+
return raw
|
|
223
|
+
|
|
224
|
+
return _SLOT_RE.sub(repl, html)
|
nexom/web/__init__.py
ADDED
nexom/web/cookie.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from .response import Response
|
|
4
|
+
from ..core.error import CookieInvalidValueError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cookie:
|
|
8
|
+
"""
|
|
9
|
+
Represents a single HTTP cookie.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
name: str,
|
|
15
|
+
value: str,
|
|
16
|
+
*,
|
|
17
|
+
http_only: bool = True,
|
|
18
|
+
secure: bool = True,
|
|
19
|
+
**kwargs: str | int,
|
|
20
|
+
) -> None:
|
|
21
|
+
if name is None:
|
|
22
|
+
raise CookieInvalidValueError("Cookie name cannot be None")
|
|
23
|
+
self.name: str = name
|
|
24
|
+
self.value: str = value
|
|
25
|
+
self.http_only: bool = http_only
|
|
26
|
+
self.secure: bool = secure
|
|
27
|
+
self.attributes: dict[str, str | int] = kwargs
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
parts = [f"{self.name}={self.value};"]
|
|
31
|
+
for k, v in self.attributes.items():
|
|
32
|
+
parts.append(f"{k}={v};")
|
|
33
|
+
if self.http_only:
|
|
34
|
+
parts.append("HttpOnly;")
|
|
35
|
+
if self.secure:
|
|
36
|
+
parts.append("Secure;")
|
|
37
|
+
return " ".join(parts)
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return repr(self)
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: str | int) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Add or update an attribute of the cookie.
|
|
45
|
+
"""
|
|
46
|
+
self.attributes[key] = value
|
|
47
|
+
|
|
48
|
+
def to_header(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Return the cookie string for Set-Cookie header.
|
|
51
|
+
"""
|
|
52
|
+
return str(self)
|
|
53
|
+
|
|
54
|
+
def response(self, body: str | bytes = "OK") -> Response:
|
|
55
|
+
"""
|
|
56
|
+
Generate a Response object with this cookie set.
|
|
57
|
+
"""
|
|
58
|
+
res = Response(body)
|
|
59
|
+
res.headers.append(("Set-Cookie", self.to_header()))
|
|
60
|
+
return res
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RequestCookies(dict[str, str | None]):
|
|
64
|
+
"""
|
|
65
|
+
Container for cookies parsed from a request.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, **kwargs: str) -> None:
|
|
69
|
+
super().__init__(kwargs)
|
|
70
|
+
self.default: str | None = None
|
|
71
|
+
|
|
72
|
+
def get(self, key: str) -> str | None:
|
|
73
|
+
return super().get(key, self.default)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
http_status_codes = {
|
|
2
|
+
# 1xx Informational
|
|
3
|
+
100: "Continue",
|
|
4
|
+
101: "Switching Protocols",
|
|
5
|
+
102: "Processing",
|
|
6
|
+
103: "Early Hints",
|
|
7
|
+
|
|
8
|
+
# 2xx Success
|
|
9
|
+
200: "OK",
|
|
10
|
+
201: "Created",
|
|
11
|
+
202: "Accepted",
|
|
12
|
+
203: "Non-Authoritative Information",
|
|
13
|
+
204: "No Content",
|
|
14
|
+
205: "Reset Content",
|
|
15
|
+
206: "Partial Content",
|
|
16
|
+
207: "Multi-Status",
|
|
17
|
+
208: "Already Reported",
|
|
18
|
+
226: "IM Used",
|
|
19
|
+
|
|
20
|
+
# 3xx Redirection
|
|
21
|
+
300: "Multiple Choices",
|
|
22
|
+
301: "Moved Permanently",
|
|
23
|
+
302: "Found",
|
|
24
|
+
303: "See Other",
|
|
25
|
+
304: "Not Modified",
|
|
26
|
+
307: "Temporary Redirect",
|
|
27
|
+
308: "Permanent Redirect",
|
|
28
|
+
|
|
29
|
+
# 4xx Client Errors
|
|
30
|
+
400: "Bad Request",
|
|
31
|
+
401: "Unauthorized",
|
|
32
|
+
402: "Payment Required",
|
|
33
|
+
403: "Forbidden",
|
|
34
|
+
404: "Not Found",
|
|
35
|
+
405: "Method Not Allowed",
|
|
36
|
+
406: "Not Acceptable",
|
|
37
|
+
407: "Proxy Authentication Required",
|
|
38
|
+
408: "Request Timeout",
|
|
39
|
+
409: "Conflict",
|
|
40
|
+
410: "Gone",
|
|
41
|
+
411: "Length Required",
|
|
42
|
+
412: "Precondition Failed",
|
|
43
|
+
413: "Content Too Large", # Payload Too Large
|
|
44
|
+
414: "URI Too Long",
|
|
45
|
+
415: "Unsupported Media Type",
|
|
46
|
+
416: "Range Not Satisfiable",
|
|
47
|
+
417: "Expectation Failed",
|
|
48
|
+
418: "I'm a teapot",
|
|
49
|
+
421: "Misdirected Request",
|
|
50
|
+
422: "Unprocessable Content", # Unprocessable Entity の代替
|
|
51
|
+
423: "Locked",
|
|
52
|
+
424: "Failed Dependency",
|
|
53
|
+
425: "Too Early",
|
|
54
|
+
426: "Upgrade Required",
|
|
55
|
+
428: "Precondition Required",
|
|
56
|
+
429: "Too Many Requests",
|
|
57
|
+
431: "Request Header Fields Too Large",
|
|
58
|
+
451: "Unavailable For Legal Reasons",
|
|
59
|
+
|
|
60
|
+
# 5xx Server Errors
|
|
61
|
+
500: "Internal Server Error",
|
|
62
|
+
501: "Not Implemented",
|
|
63
|
+
502: "Bad Gateway",
|
|
64
|
+
503: "Service Unavailable",
|
|
65
|
+
504: "Gateway Timeout",
|
|
66
|
+
505: "HTTP Version Not Supported",
|
|
67
|
+
506: "Variant Also Negotiates",
|
|
68
|
+
507: "Insufficient Storage",
|
|
69
|
+
508: "Loop Detected",
|
|
70
|
+
510: "Not Extended",
|
|
71
|
+
511: "Network Authentication Required",
|
|
72
|
+
}
|
nexom/web/middleware.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Protocol, TypeAlias, Any
|
|
5
|
+
|
|
6
|
+
from .request import Request
|
|
7
|
+
from .response import Response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Handler: TypeAlias = Callable[[Request, dict[str, str | None]], Response]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Middleware(Protocol):
|
|
14
|
+
"""
|
|
15
|
+
Middleware interface.
|
|
16
|
+
|
|
17
|
+
A middleware receives the request, route args, and next handler.
|
|
18
|
+
It must return a Response.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __call__(self, request: Request, args: dict[str, str | None], next_: Handler) -> Response:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class MiddlewareChain:
|
|
27
|
+
"""
|
|
28
|
+
Build and execute a middleware chain.
|
|
29
|
+
"""
|
|
30
|
+
middlewares: tuple[Middleware, ...]
|
|
31
|
+
|
|
32
|
+
def wrap(self, handler: Handler) -> Handler:
|
|
33
|
+
"""
|
|
34
|
+
Wrap the given handler with middlewares (outer -> inner).
|
|
35
|
+
"""
|
|
36
|
+
def wrapped(request: Request, args: dict[str, str | None]) -> Response:
|
|
37
|
+
# Build chain lazily per call (safe and simple)
|
|
38
|
+
def call_at(i: int, req: Request, a: dict[str, str | None]) -> Response:
|
|
39
|
+
if i >= len(self.middlewares):
|
|
40
|
+
return handler(req, a)
|
|
41
|
+
|
|
42
|
+
mw = self.middlewares[i]
|
|
43
|
+
|
|
44
|
+
def next_(r: Request, aa: dict[str, str | None]) -> Response:
|
|
45
|
+
return call_at(i + 1, r, aa)
|
|
46
|
+
|
|
47
|
+
return mw(req, a, next_)
|
|
48
|
+
|
|
49
|
+
return call_at(0, request, args)
|
|
50
|
+
|
|
51
|
+
return wrapped
|
nexom/web/path.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
from mimetypes import guess_type
|
|
6
|
+
from typing import Callable, Any, Optional, Iterable
|
|
7
|
+
|
|
8
|
+
from ..core.error import (
|
|
9
|
+
PathNotFoundError,
|
|
10
|
+
PathlibTypeError,
|
|
11
|
+
PathInvalidHandlerTypeError,
|
|
12
|
+
PathHandlerMissingArgError,
|
|
13
|
+
)
|
|
14
|
+
from .request import Request
|
|
15
|
+
from .response import Response, JsonResponse
|
|
16
|
+
from .middleware import Middleware, MiddlewareChain, Handler
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Path:
|
|
20
|
+
"""
|
|
21
|
+
Represents a route with optional path arguments and its handler.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: str, handler: Handler, name: str):
|
|
25
|
+
self.handler = handler
|
|
26
|
+
self.name: str = name
|
|
27
|
+
|
|
28
|
+
path_segments = path.strip("/").split("/")
|
|
29
|
+
self.path_args: dict[int, str] = {}
|
|
30
|
+
detection_index = 0
|
|
31
|
+
|
|
32
|
+
for idx, segment in enumerate(path_segments):
|
|
33
|
+
m = re.match(r"{(.*?)}", segment)
|
|
34
|
+
if m:
|
|
35
|
+
if detection_index == 0:
|
|
36
|
+
detection_index = idx
|
|
37
|
+
self.path_args[idx] = m.group(1)
|
|
38
|
+
if idx == len(path_segments) - 1 and detection_index == 0:
|
|
39
|
+
detection_index = idx + 1
|
|
40
|
+
|
|
41
|
+
self.path: str = "/".join(path_segments[:detection_index])
|
|
42
|
+
self.detection_range: int = detection_index
|
|
43
|
+
self.args: dict[str, Optional[str]] = {}
|
|
44
|
+
|
|
45
|
+
def _read_args(self, request_path: str) -> None:
|
|
46
|
+
segments = request_path.strip("/").split("/")
|
|
47
|
+
for idx, arg_name in self.path_args.items():
|
|
48
|
+
self.args[arg_name] = segments[idx] if idx < len(segments) else None
|
|
49
|
+
|
|
50
|
+
def call_handler(self, request: Request, middlewares: tuple[Middleware, ...] = ()) -> Response:
|
|
51
|
+
try:
|
|
52
|
+
self._read_args(request.path)
|
|
53
|
+
|
|
54
|
+
handler = self.handler
|
|
55
|
+
if middlewares:
|
|
56
|
+
handler = MiddlewareChain(middlewares).wrap(handler)
|
|
57
|
+
|
|
58
|
+
res = handler(request, self.args)
|
|
59
|
+
if isinstance(res, dict):
|
|
60
|
+
return JsonResponse(res)
|
|
61
|
+
if not isinstance(res, Response):
|
|
62
|
+
raise PathInvalidHandlerTypeError(self.handler)
|
|
63
|
+
return res
|
|
64
|
+
except TypeError as e:
|
|
65
|
+
if re.search(r"takes \d+ positional arguments? but \d+ were given", str(e)):
|
|
66
|
+
raise PathHandlerMissingArgError()
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Static(Path):
|
|
71
|
+
"""
|
|
72
|
+
Represents a static file route.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, path: str, static_directory: str, name: str) -> None:
|
|
76
|
+
self.static_directory = os.path.abspath(static_directory.rstrip("/"))
|
|
77
|
+
super().__init__(path, self._access, name)
|
|
78
|
+
|
|
79
|
+
def _access(self, request: Request, args: dict[str, Optional[str]]) -> Response:
|
|
80
|
+
segments = request.path.strip("/").split("/")
|
|
81
|
+
relative_path = os.path.join(*segments[self.detection_range :]) if len(segments) > self.detection_range else ""
|
|
82
|
+
abs_path = os.path.abspath(os.path.join(self.static_directory, relative_path))
|
|
83
|
+
|
|
84
|
+
if os.path.isdir(abs_path):
|
|
85
|
+
abs_path = os.path.join(abs_path, "index.html")
|
|
86
|
+
|
|
87
|
+
if not abs_path.startswith(self.static_directory) or not os.path.exists(abs_path):
|
|
88
|
+
raise PathNotFoundError(request.path)
|
|
89
|
+
|
|
90
|
+
with open(abs_path, "rb") as f:
|
|
91
|
+
content = f.read()
|
|
92
|
+
|
|
93
|
+
mime_type, _ = guess_type(abs_path)
|
|
94
|
+
return Response(content, headers=[("Content-Type", mime_type or "application/octet-stream")])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Pathlib(list[Path]):
|
|
98
|
+
"""
|
|
99
|
+
Collection of Path objects with middleware support.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, *paths: Path) -> None:
|
|
103
|
+
for p in paths:
|
|
104
|
+
self._check(p)
|
|
105
|
+
super().__init__(paths)
|
|
106
|
+
self.raise_if_not_exist: bool = True
|
|
107
|
+
self.middlewares: list[Middleware] = []
|
|
108
|
+
|
|
109
|
+
def _check(self, arg: object) -> None:
|
|
110
|
+
if not isinstance(arg, Path):
|
|
111
|
+
raise PathlibTypeError
|
|
112
|
+
|
|
113
|
+
def add_middleware(self, *middlewares: Middleware) -> None:
|
|
114
|
+
self.middlewares.extend(middlewares)
|
|
115
|
+
|
|
116
|
+
def get(self, request_path: str) -> Path | None:
|
|
117
|
+
segments = request_path.rstrip("/").split("/")
|
|
118
|
+
for p in self:
|
|
119
|
+
detection_path = "/".join(segments[: p.detection_range])
|
|
120
|
+
if detection_path == p.path:
|
|
121
|
+
return p
|
|
122
|
+
|
|
123
|
+
if self.raise_if_not_exist:
|
|
124
|
+
raise PathNotFoundError(request_path)
|
|
125
|
+
return None
|