swbt-python 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.
swbt/transport/fake.py ADDED
@@ -0,0 +1,315 @@
1
+ """In-memory HID transport for integration tests."""
2
+
3
+ import asyncio
4
+ from typing import Literal
5
+
6
+ from swbt.errors import ClosedError, InvalidKeyStoreError
7
+ from swbt.transport.base import (
8
+ BondedPeer,
9
+ ConnectedCallback,
10
+ ControlDataCallback,
11
+ DisconnectedCallback,
12
+ DisconnectRequestResult,
13
+ InterruptDataCallback,
14
+ )
15
+
16
+
17
+ class FakeHidTransport:
18
+ """Record transport operations without opening Bluetooth resources."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ disconnect_request_auto_complete: bool = True,
24
+ disconnect_request_error: Exception | None = None,
25
+ bonded_peer_addresses: tuple[str, ...] = (),
26
+ active_reconnect_auto_connect: bool = True,
27
+ active_reconnect_error: BaseException | None = None,
28
+ send_interrupt_error: Exception | None = None,
29
+ close_wait: asyncio.Event | None = None,
30
+ ) -> None:
31
+ """Create a closed fake transport."""
32
+ self._is_open = False
33
+ self._open_count = 0
34
+ self._close_count = 0
35
+ self._disconnect_request_auto_complete = disconnect_request_auto_complete
36
+ self._disconnect_request_error = disconnect_request_error
37
+ self._bonded_peer_addresses = tuple(bonded_peer_addresses)
38
+ self._active_reconnect_auto_connect = active_reconnect_auto_connect
39
+ self._active_reconnect_error = active_reconnect_error
40
+ self._send_interrupt_error = send_interrupt_error
41
+ self._close_wait = close_wait
42
+ self._events: list[str] = []
43
+ self._control_channel_open = False
44
+ self._interrupt_channel_open = False
45
+ self._connected_emitted = False
46
+ self._disconnect_request_sent_interrupt_count: int | None = None
47
+ self._sent_interrupt_reports: list[bytes] = []
48
+ self._sent_control_reports: list[bytes] = []
49
+ self._interrupt_report_event = asyncio.Event()
50
+ self._disconnect_request_event = asyncio.Event()
51
+ self._close_start_event = asyncio.Event()
52
+ self._interrupt_callback: InterruptDataCallback | None = None
53
+ self._control_callback: ControlDataCallback | None = None
54
+ self._connected_callback: ConnectedCallback | None = None
55
+ self._disconnected_callback: DisconnectedCallback | None = None
56
+
57
+ @property
58
+ def is_open(self) -> bool:
59
+ """Return whether the fake transport is open."""
60
+ return self._is_open
61
+
62
+ @property
63
+ def open_count(self) -> int:
64
+ """Return the number of state-changing open calls."""
65
+ return self._open_count
66
+
67
+ @property
68
+ def close_count(self) -> int:
69
+ """Return the number of state-changing close calls."""
70
+ return self._close_count
71
+
72
+ @property
73
+ def events(self) -> tuple[str, ...]:
74
+ """Return transport lifecycle events in order."""
75
+ return tuple(self._events)
76
+
77
+ @property
78
+ def sent_interrupt_reports(self) -> tuple[bytes, ...]:
79
+ """Return interrupt reports sent by the gamepad."""
80
+ return tuple(self._sent_interrupt_reports)
81
+
82
+ @property
83
+ def sent_control_reports(self) -> tuple[bytes, ...]:
84
+ """Return control reports sent by the gamepad."""
85
+ return tuple(self._sent_control_reports)
86
+
87
+ @property
88
+ def disconnect_request_sent_interrupt_count(self) -> int | None:
89
+ """Return how many interrupt reports existed when disconnect was requested."""
90
+ return self._disconnect_request_sent_interrupt_count
91
+
92
+ async def open(self) -> None:
93
+ """Open the fake transport."""
94
+ if self._is_open:
95
+ return
96
+ self._is_open = True
97
+ self._open_count += 1
98
+ self._disconnect_request_sent_interrupt_count = None
99
+ self._disconnect_request_event.clear()
100
+ self._close_start_event.clear()
101
+ self._events.append("open")
102
+
103
+ async def start_advertising(self) -> None:
104
+ """Record an advertising transition."""
105
+ self._require_open()
106
+ self._events.append("start_advertising")
107
+
108
+ async def close(self) -> None:
109
+ """Close the fake transport."""
110
+ if not self._is_open:
111
+ return
112
+ self._close_start_event.set()
113
+ if self._close_wait is not None:
114
+ await self._close_wait.wait()
115
+ self._is_open = False
116
+ self._control_channel_open = False
117
+ self._interrupt_channel_open = False
118
+ self._connected_emitted = False
119
+ self._close_count += 1
120
+ self._events.append("close")
121
+
122
+ async def request_disconnect(self) -> DisconnectRequestResult:
123
+ """Record a best-effort remote disconnect request."""
124
+ self._require_open()
125
+ if self._disconnect_request_error is not None:
126
+ error = self._disconnect_request_error
127
+ self._events.append("request_disconnect_failed")
128
+ self._disconnect_request_event.set()
129
+ return DisconnectRequestResult(
130
+ status="failed",
131
+ error_type=type(error).__name__,
132
+ message=str(error),
133
+ )
134
+ if not self._control_channel_open and not self._interrupt_channel_open:
135
+ self._events.append("request_disconnect_unavailable")
136
+ self._disconnect_request_event.set()
137
+ return DisconnectRequestResult(
138
+ status="unavailable",
139
+ reason="channels_not_connected",
140
+ )
141
+ self._disconnect_request_sent_interrupt_count = len(self._sent_interrupt_reports)
142
+ self._events.append("request_disconnect")
143
+ self._disconnect_request_event.set()
144
+ if self._disconnect_request_auto_complete:
145
+ await self.complete_disconnect_request()
146
+ return DisconnectRequestResult(
147
+ status="requested",
148
+ channels=("control", "interrupt"),
149
+ )
150
+
151
+ async def list_bonded_peers(self) -> tuple[BondedPeer, ...]:
152
+ """Return the fake current reconnect candidate configured by a test."""
153
+ self._require_open()
154
+ if len(self._bonded_peer_addresses) > 1:
155
+ msg = "fake transport contains multiple current reconnect candidates"
156
+ raise InvalidKeyStoreError(msg)
157
+ return tuple(BondedPeer(address=address) for address in self._bonded_peer_addresses)
158
+
159
+ async def connect_bonded_peer(
160
+ self,
161
+ peer_address: str,
162
+ *,
163
+ connect_timeout: float | None,
164
+ ) -> None:
165
+ """Record an active reconnect attempt to a fake bonded peer."""
166
+ _ = connect_timeout
167
+ self._require_open()
168
+ if peer_address not in self._bonded_peer_addresses:
169
+ msg = f"unknown bonded peer: {peer_address}"
170
+ raise ValueError(msg)
171
+ self._events.append("active_reconnect")
172
+ if self._active_reconnect_error is not None:
173
+ raise self._active_reconnect_error
174
+ if self._active_reconnect_auto_connect:
175
+ await self.connect()
176
+
177
+ async def wait_for_disconnect_request(self, *, max_wait: float = 0.5) -> None:
178
+ """Wait until a fake remote disconnect request has been recorded."""
179
+ async with asyncio.timeout(max_wait):
180
+ await self._disconnect_request_event.wait()
181
+
182
+ async def wait_for_close_start(self, *, max_wait: float = 0.5) -> None:
183
+ """Wait until fake transport close has started."""
184
+ async with asyncio.timeout(max_wait):
185
+ await self._close_start_event.wait()
186
+
187
+ async def complete_disconnect_request(self, reason: int | None = None) -> None:
188
+ """Emit the host-side close completion for a pending fake request."""
189
+ if not self._control_channel_open and not self._interrupt_channel_open:
190
+ return
191
+ self._require_open()
192
+ self._control_channel_open = False
193
+ self._interrupt_channel_open = False
194
+ self._connected_emitted = False
195
+ self._events.append("disconnect_request_closed")
196
+ if self._disconnected_callback is not None:
197
+ await self._disconnected_callback(reason)
198
+
199
+ async def connect(self) -> None:
200
+ """Emit a fake host connection event."""
201
+ self._require_open()
202
+ self._control_channel_open = True
203
+ self._interrupt_channel_open = True
204
+ await self._emit_connected_once()
205
+
206
+ async def open_l2cap_channel(self, channel: Literal["control", "interrupt"]) -> None:
207
+ """Emit one fake L2CAP channel-open event."""
208
+ self._require_open()
209
+ if channel == "control":
210
+ self._control_channel_open = True
211
+ self._events.append("l2cap_control_open")
212
+ else:
213
+ self._interrupt_channel_open = True
214
+ self._events.append("l2cap_interrupt_open")
215
+ await self._emit_connected_once()
216
+
217
+ async def disconnect(self, reason: int | None = None) -> None:
218
+ """Emit a fake host disconnection event."""
219
+ self._require_open()
220
+ self._control_channel_open = False
221
+ self._interrupt_channel_open = False
222
+ self._connected_emitted = False
223
+ self._events.append("disconnected")
224
+ if self._disconnected_callback is not None:
225
+ await self._disconnected_callback(reason)
226
+
227
+ async def inject_interrupt_data(self, payload: bytes) -> None:
228
+ """Inject host-to-device data into the registered interrupt callback."""
229
+ self._require_open()
230
+ self._events.append("interrupt_rx")
231
+ if self._interrupt_callback is not None:
232
+ await self._interrupt_callback(bytes(payload))
233
+
234
+ async def inject_control_data(self, payload: bytes) -> None:
235
+ """Inject host-to-device data into the registered control callback."""
236
+ self._require_open()
237
+ self._events.append("control_rx")
238
+ if self._control_callback is not None:
239
+ await self._control_callback(bytes(payload))
240
+
241
+ async def send_interrupt(self, payload: bytes) -> None:
242
+ """Record an interrupt report."""
243
+ self._require_open()
244
+ if self._send_interrupt_error is not None:
245
+ raise self._send_interrupt_error
246
+ self._sent_interrupt_reports.append(bytes(payload))
247
+ self._interrupt_report_event.set()
248
+
249
+ async def wait_for_interrupt_report_count(
250
+ self,
251
+ count: int,
252
+ *,
253
+ max_wait: float = 0.5,
254
+ ) -> tuple[bytes, ...]:
255
+ """Wait until at least count interrupt reports have been recorded."""
256
+ async with asyncio.timeout(max_wait):
257
+ while len(self._sent_interrupt_reports) < count:
258
+ self._interrupt_report_event.clear()
259
+ if len(self._sent_interrupt_reports) >= count:
260
+ break
261
+ await self._interrupt_report_event.wait()
262
+ return self.sent_interrupt_reports
263
+
264
+ async def wait_for_interrupt_report_id(
265
+ self,
266
+ report_id: int,
267
+ *,
268
+ max_wait: float = 0.5,
269
+ ) -> bytes:
270
+ """Wait until an interrupt report with report_id has been recorded."""
271
+ async with asyncio.timeout(max_wait):
272
+ while True:
273
+ for report in self._sent_interrupt_reports:
274
+ if report and report[0] == report_id:
275
+ return report
276
+ self._interrupt_report_event.clear()
277
+ await self._interrupt_report_event.wait()
278
+
279
+ async def send_control(self, payload: bytes) -> None:
280
+ """Record a control report."""
281
+ self._require_open()
282
+ self._sent_control_reports.append(bytes(payload))
283
+
284
+ def on_interrupt_data(self, callback: InterruptDataCallback) -> None:
285
+ """Register an interrupt data callback."""
286
+ self._interrupt_callback = callback
287
+
288
+ def on_control_data(self, callback: ControlDataCallback) -> None:
289
+ """Register a control data callback."""
290
+ self._control_callback = callback
291
+
292
+ def on_connected(self, callback: ConnectedCallback) -> None:
293
+ """Register a connection callback."""
294
+ self._connected_callback = callback
295
+
296
+ def on_disconnected(self, callback: DisconnectedCallback) -> None:
297
+ """Register a disconnection callback."""
298
+ self._disconnected_callback = callback
299
+
300
+ def _require_open(self) -> None:
301
+ if not self._is_open:
302
+ msg = "fake transport is not open"
303
+ raise ClosedError(msg)
304
+
305
+ async def _emit_connected_once(self) -> None:
306
+ if (
307
+ self._connected_emitted
308
+ or not self._control_channel_open
309
+ or not self._interrupt_channel_open
310
+ ):
311
+ return
312
+ self._connected_emitted = True
313
+ self._events.append("connected")
314
+ if self._connected_callback is not None:
315
+ await self._connected_callback()
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: swbt-python
3
+ Version: 0.1.0
4
+ Summary: Python library for presenting an NX-compatible virtual Bluetooth HID input device.
5
+ Keywords: bluetooth,bluetooth-hid,controller,gamepad,hid,nx
6
+ Author: niart120
7
+ Author-email: niart120 <38847256+niart120@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: bumble>=0.0.230,<0.0.231
22
+ Maintainer: niart120
23
+ Maintainer-email: niart120 <38847256+niart120@users.noreply.github.com>
24
+ Requires-Python: >=3.12
25
+ Description-Content-Type: text/markdown
26
+
27
+ # swbt-python
28
+
29
+ NX 向けの仮想 Bluetooth HID 入力デバイスを Python から扱うためのライブラリです。
30
+
31
+ 本ライブラリは pre-alpha 版です。実機での動作は Bluetooth adapter、driver、対象機器の firmware に依存します。
32
+
33
+ ## 必要なもの
34
+
35
+ - Python 3.12 以降
36
+ - uv
37
+ - Bumble が利用可能な専用 USB Bluetooth dongle
38
+
39
+ ## インストール
40
+
41
+ ```powershell
42
+ pip install swbt-python
43
+ ```
44
+
45
+ ソースから動かす場合は次を使います。
46
+
47
+ ```powershell
48
+ uv sync --dev
49
+ ```
50
+
51
+ ## ドキュメント
52
+
53
+ [公開ドキュメント](https://niart120.github.io/swbt-python/) には API、利用例、実機準備、AI エージェント向け要約があります。
54
+
55
+ - API 仕様: [API Reference](https://niart120.github.io/swbt-python/api/)
56
+ - 利用例: [Usage Guide](https://niart120.github.io/swbt-python/usage/)
57
+ - 実機構成と troubleshooting: [Hardware Guide](https://niart120.github.io/swbt-python/hardware/)
58
+ - AI エージェント向け要約: [Agent Brief](https://niart120.github.io/swbt-python/agent-brief/)
59
+
60
+ リポジトリを checkout している場合、同じ内容は `docs/` 配下でも確認できます。
61
+
62
+ ## 利用例
63
+
64
+ ```python
65
+ import asyncio
66
+ from swbt import Button, SwitchGamepad
67
+
68
+
69
+ async def main() -> None:
70
+ async with SwitchGamepad(
71
+ adapter="usb:0",
72
+ key_store_path="switch-bond.json",
73
+ ) as pad:
74
+ await pad.connect(
75
+ timeout=30.0,
76
+ allow_pairing=True,
77
+ )
78
+ await pad.tap(Button.A)
79
+ await pad.neutral()
80
+
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ この例は専用 Bluetooth adapter を使い、HID advertising、pairing または reconnect、periodic report loop、入力送信を行います。専用 USB Bluetooth dongle と接続情報のファイルパスを指定し、終了時は neutral を送ってから接続を閉じます。
86
+
87
+ 接続方法、`key_store_path`、入力 API の使い分けは [Usage Guide](https://niart120.github.io/swbt-python/usage/) にあります。
88
+
89
+ ## 実機検証
90
+
91
+ 実機接続には、PC の通常 Bluetooth 機能と共有しない専用 USB Bluetooth dongle と、OS ごとの driver 準備が必要です。Windows では、[Zadig](https://zadig.akeo.ie/) などで専用 dongle に WinUSB / libwdi driver を入れてから adapter 名を確認します。
92
+
93
+ driver 準備、adapter 名の確認、troubleshooting は [Hardware Guide](https://niart120.github.io/swbt-python/hardware/) にあります。実機ログの正本は [hardware-test-log](https://niart120.github.io/swbt-python/hardware-test-log/) です。
94
+
95
+ ### 確認済み構成
96
+
97
+ 2026-07-04 時点では、Windows 11 / CSR8510 A10 / WinUSB / Switch 2 firmware 22.1.0 で、pairing、reconnect、Button A、D-pad、left / right stick、neutral 後の入力残りなしを確認済みです。adapter 名の例は `usb:0` です。
98
+
99
+ ### 試験的構成
100
+
101
+ Linux / macOS は experimental です。手順は Hardware Guide に整備されていますが、動作検証されていないことに留意してください。adapter が開けるか、pairing できるか、入力が反映されるかは未確認です。
102
+
103
+ CSR8510 A10 以外の Bluetooth dongle、Switch 2 firmware 22.1.0 以外の対象機器は確認済み構成に含めていません。
104
+
105
+ ## 開発
106
+
107
+ ```powershell
108
+ uv sync --dev
109
+ uv run ruff format --check .
110
+ uv run ruff check .
111
+ uv run ty check --no-progress
112
+ uv run pytest tests/unit
113
+ uv run pytest tests/integration
114
+ ```
115
+
116
+ ## ライセンス
117
+
118
+ MIT ライセンスです。全文は [LICENSE](https://github.com/niart120/swbt-python/blob/main/LICENSE) にあります。
119
+
120
+ ## 注記
121
+
122
+ このプロジェクトは、対象機器や関連商標の権利者から承認、後援、提携を受けたものではありません。
@@ -0,0 +1,34 @@
1
+ swbt/__init__.py,sha256=PuZgNf9fGz3pj6M4n6UJk3FmJBCZ_7ScVS2yH7cGvR4,977
2
+ swbt/diagnostics.py,sha256=vEdPBA82D-de7KpJZ3uc6nGQJHTxCx2LJrWKYfaYF-M,6839
3
+ swbt/errors.py,sha256=4GESTg-mBGYMHPpyiZ9FtcZGDOXH5nkFV8q0bT-MylY,848
4
+ swbt/gamepad/__init__.py,sha256=2lbuvAZd7L5nErkQyaoDIF5TaSDgX_HdAxMX2j7nzmY,362
5
+ swbt/gamepad/connection.py,sha256=eqQ2CIcbhJaT9UpAtqOKsdwObQoPlRrfBwvBJcSboSI,7477
6
+ swbt/gamepad/core.py,sha256=BeAGQWkoE9fMnLoulnrENyYB3iYF5v81kHm6Lw6kFAU,26994
7
+ swbt/gamepad/output.py,sha256=2L1yWjGGgEfG4rhc2q2tlKVW9Q6r5K_tg6NmjbMkgy4,2805
8
+ swbt/gamepad/transport_factory.py,sha256=ezw0Xa-zTcFZneFOF6uFlZJG8JSmyzQWbnJA0dFnLnY,666
9
+ swbt/input.py,sha256=wQdyNqlzv8yi95damb9eGE-sR0VHFMqRqVdUdG_Cwoo,17427
10
+ swbt/probe.py,sha256=E-N84PKrENvT7pm8ZxGe_Er5kHOievDimqC4O0GqXXY,4479
11
+ swbt/protocol/__init__.py,sha256=Uvdlp06CBsKDivW1juPY-T5LaqJVCF00MbQaHl13XPo,35
12
+ swbt/protocol/input_report.py,sha256=bXUFr7B0RWyFAxZXOs4D5ekOdDSm8q_H2F_V86KHJDY,2459
13
+ swbt/protocol/output_report.py,sha256=00d-QZRBWFJ9k5ZLl5I0nSAe2pZY0oR50Z0YvZ2_YWs,1751
14
+ swbt/protocol/profile.py,sha256=sIjRDrD3kvIiCNfbrc_5cuzTSiffrXg9cEp_l_qkYKQ,3340
15
+ swbt/protocol/rumble.py,sha256=94Qq4gstmU5KlER-i0eW3mSr1HKY3CgArdz9D5gpqso,584
16
+ swbt/protocol/spi.py,sha256=zBXti5vyZ0b5_cw_m_xGEWuxt-iTYejfX0D-PV9nR_E,1347
17
+ swbt/protocol/subcommand.py,sha256=STXPKN1sCYSxvVtykuvO_PMBjX6OdMrPcBol07N3v1M,3825
18
+ swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
19
+ swbt/report_loop.py,sha256=W9cEZjs7VHpIyJ1WZwwmDeGWGiiJN4IE4ksEIhNgjps,4422
20
+ swbt/state_store.py,sha256=06niyjEJ9Z9ht3aJCSCvhe5R2dCmKCKuoKFafBp-JyE,2172
21
+ swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
22
+ swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
23
+ swbt/transport/_bumble_hidp.py,sha256=mL_eT-otopHzg5G0p7uhqTYsqSuDwdXAiAwakQiHbCY,835
24
+ swbt/transport/_bumble_key_store.py,sha256=r8rFJ1QvhnBwczEnFABE2z66YFkXU_aEu64ihHec96s,7605
25
+ swbt/transport/_bumble_lifecycle.py,sha256=DHEMbZOfRLHD9jF62c2zLAn-TuFLk-8WzE1OdTSxoKI,11187
26
+ swbt/transport/_bumble_sdp.py,sha256=swD9yhqrgjqOOpT0-rgBdtiFX1VmKjS3A6TWPu6mV0k,8132
27
+ swbt/transport/base.py,sha256=wv3HOhv8CDWbX0T8f687DtH01gh8TW4gsCWwIi5tKH4,2885
28
+ swbt/transport/bumble.py,sha256=6tzI4Pn3W1ndsbAo5LO7-7v7MfU8N2pRHeOLIvkoo4Q,27524
29
+ swbt/transport/fake.py,sha256=Y5gTiOrmldkxp0xdG5SxYgTXpeLW8pRRRvb3F7HL9qE,12542
30
+ swbt_python-0.1.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
31
+ swbt_python-0.1.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
32
+ swbt_python-0.1.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
33
+ swbt_python-0.1.0.dist-info/METADATA,sha256=P4iKDIXfy6Xi6Ol_HRHF_w5AHpbyKzfmzR9-bXaUbTw,4811
34
+ swbt_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.26
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ swbt-probe = swbt.probe:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 niart120
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.