pytest-xspec 0.5.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,23 @@
1
+ """Validate requirement associations before pytest filters collected tests."""
2
+
3
+ from collections.abc import Collection
4
+
5
+ import pytest
6
+
7
+
8
+ def requirement_ids(item: pytest.Item, inventory: Collection[str]) -> tuple[str, ...]:
9
+ identifiers: dict[str, None] = {}
10
+
11
+ for marker in item.iter_markers("xspec"):
12
+ if len(marker.args) != 1 or marker.kwargs or not isinstance(marker.args[0], str):
13
+ raise pytest.UsageError(
14
+ f"{item.nodeid}: xspec requires exactly one positional string ID and no keywords"
15
+ )
16
+
17
+ identifier = marker.args[0]
18
+ if identifier not in inventory:
19
+ raise pytest.UsageError(f"{item.nodeid}: unknown xspec requirement ID {identifier!r}")
20
+
21
+ identifiers[identifier] = None
22
+
23
+ return tuple(identifiers)
pytest_xspec/config.py ADDED
@@ -0,0 +1,60 @@
1
+ """Read requirement inventories from the two supported project files."""
2
+
3
+ import configparser
4
+ import sys
5
+ from importlib import import_module
6
+
7
+ import pytest
8
+
9
+
10
+ def load_requirements(config: pytest.Config) -> list[str]:
11
+ inventories: dict[str, object] = {}
12
+ pyproject = config.rootpath / "pyproject.toml"
13
+ ini = config.rootpath / "pytest.ini"
14
+
15
+ try:
16
+ if pyproject.is_file():
17
+ # pytest supplies tomli on Python 3.10; newer Python supplies tomllib.
18
+ toml = import_module("tomllib" if sys.version_info >= (3, 11) else "tomli")
19
+ with pyproject.open("rb") as stream:
20
+ section = toml.load(stream).get("tool", {}).get("xspec", {})
21
+
22
+ if not isinstance(section, dict):
23
+ raise pytest.UsageError("pyproject.toml: xspec must be a table")
24
+
25
+ if "requirements" in section:
26
+ inventories[pyproject.name] = section["requirements"]
27
+
28
+ if ini.is_file():
29
+ parser = configparser.ConfigParser(interpolation=None)
30
+ parser.read(ini, encoding="utf-8")
31
+
32
+ if parser.has_option("pytest", "xspec"):
33
+ inventories[ini.name] = [
34
+ line.strip()
35
+ for line in parser.get("pytest", "xspec").splitlines()
36
+ if line.strip()
37
+ ]
38
+ except (OSError, ValueError, configparser.Error) as error:
39
+ raise pytest.UsageError(f"Cannot read xspec requirements: {error}") from error
40
+
41
+ if len(inventories) > 1:
42
+ raise pytest.UsageError("Declare requirements in only one of pyproject.toml and pytest.ini")
43
+
44
+ if not inventories:
45
+ return []
46
+
47
+ filename, requirements = next(iter(inventories.items()))
48
+ if not isinstance(requirements, list) or any(
49
+ not isinstance(requirement, str) for requirement in requirements
50
+ ):
51
+ raise pytest.UsageError(f"{filename}: requirements must be a list of string IDs")
52
+
53
+ seen: set[str] = set()
54
+ for requirement in requirements:
55
+ if requirement in seen:
56
+ raise pytest.UsageError(f"{filename}: duplicate requirements ID {requirement!r}")
57
+
58
+ seen.add(requirement)
59
+
60
+ return requirements
@@ -0,0 +1,38 @@
1
+ """Translate execution phases into requirement verification outcomes."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ PRECEDENCE = {
6
+ status: rank
7
+ for rank, status in enumerate(
8
+ ("PASSED", "DESELECTED", "SKIPPED", "XFAILED", "XPASSED", "FAILED", "ERROR")
9
+ )
10
+ }
11
+
12
+
13
+ def phase_outcome(
14
+ when: str | None, outcome: str, *, wasxfail: bool = False, longrepr: object = None
15
+ ) -> str | None:
16
+ if wasxfail and outcome == "skipped":
17
+ return "XFAILED"
18
+
19
+ if outcome == "failed":
20
+ if when != "call":
21
+ return "ERROR"
22
+
23
+ if isinstance(longrepr, str) and longrepr.startswith("[XPASS(strict)] "):
24
+ return "XPASSED"
25
+
26
+ return "FAILED"
27
+
28
+ if outcome == "skipped":
29
+ return "SKIPPED"
30
+
31
+ if when == "call":
32
+ return "XPASSED" if wasxfail else "PASSED"
33
+
34
+ return None
35
+
36
+
37
+ def aggregate(outcomes: Iterable[str]) -> str:
38
+ return max(outcomes, key=PRECEDENCE.__getitem__, default="MISSING")
pytest_xspec/plugin.py ADDED
@@ -0,0 +1,94 @@
1
+ """Pytest entry point for executable requirement verification."""
2
+
3
+ from collections.abc import Generator, Sequence
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from _pytest.terminal import TerminalReporter
8
+
9
+ from pytest_xspec.collector import requirement_ids
10
+ from pytest_xspec.config import load_requirements
11
+ from pytest_xspec.outcomes import aggregate, phase_outcome
12
+ from pytest_xspec.reporting import build_report, write_json, write_terminal
13
+
14
+
15
+ def pytest_addoption(parser: pytest.Parser) -> None:
16
+ group = parser.getgroup("xspec")
17
+ group.addoption("--xspec-report", action="store_true", help="Print requirement verification")
18
+ group.addoption("--xspec-strict", action="store_true", help="Fail on unverified requirements")
19
+ group.addoption("--xspec-json", metavar="PATH", help="Write requirement verification as JSON")
20
+
21
+ parser.addini("xspec", "Requirement IDs, one per line (pytest.ini only)", "linelist")
22
+
23
+
24
+ def pytest_configure(config: pytest.Config) -> None:
25
+ config.addinivalue_line("markers", "xspec(id): associate a test with a declared requirement")
26
+ config.pluginmanager.register(RequirementPlugin(config), "xspec-results")
27
+
28
+
29
+ class RequirementPlugin:
30
+ def __init__(self, config: pytest.Config) -> None:
31
+ self.config = config
32
+ self.requirements: dict[str, list[str]] = {
33
+ identifier: [] for identifier in load_requirements(config)
34
+ }
35
+ self.outcomes: dict[str, str | None] = {}
36
+ self.collection_errors = 0
37
+
38
+ @pytest.hookimpl(wrapper=True, tryfirst=True)
39
+ def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> Generator[None]:
40
+ for item in items:
41
+ identifiers = requirement_ids(item, self.requirements)
42
+
43
+ if identifiers and item.nodeid not in self.outcomes:
44
+ self.outcomes[item.nodeid] = None
45
+
46
+ for identifier in identifiers:
47
+ self.requirements[identifier].append(item.nodeid)
48
+
49
+ yield
50
+
51
+ def pytest_deselected(self, items: Sequence[pytest.Item]) -> None:
52
+ for item in items:
53
+ if item.nodeid in self.outcomes:
54
+ self.outcomes[item.nodeid] = "DESELECTED"
55
+
56
+ def pytest_collectreport(self, report: pytest.CollectReport) -> None:
57
+ if report.failed:
58
+ self.collection_errors += 1
59
+
60
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
61
+ if report.nodeid in self.outcomes:
62
+ outcome = phase_outcome(
63
+ report.when,
64
+ report.outcome,
65
+ wasxfail=hasattr(report, "wasxfail"),
66
+ longrepr=report.longrepr,
67
+ )
68
+
69
+ if outcome is not None:
70
+ previous = self.outcomes[report.nodeid]
71
+ self.outcomes[report.nodeid] = aggregate(
72
+ [previous, outcome] if previous is not None else [outcome]
73
+ )
74
+
75
+ def pytest_sessionfinish(self, session: pytest.Session) -> None:
76
+ report = build_report(self.requirements, self.outcomes, self.collection_errors)
77
+
78
+ path = self.config.getoption("xspec_json")
79
+ if path:
80
+ write_json(report, Path(path))
81
+
82
+ if (
83
+ self.config.getoption("xspec_strict")
84
+ and not report["summary"]["verified"]
85
+ and session.exitstatus in (pytest.ExitCode.OK, pytest.ExitCode.NO_TESTS_COLLECTED)
86
+ ):
87
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
88
+
89
+ def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
90
+ if self.config.getoption("xspec_report") or self.config.getoption("xspec_strict"):
91
+ write_terminal(
92
+ build_report(self.requirements, self.outcomes, self.collection_errors),
93
+ terminalreporter,
94
+ )
@@ -0,0 +1,103 @@
1
+ """Build and render the public requirement report."""
2
+
3
+ import json
4
+ from collections.abc import Mapping, Sequence
5
+ from pathlib import Path
6
+ from typing import TypedDict
7
+
8
+ import pytest
9
+ from _pytest.terminal import TerminalReporter
10
+
11
+ from pytest_xspec.outcomes import aggregate
12
+
13
+
14
+ class TestResult(TypedDict):
15
+ nodeid: str
16
+ outcome: str
17
+
18
+
19
+ class RequirementResult(TypedDict):
20
+ status: str
21
+ tests: list[TestResult]
22
+
23
+
24
+ class Summary(TypedDict):
25
+ requirements: int
26
+ passed: int
27
+ unverified: int
28
+ coverage: float
29
+ verified: bool
30
+ collection_errors: int
31
+
32
+
33
+ class Report(TypedDict):
34
+ requirements: dict[str, RequirementResult]
35
+ summary: Summary
36
+
37
+
38
+ def build_report(
39
+ requirements: Mapping[str, Sequence[str]],
40
+ outcomes: Mapping[str, str | None],
41
+ collection_errors: int,
42
+ ) -> Report:
43
+ results: dict[str, RequirementResult] = {}
44
+
45
+ for identifier, nodeids in requirements.items():
46
+ tests: list[TestResult] = [
47
+ {"nodeid": nodeid, "outcome": outcomes[nodeid] or "SKIPPED"} for nodeid in nodeids
48
+ ]
49
+ results[identifier] = {
50
+ "status": aggregate(test["outcome"] for test in tests),
51
+ "tests": tests,
52
+ }
53
+
54
+ count = len(results)
55
+ passed = sum(result["status"] == "PASSED" for result in results.values())
56
+
57
+ return {
58
+ "requirements": results,
59
+ "summary": {
60
+ "requirements": count,
61
+ "passed": passed,
62
+ "unverified": count - passed,
63
+ "coverage": passed / count * 100 if count else 0.0,
64
+ "verified": passed == count and collection_errors == 0,
65
+ "collection_errors": collection_errors,
66
+ },
67
+ }
68
+
69
+
70
+ def write_json(report: Report, path: Path) -> None:
71
+ try:
72
+ path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
73
+ except OSError as error:
74
+ raise pytest.UsageError(f"Cannot write xspec JSON report {path}: {error}") from error
75
+
76
+
77
+ def write_terminal(report: Report, terminal: TerminalReporter) -> None:
78
+ red = not report["summary"]["verified"]
79
+ terminal.write_sep("=", "xspec", red=red, green=not red)
80
+
81
+ width = max([len("Requirement"), *(len(identifier) for identifier in report["requirements"])])
82
+ terminal.write_line(f"{'Requirement':<{width}} {'Status':<10} Tests", red=red, green=not red)
83
+
84
+ for identifier, result in report["requirements"].items():
85
+ terminal.write_line(
86
+ f"{identifier:<{width}} {result['status']:<10} {len(result['tests'])}",
87
+ red=red,
88
+ green=not red,
89
+ )
90
+
91
+ for label, field in (
92
+ ("Requirements", "requirements"),
93
+ ("Passed", "passed"),
94
+ ("Unverified", "unverified"),
95
+ ):
96
+ terminal.write_line(f"{label}: {report['summary'][field]}", red=red, green=not red)
97
+
98
+ terminal.write_line(f"Coverage: {report['summary']['coverage']:.1f}%", red=red, green=not red)
99
+
100
+ if report["summary"]["collection_errors"]:
101
+ terminal.write_line(
102
+ f"Collection errors: {report['summary']['collection_errors']}", red=red, green=not red
103
+ )
@@ -0,0 +1,205 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-xspec
3
+ Version: 0.5.0
4
+ Summary: Executable acceptance criteria for pytest
5
+ Project-URL: Repository, https://github.com/Thyholt/pytest-xspec
6
+ Project-URL: Issues, https://github.com/Thyholt/pytest-xspec/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pytest>=8
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pytest-xspec
14
+
15
+ [![PyPI version][pypi-version-badge]][pypi]
16
+ [![Python versions][python-versions-badge]][pypi]
17
+ [![Tests][tests-badge]][tests]
18
+ [![Code coverage][codecov-badge]][codecov]
19
+
20
+ **Executable specifications with pytest.**
21
+
22
+ Link specifications to your existing pytest tests, organize your test suite your way, and see
23
+ which specifications your test suite verifies. Detect missing tests and enforce verification in CI.
24
+
25
+ ## Usage
26
+
27
+ Install `pytest-xspec` into your pytest environment. The plugin is discovered
28
+ automatically. Python 3.10+ and pytest 8+ are supported.
29
+
30
+ ```sh
31
+ pip install pytest-xspec
32
+ ```
33
+
34
+ ### Declare specifications
35
+
36
+ Declare specification IDs under `requirements` in `pyproject.toml`.
37
+
38
+ ```toml
39
+ [tool.xspec]
40
+ requirements = ["REQ-001", "REQ-002"]
41
+ ```
42
+
43
+ Alternatively, use one ID per line in `pytest.ini`.
44
+
45
+ ```ini
46
+ [pytest]
47
+ xspec =
48
+ REQ-001
49
+ REQ-002
50
+ ```
51
+
52
+ Declare specifications in one of these files. Using both is an error, and other
53
+ files do not supply specifications. IDs are opaque strings. Duplicates and marked
54
+ IDs that have not been declared are rejected. No inventory, or an empty inventory,
55
+ means zero specifications.
56
+
57
+ ### Mark tests
58
+
59
+ Associate tests with specifications, using one marker per specification.
60
+
61
+ ```python
62
+ import pytest
63
+
64
+
65
+ @pytest.mark.xspec("REQ-001")
66
+ @pytest.mark.xspec("REQ-002")
67
+ def test_shared_behavior():
68
+ assert 1 + 1 == 2
69
+ ```
70
+
71
+ ### Run pytest
72
+
73
+ Plain `pytest` checks specification configuration and markers without printing
74
+ a specification report. Add `--xspec-report` to see the report, or `--xspec-strict`
75
+ to also fail the run when a specification is unverified.
76
+
77
+ ```sh
78
+ pytest --xspec-report # Print the verification report
79
+ pytest --xspec-json=report.json # Export statuses and individual test outcomes
80
+ pytest --xspec-strict # Report and fail on unverified specifications
81
+ pytest -m xspec --xspec-strict # Run only marked specification tests
82
+ ```
83
+
84
+ The report is green when all specifications are verified and red when verification fails.
85
+ The first report shows the example above; the second shows a specification without tests.
86
+
87
+ ![Terminal reports showing verified specifications in green and a missing specification in red.](https://raw.githubusercontent.com/Thyholt/pytest-xspec/master/docs/images/terminal-reports.png)
88
+
89
+ ### Read specification statuses
90
+
91
+ Reports label specifications as requirements.
92
+
93
+ A specification is verified when every test marked with its ID passes. One passing
94
+ test is not enough if another test for the same specification fails or skips.
95
+
96
+ When those tests have different results, the report uses the first matching
97
+ status in this table, reading from top to bottom.
98
+
99
+ | Status | Meaning |
100
+ | --- | --- |
101
+ | `ERROR` | A test's setup or cleanup failed. |
102
+ | `FAILED` | A test failed. |
103
+ | `XPASSED` | A test marked as an expected failure passed instead. |
104
+ | `XFAILED` | A test failed as expected. |
105
+ | `SKIPPED` | A test was skipped or never ran. |
106
+ | `DESELECTED` | A test was excluded by a filter such as `-k` or `-m`. |
107
+ | `PASSED` | Every test for the specification passed. |
108
+
109
+ For example, if one test passes and another skips, the specification is `SKIPPED`
110
+ and remains unverified. If one test fails and another skips, it is `FAILED`.
111
+
112
+ ### Missing and unfinished tests
113
+
114
+ Pytest first finds tests, then runs them. If it finds no marked tests for a
115
+ declared specification, that specification is `MISSING`. Tests that were found but
116
+ never ran are `SKIPPED`, including runs that only find tests or stop early.
117
+ If pytest encounters an error while finding tests, such as a broken import,
118
+ the verification check cannot succeed.
119
+
120
+ ### Tests without markers
121
+
122
+ Tests without a specification marker do not change specification statuses. They
123
+ can still make pytest fail. In [the duck example][acceptance-only],
124
+ the specification passes, but the unmarked cheese test fails the pytest run.
125
+
126
+ ### Check coverage
127
+
128
+ Coverage is the percentage of declared specifications that are verified. With
129
+ no declared specifications and no errors while finding tests, the verification
130
+ check succeeds and coverage is 0.0%.
131
+
132
+ Errors while finding tests make verification fail, even at 100% coverage. The report follows
133
+ pytest's color settings, so `--color=no` disables color.
134
+
135
+ For a complete report, let pytest find the whole test suite. With `-k` or `-m`,
136
+ the plugin still knows about the tests excluded by the filter and reports marked
137
+ ones as `DESELECTED`. If you run only one folder, it cannot see tests in other
138
+ folders.
139
+
140
+ ## Runnable examples
141
+
142
+ Start with [the pizza][pizza], then try the other
143
+ examples to see what makes a specification pass or remain unverified.
144
+
145
+ Run the commands below from the repository root. Dependencies are installed automatically.
146
+
147
+ | Example | With `--xspec-strict` | Exit code |
148
+ | --- | --- | --- |
149
+ | [Quickstart][quickstart] - Two slices of pizza | `PASSED`, 100% coverage | 0 |
150
+ | [Missing specification][missing-requirement] - A lost sock | `MISSING`, 50% coverage | 1 |
151
+ | [Partial verification][partial-verification] - Cat skips a meeting | `SKIPPED`, 0% coverage | 1 |
152
+ | [Acceptance only][acceptance-only] - Duck versus cheese | `PASSED` with `-m xspec` | 0 |
153
+
154
+ ### Run an example
155
+
156
+ Run the `Quickstart` example, then replace `quickstart` with another example folder.
157
+
158
+ ```sh
159
+ uv run --locked pytest examples/quickstart --xspec-strict
160
+ ```
161
+
162
+ For `acceptance_only`, add `-m xspec` to run only the marked test.
163
+ Without that filter, the unmarked test makes pytest fail.
164
+
165
+ ### Inspect the JSON report
166
+
167
+ Export the example results to see the specification status and individual test outcomes.
168
+
169
+ ```sh
170
+ uv run --locked pytest examples/quickstart --xspec-json=examples/quickstart/report.json
171
+ ```
172
+
173
+ Open [examples/quickstart/report.json][report] after running the command.
174
+
175
+ ### Verify all examples
176
+
177
+ Check that every example behaves as described.
178
+
179
+ ```sh
180
+ uv run --locked pytest examples/test_examples.py -n 0
181
+ ```
182
+
183
+ All five checks should pass, including checks for the examples that deliberately
184
+ fail. The examples are also verified in CI.
185
+
186
+ ## Contributing
187
+
188
+ See [CONTRIBUTING.md][contributing] for setup, tests and pull requests.
189
+
190
+ [acceptance-only]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/acceptance_only
191
+ [pizza]: https://github.com/Thyholt/pytest-xspec/blob/master/examples/quickstart/test_behavior.py
192
+ [quickstart]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/quickstart
193
+ [missing-requirement]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/missing_requirement
194
+ [partial-verification]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/partial_verification
195
+ [report]: https://github.com/Thyholt/pytest-xspec/blob/master/examples/quickstart/report.json
196
+ [contributing]: https://github.com/Thyholt/pytest-xspec/blob/master/CONTRIBUTING.md
197
+
198
+ [pypi]: https://pypi.org/project/pytest-xspec/
199
+ [pypi-version-badge]: https://img.shields.io/pypi/v/pytest-xspec
200
+ [python-versions-badge]: https://img.shields.io/badge/python-3.10%2B-blue
201
+ [tests]: https://github.com/Thyholt/pytest-xspec/actions/workflows/check.yml
202
+ [tests-badge]: https://img.shields.io/github/actions/workflow/status/Thyholt/pytest-xspec/check.yml?branch=master&label=tests
203
+
204
+ [codecov]: https://app.codecov.io/gh/Thyholt/pytest-xspec
205
+ [codecov-badge]: https://codecov.io/gh/Thyholt/pytest-xspec/branch/master/graph/badge.svg
@@ -0,0 +1,11 @@
1
+ pytest_xspec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pytest_xspec/collector.py,sha256=4qSrsbvJ78bqggYYG4Sbq7bXp2wc0DjRs_50W2KE76c,794
3
+ pytest_xspec/config.py,sha256=KFRbd4GRj20SjDv9d5lGtq0fsLUdiON44OEyVpxdhfc,2181
4
+ pytest_xspec/outcomes.py,sha256=K4EpjJMq16J9ffDSUKceNf1U5I1Bx2e8Ngi3iL_R1GQ,952
5
+ pytest_xspec/plugin.py,sha256=9UYo-qN-VS5vtTIbeND9bBCZ3JyCnoJwGU7GAIc7V4Y,3770
6
+ pytest_xspec/reporting.py,sha256=T4v4i8bCxzG6srsXCW3sv1jvARdvpn1C7dZHXGm8C3k,3096
7
+ pytest_xspec-0.5.0.dist-info/METADATA,sha256=8l014jjgVJ2-XByMd0J7vtAJO2jfSkWeU3aJsMGcgSg,7687
8
+ pytest_xspec-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ pytest_xspec-0.5.0.dist-info/entry_points.txt,sha256=TCQsg6OwG8dKvjg3eeazOa9fY5GgKp1ONpBbMtoCZiI,39
10
+ pytest_xspec-0.5.0.dist-info/licenses/LICENSE,sha256=UKFrzi3aP48KpaWL0SxbfrYo85PrVDvEqw3oShDk6xg,1082
11
+ pytest_xspec-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ xspec = pytest_xspec.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Thyholt Henriksen
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.