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.
@@ -0,0 +1,22 @@
1
+ debug/
2
+ target/
3
+ Cargo.lock
4
+ **/*.rs.bk
5
+ *.pdb
6
+
7
+ *.so
8
+ .coverage*
9
+ __pycache__/
10
+ *.py[cod]
11
+ build/
12
+ dist/
13
+ *.whl
14
+ *.egg-info
15
+ *.egg
16
+ venv
17
+ .venv
18
+ .hypothesis
19
+ .benchmarks
20
+ uv.lock
21
+
22
+ schema-coverage.html
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracecov
3
+ Version: 0.8.0
4
+ Requires-Python: >=3.10
5
+ Provides-Extra: community
6
+ Requires-Dist: tracecov-community; extra == 'community'
@@ -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,7 @@
1
+ from importlib import metadata
2
+
3
+ try:
4
+ VERSION = metadata.version("tracecov")
5
+ except metadata.PackageNotFoundError:
6
+ # Local run without installation
7
+ VERSION = "dev"
@@ -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,2 @@
1
+ def install() -> None:
2
+ from . import handler, options # noqa: F401
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ import shutil
2
+
3
+
4
+ def get_width() -> int:
5
+ return shutil.get_terminal_size((80, 24)).columns