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/auth.py ADDED
@@ -0,0 +1,152 @@
1
+ """Certificate authentication helpers.
2
+
3
+ This module provides decorators and utilities for working with
4
+ Gemini client certificate authentication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from functools import wraps
11
+ from typing import TYPE_CHECKING, Any, Callable
12
+
13
+ from .exceptions import CertificateNotAuthorized, CertificateRequired
14
+
15
+ if TYPE_CHECKING:
16
+ from cryptography.x509 import Certificate
17
+
18
+ from .requests import Request
19
+
20
+
21
+ @dataclass
22
+ class CertificateIdentity:
23
+ """Represents a user identity based on their client certificate.
24
+
25
+ Example:
26
+ identity = get_identity(request)
27
+ if identity:
28
+ print(f"User: {identity.short_id}")
29
+ """
30
+
31
+ fingerprint: str
32
+ """SHA-256 fingerprint of the certificate."""
33
+
34
+ cert: "Certificate | None" = None
35
+ """The raw certificate object (if available)."""
36
+
37
+ @property
38
+ def short_id(self) -> str:
39
+ """Short identifier suitable for display (first 16 chars)."""
40
+ return self.fingerprint[:16]
41
+
42
+ def __str__(self) -> str:
43
+ return f"CertIdentity({self.short_id}...)"
44
+
45
+
46
+ def get_identity(request: "Request") -> CertificateIdentity | None:
47
+ """Get the certificate-based identity from a request.
48
+
49
+ Args:
50
+ request: The current request.
51
+
52
+ Returns:
53
+ CertificateIdentity if a client certificate was provided, None otherwise.
54
+
55
+ Example:
56
+ @app.gemini("/whoami")
57
+ def whoami(request: Request):
58
+ identity = get_identity(request)
59
+ if identity:
60
+ return f"# Your ID: {identity.short_id}"
61
+ return "# You are anonymous"
62
+ """
63
+ if request.client_cert_fingerprint:
64
+ return CertificateIdentity(
65
+ fingerprint=request.client_cert_fingerprint,
66
+ cert=request.client_cert,
67
+ )
68
+ return None
69
+
70
+
71
+ def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
72
+ """Decorator that requires a valid client certificate.
73
+
74
+ If no certificate is provided, returns status 60 (certificate required).
75
+
76
+ Example:
77
+ @app.gemini("/admin")
78
+ @require_certificate
79
+ def admin_panel(request: Request):
80
+ return "# Admin Panel"
81
+ """
82
+
83
+ @wraps(handler)
84
+ def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
85
+ if not request.client_cert_fingerprint:
86
+ raise CertificateRequired("Client certificate required")
87
+ return handler(request, *args, **kwargs)
88
+
89
+ return wrapper
90
+
91
+
92
+ def require_fingerprint(
93
+ *allowed_fingerprints: str,
94
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
95
+ """Decorator factory that requires specific certificate fingerprints.
96
+
97
+ If the client certificate fingerprint is not in the allowed list,
98
+ returns status 61 (certificate not authorized).
99
+
100
+ Args:
101
+ *allowed_fingerprints: SHA-256 fingerprints that are allowed.
102
+
103
+ Example:
104
+ ADMIN_CERTS = [
105
+ "abc123...", # Alice's certificate
106
+ "def456...", # Bob's certificate
107
+ ]
108
+
109
+ @app.gemini("/admin")
110
+ @require_fingerprint(*ADMIN_CERTS)
111
+ def admin_panel(request: Request):
112
+ return "# Admin Panel"
113
+ """
114
+ allowed_set = set(allowed_fingerprints)
115
+
116
+ def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
117
+ @wraps(handler)
118
+ def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
119
+ if not request.client_cert_fingerprint:
120
+ raise CertificateRequired("Client certificate required")
121
+
122
+ if request.client_cert_fingerprint not in allowed_set:
123
+ raise CertificateNotAuthorized("Certificate not authorized")
124
+
125
+ return handler(request, *args, **kwargs)
126
+
127
+ return wrapper
128
+
129
+ return decorator
130
+
131
+
132
+ def optional_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
133
+ """Decorator that makes certificate identity available but not required.
134
+
135
+ Sets request.state.identity to CertificateIdentity or None.
136
+
137
+ Example:
138
+ @app.gemini("/profile")
139
+ @optional_certificate
140
+ def profile(request: Request):
141
+ identity = request.state.identity
142
+ if identity:
143
+ return f"# Welcome back, {identity.short_id}"
144
+ return "# Welcome, anonymous visitor"
145
+ """
146
+
147
+ @wraps(handler)
148
+ def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
149
+ request.state.identity = get_identity(request)
150
+ return handler(request, *args, **kwargs)
151
+
152
+ return wrapper
xitzin/cgi.py ADDED
@@ -0,0 +1,555 @@
1
+ """CGI support for Xitzin applications.
2
+
3
+ This module provides CGI script execution capabilities for Xitzin,
4
+ following the Gemini CGI specification conventions established by
5
+ servers like Jetforce.
6
+
7
+ Example:
8
+ from xitzin import Xitzin
9
+ from xitzin.cgi import CGIHandler, CGIConfig
10
+
11
+ app = Xitzin()
12
+
13
+ # Mount a CGI directory
14
+ config = CGIConfig(timeout=30)
15
+ app.mount("/cgi-bin", CGIHandler("/srv/cgi-bin", config=config))
16
+
17
+ # Or mount a single script
18
+ app.mount("/calculator", CGIScript("/srv/scripts/calc.py"))
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import os
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from nauyaca.protocol.response import GeminiResponse
30
+
31
+ from .exceptions import BadRequest, CGIError, NotFound
32
+
33
+ if TYPE_CHECKING:
34
+ from .requests import Request
35
+
36
+
37
+ # CGI protocol constants
38
+ GATEWAY_INTERFACE = "CGI/1.1"
39
+ SERVER_PROTOCOL = "GEMINI"
40
+ SERVER_SOFTWARE = "Xitzin/0.1.0"
41
+
42
+
43
+ @dataclass
44
+ class CGIConfig:
45
+ """Configuration for CGI script execution.
46
+
47
+ Attributes:
48
+ timeout: Maximum execution time in seconds.
49
+ max_header_size: Maximum size of the status line in bytes.
50
+ streaming: Enable streaming mode for large responses.
51
+ check_execute_permission: Whether to verify execute permission.
52
+ inherit_environment: Whether to inherit parent environment variables.
53
+ """
54
+
55
+ timeout: float = 30.0
56
+ max_header_size: int = 8192
57
+ streaming: bool = False
58
+ check_execute_permission: bool = True
59
+ inherit_environment: bool = True
60
+ app_state_keys: list[str] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class CGIResponse:
65
+ """Parsed response from a CGI script.
66
+
67
+ Attributes:
68
+ status: Gemini status code (10-62).
69
+ meta: Status meta field (MIME type, prompt, URL, or error message).
70
+ body: Response body content, if any.
71
+ """
72
+
73
+ status: int
74
+ meta: str
75
+ body: str | None = None
76
+
77
+
78
+ def build_cgi_env(
79
+ request: Request,
80
+ script_name: str,
81
+ path_info: str,
82
+ *,
83
+ app_state_vars: dict[str, str] | None = None,
84
+ inherit_environment: bool = True,
85
+ ) -> dict[str, str]:
86
+ """Build CGI environment variables from a request.
87
+
88
+ This follows the Gemini CGI conventions established by Jetforce
89
+ and other Gemini servers, based on RFC 3875.
90
+
91
+ Args:
92
+ request: The Gemini request.
93
+ script_name: Name/path of the CGI script.
94
+ path_info: Additional path info after the script name.
95
+ app_state_vars: Application state variables to pass as XITZIN_*.
96
+ inherit_environment: Whether to inherit current environment.
97
+
98
+ Returns:
99
+ Dictionary of environment variables for the CGI script.
100
+ """
101
+ if inherit_environment:
102
+ env = os.environ.copy()
103
+ else:
104
+ env = {}
105
+
106
+ # Standard CGI variables (RFC 3875)
107
+ env["GATEWAY_INTERFACE"] = GATEWAY_INTERFACE
108
+ env["SERVER_PROTOCOL"] = SERVER_PROTOCOL
109
+ env["SERVER_SOFTWARE"] = SERVER_SOFTWARE
110
+
111
+ # Gemini-specific
112
+ env["GEMINI_URL"] = request.url
113
+ env["SCRIPT_NAME"] = script_name
114
+ env["PATH_INFO"] = path_info
115
+ env["QUERY_STRING"] = request.raw_query or ""
116
+
117
+ # Server information
118
+ env["SERVER_NAME"] = request.hostname
119
+ env["SERVER_PORT"] = str(request.port)
120
+
121
+ # Client information
122
+ if request.remote_addr:
123
+ env["REMOTE_ADDR"] = request.remote_addr
124
+ env["REMOTE_HOST"] = request.remote_addr # Could do reverse DNS
125
+
126
+ # TLS/Certificate information
127
+ if request.client_cert_fingerprint:
128
+ env["TLS_CLIENT_HASH"] = request.client_cert_fingerprint
129
+ env["TLS_CLIENT_AUTHORISED"] = "1"
130
+ env["AUTH_TYPE"] = "CERTIFICATE"
131
+ else:
132
+ env["TLS_CLIENT_AUTHORISED"] = "0"
133
+
134
+ # Application state as XITZIN_* variables
135
+ if app_state_vars:
136
+ for key, value in app_state_vars.items():
137
+ env[f"XITZIN_{key.upper()}"] = str(value)
138
+
139
+ return env
140
+
141
+
142
+ def parse_cgi_output(stdout: bytes, stderr: bytes | None = None) -> CGIResponse:
143
+ """Parse CGI script output into a structured response.
144
+
145
+ The expected format is:
146
+ <STATUS><SPACE><META>\\r\\n
147
+ [optional body]
148
+
149
+ Or for backwards compatibility:
150
+ <META>\\r\\n
151
+ [body] # Assumes status 20
152
+
153
+ Args:
154
+ stdout: The script's standard output.
155
+ stderr: The script's standard error (for error messages).
156
+
157
+ Returns:
158
+ Parsed CGI response.
159
+
160
+ Raises:
161
+ CGIError: If the output format is invalid.
162
+ """
163
+ if not stdout:
164
+ raise CGIError("CGI script produced no output")
165
+
166
+ try:
167
+ output = stdout.decode("utf-8")
168
+ except UnicodeDecodeError:
169
+ output = stdout.decode("utf-8", errors="replace")
170
+
171
+ # Split header from body
172
+ if "\r\n" in output:
173
+ header, body = output.split("\r\n", 1)
174
+ elif "\n" in output:
175
+ header, body = output.split("\n", 1)
176
+ else:
177
+ # No newline - treat entire output as header (no body)
178
+ header = output
179
+ body = ""
180
+
181
+ # Parse header line: "20 text/gemini" or just "text/gemini"
182
+ header = header.strip()
183
+ if not header:
184
+ raise CGIError("CGI script produced empty header")
185
+
186
+ parts = header.split(None, 1) # Split on first whitespace
187
+
188
+ if len(parts) == 2 and parts[0].isdigit():
189
+ # Format: "20 text/gemini"
190
+ status = int(parts[0])
191
+ meta = parts[1]
192
+ elif len(parts) == 1 and not parts[0][0].isdigit():
193
+ # Format: "text/gemini" (assume status 20)
194
+ status = 20
195
+ meta = parts[0]
196
+ elif len(parts) == 1 and parts[0].isdigit():
197
+ # Just a status code without meta
198
+ status = int(parts[0])
199
+ meta = "text/gemini" if status == 20 else ""
200
+ else:
201
+ raise CGIError(f"Invalid CGI header format: {header[:100]}")
202
+
203
+ # Validate status code
204
+ if not (10 <= status <= 69):
205
+ raise CGIError(f"Invalid CGI status code: {status}")
206
+
207
+ return CGIResponse(status=status, meta=meta, body=body if body else None)
208
+
209
+
210
+ class CGIHandler:
211
+ """Execute CGI scripts from a directory.
212
+
213
+ This handler executes scripts located in a specified directory,
214
+ with proper environment variable setup and security validation.
215
+
216
+ Example:
217
+ from xitzin.cgi import CGIHandler, CGIConfig
218
+
219
+ config = CGIConfig(timeout=30)
220
+ handler = CGIHandler("/srv/gemini/cgi-bin", config=config)
221
+ app.mount("/cgi-bin", handler)
222
+
223
+ # Requests to /cgi-bin/hello.py execute /srv/gemini/cgi-bin/hello.py
224
+ """
225
+
226
+ def __init__(
227
+ self,
228
+ script_dir: Path | str,
229
+ *,
230
+ config: CGIConfig | None = None,
231
+ ) -> None:
232
+ """Create a CGI directory handler.
233
+
234
+ Args:
235
+ script_dir: Directory containing CGI scripts.
236
+ config: CGI execution configuration.
237
+
238
+ Raises:
239
+ ValueError: If script_dir doesn't exist or isn't a directory.
240
+ """
241
+ self.script_dir = Path(script_dir).resolve()
242
+ self.config = config or CGIConfig()
243
+
244
+ if not self.script_dir.exists():
245
+ msg = f"CGI script directory not found: {script_dir}"
246
+ raise ValueError(msg)
247
+ if not self.script_dir.is_dir():
248
+ msg = f"CGI script path is not a directory: {script_dir}"
249
+ raise ValueError(msg)
250
+
251
+ async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
252
+ """Handle a request by executing the appropriate CGI script.
253
+
254
+ Args:
255
+ request: The Gemini request.
256
+ path_info: Path after the mount prefix (e.g., "/script.py").
257
+
258
+ Returns:
259
+ GeminiResponse from the CGI script.
260
+
261
+ Raises:
262
+ NotFound: If the script doesn't exist.
263
+ CGIError: If execution fails.
264
+ BadRequest: If path validation fails.
265
+ """
266
+ # Extract script name and extra path info
267
+ # path_info is like "/script.py" or "/script.py/extra/path"
268
+ path_info = path_info.lstrip("/")
269
+ if not path_info:
270
+ raise NotFound("No CGI script specified")
271
+
272
+ parts = path_info.split("/", 1)
273
+ script_name = parts[0]
274
+ extra_path = "/" + parts[1] if len(parts) > 1 else ""
275
+
276
+ # Validate script name
277
+ if not script_name:
278
+ raise NotFound("No CGI script specified")
279
+
280
+ # Security: check for path traversal attempts
281
+ if ".." in script_name or script_name.startswith("/"):
282
+ raise BadRequest("Invalid script name")
283
+
284
+ # Resolve script path
285
+ script_path = (self.script_dir / script_name).resolve()
286
+
287
+ # Security: ensure script is within allowed directory
288
+ try:
289
+ script_path.relative_to(self.script_dir)
290
+ except ValueError:
291
+ raise BadRequest("Script path outside CGI directory") from None
292
+
293
+ # Check script exists
294
+ if not script_path.exists():
295
+ raise NotFound(f"CGI script not found: {script_name}")
296
+
297
+ if not script_path.is_file():
298
+ raise NotFound(f"CGI script is not a file: {script_name}")
299
+
300
+ # Check execute permission
301
+ if self.config.check_execute_permission:
302
+ if not os.access(script_path, os.X_OK):
303
+ raise CGIError(f"CGI script not executable: {script_name}")
304
+
305
+ # Build environment
306
+ app_state_vars = self._get_app_state_vars(request)
307
+ env = build_cgi_env(
308
+ request,
309
+ script_name=f"/{script_name}",
310
+ path_info=extra_path,
311
+ app_state_vars=app_state_vars,
312
+ inherit_environment=self.config.inherit_environment,
313
+ )
314
+
315
+ # Execute script
316
+ cgi_response = await self._execute_script(script_path, env)
317
+
318
+ return GeminiResponse(
319
+ status=cgi_response.status,
320
+ meta=cgi_response.meta,
321
+ body=cgi_response.body,
322
+ )
323
+
324
+ def _get_app_state_vars(self, request: Request) -> dict[str, str]:
325
+ """Extract app state variables to pass to CGI scripts."""
326
+ if not self.config.app_state_keys:
327
+ return {}
328
+
329
+ result = {}
330
+ try:
331
+ app_state = request.app.state
332
+ for key in self.config.app_state_keys:
333
+ try:
334
+ value = getattr(app_state, key)
335
+ result[key] = str(value)
336
+ except AttributeError:
337
+ pass
338
+ except RuntimeError:
339
+ # Request not bound to app
340
+ pass
341
+
342
+ return result
343
+
344
+ async def _execute_script(
345
+ self, script_path: Path, env: dict[str, str]
346
+ ) -> CGIResponse:
347
+ """Execute a CGI script and return the parsed response.
348
+
349
+ Args:
350
+ script_path: Path to the script.
351
+ env: Environment variables.
352
+
353
+ Returns:
354
+ Parsed CGI response.
355
+
356
+ Raises:
357
+ CGIError: If execution fails or times out.
358
+ """
359
+ try:
360
+ process = await asyncio.create_subprocess_exec(
361
+ str(script_path),
362
+ stdin=asyncio.subprocess.DEVNULL,
363
+ stdout=asyncio.subprocess.PIPE,
364
+ stderr=asyncio.subprocess.PIPE,
365
+ env=env,
366
+ )
367
+
368
+ try:
369
+ stdout, stderr = await asyncio.wait_for(
370
+ process.communicate(),
371
+ timeout=self.config.timeout,
372
+ )
373
+ except asyncio.TimeoutError:
374
+ process.kill()
375
+ await process.wait()
376
+ raise CGIError(
377
+ f"CGI script timeout after {self.config.timeout}s"
378
+ ) from None
379
+
380
+ # Check exit code
381
+ if process.returncode != 0:
382
+ error_msg = stderr.decode("utf-8", errors="replace")[:200]
383
+ if error_msg:
384
+ raise CGIError(
385
+ f"CGI script exited with code {process.returncode}: {error_msg}"
386
+ )
387
+ raise CGIError(f"CGI script exited with code {process.returncode}")
388
+
389
+ # Check header size
390
+ first_line_end = stdout.find(b"\n")
391
+ if first_line_end > self.config.max_header_size:
392
+ raise CGIError("CGI header exceeds maximum size")
393
+
394
+ return parse_cgi_output(stdout, stderr)
395
+
396
+ except FileNotFoundError:
397
+ raise NotFound(f"CGI script not found: {script_path.name}") from None
398
+ except PermissionError:
399
+ raise CGIError(f"Permission denied: {script_path.name}") from None
400
+
401
+
402
+ class CGIScript:
403
+ """Execute a single CGI script.
404
+
405
+ This handler executes a specific CGI script for all requests,
406
+ useful for mounting a single script at a specific path.
407
+
408
+ Example:
409
+ from xitzin.cgi import CGIScript
410
+
411
+ handler = CGIScript("/srv/scripts/calculator.py", timeout=10)
412
+ app.mount("/calculator", handler)
413
+
414
+ # All requests to /calculator execute /srv/scripts/calculator.py
415
+ """
416
+
417
+ def __init__(
418
+ self,
419
+ script_path: Path | str,
420
+ *,
421
+ timeout: float = 30.0,
422
+ check_execute_permission: bool = True,
423
+ inherit_environment: bool = True,
424
+ app_state_keys: list[str] | None = None,
425
+ ) -> None:
426
+ """Create a single-script CGI handler.
427
+
428
+ Args:
429
+ script_path: Path to the CGI script.
430
+ timeout: Maximum execution time in seconds.
431
+ check_execute_permission: Whether to verify execute permission.
432
+ inherit_environment: Whether to inherit parent environment.
433
+ app_state_keys: App state keys to pass as XITZIN_* variables.
434
+
435
+ Raises:
436
+ ValueError: If script doesn't exist.
437
+ """
438
+ self.script_path = Path(script_path).resolve()
439
+ self.config = CGIConfig(
440
+ timeout=timeout,
441
+ check_execute_permission=check_execute_permission,
442
+ inherit_environment=inherit_environment,
443
+ app_state_keys=app_state_keys or [],
444
+ )
445
+
446
+ if not self.script_path.exists():
447
+ msg = f"CGI script not found: {script_path}"
448
+ raise ValueError(msg)
449
+ if not self.script_path.is_file():
450
+ msg = f"CGI script path is not a file: {script_path}"
451
+ raise ValueError(msg)
452
+
453
+ async def __call__(self, request: Request, path_info: str = "") -> GeminiResponse:
454
+ """Execute the CGI script for this request.
455
+
456
+ Args:
457
+ request: The Gemini request.
458
+ path_info: Additional path info (usually empty for single scripts).
459
+
460
+ Returns:
461
+ GeminiResponse from the CGI script.
462
+
463
+ Raises:
464
+ CGIError: If execution fails.
465
+ """
466
+ # Check execute permission
467
+ if self.config.check_execute_permission:
468
+ if not os.access(self.script_path, os.X_OK):
469
+ raise CGIError(f"CGI script not executable: {self.script_path.name}")
470
+
471
+ # Build environment
472
+ app_state_vars = self._get_app_state_vars(request)
473
+ env = build_cgi_env(
474
+ request,
475
+ script_name=f"/{self.script_path.name}",
476
+ path_info=path_info,
477
+ app_state_vars=app_state_vars,
478
+ inherit_environment=self.config.inherit_environment,
479
+ )
480
+
481
+ # Execute script
482
+ cgi_response = await self._execute_script(env)
483
+
484
+ return GeminiResponse(
485
+ status=cgi_response.status,
486
+ meta=cgi_response.meta,
487
+ body=cgi_response.body,
488
+ )
489
+
490
+ def _get_app_state_vars(self, request: Request) -> dict[str, str]:
491
+ """Extract app state variables to pass to CGI scripts."""
492
+ if not self.config.app_state_keys:
493
+ return {}
494
+
495
+ result = {}
496
+ try:
497
+ app_state = request.app.state
498
+ for key in self.config.app_state_keys:
499
+ try:
500
+ value = getattr(app_state, key)
501
+ result[key] = str(value)
502
+ except AttributeError:
503
+ pass
504
+ except RuntimeError:
505
+ pass
506
+
507
+ return result
508
+
509
+ async def _execute_script(self, env: dict[str, str]) -> CGIResponse:
510
+ """Execute the CGI script and return the parsed response."""
511
+ try:
512
+ process = await asyncio.create_subprocess_exec(
513
+ str(self.script_path),
514
+ stdin=asyncio.subprocess.DEVNULL,
515
+ stdout=asyncio.subprocess.PIPE,
516
+ stderr=asyncio.subprocess.PIPE,
517
+ env=env,
518
+ )
519
+
520
+ try:
521
+ stdout, stderr = await asyncio.wait_for(
522
+ process.communicate(),
523
+ timeout=self.config.timeout,
524
+ )
525
+ except asyncio.TimeoutError:
526
+ process.kill()
527
+ await process.wait()
528
+ raise CGIError(
529
+ f"CGI script timeout after {self.config.timeout}s"
530
+ ) from None
531
+
532
+ if process.returncode != 0:
533
+ error_msg = stderr.decode("utf-8", errors="replace")[:200]
534
+ if error_msg:
535
+ raise CGIError(
536
+ f"CGI script exited with code {process.returncode}: {error_msg}"
537
+ )
538
+ raise CGIError(f"CGI script exited with code {process.returncode}")
539
+
540
+ return parse_cgi_output(stdout, stderr)
541
+
542
+ except FileNotFoundError:
543
+ raise NotFound(f"CGI script not found: {self.script_path.name}") from None
544
+ except PermissionError:
545
+ raise CGIError(f"Permission denied: {self.script_path.name}") from None
546
+
547
+
548
+ __all__ = [
549
+ "CGIConfig",
550
+ "CGIHandler",
551
+ "CGIResponse",
552
+ "CGIScript",
553
+ "build_cgi_env",
554
+ "parse_cgi_output",
555
+ ]