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.
Files changed (39) hide show
  1. nexom/__init__.py +19 -0
  2. nexom/__main__.py +61 -0
  3. nexom/assets/error_page/error.html +44 -0
  4. nexom/assets/server/config.py +27 -0
  5. nexom/assets/server/gunicorn.conf.py +16 -0
  6. nexom/assets/server/pages/__init__.py +3 -0
  7. nexom/assets/server/pages/__pycache__/__init__.cpython-313.pyc +0 -0
  8. nexom/assets/server/pages/_templates.py +11 -0
  9. nexom/assets/server/pages/default.py +10 -0
  10. nexom/assets/server/pages/document.py +10 -0
  11. nexom/assets/server/router.py +18 -0
  12. nexom/assets/server/static/dog.jpeg +0 -0
  13. nexom/assets/server/static/style.css +39 -0
  14. nexom/assets/server/templates/base.html +18 -0
  15. nexom/assets/server/templates/default.html +7 -0
  16. nexom/assets/server/templates/document.html +169 -0
  17. nexom/assets/server/templates/footer.html +3 -0
  18. nexom/assets/server/templates/header.html +3 -0
  19. nexom/assets/server/wsgi.py +30 -0
  20. nexom/buildTools/__init__.py +0 -0
  21. nexom/buildTools/build.py +99 -0
  22. nexom/core/__init__.py +1 -0
  23. nexom/core/error.py +149 -0
  24. nexom/engine/__init__.py +1 -0
  25. nexom/engine/object_html_render.py +224 -0
  26. nexom/web/__init__.py +5 -0
  27. nexom/web/cookie.py +73 -0
  28. nexom/web/http_status_codes.py +72 -0
  29. nexom/web/middleware.py +51 -0
  30. nexom/web/path.py +125 -0
  31. nexom/web/request.py +62 -0
  32. nexom/web/response.py +146 -0
  33. nexom/web/template.py +115 -0
  34. nexom-0.1.3.dist-info/METADATA +168 -0
  35. nexom-0.1.3.dist-info/RECORD +39 -0
  36. nexom-0.1.3.dist-info/WHEEL +5 -0
  37. nexom-0.1.3.dist-info/entry_points.txt +2 -0
  38. nexom-0.1.3.dist-info/licenses/LICENSE +21 -0
  39. 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
+ )
@@ -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
@@ -0,0 +1,5 @@
1
+ from . import request
2
+ from . import response
3
+ from . import path
4
+ from . import cookie
5
+ from . import template
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
+ }
@@ -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