plain 0.68.0__py3-none-any.whl → 0.103.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.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.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(
|
|
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(
|
|
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(
|
|
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")
|