pytest-fly 0.0.1__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_fly/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .plugin import pytest_addoption, pytest_runtest_logreport, pytest_sessionfinish
pytest_fly/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ from pathlib import Path
2
+
3
+ from ismain import is_main
4
+
5
+ from .visualization import visualize
6
+
7
+
8
+ def main():
9
+ visualize(Path("temp", "ptest-fly.png")) # todo: make output file a command line argument
10
+
11
+
12
+ if is_main():
13
+ main()
@@ -0,0 +1,2 @@
1
+ author = "abel"
2
+ application_name = "pytest-fly"
pytest_fly/db.py ADDED
@@ -0,0 +1,207 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+ from msqlite import MSQLite
4
+ import json
5
+ import uuid
6
+ from functools import cache
7
+ from logging import getLogger
8
+ import time
9
+ from dataclasses import dataclass
10
+ from collections import defaultdict
11
+
12
+ from appdirs import user_data_dir
13
+
14
+ from _pytest.reports import BaseReport
15
+
16
+ from src.pytest_fly.report_converter import report_to_json
17
+
18
+ from .__version__ import author, application_name
19
+
20
+ g_table_name = "" # will get set at initialization
21
+ fly_db_path = Path(user_data_dir(application_name, author), f"{application_name}.db")
22
+
23
+ log = getLogger(application_name)
24
+
25
+
26
+ def set_db_path(db_path: Path | str):
27
+ global fly_db_path
28
+ fly_db_path = Path(db_path)
29
+
30
+
31
+ def get_db_path() -> Path:
32
+ fly_db_path.parent.mkdir(parents=True, exist_ok=True)
33
+ return Path(fly_db_path)
34
+
35
+
36
+ def set_table_name(table_name: str):
37
+ global g_table_name
38
+ g_table_name = table_name
39
+
40
+
41
+ def get_table_name() -> str:
42
+ return g_table_name
43
+
44
+
45
+ @cache
46
+ def _get_process_guid() -> str:
47
+ """
48
+ Get a unique guid for this process by using functools.cache.
49
+ :return: GUID string
50
+ """
51
+ return str(uuid.uuid4())
52
+
53
+
54
+ # "when" is a keyword in SQLite so use "pt_when"
55
+ fly_schema = {"id PRIMARY KEY": int, "ts": float, "uid": str, "pt_when": str, "nodeid": str, "report": json}
56
+
57
+
58
+ def get_table_name_from_report(report: BaseReport) -> str:
59
+ """
60
+ Get the table name from the report file path
61
+ """
62
+ table_name = Path(report.fspath).parts[0]
63
+ set_table_name(table_name)
64
+ return table_name
65
+
66
+
67
+ def write_report(report: BaseReport):
68
+ """
69
+ Write a pytest report to the database
70
+ :param report: pytest report
71
+ """
72
+ try:
73
+ testrun_uid = report.testrun_uid # pytest-xdist
74
+ is_xdist = True
75
+ except AttributeError:
76
+ testrun_uid = _get_process_guid() # single threaded
77
+ is_xdist = False
78
+ table_name = get_table_name_from_report(report)
79
+ pt_when = report.when
80
+ node_id = report.nodeid
81
+ setattr(report, "is_xdist", is_xdist) # signify if we're running pytest-xdist or not
82
+ db_path = get_db_path()
83
+ with MSQLite(db_path, table_name, fly_schema) as db:
84
+ report_json = report_to_json(report)
85
+ statement = f"INSERT OR REPLACE INTO {table_name} (ts, uid, pt_when, nodeid, report) VALUES ({time.time()}, '{testrun_uid}', '{pt_when}', '{node_id}', '{report_json}')"
86
+ try:
87
+ db.execute(statement)
88
+ except sqlite3.OperationalError as e:
89
+ log.error(f"{e}:{statement}")
90
+
91
+
92
+ def read_json_objects_by_uid(uid: str, table_name: str = get_table_name()):
93
+ with MSQLite(get_db_path(), table_name) as db:
94
+ statement = f"SELECT * FROM {table_name} WHERE uid = {uid}"
95
+ rows = db.execute(statement)
96
+ return rows
97
+
98
+
99
+ def get_all_test_run_ids(table_name: str = get_table_name()) -> set[str]:
100
+ with MSQLite(get_db_path(), table_name) as db:
101
+ test_run_ids = set()
102
+ rows = db.execute(f"SELECT * FROM {table_name}")
103
+ for row in rows:
104
+ run_id = row[2]
105
+ test_run_ids.add(run_id)
106
+ return test_run_ids
107
+
108
+
109
+ meta_session_table_name = "_session"
110
+ meta_session_schema = {"id PRIMARY KEY": int, "ts": float, "test_name": str, "state": str}
111
+
112
+
113
+ def _get_most_recent_row_values(db) -> tuple[int | None, str | None, float | None]:
114
+ statement = f"SELECT * FROM {meta_session_table_name} ORDER BY ts DESC LIMIT 1"
115
+ rows = list(db.execute(statement))
116
+ row = rows[0] if len(rows) > 0 else None
117
+ if row is None:
118
+ id_state_ts = None, None, None
119
+ else:
120
+ id_state_ts = row[0], row[3], row[1]
121
+ return id_state_ts
122
+
123
+
124
+ def write_start():
125
+ db_path = get_db_path()
126
+ with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
127
+ # get the most recent state
128
+ id_value, state, ts = _get_most_recent_row_values(db)
129
+ if state != "start":
130
+ statement = f"INSERT OR REPLACE INTO {meta_session_table_name} (ts, state) VALUES ({time.time()}, 'start')"
131
+ db.execute(statement)
132
+
133
+
134
+ def write_finish(test_name: str):
135
+ db_path = get_db_path()
136
+ with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
137
+ id_value, state, ts = _get_most_recent_row_values(db)
138
+ now = time.time()
139
+ if state == "start":
140
+ statement = f"INSERT INTO {meta_session_table_name} (ts, test_name, state) VALUES ({now}, '{test_name}', 'finish')"
141
+ else:
142
+ statement = f"UPDATE {meta_session_table_name} SET ts = {now}, state = 'finish' WHERE id = {id_value}"
143
+ db.execute(statement)
144
+
145
+
146
+ def get_most_recent_start_and_finish() -> tuple[str | None, float | None, float | None]:
147
+ db_path = get_db_path()
148
+ with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
149
+ statement = f"SELECT * FROM {meta_session_table_name} ORDER BY ts DESC LIMIT 2"
150
+ rows = list(db.execute(statement))
151
+ start_ts = rows[1][1]
152
+ finish_ts = rows[0][1]
153
+ test_name = rows[0][2]
154
+ return test_name, start_ts, finish_ts
155
+
156
+
157
+ @dataclass
158
+ class RunInfo:
159
+ worker_id: str | None = None
160
+ start: float | None = None
161
+ stop: float | None = None
162
+ passed: bool | None = None
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class RunInfoKey:
167
+ test_id: str
168
+ when: str
169
+
170
+
171
+ def get_most_recent_run_info() -> dict[str, dict[str, RunInfo]]:
172
+ test_name, start_ts, finish_ts = get_most_recent_start_and_finish()
173
+ db_path = get_db_path()
174
+ with MSQLite(db_path, test_name) as db:
175
+ statement = f"SELECT * FROM {test_name} WHERE ts >= {start_ts} and ts <= {finish_ts} ORDER BY ts"
176
+ rows = list(db.execute(statement))
177
+ run_infos = {}
178
+ for row in rows:
179
+ test_data = json.loads(row[-1])
180
+ test_id = test_data["nodeid"]
181
+ worker_id = test_data.get("worker_id")
182
+ when = test_data.get("when")
183
+ start = test_data.get("start")
184
+ stop = test_data.get("stop")
185
+ passed = test_data.get("passed")
186
+ if test_id in run_infos:
187
+ run_info = run_infos[test_id]
188
+ if start is not None:
189
+ if run_info[when].start is None:
190
+ run_info[when].start = start
191
+ else:
192
+ run_info[when].start = min(run_info[when].start, start)
193
+ if stop is not None:
194
+ if run_info[when].stop is None:
195
+ run_info[when].stop = stop
196
+ else:
197
+ run_info[when].stop = max(run_info[when].stop, stop)
198
+ if passed is not None:
199
+ run_info[when].passed = passed
200
+ if worker_id is not None:
201
+ run_info[when].worker_id = worker_id
202
+ else:
203
+ run_infos[test_id] = defaultdict(RunInfo)
204
+ run_infos[test_id][when] = RunInfo(worker_id, start, stop, passed)
205
+ # convert defaultdict to dict
206
+ run_infos = {test_id: dict(run_info) for test_id, run_info in run_infos.items()}
207
+ return run_infos
pytest_fly/plugin.py ADDED
@@ -0,0 +1,19 @@
1
+ import pytest
2
+ from _pytest.reports import BaseReport
3
+ from src.pytest_fly.db import write_report, write_start, write_finish
4
+
5
+
6
+ def pytest_addoption(parser):
7
+ write_start()
8
+ parser.addoption("--fly", action="store_true")
9
+
10
+
11
+ @pytest.hookimpl(tryfirst=True)
12
+ def pytest_runtest_logreport(report: BaseReport):
13
+ write_report(report)
14
+
15
+
16
+ @pytest.hookimpl(trylast=True)
17
+ def pytest_sessionfinish(session, exitstatus):
18
+ table_name = session.startpath.name
19
+ write_finish(table_name)
@@ -0,0 +1,67 @@
1
+ from typing import Sized, Any
2
+ import json
3
+ import time
4
+ import platform
5
+ import getpass
6
+ from functools import cache
7
+
8
+ from _pytest.reports import BaseReport
9
+
10
+
11
+ @cache
12
+ def get_username() -> str:
13
+ """
14
+ Get the username of the current user
15
+ """
16
+ return getpass.getuser()
17
+
18
+
19
+ @cache
20
+ def get_computer_name() -> str:
21
+ """
22
+ Get the computer name
23
+ """
24
+ return platform.node()
25
+
26
+
27
+ def _convert_report_to_dict(report: BaseReport) -> dict[str, Any]:
28
+ """
29
+ Convert a pytest Report to a dict, excluding zero-length iterables, None values, and other data that's not serializable. This isn't perfect but it seems to preserve
30
+ the data we're interested in.
31
+ :param report: Pytest Report
32
+ :return: a dict representation of the report
33
+ """
34
+ report_dict = {}
35
+ for attr in dir(report):
36
+ # Exclude private attributes and methods, None, and zero-length lists
37
+ if not attr.startswith("__") and (value := getattr(report, attr)) is not None:
38
+ has_size = isinstance(value, Sized)
39
+ if not has_size or has_size and len(value) > 0:
40
+ # Check if the attribute is serializable
41
+ try:
42
+ json.dumps(value)
43
+ report_dict[attr] = value
44
+ except TypeError:
45
+ # a string representation starting with "<" generally isn't a useful serialization, so ignore it
46
+ if not (s := str(value)).startswith("<"):
47
+ report_dict[attr] = s
48
+ return report_dict
49
+
50
+
51
+ def report_to_json(report: BaseReport) -> str:
52
+ """
53
+ Convert Pytest Report object to a JSON string (as much as possible)
54
+ :param report: Pytest Report
55
+ :return: JSON string representation of the report
56
+ """
57
+ d = _convert_report_to_dict(report)
58
+ # remove fields that we don't need that can have formatting issues with SQLite JSON
59
+ removes = ["sections", "capstdout", "capstderr"]
60
+ for remove in removes:
61
+ if remove in d:
62
+ del d[remove]
63
+ d["fly_timestamp"] = time.time() # pytest-fly's own timestamp
64
+ d["username"] = get_username()
65
+ d["computer_name"] = get_computer_name()
66
+ s = json.dumps(d)
67
+ return s
@@ -0,0 +1,58 @@
1
+ from typing import List, Tuple, Dict
2
+ from collections import defaultdict
3
+
4
+ from .db import RunInfo
5
+
6
+
7
+ def merge_intervals(intervals: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
8
+ """Merge overlapping intervals."""
9
+ if not intervals:
10
+ return []
11
+
12
+ # Sort intervals by the start time
13
+ intervals.sort(key=lambda x: x[0])
14
+ merged = [intervals[0]]
15
+
16
+ for current_start, current_end in intervals[1:]:
17
+ last_end = merged[-1][1]
18
+
19
+ if current_start <= last_end:
20
+ merged[-1] = (merged[-1][0], max(last_end, current_end))
21
+ else:
22
+ merged.append((current_start, current_end))
23
+
24
+ return merged
25
+
26
+
27
+ def calculate_utilization(data: Dict[str, Dict[str, RunInfo]]) -> Tuple[Dict[str, float], float]:
28
+ """
29
+ Calculate utilization for each worker and overall utilization.
30
+ :param data: Dictionary with test names as keys and dictionaries with phase names as keys and RunInfo objects as values.
31
+ :return: Tuple with a dictionary with worker IDs as keys and utilization as values and overall utilization.
32
+ """
33
+ worker_intervals = defaultdict(list)
34
+ all_times = []
35
+
36
+ # Collect intervals for each worker and all timestamps
37
+ for test in data.values():
38
+ for phase in test.values():
39
+ worker_intervals[phase.worker_id].append((phase.start, phase.stop))
40
+ all_times.extend([phase.start, phase.stop])
41
+
42
+ # Calculate total time span
43
+ total_time_span = max(all_times) - min(all_times)
44
+
45
+ # Calculate utilization for each worker
46
+ utilization = {}
47
+ total_busy_time_all_workers = 0
48
+ for worker_id, intervals in worker_intervals.items():
49
+ merged = merge_intervals(intervals)
50
+ total_busy_time = sum(end - start for start, end in merged)
51
+ total_busy_time_all_workers += total_busy_time # Sum busy times for overall utilization
52
+ utilization[worker_id] = total_busy_time / total_time_span
53
+
54
+ # Calculate overall utilization
55
+ num_processors = len(worker_intervals)
56
+ overall_utilization = total_busy_time_all_workers / (total_time_span * num_processors)
57
+
58
+ return utilization, overall_utilization
@@ -0,0 +1,85 @@
1
+ import tkinter as tk
2
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from typing import Dict
6
+ from pathlib import Path
7
+
8
+ from .db import get_most_recent_run_info, RunInfo
9
+ from .utilization import calculate_utilization
10
+
11
+
12
+ def visualize(plot_file_path: Path | None = None) -> None:
13
+ """
14
+ Visualize a timeline of test phases per worker.
15
+ :param plot_file_path: Path to save the plot to.
16
+ """
17
+ run_info = get_most_recent_run_info()
18
+
19
+ root = tk.Tk()
20
+ root.title("Test Phases Timeline")
21
+
22
+ fig, ax = plt.subplots(figsize=(20, 3 + len(run_info) * 0.5)) # Dynamic height based on the number of tests
23
+ _plot_timeline(run_info, fig, ax, plot_file_path)
24
+
25
+ canvas = FigureCanvasTkAgg(fig, master=root) # A tk.DrawingArea
26
+ canvas.draw()
27
+ canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1)
28
+
29
+ tk.mainloop()
30
+
31
+
32
+ def _plot_timeline(data: Dict[str, Dict[str, RunInfo]], fig, ax, plot_file_path: Path | None) -> None:
33
+ """
34
+ Plot a timeline of test phases per worker.
35
+ :param data: Dictionary with test names as keys and dictionaries with phase names as keys and RunInfo objects as values.
36
+ :param fig: Matplotlib figure object.
37
+ :param ax: Matplotlib axis object.
38
+ :param plot_file_path: Path to save the plot to.
39
+ """
40
+ sorted_data = dict(sorted(data.items(), key=lambda x: x[0], reverse=True))
41
+ worker_utilization, overall_utilization = calculate_utilization(sorted_data)
42
+ earliest_start = min(phase.start for test in sorted_data.values() for phase in test.values())
43
+
44
+ workers = set(info.worker_id for test in sorted_data.values() for info in test.values())
45
+ colors = plt.cm.jet(np.linspace(0, 1, len(workers)))
46
+ worker_colors = dict(zip(workers, colors))
47
+
48
+ yticks, yticklabels = [], []
49
+ for i, (test_name, phases) in enumerate(sorted_data.items()):
50
+ for phase_name, phase_info in phases.items():
51
+ relative_start = phase_info.start - earliest_start
52
+ relative_stop = phase_info.stop - earliest_start
53
+ worker_id = phase_info.worker_id
54
+
55
+ ax.plot([relative_start, relative_stop], [i, i], color=worker_colors[worker_id], marker="o", markersize=4, label=worker_id if phase_name == "setup" else "")
56
+
57
+ if phase_name == list(phases.keys())[0]:
58
+ yticks.append(i)
59
+ yticklabels.append(test_name)
60
+
61
+ ax.set_yticks(yticks)
62
+ ax.set_yticklabels(yticklabels)
63
+ ax.set_xlabel("Time (seconds)")
64
+ ax.set_ylabel("Test Names")
65
+ ax.set_title("Timeline of Test Phases per Worker")
66
+ ax.grid(True)
67
+
68
+ ax.text(1.0, 1.02, f"Overall Utilization: {overall_utilization:.2%}", transform=ax.transAxes, horizontalalignment="right", fontsize=9)
69
+ text_position = 1.05
70
+ for worker, utilization in worker_utilization.items():
71
+ ax.text(1.0, text_position, f"{worker}: {utilization:.2%}", transform=ax.transAxes, horizontalalignment="right", fontsize=9)
72
+ text_position += 0.03
73
+
74
+ handles, labels = ax.get_legend_handles_labels()
75
+ by_label = dict(zip(labels, handles))
76
+ legend = ax.legend(by_label.values(), by_label.keys(), title="Workers", loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=9)
77
+
78
+ plt.subplots_adjust(right=0.9) # Make space for the legend
79
+
80
+ # Use bbox_extra_artists to include the legend in the tight layout calculation
81
+ if plot_file_path is not None:
82
+ plot_file_path.parent.mkdir(parents=True, exist_ok=True)
83
+ fig.savefig(plot_file_path, bbox_extra_artists=[legend])
84
+
85
+ # plt.show()
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.3
2
+ Name: pytest-fly
3
+ Version: 0.0.1
4
+ Summary: pytest observer
5
+ Project-URL: Home, https://github.com/jamesabel/pytest-fly
6
+ Project-URL: Repository, https://github.com/jamesabel/pytest-fly
7
+ Author-email: James Abel <j@abel.co>
8
+ Maintainer-email: James Abel <j@abel.co>
9
+ License:
10
+ The MIT License (MIT)
11
+
12
+ Copyright (c) 2024 James Abel
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in
22
+ all copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
30
+ THE SOFTWARE.
31
+ License-File: LICENSE
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Framework :: Pytest
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python
38
+ Classifier: Programming Language :: Python :: 3 :: Only
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Topic :: Software Development :: Testing
41
+ Requires-Python: >=3.12
42
+ Requires-Dist: appdirs
43
+ Requires-Dist: ismain
44
+ Requires-Dist: msqlite
45
+ Requires-Dist: pytest
46
+ Description-Content-Type: text/markdown
47
+
48
+ # pytest-fly
49
+
50
+ pytest observer
51
+
52
+ Installation
53
+ ------------
54
+
55
+ You can install `pytest-fly` via `pip` from `PyPI`:
56
+
57
+ ```
58
+ pip install pytest-fly
59
+ ```
@@ -0,0 +1,13 @@
1
+ pytest_fly/__init__.py,sha256=eIUdSiN4Fg5Rge-MozltxW8SaifDIO35mT9Ni6WQ8qI,86
2
+ pytest_fly/__main__.py,sha256=UaJOPF1c6vlAuRFgxqsJvdPt5YMV9SBn0HmxH6G4jf0,240
3
+ pytest_fly/__version__.py,sha256=Vu3vOZ_k30PP5uukW7uhpYxCgHXh9yYl-eKzlb2D0N4,50
4
+ pytest_fly/db.py,sha256=dgwmy9Lb36W3yVkdrf1qRxGEdAfEJ9KMH3uaUz9fVJ8,7103
5
+ pytest_fly/plugin.py,sha256=z1XAyS86a2uNFcC9M4iS2AETdO82t5PPVwgdSUJDWeg,501
6
+ pytest_fly/report_converter.py,sha256=Zm-dRG8bhCHiCv4DgvKqO52pQsQr4gc-9y3NgSc1k1E,2253
7
+ pytest_fly/utilization.py,sha256=pzKpNWpK_FF1Ocfj7fkd-tZvmc1Jx1X60x3nVbVJsHs,2198
8
+ pytest_fly/visualization.py,sha256=DhaqOnzAUMTyNizVvgF0ayOJFTn7wziFnne-13UeYYk,3655
9
+ pytest_fly-0.0.1.dist-info/METADATA,sha256=hhHg3k41Zb9ryGJElMVI34VPrsreMfE-JxZKuC4gPAk,2279
10
+ pytest_fly-0.0.1.dist-info/WHEEL,sha256=as-1oFTWSeWBgyzh0O_qF439xqBe6AbBgt4MfYe5zwY,87
11
+ pytest_fly-0.0.1.dist-info/entry_points.txt,sha256=V1GWjglijPbLnVLJ5o6y7fSh75rIbgoT4ShTkF53xic,35
12
+ pytest_fly-0.0.1.dist-info/licenses/LICENSE,sha256=GRULexapWa5z0SwQbuxdiK3Kj-FPOWkXshLFGzkOWG4,1100
13
+ pytest_fly-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.22.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ fly = pytest_fly.plugin
@@ -0,0 +1,22 @@
1
+
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2024 James Abel
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.