papollo 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.
- papollo/__init__.py +5 -0
- papollo/_core.py +94 -0
- papollo/async_client.py +115 -0
- papollo/client.py +120 -0
- papollo/exceptions.py +6 -0
- papollo-0.1.0.dist-info/METADATA +68 -0
- papollo-0.1.0.dist-info/RECORD +9 -0
- papollo-0.1.0.dist-info/WHEEL +4 -0
- papollo-0.1.0.dist-info/licenses/LICENSE +20 -0
papollo/__init__.py
ADDED
papollo/_core.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Sans-IO protocol logic shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .exceptions import ApolloError
|
|
15
|
+
|
|
16
|
+
DEFAULT_NAMESPACE = "application"
|
|
17
|
+
|
|
18
|
+
_PROPERTIES_SUFFIX = ".properties"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Snapshot:
|
|
23
|
+
release_key: str
|
|
24
|
+
configurations: Mapping[str, str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_namespace(name: str) -> str:
|
|
28
|
+
# Apollo treats "foo" and "foo.properties" as the same namespace.
|
|
29
|
+
if name.lower().endswith(_PROPERTIES_SUFFIX):
|
|
30
|
+
return name[: -len(_PROPERTIES_SUFFIX)]
|
|
31
|
+
return name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sign(timestamp: str, path_with_query: str, secret: str) -> str:
|
|
35
|
+
message = f"{timestamp}\n{path_with_query}".encode()
|
|
36
|
+
digest = hmac.new(secret.encode(), message, hashlib.sha1).digest()
|
|
37
|
+
return base64.b64encode(digest).decode()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Settings:
|
|
42
|
+
server_url: str
|
|
43
|
+
app_id: str
|
|
44
|
+
cluster: str
|
|
45
|
+
secret: str | None
|
|
46
|
+
ip: str | None
|
|
47
|
+
label: str | None
|
|
48
|
+
timeout: float | None
|
|
49
|
+
|
|
50
|
+
def build_config_request(
|
|
51
|
+
self,
|
|
52
|
+
http: httpx.Client | httpx.AsyncClient,
|
|
53
|
+
namespace: str,
|
|
54
|
+
release_key: str | None,
|
|
55
|
+
) -> httpx.Request:
|
|
56
|
+
path = "/".join(quote(part, safe="") for part in (self.app_id, self.cluster, namespace))
|
|
57
|
+
params = {
|
|
58
|
+
key: value
|
|
59
|
+
for key, value in (("releaseKey", release_key), ("ip", self.ip), ("label", self.label))
|
|
60
|
+
if value is not None
|
|
61
|
+
}
|
|
62
|
+
request = http.build_request(
|
|
63
|
+
"GET",
|
|
64
|
+
f"{self.server_url}/configs/{path}",
|
|
65
|
+
params=params,
|
|
66
|
+
timeout=self.timeout if self.timeout is not None else httpx.USE_CLIENT_DEFAULT,
|
|
67
|
+
)
|
|
68
|
+
if self.secret is not None:
|
|
69
|
+
# Sign exactly what goes on the wire so the server side check matches.
|
|
70
|
+
timestamp = str(int(time.time() * 1000))
|
|
71
|
+
signature = sign(timestamp, request.url.raw_path.decode("ascii"), self.secret)
|
|
72
|
+
request.headers["Authorization"] = f"Apollo {self.app_id}:{signature}"
|
|
73
|
+
request.headers["Timestamp"] = timestamp
|
|
74
|
+
return request
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_config_response(response: httpx.Response, namespace: str) -> Snapshot | None:
|
|
78
|
+
"""Return the new snapshot, or None when the server answers 304 Not Modified."""
|
|
79
|
+
if response.status_code == 304:
|
|
80
|
+
return None
|
|
81
|
+
if response.status_code != 200:
|
|
82
|
+
raise ApolloError(
|
|
83
|
+
f"failed to fetch namespace {namespace!r}: HTTP {response.status_code}",
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
body = response.json()
|
|
88
|
+
release_key = body["releaseKey"]
|
|
89
|
+
configurations = dict(body["configurations"])
|
|
90
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
91
|
+
raise ApolloError(
|
|
92
|
+
f"invalid response for namespace {namespace!r}", status_code=response.status_code
|
|
93
|
+
) from exc
|
|
94
|
+
return Snapshot(release_key, MappingProxyType(configurations))
|
papollo/async_client.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import TypeVar, overload
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
from typing import Self
|
|
11
|
+
else:
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from ._core import (
|
|
15
|
+
DEFAULT_NAMESPACE,
|
|
16
|
+
Settings,
|
|
17
|
+
Snapshot,
|
|
18
|
+
normalize_namespace,
|
|
19
|
+
parse_config_response,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import ApolloError
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncApollo:
|
|
27
|
+
"""Asyncio Apollo config client, same API as ``Apollo``.
|
|
28
|
+
|
|
29
|
+
A client instance is bound to the event loop it is first used in.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
server_url: str,
|
|
35
|
+
app_id: str,
|
|
36
|
+
*,
|
|
37
|
+
cluster: str = "default",
|
|
38
|
+
secret: str | None = None,
|
|
39
|
+
ip: str | None = None,
|
|
40
|
+
label: str | None = None,
|
|
41
|
+
timeout: float | None = None,
|
|
42
|
+
http_client: httpx.AsyncClient | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._settings = Settings(
|
|
45
|
+
server_url.rstrip("/"), app_id, cluster, secret, ip, label, timeout
|
|
46
|
+
)
|
|
47
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
48
|
+
self._owns_http = http_client is None
|
|
49
|
+
self._snapshots: dict[str, Snapshot] = {}
|
|
50
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
51
|
+
|
|
52
|
+
@overload
|
|
53
|
+
async def get(self, key: str, *, namespace: str = DEFAULT_NAMESPACE) -> str | None: ...
|
|
54
|
+
@overload
|
|
55
|
+
async def get(self, key: str, default: T, *, namespace: str = DEFAULT_NAMESPACE) -> str | T: ...
|
|
56
|
+
async def get(
|
|
57
|
+
self, key: str, default: object = None, *, namespace: str = DEFAULT_NAMESPACE
|
|
58
|
+
) -> object:
|
|
59
|
+
return (await self.namespace(namespace)).get(key, default)
|
|
60
|
+
|
|
61
|
+
async def namespace(self, name: str = DEFAULT_NAMESPACE) -> Mapping[str, str]:
|
|
62
|
+
name = normalize_namespace(name)
|
|
63
|
+
snapshot = self._snapshots.get(name)
|
|
64
|
+
if snapshot is None:
|
|
65
|
+
snapshot = await self._load(name, only_if_missing=True)
|
|
66
|
+
return snapshot.configurations
|
|
67
|
+
|
|
68
|
+
async def refresh(self, name: str | None = None) -> None:
|
|
69
|
+
"""Refetch one namespace, or every loaded namespace when ``name`` is None.
|
|
70
|
+
|
|
71
|
+
A namespace that has not been loaded yet is loaded, so this can also be
|
|
72
|
+
used to preload namespaces at startup. On failure the cached config is
|
|
73
|
+
kept and the error is raised after all namespaces were tried.
|
|
74
|
+
"""
|
|
75
|
+
names = [normalize_namespace(name)] if name is not None else list(self._snapshots)
|
|
76
|
+
results = await asyncio.gather(*(self._load(ns) for ns in names), return_exceptions=True)
|
|
77
|
+
for result in results:
|
|
78
|
+
if isinstance(result, BaseException):
|
|
79
|
+
raise result
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
if self._owns_http:
|
|
83
|
+
await self._http.aclose()
|
|
84
|
+
|
|
85
|
+
async def __aenter__(self) -> Self:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
async def __aexit__(
|
|
89
|
+
self,
|
|
90
|
+
exc_type: type[BaseException] | None,
|
|
91
|
+
exc: BaseException | None,
|
|
92
|
+
tb: TracebackType | None,
|
|
93
|
+
) -> None:
|
|
94
|
+
await self.aclose()
|
|
95
|
+
|
|
96
|
+
async def _load(self, name: str, *, only_if_missing: bool = False) -> Snapshot:
|
|
97
|
+
async with self._locks.setdefault(name, asyncio.Lock()):
|
|
98
|
+
current = self._snapshots.get(name)
|
|
99
|
+
if only_if_missing and current is not None:
|
|
100
|
+
return current
|
|
101
|
+
release_key = current.release_key if current is not None else None
|
|
102
|
+
try:
|
|
103
|
+
request = self._settings.build_config_request(self._http, name, release_key)
|
|
104
|
+
response = await self._http.send(request)
|
|
105
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
106
|
+
raise ApolloError(f"failed to fetch namespace {name!r}: {exc}") from exc
|
|
107
|
+
snapshot = parse_config_response(response, name)
|
|
108
|
+
if snapshot is None:
|
|
109
|
+
if current is None:
|
|
110
|
+
raise ApolloError(
|
|
111
|
+
f"unexpected 304 for unloaded namespace {name!r}", status_code=304
|
|
112
|
+
)
|
|
113
|
+
return current
|
|
114
|
+
self._snapshots[name] = snapshot
|
|
115
|
+
return snapshot
|
papollo/client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import TypeVar, overload
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
from typing import Self
|
|
11
|
+
else:
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from ._core import (
|
|
15
|
+
DEFAULT_NAMESPACE,
|
|
16
|
+
Settings,
|
|
17
|
+
Snapshot,
|
|
18
|
+
normalize_namespace,
|
|
19
|
+
parse_config_response,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import ApolloError
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Apollo:
|
|
27
|
+
"""Blocking Apollo config client.
|
|
28
|
+
|
|
29
|
+
Namespaces are fetched on first access and then served from memory.
|
|
30
|
+
Call ``refresh()`` to pull the latest release.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
server_url: str,
|
|
36
|
+
app_id: str,
|
|
37
|
+
*,
|
|
38
|
+
cluster: str = "default",
|
|
39
|
+
secret: str | None = None,
|
|
40
|
+
ip: str | None = None,
|
|
41
|
+
label: str | None = None,
|
|
42
|
+
timeout: float | None = None,
|
|
43
|
+
http_client: httpx.Client | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._settings = Settings(
|
|
46
|
+
server_url.rstrip("/"), app_id, cluster, secret, ip, label, timeout
|
|
47
|
+
)
|
|
48
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
49
|
+
self._owns_http = http_client is None
|
|
50
|
+
self._snapshots: dict[str, Snapshot] = {}
|
|
51
|
+
self._locks: dict[str, threading.Lock] = {}
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def get(self, key: str, *, namespace: str = DEFAULT_NAMESPACE) -> str | None: ...
|
|
55
|
+
@overload
|
|
56
|
+
def get(self, key: str, default: T, *, namespace: str = DEFAULT_NAMESPACE) -> str | T: ...
|
|
57
|
+
def get(
|
|
58
|
+
self, key: str, default: object = None, *, namespace: str = DEFAULT_NAMESPACE
|
|
59
|
+
) -> object:
|
|
60
|
+
return self.namespace(namespace).get(key, default)
|
|
61
|
+
|
|
62
|
+
def namespace(self, name: str = DEFAULT_NAMESPACE) -> Mapping[str, str]:
|
|
63
|
+
name = normalize_namespace(name)
|
|
64
|
+
snapshot = self._snapshots.get(name)
|
|
65
|
+
if snapshot is None:
|
|
66
|
+
snapshot = self._load(name, only_if_missing=True)
|
|
67
|
+
return snapshot.configurations
|
|
68
|
+
|
|
69
|
+
def refresh(self, name: str | None = None) -> None:
|
|
70
|
+
"""Refetch one namespace, or every loaded namespace when ``name`` is None.
|
|
71
|
+
|
|
72
|
+
A namespace that has not been loaded yet is loaded, so this can also be
|
|
73
|
+
used to preload namespaces at startup. On failure the cached config is
|
|
74
|
+
kept and the error is raised after all namespaces were tried.
|
|
75
|
+
"""
|
|
76
|
+
names = [normalize_namespace(name)] if name is not None else list(self._snapshots)
|
|
77
|
+
error: Exception | None = None
|
|
78
|
+
for ns in names:
|
|
79
|
+
try:
|
|
80
|
+
self._load(ns)
|
|
81
|
+
except Exception as exc: # noqa: BLE001 raised below after all were tried
|
|
82
|
+
error = error or exc
|
|
83
|
+
if error is not None:
|
|
84
|
+
raise error
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
if self._owns_http:
|
|
88
|
+
self._http.close()
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> Self:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(
|
|
94
|
+
self,
|
|
95
|
+
exc_type: type[BaseException] | None,
|
|
96
|
+
exc: BaseException | None,
|
|
97
|
+
tb: TracebackType | None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.close()
|
|
100
|
+
|
|
101
|
+
def _load(self, name: str, *, only_if_missing: bool = False) -> Snapshot:
|
|
102
|
+
with self._locks.setdefault(name, threading.Lock()):
|
|
103
|
+
current = self._snapshots.get(name)
|
|
104
|
+
if only_if_missing and current is not None:
|
|
105
|
+
return current
|
|
106
|
+
release_key = current.release_key if current is not None else None
|
|
107
|
+
try:
|
|
108
|
+
request = self._settings.build_config_request(self._http, name, release_key)
|
|
109
|
+
response = self._http.send(request)
|
|
110
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
111
|
+
raise ApolloError(f"failed to fetch namespace {name!r}: {exc}") from exc
|
|
112
|
+
snapshot = parse_config_response(response, name)
|
|
113
|
+
if snapshot is None:
|
|
114
|
+
if current is None:
|
|
115
|
+
raise ApolloError(
|
|
116
|
+
f"unexpected 304 for unloaded namespace {name!r}", status_code=304
|
|
117
|
+
)
|
|
118
|
+
return current
|
|
119
|
+
self._snapshots[name] = snapshot
|
|
120
|
+
return snapshot
|
papollo/exceptions.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: papollo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Apollo config center, with sync and async support
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: httpx>=0.28
|
|
8
|
+
Requires-Dist: typing-extensions>=4.0 ; python_full_version < '3.11'
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# papollo
|
|
13
|
+
|
|
14
|
+
Python client for [Apollo](https://github.com/apolloconfig/apollo) config center, with sync and async support.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from papollo import Apollo
|
|
18
|
+
|
|
19
|
+
client = Apollo("http://apollo-config:8080", "demo-app")
|
|
20
|
+
|
|
21
|
+
client.get("timeout") # "30", or None if missing
|
|
22
|
+
client.get("timeout", "10") # with default
|
|
23
|
+
client.get("db.url", namespace="database")
|
|
24
|
+
client.namespace("app.json")["content"] # non-properties namespace
|
|
25
|
+
client.refresh() # pull latest releases
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from papollo import AsyncApollo
|
|
30
|
+
|
|
31
|
+
client = AsyncApollo("http://apollo-config:8080", "demo-app")
|
|
32
|
+
|
|
33
|
+
await client.get("timeout")
|
|
34
|
+
await client.refresh()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A client is meant to live as long as the process, usually as a module level object. It holds an
|
|
38
|
+
httpx connection pool, so call `close()` (or `await client.aclose()`) if you create short lived
|
|
39
|
+
clients. Both also work as context managers. An `AsyncApollo` is bound to the event loop it
|
|
40
|
+
is first used in.
|
|
41
|
+
|
|
42
|
+
Namespaces are fetched on first access and then served from memory. `refresh()` refetches every
|
|
43
|
+
loaded namespace, and `refresh(name)` also loads a namespace that was not loaded yet, which is handy
|
|
44
|
+
for failing fast at startup. Failures raise `ApolloError` and keep the previously cached config.
|
|
45
|
+
|
|
46
|
+
Other options: `cluster`, `secret` (access key), `ip` and `label` (gray release), `timeout`, and
|
|
47
|
+
`http_client` to bring your own `httpx.Client` / `httpx.AsyncClient`. When `timeout` is not set, the
|
|
48
|
+
httpx client's own timeout is used. `ip` is not detected automatically, so IP based gray release
|
|
49
|
+
only works when it is set.
|
|
50
|
+
|
|
51
|
+
## Testing
|
|
52
|
+
|
|
53
|
+
Most tests run against a real Apollo at `http://localhost:8080`, the rest cover pure functions and
|
|
54
|
+
failures a real server can not produce on demand. The easiest server is the
|
|
55
|
+
[quick start](https://github.com/apolloconfig/apollo-quick-start) all in one jar with an in memory
|
|
56
|
+
H2 database:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
SPRING_PROFILES_ACTIVE=github,database-discovery,auth SPRING_PROFILES_GROUP_GITHUB=h2 \
|
|
60
|
+
LOGGING_FILE_NAME=/tmp/apollo.log java -jar apollo-all-in-one.jar
|
|
61
|
+
|
|
62
|
+
uv run pytest
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Tests that need Apollo are skipped when it is not reachable. Set `PAPOLLO_REQUIRE_APOLLO=1` to make
|
|
66
|
+
them fail instead, which is what CI should do. The portal at `http://localhost:8070` is used to
|
|
67
|
+
create apps and publish releases. Use `APOLLO_CONFIG_URL`, `APOLLO_PORTAL_URL`,
|
|
68
|
+
`APOLLO_PORTAL_USER`, `APOLLO_PORTAL_PASSWORD` and `APOLLO_ENV` to point elsewhere.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
papollo/__init__.py,sha256=7jVHK-L7pmmKjAcEX1rm_rrI175RcPHp0-BAvrELxBg,153
|
|
2
|
+
papollo/_core.py,sha256=34wJuOitjSTiBQdssMl7Wq9hn5ufBzrnSNJFrjmn3gY,3104
|
|
3
|
+
papollo/async_client.py,sha256=BRC6tkvr2Xxni4DQ-zfpG5DNWxODgeIdNseK5vELLCI,4116
|
|
4
|
+
papollo/client.py,sha256=j5jAc1Ix_RN4qyNRcQ06CRM1O9MWAdILoOuEnB6Sw5g,4122
|
|
5
|
+
papollo/exceptions.py,sha256=ODLw1ABRW0RefbGHxHLWnutO-EAT51vfUgge1umSnJg,246
|
|
6
|
+
papollo-0.1.0.dist-info/licenses/LICENSE,sha256=5LtonWQJ4JYxkh2u5scD3oN1kfZ1yGrPG6bTZMvwA_s,1074
|
|
7
|
+
papollo-0.1.0.dist-info/WHEEL,sha256=V5-3dKee3Zs8C4JP6swr6zdqriLsOpItBEQxe6_oWpY,81
|
|
8
|
+
papollo-0.1.0.dist-info/METADATA,sha256=YL0DfhvyGkcymZgKF2Rvujg6UqwTivCZUHKevunxyng,2805
|
|
9
|
+
papollo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AN Long
|
|
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 of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
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, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|