|
18 | 18 | from keyLabels import localizedKeyLabels |
19 | 19 | import speech |
20 | 20 | try: |
21 | | - from speech.commands import ( # noqa: F401 - may be used in the evaluated speech sequence |
| 21 | + from speech.commands import ( |
22 | 22 | CallbackCommand, |
23 | 23 | BeepCommand, |
24 | 24 | ConfigProfileTriggerCommand, |
25 | 25 | ) |
26 | | - from speech.commands import ( # noqa: F401 - may be used in the evaluated speech sequence |
| 26 | + from speech.commands import ( |
27 | 27 | CharacterModeCommand, |
28 | 28 | LangChangeCommand, |
29 | 29 | BreakCommand, |
|
37 | 37 | preSpeechRefactor = False |
38 | 38 | except ImportError: |
39 | 39 | # NVDA <= 2019.2.1 |
40 | | - from speech import ( # noqa: F401 - may be used in the evaluated speech sequence |
| 40 | + from speech import ( |
41 | 41 | CharacterModeCommand, |
42 | 42 | LangChangeCommand, |
43 | 43 | BreakCommand, |
44 | | - # EndUtteranceCommand, |
45 | 44 | PitchCommand, |
46 | 45 | VolumeCommand, |
47 | 46 | RateCommand, |
48 | 47 | PhonemeCommand, |
49 | | - # CallbackCommand, |
50 | | - # BeepCommand, |
51 | | - # WaveFileCommand, |
52 | | - # ConfigProfileTriggerCommand, |
53 | 48 | ) |
54 | 49 | preSpeechRefactor = True |
55 | 50 | from logHandler import log |
|
75 | 70 |
|
76 | 71 | import re |
77 | 72 | import os |
| 73 | +import sys |
| 74 | +import ast |
78 | 75 |
|
79 | 76 |
|
80 | 77 | addonHandler.initTranslation() |
@@ -157,6 +154,118 @@ def noFilter(msg): |
157 | 154 | return True |
158 | 155 |
|
159 | 156 |
|
| 157 | +cmdList = [] |
| 158 | +if not preSpeechRefactor: |
| 159 | + # We ignore CallbackCommand and ConfigProfileTriggerCommand to avoid producing errors or unexpected |
| 160 | + # side effects. |
| 161 | + cmdList.append(CallbackCommand) |
| 162 | + cmdList.append(ConfigProfileTriggerCommand) |
| 163 | +FORBIDDEN_COMMANDS = {c.__name__ for c in cmdList} |
| 164 | + |
| 165 | +cmdList = [ |
| 166 | + CharacterModeCommand, |
| 167 | + LangChangeCommand, |
| 168 | + BreakCommand, |
| 169 | + PitchCommand, |
| 170 | + VolumeCommand, |
| 171 | + RateCommand, |
| 172 | + PhonemeCommand, |
| 173 | +] |
| 174 | +if not preSpeechRefactor: |
| 175 | + cmdList.extend([ |
| 176 | + BeepCommand, |
| 177 | + EndUtteranceCommand, |
| 178 | + WaveFileCommand, |
| 179 | + ]) |
| 180 | +ALLOWED_COMMANDS = {c.__name__: c for c in cmdList} |
| 181 | + |
| 182 | + |
| 183 | +def astNodeToStr(node): |
| 184 | + # NVDA >= 2024.1 (using Python 3.11 or higher) |
| 185 | + if sys.version_info >= (3, 8): |
| 186 | + if isinstance(node, ast.Constant) and isinstance(node.value, str): |
| 187 | + return node.value |
| 188 | + # NVDA < 2024.1 (using Python 2.7 or 3.7) |
| 189 | + else: |
| 190 | + if isinstance(node, ast.Str): |
| 191 | + return node.s |
| 192 | + raise ValueError("Node is not a string") |
| 193 | + |
| 194 | + |
| 195 | +def astNodeToLitteral(node): |
| 196 | + # NVDA >= 2024.1 (using Python 3.11 or higher) |
| 197 | + if sys.version_info >= (3, 8): |
| 198 | + if isinstance(node, ast.Constant): |
| 199 | + return node.value |
| 200 | + # NVDA < 2024.1 (using Python 2.7 or 3.7) |
| 201 | + else: |
| 202 | + if isinstance(node, ast.Str): |
| 203 | + return node.s |
| 204 | + if isinstance(node, ast.Num): |
| 205 | + return node.n |
| 206 | + if isinstance(node, ast.NameConstant): |
| 207 | + return node.value |
| 208 | + raise ValueError("Node is not a litteral") |
| 209 | + |
| 210 | +def generateSpeechSequence(txtSeq): |
| 211 | + """Generates a speech sequence from its representation in the log. |
| 212 | + Irrelevant commands are filtered out (CallbackCommand, _CancellableSpeechCommand) |
| 213 | + We do not use "eval" to avoid executing arbitrary code from a crafted log. |
| 214 | + """ |
| 215 | + |
| 216 | + tree = ast.parse(txtSeq, mode="eval") |
| 217 | + if not isinstance(tree.body, ast.List): |
| 218 | + raise ValueError("Speech sequence must be a list") |
| 219 | + seq = [] |
| 220 | + for elt in tree.body.elts: |
| 221 | + try: |
| 222 | + strVal = astNodeToStr(elt) |
| 223 | + except ValueError: |
| 224 | + pass |
| 225 | + else: |
| 226 | + seq.append(strVal) |
| 227 | + continue |
| 228 | + if isinstance(elt, ast.Call) and isinstance(elt.func, ast.Name): |
| 229 | + name = elt.func.id |
| 230 | + if name in FORBIDDEN_COMMANDS: |
| 231 | + continue |
| 232 | + if name not in ALLOWED_COMMANDS: |
| 233 | + log.error("Unsupported command: {name}".format(name=name)) |
| 234 | + continue |
| 235 | + args = [] |
| 236 | + valid = False |
| 237 | + for arg in elt.args: |
| 238 | + try: |
| 239 | + litteralVal = astNodeToLitteral(arg) |
| 240 | + except ValueError: |
| 241 | + log.error("Non-literal argument in {name}".format(name=name)) |
| 242 | + break |
| 243 | + args.append(litteralVal) |
| 244 | + else: |
| 245 | + valid = True |
| 246 | + if not valid: |
| 247 | + continue |
| 248 | + |
| 249 | + kwargs = {} |
| 250 | + valid = False |
| 251 | + for kw in elt.keywords: |
| 252 | + try: |
| 253 | + litteralVal = astNodeToLitteral(kw.value) |
| 254 | + except ValueError: |
| 255 | + log.error("Non-literal kwarg in {name}".format(name=name)) |
| 256 | + break |
| 257 | + kwargs[kw.arg] = litteralVal |
| 258 | + else: |
| 259 | + valid = True |
| 260 | + if not valid: |
| 261 | + continue |
| 262 | + cmd_cls = ALLOWED_COMMANDS[name] |
| 263 | + seq.append(cmd_cls(*args, **kwargs)) |
| 264 | + continue |
| 265 | + log.error("Unsupported AST node: {node}".format(node=ast.dump(elt))) |
| 266 | + continue |
| 267 | + return seq |
| 268 | + |
160 | 269 | class LogMessageHeader(object): |
161 | 270 | def __init__(self, level, codePath, time, threadName=None, thread=None): |
162 | 271 | self.level = level |
@@ -303,13 +412,16 @@ def getSpeakIoMessage(self, mode): |
303 | 412 | except Exception: |
304 | 413 | log.error("Sequence cannot be spoken: {seq}".format(seq=match['seq'])) |
305 | 414 | return self.content |
| 415 | + # Supress Cancellable speech commands and callback speech commands before parsing the text because their |
| 416 | + # representation in the log is not valid Python syntax and in any case, we want to discard them. |
306 | 417 | txtSeq = RE_CANCELLABLE_SPEECH.sub('', txtSeq) |
307 | 418 | txtSeq = RE_CALLBACK_COMMAND.sub('', txtSeq) |
308 | | - seq = eval(txtSeq) |
309 | | - # Ignore CallbackCommand and ConfigProfileTriggerCommand to avoid producing errors or unexpected |
310 | | - # side effects. |
311 | | - if not preSpeechRefactor: |
312 | | - seq = [c for c in seq if not isinstance(c, (CallbackCommand, ConfigProfileTriggerCommand))] |
| 419 | + try: |
| 420 | + seq = generateSpeechSequence(txtSeq) |
| 421 | + except SyntaxError: # When parsing the logged text of the sequence. |
| 422 | + log.error("Sequence cannot be spoken: {seq}".format(seq=match['seq'])) |
| 423 | + # Fallback to full line |
| 424 | + return self.content |
313 | 425 | if LogContainer.translateLog: |
314 | 426 | seq2 = [] |
315 | 427 | for s in seq: |
|
0 commit comments