codeaway 0.1.0__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.
codeaway/server.py ADDED
@@ -0,0 +1,673 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ import threading
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import datetime, timezone
10
+ from http.server import BaseHTTPRequestHandler
11
+ from importlib import resources
12
+ from io import BytesIO
13
+ from numbers import Real
14
+ from pathlib import Path
15
+ from typing import Any, Mapping
16
+ from urllib.parse import urlsplit
17
+
18
+ from .agents import (
19
+ AgentBackend,
20
+ AgentRegistry,
21
+ AgentTarget,
22
+ ClickAction,
23
+ NavigationAction,
24
+ SurfaceMap,
25
+ TargetUnavailable,
26
+ )
27
+ from .config import AppConfig, _parse_config, save_config
28
+ from .desktop import (
29
+ AccessibilityUnavailable,
30
+ DesktopBackend,
31
+ FractionalRegion,
32
+ InputUnavailable,
33
+ )
34
+
35
+
36
+ _MAX_JSON_BODY = 65_536
37
+ _STATIC_RESOURCES = {
38
+ "/": ("index.html", "text/html; charset=utf-8"),
39
+ "/setup": ("setup.html", "text/html; charset=utf-8"),
40
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
41
+ "/style.css": ("style.css", "text/css; charset=utf-8"),
42
+ }
43
+ _SCREENSHOT_SURFACES = frozenset({"window", "sidebar", "conversation"})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Response:
48
+ status: int
49
+ content_type: str
50
+ body: bytes
51
+ headers: Mapping[str, str] = field(default_factory=dict)
52
+
53
+
54
+ @dataclass
55
+ class AppState:
56
+ config: AppConfig
57
+ target: AgentTarget | None
58
+ revision: int = 0
59
+ state_lock: threading.RLock = field(default_factory=threading.RLock)
60
+ action_lock: threading.Lock = field(default_factory=threading.Lock)
61
+ conversation_png: bytes | None = None
62
+ conversation_digest: bytes | None = None
63
+ conversation_token: tuple[object, ...] | None = None
64
+
65
+
66
+ class Application:
67
+ def __init__(
68
+ self,
69
+ state: AppState,
70
+ registry: AgentRegistry,
71
+ agents: Mapping[str, AgentBackend],
72
+ desktop: DesktopBackend,
73
+ config_path: str | Path,
74
+ ) -> None:
75
+ self.state = state
76
+ self.registry = registry
77
+ self.agents = dict(agents)
78
+ self.desktop = desktop
79
+ self.config_path = Path(config_path)
80
+ self._discovered_targets: dict[str, AgentTarget] = {}
81
+
82
+ @staticmethod
83
+ def _json_response(status: int, value: Any) -> Response:
84
+ return Response(
85
+ status,
86
+ "application/json; charset=utf-8",
87
+ json.dumps(value, separators=(",", ":")).encode("utf-8"),
88
+ )
89
+
90
+ @classmethod
91
+ def _error(cls, status: int, code: str, message: str) -> Response:
92
+ return cls._json_response(
93
+ status, {"error": {"code": code, "message": message}}
94
+ )
95
+
96
+ @staticmethod
97
+ def _headers(headers: Mapping[str, str]) -> dict[str, str]:
98
+ return {name.casefold(): value for name, value in headers.items()}
99
+
100
+ def _request_json(
101
+ self, headers: Mapping[str, str], body: bytes
102
+ ) -> tuple[dict[str, Any] | None, Response | None]:
103
+ normalized = self._headers(headers)
104
+ content_type = normalized.get("content-type", "")
105
+ content_type_parts = [part.strip() for part in content_type.split(";")]
106
+ media_type = content_type_parts[0].casefold()
107
+ parameters = content_type_parts[1:]
108
+ charset_valid = not parameters
109
+ if len(parameters) == 1:
110
+ name, separator, value = parameters[0].partition("=")
111
+ charset_valid = (
112
+ separator == "="
113
+ and name.strip().casefold() == "charset"
114
+ and bool(value.strip())
115
+ )
116
+ if media_type != "application/json" or not charset_valid:
117
+ return None, self._error(
118
+ 415, "unsupported_media_type", "Content-Type must be application/json."
119
+ )
120
+ if len(body) > _MAX_JSON_BODY:
121
+ return None, self._error(
122
+ 413, "body_too_large", "JSON request body exceeds 65,536 bytes."
123
+ )
124
+ with self.state.state_lock:
125
+ expected_authority = (
126
+ f"{self.state.config.bind_ip}:{self.state.config.port}"
127
+ ).casefold()
128
+ host = normalized.get("host", "").strip().casefold()
129
+ if host != expected_authority:
130
+ return None, self._error(
131
+ 403,
132
+ "host_mismatch",
133
+ "Request Host must match the configured CodeAway address.",
134
+ )
135
+ origin = normalized.get("origin")
136
+ if origin is not None:
137
+ try:
138
+ parsed = urlsplit(origin)
139
+ except ValueError:
140
+ return None, self._error(
141
+ 403,
142
+ "origin_mismatch",
143
+ "Request Origin must match the CodeAway Host.",
144
+ )
145
+ if (
146
+ parsed.scheme.casefold() != "http"
147
+ or not parsed.netloc
148
+ or parsed.netloc.casefold() != expected_authority
149
+ or parsed.path not in {"", "/"}
150
+ or parsed.query
151
+ or parsed.fragment
152
+ ):
153
+ return None, self._error(
154
+ 403,
155
+ "origin_mismatch",
156
+ "Request Origin must match the CodeAway Host.",
157
+ )
158
+ try:
159
+ value = json.loads(body)
160
+ except (ValueError, RecursionError):
161
+ return None, self._error(400, "invalid_json", "Request body is not valid JSON.")
162
+ if not isinstance(value, dict):
163
+ return None, self._error(400, "invalid_request", "JSON body must be an object.")
164
+ return value, None
165
+
166
+ @staticmethod
167
+ def _surface_values(surface: FractionalRegion) -> list[int | float]:
168
+ return [surface.x, surface.y, surface.width, surface.height]
169
+
170
+ @classmethod
171
+ def _surfaces_value(cls, surfaces: SurfaceMap) -> dict[str, list[int | float]]:
172
+ return {
173
+ "sidebar": cls._surface_values(surfaces.sidebar),
174
+ "conversation": cls._surface_values(surfaces.conversation),
175
+ "composer": cls._surface_values(surfaces.composer),
176
+ }
177
+
178
+ def _fixed_resource(self, path: str) -> Response:
179
+ resource = _STATIC_RESOURCES.get(path)
180
+ if resource is None:
181
+ return self._error(404, "not_found", "Resource not found.")
182
+ filename, content_type = resource
183
+ try:
184
+ body = resources.files("codeaway.web").joinpath(filename).read_bytes()
185
+ except (ModuleNotFoundError, FileNotFoundError, TypeError):
186
+ return self._error(404, "not_found", "Resource not found.")
187
+ return Response(200, content_type, body)
188
+
189
+ def _resolve_runtime_target(self, selected: AgentTarget) -> AgentTarget | None:
190
+ backend = self._backend(selected)
191
+ if backend is None:
192
+ return None
193
+ process_path = os.path.normcase(selected.window.process_path)
194
+ window = next(
195
+ (
196
+ candidate
197
+ for candidate in self.desktop.list_windows()
198
+ if candidate.native_handle == selected.window.native_handle
199
+ and os.path.normcase(candidate.process_path) == process_path
200
+ and backend.matches(candidate)
201
+ ),
202
+ None,
203
+ )
204
+ if window is None:
205
+ return None
206
+ return AgentTarget(selected.agent_id, window, selected.surfaces)
207
+
208
+ @staticmethod
209
+ def _selection_token(target: AgentTarget | None) -> tuple[object, ...] | None:
210
+ if target is None:
211
+ return None
212
+ return (
213
+ target.agent_id,
214
+ target.window.native_handle,
215
+ os.path.normcase(target.window.process_path),
216
+ target.surfaces,
217
+ )
218
+
219
+ @staticmethod
220
+ def _same_window_identity(first: AgentTarget, second: AgentTarget) -> bool:
221
+ return (
222
+ first.agent_id == second.agent_id
223
+ and first.window.native_handle == second.window.native_handle
224
+ and os.path.normcase(os.path.normpath(first.window.process_path))
225
+ == os.path.normcase(os.path.normpath(second.window.process_path))
226
+ )
227
+
228
+ def _current_target(self) -> AgentTarget | None:
229
+ with self.state.state_lock:
230
+ selected = self.state.target
231
+ selection_token = self._selection_token(selected)
232
+ if selected is None:
233
+ return None
234
+ current = self._resolve_runtime_target(selected)
235
+ with self.state.state_lock:
236
+ if self._selection_token(self.state.target) != selection_token:
237
+ return None
238
+ self.state.target = current
239
+ return current
240
+
241
+ def _clear_conversation_cache_locked(self) -> None:
242
+ self.state.conversation_png = None
243
+ self.state.conversation_digest = None
244
+ self.state.conversation_token = None
245
+
246
+ def _capture_png(self, region) -> bytes:
247
+ image = self.desktop.capture(region)
248
+ output = BytesIO()
249
+ image.save(output, format="PNG")
250
+ return output.getvalue()
251
+
252
+ def _observe_conversation_locked(self, target: AgentTarget) -> None:
253
+ region = target.surfaces.conversation.resolve(target.window.region)
254
+ try:
255
+ png = self._capture_png(region)
256
+ except Exception:
257
+ return
258
+ digest = hashlib.sha256(png).digest()
259
+ token = self._selection_token(target)
260
+ with self.state.state_lock:
261
+ if self._selection_token(self.state.target) != token:
262
+ return
263
+ if (
264
+ self.state.conversation_token == token
265
+ and self.state.conversation_digest is not None
266
+ and self.state.conversation_digest != digest
267
+ ):
268
+ self.state.revision += 1
269
+ self.state.conversation_png = png
270
+ self.state.conversation_digest = digest
271
+ self.state.conversation_token = token
272
+
273
+ def _backend(self, target: AgentTarget) -> AgentBackend | None:
274
+ return self.agents.get(target.agent_id)
275
+
276
+ def _status(self) -> Response:
277
+ with self.state.action_lock:
278
+ target = self._current_target()
279
+ if target is None:
280
+ with self.state.state_lock:
281
+ self._clear_conversation_cache_locked()
282
+ else:
283
+ self._observe_conversation_locked(target)
284
+ return self._status_locked()
285
+
286
+ def _status_locked(self) -> Response:
287
+ with self.state.state_lock:
288
+ config = self.state.config
289
+ target = self.state.target
290
+ value = {
291
+ "bind_ip": config.bind_ip,
292
+ "port": config.port,
293
+ "ready": config.setup_complete and target is not None,
294
+ "revision": self.state.revision,
295
+ "setup_complete": config.setup_complete,
296
+ "target": (
297
+ None
298
+ if target is None
299
+ else {"agent_id": target.agent_id, "title": target.window.title}
300
+ ),
301
+ }
302
+ return self._json_response(200, value)
303
+
304
+ def _windows(self) -> Response:
305
+ targets = self.registry.discover(self.desktop)
306
+ with self.state.state_lock:
307
+ current = self.state.target
308
+ presented_targets = [
309
+ AgentTarget(target.agent_id, target.window, current.surfaces)
310
+ if current is not None
311
+ and self._same_window_identity(target, current)
312
+ else target
313
+ for target in targets
314
+ ]
315
+ self._discovered_targets = {
316
+ target.window.id: target for target in presented_targets
317
+ }
318
+ return self._json_response(
319
+ 200,
320
+ {
321
+ "windows": [
322
+ {
323
+ "agent_id": target.agent_id,
324
+ "current": (
325
+ current is not None
326
+ and self._same_window_identity(target, current)
327
+ ),
328
+ "id": target.window.id,
329
+ "process_path": target.window.process_path,
330
+ "surfaces": self._surfaces_value(target.surfaces),
331
+ "title": target.window.title,
332
+ }
333
+ for target in presented_targets
334
+ ]
335
+ },
336
+ )
337
+
338
+ def _select(self, value: dict[str, Any]) -> Response:
339
+ with self.state.action_lock:
340
+ return self._select_locked(value)
341
+
342
+ def _select_locked(self, value: dict[str, Any]) -> Response:
343
+ window_id = value.get("window_id")
344
+ if not isinstance(window_id, str):
345
+ return self._error(400, "invalid_request", "window_id must be a string.")
346
+ with self.state.state_lock:
347
+ selected = self._discovered_targets.get(window_id)
348
+ if selected is None:
349
+ discovered = self.registry.discover(self.desktop)
350
+ with self.state.state_lock:
351
+ self._discovered_targets = {
352
+ candidate.window.id: candidate for candidate in discovered
353
+ }
354
+ selected = next(
355
+ (candidate for candidate in discovered if candidate.window.id == window_id),
356
+ None,
357
+ )
358
+ if selected is None:
359
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
360
+ target = self._resolve_runtime_target(selected)
361
+ if target is None:
362
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
363
+ with self.state.state_lock:
364
+ self.state.target = target
365
+ self._clear_conversation_cache_locked()
366
+ self.state.revision += 1
367
+ revision = self.state.revision
368
+ return self._json_response(200, {"revision": revision})
369
+
370
+ def _screenshot(self, surface_name: str) -> Response:
371
+ with self.state.action_lock:
372
+ return self._screenshot_locked(surface_name)
373
+
374
+ def _screenshot_locked(self, surface_name: str) -> Response:
375
+ if surface_name not in _SCREENSHOT_SURFACES:
376
+ return self._error(404, "not_found", "Screenshot surface not found.")
377
+ target = self._current_target()
378
+ if target is None:
379
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
380
+ region = (
381
+ target.window.region
382
+ if surface_name == "window"
383
+ else getattr(target.surfaces, surface_name).resolve(target.window.region)
384
+ )
385
+ token = self._selection_token(target)
386
+ if surface_name == "conversation":
387
+ with self.state.state_lock:
388
+ if (
389
+ self.state.conversation_token == token
390
+ and self.state.conversation_png is not None
391
+ ):
392
+ return Response(
393
+ 200,
394
+ "image/png",
395
+ self.state.conversation_png,
396
+ {"Cache-Control": "no-store"},
397
+ )
398
+ try:
399
+ png = self._capture_png(region)
400
+ except Exception:
401
+ return self._error(503, "screenshot_failed", "Screenshot capture failed.")
402
+ if surface_name == "conversation":
403
+ with self.state.state_lock:
404
+ if self._selection_token(self.state.target) == token:
405
+ self.state.conversation_png = png
406
+ self.state.conversation_digest = hashlib.sha256(png).digest()
407
+ self.state.conversation_token = token
408
+ return Response(200, "image/png", png, {"Cache-Control": "no-store"})
409
+
410
+ def _navigator(self) -> Response:
411
+ with self.state.action_lock:
412
+ return self._navigator_locked()
413
+
414
+ def _navigator_locked(self) -> Response:
415
+ target = self._current_target()
416
+ if target is None:
417
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
418
+ backend = self._backend(target)
419
+ if backend is None:
420
+ return self._error(409, "target_unavailable", "Selected agent is unavailable.")
421
+ try:
422
+ snapshot = backend.inspect(self.desktop, target)
423
+ except AccessibilityUnavailable:
424
+ return self._json_response(
425
+ 200,
426
+ {
427
+ "available": False,
428
+ "source": "accessibility",
429
+ "projects": [],
430
+ "captured_at": datetime.now(timezone.utc).isoformat(),
431
+ "error": "The accessibility navigator is unavailable; screenshots and direct controls remain available.",
432
+ },
433
+ )
434
+ except TargetUnavailable:
435
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
436
+ return self._json_response(200, asdict(snapshot))
437
+
438
+ def _calibration(self, value: dict[str, Any]) -> Response:
439
+ with self.state.action_lock:
440
+ return self._calibration_locked(value)
441
+
442
+ def _calibration_locked(self, value: dict[str, Any]) -> Response:
443
+ surfaces = value.get("surfaces")
444
+ if not isinstance(surfaces, dict) or set(surfaces) != {
445
+ "sidebar",
446
+ "conversation",
447
+ "composer",
448
+ }:
449
+ return self._error(
450
+ 400,
451
+ "invalid_calibration",
452
+ "Calibration requires sidebar, conversation, and composer surfaces.",
453
+ )
454
+ target = self._current_target()
455
+ if target is None:
456
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
457
+ with self.state.state_lock:
458
+ current = self.state.config
459
+ try:
460
+ config = _parse_config(
461
+ {
462
+ "bind_ip": current.bind_ip,
463
+ "port": current.port,
464
+ "selected_agent": target.agent_id,
465
+ "selected_window": {
466
+ "process_path": target.window.process_path,
467
+ "title_hint": target.window.title,
468
+ },
469
+ "surfaces": surfaces,
470
+ }
471
+ )
472
+ except (KeyError, TypeError, ValueError):
473
+ return self._error(400, "invalid_calibration", "Calibration is invalid.")
474
+ calibrated_target = AgentTarget(target.agent_id, target.window, config.surfaces)
475
+ with self.state.state_lock:
476
+ try:
477
+ save_config(self.config_path, config)
478
+ except OSError:
479
+ return self._error(500, "config_save_failed", "Configuration could not be saved.")
480
+ self.state.config = config
481
+ self.state.target = calibrated_target
482
+ self._clear_conversation_cache_locked()
483
+ self.state.revision += 1
484
+ revision = self.state.revision
485
+ return self._json_response(200, {"revision": revision})
486
+
487
+ @staticmethod
488
+ def _number(value: Any, name: str) -> float:
489
+ if (
490
+ isinstance(value, bool)
491
+ or not isinstance(value, Real)
492
+ or not math.isfinite(value)
493
+ or not 0 <= value <= 1
494
+ ):
495
+ raise ValueError(f"{name} must be a number between 0 and 1")
496
+ return float(value)
497
+
498
+ def _perform_action(
499
+ self, backend: AgentBackend, target: AgentTarget, value: dict[str, Any]
500
+ ) -> None:
501
+ kind = value.get("kind")
502
+ if kind == "scroll":
503
+ amount = value.get("amount")
504
+ if (
505
+ isinstance(amount, bool)
506
+ or not isinstance(amount, int)
507
+ or amount == 0
508
+ or not -12 <= amount <= 12
509
+ ):
510
+ raise ValueError("amount must be a nonzero integer between -12 and 12")
511
+ backend.scroll(self.desktop, target, amount)
512
+ return
513
+ if kind == "send":
514
+ text = value.get("text")
515
+ if not isinstance(text, str):
516
+ raise ValueError("text must be a string")
517
+ backend.send(self.desktop, target, text)
518
+ return
519
+ if kind == "click":
520
+ surface = value.get("surface")
521
+ if surface not in {"sidebar", "conversation"}:
522
+ raise ValueError("surface must be sidebar or conversation")
523
+ backend.click(
524
+ self.desktop,
525
+ target,
526
+ ClickAction(
527
+ surface,
528
+ self._number(value.get("x"), "x"),
529
+ self._number(value.get("y"), "y"),
530
+ ),
531
+ )
532
+ return
533
+ if kind == "navigate":
534
+ navigation_kind = value.get("target")
535
+ project = value.get("project")
536
+ if navigation_kind not in {"project", "task"} or not isinstance(project, str):
537
+ raise ValueError("navigate requires a target and project")
538
+ title = value.get("title")
539
+ expanded = value.get("expanded")
540
+ if navigation_kind == "task" and not isinstance(title, str):
541
+ raise ValueError("task navigation requires a title")
542
+ if navigation_kind == "project" and not isinstance(expanded, bool):
543
+ raise ValueError("project navigation requires expanded")
544
+ backend.navigate(
545
+ self.desktop,
546
+ target,
547
+ NavigationAction(navigation_kind, project, title=title, expanded=expanded),
548
+ )
549
+ return
550
+ raise ValueError("unknown action kind")
551
+
552
+ def _action(self, value: dict[str, Any]) -> Response:
553
+ with self.state.action_lock:
554
+ target = self._current_target()
555
+ if target is None:
556
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
557
+ backend = self._backend(target)
558
+ if backend is None:
559
+ return self._error(409, "target_unavailable", "Selected agent is unavailable.")
560
+ try:
561
+ self._perform_action(backend, target, value)
562
+ except ValueError as error:
563
+ return self._error(400, "invalid_action", str(error))
564
+ except (InputUnavailable, TargetUnavailable):
565
+ return self._error(409, "target_unavailable", "Selected window is unavailable.")
566
+ with self.state.state_lock:
567
+ self._clear_conversation_cache_locked()
568
+ self.state.revision += 1
569
+ revision = self.state.revision
570
+ return self._json_response(200, {"revision": revision})
571
+
572
+ def _dispatch(
573
+ self,
574
+ method: str,
575
+ path: str,
576
+ headers: Mapping[str, str],
577
+ body: bytes,
578
+ ) -> Response:
579
+ route_path = urlsplit(path).path
580
+ if method == "GET":
581
+ if route_path == "/api/status":
582
+ return self._status()
583
+ if route_path == "/api/windows":
584
+ return self._windows()
585
+ if route_path == "/api/navigator":
586
+ return self._navigator()
587
+ prefix = "/api/screenshot/"
588
+ if route_path.startswith(prefix):
589
+ return self._screenshot(route_path[len(prefix) :])
590
+ return self._fixed_resource(route_path)
591
+
592
+ if method in {"POST", "PUT"}:
593
+ value, error = self._request_json(headers, body)
594
+ if error is not None:
595
+ return error
596
+ assert value is not None
597
+ if method == "POST" and route_path == "/api/select":
598
+ return self._select(value)
599
+ if method == "POST" and route_path == "/api/action":
600
+ return self._action(value)
601
+ if method == "PUT" and route_path == "/api/calibration":
602
+ return self._calibration(value)
603
+ return self._error(404, "not_found", "Resource not found.")
604
+
605
+ def dispatch(
606
+ self,
607
+ method: str,
608
+ path: str,
609
+ headers: Mapping[str, str],
610
+ body: bytes,
611
+ ) -> Response:
612
+ try:
613
+ return self._dispatch(method, path, headers, body)
614
+ except OSError:
615
+ return self._error(503, "backend_error", "Backend operation failed.")
616
+ except Exception:
617
+ return self._error(500, "internal_error", "Internal server error.")
618
+
619
+
620
+ def make_handler(application: Application, logger: Any | None = None):
621
+ class Handler(BaseHTTPRequestHandler):
622
+ def _dispatch(self) -> None:
623
+ try:
624
+ content_length = int(self.headers.get("Content-Length", "0"))
625
+ if content_length < 0:
626
+ raise ValueError
627
+ except ValueError:
628
+ response = application._error(
629
+ 400, "invalid_request", "Content-Length must be a non-negative integer."
630
+ )
631
+ else:
632
+ if content_length > _MAX_JSON_BODY:
633
+ body = b"\0" * (_MAX_JSON_BODY + 1)
634
+ self.close_connection = True
635
+ else:
636
+ body = self.rfile.read(content_length)
637
+ try:
638
+ response = application.dispatch(
639
+ self.command,
640
+ self.path,
641
+ dict(self.headers.items()),
642
+ body,
643
+ )
644
+ except Exception:
645
+ if logger is not None:
646
+ logger.exception("Unhandled CodeAway request failure")
647
+ response = Application._error(
648
+ 500, "internal_error", "Internal server error."
649
+ )
650
+ self.send_response(response.status)
651
+ self.send_header("Content-Type", response.content_type)
652
+ self.send_header("Content-Length", str(len(response.body)))
653
+ self.send_header("X-Frame-Options", "DENY")
654
+ self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
655
+ for name, value in response.headers.items():
656
+ self.send_header(name, value)
657
+ self.end_headers()
658
+ self.wfile.write(response.body)
659
+
660
+ def do_GET(self) -> None:
661
+ self._dispatch()
662
+
663
+ def do_POST(self) -> None:
664
+ self._dispatch()
665
+
666
+ def do_PUT(self) -> None:
667
+ self._dispatch()
668
+
669
+ def log_message(self, format: str, *args: Any) -> None:
670
+ if logger is not None:
671
+ logger.info(format, *args)
672
+
673
+ return Handler
@@ -0,0 +1 @@
1
+ """Static browser resources for the local CodeAway server."""