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.
- tursu-0.1.2/CHANGELOG.md +4 -0
- tursu-0.1.2/LICENSE +21 -0
- tursu-0.1.2/PKG-INFO +43 -0
- tursu-0.1.2/README.md +20 -0
- tursu-0.1.2/pyproject.toml +107 -0
- tursu-0.1.2/src/tursu/__init__.py +14 -0
- tursu-0.1.2/src/tursu/compile_all.py +37 -0
- tursu-0.1.2/src/tursu/compiler.py +218 -0
- tursu-0.1.2/src/tursu/domain/__init__.py +0 -0
- tursu-0.1.2/src/tursu/domain/model/__init__.py +0 -0
- tursu-0.1.2/src/tursu/domain/model/gherkin.py +149 -0
- tursu-0.1.2/src/tursu/domain/model/testmod.py +22 -0
- tursu-0.1.2/src/tursu/exceptions.py +1 -0
- tursu-0.1.2/src/tursu/pattern_matcher.py +89 -0
- tursu-0.1.2/src/tursu/py.typed +0 -0
- tursu-0.1.2/src/tursu/registry.py +102 -0
- tursu-0.1.2/src/tursu/steps.py +35 -0
tursu-0.1.2/CHANGELOG.md
ADDED
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)
|