xpectral-utils 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.
@@ -0,0 +1,20 @@
1
+ """Shared utility functions for xpectral projects."""
2
+
3
+ # ----------------------------------------------------------------------------
4
+ # Imports
5
+ # ----------------------------------------------------------------------------
6
+
7
+ # Local imports
8
+ from .logger import setup_logging
9
+
10
+ # ----------------------------------------------------------------------------
11
+ # Globals and constants
12
+ # ----------------------------------------------------------------------------
13
+
14
+ # Importing ``logger`` (above) registers the package's NullHandler on import,
15
+ # keeping the library silent until an app opts in. Because importing any
16
+ # ``xpectral_utils.*`` submodule runs this ``__init__`` first, that registration
17
+ # happens automatically for every module that logs -- no per-module wiring needed.
18
+ # ``setup_logging`` is re-exported for convenience:
19
+ # ``from xpectral_utils import setup_logging``.
20
+ __all__ = ["setup_logging"]
@@ -0,0 +1,107 @@
1
+ """On-disk function memoization built on diskcache (install ``xpectral-utils[cache]``)."""
2
+
3
+ # -----------------------------------------------------------------------------
4
+ # Imports
5
+ # -----------------------------------------------------------------------------
6
+
7
+ # Standard library imports
8
+ import os
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ # Third-party imports
14
+ from diskcache import Cache
15
+
16
+ # -----------------------------------------------------------------------------
17
+ # Globals and constants
18
+ # -----------------------------------------------------------------------------
19
+
20
+ __all__ = ["EvictionPolicy", "disk_cache"]
21
+
22
+ # The eviction policies diskcache accepts once ``size_limit`` is reached.
23
+ EvictionPolicy = Literal[
24
+ "none",
25
+ "least-recently-stored",
26
+ "least-recently-used",
27
+ "least-frequently-used",
28
+ ]
29
+
30
+ # Root under which every function gets its own cache subdirectory. Overridable
31
+ # per call, or globally via the XPECTRAL_CACHE_DIR environment variable.
32
+ _DEFAULT_CACHE_DIR = Path.home() / ".cache" / "xpectral"
33
+
34
+ # Default per-function size cap before least-recently-used eviction kicks in.
35
+ _SIZE_LIMIT = 2 * 2**30 # 2 GiB
36
+
37
+ # -----------------------------------------------------------------------------
38
+ # General API
39
+ # -----------------------------------------------------------------------------
40
+
41
+
42
+ def disk_cache(
43
+ name: str | None = None,
44
+ *,
45
+ expire: float | None = None,
46
+ cache_dir: str | os.PathLike | None = None,
47
+ size_limit: int = _SIZE_LIMIT,
48
+ eviction_policy: EvictionPolicy = "least-recently-used",
49
+ typed: bool = False,
50
+ ) -> Callable:
51
+ """Cache a function's return values on disk, keyed by its arguments.
52
+
53
+ Each decorated function gets its own :class:`diskcache.Cache` backed by its
54
+ own subdirectory -- there is no shared global cache instance. Values are
55
+ pickled, so the function must return picklable objects and take
56
+ picklable/hashable arguments.
57
+
58
+ Args:
59
+ name: Subdirectory name for this function's cache. Defaults to the
60
+ function's qualified name.
61
+ expire: Time-to-live in seconds. ``None`` caches indefinitely.
62
+ cache_dir: Root directory for the cache. Defaults to the
63
+ ``XPECTRAL_CACHE_DIR`` environment variable, else
64
+ ``~/.cache/xpectral``.
65
+ size_limit: Byte cap for this function's cache before entries are evicted
66
+ per ``eviction_policy``.
67
+ eviction_policy: How entries are chosen for eviction once ``size_limit``
68
+ is reached. One of ``"least-recently-stored"``,
69
+ ``"least-recently-used"``, ``"least-frequently-used"``, or ``"none"``
70
+ (no automatic eviction).
71
+ typed: When ``True``, arguments of different types are cached separately
72
+ (e.g. ``1`` and ``1.0``).
73
+
74
+ Returns:
75
+ A decorator that wraps the target function with on-disk memoization.
76
+ The wrapped function exposes its backing store as ``func.cache`` (a
77
+ :class:`diskcache.Cache`), so callers can ``func.cache.clear()`` or
78
+ check ``len(func.cache)``.
79
+ """
80
+
81
+ def decorator(func: Callable) -> Callable:
82
+ directory = _resolve_dir(cache_dir) / (name or func.__qualname__)
83
+ cache = Cache(
84
+ directory=str(directory),
85
+ size_limit=size_limit,
86
+ eviction_policy=eviction_policy,
87
+ )
88
+ memoized = cache.memoize(expire=expire, typed=typed)(func)
89
+ # Expose the backing Cache so callers can inspect or clear it, e.g.
90
+ # ``func.cache.clear()`` or ``len(func.cache)``.
91
+ memoized.cache = cache
92
+ return memoized
93
+
94
+ return decorator
95
+
96
+
97
+ # -----------------------------------------------------------------------------
98
+ # Private API
99
+ # -----------------------------------------------------------------------------
100
+
101
+
102
+ def _resolve_dir(cache_dir: str | os.PathLike | None) -> Path:
103
+ """Resolve the cache root: explicit arg, else env var, else default."""
104
+ if cache_dir is not None:
105
+ return Path(cache_dir)
106
+ env = os.getenv("XPECTRAL_CACHE_DIR")
107
+ return Path(env) if env else _DEFAULT_CACHE_DIR
@@ -0,0 +1,61 @@
1
+ """Date and datetime normalization helpers."""
2
+
3
+ # ----------------------------------------------------------------------------
4
+ # Imports
5
+ # ----------------------------------------------------------------------------
6
+
7
+ # Standard library imports
8
+ from datetime import UTC
9
+ from datetime import date
10
+ from datetime import datetime
11
+
12
+ # ----------------------------------------------------------------------------
13
+ # Globals and constants
14
+ # ----------------------------------------------------------------------------
15
+
16
+ __all__ = ["to_date", "to_datetime"]
17
+
18
+ # ----------------------------------------------------------------------------
19
+ # General API
20
+ # ----------------------------------------------------------------------------
21
+
22
+
23
+ def to_date(value: str | date) -> date:
24
+ """Normalize a calendar day to a ``date``.
25
+
26
+ Args:
27
+ value: ISO 8601 date string (``2024-03-01``) or a ``date``.
28
+
29
+ Returns:
30
+ The calendar day as a ``date``.
31
+
32
+ Raises:
33
+ TypeError: If ``value`` is a ``datetime``.
34
+ ValueError: If ``value`` is a malformed string.
35
+ """
36
+ # datetime is a subclass of date, so reject it before anything else.
37
+ if isinstance(value, datetime):
38
+ raise TypeError("expected a date, got a datetime")
39
+ if isinstance(value, str):
40
+ return date.fromisoformat(value)
41
+ return value
42
+
43
+
44
+ def to_datetime(value: str | datetime) -> datetime:
45
+ """Normalize an instant to a timezone-aware ``datetime`` in UTC.
46
+
47
+ Args:
48
+ value: ISO 8601 string with an offset (``2024-03-01T14:30:00Z``) or an
49
+ aware ``datetime``.
50
+
51
+ Returns:
52
+ The instant as an aware ``datetime`` in UTC.
53
+
54
+ Raises:
55
+ ValueError: If ``value`` is naive or a malformed string.
56
+ """
57
+ if isinstance(value, str):
58
+ value = datetime.fromisoformat(value)
59
+ if value.tzinfo is None:
60
+ raise ValueError("datetime must be timezone-aware")
61
+ return value.astimezone(UTC)
@@ -0,0 +1,84 @@
1
+ """Opt-in console logging for the xpectral_utils package logger."""
2
+
3
+ # -----------------------------------------------------------------------------
4
+ # Imports
5
+ # -----------------------------------------------------------------------------
6
+
7
+ # Standard library imports
8
+ import logging
9
+ import sys
10
+ from typing import Literal
11
+
12
+ # -----------------------------------------------------------------------------
13
+ # Globals and constants
14
+ # -----------------------------------------------------------------------------
15
+
16
+ __all__ = ["LogLevel", "setup_logging"]
17
+
18
+ # Package-root logger. Modules log via ``logging.getLogger(__name__)``, which
19
+ # descends from this as ``xpectral_utils.*``.
20
+ _ROOT = "xpectral_utils"
21
+
22
+ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
23
+
24
+ # ANSI colors per level for the optional colored console handler.
25
+ _COLORS = {
26
+ "DEBUG": "\033[94m",
27
+ "INFO": "\033[92m",
28
+ "WARNING": "\033[93m",
29
+ "ERROR": "\033[91m",
30
+ "CRITICAL": "\033[95m",
31
+ }
32
+ _RESET = "\033[0m"
33
+
34
+ # Silent by default: a no-op handler on the package root stops Python's
35
+ # last-resort handler from printing until the app opts in (via setup_logging or
36
+ # its own logging config).
37
+ logging.getLogger(_ROOT).addHandler(logging.NullHandler())
38
+
39
+ # -----------------------------------------------------------------------------
40
+ # General API
41
+ # -----------------------------------------------------------------------------
42
+
43
+
44
+ def setup_logging(
45
+ level: LogLevel = "INFO", *, color: bool = True, force_color: bool = False
46
+ ) -> None:
47
+ """Turn on console logging for xpectral_utils (opt-in, to stderr).
48
+
49
+ Apps that manage their own logging can ignore this and configure the
50
+ standard ``logging`` module directly. Color is applied only on a TTY, so
51
+ ANSI escapes don't leak into files or pipes; pass ``force_color=True`` to
52
+ color output regardless (e.g. in a notebook, which renders ANSI but is not
53
+ a TTY).
54
+ """
55
+ logger = logging.getLogger(_ROOT)
56
+ # Replace a handler a previous call added so repeats don't duplicate output.
57
+ logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)]
58
+ logger.setLevel(level)
59
+ logger.propagate = False
60
+
61
+ handler = logging.StreamHandler()
62
+ handler.setFormatter(
63
+ _ColorFormatter(color and (force_color or sys.stderr.isatty()))
64
+ )
65
+ logger.addHandler(handler)
66
+
67
+
68
+ # -----------------------------------------------------------------------------
69
+ # Private API
70
+ # -----------------------------------------------------------------------------
71
+
72
+
73
+ class _ColorFormatter(logging.Formatter):
74
+ """Render ``[LEVEL] name: message``, coloring the line when enabled."""
75
+
76
+ def __init__(self, color: bool):
77
+ super().__init__("[%(levelname)s] %(name)s: %(message)s")
78
+ self._color = color
79
+
80
+ def format(self, record: logging.LogRecord) -> str:
81
+ text = super().format(record)
82
+ if not self._color:
83
+ return text
84
+ return f"{_COLORS.get(record.levelname, '')}{text}{_RESET}"
File without changes
@@ -0,0 +1,63 @@
1
+ """Sliding-window rate limiter for throttling API calls."""
2
+
3
+ # ----------------------------------------------------------------------------
4
+ # Imports
5
+ # ----------------------------------------------------------------------------
6
+
7
+ # Standard library imports
8
+ import logging
9
+ import time
10
+ from collections import deque
11
+
12
+ # ----------------------------------------------------------------------------
13
+ # Globals and constants
14
+ # ----------------------------------------------------------------------------
15
+
16
+ __all__ = ["RateLimiter"]
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ----------------------------------------------------------------------------
21
+ # General API
22
+ # ----------------------------------------------------------------------------
23
+
24
+
25
+ class RateLimiter:
26
+ """Allow at most ``calls`` acquisitions per ``per_seconds`` window.
27
+
28
+ Call :meth:`acquire` before each request. It returns immediately while the
29
+ window has room, and otherwise blocks until the oldest call leaves the
30
+ window.
31
+
32
+ Args:
33
+ calls: Maximum acquisitions allowed in one window.
34
+ per_seconds: Window length in seconds.
35
+
36
+ Raises:
37
+ ValueError: If ``calls`` or ``per_seconds`` is not positive.
38
+ """
39
+
40
+ def __init__(self, calls: int, per_seconds: float):
41
+ if calls <= 0 or per_seconds <= 0:
42
+ raise ValueError("calls and per_seconds must be positive")
43
+ self.calls = calls
44
+ self.per_seconds = per_seconds
45
+ self._hits: deque[float] = deque()
46
+
47
+ def acquire(self) -> None:
48
+ """Block until a call is allowed, then record it."""
49
+ now = time.monotonic()
50
+ window_start = now - self.per_seconds
51
+
52
+ while self._hits and self._hits[0] <= window_start:
53
+ self._hits.popleft()
54
+
55
+ if len(self._hits) >= self.calls:
56
+ sleep_for = self.per_seconds - (now - self._hits[0])
57
+ if sleep_for > 0:
58
+ logger.info("sleeping for %.0f seconds", sleep_for)
59
+ time.sleep(sleep_for)
60
+ self.acquire()
61
+ return
62
+
63
+ self._hits.append(time.monotonic())
xpectral_utils/s3.py ADDED
@@ -0,0 +1,136 @@
1
+ """Read-only S3 downloader built on boto3 (install ``xpectral-utils[s3]``)."""
2
+
3
+ # -----------------------------------------------------------------------------
4
+ # Imports
5
+ # -----------------------------------------------------------------------------
6
+
7
+ # Standard library imports
8
+ import logging
9
+ import os
10
+ from pathlib import Path
11
+
12
+ # Third-party imports
13
+ import boto3
14
+ from botocore.config import Config
15
+ from botocore.exceptions import BotoCoreError
16
+ from botocore.exceptions import ClientError
17
+
18
+ # -----------------------------------------------------------------------------
19
+ # Globals and constants
20
+ # -----------------------------------------------------------------------------
21
+
22
+ __all__ = ["S3Downloader"]
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # -----------------------------------------------------------------------------
27
+ # General API
28
+ # -----------------------------------------------------------------------------
29
+
30
+
31
+ class S3Downloader:
32
+ """Download objects from an S3-compatible bucket to local disk.
33
+
34
+ A thin read-only wrapper over a boto3 S3 client: it lists object keys under
35
+ a prefix and downloads them to a local root, preserving each key's path,
36
+ skipping files already on disk, and tolerating missing objects. It knows
37
+ nothing about the data it moves -- object-key layout, parsing, caching,
38
+ and whether to attempt the network at all belong to the caller.
39
+
40
+ Args:
41
+ bucket: Name of the S3 bucket to read from.
42
+ dest_dir: Default local directory downloaded objects are written under,
43
+ preserving each key's path. Overridable per :meth:`download` call.
44
+ endpoint_url: S3-compatible endpoint. ``None`` uses the AWS default.
45
+ access_key: Access key id. ``None`` falls back to boto3's own
46
+ credential resolution.
47
+ secret_key: Secret access key. ``None`` falls back to boto3's own
48
+ credential resolution.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ bucket: str,
54
+ dest_dir: str | os.PathLike,
55
+ endpoint_url: str | None = None,
56
+ access_key: str | None = None,
57
+ secret_key: str | None = None,
58
+ ):
59
+ # boto3 resolves credentials lazily -- building the session and client
60
+ # requires none; they are only needed on an actual API call.
61
+ session = boto3.Session(
62
+ aws_access_key_id=access_key,
63
+ aws_secret_access_key=secret_key,
64
+ )
65
+ self._client = session.client(
66
+ "s3",
67
+ endpoint_url=endpoint_url,
68
+ config=Config(signature_version="s3v4"),
69
+ )
70
+ self._bucket = bucket
71
+ self._dest_dir = Path(dest_dir)
72
+
73
+ def list_keys(self, prefix: str) -> list[str]:
74
+ """List every object key under ``prefix`` in the bucket."""
75
+ keys = []
76
+ paginator = self._client.get_paginator("list_objects_v2")
77
+ for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
78
+ keys.extend(obj["Key"] for obj in page.get("Contents", []))
79
+ return keys
80
+
81
+ def download(
82
+ self,
83
+ keys: list[str],
84
+ dest_dir: str | os.PathLike | None = None,
85
+ overwrite: bool = False,
86
+ ) -> list[Path]:
87
+ """Download ``keys`` to ``dest_dir`` and return the local paths present.
88
+
89
+ Files already on disk are reused rather than re-downloaded, and keys
90
+ with no object (a 404/403) are logged and omitted from the result.
91
+ Whether to attempt the network at all is the caller's decision, not
92
+ this method's -- e.g. skip calling this entirely when working offline.
93
+
94
+ Args:
95
+ keys: Object keys to download.
96
+ dest_dir: Destination directory; the key path is preserved beneath
97
+ it. Defaults to the instance ``dest_dir``.
98
+ overwrite: When ``True``, re-download and replace files that
99
+ already exist locally instead of reusing them.
100
+
101
+ Returns:
102
+ Local paths, one per key that exists locally after the call.
103
+ """
104
+ root = Path(dest_dir) if dest_dir is not None else self._dest_dir
105
+ paths = []
106
+ for key in keys:
107
+ path = root / key
108
+ is_cached = path.exists()
109
+ if is_cached and not overwrite:
110
+ paths.append(path)
111
+ continue
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ try:
114
+ self._client.download_file(self._bucket, key, str(path))
115
+ except ClientError as error:
116
+ # S3-compatible buckets that withhold ``s3:ListBucket`` from
117
+ # download-only credentials hide object existence: a missing
118
+ # key comes back as 403/AccessDenied rather than 404/NoSuchKey.
119
+ # Treat both as "not there" so gaps (weekends, holidays,
120
+ # unpublished days) are skipped. Trade-off: genuinely bad
121
+ # credentials also land here, surfacing as an empty result
122
+ # instead of a raised auth error.
123
+ code = error.response.get("Error", {}).get("Code")
124
+ if code in {"404", "NoSuchKey", "403", "AccessDenied"}:
125
+ logger.warning("no object for key, skipping: %s", key)
126
+ continue
127
+ raise
128
+ except BotoCoreError:
129
+ # Unlike a missing object, this means the request never
130
+ # reached S3 at all (DNS/connection/timeout) -- surface it
131
+ # loudly rather than treating it as "no data available".
132
+ logger.error("connection failure downloading key: %s", key)
133
+ raise
134
+ logger.info("downloaded: %s", key)
135
+ paths.append(path)
136
+ return paths
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.5
2
+ Name: xpectral-utils
3
+ Version: 0.1.0
4
+ Summary: Shared utility functions for xpectral projects
5
+ Project-URL: Homepage, https://github.com/bayquant/xpectral-utils
6
+ Author: BayQuant
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 BayQuant
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: quant,utils
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Operating System :: OS Independent
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Requires-Python: >=3.12
35
+ Provides-Extra: cache
36
+ Requires-Dist: diskcache>=5.6.3; extra == 'cache'
37
+ Provides-Extra: s3
38
+ Requires-Dist: boto3>=1.34; extra == 's3'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # xpectral-utils
42
+
43
+ ![Spectral decomposition](https://raw.githubusercontent.com/bayquant/xpectral-utils/main/assets/xpectral_banner.gif)
44
+
45
+ Shared utility functions used across xpectral projects.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv pip install xpectral-utils
51
+ ```
52
+
53
+ ## Development
54
+
55
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,11 @@
1
+ xpectral_utils/__init__.py,sha256=INMGXKV3o2eGLy6oE1kOk2BSDskYCV3DnD8Em6WmF2Y,901
2
+ xpectral_utils/cache.py,sha256=WMWfYwmVvA9b5kPBfQRmmmU-PiC-feuJ8KZRvWvSDGI,4134
3
+ xpectral_utils/dates.py,sha256=yepN2TdXQmBUWd3XXdikGQZhldL0xBhXqhs16dkJx2U,1923
4
+ xpectral_utils/logger.py,sha256=xNPSRk2VH9ua8nGRVJIwZpz7U-X07PgVs8qiZWfe7Ok,2999
5
+ xpectral_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ xpectral_utils/rate_limiter.py,sha256=6hfhyfX_KjK-ceGOH3MkkuohP8Ye8pKo5AElULRj-Xg,2081
7
+ xpectral_utils/s3.py,sha256=xptoXM7NiROV-1SQ-iMUx6eQA3hX1D824nvxBqra9zc,5691
8
+ xpectral_utils-0.1.0.dist-info/METADATA,sha256=aGUzMIp3E9f7BlH38AD17OHG0OGEPIfOZnrneLiDLIs,2174
9
+ xpectral_utils-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ xpectral_utils-0.1.0.dist-info/licenses/LICENSE,sha256=oKFaOASRExmxKyg4h4tzD8yffNm1zGNFwubGUOVwq_E,1065
11
+ xpectral_utils-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 BayQuant
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.