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/templating.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Gemtext template engine using Jinja2.
|
|
2
|
+
|
|
3
|
+
This module provides a Jinja2-based template engine configured for
|
|
4
|
+
rendering Gemtext (.gmi) templates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from jinja2 import Environment, FileSystemLoader
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .application import Xitzin
|
|
16
|
+
|
|
17
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
18
|
+
from nauyaca.protocol.status import StatusCode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TemplateResponse:
|
|
22
|
+
"""Response from a rendered template.
|
|
23
|
+
|
|
24
|
+
Can be returned from handlers and will be converted to a GeminiResponse.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, content: str, mime_type: str = "text/gemini") -> None:
|
|
28
|
+
self.content = content
|
|
29
|
+
self.mime_type = mime_type
|
|
30
|
+
|
|
31
|
+
def to_gemini_response(self) -> GeminiResponse:
|
|
32
|
+
return GeminiResponse(
|
|
33
|
+
status=StatusCode.SUCCESS,
|
|
34
|
+
meta=self.mime_type,
|
|
35
|
+
body=self.content,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _link_filter(url: str, text: str | None = None) -> str:
|
|
40
|
+
"""Generate a Gemtext link line.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
url: The URL to link to.
|
|
44
|
+
text: Optional link text.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A Gemtext link line.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
{{ "/about" | link("About Us") }}
|
|
51
|
+
=> /about About Us
|
|
52
|
+
"""
|
|
53
|
+
if text:
|
|
54
|
+
return f"=> {url} {text}"
|
|
55
|
+
return f"=> {url}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _heading_filter(text: str, level: int = 1) -> str:
|
|
59
|
+
"""Generate a Gemtext heading.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
text: The heading text.
|
|
63
|
+
level: Heading level (1-3).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A Gemtext heading line.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
{{ "Welcome" | heading(1) }}
|
|
70
|
+
# Welcome
|
|
71
|
+
"""
|
|
72
|
+
prefix = "#" * min(max(level, 1), 3)
|
|
73
|
+
return f"{prefix} {text}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _list_filter(items: list[str]) -> str:
|
|
77
|
+
"""Generate a Gemtext list.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
items: List of items.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Gemtext list lines joined by newlines.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
{{ ["Apple", "Banana", "Cherry"] | list }}
|
|
87
|
+
* Apple
|
|
88
|
+
* Banana
|
|
89
|
+
* Cherry
|
|
90
|
+
"""
|
|
91
|
+
return "\n".join(f"* {item}" for item in items)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _quote_filter(text: str) -> str:
|
|
95
|
+
"""Generate a Gemtext blockquote.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
text: The text to quote. Multi-line text is supported.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Gemtext quote lines.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
{{ "Hello world" | quote }}
|
|
105
|
+
> Hello world
|
|
106
|
+
"""
|
|
107
|
+
lines = text.split("\n")
|
|
108
|
+
return "\n".join(f"> {line}" for line in lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _preformat_filter(text: str, alt_text: str = "") -> str:
|
|
112
|
+
"""Generate a Gemtext preformatted block.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
text: The preformatted text.
|
|
116
|
+
alt_text: Optional alt text for the preformat toggle.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Gemtext preformatted block.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
{{ code | preformat("python") }}
|
|
123
|
+
```python
|
|
124
|
+
def hello():
|
|
125
|
+
print("Hello!")
|
|
126
|
+
```
|
|
127
|
+
"""
|
|
128
|
+
return f"```{alt_text}\n{text}\n```"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class GemtextEnvironment(Environment):
|
|
132
|
+
"""Jinja2 environment configured for Gemtext templates.
|
|
133
|
+
|
|
134
|
+
This environment:
|
|
135
|
+
- Disables HTML autoescaping (not needed for Gemtext)
|
|
136
|
+
- Trims blocks and strips leading whitespace
|
|
137
|
+
- Adds Gemtext-specific filters
|
|
138
|
+
- Provides a `reverse()` global function for URL reversing (when app is provided)
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, templates_dir: Path, app: Xitzin | None = None) -> None:
|
|
142
|
+
loader = FileSystemLoader(str(templates_dir))
|
|
143
|
+
super().__init__(
|
|
144
|
+
loader=loader,
|
|
145
|
+
autoescape=False, # Gemtext doesn't need HTML escaping
|
|
146
|
+
trim_blocks=True,
|
|
147
|
+
lstrip_blocks=True,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Register Gemtext-specific filters
|
|
151
|
+
self.filters["link"] = _link_filter
|
|
152
|
+
self.filters["heading"] = _heading_filter
|
|
153
|
+
self.filters["list"] = _list_filter
|
|
154
|
+
self.filters["quote"] = _quote_filter
|
|
155
|
+
self.filters["preformat"] = _preformat_filter
|
|
156
|
+
|
|
157
|
+
# Register reverse() global if app is provided
|
|
158
|
+
if app is not None:
|
|
159
|
+
self.globals["reverse"] = app.reverse
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TemplateEngine:
|
|
163
|
+
"""High-level template rendering interface.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
engine = TemplateEngine(Path("templates"))
|
|
167
|
+
response = engine.render("page.gmi", title="Welcome", items=["a", "b"])
|
|
168
|
+
|
|
169
|
+
With app integration (enables reverse() in templates):
|
|
170
|
+
engine = TemplateEngine(Path("templates"), app=app)
|
|
171
|
+
# In templates:
|
|
172
|
+
# {{ reverse("user_profile", username="alice") | link("Profile") }}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, templates_dir: Path, app: Xitzin | None = None) -> None:
|
|
176
|
+
"""Create a template engine.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
templates_dir: Directory containing template files.
|
|
180
|
+
app: Optional Xitzin app instance for URL reversing in templates.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If templates_dir doesn't exist.
|
|
184
|
+
"""
|
|
185
|
+
if not templates_dir.exists():
|
|
186
|
+
msg = f"Templates directory does not exist: {templates_dir}"
|
|
187
|
+
raise ValueError(msg)
|
|
188
|
+
|
|
189
|
+
self._env = GemtextEnvironment(templates_dir, app=app)
|
|
190
|
+
|
|
191
|
+
def render(self, template_name: str, **context: Any) -> TemplateResponse:
|
|
192
|
+
"""Render a template file.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
template_name: Name of the template file (e.g., "page.gmi").
|
|
196
|
+
**context: Variables to pass to the template.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
TemplateResponse that can be returned from handlers.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
return engine.render("user.gmi", username="alice", posts=posts)
|
|
203
|
+
"""
|
|
204
|
+
template = self._env.get_template(template_name)
|
|
205
|
+
content = template.render(**context)
|
|
206
|
+
return TemplateResponse(content)
|
|
207
|
+
|
|
208
|
+
def render_string(self, source: str, **context: Any) -> str:
|
|
209
|
+
"""Render a template from a string.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
source: Template source string.
|
|
213
|
+
**context: Variables to pass to the template.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Rendered string.
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
result = engine.render_string("# {{ title }}", title="Hello")
|
|
220
|
+
"""
|
|
221
|
+
template = self._env.from_string(source)
|
|
222
|
+
return template.render(**context)
|
xitzin/testing.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Testing utilities for Xitzin applications.
|
|
2
|
+
|
|
3
|
+
This module provides a TestClient for testing Gemini handlers
|
|
4
|
+
without running a real server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Generator
|
|
13
|
+
from urllib.parse import quote_plus
|
|
14
|
+
|
|
15
|
+
from nauyaca.protocol.request import GeminiRequest
|
|
16
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .application import Xitzin
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TestResponse:
|
|
24
|
+
"""Response from the test client.
|
|
25
|
+
|
|
26
|
+
Provides convenient access to response data and status checking methods.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
status: int
|
|
30
|
+
"""The status code (10-62)."""
|
|
31
|
+
|
|
32
|
+
meta: str
|
|
33
|
+
"""The meta field (MIME type, prompt, redirect URL, or error message)."""
|
|
34
|
+
|
|
35
|
+
body: str | None
|
|
36
|
+
"""The response body (only present for 2x success responses)."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_success(self) -> bool:
|
|
40
|
+
"""Check if this is a success response (2x)."""
|
|
41
|
+
return 20 <= self.status < 30
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_input_required(self) -> bool:
|
|
45
|
+
"""Check if input is required (1x)."""
|
|
46
|
+
return 10 <= self.status < 20
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_redirect(self) -> bool:
|
|
50
|
+
"""Check if this is a redirect (3x)."""
|
|
51
|
+
return 30 <= self.status < 40
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_error(self) -> bool:
|
|
55
|
+
"""Check if this is an error response (4x, 5x, 6x)."""
|
|
56
|
+
return 40 <= self.status < 70
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_certificate_required(self) -> bool:
|
|
60
|
+
"""Check if a client certificate is required (6x)."""
|
|
61
|
+
return 60 <= self.status < 70
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def redirect_url(self) -> str | None:
|
|
65
|
+
"""Get the redirect URL if this is a redirect response."""
|
|
66
|
+
if self.is_redirect:
|
|
67
|
+
return self.meta
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def input_prompt(self) -> str | None:
|
|
72
|
+
"""Get the input prompt if input is required."""
|
|
73
|
+
if self.is_input_required:
|
|
74
|
+
return self.meta
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def mime_type(self) -> str | None:
|
|
79
|
+
"""Get the MIME type if this is a success response."""
|
|
80
|
+
if self.is_success:
|
|
81
|
+
return self.meta.split(";")[0].strip()
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
lines = [f"TestResponse(status={self.status}, meta={self.meta!r})"]
|
|
86
|
+
if self.body:
|
|
87
|
+
preview = self.body[:100] + "..." if len(self.body) > 100 else self.body
|
|
88
|
+
lines.append(f" body={preview!r}")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestClient:
|
|
93
|
+
"""Test client for Xitzin applications.
|
|
94
|
+
|
|
95
|
+
Allows testing handlers without running a real Gemini server.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
app = Xitzin()
|
|
99
|
+
|
|
100
|
+
@app.gemini("/")
|
|
101
|
+
def home(request: Request):
|
|
102
|
+
return "# Welcome"
|
|
103
|
+
|
|
104
|
+
client = TestClient(app)
|
|
105
|
+
response = client.get("/")
|
|
106
|
+
assert response.status == 20
|
|
107
|
+
assert "Welcome" in response.body
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, app: "Xitzin") -> None:
|
|
111
|
+
"""Create a test client for an application.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
app: The Xitzin application to test.
|
|
115
|
+
"""
|
|
116
|
+
self._app = app
|
|
117
|
+
self._default_fingerprint: str | None = None
|
|
118
|
+
|
|
119
|
+
def get(
|
|
120
|
+
self,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
query: str | None = None,
|
|
124
|
+
cert_fingerprint: str | None = None,
|
|
125
|
+
) -> TestResponse:
|
|
126
|
+
"""Make a test request.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
path: The request path (e.g., "/user/alice").
|
|
130
|
+
query: Optional query string (for input responses).
|
|
131
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
TestResponse with status, meta, and body.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
response = client.get("/")
|
|
138
|
+
assert response.is_success
|
|
139
|
+
"""
|
|
140
|
+
# Build URL
|
|
141
|
+
url = f"gemini://testserver{path}"
|
|
142
|
+
if query:
|
|
143
|
+
url += f"?{quote_plus(query)}"
|
|
144
|
+
|
|
145
|
+
# Create mock GeminiRequest
|
|
146
|
+
request = GeminiRequest.from_line(url)
|
|
147
|
+
|
|
148
|
+
# Set certificate info
|
|
149
|
+
fingerprint = cert_fingerprint or self._default_fingerprint
|
|
150
|
+
if fingerprint:
|
|
151
|
+
request.client_cert_fingerprint = fingerprint
|
|
152
|
+
|
|
153
|
+
# Handle request through the app
|
|
154
|
+
response = self._handle_sync(request)
|
|
155
|
+
|
|
156
|
+
return TestResponse(
|
|
157
|
+
status=response.status,
|
|
158
|
+
meta=response.meta,
|
|
159
|
+
body=response.body,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def get_input(
|
|
163
|
+
self,
|
|
164
|
+
path: str,
|
|
165
|
+
input_value: str,
|
|
166
|
+
*,
|
|
167
|
+
cert_fingerprint: str | None = None,
|
|
168
|
+
) -> TestResponse:
|
|
169
|
+
"""Make a request with an input value.
|
|
170
|
+
|
|
171
|
+
Simulates a user responding to a status 10/11 input prompt.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
path: The request path.
|
|
175
|
+
input_value: The user's input (will be URL-encoded).
|
|
176
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
TestResponse from the handler.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
# First request gets input prompt
|
|
183
|
+
response = client.get("/search")
|
|
184
|
+
assert response.is_input_required
|
|
185
|
+
|
|
186
|
+
# Second request with input value
|
|
187
|
+
response = client.get_input("/search", "hello world")
|
|
188
|
+
assert response.is_success
|
|
189
|
+
"""
|
|
190
|
+
return self.get(path, query=input_value, cert_fingerprint=cert_fingerprint)
|
|
191
|
+
|
|
192
|
+
def with_certificate(self, fingerprint: str) -> "TestClient":
|
|
193
|
+
"""Create a new client with a default certificate fingerprint.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
fingerprint: The certificate fingerprint to use for all requests.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A new TestClient with the default fingerprint set.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
# Create authenticated client
|
|
203
|
+
auth_client = client.with_certificate("abc123...")
|
|
204
|
+
|
|
205
|
+
# All requests from this client include the certificate
|
|
206
|
+
response = auth_client.get("/admin")
|
|
207
|
+
assert response.is_success
|
|
208
|
+
"""
|
|
209
|
+
new_client = TestClient(self._app)
|
|
210
|
+
new_client._default_fingerprint = fingerprint
|
|
211
|
+
return new_client
|
|
212
|
+
|
|
213
|
+
def _handle_sync(self, request: GeminiRequest) -> GeminiResponse:
|
|
214
|
+
"""Handle a request synchronously."""
|
|
215
|
+
try:
|
|
216
|
+
loop = asyncio.get_event_loop()
|
|
217
|
+
except RuntimeError:
|
|
218
|
+
loop = asyncio.new_event_loop()
|
|
219
|
+
asyncio.set_event_loop(loop)
|
|
220
|
+
|
|
221
|
+
return loop.run_until_complete(self._app._handle_request(request))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@contextmanager
|
|
225
|
+
def test_app(app: "Xitzin") -> Generator[TestClient, None, None]:
|
|
226
|
+
"""Context manager that runs the app's lifespan for testing.
|
|
227
|
+
|
|
228
|
+
This runs startup handlers before yielding and shutdown handlers
|
|
229
|
+
when the context exits.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
app: The Xitzin application to test.
|
|
233
|
+
|
|
234
|
+
Yields:
|
|
235
|
+
A TestClient bound to the application.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
app = Xitzin()
|
|
239
|
+
|
|
240
|
+
@app.on_startup
|
|
241
|
+
async def startup():
|
|
242
|
+
app.state.db = await connect_db()
|
|
243
|
+
|
|
244
|
+
@app.on_shutdown
|
|
245
|
+
async def shutdown():
|
|
246
|
+
await app.state.db.close()
|
|
247
|
+
|
|
248
|
+
with test_app(app) as client:
|
|
249
|
+
# Startup has run, db is connected
|
|
250
|
+
response = client.get("/")
|
|
251
|
+
assert response.is_success
|
|
252
|
+
# Shutdown has run, db is closed
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
loop = asyncio.get_event_loop()
|
|
256
|
+
except RuntimeError:
|
|
257
|
+
loop = asyncio.new_event_loop()
|
|
258
|
+
asyncio.set_event_loop(loop)
|
|
259
|
+
|
|
260
|
+
# Run startup
|
|
261
|
+
loop.run_until_complete(app._run_startup())
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
yield TestClient(app)
|
|
265
|
+
finally:
|
|
266
|
+
# Run shutdown
|
|
267
|
+
loop.run_until_complete(app._run_shutdown())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: xitzin
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A Gemini Application Framework
|
|
5
|
+
Keywords: gemini,protocol,framework,async,geminispace
|
|
6
|
+
Author: Alan Velasco
|
|
7
|
+
Author-email: Alan Velasco <alanvelasco.a@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: jinja2>=3.1.0
|
|
21
|
+
Requires-Dist: nauyaca>=0.3.2
|
|
22
|
+
Requires-Dist: rich>=14.2.0
|
|
23
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
26
|
+
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
27
|
+
Project-URL: Homepage, https://github.com/alanbato/xitzin
|
|
28
|
+
Project-URL: Issues, https://github.com/alanbato/xitzin/issues
|
|
29
|
+
Project-URL: Repository, https://github.com/alanbato/xitzin.git
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Xitzin
|
|
33
|
+
|
|
34
|
+
**Application Framework for the Geminispace**
|
|
35
|
+
|
|
36
|
+
Xitzin brings a modern Python developer experience to the [Gemini protocol](https://geminiprotocol.net/). Build Gemini capsules with familiar patterns: decorators for routing and type-annotated path parameters.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from xitzin import Xitzin, Request
|
|
40
|
+
|
|
41
|
+
app = Xitzin()
|
|
42
|
+
|
|
43
|
+
@app.gemini("/")
|
|
44
|
+
def home(request: Request):
|
|
45
|
+
return "# Welcome to my capsule!"
|
|
46
|
+
|
|
47
|
+
@app.gemini("/user/{username}")
|
|
48
|
+
def profile(request: Request, username: str):
|
|
49
|
+
return f"# {username}'s Profile"
|
|
50
|
+
|
|
51
|
+
@app.input("/search", prompt="Enter query:")
|
|
52
|
+
def search(request: Request, query: str):
|
|
53
|
+
return f"# Results for: {query}"
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
app.run()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- **Decorator-based routing** with `@app.gemini()` and automatic path parameter extraction
|
|
62
|
+
- **User input handling** via `@app.input()` for Gemini's status 10/11 prompts
|
|
63
|
+
- **Certificate authentication** with `@require_certificate` and fingerprint whitelisting
|
|
64
|
+
- **Jinja2 templates** with Gemtext-aware filters (links, headings, lists)
|
|
65
|
+
- **Middleware support** for logging, rate limiting, and custom processing
|
|
66
|
+
- **Testing utilities** with in-memory `TestClient`
|
|
67
|
+
- **Async support** for both sync and async handlers
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install xitzin
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv add xitzin
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
1. Create `app.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from xitzin import Xitzin, Request
|
|
87
|
+
|
|
88
|
+
app = Xitzin()
|
|
89
|
+
|
|
90
|
+
@app.gemini("/")
|
|
91
|
+
def home(request: Request):
|
|
92
|
+
return "# Hello, Geminispace!"
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
app.run()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
2. Generate TLS certificates:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
3. Run your capsule:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python app.py
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
4. Visit `gemini://localhost/` with a Gemini client like [Astronomo](https://github.com/alanbato/astronomo/).
|
|
111
|
+
|
|
112
|
+
## Documentation
|
|
113
|
+
|
|
114
|
+
Full documentation is available at [xitzin.readthedocs.io](https://xitzin.readthedocs.io/).
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
xitzin/__init__.py,sha256=9zCk_h4rI-QGLE3q95NSgPha-dtvSOEdwiZuTQmbBR4,1637
|
|
2
|
+
xitzin/application.py,sha256=GWcL-u1C6xiUwYwMxg60kQxprJ_KaLcJFPcIBf8xCzU,17909
|
|
3
|
+
xitzin/auth.py,sha256=KT1WprT4qF1u03T8lAGO_UzQBLQcg-OegIFubay7VlA,4511
|
|
4
|
+
xitzin/cgi.py,sha256=nmKaeLwYfk3esRnxQnn1Rx-6EHKq2zL-Xvpw5hdI-rk,17627
|
|
5
|
+
xitzin/exceptions.py,sha256=JdxAXHtnXMf6joXeFQMfxlcdbv_R5odb-qYPMbgRTZY,3382
|
|
6
|
+
xitzin/middleware.py,sha256=z2QJJjMEinz5e4NeZvjsCeyMLt8ZD2a3Ox2V2vuTnk8,6748
|
|
7
|
+
xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
xitzin/requests.py,sha256=EeDqh0roz7eTItqarEDj53iFxMZlVsEIcimybKdqAHI,4612
|
|
9
|
+
xitzin/responses.py,sha256=N4HeP9Yy2PIHY0zsGa2pj8xvX2OFHAjtqR5nogNh8UQ,6545
|
|
10
|
+
xitzin/routing.py,sha256=SiT9J617GfQR_rPDD3Ivw0_N4CTh8-Jb_AZjWJnT5sw,12409
|
|
11
|
+
xitzin/templating.py,sha256=spjxb05wgwKA4txOMbWJuwEVMaoLovo13_ukbXAcBDY,6040
|
|
12
|
+
xitzin/testing.py,sha256=JO41TeIJeb1CqHVqBOjCVAvv9BOlvDJYzAeG83ZofdE,7572
|
|
13
|
+
xitzin-0.1.2.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
|
|
14
|
+
xitzin-0.1.2.dist-info/METADATA,sha256=wCUUakIFCbz5xTxqYnn5OymfmjoAIxJpxI-YD5v910E,3272
|
|
15
|
+
xitzin-0.1.2.dist-info/RECORD,,
|