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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any