robotcode-runner 2.7.0__py3-none-any.whl → 2.8.0.dev548__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.
@@ -1 +1 @@
1
- __version__ = "2.7.0"
1
+ __version__ = "2.8.0-dev.548"
@@ -294,6 +294,19 @@ def highlight_md(text: str, highlight: Optional[Callable[[str], str]]) -> str:
294
294
  return highlight(text) if highlight else text
295
295
 
296
296
 
297
+ def metadata_md(metadata: Dict[str, str], highlight: Optional[Callable[[str], str]] = None) -> str:
298
+ """Test metadata as `name: value, name: value`.
299
+
300
+ All pairs share one line, so the lines of a multi-line value are joined
301
+ with spaces.
302
+ """
303
+
304
+ def hi(text: str) -> str:
305
+ return highlight_md(md_escape(text), highlight)
306
+
307
+ return ", ".join(f"{hi(name)}: {hi(' '.join(value.splitlines()))}" for name, value in metadata.items())
308
+
309
+
297
310
  def make_md_highlighter(search_substring: Optional[str], search_regex: Optional[str]) -> Optional[Callable[[str], str]]:
298
311
  """Build a `text -> highlighted text` callback that wraps each
299
312
  regex/substring match in an inline-code span. Returns `None` when
@@ -0,0 +1,28 @@
1
+ """Click options and commands that RobotCode offers only from a certain
2
+ Robot Framework version on.
3
+
4
+ The version is declared once, with `since`: before it, `--help` hides the
5
+ option or command; `scripts/create_cmdline_doc.py` marks it with the version
6
+ in the generated CLI reference. `--help` itself carries no note, since it only
7
+ lists the option where the installed version has it.
8
+ """
9
+
10
+ from typing import Any, Tuple
11
+
12
+ import click
13
+
14
+ from robotcode.robot.utils import RF_VERSION
15
+
16
+
17
+ class RobotVersionOption(click.Option):
18
+ def __init__(self, *args: Any, since: Tuple[int, int], **kwargs: Any) -> None:
19
+ kwargs["hidden"] = RF_VERSION < since
20
+ super().__init__(*args, **kwargs)
21
+ self.since = since
22
+
23
+
24
+ class RobotVersionCommand(click.Command):
25
+ def __init__(self, *args: Any, since: Tuple[int, int], **kwargs: Any) -> None:
26
+ kwargs["hidden"] = RF_VERSION < since
27
+ super().__init__(*args, **kwargs)
28
+ self.since = since
@@ -12,7 +12,7 @@ The `--search` / `--search-regex` semantics live here so both `results` and
12
12
 
13
13
  import re
14
14
  from dataclasses import dataclass
15
- from typing import TYPE_CHECKING, Callable, Iterable, Optional
15
+ from typing import TYPE_CHECKING, Callable, Dict, Iterable, Mapping, Optional
16
16
 
17
17
  import click
18
18
  from robot.api import SuiteVisitor
@@ -21,6 +21,7 @@ from robot.model import TestCase as ModelTestCase
21
21
  from robot.model.control import For, IfBranch, Return, TryBranch, While
22
22
  from robot.model.message import Message
23
23
  from robot.result import Keyword as ResultKeyword
24
+ from robot.result import TestCase as ResultTestCase
24
25
  from robot.result.model import StatusMixin
25
26
  from robot.running import TestCase as RunningTestCase
26
27
  from robot.running import TestSuite
@@ -51,6 +52,34 @@ else:
51
52
  return for_.variables # type: ignore[attr-defined,no-any-return,unused-ignore]
52
53
 
53
54
 
55
+ # RF 7.5 added `[Metadata]` to tests and tasks; `TestCase.metadata` does not
56
+ # exist before.
57
+ SUPPORTS_TEST_METADATA = RF_VERSION >= (7, 5)
58
+
59
+
60
+ def named_metadata(metadata: Mapping[str, str]) -> Dict[str, str]:
61
+ """The entries of a suite's or test's metadata that have a name.
62
+
63
+ A `Metadata` setting with nothing after it gives an entry with an empty
64
+ name and an empty value in Robot's model. That is no metadata: Robot
65
+ itself leaves it out of `output.xml` (but not out of `output.json`).
66
+ """
67
+ return {name: value for name, value in metadata.items() if name}
68
+
69
+
70
+ # Shared by `discover` and `results` so both emit the same name → value
71
+ # mapping (author's spelling, RF's order).
72
+ if SUPPORTS_TEST_METADATA:
73
+
74
+ def get_test_metadata(test: ModelTestCase) -> Dict[str, str]:
75
+ return named_metadata(test.metadata) # type: ignore[attr-defined,unused-ignore]
76
+
77
+ else:
78
+
79
+ def get_test_metadata(test: ModelTestCase) -> Dict[str, str]:
80
+ return {}
81
+
82
+
54
83
  @dataclass(frozen=True)
55
84
  class SearchMatcher:
56
85
  general: Callable[[Optional[str]], bool]
@@ -296,7 +325,9 @@ class SearchModifier(SuiteVisitor):
296
325
  A test matches if any of the following matches the predicate:
297
326
  - name, full name, source path
298
327
  - documentation, template name (running model only), timeout setting
328
+ - the test's own message, e.g. its failure message (result-tree only)
299
329
  - any of its tags (with Robot's tag normalisation)
330
+ - any of its `[Metadata]` names or values (Robot Framework 7.5+)
300
331
  - any keyword name, keyword argument, assigned variable, FOR/WHILE
301
332
  condition or VAR/RETURN/EXCEPT/GROUP element inside the test body,
302
333
  setup or teardown
@@ -328,10 +359,15 @@ class SearchModifier(SuiteVisitor):
328
359
  # don't preserve it, so guard with isinstance.
329
360
  if isinstance(test, RunningTestCase) and self.matcher.general(test.template):
330
361
  return True
362
+ if isinstance(test, ResultTestCase) and self.matcher.general(test.message):
363
+ return True
331
364
  if test.timeout is not None and self.matcher.general(str(test.timeout)):
332
365
  return True
333
366
  if any(self.matcher.tag(str(t)) for t in test.tags):
334
367
  return True
368
+ for name, value in get_test_metadata(test).items():
369
+ if self.matcher.general(name) or self.matcher.general(value):
370
+ return True
335
371
  if test.has_setup and self.matcher.matches_body([test.setup]):
336
372
  return True
337
373
  if test.has_teardown and self.matcher.matches_body([test.teardown]):
@@ -1,7 +1,7 @@
1
1
  """Data models emitted by `robotcode discover`.
2
2
 
3
3
  All models inherit from `CamelSnakeMixin` so the JSON output uses
4
- camelCase keys (`fullName`, `relSource`, `supportsParseInclude`, …)
4
+ camelCase keys (`relSource`, `supportsParseInclude`, `robotVersionString`, …)
5
5
  consistent with the `results` family. Editor integrations and CI
6
6
  recipes rely on that shape.
7
7
  """
@@ -27,6 +27,7 @@ class TestItem(CamelSnakeMixin):
27
27
  description: Optional[str] = None
28
28
  range: Optional[Range] = None
29
29
  tags: Optional[List[str]] = None
30
+ metadata: Optional[Dict[str, str]] = None
30
31
  error: Optional[str] = None
31
32
  rpa: Optional[bool] = None
32
33
 
@@ -54,6 +55,15 @@ class TagsResult(CamelSnakeMixin):
54
55
  filters_applied: Optional[Dict[str, str]] = None
55
56
 
56
57
 
58
+ @dataclass
59
+ class MetadataResult(CamelSnakeMixin):
60
+ """`discover metadata`: name → value → items, for tests/tasks and for suites."""
61
+
62
+ metadata: Dict[str, Dict[str, List[TestItem]]]
63
+ suite_metadata: Dict[str, Dict[str, List[TestItem]]]
64
+ filters_applied: Optional[Dict[str, str]] = None
65
+
66
+
57
67
  @dataclass
58
68
  class Info(CamelSnakeMixin):
59
69
  robot_version_string: str
@@ -25,6 +25,7 @@ from .._markdown import (
25
25
  filters_footer_md,
26
26
  highlight_md,
27
27
  md_escape,
28
+ metadata_md,
28
29
  path_paren,
29
30
  )
30
31
  from ._models import Info, Statistics, TestItem
@@ -301,6 +302,7 @@ def render_tests_or_tasks(
301
302
  *,
302
303
  selected_type: str,
303
304
  show_tags: bool,
305
+ show_metadata: bool = False,
304
306
  full_paths: bool,
305
307
  highlight: Optional[Callable[[str], str]] = None,
306
308
  search_substring: Optional[str] = None,
@@ -325,6 +327,8 @@ def render_tests_or_tasks(
325
327
  if show_tags and item.tags:
326
328
  tags = ", ".join(f"`{_hi(str(tag), highlight)}`" for tag in sorted(item.tags))
327
329
  lines.append(f" - _Tags:_ {tags}")
330
+ if show_metadata and item.metadata:
331
+ lines.append(f" - _Metadata:_ {metadata_md(item.metadata, highlight)}")
328
332
  body_md = "\n".join(lines)
329
333
 
330
334
  return _block_md(
@@ -391,6 +395,88 @@ def render_tags(
391
395
  )
392
396
 
393
397
 
398
+ # ---------------------------------------------------------------------------
399
+ # `discover metadata`
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def _metadata_section_md(
404
+ index: Dict[str, Dict[str, List[TestItem]]],
405
+ *,
406
+ show_values: bool,
407
+ show_item: Callable[[TestItem], bool],
408
+ full_paths: bool,
409
+ highlight: Optional[Callable[[str], str]],
410
+ ) -> List[str]:
411
+ def item_md(item: TestItem) -> str:
412
+ paren = _item_paren(item, full_paths=full_paths, with_line=item.type != "suite")
413
+ return f"**{_hi(item.longname, highlight)}**{paren}"
414
+
415
+ lines: List[str] = []
416
+ for name, values in index.items():
417
+ lines.append(f"- **{_hi(name, highlight)}**")
418
+ if show_values:
419
+ for value, items in values.items():
420
+ lines.append(f" - {_hi(value, highlight) if value else '_(no value)_'}")
421
+ lines.extend(f" - {item_md(item)}" for item in items if show_item(item))
422
+ else:
423
+ # Each item once under its name, even when it has several values.
424
+ listed: List[TestItem] = []
425
+ for items in values.values():
426
+ listed.extend(item for item in items if show_item(item) and all(item is not i for i in listed))
427
+ lines.extend(f" - {item_md(item)}" for item in listed)
428
+ return lines
429
+
430
+
431
+ def render_metadata(
432
+ test_metadata: Dict[str, Dict[str, List[TestItem]]],
433
+ suite_metadata: Dict[str, Dict[str, List[TestItem]]],
434
+ statistics: Statistics,
435
+ *,
436
+ show_values: bool,
437
+ show_tests: bool,
438
+ show_tasks: bool,
439
+ show_suites: bool,
440
+ full_paths: bool,
441
+ highlight: Optional[Callable[[str], str]] = None,
442
+ search_substring: Optional[str] = None,
443
+ search_regex: Optional[str] = None,
444
+ diagnostics: Optional[Dict[str, List[Diagnostic]]] = None,
445
+ show_diagnostics: bool = False,
446
+ root_folder: Optional[Path] = None,
447
+ ) -> str:
448
+ def show_test_or_task(item: TestItem) -> bool:
449
+ if show_tests == show_tasks:
450
+ return show_tests
451
+ return item.type == ("test" if show_tests else "task")
452
+
453
+ parts: List[str] = []
454
+ for heading, index, show_item in (
455
+ ("Tests and Tasks", test_metadata, show_test_or_task),
456
+ ("Suites", suite_metadata, lambda _item: show_suites),
457
+ ):
458
+ if not index:
459
+ continue
460
+ if parts:
461
+ parts.append("")
462
+ parts += [f"## {heading}", ""]
463
+ parts += _metadata_section_md(
464
+ index, show_values=show_values, show_item=show_item, full_paths=full_paths, highlight=highlight
465
+ )
466
+
467
+ return _block_md(
468
+ heading="Metadata",
469
+ body_md="\n".join(parts) if parts else "_(no metadata matched)_",
470
+ statistics=statistics,
471
+ diagnostics=diagnostics,
472
+ show_diagnostics=show_diagnostics,
473
+ full_paths=full_paths,
474
+ root_folder=root_folder,
475
+ search_substring=search_substring,
476
+ search_regex=search_regex,
477
+ )
478
+
479
+
394
480
  # ---------------------------------------------------------------------------
395
481
  # `discover all` — workspace → suites → tests/tasks tree as nested list
396
482
  # ---------------------------------------------------------------------------
@@ -401,6 +487,7 @@ def render_all(
401
487
  statistics: Statistics,
402
488
  *,
403
489
  show_tags: bool,
490
+ show_metadata: bool = False,
404
491
  full_paths: bool,
405
492
  highlight: Optional[Callable[[str], str]] = None,
406
493
  search_substring: Optional[str] = None,
@@ -412,7 +499,15 @@ def render_all(
412
499
  """The full discovery tree: workspace at depth 0, suites at
413
500
  increasing depth, tests/tasks as leaves with their line number."""
414
501
  tree_lines: List[str] = []
415
- _emit_tree(root, tree_lines, depth=0, show_tags=show_tags, full_paths=full_paths, highlight=highlight)
502
+ _emit_tree(
503
+ root,
504
+ tree_lines,
505
+ depth=0,
506
+ show_tags=show_tags,
507
+ show_metadata=show_metadata,
508
+ full_paths=full_paths,
509
+ highlight=highlight,
510
+ )
416
511
  return _block_md(
417
512
  heading="All",
418
513
  body_md="\n".join(tree_lines),
@@ -432,6 +527,7 @@ def _emit_tree(
432
527
  *,
433
528
  depth: int,
434
529
  show_tags: bool,
530
+ show_metadata: bool,
435
531
  full_paths: bool,
436
532
  highlight: Optional[Callable[[str], str]] = None,
437
533
  ) -> None:
@@ -443,5 +539,15 @@ def _emit_tree(
443
539
  if is_leaf and show_tags and item.tags:
444
540
  tags = ", ".join(f"`{_hi(str(tag), highlight)}`" for tag in sorted(item.tags))
445
541
  out.append(f"{indent} - _Tags:_ {tags}")
542
+ if is_leaf and show_metadata and item.metadata:
543
+ out.append(f"{indent} - _Metadata:_ {metadata_md(item.metadata, highlight)}")
446
544
  for child in item.children or []:
447
- _emit_tree(child, out, depth=depth + 1, show_tags=show_tags, full_paths=full_paths, highlight=highlight)
545
+ _emit_tree(
546
+ child,
547
+ out,
548
+ depth=depth + 1,
549
+ show_tags=show_tags,
550
+ show_metadata=show_metadata,
551
+ full_paths=full_paths,
552
+ highlight=highlight,
553
+ )