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 ADDED
@@ -0,0 +1,78 @@
1
+ """Xitzin - A Gemini Application Framework.
2
+
3
+ Xitzin is a framework for building Gemini protocol applications.
4
+ It uses Nauyaca for Gemini protocol communication.
5
+
6
+ Example:
7
+ from xitzin import Xitzin, Request
8
+
9
+ app = Xitzin()
10
+
11
+ @app.gemini("/")
12
+ def home(request: Request):
13
+ return "# Welcome to Gemini!"
14
+
15
+ @app.gemini("/user/{username}")
16
+ def profile(request: Request, username: str):
17
+ return f"# {username}'s Profile"
18
+
19
+ if __name__ == "__main__":
20
+ app.run()
21
+ """
22
+
23
+ from .application import Xitzin
24
+ from .cgi import CGIConfig, CGIHandler, CGIScript
25
+ from .exceptions import (
26
+ BadRequest,
27
+ CertificateNotAuthorized,
28
+ CertificateNotValid,
29
+ CertificateRequired,
30
+ CGIError,
31
+ GeminiException,
32
+ Gone,
33
+ InputRequired,
34
+ NotFound,
35
+ PermanentFailure,
36
+ ProxyError,
37
+ ProxyRequestRefused,
38
+ SensitiveInputRequired,
39
+ ServerUnavailable,
40
+ SlowDown,
41
+ TemporaryFailure,
42
+ )
43
+ from .requests import Request
44
+ from .responses import Input, Link, Redirect, Response
45
+
46
+ __all__ = [
47
+ # Main application
48
+ "Xitzin",
49
+ # Request/Response
50
+ "Request",
51
+ "Response",
52
+ "Input",
53
+ "Redirect",
54
+ "Link",
55
+ # CGI support
56
+ "CGIConfig",
57
+ "CGIHandler",
58
+ "CGIScript",
59
+ # Exceptions
60
+ "GeminiException",
61
+ "InputRequired",
62
+ "SensitiveInputRequired",
63
+ "TemporaryFailure",
64
+ "ServerUnavailable",
65
+ "CGIError",
66
+ "ProxyError",
67
+ "SlowDown",
68
+ "PermanentFailure",
69
+ "NotFound",
70
+ "Gone",
71
+ "ProxyRequestRefused",
72
+ "BadRequest",
73
+ "CertificateRequired",
74
+ "CertificateNotAuthorized",
75
+ "CertificateNotValid",
76
+ ]
77
+
78
+ __version__ = "0.1.0"
xitzin/application.py ADDED
@@ -0,0 +1,548 @@
1
+ """Main Xitzin application class.
2
+
3
+ This module provides the Xitzin class, the main entry point for creating
4
+ Gemini applications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, Callable
12
+
13
+ from nauyaca.protocol.request import GeminiRequest
14
+ from nauyaca.protocol.response import GeminiResponse
15
+ from nauyaca.protocol.status import StatusCode
16
+
17
+ from .exceptions import GeminiException, NotFound
18
+ from .requests import Request
19
+ from .responses import Input, Redirect, convert_response
20
+ from .routing import MountedRoute, Route, Router
21
+
22
+ if TYPE_CHECKING:
23
+ from .templating import TemplateEngine
24
+
25
+
26
+ class AppState:
27
+ """Application-level state storage.
28
+
29
+ Store shared resources like database connections here.
30
+
31
+ Example:
32
+ app.state.db = create_db_connection()
33
+ """
34
+
35
+ def __setattr__(self, name: str, value: Any) -> None:
36
+ self.__dict__[name] = value
37
+
38
+ def __getattr__(self, name: str) -> Any:
39
+ try:
40
+ return self.__dict__[name]
41
+ except KeyError:
42
+ raise AttributeError(f"'AppState' has no attribute '{name}'") from None
43
+
44
+
45
+ class Xitzin:
46
+ """Gemini Application Framework.
47
+
48
+ Xitzin provides an interface for building Gemini applications.
49
+
50
+ Example:
51
+ app = Xitzin(title="My Capsule")
52
+
53
+ @app.gemini("/")
54
+ def homepage(request: Request):
55
+ return "# Welcome to my capsule!"
56
+
57
+ @app.gemini("/user/{username}")
58
+ def profile(request: Request, username: str):
59
+ return f"# {username}'s Profile"
60
+
61
+ if __name__ == "__main__":
62
+ app.run()
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ title: str = "Xitzin App",
69
+ version: str = "0.1.0",
70
+ templates_dir: Path | str | None = None,
71
+ ) -> None:
72
+ """Create a new Xitzin application.
73
+
74
+ Args:
75
+ title: Application title (for documentation).
76
+ version: Application version.
77
+ templates_dir: Directory containing Gemtext templates.
78
+ """
79
+ self.title = title
80
+ self.version = version
81
+ self._router = Router()
82
+ self._state = AppState()
83
+ self._templates: TemplateEngine | None = None
84
+ self._startup_handlers: list[Callable[[], Any]] = []
85
+ self._shutdown_handlers: list[Callable[[], Any]] = []
86
+ self._middleware: list[Callable[..., Any]] = []
87
+
88
+ if templates_dir:
89
+ self._init_templates(Path(templates_dir))
90
+
91
+ def _init_templates(self, templates_dir: Path) -> None:
92
+ """Initialize the template engine."""
93
+ from .templating import TemplateEngine
94
+
95
+ self._templates = TemplateEngine(templates_dir, app=self)
96
+
97
+ @property
98
+ def state(self) -> AppState:
99
+ """Application-level state storage."""
100
+ return self._state
101
+
102
+ def template(self, name: str, **context: Any) -> Any:
103
+ """Render a template.
104
+
105
+ Args:
106
+ name: Template filename (e.g., "page.gmi").
107
+ **context: Variables to pass to the template.
108
+
109
+ Returns:
110
+ TemplateResponse that can be returned from handlers.
111
+
112
+ Raises:
113
+ RuntimeError: If no templates directory was configured.
114
+ """
115
+ if self._templates is None:
116
+ msg = "No templates directory configured"
117
+ raise RuntimeError(msg)
118
+ return self._templates.render(name, **context)
119
+
120
+ def reverse(self, name: str, **params: Any) -> str:
121
+ """Build URL for a named route.
122
+
123
+ Args:
124
+ name: Route name.
125
+ **params: Path parameters.
126
+
127
+ Returns:
128
+ URL path string.
129
+
130
+ Raises:
131
+ ValueError: If route name not found or parameters missing.
132
+
133
+ Example:
134
+ url = app.reverse("user_profile", username="alice")
135
+ # Returns "/user/alice"
136
+ """
137
+ return self._router.reverse(name, **params)
138
+
139
+ def redirect(
140
+ self, name: str, *, permanent: bool = False, **params: Any
141
+ ) -> Redirect:
142
+ """Create a redirect to a named route.
143
+
144
+ Args:
145
+ name: Route name.
146
+ permanent: If True, use status 31 (permanent redirect).
147
+ **params: Path parameters.
148
+
149
+ Returns:
150
+ Redirect response object.
151
+
152
+ Example:
153
+ @app.gemini("/old-profile/{username}")
154
+ def old_profile(request: Request, username: str):
155
+ return app.redirect("user_profile", username=username, permanent=True)
156
+ """
157
+ url = self.reverse(name, **params)
158
+ return Redirect(url, permanent=permanent)
159
+
160
+ def gemini(
161
+ self, path: str, *, name: str | None = None
162
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
163
+ """Register a route handler.
164
+
165
+ Args:
166
+ path: URL path pattern (e.g., "/user/{id}").
167
+ name: Optional route name for URL reversing. Defaults to function name.
168
+
169
+ Returns:
170
+ Decorator function.
171
+
172
+ Example:
173
+ @app.gemini("/")
174
+ def home(request: Request):
175
+ return "# Home"
176
+
177
+ @app.gemini("/user/{username}", name="user_profile")
178
+ def profile(request: Request, username: str):
179
+ return f"# {username}"
180
+ """
181
+
182
+ def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
183
+ route = Route(path, handler, name=name)
184
+ self._router.add_route(route)
185
+ return handler
186
+
187
+ return decorator
188
+
189
+ def input(
190
+ self,
191
+ path: str,
192
+ *,
193
+ prompt: str,
194
+ sensitive: bool = False,
195
+ name: str | None = None,
196
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
197
+ """Register an input route (status 10/11 flow).
198
+
199
+ When a request arrives without a query string, the client is prompted
200
+ for input. When the request includes a query string, the handler is
201
+ called with the decoded input as the `query` parameter.
202
+
203
+ Args:
204
+ path: URL path pattern.
205
+ prompt: Prompt text shown to the user.
206
+ sensitive: If True, use status 11 (sensitive input).
207
+ name: Optional route name for URL reversing. Defaults to function name.
208
+
209
+ Returns:
210
+ Decorator function.
211
+
212
+ Example:
213
+ @app.input("/search", prompt="Enter search query:", name="search")
214
+ def search(request: Request, query: str):
215
+ return f"# Results for: {query}"
216
+ """
217
+
218
+ def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
219
+ route = Route(
220
+ path, handler, name=name, input_prompt=prompt, sensitive_input=sensitive
221
+ )
222
+ self._router.add_route(route)
223
+ return handler
224
+
225
+ return decorator
226
+
227
+ def mount(
228
+ self,
229
+ path: str,
230
+ handler: Callable[..., Any],
231
+ *,
232
+ name: str | None = None,
233
+ ) -> None:
234
+ """Mount a handler at a path prefix.
235
+
236
+ Mounted handlers receive requests for any path starting with the prefix.
237
+ The handler receives (request, path_info) where path_info is the
238
+ remaining path after the mount prefix.
239
+
240
+ Args:
241
+ path: Mount point prefix (e.g., "/cgi-bin", "/api").
242
+ handler: Callable that takes (request, path_info) and returns a response.
243
+ name: Optional name for the mount.
244
+
245
+ Example:
246
+ from xitzin.cgi import CGIHandler
247
+
248
+ app.mount("/cgi-bin", CGIHandler(script_dir="./scripts"))
249
+
250
+ # Requests to /cgi-bin/hello.py will call:
251
+ # handler(request, path_info="/hello.py")
252
+ """
253
+ mounted = MountedRoute(path, handler, name=name)
254
+ self._router.add_mounted_route(mounted)
255
+
256
+ def cgi(
257
+ self,
258
+ path: str,
259
+ script_dir: Path | str,
260
+ *,
261
+ name: str | None = None,
262
+ timeout: float = 30.0,
263
+ app_state_keys: list[str] | None = None,
264
+ ) -> None:
265
+ """Mount a CGI directory at a path prefix.
266
+
267
+ This is a convenience method that creates a CGIHandler and mounts it.
268
+
269
+ Args:
270
+ path: Mount point prefix (e.g., "/cgi-bin").
271
+ script_dir: Directory containing CGI scripts.
272
+ name: Optional name for the mount.
273
+ timeout: Maximum script execution time in seconds.
274
+ app_state_keys: App state keys to pass as XITZIN_* env vars.
275
+
276
+ Example:
277
+ app.cgi("/cgi-bin", "/srv/gemini/cgi-bin", timeout=30)
278
+
279
+ # Requests to /cgi-bin/hello.py execute:
280
+ # /srv/gemini/cgi-bin/hello.py
281
+ """
282
+ from .cgi import CGIConfig, CGIHandler
283
+
284
+ config = CGIConfig(
285
+ timeout=timeout,
286
+ app_state_keys=app_state_keys or [],
287
+ )
288
+ handler = CGIHandler(script_dir, config=config)
289
+ self.mount(path, handler, name=name)
290
+
291
+ def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
292
+ """Register a startup event handler.
293
+
294
+ Args:
295
+ handler: Function to call on startup.
296
+
297
+ Example:
298
+ @app.on_startup
299
+ async def startup():
300
+ app.state.db = await create_db_pool()
301
+ """
302
+ self._startup_handlers.append(handler)
303
+ return handler
304
+
305
+ def on_shutdown(self, handler: Callable[[], Any]) -> Callable[[], Any]:
306
+ """Register a shutdown event handler.
307
+
308
+ Args:
309
+ handler: Function to call on shutdown.
310
+
311
+ Example:
312
+ @app.on_shutdown
313
+ async def shutdown():
314
+ await app.state.db.close()
315
+ """
316
+ self._shutdown_handlers.append(handler)
317
+ return handler
318
+
319
+ def middleware(self, handler: Callable[..., Any]) -> Callable[..., Any]:
320
+ """Register middleware as a decorator.
321
+
322
+ Middleware receives (request, call_next) and must call call_next
323
+ to continue processing.
324
+
325
+ Args:
326
+ handler: Middleware function.
327
+
328
+ Example:
329
+ @app.middleware
330
+ async def log_requests(request: Request, call_next):
331
+ print(f"Request: {request.path}")
332
+ response = await call_next(request)
333
+ print(f"Response: {response.status}")
334
+ return response
335
+ """
336
+ self._middleware.append(handler)
337
+ return handler
338
+
339
+ async def _run_startup(self) -> None:
340
+ """Run all startup handlers."""
341
+ for handler in self._startup_handlers:
342
+ if asyncio.iscoroutinefunction(handler):
343
+ await handler()
344
+ else:
345
+ handler()
346
+
347
+ async def _run_shutdown(self) -> None:
348
+ """Run all shutdown handlers in reverse order."""
349
+ for handler in reversed(self._shutdown_handlers):
350
+ if asyncio.iscoroutinefunction(handler):
351
+ await handler()
352
+ else:
353
+ handler()
354
+
355
+ async def _handle_request(self, raw_request: GeminiRequest) -> GeminiResponse:
356
+ """Handle an incoming request.
357
+
358
+ This is the main request processing logic.
359
+ """
360
+ request = Request(raw_request, self)
361
+
362
+ try:
363
+ # Check mounted routes first
364
+ mount_match = self._router.match_mount(request.path)
365
+ if mount_match is not None:
366
+ mounted_route, path_info = mount_match
367
+
368
+ # Build middleware chain for mounted handler
369
+ async def call_mounted_handler(req: Request) -> GeminiResponse:
370
+ result = await mounted_route.call_handler(req, path_info)
371
+ return convert_response(result, req)
372
+
373
+ # Apply middleware
374
+ handler = call_mounted_handler
375
+ for mw in reversed(self._middleware):
376
+ handler = self._wrap_middleware(mw, handler)
377
+
378
+ return await handler(request)
379
+
380
+ # Match regular route
381
+ match = self._router.match(request.path)
382
+ if match is None:
383
+ raise NotFound(f"No route matches: {request.path}")
384
+
385
+ route, params = match
386
+
387
+ # Handle input flow
388
+ if route.input_prompt and not request.query:
389
+ return Input(
390
+ route.input_prompt, route.sensitive_input
391
+ ).to_gemini_response()
392
+
393
+ # Add query to params for input routes
394
+ if route.input_prompt and request.query:
395
+ params["query"] = request.query
396
+
397
+ # Build middleware chain
398
+ async def call_handler(req: Request) -> GeminiResponse:
399
+ result = await route.call_handler(req, params)
400
+ return convert_response(result, req)
401
+
402
+ # Apply middleware (in reverse order so first registered runs first)
403
+ handler = call_handler
404
+ for mw in reversed(self._middleware):
405
+ handler = self._wrap_middleware(mw, handler)
406
+
407
+ return await handler(request)
408
+
409
+ except GeminiException as e:
410
+ return GeminiResponse(status=e.status_code, meta=e.message)
411
+ except Exception as e:
412
+ # Log the error and return a generic failure
413
+ import traceback
414
+
415
+ traceback.print_exc()
416
+ return GeminiResponse(
417
+ status=StatusCode.TEMPORARY_FAILURE,
418
+ meta=f"Internal error: {type(e).__name__}",
419
+ )
420
+
421
+ def _wrap_middleware(
422
+ self,
423
+ middleware: Callable[..., Any],
424
+ next_handler: Callable[[Request], Any],
425
+ ) -> Callable[[Request], Any]:
426
+ """Wrap a handler with middleware."""
427
+
428
+ async def wrapped(request: Request) -> GeminiResponse:
429
+ if asyncio.iscoroutinefunction(middleware):
430
+ return await middleware(request, next_handler)
431
+ return middleware(request, next_handler)
432
+
433
+ return wrapped
434
+
435
+ def handle_request_sync(self, raw_request: GeminiRequest) -> GeminiResponse:
436
+ """Handle a request synchronously (for testing)."""
437
+ return asyncio.get_event_loop().run_until_complete(
438
+ self._handle_request(raw_request)
439
+ )
440
+
441
+ async def run_async(
442
+ self,
443
+ host: str = "localhost",
444
+ port: int = 1965,
445
+ certfile: Path | str | None = None,
446
+ keyfile: Path | str | None = None,
447
+ ) -> None:
448
+ """Run the server asynchronously.
449
+
450
+ Args:
451
+ host: Host address to bind to.
452
+ port: Port to bind to.
453
+ certfile: Path to TLS certificate file.
454
+ keyfile: Path to TLS private key file.
455
+ """
456
+ from nauyaca.server.protocol import GeminiServerProtocol
457
+ from nauyaca.server.tls_protocol import TLSServerProtocol
458
+ from nauyaca.security.certificates import generate_self_signed_cert
459
+ from nauyaca.security.pyopenssl_tls import create_pyopenssl_server_context
460
+ import tempfile
461
+
462
+ # Run startup handlers
463
+ await self._run_startup()
464
+
465
+ try:
466
+ # Create PyOpenSSL context (accepts any self-signed client cert)
467
+ if certfile and keyfile:
468
+ ssl_context = create_pyopenssl_server_context(
469
+ str(certfile),
470
+ str(keyfile),
471
+ request_client_cert=True,
472
+ )
473
+ else:
474
+ # Generate self-signed cert for development
475
+ cert_pem, key_pem = generate_self_signed_cert(
476
+ hostname="localhost",
477
+ key_size=2048,
478
+ valid_days=365,
479
+ )
480
+
481
+ with (
482
+ tempfile.NamedTemporaryFile(
483
+ suffix=".pem", delete=False, mode="wb"
484
+ ) as cf,
485
+ tempfile.NamedTemporaryFile(
486
+ suffix=".key", delete=False, mode="wb"
487
+ ) as kf,
488
+ ):
489
+ cf.write(cert_pem)
490
+ kf.write(key_pem)
491
+ cf.flush()
492
+ kf.flush()
493
+ print("[Xitzin] Using self-signed certificate (development only)")
494
+ ssl_context = create_pyopenssl_server_context(
495
+ cf.name,
496
+ kf.name,
497
+ request_client_cert=True,
498
+ )
499
+
500
+ # Create handler that routes to our app
501
+ async def handle(request: GeminiRequest) -> GeminiResponse:
502
+ return await self._handle_request(request)
503
+
504
+ # Use TLSServerProtocol for manual TLS handling
505
+ # (supports self-signed client certs)
506
+ def create_protocol() -> TLSServerProtocol:
507
+ return TLSServerProtocol(
508
+ lambda: GeminiServerProtocol(handle, None), # type: ignore[arg-type]
509
+ ssl_context,
510
+ )
511
+
512
+ loop = asyncio.get_running_loop()
513
+ server = await loop.create_server(
514
+ create_protocol,
515
+ host,
516
+ port,
517
+ )
518
+
519
+ print(f"[Xitzin] {self.title} v{self.version}")
520
+ print(f"[Xitzin] Serving at gemini://{host}:{port}/")
521
+
522
+ async with server:
523
+ await server.serve_forever()
524
+
525
+ finally:
526
+ await self._run_shutdown()
527
+
528
+ def run(
529
+ self,
530
+ host: str = "localhost",
531
+ port: int = 1965,
532
+ certfile: Path | str | None = None,
533
+ keyfile: Path | str | None = None,
534
+ ) -> None:
535
+ """Run the server (blocking).
536
+
537
+ Args:
538
+ host: Host address to bind to.
539
+ port: Port to bind to.
540
+ certfile: Path to TLS certificate file.
541
+ keyfile: Path to TLS private key file.
542
+ """
543
+ try:
544
+ asyncio.run(
545
+ self.run_async(host=host, port=port, certfile=certfile, keyfile=keyfile)
546
+ )
547
+ except KeyboardInterrupt:
548
+ print("\n[Xitzin] Shutting down...")