• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tcalmant / ipopo / 10549145652

25 Aug 2024 06:19PM UTC coverage: 85.081% (+2.3%) from 82.765%
10549145652

push

github

web-flow
Merge pull request #138 from tcalmant/v3-prep

Project review for V3 release

161 of 170 new or added lines in 91 files covered. (94.71%)

13932 of 16375 relevant lines covered (85.08%)

2.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.71
/pelix/shell/parser.py
1
#!/usr/bin/python
2
# -- Content-Encoding: UTF-8 --
3
"""
4
Common parser for shell implementations
5

6
:author: Thomas Calmant
7
:copyright: Copyright 2024, Thomas Calmant
8
:license: Apache License 2.0
9
:version: 3.0.0
10
:status: Alpha
11

12
..
13

14
    Copyright 2024 Thomas Calmant
15

16
    Licensed under the Apache License, Version 2.0 (the "License");
17
    you may not use this file except in compliance with the License.
18
    You may obtain a copy of the License at
19

20
        https://www.apache.org/licenses/LICENSE-2.0
21

22
    Unless required by applicable law or agreed to in writing, software
23
    distributed under the License is distributed on an "AS IS" BASIS,
24
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
25
    See the License for the specific language governing permissions and
26
    limitations under the License.
27
"""
28

29
import collections
3✔
30
import inspect
3✔
31
import logging
3✔
32
import shlex
3✔
33
import string
3✔
34
import sys
3✔
35
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast
3✔
36

37
import pelix.shell.beans as beans
3✔
38
from pelix.shell import ShellCommandMethod
3✔
39
from pelix.shell.completion import ATTR_COMPLETERS, CompletionInfo
3✔
40
from pelix.utilities import get_method_arguments, to_str
3✔
41

42
if TYPE_CHECKING:
3✔
43
    from pelix.framework import Framework
×
44

45
# ------------------------------------------------------------------------------
46

47
# Module version
48
__version_info__ = (3, 0, 0)
3✔
49
__version__ = ".".join(str(x) for x in __version_info__)
3✔
50

51
# Documentation strings format
52
__docformat__ = "restructuredtext en"
3✔
53

54
# ------------------------------------------------------------------------------
55

56
DEFAULT_NAMESPACE = "default"
3✔
57
""" Default command name space: default """
3✔
58

59
# ------------------------------------------------------------------------------
60

61

62
def _find_assignment(arg_token: str) -> int:
3✔
63
    """
64
    Find the first non-escaped assignment in the given argument token.
65
    Returns -1 if no assignment was found.
66

67
    :param arg_token: The argument token
68
    :return: The index of the first assignment, or -1
69
    """
70
    idx = arg_token.find("=")
3✔
71
    while idx != -1:
3✔
72
        if idx != 0 and arg_token[idx - 1] != "\\":
3✔
73
            # No escape character
74
            return idx
3✔
75

76
        idx = arg_token.find("=", idx + 1)
×
77

78
    # No assignment found
79
    return -1
3✔
80

81

82
class _ArgTemplate(string.Template):
3✔
83
    """
84
    Argument string template class
85
    """
86

87
    idpattern = r"[_a-z\?][_a-z0-9\.]*"
3✔
88

89

90
def _make_args(
3✔
91
    args_list: List[str], session: beans.ShellSession, fw_props: Dict[str, Any]
92
) -> Tuple[List[str], Dict[str, str]]:
93
    """
94
    Converts the given list of arguments into a list (args) and a
95
    dictionary (kwargs).
96
    All arguments with an assignment are put into kwargs, others in args.
97

98
    :param args_list: The list of arguments to be treated
99
    :param session: The current shell session
100
    :return: The (arg_token, kwargs) tuple.
101
    """
102
    args = []
3✔
103
    kwargs = {}
3✔
104

105
    for arg_token in args_list:
3✔
106
        idx = _find_assignment(arg_token)
3✔
107
        if idx != -1:
3✔
108
            # Assignment
109
            key = arg_token[:idx]
3✔
110
            value = arg_token[idx + 1 :]
3✔
111
            kwargs[key] = value
3✔
112
        else:
113
            # Direct argument
114
            args.append(arg_token)
3✔
115

116
    # Prepare the dictionary of variables
117
    variables: Dict[str, Any] = collections.defaultdict(str)
3✔
118
    variables.update(fw_props)
3✔
119
    variables.update(session.variables)
3✔
120

121
    # Replace variables
122
    args = [_ArgTemplate(arg).safe_substitute(variables) for arg in args]
3✔
123
    kwargs = {key: _ArgTemplate(value).safe_substitute(variables) for key, value in kwargs.items()}
3✔
124
    return args, kwargs
3✔
125

126

127
def _split_ns_command(cmd_token: str) -> Tuple[str, str]:
3✔
128
    """
129
    Extracts the name space and the command name of the given command token.
130

131
    :param cmd_token: The command token
132
    :return: The extracted (name space, command) tuple
133
    """
134
    namespace = None
3✔
135
    cmd_split = cmd_token.split(".", 1)
3✔
136
    if len(cmd_split) == 1:
3✔
137
        # No name space given
138
        command = cmd_split[0]
3✔
139
    else:
140
        # Got a name space and a command
141
        namespace = cmd_split[0]
3✔
142
        command = cmd_split[1]
3✔
143

144
    if not namespace:
3✔
145
        # No name space given: given an empty one
146
        namespace = ""
3✔
147

148
    # Use lower case values only
149
    return namespace.lower(), command.lower()
3✔
150

151

152
# ------------------------------------------------------------------------------
153

154

155
class Shell:
3✔
156
    """
157
    A simple shell, based on shlex.
158

159
    Allows the use of name spaces.
160
    """
161

162
    def __init__(self, framework: "Framework", logname: Optional[str] = None) -> None:
3✔
163
        """
164
        Sets up members
165

166
        :param framework: The Pelix Framework instance
167
        :param logname: Custom name for the shell logger
168
        """
169
        self._commands: Dict[str, Dict[str, ShellCommandMethod]] = {}
3✔
170
        self._framework = framework
3✔
171
        self._logger = logging.getLogger(logname or __name__)
3✔
172

173
        # Register the help command
174
        self.register_command(None, "help", self.print_help)
3✔
175
        self.register_command(None, "?", self.print_help)
3✔
176

177
        # Basic commands
178
        self.register_command(None, "echo", self.echo)
3✔
179
        self.register_command(None, "quit", self.quit)
3✔
180
        self.register_command(None, "exit", self.quit)
3✔
181

182
        # Variable commands
183
        self.register_command(None, "set", self.var_set)
3✔
184
        self.register_command(None, "unset", self.var_unset)
3✔
185

186
        # File commands
187
        self.register_command(None, "run", self.run_file)
3✔
188

189
    @staticmethod
3✔
190
    def get_banner() -> str:
3✔
191
        """
192
        Returns the Shell banner
193
        """
194
        return "** Shell prompt **\n"
×
195

196
    @staticmethod
3✔
197
    def get_ps1() -> str:
3✔
198
        """
199
        Returns the PS1, the basic shell prompt
200
        """
201
        return "$ "
3✔
202

203
    def register_command(self, namespace: Optional[str], command: str, method: ShellCommandMethod) -> bool:
3✔
204
        """
205
        Registers the given command to the shell.
206

207
        The namespace can be None, empty or "default"
208

209
        :param namespace: The command name space.
210
        :param command: The shell name of the command
211
        :param method: The method to call
212
        :return: True if the method has been registered, False if it was already known or invalid
213
        """
214
        if method is None:
3✔
215
            self._logger.error("No method given for %s.%s", namespace, command)
3✔
216
            return False
3✔
217

218
        # Store everything in lower case
219
        namespace = (namespace or "").strip().lower()
3✔
220
        command = (command or "").strip().lower()
3✔
221

222
        if not namespace:
3✔
223
            namespace = DEFAULT_NAMESPACE
3✔
224

225
        if not command:
3✔
226
            self._logger.error("No command name given")
3✔
227
            return False
3✔
228

229
        if namespace not in self._commands:
3✔
230
            space = self._commands[namespace] = cast(Dict[str, Callable[..., Any]], {})
3✔
231
        else:
232
            space = self._commands[namespace]
3✔
233

234
        if command in space:
3✔
235
            self._logger.error("Command already registered: %s.%s", namespace, command)
3✔
236
            return False
3✔
237

238
        space[command] = method
3✔
239
        return True
3✔
240

241
    def get_command_completers(self, namespace: str, command: str) -> Optional[CompletionInfo]:
3✔
242
        """
243
        Returns the completer method associated to the given command, or None
244

245
        :param namespace: The command name space.
246
        :param command: The shell name of the command
247
        :return: A CompletionConfiguration object
248
        :raise KeyError: Unknown command or name space
249
        """
250
        # Find the method (can raise a KeyError)
251
        method = self._commands[namespace][command]
×
252

253
        # Return the completer, if any
254
        return getattr(method, ATTR_COMPLETERS, None)
×
255

256
    def unregister(self, namespace: str, command: Optional[str] = None) -> bool:
3✔
257
        """
258
        Unregisters the given command. If command is None, the whole name space
259
        is unregistered.
260

261
        :param namespace: The command name space.
262
        :param command: The shell name of the command, or None
263
        :return: True if the command was known, else False
264
        """
265
        if not namespace:
3✔
266
            namespace = DEFAULT_NAMESPACE
×
267

268
        namespace = namespace.strip().lower()
3✔
269
        if namespace not in self._commands:
3✔
270
            self._logger.warning("Unknown name space: %s", namespace)
3✔
271
            return False
3✔
272

273
        if command is not None:
3✔
274
            # Remove the command
275
            command = command.strip().lower()
3✔
276
            if command not in self._commands[namespace]:
3✔
277
                self._logger.warning("Unknown command: %s.%s", namespace, command)
×
278
                return False
×
279

280
            del self._commands[namespace][command]
3✔
281

282
            # Remove the name space if necessary
283
            if not self._commands[namespace]:
3✔
284
                del self._commands[namespace]
3✔
285
        else:
286
            # Remove the whole name space
287
            del self._commands[namespace]
×
288

289
        return True
3✔
290

291
    def __find_command_ns(self, command: str) -> List[str]:
3✔
292
        """
293
        Returns the name spaces where the given command named is registered.
294
        If the command exists in the default name space, the returned list will
295
        only contain the default name space.
296
        Returns an empty list of the command is unknown
297

298
        :param command: A command name
299
        :return: A list of name spaces
300
        """
301
        # Look for the spaces where the command name appears
302
        namespaces: List[str] = []
3✔
303
        for namespace, commands in self._commands.items():
3✔
304
            if command in commands:
3✔
305
                namespaces.append(namespace)
3✔
306

307
        # Sort name spaces
308
        namespaces.sort()
3✔
309

310
        # Default name space must always come first
311
        try:
3✔
312
            namespaces.remove(DEFAULT_NAMESPACE)
3✔
313
            namespaces.insert(0, DEFAULT_NAMESPACE)
3✔
314
        except ValueError:
3✔
315
            # Default name space wasn't present
316
            pass
3✔
317

318
        return namespaces
3✔
319

320
    def get_namespaces(self) -> List[str]:
3✔
321
        """
322
        Retrieves the list of known name spaces (without the default one)
323

324
        :return: The list of known name spaces
325
        """
326
        namespaces = list(self._commands.keys())
3✔
327
        namespaces.remove(DEFAULT_NAMESPACE)
3✔
328
        namespaces.sort()
3✔
329
        return namespaces
3✔
330

331
    def get_commands(self, namespace: Optional[str]) -> List[str]:
3✔
332
        """
333
        Retrieves the commands of the given name space. If *namespace* is None
334
        or empty, it retrieves the commands of the default name space
335

336
        :param namespace: The commands name space
337
        :return: A list of commands names
338
        """
339
        if not namespace:
3✔
340
            # Default name space:
341
            namespace = DEFAULT_NAMESPACE
3✔
342

343
        try:
3✔
344
            namespace.strip().lower()
3✔
345
            commands = list(self._commands[namespace].keys())
3✔
346
            commands.sort()
3✔
347
            return commands
3✔
348
        except KeyError:
3✔
349
            # Unknown name space
350
            return []
3✔
351

352
    def get_ns_commands(self, cmd_name: str) -> List[Tuple[str, str]]:
3✔
353
        """
354
        Retrieves the possible name spaces and commands associated to the given
355
        command name.
356

357
        :param cmd_name: The given command name
358
        :return: A list of 2-tuples (name space, command)
359
        :raise ValueError: Unknown command name
360
        """
361
        namespace, command = _split_ns_command(cmd_name)
3✔
362
        if not namespace:
3✔
363
            # Name space not given, look for the commands
364
            spaces = self.__find_command_ns(command)
3✔
365
            if not spaces:
3✔
366
                # Unknown command
367
                raise ValueError(f"Unknown command {command}")
3✔
368

369
            # Return a sorted list of tuples
370
            return sorted((namespace, command) for namespace in spaces)
3✔
371

372
        # Single match
373
        return [(namespace, command)]
×
374

375
    def get_ns_command(self, cmd_name: str) -> Tuple[str, str]:
3✔
376
        """
377
        Retrieves the name space and the command associated to the given
378
        command name.
379

380
        :param cmd_name: The given command name
381
        :return: A 2-tuple (name space, command)
382
        :raise ValueError: Unknown command name
383
        """
384
        namespace, command = _split_ns_command(cmd_name)
3✔
385
        if not namespace:
3✔
386
            # Name space not given, look for the command
387
            spaces = self.__find_command_ns(command)
3✔
388
            if not spaces:
3✔
389
                # Unknown command
390
                raise ValueError(f"Unknown command {command}")
3✔
391

392
            if len(spaces) > 1:
3✔
393
                # Multiple possibilities
394
                if spaces[0] == DEFAULT_NAMESPACE:
3✔
395
                    # Default name space has priority
396
                    namespace = DEFAULT_NAMESPACE
×
397
                else:
398
                    # Ambiguous name
399
                    raise ValueError(
3✔
400
                        f"Multiple name spaces for command '{command}': {', '.join(sorted(spaces))}"
401
                    )
402
            else:
403
                # Use the found name space
404
                namespace = spaces[0]
3✔
405

406
        # Command found
407
        return namespace, command
3✔
408

409
    def execute(self, cmdline: str, session: Optional[beans.ShellSession] = None) -> bool:
3✔
410
        """
411
        Executes the command corresponding to the given line
412

413
        :param cmdline: Command line to parse
414
        :param session: Current shell session
415
        :return: True if command succeeded, else False
416
        """
417
        if session is None:
3✔
418
            # Default session
419
            session = beans.ShellSession(beans.IOHandler(sys.stdin, sys.stdout), {})
3✔
420

421
        assert isinstance(session, beans.ShellSession)
3✔
422

423
        # Split the command line
424
        if not cmdline:
3✔
425
            return False
3✔
426

427
        # Convert the line into a string
428
        cmdline = to_str(cmdline)
3✔
429

430
        try:
3✔
431
            line_split = shlex.split(cmdline, True, True)
3✔
432
        except ValueError as ex:
×
433
            session.write_line(f"Error reading line: {ex}")
×
434
            return False
×
435

436
        if not line_split:
3✔
437
            return False
3✔
438

439
        try:
3✔
440
            # Extract command information
441
            namespace, command = self.get_ns_command(line_split[0])
3✔
442
        except ValueError as ex:
3✔
443
            # Unknown command
444
            session.write_line(str(ex))
3✔
445
            return False
3✔
446

447
        # Get the content of the name space
448
        space = self._commands.get(namespace, None)
3✔
449
        if not space:
3✔
450
            session.write_line(
3✔
451
                f"Unknown name space {namespace}",
452
            )
453
            return False
3✔
454

455
        # Get the method object
456
        method = space.get(command, None)
3✔
457
        if method is None:
3✔
458
            session.write_line(f"Unknown command: {namespace}.{command}")
×
459
            return False
×
460

461
        # Make arguments and keyword arguments
462
        args, kwargs = _make_args(line_split[1:], session, self._framework.get_properties())
3✔
463
        try:
3✔
464
            # Execute it
465
            result = method(session, *args, **kwargs)
3✔
466

467
            # Store the result as $?
468
            if result is not None:
3✔
469
                session.set(beans.RESULT_VAR_NAME, result)
3✔
470

471
            # 0, None are considered as success, so don't use not nor bool
472
            return result is not False
3✔
473
        except TypeError as ex:
3✔
474
            # Invalid arguments...
475
            self._logger.error("Error calling %s.%s: %s", namespace, command, ex)
3✔
476
            session.write_line(f"Invalid method call: {ex}")
3✔
477
            self.__print_namespace_help(session, namespace, command)
3✔
478
            return False
3✔
479
        except Exception as ex:
3✔
480
            # Error
481
            self._logger.exception("Error calling %s.%s: %s", namespace, command, ex)
3✔
482
            session.write_line(f"{type(ex).__name__}: {ex}")
3✔
483
            return False
×
484
        finally:
485
            # Try to flush in any case
486
            try:
3✔
487
                session.flush()
3✔
488
            except IOError:
×
489
                pass
×
490

491
    @staticmethod
3✔
492
    def __extract_help(method: Callable[..., Any]) -> Tuple[str, str]:
3✔
493
        """
494
        Formats the help string for the given method
495

496
        :param method: The method to document
497
        :return: A tuple: (arguments list, documentation line)
498
        """
499
        if method is None:
3✔
500
            return "", "(No associated method)"
×
501

502
        # Get the arguments
503
        arg_spec = get_method_arguments(method)
3✔
504

505
        # Ignore the session argument
506
        start_arg = 1
3✔
507

508
        # Compute the number of arguments with default value
509
        if arg_spec.defaults is not None:
3✔
510
            nb_optional = len(arg_spec.defaults)
3✔
511

512
            # Let the mandatory arguments as they are
513
            args = [f"<{arg}>" for arg in arg_spec.args[start_arg:-nb_optional]]
3✔
514

515
            # Add the other arguments
516
            for name, value in zip(arg_spec.args[-nb_optional:], arg_spec.defaults[-nb_optional:]):
3✔
517
                if value is not None:
3✔
518
                    args.append(f"[<{name}>={value}]")
3✔
519
                else:
520
                    args.append(f"[<{name}>]")
3✔
521
        else:
522
            # All arguments are mandatory
523
            args = [f"<{arg}>" for arg in arg_spec.args[start_arg:]]
3✔
524

525
        # Extra arguments
526
        if arg_spec.keywords:
3✔
527
            args.append("[<property=value> ...]")
3✔
528

529
        if arg_spec.varargs:
3✔
530
            args.append(f"[<{arg_spec.varargs} ...>]")
3✔
531

532
        # Get the documentation string
533
        doc = inspect.getdoc(method) or "(Documentation missing)"
3✔
534
        return " ".join(args), " ".join(doc.split())
3✔
535

536
    def __print_command_help(self, session: beans.ShellSession, namespace: str, cmd_name: str) -> None:
3✔
537
        """
538
        Prints the documentation of the given command
539

540
        :param session: Session handler
541
        :param namespace: Name space of the command
542
        :param cmd_name: Name of the command
543
        """
544
        # Extract documentation
545
        args, doc = self.__extract_help(self._commands[namespace][cmd_name])
3✔
546

547
        # Print the command name, and its arguments
548
        if args:
3✔
549
            session.write_line("- {0} {1}", cmd_name, args)
3✔
550
        else:
551
            session.write_line("- {0}", cmd_name)
3✔
552

553
        # Print the documentation line
554
        session.write_line("\t\t{0}", doc)
3✔
555

556
    def __print_namespace_help(
3✔
557
        self, session: beans.ShellSession, namespace: str, cmd_name: Optional[str] = None
558
    ) -> None:
559
        """
560
        Prints the documentation of all the commands in the given name space,
561
        or only of the given command
562

563
        :param session: Session Handler
564
        :param namespace: Name space of the command
565
        :param cmd_name: Name of the command to show, None to show them all
566
        """
567
        session.write_line(f"=== Name space '{namespace}' ===")
3✔
568

569
        # Get all commands in this name space
570
        if cmd_name is None:
3✔
571
            names = list(self._commands[namespace])
3✔
572
            names.sort()
3✔
573
        else:
574
            names = [cmd_name]
3✔
575

576
        first_cmd = True
3✔
577
        for command in names:
3✔
578
            if not first_cmd:
3✔
579
                # Print an empty line
580
                session.write_line("\n")
3✔
581

582
            self.__print_command_help(session, namespace, command)
3✔
583
            first_cmd = False
3✔
584

585
    def print_help(self, session: beans.ShellSession, command: Optional[str] = None) -> Any:
3✔
586
        """
587
        Prints the available methods and their documentation, or the
588
        documentation of the given command.
589
        """
590
        if command:
3✔
591
            # Single command mode
592
            if command in self._commands:
3✔
593
                # Argument is a name space
594
                self.__print_namespace_help(session, command)
3✔
595
                was_namespace = True
3✔
596
            else:
597
                was_namespace = False
3✔
598

599
            # Also print the name of matching commands
600
            try:
3✔
601
                # Extract command name space and name
602
                possibilities = self.get_ns_commands(command)
3✔
603
            except ValueError as ex:
3✔
604
                # Unknown command
605
                if not was_namespace:
3✔
606
                    # ... and no name space were matching either -> error
607
                    session.write_line(str(ex))
×
608
                    return False
×
609
            else:
610
                # Print the help of the found command
611
                if was_namespace:
3✔
612
                    # Give some space
613
                    session.write_line("\n\n")
×
614

615
                for namespace, cmd_name in possibilities:
3✔
616
                    self.__print_namespace_help(session, namespace, cmd_name)
3✔
617
        else:
618
            # Get all name spaces
619
            namespaces = list(self._commands.keys())
3✔
620
            namespaces.remove(DEFAULT_NAMESPACE)
3✔
621
            namespaces.sort()
3✔
622
            namespaces.insert(0, DEFAULT_NAMESPACE)
3✔
623

624
            first_ns = True
3✔
625
            for namespace in namespaces:
3✔
626
                if not first_ns:
3✔
627
                    # Add empty lines
628
                    session.write_line("\n\n")
3✔
629

630
                # Print the help of all commands
631
                self.__print_namespace_help(session, namespace)
3✔
632
                first_ns = False
3✔
633

634
        return None
3✔
635

636
    @staticmethod
3✔
637
    def echo(session: beans.ShellSession, *words: str) -> None:
3✔
638
        """
639
        Echoes the given words
640
        """
641
        session.write_line(" ".join(words))
3✔
642

643
    @staticmethod
3✔
644
    def quit(session: beans.ShellSession) -> None:
3✔
645
        """
646
        Stops the current shell session (raises a KeyboardInterrupt exception)
647
        """
648
        session.write_line("Raising KeyboardInterrupt to stop main thread")
×
649
        raise KeyboardInterrupt()
×
650

651
    def var_set(self, session: beans.ShellSession, **kwargs: Any) -> None:
3✔
652
        """
653
        Sets the given variables or prints the current ones. "set answer=42"
654
        """
655
        if not kwargs:
×
656
            for name, value in session.variables.items():
×
657
                session.write_line(f"{name}={value}")
×
658
        else:
659
            for name, value in kwargs.items():
×
660
                name = name.strip()
×
661
                session.set(name, value)
×
662
                session.write_line(f"{name}={value}")
×
663

664
    @staticmethod
3✔
665
    def var_unset(session: beans.ShellSession, name: str) -> Any:
3✔
666
        """
667
        Unsets the given variable
668
        """
669
        name = name.strip()
3✔
670
        try:
3✔
671
            session.unset(name)
3✔
672
            session.write_line(f"Variable {name} unset.")
3✔
673
        except KeyError:
×
NEW
674
            session.write_line(f"Unknown variable: {name}")
×
675
            return False
×
676

677
        return None
3✔
678

679
    def run_file(self, session: beans.ShellSession, filename: str) -> Any:
3✔
680
        """
681
        Runs the given "script" file
682
        """
683
        try:
3✔
684
            with open(filename, "r", encoding="utf8") as filep:
3✔
685
                for lineno, line in enumerate(filep):
3✔
686
                    line = line.strip()
3✔
687
                    if not line or line.startswith("#"):
3✔
688
                        # Ignore comments and empty lines
689
                        continue
3✔
690

691
                    # Print out the executed line
692
                    session.write_line("[{0:02d}] >> {1}", lineno, line)
3✔
693

694
                    # Execute the line
695
                    if not self.execute(line, session):
3✔
696
                        session.write_line("Command at line {0} failed. Abandon.", lineno + 1)
3✔
697
                        return False
3✔
698

699
                session.write_line("Script execution succeeded")
3✔
700
        except IOError as ex:
3✔
701
            session.write_line("Error reading file {0}: {1}", filename, ex)
3✔
702
            return False
3✔
703

704
        return None
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc