ominfra 0.0.0.dev123__py3-none-any.whl → 0.0.0.dev124__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.
@@ -12,13 +12,18 @@ import ctypes as ct
12
12
  import dataclasses as dc
13
13
  import datetime
14
14
  import decimal
15
+ import email.utils
15
16
  import enum
16
17
  import errno
17
18
  import fcntl
18
19
  import fractions
19
20
  import functools
20
21
  import grp
22
+ import html
23
+ import http.client
24
+ import http.server
21
25
  import inspect
26
+ import io
22
27
  import itertools
23
28
  import json
24
29
  import logging
@@ -30,11 +35,13 @@ import resource
30
35
  import select
31
36
  import shlex
32
37
  import signal
38
+ import socket
33
39
  import stat
34
40
  import string
35
41
  import sys
36
42
  import syslog
37
43
  import tempfile
44
+ import textwrap
38
45
  import threading
39
46
  import time
40
47
  import traceback
@@ -49,8 +56,7 @@ import weakref # noqa
49
56
 
50
57
 
51
58
  if sys.version_info < (3, 8):
52
- raise OSError(
53
- f'Requires python (3, 8), got {sys.version_info} from {sys.executable}') # noqa
59
+ raise OSError(f'Requires python (3, 8), got {sys.version_info} from {sys.executable}') # noqa
54
60
 
55
61
 
56
62
  ########################################
@@ -64,6 +70,13 @@ TomlPos = int # ta.TypeAlias
64
70
  # ../../../omlish/lite/cached.py
65
71
  T = ta.TypeVar('T')
66
72
 
73
+ # ../../../omlish/lite/socket.py
74
+ SocketAddress = ta.Any
75
+ SocketHandlerFactory = ta.Callable[[SocketAddress, ta.BinaryIO, ta.BinaryIO], 'SocketHandler']
76
+
77
+ # ../../../omlish/lite/http/parsing.py
78
+ HttpHeaders = http.client.HTTPMessage # ta.TypeAlias
79
+
67
80
  # ../../../omlish/lite/inject.py
68
81
  InjectorKeyCls = ta.Union[type, ta.NewType]
69
82
  InjectorProviderFn = ta.Callable[['Injector'], ta.Any]
@@ -73,6 +86,12 @@ InjectorBindingOrBindings = ta.Union['InjectorBinding', 'InjectorBindings']
73
86
  # ../../configs.py
74
87
  ConfigMapping = ta.Mapping[str, ta.Any]
75
88
 
89
+ # ../../../omlish/lite/http/handlers.py
90
+ HttpHandler = ta.Callable[['HttpHandlerRequest'], 'HttpHandlerResponse']
91
+
92
+ # ../../../omlish/lite/http/coroserver.py
93
+ CoroHttpServerFactory = ta.Callable[[SocketAddress], 'CoroHttpServer']
94
+
76
95
  # ../context.py
77
96
  ServerEpoch = ta.NewType('ServerEpoch', int)
78
97
 
@@ -1254,6 +1273,11 @@ def check_not_isinstance(v: T, spec: ta.Union[type, tuple]) -> T:
1254
1273
  return v
1255
1274
 
1256
1275
 
1276
+ def check_none(v: T) -> None:
1277
+ if v is not None:
1278
+ raise ValueError(v)
1279
+
1280
+
1257
1281
  def check_not_none(v: ta.Optional[T]) -> T:
1258
1282
  if v is None:
1259
1283
  raise ValueError
@@ -1294,6 +1318,25 @@ def check_single(vs: ta.Iterable[T]) -> T:
1294
1318
  return v
1295
1319
 
1296
1320
 
1321
+ ########################################
1322
+ # ../../../omlish/lite/http/versions.py
1323
+
1324
+
1325
+ class HttpProtocolVersion(ta.NamedTuple):
1326
+ major: int
1327
+ minor: int
1328
+
1329
+ def __str__(self) -> str:
1330
+ return f'HTTP/{self.major}.{self.minor}'
1331
+
1332
+
1333
+ class HttpProtocolVersions:
1334
+ HTTP_0_9 = HttpProtocolVersion(0, 9)
1335
+ HTTP_1_0 = HttpProtocolVersion(1, 0)
1336
+ HTTP_1_1 = HttpProtocolVersion(1, 1)
1337
+ HTTP_2_0 = HttpProtocolVersion(2, 0)
1338
+
1339
+
1297
1340
  ########################################
1298
1341
  # ../../../omlish/lite/json.py
1299
1342
 
@@ -1424,6 +1467,76 @@ def deep_subclasses(cls: ta.Type[T]) -> ta.Iterator[ta.Type[T]]:
1424
1467
  todo.extend(reversed(cur.__subclasses__()))
1425
1468
 
1426
1469
 
1470
+ ########################################
1471
+ # ../../../omlish/lite/socket.py
1472
+ """
1473
+ TODO:
1474
+ - SocketClientAddress family / tuple pairs
1475
+ + codification of https://docs.python.org/3/library/socket.html#socket-families
1476
+ """
1477
+
1478
+
1479
+ ##
1480
+
1481
+
1482
+ @dc.dataclass(frozen=True)
1483
+ class SocketAddressInfoArgs:
1484
+ host: ta.Optional[str]
1485
+ port: ta.Union[str, int, None]
1486
+ family: socket.AddressFamily = socket.AddressFamily.AF_UNSPEC
1487
+ type: int = 0
1488
+ proto: int = 0
1489
+ flags: socket.AddressInfo = socket.AddressInfo(0)
1490
+
1491
+
1492
+ @dc.dataclass(frozen=True)
1493
+ class SocketAddressInfo:
1494
+ family: socket.AddressFamily
1495
+ type: int
1496
+ proto: int
1497
+ canonname: ta.Optional[str]
1498
+ sockaddr: SocketAddress
1499
+
1500
+
1501
+ def get_best_socket_family(
1502
+ host: ta.Optional[str],
1503
+ port: ta.Union[str, int, None],
1504
+ family: ta.Union[int, socket.AddressFamily] = socket.AddressFamily.AF_UNSPEC,
1505
+ ) -> ta.Tuple[socket.AddressFamily, SocketAddress]:
1506
+ """https://github.com/python/cpython/commit/f289084c83190cc72db4a70c58f007ec62e75247"""
1507
+
1508
+ infos = socket.getaddrinfo(
1509
+ host,
1510
+ port,
1511
+ family,
1512
+ type=socket.SOCK_STREAM,
1513
+ flags=socket.AI_PASSIVE,
1514
+ )
1515
+ ai = SocketAddressInfo(*next(iter(infos)))
1516
+ return ai.family, ai.sockaddr
1517
+
1518
+
1519
+ ##
1520
+
1521
+
1522
+ class SocketHandler(abc.ABC):
1523
+ def __init__(
1524
+ self,
1525
+ client_address: SocketAddress,
1526
+ rfile: ta.BinaryIO,
1527
+ wfile: ta.BinaryIO,
1528
+ ) -> None:
1529
+ super().__init__()
1530
+
1531
+ self._client_address = client_address
1532
+ self._rfile = rfile
1533
+ self._wfile = wfile
1534
+
1535
+ @abc.abstractmethod
1536
+ def handle(self) -> None:
1537
+ raise NotImplementedError
1538
+
1539
+
1427
1540
  ########################################
1428
1541
  # ../events.py
1429
1542
 
@@ -1910,6 +2023,371 @@ def timeslice(period: int, when: float) -> int:
1910
2023
  return int(when - (when % period))
1911
2024
 
1912
2025
 
2026
+ ########################################
2027
+ # ../../../omlish/lite/http/parsing.py
2028
+
2029
+
2030
+ ##
2031
+
2032
+
2033
+ class ParseHttpRequestResult(abc.ABC): # noqa
2034
+ __slots__ = (
2035
+ 'server_version',
2036
+ 'request_line',
2037
+ 'request_version',
2038
+ 'version',
2039
+ 'headers',
2040
+ 'close_connection',
2041
+ )
2042
+
2043
+ def __init__(
2044
+ self,
2045
+ *,
2046
+ server_version: HttpProtocolVersion,
2047
+ request_line: str,
2048
+ request_version: HttpProtocolVersion,
2049
+ version: HttpProtocolVersion,
2050
+ headers: ta.Optional[HttpHeaders],
2051
+ close_connection: bool,
2052
+ ) -> None:
2053
+ super().__init__()
2054
+
2055
+ self.server_version = server_version
2056
+ self.request_line = request_line
2057
+ self.request_version = request_version
2058
+ self.version = version
2059
+ self.headers = headers
2060
+ self.close_connection = close_connection
2061
+
2062
+ def __repr__(self) -> str:
2063
+ return f'{self.__class__.__name__}({", ".join(f"{a}={getattr(self, a)!r}" for a in self.__slots__)})'
2064
+
2065
+
2066
+ class EmptyParsedHttpResult(ParseHttpRequestResult):
2067
+ pass
2068
+
2069
+
2070
+ class ParseHttpRequestError(ParseHttpRequestResult):
2071
+ __slots__ = (
2072
+ 'code',
2073
+ 'message',
2074
+ *ParseHttpRequestResult.__slots__,
2075
+ )
2076
+
2077
+ def __init__(
2078
+ self,
2079
+ *,
2080
+ code: http.HTTPStatus,
2081
+ message: ta.Union[str, ta.Tuple[str, str]],
2082
+
2083
+ **kwargs: ta.Any,
2084
+ ) -> None:
2085
+ super().__init__(**kwargs)
2086
+
2087
+ self.code = code
2088
+ self.message = message
2089
+
2090
+
2091
+ class ParsedHttpRequest(ParseHttpRequestResult):
2092
+ __slots__ = (
2093
+ 'method',
2094
+ 'path',
2095
+ 'headers',
2096
+ 'expects_continue',
2097
+ *[a for a in ParseHttpRequestResult.__slots__ if a != 'headers'],
2098
+ )
2099
+
2100
+ def __init__(
2101
+ self,
2102
+ *,
2103
+ method: str,
2104
+ path: str,
2105
+ headers: HttpHeaders,
2106
+ expects_continue: bool,
2107
+
2108
+ **kwargs: ta.Any,
2109
+ ) -> None:
2110
+ super().__init__(
2111
+ headers=headers,
2112
+ **kwargs,
2113
+ )
2114
+
2115
+ self.method = method
2116
+ self.path = path
2117
+ self.expects_continue = expects_continue
2118
+
2119
+ headers: HttpHeaders
2120
+
2121
+
2122
+ #
2123
+
2124
+
2125
+ class HttpRequestParser:
2126
+ DEFAULT_SERVER_VERSION = HttpProtocolVersions.HTTP_1_0
2127
+
2128
+ # The default request version. This only affects responses up until the point where the request line is parsed, so
2129
+ # it mainly decides what the client gets back when sending a malformed request line.
2130
+ # Most web servers default to HTTP 0.9, i.e. don't send a status line.
2131
+ DEFAULT_REQUEST_VERSION = HttpProtocolVersions.HTTP_0_9
2132
+
2133
+ #
2134
+
2135
+ DEFAULT_MAX_LINE: int = 0x10000
2136
+ DEFAULT_MAX_HEADERS: int = 100
2137
+
2138
+ #
2139
+
2140
+ def __init__(
2141
+ self,
2142
+ *,
2143
+ server_version: HttpProtocolVersion = DEFAULT_SERVER_VERSION,
2144
+
2145
+ max_line: int = DEFAULT_MAX_LINE,
2146
+ max_headers: int = DEFAULT_MAX_HEADERS,
2147
+ ) -> None:
2148
+ super().__init__()
2149
+
2150
+ if server_version >= HttpProtocolVersions.HTTP_2_0:
2151
+ raise ValueError(f'Unsupported protocol version: {server_version}')
2152
+ self._server_version = server_version
2153
+
2154
+ self._max_line = max_line
2155
+ self._max_headers = max_headers
2156
+
2157
+ #
2158
+
2159
+ @property
2160
+ def server_version(self) -> HttpProtocolVersion:
2161
+ return self._server_version
2162
+
2163
+ #
2164
+
2165
+ def _run_read_line_coro(
2166
+ self,
2167
+ gen: ta.Generator[int, bytes, T],
2168
+ read_line: ta.Callable[[int], bytes],
2169
+ ) -> T:
2170
+ sz = next(gen)
2171
+ while True:
2172
+ try:
2173
+ sz = gen.send(read_line(sz))
2174
+ except StopIteration as e:
2175
+ return e.value
2176
+
2177
+ #
2178
+
2179
+ def parse_request_version(self, version_str: str) -> HttpProtocolVersion:
2180
+ if not version_str.startswith('HTTP/'):
2181
+ raise ValueError(version_str) # noqa
2182
+
2183
+ base_version_number = version_str.split('/', 1)[1]
2184
+ version_number_parts = base_version_number.split('.')
2185
+
2186
+ # RFC 2145 section 3.1 says there can be only one "." and
2187
+ # - major and minor numbers MUST be treated as separate integers;
2188
+ # - HTTP/2.4 is a lower version than HTTP/2.13, which in turn is lower than HTTP/12.3;
2189
+ # - Leading zeros MUST be ignored by recipients.
2190
+ if len(version_number_parts) != 2:
2191
+ raise ValueError(version_number_parts) # noqa
2192
+ if any(not component.isdigit() for component in version_number_parts):
2193
+ raise ValueError('non digit in http version') # noqa
2194
+ if any(len(component) > 10 for component in version_number_parts):
2195
+ raise ValueError('unreasonable length http version') # noqa
2196
+
2197
+ return HttpProtocolVersion(
2198
+ int(version_number_parts[0]),
2199
+ int(version_number_parts[1]),
2200
+ )
2201
+
2202
+ #
2203
+
2204
+ def coro_read_raw_headers(self) -> ta.Generator[int, bytes, ta.List[bytes]]:
2205
+ raw_headers: ta.List[bytes] = []
2206
+ while True:
2207
+ line = yield self._max_line + 1
2208
+ if len(line) > self._max_line:
2209
+ raise http.client.LineTooLong('header line')
2210
+ raw_headers.append(line)
2211
+ if len(raw_headers) > self._max_headers:
2212
+ raise http.client.HTTPException(f'got more than {self._max_headers} headers')
2213
+ if line in (b'\r\n', b'\n', b''):
2214
+ break
2215
+ return raw_headers
2216
+
2217
+ def read_raw_headers(self, read_line: ta.Callable[[int], bytes]) -> ta.List[bytes]:
2218
+ return self._run_read_line_coro(self.coro_read_raw_headers(), read_line)
2219
+
2220
+ def parse_raw_headers(self, raw_headers: ta.Sequence[bytes]) -> HttpHeaders:
2221
+ return http.client.parse_headers(io.BytesIO(b''.join(raw_headers)))
2222
+
2223
+ #
2224
+
2225
+ def coro_parse(self) -> ta.Generator[int, bytes, ParseHttpRequestResult]:
2226
+ raw_request_line = yield self._max_line + 1
2227
+
2228
+ # Common result kwargs
2229
+
2230
+ request_line = '-'
2231
+ request_version = self.DEFAULT_REQUEST_VERSION
2232
+
2233
+ # Set to min(server, request) when it gets that far, but if it fails before that the server authoritatively
2234
+ # responds with its own version.
2235
+ version = self._server_version
2236
+
2237
+ headers: HttpHeaders | None = None
2238
+
2239
+ close_connection = True
2240
+
2241
+ def result_kwargs():
2242
+ return dict(
2243
+ server_version=self._server_version,
2244
+ request_line=request_line,
2245
+ request_version=request_version,
2246
+ version=version,
2247
+ headers=headers,
2248
+ close_connection=close_connection,
2249
+ )
2250
+
2251
+ # Decode line
2252
+
2253
+ if len(raw_request_line) > self._max_line:
2254
+ return ParseHttpRequestError(
2255
+ code=http.HTTPStatus.REQUEST_URI_TOO_LONG,
2256
+ message='Request line too long',
2257
+ **result_kwargs(),
2258
+ )
2259
+
2260
+ if not raw_request_line:
2261
+ return EmptyParsedHttpResult(**result_kwargs())
2262
+
2263
+ request_line = raw_request_line.decode('iso-8859-1').rstrip('\r\n')
2264
+
2265
+ # Split words
2266
+
2267
+ words = request_line.split()
2268
+ if len(words) == 0:
2269
+ return EmptyParsedHttpResult(**result_kwargs())
2270
+
2271
+ # Parse and set version
2272
+
2273
+ if len(words) >= 3: # Enough to determine protocol version
2274
+ version_str = words[-1]
2275
+ try:
2276
+ request_version = self.parse_request_version(version_str)
2277
+
2278
+ except (ValueError, IndexError):
2279
+ return ParseHttpRequestError(
2280
+ code=http.HTTPStatus.BAD_REQUEST,
2281
+ message=f'Bad request version ({version_str!r})',
2282
+ **result_kwargs(),
2283
+ )
2284
+
2285
+ if (
2286
+ request_version < HttpProtocolVersions.HTTP_0_9 or
2287
+ request_version >= HttpProtocolVersions.HTTP_2_0
2288
+ ):
2289
+ return ParseHttpRequestError(
2290
+ code=http.HTTPStatus.HTTP_VERSION_NOT_SUPPORTED,
2291
+ message=f'Invalid HTTP version ({version_str})',
2292
+ **result_kwargs(),
2293
+ )
2294
+
2295
+ version = min([self._server_version, request_version])
2296
+
2297
+ if version >= HttpProtocolVersions.HTTP_1_1:
2298
+ close_connection = False
2299
+
2300
+ # Verify word count
2301
+
2302
+ if not 2 <= len(words) <= 3:
2303
+ return ParseHttpRequestError(
2304
+ code=http.HTTPStatus.BAD_REQUEST,
2305
+ message=f'Bad request syntax ({request_line!r})',
2306
+ **result_kwargs(),
2307
+ )
2308
+
2309
+ # Parse method and path
2310
+
2311
+ method, path = words[:2]
2312
+ if len(words) == 2:
2313
+ close_connection = True
2314
+ if method != 'GET':
2315
+ return ParseHttpRequestError(
2316
+ code=http.HTTPStatus.BAD_REQUEST,
2317
+ message=f'Bad HTTP/0.9 request type ({method!r})',
2318
+ **result_kwargs(),
2319
+ )
2320
+
2321
+ # gh-87389: The purpose of replacing '//' with '/' is to protect against open redirect attacks possibly
2322
+ # triggered if the path starts with '//' because http clients treat //path as an absolute URI without scheme
2323
+ # (similar to http://path) rather than a path.
2324
+ if path.startswith('//'):
2325
+ path = '/' + path.lstrip('/') # Reduce to a single /
2326
+
2327
+ # Parse headers
2328
+
2329
+ try:
2330
+ raw_gen = self.coro_read_raw_headers()
2331
+ raw_sz = next(raw_gen)
2332
+ while True:
2333
+ buf = yield raw_sz
2334
+ try:
2335
+ raw_sz = raw_gen.send(buf)
2336
+ except StopIteration as e:
2337
+ raw_headers = e.value
2338
+ break
2339
+
2340
+ headers = self.parse_raw_headers(raw_headers)
2341
+
2342
+ except http.client.LineTooLong as err:
2343
+ return ParseHttpRequestError(
2344
+ code=http.HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE,
2345
+ message=('Line too long', str(err)),
2346
+ **result_kwargs(),
2347
+ )
2348
+
2349
+ except http.client.HTTPException as err:
2350
+ return ParseHttpRequestError(
2351
+ code=http.HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE,
2352
+ message=('Too many headers', str(err)),
2353
+ **result_kwargs(),
2354
+ )
2355
+
2356
+ # Check for connection directive
2357
+
2358
+ conn_type = headers.get('Connection', '')
2359
+ if conn_type.lower() == 'close':
2360
+ close_connection = True
2361
+ elif (
2362
+ conn_type.lower() == 'keep-alive' and
2363
+ version >= HttpProtocolVersions.HTTP_1_1
2364
+ ):
2365
+ close_connection = False
2366
+
2367
+ # Check for expect directive
2368
+
2369
+ expect = headers.get('Expect', '')
2370
+ if (
2371
+ expect.lower() == '100-continue' and
2372
+ version >= HttpProtocolVersions.HTTP_1_1
2373
+ ):
2374
+ expects_continue = True
2375
+ else:
2376
+ expects_continue = False
2377
+
2378
+ # Return
2379
+
2380
+ return ParsedHttpRequest(
2381
+ method=method,
2382
+ path=path,
2383
+ expects_continue=expects_continue,
2384
+ **result_kwargs(),
2385
+ )
2386
+
2387
+ def parse(self, read_line: ta.Callable[[int], bytes]) -> ParseHttpRequestResult:
2388
+ return self._run_read_line_coro(self.coro_parse(), read_line)
2389
+
2390
+
1913
2391
  ########################################
1914
2392
  # ../../../omlish/lite/inject.py
1915
2393
 
@@ -1919,12 +2397,22 @@ def timeslice(period: int, when: float) -> int:
1919
2397
 
1920
2398
 
1921
2399
  @dc.dataclass(frozen=True)
1922
- class InjectorKey:
2400
+ class InjectorKey(ta.Generic[T]):
1923
2401
  cls: InjectorKeyCls
1924
2402
  tag: ta.Any = None
1925
2403
  array: bool = False
1926
2404
 
1927
2405
 
2406
+ def is_valid_injector_key_cls(cls: ta.Any) -> bool:
2407
+ return isinstance(cls, type) or is_new_type(cls)
2408
+
2409
+
2410
+ def check_valid_injector_key_cls(cls: T) -> T:
2411
+ if not is_valid_injector_key_cls(cls):
2412
+ raise TypeError(cls)
2413
+ return cls
2414
+
2415
+
1928
2416
  ##
1929
2417
 
1930
2418
 
@@ -1968,6 +2456,12 @@ class Injector(abc.ABC):
1968
2456
  def inject(self, obj: ta.Any) -> ta.Any:
1969
2457
  raise NotImplementedError
1970
2458
 
2459
+ def __getitem__(
2460
+ self,
2461
+ target: ta.Union[InjectorKey[T], ta.Type[T]],
2462
+ ) -> T:
2463
+ return self.provide(target)
2464
+
1971
2465
 
1972
2466
  ###
1973
2467
  # exceptions
@@ -2000,7 +2494,7 @@ def as_injector_key(o: ta.Any) -> InjectorKey:
2000
2494
  raise TypeError(o)
2001
2495
  if isinstance(o, InjectorKey):
2002
2496
  return o
2003
- if isinstance(o, type) or is_new_type(o):
2497
+ if is_valid_injector_key_cls(o):
2004
2498
  return InjectorKey(o)
2005
2499
  raise TypeError(o)
2006
2500
 
@@ -2332,8 +2826,8 @@ class InjectorBinder:
2332
2826
  to_fn = obj
2333
2827
  if key is None:
2334
2828
  sig = _injection_signature(obj)
2335
- ty = check_isinstance(sig.return_annotation, type)
2336
- key = InjectorKey(ty)
2829
+ key_cls = check_valid_injector_key_cls(sig.return_annotation)
2830
+ key = InjectorKey(key_cls)
2337
2831
  else:
2338
2832
  if to_const is not None:
2339
2833
  raise TypeError('Cannot bind instance with to_const')
@@ -2387,7 +2881,7 @@ class InjectorBinder:
2387
2881
  # injector
2388
2882
 
2389
2883
 
2390
- _INJECTOR_INJECTOR_KEY = InjectorKey(Injector)
2884
+ _INJECTOR_INJECTOR_KEY: InjectorKey[Injector] = InjectorKey(Injector)
2391
2885
 
2392
2886
 
2393
2887
  class _Injector(Injector):
@@ -2542,7 +3036,6 @@ sd_iovec._fields_ = [
2542
3036
  def sd_libsystemd() -> ta.Any:
2543
3037
  lib = ct.CDLL('libsystemd.so.0')
2544
3038
 
2545
- lib.sd_journal_sendv = lib['sd_journal_sendv'] # type: ignore
2546
3039
  lib.sd_journal_sendv.restype = ct.c_int
2547
3040
  lib.sd_journal_sendv.argtypes = [ct.POINTER(sd_iovec), ct.c_int]
2548
3041
 
@@ -3582,6 +4075,36 @@ def get_poller_impl() -> ta.Type[Poller]:
3582
4075
  return SelectPoller
3583
4076
 
3584
4077
 
4078
+ ########################################
4079
+ # ../../../omlish/lite/http/handlers.py
4080
+
4081
+
4082
+ @dc.dataclass(frozen=True)
4083
+ class HttpHandlerRequest:
4084
+ client_address: SocketAddress
4085
+ method: str
4086
+ path: str
4087
+ headers: HttpHeaders
4088
+ data: ta.Optional[bytes]
4089
+
4090
+
4091
+ @dc.dataclass(frozen=True)
4092
+ class HttpHandlerResponse:
4093
+ status: ta.Union[http.HTTPStatus, int]
4094
+
4095
+ headers: ta.Optional[ta.Mapping[str, str]] = None
4096
+ data: ta.Optional[bytes] = None
4097
+ close_connection: ta.Optional[bool] = None
4098
+
4099
+
4100
+ class HttpHandlerError(Exception):
4101
+ pass
4102
+
4103
+
4104
+ class UnsupportedMethodHttpHandlerError(Exception):
4105
+ pass
4106
+
4107
+
3585
4108
  ########################################
3586
4109
  # ../configs.py
3587
4110
 
@@ -3703,6 +4226,566 @@ def prepare_server_config(dct: ta.Mapping[str, ta.Any]) -> ta.Mapping[str, ta.An
3703
4226
  return out
3704
4227
 
3705
4228
 
4229
+ ########################################
4230
+ # ../../../omlish/lite/http/coroserver.py
4231
+ # PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
4232
+ # --------------------------------------------
4233
+ #
4234
+ # 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization
4235
+ # ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated
4236
+ # documentation.
4237
+ #
4238
+ # 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive,
4239
+ # royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative
4240
+ # works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License
4241
+ # Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009,
4242
+ # 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017 Python Software Foundation; All Rights Reserved" are retained in Python
4243
+ # alone or in any derivative version prepared by Licensee.
4244
+ #
4245
+ # 3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and
4246
+ # wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in
4247
+ # any such work a brief summary of the changes made to Python.
4248
+ #
4249
+ # 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES,
4250
+ # EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY
4251
+ # OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY
4252
+ # RIGHTS.
4253
+ #
4254
+ # 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL
4255
+ # DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF
4256
+ # ADVISED OF THE POSSIBILITY THEREOF.
4257
+ #
4258
+ # 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions.
4259
+ #
4260
+ # 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint
4261
+ # venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade
4262
+ # name in a trademark sense to endorse or promote products or services of Licensee, or any third party.
4263
+ #
4264
+ # 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this
4265
+ # License Agreement.
4266
+ """
4267
+ "Test suite" lol:
4268
+
4269
+ curl -v localhost:8000
4270
+ curl -v localhost:8000 -d 'foo'
4271
+ curl -v -XFOO localhost:8000 -d 'foo'
4272
+ curl -v -XPOST -H 'Expect: 100-Continue' localhost:8000 -d 'foo'
4273
+
4274
+ curl -v -0 localhost:8000
4275
+ curl -v -0 localhost:8000 -d 'foo'
4276
+ curl -v -0 -XFOO localhost:8000 -d 'foo'
4277
+
4278
+ curl -v -XPOST localhost:8000 -d 'foo' --next -XPOST localhost:8000 -d 'bar'
4279
+ curl -v -XPOST localhost:8000 -d 'foo' --next -XFOO localhost:8000 -d 'bar'
4280
+ curl -v -XFOO localhost:8000 -d 'foo' --next -XPOST localhost:8000 -d 'bar'
4281
+ curl -v -XFOO localhost:8000 -d 'foo' --next -XFOO localhost:8000 -d 'bar'
4282
+ """
4283
+
4284
+
4285
+ ##
4286
+
4287
+
4288
+ class CoroHttpServer:
4289
+ """
4290
+ Adapted from stdlib:
4291
+ - https://github.com/python/cpython/blob/4b4e0dbdf49adc91c35a357ad332ab3abd4c31b1/Lib/http/server.py#L146
4292
+ """
4293
+
4294
+ #
4295
+
4296
+ def __init__(
4297
+ self,
4298
+ client_address: SocketAddress,
4299
+ *,
4300
+ handler: HttpHandler,
4301
+ parser: HttpRequestParser = HttpRequestParser(),
4302
+
4303
+ default_content_type: ta.Optional[str] = None,
4304
+
4305
+ error_message_format: ta.Optional[str] = None,
4306
+ error_content_type: ta.Optional[str] = None,
4307
+ ) -> None:
4308
+ super().__init__()
4309
+
4310
+ self._client_address = client_address
4311
+
4312
+ self._handler = handler
4313
+ self._parser = parser
4314
+
4315
+ self._default_content_type = default_content_type or self.DEFAULT_CONTENT_TYPE
4316
+
4317
+ self._error_message_format = error_message_format or self.DEFAULT_ERROR_MESSAGE
4318
+ self._error_content_type = error_content_type or self.DEFAULT_ERROR_CONTENT_TYPE
4319
+
4320
+ #
4321
+
4322
+ @property
4323
+ def client_address(self) -> SocketAddress:
4324
+ return self._client_address
4325
+
4326
+ @property
4327
+ def handler(self) -> HttpHandler:
4328
+ return self._handler
4329
+
4330
+ @property
4331
+ def parser(self) -> HttpRequestParser:
4332
+ return self._parser
4333
+
4334
+ #
4335
+
4336
+ def _format_timestamp(self, timestamp: ta.Optional[float] = None) -> str:
4337
+ if timestamp is None:
4338
+ timestamp = time.time()
4339
+ return email.utils.formatdate(timestamp, usegmt=True)
4340
+
4341
+ #
4342
+
4343
+ def _header_encode(self, s: str) -> bytes:
4344
+ return s.encode('latin-1', 'strict')
4345
+
4346
+ class _Header(ta.NamedTuple):
4347
+ key: str
4348
+ value: str
4349
+
4350
+ def _format_header_line(self, h: _Header) -> str:
4351
+ return f'{h.key}: {h.value}\r\n'
4352
+
4353
+ def _get_header_close_connection_action(self, h: _Header) -> ta.Optional[bool]:
4354
+ if h.key.lower() != 'connection':
4355
+ return None
4356
+ elif h.value.lower() == 'close':
4357
+ return True
4358
+ elif h.value.lower() == 'keep-alive':
4359
+ return False
4360
+ else:
4361
+ return None
4362
+
4363
+ def _make_default_headers(self) -> ta.List[_Header]:
4364
+ return [
4365
+ self._Header('Date', self._format_timestamp()),
4366
+ ]
4367
+
4368
+ #
4369
+
4370
+ _STATUS_RESPONSES: ta.Mapping[int, ta.Tuple[str, str]] = {
4371
+ v: (v.phrase, v.description)
4372
+ for v in http.HTTPStatus.__members__.values()
4373
+ }
4374
+
4375
+ def _format_status_line(
4376
+ self,
4377
+ version: HttpProtocolVersion,
4378
+ code: ta.Union[http.HTTPStatus, int],
4379
+ message: ta.Optional[str] = None,
4380
+ ) -> str:
4381
+ if message is None:
4382
+ if code in self._STATUS_RESPONSES:
4383
+ message = self._STATUS_RESPONSES[code][0]
4384
+ else:
4385
+ message = ''
4386
+
4387
+ return f'{version} {int(code)} {message}\r\n'
4388
+
4389
+ #
4390
+
4391
+ @dc.dataclass(frozen=True)
4392
+ class _Response:
4393
+ version: HttpProtocolVersion
4394
+ code: http.HTTPStatus
4395
+
4396
+ message: ta.Optional[str] = None
4397
+ headers: ta.Optional[ta.Sequence['CoroHttpServer._Header']] = None
4398
+ data: ta.Optional[bytes] = None
4399
+ close_connection: ta.Optional[bool] = False
4400
+
4401
+ def get_header(self, key: str) -> ta.Optional['CoroHttpServer._Header']:
4402
+ for h in self.headers or []:
4403
+ if h.key.lower() == key.lower():
4404
+ return h
4405
+ return None
4406
+
4407
+ #
4408
+
4409
+ def _build_response_bytes(self, a: _Response) -> bytes:
4410
+ out = io.BytesIO()
4411
+
4412
+ if a.version >= HttpProtocolVersions.HTTP_1_0:
4413
+ out.write(self._header_encode(self._format_status_line(
4414
+ a.version,
4415
+ a.code,
4416
+ a.message,
4417
+ )))
4418
+
4419
+ for h in a.headers or []:
4420
+ out.write(self._header_encode(self._format_header_line(h)))
4421
+
4422
+ out.write(b'\r\n')
4423
+
4424
+ if a.data is not None:
4425
+ out.write(a.data)
4426
+
4427
+ return out.getvalue()
4428
+
4429
+ #
4430
+
4431
+ DEFAULT_CONTENT_TYPE = 'text/plain'
4432
+
4433
+ def _preprocess_response(self, resp: _Response) -> _Response:
4434
+ nh: ta.List[CoroHttpServer._Header] = []
4435
+ kw: ta.Dict[str, ta.Any] = {}
4436
+
4437
+ if resp.get_header('Content-Type') is None:
4438
+ nh.append(self._Header('Content-Type', self._default_content_type))
4439
+ if resp.data is not None and resp.get_header('Content-Length') is None:
4440
+ nh.append(self._Header('Content-Length', str(len(resp.data))))
4441
+
4442
+ if nh:
4443
+ kw.update(headers=[*(resp.headers or []), *nh])
4444
+
4445
+ if (clh := resp.get_header('Connection')) is not None:
4446
+ if self._get_header_close_connection_action(clh):
4447
+ kw.update(close_connection=True)
4448
+
4449
+ if not kw:
4450
+ return resp
4451
+ return dc.replace(resp, **kw)
4452
+
4453
+ #
4454
+
4455
+ @dc.dataclass(frozen=True)
4456
+ class Error:
4457
+ version: HttpProtocolVersion
4458
+ code: http.HTTPStatus
4459
+ message: str
4460
+ explain: str
4461
+
4462
+ method: ta.Optional[str] = None
4463
+
4464
+ def _build_error(
4465
+ self,
4466
+ code: ta.Union[http.HTTPStatus, int],
4467
+ message: ta.Optional[str] = None,
4468
+ explain: ta.Optional[str] = None,
4469
+ *,
4470
+ version: ta.Optional[HttpProtocolVersion] = None,
4471
+ method: ta.Optional[str] = None,
4472
+ ) -> Error:
4473
+ code = http.HTTPStatus(code)
4474
+
4475
+ try:
4476
+ short_msg, long_msg = self._STATUS_RESPONSES[code]
4477
+ except KeyError:
4478
+ short_msg, long_msg = '???', '???'
4479
+ if message is None:
4480
+ message = short_msg
4481
+ if explain is None:
4482
+ explain = long_msg
4483
+
4484
+ if version is None:
4485
+ version = self._parser.server_version
4486
+
4487
+ return self.Error(
4488
+ version=version,
4489
+ code=code,
4490
+ message=message,
4491
+ explain=explain,
4492
+
4493
+ method=method,
4494
+ )
4495
+
4496
+ #
4497
+
4498
+ DEFAULT_ERROR_MESSAGE = textwrap.dedent("""\
4499
+ <!DOCTYPE HTML>
4500
+ <html lang="en">
4501
+ <head>
4502
+ <meta charset="utf-8">
4503
+ <title>Error response</title>
4504
+ </head>
4505
+ <body>
4506
+ <h1>Error response</h1>
4507
+ <p>Error code: %(code)d</p>
4508
+ <p>Message: %(message)s.</p>
4509
+ <p>Error code explanation: %(code)s - %(explain)s.</p>
4510
+ </body>
4511
+ </html>
4512
+ """)
4513
+
4514
+ DEFAULT_ERROR_CONTENT_TYPE = 'text/html;charset=utf-8'
4515
+
4516
+ def _build_error_response(self, err: Error) -> _Response:
4517
+ headers: ta.List[CoroHttpServer._Header] = [
4518
+ *self._make_default_headers(),
4519
+ self._Header('Connection', 'close'),
4520
+ ]
4521
+
4522
+ # Message body is omitted for cases described in:
4523
+ # - RFC7230: 3.3. 1xx, 204(No Content), 304(Not Modified)
4524
+ # - RFC7231: 6.3.6. 205(Reset Content)
4525
+ data: ta.Optional[bytes] = None
4526
+ if (
4527
+ err.code >= http.HTTPStatus.OK and
4528
+ err.code not in (
4529
+ http.HTTPStatus.NO_CONTENT,
4530
+ http.HTTPStatus.RESET_CONTENT,
4531
+ http.HTTPStatus.NOT_MODIFIED,
4532
+ )
4533
+ ):
4534
+ # HTML encode to prevent Cross Site Scripting attacks (see bug #1100201)
4535
+ content = self._error_message_format.format(
4536
+ code=err.code,
4537
+ message=html.escape(err.message, quote=False),
4538
+ explain=html.escape(err.explain, quote=False),
4539
+ )
4540
+ body = content.encode('UTF-8', 'replace')
4541
+
4542
+ headers.extend([
4543
+ self._Header('Content-Type', self._error_content_type),
4544
+ self._Header('Content-Length', str(len(body))),
4545
+ ])
4546
+
4547
+ if err.method != 'HEAD' and body:
4548
+ data = body
4549
+
4550
+ return self._Response(
4551
+ version=err.version,
4552
+ code=err.code,
4553
+ message=err.message,
4554
+ headers=headers,
4555
+ data=data,
4556
+ close_connection=True,
4557
+ )
4558
+
4559
+ #
4560
+
4561
+ class Io(abc.ABC): # noqa
4562
+ pass
4563
+
4564
+ #
4565
+
4566
+ class AnyLogIo(Io):
4567
+ pass
4568
+
4569
+ @dc.dataclass(frozen=True)
4570
+ class ParsedRequestLogIo(AnyLogIo):
4571
+ request: ParsedHttpRequest
4572
+
4573
+ @dc.dataclass(frozen=True)
4574
+ class ErrorLogIo(AnyLogIo):
4575
+ error: 'CoroHttpServer.Error'
4576
+
4577
+ #
4578
+
4579
+ class AnyReadIo(Io): # noqa
4580
+ pass
4581
+
4582
+ @dc.dataclass(frozen=True)
4583
+ class ReadIo(AnyReadIo):
4584
+ sz: int
4585
+
4586
+ @dc.dataclass(frozen=True)
4587
+ class ReadLineIo(AnyReadIo):
4588
+ sz: int
4589
+
4590
+ #
4591
+
4592
+ @dc.dataclass(frozen=True)
4593
+ class WriteIo(Io):
4594
+ data: bytes
4595
+
4596
+ #
4597
+
4598
+ def coro_handle(self) -> ta.Generator[Io, ta.Optional[bytes], None]:
4599
+ while True:
4600
+ gen = self.coro_handle_one()
4601
+
4602
+ o = next(gen)
4603
+ i: ta.Optional[bytes]
4604
+ while True:
4605
+ if isinstance(o, self.AnyLogIo):
4606
+ i = None
4607
+ yield o
4608
+
4609
+ elif isinstance(o, self.AnyReadIo):
4610
+ i = check_isinstance((yield o), bytes)
4611
+
4612
+ elif isinstance(o, self._Response):
4613
+ i = None
4614
+ r = self._preprocess_response(o)
4615
+ b = self._build_response_bytes(r)
4616
+ check_none((yield self.WriteIo(b)))
4617
+
4618
+ else:
4619
+ raise TypeError(o)
4620
+
4621
+ try:
4622
+ o = gen.send(i)
4623
+ except EOFError:
4624
+ return
4625
+ except StopIteration:
4626
+ break
4627
+
4628
+ def coro_handle_one(self) -> ta.Generator[
4629
+ ta.Union[AnyLogIo, AnyReadIo, _Response],
4630
+ ta.Optional[bytes],
4631
+ None,
4632
+ ]:
4633
+ # Parse request
4634
+
4635
+ gen = self._parser.coro_parse()
4636
+ sz = next(gen)
4637
+ while True:
4638
+ try:
4639
+ line = check_isinstance((yield self.ReadLineIo(sz)), bytes)
4640
+ sz = gen.send(line)
4641
+ except StopIteration as e:
4642
+ parsed = e.value
4643
+ break
4644
+
4645
+ if isinstance(parsed, EmptyParsedHttpResult):
4646
+ raise EOFError # noqa
4647
+
4648
+ if isinstance(parsed, ParseHttpRequestError):
4649
+ err = self._build_error(
4650
+ parsed.code,
4651
+ *parsed.message,
4652
+ version=parsed.version,
4653
+ )
4654
+ yield self.ErrorLogIo(err)
4655
+ yield self._build_error_response(err)
4656
+ return
4657
+
4658
+ parsed = check_isinstance(parsed, ParsedHttpRequest)
4659
+
4660
+ # Log
4661
+
4662
+ check_none((yield self.ParsedRequestLogIo(parsed)))
4663
+
4664
+ # Handle CONTINUE
4665
+
4666
+ if parsed.expects_continue:
4667
+ # https://bugs.python.org/issue1491
4668
+ # https://github.com/python/cpython/commit/0f476d49f8d4aa84210392bf13b59afc67b32b31
4669
+ yield self._Response(
4670
+ version=parsed.version,
4671
+ code=http.HTTPStatus.CONTINUE,
4672
+ )
4673
+
4674
+ # Read data
4675
+
4676
+ request_data: ta.Optional[bytes]
4677
+ if (cl := parsed.headers.get('Content-Length')) is not None:
4678
+ request_data = check_isinstance((yield self.ReadIo(int(cl))), bytes)
4679
+ else:
4680
+ request_data = None
4681
+
4682
+ # Build request
4683
+
4684
+ handler_request = HttpHandlerRequest(
4685
+ client_address=self._client_address,
4686
+ method=check_not_none(parsed.method),
4687
+ path=parsed.path,
4688
+ headers=parsed.headers,
4689
+ data=request_data,
4690
+ )
4691
+
4692
+ # Build handler response
4693
+
4694
+ try:
4695
+ handler_response = self._handler(handler_request)
4696
+
4697
+ except UnsupportedMethodHttpHandlerError:
4698
+ err = self._build_error(
4699
+ http.HTTPStatus.NOT_IMPLEMENTED,
4700
+ f'Unsupported method ({parsed.method!r})',
4701
+ version=parsed.version,
4702
+ method=parsed.method,
4703
+ )
4704
+ yield self.ErrorLogIo(err)
4705
+ yield self._build_error_response(err)
4706
+ return
4707
+
4708
+ # Build internal response
4709
+
4710
+ response_headers = handler_response.headers or {}
4711
+ response_data = handler_response.data
4712
+
4713
+ headers: ta.List[CoroHttpServer._Header] = [
4714
+ *self._make_default_headers(),
4715
+ ]
4716
+
4717
+ for k, v in response_headers.items():
4718
+ headers.append(self._Header(k, v))
4719
+
4720
+ if handler_response.close_connection and 'Connection' not in headers:
4721
+ headers.append(self._Header('Connection', 'close'))
4722
+
4723
+ yield self._Response(
4724
+ version=parsed.version,
4725
+ code=http.HTTPStatus(handler_response.status),
4726
+ headers=headers,
4727
+ data=response_data,
4728
+ close_connection=handler_response.close_connection,
4729
+ )
4730
+
4731
+
4732
+ ##
4733
+
4734
+
4735
+ class CoroHttpServerSocketHandler(SocketHandler):
4736
+ def __init__(
4737
+ self,
4738
+ client_address: SocketAddress,
4739
+ rfile: ta.BinaryIO,
4740
+ wfile: ta.BinaryIO,
4741
+ *,
4742
+ server_factory: CoroHttpServerFactory,
4743
+ log_handler: ta.Optional[ta.Callable[[CoroHttpServer, CoroHttpServer.AnyLogIo], None]] = None,
4744
+ ) -> None:
4745
+ super().__init__(
4746
+ client_address,
4747
+ rfile,
4748
+ wfile,
4749
+ )
4750
+
4751
+ self._server_factory = server_factory
4752
+ self._log_handler = log_handler
4753
+
4754
+ def handle(self) -> None:
4755
+ server = self._server_factory(self._client_address)
4756
+
4757
+ gen = server.coro_handle()
4758
+
4759
+ o = next(gen)
4760
+ while True:
4761
+ if isinstance(o, CoroHttpServer.AnyLogIo):
4762
+ i = None
4763
+ if self._log_handler is not None:
4764
+ self._log_handler(server, o)
4765
+
4766
+ elif isinstance(o, CoroHttpServer.ReadIo):
4767
+ i = self._rfile.read(o.sz)
4768
+
4769
+ elif isinstance(o, CoroHttpServer.ReadLineIo):
4770
+ i = self._rfile.readline(o.sz)
4771
+
4772
+ elif isinstance(o, CoroHttpServer.WriteIo):
4773
+ i = None
4774
+ self._wfile.write(o.data)
4775
+ self._wfile.flush()
4776
+
4777
+ else:
4778
+ raise TypeError(o)
4779
+
4780
+ try:
4781
+ if i is not None:
4782
+ o = gen.send(i)
4783
+ else:
4784
+ o = next(gen)
4785
+ except StopIteration:
4786
+ break
4787
+
4788
+
3706
4789
  ########################################
3707
4790
  # ../types.py
3708
4791
 
@@ -5740,13 +6823,13 @@ class Supervisor:
5740
6823
 
5741
6824
 
5742
6825
  ########################################
5743
- # main.py
6826
+ # ../inject.py
5744
6827
 
5745
6828
 
5746
6829
  ##
5747
6830
 
5748
6831
 
5749
- def build_server_bindings(
6832
+ def bind_server(
5750
6833
  config: ServerConfig,
5751
6834
  *,
5752
6835
  server_epoch: ta.Optional[ServerEpoch] = None,
@@ -5795,6 +6878,10 @@ def build_server_bindings(
5795
6878
  return inj.as_bindings(*lst)
5796
6879
 
5797
6880
 
6881
+ ########################################
6882
+ # main.py
6883
+
6884
+
5798
6885
  ##
5799
6886
 
5800
6887
 
@@ -5803,6 +6890,10 @@ def main(
5803
6890
  *,
5804
6891
  no_logging: bool = False,
5805
6892
  ) -> None:
6893
+ server_cls = CoroHttpServer # noqa
6894
+
6895
+ #
6896
+
5806
6897
  import argparse
5807
6898
 
5808
6899
  parser = argparse.ArgumentParser()
@@ -5836,14 +6927,14 @@ def main(
5836
6927
  prepare=prepare_server_config,
5837
6928
  )
5838
6929
 
5839
- injector = inj.create_injector(build_server_bindings(
6930
+ injector = inj.create_injector(bind_server(
5840
6931
  config,
5841
6932
  server_epoch=ServerEpoch(epoch),
5842
6933
  inherited_fds=inherited_fds,
5843
6934
  ))
5844
6935
 
5845
- context = injector.provide(ServerContext)
5846
- supervisor = injector.provide(Supervisor)
6936
+ context = injector[ServerContext]
6937
+ supervisor = injector[Supervisor]
5847
6938
 
5848
6939
  try:
5849
6940
  supervisor.main()