tracecov 0.8.0__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.
- tracecov-0.8.0/.gitignore +22 -0
- tracecov-0.8.0/PKG-INFO +6 -0
- tracecov-0.8.0/pyproject.toml +21 -0
- tracecov-0.8.0/src/tracecov/__init__.py +31 -0
- tracecov-0.8.0/src/tracecov/_version.py +7 -0
- tracecov-0.8.0/src/tracecov/http.py +27 -0
- tracecov-0.8.0/src/tracecov/schemathesis/__init__.py +2 -0
- tracecov-0.8.0/src/tracecov/schemathesis/handler.py +236 -0
- tracecov-0.8.0/src/tracecov/schemathesis/options.py +52 -0
- tracecov-0.8.0/src/tracecov/terminal.py +5 -0
tracecov-0.8.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tracecov"
|
|
3
|
+
version = "0.8.0"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
|
|
6
|
+
[project.optional-dependencies]
|
|
7
|
+
community = [
|
|
8
|
+
"tracecov-community",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.sdist]
|
|
12
|
+
only-include = [
|
|
13
|
+
"src",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
only-packages = true
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling >= 1.26"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from . import schemathesis
|
|
2
|
+
from ._version import VERSION
|
|
3
|
+
from .http import HttpInteraction, HttpRequest, HttpResponse # noqa: F401
|
|
4
|
+
|
|
5
|
+
__version__ = VERSION
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
# Try professional first, fall back to community
|
|
9
|
+
try:
|
|
10
|
+
from tracecov_professional import CoverageMap # noqa: F401
|
|
11
|
+
|
|
12
|
+
__edition__ = "professional"
|
|
13
|
+
except ImportError:
|
|
14
|
+
from tracecov_community import CoverageMap # noqa: F401
|
|
15
|
+
|
|
16
|
+
__edition__ = "community"
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"No TraceCov implementation found. Please install either 'tracecov[community]' or 'tracecov-professional'."
|
|
20
|
+
) from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"__edition__",
|
|
26
|
+
"HttpInteraction",
|
|
27
|
+
"HttpRequest",
|
|
28
|
+
"HttpResponse",
|
|
29
|
+
"CoverageMap",
|
|
30
|
+
"schemathesis",
|
|
31
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class HttpRequest:
|
|
6
|
+
method: str
|
|
7
|
+
url: str
|
|
8
|
+
body: bytes | None
|
|
9
|
+
headers: dict[str, str]
|
|
10
|
+
|
|
11
|
+
__slots__ = ("method", "url", "body", "headers")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HttpResponse:
|
|
16
|
+
status_code: int
|
|
17
|
+
elapsed: float
|
|
18
|
+
|
|
19
|
+
__slots__ = ("status_code", "elapsed")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class HttpInteraction:
|
|
24
|
+
request: HttpRequest
|
|
25
|
+
response: HttpResponse | None
|
|
26
|
+
|
|
27
|
+
__slots__ = ("request", "response")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from schemathesis import SCHEMATHESIS_VERSION, cli
|
|
12
|
+
from schemathesis.core import Specification, SpecificationKind
|
|
13
|
+
from schemathesis.engine import events
|
|
14
|
+
from schemathesis.engine.recorder import Interaction, ScenarioRecorder
|
|
15
|
+
|
|
16
|
+
from tracecov import CoverageMap, HttpInteraction, HttpRequest, HttpResponse, terminal
|
|
17
|
+
|
|
18
|
+
COVERAGE_REPORT_TITLE = "Schema Coverage Report"
|
|
19
|
+
DEFAULT_HTML_REPORT_PATH = "./schema-coverage.html"
|
|
20
|
+
ENABLE_PROFILING = os.environ.get("SCHEMATHESIS_COVERAGE_PROFILE", "").lower() in ("1", "true", "yes", "on")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExtensionError(Exception):
|
|
24
|
+
def __init__(self, inner: BaseException) -> None:
|
|
25
|
+
self.inner = inner
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _transform_headers(headers: dict[str, list[str]]) -> dict[str, str]:
|
|
29
|
+
return {key: value[0] for key, value in headers.items()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def group_interactions_by_method_path(recorder: ScenarioRecorder) -> dict[tuple[str, str], list[Interaction]]:
|
|
33
|
+
"""Group interactions by method and path template."""
|
|
34
|
+
groups = defaultdict(list)
|
|
35
|
+
|
|
36
|
+
for case_id, case_node in recorder.cases.items():
|
|
37
|
+
case = case_node.value
|
|
38
|
+
interaction = recorder.interactions.get(case_id)
|
|
39
|
+
if interaction:
|
|
40
|
+
groups[(case.method, case.operation.full_path)].append(interaction)
|
|
41
|
+
else:
|
|
42
|
+
# Could only happen with some hard-to-trigger network errors
|
|
43
|
+
... # pragma: no cover
|
|
44
|
+
|
|
45
|
+
return groups
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
T = TypeVar("T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def profile(name: str) -> Callable:
|
|
52
|
+
"""Decorator that profiles collector operations if profiling is enabled."""
|
|
53
|
+
|
|
54
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
55
|
+
if not ENABLE_PROFILING:
|
|
56
|
+
return func
|
|
57
|
+
|
|
58
|
+
@wraps(func)
|
|
59
|
+
def wrapper(self: cli.EventHandler, *args: Any, **kwargs: Any) -> Any:
|
|
60
|
+
start = time.perf_counter()
|
|
61
|
+
result = func(self, *args, **kwargs)
|
|
62
|
+
elapsed = time.perf_counter() - start
|
|
63
|
+
self.profile_log.append(f"Collector.{name}: {elapsed:.6f}s")
|
|
64
|
+
self.collector_total_time += elapsed
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
return wrapper
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def safe_collector(name: str) -> Callable:
|
|
73
|
+
"""Decorator that catches exceptions from collector operations."""
|
|
74
|
+
|
|
75
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
76
|
+
@wraps(func)
|
|
77
|
+
def wrapper(self: cli.EventHandler, *args: Any, **kwargs: Any) -> Any:
|
|
78
|
+
try:
|
|
79
|
+
return func(self, *args, **kwargs)
|
|
80
|
+
except BaseException as e:
|
|
81
|
+
self.coverage_errors.append(f"Error in {name}: {str(e)}")
|
|
82
|
+
raise ExtensionError(e) from None
|
|
83
|
+
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
return decorator
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@cli.handler()
|
|
90
|
+
class TracecovHandler(cli.EventHandler):
|
|
91
|
+
show_missing: list[str] | None
|
|
92
|
+
|
|
93
|
+
def __init__(self, *args: Any, **params: Any) -> None:
|
|
94
|
+
self.format = params["coverage_format"]
|
|
95
|
+
self.report_path = params["coverage_report_path"]
|
|
96
|
+
self.no_report = params["coverage_no_report"]
|
|
97
|
+
self.skip_covered = params["coverage_skip_covered"]
|
|
98
|
+
self.skip_empty = params["coverage_skip_empty"]
|
|
99
|
+
show_missing = params["coverage_show_missing"]
|
|
100
|
+
self.profile_log: list[str] = []
|
|
101
|
+
self.collector_total_time = 0.0
|
|
102
|
+
if isinstance(show_missing, str):
|
|
103
|
+
value = {
|
|
104
|
+
"parameters": "parameter",
|
|
105
|
+
}[show_missing]
|
|
106
|
+
self.show_missing = [value]
|
|
107
|
+
else:
|
|
108
|
+
self.show_missing = None
|
|
109
|
+
if params.get("no_color"):
|
|
110
|
+
self.colored_report = False
|
|
111
|
+
else:
|
|
112
|
+
self.colored_report = True
|
|
113
|
+
self.coverage_map: CoverageMap | None = None
|
|
114
|
+
self.coverage_errors: list[str] = []
|
|
115
|
+
self.specification: Specification | None = None
|
|
116
|
+
|
|
117
|
+
@profile("create")
|
|
118
|
+
@safe_collector("create_collector")
|
|
119
|
+
def create_coverage_map(self, schema: dict[str, Any], base_path: str) -> CoverageMap:
|
|
120
|
+
return CoverageMap.from_dict(schema, base_path=base_path)
|
|
121
|
+
|
|
122
|
+
@profile("record_schemathesis_interactions")
|
|
123
|
+
@safe_collector("process_batch")
|
|
124
|
+
def process_batch(self, method: str, path: str, batch: list[HttpInteraction]) -> None:
|
|
125
|
+
assert self.coverage_map is not None
|
|
126
|
+
self.coverage_map.record_schemathesis_interactions(method, path, batch)
|
|
127
|
+
|
|
128
|
+
@profile("generate_text_report")
|
|
129
|
+
@safe_collector("generate_text_report")
|
|
130
|
+
def generate_text_report(self, width: int) -> str:
|
|
131
|
+
assert self.coverage_map is not None
|
|
132
|
+
return self.coverage_map.generate_text_report(
|
|
133
|
+
width=width,
|
|
134
|
+
colored=self.colored_report,
|
|
135
|
+
skip_covered=self.skip_covered,
|
|
136
|
+
skip_empty=self.skip_empty,
|
|
137
|
+
show_missing=self.show_missing,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@profile("generate_report")
|
|
141
|
+
@safe_collector("generate_report")
|
|
142
|
+
def generate_report(self, title: str | None = None) -> str:
|
|
143
|
+
assert self.coverage_map is not None
|
|
144
|
+
return self.coverage_map.generate_report(title=title)
|
|
145
|
+
|
|
146
|
+
@safe_collector("record_error")
|
|
147
|
+
def record_error(self, method: str, path: str, message: str) -> None:
|
|
148
|
+
assert self.coverage_map is not None
|
|
149
|
+
self.coverage_map.record_error(method, path, message)
|
|
150
|
+
|
|
151
|
+
def handle_event(self, ctx: cli.ExecutionContext, event: events.ExecutionEvent) -> None:
|
|
152
|
+
if isinstance(event, cli.LoadingFinished):
|
|
153
|
+
self.specification = event.specification
|
|
154
|
+
if event.specification.kind == SpecificationKind.OPENAPI:
|
|
155
|
+
try:
|
|
156
|
+
self.coverage_map = self.create_coverage_map(event.schema, base_path=event.base_path)
|
|
157
|
+
except ExtensionError:
|
|
158
|
+
pass
|
|
159
|
+
elif (
|
|
160
|
+
isinstance(event, events.NonFatalError)
|
|
161
|
+
and self.coverage_map is not None
|
|
162
|
+
and event.label is not None
|
|
163
|
+
and event.label.startswith(("GET ", "PUT ", "POST ", "DELETE ", "OPTIONS ", "HEAD ", "PATCH ", "TRACE "))
|
|
164
|
+
):
|
|
165
|
+
method, path = event.label.split(" ", maxsplit=1)
|
|
166
|
+
try:
|
|
167
|
+
self.record_error(method, path, event.info.message)
|
|
168
|
+
except ExtensionError:
|
|
169
|
+
pass
|
|
170
|
+
elif isinstance(event, events.ScenarioFinished) and self.coverage_map is not None:
|
|
171
|
+
grouped_interactions = group_interactions_by_method_path(event.recorder)
|
|
172
|
+
for (method, path), interactions in grouped_interactions.items():
|
|
173
|
+
batch = [
|
|
174
|
+
HttpInteraction(
|
|
175
|
+
request=HttpRequest(
|
|
176
|
+
method=method,
|
|
177
|
+
url=interaction.request.uri,
|
|
178
|
+
body=interaction.request.body,
|
|
179
|
+
headers=_transform_headers(interaction.request.headers),
|
|
180
|
+
),
|
|
181
|
+
response=HttpResponse(
|
|
182
|
+
status_code=interaction.response.status_code,
|
|
183
|
+
elapsed=interaction.response.elapsed,
|
|
184
|
+
)
|
|
185
|
+
if interaction.response
|
|
186
|
+
else None,
|
|
187
|
+
)
|
|
188
|
+
for interaction in interactions
|
|
189
|
+
]
|
|
190
|
+
try:
|
|
191
|
+
self.process_batch(method, path, batch)
|
|
192
|
+
except ExtensionError:
|
|
193
|
+
pass
|
|
194
|
+
elif isinstance(event, events.EngineFinished):
|
|
195
|
+
if self.coverage_map is not None:
|
|
196
|
+
try:
|
|
197
|
+
if not self.no_report:
|
|
198
|
+
if self.format == "text":
|
|
199
|
+
width = _report_section(ctx)
|
|
200
|
+
report = self.generate_text_report(width)
|
|
201
|
+
ctx.add_summary_line(report)
|
|
202
|
+
if self.format == "html":
|
|
203
|
+
report = self.generate_report(title=f"Schemathesis {SCHEMATHESIS_VERSION}")
|
|
204
|
+
path = self.report_path or DEFAULT_HTML_REPORT_PATH
|
|
205
|
+
with open(path, "w") as fd:
|
|
206
|
+
fd.write(report)
|
|
207
|
+
ctx.add_summary_line(f"Schema Coverage report: {path}")
|
|
208
|
+
except ExtensionError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
if self.coverage_errors:
|
|
212
|
+
width = terminal.get_width()
|
|
213
|
+
ctx.add_summary_line(click.style(" Coverage Errors ".center(width, "-"), bold=True, fg="red"))
|
|
214
|
+
ctx.add_summary_line("")
|
|
215
|
+
for error in self.coverage_errors:
|
|
216
|
+
ctx.add_summary_line(click.style(f"- {error}", fg="red"))
|
|
217
|
+
|
|
218
|
+
if ENABLE_PROFILING:
|
|
219
|
+
width = terminal.get_width()
|
|
220
|
+
ctx.add_summary_line("")
|
|
221
|
+
ctx.add_summary_line(click.style(" Coverage Profiling ".center(width, "-"), bold=True))
|
|
222
|
+
for entry in self.profile_log:
|
|
223
|
+
ctx.add_summary_line(entry)
|
|
224
|
+
ctx.add_summary_line(f"Total collector time: {self.collector_total_time:.6f}s")
|
|
225
|
+
else:
|
|
226
|
+
assert self.specification is not None
|
|
227
|
+
width = _report_section(ctx)
|
|
228
|
+
title = click.style("Error", bold=True)
|
|
229
|
+
ctx.add_summary_line(click.style(f"{title}: {self.specification.name} is not supported", fg="red"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _report_section(context: cli.ExecutionContext) -> int:
|
|
233
|
+
width = terminal.get_width()
|
|
234
|
+
context.add_summary_line(click.style(f" {COVERAGE_REPORT_TITLE} ".center(width, "_"), bold=True))
|
|
235
|
+
context.add_summary_line("")
|
|
236
|
+
return width
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from schemathesis import cli
|
|
3
|
+
|
|
4
|
+
group = cli.add_group("Coverage options")
|
|
5
|
+
group.add_option(
|
|
6
|
+
"--coverage-format",
|
|
7
|
+
help="Output format for coverage reports",
|
|
8
|
+
type=click.Choice(["text", "html"]),
|
|
9
|
+
default="text",
|
|
10
|
+
metavar="FORMAT",
|
|
11
|
+
envvar="SCHEMATHESIS_COVERAGE_FORMAT",
|
|
12
|
+
)
|
|
13
|
+
group.add_option(
|
|
14
|
+
"--coverage-report-path",
|
|
15
|
+
help="File path for the API coverage report if output format is not text",
|
|
16
|
+
type=click.Path(
|
|
17
|
+
file_okay=True,
|
|
18
|
+
dir_okay=False,
|
|
19
|
+
writable=True,
|
|
20
|
+
path_type=str,
|
|
21
|
+
),
|
|
22
|
+
envvar="SCHEMATHESIS_COVERAGE_REPORT_PATH",
|
|
23
|
+
)
|
|
24
|
+
group.add_option(
|
|
25
|
+
"--coverage-no-report",
|
|
26
|
+
help="Do not generate coverage report",
|
|
27
|
+
is_flag=True,
|
|
28
|
+
default=False,
|
|
29
|
+
envvar="SCHEMATHESIS_COVERAGE_NO_REPORT",
|
|
30
|
+
)
|
|
31
|
+
group.add_option(
|
|
32
|
+
"--coverage-show-missing",
|
|
33
|
+
help="Show items with no coverage",
|
|
34
|
+
type=click.Choice(["parameters"]),
|
|
35
|
+
default=None,
|
|
36
|
+
metavar="",
|
|
37
|
+
envvar="SCHEMATHESIS_COVERAGE_SHOW_MISSING",
|
|
38
|
+
)
|
|
39
|
+
group.add_option(
|
|
40
|
+
"--coverage-skip-covered",
|
|
41
|
+
help="Skip operations with 100% coverage",
|
|
42
|
+
is_flag=True,
|
|
43
|
+
default=False,
|
|
44
|
+
envvar="SCHEMATHESIS_COVERAGE_SKIP_COVERED",
|
|
45
|
+
)
|
|
46
|
+
group.add_option(
|
|
47
|
+
"--coverage-skip-empty",
|
|
48
|
+
help="Skip operations without any collected data",
|
|
49
|
+
is_flag=True,
|
|
50
|
+
default=False,
|
|
51
|
+
envvar="SCHEMATHESIS_COVERAGE_SKIP_EMPTY",
|
|
52
|
+
)
|