git-alternative 0.2.2__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.
- git_alternative/__init__.py +63 -0
- git_alternative/ci.py +162 -0
- git_alternative/cli.py +117 -0
- git_alternative/client.py +72 -0
- git_alternative/exceptions.py +43 -0
- git_alternative/http.py +137 -0
- git_alternative/managers/__init__.py +1 -0
- git_alternative/managers/issues.py +292 -0
- git_alternative/managers/repos.py +218 -0
- git_alternative/models.py +291 -0
- git_alternative/pagination.py +45 -0
- git_alternative/resources/__init__.py +1 -0
- git_alternative/resources/issues.py +291 -0
- git_alternative/resources/labels.py +91 -0
- git_alternative/resources/pipelines.py +118 -0
- git_alternative/resources/pulls.py +207 -0
- git_alternative/resources/repos.py +184 -0
- git_alternative/resources/ssh_keys.py +64 -0
- git_alternative/ssh_keys.py +80 -0
- git_alternative/utils.py +91 -0
- git_alternative/webhooks.py +104 -0
- git_alternative-0.2.2.dist-info/METADATA +36 -0
- git_alternative-0.2.2.dist-info/RECORD +26 -0
- git_alternative-0.2.2.dist-info/WHEEL +4 -0
- git_alternative-0.2.2.dist-info/entry_points.txt +3 -0
- git_alternative-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SSH key utilities for the Forge platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
from git_alternative.exceptions import ForgeInvalidSshKeyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute_key_fingerprint(public_key_str: str) -> str:
|
|
12
|
+
"""Compute the SHA256 fingerprint of an SSH public key.
|
|
13
|
+
|
|
14
|
+
Parses the key material (ignoring key type prefix variations and comment
|
|
15
|
+
fields) and returns the fingerprint in OpenSSH format: ``SHA256:<base64>``.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
public_key_str: The full SSH public key string, e.g.
|
|
19
|
+
``"ssh-ed25519 AAAA... optional-comment"``.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The fingerprint string in the format ``"SHA256:<base64>"``.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ForgeInvalidSshKeyError: If the key is malformed or contains invalid
|
|
26
|
+
base64 data.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> fp = compute_key_fingerprint("ssh-ed25519 AAAA... my-laptop")
|
|
30
|
+
>>> fp.startswith("SHA256:")
|
|
31
|
+
True
|
|
32
|
+
"""
|
|
33
|
+
parts = public_key_str.strip().split()
|
|
34
|
+
if len(parts) < 2:
|
|
35
|
+
raise ForgeInvalidSshKeyError("Malformed public key: expected at least 2 fields")
|
|
36
|
+
|
|
37
|
+
key_type = parts[0]
|
|
38
|
+
valid_key_types = {
|
|
39
|
+
"ssh-rsa",
|
|
40
|
+
"ssh-dss",
|
|
41
|
+
"ssh-ed25519",
|
|
42
|
+
"ecdsa-sha2-nistp256",
|
|
43
|
+
"ecdsa-sha2-nistp384",
|
|
44
|
+
"ecdsa-sha2-nistp521",
|
|
45
|
+
"sk-ssh-ed25519@openssh.com",
|
|
46
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
47
|
+
}
|
|
48
|
+
if key_type not in valid_key_types:
|
|
49
|
+
raise ForgeInvalidSshKeyError(f"Unsupported key type: {key_type!r}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
key_bytes = base64.b64decode(parts[1])
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise ForgeInvalidSshKeyError(f"Invalid base64 in key: {exc}") from exc
|
|
55
|
+
|
|
56
|
+
digest = hashlib.sha256(key_bytes).digest()
|
|
57
|
+
# OpenSSH omits padding in the fingerprint base64
|
|
58
|
+
b64 = base64.b64encode(digest).decode().rstrip("=")
|
|
59
|
+
return f"SHA256:{b64}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_public_key(public_key_str: str) -> str:
|
|
63
|
+
"""Return the canonical form of an SSH public key (type + base64 material only).
|
|
64
|
+
|
|
65
|
+
Strips the optional comment field so that keys with different comments but
|
|
66
|
+
the same key material are treated as identical.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
public_key_str: The full SSH public key string.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Normalized key string: ``"<type> <base64>"``.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ForgeInvalidSshKeyError: If the key is malformed.
|
|
76
|
+
"""
|
|
77
|
+
parts = public_key_str.strip().split()
|
|
78
|
+
if len(parts) < 2:
|
|
79
|
+
raise ForgeInvalidSshKeyError("Malformed public key: expected at least 2 fields")
|
|
80
|
+
return f"{parts[0]} {parts[1]}"
|
git_alternative/utils.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Utility functions for the git-alternative SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
from git_alternative.exceptions import ForgeSshKeyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute_key_fingerprint(public_key_str: str) -> str:
|
|
12
|
+
"""Compute the SHA-256 fingerprint of an SSH public key.
|
|
13
|
+
|
|
14
|
+
Parses the key material from a standard OpenSSH public key string,
|
|
15
|
+
ignoring the key type prefix and optional comment field. The fingerprint
|
|
16
|
+
is returned in OpenSSH format: ``SHA256:<base64>``.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
public_key_str: An OpenSSH public key string, e.g.
|
|
20
|
+
``ssh-rsa AAAA... optional-comment``
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The SHA-256 fingerprint in OpenSSH format, e.g.
|
|
24
|
+
``SHA256:abc123...``
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ForgeSshKeyError: If the key string is malformed or contains
|
|
28
|
+
invalid base64 data.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> fingerprint = compute_key_fingerprint("ssh-ed25519 AAAA... mykey")
|
|
32
|
+
>>> fingerprint.startswith("SHA256:")
|
|
33
|
+
True
|
|
34
|
+
"""
|
|
35
|
+
parts = public_key_str.strip().split()
|
|
36
|
+
if len(parts) < 2:
|
|
37
|
+
raise ForgeSshKeyError("Malformed public key: expected at least 2 space-separated parts")
|
|
38
|
+
|
|
39
|
+
key_b64 = parts[1]
|
|
40
|
+
try:
|
|
41
|
+
key_bytes = base64.b64decode(key_b64)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise ForgeSshKeyError(f"Invalid base64 in SSH key: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
digest = hashlib.sha256(key_bytes).digest()
|
|
46
|
+
encoded = base64.b64encode(digest).decode("ascii")
|
|
47
|
+
return f"SHA256:{encoded}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def next_retry_delay(attempt: int, base_delay_secs: int = 10, max_delay_secs: int = 86400) -> int:
|
|
51
|
+
"""Compute exponential backoff delay for webhook retries.
|
|
52
|
+
|
|
53
|
+
Uses the formula ``base_delay * 10^attempt``, capped at ``max_delay_secs``.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
attempt: Zero-based attempt number (0 = first retry).
|
|
57
|
+
base_delay_secs: Base delay in seconds (default: 10).
|
|
58
|
+
max_delay_secs: Maximum delay in seconds (default: 86400 = 24 hours).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Delay in seconds before the next retry attempt.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> next_retry_delay(0) # 10s
|
|
65
|
+
10
|
|
66
|
+
>>> next_retry_delay(1) # 100s
|
|
67
|
+
100
|
|
68
|
+
>>> next_retry_delay(5) # capped at 86400s
|
|
69
|
+
86400
|
|
70
|
+
"""
|
|
71
|
+
delay = base_delay_secs * (10**attempt)
|
|
72
|
+
return min(delay, max_delay_secs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_url(host: str, *path_parts: str) -> str:
|
|
76
|
+
"""Build a full URL from a host and path parts.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
host: Base host URL (trailing slash is stripped).
|
|
80
|
+
*path_parts: URL path segments to join with ``/``.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The full URL string.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> build_url("https://forge.example.com", "api", "v1", "repos")
|
|
87
|
+
'https://forge.example.com/api/v1/repos'
|
|
88
|
+
"""
|
|
89
|
+
base = host.rstrip("/")
|
|
90
|
+
path = "/".join(part.strip("/") for part in path_parts if part)
|
|
91
|
+
return f"{base}/{path}"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Webhook delivery utilities with exponential backoff retry logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
_MAX_RETRIES: int = 5
|
|
9
|
+
_BASE_DELAY_SECS: int = 10
|
|
10
|
+
_MAX_DELAY_SECS: int = 86400 # 24 hours
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def next_retry_delay(attempt: int) -> timedelta:
|
|
14
|
+
"""Calculate the exponential backoff delay for a webhook retry attempt.
|
|
15
|
+
|
|
16
|
+
Uses the formula: ``BASE_DELAY * 10^attempt``, capped at 24 hours.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
attempt: Zero-based attempt index (0 = first retry, 4 = fifth retry).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A :class:`datetime.timedelta` representing how long to wait before the
|
|
23
|
+
next delivery attempt.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> next_retry_delay(0).total_seconds()
|
|
27
|
+
10.0
|
|
28
|
+
>>> next_retry_delay(1).total_seconds()
|
|
29
|
+
100.0
|
|
30
|
+
>>> next_retry_delay(4).total_seconds()
|
|
31
|
+
86400.0
|
|
32
|
+
"""
|
|
33
|
+
secs = _BASE_DELAY_SECS * (10**attempt)
|
|
34
|
+
return timedelta(seconds=min(secs, _MAX_DELAY_SECS))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WebhookDeliveryConfig:
|
|
39
|
+
"""Configuration for webhook delivery retry behaviour.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
max_retries: Maximum number of retry attempts before marking the
|
|
43
|
+
delivery as permanently failed.
|
|
44
|
+
base_delay_secs: Base delay in seconds for exponential backoff.
|
|
45
|
+
max_delay_secs: Maximum delay cap in seconds.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
max_retries: int = _MAX_RETRIES
|
|
49
|
+
base_delay_secs: int = _BASE_DELAY_SECS
|
|
50
|
+
max_delay_secs: int = _MAX_DELAY_SECS
|
|
51
|
+
|
|
52
|
+
def delay_for_attempt(self, attempt: int) -> timedelta:
|
|
53
|
+
"""Return the retry delay for the given attempt index.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
attempt: Zero-based attempt index.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Backoff delay as a :class:`datetime.timedelta`.
|
|
60
|
+
"""
|
|
61
|
+
secs = self.base_delay_secs * (10**attempt)
|
|
62
|
+
return timedelta(seconds=min(secs, self.max_delay_secs))
|
|
63
|
+
|
|
64
|
+
def should_retry(self, attempt: int) -> bool:
|
|
65
|
+
"""Return True if another retry should be attempted.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
attempt: The number of attempts already made.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
``True`` if ``attempt < max_retries``.
|
|
72
|
+
"""
|
|
73
|
+
return attempt < self.max_retries
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class WebhookDelivery:
|
|
78
|
+
"""Represents a webhook delivery record.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
id: Unique delivery identifier (UUID string).
|
|
82
|
+
webhook_id: ID of the parent webhook configuration.
|
|
83
|
+
event_type: The event type that triggered this delivery.
|
|
84
|
+
payload: The JSON payload to deliver.
|
|
85
|
+
attempt_count: Number of delivery attempts made so far.
|
|
86
|
+
state: Current state: ``"pending"``, ``"success"``, or ``"failed"``.
|
|
87
|
+
next_attempt_at: When the next delivery attempt should be made.
|
|
88
|
+
created_at: When this delivery record was created.
|
|
89
|
+
last_response_code: HTTP status code from the last attempt.
|
|
90
|
+
last_response_body: Response body from the last attempt.
|
|
91
|
+
completed_at: When the delivery reached a terminal state.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
webhook_id: str
|
|
96
|
+
event_type: str
|
|
97
|
+
payload: dict
|
|
98
|
+
attempt_count: int = 0
|
|
99
|
+
state: str = "pending"
|
|
100
|
+
next_attempt_at: datetime | None = None
|
|
101
|
+
created_at: datetime | None = None
|
|
102
|
+
last_response_code: int | None = None
|
|
103
|
+
last_response_body: str | None = None
|
|
104
|
+
completed_at: datetime | None = None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-alternative
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: A Python SDK and CLI toolkit for Forge — a keyboard-driven, self-hosted Git collaboration platform
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: git,forge,collaboration,devtools,cli
|
|
8
|
+
Author: AgentSoft
|
|
9
|
+
Author-email: agentsoft@example.com
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
23
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
24
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
25
|
+
Requires-Dist: tomli (>=2.0,<3.0) ; python_version < "3.11"
|
|
26
|
+
Project-URL: Homepage, https://github.com/agentsoft/git-alternative
|
|
27
|
+
Project-URL: Repository, https://github.com/agentsoft/git-alternative
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# git-alternative
|
|
31
|
+
|
|
32
|
+
A Python SDK and CLI toolkit for interacting with **Forge** — a keyboard-driven, self-hosted Git collaboration platform. This package provides a clean Python API for managing repositories, issues, pull requests, CI pipelines, and more via the Forge REST API.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
git_alternative/__init__.py,sha256=Wbbo8fP0kHvbbbcQ46Wr4_8ILHpT9KXjZ7c1VGmWUBA,1248
|
|
2
|
+
git_alternative/ci.py,sha256=ORFyCX3peykbYPSqXaMzX7D7p6VXl-k1If1ycl2UeF0,4750
|
|
3
|
+
git_alternative/cli.py,sha256=8cGA0L8YDzjlo2fKMohWaXn9MjUlU3WKECJnXSuPFcE,3629
|
|
4
|
+
git_alternative/client.py,sha256=NHqpDC_HurT_1IEK4FJh6TxUHvpOB60BoTg7i-RJw_A,2442
|
|
5
|
+
git_alternative/exceptions.py,sha256=4Dg4spDAMz4pxlLSWY1keyAI7LZhxnv7DAusRoBapjU,1214
|
|
6
|
+
git_alternative/http.py,sha256=vPTNDDxwl6e3_qSBA2_4mSMyC3qL0sfxD6pBHWkHTNU,4153
|
|
7
|
+
git_alternative/managers/__init__.py,sha256=pOYHc-FjvECzsBlG4XPWe9qtak4H7VdCjGEuDnG_7a0,50
|
|
8
|
+
git_alternative/managers/issues.py,sha256=qPQuSqUu8Eg9aKjoTxeuO2zlDbATjDnBl8dfH5a0nNQ,9074
|
|
9
|
+
git_alternative/managers/repos.py,sha256=Sj2Lo7UZPQCRaeTxWFX5qsew4c7aUzmOUgbNRBUwiZY,6913
|
|
10
|
+
git_alternative/models.py,sha256=goIdtwZM7T8qdUV6KCDK0y4q8lHZUdCx_dh3_JtCeTY,5836
|
|
11
|
+
git_alternative/pagination.py,sha256=Zf9PeQvzuEvlP-SVascX5N9i35vZ7RLRFarqdwXFpAo,1336
|
|
12
|
+
git_alternative/resources/__init__.py,sha256=vts06S5CWAN05oQ7yGkjBU5AnQgLqxwvVnfjHxMvDdk,46
|
|
13
|
+
git_alternative/resources/issues.py,sha256=VmwKFOEWj8iCofCQU-Wh6Ekr4beZHdDXCSo9Lo2c48Y,9338
|
|
14
|
+
git_alternative/resources/labels.py,sha256=NtPHU-o1m2chMkXEZxcx3sonAXVt33Ci14P96fP36OY,2547
|
|
15
|
+
git_alternative/resources/pipelines.py,sha256=LwmqQTtnjdgKwRwlQZLHgxvGYdkAqhgFKizx0iezj4Y,3772
|
|
16
|
+
git_alternative/resources/pulls.py,sha256=dXzldj8OovlwTF-oXXR82XyLSWnrDxeoFTiykQ6gong,6752
|
|
17
|
+
git_alternative/resources/repos.py,sha256=wYTQsC3ArUaPL_XBcwxjZ701_ZeFZfZSCYsYeSTVkPo,5995
|
|
18
|
+
git_alternative/resources/ssh_keys.py,sha256=3BJG39p2x3kI5IXC9Ynj-xhXzWTRPqwWEEGMXS8JpJs,1998
|
|
19
|
+
git_alternative/ssh_keys.py,sha256=sfYTZWBs3dr8sV1okuq0XW4VZxLWZL2h6VXwEnvQy3Q,2523
|
|
20
|
+
git_alternative/utils.py,sha256=1kfjTplxTT1JzJIoLvx_oqEteJb0EfUBqSNSz093dd0,2821
|
|
21
|
+
git_alternative/webhooks.py,sha256=BSWjqLZtTkA8a_WsR6jkmpPTS2BnRYluy2LaeS2z1VA,3324
|
|
22
|
+
git_alternative-0.2.2.dist-info/METADATA,sha256=Y54SW-EIMqANRbBBQJAPqgMMJUg7PHCkzrnDpm8eJS8,1573
|
|
23
|
+
git_alternative-0.2.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
24
|
+
git_alternative-0.2.2.dist-info/entry_points.txt,sha256=pafcoPXxfuhP6LL3yzDmX3NM5FniKrC71aP6_AiEkY4,50
|
|
25
|
+
git_alternative-0.2.2.dist-info/licenses/LICENSE,sha256=6Y47RSQ-wXvZrjkkdspmKQ7pAlT-Qq6N6eWBjFqOjoo,1066
|
|
26
|
+
git_alternative-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AgentSoft
|
|
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.
|