tabapi 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.
tabapi/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """TabAPI: scikit-learn-style client for tabular foundation models served over HTTP."""
2
+
3
+ from tabapi.classifier import TabAPIClassifier
4
+ from tabapi.client import TabAPIClient
5
+
6
+ __all__ = ["TabAPIClassifier", "TabAPIClient"]
7
+ __version__ = "0.1.0"
tabapi/_base.py ADDED
@@ -0,0 +1,59 @@
1
+ """Base class for the scikit-learn estimators backed by the TabICL API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.base import BaseEstimator
8
+ from sklearn.utils.validation import check_is_fitted
9
+
10
+ from tabapi.client import TabAPIClient
11
+
12
+
13
+ class _TabAPIEstimator(BaseEstimator):
14
+ """Fit and predict on a remote TabICL server, whatever the task.
15
+
16
+ Subclasses set ``_task`` and list every parameter in their ``__init__``
17
+ (scikit-learn reads them from its signature). The connection parameters
18
+ are ``base_url``, ``timeout``, ``token``, ``max_retries`` and
19
+ ``backoff_factor``. The model parameters shared by all tasks are
20
+ ``n_estimators``, ``random_state`` and ``target_name``.
21
+ """
22
+
23
+ _task: str
24
+
25
+ def __repr__(self) -> str:
26
+ """Like the sklearn repr, but never show the token."""
27
+ text = super().__repr__()
28
+ if self.token:
29
+ text = text.replace(repr(self.token), "'***'")
30
+ return text
31
+
32
+ def __sklearn_is_fitted__(self) -> bool:
33
+ return hasattr(self, "_model_id")
34
+
35
+ def _fit_remote(self, X, y, **config) -> None:
36
+ """Fit a model on the server, with ``config`` as the task-specific parameters."""
37
+ self._client = TabAPIClient(
38
+ base_url=self.base_url,
39
+ timeout=self.timeout,
40
+ token=self.token,
41
+ max_retries=self.max_retries,
42
+ backoff_factor=self.backoff_factor,
43
+ )
44
+ self._model_id = self._client.fit(
45
+ pd.DataFrame(X),
46
+ y if isinstance(y, pd.Series) else pd.Series(y),
47
+ task=self._task,
48
+ target_name=self.target_name,
49
+ n_estimators=self.n_estimators,
50
+ random_state=self.random_state,
51
+ **config,
52
+ )
53
+
54
+ def _predict_remote(self, X, output_type: str) -> np.ndarray:
55
+ check_is_fitted(self)
56
+ prediction = self._client.predict(
57
+ self._model_id, pd.DataFrame(X), task=self._task, output_type=output_type
58
+ )
59
+ return np.asarray(prediction)
tabapi/classifier.py ADDED
@@ -0,0 +1,133 @@
1
+ """TabAPIClassifier: scikit-learn-style classifier that calls the TabICL API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.base import ClassifierMixin
8
+
9
+ from tabapi._base import _TabAPIEstimator
10
+
11
+
12
+ class TabAPIClassifier(ClassifierMixin, _TabAPIEstimator):
13
+ """
14
+ Scikit-learn-style classifier that fits and predicts via the TabICL API.
15
+
16
+ This estimator does not run TabICL locally; it sends data to a remote
17
+ server (e.g. deployed via the tabapi Docker image) and uses the returned
18
+ model_id for subsequent predictions.
19
+
20
+ Parameters
21
+ ----------
22
+ base_url : str
23
+ Base URL of the TabICL API (e.g. "http://localhost:8000").
24
+ timeout : float
25
+ Request timeout in seconds for fit and predict.
26
+ token : str or None
27
+ Bearer token sent as "Authorization: Bearer <token>" when the server
28
+ requires auth. If None, the TABAPI_API_TOKEN environment variable is
29
+ read at request time. Prefer the environment variable: an explicit
30
+ token is an estimator parameter, so it is kept when the estimator is
31
+ pickled (e.g. saved to a skore project).
32
+ max_retries : int
33
+ How many times a request the server could not take (busy, or no
34
+ healthy backend behind the proxy) is retried. 0 disables retries.
35
+ backoff_factor : float
36
+ Base wait between retries, in seconds; doubled after each retry
37
+ unless the server sends Retry-After.
38
+ n_estimators : int or None
39
+ Passed to the server at fit time (TabICL ensemble size).
40
+ random_state : int or None
41
+ Passed to the server at fit time.
42
+ softmax_temperature : float or None
43
+ Passed to the server at fit time.
44
+ target_name : str
45
+ Name the target column is given in the payload sent to the server. Only
46
+ matters for the server-side schema; predictions are made by model_id.
47
+ Change it if X already has a feature column named "target", otherwise
48
+ the client refuses to overwrite that column.
49
+ """
50
+
51
+ _task = "classification"
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ base_url: str = "http://localhost:8000",
57
+ timeout: float = 300.0,
58
+ token: str | None = None,
59
+ max_retries: int = 3,
60
+ backoff_factor: float = 1.0,
61
+ n_estimators: int | None = None,
62
+ random_state: int | None = None,
63
+ softmax_temperature: float | None = None,
64
+ target_name: str = "target",
65
+ ):
66
+ self.base_url = base_url
67
+ self.timeout = timeout
68
+ self.token = token
69
+ self.max_retries = max_retries
70
+ self.backoff_factor = backoff_factor
71
+ self.n_estimators = n_estimators
72
+ self.random_state = random_state
73
+ self.softmax_temperature = softmax_temperature
74
+ self.target_name = target_name
75
+
76
+ def fit(self, X: pd.DataFrame | np.ndarray, y: pd.Series | np.ndarray) -> TabAPIClassifier:
77
+ """
78
+ Fit a model on the server using (X, y).
79
+
80
+ Parameters
81
+ ----------
82
+ X : DataFrame or ndarray
83
+ Feature matrix. Converted to DataFrame if ndarray.
84
+ y : Series or ndarray
85
+ Target labels.
86
+
87
+ Returns
88
+ -------
89
+ self
90
+ """
91
+ self._fit_remote(X, y, softmax_temperature=self.softmax_temperature)
92
+ self.classes_ = np.unique(y)
93
+ return self
94
+
95
+ def predict(self, X: pd.DataFrame | np.ndarray) -> np.ndarray:
96
+ """
97
+ Predict class labels for X using the model fitted on the server.
98
+
99
+ Parameters
100
+ ----------
101
+ X : DataFrame or ndarray
102
+ Feature matrix. Must have same columns as training data.
103
+
104
+ Returns
105
+ -------
106
+ ndarray of shape (n_samples,) with class labels.
107
+
108
+ Raises
109
+ ------
110
+ sklearn.exceptions.NotFittedError
111
+ If called before ``fit``.
112
+ """
113
+ return self._predict_remote(X, output_type="preds")
114
+
115
+ def predict_proba(self, X: pd.DataFrame | np.ndarray) -> np.ndarray:
116
+ """
117
+ Predict class probabilities for X using the model fitted on the server.
118
+
119
+ Parameters
120
+ ----------
121
+ X : DataFrame or ndarray
122
+ Feature matrix.
123
+
124
+ Returns
125
+ -------
126
+ ndarray of shape (n_samples, n_classes) with class probabilities.
127
+
128
+ Raises
129
+ ------
130
+ sklearn.exceptions.NotFittedError
131
+ If called before ``fit``.
132
+ """
133
+ return self._predict_remote(X, output_type="probas")
tabapi/client.py ADDED
@@ -0,0 +1,306 @@
1
+ """Low-level HTTP client for the TabICL API (fit and predict)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import logging
8
+ import os
9
+ import random
10
+ import time
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import pandas as pd
15
+ import requests
16
+
17
+ TOKEN_ENV_VAR = "TABAPI_API_TOKEN"
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # 503 means the request was not processed: either the server had no free
22
+ # compute slot (it says so with "retryable": true), or the reverse proxy had
23
+ # no healthy backend to forward it to. Safe to retry for every endpoint.
24
+ _RETRY_ALWAYS = frozenset({503})
25
+ # 502/504 may come back after the server started (or even finished) the work.
26
+ # Retrying is only safe for idempotent calls: predict, not fit (a retried fit
27
+ # would train, and keep in server memory, a second model).
28
+ _RETRY_IF_IDEMPOTENT = frozenset({502, 503, 504})
29
+ # Upper bound on one wait, whatever the server's Retry-After says.
30
+ _MAX_RETRY_DELAY = 60.0
31
+
32
+
33
+ def _resolve_token(token: str | None) -> str | None:
34
+ """Token to send: explicit value, else TABAPI_API_TOKEN.
35
+
36
+ Whitespace is stripped, and for a comma-separated list (the server-side
37
+ format of TABAPI_API_TOKEN) the first non-empty entry is used.
38
+ """
39
+ raw = token if token is not None else os.environ.get(TOKEN_ENV_VAR)
40
+ if not raw:
41
+ return None
42
+ for part in raw.split(","):
43
+ if part.strip():
44
+ return part.strip()
45
+ return None
46
+
47
+
48
+ class TabAPIClient:
49
+ """Client for the TabICL API (POST /v1/fit and POST /v1/predict).
50
+
51
+ If the server requires bearer auth, pass ``token`` (or set the
52
+ TABAPI_API_TOKEN environment variable); it is sent as
53
+ "Authorization: Bearer <token>". The environment variable is read at
54
+ request time and never stored on the client, so it is not persisted
55
+ when a fitted estimator is pickled.
56
+
57
+ Requests the server could not take (busy server, or proxy with no healthy
58
+ backend) are retried up to ``max_retries`` times with exponential backoff
59
+ (``backoff_factor`` * 1, 2, 4, ... seconds, or the server's Retry-After).
60
+ fit is retried only on 503 and on errors marked ``"retryable": true``;
61
+ predict, which is idempotent, also on 502 and 504. Set ``max_retries=0``
62
+ to disable retries.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str = "http://localhost:8000",
68
+ timeout: float = 300.0,
69
+ token: str | None = None,
70
+ max_retries: int = 3,
71
+ backoff_factor: float = 1.0,
72
+ ):
73
+ if max_retries < 0:
74
+ raise ValueError(f"max_retries must be >= 0, got {max_retries}")
75
+ if backoff_factor < 0:
76
+ raise ValueError(f"backoff_factor must be >= 0, got {backoff_factor}")
77
+ self.base_url = base_url.rstrip("/")
78
+ self.timeout = timeout
79
+ self.token = token
80
+ self.max_retries = max_retries
81
+ self.backoff_factor = backoff_factor
82
+
83
+ def _headers(self) -> dict[str, str]:
84
+ token = _resolve_token(self.token)
85
+ if token:
86
+ return {"Authorization": f"Bearer {token}"}
87
+ return {}
88
+
89
+ @staticmethod
90
+ def _check_response(r: requests.Response) -> None:
91
+ """Raise requests.HTTPError, including the server's error detail if present."""
92
+ if r.ok:
93
+ return
94
+ try:
95
+ body = r.json()
96
+ except ValueError:
97
+ body = None
98
+ if isinstance(body, dict):
99
+ detail = body.get("detail", r.text)
100
+ else:
101
+ # Non-dict JSON (bare string, list, ...) or non-JSON body: fall back to text.
102
+ detail = r.text if body is None else str(body)
103
+ if isinstance(detail, dict):
104
+ detail = detail.get("detail") or detail.get("code") or str(detail)
105
+ raise requests.HTTPError(
106
+ f"{r.status_code} {r.reason} for {r.url}: {detail}",
107
+ response=r,
108
+ )
109
+
110
+ @staticmethod
111
+ def _marked_retryable(r: requests.Response) -> bool:
112
+ """True if the server's structured error says ``"retryable": true``."""
113
+ try:
114
+ body = r.json()
115
+ except ValueError:
116
+ return False
117
+ detail = body.get("detail") if isinstance(body, dict) else None
118
+ return isinstance(detail, dict) and detail.get("retryable") is True
119
+
120
+ def _should_retry(self, r: requests.Response, idempotent: bool) -> bool:
121
+ if r.ok:
122
+ return False
123
+ if r.status_code in _RETRY_ALWAYS:
124
+ return True
125
+ if idempotent and r.status_code in _RETRY_IF_IDEMPOTENT:
126
+ return True
127
+ return self._marked_retryable(r)
128
+
129
+ def _retry_delay(self, r: requests.Response, attempt: int) -> float:
130
+ """Server's Retry-After (in seconds) if given, else exponential backoff.
131
+
132
+ Stretched by a random 0-50% so clients turned away together do not all
133
+ come back at the same instant and collide again.
134
+ """
135
+ retry_after = r.headers.get("Retry-After")
136
+ try:
137
+ delay = float(retry_after) if retry_after is not None else None
138
+ except ValueError:
139
+ delay = None # HTTP-date form: fall back to backoff
140
+ if delay is None or delay < 0:
141
+ delay = self.backoff_factor * (2**attempt)
142
+ delay *= random.uniform(1.0, 1.5)
143
+ return min(delay, _MAX_RETRY_DELAY)
144
+
145
+ def _post(
146
+ self,
147
+ path: str,
148
+ make_files: Callable[[], dict[str, Any]],
149
+ *,
150
+ idempotent: bool,
151
+ ) -> requests.Response:
152
+ """POST with retries; ``make_files`` builds fresh upload buffers per attempt."""
153
+ url = f"{self.base_url}{path}"
154
+ attempt = 0
155
+ while True:
156
+ r = requests.post(
157
+ url,
158
+ files=make_files(),
159
+ headers=self._headers(),
160
+ timeout=self.timeout,
161
+ )
162
+ if attempt >= self.max_retries or not self._should_retry(r, idempotent):
163
+ self._check_response(r)
164
+ return r
165
+ delay = self._retry_delay(r, attempt)
166
+ attempt += 1
167
+ logger.warning(
168
+ "%s returned %s; retrying in %.1fs (attempt %d/%d)",
169
+ path,
170
+ r.status_code,
171
+ delay,
172
+ attempt,
173
+ self.max_retries,
174
+ )
175
+ time.sleep(delay)
176
+
177
+ def fit(
178
+ self,
179
+ X: pd.DataFrame,
180
+ y: pd.Series,
181
+ *,
182
+ task: str = "classification",
183
+ target_name: str = "target",
184
+ n_estimators: int | None = None,
185
+ random_state: int | None = None,
186
+ softmax_temperature: float | None = None,
187
+ ) -> str:
188
+ """
189
+ Fit a model on the server. Returns the model_id for use in predict.
190
+
191
+ Parameters
192
+ ----------
193
+ X : DataFrame
194
+ Feature matrix.
195
+ y : Series
196
+ Target labels.
197
+ task : str
198
+ Task the server fits the model for.
199
+ target_name : str
200
+ Name of the target column (used in the API schema).
201
+ n_estimators, random_state, softmax_temperature
202
+ Optional server-side TabICL config.
203
+
204
+ Returns
205
+ -------
206
+ model_id : str
207
+ UUID of the fitted model on the server.
208
+
209
+ Raises
210
+ ------
211
+ ValueError
212
+ If ``target_name`` is already a column of ``X``.
213
+ """
214
+ if target_name in X.columns:
215
+ raise ValueError(
216
+ f"target_name {target_name!r} is already a column in X; "
217
+ "rename that feature or pass a different target_name"
218
+ )
219
+ df = X.copy()
220
+ df[target_name] = y
221
+ buf = io.BytesIO()
222
+ df.to_csv(buf, index=False)
223
+ csv_bytes = buf.getvalue()
224
+
225
+ payload: dict[str, Any] = {
226
+ "task": task,
227
+ "schema": {"target": target_name},
228
+ }
229
+ config: dict[str, Any] = {}
230
+ if n_estimators is not None:
231
+ config["n_estimators"] = n_estimators
232
+ if random_state is not None:
233
+ config["random_state"] = random_state
234
+ if softmax_temperature is not None:
235
+ config["softmax_temperature"] = softmax_temperature
236
+ if config:
237
+ payload["config"] = config
238
+ data_field = json.dumps(payload)
239
+
240
+ r = self._post(
241
+ "/v1/fit",
242
+ lambda: {
243
+ "data": (None, data_field, "application/json"),
244
+ "dataset_file": ("train.csv", io.BytesIO(csv_bytes), "text/csv"),
245
+ },
246
+ idempotent=False,
247
+ )
248
+ data = r.json()
249
+ return data["model_id"]
250
+
251
+ def predict(
252
+ self,
253
+ model_id: str,
254
+ X: pd.DataFrame,
255
+ *,
256
+ task: str = "classification",
257
+ output_type: str = "preds",
258
+ ) -> Any:
259
+ """
260
+ Run predictions on the server.
261
+
262
+ Parameters
263
+ ----------
264
+ model_id : str
265
+ Model ID returned by fit().
266
+ X : DataFrame
267
+ Feature matrix (same columns as training).
268
+ task : str
269
+ Task the model was fitted for.
270
+ output_type : str
271
+ "preds" for class labels, "probas" for probability arrays.
272
+
273
+ Returns
274
+ -------
275
+ If output_type == "preds": list of class labels.
276
+ If output_type == "probas": list of lists (probability per class).
277
+ """
278
+ buf = io.BytesIO()
279
+ X.to_csv(buf, index=False)
280
+ csv_bytes = buf.getvalue()
281
+
282
+ payload = {
283
+ "model_id": model_id,
284
+ "task": task,
285
+ "output_type": output_type,
286
+ }
287
+ data_field = json.dumps(payload)
288
+
289
+ r = self._post(
290
+ "/v1/predict",
291
+ lambda: {
292
+ "data": (None, data_field, "application/json"),
293
+ "file": ("predict.csv", io.BytesIO(csv_bytes), "text/csv"),
294
+ },
295
+ idempotent=True,
296
+ )
297
+ data = r.json()
298
+ return data["prediction"]
299
+
300
+ def health(self) -> bool:
301
+ """Return True if the server is reachable."""
302
+ try:
303
+ r = requests.get(f"{self.base_url}/health", timeout=5.0)
304
+ return r.status_code == 200
305
+ except Exception:
306
+ return False
tabapi/py.typed ADDED
File without changes
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.5
2
+ Name: tabapi
3
+ Version: 0.1.0
4
+ Summary: scikit-learn-style Python client for tabular foundation models (TabICL) served over HTTP
5
+ Project-URL: Homepage, https://probabl.ai
6
+ Author: Probabl
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Keywords: api-client,foundation-model,in-context-learning,scikit-learn,tabicl,tabular
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
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: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: numpy>=1.24.0
24
+ Requires-Dist: pandas>=2.0.0
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: scikit-learn>=1.3.0
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=8.0; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # tabapi
32
+
33
+ A scikit-learn-style Python client for tabular foundation models (TFMs) such as
34
+ [TabICL](https://github.com/soda-inria/tabicl), served over HTTP.
35
+
36
+ TFMs predict by in-context learning: there is no training loop on your machine.
37
+ `tabapi` sends your labelled rows to a TabAPI server, which runs the model, and
38
+ gives you back predictions through the familiar `fit` / `predict` /
39
+ `predict_proba` interface. You don't need torch or a GPU locally.
40
+
41
+ > **Status: alpha.** The API may change between minor versions. You need access
42
+ > to a running TabAPI server.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install tabapi
48
+ ```
49
+
50
+ Requires Python 3.10+.
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ from sklearn.datasets import load_breast_cancer
56
+ from sklearn.model_selection import train_test_split
57
+ from tabapi import TabAPIClassifier
58
+
59
+ X, y = load_breast_cancer(return_X_y=True, as_frame=True)
60
+ X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)
61
+
62
+ clf = TabAPIClassifier(base_url="https://your-tabapi-server.example.com")
63
+ clf.fit(X_train, y_train) # uploads the training context, gets a model id
64
+ y_pred = clf.predict(X_test)
65
+ proba = clf.predict_proba(X_test)
66
+ print(clf.score(X_test, y_test))
67
+ ```
68
+
69
+ `TabAPIClassifier` is a regular scikit-learn estimator, so it works with
70
+ `cross_val_score`, `Pipeline`, `clone`, and so on.
71
+
72
+ ## Authentication
73
+
74
+ If the server requires a bearer token, pass `token=...` or set the
75
+ `TABAPI_API_TOKEN` environment variable. The environment variable is read at
76
+ request time and is never stored on the estimator, so it does not end up in a
77
+ pickled model.
78
+
79
+ ## Retries
80
+
81
+ Requests the server could not take (busy server, proxy with no healthy backend)
82
+ are retried with exponential backoff, honouring `Retry-After`. Tune this with
83
+ `max_retries` and `backoff_factor`, or turn it off with `max_retries=0`.
84
+
85
+ ## Low-level client
86
+
87
+ `TabAPIClient` exposes the raw HTTP calls (`fit`, `predict`, `health`) if you
88
+ don't want the scikit-learn wrapper.
89
+
90
+ ## License
91
+
92
+ BSD-3-Clause.
@@ -0,0 +1,9 @@
1
+ tabapi/__init__.py,sha256=1CXIF99E1z7TUnz0piwGJS5mVXqJBjZYEm_fXggdDxQ,245
2
+ tabapi/_base.py,sha256=u_TB3g8ScBgrymHWL5V4szgBOQeqxU4kLSg0Pep6zJw,2061
3
+ tabapi/classifier.py,sha256=mLhyPX7Gy6WeDiBka6mMFEiszRr8_m9w6UNViXPdeBw,4482
4
+ tabapi/client.py,sha256=a4AnoWsHlE0lyich9iZgo-JnHnDaG050q7UB711MW-k,10155
5
+ tabapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tabapi-0.1.0.dist-info/METADATA,sha256=-OwG9-vSKj_dpOGaY0MiduEaMElUF9BhRKvkXheMx2A,3168
7
+ tabapi-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ tabapi-0.1.0.dist-info/licenses/LICENSE,sha256=p5FMhe5GUbgQ_RZASK7inMy3EB_i58z0O6YuI2wVLtA,1494
9
+ tabapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Probabl
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.