milopy 0.3.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.
- milopy/__init__.py +21 -0
- milopy/core.py +190 -0
- milopy/meta.py +147 -0
- milopy-0.3.0.dist-info/METADATA +32 -0
- milopy-0.3.0.dist-info/RECORD +7 -0
- milopy-0.3.0.dist-info/WHEEL +4 -0
- milopy-0.3.0.dist-info/licenses/LICENSE +21 -0
milopy/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
build_graph,
|
|
3
|
+
make_nhoods,
|
|
4
|
+
count_cells,
|
|
5
|
+
calc_nhood_distance,
|
|
6
|
+
test_nhoods,
|
|
7
|
+
)
|
|
8
|
+
from .meta import (
|
|
9
|
+
test_nhoods_meta,
|
|
10
|
+
test_nhoods_mixed,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"build_graph",
|
|
15
|
+
"make_nhoods",
|
|
16
|
+
"count_cells",
|
|
17
|
+
"calc_nhood_distance",
|
|
18
|
+
"test_nhoods",
|
|
19
|
+
"test_nhoods_meta",
|
|
20
|
+
"test_nhoods_mixed",
|
|
21
|
+
]
|
milopy/core.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import scipy.sparse as sp
|
|
4
|
+
import scanpy as sc
|
|
5
|
+
import anndata
|
|
6
|
+
|
|
7
|
+
def build_graph(adata, k=30, d=30, **kwargs):
|
|
8
|
+
"""
|
|
9
|
+
Builds a kNN graph. Wraps scanpy.pp.neighbors.
|
|
10
|
+
"""
|
|
11
|
+
if 'X_pca' not in adata.obsm:
|
|
12
|
+
raise ValueError("PCA must be computed first. Run sc.pp.pca(adata)")
|
|
13
|
+
|
|
14
|
+
sc.pp.neighbors(adata, n_neighbors=k, n_pcs=d, **kwargs)
|
|
15
|
+
return adata
|
|
16
|
+
|
|
17
|
+
def make_nhoods(adata, prop=0.1, k=30, d=30, refined=True, random_state=None):
|
|
18
|
+
"""
|
|
19
|
+
Defines neighbourhoods on the kNN graph.
|
|
20
|
+
"""
|
|
21
|
+
if 'distances' not in adata.obsp:
|
|
22
|
+
raise ValueError("kNN graph not found. Run build_graph(adata)")
|
|
23
|
+
|
|
24
|
+
n_cells = adata.n_obs
|
|
25
|
+
n_nhoods = int(np.round(n_cells * prop))
|
|
26
|
+
|
|
27
|
+
if random_state is not None:
|
|
28
|
+
np.random.seed(random_state)
|
|
29
|
+
|
|
30
|
+
# Initial random sampling
|
|
31
|
+
vertex_indices = np.random.choice(n_cells, size=n_nhoods, replace=False)
|
|
32
|
+
|
|
33
|
+
knn_graph = adata.obsp['connectivities']
|
|
34
|
+
|
|
35
|
+
if refined:
|
|
36
|
+
# Refinement step: for each random vertex, find its neighbourhood,
|
|
37
|
+
# compute the median profile in PCA space, and pick the closest vertex.
|
|
38
|
+
pca_coords = adata.obsm['X_pca'][:, :d]
|
|
39
|
+
refined_vertices = []
|
|
40
|
+
for v in vertex_indices:
|
|
41
|
+
# Find neighbors of v
|
|
42
|
+
neighbors = knn_graph[v].nonzero()[1]
|
|
43
|
+
# include self
|
|
44
|
+
neighborhood = np.append(neighbors, v)
|
|
45
|
+
|
|
46
|
+
# Compute median profile
|
|
47
|
+
median_profile = np.median(pca_coords[neighborhood], axis=0)
|
|
48
|
+
|
|
49
|
+
# Find vertex in neighborhood closest to median
|
|
50
|
+
distances = np.linalg.norm(pca_coords[neighborhood] - median_profile, axis=1)
|
|
51
|
+
closest_idx = neighborhood[np.argmin(distances)]
|
|
52
|
+
refined_vertices.append(closest_idx)
|
|
53
|
+
|
|
54
|
+
# Remove duplicates
|
|
55
|
+
vertex_indices = np.unique(refined_vertices)
|
|
56
|
+
|
|
57
|
+
# Store neighborhoods as a sparse matrix: cells x nhoods
|
|
58
|
+
rows = []
|
|
59
|
+
cols = []
|
|
60
|
+
|
|
61
|
+
for i, v in enumerate(vertex_indices):
|
|
62
|
+
neighbors = knn_graph[v].nonzero()[1]
|
|
63
|
+
neighborhood = np.append(neighbors, v)
|
|
64
|
+
rows.extend(neighborhood)
|
|
65
|
+
cols.extend([i] * len(neighborhood))
|
|
66
|
+
|
|
67
|
+
nhoods_mat = sp.coo_matrix((np.ones(len(rows)), (rows, cols)), shape=(n_cells, len(vertex_indices))).tocsc()
|
|
68
|
+
|
|
69
|
+
adata.obsm['nhoods'] = nhoods_mat
|
|
70
|
+
adata.uns['nhood_indices'] = vertex_indices
|
|
71
|
+
return adata
|
|
72
|
+
|
|
73
|
+
def count_cells(adata, sample_col):
|
|
74
|
+
"""
|
|
75
|
+
Counts cells in each neighbourhood across samples.
|
|
76
|
+
"""
|
|
77
|
+
if 'nhoods' not in adata.obsm:
|
|
78
|
+
raise ValueError("Neighborhoods not found. Run make_nhoods(adata)")
|
|
79
|
+
|
|
80
|
+
if sample_col not in adata.obs.columns:
|
|
81
|
+
raise ValueError(f"Sample column '{sample_col}' not found in adata.obs")
|
|
82
|
+
|
|
83
|
+
samples = adata.obs[sample_col].astype('category')
|
|
84
|
+
sample_categories = samples.cat.categories
|
|
85
|
+
sample_codes = samples.cat.codes.values
|
|
86
|
+
|
|
87
|
+
n_nhoods = adata.obsm['nhoods'].shape[1]
|
|
88
|
+
n_samples = len(sample_categories)
|
|
89
|
+
|
|
90
|
+
# Initialize count matrix
|
|
91
|
+
counts = np.zeros((n_nhoods, n_samples))
|
|
92
|
+
|
|
93
|
+
nhoods_mat = adata.obsm['nhoods'].tocsc()
|
|
94
|
+
|
|
95
|
+
for i in range(n_nhoods):
|
|
96
|
+
cells_in_nhood = nhoods_mat[:, i].nonzero()[0]
|
|
97
|
+
# Count cells per sample in this neighborhood
|
|
98
|
+
nhood_sample_codes = sample_codes[cells_in_nhood]
|
|
99
|
+
counts[i, :] = np.bincount(nhood_sample_codes, minlength=n_samples)
|
|
100
|
+
|
|
101
|
+
count_df = pd.DataFrame(counts, columns=sample_categories)
|
|
102
|
+
|
|
103
|
+
# store in uns
|
|
104
|
+
adata.uns['nhood_counts'] = count_df
|
|
105
|
+
return adata
|
|
106
|
+
|
|
107
|
+
def calc_nhood_distance(adata, d=30):
|
|
108
|
+
"""
|
|
109
|
+
Calculates distances between neighbourhoods based on overlap or PCA distance.
|
|
110
|
+
"""
|
|
111
|
+
if 'nhoods' not in adata.obsm:
|
|
112
|
+
raise ValueError("Neighborhoods not found. Run make_nhoods(adata)")
|
|
113
|
+
|
|
114
|
+
nhoods_mat = adata.obsm['nhoods'].tocsc()
|
|
115
|
+
pca_coords = adata.obsm['X_pca'][:, :d]
|
|
116
|
+
|
|
117
|
+
n_nhoods = nhoods_mat.shape[1]
|
|
118
|
+
|
|
119
|
+
# compute median of each neighborhood
|
|
120
|
+
nhood_medians = np.zeros((n_nhoods, d))
|
|
121
|
+
for i in range(n_nhoods):
|
|
122
|
+
cells = nhoods_mat[:, i].nonzero()[0]
|
|
123
|
+
nhood_medians[i, :] = np.median(pca_coords[cells, :], axis=0)
|
|
124
|
+
|
|
125
|
+
# compute euclidean distance between medians
|
|
126
|
+
from scipy.spatial.distance import pdist, squareform
|
|
127
|
+
dists = pdist(nhood_medians, metric='euclidean')
|
|
128
|
+
dist_mat = squareform(dists)
|
|
129
|
+
|
|
130
|
+
adata.uns['nhood_distances'] = dist_mat
|
|
131
|
+
return adata
|
|
132
|
+
|
|
133
|
+
def test_nhoods(adata, design, design_df, model_contrasts=None):
|
|
134
|
+
"""
|
|
135
|
+
Tests for differential abundance using edgepython (pure Python edgeR port).
|
|
136
|
+
`design` is a formula string like '~ condition'
|
|
137
|
+
`design_df` is a pandas DataFrame with row names matching sample columns in `nhood_counts`
|
|
138
|
+
"""
|
|
139
|
+
if 'nhood_counts' not in adata.uns:
|
|
140
|
+
raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
|
|
141
|
+
|
|
142
|
+
import edgepython as ep
|
|
143
|
+
import patsy
|
|
144
|
+
|
|
145
|
+
# Counts are nhoods x samples
|
|
146
|
+
counts_df = adata.uns['nhood_counts']
|
|
147
|
+
|
|
148
|
+
# Ensure design_df is aligned with counts_df columns
|
|
149
|
+
design_df = design_df.loc[counts_df.columns]
|
|
150
|
+
|
|
151
|
+
# edgepython expects genes x samples (rows=features, cols=samples)
|
|
152
|
+
# Our counts_df is nhoods x samples, which is already the right orientation
|
|
153
|
+
counts_matrix = counts_df.values.astype(float)
|
|
154
|
+
|
|
155
|
+
# Create DGEList
|
|
156
|
+
y = ep.make_dgelist(counts=counts_matrix)
|
|
157
|
+
y = ep.calc_norm_factors(y, method="TMM")
|
|
158
|
+
|
|
159
|
+
# Create design matrix using patsy
|
|
160
|
+
design_mat = patsy.dmatrix(design, design_df, return_type='dataframe')
|
|
161
|
+
design_array = np.asarray(design_mat, dtype=float)
|
|
162
|
+
|
|
163
|
+
# Estimate dispersion
|
|
164
|
+
y = ep.estimate_disp(y, design=design_array)
|
|
165
|
+
|
|
166
|
+
# Fit QL GLM
|
|
167
|
+
fit = ep.glm_ql_fit(y, design=design_array, robust=True)
|
|
168
|
+
|
|
169
|
+
# Test
|
|
170
|
+
if model_contrasts is not None:
|
|
171
|
+
# Build contrast vector from string like "conditionB - conditionA"
|
|
172
|
+
# For now, support simple column index contrasts
|
|
173
|
+
res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
|
|
174
|
+
else:
|
|
175
|
+
# Default to testing the last coefficient
|
|
176
|
+
n_coefs = design_array.shape[1]
|
|
177
|
+
res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
|
|
178
|
+
|
|
179
|
+
# Get results table
|
|
180
|
+
top = ep.top_tags(res, n=counts_matrix.shape[0], sort_by="none")
|
|
181
|
+
res_df = top['table']
|
|
182
|
+
|
|
183
|
+
# Ensure it's a DataFrame with the right index
|
|
184
|
+
if not isinstance(res_df, pd.DataFrame):
|
|
185
|
+
res_df = pd.DataFrame(res_df)
|
|
186
|
+
|
|
187
|
+
res_df.index = counts_df.index
|
|
188
|
+
adata.uns['nhood_test_results'] = res_df
|
|
189
|
+
return adata
|
|
190
|
+
|
milopy/meta.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import scipy.stats
|
|
4
|
+
from statsmodels.stats.multitest import multipletests
|
|
5
|
+
|
|
6
|
+
def test_nhoods_meta(adata, design, design_df, dataset_col, model_contrasts=None):
|
|
7
|
+
"""
|
|
8
|
+
Random-Effects Meta-Analysis for multi-dataset Differential Abundance.
|
|
9
|
+
Runs edgepython GLM independently for each dataset and combines effects using
|
|
10
|
+
Inverse-Variance DerSimonian-Laird meta-analysis.
|
|
11
|
+
"""
|
|
12
|
+
if 'nhood_counts' not in adata.uns:
|
|
13
|
+
raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
|
|
14
|
+
|
|
15
|
+
import edgepython as ep
|
|
16
|
+
import patsy
|
|
17
|
+
|
|
18
|
+
counts_df = adata.uns['nhood_counts']
|
|
19
|
+
design_df = design_df.loc[counts_df.columns]
|
|
20
|
+
|
|
21
|
+
if dataset_col not in design_df.columns:
|
|
22
|
+
raise ValueError(f"Dataset column '{dataset_col}' not found in design_df")
|
|
23
|
+
|
|
24
|
+
datasets = design_df[dataset_col].unique()
|
|
25
|
+
|
|
26
|
+
n_nhoods = counts_df.shape[0]
|
|
27
|
+
|
|
28
|
+
# Store effects and standard errors
|
|
29
|
+
all_logfc = np.zeros((n_nhoods, len(datasets)))
|
|
30
|
+
all_se = np.zeros((n_nhoods, len(datasets)))
|
|
31
|
+
all_logcpm = np.zeros((n_nhoods, len(datasets)))
|
|
32
|
+
|
|
33
|
+
for k, ds in enumerate(datasets):
|
|
34
|
+
ds_samples = design_df[design_df[dataset_col] == ds].index
|
|
35
|
+
ds_counts = counts_df[ds_samples].values.astype(float)
|
|
36
|
+
ds_design = design_df.loc[ds_samples]
|
|
37
|
+
|
|
38
|
+
y = ep.make_dgelist(counts=ds_counts)
|
|
39
|
+
y = ep.calc_norm_factors(y, method="TMM")
|
|
40
|
+
|
|
41
|
+
design_mat = patsy.dmatrix(design, ds_design, return_type='dataframe')
|
|
42
|
+
design_array = np.asarray(design_mat, dtype=float)
|
|
43
|
+
|
|
44
|
+
y = ep.estimate_disp(y, design=design_array)
|
|
45
|
+
fit = ep.glm_ql_fit(y, design=design_array, robust=True)
|
|
46
|
+
|
|
47
|
+
if model_contrasts is not None:
|
|
48
|
+
res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
|
|
49
|
+
else:
|
|
50
|
+
n_coefs = design_array.shape[1]
|
|
51
|
+
res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
|
|
52
|
+
|
|
53
|
+
top = ep.top_tags(res, n=n_nhoods, sort_by="none")
|
|
54
|
+
res_df = pd.DataFrame(top['table'])
|
|
55
|
+
|
|
56
|
+
# logFC and F-statistic from edgeR/edgepython
|
|
57
|
+
logfc = res_df['logFC'].values
|
|
58
|
+
f_stat = res_df['F'].values
|
|
59
|
+
# standard error from F-stat (assuming 1 DF: F = (beta/se)^2 => se = |beta| / sqrt(F))
|
|
60
|
+
se = np.abs(logfc) / np.sqrt(f_stat + 1e-12) # add small epsilon to avoid div by zero
|
|
61
|
+
|
|
62
|
+
all_logfc[:, k] = logfc
|
|
63
|
+
all_se[:, k] = se
|
|
64
|
+
all_logcpm[:, k] = res_df['logCPM'].values
|
|
65
|
+
|
|
66
|
+
# DerSimonian-Laird Random Effects Meta-Analysis
|
|
67
|
+
variances = all_se**2
|
|
68
|
+
w_fixed = 1.0 / variances
|
|
69
|
+
w_sum = w_fixed.sum(axis=1)
|
|
70
|
+
beta_fixed = (w_fixed * all_logfc).sum(axis=1) / w_sum
|
|
71
|
+
|
|
72
|
+
K = len(datasets)
|
|
73
|
+
Q = (w_fixed * (all_logfc - beta_fixed[:, None])**2).sum(axis=1)
|
|
74
|
+
c = w_sum - (w_fixed**2).sum(axis=1) / w_sum
|
|
75
|
+
tau2 = np.maximum(0, (Q - (K - 1)) / (c + 1e-12))
|
|
76
|
+
|
|
77
|
+
w_random = 1.0 / (variances + tau2[:, None])
|
|
78
|
+
w_random_sum = w_random.sum(axis=1)
|
|
79
|
+
|
|
80
|
+
meta_logfc = (w_random * all_logfc).sum(axis=1) / w_random_sum
|
|
81
|
+
meta_se = np.sqrt(1.0 / w_random_sum)
|
|
82
|
+
|
|
83
|
+
z_stat = meta_logfc / meta_se
|
|
84
|
+
pvals = 2 * scipy.stats.norm.sf(np.abs(z_stat))
|
|
85
|
+
_, fdr, _, _ = multipletests(pvals, method='fdr_bh')
|
|
86
|
+
|
|
87
|
+
meta_res = pd.DataFrame({
|
|
88
|
+
'logFC': meta_logfc,
|
|
89
|
+
'SE': meta_se,
|
|
90
|
+
'logCPM': np.mean(all_logcpm, axis=1),
|
|
91
|
+
'PValue': pvals,
|
|
92
|
+
'FDR': fdr,
|
|
93
|
+
'Tau2': tau2
|
|
94
|
+
}, index=counts_df.index)
|
|
95
|
+
|
|
96
|
+
adata.uns['nhood_test_results'] = meta_res
|
|
97
|
+
return adata
|
|
98
|
+
|
|
99
|
+
def test_nhoods_mixed(adata, design, design_df, dataset_col, model_contrasts=None):
|
|
100
|
+
"""
|
|
101
|
+
Negative Binomial Mixed-Effects model for multi-dataset DA.
|
|
102
|
+
Uses edgepython's NEBULA-LN extension (ep.glm_sc_fit) modeling
|
|
103
|
+
'dataset_col' as the random effect grouping variable.
|
|
104
|
+
"""
|
|
105
|
+
if 'nhood_counts' not in adata.uns:
|
|
106
|
+
raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
|
|
107
|
+
|
|
108
|
+
import edgepython as ep
|
|
109
|
+
import patsy
|
|
110
|
+
|
|
111
|
+
counts_df = adata.uns['nhood_counts']
|
|
112
|
+
design_df = design_df.loc[counts_df.columns]
|
|
113
|
+
|
|
114
|
+
if dataset_col not in design_df.columns:
|
|
115
|
+
raise ValueError(f"Dataset column '{dataset_col}' not found in design_df")
|
|
116
|
+
|
|
117
|
+
counts_matrix = counts_df.values.astype(float)
|
|
118
|
+
|
|
119
|
+
design_mat = patsy.dmatrix(design, design_df, return_type='dataframe')
|
|
120
|
+
design_array = np.asarray(design_mat, dtype=float)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Fit the NEBULA-LN model with the random effect (dataset_col)
|
|
124
|
+
fit = ep.glm_sc_fit(
|
|
125
|
+
y=counts_matrix,
|
|
126
|
+
cell_meta=design_df,
|
|
127
|
+
design=design_array,
|
|
128
|
+
sample=dataset_col,
|
|
129
|
+
norm_method='TMM'
|
|
130
|
+
)
|
|
131
|
+
except AttributeError:
|
|
132
|
+
raise NotImplementedError("ep.glm_sc_fit (NEBULA-LN) is missing or not compiled in this edgepython version.")
|
|
133
|
+
|
|
134
|
+
n_nhoods = counts_matrix.shape[0]
|
|
135
|
+
|
|
136
|
+
if model_contrasts is not None:
|
|
137
|
+
res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
|
|
138
|
+
else:
|
|
139
|
+
n_coefs = design_array.shape[1]
|
|
140
|
+
res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
|
|
141
|
+
|
|
142
|
+
top = ep.top_tags(res, n=n_nhoods, sort_by="none")
|
|
143
|
+
res_df = pd.DataFrame(top['table'])
|
|
144
|
+
res_df.index = counts_df.index
|
|
145
|
+
|
|
146
|
+
adata.uns['nhood_test_results'] = res_df
|
|
147
|
+
return adata
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: milopy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A native Python implementation of miloR for differential abundance testing
|
|
5
|
+
Project-URL: Homepage, https://github.com/LuJoHae/milopy
|
|
6
|
+
Project-URL: Repository, https://github.com/LuJoHae/milopy.git
|
|
7
|
+
Author-email: LuJoHae <lukas.j.haeuser@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: anndata
|
|
12
|
+
Requires-Dist: edgepython
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pandas
|
|
15
|
+
Requires-Dist: patsy
|
|
16
|
+
Requires-Dist: scanpy
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: anndata2ri; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest; extra == 'test'
|
|
21
|
+
Requires-Dist: rpy2; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# milopy
|
|
25
|
+
|
|
26
|
+
A native Python implementation of miloR for differential abundance testing of single-cell datasets.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
milopy/__init__.py,sha256=BILZtDYnfYxayIqwS0v0qGphuQsDbH6pA2jcpB8u-BU,349
|
|
2
|
+
milopy/core.py,sha256=wcog_bwowOOY_13Y95mpVvjgfXk0SPtVLCYKWExL9dk,6641
|
|
3
|
+
milopy/meta.py,sha256=QWvwNMUAGL_Mauk_5mbaMvqcnWBjmex6Tzv0PIZzavI,5359
|
|
4
|
+
milopy-0.3.0.dist-info/METADATA,sha256=ujuOrRdnuVdtayjLtg9PcE8XZXgRNNJ3t6LYPH8LCzE,856
|
|
5
|
+
milopy-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
milopy-0.3.0.dist-info/licenses/LICENSE,sha256=_EeNUEP026NojJHSowCSC-yS5ZnUuGyN8Ym175Sbj_g,1073
|
|
7
|
+
milopy-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Lukas J. Haeuser
|
|
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.
|