omlish 0.0.0.dev4__py3-none-any.whl → 0.0.0.dev6__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.

Potentially problematic release.


This version of omlish might be problematic. Click here for more details.

Files changed (143) hide show
  1. omlish/__about__.py +1 -1
  2. omlish/__init__.py +1 -1
  3. omlish/asyncs/__init__.py +10 -4
  4. omlish/asyncs/anyio.py +142 -12
  5. omlish/asyncs/asyncio.py +23 -0
  6. omlish/asyncs/asyncs.py +9 -6
  7. omlish/asyncs/bridge.py +316 -0
  8. omlish/asyncs/flavors.py +27 -1
  9. omlish/asyncs/trio_asyncio.py +28 -18
  10. omlish/c3.py +1 -1
  11. omlish/cached.py +1 -2
  12. omlish/collections/__init__.py +5 -1
  13. omlish/collections/cache/impl.py +1 -1
  14. omlish/collections/identity.py +7 -0
  15. omlish/collections/indexed.py +1 -1
  16. omlish/collections/utils.py +38 -6
  17. omlish/configs/__init__.py +5 -0
  18. omlish/configs/classes.py +53 -0
  19. omlish/configs/strings.py +94 -0
  20. omlish/dataclasses/__init__.py +9 -0
  21. omlish/dataclasses/impl/api.py +1 -1
  22. omlish/dataclasses/impl/as_.py +1 -1
  23. omlish/dataclasses/impl/copy.py +30 -0
  24. omlish/dataclasses/impl/exceptions.py +6 -0
  25. omlish/dataclasses/impl/fields.py +25 -25
  26. omlish/dataclasses/impl/init.py +5 -3
  27. omlish/dataclasses/impl/main.py +3 -0
  28. omlish/dataclasses/impl/metaclass.py +6 -1
  29. omlish/dataclasses/impl/order.py +1 -1
  30. omlish/dataclasses/impl/reflect.py +15 -2
  31. omlish/dataclasses/utils.py +44 -0
  32. omlish/defs.py +1 -1
  33. omlish/diag/__init__.py +4 -0
  34. omlish/diag/procfs.py +31 -3
  35. omlish/diag/procstats.py +32 -0
  36. omlish/{testing → diag}/pydevd.py +35 -0
  37. omlish/diag/replserver/console.py +3 -3
  38. omlish/diag/replserver/server.py +6 -5
  39. omlish/diag/threads.py +86 -0
  40. omlish/dispatch/_dispatch2.py +65 -0
  41. omlish/dispatch/_dispatch3.py +104 -0
  42. omlish/docker.py +20 -1
  43. omlish/fnpairs.py +37 -18
  44. omlish/graphs/dags.py +113 -0
  45. omlish/graphs/domination.py +268 -0
  46. omlish/graphs/trees.py +2 -2
  47. omlish/http/__init__.py +25 -0
  48. omlish/http/asgi.py +132 -0
  49. omlish/http/collections.py +15 -0
  50. omlish/http/consts.py +47 -5
  51. omlish/http/cookies.py +194 -0
  52. omlish/http/dates.py +70 -0
  53. omlish/http/encodings.py +6 -0
  54. omlish/http/json.py +273 -0
  55. omlish/http/sessions.py +204 -0
  56. omlish/inject/__init__.py +51 -17
  57. omlish/inject/binder.py +185 -5
  58. omlish/inject/bindings.py +3 -36
  59. omlish/inject/eagers.py +2 -8
  60. omlish/inject/elements.py +30 -9
  61. omlish/inject/exceptions.py +3 -3
  62. omlish/inject/impl/elements.py +65 -31
  63. omlish/inject/impl/injector.py +20 -2
  64. omlish/inject/impl/inspect.py +33 -5
  65. omlish/inject/impl/multis.py +74 -0
  66. omlish/inject/impl/origins.py +75 -0
  67. omlish/inject/impl/{private.py → privates.py} +2 -2
  68. omlish/inject/impl/providers.py +19 -39
  69. omlish/inject/{proxy.py → impl/proxy.py} +2 -2
  70. omlish/inject/impl/scopes.py +7 -2
  71. omlish/inject/injector.py +9 -4
  72. omlish/inject/inspect.py +18 -0
  73. omlish/inject/keys.py +11 -23
  74. omlish/inject/listeners.py +26 -0
  75. omlish/inject/managed.py +76 -10
  76. omlish/inject/multis.py +120 -0
  77. omlish/inject/origins.py +27 -0
  78. omlish/inject/overrides.py +5 -4
  79. omlish/inject/{private.py → privates.py} +6 -10
  80. omlish/inject/providers.py +12 -85
  81. omlish/inject/scopes.py +20 -9
  82. omlish/inject/types.py +2 -8
  83. omlish/iterators.py +13 -0
  84. omlish/lang/__init__.py +12 -2
  85. omlish/lang/cached.py +2 -2
  86. omlish/lang/classes/restrict.py +3 -2
  87. omlish/lang/classes/simple.py +18 -8
  88. omlish/lang/classes/virtual.py +2 -2
  89. omlish/lang/contextmanagers.py +75 -2
  90. omlish/lang/datetimes.py +6 -5
  91. omlish/lang/descriptors.py +131 -0
  92. omlish/lang/functions.py +18 -28
  93. omlish/lang/imports.py +11 -2
  94. omlish/lang/iterables.py +20 -1
  95. omlish/lang/typing.py +6 -0
  96. omlish/lifecycles/__init__.py +34 -0
  97. omlish/lifecycles/abstract.py +43 -0
  98. omlish/lifecycles/base.py +51 -0
  99. omlish/lifecycles/contextmanagers.py +74 -0
  100. omlish/lifecycles/controller.py +116 -0
  101. omlish/lifecycles/manager.py +161 -0
  102. omlish/lifecycles/states.py +43 -0
  103. omlish/lifecycles/transitions.py +64 -0
  104. omlish/logs/formatters.py +1 -1
  105. omlish/logs/utils.py +1 -1
  106. omlish/marshal/__init__.py +4 -0
  107. omlish/marshal/datetimes.py +1 -1
  108. omlish/marshal/naming.py +4 -0
  109. omlish/marshal/objects.py +1 -0
  110. omlish/marshal/polymorphism.py +4 -4
  111. omlish/reflect.py +139 -18
  112. omlish/secrets/__init__.py +7 -0
  113. omlish/secrets/marshal.py +41 -0
  114. omlish/secrets/passwords.py +120 -0
  115. omlish/secrets/secrets.py +47 -0
  116. omlish/serde/__init__.py +0 -0
  117. omlish/serde/dotenv.py +574 -0
  118. omlish/{json.py → serde/json.py} +4 -2
  119. omlish/serde/props.py +604 -0
  120. omlish/serde/yaml.py +223 -0
  121. omlish/sql/dbs.py +1 -1
  122. omlish/sql/duckdb.py +136 -0
  123. omlish/sql/sqlean.py +17 -0
  124. omlish/sync.py +70 -0
  125. omlish/term.py +7 -2
  126. omlish/testing/pytest/__init__.py +8 -2
  127. omlish/testing/pytest/helpers.py +0 -24
  128. omlish/testing/pytest/inject/harness.py +4 -4
  129. omlish/testing/pytest/marks.py +45 -0
  130. omlish/testing/pytest/plugins/__init__.py +3 -0
  131. omlish/testing/pytest/plugins/asyncs.py +136 -0
  132. omlish/testing/pytest/plugins/managermarks.py +60 -0
  133. omlish/testing/pytest/plugins/pydevd.py +1 -1
  134. omlish/testing/testing.py +10 -0
  135. omlish/text/delimit.py +4 -0
  136. omlish/text/glyphsplit.py +92 -0
  137. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/METADATA +1 -1
  138. omlish-0.0.0.dev6.dist-info/RECORD +240 -0
  139. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/WHEEL +1 -1
  140. omlish/configs/props.py +0 -64
  141. omlish-0.0.0.dev4.dist-info/RECORD +0 -195
  142. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/LICENSE +0 -0
  143. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,15 @@
1
+ import typing as ta
2
+
3
+
4
+ V = ta.TypeVar('V')
5
+
6
+
7
+ class HttpMap(ta.Mapping[str, V]):
8
+ def __getitem__(self, k):
9
+ raise NotImplementedError
10
+
11
+ def __len__(self):
12
+ raise NotImplementedError
13
+
14
+ def __iter__(self):
15
+ raise NotImplementedError
omlish/http/consts.py CHANGED
@@ -1,4 +1,8 @@
1
- import http # noqa
1
+ import base64
2
+ import http
3
+
4
+
5
+ ##
2
6
 
3
7
 
4
8
  def format_status(status: http.HTTPStatus) -> str:
@@ -6,15 +10,53 @@ def format_status(status: http.HTTPStatus) -> str:
6
10
 
7
11
 
8
12
  STATUS_OK = format_status(http.HTTPStatus.OK)
13
+
14
+ STATUS_FOUND = format_status(http.HTTPStatus.FOUND)
15
+ STATUS_TEMPORARY_REDIRECT = format_status(http.HTTPStatus.TEMPORARY_REDIRECT)
16
+
9
17
  STATUS_BAD_REQUEST = format_status(http.HTTPStatus.BAD_REQUEST)
18
+ STATUS_UNAUTHORIZED = format_status(http.HTTPStatus.UNAUTHORIZED)
10
19
  STATUS_FORBIDDEN = format_status(http.HTTPStatus.FORBIDDEN)
11
20
  STATUS_NOT_FOUND = format_status(http.HTTPStatus.NOT_FOUND)
12
21
  STATUS_METHOD_NOT_ALLOWED = format_status(http.HTTPStatus.METHOD_NOT_ALLOWED)
22
+ STATUS_REQUEST_TIMEOUT = format_status(http.HTTPStatus.REQUEST_TIMEOUT)
23
+
24
+ STATUS_INTERNAL_SERVER_ERROR = format_status(http.HTTPStatus.INTERNAL_SERVER_ERROR)
25
+ STATUS_NOT_IMPLEMENTED = format_status(http.HTTPStatus.NOT_IMPLEMENTED)
26
+ STATUS_BAD_GATEWAY = format_status(http.HTTPStatus.BAD_GATEWAY)
27
+ STATUS_SERVICE_UNAVAILABLE = format_status(http.HTTPStatus.SERVICE_UNAVAILABLE)
28
+ STATUS_GATEWAY_TIMEOUT = format_status(http.HTTPStatus.GATEWAY_TIMEOUT)
29
+
30
+
31
+ ##
13
32
 
14
33
 
15
34
  HEADER_CONTENT_TYPE = b'Content-Type'
16
- CONTENT_TYPE_TEXT = b'text/plain'
17
- CONTENT_TYPE_TEXT_UTF8 = CONTENT_TYPE_TEXT + b'; charset=utf-8'
18
- CONTENT_TYPE_JSON = b'application/json'
19
- CONTENT_TYPE_ICON = b'image/x-icon'
35
+ HEADER_ACCEPT = b'Accept'
36
+
37
+ CONTENT_CHARSET_UTF8 = b'charset=utf-8'
38
+
20
39
  CONTENT_TYPE_BYTES = b'application/octet-stream'
40
+
41
+ CONTENT_TYPE_HTML = b'text/html'
42
+ CONTENT_TYPE_HTML_UTF8 = b'; '.join([CONTENT_TYPE_HTML, CONTENT_CHARSET_UTF8])
43
+
44
+ CONTENT_TYPE_ICON = b'image/x-icon'
45
+
46
+ CONTENT_TYPE_JSON = b'application/json'
47
+ CONTENT_TYPE_JSON_UTF8 = b'; '.join([CONTENT_TYPE_JSON, CONTENT_CHARSET_UTF8])
48
+
49
+ CONTENT_TYPE_TEXT = b'text/plain'
50
+ CONTENT_TYPE_TEXT_UTF8 = b'; '.join([CONTENT_TYPE_TEXT, CONTENT_CHARSET_UTF8])
51
+
52
+
53
+ ##
54
+
55
+
56
+ HEADER_AUTH = b'Authorization'
57
+ BEARER_AUTH_HEADER_PREFIX = b'Bearer '
58
+ BASIC_AUTH_HEADER_PREFIX = b'Basic '
59
+
60
+
61
+ def format_basic_auth_header(username: str, password: str) -> bytes:
62
+ return BASIC_AUTH_HEADER_PREFIX + base64.b64encode(':'.join([username, password]).encode())
omlish/http/cookies.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ https://github.com/pallets/werkzeug/blob/9e050f7750214d6779636813b8d661250804e811/src/werkzeug/http.py
3
+ https://github.com/pallets/werkzeug/blob/9e050f7750214d6779636813b8d661250804e811/src/werkzeug/sansio/http.py
4
+ """
5
+ # Copyright 2007 Pallets
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
8
+ # following conditions are met:
9
+ #
10
+ # 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
11
+ # disclaimer.
12
+ #
13
+ # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
14
+ # following disclaimer in the documentation and/or other materials provided with the distribution.
15
+ #
16
+ # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
17
+ # products derived from this software without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
20
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
24
+ # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+ import datetime
27
+ import re
28
+ import typing as ta
29
+ import urllib.parse
30
+
31
+ from .. import collections as col
32
+ from .dates import http_date
33
+
34
+
35
+ ##
36
+
37
+
38
+ _COOKIE_RE = re.compile(
39
+ r"""
40
+ ([^=;]*)
41
+ (?:\s*=\s*
42
+ (
43
+ "(?:[^\\"]|\\.)*"
44
+ |
45
+ .*?
46
+ )
47
+ )?
48
+ \s*;\s*
49
+ """,
50
+ flags=re.ASCII | re.VERBOSE,
51
+ )
52
+ _COOKIE_UNSLASH_RE = re.compile(rb'\\([0-3][0-7]{2}|.)')
53
+
54
+
55
+ def _cookie_unslash_replace(m: ta.Match[bytes]) -> bytes:
56
+ v = m.group(1)
57
+
58
+ if len(v) == 1:
59
+ return v
60
+
61
+ return int(v, 8).to_bytes(1, 'big')
62
+
63
+
64
+ def parse_cookie(
65
+ cookie: str | None = None,
66
+ *,
67
+ no_latin1: bool = False,
68
+ ) -> ta.MutableMapping[str, list[str]]:
69
+ if (not no_latin1) and cookie:
70
+ cookie = cookie.encode('latin1').decode()
71
+
72
+ if not cookie:
73
+ return {}
74
+
75
+ cookie = f'{cookie};'
76
+ out = []
77
+
78
+ for ck, cv in _COOKIE_RE.findall(cookie):
79
+ ck = ck.strip()
80
+ cv = cv.strip()
81
+
82
+ if not ck:
83
+ continue
84
+
85
+ if len(cv) >= 2 and cv[0] == cv[-1] == '"':
86
+ # Work with bytes here, since a UTF-8 character could be multiple bytes.
87
+ cv = _COOKIE_UNSLASH_RE.sub(
88
+ _cookie_unslash_replace,
89
+ cv[1:-1].encode(),
90
+ ).decode(errors='replace')
91
+
92
+ out.append((ck, cv))
93
+
94
+ return col.multi_map(out)
95
+
96
+
97
+ ##
98
+
99
+
100
+ _COOKIE_NO_QUOTE_RE = re.compile(r"[\w!#$%&'()*+\-./:<=>?@\[\]^`{|}~]*", re.ASCII)
101
+ _COOKIE_SLASH_RE = re.compile(rb'[\x00-\x19\",;\\\x7f-\xff]', re.ASCII)
102
+ _COOKIE_SLASH_MAP = {b'"': b'\\"', b'\\': b'\\\\'}
103
+ _COOKIE_SLASH_MAP.update(
104
+ (v.to_bytes(1, 'big'), b'\\%03o' % v)
105
+ for v in [*range(0x20), *b',;', *range(0x7F, 256)]
106
+ )
107
+
108
+
109
+ class CookieTooBigError(Exception):
110
+ pass
111
+
112
+
113
+ def dump_cookie(
114
+ key: str,
115
+ value: str = '',
116
+ *,
117
+ max_age: datetime.timedelta | int | None = None,
118
+ expires: str | datetime.datetime | float | None = None,
119
+ path: str | None = '/',
120
+ domain: str | None = None,
121
+ secure: bool = False,
122
+ httponly: bool = False,
123
+ sync_expires: bool = True,
124
+ max_size: int = 4093,
125
+ samesite: str | None = None,
126
+ partitioned: bool = False,
127
+ ) -> str:
128
+ if path is not None:
129
+ # safe = https://url.spec.whatwg.org/#url-path-segment-string as well as percent for things that are already
130
+ # quoted excluding semicolon since it's part of the header syntax
131
+ path = urllib.parse.quote(path, safe="%!$&'()*+,/:=@")
132
+
133
+ if domain:
134
+ domain = domain.partition(':')[0].lstrip('.').encode('idna').decode('ascii')
135
+
136
+ if isinstance(max_age, datetime.timedelta):
137
+ max_age = int(max_age.total_seconds())
138
+
139
+ if expires is not None:
140
+ if not isinstance(expires, str):
141
+ expires = http_date(expires)
142
+ elif max_age is not None and sync_expires:
143
+ expires = http_date(datetime.datetime.now(tz=datetime.UTC).timestamp() + max_age)
144
+
145
+ if samesite is not None:
146
+ samesite = samesite.title()
147
+
148
+ if samesite not in {'Strict', 'Lax', 'None'}:
149
+ raise ValueError("SameSite must be 'Strict', 'Lax', or 'None'.")
150
+
151
+ if partitioned:
152
+ secure = True
153
+
154
+ # Quote value if it contains characters not allowed by RFC 6265. Slash-escape with
155
+ # three octal digits, which matches http.cookies, although the RFC suggests base64.
156
+ if not _COOKIE_NO_QUOTE_RE.fullmatch(value):
157
+ # Work with bytes here, since a UTF-8 character could be multiple bytes.
158
+ value = _COOKIE_SLASH_RE.sub(
159
+ lambda m: _COOKIE_SLASH_MAP[m.group()], value.encode(),
160
+ ).decode('ascii')
161
+ value = f'"{value}"'
162
+
163
+ # Send a non-ASCII key as mojibake. Everything else should already be ASCII.
164
+ # TODO Remove encoding dance, it seems like clients accept UTF-8 keys
165
+ buf = [f"{key.encode().decode('latin1')}={value}"]
166
+
167
+ for k, v in (
168
+ ('Domain', domain),
169
+ ('Expires', expires),
170
+ ('Max-Age', max_age),
171
+ ('Secure', secure),
172
+ ('HttpOnly', httponly),
173
+ ('Path', path),
174
+ ('SameSite', samesite),
175
+ ('Partitioned', partitioned),
176
+ ):
177
+ if v is None or v is False:
178
+ continue
179
+
180
+ if v is True:
181
+ buf.append(k)
182
+ continue
183
+
184
+ buf.append(f'{k}={v}')
185
+
186
+ rv = '; '.join(buf)
187
+
188
+ # Warn if the final value of the cookie is larger than the limit. If the cookie is too large, then it may be
189
+ # silently ignored by the browser, which can be quite hard to debug.
190
+ cookie_size = len(rv)
191
+ if max_size and cookie_size > max_size:
192
+ raise CookieTooBigError(cookie_size)
193
+
194
+ return rv
omlish/http/dates.py ADDED
@@ -0,0 +1,70 @@
1
+ # Copyright 2007 Pallets
2
+ #
3
+ # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
4
+ # following conditions are met:
5
+ #
6
+ # 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
7
+ # disclaimer.
8
+ #
9
+ # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
10
+ # following disclaimer in the documentation and/or other materials provided with the distribution.
11
+ #
12
+ # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
13
+ # products derived from this software without specific prior written permission.
14
+ #
15
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
20
+ # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
+ import datetime
23
+ import email.utils
24
+ import time
25
+
26
+ from .. import check
27
+
28
+
29
+ def _dt_as_utc(dt: datetime.datetime | None) -> datetime.datetime | None:
30
+ if dt is None:
31
+ return dt
32
+
33
+ if dt.tzinfo is None:
34
+ return dt.replace(tzinfo=datetime.UTC)
35
+ elif dt.tzinfo != datetime.UTC:
36
+ return dt.astimezone(datetime.UTC)
37
+
38
+ return dt
39
+
40
+
41
+ def http_date(timestamp: datetime.datetime | datetime.date | float | time.struct_time | None = None) -> str:
42
+ if isinstance(timestamp, datetime.date):
43
+ if not isinstance(timestamp, datetime.datetime):
44
+ # Assume plain date is midnight UTC.
45
+ timestamp = datetime.datetime.combine(timestamp, datetime.time(), tzinfo=datetime.UTC)
46
+ else:
47
+ # Ensure datetime is timezone-aware.
48
+ timestamp = _dt_as_utc(timestamp)
49
+
50
+ return email.utils.format_datetime(check.not_none(timestamp), usegmt=True)
51
+
52
+ if isinstance(timestamp, time.struct_time):
53
+ timestamp = time.mktime(timestamp)
54
+
55
+ return email.utils.formatdate(timestamp, usegmt=True)
56
+
57
+
58
+ def parse_date(value: str | None) -> datetime.datetime | None:
59
+ if value is None:
60
+ return None
61
+
62
+ try:
63
+ dt = email.utils.parsedate_to_datetime(value)
64
+ except (TypeError, ValueError):
65
+ return None
66
+
67
+ if dt.tzinfo is None:
68
+ return dt.replace(tzinfo=datetime.UTC)
69
+
70
+ return dt
@@ -0,0 +1,6 @@
1
+ def latin1_decode(s: str) -> str:
2
+ return s.encode('latin1').decode(errors='replace')
3
+
4
+
5
+ def latin1_encode(s: str) -> str:
6
+ return s.encode().decode('latin1')
omlish/http/json.py ADDED
@@ -0,0 +1,273 @@
1
+ # Copyright 2010 Pallets
2
+ #
3
+ # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
4
+ # following conditions are met:
5
+ #
6
+ # 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
7
+ # disclaimer.
8
+ #
9
+ # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
10
+ # following disclaimer in the documentation and/or other materials provided with the distribution.
11
+ #
12
+ # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
13
+ # products derived from this software without specific prior written permission.
14
+ #
15
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
20
+ # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
+ import base64
23
+ import dataclasses as dc
24
+ import datetime
25
+ import decimal
26
+ import json as _json
27
+ import typing as ta
28
+ import uuid
29
+
30
+ from .. import lang
31
+ from .dates import http_date
32
+ from .dates import parse_date
33
+
34
+
35
+ if ta.TYPE_CHECKING:
36
+ import markupsafe
37
+ else:
38
+ markupsafe = lang.proxy_import('markupsafe')
39
+
40
+
41
+ ##
42
+
43
+
44
+ def _default(o: ta.Any) -> ta.Any:
45
+ if isinstance(o, datetime.date):
46
+ return http_date(o)
47
+
48
+ if isinstance(o, (decimal.Decimal, uuid.UUID)):
49
+ return str(o)
50
+
51
+ if dc.is_dataclass(o):
52
+ return dc.asdict(o) # type: ignore
53
+
54
+ if hasattr(o, '__html__'):
55
+ return str(o.__html__())
56
+
57
+ raise TypeError(f'Object of type {type(o).__name__} is not Json serializable')
58
+
59
+
60
+ def json_dumps(obj: ta.Any, **kwargs: ta.Any) -> str:
61
+ kwargs.setdefault('default', _default)
62
+ return _json.dumps(obj, **kwargs)
63
+
64
+
65
+ def json_loads(s: str | bytes, **kwargs: ta.Any) -> ta.Any:
66
+ return _json.loads(s, **kwargs)
67
+
68
+
69
+ ##
70
+
71
+
72
+ class JsonTag:
73
+ key: str = ''
74
+
75
+ def __init__(self, tagger: 'JsonTagger') -> None:
76
+ super().__init__()
77
+
78
+ self.tagger = tagger
79
+
80
+ def check(self, value: ta.Any) -> bool:
81
+ raise NotImplementedError
82
+
83
+ def to_json(self, value: ta.Any) -> ta.Any:
84
+ raise NotImplementedError
85
+
86
+ def to_python(self, value: ta.Any) -> ta.Any:
87
+ raise NotImplementedError
88
+
89
+ def tag(self, value: ta.Any) -> dict[str, ta.Any]:
90
+ return {self.key: self.to_json(value)}
91
+
92
+
93
+ class TagDict(JsonTag):
94
+ key = ' di'
95
+
96
+ def check(self, value: ta.Any) -> bool:
97
+ return (
98
+ isinstance(value, dict)
99
+ and len(value) == 1
100
+ and next(iter(value)) in self.tagger.tags
101
+ )
102
+
103
+ def to_json(self, value: ta.Any) -> ta.Any:
104
+ key = next(iter(value))
105
+ return {f'{key}__': self.tagger.tag(value[key])}
106
+
107
+ def to_python(self, value: ta.Any) -> ta.Any:
108
+ key = next(iter(value))
109
+ return {key[:-2]: value[key]}
110
+
111
+
112
+ class PassDict(JsonTag):
113
+ def check(self, value: ta.Any) -> bool:
114
+ return isinstance(value, dict)
115
+
116
+ def to_json(self, value: ta.Any) -> ta.Any:
117
+ return {k: self.tagger.tag(v) for k, v in value.items()}
118
+
119
+ tag = to_json
120
+
121
+
122
+ class TagTuple(JsonTag):
123
+ key = ' t'
124
+
125
+ def check(self, value: ta.Any) -> bool:
126
+ return isinstance(value, tuple)
127
+
128
+ def to_json(self, value: ta.Any) -> ta.Any:
129
+ return [self.tagger.tag(item) for item in value]
130
+
131
+ def to_python(self, value: ta.Any) -> ta.Any:
132
+ return tuple(value)
133
+
134
+
135
+ class PassList(JsonTag):
136
+ def check(self, value: ta.Any) -> bool:
137
+ return isinstance(value, list)
138
+
139
+ def to_json(self, value: ta.Any) -> ta.Any:
140
+ return [self.tagger.tag(item) for item in value]
141
+
142
+ tag = to_json
143
+
144
+
145
+ class TagBytes(JsonTag):
146
+ key = ' b'
147
+
148
+ def check(self, value: ta.Any) -> bool:
149
+ return isinstance(value, bytes)
150
+
151
+ def to_json(self, value: ta.Any) -> ta.Any:
152
+ return base64.b64encode(value).decode('ascii')
153
+
154
+ def to_python(self, value: ta.Any) -> ta.Any:
155
+ return base64.b64decode(value)
156
+
157
+
158
+ class TagMarkup(JsonTag):
159
+ key = ' m'
160
+
161
+ def check(self, value: ta.Any) -> bool:
162
+ return callable(getattr(value, '__html__', None))
163
+
164
+ def to_json(self, value: ta.Any) -> ta.Any:
165
+ return str(value.__html__())
166
+
167
+ def to_python(self, value: ta.Any) -> ta.Any:
168
+ return markupsafe.Markup(value)
169
+
170
+
171
+ class TagUuid(JsonTag):
172
+ key = ' u'
173
+
174
+ def check(self, value: ta.Any) -> bool:
175
+ return isinstance(value, uuid.UUID)
176
+
177
+ def to_json(self, value: ta.Any) -> ta.Any:
178
+ return value.hex
179
+
180
+ def to_python(self, value: ta.Any) -> ta.Any:
181
+ return uuid.UUID(value)
182
+
183
+
184
+ class TagDatetime(JsonTag):
185
+ key = ' dt'
186
+
187
+ def check(self, value: ta.Any) -> bool:
188
+ return isinstance(value, datetime.datetime)
189
+
190
+ def to_json(self, value: ta.Any) -> ta.Any:
191
+ return http_date(value)
192
+
193
+ def to_python(self, value: ta.Any) -> ta.Any:
194
+ return parse_date(value)
195
+
196
+
197
+ class JsonTagger:
198
+ default_tags: ta.ClassVar[ta.Sequence[type[JsonTag]]] = [
199
+ TagDict,
200
+ PassDict,
201
+ TagTuple,
202
+ PassList,
203
+ TagBytes,
204
+ *([TagMarkup] if lang.can_import('markupsafe') else []),
205
+ TagUuid,
206
+ TagDatetime,
207
+ ]
208
+
209
+ def __init__(self) -> None:
210
+ super().__init__()
211
+
212
+ self.tags: dict[str, JsonTag] = {}
213
+ self.order: list[JsonTag] = []
214
+
215
+ for cls in self.default_tags:
216
+ self.register(cls)
217
+
218
+ def register(
219
+ self,
220
+ tag_class: type[JsonTag],
221
+ force: bool = False,
222
+ index: int | None = None,
223
+ ) -> None:
224
+ tag = tag_class(self)
225
+ key = tag.key
226
+
227
+ if key:
228
+ if not force and key in self.tags:
229
+ raise KeyError(f"Tag '{key}' is already registered.")
230
+
231
+ self.tags[key] = tag
232
+
233
+ if index is None:
234
+ self.order.append(tag)
235
+ else:
236
+ self.order.insert(index, tag)
237
+
238
+ def tag(self, value: ta.Any) -> ta.Any:
239
+ for tag in self.order:
240
+ if tag.check(value):
241
+ return tag.tag(value)
242
+
243
+ return value
244
+
245
+ def untag(self, value: dict[str, ta.Any]) -> ta.Any:
246
+ if len(value) != 1:
247
+ return value
248
+
249
+ key = next(iter(value))
250
+
251
+ if key not in self.tags:
252
+ return value
253
+
254
+ return self.tags[key].to_python(value[key])
255
+
256
+ def untag_scan(self, value: ta.Any) -> ta.Any:
257
+ if isinstance(value, dict):
258
+ value = {k: self.untag_scan(v) for k, v in value.items()}
259
+ value = self.untag(value)
260
+
261
+ elif isinstance(value, list):
262
+ value = [self.untag_scan(item) for item in value]
263
+
264
+ return value
265
+
266
+ def dumps(self, value: ta.Any) -> str:
267
+ return json_dumps(self.tag(value), separators=(',', ':'))
268
+
269
+ def loads(self, value: str) -> ta.Any:
270
+ return self.untag_scan(json_loads(value))
271
+
272
+
273
+ JSON_TAGGER = JsonTagger()