robotcode-debugger 2.6.2__tar.gz → 2.8.0.dev548__tar.gz

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 (24) hide show
  1. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/.gitignore +4 -1
  2. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/PKG-INFO +5 -4
  3. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/pyproject.toml +3 -2
  4. robotcode_debugger-2.8.0.dev548/src/robotcode/debugger/__version__.py +1 -0
  5. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/cli.py +6 -0
  6. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/dap_types.py +18 -0
  7. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/debugger.py +167 -22
  8. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/default_capabilities.py +4 -3
  9. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/launcher/server.py +31 -8
  10. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/listeners.py +133 -5
  11. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/run.py +27 -17
  12. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/server.py +11 -0
  13. robotcode_debugger-2.6.2/src/robotcode/debugger/__version__.py +0 -1
  14. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/README.md +0 -0
  15. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/__init__.py +0 -0
  16. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/hooks.py +0 -0
  17. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/id_manager.py +0 -0
  18. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/launcher/__init__.py +0 -0
  19. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/launcher/cli.py +0 -0
  20. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/launcher/client.py +0 -0
  21. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/launcher/run.py +0 -0
  22. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/mixins.py +0 -0
  23. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/protocol.py +0 -0
  24. {robotcode_debugger-2.6.2 → robotcode_debugger-2.8.0.dev548}/src/robotcode/debugger/py.typed +0 -0
@@ -279,6 +279,9 @@ dist
279
279
  # vuepress build output
280
280
  .vuepress/dist
281
281
 
282
+ # Astro (documentation site)
283
+ docs/.astro/
284
+
282
285
  # Serverless directories
283
286
  .serverless/
284
287
 
@@ -339,7 +342,7 @@ bundled/libs
339
342
  # .agents
340
343
  .agents/
341
344
  skills-lock.json
342
- .claude
345
+ #.claude
343
346
 
344
347
  # sarif files
345
348
  /**/*.sarif.json
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: robotcode-debugger
3
- Version: 2.6.2
3
+ Version: 2.8.0-dev.548
4
4
  Summary: RobotCode Debugger for Robot Framework
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -20,13 +20,14 @@ Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
23
24
  Classifier: Programming Language :: Python :: Implementation :: CPython
24
25
  Classifier: Programming Language :: Python :: Implementation :: PyPy
25
26
  Classifier: Topic :: Utilities
26
27
  Classifier: Typing :: Typed
27
28
  Requires-Python: >=3.10
28
- Requires-Dist: robotcode-jsonrpc2==2.6.2
29
- Requires-Dist: robotcode-runner==2.6.2
29
+ Requires-Dist: robotcode-jsonrpc2==2.8.0-dev.548
30
+ Requires-Dist: robotcode-runner==2.8.0-dev.548
30
31
  Requires-Dist: robotframework>=5.0.0
31
32
  Provides-Extra: debugpy
32
33
  Requires-Dist: debugpy; extra == 'debugpy'
@@ -18,6 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.12",
19
19
  "Programming Language :: Python :: 3.13",
20
20
  "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
21
22
  "Programming Language :: Python :: Implementation :: CPython",
22
23
  "Programming Language :: Python :: Implementation :: PyPy",
23
24
  "Operating System :: OS Independent",
@@ -29,8 +30,8 @@ classifiers = [
29
30
  dynamic = ["version"]
30
31
  dependencies = [
31
32
  "robotframework>=5.0.0",
32
- "robotcode-jsonrpc2==2.6.2",
33
- "robotcode-runner==2.6.2",
33
+ "robotcode-jsonrpc2==2.8.0-dev.548",
34
+ "robotcode-runner==2.8.0-dev.548",
34
35
  ]
35
36
 
36
37
  [project.optional-dependencies]
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0-dev.548"
@@ -9,6 +9,7 @@ from robotcode.plugin.click_helper.options import (
9
9
  server_options,
10
10
  )
11
11
  from robotcode.plugin.click_helper.types import AddressesPort, add_options
12
+ from robotcode.plugin.click_helper.wrappable import wrappable
12
13
 
13
14
  from .__version__ import __version__
14
15
 
@@ -16,6 +17,7 @@ DEBUGGER_DEFAULT_PORT = 6612
16
17
  DEBUGPY_DEFAULT_PORT = 5678
17
18
 
18
19
 
20
+ @wrappable
19
21
  @click.command(
20
22
  context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
21
23
  add_help_option=True,
@@ -202,5 +204,9 @@ def debug(
202
204
  raise
203
205
  except KeyboardInterrupt:
204
206
  app.keyboard_interrupt()
207
+ except click.ClickException:
208
+ # Errors robotcode reports itself, such as a broken configuration; the root
209
+ # group gives them Robot Framework's exit code.
210
+ raise
205
211
  except Exception as e:
206
212
  raise UnknownError(str(e)) from e
@@ -1119,6 +1119,24 @@ class SetExceptionBreakpointsResponse(Response):
1119
1119
  body: Optional[SetExceptionBreakpointsResponseBody] = None
1120
1120
 
1121
1121
 
1122
+ @dataclass
1123
+ class ExceptionInfoArguments(Model):
1124
+ thread_id: int
1125
+
1126
+
1127
+ @dataclass
1128
+ class ExceptionDetails(Model):
1129
+ stack_trace: Optional[str] = None
1130
+
1131
+
1132
+ @dataclass
1133
+ class ExceptionInfoResponseBody(Model):
1134
+ exception_id: str
1135
+ break_mode: ExceptionBreakMode
1136
+ description: Optional[str] = None
1137
+ details: Optional[ExceptionDetails] = None
1138
+
1139
+
1122
1140
  @dataclass
1123
1141
  class _CompletionsRequest:
1124
1142
  arguments: CompletionsArguments
@@ -1,4 +1,5 @@
1
1
  import itertools
2
+ import operator
2
3
  import os
3
4
  import pathlib
4
5
  import re
@@ -35,6 +36,7 @@ from typing import (
35
36
  from robot.api.parsing import get_model
36
37
  from robot.errors import VariableError
37
38
  from robot.output import LOGGER
39
+ from robot.result import Keyword as ResultKeyword
38
40
  from robot.running import EXECUTION_CONTEXTS, Keyword, TestCase, TestSuite
39
41
  from robot.running.model import Try
40
42
  from robot.utils import Matcher as RobotMatcher
@@ -52,7 +54,10 @@ from .dap_types import (
52
54
  ContinuedEventBody,
53
55
  EvaluateArgumentContext,
54
56
  Event,
57
+ ExceptionBreakMode,
58
+ ExceptionDetails,
55
59
  ExceptionFilterOptions,
60
+ ExceptionInfoResponseBody,
56
61
  ExceptionOptions,
57
62
  OutputCategory,
58
63
  OutputEvent,
@@ -291,6 +296,30 @@ class HitCountEntry(NamedTuple):
291
296
  type: str
292
297
 
293
298
 
299
+ # A breakpoint's hit count is read as in debugpy: `N` or `== N` stops at the N-th hit, `>`, `>=`,
300
+ # `<` and `<=` compare the hits with N, and `% N` stops at every N-th hit.
301
+ _HIT_CONDITION_PATTERN = re.compile(r"^\s*(==|>=|<=|>|<|%)?\s*(\d+)\s*$")
302
+ _HIT_CONDITION_OPERATORS: Dict[str, Callable[[int, int], bool]] = {
303
+ "==": operator.eq,
304
+ ">": operator.gt,
305
+ ">=": operator.ge,
306
+ "<": operator.lt,
307
+ "<=": operator.le,
308
+ "%": lambda hits, value: hits % value == 0,
309
+ }
310
+
311
+
312
+ def hit_condition_met(hit_condition: str, hits: int) -> bool:
313
+ """Whether a breakpoint with `hit_condition` stops at its `hits`-th hit.
314
+
315
+ Raises `ValueError` for a hit condition that has none of the forms above.
316
+ """
317
+ match = _HIT_CONDITION_PATTERN.match(hit_condition)
318
+ if match is None or (match.group(1) == "%" and int(match.group(2)) == 0):
319
+ raise ValueError(f"'{hit_condition}' is not a valid hit count; use N, == N, > N, >= N, < N, <= N or % N.")
320
+ return _HIT_CONDITION_OPERATORS[match.group(1) or "=="](hits, int(match.group(2)))
321
+
322
+
294
323
  class PathMapping(NamedTuple):
295
324
  local_root: Optional[str]
296
325
  remote_root: Optional[str]
@@ -299,6 +328,21 @@ class PathMapping(NamedTuple):
299
328
  class DebugLoggerBase:
300
329
  def __init__(self) -> None:
301
330
  self.steps: List[Any] = []
331
+ self.last_fail_message: Optional[str] = None
332
+ self.last_fail_traceback: Optional[str] = None
333
+
334
+ def log_message(self, message: Any) -> None:
335
+ # Robot Framework logs a failure's Python traceback at DEBUG right after its message; unlike a listener, a
336
+ # registered logger gets it whatever the log level
337
+ if message.level == "FAIL":
338
+ self.last_fail_message = message.message
339
+ self.last_fail_traceback = None
340
+ elif (
341
+ message.level == "DEBUG"
342
+ and message.message.startswith("Traceback (most recent call last):")
343
+ and message.message.splitlines()[1:2] != [" None"]
344
+ ):
345
+ self.last_fail_traceback = message.message
302
346
 
303
347
 
304
348
  if RF_VERSION < (7, 0):
@@ -437,6 +481,7 @@ class Debugger:
437
481
  self._variables_cache: Dict[int, Any] = {}
438
482
  self._variables_object_cache: List[Any] = []
439
483
  self._current_exception: Optional[ExceptionInformation] = None
484
+ self._current_exception_info: Optional[ExceptionInfoResponseBody] = None
440
485
 
441
486
  @property
442
487
  def state(self) -> State:
@@ -658,7 +703,17 @@ class Debugger:
658
703
  if self.state == State.Stopped:
659
704
  return
660
705
 
661
- if self.requested_state == RequestedState.Pause:
706
+ if self.terminated:
707
+ # after a terminate request the run goes on to its end, so pending pause and step requests are dropped
708
+ if self.requested_state in (
709
+ RequestedState.Pause,
710
+ RequestedState.Next,
711
+ RequestedState.StepIn,
712
+ RequestedState.StepOut,
713
+ ):
714
+ self.requested_state = RequestedState.Nothing
715
+
716
+ elif self.requested_state == RequestedState.Pause:
662
717
  self.requested_state = RequestedState.Nothing
663
718
  self.state = State.Paused
664
719
 
@@ -723,6 +778,9 @@ class Debugger:
723
778
  source_path = self.map_path_to_client(str(Path(source).absolute()))
724
779
  if source_path in self.breakpoints:
725
780
  breakpoints = [v for v in self.breakpoints[source_path].breakpoints if v.line == line_no]
781
+ if self.terminated:
782
+ # after a terminate request only breakpoints that write a log message, and never pause, are left
783
+ breakpoints = [v for v in breakpoints if v.log_message]
726
784
  if len(breakpoints) > 0:
727
785
  for point in breakpoints:
728
786
  if point.condition is not None:
@@ -737,23 +795,44 @@ class Debugger:
737
795
  )
738
796
  except (SystemExit, KeyboardInterrupt):
739
797
  raise
740
- except BaseException:
798
+ except BaseException as e:
741
799
  hit = False
800
+ error = str(e) or e.__class__.__name__
801
+ self.send_event(
802
+ self,
803
+ OutputEvent(
804
+ body=OutputEventBody(
805
+ output=f"Breakpoint condition error: {error}{os.linesep}",
806
+ category=OutputCategory.STDERR,
807
+ source=Source(path=str(source_path)),
808
+ line=line_no,
809
+ )
810
+ ),
811
+ )
742
812
 
743
813
  if not hit:
744
814
  return
745
- if point.hit_condition is not None:
815
+ if point.hit_condition:
746
816
  hit = False
747
817
  entry = HitCountEntry(source_path, line_no, type)
748
818
  if entry not in self.hit_counts:
749
819
  self.hit_counts[entry] = 0
750
820
  self.hit_counts[entry] += 1
751
821
  try:
752
- hit = self.hit_counts[entry] != int(point.hit_condition)
753
- except (SystemExit, KeyboardInterrupt):
754
- raise
755
- except BaseException:
822
+ hit = hit_condition_met(point.hit_condition, self.hit_counts[entry])
823
+ except ValueError as e:
756
824
  hit = False
825
+ self.send_event(
826
+ self,
827
+ OutputEvent(
828
+ body=OutputEventBody(
829
+ output=f"Breakpoint hit count error: {e}{os.linesep}",
830
+ category=OutputCategory.STDERR,
831
+ source=Source(path=str(source_path)),
832
+ line=line_no,
833
+ )
834
+ ),
835
+ )
757
836
  if not hit:
758
837
  return
759
838
  if point.log_message:
@@ -798,27 +877,39 @@ class Debugger:
798
877
  filter_id: Set[str],
799
878
  description: str,
800
879
  text: Optional[str],
880
+ name: str,
881
+ message: Optional[str],
801
882
  ) -> None:
802
883
  if self.state == State.CallKeyword:
803
884
  return
804
885
  if self.state == State.Stopped:
805
886
  return
806
887
 
807
- if (
808
- not self.terminated
809
- and status == "FAIL"
810
- and any(
811
- v
812
- for v in self.exception_breakpoints
813
- if v.filter_options and any(o for o in v.filter_options if o.filter_id in filter_id)
814
- )
815
- ):
888
+ matched_filter_ids = {
889
+ o.filter_id
890
+ for v in self.exception_breakpoints
891
+ if v.filter_options
892
+ for o in v.filter_options
893
+ if o.filter_id in filter_id
894
+ }
895
+
896
+ if not self.terminated and status == "FAIL" and matched_filter_ids:
816
897
  reason = StoppedReason.EXCEPTION
817
898
 
818
899
  self.requested_state = RequestedState.Nothing
819
900
  self.state = State.Paused
820
901
 
821
902
  self._current_exception = {"text": text, "description": description, "status": status}
903
+ self._current_exception_info = ExceptionInfoResponseBody(
904
+ exception_id=name,
905
+ break_mode=(
906
+ ExceptionBreakMode.UNHANDLED
907
+ if "uncaught_failed_keyword" in matched_filter_ids
908
+ else ExceptionBreakMode.ALWAYS
909
+ ),
910
+ description=message,
911
+ details=self._create_exception_details(message),
912
+ )
822
913
 
823
914
  self.send_event(
824
915
  self,
@@ -840,8 +931,16 @@ class Debugger:
840
931
  lambda: (
841
932
  self.state in [State.Running, State.Stopped, State.CallKeyword]
842
933
  or self.requested_state != RequestedState.Nothing
934
+ or self.terminated
843
935
  )
844
936
  )
937
+ # a pause that began while a terminate request arrived ends at once, the run goes on to its end
938
+ if (
939
+ self.terminated
940
+ and self.state == State.Paused
941
+ and self.requested_state == RequestedState.Nothing
942
+ ):
943
+ self.requested_state = RequestedState.Running
845
944
 
846
945
  if self.state == State.CallKeyword:
847
946
  self._evaluated_keyword_result = None
@@ -876,6 +975,7 @@ class Debugger:
876
975
 
877
976
  break
878
977
  self._current_exception = None
978
+ self._current_exception_info = None
879
979
 
880
980
  def start_output_group(self, name: str, attributes: AttributeDict, type: Optional[str] = None) -> None:
881
981
  if self.group_output:
@@ -887,7 +987,7 @@ class Debugger:
887
987
  OutputEvent(
888
988
  body=OutputEventBody(
889
989
  output=f"\u001b[38;5;14m{(type + ' ') if type else ''}\u001b[0m{name}\n",
890
- category="OutputCategory.CONSOLE",
990
+ category=OutputCategory.CONSOLE,
891
991
  group=OutputGroup.START,
892
992
  source=Source(path=str(self.map_path_to_client(source))) if source else None,
893
993
  line=line_no if source is not None else None,
@@ -1018,7 +1118,7 @@ class Debugger:
1018
1118
  entry = self.add_stackframe_entry(name, type, source, line_no, longname=longname)
1019
1119
 
1020
1120
  if self.debug:
1021
- if self.stop_on_entry:
1121
+ if self.stop_on_entry and not self.terminated:
1022
1122
  self.stop_on_entry = False
1023
1123
 
1024
1124
  self.requested_state = RequestedState.Nothing
@@ -1057,6 +1157,8 @@ class Debugger:
1057
1157
  {"failed_suite"},
1058
1158
  "Suite failed.",
1059
1159
  f"Suite failed{f': {v}' if (v := attributes.get('message')) else ''}",
1160
+ name,
1161
+ attributes.get("message"),
1060
1162
  )
1061
1163
  self.wait_for_running()
1062
1164
 
@@ -1103,9 +1205,11 @@ class Debugger:
1103
1205
  if status == "FAIL":
1104
1206
  self.process_end_state(
1105
1207
  status,
1106
- {""},
1208
+ {"failed_test"},
1107
1209
  "Test failed.",
1108
1210
  f"Test failed{f': {v}' if (v := attributes.get('message')) else ''}",
1211
+ name,
1212
+ attributes.get("message"),
1109
1213
  )
1110
1214
 
1111
1215
  self.wait_for_running()
@@ -1300,6 +1404,8 @@ class Debugger:
1300
1404
  },
1301
1405
  "Keyword failed.",
1302
1406
  f"Keyword failed: {self.last_fail_message}" if self.last_fail_message else "Keyword failed.",
1407
+ name,
1408
+ self.last_fail_message,
1303
1409
  )
1304
1410
 
1305
1411
  self.wait_for_running()
@@ -1757,7 +1863,14 @@ class Debugger:
1757
1863
  if RF_VERSION >= (7, 0):
1758
1864
 
1759
1865
  def _run_keyword(self, kw: Keyword, context: Any) -> Any:
1760
- return kw.run(context.steps[-1][1], context)
1866
+ # An evaluated keyword becomes a child of the running step. At a stop
1867
+ # where no step runs, such as the end of a test or suite, it goes to the
1868
+ # test, and without a test to a detached result.
1869
+ if context.steps:
1870
+ parent = context.steps[-1][1]
1871
+ else:
1872
+ parent = context.test if context.test is not None else ResultKeyword()
1873
+ return kw.run(parent, context)
1761
1874
 
1762
1875
  else:
1763
1876
 
@@ -2062,7 +2175,7 @@ class Debugger:
2062
2175
 
2063
2176
  self._after_evaluate_keyword_event.set()
2064
2177
 
2065
- return result
2178
+ return result
2066
2179
 
2067
2180
  def _create_set_variable_result(self, value: Any) -> SetVariableResult:
2068
2181
  if isinstance(value, Mapping):
@@ -2135,7 +2248,7 @@ class Debugger:
2135
2248
  if option.filter_id in [
2136
2249
  "failed_keyword",
2137
2250
  "uncaught_failed_keyword",
2138
- "",
2251
+ "failed_test",
2139
2252
  "failed_suite",
2140
2253
  ]:
2141
2254
  entry = ExceptionBreakpointsEntry(
@@ -2151,6 +2264,38 @@ class Debugger:
2151
2264
 
2152
2265
  return result or None
2153
2266
 
2267
+ def _create_exception_details(self, message: Optional[str]) -> Optional[ExceptionDetails]:
2268
+ rows: List[Tuple[str, Optional[str]]] = []
2269
+ # outermost first, as Python lists calls, so that a traceback below continues the list into the library
2270
+ for entry in reversed(self.stack_frames):
2271
+ if entry.type == "SUITE":
2272
+ continue
2273
+ position = entry.stack_frames[0] if entry.stack_frames else None if entry.top_hidden else entry
2274
+ if position is None:
2275
+ continue
2276
+ source = self.source_from_entry(position)
2277
+ location = None
2278
+ if source is not None and source.path:
2279
+ location = source.path if position.line is None else f"{source.path}:{position.line}"
2280
+ rows.append((entry.longname or entry.kwname or entry.name or entry.type, location))
2281
+
2282
+ width = max((len(name) for name, _ in rows), default=0)
2283
+ stack_trace = "\n".join(f"{name.ljust(width)} {location}" if location else name for name, location in rows)
2284
+
2285
+ logger = self.debug_logger
2286
+ if message and logger is not None and logger.last_fail_message == message and logger.last_fail_traceback:
2287
+ stack_trace = "\n\n".join(part for part in (stack_trace, logger.last_fail_traceback) if part)
2288
+
2289
+ return ExceptionDetails(stack_trace=stack_trace) if stack_trace else None
2290
+
2291
+ def get_exception_info(self, thread_id: int) -> ExceptionInfoResponseBody:
2292
+ self.check_thread_id(thread_id)
2293
+
2294
+ if self._current_exception_info is None:
2295
+ raise RuntimeError("The run is not stopped at a failure.")
2296
+
2297
+ return self._current_exception_info
2298
+
2154
2299
  if RF_VERSION >= (7, 0):
2155
2300
 
2156
2301
  def _get_keywords_from_lib(self, lib: Any) -> Any:
@@ -28,21 +28,22 @@ DFEAULT_CAPABILITIES = Capabilities(
28
28
  ),
29
29
  ExceptionBreakpointsFilter(
30
30
  filter="failed_test",
31
- label="Failed Test",
31
+ label="Failed Tests",
32
32
  description="Breaks on failed tests",
33
33
  default=False,
34
34
  supports_condition=True,
35
35
  ),
36
36
  ExceptionBreakpointsFilter(
37
37
  filter="failed_suite",
38
- label="Failed Suite",
39
- description="Breaks on failed suite",
38
+ label="Failed Suites",
39
+ description="Breaks on failed suites",
40
40
  default=False,
41
41
  supports_condition=True,
42
42
  ),
43
43
  ],
44
44
  supports_exception_options=True,
45
45
  supports_exception_filter_options=True,
46
+ supports_exception_info_request=True,
46
47
  supports_completions_request=True,
47
48
  supports_a_n_s_i_styling=True,
48
49
  )
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import itertools
5
5
  import os
6
+ import shlex
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from typing import Any, Dict, List, Literal, Optional
@@ -43,6 +44,32 @@ from ..protocol import DebugAdapterProtocol
43
44
  from .client import DAPClient, DAPClientError
44
45
 
45
46
 
47
+ def _build_robotcode_run_args(
48
+ python: str,
49
+ debugger_script: List[str],
50
+ profiles: Optional[List[str]],
51
+ paths: Optional[List[str]],
52
+ wrapper: Optional[List[str]],
53
+ robot_code_args: Optional[List[str]],
54
+ ) -> List[str]:
55
+ """Build the ``robotcode … debug`` command line.
56
+
57
+ A ``wrapper`` from the launch request (VS Code ``robotcode.debug.launchWrapper``)
58
+ is forwarded to robotcode as ``--wrapper``; robotcode applies it itself (and
59
+ ``--wrapper`` overrides the profile's ``wrapper``). Without one nothing is added
60
+ and robotcode resolves its profile ``wrapper`` alone.
61
+ """
62
+ return [
63
+ python,
64
+ *debugger_script,
65
+ *itertools.chain.from_iterable(["-p", p] for p in profiles or []),
66
+ *itertools.chain.from_iterable(["-dp", p] for p in paths or []),
67
+ *(["--wrapper", shlex.join(wrapper)] if wrapper else []),
68
+ *(robot_code_args or []),
69
+ "debug",
70
+ ]
71
+
72
+
46
73
  class OutputProtocol(asyncio.SubprocessProtocol):
47
74
  def __init__(self, parent: LauncherDebugAdapterProtocol) -> None:
48
75
  super().__init__()
@@ -133,6 +160,7 @@ class LauncherDebugAdapterProtocol(DebugAdapterProtocol):
133
160
  include: Optional[List[str]] = None,
134
161
  exclude: Optional[List[str]] = None,
135
162
  robotCodeArgs: Optional[List[str]] = None, # noqa: N803
163
+ wrapper: Optional[List[str]] = None,
136
164
  arguments: Optional[LaunchRequestArguments] = None,
137
165
  *_args: Any,
138
166
  **_kwargs: Any,
@@ -145,14 +173,9 @@ class LauncherDebugAdapterProtocol(DebugAdapterProtocol):
145
173
 
146
174
  debugger_script = ["-m", "robotcode.cli"] if self.debugger_script is None else [str(Path(self.debugger_script))]
147
175
 
148
- robotcode_run_args = [
149
- python or sys.executable,
150
- *debugger_script,
151
- *itertools.chain.from_iterable(["-p", p] for p in profiles or []),
152
- *itertools.chain.from_iterable(["-dp", p] for p in paths or []),
153
- *(robotCodeArgs or []),
154
- "debug",
155
- ]
176
+ robotcode_run_args = _build_robotcode_run_args(
177
+ python or sys.executable, debugger_script, profiles, paths, wrapper, robotCodeArgs
178
+ )
156
179
 
157
180
  if no_debug:
158
181
  robotcode_run_args += ["--no-debug"]
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from dataclasses import dataclass
3
3
  from pathlib import Path
4
- from typing import Any, Dict, Iterator, List, Optional, Union, cast
4
+ from typing import Any, Dict, Iterator, List, Optional, Set, Union, cast
5
5
 
6
6
  from robot import result, running
7
7
  from robot.model import Message
@@ -13,6 +13,14 @@ from .debugger import Debugger, LogMessage
13
13
  from .mixins import SyncedEventBody
14
14
 
15
15
 
16
+ @dataclass
17
+ class RobotFailure(Model):
18
+ # indices into failed_keywords: the failed keyword whose message and caught mark are the failure's, and the failed
19
+ # keywords from the innermost call out to the test
20
+ keyword: int
21
+ calls: List[int]
22
+
23
+
16
24
  @dataclass
17
25
  class RobotExecutionEventBody(Model, SyncedEventBody):
18
26
  type: str
@@ -21,6 +29,8 @@ class RobotExecutionEventBody(Model, SyncedEventBody):
21
29
  parent_id: Optional[str] = None
22
30
  attributes: Optional[Dict[str, Any]] = None
23
31
  failed_keywords: Optional[List[Dict[str, Any]]] = None
32
+ # in the order their keywords ended
33
+ failures: Optional[List[RobotFailure]] = None
24
34
  source: Optional[str] = None
25
35
  lineno: Optional[int] = None
26
36
  synced: bool = True
@@ -61,6 +71,17 @@ class ListenerV2:
61
71
 
62
72
  def __init__(self) -> None:
63
73
  self.failed_keywords: Optional[List[Dict[str, Any]]] = None
74
+ # the number of failed keywords when each running keyword or control structure started; per depth, the failed
75
+ # keywords of the last TRY branch there, and those of them that its EXCEPT branch caught
76
+ self.failed_keyword_marks: List[int] = []
77
+ # by the id of a failed keyword, the nearest failed keyword it ran in
78
+ self.failed_keyword_parents: Dict[int, Dict[str, Any]] = {}
79
+ # per running keyword or control structure, whether it logged a failure itself, as Robot Framework does for the
80
+ # keyword whose error it is; and the ids of the failed keywords that did
81
+ self.failure_logged: List[bool] = []
82
+ self.failed_keywords_with_own_failure: Set[int] = set()
83
+ self.try_failures: Dict[int, List[Dict[str, Any]]] = {}
84
+ self.caught_by_except: Dict[int, List[Dict[str, Any]]] = {}
64
85
  self.last_fail_message: Optional[str] = None
65
86
  self.suite_id_stack: List[str] = []
66
87
 
@@ -105,16 +126,17 @@ class ListenerV2:
105
126
  id=id,
106
127
  parent_id=self.suite_id_stack[-1] if self.suite_id_stack else None,
107
128
  failed_keywords=self.failed_keywords,
129
+ failures=self._failures(),
108
130
  source=source_from_attributes(attributes) or None,
109
131
  lineno=attributes.get("lineno", None),
110
132
  ),
111
133
  ),
112
134
  )
113
135
  self.suite_id_stack.pop()
114
- self.failed_keywords = None
136
+ self._forget_failed_keywords()
115
137
 
116
138
  def start_test(self, name: str, attributes: Dict[str, Any]) -> None:
117
- self.failed_keywords = None
139
+ self._forget_failed_keywords()
118
140
 
119
141
  Debugger.instance.send_event(
120
142
  self,
@@ -154,15 +176,85 @@ class ListenerV2:
154
176
  parent_id=self.suite_id_stack[-1] if self.suite_id_stack else None,
155
177
  attributes=dict(attributes),
156
178
  failed_keywords=self.failed_keywords,
179
+ failures=self._failures(),
157
180
  source=source_from_attributes(attributes) or None,
158
181
  lineno=attributes.get("lineno", None),
159
182
  ),
160
183
  ),
161
184
  )
162
185
 
186
+ self._forget_failed_keywords()
187
+
188
+ def _forget_failed_keywords(self) -> None:
163
189
  self.failed_keywords = None
190
+ self.failed_keyword_parents = {}
191
+ self.failed_keywords_with_own_failure = set()
192
+
193
+ def _failures(self) -> Optional[List[RobotFailure]]:
194
+ keywords = self.failed_keywords
195
+ if not keywords:
196
+ return None
197
+
198
+ index = {id(keyword): i for i, keyword in enumerate(keywords)}
199
+ parents = self.failed_keyword_parents
200
+
201
+ # the failed keywords inside a keyword are those it is the parent of that are caught, or not caught, like it; a
202
+ # caught one inside a keyword that is not caught was caught within it and stays a failure of its own
203
+ def is_inside(keyword: Dict[str, Any], parent: Optional[Dict[str, Any]]) -> bool:
204
+ return parent is not None and bool(parent.get("caught")) == bool(keyword.get("caught"))
205
+
206
+ inside: Dict[int, List[Dict[str, Any]]] = {}
207
+ for keyword in keywords:
208
+ parent = parents.get(id(keyword))
209
+ if parent is not None and is_inside(keyword, parent):
210
+ inside.setdefault(id(parent), []).append(keyword)
211
+
212
+ # a keyword that logged a failure of its own after failures inside it turned theirs into its own, as Wait Until
213
+ # Keyword Succeeds does after its last attempt; one that passes them on or collects them logs none
214
+ def turns_into_own(keyword: Dict[str, Any]) -> bool:
215
+ return bool(inside.get(id(keyword))) and id(keyword) in self.failed_keywords_with_own_failure
216
+
217
+ def within(keyword: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
218
+ current, parent = keyword, parents.get(id(keyword))
219
+ while parent is not None and is_inside(current, parent):
220
+ yield parent
221
+ current, parent = parent, parents.get(id(parent))
222
+
223
+ def outwards(keyword: Dict[str, Any]) -> List[int]:
224
+ calls = []
225
+ current: Optional[Dict[str, Any]] = keyword
226
+ while current is not None:
227
+ calls.append(index[id(current)])
228
+ current = parents.get(id(current))
229
+ return calls
230
+
231
+ # the calls of the newest failure inside the keyword, up to it
232
+ def last_inside(keyword: Dict[str, Any]) -> List[int]:
233
+ for candidate in keywords:
234
+ if inside.get(id(candidate)):
235
+ continue
236
+ calls = [index[id(candidate)]]
237
+ for outer in within(candidate):
238
+ if outer is keyword:
239
+ return calls
240
+ calls.append(index[id(outer)])
241
+ return []
242
+
243
+ failures = []
244
+ for keyword in reversed(keywords):
245
+ if any(turns_into_own(outer) for outer in within(keyword)):
246
+ continue
247
+ if turns_into_own(keyword):
248
+ failures.append(RobotFailure(index[id(keyword)], last_inside(keyword) + outwards(keyword)))
249
+ elif not inside.get(id(keyword)):
250
+ failures.append(RobotFailure(index[id(keyword)], outwards(keyword)))
251
+
252
+ return failures
164
253
 
165
254
  def start_keyword(self, name: str, attributes: Dict[str, Any]) -> None:
255
+ self.failed_keyword_marks.append(len(self.failed_keywords or []))
256
+ self.failure_logged.append(False)
257
+
166
258
  if attributes["type"] in ["KEYWORD", "SETUP", "TEARDOWN"]:
167
259
  Debugger.instance.start_output_group(
168
260
  f"{name}({', '.join(repr(v) for v in attributes.get('args', []))})",
@@ -191,6 +283,8 @@ class ListenerV2:
191
283
 
192
284
  def end_keyword(self, name: str, attributes: Dict[str, Any]) -> None:
193
285
  Debugger.instance.end_keyword(name, attributes)
286
+ mark = self.failed_keyword_marks.pop() if self.failed_keyword_marks else 0
287
+ failure_logged = self.failure_logged.pop() if self.failure_logged else False
194
288
 
195
289
  # if Debugger.instance.state != State.CallKeyword:
196
290
  # Debugger.instance.send_event(
@@ -216,7 +310,38 @@ class ListenerV2:
216
310
  if self.failed_keywords is None:
217
311
  self.failed_keywords = []
218
312
 
219
- self.failed_keywords.insert(0, {"message": self.last_fail_message, **attributes})
313
+ keyword = {"message": self.last_fail_message, **attributes}
314
+ # it is the nearest failed keyword around those that ended since it started and have none yet
315
+ for inner in self.failed_keywords[: max(0, len(self.failed_keywords) - mark)]:
316
+ self.failed_keyword_parents.setdefault(id(inner), keyword)
317
+ self.failed_keywords.insert(0, keyword)
318
+ if failure_logged:
319
+ self.failed_keywords_with_own_failure.add(id(keyword))
320
+
321
+ self._mark_caught_failures(attributes, mark, len(self.failed_keyword_marks))
322
+
323
+ def _mark_caught_failures(self, attributes: Dict[str, Any], mark: int, depth: int) -> None:
324
+ # Robot Framework's log expands a failed test only along what failed, so a failure inside a keyword or control
325
+ # structure that did not fail was caught, as by Run Keyword And Ignore Error. Listener version 2 gets the
326
+ # branches of a TRY/EXCEPT but not the TRY/EXCEPT, whose status the log follows: an EXCEPT branch that passed
327
+ # caught the failures of its TRY branch, unless the FINALLY branch fails and with it the TRY/EXCEPT
328
+ kind, status = attributes["type"], attributes["status"]
329
+ failed = self.failed_keywords or []
330
+ inside = failed[: max(0, len(failed) - mark)]
331
+ if status != "FAIL":
332
+ for keyword in inside:
333
+ keyword["caught"] = True
334
+ if kind == "TRY":
335
+ self.try_failures[depth] = inside
336
+ self.caught_by_except[depth] = []
337
+ elif kind == "EXCEPT" and status == "PASS":
338
+ caught = [keyword for keyword in self.try_failures.get(depth, []) if not keyword.get("caught")]
339
+ for keyword in caught:
340
+ keyword["caught"] = True
341
+ self.caught_by_except[depth] = caught
342
+ elif kind == "FINALLY" and status == "FAIL":
343
+ for keyword in self.caught_by_except.pop(depth, []):
344
+ keyword.pop("caught", None)
220
345
 
221
346
  RE_FILE_LINE_MATCHER = re.compile(r".+\sin\sfile\s'(?P<file>.*)'\son\sline\s(?P<line>\d+):(?P<message>.*)")
222
347
 
@@ -227,6 +352,8 @@ class ListenerV2:
227
352
  if message["level"] == "FAIL":
228
353
  self.last_fail_message = message["message"]
229
354
  Debugger.instance.last_fail_message = self.last_fail_message
355
+ if self.failure_logged:
356
+ self.failure_logged[-1] = True
230
357
 
231
358
  source = current_frame.source if current_frame else None
232
359
  line = current_frame.line if current_frame else None
@@ -404,7 +531,8 @@ class ListenerV3:
404
531
  "lineno": data_item.lineno if data_item is not None else 0,
405
532
  "message": result_item.message,
406
533
  },
407
- id=f"{result_item.source or ''};{result_item.longname or ''}"
534
+ id=f"{normalized_path(Path(result_item.source)) if result_item.source else ''};"
535
+ f"{result_item.longname or ''}"
408
536
  + (
409
537
  f";{data_item.lineno if data_item is not None else 0}"
410
538
  if isinstance(result_item, result.TestCase)
@@ -145,6 +145,27 @@ def start_debugpy(
145
145
  config_done_callback = connect_debugpy
146
146
 
147
147
 
148
+ def _run_robot(ctx: click.Context, app: Application, args: List[str]) -> int:
149
+ from robotcode.runner.cli.robot import robot
150
+
151
+ args = [
152
+ "--listener",
153
+ "robotcode.debugger.listeners.ListenerV3",
154
+ "--listener",
155
+ "robotcode.debugger.listeners.ListenerV2",
156
+ *args,
157
+ ]
158
+
159
+ app.verbose("Start robot")
160
+ try:
161
+ app.verbose(f"Create robot context with args: {args}")
162
+ robot_ctx = robot.make_context("robot", args, parent=ctx)
163
+ robot.invoke(robot_ctx)
164
+ except SystemExit as e:
165
+ return cast(int, e.code)
166
+ return 0
167
+
168
+
148
169
  @_logger.call
149
170
  def run_debugger(
150
171
  ctx: click.Context,
@@ -167,6 +188,11 @@ def run_debugger(
167
188
  output_timestamps: bool = False,
168
189
  group_output: bool = False,
169
190
  ) -> int:
191
+ if app.config.dry:
192
+ # A dry run starts neither debugpy nor the debug adapter and waits for no
193
+ # client; the robot command only prints what it would execute.
194
+ return _run_robot(ctx, app, args)
195
+
170
196
  if debug and debugpy and not is_debugpy_installed():
171
197
  app.warning("Debugpy not installed")
172
198
 
@@ -215,14 +241,6 @@ def run_debugger(
215
241
  if debugpy and debugpy_wait_for_client:
216
242
  debugpy_connected.wait(wait_for_client_timeout)
217
243
 
218
- args = [
219
- "--listener",
220
- "robotcode.debugger.listeners.ListenerV3",
221
- "--listener",
222
- "robotcode.debugger.listeners.ListenerV2",
223
- *args,
224
- ]
225
-
226
244
  Debugger.instance.stop_on_entry = stop_on_entry
227
245
  Debugger.instance.output_messages = output_messages
228
246
  Debugger.instance.output_log = output_log
@@ -237,15 +255,7 @@ def run_debugger(
237
255
 
238
256
  exit_code = 0
239
257
  try:
240
- from robotcode.runner.cli.robot import robot
241
-
242
- app.verbose("Start robot")
243
- try:
244
- app.verbose(f"Create robot context with args: {args}")
245
- robot_ctx = robot.make_context("robot", args, parent=ctx)
246
- robot.invoke(robot_ctx)
247
- except SystemExit as e:
248
- exit_code = cast(int, e.code)
258
+ exit_code = _run_robot(ctx, app, args)
249
259
  finally:
250
260
  if server.protocol.connected:
251
261
  server.protocol.send_event(
@@ -23,6 +23,8 @@ from .dap_types import (
23
23
  EvaluateArguments,
24
24
  EvaluateResponseBody,
25
25
  Event,
26
+ ExceptionInfoArguments,
27
+ ExceptionInfoResponseBody,
26
28
  ExitedEvent,
27
29
  ExitedEventBody,
28
30
  InitializedEvent,
@@ -384,6 +386,15 @@ class DebugAdapterServerProtocol(DebugAdapterProtocol):
384
386
  )
385
387
  return SetExceptionBreakpointsResponseBody(breakpoints=result) if result else None
386
388
 
389
+ @rpc_method(name="exceptionInfo", param_type=ExceptionInfoArguments)
390
+ async def _exception_info(
391
+ self,
392
+ arguments: ExceptionInfoArguments,
393
+ *args: Any,
394
+ **kwargs: Any,
395
+ ) -> ExceptionInfoResponseBody:
396
+ return Debugger.instance.get_exception_info(arguments.thread_id)
397
+
387
398
  @rpc_method(name="completions", param_type=CompletionsArguments)
388
399
  async def _completions(
389
400
  self,
@@ -1 +0,0 @@
1
- __version__ = "2.6.2"