scriptcast 0.1.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.
- scriptcast/__init__.py +0 -0
- scriptcast/__main__.py +371 -0
- scriptcast/assets/__init__.py +0 -0
- scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
- scriptcast/assets/fonts/Pacifico.ttf +0 -0
- scriptcast/assets/themes/aurora.sh +20 -0
- scriptcast/assets/themes/dark.sh +19 -0
- scriptcast/assets/themes/light.sh +19 -0
- scriptcast/config.py +199 -0
- scriptcast/directives.py +444 -0
- scriptcast/export.py +595 -0
- scriptcast/generator.py +265 -0
- scriptcast/recorder.py +212 -0
- scriptcast/shell/__init__.py +20 -0
- scriptcast/shell/adapter.py +13 -0
- scriptcast/shell/bash.py +11 -0
- scriptcast/shell/zsh.py +11 -0
- scriptcast-0.1.0.dist-info/METADATA +21 -0
- scriptcast-0.1.0.dist-info/RECORD +33 -0
- scriptcast-0.1.0.dist-info/WHEEL +5 -0
- scriptcast-0.1.0.dist-info/entry_points.txt +2 -0
- scriptcast-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli.py +304 -0
- tests/test_config.py +400 -0
- tests/test_directives.py +606 -0
- tests/test_export.py +986 -0
- tests/test_generator.py +434 -0
- tests/test_integration.py +97 -0
- tests/test_recorder.py +462 -0
- tests/test_registry.py +57 -0
- tests/test_shell.py +34 -0
- tests/test_theme.py +204 -0
scriptcast/directives.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import warnings
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.metadata import entry_points
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from .config import ScriptcastConfig
|
|
13
|
+
|
|
14
|
+
EventType = Literal["cmd", "out", "dir"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iter_heredoc(
|
|
18
|
+
lines: list[str],
|
|
19
|
+
pattern: re.Pattern[str],
|
|
20
|
+
) -> Iterator[str | tuple[re.Match[str], list[str], str]]:
|
|
21
|
+
"""Iterate lines, yielding either pass-through strings or heredoc block tuples.
|
|
22
|
+
|
|
23
|
+
For each line that matches *pattern*, collects the body lines up to the
|
|
24
|
+
heredoc delimiter and yields ``(match, body_lines, closing_line)``.
|
|
25
|
+
Non-matching lines are yielded as plain strings.
|
|
26
|
+
"""
|
|
27
|
+
i = 0
|
|
28
|
+
while i < len(lines):
|
|
29
|
+
m = pattern.match(lines[i].rstrip("\n\r"))
|
|
30
|
+
if not m:
|
|
31
|
+
yield lines[i]
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
delim = m.group(3)
|
|
35
|
+
i += 1
|
|
36
|
+
body: list[str] = []
|
|
37
|
+
while i < len(lines) and lines[i].rstrip("\n\r") != delim:
|
|
38
|
+
body.append(lines[i])
|
|
39
|
+
i += 1
|
|
40
|
+
closing = lines[i] if i < len(lines) else delim + "\n"
|
|
41
|
+
i += 1
|
|
42
|
+
yield m, body, closing
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ScEvent:
|
|
47
|
+
ts: float
|
|
48
|
+
type: EventType
|
|
49
|
+
text: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Directive:
|
|
53
|
+
priority: int = 50
|
|
54
|
+
handles: str | None = None # gen-phase: directive name matched in .sc events
|
|
55
|
+
|
|
56
|
+
def __init__(self, dp: str = "SC", tp: str = "+"):
|
|
57
|
+
self.dp = dp
|
|
58
|
+
self.tp = tp
|
|
59
|
+
|
|
60
|
+
def pre(self, lines: list[str]) -> list[str]:
|
|
61
|
+
"""Pre-phase: rewrite script lines before shell execution.
|
|
62
|
+
|
|
63
|
+
Receive the full list of script lines, return a transformed list.
|
|
64
|
+
Lines not recognised by this directive must be passed through unchanged.
|
|
65
|
+
"""
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
69
|
+
"""Post-phase: transform ScEvents.
|
|
70
|
+
|
|
71
|
+
Receive the full list, return a transformed list.
|
|
72
|
+
Events not recognised by this directive must be passed through unchanged.
|
|
73
|
+
"""
|
|
74
|
+
return events
|
|
75
|
+
|
|
76
|
+
def gen(
|
|
77
|
+
self,
|
|
78
|
+
event: tuple,
|
|
79
|
+
queue: deque[tuple],
|
|
80
|
+
active: ScriptcastConfig,
|
|
81
|
+
cursor: float,
|
|
82
|
+
) -> tuple[float, list[str]]:
|
|
83
|
+
"""Gen-phase: emit cast lines for a directive event."""
|
|
84
|
+
return cursor, []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MockDirective(Directive):
|
|
88
|
+
priority = 20
|
|
89
|
+
|
|
90
|
+
def __init__(self, dp: str = "SC", tp: str = "+"):
|
|
91
|
+
super().__init__(dp, tp)
|
|
92
|
+
self._mock_re = re.compile(
|
|
93
|
+
rf"^:\s+{re.escape(dp)}\s+mock\s+(.+?)\s*<<(['\"]?)(\w+)\2\s*$"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def pre(self, lines: list[str]) -> list[str]:
|
|
97
|
+
out: list[str] = []
|
|
98
|
+
for item in _iter_heredoc(lines, self._mock_re):
|
|
99
|
+
if isinstance(item, str):
|
|
100
|
+
out.append(item)
|
|
101
|
+
continue
|
|
102
|
+
m, body, closing = item
|
|
103
|
+
cmd_args = m.group(1).strip()
|
|
104
|
+
quote = m.group(2)
|
|
105
|
+
delim = m.group(3)
|
|
106
|
+
out.extend([
|
|
107
|
+
f"(: {self.dp} mark mock; set +x; echo + {cmd_args}; cat)"
|
|
108
|
+
f" <<{quote}{delim}{quote}\n",
|
|
109
|
+
*body,
|
|
110
|
+
closing,
|
|
111
|
+
])
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
115
|
+
out: list[ScEvent] = []
|
|
116
|
+
i = 0
|
|
117
|
+
while i < len(events):
|
|
118
|
+
e = events[i]
|
|
119
|
+
if e.type == "dir" and e.text == "mark mock":
|
|
120
|
+
i += 1
|
|
121
|
+
# drop following "set +x" cmd event if present
|
|
122
|
+
if i < len(events) and events[i].type == "cmd" and events[i].text == "set +x":
|
|
123
|
+
i += 1
|
|
124
|
+
else:
|
|
125
|
+
out.append(e)
|
|
126
|
+
i += 1
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ExpectDirective(Directive):
|
|
131
|
+
priority = 30
|
|
132
|
+
handles = "expect-input"
|
|
133
|
+
|
|
134
|
+
def __init__(self, dp: str = "SC", tp: str = "+"):
|
|
135
|
+
super().__init__(dp, tp)
|
|
136
|
+
self._expect_re = re.compile(
|
|
137
|
+
rf"^:\s+{re.escape(dp)}\s+expect\s+(.+?)\s*<<(['\"]?)(\w+)\2\s*$"
|
|
138
|
+
)
|
|
139
|
+
self._send_re = re.compile(r"""^\s*send\s+(['"])(.*?)\1\s*$""")
|
|
140
|
+
self._mark_input_re = re.compile(
|
|
141
|
+
rf"(.*?): {re.escape(dp)} mark input\s*(.*)$"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def pre(self, lines: list[str]) -> list[str]:
|
|
145
|
+
out: list[str] = []
|
|
146
|
+
for item in _iter_heredoc(lines, self._expect_re):
|
|
147
|
+
if isinstance(item, str):
|
|
148
|
+
out.append(item)
|
|
149
|
+
continue
|
|
150
|
+
m, body, closing = item
|
|
151
|
+
cmd_args = m.group(1).strip()
|
|
152
|
+
delim = m.group(3)
|
|
153
|
+
new_body: list[str] = [f"spawn {cmd_args}\n"]
|
|
154
|
+
for bl in body:
|
|
155
|
+
sm = self._send_re.match(bl.rstrip("\n\r"))
|
|
156
|
+
if sm:
|
|
157
|
+
input_text = sm.group(2)
|
|
158
|
+
if input_text.endswith("\\r"):
|
|
159
|
+
input_text = input_text[:-2]
|
|
160
|
+
new_body.append(
|
|
161
|
+
f'send_user ": {self.dp} mark input {input_text}\\n"\n'
|
|
162
|
+
)
|
|
163
|
+
elif re.match(r"^\s*send\s+", bl):
|
|
164
|
+
new_body.append(f'send_user ": {self.dp} mark input\\n"\n')
|
|
165
|
+
new_body.append(bl)
|
|
166
|
+
out.extend([
|
|
167
|
+
f": {self.dp} mark expect {cmd_args}\n",
|
|
168
|
+
f"expect <<'{delim}'\n",
|
|
169
|
+
*new_body,
|
|
170
|
+
closing,
|
|
171
|
+
])
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
175
|
+
out: list[ScEvent] = []
|
|
176
|
+
i = 0
|
|
177
|
+
while i < len(events):
|
|
178
|
+
e = events[i]
|
|
179
|
+
# Pattern 1: SC expect directive marker
|
|
180
|
+
if e.type == "dir" and e.text.startswith("mark expect "):
|
|
181
|
+
cmd = e.text[len("mark expect "):].strip()
|
|
182
|
+
out.append(ScEvent(e.ts, "cmd", cmd))
|
|
183
|
+
i += 1
|
|
184
|
+
i, session_events = self._consume_session(events, i)
|
|
185
|
+
out.extend(session_events)
|
|
186
|
+
# Pattern 2: raw expect call (not via SC expect directive)
|
|
187
|
+
elif e.type == "cmd" and (e.text == "expect" or e.text.startswith("expect ")):
|
|
188
|
+
i += 1
|
|
189
|
+
if (i < len(events) and events[i].type == "out"
|
|
190
|
+
and events[i].text.startswith("spawn ")):
|
|
191
|
+
cmd = events[i].text[len("spawn "):]
|
|
192
|
+
out.append(ScEvent(e.ts, "cmd", cmd))
|
|
193
|
+
i += 1
|
|
194
|
+
i, session_events = self._consume_session(events, i)
|
|
195
|
+
out.extend(session_events)
|
|
196
|
+
else:
|
|
197
|
+
out.append(e)
|
|
198
|
+
i += 1
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
def gen(
|
|
202
|
+
self,
|
|
203
|
+
event: tuple,
|
|
204
|
+
queue: deque[tuple],
|
|
205
|
+
active: ScriptcastConfig,
|
|
206
|
+
cursor: float,
|
|
207
|
+
) -> tuple[float, list[str]]:
|
|
208
|
+
_, _, text = event
|
|
209
|
+
# text is "expect-input <input_text>" or just "expect-input"
|
|
210
|
+
parts = text.split(maxsplit=1)
|
|
211
|
+
input_text = parts[1] if len(parts) > 1 else ""
|
|
212
|
+
lines: list[str] = []
|
|
213
|
+
cursor += active.input_wait / 1000.0
|
|
214
|
+
for char in input_text:
|
|
215
|
+
cursor += active.type_speed / 1000.0
|
|
216
|
+
lines.append(json.dumps([round(cursor, 6), "o", char]))
|
|
217
|
+
if char == " ":
|
|
218
|
+
cursor += active.effective_word_pause_s
|
|
219
|
+
return cursor, lines
|
|
220
|
+
|
|
221
|
+
def _consume_session(
|
|
222
|
+
self, events: list[ScEvent], i: int
|
|
223
|
+
) -> tuple[int, list[ScEvent]]:
|
|
224
|
+
session: list[ScEvent] = []
|
|
225
|
+
while i < len(events):
|
|
226
|
+
e = events[i]
|
|
227
|
+
# Terminator: outer shell cmd or SC directive
|
|
228
|
+
if e.type == "cmd":
|
|
229
|
+
# Inner expect calls within the session — skip
|
|
230
|
+
if e.text == "expect" or e.text.startswith("expect "):
|
|
231
|
+
i += 1
|
|
232
|
+
continue
|
|
233
|
+
break
|
|
234
|
+
if e.type == "dir":
|
|
235
|
+
break
|
|
236
|
+
# Check for mark input pattern in out events
|
|
237
|
+
mi = self._mark_input_re.match(e.text)
|
|
238
|
+
if mi:
|
|
239
|
+
prefix_out = mi.group(1)
|
|
240
|
+
input_text = mi.group(2).strip()
|
|
241
|
+
if prefix_out.strip():
|
|
242
|
+
session.append(ScEvent(e.ts, "out", prefix_out))
|
|
243
|
+
i += 1
|
|
244
|
+
# Check for PTY echo on next line
|
|
245
|
+
if i < len(events) and events[i].type == "out":
|
|
246
|
+
next_e = events[i]
|
|
247
|
+
if next_e.text.rstrip("\r\n") == input_text:
|
|
248
|
+
# Strip the echoed characters; keep the trailing \r\n
|
|
249
|
+
# (Enter echo) so the cast shows a newline after typing.
|
|
250
|
+
remaining = next_e.text[len(input_text):]
|
|
251
|
+
i += 1
|
|
252
|
+
session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
|
|
253
|
+
if remaining:
|
|
254
|
+
session.append(ScEvent(next_e.ts, "out", remaining))
|
|
255
|
+
else:
|
|
256
|
+
session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
|
|
257
|
+
else:
|
|
258
|
+
session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
|
|
259
|
+
continue
|
|
260
|
+
# Skip spawn line
|
|
261
|
+
if e.text.startswith("spawn "):
|
|
262
|
+
i += 1
|
|
263
|
+
continue
|
|
264
|
+
session.append(ScEvent(e.ts, "out", e.text))
|
|
265
|
+
i += 1
|
|
266
|
+
return i, session
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class FilterDirective(Directive):
|
|
270
|
+
priority = 40
|
|
271
|
+
|
|
272
|
+
def __init__(self, dp: str = "SC", tp: str = "+"):
|
|
273
|
+
super().__init__(dp, tp)
|
|
274
|
+
self._filters: list[list[str]] = []
|
|
275
|
+
|
|
276
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
277
|
+
out: list[ScEvent] = []
|
|
278
|
+
for e in events:
|
|
279
|
+
if e.type == "dir" and e.text.startswith("filter "):
|
|
280
|
+
argv = shlex.split(e.text[len("filter "):])
|
|
281
|
+
if argv:
|
|
282
|
+
self._filters = [argv]
|
|
283
|
+
# consumed — not emitted
|
|
284
|
+
elif e.type == "dir" and e.text.startswith("filter-add "):
|
|
285
|
+
argv = shlex.split(e.text[len("filter-add "):])
|
|
286
|
+
if argv:
|
|
287
|
+
self._filters.append(argv)
|
|
288
|
+
# consumed — not emitted
|
|
289
|
+
elif e.type in ("out", "cmd"):
|
|
290
|
+
out.append(ScEvent(e.ts, e.type, self.apply(e.text)))
|
|
291
|
+
else:
|
|
292
|
+
out.append(e)
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
def apply(self, text: str) -> str:
|
|
296
|
+
# Separate terminator so filters operate on content only
|
|
297
|
+
if text.endswith('\r\n'):
|
|
298
|
+
term, body = '\r\n', text[:-2]
|
|
299
|
+
elif text.endswith('\n'):
|
|
300
|
+
term, body = '\n', text[:-1]
|
|
301
|
+
elif text.endswith('\r'):
|
|
302
|
+
term, body = '\r', text[:-1]
|
|
303
|
+
else:
|
|
304
|
+
term, body = '', text
|
|
305
|
+
for argv in self._filters:
|
|
306
|
+
try:
|
|
307
|
+
result = subprocess.run(argv, input=body.encode(), capture_output=True)
|
|
308
|
+
body = result.stdout.decode('utf-8', errors='replace').rstrip('\n')
|
|
309
|
+
except OSError:
|
|
310
|
+
body = ''
|
|
311
|
+
return body + term
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class RecordDirective(Directive):
|
|
315
|
+
priority = 10
|
|
316
|
+
|
|
317
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
318
|
+
out: list[ScEvent] = []
|
|
319
|
+
i = 0
|
|
320
|
+
while i < len(events):
|
|
321
|
+
e = events[i]
|
|
322
|
+
if e.type == "dir" and e.text == "record pause":
|
|
323
|
+
i += 1
|
|
324
|
+
while i < len(events):
|
|
325
|
+
if events[i].type == "dir" and events[i].text == "record resume":
|
|
326
|
+
i += 1
|
|
327
|
+
break
|
|
328
|
+
i += 1
|
|
329
|
+
else:
|
|
330
|
+
out.append(e)
|
|
331
|
+
i += 1
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class HelpersDirective(Directive):
|
|
336
|
+
priority = 5
|
|
337
|
+
|
|
338
|
+
def __init__(self, dp: str = "SC", tp: str = "+"):
|
|
339
|
+
super().__init__(dp, tp)
|
|
340
|
+
self._helpers_re = re.compile(rf"^:\s+{re.escape(dp)}\s+helpers\s*$")
|
|
341
|
+
|
|
342
|
+
def pre(self, lines: list[str]) -> list[str]:
|
|
343
|
+
out: list[str] = []
|
|
344
|
+
for line in lines:
|
|
345
|
+
if self._helpers_re.match(line.rstrip("\n\r")):
|
|
346
|
+
out.extend([
|
|
347
|
+
f": {self.dp} record pause\n",
|
|
348
|
+
"RED=$'\\033[31m'\n",
|
|
349
|
+
"YELLOW=$'\\033[33m'\n",
|
|
350
|
+
"GREEN=$'\\033[32m'\n",
|
|
351
|
+
"CYAN=$'\\033[36m'\n",
|
|
352
|
+
"BOLD=$'\\033[1m'\n",
|
|
353
|
+
"RESET=$'\\033[0m'\n",
|
|
354
|
+
f": {self.dp} record resume\n",
|
|
355
|
+
])
|
|
356
|
+
else:
|
|
357
|
+
out.append(line)
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class CommentDirective(Directive):
|
|
362
|
+
"""Converts `: SC '\\' comment` directive events to cmd events with # comment.
|
|
363
|
+
|
|
364
|
+
Script syntax: `: SC '\\' This is a comment`
|
|
365
|
+
After _parse_raw strips the prefix, dir event text is: `'\\' This is a comment`
|
|
366
|
+
Emits: `ScEvent(ts, "cmd", "# This is a comment")`
|
|
367
|
+
"""
|
|
368
|
+
priority = 45
|
|
369
|
+
|
|
370
|
+
def post(self, events: list[ScEvent]) -> list[ScEvent]:
|
|
371
|
+
out: list[ScEvent] = []
|
|
372
|
+
for e in events:
|
|
373
|
+
if e.type == "dir" and e.text.startswith("'\\\' "):
|
|
374
|
+
out.append(ScEvent(e.ts, "cmd", f"# {e.text[4:]}"))
|
|
375
|
+
elif e.type == "dir" and e.text == "'\\\'":
|
|
376
|
+
out.append(ScEvent(e.ts, "cmd", "#"))
|
|
377
|
+
else:
|
|
378
|
+
out.append(e)
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class SetDirective(Directive):
|
|
383
|
+
priority = 50 # gen-only; ordering relative to SleepDirective is irrelevant
|
|
384
|
+
handles = "set"
|
|
385
|
+
|
|
386
|
+
def gen(
|
|
387
|
+
self,
|
|
388
|
+
event: tuple,
|
|
389
|
+
queue: deque[tuple],
|
|
390
|
+
active: ScriptcastConfig,
|
|
391
|
+
cursor: float,
|
|
392
|
+
) -> tuple[float, list[str]]:
|
|
393
|
+
_, _, text = event
|
|
394
|
+
parts = shlex.split(text)
|
|
395
|
+
args = parts[1:]
|
|
396
|
+
if len(args) >= 2:
|
|
397
|
+
active.apply("set", args)
|
|
398
|
+
return cursor, []
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class SleepDirective(Directive):
|
|
402
|
+
priority = 50
|
|
403
|
+
handles = "sleep"
|
|
404
|
+
|
|
405
|
+
def gen(
|
|
406
|
+
self,
|
|
407
|
+
event: tuple,
|
|
408
|
+
queue: deque[tuple],
|
|
409
|
+
active: ScriptcastConfig,
|
|
410
|
+
cursor: float,
|
|
411
|
+
) -> tuple[float, list[str]]:
|
|
412
|
+
_, _, text = event
|
|
413
|
+
parts = text.split()
|
|
414
|
+
if len(parts) >= 2:
|
|
415
|
+
cursor += int(parts[1]) / 1000.0
|
|
416
|
+
return cursor, []
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def build_directives(dp: str = "SC", tp: str = "+") -> list[Directive]:
|
|
420
|
+
"""Build the full sorted directive list for the given prefix settings."""
|
|
421
|
+
core: list[Directive] = [
|
|
422
|
+
RecordDirective(dp, tp),
|
|
423
|
+
HelpersDirective(dp, tp),
|
|
424
|
+
MockDirective(dp, tp),
|
|
425
|
+
ExpectDirective(dp, tp),
|
|
426
|
+
FilterDirective(dp, tp),
|
|
427
|
+
CommentDirective(dp, tp),
|
|
428
|
+
SetDirective(dp, tp),
|
|
429
|
+
SleepDirective(dp, tp),
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
eps = entry_points(group="scriptcast.directives")
|
|
433
|
+
plugins: list[Directive] = []
|
|
434
|
+
for ep in eps:
|
|
435
|
+
try:
|
|
436
|
+
plugins.append(ep.load()(dp, tp))
|
|
437
|
+
except Exception as exc: # noqa: BLE001
|
|
438
|
+
warnings.warn(
|
|
439
|
+
f"Failed to load scriptcast directive plugin {ep.name!r}: {exc}",
|
|
440
|
+
UserWarning,
|
|
441
|
+
stacklevel=2,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return sorted(core + plugins, key=lambda d: d.priority)
|