metahq-core 0.1.1__py3-none-any.whl → 1.0.0rc1__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.
@@ -20,6 +20,7 @@ from pathlib import Path
20
20
  import polars as pl
21
21
 
22
22
  from metahq_core.logger import setup_logger
23
+ from metahq_core.util.supported import get_default_log_dir
23
24
 
24
25
  # these are used a lot so defining them as constants to
25
26
  # make them easy to change later.
@@ -35,7 +36,7 @@ class RelationsLoader:
35
36
 
36
37
  """
37
38
 
38
- def __init__(self, file, logger=None, loglevel=20, logdir=Path(".")):
39
+ def __init__(self, file, logger=None, loglevel=20, logdir=get_default_log_dir()):
39
40
  self.relations: pl.LazyFrame = self.setup(file)
40
41
 
41
42
  if logger is None:
metahq_core/search.py CHANGED
@@ -25,6 +25,8 @@ but the current implementation is IMHO a reasonable starting point.
25
25
 
26
26
  Author: Faisal Alquaddoomi
27
27
  Date: 2025-09-25
28
+
29
+ Last updated: 2025-11-28 by Parker Hicks
28
30
  """
29
31
 
30
32
  from __future__ import annotations
@@ -60,13 +62,21 @@ DEFAULT_SCOPE = "RELATED"
60
62
 
61
63
 
62
64
  class SynonymEntry(TypedDict):
65
+ """Storage of synonyms and their scope.
66
+
67
+ Attributes:
68
+ text (str):
69
+ Any piece of text.
70
+ scope (NotRequired[Literal["EXACT", "NARROW", "BROAD", "RELATED"]]):
71
+ The importance of `text`.
72
+ """
73
+
63
74
  text: str
64
75
  scope: NotRequired[Literal["EXACT", "NARROW", "BROAD", "RELATED"]]
65
76
 
66
77
 
67
78
  def doc_text_for(name: str, syns: list[SynonymEntry]) -> str:
68
- """
69
- Build the doc_text column for BM25 indexing from the name and synonyms.
79
+ """Build the doc_text column for BM25 indexing from the name and synonyms.
70
80
 
71
81
  See the NAME_WEIGHT and SCOPE_WEIGHTS constants for how parts of the record
72
82
  are weighted in the resulting document.
@@ -74,9 +84,14 @@ def doc_text_for(name: str, syns: list[SynonymEntry]) -> str:
74
84
  Per the OBO 1.4 spec, synonyms can have scopes in {EXACT, BROAD, NARROW,
75
85
  RELATED}. If no scope is given, it is treated as RELATED.
76
86
 
77
- :param name: The primary name of the term.
78
- :param syns: List of {"text": str, "scope": str|None} synonym entries.
79
- :returns: A string suitable for BM25 indexing.
87
+ Arguments:
88
+ name (str):
89
+ The primary name of the term.
90
+ syns (list[SynonymEntry]):
91
+ List of {"text": str, "scope": str|None} synonym entries.
92
+
93
+ Returns:
94
+ A string suitable for BM25 indexing.
80
95
  """
81
96
  parts = []
82
97
 
@@ -101,20 +116,28 @@ def search(
101
116
  logger: logging.Logger | None = None,
102
117
  verbose: bool = False,
103
118
  ) -> pl.DataFrame:
104
- """
105
- Given a query string, return the top k hits from the ontology search index.
119
+ """Given a query string, return the top k hits from the ontology search index.
106
120
 
107
121
  The search index is built from the ontology terms' names and synonyms, where
108
122
  names are weighted more heavily than synonyms. The search uses the BM25+ algorithm
109
123
  to rank the results.
110
124
 
111
- :param query: the query string
112
- :param db: path to the DuckDB database file, or None to use the default location
113
- :param k: the number of top hits to return
114
- :param type: if given, restrict results to this type (e.g. "celltype", "disease", or "tissue")
115
- :param ontology: if given, restrict results to this ontology (e.g. "CL", "UBERON", or "MONDO")
116
- :param verbose: if True, print debug information
117
- :return: a polars DataFrame with columns: term_id, ontology, name, type, synonyms, score
125
+ Arguments:
126
+ query (str):
127
+ The query string.
128
+ db (Path | None):
129
+ Path to the DuckDB database file, or None to use the default location.
130
+ k (int):
131
+ The number of top hits to return.
132
+ type (str | None):
133
+ If given, restrict results to this type (e.g. "celltype", "disease", or "tissue").
134
+ ontology (str | None):
135
+ If given, restrict results to this ontology (e.g. "CL", "UBERON", or "MONDO").
136
+ verbose (bool):
137
+ If True, print debug information.
138
+
139
+ Returns:
140
+ A `polars.DataFrame` object with columns: term_id, ontology, name, type, synonyms, score.
118
141
  """
119
142
 
120
143
  if logger is None:
metahq_core/util/io.py CHANGED
@@ -1,17 +1,36 @@
1
+ """
2
+ Input/output functions.
3
+
4
+ Author: Parker Hicks
5
+ Date: 2025-04
6
+
7
+ Last updated: 2025-11-28 by Parker Hicks
8
+ """
9
+
1
10
  import json
2
11
  import re
3
- import subprocess
4
12
  import sys
5
13
  from pathlib import Path
6
- from typing import Any, Optional
14
+ from typing import Any
7
15
 
8
16
  import yaml
9
17
  from bson import BSON
10
18
 
11
- from metahq_core.util.alltypes import FilePath, StringArray
19
+ from metahq_core.util.alltypes import StringArray
12
20
 
13
21
 
14
- def checkdir(path: FilePath, is_file: bool = False):
22
+ def checkdir(path: str | Path, is_file: bool = False) -> Path:
23
+ """Check if directory exists. If not, creates it.
24
+
25
+ Arguments:
26
+ path (str | Path):
27
+ A path to a directory or file.
28
+ is_file (bool):
29
+ If `True` will check the parent of the file path.
30
+
31
+ Returns:
32
+ A `pathlib.Path` object of `path`.
33
+ """
15
34
  if isinstance(path, str):
16
35
  path = Path(path)
17
36
  if is_file:
@@ -23,24 +42,48 @@ def checkdir(path: FilePath, is_file: bool = False):
23
42
  return path
24
43
 
25
44
 
26
- def load_bson(file: FilePath, **kwargs) -> dict[str, Any]:
27
- """Load dictionary from compressed bson."""
45
+ def load_bson(file: str | Path, **kwargs) -> dict[str, Any]:
46
+ """Load dictionary from compressed bson.
47
+
48
+ Arguments:
49
+ file (str | Path):
50
+ Path to file.bson to load.
51
+ """
28
52
  with open(file, "rb") as bf:
29
53
  return BSON(bf.read()).decode(**kwargs)
30
54
 
31
55
 
32
- def load_json(file: FilePath, encoding: str = "utf-8") -> dict[str, Any]:
56
+ def load_json(file: str | Path, encoding: str = "utf-8") -> dict[str, Any]:
57
+ """Load dictionary from JSON.
58
+
59
+ Arguments:
60
+ file (str | Path):
61
+ Path to file.json to load.
62
+ """
33
63
  with open(file, "r", encoding=encoding) as jf:
34
64
  return json.load(jf)
35
65
 
36
66
 
37
67
  def load_txt(
38
- file: FilePath,
68
+ file: str | Path,
39
69
  cols: int = 1,
40
70
  delimiter: str = ",",
41
71
  encoding: str = "utf-8",
42
72
  ) -> list[str]:
43
- """Loads a txt file."""
73
+ """Loads a txt file.
74
+
75
+ Arguments:
76
+ file (str | Path):
77
+ Path to file.txt to load.
78
+ cols (int):
79
+ The number of columns in `file`.
80
+ delimiter (str | None):
81
+ Character to separate entries if the `cols>1`.
82
+ encoding (str):
83
+ Text encoding format.
84
+ Returns:
85
+ An list of strings.
86
+ """
44
87
  out = []
45
88
  with open(file, "r", encoding=encoding) as f:
46
89
  for line in f.readlines():
@@ -54,21 +97,19 @@ def load_txt(
54
97
  return out
55
98
 
56
99
 
57
- def load_txt_sections(file: FilePath, delimiter: str, encoding="utf-8") -> list[str]:
58
- """
59
- Generator to load a .txt file in sections.
100
+ def load_txt_sections(file: str | Path, delimiter: str, encoding="utf-8") -> list[str]:
101
+ """Load a .txt file in sections.
60
102
 
61
- Args
62
- ----
63
- file: FilePath
64
- Path to .txt file to load in sections.
103
+ Arguments:
104
+ file (str | Path):
105
+ Path to .txt file to load in sections.
106
+ delimiter (str):
107
+ Pattern to split entries in the .txt file.
108
+ encoding (str):
109
+ Text encoding format.
65
110
 
66
- delimter: rstring
67
- Pattern to split entries in the .txt file.
68
-
69
- Returns
70
- ------
71
- Sections of the .txt file separated by the specified delimiter.
111
+ Returns:
112
+ Sections of the .txt file separated by the specified delimiter.
72
113
 
73
114
  """
74
115
  with open(file, "r", encoding=encoding) as f:
@@ -77,8 +118,15 @@ def load_txt_sections(file: FilePath, delimiter: str, encoding="utf-8") -> list[
77
118
  return re.split(delimiter, text.strip())
78
119
 
79
120
 
80
- def load_yaml(file: FilePath, encoding: str = "utf-8") -> dict[str, Any]:
81
- """Load a yaml dictionary."""
121
+ def load_yaml(file: str | Path, encoding: str = "utf-8") -> dict[str, Any]:
122
+ """Load a yaml dictionary.
123
+
124
+ Arguments:
125
+ file (str | Path):
126
+ Path to .yaml file to load.
127
+ encoding (str):
128
+ Text encoding format.
129
+ """
82
130
  with open(file, "r", encoding=encoding) as stream:
83
131
  try:
84
132
  return yaml.safe_load(stream)
@@ -86,42 +134,57 @@ def load_yaml(file: FilePath, encoding: str = "utf-8") -> dict[str, Any]:
86
134
  sys.exit(str(e))
87
135
 
88
136
 
89
- def save_bson(data: dict, file: FilePath, **kwargs):
90
- """Save dictionary to compressed bson."""
137
+ def save_bson(data: dict, file: str | Path, **kwargs):
138
+ """Save dictionary to compressed bson.
139
+
140
+ Arguments:
141
+ data (dict):
142
+ Dictionary to compress and save.
143
+ file (str | Path):
144
+ Path to file.txt to save `data`.
145
+ """
91
146
  with open(file, "wb") as bf:
92
147
  bf.write(BSON.encode(data, **kwargs))
93
148
 
94
149
 
95
- def save_json(data: dict, file: FilePath, encoding: str = "utf-8"):
150
+ def save_json(data: dict, file: str | Path, encoding: str = "utf-8"):
151
+ """Saves a dictionary to file in JSON format.
152
+
153
+ Arguments:
154
+ data (dict):
155
+ Dictionary to save.
156
+ file (str | Path):
157
+ Path to file.txt to save `data`.
158
+ encoding (str):
159
+ Text encoding format.
160
+ """
96
161
  with open(file, "w", encoding=encoding) as jf:
97
162
  json.dump(data, jf, indent=4)
98
163
 
99
164
 
100
165
  def save_txt(
101
166
  data: StringArray | list[str],
102
- file: FilePath,
103
- delimiter: Optional[str] = None,
104
- encoding="utf-8",
167
+ file: str | Path,
168
+ delimiter: str | None = None,
169
+ encoding: str = "utf-8",
105
170
  ):
171
+ """Save an array or list to a `.txt` file.
172
+
173
+ Arguments:
174
+ data (StringArray | list[str]):
175
+ An array or list of string entries.
176
+ file (str | Path):
177
+ Path to file.txt to save `data`.
178
+ delimiter (str | None):
179
+ Allows for multidimensional arrays to be saves as
180
+ single dimension arrays by concatenating each
181
+ element in each row by the passed delimiter.
182
+ encoding (str):
183
+ Text encoding format.
184
+ """
106
185
  if delimiter:
107
186
  data = [delimiter.join(entry) for entry in data]
108
187
 
109
188
  with open(file, "w", encoding=encoding) as f:
110
189
  for entry in data:
111
190
  f.write(f"{entry}\n")
112
-
113
-
114
- def run_subprocess(command: str) -> subprocess.CompletedProcess[str] | int:
115
- try:
116
- result = subprocess.run(
117
- ["bash", "-c", command],
118
- stdout=subprocess.PIPE,
119
- stderr=subprocess.PIPE,
120
- check=True,
121
- text=True,
122
- )
123
- return result
124
-
125
- except subprocess.CalledProcessError as e:
126
- sys.stderr.write(f"{e}\n")
127
- return 1
@@ -7,7 +7,7 @@ Functions beginning with an underscore are intended to be called through the
7
7
  Author: Parker Hicks
8
8
  Date: 2025-04-15
9
9
 
10
- Last updated: 2025-11-24 by Parker Hicks
10
+ Last updated: 2026-02-02 by Parker Hicks
11
11
  """
12
12
 
13
13
  from pathlib import Path
@@ -122,7 +122,7 @@ def _age_groups() -> list[str]:
122
122
  "adolescent",
123
123
  "adult",
124
124
  "older_adult",
125
- "eldery_adult",
125
+ "elderly_adult",
126
126
  ]
127
127
 
128
128
 
@@ -141,8 +141,6 @@ def species_map() -> dict[str, str]:
141
141
  return {
142
142
  "human": "homo sapiens",
143
143
  "mouse": "mus musculus",
144
- "worm": "caenorhabditis elegans",
145
- "fly": "drosophila melanogaster",
146
144
  "zebrafish": "danio rerio",
147
145
  "rat": "rattus norvegicus",
148
146
  }
@@ -161,7 +159,13 @@ def get_annotations(level: Literal["sample", "series"]) -> Path:
161
159
 
162
160
  def get_config():
163
161
  """Loads the MetaHQ config file."""
164
- return load_yaml(get_config_file())
162
+ config = load_yaml(get_config_file())
163
+ if config is None:
164
+ raise RuntimeError(
165
+ "The MetaHQ configuration is contaminated. Run `metahq setup`."
166
+ )
167
+
168
+ return config
165
169
 
166
170
 
167
171
  def get_config_file():
@@ -326,6 +330,13 @@ def ecodes(query: list[str] | str) -> list[str]:
326
330
  return query
327
331
 
328
332
 
333
+ def levels(query: str) -> str:
334
+ """Check if queried level is supported."""
335
+ if query in supported("levels"):
336
+ return query
337
+ raise ValueError(f"Expected query in {supported("levels")}, got {query}.")
338
+
339
+
329
340
  def metadata_fields(level: str) -> list[str]:
330
341
  """Returns supported metadata fields for a specified level."""
331
342
  if level == "sample":
@@ -1,17 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metahq-core
3
- Version: 0.1.1
3
+ Version: 1.0.0rc1
4
4
  Summary: Core API for the meta-hq CLI.
5
5
  Author-email: Parker Hicks <parker.hicks@cuanschutz.edu>, Faisal Alquaddoomi <faisal.alquaddoomi@cuanschutz.edu>
6
- Keywords: CLI,Data Curation,Database,Public Biomedical Data
6
+ License-File: LICENSE
7
+ Keywords: Data Curation,Database,Public Biomedical Data
7
8
  Classifier: Development Status :: 3 - Alpha
8
9
  Classifier: Intended Audience :: Science/Research
9
10
  Classifier: Operating System :: OS Independent
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
11
  Classifier: Programming Language :: Python :: 3.12
13
12
  Classifier: Programming Language :: Python :: 3.13
14
- Requires-Python: >=3.11
13
+ Requires-Python: >=3.12
15
14
  Requires-Dist: duckdb>=1.4.0
16
15
  Requires-Dist: networkx>=3.0
17
16
  Requires-Dist: numpy>=2.3.0
@@ -21,14 +20,39 @@ Requires-Dist: pydantic>=2.0.0
21
20
  Requires-Dist: pymongo>=4.13.0
22
21
  Requires-Dist: pyyaml>=5.1
23
22
  Requires-Dist: rank-bm25>=0.2.2
23
+ Requires-Dist: rich>=13.0.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: black; extra == 'dev'
26
26
  Requires-Dist: flake8; extra == 'dev'
27
27
  Requires-Dist: isort; extra == 'dev'
28
+ Requires-Dist: mkdocs-click; extra == 'dev'
29
+ Requires-Dist: mkdocs-material; extra == 'dev'
30
+ Requires-Dist: mkdocs>=1.6.1; extra == 'dev'
31
+ Requires-Dist: mkdocstrings[python]; extra == 'dev'
28
32
  Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pymdown-extensions; extra == 'dev'
29
34
  Requires-Dist: pytest-cov; extra == 'dev'
30
35
  Requires-Dist: pytest>=8.0; extra == 'dev'
36
+ Requires-Dist: vulture; extra == 'dev'
31
37
  Provides-Extra: test
32
38
  Requires-Dist: pytest-benchmark; extra == 'test'
33
39
  Requires-Dist: pytest-cov; extra == 'test'
34
40
  Requires-Dist: pytest>=8.0; extra == 'test'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # metahq-core
44
+
45
+ ![Core Tests](https://github.com/krishnanlab/meta-hq/workflows/Core%20Tests/badge.svg)
46
+ ![Python](https://img.shields.io/badge/python-3.12+-blue.svg)
47
+ ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)
48
+ ![pypi](https://img.shields.io/pypi/v/metahq-core.svg)
49
+ [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
50
+
51
+ Backend package for `metahq-cli` also available on PyPI. Currently this package
52
+ is solely intended to be used through the MetaHQ CLI.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install metahq-core
58
+ ```
@@ -0,0 +1,30 @@
1
+ metahq_core/README.md,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ metahq_core/__init__.py,sha256=co7RISTHQOXWWL_YBSfz24445wRZprPGwQxVxn-FWKk,27
3
+ metahq_core/logger.py,sha256=Nq5VjjzrDniL2mZaydniKgElgIWdQOp-k-8DvXCpubU,1165
4
+ metahq_core/query.py,sha256=RlPCUaExFrowfBuwJ8K9SAotTo_ko1FeJr01sskGbq4,22373
5
+ metahq_core/relations_loader.py,sha256=WQMJs4bN9Uy2E8qqrGlDtJMW65A1wt0s4azLLOE0Mew,4758
6
+ metahq_core/search.py,sha256=g0LHprB2Z-8-kmLaOgZ4VUN4RRZNUY_d8RP7Cqzl0c0,10038
7
+ metahq_core/curations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ metahq_core/curations/_multiprocess_propagator.py,sha256=KUiTgcOSLJv0VskzEk2kOpSFHZsUCyupdpOCVdN8vwM,3510
9
+ metahq_core/curations/annotation_converter.py,sha256=lnDjnGOMXspUgUS7YA13G2BZw-mTApkcQU_6H5C1Nt8,8873
10
+ metahq_core/curations/annotations.py,sha256=WXZMTYuNnDXf8K84yCzOxCJiPm59WKyUoIcCvC9VBPY,26559
11
+ metahq_core/curations/base.py,sha256=3Q3gd9G7K4J9rewg7Cn9rAfml1N2Xgor-V2C3OHWeuI,1829
12
+ metahq_core/curations/index.py,sha256=Chy5Y5z8A2ob-cl0JenR1hWunRRikOpc1BdvrORSbtE,5020
13
+ metahq_core/curations/labels.py,sha256=BVnxd9qeRvS7Ji5OR6fTCcneLYJI4PwgVNwZ5jFIVgs,17205
14
+ metahq_core/curations/propagator.py,sha256=o4MDmZ4ziJ3yZv0yDdAHLcvNtLLA9DKK-TdzGMEquT8,9639
15
+ metahq_core/export/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ metahq_core/export/annotations.py,sha256=kut8HaNBp0Sq1T5SQ7X8KywXfGDXSRU2dLO9Dl6He_Y,14728
17
+ metahq_core/export/base.py,sha256=j7cKc-ru0ggAXEmGFYiaxBn_OROahQ9HPiR1tK-VX94,1394
18
+ metahq_core/export/labels.py,sha256=K9BFz8e-kJtDQOr9RwlF2ebsjWuxWbLUsoojourKeFw,14816
19
+ metahq_core/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ metahq_core/util/alltypes.py,sha256=zYrBKvtkAhR7lmqDlGp7BOh2bUNI-toQfd7o7isDCxw,721
21
+ metahq_core/util/checkers.py,sha256=_7Aw2bO1PNg-9TPYOwKwvTqFtmb5vMyYU-omfUID_DE,508
22
+ metahq_core/util/exceptions.py,sha256=FlYUN8g4lNB3OSwQ20dfnSDmgX8NKZV5Yx7xERurrWc,115
23
+ metahq_core/util/helpers.py,sha256=sak1A7gp2qvnbhCBYHNQ5IUxIZeljgyRSu5U7C8a7bU,949
24
+ metahq_core/util/io.py,sha256=oouWuSZhECiXdZN7K9cXU0q4O8RqsEMsiVOpTrsX2gg,4730
25
+ metahq_core/util/progress.py,sha256=NF158KVFO9pO3vrsl7OrPAhYtd-cXaXRavL0iOV5XeM,2128
26
+ metahq_core/util/supported.py,sha256=UJ8S7esdOTFW5oq9kIHQ1N5GPanXIgntVvxMrgiCjjM,11287
27
+ metahq_core-1.0.0rc1.dist-info/METADATA,sha256=QTTHZqHXg4Uu7QLEUmow5_Cp_rkcruJQ4N5TbwxUIn8,2183
28
+ metahq_core-1.0.0rc1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
+ metahq_core-1.0.0rc1.dist-info/licenses/LICENSE,sha256=1KNh4-XEVT4pk2tvDi-deIXms0suss2eiYLBKBiQDGo,1499
30
+ metahq_core-1.0.0rc1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Krishnan Lab
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.