tursu 0.1.2__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.
@@ -0,0 +1,4 @@
1
+ 0.1.2 - Released on 2025-03-09
2
+ ------------------------------
3
+ * Initial release
4
+
tursu-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Guillaume Gauvrit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tursu-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.1
2
+ Name: tursu
3
+ Version: 0.1.2
4
+ Summary: A gherkin tests runner
5
+ Author-Email: Guillaume Gauvrit <guillaume@gauvr.it>
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
11
+ Classifier: Typing :: Typed
12
+ Project-URL: Homepage, https://mardiros.github.io/tursu
13
+ Project-URL: Documentation, https://mardiros.github.io/tursu
14
+ Project-URL: Repository, https://github.com/mardiros/tursu.git
15
+ Project-URL: Issues, https://github.com/mardiros/tursu/issues
16
+ Project-URL: Changelog, https://mardiros.github.io/tursu/user/changelog.html
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: gherkin-official>=32.0.0
19
+ Requires-Dist: pydantic>=2.10.6
20
+ Requires-Dist: pytest>=8.3.5
21
+ Requires-Dist: venusian<4,>=3.1.1
22
+ Description-Content-Type: text/markdown
23
+
24
+ # turşu
25
+
26
+ This project allows you to write **Gherkin**-based behavior-driven development (BDD) tests
27
+ and execute them using **pytest**.
28
+
29
+ It compiles Gherkin syntax into Python code using **Abstract Syntax Tree (AST)** manipulation,
30
+ enabling seamless integration with pytest for running your tests.
31
+
32
+
33
+ ## Features
34
+
35
+ - Write tests using **Gherkin syntax** (Given, When, Then).
36
+ - Compile Gherkin scenarios to Python code using **AST**.
37
+ - Execute tests directly with **pytest**.
38
+ - Supports **step definitions** in Python for easy test scenario implementation.
39
+ - Allows integration with existing pytest setups.
40
+
41
+ ## Installation
42
+
43
+ will come soon
tursu-0.1.2/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # turşu
2
+
3
+ This project allows you to write **Gherkin**-based behavior-driven development (BDD) tests
4
+ and execute them using **pytest**.
5
+
6
+ It compiles Gherkin syntax into Python code using **Abstract Syntax Tree (AST)** manipulation,
7
+ enabling seamless integration with pytest for running your tests.
8
+
9
+
10
+ ## Features
11
+
12
+ - Write tests using **Gherkin syntax** (Given, When, Then).
13
+ - Compile Gherkin scenarios to Python code using **AST**.
14
+ - Execute tests directly with **pytest**.
15
+ - Supports **step definitions** in Python for easy test scenario implementation.
16
+ - Allows integration with existing pytest setups.
17
+
18
+ ## Installation
19
+
20
+ will come soon
@@ -0,0 +1,107 @@
1
+ [project]
2
+ authors = [
3
+ { name = "Guillaume Gauvrit", email = "guillaume@gauvr.it" },
4
+ ]
5
+ description = "A gherkin tests runner"
6
+ name = "tursu"
7
+ readme = "README.md"
8
+ classifiers = [
9
+ "Development Status :: 4 - Beta",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Topic :: Software Development :: Libraries :: Python Modules",
13
+ "Typing :: Typed",
14
+ ]
15
+ version = "0.1.2"
16
+ requires-python = ">=3.10"
17
+ dependencies = [
18
+ "gherkin-official>=32.0.0",
19
+ "pydantic>=2.10.6",
20
+ "pytest>=8.3.5",
21
+ "venusian>=3.1.1,<4",
22
+ ]
23
+
24
+ [project.license]
25
+ text = "MIT"
26
+
27
+ [project.urls]
28
+ Homepage = "https://mardiros.github.io/tursu"
29
+ Documentation = "https://mardiros.github.io/tursu"
30
+ Repository = "https://github.com/mardiros/tursu.git"
31
+ Issues = "https://github.com/mardiros/tursu/issues"
32
+ Changelog = "https://mardiros.github.io/tursu/user/changelog.html"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "mypy>=1.4.0,<2",
37
+ "pytest>=8,<9",
38
+ "pytest-cov>=6.0.0,<7",
39
+ ]
40
+
41
+ [tool.uv]
42
+ default-groups = []
43
+
44
+ [tool.pdm.build]
45
+ includes = [
46
+ "src",
47
+ "CHANGELOG.md",
48
+ ]
49
+ excludes = [
50
+ "tests",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ target-version = "py39"
55
+ line-length = 88
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "B",
60
+ "I",
61
+ "F",
62
+ "UP",
63
+ "RUF",
64
+ ]
65
+ ignore = [
66
+ "RUF022",
67
+ ]
68
+
69
+ [tool.pyright]
70
+ ignore = [
71
+ "examples",
72
+ ]
73
+ include = [
74
+ "src",
75
+ "tests",
76
+ ]
77
+ reportPrivateUsage = false
78
+ reportUnknownMemberType = false
79
+ reportUnknownParameterType = false
80
+ reportUnknownVariableType = false
81
+ reportShadowedImports = false
82
+ reportUnknownLambdaType = false
83
+ reportUnknownArgumentType = false
84
+ useLibraryCodeForTypes = true
85
+ reportMissingTypeStubs = false
86
+ typeCheckingMode = "strict"
87
+ venvPath = ".venv"
88
+
89
+ [tool.mypy]
90
+ overrides = [
91
+ { disallow_any_generics = true, disallow_untyped_defs = true, module = "tursu.*" },
92
+ { ignore_missing_imports = true, module = "venusian" },
93
+ ]
94
+
95
+ [tool.coverage.report]
96
+ exclude_lines = [
97
+ "if TYPE_CHECKING:",
98
+ "except ImportError",
99
+ "\\s+\\.\\.\\.$",
100
+ "# coverage: ignore",
101
+ ]
102
+
103
+ [build-system]
104
+ requires = [
105
+ "pdm-backend",
106
+ ]
107
+ build-backend = "pdm.backend"
@@ -0,0 +1,14 @@
1
+ from importlib import metadata
2
+
3
+ from .compile_all import generate_tests
4
+ from .registry import StepRegistry, given, then, when
5
+
6
+ __version__ = metadata.version("tursu")
7
+
8
+ __all__ = [
9
+ "given",
10
+ "when",
11
+ "then",
12
+ "StepRegistry",
13
+ "generate_tests",
14
+ ]
@@ -0,0 +1,37 @@
1
+ import atexit
2
+ import inspect
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+
6
+ from tursu.compiler import GherkinCompiler
7
+ from tursu.domain.model.gherkin import GherkinDocument
8
+ from tursu.registry import StepRegistry
9
+
10
+
11
+ def walk_features(path: Path) -> Iterator[GherkinDocument]:
12
+ for sub in path.glob("**/*.feature"):
13
+ yield GherkinDocument.from_file(sub)
14
+
15
+
16
+ def generate_tests() -> None:
17
+ caller_module = inspect.getmodule(inspect.stack()[1][0])
18
+ assert caller_module
19
+ assert caller_module.__file__
20
+
21
+ reg = StepRegistry()
22
+ reg.scan(caller_module)
23
+
24
+ to_removes: list[Path] = []
25
+ for doc in walk_features(Path(caller_module.__file__).parent):
26
+ compiler = GherkinCompiler(doc, reg)
27
+
28
+ case = compiler.to_module()
29
+ casefile = doc.filepath.parent / case.filename
30
+ casefile.write_text(str(case))
31
+ to_removes.append(casefile)
32
+
33
+ def clean_up() -> None:
34
+ for r in to_removes:
35
+ r.unlink()
36
+
37
+ atexit.register(clean_up)
@@ -0,0 +1,218 @@
1
+ import ast
2
+ import re
3
+ from collections.abc import Iterator
4
+ from typing import Any, TypeGuard, get_args
5
+
6
+ from tursu.domain.model.gherkin import (
7
+ GherkinBackgroundEnvelope,
8
+ GherkinDocument,
9
+ GherkinFeature,
10
+ GherkinKeyword,
11
+ GherkinRuleEnvelope,
12
+ GherkinScenario,
13
+ GherkinScenarioEnvelope,
14
+ GherkinStep,
15
+ )
16
+ from tursu.domain.model.testmod import TestModule
17
+ from tursu.registry import StepRegistry
18
+ from tursu.steps import StepKeyword
19
+
20
+
21
+ class GherkinIterator:
22
+ def __init__(self, doc: GherkinDocument) -> None:
23
+ self.doc = doc
24
+ self.stack: list[Any] = []
25
+
26
+ def emit(self) -> Iterator[Any]:
27
+ self.stack.append(self.doc)
28
+ yield self.stack
29
+ for _ in self.emit_feature(self.doc.feature):
30
+ yield self.stack
31
+ self.stack.pop()
32
+
33
+ def emit_feature(self, feature: GherkinFeature) -> Iterator[Any]:
34
+ self.stack.append(feature)
35
+ yield self.stack
36
+ for child in self.doc.feature.children:
37
+ match child:
38
+ case GherkinBackgroundEnvelope(background=background):
39
+ self.stack.append(background)
40
+ yield self.stack
41
+ self.stack.pop()
42
+ case GherkinScenarioEnvelope(scenario=scenario):
43
+ self.stack.append(scenario)
44
+ yield self.stack
45
+ for _ in self.emit_scenario(scenario):
46
+ yield self.stack
47
+ self.stack.pop()
48
+ case GherkinRuleEnvelope(rule=rule):
49
+ self.stack.append(rule)
50
+ yield self.stack
51
+ self.stack.pop()
52
+ self.stack.pop()
53
+
54
+ def emit_scenario(self, scenario: GherkinScenario) -> Iterator[Any]:
55
+ for step in scenario.steps:
56
+ self.stack.append(step)
57
+ yield self.stack
58
+ self.stack.pop()
59
+
60
+
61
+ def is_step_keyword(value: GherkinKeyword) -> TypeGuard[StepKeyword]:
62
+ return value in get_args(StepKeyword)
63
+
64
+
65
+ def sanitize(name: str) -> str:
66
+ return re.sub(r"\W+", "_", name)[:100]
67
+
68
+
69
+ class GherkinCompiler:
70
+ feat_idx = 1
71
+
72
+ def __init__(self, doc: GherkinDocument, registry: StepRegistry) -> None:
73
+ self.emmiter = GherkinIterator(doc)
74
+ self.registry = registry
75
+
76
+ def to_module(self) -> TestModule:
77
+ last_keyword: StepKeyword | None = None
78
+
79
+ module_name = None
80
+ module_node = None
81
+ test_function = None
82
+ args: Any = None
83
+
84
+ for stack in self.emmiter.emit():
85
+ el = stack[-1]
86
+ match el:
87
+ case GherkinFeature(
88
+ location=_,
89
+ tags=_,
90
+ language=_,
91
+ keyword=_,
92
+ name=name,
93
+ description=description,
94
+ children=_,
95
+ ):
96
+ assert module_node is None
97
+ docstring = f"{name}\n\n{description}".strip()
98
+ import_node = ast.ImportFrom(
99
+ module="tursu", # the module name
100
+ names=[ast.alias(name="StepRegistry", asname=None)],
101
+ level=0, # import at the top level
102
+ )
103
+ module_node = ast.Module(
104
+ body=[
105
+ ast.Expr(value=ast.Constant(docstring), lineno=1),
106
+ import_node,
107
+ ],
108
+ type_ignores=[],
109
+ )
110
+ module_name = f"test_{GherkinCompiler.feat_idx}_{sanitize(name)}.py"
111
+ GherkinCompiler.feat_idx += 1
112
+
113
+ case GherkinScenario(
114
+ id=id,
115
+ location=location,
116
+ tags=_,
117
+ keyword=_,
118
+ name=name,
119
+ description=description,
120
+ steps=steps,
121
+ examples=_,
122
+ ):
123
+ fixtures: dict[str, type] = {}
124
+ step_last_keyword = None
125
+ for step in steps:
126
+ if step.keyword_type == "Conjunction":
127
+ assert step_last_keyword is not None, (
128
+ f"Using {step.keyword} without context"
129
+ )
130
+ else:
131
+ step_last_keyword = step.keyword
132
+ assert is_step_keyword(step_last_keyword)
133
+
134
+ fixtures.update(
135
+ self.registry.extract_fixtures(step_last_keyword, step.text)
136
+ )
137
+
138
+ args = [
139
+ ast.arg(
140
+ arg="registry",
141
+ annotation=ast.Name(id="StepRegistry", ctx=ast.Load()),
142
+ )
143
+ ]
144
+ for key, _val in fixtures.items():
145
+ args.append(
146
+ ast.arg(
147
+ arg=key,
148
+ # annotation=ast.Name(id=val.__name__, ctx=ast.Load()),
149
+ )
150
+ )
151
+
152
+ docstring = f"{name}\n\n{description}".strip()
153
+ test_function = ast.FunctionDef(
154
+ name=f"test_{id}_{sanitize(name)}",
155
+ args=ast.arguments(
156
+ args=args,
157
+ ),
158
+ body=[
159
+ ast.Expr(
160
+ value=ast.Constant(docstring), lineno=location.line + 1
161
+ ),
162
+ ],
163
+ decorator_list=[],
164
+ lineno=location.line,
165
+ )
166
+ assert module_node is not None
167
+ module_node.body.append(test_function)
168
+
169
+ case GherkinStep(
170
+ id=_,
171
+ location=location,
172
+ keyword=keyword,
173
+ text=text,
174
+ keyword_type=keyword_type,
175
+ data_table=_,
176
+ docstring=_,
177
+ ):
178
+ if keyword_type == "Conjunction":
179
+ assert last_keyword is not None, (
180
+ f"Using {keyword} without context"
181
+ )
182
+ keyword = last_keyword
183
+ assert is_step_keyword(keyword)
184
+ last_keyword = keyword
185
+
186
+ assert test_function is not None
187
+ keywords = []
188
+ step_fixtures = self.registry.extract_fixtures(last_keyword, text)
189
+ for key, _val in step_fixtures.items():
190
+ keywords.append(
191
+ ast.keyword(arg=key, value=ast.Name(id=key, ctx=ast.Load()))
192
+ )
193
+
194
+ call_node = ast.Call(
195
+ func=ast.Attribute(
196
+ value=ast.Name(id="registry", ctx=ast.Load()),
197
+ attr="run_step",
198
+ ctx=ast.Load(),
199
+ ), # registry.run_step
200
+ args=[
201
+ ast.Constant(value=keyword),
202
+ ast.Constant(value=text),
203
+ ],
204
+ keywords=keywords,
205
+ )
206
+
207
+ # Add the call node to the body of the function
208
+ test_function.body.append(
209
+ ast.Expr(value=call_node, lineno=location.line)
210
+ )
211
+
212
+ case _:
213
+ # print(el)
214
+ ...
215
+
216
+ assert module_node is not None
217
+ assert module_name is not None
218
+ return TestModule(module_name, module_node)
File without changes
File without changes
@@ -0,0 +1,149 @@
1
+ from collections.abc import Sequence
2
+ from pathlib import Path
3
+ from typing import Annotated, Any, Literal
4
+
5
+ from gherkin import Parser
6
+ from pydantic import BaseModel, Field
7
+ from pydantic.functional_validators import BeforeValidator
8
+
9
+
10
+ def sanitize(value: Any) -> str:
11
+ return value.strip().lower() if isinstance(value, str) else value
12
+
13
+
14
+ GherkinKeyword = Annotated[
15
+ Literal["feature", "scenario", "given", "when", "then", "and", "but"],
16
+ BeforeValidator(sanitize),
17
+ ]
18
+
19
+
20
+ class GherkinLocation(BaseModel):
21
+ line: int
22
+ column: int | None = Field(default=None)
23
+
24
+
25
+ class GherkinComment(BaseModel):
26
+ location: GherkinLocation
27
+ text: str
28
+
29
+
30
+ class GherkinTag(BaseModel):
31
+ id: str
32
+ location: GherkinLocation
33
+ name: str
34
+
35
+
36
+ class GherkinCell(BaseModel):
37
+ location: GherkinLocation
38
+ value: str
39
+
40
+
41
+ class GherkinTableRow(BaseModel):
42
+ id: str
43
+ location: GherkinLocation
44
+ cells: Sequence[GherkinCell]
45
+
46
+
47
+ class GherkinDataTable(BaseModel):
48
+ location: GherkinLocation
49
+ rows: Sequence[GherkinTableRow]
50
+
51
+
52
+ class GherkinDocString(BaseModel):
53
+ location: GherkinLocation
54
+ content: str
55
+ delimiter: str
56
+ media_type: str | None = Field(alias="mediaType")
57
+
58
+
59
+ class GherkinStep(BaseModel):
60
+ id: str
61
+ location: GherkinLocation
62
+ keyword: GherkinKeyword
63
+ text: str
64
+ keyword_type: str = Field(alias="keywordType")
65
+ data_table: GherkinDataTable | None = Field(default=None, alias="data_table")
66
+ docstring: GherkinDocString | None = Field(default=None, alias="docstring")
67
+
68
+
69
+ class GherkinBackground(BaseModel):
70
+ id: str
71
+ location: GherkinLocation
72
+ keyword: GherkinKeyword
73
+ name: str
74
+ description: str
75
+ steps: Sequence[GherkinStep]
76
+
77
+
78
+ class GherkinExamples(BaseModel):
79
+ id: str
80
+ location: GherkinLocation
81
+ tags: Sequence[GherkinTag]
82
+ keyword: GherkinKeyword
83
+ name: str
84
+ description: str
85
+ table_header: GherkinTableRow = Field(alias="tableHeader")
86
+ table_body: Sequence[GherkinTableRow] = Field(alias="tableBody")
87
+
88
+
89
+ class GherkinScenario(BaseModel):
90
+ id: str
91
+ location: GherkinLocation
92
+ tags: Sequence[GherkinTag]
93
+ keyword: GherkinKeyword
94
+ name: str
95
+ description: str
96
+ steps: Sequence[GherkinStep]
97
+ examples: Sequence[GherkinExamples]
98
+
99
+
100
+ class GherkinBackgroundEnvelope(BaseModel):
101
+ background: GherkinBackground
102
+
103
+
104
+ class GherkinScenarioEnvelope(BaseModel):
105
+ scenario: GherkinScenario
106
+
107
+
108
+ class GherkinRuleEnvelope(BaseModel):
109
+ rule: "GherkinRule"
110
+
111
+
112
+ GherkinEnvelope = (
113
+ GherkinBackgroundEnvelope | GherkinScenarioEnvelope | GherkinRuleEnvelope
114
+ )
115
+
116
+
117
+ class GherkinRule(BaseModel):
118
+ id: str
119
+ location: GherkinLocation
120
+ tags: Sequence[GherkinTag]
121
+ keyword: GherkinKeyword
122
+ name: str
123
+ description: str
124
+ children: Sequence[GherkinEnvelope]
125
+
126
+
127
+ class GherkinFeature(BaseModel):
128
+ location: GherkinLocation
129
+ tags: Sequence[GherkinTag]
130
+ language: str
131
+ keyword: GherkinKeyword
132
+ name: str
133
+ description: str
134
+ children: Sequence[GherkinEnvelope]
135
+
136
+
137
+ class GherkinDocument(BaseModel):
138
+ name: str
139
+ filepath: Path
140
+ feature: GherkinFeature
141
+ comments: Sequence[GherkinComment]
142
+
143
+ @classmethod
144
+ def from_file(cls, file: Path) -> "GherkinDocument":
145
+ return GherkinDocument(
146
+ name=file.name[: -len(".feature")],
147
+ filepath=file,
148
+ **Parser().parse(file.read_text()), # type: ignore
149
+ )
@@ -0,0 +1,22 @@
1
+ import ast
2
+ from types import CodeType
3
+
4
+
5
+ class TestModule:
6
+ def __init__(self, filename: str, module_node: ast.Module):
7
+ self.filename = filename
8
+ self.module_node = module_node
9
+
10
+ @property
11
+ def modname(self) -> str:
12
+ return self.filename[:-3]
13
+
14
+ def compile(self) -> CodeType:
15
+ return compile(
16
+ ast.unparse(self.module_node), filename=self.filename, mode="exec"
17
+ )
18
+
19
+ def __str__(self) -> str:
20
+ return ast.unparse(self.module_node)
21
+
22
+ __repr__ = __str__
@@ -0,0 +1 @@
1
+ class Unregistered(RuntimeError): ...
@@ -0,0 +1,89 @@
1
+ import abc
2
+ import re
3
+ from collections.abc import Mapping
4
+ from inspect import Signature
5
+ from typing import Any
6
+
7
+
8
+ class AbstractPatternMatcher(abc.ABC):
9
+ def __init__(self, pattern: str, signature: Signature) -> None:
10
+ self.pattern = pattern
11
+ self.signature = signature
12
+
13
+ def __eq__(self, other: Any) -> bool:
14
+ if self.__class__ != other.__class__:
15
+ return False
16
+ return self.pattern == self.pattern
17
+
18
+ def __str__(self) -> str:
19
+ return self.pattern
20
+
21
+ def __repr__(self) -> str:
22
+ return f'"{self.pattern}"'
23
+
24
+ @abc.abstractmethod
25
+ def get_matches(
26
+ self, text: str, kwargs: Mapping[str, Any]
27
+ ) -> Mapping[str, Any] | None: ...
28
+
29
+ @abc.abstractmethod
30
+ def extract_fixtures(self, text: str) -> Mapping[str, Any] | None: ...
31
+
32
+
33
+ class AbstractPattern(abc.ABC):
34
+ def __init__(self, pattern: str) -> None:
35
+ self.pattern = pattern
36
+
37
+ @classmethod
38
+ @abc.abstractmethod
39
+ def get_matcher(cls) -> type[AbstractPatternMatcher]: ...
40
+
41
+
42
+ class DefaultPatternMatcher(AbstractPatternMatcher):
43
+ def __init__(self, pattern: str, signature: Signature) -> None:
44
+ super().__init__(pattern, signature)
45
+ re_pattern = pattern
46
+ for key, val in signature.parameters.items():
47
+ match val.annotation:
48
+ case type() if val.annotation is int:
49
+ re_pattern = re_pattern.replace(f"{{{key}}}", rf"(?P<{key}>\d+)")
50
+ case _:
51
+ # if enclosed by double quote, use double quote as escaper
52
+ # not a gherkin spec.
53
+ re_pattern = re_pattern.replace(f'"{{{key}}}"', rf'"(?P<{key}>[^"]+)"')
54
+ # otherwise, match one word
55
+ re_pattern = re_pattern.replace(f"{{{key}}}", rf"(?P<{key}>[^\s]+)")
56
+ self.re_pattern = re.compile(f"^{re_pattern}$")
57
+
58
+ def get_matches(
59
+ self, text: str, kwargs: Mapping[str, Any]
60
+ ) -> Mapping[str, Any] | None:
61
+ matches = self.re_pattern.match(text)
62
+ if matches:
63
+ res = {}
64
+ matchdict = matches.groupdict()
65
+ for key, val in self.signature.parameters.items():
66
+ if key in matchdict:
67
+ res[key] = self.signature.parameters[key].annotation(matchdict[key])
68
+ elif key in kwargs:
69
+ res[key] = kwargs[key]
70
+ elif val.default and val.default != val.empty:
71
+ res[key] = val.default
72
+ return res
73
+
74
+ return None
75
+
76
+ def extract_fixtures(self, text: str) -> Mapping[str, Any] | None:
77
+ matches = self.re_pattern.match(text)
78
+ if matches:
79
+ res = {}
80
+ matchdict = matches.groupdict()
81
+ for key, val in self.signature.parameters.items():
82
+ if key in matchdict:
83
+ continue
84
+ if val.default != val.empty:
85
+ continue
86
+ res[key] = val.annotation
87
+ return res
88
+
89
+ return None
File without changes
@@ -0,0 +1,102 @@
1
+ import sys
2
+ from collections.abc import Mapping
3
+ from types import ModuleType
4
+ from typing import Callable
5
+
6
+ import venusian
7
+ from typing_extensions import Any
8
+
9
+ from .exceptions import Unregistered
10
+ from .steps import Handler, Step, StepKeyword
11
+
12
+ VENUSIAN_CATEGORY = "tursu"
13
+
14
+
15
+ def _step(step_name: str, step_pattern: str) -> Callable[[Handler], Handler]:
16
+ def wrapper(wrapped: Handler) -> Handler:
17
+ def callback(scanner: venusian.Scanner, name: str, ob: Handler) -> None:
18
+ if not hasattr(scanner, "registry"):
19
+ return # coverage: ignore
20
+ scanner.registry.register_handler(step_name, step_pattern, wrapped) # type: ignore
21
+
22
+ venusian.attach(wrapped, callback, category=VENUSIAN_CATEGORY) # type: ignore
23
+ return wrapped
24
+
25
+ return wrapper
26
+
27
+
28
+ def given(pattern: str) -> Callable[[Handler], Handler]:
29
+ """
30
+ Decorator to listen for the given gherkin keyword.
31
+ """
32
+ return _step("given", pattern)
33
+
34
+
35
+ def when(pattern: str) -> Callable[[Handler], Handler]:
36
+ """
37
+ Decorator to listen for the when gherkin keyword.
38
+ """
39
+ return _step("when", pattern)
40
+
41
+
42
+ def then(pattern: str) -> Callable[[Handler], Handler]:
43
+ """
44
+ Decorator to listen for the then gherkin keyword.
45
+ """
46
+ return _step("then", pattern)
47
+
48
+
49
+ class StepRegistry:
50
+ """Store all the handlers for gherkin action."""
51
+
52
+ def __init__(self) -> None:
53
+ self._handlers: dict[StepKeyword, list[Step]] = {
54
+ "given": [],
55
+ "when": [],
56
+ "then": [],
57
+ }
58
+
59
+ def register_handler(
60
+ self, type: StepKeyword, pattern: str, handler: Handler
61
+ ) -> None:
62
+ self._handlers[type].append(Step(pattern, handler))
63
+
64
+ def run_step(self, step: StepKeyword, text: str, **kwargs: Any) -> None:
65
+ handlers = self._handlers[step]
66
+ for handler in handlers:
67
+ matches = handler.pattern.get_matches(text, kwargs)
68
+ if matches is not None:
69
+ handler(**matches)
70
+ break
71
+ else:
72
+ raise Unregistered(f"{step.capitalize()} {text}")
73
+
74
+ def extract_fixtures(
75
+ self, step: StepKeyword, text: str, **kwargs: Any
76
+ ) -> Mapping[str, Any]:
77
+ handlers = self._handlers[step]
78
+ for handler in handlers:
79
+ fixtures = handler.pattern.extract_fixtures(text)
80
+ if fixtures is not None:
81
+ return fixtures
82
+ break
83
+ else:
84
+ raise Unregistered(f"{step.capitalize()} {text}")
85
+
86
+ def scan(self, mod: ModuleType | None = None) -> "StepRegistry":
87
+ """
88
+ Scan the module (or modules) containing steps.
89
+ """
90
+ if mod is None:
91
+ import inspect
92
+
93
+ mod = inspect.getmodule(inspect.stack()[1][0])
94
+ assert mod
95
+ module_name = mod.__name__
96
+ if "." in module_name: # Check if it's a submodule
97
+ parent_name = module_name.rsplit(".", 1)[0] # Remove the last part
98
+ mod = sys.modules.get(parent_name)
99
+
100
+ scanner = venusian.Scanner(registry=self)
101
+ scanner.scan(mod, categories=[VENUSIAN_CATEGORY]) # type: ignore
102
+ return self
@@ -0,0 +1,35 @@
1
+ import inspect
2
+ from typing import Any, Callable, Literal
3
+
4
+ from .pattern_matcher import (
5
+ AbstractPattern,
6
+ AbstractPatternMatcher,
7
+ DefaultPatternMatcher,
8
+ )
9
+
10
+ StepKeyword = Literal["given", "when", "then"]
11
+ Handler = Callable[..., None]
12
+
13
+
14
+ class Step:
15
+ def __init__(self, pattern: str | AbstractPattern, hook: Handler):
16
+ matcher: type[AbstractPatternMatcher]
17
+ if isinstance(pattern, str):
18
+ matcher = DefaultPatternMatcher
19
+ else:
20
+ matcher = pattern.get_matcher()
21
+ pattern = pattern.pattern
22
+
23
+ self.pattern = matcher(pattern, inspect.signature(hook))
24
+ self.hook = hook
25
+
26
+ def __eq__(self, other: Any) -> bool:
27
+ if not isinstance(other, Step):
28
+ return False
29
+ return self.pattern == other.pattern and self.hook == other.hook
30
+
31
+ def __repr__(self) -> str:
32
+ return f"Step({self.pattern}, {self.hook.__qualname__})"
33
+
34
+ def __call__(self, **kwargs: Any) -> None:
35
+ self.hook(**kwargs)