xitzin 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xitzin/__init__.py +78 -0
- xitzin/application.py +548 -0
- xitzin/auth.py +152 -0
- xitzin/cgi.py +555 -0
- xitzin/exceptions.py +138 -0
- xitzin/middleware.py +219 -0
- xitzin/py.typed +0 -0
- xitzin/requests.py +150 -0
- xitzin/responses.py +235 -0
- xitzin/routing.py +381 -0
- xitzin/templating.py +222 -0
- xitzin/testing.py +267 -0
- xitzin-0.1.2.dist-info/METADATA +118 -0
- xitzin-0.1.2.dist-info/RECORD +15 -0
- xitzin-0.1.2.dist-info/WHEEL +4 -0
xitzin/exceptions.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Gemini exception classes for error responses.
|
|
2
|
+
|
|
3
|
+
These exceptions can be raised in handlers to return specific Gemini status codes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GeminiException(Exception):
|
|
10
|
+
"""Base exception for Gemini error responses.
|
|
11
|
+
|
|
12
|
+
Subclasses define specific status codes for different error types.
|
|
13
|
+
Raise these in handlers to return the appropriate Gemini status.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
@app.gemini("/page/{page_id}")
|
|
17
|
+
def get_page(request: Request, page_id: int):
|
|
18
|
+
page = db.get(page_id)
|
|
19
|
+
if not page:
|
|
20
|
+
raise NotFound(f"Page {page_id} not found")
|
|
21
|
+
return page.content
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
status_code: int = 50
|
|
25
|
+
default_message: str = "Permanent failure"
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str | None = None) -> None:
|
|
28
|
+
self.message = message or self.default_message
|
|
29
|
+
super().__init__(self.message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Input required (1x)
|
|
33
|
+
class InputRequired(GeminiException):
|
|
34
|
+
"""Request requires user input (status 10)."""
|
|
35
|
+
|
|
36
|
+
status_code = 10
|
|
37
|
+
default_message = "Input required"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SensitiveInputRequired(GeminiException):
|
|
41
|
+
"""Request requires sensitive user input (status 11)."""
|
|
42
|
+
|
|
43
|
+
status_code = 11
|
|
44
|
+
default_message = "Sensitive input required"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Temporary failures (4x)
|
|
48
|
+
class TemporaryFailure(GeminiException):
|
|
49
|
+
"""Temporary failure - client may retry (status 40)."""
|
|
50
|
+
|
|
51
|
+
status_code = 40
|
|
52
|
+
default_message = "Temporary failure"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ServerUnavailable(GeminiException):
|
|
56
|
+
"""Server unavailable due to maintenance or overload (status 41)."""
|
|
57
|
+
|
|
58
|
+
status_code = 41
|
|
59
|
+
default_message = "Server unavailable"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CGIError(GeminiException):
|
|
63
|
+
"""CGI or similar process error (status 42)."""
|
|
64
|
+
|
|
65
|
+
status_code = 42
|
|
66
|
+
default_message = "CGI error"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProxyError(GeminiException):
|
|
70
|
+
"""Proxy request failed (status 43)."""
|
|
71
|
+
|
|
72
|
+
status_code = 43
|
|
73
|
+
default_message = "Proxy error"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SlowDown(GeminiException):
|
|
77
|
+
"""Rate limiting - client should slow down (status 44)."""
|
|
78
|
+
|
|
79
|
+
status_code = 44
|
|
80
|
+
default_message = "Slow down"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Permanent failures (5x)
|
|
84
|
+
class PermanentFailure(GeminiException):
|
|
85
|
+
"""Permanent failure - client should not retry (status 50)."""
|
|
86
|
+
|
|
87
|
+
status_code = 50
|
|
88
|
+
default_message = "Permanent failure"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NotFound(GeminiException):
|
|
92
|
+
"""Resource not found (status 51)."""
|
|
93
|
+
|
|
94
|
+
status_code = 51
|
|
95
|
+
default_message = "Not found"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Gone(GeminiException):
|
|
99
|
+
"""Resource permanently removed (status 52)."""
|
|
100
|
+
|
|
101
|
+
status_code = 52
|
|
102
|
+
default_message = "Gone"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ProxyRequestRefused(GeminiException):
|
|
106
|
+
"""Proxy request refused (status 53)."""
|
|
107
|
+
|
|
108
|
+
status_code = 53
|
|
109
|
+
default_message = "Proxy request refused"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BadRequest(GeminiException):
|
|
113
|
+
"""Malformed request (status 59)."""
|
|
114
|
+
|
|
115
|
+
status_code = 59
|
|
116
|
+
default_message = "Bad request"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Client certificate errors (6x)
|
|
120
|
+
class CertificateRequired(GeminiException):
|
|
121
|
+
"""Client certificate required (status 60)."""
|
|
122
|
+
|
|
123
|
+
status_code = 60
|
|
124
|
+
default_message = "Client certificate required"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CertificateNotAuthorized(GeminiException):
|
|
128
|
+
"""Certificate not authorized for this resource (status 61)."""
|
|
129
|
+
|
|
130
|
+
status_code = 61
|
|
131
|
+
default_message = "Certificate not authorized"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CertificateNotValid(GeminiException):
|
|
135
|
+
"""Certificate is not valid (status 62)."""
|
|
136
|
+
|
|
137
|
+
status_code = 62
|
|
138
|
+
default_message = "Certificate not valid"
|
xitzin/middleware.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Middleware system for Xitzin.
|
|
2
|
+
|
|
3
|
+
Middleware functions can intercept requests before they reach handlers
|
|
4
|
+
and modify responses before they are sent to clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC
|
|
11
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
14
|
+
from nauyaca.protocol.status import StatusCode
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .requests import Request
|
|
18
|
+
|
|
19
|
+
# Type alias for middleware call_next function
|
|
20
|
+
CallNext = Callable[["Request"], Awaitable[GeminiResponse]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseMiddleware(ABC):
|
|
24
|
+
"""Base class for class-based middleware.
|
|
25
|
+
|
|
26
|
+
Subclass this and implement before_request and/or after_response
|
|
27
|
+
for a cleaner interface than writing raw middleware functions.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
class LoggingMiddleware(BaseMiddleware):
|
|
31
|
+
async def before_request(
|
|
32
|
+
self, request: Request
|
|
33
|
+
) -> Request | GeminiResponse | None:
|
|
34
|
+
print(f"Request: {request.path}")
|
|
35
|
+
return None # Continue processing
|
|
36
|
+
|
|
37
|
+
async def after_response(
|
|
38
|
+
self, request: Request, response: GeminiResponse
|
|
39
|
+
) -> GeminiResponse:
|
|
40
|
+
print(f"Response: {response.status}")
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
app.add_middleware(LoggingMiddleware())
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def before_request(
|
|
47
|
+
self, request: "Request"
|
|
48
|
+
) -> "Request | GeminiResponse | None":
|
|
49
|
+
"""Called before the handler.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
request: The incoming request.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
- None: Continue to next middleware/handler
|
|
56
|
+
- Request: Use this modified request
|
|
57
|
+
- GeminiResponse: Short-circuit and return this response immediately
|
|
58
|
+
"""
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def after_response(
|
|
62
|
+
self, request: "Request", response: GeminiResponse
|
|
63
|
+
) -> GeminiResponse:
|
|
64
|
+
"""Called after the handler.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
request: The original request.
|
|
68
|
+
response: The response from the handler.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The response to send (can be modified).
|
|
72
|
+
"""
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
async def __call__(self, request: "Request", call_next: CallNext) -> GeminiResponse:
|
|
76
|
+
"""Process the request through this middleware.
|
|
77
|
+
|
|
78
|
+
This implements the middleware protocol by calling before_request,
|
|
79
|
+
then call_next, then after_response.
|
|
80
|
+
"""
|
|
81
|
+
# Before request
|
|
82
|
+
result = await self.before_request(request)
|
|
83
|
+
if isinstance(result, GeminiResponse):
|
|
84
|
+
return result # Short-circuit
|
|
85
|
+
if result is not None:
|
|
86
|
+
request = result # Use modified request
|
|
87
|
+
|
|
88
|
+
# Call next handler
|
|
89
|
+
response = await call_next(request)
|
|
90
|
+
|
|
91
|
+
# After response
|
|
92
|
+
return await self.after_response(request, response)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TimingMiddleware(BaseMiddleware):
|
|
96
|
+
"""Middleware that tracks request processing time.
|
|
97
|
+
|
|
98
|
+
Stores the elapsed time in request.state.elapsed_time.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
app.add_middleware(TimingMiddleware())
|
|
102
|
+
|
|
103
|
+
@app.gemini("/")
|
|
104
|
+
def home(request: Request):
|
|
105
|
+
elapsed = getattr(request.state, 'elapsed_time', 0)
|
|
106
|
+
return f"# Response generated in {elapsed:.3f}s"
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
async def before_request(
|
|
110
|
+
self, request: "Request"
|
|
111
|
+
) -> "Request | GeminiResponse | None":
|
|
112
|
+
request.state.start_time = time.perf_counter()
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
async def after_response(
|
|
116
|
+
self, request: "Request", response: GeminiResponse
|
|
117
|
+
) -> GeminiResponse:
|
|
118
|
+
elapsed = time.perf_counter() - request.state.start_time
|
|
119
|
+
request.state.elapsed_time = elapsed
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LoggingMiddleware(BaseMiddleware):
|
|
124
|
+
"""Middleware that logs requests and responses.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
app.add_middleware(LoggingMiddleware())
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, logger: Callable[[str], None] | None = None) -> None:
|
|
131
|
+
"""Create logging middleware.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
logger: Custom logging function. Defaults to print.
|
|
135
|
+
"""
|
|
136
|
+
self._log = logger or print
|
|
137
|
+
|
|
138
|
+
async def before_request(
|
|
139
|
+
self, request: "Request"
|
|
140
|
+
) -> "Request | GeminiResponse | None":
|
|
141
|
+
cert_info = ""
|
|
142
|
+
if request.client_cert_fingerprint:
|
|
143
|
+
cert_info = f" [cert:{request.client_cert_fingerprint[:8]}]"
|
|
144
|
+
self._log(f"[Xitzin] Request: {request.path}{cert_info}")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
async def after_response(
|
|
148
|
+
self, request: "Request", response: GeminiResponse
|
|
149
|
+
) -> GeminiResponse:
|
|
150
|
+
self._log(f"[Xitzin] Response: {response.status} {response.meta}")
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RateLimitMiddleware(BaseMiddleware):
|
|
155
|
+
"""Simple in-memory rate limiting middleware.
|
|
156
|
+
|
|
157
|
+
Limits requests per client based on certificate fingerprint or IP.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
app.add_middleware(RateLimitMiddleware(max_requests=10, window_seconds=60))
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
max_requests: int = 10,
|
|
166
|
+
window_seconds: float = 60.0,
|
|
167
|
+
retry_after: int = 30,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create rate limit middleware.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
max_requests: Maximum requests allowed per window.
|
|
173
|
+
window_seconds: Time window in seconds.
|
|
174
|
+
retry_after: Seconds to tell client to wait.
|
|
175
|
+
"""
|
|
176
|
+
self.max_requests = max_requests
|
|
177
|
+
self.window_seconds = window_seconds
|
|
178
|
+
self.retry_after = retry_after
|
|
179
|
+
self._requests: dict[str, list[float]] = {}
|
|
180
|
+
|
|
181
|
+
def _get_client_id(self, request: "Request") -> str:
|
|
182
|
+
"""Get a unique identifier for the client."""
|
|
183
|
+
if request.client_cert_fingerprint:
|
|
184
|
+
return f"cert:{request.client_cert_fingerprint}"
|
|
185
|
+
# Fall back to a placeholder (in production, use IP from transport)
|
|
186
|
+
return "unknown"
|
|
187
|
+
|
|
188
|
+
def _is_rate_limited(self, client_id: str) -> bool:
|
|
189
|
+
"""Check if a client is rate limited."""
|
|
190
|
+
now = time.time()
|
|
191
|
+
cutoff = now - self.window_seconds
|
|
192
|
+
|
|
193
|
+
# Get request timestamps for this client
|
|
194
|
+
timestamps = self._requests.get(client_id, [])
|
|
195
|
+
|
|
196
|
+
# Filter to only recent requests
|
|
197
|
+
recent = [t for t in timestamps if t > cutoff]
|
|
198
|
+
self._requests[client_id] = recent
|
|
199
|
+
|
|
200
|
+
# Check if over limit
|
|
201
|
+
if len(recent) >= self.max_requests:
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
# Record this request
|
|
205
|
+
recent.append(now)
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
async def before_request(
|
|
209
|
+
self, request: "Request"
|
|
210
|
+
) -> "Request | GeminiResponse | None":
|
|
211
|
+
client_id = self._get_client_id(request)
|
|
212
|
+
|
|
213
|
+
if self._is_rate_limited(client_id):
|
|
214
|
+
return GeminiResponse(
|
|
215
|
+
status=StatusCode.SLOW_DOWN,
|
|
216
|
+
meta=str(self.retry_after),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return None
|
xitzin/py.typed
ADDED
|
File without changes
|
xitzin/requests.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Request wrapper for Xitzin handlers.
|
|
2
|
+
|
|
3
|
+
Provides a convenient interface to the underlying Nauyaca GeminiRequest.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
from urllib.parse import unquote_plus
|
|
10
|
+
|
|
11
|
+
from nauyaca.protocol.request import GeminiRequest
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from cryptography.x509 import Certificate
|
|
15
|
+
|
|
16
|
+
from .application import Xitzin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RequestState:
|
|
20
|
+
"""Arbitrary state storage for a request.
|
|
21
|
+
|
|
22
|
+
Middleware and handlers can store arbitrary data here.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
request.state.user = get_current_user()
|
|
26
|
+
request.state.start_time = time.time()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
30
|
+
self.__dict__[name] = value
|
|
31
|
+
|
|
32
|
+
def __getattr__(self, name: str) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
return self.__dict__[name]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise AttributeError(f"'RequestState' has no attribute '{name}'") from None
|
|
37
|
+
|
|
38
|
+
def __delattr__(self, name: str) -> None:
|
|
39
|
+
try:
|
|
40
|
+
del self.__dict__[name]
|
|
41
|
+
except KeyError:
|
|
42
|
+
raise AttributeError(f"'RequestState' has no attribute '{name}'") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Request:
|
|
46
|
+
"""Wraps a Nauyaca GeminiRequest with convenient accessors.
|
|
47
|
+
|
|
48
|
+
Handlers receive this object as their first argument.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
@app.gemini("/user/{username}")
|
|
52
|
+
def profile(request: Request, username: str):
|
|
53
|
+
cert_id = request.client_cert_fingerprint
|
|
54
|
+
viewer = cert_id[:16] if cert_id else 'anonymous'
|
|
55
|
+
return f"# {username}'s Profile\\n\\nViewing as: {viewer}"
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
app: The Xitzin application instance.
|
|
59
|
+
state: Arbitrary state storage for this request.
|
|
60
|
+
path: The URL path component.
|
|
61
|
+
query: The decoded query string (user input).
|
|
62
|
+
raw_query: The raw (URL-encoded) query string.
|
|
63
|
+
client_cert: The client's TLS certificate, if provided.
|
|
64
|
+
client_cert_fingerprint: SHA-256 fingerprint of client certificate.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, raw_request: GeminiRequest, app: Xitzin | None = None) -> None:
|
|
68
|
+
self._raw_request = raw_request
|
|
69
|
+
self._app = app
|
|
70
|
+
self._state = RequestState()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def app(self) -> Xitzin:
|
|
74
|
+
"""The Xitzin application handling this request."""
|
|
75
|
+
if self._app is None:
|
|
76
|
+
msg = "Request is not bound to an application"
|
|
77
|
+
raise RuntimeError(msg)
|
|
78
|
+
return self._app
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def state(self) -> RequestState:
|
|
82
|
+
"""Arbitrary state storage for this request."""
|
|
83
|
+
return self._state
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def path(self) -> str:
|
|
87
|
+
"""The URL path component."""
|
|
88
|
+
return self._raw_request.path
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def raw_query(self) -> str:
|
|
92
|
+
"""The raw (URL-encoded) query string."""
|
|
93
|
+
return self._raw_request.query
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def query(self) -> str:
|
|
97
|
+
"""The decoded query string.
|
|
98
|
+
|
|
99
|
+
Gemini uses URL query strings for user input (status 10/11 flow).
|
|
100
|
+
This property decodes the query string for convenient access.
|
|
101
|
+
"""
|
|
102
|
+
if not self._raw_request.query:
|
|
103
|
+
return ""
|
|
104
|
+
return unquote_plus(self._raw_request.query)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def url(self) -> str:
|
|
108
|
+
"""The full normalized URL."""
|
|
109
|
+
return self._raw_request.normalized_url
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def raw_url(self) -> str:
|
|
113
|
+
"""The original URL from the request."""
|
|
114
|
+
return self._raw_request.raw_url
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def hostname(self) -> str:
|
|
118
|
+
"""The server hostname from the URL."""
|
|
119
|
+
return self._raw_request.hostname
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def port(self) -> int:
|
|
123
|
+
"""The server port from the URL."""
|
|
124
|
+
return self._raw_request.port
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def client_cert(self) -> Certificate | None:
|
|
128
|
+
"""The client's TLS certificate, if provided."""
|
|
129
|
+
return self._raw_request.client_cert
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def client_cert_fingerprint(self) -> str | None:
|
|
133
|
+
"""SHA-256 fingerprint of the client certificate."""
|
|
134
|
+
return self._raw_request.client_cert_fingerprint
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def remote_addr(self) -> str | None:
|
|
138
|
+
"""The client's IP address, if available.
|
|
139
|
+
|
|
140
|
+
Note: This property returns the client IP address if it was set
|
|
141
|
+
by the server or middleware. In CGI context, this is passed to
|
|
142
|
+
scripts via the REMOTE_ADDR environment variable.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The client IP address string, or None if not available.
|
|
146
|
+
"""
|
|
147
|
+
return getattr(self._raw_request, "remote_addr", None)
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Request({self._raw_request.raw_url!r})"
|