netizen-cli 0.10.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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,935 @@
1
+ """Bounded HTTP/1.1 transport for the in-process Admin Web.
2
+
3
+ This module deliberately stops at the transport boundary. Authentication,
4
+ routing, and business operations belong to the Admin Web application layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import errno
12
+ import logging
13
+ import secrets
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+ from typing import Final, TypeAlias
18
+
19
+ import h11
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ MAX_ACTIVE_CONNECTIONS: Final = 32
26
+ HEADER_TIMEOUT_SECONDS: Final = 5.0
27
+ BODY_TIMEOUT_SECONDS: Final = 5.0
28
+ KEEPALIVE_TIMEOUT_SECONDS: Final = 15.0
29
+ MAX_REQUEST_OR_HEADER_LINE_BYTES: Final = 8192
30
+ MAX_HEADER_SECTION_BYTES: Final = 32768
31
+ MAX_BODY_BYTES: Final = 65536
32
+
33
+ _READ_CHUNK_BYTES: Final = 16384
34
+ _DRAIN_CANCELLATION_GRACE_SECONDS: Final = 0.05
35
+ _HEADER_TERMINATOR: Final = b"\r\n\r\n"
36
+ _RESERVED_RESPONSE_HEADERS: Final = frozenset(
37
+ {b"connection", b"content-length", b"transfer-encoding"}
38
+ )
39
+
40
+ Address: TypeAlias = tuple[object, ...] | str | None
41
+ RawHeaders: TypeAlias = tuple[tuple[bytes, bytes], ...]
42
+ Handler: TypeAlias = Callable[["Request"], Awaitable["Response"]]
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Request:
47
+ """One fully buffered, parser-validated HTTP request."""
48
+
49
+ method: bytes
50
+ target: bytes
51
+ http_version: bytes
52
+ raw_headers: RawHeaders
53
+ body: bytes
54
+ peer: Address
55
+ sockname: Address
56
+ request_id: str
57
+
58
+ def __post_init__(self) -> None:
59
+ for value, field_name in (
60
+ (self.method, "method"),
61
+ (self.target, "target"),
62
+ (self.http_version, "http_version"),
63
+ (self.body, "body"),
64
+ ):
65
+ if not isinstance(value, bytes):
66
+ raise TypeError(f"request {field_name} must be bytes")
67
+ if not isinstance(self.request_id, str):
68
+ raise TypeError("request_id must be a string")
69
+ object.__setattr__(self, "raw_headers", _freeze_headers(self.raw_headers))
70
+ object.__setattr__(self, "peer", _immutable_address(self.peer))
71
+ object.__setattr__(self, "sockname", _immutable_address(self.sockname))
72
+
73
+ def header_values(self, name: bytes) -> tuple[bytes, ...]:
74
+ lowered = name.lower()
75
+ return tuple(
76
+ value for key, value in self.raw_headers if key.lower() == lowered
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class Response:
82
+ """A complete response returned by an Admin HTTP handler."""
83
+
84
+ status: int
85
+ headers: RawHeaders = field(default_factory=tuple)
86
+ body: bytes = b""
87
+
88
+ def __post_init__(self) -> None:
89
+ object.__setattr__(self, "headers", _freeze_headers(self.headers))
90
+ if not isinstance(self.body, bytes):
91
+ raise TypeError("response body must be bytes")
92
+
93
+
94
+ class AdminHttpState(str, Enum):
95
+ NEW = "new"
96
+ BOUND_CLOSED = "bound_closed"
97
+ CLOSED = "closed"
98
+
99
+
100
+ class _RequestRejected(Exception):
101
+ def __init__(self, status: int, code: str) -> None:
102
+ super().__init__(code)
103
+ self.status = status
104
+ self.code = code
105
+
106
+
107
+ class _ClientDisconnected(Exception):
108
+ pass
109
+
110
+
111
+ @dataclass(slots=True, eq=False)
112
+ class _Connection:
113
+ writer: asyncio.StreamWriter
114
+ accepted_at: float
115
+ task: asyncio.Task[None] | None = None
116
+ handler_task: asyncio.Task[Response] | None = None
117
+
118
+
119
+ class AdminHttpTransport:
120
+ """A loop-owned, bounded HTTP transport with explicit admission control.
121
+
122
+ ``bind`` reserves the listener but leaves the transport in
123
+ :attr:`AdminHttpState.BOUND_CLOSED`. Call ``open_admission`` only after
124
+ the surrounding application is ready. Shutdown is intentionally split:
125
+ ``close`` closes admission and the listener first, while ``drain`` waits
126
+ for tracked connection/handler tasks until an absolute monotonic deadline.
127
+ """
128
+
129
+ def __init__(self, host: str, port: int, handler: Handler) -> None:
130
+ if not isinstance(host, str) or not host:
131
+ raise ValueError("host must be a non-empty string")
132
+ if not isinstance(port, int) or isinstance(port, bool):
133
+ raise TypeError("port must be an integer")
134
+ if not 0 <= port <= 65535:
135
+ raise ValueError("port must be between 0 and 65535")
136
+ if not callable(handler):
137
+ raise TypeError("handler must be callable")
138
+
139
+ self._host = host
140
+ self._port = port
141
+ self._handler = handler
142
+ self._loop: asyncio.AbstractEventLoop | None = None
143
+ self._server: asyncio.AbstractServer | None = None
144
+ self._closed_servers: list[asyncio.AbstractServer] = []
145
+ self._state = AdminHttpState.NEW
146
+ self._admission_open = False
147
+ self._closing = False
148
+ self._connections: set[_Connection] = set()
149
+ self._connection_tasks: set[asyncio.Task[None]] = set()
150
+ self._handler_tasks: set[asyncio.Task[Response]] = set()
151
+
152
+ @property
153
+ def state(self) -> AdminHttpState:
154
+ return self._state
155
+
156
+ @property
157
+ def admission_open(self) -> bool:
158
+ return self._admission_open
159
+
160
+ @property
161
+ def active_connection_count(self) -> int:
162
+ return len(self._connections)
163
+
164
+ @property
165
+ def active_handler_count(self) -> int:
166
+ return len(self._handler_tasks)
167
+
168
+ @property
169
+ def addresses(self) -> tuple[Address, ...]:
170
+ server = self._server
171
+ if server is None or server.sockets is None:
172
+ return ()
173
+ return tuple(sock.getsockname() for sock in server.sockets)
174
+
175
+ async def bind(self) -> None:
176
+ """Bind the listener on the current loop with admission still closed."""
177
+
178
+ loop = asyncio.get_running_loop()
179
+ if self._loop is None:
180
+ self._loop = loop
181
+ self._assert_loop()
182
+ if self._state is not AdminHttpState.NEW:
183
+ raise RuntimeError("Admin HTTP transport can only be bound once")
184
+
185
+ try:
186
+ # asyncio closes every partially bound address if bind fails.
187
+ # Defer serving until we own the returned server so cancellation
188
+ # or listen/start_serving failure can also close every socket.
189
+ server = await asyncio.start_server(
190
+ self._accepted,
191
+ self._host,
192
+ self._port,
193
+ start_serving=False,
194
+ limit=MAX_HEADER_SECTION_BYTES + MAX_BODY_BYTES + 1,
195
+ reuse_port=False,
196
+ )
197
+ self._server = server
198
+ if not server.sockets:
199
+ raise OSError(errno.EADDRNOTAVAIL, "Admin listener has no bound sockets")
200
+ await server.start_serving()
201
+ except BaseException:
202
+ if self._server is not None:
203
+ self._server.close()
204
+ await self._server.wait_closed()
205
+ self._server = None
206
+ self._state = AdminHttpState.CLOSED
207
+ self._closing = True
208
+ raise
209
+
210
+ self._state = AdminHttpState.BOUND_CLOSED
211
+
212
+ def open_admission(self) -> None:
213
+ self._assert_loop()
214
+ if self._state is not AdminHttpState.BOUND_CLOSED or self._closing:
215
+ raise RuntimeError("Admin HTTP listener is not available")
216
+ self._admission_open = True
217
+
218
+ def close_admission(self) -> None:
219
+ self._assert_loop()
220
+ self._admission_open = False
221
+
222
+ async def close(self) -> None:
223
+ """Idempotently close admission and the listener, but not handlers."""
224
+
225
+ self._assert_loop()
226
+ if self._state is AdminHttpState.CLOSED:
227
+ return
228
+
229
+ self._admission_open = False
230
+ self._closing = True
231
+ server = self._server
232
+ self._server = None
233
+ if server is not None:
234
+ server.close()
235
+ # Python 3.12's wait_closed() also waits for accepted clients.
236
+ # Retain the server and defer that wait to drain(), so close()
237
+ # remains the listener-first half of shutdown.
238
+ self._closed_servers.append(server)
239
+ self._state = AdminHttpState.CLOSED
240
+
241
+ # Once the listener is closed, sockets which are not executing a
242
+ # handler cannot contribute useful shutdown work. Active handlers get
243
+ # the drain budget and close their socket when they finish.
244
+ for connection in tuple(self._connections):
245
+ if connection.handler_task is None:
246
+ connection.writer.close()
247
+
248
+ async def drain(self, deadline: float) -> None:
249
+ """Drain tracked work until an absolute ``loop.time()`` deadline.
250
+
251
+ The listener is always closed before waiting. At the deadline all
252
+ remaining transport-owned work is cancelled and every socket is
253
+ closed. Application handlers which shield independently tracked
254
+ native mutations may continue their application-owned work.
255
+ """
256
+
257
+ self._assert_loop()
258
+ if not isinstance(deadline, (int, float)) or isinstance(deadline, bool):
259
+ raise TypeError("deadline must be a monotonic timestamp")
260
+ await self.close()
261
+
262
+ tasks = self._all_tasks()
263
+ remaining = max(0.0, deadline - self._owned_loop().time())
264
+ if tasks and remaining > 0:
265
+ cancellation_grace = min(
266
+ _DRAIN_CANCELLATION_GRACE_SECONDS,
267
+ remaining,
268
+ )
269
+ _done, pending = await asyncio.wait(
270
+ tasks,
271
+ timeout=max(0.0, remaining - cancellation_grace),
272
+ )
273
+ else:
274
+ pending = tasks
275
+
276
+ for connection in tuple(self._connections):
277
+ connection.writer.close()
278
+ for task in pending:
279
+ task.cancel()
280
+ if pending:
281
+ # Give cooperative cancellation a chance without allowing an
282
+ # application handler which suppresses cancellation to overrun
283
+ # the caller's absolute deadline.
284
+ await asyncio.sleep(0)
285
+ remaining = max(0.0, deadline - self._owned_loop().time())
286
+ if remaining > 0:
287
+ await asyncio.wait(pending, timeout=remaining)
288
+ remaining = max(0.0, deadline - self._owned_loop().time())
289
+ if remaining > 0:
290
+ with contextlib.suppress(asyncio.TimeoutError):
291
+ await asyncio.wait_for(
292
+ self._wait_writers_closed(),
293
+ timeout=remaining,
294
+ )
295
+ remaining = max(0.0, deadline - self._owned_loop().time())
296
+ servers, self._closed_servers = self._closed_servers, []
297
+ if servers and remaining > 0:
298
+ with contextlib.suppress(asyncio.TimeoutError):
299
+ await asyncio.wait_for(
300
+ asyncio.gather(*(server.wait_closed() for server in servers)),
301
+ timeout=remaining,
302
+ )
303
+
304
+ def _assert_loop(self) -> None:
305
+ loop = self._loop
306
+ if loop is None:
307
+ raise RuntimeError("Admin HTTP transport is not bound")
308
+ try:
309
+ running = asyncio.get_running_loop()
310
+ except RuntimeError as exc:
311
+ raise RuntimeError(
312
+ "Admin HTTP lifecycle requires its creator event loop"
313
+ ) from exc
314
+ if running is not loop:
315
+ raise RuntimeError(
316
+ "Admin HTTP lifecycle requires its creator event loop"
317
+ )
318
+
319
+ def _owned_loop(self) -> asyncio.AbstractEventLoop:
320
+ loop = self._loop
321
+ assert loop is not None
322
+ return loop
323
+
324
+ def _accepted(
325
+ self,
326
+ reader: asyncio.StreamReader,
327
+ writer: asyncio.StreamWriter,
328
+ ) -> None:
329
+ # The callback runs synchronously on the owner loop. Reserving the
330
+ # slot before creating the task prevents simultaneous accepts from
331
+ # exceeding the application-visible cap.
332
+ if (
333
+ not self._admission_open
334
+ or self._closing
335
+ or len(self._connections) >= MAX_ACTIVE_CONNECTIONS
336
+ ):
337
+ # Do not let a flood above the admitted-connection cap create an
338
+ # unbounded task set. StreamWriter.close() flushes the already
339
+ # buffered fixed response while preventing further reads.
340
+ with contextlib.suppress(ConnectionError, OSError, RuntimeError):
341
+ writer.write(_minimal_response_bytes(503, b"service unavailable"))
342
+ writer.close()
343
+ return
344
+
345
+ connection = _Connection(
346
+ writer=writer,
347
+ accepted_at=self._owned_loop().time(),
348
+ )
349
+ self._connections.add(connection)
350
+ task = self._owned_loop().create_task(
351
+ self._serve_connection(connection, reader)
352
+ )
353
+ connection.task = task
354
+ self._track_task(task, self._connection_tasks)
355
+
356
+ async def _serve_connection(
357
+ self,
358
+ connection: _Connection,
359
+ reader: asyncio.StreamReader,
360
+ ) -> None:
361
+ writer = connection.writer
362
+ peer = _immutable_address(writer.get_extra_info("peername"))
363
+ sockname = _immutable_address(writer.get_extra_info("sockname"))
364
+ parser = h11.Connection(
365
+ h11.SERVER,
366
+ max_incomplete_event_size=MAX_HEADER_SECTION_BYTES,
367
+ )
368
+ pending = b""
369
+ first_request = True
370
+
371
+ try:
372
+ while not self._closing:
373
+ request_id = secrets.token_hex(16)
374
+ try:
375
+ request, pending = await self._read_request(
376
+ reader=reader,
377
+ parser=parser,
378
+ pending=pending,
379
+ first_request=first_request,
380
+ accepted_at=connection.accepted_at,
381
+ peer=peer,
382
+ sockname=sockname,
383
+ request_id=request_id,
384
+ )
385
+ except _ClientDisconnected:
386
+ return
387
+ except asyncio.TimeoutError:
388
+ await self._safe_error(
389
+ parser, writer, 408, b"request timeout", request_id
390
+ )
391
+ return
392
+ except _RequestRejected as exc:
393
+ self._log_rejection(request_id, exc.code)
394
+ await self._safe_error(
395
+ parser,
396
+ writer,
397
+ exc.status,
398
+ _status_body(exc.status),
399
+ request_id,
400
+ )
401
+ return
402
+ except (h11.RemoteProtocolError, h11.LocalProtocolError):
403
+ self._log_rejection(request_id, "protocol_error")
404
+ await self._safe_error(
405
+ parser, writer, 400, b"bad request", request_id
406
+ )
407
+ return
408
+
409
+ first_request = False
410
+ if self._closing:
411
+ return
412
+ if not self._admission_open:
413
+ await self._safe_error(
414
+ parser,
415
+ writer,
416
+ 503,
417
+ b"service unavailable",
418
+ request_id,
419
+ )
420
+ return
421
+ try:
422
+ await _reject_immediately_buffered_pipeline(reader)
423
+ except _ClientDisconnected:
424
+ return
425
+ except _RequestRejected as exc:
426
+ self._log_rejection(request_id, exc.code)
427
+ await self._safe_error(
428
+ parser,
429
+ writer,
430
+ exc.status,
431
+ _status_body(exc.status),
432
+ request_id,
433
+ )
434
+ return
435
+ force_close = _must_close_request(request)
436
+ handler_task = self._owned_loop().create_task(self._handler(request))
437
+ connection.handler_task = handler_task
438
+ self._track_task(handler_task, self._handler_tasks)
439
+ watcher = self._owned_loop().create_task(reader.read(1))
440
+
441
+ done, _pending_tasks = await asyncio.wait(
442
+ {handler_task, watcher},
443
+ return_when=asyncio.FIRST_COMPLETED,
444
+ )
445
+ if watcher in done:
446
+ extra = watcher.result()
447
+ if not handler_task.done():
448
+ handler_task.cancel()
449
+ await asyncio.gather(handler_task, return_exceptions=True)
450
+ connection.handler_task = None
451
+ if extra:
452
+ self._log_rejection(request_id, "pipelined_request")
453
+ await self._safe_error(
454
+ parser, writer, 400, b"bad request", request_id
455
+ )
456
+ return
457
+
458
+ watcher.cancel()
459
+ await asyncio.gather(watcher, return_exceptions=True)
460
+ connection.handler_task = None
461
+ try:
462
+ response = handler_task.result()
463
+ await self._write_response(
464
+ parser,
465
+ writer,
466
+ response,
467
+ force_close=force_close or self._closing,
468
+ )
469
+ except asyncio.CancelledError:
470
+ raise
471
+ except Exception:
472
+ # Never render exception strings or tracebacks into either
473
+ # the response or logs; handler inputs can reach both.
474
+ logger.error(
475
+ "admin HTTP handler failed request_id=%s",
476
+ request_id,
477
+ )
478
+ await self._safe_error(
479
+ parser, writer, 500, b"internal server error", request_id
480
+ )
481
+ return
482
+
483
+ if force_close or self._closing:
484
+ return
485
+ try:
486
+ parser.start_next_cycle()
487
+ except h11.LocalProtocolError:
488
+ return
489
+ pending = b""
490
+ except asyncio.CancelledError:
491
+ handler = connection.handler_task
492
+ if handler is not None and not handler.done():
493
+ handler.cancel()
494
+ await asyncio.gather(handler, return_exceptions=True)
495
+ raise
496
+ except (ConnectionError, OSError, h11.LocalProtocolError):
497
+ return
498
+ finally:
499
+ connection.handler_task = None
500
+ self._connections.discard(connection)
501
+ await _close_writer(writer)
502
+
503
+ async def _read_request(
504
+ self,
505
+ *,
506
+ reader: asyncio.StreamReader,
507
+ parser: h11.Connection,
508
+ pending: bytes,
509
+ first_request: bool,
510
+ accepted_at: float,
511
+ peer: Address,
512
+ sockname: Address,
513
+ request_id: str,
514
+ ) -> tuple[Request, bytes]:
515
+ loop = self._owned_loop()
516
+
517
+ if first_request:
518
+ header_deadline = accepted_at + HEADER_TIMEOUT_SECONDS
519
+ elif pending:
520
+ header_deadline = loop.time() + HEADER_TIMEOUT_SECONDS
521
+ else:
522
+ try:
523
+ first_byte = await asyncio.wait_for(
524
+ reader.read(1), timeout=KEEPALIVE_TIMEOUT_SECONDS
525
+ )
526
+ except asyncio.TimeoutError as exc:
527
+ raise _ClientDisconnected from exc
528
+ if not first_byte:
529
+ raise _ClientDisconnected
530
+ pending = first_byte
531
+ header_deadline = loop.time() + HEADER_TIMEOUT_SECONDS
532
+
533
+ header, pending = await self._read_header_section(
534
+ reader, pending, header_deadline
535
+ )
536
+ _validate_raw_header_section(header)
537
+
538
+ parser.receive_data(header)
539
+ event = parser.next_event()
540
+ if not isinstance(event, h11.Request):
541
+ raise _RequestRejected(400, "missing_request")
542
+ _validate_request_event(event)
543
+
544
+ content_length = _content_length(event)
545
+ if content_length > MAX_BODY_BYTES:
546
+ raise _RequestRejected(413, "body_too_large")
547
+ if event.method != b"POST" and content_length:
548
+ raise _RequestRejected(400, "body_not_allowed")
549
+
550
+ body = bytearray()
551
+ body_deadline = loop.time() + BODY_TIMEOUT_SECONDS
552
+ while True:
553
+ next_event = parser.next_event()
554
+ if isinstance(next_event, h11.Data):
555
+ body.extend(next_event.data)
556
+ if len(body) > MAX_BODY_BYTES:
557
+ raise _RequestRejected(413, "body_too_large")
558
+ continue
559
+ if isinstance(next_event, h11.EndOfMessage):
560
+ break
561
+ if next_event is h11.PAUSED:
562
+ raise _RequestRejected(400, "pipelined_request")
563
+ if next_event is not h11.NEED_DATA:
564
+ raise _RequestRejected(400, "invalid_request_event")
565
+
566
+ if pending:
567
+ data, pending = pending, b""
568
+ else:
569
+ data = await _read_before(reader, body_deadline)
570
+ if not data:
571
+ raise _ClientDisconnected
572
+ parser.receive_data(data)
573
+
574
+ parser_pending, _closed = parser.trailing_data
575
+ pending = parser_pending + pending
576
+ if pending:
577
+ # Any bytes following one complete request before its handler is
578
+ # entered are pipelining, including a same-write second request.
579
+ raise _RequestRejected(400, "pipelined_request")
580
+
581
+ request = Request(
582
+ method=event.method,
583
+ target=event.target,
584
+ http_version=event.http_version,
585
+ raw_headers=tuple(event.headers.raw_items()),
586
+ body=bytes(body),
587
+ peer=peer,
588
+ sockname=sockname,
589
+ request_id=request_id,
590
+ )
591
+ return request, b""
592
+
593
+ async def _read_header_section(
594
+ self,
595
+ reader: asyncio.StreamReader,
596
+ pending: bytes,
597
+ deadline: float,
598
+ ) -> tuple[bytes, bytes]:
599
+ collected = bytearray(pending)
600
+ while True:
601
+ terminator_at = collected.find(_HEADER_TERMINATOR)
602
+ if terminator_at >= 0:
603
+ end = terminator_at + len(_HEADER_TERMINATOR)
604
+ if end > MAX_HEADER_SECTION_BYTES:
605
+ raise _RequestRejected(431, "headers_too_large")
606
+ return bytes(collected[:end]), bytes(collected[end:])
607
+ if len(collected) > MAX_HEADER_SECTION_BYTES:
608
+ raise _RequestRejected(431, "headers_too_large")
609
+ _validate_incomplete_header_lines(collected)
610
+ data = await _read_before(reader, deadline)
611
+ if not data:
612
+ raise _ClientDisconnected
613
+ collected.extend(data)
614
+
615
+ async def _write_response(
616
+ self,
617
+ parser: h11.Connection,
618
+ writer: asyncio.StreamWriter,
619
+ response: Response,
620
+ *,
621
+ force_close: bool,
622
+ ) -> None:
623
+ if not isinstance(response, Response):
624
+ raise TypeError("handler must return Response")
625
+ if not 200 <= response.status <= 599:
626
+ raise ValueError("response status is invalid")
627
+ if response.status in {204, 304} and response.body:
628
+ raise ValueError("response status does not permit a body")
629
+
630
+ headers: list[tuple[bytes, bytes]] = []
631
+ for name, value in response.headers:
632
+ if not isinstance(name, bytes) or not isinstance(value, bytes):
633
+ raise TypeError("response headers must be bytes pairs")
634
+ if name.lower() in _RESERVED_RESPONSE_HEADERS:
635
+ raise ValueError("response contains a transport-owned header")
636
+ headers.append((name, value))
637
+ headers.append((b"Content-Length", str(len(response.body)).encode("ascii")))
638
+ if force_close:
639
+ headers.append((b"Connection", b"close"))
640
+
641
+ head = parser.send(
642
+ h11.Response(status_code=response.status, headers=headers)
643
+ )
644
+ data = parser.send(h11.Data(data=response.body))
645
+ end = parser.send(h11.EndOfMessage())
646
+ writer.write(head + data + end)
647
+ await writer.drain()
648
+
649
+ async def _safe_error(
650
+ self,
651
+ parser: h11.Connection,
652
+ writer: asyncio.StreamWriter,
653
+ status: int,
654
+ body: bytes,
655
+ request_id: str,
656
+ ) -> None:
657
+ try:
658
+ if parser.our_state is h11.IDLE:
659
+ await self._write_response(
660
+ parser,
661
+ writer,
662
+ Response(status=status, body=body),
663
+ force_close=True,
664
+ )
665
+ return
666
+ except (h11.ProtocolError, ConnectionError, OSError, RuntimeError):
667
+ pass
668
+ try:
669
+ await self._write_minimal_response(writer, status, body)
670
+ except (ConnectionError, OSError, RuntimeError):
671
+ logger.info(
672
+ "admin HTTP error response unavailable request_id=%s",
673
+ request_id,
674
+ )
675
+
676
+ async def _write_minimal_response(
677
+ self,
678
+ writer: asyncio.StreamWriter,
679
+ status: int,
680
+ body: bytes,
681
+ ) -> None:
682
+ writer.write(_minimal_response_bytes(status, body))
683
+ await writer.drain()
684
+
685
+ def _log_rejection(self, request_id: str, code: str) -> None:
686
+ logger.info(
687
+ "admin HTTP request rejected request_id=%s code=%s",
688
+ request_id,
689
+ code,
690
+ )
691
+
692
+ def _track_task(self, task: asyncio.Task, bucket: set) -> None:
693
+ bucket.add(task)
694
+
695
+ def finished(done: asyncio.Task) -> None:
696
+ bucket.discard(done)
697
+ if not done.cancelled():
698
+ # Retrieve failures even when shutdown races with the normal
699
+ # owner await, preventing unobserved-task diagnostics.
700
+ done.exception()
701
+
702
+ task.add_done_callback(finished)
703
+
704
+ def _all_tasks(self) -> set[asyncio.Task]:
705
+ return {
706
+ *self._connection_tasks,
707
+ *self._handler_tasks,
708
+ }
709
+
710
+ async def _wait_writers_closed(self) -> None:
711
+ writers = [connection.writer for connection in tuple(self._connections)]
712
+ if writers:
713
+ await asyncio.gather(
714
+ *(_wait_writer_closed(writer) for writer in writers),
715
+ return_exceptions=True,
716
+ )
717
+
718
+
719
+ async def _read_before(
720
+ reader: asyncio.StreamReader,
721
+ deadline: float,
722
+ ) -> bytes:
723
+ loop = asyncio.get_running_loop()
724
+ remaining = deadline - loop.time()
725
+ if remaining <= 0:
726
+ raise asyncio.TimeoutError
727
+ return await asyncio.wait_for(reader.read(_READ_CHUNK_BYTES), timeout=remaining)
728
+
729
+
730
+ async def _reject_immediately_buffered_pipeline(
731
+ reader: asyncio.StreamReader,
732
+ ) -> None:
733
+ """Reject bytes already queued behind a complete request.
734
+
735
+ h11 exposes bytes delivered to it through ``trailing_data``. A public
736
+ StreamReader probe covers bytes from the same socket write which asyncio
737
+ retained in its own buffer, without reaching into StreamReader internals.
738
+ """
739
+
740
+ probe = asyncio.create_task(reader.read(1))
741
+ await asyncio.sleep(0)
742
+ if probe.done():
743
+ try:
744
+ data = probe.result()
745
+ except asyncio.CancelledError as exc:
746
+ raise _ClientDisconnected from exc
747
+ if data:
748
+ raise _RequestRejected(400, "pipelined_request")
749
+ raise _ClientDisconnected
750
+ probe.cancel()
751
+ await asyncio.gather(probe, return_exceptions=True)
752
+
753
+
754
+ def _validate_incomplete_header_lines(data: bytearray) -> None:
755
+ if b"\x00" in data:
756
+ raise _RequestRejected(400, "nul_byte")
757
+ previous = -1
758
+ for index, byte in enumerate(data):
759
+ if byte == 0x0A and (index == 0 or data[index - 1] != 0x0D):
760
+ raise _RequestRejected(400, "bare_lf")
761
+ if byte == 0x0A:
762
+ if index - previous > MAX_REQUEST_OR_HEADER_LINE_BYTES:
763
+ raise _RequestRejected(431, "header_line_too_large")
764
+ previous = index
765
+ if len(data) - (previous + 1) > MAX_REQUEST_OR_HEADER_LINE_BYTES:
766
+ raise _RequestRejected(431, "header_line_too_large")
767
+
768
+
769
+ def _validate_raw_header_section(header: bytes) -> None:
770
+ data = bytearray(header)
771
+ _validate_incomplete_header_lines(data)
772
+ if not header.endswith(_HEADER_TERMINATOR):
773
+ raise _RequestRejected(400, "incomplete_headers")
774
+
775
+ lines = header[:-4].split(b"\r\n")
776
+ if not lines or not lines[0]:
777
+ raise _RequestRejected(400, "missing_request_line")
778
+ names: list[bytes] = []
779
+ for line in lines[1:]:
780
+ if b":" not in line:
781
+ raise _RequestRejected(400, "invalid_header")
782
+ name, _value = line.split(b":", 1)
783
+ names.append(name.lower())
784
+ if names.count(b"host") != 1:
785
+ raise _RequestRejected(400, "invalid_host_count")
786
+ if names.count(b"content-length") > 1:
787
+ raise _RequestRejected(400, "duplicate_content_length")
788
+
789
+
790
+ def _validate_request_event(event: h11.Request) -> None:
791
+ if event.http_version != b"1.1":
792
+ raise _RequestRejected(505, "unsupported_http_version")
793
+ if event.method == b"CONNECT":
794
+ raise _RequestRejected(405, "connect_not_allowed")
795
+ if event.method not in {b"GET", b"POST"}:
796
+ raise _RequestRejected(405, "method_not_allowed")
797
+
798
+ grouped: dict[bytes, list[bytes]] = {}
799
+ for name, value in event.headers:
800
+ grouped.setdefault(name.lower(), []).append(value)
801
+ host_values = grouped.get(b"host", [])
802
+ if len(host_values) != 1 or not host_values[0]:
803
+ raise _RequestRejected(400, "invalid_host")
804
+ if b"transfer-encoding" in grouped or b"te" in grouped:
805
+ raise _RequestRejected(400, "transfer_encoding_not_allowed")
806
+ if b"content-encoding" in grouped:
807
+ raise _RequestRejected(400, "content_encoding_not_allowed")
808
+ if b"expect" in grouped:
809
+ raise _RequestRejected(417, "expect_not_allowed")
810
+ if b"upgrade" in grouped:
811
+ raise _RequestRejected(400, "upgrade_not_allowed")
812
+ for value in grouped.get(b"connection", []):
813
+ if b"upgrade" in {token.strip().lower() for token in value.split(b",")}:
814
+ raise _RequestRejected(400, "upgrade_not_allowed")
815
+ for value in grouped.get(b"content-type", []):
816
+ media_type = value.split(b";", 1)[0].strip().lower()
817
+ if media_type.startswith(b"multipart/"):
818
+ raise _RequestRejected(415, "multipart_not_allowed")
819
+
820
+
821
+ def _content_length(event: h11.Request) -> int:
822
+ values = [value for name, value in event.headers if name == b"content-length"]
823
+ if not values:
824
+ return 0
825
+ if len(values) != 1:
826
+ raise _RequestRejected(400, "duplicate_content_length")
827
+ value = values[0]
828
+ if not value or not value.isdigit():
829
+ raise _RequestRejected(400, "invalid_content_length")
830
+ try:
831
+ return int(value)
832
+ except ValueError as exc:
833
+ raise _RequestRejected(400, "invalid_content_length") from exc
834
+
835
+
836
+ def _must_close_request(request: Request) -> bool:
837
+ if request.method == b"POST":
838
+ return True
839
+ for value in request.header_values(b"connection"):
840
+ if b"close" in {token.strip().lower() for token in value.split(b",")}:
841
+ return True
842
+ return False
843
+
844
+
845
+ def _freeze_headers(headers: RawHeaders) -> RawHeaders:
846
+ try:
847
+ iterator = iter(headers)
848
+ except TypeError as exc:
849
+ raise TypeError("headers must contain name/value pairs") from exc
850
+ frozen: list[tuple[bytes, bytes]] = []
851
+ for item in iterator:
852
+ try:
853
+ name, value = item
854
+ except (TypeError, ValueError) as exc:
855
+ raise TypeError("headers must contain name/value pairs") from exc
856
+ if not isinstance(name, bytes) or not isinstance(value, bytes):
857
+ raise TypeError("header names and values must be bytes")
858
+ frozen.append((name, value))
859
+ return tuple(frozen)
860
+
861
+
862
+ def _immutable_address(value: object) -> Address:
863
+ if isinstance(value, tuple):
864
+ return tuple(value)
865
+ if isinstance(value, str) or value is None:
866
+ return value
867
+ return repr(type(value).__name__)
868
+
869
+
870
+ async def _close_writer(writer: asyncio.StreamWriter) -> None:
871
+ writer.close()
872
+ await _wait_writer_closed(writer)
873
+
874
+
875
+ async def _wait_writer_closed(writer: asyncio.StreamWriter) -> None:
876
+ with contextlib.suppress(ConnectionError, OSError, RuntimeError):
877
+ await writer.wait_closed()
878
+
879
+
880
+ def _status_body(status: int) -> bytes:
881
+ return {
882
+ 400: b"bad request",
883
+ 405: b"method not allowed",
884
+ 413: b"payload too large",
885
+ 415: b"unsupported media type",
886
+ 417: b"expectation failed",
887
+ 431: b"request header fields too large",
888
+ 505: b"http version not supported",
889
+ }.get(status, b"bad request")
890
+
891
+
892
+ def _reason_phrase(status: int) -> bytes:
893
+ return {
894
+ 200: b"OK",
895
+ 204: b"No Content",
896
+ 400: b"Bad Request",
897
+ 405: b"Method Not Allowed",
898
+ 408: b"Request Timeout",
899
+ 413: b"Payload Too Large",
900
+ 415: b"Unsupported Media Type",
901
+ 417: b"Expectation Failed",
902
+ 431: b"Request Header Fields Too Large",
903
+ 500: b"Internal Server Error",
904
+ 503: b"Service Unavailable",
905
+ 505: b"HTTP Version Not Supported",
906
+ }.get(status, b"Error")
907
+
908
+
909
+ def _minimal_response_bytes(status: int, body: bytes) -> bytes:
910
+ return (
911
+ b"HTTP/1.1 "
912
+ + str(status).encode("ascii")
913
+ + b" "
914
+ + _reason_phrase(status)
915
+ + b"\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\n"
916
+ + b"Content-Length: "
917
+ + str(len(body)).encode("ascii")
918
+ + b"\r\n\r\n"
919
+ + body
920
+ )
921
+
922
+
923
+ __all__ = [
924
+ "AdminHttpState",
925
+ "AdminHttpTransport",
926
+ "BODY_TIMEOUT_SECONDS",
927
+ "HEADER_TIMEOUT_SECONDS",
928
+ "KEEPALIVE_TIMEOUT_SECONDS",
929
+ "MAX_ACTIVE_CONNECTIONS",
930
+ "MAX_BODY_BYTES",
931
+ "MAX_HEADER_SECTION_BYTES",
932
+ "MAX_REQUEST_OR_HEADER_LINE_BYTES",
933
+ "Request",
934
+ "Response",
935
+ ]