apifetch 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.
apifetch/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """apifetch — a generic toolkit for token-authenticated REST API retrieval.
2
+
3
+ Quick start::
4
+
5
+ import apifetch as af
6
+
7
+ api = af.Api(
8
+ endpoint="https://api.example.com/v1/search",
9
+ service="Example",
10
+ auth=af.AuthBearer(),
11
+ pagination=af.PaginateOffset(where="query"),
12
+ )
13
+ af.store_token("reports", "my-secret-token", service="Example")
14
+ rows = af.fetch_all(api, "reports", chunk_size=1000)
15
+
16
+ This is the Python sibling of the R package ``apifetch``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .api import (
22
+ Api,
23
+ Auth,
24
+ AuthBearer,
25
+ AuthHeader,
26
+ AuthQuery,
27
+ AuthRaw,
28
+ PaginateNone,
29
+ PaginateOffset,
30
+ Pagination,
31
+ )
32
+ from .fetch import ApiError, fetch, fetch_all
33
+ from .tokens import get_token, list_tokens, remove_token, store_token
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "Api",
39
+ "Auth",
40
+ "AuthRaw",
41
+ "AuthBearer",
42
+ "AuthHeader",
43
+ "AuthQuery",
44
+ "Pagination",
45
+ "PaginateOffset",
46
+ "PaginateNone",
47
+ "fetch",
48
+ "fetch_all",
49
+ "ApiError",
50
+ "store_token",
51
+ "get_token",
52
+ "remove_token",
53
+ "list_tokens",
54
+ "__version__",
55
+ ]
apifetch/_utils.py ADDED
@@ -0,0 +1,31 @@
1
+ """Internal helpers shared across the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import unicodedata
7
+
8
+
9
+ def sanitize_name(value: str) -> str:
10
+ """Transliterate to ASCII (dropping accents) and turn spaces into underscores.
11
+
12
+ This matches the contract shared by every token function so that the same
13
+ environment-variable name is computed everywhere.
14
+ """
15
+ nfkd = unicodedata.normalize("NFKD", value)
16
+ ascii_only = nfkd.encode("ascii", "ignore").decode("ascii")
17
+ return ascii_only.replace(" ", "_")
18
+
19
+
20
+ def token_var(name: str, service: str) -> str:
21
+ """Build the environment-variable name for a token: ``<service>_<name>``."""
22
+ return f"{sanitize_name(service)}_{sanitize_name(name)}"
23
+
24
+
25
+ def is_unset(value) -> bool:
26
+ """True for values that mean "no pagination bound": ``None``, ``<= 0``, ``inf``."""
27
+ if value is None:
28
+ return True
29
+ if isinstance(value, float) and math.isinf(value):
30
+ return True
31
+ return value <= 0
apifetch/api.py ADDED
@@ -0,0 +1,168 @@
1
+ """API profiles: authentication and pagination strategies.
2
+
3
+ An :class:`Api` describes *where* to call, *how* to authenticate, and *how* to
4
+ paginate. Auth and pagination are pluggable strategy objects, so the same fetch
5
+ functions work against APIs with different conventions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from ._utils import is_unset
13
+
14
+ __all__ = [
15
+ "Auth",
16
+ "AuthRaw",
17
+ "AuthBearer",
18
+ "AuthHeader",
19
+ "AuthQuery",
20
+ "Pagination",
21
+ "PaginateOffset",
22
+ "PaginateNone",
23
+ "Api",
24
+ ]
25
+
26
+
27
+ # ---- Authentication strategies -------------------------------------------
28
+
29
+
30
+ class Auth:
31
+ """Base authentication strategy.
32
+
33
+ Subclasses contribute to a request's headers and/or query parameters.
34
+ """
35
+
36
+ def headers(self, token: str) -> dict:
37
+ return {}
38
+
39
+ def params(self, token: str) -> dict:
40
+ return {}
41
+
42
+
43
+ @dataclass
44
+ class AuthRaw(Auth):
45
+ """Send the token verbatim in a header (default ``Authorization``).
46
+
47
+ This is what the Big Data PE API expects.
48
+ """
49
+
50
+ header: str = "Authorization"
51
+
52
+ def headers(self, token: str) -> dict:
53
+ return {self.header: token}
54
+
55
+
56
+ @dataclass
57
+ class AuthBearer(Auth):
58
+ """Send ``"<prefix><token>"`` in a header (default ``Authorization: Bearer``)."""
59
+
60
+ header: str = "Authorization"
61
+ prefix: str = "Bearer "
62
+
63
+ def headers(self, token: str) -> dict:
64
+ return {self.header: f"{self.prefix}{token}"}
65
+
66
+
67
+ @dataclass
68
+ class AuthHeader(Auth):
69
+ """Send the token in an arbitrary header (e.g. ``X-API-Key``)."""
70
+
71
+ header: str = "X-API-Key"
72
+
73
+ def headers(self, token: str) -> dict:
74
+ return {self.header: token}
75
+
76
+
77
+ @dataclass
78
+ class AuthQuery(Auth):
79
+ """Send the token as a URL query parameter."""
80
+
81
+ param: str = "api_key"
82
+
83
+ def params(self, token: str) -> dict:
84
+ return {self.param: token}
85
+
86
+
87
+ # ---- Pagination strategies -----------------------------------------------
88
+
89
+
90
+ class Pagination:
91
+ """Base pagination strategy."""
92
+
93
+ def headers(self, limit, offset) -> dict:
94
+ return {}
95
+
96
+ def params(self, limit, offset) -> dict:
97
+ return {}
98
+
99
+
100
+ @dataclass
101
+ class PaginateOffset(Pagination):
102
+ """Send ``limit``/``offset`` as HTTP headers (default) or query parameters.
103
+
104
+ Non-positive, ``None`` and infinite values are omitted.
105
+ """
106
+
107
+ where: str = "header" # "header" or "query"
108
+ limit_param: str = "limit"
109
+ offset_param: str = "offset"
110
+
111
+ def _values(self, limit, offset) -> dict:
112
+ vals = {}
113
+ if not is_unset(limit):
114
+ vals[self.limit_param] = int(limit)
115
+ if not is_unset(offset):
116
+ vals[self.offset_param] = int(offset)
117
+ return vals
118
+
119
+ def headers(self, limit, offset) -> dict:
120
+ # HTTP header values must be strings.
121
+ if self.where != "header":
122
+ return {}
123
+ return {k: str(v) for k, v in self._values(limit, offset).items()}
124
+
125
+ def params(self, limit, offset) -> dict:
126
+ return self._values(limit, offset) if self.where == "query" else {}
127
+
128
+
129
+ @dataclass
130
+ class PaginateNone(Pagination):
131
+ """Send no pagination parameters."""
132
+
133
+
134
+ # ---- API profile ----------------------------------------------------------
135
+
136
+
137
+ @dataclass
138
+ class Api:
139
+ """Describe an API endpoint together with its auth and pagination strategies.
140
+
141
+ Args:
142
+ endpoint: The base API URL.
143
+ service: Namespace used to look up the token (see :func:`get_token`).
144
+ auth: An :class:`Auth` strategy. Defaults to bearer-token auth.
145
+ pagination: A :class:`Pagination` strategy. Defaults to offset paging
146
+ sent as HTTP headers.
147
+ drop_cols: Keys to drop from each record after parsing (e.g. a status
148
+ column).
149
+ connect_hint: Optional extra line shown on a connection error (e.g. a
150
+ VPN requirement).
151
+ """
152
+
153
+ endpoint: str
154
+ service: str = "apifetch"
155
+ auth: Auth = field(default_factory=AuthBearer)
156
+ pagination: Pagination = field(default_factory=PaginateOffset)
157
+ drop_cols: tuple[str, ...] = ()
158
+ connect_hint: str | None = None
159
+
160
+ def __post_init__(self):
161
+ if not self.endpoint:
162
+ raise ValueError("endpoint must be a non-empty string.")
163
+ if not isinstance(self.auth, Auth):
164
+ raise TypeError("auth must be an Auth instance (e.g. AuthBearer()).")
165
+ if not isinstance(self.pagination, Pagination):
166
+ raise TypeError(
167
+ "pagination must be a Pagination instance (e.g. PaginateOffset())."
168
+ )
apifetch/fetch.py ADDED
@@ -0,0 +1,152 @@
1
+ """Data fetching: single page and chunked retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import sys
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+ from .api import Api
12
+ from .tokens import get_token
13
+
14
+ __all__ = ["fetch", "fetch_all", "ApiError"]
15
+
16
+
17
+ class ApiError(RuntimeError):
18
+ """Raised when the API is unreachable or returns an HTTP error."""
19
+
20
+
21
+ def _log(verbosity: int, message: str) -> None:
22
+ if verbosity > 0:
23
+ print(message, file=sys.stderr)
24
+
25
+
26
+ def fetch(
27
+ api: Api,
28
+ name: str,
29
+ limit: Optional[float] = None,
30
+ offset: int = 0,
31
+ query: Optional[dict] = None,
32
+ verbosity: int = 0,
33
+ client: Optional[httpx.Client] = None,
34
+ ) -> list[dict[str, Any]]:
35
+ """Fetch a single page from ``api`` and return it as a list of records.
36
+
37
+ Args:
38
+ api: An :class:`Api` profile.
39
+ name: Token name to authenticate with (looked up via ``api.service``).
40
+ limit: Maximum records to request. ``None``/``inf`` means no limit.
41
+ offset: Starting record (omitted when ``0``).
42
+ query: Extra query-string filters.
43
+ verbosity: ``0`` silent, ``>=1`` progress messages.
44
+ client: Optional pre-built ``httpx.Client`` (useful for testing or
45
+ connection reuse). One is created and closed per call otherwise.
46
+
47
+ Returns:
48
+ The parsed JSON body as a list of dictionaries.
49
+ """
50
+ if not isinstance(api, Api):
51
+ raise TypeError("api must be an Api instance (see apifetch.Api).")
52
+ query = query or {}
53
+
54
+ token = get_token(name, service=api.service)
55
+ if token is None:
56
+ raise ApiError(
57
+ f"No token available for {name!r}; store one with apifetch.store_token()."
58
+ )
59
+
60
+ headers = {**api.auth.headers(token), **api.pagination.headers(limit, offset)}
61
+ params = {**query, **api.auth.params(token), **api.pagination.params(limit, offset)}
62
+
63
+ owns_client = client is None
64
+ client = client or httpx.Client()
65
+ try:
66
+ try:
67
+ resp = client.get(api.endpoint, headers=headers, params=params)
68
+ except httpx.RequestError as exc:
69
+ hint = f" {api.connect_hint}" if api.connect_hint else ""
70
+ raise ApiError(
71
+ f"Unable to connect to the API at {api.endpoint}. "
72
+ f"Check your network connection.{hint} ({exc})"
73
+ ) from exc
74
+ finally:
75
+ if owns_client:
76
+ client.close()
77
+
78
+ if resp.status_code >= 400:
79
+ raise ApiError(
80
+ f"The API returned an error (HTTP {resp.status_code} - {resp.reason_phrase}). "
81
+ "Try again later, and check that the endpoint and token are valid."
82
+ )
83
+
84
+ data = resp.json()
85
+ if isinstance(data, dict):
86
+ data = [data]
87
+ _log(verbosity, f"i Fetched {len(data)} records.")
88
+ return data
89
+
90
+
91
+ def fetch_all(
92
+ api: Api,
93
+ name: str,
94
+ total_limit: Optional[float] = None,
95
+ chunk_size: int = 50_000,
96
+ query: Optional[dict] = None,
97
+ verbosity: int = 0,
98
+ client: Optional[httpx.Client] = None,
99
+ ) -> list[dict[str, Any]]:
100
+ """Fetch every record by paging through ``api`` in chunks.
101
+
102
+ Iteratively calls :func:`fetch` with an advancing ``offset`` until a chunk
103
+ comes back empty or ``total_limit`` is reached. Columns listed in
104
+ ``api.drop_cols`` are removed from each record.
105
+ """
106
+ if not isinstance(api, Api):
107
+ raise TypeError("api must be an Api instance (see apifetch.Api).")
108
+ if chunk_size <= 0:
109
+ raise ValueError("chunk_size must be a positive integer.")
110
+
111
+ total = math.inf if total_limit is None else total_limit
112
+ offset = 0
113
+ fetched = 0
114
+ records: list[dict[str, Any]] = []
115
+
116
+ owns_client = client is None
117
+ client = client or httpx.Client()
118
+ try:
119
+ while True:
120
+ current_limit = int(min(chunk_size, total - fetched))
121
+ if current_limit <= 0:
122
+ break
123
+
124
+ chunk = fetch(
125
+ api, name,
126
+ limit=current_limit,
127
+ offset=offset,
128
+ query=query,
129
+ verbosity=verbosity,
130
+ client=client,
131
+ )
132
+ if api.drop_cols:
133
+ chunk = [
134
+ {k: v for k, v in row.items() if k not in api.drop_cols}
135
+ for row in chunk
136
+ ]
137
+ if not chunk:
138
+ break
139
+
140
+ records.extend(chunk)
141
+ fetched += len(chunk)
142
+ offset += len(chunk)
143
+ _log(verbosity, f"i Fetched {len(chunk)} records (total: {fetched}).")
144
+
145
+ if fetched >= total:
146
+ break
147
+ finally:
148
+ if owns_client:
149
+ client.close()
150
+
151
+ _log(verbosity, f"✓ Fetching complete: {len(records)} records retrieved.")
152
+ return records
apifetch/tokens.py ADDED
@@ -0,0 +1,67 @@
1
+ """Token management via environment variables.
2
+
3
+ Tokens are never written to disk; they live only in process environment
4
+ variables named ``<service>_<name>``. ``service`` acts as a namespace so a single
5
+ process can hold tokens for several different APIs without clashing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from ._utils import sanitize_name, token_var
13
+
14
+ __all__ = ["store_token", "get_token", "remove_token", "list_tokens"]
15
+
16
+
17
+ def store_token(name: str, token: str, service: str = "apifetch") -> None:
18
+ """Store ``token`` for ``name`` in an environment variable.
19
+
20
+ Refuses to overwrite an existing, non-empty variable.
21
+ """
22
+ if not name:
23
+ raise ValueError("name must be a non-empty string.")
24
+ if not token:
25
+ raise ValueError("token must be a non-empty string.")
26
+
27
+ var = token_var(name, service)
28
+ if os.environ.get(var):
29
+ print(f"! {var} is already defined; not overwriting to avoid data loss.")
30
+ return
31
+
32
+ os.environ[var] = token
33
+ print(f"✓ Token stored in environment variable: {var}")
34
+
35
+
36
+ def get_token(name: str, service: str = "apifetch") -> str | None:
37
+ """Return the token stored for ``name``/``service``, or ``None`` if missing."""
38
+ if not name:
39
+ raise ValueError("name must be a non-empty string.")
40
+
41
+ token = os.environ.get(token_var(name, service))
42
+ if not token:
43
+ print(f"! No token found for {name!r} (service {service!r}).")
44
+ return None
45
+ return token
46
+
47
+
48
+ def remove_token(name: str, service: str = "apifetch") -> None:
49
+ """Remove the token stored for ``name``/``service`` if present."""
50
+ if not name:
51
+ raise ValueError("name must be a non-empty string.")
52
+
53
+ var = token_var(name, service)
54
+ if os.environ.get(var):
55
+ del os.environ[var]
56
+ print(f"✓ Token removed for {name!r} (service {service!r}).")
57
+ else:
58
+ print(f"! No token found for {name!r} (service {service!r}).")
59
+
60
+
61
+ def list_tokens(service: str = "apifetch") -> list[str]:
62
+ """Return the names (without the ``service`` prefix) of stored tokens."""
63
+ prefix = f"{sanitize_name(service)}_"
64
+ names = [key[len(prefix):] for key in os.environ if key.startswith(prefix)]
65
+ if not names:
66
+ print(f"i No tokens found for service {service!r}.")
67
+ return sorted(names)
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: apifetch
3
+ Version: 0.1.0
4
+ Summary: A generic toolkit for token-authenticated REST API retrieval.
5
+ Project-URL: Homepage, https://github.com/StrategicProjects/apifetch-py
6
+ Project-URL: Repository, https://github.com/StrategicProjects/apifetch-py
7
+ Project-URL: R sibling, https://github.com/StrategicProjects/apifetch
8
+ Author-email: André Leite <leite@de.ufpe.br>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 André Leite
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: api,client,http,pagination,rest,token
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Internet :: WWW/HTTP
37
+ Requires-Python: >=3.9
38
+ Requires-Dist: httpx>=0.24
39
+ Provides-Extra: dev
40
+ Requires-Dist: pandas>=1.3; extra == 'dev'
41
+ Requires-Dist: pytest>=7; extra == 'dev'
42
+ Requires-Dist: respx>=0.20; extra == 'dev'
43
+ Provides-Extra: docs
44
+ Requires-Dist: mkdocs-material>=9; extra == 'docs'
45
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
46
+ Provides-Extra: pandas
47
+ Requires-Dist: pandas>=1.3; extra == 'pandas'
48
+ Description-Content-Type: text/markdown
49
+
50
+ <p align="center">
51
+ <img src="docs/assets/logo.png" width="200" alt="apifetch">
52
+ </p>
53
+
54
+ # apifetch (Python)
55
+
56
+ `apifetch` is a small, dependency-light toolkit for talking to
57
+ token-authenticated REST APIs. It handles three recurring chores:
58
+
59
+ 1. **Token management** — store/get/remove/list tokens in process environment
60
+ variables (never written to disk), namespaced per service.
61
+ 2. **Request building** — pluggable **authentication** and **pagination**
62
+ strategies, bundled into a reusable `Api` profile.
63
+ 3. **Data retrieval** — fetch one page, or fetch everything in chunks.
64
+
65
+ This is the Python sibling of the R package
66
+ [apifetch](https://github.com/StrategicProjects/apifetch). Both were extracted
67
+ from the [BigDataPE](https://github.com/StrategicProjects/BigDataPE) package,
68
+ which is now one *use case* (see `examples/bigdatape.py`).
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ pip install apifetch
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ ```python
79
+ import apifetch as af
80
+
81
+ # 1. Describe the API once: where, how to authenticate, how to paginate.
82
+ api = af.Api(
83
+ endpoint="https://api.example.com/v1/search",
84
+ service="Example",
85
+ auth=af.AuthBearer(), # "Authorization: Bearer <token>"
86
+ pagination=af.PaginateOffset(where="query"),
87
+ )
88
+
89
+ # 2. Store a token (kept only in this process's environment).
90
+ af.store_token("reports", "my-secret-token", service="Example")
91
+
92
+ # 3. Fetch.
93
+ one_page = af.fetch(api, "reports", limit=50)
94
+ everything = af.fetch_all(api, "reports", chunk_size=1000)
95
+
96
+ # Optional: turn it into a DataFrame.
97
+ # import pandas as pd; df = pd.DataFrame(everything)
98
+ ```
99
+
100
+ ### Strategies
101
+
102
+ **Authentication:** `AuthBearer`, `AuthRaw`, `AuthHeader`, `AuthQuery`.
103
+
104
+ **Pagination:** `PaginateOffset(where="header" | "query")`, `PaginateNone`.
105
+
106
+ ## License
107
+
108
+ MIT © André Leite
@@ -0,0 +1,9 @@
1
+ apifetch/__init__.py,sha256=FdebCUxfc_A1nHJQct34lQcCVe0xxlS4bDF5sdL4fCs,1133
2
+ apifetch/_utils.py,sha256=IQtgRnd1hi4xCF1nz0JpJ1ECv_HMh5JjhBghpVPQvdY,983
3
+ apifetch/api.py,sha256=ZSf6mUooVmbeQ-pOnLhBBMGdjDvyoy5gEzjRaEYYY_8,4502
4
+ apifetch/fetch.py,sha256=BF7BW8JBbPJJZy-YcM-V7QRpRkuayz6weg_z_fOmHlw,4732
5
+ apifetch/tokens.py,sha256=Ox9VAy-MfOAkDq7PmEbV-qbPm4V5c9Eb-Rym2r5-Svs,2291
6
+ apifetch-0.1.0.dist-info/METADATA,sha256=8PIhDVkX1Djj9iTtNy6O8AGxtqcSkjNssYA3GvsoXpE,4126
7
+ apifetch-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ apifetch-0.1.0.dist-info/licenses/LICENSE,sha256=OGxCK_IX0xDkZ7FRQynkGICKfxuVKrvLVheW4A7dQsA,1069
9
+ apifetch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 André Leite
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.