stacklink 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.
stacklink/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """stacklink — health checks, config validation, and structured logging for FastAPI."""
2
+
3
+ from stacklink.config import Config, ConfigError, Field, StacklinkError
4
+ from stacklink.health import HealthError, health
5
+ from stacklink.logger import LoggerError, logger
6
+
7
+ __all__ = [
8
+ "Config",
9
+ "ConfigError",
10
+ "Field",
11
+ "HealthError",
12
+ "LoggerError",
13
+ "StacklinkError",
14
+ "health",
15
+ "logger",
16
+ ]
stacklink/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """Configuration loading from environment variables and .env files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast, get_type_hints
8
+
9
+ from dotenv import find_dotenv, load_dotenv
10
+
11
+ # Sentinel to distinguish "no default" from None/False/0
12
+ _MISSING: Any = object() # Any: sentinel needs to be assignable anywhere
13
+
14
+ _BOOL_TRUE = frozenset({"true", "1", "yes"})
15
+ _BOOL_FALSE = frozenset({"false", "0", "no"})
16
+ _COERCIBLE_TYPES: dict[type, str] = {
17
+ str: "string",
18
+ int: "integer",
19
+ float: "float",
20
+ bool: "boolean",
21
+ }
22
+
23
+
24
+ class StacklinkError(Exception):
25
+ """Base exception for all stacklink errors."""
26
+
27
+
28
+ class ConfigError(StacklinkError):
29
+ """Raised when configuration loading or validation fails."""
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class _FieldMeta:
34
+ """Internal metadata for a config field."""
35
+
36
+ required: bool
37
+ default: Any # Any: default can be any type matching the field
38
+ secret: bool
39
+
40
+
41
+ def Field(
42
+ *,
43
+ required: bool = False,
44
+ default: Any = _MISSING, # Any: default can be any type matching the field
45
+ secret: bool = False,
46
+ ) -> Any: # Any: return is assigned as a class-level default annotation
47
+ """Declare metadata for a config field."""
48
+ return _FieldMeta(required=required, default=default, secret=secret)
49
+
50
+
51
+ class Config:
52
+ """Base class for typed configuration loaded from environment variables."""
53
+
54
+ def __init__(self) -> None:
55
+ """Load .env file and populate fields from environment variables."""
56
+ load_dotenv(find_dotenv(usecwd=True), override=True)
57
+ hints = get_type_hints(type(self))
58
+ for name, field_type in hints.items():
59
+ meta = _extract_meta(type(self), name)
60
+ env_var = name.upper()
61
+ raw = os.environ.get(env_var)
62
+ value = _resolve_value(name, env_var, raw, meta, field_type)
63
+ object.__setattr__(self, name, value)
64
+
65
+ def secret_field_names(self) -> frozenset[str]:
66
+ """Return names of all fields marked as secret."""
67
+ hints = get_type_hints(type(self))
68
+ return frozenset(
69
+ name for name in hints if _extract_meta(type(self), name).secret
70
+ )
71
+
72
+ def as_dict(self) -> dict[str, Any]: # Any: field values can be any coercible type
73
+ """Return all fields as a dictionary, redacting secret values."""
74
+ hints = get_type_hints(type(self))
75
+ result: dict[str, Any] = {}
76
+ for name in hints:
77
+ meta = _extract_meta(type(self), name)
78
+ value = getattr(self, name)
79
+ result[name] = "***" if meta.secret else value
80
+ return result
81
+
82
+
83
+ def _extract_meta(cls: type, name: str) -> _FieldMeta:
84
+ """Extract field metadata from a class attribute."""
85
+ raw = cls.__dict__.get(name, _MISSING)
86
+ if isinstance(raw, _FieldMeta):
87
+ return raw
88
+ return _FieldMeta(
89
+ required=False, default=raw if raw is not _MISSING else _MISSING, secret=False
90
+ )
91
+
92
+
93
+ def _resolve_value(
94
+ name: str,
95
+ env_var: str,
96
+ raw: str | None,
97
+ meta: _FieldMeta,
98
+ field_type: type,
99
+ ) -> Any: # Any: return type varies by field_type
100
+ """Resolve a field's value from env, default, or raise on missing required."""
101
+ if raw is not None:
102
+ return _coerce(name, env_var, raw, field_type, meta.secret)
103
+ if meta.required:
104
+ _raise_missing(name, env_var)
105
+ if meta.default is not _MISSING:
106
+ return meta.default
107
+ return None
108
+
109
+
110
+ def _coerce(
111
+ name: str, env_var: str, raw: str, field_type: type, secret: bool
112
+ ) -> Any: # Any: varies by field_type
113
+ """Coerce a raw string to the target type."""
114
+ if field_type is str:
115
+ return raw
116
+ if field_type is bool:
117
+ return _coerce_bool(name, env_var, raw, secret)
118
+ return _coerce_numeric(name, env_var, raw, field_type, secret)
119
+
120
+
121
+ def _coerce_bool(name: str, env_var: str, raw: str, secret: bool) -> bool:
122
+ """Coerce a raw string to a boolean value."""
123
+ lower = raw.lower()
124
+ if lower in _BOOL_TRUE:
125
+ return True
126
+ if lower in _BOOL_FALSE:
127
+ return False
128
+ display = "***" if secret else repr(raw)
129
+ raise ConfigError(
130
+ f"Invalid boolean value for '{name}'.\n"
131
+ f"{env_var}={display} is not a recognized boolean.\n"
132
+ f"Use one of: true, false, 1, 0, yes, no."
133
+ ) from None
134
+
135
+
136
+ def _coerce_numeric(
137
+ name: str,
138
+ env_var: str,
139
+ raw: str,
140
+ field_type: type,
141
+ secret: bool,
142
+ ) -> int | float:
143
+ """Coerce a raw string to int or float."""
144
+ try:
145
+ return cast(int | float, field_type(raw))
146
+ except (ValueError, TypeError):
147
+ display = "***" if secret else repr(raw)
148
+ type_name = _COERCIBLE_TYPES.get(field_type, field_type.__name__)
149
+ raise ConfigError(
150
+ f"Invalid {type_name} value for '{name}'.\n"
151
+ f"{env_var}={display} cannot be converted to {type_name}.\n"
152
+ f"Set {env_var} to a valid {type_name}."
153
+ ) from None
154
+
155
+
156
+ def _raise_missing(name: str, env_var: str) -> None:
157
+ """Raise ConfigError for a missing required field."""
158
+ raise ConfigError(
159
+ f"Required config field '{name}' is missing.\n"
160
+ f"Environment variable {env_var} is not set.\n"
161
+ f"Set {env_var} in your .env file or environment."
162
+ ) from None
stacklink/health.py ADDED
@@ -0,0 +1,103 @@
1
+ """Health check endpoints for FastAPI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from stacklink.config import Config, StacklinkError
12
+
13
+ CheckFn = Callable[[], Awaitable[bool]]
14
+
15
+
16
+ class HealthError(StacklinkError):
17
+ """Raised when the health module encounters an error."""
18
+
19
+
20
+ class HealthRegistry:
21
+ """Registry for health check endpoints and custom readiness checks."""
22
+
23
+ def __init__(self) -> None:
24
+ """Initialize an empty health registry."""
25
+ self._checks: dict[str, CheckFn] = {}
26
+ self._start_time: float | None = None
27
+ self._service: str | None = None
28
+ self._env: str | None = None
29
+
30
+ def register(self, app: FastAPI, config: Config | None = None) -> None:
31
+ """Attach /healthz and /readyz health check endpoints to a FastAPI app."""
32
+ self._start_time = time.monotonic()
33
+ self._extract_config(config)
34
+ app.add_api_route("/healthz", self._healthz, methods=["GET"])
35
+ app.add_api_route("/readyz", self._readyz, methods=["GET"])
36
+
37
+ def add_check(self, name: str, fn: CheckFn) -> None:
38
+ """Register a custom async check function for readiness."""
39
+ self._checks[name] = fn
40
+
41
+ def _extract_config(self, config: Config | None) -> None:
42
+ """Pull service and env from config if available."""
43
+ if config is None:
44
+ return
45
+ service = getattr(config, "service", None)
46
+ env = getattr(config, "env", None)
47
+ if isinstance(service, str):
48
+ self._service = service
49
+ if isinstance(env, str):
50
+ self._env = env
51
+
52
+ def _uptime(self) -> float:
53
+ """Calculate seconds since register() was called."""
54
+ if self._start_time is None:
55
+ return 0.0
56
+ return round(time.monotonic() - self._start_time, 1)
57
+
58
+ def _base_response(self) -> dict[str, object]:
59
+ """Build the common response fields."""
60
+ data: dict[str, object] = {}
61
+ if self._service is not None:
62
+ data["service"] = self._service
63
+ if self._env is not None:
64
+ data["env"] = self._env
65
+ data["uptime_seconds"] = self._uptime()
66
+ return data
67
+
68
+ async def _healthz(self) -> JSONResponse:
69
+ """Handle GET /healthz -- liveness check."""
70
+ data = self._base_response()
71
+ data["status"] = "ok"
72
+ return JSONResponse(content=data, status_code=200)
73
+
74
+ async def _readyz(self) -> JSONResponse:
75
+ """Handle GET /readyz -- readiness check."""
76
+ check_results = await self._run_checks()
77
+ all_ok = all(v == "ok" for v in check_results.values())
78
+ status = "ok" if all_ok else "degraded"
79
+ status_code = 200 if all_ok else 503
80
+
81
+ data = self._base_response()
82
+ data["status"] = status
83
+ data["checks"] = check_results
84
+ return JSONResponse(content=data, status_code=status_code)
85
+
86
+ async def _run_checks(self) -> dict[str, str]:
87
+ """Execute all registered checks and collect results."""
88
+ results: dict[str, str] = {}
89
+ for name, fn in self._checks.items():
90
+ results[name] = await _execute_check(fn)
91
+ return results
92
+
93
+
94
+ async def _execute_check(fn: CheckFn) -> str:
95
+ """Run a single check function, returning 'ok' or 'failing'."""
96
+ try:
97
+ result = await fn()
98
+ return "ok" if result else "failing"
99
+ except Exception:
100
+ return "failing"
101
+
102
+
103
+ health = HealthRegistry()
stacklink/logger.py ADDED
@@ -0,0 +1,168 @@
1
+ """Structured JSON logging with optional config-based tagging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from stacklink.config import Config, StacklinkError
13
+
14
+
15
+ class LoggerError(StacklinkError):
16
+ """Raised when the logger encounters an error."""
17
+
18
+
19
+ class _LoggerState:
20
+ """Mutable state shared between the logger and its formatter."""
21
+
22
+ def __init__(self) -> None:
23
+ """Initialize empty logger state."""
24
+ self.service: str | None = None
25
+ self.env: str | None = None
26
+ self.secret_names: frozenset[str] = frozenset()
27
+
28
+
29
+ class _JsonFormatter(logging.Formatter):
30
+ """Formats log records as single-line JSON objects."""
31
+
32
+ def __init__(self, logger_state: _LoggerState) -> None:
33
+ """Bind formatter to shared logger state."""
34
+ super().__init__()
35
+ self._state = logger_state
36
+
37
+ def format(self, record: logging.LogRecord) -> str:
38
+ """Serialize a log record to a JSON string."""
39
+ entry = _build_entry(record, self._state)
40
+ return json.dumps(entry, default=str)
41
+
42
+
43
+ def _build_entry(
44
+ record: logging.LogRecord,
45
+ state: _LoggerState,
46
+ ) -> dict[str, Any]: # Any: log values can be any JSON-serializable type
47
+ """Assemble the JSON dict for one log line."""
48
+ entry: dict[str, Any] = { # Any: mixed value types in log output
49
+ "timestamp": _utc_timestamp(),
50
+ "level": record.levelname,
51
+ }
52
+ if state.service is not None:
53
+ entry["service"] = state.service
54
+ if state.env is not None:
55
+ entry["env"] = state.env
56
+ entry["message"] = record.getMessage()
57
+ extras: dict[str, Any] = getattr(
58
+ record, "_extra_kwargs", {}
59
+ ) # Any: user-supplied kwargs
60
+ entry.update(_filter_secrets(extras, state.secret_names))
61
+ return entry
62
+
63
+
64
+ def _utc_timestamp() -> str:
65
+ """Return the current UTC time in ISO 8601 format."""
66
+ now = datetime.now(timezone.utc)
67
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
68
+
69
+
70
+ def _filter_secrets(
71
+ data: dict[str, Any], # Any: user-supplied kwargs
72
+ secret_names: frozenset[str],
73
+ ) -> dict[str, Any]: # Any: filtered kwargs
74
+ """Remove any keys whose names match secret config fields."""
75
+ return {k: v for k, v in data.items() if k not in secret_names}
76
+
77
+
78
+ def _get_log_level() -> int:
79
+ """Read LOG_LEVEL from env and map to a logging constant."""
80
+ level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
81
+ level_map: dict[str, int] = {
82
+ "DEBUG": logging.DEBUG,
83
+ "INFO": logging.INFO,
84
+ "WARNING": logging.WARNING,
85
+ "ERROR": logging.ERROR,
86
+ }
87
+ level = level_map.get(level_name)
88
+ if level is None:
89
+ raise LoggerError(
90
+ f"Invalid LOG_LEVEL '{level_name}'.\n"
91
+ f"LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR.\n"
92
+ f"Set LOG_LEVEL to a valid level or remove it to default to INFO."
93
+ ) from None
94
+ return level
95
+
96
+
97
+ class StacklinkLogger:
98
+ """Singleton structured JSON logger for stacklink."""
99
+
100
+ def __init__(self) -> None:
101
+ """Set up the underlying Python logger with a JSON handler."""
102
+ self._state = _LoggerState()
103
+ self._logger = logging.getLogger("stacklink")
104
+ self._logger.handlers.clear()
105
+ self._logger.propagate = False
106
+ handler = logging.StreamHandler(sys.stdout)
107
+ handler.setFormatter(_JsonFormatter(self._state))
108
+ self._logger.addHandler(handler)
109
+ self._logger.setLevel(_get_log_level())
110
+
111
+ def configure(self, config: Config) -> None:
112
+ """Attach service and env from a Config instance to all future log lines."""
113
+ self._state.secret_names = config.secret_field_names()
114
+ service = getattr(config, "service", None)
115
+ env = getattr(config, "env", None)
116
+ if isinstance(service, str):
117
+ self._state.service = service
118
+ if isinstance(env, str):
119
+ self._state.env = env
120
+
121
+ def info(
122
+ self, message: str, **kwargs: Any
123
+ ) -> None: # Any: arbitrary structured data
124
+ """Log a message at INFO level with optional structured fields."""
125
+ self._log(logging.INFO, message, kwargs)
126
+
127
+ def warning(
128
+ self, message: str, **kwargs: Any
129
+ ) -> None: # Any: arbitrary structured data
130
+ """Log a message at WARNING level with optional structured fields."""
131
+ self._log(logging.WARNING, message, kwargs)
132
+
133
+ def error(
134
+ self, message: str, **kwargs: Any
135
+ ) -> None: # Any: arbitrary structured data
136
+ """Log a message at ERROR level with optional structured fields."""
137
+ self._log(logging.ERROR, message, kwargs)
138
+
139
+ def debug(
140
+ self, message: str, **kwargs: Any
141
+ ) -> None: # Any: arbitrary structured data
142
+ """Log a message at DEBUG level with optional structured fields."""
143
+ self._log(logging.DEBUG, message, kwargs)
144
+
145
+ def _log(
146
+ self,
147
+ level: int,
148
+ message: str,
149
+ extra_kwargs: dict[str, Any], # Any: user-supplied structured data
150
+ ) -> None:
151
+ """Dispatch a log record with extra kwargs attached."""
152
+ self._logger.setLevel(_get_log_level())
153
+ if not self._logger.isEnabledFor(level):
154
+ return
155
+ record = self._logger.makeRecord(
156
+ name="stacklink",
157
+ level=level,
158
+ fn="",
159
+ lno=0,
160
+ msg=message,
161
+ args=(),
162
+ exc_info=None,
163
+ )
164
+ record._extra_kwargs = extra_kwargs # dynamic attr for formatter
165
+ self._logger.handle(record)
166
+
167
+
168
+ logger = StacklinkLogger()
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: stacklink
3
+ Version: 0.1.0
4
+ Summary: Health checks, config validation, and structured logging for FastAPI
5
+ Project-URL: Homepage, https://github.com/dressupdarling/stacklink
6
+ Project-URL: Repository, https://github.com/dressupdarling/stacklink
7
+ Project-URL: Issues, https://github.com/dressupdarling/stacklink/issues
8
+ Author-email: Yevgeny <yevgenyokun2@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: config,fastapi,health-check,logging,structured-logging
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: fastapi>=0.100.0
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: black>=23.0; extra == 'dev'
26
+ Requires-Dist: build>=1.0; extra == 'dev'
27
+ Requires-Dist: httpx>=0.24.0; extra == 'dev'
28
+ Requires-Dist: mypy>=1.0; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
32
+ Requires-Dist: twine>=5.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # stacklink
36
+
37
+ Health checks, config validation, and structured logging for FastAPI — in three lines of code.
38
+
39
+ Built for solo devs and small teams who want production-grade infrastructure without the setup overhead.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install stacklink
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ from fastapi import FastAPI
51
+ from stacklink import Config, Field, health, logger
52
+
53
+ app = FastAPI()
54
+
55
+ class AppConfig(Config):
56
+ env: str = Field(required=True)
57
+ service: str = Field(default="my-api")
58
+ port: int = Field(default=8080)
59
+ db_url: str = Field(required=True, secret=True)
60
+
61
+ config = AppConfig()
62
+
63
+ logger.configure(config)
64
+ logger.info("server starting", port=config.port)
65
+
66
+ health.register(app, config=config)
67
+ ```
68
+
69
+ That's it. You now have:
70
+
71
+ - Validated, typed config from `.env` and environment variables
72
+ - Structured JSON logging to stdout
73
+ - `/healthz` and `/readyz` endpoints on your FastAPI app
74
+
75
+ ## Modules
76
+
77
+ ### Config
78
+
79
+ Reads `.env` files and environment variables. Validates types. Fails loudly at startup if anything is wrong. Redacts secrets from output.
80
+
81
+ ```python
82
+ from stacklink import Config, Field
83
+
84
+ class AppConfig(Config):
85
+ env: str = Field(required=True)
86
+ port: int = Field(default=8080)
87
+ db_url: str = Field(required=True, secret=True)
88
+
89
+ config = AppConfig()
90
+ config.as_dict() # {"env": "production", "port": 8080, "db_url": "***"}
91
+ ```
92
+
93
+ Supported types: `str`, `int`, `float`, `bool`. Bool coerces from `true/false/1/0/yes/no`.
94
+
95
+ ### Logger
96
+
97
+ Structured JSON logging. Every line is machine-parseable. Auto-tags with service name and environment when configured.
98
+
99
+ ```python
100
+ from stacklink import logger
101
+
102
+ logger.info("request handled", method="GET", path="/users", status=200)
103
+ # {"timestamp": "...", "level": "INFO", "message": "request handled", "method": "GET", "path": "/users", "status": 200}
104
+ ```
105
+
106
+ Set `LOG_LEVEL` env var to control filtering (default: `INFO`).
107
+
108
+ ### Health
109
+
110
+ Registers `/healthz` (liveness) and `/readyz` (readiness) endpoints on your FastAPI app.
111
+
112
+ ```python
113
+ from stacklink import health
114
+
115
+ health.register(app, config=config)
116
+
117
+ async def check_database():
118
+ try:
119
+ await db.execute("SELECT 1")
120
+ return True
121
+ except Exception:
122
+ return False
123
+
124
+ health.add_check("database", check_database)
125
+ ```
126
+
127
+ `/healthz` always returns `200`. `/readyz` returns `200` when all checks pass, `503` when any check fails.
128
+
129
+ ## Documentation
130
+
131
+ - [Config](docs/config.md)
132
+ - [Logger](docs/logger.md)
133
+ - [Health](docs/health.md)
134
+
135
+ ## Requirements
136
+
137
+ - Python 3.10+
138
+ - FastAPI 0.100+
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ pip install -e ".[dev]"
144
+ pytest
145
+ ```
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,8 @@
1
+ stacklink/__init__.py,sha256=tliqTCFN_2mB64vG3h9PF_0sHiqEeO6KtkTUGwoUxw4,410
2
+ stacklink/config.py,sha256=EcyBvqftADYsCxoME-N5unAiMPtkRYDNoF4CiQDe_Vc,5272
3
+ stacklink/health.py,sha256=KXkO6gnXMPCF4tdWLBuNmHWwK1A_gQrk7pFI2BNl6Fw,3532
4
+ stacklink/logger.py,sha256=qIW91YGf7D9yjr5A2nnn7Pj3FNbFZ4Qdeq81mjx-W4c,5643
5
+ stacklink-0.1.0.dist-info/METADATA,sha256=FDokkwrbnQ3lTqfbSeouLEIIQEaRDu5padXBJA6PpzM,3980
6
+ stacklink-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ stacklink-0.1.0.dist-info/licenses/LICENSE,sha256=6buXR5E8cfifGgwqI3PACDnT--AC0obp0c_VdsQ15ZI,1070
8
+ stacklink-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cookies_lover
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.