robotcode-modifiers 2.7.0__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.
@@ -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-modifiers
3
- Version: 2.7.0
3
+ Version: 2.8.0-dev.548
4
4
  Summary: Some Robot Framework Modifiers for RobotCode
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -20,6 +20,7 @@ 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
@@ -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",
@@ -0,0 +1,4 @@
1
+ from .longname_modifiers import ByLongName, ExcludedByLongName
2
+ from .metadata_modifiers import ByTestMetadata, ExcludedByTestMetadata
3
+
4
+ __all__ = ["ByLongName", "ByTestMetadata", "ExcludedByLongName", "ExcludedByTestMetadata"]
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0-dev.548"
@@ -0,0 +1,159 @@
1
+ """Select tests and tasks by their own metadata (Robot Framework 7.5+).
2
+
3
+ A test is matched by its entries, `Name:Line` for every line of every
4
+ metadata value. A pattern combines terms `NAME:VALUE` with `AND`, `OR` and
5
+ `NOT` like a tag pattern, but an operator is only a word of its own, so
6
+ upper-case values such as `CORE-123` or `NORBERT` stay literal.
7
+ """
8
+
9
+ import re
10
+ from typing import Callable, List, Mapping, Optional, Sequence, Tuple, Union
11
+
12
+ from robot.api import SuiteVisitor
13
+ from robot.errors import DataError
14
+ from robot.model import TestCase, TestSuite
15
+ from robot.utils import Matcher, normalize
16
+ from robot.version import VERSION
17
+
18
+ # `TestCase.metadata` exists since Robot Framework 7.5. This package must stay
19
+ # importable with plain Robot Framework, so it reads the version itself.
20
+ if tuple(int(part) for part in re.findall(r"\d+", VERSION)[:2]) >= (7, 5):
21
+
22
+ def _test_metadata(test: TestCase) -> Mapping[str, str]:
23
+ return test.metadata # type: ignore[attr-defined, no-any-return, unused-ignore]
24
+
25
+ else:
26
+
27
+ def _test_metadata(test: TestCase) -> Mapping[str, str]:
28
+ return {}
29
+
30
+
31
+ _Predicate = Callable[[Sequence[str]], bool]
32
+
33
+
34
+ def metadata_entries(metadata: Mapping[str, str]) -> List[Tuple[str, str]]:
35
+ """The entries of a test's or suite's metadata as `(name, line)` pairs.
36
+
37
+ Every non-empty line of a value is an entry of its own, a value without
38
+ such a line gives one entry with an empty line. Cells of one `Metadata`
39
+ row are not split: Robot joins them with the separator of the source, so
40
+ they cannot be told apart from spaces inside a value. An item without a
41
+ name (a `Metadata` setting with nothing after it) is no metadata.
42
+ """
43
+ entries: List[Tuple[str, str]] = []
44
+ for name, value in metadata.items():
45
+ if not name:
46
+ continue
47
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
48
+ entries.extend((name, line) for line in lines or [""])
49
+ return entries
50
+
51
+
52
+ def _normalized_entries(metadata: Mapping[str, str]) -> List[str]:
53
+ return [normalize(f"{name}:{line}", ignore="_") for name, line in metadata_entries(metadata)]
54
+
55
+
56
+ def _split(words: List[str], operator: str) -> List[List[str]]:
57
+ parts: List[List[str]] = [[]]
58
+ for word in words:
59
+ if word == operator:
60
+ parts.append([])
61
+ else:
62
+ parts[-1].append(word)
63
+ return parts
64
+
65
+
66
+ class MetadataPattern:
67
+ """A metadata pattern such as `Issue:4409 AND Author:Hans*`.
68
+
69
+ Terms match like tag patterns (`*`, `?`, `[…]`; case, spaces and
70
+ underscores ignored). The operators bind as in Robot's tag patterns:
71
+ `AND` before `OR`, `NOT` last. Raises `DataError` for an invalid pattern.
72
+ """
73
+
74
+ def __init__(self, pattern: str) -> None:
75
+ self.pattern = pattern
76
+ words = pattern.split()
77
+ if not words:
78
+ raise self._invalid("it is empty")
79
+ must_match, *must_not_match = _split(words, "NOT")
80
+ # Only a leading `NOT` leaves the first part empty: then every test
81
+ # that matches none of the other parts is selected.
82
+ self._must_match = self._any_of(must_match) if must_match else None
83
+ self._must_not_match = [self._any_of(part) for part in must_not_match]
84
+
85
+ def __str__(self) -> str:
86
+ return self.pattern
87
+
88
+ def match(self, metadata: Mapping[str, str]) -> bool:
89
+ return self._match_normalized(_normalized_entries(metadata))
90
+
91
+ def _match_normalized(self, entries: Sequence[str]) -> bool:
92
+ if self._must_match is not None and not self._must_match(entries):
93
+ return False
94
+ return not any(part(entries) for part in self._must_not_match)
95
+
96
+ def _any_of(self, words: List[str]) -> _Predicate:
97
+ alternatives = [
98
+ [self._term(term) for term in _split(alternative, "AND")] for alternative in _split(words, "OR")
99
+ ]
100
+ return lambda entries: any(all(term(entries) for term in terms) for terms in alternatives)
101
+
102
+ def _term(self, words: List[str]) -> _Predicate:
103
+ if not words:
104
+ raise self._invalid("an operator has no term on one of its sides")
105
+ term = " ".join(words)
106
+ if ":" not in term:
107
+ raise self._invalid(f"term '{term}' is not in the format NAME:VALUE")
108
+ # Normalised here, like Robot's own tag patterns do, so the matcher's
109
+ # own normalisation is a no-op.
110
+ matcher = Matcher(normalize(term, ignore="_"), caseless=False, spaceless=False)
111
+ return lambda entries: bool(matcher.match_any(entries))
112
+
113
+ def _invalid(self, reason: str) -> DataError:
114
+ return DataError(f"Invalid metadata pattern '{self.pattern}': {reason}.")
115
+
116
+
117
+ class _ByTestMetadataBase(SuiteVisitor):
118
+ def __init__(self, *patterns: Union[str, MetadataPattern]) -> None:
119
+ super().__init__()
120
+ self.patterns: List[MetadataPattern] = []
121
+ self._error: Optional[DataError] = None
122
+ try:
123
+ self.patterns = [p if isinstance(p, MetadataPattern) else MetadataPattern(p) for p in patterns]
124
+ except DataError as error:
125
+ # Robot skips a modifier whose constructor fails and runs every
126
+ # test. An invalid pattern must never widen a selection, so the
127
+ # error is raised on the first suite, after removing its tests.
128
+ self._error = error
129
+
130
+ def start_suite(self, suite: TestSuite) -> None:
131
+ if self._error is not None:
132
+ suite.tests = []
133
+ suite.suites = []
134
+ raise self._error
135
+ suite.tests = [test for test in suite.tests if self._keep(test)]
136
+
137
+ def end_suite(self, suite: TestSuite) -> None:
138
+ suite.suites = [s for s in suite.suites if s.test_count > 0]
139
+
140
+ def _matches(self, test: TestCase) -> bool:
141
+ entries = _normalized_entries(_test_metadata(test))
142
+ return any(pattern._match_normalized(entries) for pattern in self.patterns)
143
+
144
+ def _keep(self, test: TestCase) -> bool:
145
+ raise NotImplementedError
146
+
147
+
148
+ class ByTestMetadata(_ByTestMetadataBase):
149
+ """Keeps the tests and tasks whose metadata matches one of the patterns."""
150
+
151
+ def _keep(self, test: TestCase) -> bool:
152
+ return self._matches(test)
153
+
154
+
155
+ class ExcludedByTestMetadata(_ByTestMetadataBase):
156
+ """Removes the tests and tasks whose metadata matches one of the patterns."""
157
+
158
+ def _keep(self, test: TestCase) -> bool:
159
+ return not self._matches(test)
@@ -1,3 +0,0 @@
1
- from .longname_modifiers import ByLongName, ExcludedByLongName
2
-
3
- __all__ = ["ByLongName", "ExcludedByLongName"]
@@ -1 +0,0 @@
1
- __version__ = "2.7.0"