pytest-timing 0.1.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.
- pytest_timing/__init__.py +5 -0
- pytest_timing/cli.py +150 -0
- pytest_timing/collector.py +210 -0
- pytest_timing/model.py +522 -0
- pytest_timing/outputs.py +54 -0
- pytest_timing/plugin.py +382 -0
- pytest_timing/render/__init__.py +1 -0
- pytest_timing/render/ascii.py +283 -0
- pytest_timing/render/html.py +32 -0
- pytest_timing/render/trace.py +126 -0
- pytest_timing/static/__init__.py +0 -0
- pytest_timing/static/report.html +653 -0
- pytest_timing/xdist_compat.py +158 -0
- pytest_timing-0.1.0.dist-info/METADATA +161 -0
- pytest_timing-0.1.0.dist-info/RECORD +18 -0
- pytest_timing-0.1.0.dist-info/WHEEL +4 -0
- pytest_timing-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_timing-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Everything that touches pytest-xdist internals, in one place.
|
|
2
|
+
|
|
3
|
+
xdist has no public API for "why did the controller stop?", so this module reads the
|
|
4
|
+
few attributes the controller session (``DSession``) exposes. Keep every such lookup
|
|
5
|
+
here so a change in xdist has exactly one place to break.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_distributed(config: pytest.Config) -> bool:
|
|
18
|
+
"""True on a controller that is actually distributing tests (``-n`` > 0)."""
|
|
19
|
+
return config.pluginmanager.hasplugin("dsession")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_worker(config: pytest.Config) -> bool:
|
|
23
|
+
return hasattr(config, "workerinput")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def worker_id(report: pytest.TestReport) -> str | None:
|
|
27
|
+
"""The gateway id (``gw0``) xdist attaches to reports on the controller."""
|
|
28
|
+
node = getattr(report, "node", None)
|
|
29
|
+
if node is None:
|
|
30
|
+
return None
|
|
31
|
+
gateway = getattr(node, "gateway", None)
|
|
32
|
+
ident = getattr(gateway, "id", None)
|
|
33
|
+
return str(ident) if ident is not None else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def node_id(node: Any) -> str:
|
|
37
|
+
return str(node.gateway.id)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def gateway_id(gateway: Any) -> str:
|
|
41
|
+
return str(gateway.id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_ABSENT = object()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def observe_gateway_creation(
|
|
48
|
+
config: pytest.Config, on_created: Callable[[str, float], None]
|
|
49
|
+
) -> Callable[[], None] | None:
|
|
50
|
+
"""Time every gateway creation on the controller's execnet group.
|
|
51
|
+
|
|
52
|
+
``pytest_xdist_newgateway`` fires only after a gateway exists, and every hook-based
|
|
53
|
+
marker before it sits ahead of some of xdist's own work (hook wrappers' post-yield
|
|
54
|
+
code, preparing the remote module), so inferring a launch time from neighbouring
|
|
55
|
+
events always leaks the previous worker's work into the next boot span. Instead,
|
|
56
|
+
wrap the node manager's ``group.makegateway`` for the session: the timestamp is
|
|
57
|
+
taken immediately before the call and associated with the gateway it returns.
|
|
58
|
+
Covers initial workers and replacements alike. Returns a function that removes the
|
|
59
|
+
observer, or ``None`` when the group is not reachable (then nothing is patched).
|
|
60
|
+
"""
|
|
61
|
+
dsession = config.pluginmanager.getplugin("dsession")
|
|
62
|
+
group = getattr(getattr(dsession, "nodemanager", None), "group", None)
|
|
63
|
+
original = getattr(group, "makegateway", None)
|
|
64
|
+
if group is None or original is None:
|
|
65
|
+
return None
|
|
66
|
+
# Another observer may already sit on the instance; keep it and put it back later.
|
|
67
|
+
previous = group.__dict__.get("makegateway", _ABSENT)
|
|
68
|
+
|
|
69
|
+
def makegateway(*args: Any, **kwargs: Any) -> Any:
|
|
70
|
+
started = time.time()
|
|
71
|
+
gateway = original(*args, **kwargs)
|
|
72
|
+
on_created(gateway_id(gateway), started)
|
|
73
|
+
return gateway
|
|
74
|
+
|
|
75
|
+
group.makegateway = makegateway
|
|
76
|
+
|
|
77
|
+
def restore() -> None:
|
|
78
|
+
if group.__dict__.get("makegateway") is not makegateway:
|
|
79
|
+
return # someone else replaced it after us; not ours to touch
|
|
80
|
+
if previous is _ABSENT:
|
|
81
|
+
del group.__dict__["makegateway"]
|
|
82
|
+
else:
|
|
83
|
+
group.makegateway = previous
|
|
84
|
+
|
|
85
|
+
return restore
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CollectionWatch:
|
|
89
|
+
"""Observe what each worker collected, to recognise a genuine mismatch abort.
|
|
90
|
+
|
|
91
|
+
xdist's load-style schedulers abort when workers collect different node ids. They
|
|
92
|
+
announce it with a failed ``CollectReport`` whose nodeid is the disagreeing worker's
|
|
93
|
+
gateway id; but a worker can be given any id (``--tx popen//id=test_x.py``), so that
|
|
94
|
+
shape alone is ambiguous with a forwarded collection error from a file of the same
|
|
95
|
+
name. The abort is recognised only when this watch has itself seen two workers
|
|
96
|
+
collect different ids.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._digests: dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
def collected(self, worker_id: str, ids: Any) -> None:
|
|
103
|
+
import hashlib
|
|
104
|
+
|
|
105
|
+
digest = hashlib.sha1("\0".join(str(i) for i in ids).encode("utf-8")).hexdigest()
|
|
106
|
+
self._digests[worker_id] = digest
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def differs(self) -> bool:
|
|
110
|
+
return len(set(self._digests.values())) > 1
|
|
111
|
+
|
|
112
|
+
def mismatch_reason(self, report: Any) -> str | None:
|
|
113
|
+
"""The abort reason if ``report`` is the scheduler's mismatch announcement."""
|
|
114
|
+
if not self.differs:
|
|
115
|
+
return None
|
|
116
|
+
if getattr(report, "outcome", None) != "failed":
|
|
117
|
+
return None
|
|
118
|
+
nodeid = getattr(report, "nodeid", None)
|
|
119
|
+
if nodeid not in self._digests:
|
|
120
|
+
return None
|
|
121
|
+
longrepr = getattr(report, "longrepr", None)
|
|
122
|
+
if not isinstance(longrepr, str):
|
|
123
|
+
return None # the scheduler's announcement is a plain message
|
|
124
|
+
first = longrepr.strip().splitlines()[0] if longrepr.strip() else "collections differ"
|
|
125
|
+
return f"{nodeid}: {first}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def stop_reason(config: pytest.Config) -> str | None:
|
|
129
|
+
"""Why the controller decided to stop scheduling, if it did.
|
|
130
|
+
|
|
131
|
+
Covers ``-x`` / ``--maxfail`` relayed from workers and a worker keyboard interrupt.
|
|
132
|
+
A stop raises ``Interrupted`` out of the run loop, so it usually also surfaces as
|
|
133
|
+
``pytest_keyboard_interrupt``; this is the fallback when it does not.
|
|
134
|
+
"""
|
|
135
|
+
dsession = config.pluginmanager.getplugin("dsession")
|
|
136
|
+
value = getattr(dsession, "shouldstop", None)
|
|
137
|
+
return str(value) if value else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def abort_reason(config: pytest.Config) -> str | None:
|
|
141
|
+
"""Why the controller shut down early without raising, if it did.
|
|
142
|
+
|
|
143
|
+
When a worker crashes and restarts are exhausted, ``DSession`` records the message
|
|
144
|
+
it prints in the terminal summary and triggers a quiet shutdown. A crash that was
|
|
145
|
+
recovered by restarting the worker leaves no such record, and the run completes.
|
|
146
|
+
"""
|
|
147
|
+
dsession = config.pluginmanager.getplugin("dsession")
|
|
148
|
+
value = getattr(dsession, "_summary_report", None)
|
|
149
|
+
return str(value) if value else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def version() -> str | None:
|
|
153
|
+
import importlib.metadata
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return importlib.metadata.version("pytest-xdist")
|
|
157
|
+
except importlib.metadata.PackageNotFoundError:
|
|
158
|
+
return None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-timing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record test timings under pytest-xdist and render cargo-style timing reports (ASCII Gantt and HTML).
|
|
5
|
+
Project-URL: Homepage, https://github.com/messense/pytest-timing
|
|
6
|
+
Author-email: messense <messense@icloud.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: gantt,profiling,pytest,timing,xdist
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: pytest>=7.3
|
|
23
|
+
Provides-Extra: xdist
|
|
24
|
+
Requires-Dist: pytest-xdist>=3.0; extra == 'xdist'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pytest-timing
|
|
28
|
+
|
|
29
|
+
Record when every test ran, on which [pytest-xdist](https://github.com/pytest-dev/pytest-xdist)
|
|
30
|
+
worker, and for how long, then render the run as a timing report in the spirit of
|
|
31
|
+
`cargo build --timings`: an ASCII Gantt chart in the terminal and a self-contained
|
|
32
|
+
HTML report with worker lanes, a concurrency graph and a sortable table.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pytest -n 4 --timing --timing-html
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
================================ timing report =================================
|
|
40
|
+
pytest-timing: 129 tests (1 error, 1 failed), 4 workers, wall 1.37s, busy 95.4%
|
|
41
|
+
worker |-----------|-----------|------------|-----------|-----------|-------- busy%
|
|
42
|
+
gw0 ░░░░░░ ▄███████████████▄███████████████████████████████████████████ 93.6%
|
|
43
|
+
gw1 ░░░░░░░ ▄██████████████████████████████████████████████████X 94.1%
|
|
44
|
+
gw2 ░░░░░░░░▒▄███████████████████████████████████████████████████▄ 96.2%
|
|
45
|
+
gw3 ░░░░░░░░░▄████████████████████████████████▄███████████XXXXX 98.2%
|
|
46
|
+
0s 0.20s 0.40s 0.60s 0.80s 1.00s 1.37s
|
|
47
|
+
legend: ░ boot ▒ collect █ tests ▄ <50% busy X failure
|
|
48
|
+
|
|
49
|
+
slowest 3 tests (setup ░ / call █ / teardown ▒):
|
|
50
|
+
gw0 ████████████████████▒ 0.36s
|
|
51
|
+
test_big.py::test_slow[5]
|
|
52
|
+
gw2 ████████████████▒ 0.30s
|
|
53
|
+
test_big.py::test_slow[4]
|
|
54
|
+
gw1 ░░░░░░░░░░░░░▒ 0.21s
|
|
55
|
+
test_big.py::test_many[8]
|
|
56
|
+
HTML report written to pytest-timing.html
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Works with and without `-n`. Without xdist the run is a single `main` lane.
|
|
60
|
+
|
|
61
|
+
Each lane shows worker boot, collection, tests (`▄` marks a column that is under half busy,
|
|
62
|
+
so idle gaps stand out) and failures. The slowest tests are drawn on the same axis with
|
|
63
|
+
their setup / call / teardown split; note how the module-scoped fixture above lands in the
|
|
64
|
+
setup phase of the first test on each worker.
|
|
65
|
+
|
|
66
|
+
The HTML report has the same data with a zoomable lane Gantt, a concurrency graph, filters,
|
|
67
|
+
hover details, and a sortable table:
|
|
68
|
+
|
|
69
|
+

|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
pip install pytest-timing # plugin only
|
|
75
|
+
pip install "pytest-timing[xdist]" # with pytest-xdist
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Python 3.10+, pytest 7.3+ (the version that added wall-clock `start`/`stop` to test reports).
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
| Option | Effect |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `--timing` | Record timings and print the ASCII chart in the terminal summary. |
|
|
85
|
+
| `--timing-html` | Write a self-contained HTML report to `pytest-timing.html`. |
|
|
86
|
+
| `--timing-json` | Write the recorded run to `pytest-timing.json`. |
|
|
87
|
+
| `--timing-trace` | Write a Chrome trace file for [Perfetto](https://ui.perfetto.dev) to `pytest-timing.trace.json`. |
|
|
88
|
+
| `--timing-html-file PATH`, `--timing-json-file PATH`, `--timing-trace-file PATH` | Same, to an explicit path. |
|
|
89
|
+
| `--timing-top=N` | Rows in the slowest-tests section (default 10, `0` hides it). |
|
|
90
|
+
| `--timing-min=SECONDS` | Hide tests shorter than this from the slowest-tests section. |
|
|
91
|
+
| `--timing-ascii-style=unicode\|ascii` | Chart glyphs. |
|
|
92
|
+
| `--timing-width=N` | Override the terminal width for the chart. |
|
|
93
|
+
|
|
94
|
+
Any output option implies `--timing`. The output flags are plain booleans and the `-file`
|
|
95
|
+
options always take a path, so `pytest --timing-json test_x.py` runs exactly `test_x.py`.
|
|
96
|
+
|
|
97
|
+
The same settings are accepted as ini keys (`timing`, `timing_html`, `timing_json`,
|
|
98
|
+
`timing_trace` as paths or `true`, plus `timing_top`, `timing_min`, `timing_ascii_style`)
|
|
99
|
+
and environment variables (`PYTEST_TIMING=1`, `PYTEST_TIMING_HTML=path`, ...), so CI can
|
|
100
|
+
enable it without touching the command line.
|
|
101
|
+
|
|
102
|
+
Every JSON run records how the session ended (`finished`, `collect_only`, `interrupted`,
|
|
103
|
+
`aborted`, `internal_error`) with the reason pytest gave, and `complete` is derived from that.
|
|
104
|
+
|
|
105
|
+
### View the trace
|
|
106
|
+
|
|
107
|
+
`--timing-trace` writes a Chrome Trace Event file. Open it in
|
|
108
|
+
[Perfetto UI](https://ui.perfetto.dev) with "Open trace file", or in `chrome://tracing`.
|
|
109
|
+
Each worker is a track, every test is a bar, and the setup / call / teardown phases nest
|
|
110
|
+
underneath it. Zoom with W/A/S/D and select a range to aggregate durations.
|
|
111
|
+
|
|
112
|
+
### Re-render or merge saved runs
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
pytest-timing render pytest-timing.json --html report.html --ascii
|
|
116
|
+
pytest-timing merge shard1.json shard2.json -o all.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`merge` places several runs (for example CI shards) on one shared time axis using their
|
|
120
|
+
absolute start times.
|
|
121
|
+
|
|
122
|
+
## How it works
|
|
123
|
+
|
|
124
|
+
Since pytest 7.3 every `TestReport` carries wall-clock `start` and `stop` timestamps. xdist
|
|
125
|
+
serialises those to the controller unchanged and attaches the worker to the report, so the
|
|
126
|
+
plugin only needs controller-side hooks: `pytest_runtest_logreport` for the setup / call /
|
|
127
|
+
teardown phases, plus xdist's node-ready, collection-finished and node-down hooks for the
|
|
128
|
+
worker lifecycle. Nothing runs inside the workers and nothing touches the execnet channel.
|
|
129
|
+
|
|
130
|
+
Each lane in the report shows boot (worker start-up until it is ready), collection, tests,
|
|
131
|
+
idle gaps and the point the worker shut down. Session-scoped fixture setup is attributed to
|
|
132
|
+
the first test's setup phase and its teardown to the last test's teardown phase, exactly as
|
|
133
|
+
pytest reports it.
|
|
134
|
+
|
|
135
|
+
## Overhead
|
|
136
|
+
|
|
137
|
+
Nothing runs inside the workers: the plugin only listens to the reports xdist already
|
|
138
|
+
sends to the controller, and each of the three phase reports per test costs a few
|
|
139
|
+
microseconds of bookkeeping. Rendering happens once, at the end of the session.
|
|
140
|
+
|
|
141
|
+
Measured on 5,000 trivial tests (a worst case, since the per-test cost is fixed while the
|
|
142
|
+
tests themselves take almost nothing), best of five runs:
|
|
143
|
+
|
|
144
|
+
| Configuration | Wall time | Overhead |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| single process, plugin disabled | 1.29 s | |
|
|
147
|
+
| single process, `--timing` | 1.38 s | +0.09 s |
|
|
148
|
+
| single process, `--timing` plus JSON, HTML and trace files | 1.45 s | +0.16 s |
|
|
149
|
+
| `-n 4`, plugin disabled | 1.18 s | |
|
|
150
|
+
| `-n 4`, `--timing` | 1.25 s | +0.07 s |
|
|
151
|
+
| `-n 4`, `--timing` plus all three files | 1.32 s | +0.14 s |
|
|
152
|
+
|
|
153
|
+
That is under 20 microseconds per test for recording, plus a fixed serialisation cost per
|
|
154
|
+
output file of roughly 10 ms per thousand tests. Output size is about 250 bytes per test
|
|
155
|
+
for the JSON and HTML files and 600 bytes for the trace. Memory held during the run is on
|
|
156
|
+
the same order as the JSON. The plugin registers nothing at all unless one of its options
|
|
157
|
+
is enabled.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pytest_timing/__init__.py,sha256=XqvwgGSYCEapcf0BaHvba9XJuOoorW4ess_UNnumltY,146
|
|
2
|
+
pytest_timing/cli.py,sha256=1LU-Sx2OmU-FAUM6oA_-ya_7bGqaZxwmNE21QWBkZ5o,5417
|
|
3
|
+
pytest_timing/collector.py,sha256=YBxL2ELS7m2VIt_PTgKkfluNqbLjG2a5AG2KaTpImJs,8643
|
|
4
|
+
pytest_timing/model.py,sha256=f8NXCoFMQ-IPn2z4QEv3dYYLQpgutUNRkv8PwfOWhok,18494
|
|
5
|
+
pytest_timing/outputs.py,sha256=tRyRA2wR3U99ToTWqot0KJBntdnki2pGoCHivL_7mBY,1671
|
|
6
|
+
pytest_timing/plugin.py,sha256=UJYAsgjpx7mD-BziZVF3kwD_x3IFYFYFrS7MtsdwSCo,15443
|
|
7
|
+
pytest_timing/xdist_compat.py,sha256=iUT-vuOA77SxTvLlxSF677hs0UR_awotlac1fnsInhU,5993
|
|
8
|
+
pytest_timing/render/__init__.py,sha256=oHRYmicsmVDtBkuH8330F9J8WIvHQJuwGdVN4ItSE_c,76
|
|
9
|
+
pytest_timing/render/ascii.py,sha256=oAfQmpxu3EGFp52Ye3VpAkKtekMgkxwmtVwMSE-t0cc,9322
|
|
10
|
+
pytest_timing/render/html.py,sha256=MNnXOW_w5_lRYE993n4UAnl-LYyNxZgTD-_THD22NZE,1032
|
|
11
|
+
pytest_timing/render/trace.py,sha256=zTwPxFCMQyGCCJZnvAU2JtYLnWsifpS2GMYcJLHJ9D0,3881
|
|
12
|
+
pytest_timing/static/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
pytest_timing/static/report.html,sha256=2q_WoeekdyNKD8hKlEoV8IhOxglMbZLb7lqduHkjQy8,38606
|
|
14
|
+
pytest_timing-0.1.0.dist-info/METADATA,sha256=XJPSW7AmZedz16skF8gydecbFPn__d58t3j9C-Zgrg8,8077
|
|
15
|
+
pytest_timing-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
16
|
+
pytest_timing-0.1.0.dist-info/entry_points.txt,sha256=DCRyOBo-gKQW2s0SY5a_lW9Zzya49sVde-vklRBxDLo,99
|
|
17
|
+
pytest_timing-0.1.0.dist-info/licenses/LICENSE,sha256=3Cd2jLg5VJrONe-6tuTRguU6hr5h7fniBENwTGIhZLg,1086
|
|
18
|
+
pytest_timing-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Messense Lv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
9
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
10
|
+
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.
|