plain 0.68.0__py3-none-any.whl → 0.101.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/utils/dotenv.py ADDED
@@ -0,0 +1,373 @@
1
+ """
2
+ Custom .env file parser targeting bash `source` compatibility.
3
+
4
+ Supports:
5
+ - KEY=value (basic unquoted)
6
+ - KEY="double quoted value" (with escape handling and multiline)
7
+ - KEY='single quoted value' (literal, including multiline)
8
+ - export KEY=value (strips export prefix)
9
+ - Comments (# comment and inline KEY=value # comment)
10
+ - Variable expansion: $VAR and ${VAR} (in unquoted and double-quoted values)
11
+ - Command substitution: $(command)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ __all__ = ["load_dotenv", "parse_dotenv"]
22
+
23
+ # Match ${VAR} or $VAR (VAR must start with letter/underscore, then alphanumeric/underscore)
24
+ _VAR_BRACE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
25
+ _VAR_BARE_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
26
+ # Placeholder for escaped $ (to prevent expansion)
27
+ _ESCAPED_DOLLAR = "\x00DOLLAR\x00"
28
+
29
+
30
+ def load_dotenv(
31
+ filepath: str | Path,
32
+ *,
33
+ override: bool = False,
34
+ ) -> bool:
35
+ """
36
+ Load environment variables from a .env file into os.environ.
37
+
38
+ Args:
39
+ filepath: Path to the .env file
40
+ override: If True, overwrite existing environment variables
41
+
42
+ Returns:
43
+ True if the file was loaded, False if it doesn't exist
44
+ """
45
+ path = Path(filepath)
46
+ if not path.exists():
47
+ return False
48
+
49
+ # Skip command execution for keys that already exist (unless override)
50
+ skip_commands_for = None if override else set(os.environ.keys())
51
+ env_vars = _parse_dotenv_internal(path, skip_commands_for=skip_commands_for)
52
+ for key, value in env_vars.items():
53
+ if override or key not in os.environ:
54
+ os.environ[key] = value
55
+
56
+ return True
57
+
58
+
59
+ def parse_dotenv(filepath: str | Path) -> dict[str, str]:
60
+ """
61
+ Parse a .env file and return a dictionary of key-value pairs.
62
+
63
+ Does not modify os.environ. Supports multiline values in quoted strings.
64
+ """
65
+ return _parse_dotenv_internal(filepath, skip_commands_for=None)
66
+
67
+
68
+ def _parse_dotenv_internal(
69
+ filepath: str | Path, skip_commands_for: set[str] | None = None
70
+ ) -> dict[str, str]:
71
+ """
72
+ Internal parser that can skip command execution for certain keys.
73
+
74
+ Args:
75
+ filepath: Path to the .env file
76
+ skip_commands_for: If provided, skip command substitution for keys in this set
77
+ and use os.environ value instead
78
+ """
79
+ path = Path(filepath)
80
+ content = path.read_text(encoding="utf-8")
81
+ return _parse_content(content, skip_commands_for=skip_commands_for)
82
+
83
+
84
+ def _parse_content(
85
+ content: str, skip_commands_for: set[str] | None = None
86
+ ) -> dict[str, str]:
87
+ """Parse .env file content and return key-value pairs."""
88
+ result: dict[str, str] = {}
89
+ pos = 0
90
+ length = len(content)
91
+
92
+ while pos < length:
93
+ # Skip whitespace and empty lines
94
+ while pos < length and content[pos] in " \t\r\n":
95
+ pos += 1
96
+
97
+ if pos >= length:
98
+ break
99
+
100
+ # Skip comment lines
101
+ if content[pos] == "#":
102
+ pos = _skip_to_eol(content, pos)
103
+ continue
104
+
105
+ # Try to parse a binding
106
+ parsed = _parse_binding(content, pos, result, skip_commands_for)
107
+ if parsed:
108
+ key, value, new_pos = parsed
109
+ result[key] = value
110
+ pos = new_pos
111
+ else:
112
+ # Skip to next line on parse failure
113
+ pos = _skip_to_eol(content, pos)
114
+
115
+ return result
116
+
117
+
118
+ def _skip_to_eol(content: str, pos: int) -> int:
119
+ """Skip to end of line, return position after newline."""
120
+ while pos < len(content) and content[pos] not in "\r\n":
121
+ pos += 1
122
+ if pos < len(content) and content[pos] == "\r":
123
+ pos += 1
124
+ if pos < len(content) and content[pos] == "\n":
125
+ pos += 1
126
+ return pos
127
+
128
+
129
+ def _parse_binding(
130
+ content: str,
131
+ pos: int,
132
+ context: dict[str, str],
133
+ skip_commands_for: set[str] | None = None,
134
+ ) -> tuple[str, str, int] | None:
135
+ """Parse a KEY=value binding, return (key, value, new_pos) or None."""
136
+ length = len(content)
137
+
138
+ # Skip optional 'export ' prefix
139
+ if content[pos:].startswith("export "):
140
+ pos += 7
141
+ while pos < length and content[pos] in " \t":
142
+ pos += 1
143
+
144
+ # Parse key
145
+ key_start = pos
146
+ while pos < length and (content[pos].isalnum() or content[pos] == "_"):
147
+ pos += 1
148
+
149
+ if pos == key_start:
150
+ return None
151
+
152
+ key = content[key_start:pos]
153
+
154
+ # Must start with letter or underscore
155
+ if not (key[0].isalpha() or key[0] == "_"):
156
+ return None
157
+
158
+ # Skip whitespace before =
159
+ while pos < length and content[pos] in " \t":
160
+ pos += 1
161
+
162
+ # Expect =
163
+ if pos >= length or content[pos] != "=":
164
+ return None
165
+ pos += 1
166
+
167
+ # Skip whitespace after =
168
+ while pos < length and content[pos] in " \t":
169
+ pos += 1
170
+
171
+ # If key already exists in env and we should skip commands, use existing value
172
+ if skip_commands_for and key in skip_commands_for:
173
+ # Skip to end of line without executing commands
174
+ new_pos = _skip_to_eol(content, pos)
175
+ return key, os.environ[key], new_pos
176
+
177
+ # Parse value (with command expansion)
178
+ value, pos = _parse_value(content, pos, context)
179
+
180
+ return key, value, pos
181
+
182
+
183
+ def _parse_value(content: str, pos: int, context: dict[str, str]) -> tuple[str, int]:
184
+ """Parse a value starting at pos, return (value, new_pos)."""
185
+ if pos >= len(content) or content[pos] in "\r\n":
186
+ return "", pos
187
+
188
+ char = content[pos]
189
+
190
+ # Single-quoted: literal value (no escape, no expansion), supports multiline
191
+ if char == "'":
192
+ return _parse_single_quoted(content, pos)
193
+
194
+ # Double-quoted: process escapes, variable expansion, and commands, supports multiline
195
+ if char == '"':
196
+ value, pos = _parse_double_quoted(content, pos)
197
+ value = _expand_variables(value, context)
198
+ value = _expand_commands(value)
199
+ value = value.replace(_ESCAPED_DOLLAR, "$") # Restore escaped $
200
+ return value, pos
201
+
202
+ # Unquoted value: variable expansion and command substitution
203
+ return _parse_unquoted(content, pos, context)
204
+
205
+
206
+ def _parse_single_quoted(content: str, pos: int) -> tuple[str, int]:
207
+ """Parse single-quoted value (literal, multiline supported)."""
208
+ pos += 1 # Skip opening quote
209
+ start = pos
210
+ length = len(content)
211
+
212
+ while pos < length:
213
+ if content[pos] == "'":
214
+ value = content[start:pos]
215
+ return value, pos + 1
216
+ pos += 1
217
+
218
+ # No closing quote found, return what we have
219
+ return content[start:], pos
220
+
221
+
222
+ def _parse_double_quoted(content: str, pos: int) -> tuple[str, int]:
223
+ """Parse double-quoted value (with escapes, multiline supported)."""
224
+ pos += 1 # Skip opening quote
225
+ result = []
226
+ length = len(content)
227
+
228
+ while pos < length:
229
+ char = content[pos]
230
+
231
+ if char == "\\" and pos + 1 < length:
232
+ next_char = content[pos + 1]
233
+ if next_char == "n":
234
+ result.append("\n")
235
+ elif next_char == "t":
236
+ result.append("\t")
237
+ elif next_char == "r":
238
+ result.append("\r")
239
+ elif next_char == '"':
240
+ result.append('"')
241
+ elif next_char == "\\":
242
+ result.append("\\")
243
+ elif next_char == "$":
244
+ result.append(_ESCAPED_DOLLAR) # Placeholder to prevent expansion
245
+ else:
246
+ # Unknown escape, keep both characters
247
+ result.append(char)
248
+ result.append(next_char)
249
+ pos += 2
250
+ elif char == '"':
251
+ return "".join(result), pos + 1
252
+ else:
253
+ result.append(char)
254
+ pos += 1
255
+
256
+ # No closing quote found, return what we have
257
+ return "".join(result), pos
258
+
259
+
260
+ def _parse_unquoted(content: str, pos: int, context: dict[str, str]) -> tuple[str, int]:
261
+ """Parse unquoted value (until comment or end of line)."""
262
+ result = []
263
+ length = len(content)
264
+
265
+ while pos < length and content[pos] not in "\r\n":
266
+ char = content[pos]
267
+
268
+ # Stop at inline comment (whitespace followed by #)
269
+ if char == "#" and result and result[-1] in " \t":
270
+ # Remove trailing whitespace
271
+ while result and result[-1] in " \t":
272
+ result.pop()
273
+ break
274
+
275
+ # Handle backslash escapes (like bash)
276
+ if char == "\\" and pos + 1 < length:
277
+ next_char = content[pos + 1]
278
+ if next_char == "$":
279
+ result.append(_ESCAPED_DOLLAR) # Placeholder to prevent expansion
280
+ pos += 2
281
+ continue
282
+ elif next_char == "\\":
283
+ result.append("\\")
284
+ pos += 2
285
+ continue
286
+ # Other backslashes kept as-is
287
+
288
+ result.append(char)
289
+ pos += 1
290
+
291
+ value = "".join(result).rstrip()
292
+
293
+ # Expand variables, then commands
294
+ value = _expand_variables(value, context)
295
+ value = _expand_commands(value)
296
+ value = value.replace(_ESCAPED_DOLLAR, "$") # Restore escaped $
297
+ return value, pos
298
+
299
+
300
+ def _expand_variables(value: str, context: dict[str, str]) -> str:
301
+ """Expand $VAR and ${VAR} references in value.
302
+
303
+ Looks up variables in context (previously parsed .env vars) first,
304
+ then falls back to os.environ. Unknown variables expand to empty string.
305
+ """
306
+
307
+ def replacer(match: re.Match[str]) -> str:
308
+ var_name = match.group(1)
309
+ # Check context first (vars defined earlier in .env), then os.environ
310
+ if var_name in context:
311
+ return context[var_name]
312
+ return os.environ.get(var_name, "")
313
+
314
+ # Expand ${VAR} first (more specific), then $VAR
315
+ value = _VAR_BRACE_RE.sub(replacer, value)
316
+ value = _VAR_BARE_RE.sub(replacer, value)
317
+ return value
318
+
319
+
320
+ def _expand_commands(value: str) -> str:
321
+ """Expand all $(command) substitutions in value.
322
+
323
+ Handles nested parentheses within commands, e.g., $(echo "(test)").
324
+ """
325
+ result = []
326
+ i = 0
327
+ length = len(value)
328
+
329
+ while i < length:
330
+ # Look for $(
331
+ if i + 1 < length and value[i] == "$" and value[i + 1] == "(":
332
+ # Find matching closing paren, accounting for nesting
333
+ cmd_start = i + 2
334
+ depth = 1
335
+ j = cmd_start
336
+
337
+ while j < length and depth > 0:
338
+ if value[j] == "(":
339
+ depth += 1
340
+ elif value[j] == ")":
341
+ depth -= 1
342
+ j += 1
343
+
344
+ if depth == 0:
345
+ # Found matching ), extract and execute command
346
+ command = value[cmd_start : j - 1]
347
+ output = _execute_command(command)
348
+ result.append(output)
349
+ i = j
350
+ else:
351
+ # No matching ), keep literal
352
+ result.append(value[i])
353
+ i += 1
354
+ else:
355
+ result.append(value[i])
356
+ i += 1
357
+
358
+ return "".join(result)
359
+
360
+
361
+ def _execute_command(command: str, timeout: float = 5.0) -> str:
362
+ """Execute a shell command and return stdout."""
363
+ try:
364
+ result = subprocess.run(
365
+ command,
366
+ shell=True,
367
+ stdout=subprocess.PIPE,
368
+ text=True,
369
+ timeout=timeout,
370
+ )
371
+ return result.stdout.strip() if result.returncode == 0 else ""
372
+ except (subprocess.TimeoutExpired, OSError):
373
+ return ""
plain/utils/duration.py CHANGED
@@ -1,7 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
 
3
5
 
4
- def _get_duration_components(duration):
6
+ def _get_duration_components(
7
+ duration: datetime.timedelta,
8
+ ) -> tuple[int, int, int, int, int]:
5
9
  days = duration.days
6
10
  seconds = duration.seconds
7
11
  microseconds = duration.microseconds
@@ -15,7 +19,7 @@ def _get_duration_components(duration):
15
19
  return days, hours, minutes, seconds, microseconds
16
20
 
17
21
 
18
- def duration_string(duration):
22
+ def duration_string(duration: datetime.timedelta) -> str:
19
23
  """Version of str(timedelta) which is not English specific."""
20
24
  days, hours, minutes, seconds, microseconds = _get_duration_components(duration)
21
25
 
@@ -28,7 +32,7 @@ def duration_string(duration):
28
32
  return string
29
33
 
30
34
 
31
- def duration_iso_string(duration):
35
+ def duration_iso_string(duration: datetime.timedelta) -> str:
32
36
  if duration < datetime.timedelta(0):
33
37
  sign = "-"
34
38
  duration *= -1
@@ -40,5 +44,5 @@ def duration_iso_string(duration):
40
44
  return f"{sign}P{days}DT{hours:02d}H{minutes:02d}M{seconds:02d}{ms}S"
41
45
 
42
46
 
43
- def duration_microseconds(delta):
47
+ def duration_microseconds(delta: datetime.timedelta) -> int:
44
48
  return (24 * 60 * 60 * delta.days + delta.seconds) * 1000000 + delta.microseconds
plain/utils/encoding.py CHANGED
@@ -1,17 +1,20 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  from decimal import Decimal
3
5
  from types import NoneType
6
+ from typing import Any
4
7
  from urllib.parse import quote
5
8
 
6
9
  from plain.utils.functional import Promise
7
10
 
8
11
 
9
12
  class PlainUnicodeDecodeError(UnicodeDecodeError):
10
- def __init__(self, obj, *args):
13
+ def __init__(self, obj: Any, *args: Any):
11
14
  self.obj = obj
12
15
  super().__init__(*args)
13
16
 
14
- def __str__(self):
17
+ def __str__(self) -> str:
15
18
  return f"{super().__str__()}. You passed in {self.obj!r} ({type(self.obj)})"
16
19
 
17
20
 
@@ -26,7 +29,7 @@ _PROTECTED_TYPES = (
26
29
  )
27
30
 
28
31
 
29
- def is_protected_type(obj):
32
+ def is_protected_type(obj: Any) -> bool:
30
33
  """Determine if the object instance is of a protected type.
31
34
 
32
35
  Objects of protected types are preserved as-is when passed to
@@ -35,7 +38,9 @@ def is_protected_type(obj):
35
38
  return isinstance(obj, _PROTECTED_TYPES)
36
39
 
37
40
 
38
- def force_str(s, encoding="utf-8", strings_only=False, errors="strict"):
41
+ def force_str(
42
+ s: Any, encoding: str = "utf-8", strings_only: bool = False, errors: str = "strict"
43
+ ) -> str | Any:
39
44
  """
40
45
  Similar to smart_str(), except that lazy instances are resolved to
41
46
  strings, rather than kept as lazy objects.
@@ -57,7 +62,9 @@ def force_str(s, encoding="utf-8", strings_only=False, errors="strict"):
57
62
  return s
58
63
 
59
64
 
60
- def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"):
65
+ def force_bytes(
66
+ s: Any, encoding: str = "utf-8", strings_only: bool = False, errors: str = "strict"
67
+ ) -> bytes | Any:
61
68
  """
62
69
  Similar to smart_bytes, except that lazy instances are resolved to
63
70
  strings, rather than kept as lazy objects.
@@ -77,7 +84,7 @@ def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"):
77
84
  return str(s).encode(encoding, errors)
78
85
 
79
86
 
80
- def iri_to_uri(iri):
87
+ def iri_to_uri(iri: str | Promise | None) -> str | None:
81
88
  """
82
89
  Convert an Internationalized Resource Identifier (IRI) portion to a URI
83
90
  portion that is suitable for inclusion in a URL.
@@ -125,6 +132,6 @@ _hextobyte.update(
125
132
  )
126
133
 
127
134
 
128
- def punycode(domain):
135
+ def punycode(domain: str) -> str:
129
136
  """Return the Punycode of the given domain if it's non-ASCII."""
130
137
  return domain.encode("idna").decode("ascii")