interlocking 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.
@@ -0,0 +1,428 @@
1
+ """
2
+ interlocking.station
3
+ ======================
4
+ Station の抽象基底クラスと LocalStation の実装。
5
+
6
+ ストレージバックエンドの差し替えは Station を継承して実現する。
7
+ 現在提供する実装:
8
+ - LocalStation: Windows/Linux ローカルファイルロック(単一ホスト専用)
9
+ - S3Station: S3 Conditional Write による分散ロック(複数ホスト対応)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import sys
17
+ import time
18
+ import uuid
19
+ from abc import ABC, abstractmethod
20
+ from pathlib import Path
21
+ from typing import Dict
22
+
23
+ from .models import SignalStatus, TrainRecord
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # 確認中にロックが入れ替わった場合のやり直し回数。確定できなければ止める側に倒す。
28
+ _MAX_ATTEMPTS = 5
29
+
30
+
31
+ class Station(ABC):
32
+ """ストレージバックエンドの抽象基底クラス。
33
+
34
+ 信号(排他ロック)と駅の記録(JSON)の読み書きを担う。
35
+ サブクラスで Local / S3 などを実装する。
36
+
37
+ Station はロック状態の報告だけを責務とする。引き継ぎを許可するか、
38
+ 例外を送出するかといった判断は Train が行う。
39
+ """
40
+
41
+ @abstractmethod
42
+ def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
43
+ """信号の状態を確認し、青信号ならロックを取得する。
44
+
45
+ 確認と取得は原子的に行う(分散環境で競合しないため)。
46
+
47
+ Args:
48
+ track_id: タスク識別子("line_name:track_name" 形式)。
49
+ stale_after: この秒数が経過したロックを stale(前回ロックの残骸)とみなす。
50
+ 時間で判定しない実装(OS ロック等)は無視してよい。
51
+
52
+ Returns:
53
+ CLEAR: ロックを取得した。
54
+ BLOCKED: 他の列車が運行中。ロックは取得していない。
55
+ STALE: 前回ロックの残骸。ロックは取得していない(引き継ぎは takeover_signal)。
56
+ """
57
+
58
+ @abstractmethod
59
+ def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
60
+ """stale なロックを引き継いで取得する。
61
+
62
+ 複数のプロセスが同時に引き継ごうとしても、取得できるのは 1 つだけ。
63
+
64
+ Returns:
65
+ True: 引き継いでロックを取得した。
66
+ False: 取得できなかった(他のプロセスが先に取得した、または stale ではなくなった)。
67
+ """
68
+
69
+ @abstractmethod
70
+ def clear_signal(self, track_id: str) -> None:
71
+ """信号を解放する(Unlock)。"""
72
+
73
+ @abstractmethod
74
+ def abort(self, track_id: str) -> None:
75
+ """信号を強制解除する。
76
+
77
+ 対象タスクのプロセスが終了済みであることを確認してから実行すること。
78
+ プロセスが生きている状態では効果がない場合がある(実装依存)。
79
+ """
80
+
81
+ @abstractmethod
82
+ def load_record(self, track_id: str) -> TrainRecord:
83
+ """列車の運行記録を読み込む。
84
+
85
+ ファイルが存在しない場合(始発)は run_id=None の空の TrainRecord を返す。
86
+ """
87
+
88
+ @abstractmethod
89
+ def save_record(self, track_id: str, record: TrainRecord) -> None:
90
+ """列車の運行記録を保存する。"""
91
+
92
+
93
+ class LocalStation(Station):
94
+ """Windows/Linux ローカル環境の駅。
95
+
96
+ OS のファイルロックで排他制御を行う。
97
+ 単一ホスト専用。複数ホストからの同一タスク実行を防ぐには
98
+ S3Station を使用すること。
99
+
100
+ Args:
101
+ base_path: シグナルファイルと記録ファイルを格納するディレクトリ。
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ base_path: str = ".interlocking",
107
+ ) -> None:
108
+ self.base_path = Path(base_path)
109
+ self.base_path.mkdir(parents=True, exist_ok=True)
110
+ self._signals: Dict[str, int] = {} # track_id -> ファイルディスクリプタ
111
+
112
+ def _get_paths(self, track_id: str) -> tuple[Path, Path]:
113
+ """シグナルファイルと記録ファイルのパスを返す。"""
114
+ safe_id = track_id.replace(":", "_").replace("/", "_")
115
+ return (
116
+ self.base_path / f"{safe_id}.signal",
117
+ self.base_path / f"{safe_id}.json",
118
+ )
119
+
120
+ @staticmethod
121
+ def _try_lock(fd: int) -> bool:
122
+ """fd の OS ロックを非ブロッキングで試みる。取得できたら True。"""
123
+ try:
124
+ if sys.platform == "win32":
125
+ import msvcrt
126
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
127
+ else:
128
+ import fcntl
129
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
130
+ except OSError:
131
+ return False
132
+ return True
133
+
134
+ @staticmethod
135
+ def _unlock(fd: int) -> None:
136
+ if sys.platform == "win32":
137
+ import msvcrt
138
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
139
+ else:
140
+ import fcntl
141
+ fcntl.flock(fd, fcntl.LOCK_UN)
142
+
143
+ @staticmethod
144
+ def _is_same_file(fd: int, path: Path) -> bool:
145
+ """ロックした fd が、パス上のファイルと同一か。ロック中に削除・再作成されていないことの確認。"""
146
+ try:
147
+ on_path = os.stat(path)
148
+ except FileNotFoundError:
149
+ return False
150
+ held = os.fstat(fd)
151
+ return (held.st_ino, held.st_dev) == (on_path.st_ino, on_path.st_dev)
152
+
153
+ def _probe_existing(self, sig_path: Path) -> SignalStatus | None:
154
+ """既存のシグナルファイルの状態を調べる(ロックは保持しない)。
155
+
156
+ OS ロックが有効なら実行中(BLOCKED)。ロックが取れるなら、プロセスが
157
+ 消失してファイルだけが残っている(STALE)。確認中にファイルが
158
+ 入れ替わった場合は None(やり直し)。
159
+ """
160
+ try:
161
+ fd = os.open(sig_path, os.O_RDWR)
162
+ except FileNotFoundError:
163
+ return None
164
+ except PermissionError:
165
+ return SignalStatus.BLOCKED # 確認できないときは止める側に倒す
166
+ try:
167
+ if not self._try_lock(fd):
168
+ return SignalStatus.BLOCKED
169
+ try:
170
+ return SignalStatus.STALE if self._is_same_file(fd, sig_path) else None
171
+ finally:
172
+ self._unlock(fd)
173
+ finally:
174
+ os.close(fd)
175
+
176
+ def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
177
+ """シグナルファイルと OS ロックで、信号の状態を確認する。
178
+
179
+ OS ロックが有効な間は、経過時間によらず BLOCKED(実行中の Busy とみなす)。
180
+ プロセスが消失してファイルだけが残っている場合のみ STALE。
181
+ 時間判定は使わないため、stale_after は無視する。
182
+ """
183
+ sig_path, _ = self._get_paths(track_id)
184
+ for _ in range(_MAX_ATTEMPTS):
185
+ try:
186
+ fd = os.open(sig_path, os.O_RDWR | os.O_CREAT | os.O_EXCL)
187
+ except FileExistsError:
188
+ status = self._probe_existing(sig_path)
189
+ if status is None:
190
+ continue # 確認中にファイルが入れ替わった → やり直し
191
+ return status
192
+ if self._try_lock(fd):
193
+ self._signals[track_id] = fd
194
+ return SignalStatus.CLEAR
195
+ os.close(fd)
196
+ return SignalStatus.BLOCKED
197
+ return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
198
+
199
+ def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
200
+ """プロセスが消失して残ったシグナルファイルのロックを取得する。
201
+
202
+ OS ロックが取れた場合のみ成功する(実行中のプロセスのロックは奪えない)。
203
+ """
204
+ sig_path, _ = self._get_paths(track_id)
205
+ try:
206
+ fd = os.open(sig_path, os.O_RDWR | os.O_CREAT)
207
+ except OSError:
208
+ return False
209
+ if not self._try_lock(fd):
210
+ os.close(fd)
211
+ return False
212
+ if not self._is_same_file(fd, sig_path):
213
+ self._unlock(fd)
214
+ os.close(fd)
215
+ return False
216
+ self._signals[track_id] = fd
217
+ return True
218
+
219
+ def clear_signal(self, track_id: str) -> None:
220
+ """ロックを解除してファイルディスクリプタを閉じ、シグナルファイルを削除する。"""
221
+ fd = self._signals.pop(track_id, None)
222
+ if fd is None:
223
+ return
224
+ try:
225
+ self._unlock(fd)
226
+ finally:
227
+ os.close(fd)
228
+ sig_path, _ = self._get_paths(track_id)
229
+ try:
230
+ sig_path.unlink()
231
+ except OSError:
232
+ pass # 削除できなかった場合は残骸として STALE と判定される(止める側に倒す)
233
+
234
+ def abort(self, track_id: str) -> None:
235
+ """シグナルファイルを削除する。
236
+
237
+ 対象プロセスが既に終了していれば成功する。
238
+ プロセスが生きている場合、削除に失敗する可能性がある(Windows)、
239
+ またはファイルは削除できてもロックが残る(Linux)。
240
+
241
+ 想定フロー:
242
+ 1. タスクプロセスの異常終了を確認
243
+ 2. プロセスが完全に終了していることを確認
244
+ 3. この abort を実行してシグナルファイルを削除
245
+ """
246
+ self.clear_signal(track_id) # fd が残っていれば解放
247
+ # fd がなくても強制削除を試みる
248
+ sig_path, _ = self._get_paths(track_id)
249
+ if sig_path.exists():
250
+ sig_path.unlink()
251
+
252
+ def load_record(self, track_id: str) -> TrainRecord:
253
+ """記録ファイルを読み込む。存在しない場合(始発)は空の TrainRecord を返す。"""
254
+ _, record_path = self._get_paths(track_id)
255
+ if record_path.exists():
256
+ with open(record_path, "r", encoding="utf-8") as f:
257
+ try:
258
+ return TrainRecord.from_dict(json.load(f))
259
+ except json.JSONDecodeError:
260
+ raise ValueError(f"記録ファイルの読み込みに失敗: {record_path}")
261
+ return TrainRecord(track_id=track_id) # run_id=None → 始発
262
+
263
+ def save_record(self, track_id: str, record: TrainRecord) -> None:
264
+ """記録ファイルに保存する。"""
265
+ _, record_path = self._get_paths(track_id)
266
+ # 書き込み前にシリアライズし、失敗したときに記録ファイルを空にしない。
267
+ payload = json.dumps(record.to_dict(), ensure_ascii=False, indent=2)
268
+ with open(record_path, "w", encoding="utf-8") as f:
269
+ f.write(payload)
270
+
271
+
272
+ class S3Station(Station):
273
+ """S3 を使った分散環境対応の駅。
274
+
275
+ シグナル(排他ロック)に S3 Conditional Write(IfNoneMatch='*')を使用する。
276
+ 同一オブジェクトが存在しない場合のみ PUT が成功するため
277
+ 複数ホストからの同時実行を防ぐことができる。
278
+
279
+ 必要な S3 バケット権限:
280
+ s3:PutObject, s3:DeleteObject, s3:GetObject
281
+
282
+ Args:
283
+ bucket: S3 バケット名。
284
+ prefix: オブジェクトキーのプレフィックス(末尾スラッシュなし)。
285
+ boto3_client: boto3 の S3 クライアント。None の場合はデフォルト認証で生成。
286
+
287
+ 使用例::
288
+
289
+ import boto3
290
+ station = S3Station(
291
+ bucket="my-bucket",
292
+ prefix="interlocking/prod",
293
+ boto3_client=boto3.client("s3", region_name="ap-northeast-1"),
294
+ )
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ bucket: str,
300
+ prefix: str = "interlocking",
301
+ boto3_client=None,
302
+ ) -> None:
303
+ self.bucket = bucket
304
+ self.prefix = prefix.rstrip("/")
305
+ if boto3_client is not None:
306
+ self._s3 = boto3_client
307
+ else:
308
+ import boto3 as _boto3
309
+ self._s3 = _boto3.client("s3")
310
+
311
+ def _signal_key(self, track_id: str) -> str:
312
+ safe_id = track_id.replace(":", "_").replace("/", "_")
313
+ return f"{self.prefix}/{safe_id}.signal"
314
+
315
+ def _record_key(self, track_id: str) -> str:
316
+ safe_id = track_id.replace(":", "_").replace("/", "_")
317
+ return f"{self.prefix}/{safe_id}.json"
318
+
319
+ def _put_signal(self, key: str, **condition) -> bool:
320
+ """条件付き PUT でシグナルオブジェクトを書く。競合に負けたら False。
321
+
322
+ 本文は毎回ユニークにする。空の本文だと ETag(MD5)が常に同じになり、
323
+ IfMatch の比較が何も区別しなくなる。
324
+ """
325
+ from botocore.exceptions import ClientError
326
+ try:
327
+ self._s3.put_object(
328
+ Bucket=self.bucket,
329
+ Key=key,
330
+ Body=json.dumps({"token": uuid.uuid4().hex}).encode("utf-8"),
331
+ ContentType="application/json",
332
+ **condition,
333
+ )
334
+ return True
335
+ except ClientError as e:
336
+ if e.response["Error"]["Code"] in (
337
+ "PreconditionFailed", "ConditionalRequestConflict", "NoSuchKey",
338
+ ):
339
+ return False
340
+ raise
341
+
342
+ def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
343
+ """S3 Conditional Write でシグナルオブジェクトを排他生成する。
344
+
345
+ IfNoneMatch='*' により、オブジェクトが存在しない場合のみ PUT が成功する
346
+ (CLEAR)。既に存在する場合は、最終更新からの経過時間で判定する。
347
+
348
+ - stale_after 未満: BLOCKED(実行中)。
349
+ - stale_after 以上: STALE(前回ロックの残骸)。ロックは取得しない。
350
+ """
351
+ from botocore.exceptions import ClientError
352
+ key = self._signal_key(track_id)
353
+ for _ in range(_MAX_ATTEMPTS):
354
+ if self._put_signal(key, IfNoneMatch="*"):
355
+ return SignalStatus.CLEAR
356
+ try:
357
+ response = self._s3.head_object(Bucket=self.bucket, Key=key)
358
+ except ClientError as e:
359
+ if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
360
+ continue # 確認中に解放された → 取得をやり直す
361
+ raise
362
+ elapsed = time.time() - response["LastModified"].timestamp()
363
+ return SignalStatus.STALE if elapsed >= stale_after else SignalStatus.BLOCKED
364
+ return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
365
+
366
+ def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
367
+ """stale なシグナルオブジェクトを、ETag を条件に上書きして引き継ぐ。
368
+
369
+ IfMatch=ETag の条件付き PUT なので、複数プロセスが同時に引き継ごうとしても
370
+ 成功するのは 1 つだけ。削除してから再取得する方式と違い、他のプロセスが
371
+ 取得した新しいロックを消すことがない。
372
+ """
373
+ from botocore.exceptions import ClientError
374
+ key = self._signal_key(track_id)
375
+ try:
376
+ response = self._s3.head_object(Bucket=self.bucket, Key=key)
377
+ except ClientError as e:
378
+ if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
379
+ return self._put_signal(key, IfNoneMatch="*") # 既に解放済み
380
+ raise
381
+ elapsed = time.time() - response["LastModified"].timestamp()
382
+ if elapsed < stale_after:
383
+ return False # 更新されて stale ではなくなった
384
+ return self._put_signal(key, IfMatch=response["ETag"])
385
+
386
+ def clear_signal(self, track_id: str) -> None:
387
+ """シグナルオブジェクトを削除してロックを解放する。"""
388
+ from botocore.exceptions import ClientError
389
+ try:
390
+ self._s3.delete_object(
391
+ Bucket=self.bucket,
392
+ Key=self._signal_key(track_id),
393
+ )
394
+ except ClientError:
395
+ pass # 既に削除済みの場合は無視する
396
+
397
+ def abort(self, track_id: str) -> None:
398
+ """シグナルオブジェクトを削除する。
399
+
400
+ S3版では他ホストで実行中のタスクも強制停止できる。
401
+ タスクプロセス側で signal を確認せずに実行を続けることがあるため、
402
+ 使用には注意が必要(プロセスの終了を確認してから実行することを推奨)。
403
+ """
404
+ self.clear_signal(track_id)
405
+
406
+ def load_record(self, track_id: str) -> TrainRecord:
407
+ """記録オブジェクトを読み込む。存在しない場合(始発)は空の TrainRecord を返す。"""
408
+ from botocore.exceptions import ClientError
409
+ try:
410
+ response = self._s3.get_object(
411
+ Bucket=self.bucket,
412
+ Key=self._record_key(track_id),
413
+ )
414
+ data = json.loads(response["Body"].read().decode("utf-8"))
415
+ return TrainRecord.from_dict(data)
416
+ except ClientError as e:
417
+ if e.response["Error"]["Code"] == "NoSuchKey":
418
+ return TrainRecord(track_id=track_id) # run_id=None → 始発
419
+ raise
420
+
421
+ def save_record(self, track_id: str, record: TrainRecord) -> None:
422
+ """記録オブジェクトを保存する。"""
423
+ self._s3.put_object(
424
+ Bucket=self.bucket,
425
+ Key=self._record_key(track_id),
426
+ Body=json.dumps(record.to_dict(), ensure_ascii=False, indent=2).encode("utf-8"),
427
+ ContentType="application/json",
428
+ )
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: interlocking
3
+ Version: 0.0.1
4
+ Summary: A lightweight task dispatcher with explicit control.
5
+ Keywords: task-runner,batch,exclusive-lock,cli,mutex,distributed-lock
6
+ Author: alpine
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Classifier: Topic :: System :: Distributed Computing
13
+ Classifier: Topic :: Utilities
14
+ Requires-Dist: boto3>1.40.0,<2 ; extra == 'aws'
15
+ Requires-Python: >=3.11
16
+ Provides-Extra: aws
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Interlocking
20
+
21
+ > One track, one train.
22
+ > Easy to run. Explicit to control.
23
+
24
+ interlocking は、分散環境におけるバックグラウンドタスクの「運行管理」フレームワークです。
25
+ 鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
26
+
27
+ ## 特長
28
+
29
+ - **排他制御**: 同じタスクを同時に走らせない。
30
+ - **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
31
+ - **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
32
+ - **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
33
+
34
+ ## 最小構成
35
+
36
+ ```python
37
+ # tasks.py
38
+ from typing import Annotated
39
+ from interlocking import Line, Coupler
40
+
41
+ line = Line()
42
+
43
+ @line.track(name="daily", help="日次処理")
44
+ def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
45
+ last_id = c.last_data.get("last_id", 0)
46
+ # ... 処理 ...
47
+ return {"last_id": 9999}
48
+ ```
49
+
50
+ ```python
51
+ # main.py
52
+ import sys
53
+
54
+ from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
55
+
56
+ dispatcher = TrainDispatcher(
57
+ station=LocalStation(base_path=".interlocking"),
58
+ lines={"batch": "tasks:line"},
59
+ )
60
+
61
+ cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
62
+ sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
63
+ ```
64
+
65
+ ```bash
66
+ python main.py batch daily start --target-date 2026-04-01
67
+ ```
68
+
69
+ 詳しくは [docs/user/interlocking.md](docs/user/interlocking.md) を参照してください。
70
+
71
+ ## 開発
72
+
73
+ ```bash
74
+ uv sync
75
+ uv run pytest
76
+ ```
77
+
78
+ 設計方針は [.github/copilot-instructions.md](.github/copilot-instructions.md) の Design Principles、決定事項は [docs/dev/adr/](docs/dev/adr/) を参照してください。
79
+
80
+ ## ライセンス
81
+
82
+ MIT
@@ -0,0 +1,12 @@
1
+ interlocking/__init__.py,sha256=ZS0ZBqZu_XX9xnOgTF4Pw0CqvtLKkXEuLwOLSs29bFw,1074
2
+ interlocking/cli.py,sha256=JjMxLI5mPgnCc2CDk6ODOqWkVFApwSM-V9gyQaxN8K0,11084
3
+ interlocking/dispatcher.py,sha256=BUhs4cr7VumvJWV475fGqZUxtmYLMptPHCocrBVtmZY,19149
4
+ interlocking/lambda_.py,sha256=gcBllml6N57lGaA7Y4YoBJOrNd7qHsgNu3Aj-HA6yM8,8553
5
+ interlocking/line.py,sha256=-lf2bx7w9XMgbXPoKYOGampEY2VtXB96JyX-qZq4e1E,2756
6
+ interlocking/models.py,sha256=HESGIva7x6hfcZ7cZYrIVn7XY9VBWZ6_ehS95rtILiY,7847
7
+ interlocking/station.py,sha256=MUPhTKlNc72kIJXcBQhaB-8a08l14iCuiQOXwAomVkM,17994
8
+ interlocking-0.0.1.dist-info/licenses/LICENSE,sha256=IkU_8deAc2SO0UoX4mX_Op7eee_2Aqc68joPEIeywVs,1063
9
+ interlocking-0.0.1.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
10
+ interlocking-0.0.1.dist-info/entry_points.txt,sha256=i2oN8Ae25gU-XoxtDndkB7we3AiSvh8-LPe0e6_tgrM,19
11
+ interlocking-0.0.1.dist-info/METADATA,sha256=gYUG7lAmNo1CD7GYKZzkV-dDrFlhx3p15k9JEkzzfEo,2599
12
+ interlocking-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alpine
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.