ybox 0.9.8__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.
- ybox/__init__.py +2 -0
- ybox/cmd.py +307 -0
- ybox/conf/completions/ybox.fish +93 -0
- ybox/conf/distros/arch/add-gpg-key.sh +29 -0
- ybox/conf/distros/arch/distro.ini +192 -0
- ybox/conf/distros/arch/init-base.sh +10 -0
- ybox/conf/distros/arch/init-user.sh +35 -0
- ybox/conf/distros/arch/init.sh +82 -0
- ybox/conf/distros/arch/list_fmt_long.py +76 -0
- ybox/conf/distros/arch/pkgdeps.py +276 -0
- ybox/conf/distros/deb-generic/check-package.sh +77 -0
- ybox/conf/distros/deb-generic/distro.ini +190 -0
- ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
- ybox/conf/distros/deb-generic/init-base.sh +11 -0
- ybox/conf/distros/deb-generic/init-user.sh +3 -0
- ybox/conf/distros/deb-generic/init.sh +136 -0
- ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
- ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
- ybox/conf/distros/deb-oldstable/distro.ini +21 -0
- ybox/conf/distros/deb-stable/distro.ini +21 -0
- ybox/conf/distros/supported.list +5 -0
- ybox/conf/distros/ubuntu2204/distro.ini +21 -0
- ybox/conf/distros/ubuntu2404/distro.ini +21 -0
- ybox/conf/profiles/apps.ini +26 -0
- ybox/conf/profiles/basic.ini +310 -0
- ybox/conf/profiles/dev.ini +25 -0
- ybox/conf/profiles/games.ini +39 -0
- ybox/conf/resources/entrypoint-base.sh +170 -0
- ybox/conf/resources/entrypoint-common.sh +23 -0
- ybox/conf/resources/entrypoint-cp.sh +32 -0
- ybox/conf/resources/entrypoint-root.sh +20 -0
- ybox/conf/resources/entrypoint-user.sh +21 -0
- ybox/conf/resources/entrypoint.sh +249 -0
- ybox/conf/resources/prime-run +13 -0
- ybox/conf/resources/run-in-dir +60 -0
- ybox/conf/resources/run-user-bash-cmd +14 -0
- ybox/config.py +255 -0
- ybox/env.py +205 -0
- ybox/filelock.py +77 -0
- ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
- ybox/pkg/__init__.py +0 -0
- ybox/pkg/clean.py +33 -0
- ybox/pkg/info.py +40 -0
- ybox/pkg/inst.py +638 -0
- ybox/pkg/list.py +191 -0
- ybox/pkg/mark.py +68 -0
- ybox/pkg/repair.py +150 -0
- ybox/pkg/repo.py +251 -0
- ybox/pkg/search.py +52 -0
- ybox/pkg/uninst.py +92 -0
- ybox/pkg/update.py +56 -0
- ybox/print.py +121 -0
- ybox/run/__init__.py +0 -0
- ybox/run/cmd.py +54 -0
- ybox/run/control.py +102 -0
- ybox/run/create.py +1116 -0
- ybox/run/destroy.py +64 -0
- ybox/run/graphics.py +367 -0
- ybox/run/logs.py +57 -0
- ybox/run/ls.py +64 -0
- ybox/run/pkg.py +445 -0
- ybox/schema/0.9.1-added.sql +27 -0
- ybox/schema/0.9.6-added.sql +18 -0
- ybox/schema/init.sql +39 -0
- ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
- ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
- ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
- ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
- ybox/state.py +914 -0
- ybox/util.py +351 -0
- ybox-0.9.8.dist-info/LICENSE +19 -0
- ybox-0.9.8.dist-info/METADATA +533 -0
- ybox-0.9.8.dist-info/RECORD +76 -0
- ybox-0.9.8.dist-info/WHEEL +5 -0
- ybox-0.9.8.dist-info/entry_points.txt +8 -0
- ybox-0.9.8.dist-info/top_level.txt +1 -0
ybox/util.py
ADDED
@@ -0,0 +1,351 @@
|
|
1
|
+
"""
|
2
|
+
Common utility classes and methods used by the scripts.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import stat
|
8
|
+
import subprocess
|
9
|
+
import sys
|
10
|
+
import time
|
11
|
+
from configparser import BasicInterpolation, ConfigParser, Interpolation
|
12
|
+
from dataclasses import dataclass, field
|
13
|
+
from importlib.resources import files
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import Any, Iterable, Optional, Sequence
|
16
|
+
|
17
|
+
from simple_term_menu import TerminalMenu # type: ignore
|
18
|
+
from tabulate import tabulate
|
19
|
+
|
20
|
+
from ybox import __version__ as product_version
|
21
|
+
|
22
|
+
from .cmd import build_shell_command, get_ybox_state
|
23
|
+
from .config import Consts, StaticConfiguration
|
24
|
+
from .env import Environ, PathName
|
25
|
+
from .print import fgcolor as fg
|
26
|
+
from .print import get_terminal_width, print_error, print_warn
|
27
|
+
|
28
|
+
|
29
|
+
class EnvInterpolation(BasicInterpolation):
|
30
|
+
"""
|
31
|
+
Substitute environment variables in the values using 'os.path.expandvars'.
|
32
|
+
In addition, a special substitution of ${NOW:<fmt>} is supported to substitute the
|
33
|
+
current time (captured by InitNow above) in the 'datetime.strftime' format.
|
34
|
+
|
35
|
+
This class extends `BasicInterpolation` hence the `%(.)s` syntax can be used to expand other
|
36
|
+
keys in the same section or the `[DEFAULT]` section in the `before_get`. If a bare '%' is
|
37
|
+
required in the value, then it should be escaped with a '%' i.e. use '%%' for a single '%'.
|
38
|
+
Note that the environment variable and NOW substitution is done in the `before_read` phase
|
39
|
+
before any `BasicInterpolation` is done, so any '%' characters in those environment variable
|
40
|
+
or ${NOW:...} expansions should not be escaped.
|
41
|
+
|
42
|
+
If 'skip_expansion' is specified in initialization to a non-empty list, then no
|
43
|
+
environment variable substitution is performed for those sections but the
|
44
|
+
${NOW:...} substitution is still performed.
|
45
|
+
"""
|
46
|
+
|
47
|
+
_NOW_RE = re.compile(r"\${NOW:([^}]*)}")
|
48
|
+
|
49
|
+
def __init__(self, env: Environ, skip_expansion: list[str]):
|
50
|
+
super().__init__()
|
51
|
+
self._skip_expansion = skip_expansion
|
52
|
+
# for the NOW substitution
|
53
|
+
self._now = env.now
|
54
|
+
|
55
|
+
# override before_read rather than before_get because expanded vars are needed when writing
|
56
|
+
# into the state.db database too
|
57
|
+
def before_read(self, parser, section: str, option: str, value: str) -> str: # type: ignore
|
58
|
+
"""Override before_read to substitute environment variables and ${NOW...} pattern.
|
59
|
+
This method is overridden rather than before_get because expanded variables are
|
60
|
+
also required when writing the configuration into the state.db database."""
|
61
|
+
if not value:
|
62
|
+
return value
|
63
|
+
if section not in self._skip_expansion:
|
64
|
+
value = os.path.expandvars(value)
|
65
|
+
# replace ${NOW:...} pattern with appropriately formatted datetime string
|
66
|
+
return self._NOW_RE.sub(lambda mt: self._now.strftime(mt.group(1)), value)
|
67
|
+
|
68
|
+
|
69
|
+
def resolve_inc_path(inc: str, src: PathName) -> PathName:
|
70
|
+
"""resolve `include` path specified relative to a given source, or as an absolute string"""
|
71
|
+
return Path(inc) if os.path.isabs(inc) else src.parent.joinpath(inc) # type: ignore
|
72
|
+
|
73
|
+
|
74
|
+
# read the ini file, recursing into the includes to build the final dictionary
|
75
|
+
def config_reader(conf_file: PathName, interpolation: Optional[Interpolation],
|
76
|
+
top_level: Optional[PathName] = None) -> ConfigParser:
|
77
|
+
"""
|
78
|
+
Read the container configuration INI file, recursing into the includes to build the final
|
79
|
+
dictionary having the sections with corresponding key-value pairs.
|
80
|
+
|
81
|
+
:param conf_file: the configuration file to be read as a `Path` or resource file from
|
82
|
+
importlib (`Traversable`)
|
83
|
+
:param interpolation: if provided then used for value interpolation
|
84
|
+
:param top_level: the top-level configuration file; don't pass this when calling
|
85
|
+
externally (or set it the same as `conf_file` argument)
|
86
|
+
:return: instance of `ConfigParser` built after parsing the given file as
|
87
|
+
well as any includes recursively
|
88
|
+
"""
|
89
|
+
if not conf_file.is_file():
|
90
|
+
if top_level:
|
91
|
+
raise FileNotFoundError(f"Config file '{conf_file}' among the includes of "
|
92
|
+
f"'{top_level}' does not exist or not a file")
|
93
|
+
raise FileNotFoundError(f"Config file '{conf_file}' does not exist or not a file")
|
94
|
+
with conf_file.open("r", encoding="utf-8") as conf_fd:
|
95
|
+
config = ini_file_reader(conf_fd, interpolation)
|
96
|
+
if not top_level:
|
97
|
+
top_level = conf_file
|
98
|
+
if not (includes := config.get("base", "includes", fallback="")):
|
99
|
+
return config
|
100
|
+
for include in includes.split(","):
|
101
|
+
if not (include := include.strip()):
|
102
|
+
continue
|
103
|
+
# relative paths inside an include file (e.g. scripts in distro.ini) are relative
|
104
|
+
# to the including file and not the top-level parent
|
105
|
+
inc_file = resolve_inc_path(include, conf_file)
|
106
|
+
inc_conf = config_reader(inc_file, interpolation, top_level)
|
107
|
+
# disable interpolation for inc_conf after read else it can apply again when assigning
|
108
|
+
# pylint: disable=protected-access
|
109
|
+
inc_conf._interpolation = Interpolation() # type: ignore
|
110
|
+
for section in inc_conf.sections():
|
111
|
+
if not config.has_section(section):
|
112
|
+
config[section] = inc_conf[section]
|
113
|
+
else:
|
114
|
+
conf_section = config[section]
|
115
|
+
inc_section = inc_conf[section]
|
116
|
+
for key in inc_section:
|
117
|
+
if key not in conf_section:
|
118
|
+
conf_section[key] = inc_section[key]
|
119
|
+
return config
|
120
|
+
|
121
|
+
|
122
|
+
def ini_file_reader(fd: Iterable[str], interpolation: Optional[Interpolation],
|
123
|
+
case_sensitive: bool = True) -> ConfigParser:
|
124
|
+
"""
|
125
|
+
Read an INI file from a given file handle. It applies some basic rules that are used
|
126
|
+
for all ybox configurations like allowing no values, only '=' as delimiters and
|
127
|
+
case-sensitive keys.
|
128
|
+
|
129
|
+
:param fd: file handle for the INI format data
|
130
|
+
:param interpolation: if provided then used for value interpolation
|
131
|
+
:param case_sensitive: if True then keys are case-sensitive (default) else case-insensitive
|
132
|
+
:return: instance of `ConfigParser` built after parsing the given file
|
133
|
+
"""
|
134
|
+
config = ConfigParser(allow_no_value=True, interpolation=interpolation, delimiters="=")
|
135
|
+
if case_sensitive:
|
136
|
+
config.optionxform = str # type: ignore
|
137
|
+
config.read_file(fd)
|
138
|
+
return config
|
139
|
+
|
140
|
+
|
141
|
+
def copy_file(src: PathName, dest: str, permissions: Optional[int] = None) -> None:
|
142
|
+
"""
|
143
|
+
Copy a given source file (can be on filesystem or package resource) to destination path
|
144
|
+
overwriting if it exists, and with given optional permissions. If `permissions` is not provided
|
145
|
+
then this method tries to copy the permissions of the source to the destination (thus ignoring
|
146
|
+
the `umask`), so is similar to `cp --preserve=mode` for that case. The size of the file should
|
147
|
+
not be large since this method loads the entire `src` file as bytes then writes to `dest`.
|
148
|
+
|
149
|
+
:param src: the source file or package resource
|
150
|
+
:param dest: destination file path
|
151
|
+
:param permissions: optional file permissions as an integer as accepted by :func:`os.chmod`,
|
152
|
+
defaults to None
|
153
|
+
"""
|
154
|
+
with open(dest, "wb") as dest_fd:
|
155
|
+
dest_fd.write(src.read_bytes())
|
156
|
+
if permissions is not None:
|
157
|
+
os.chmod(dest, permissions)
|
158
|
+
elif hasattr(src, "stat"): # copy the permissions
|
159
|
+
# pyright does not check hasattr, hence the "type: ignore" instead of artificial TypeGuards
|
160
|
+
if hasattr(src, "resolve"):
|
161
|
+
src = src.resolve() # type: ignore
|
162
|
+
perms = stat.S_IMODE(src.stat().st_mode) # type: ignore
|
163
|
+
os.chmod(dest, perms)
|
164
|
+
|
165
|
+
|
166
|
+
def copy_ybox_scripts_to_container(conf: StaticConfiguration, distro_config: ConfigParser) -> None:
|
167
|
+
"""
|
168
|
+
Copy ybox setup scripts to local directory mounted on container.
|
169
|
+
|
170
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
171
|
+
:param distro_config: an object of :class:`ConfigParser` from parsing the Linux
|
172
|
+
distribution's `distro.ini`
|
173
|
+
"""
|
174
|
+
env = conf.env
|
175
|
+
# copy the common scripts
|
176
|
+
for script in Consts.resource_scripts():
|
177
|
+
path = env.search_config_path(f"resources/{script}", only_sys_conf=True)
|
178
|
+
copy_file(path, f"{conf.scripts_dir}/{script}", permissions=0o755)
|
179
|
+
# also copy distribution specific scripts
|
180
|
+
base_section = distro_config["base"]
|
181
|
+
if scripts := base_section.get("scripts"):
|
182
|
+
for script in scripts.split(","):
|
183
|
+
script = script.strip()
|
184
|
+
path = env.search_config_path(conf.distribution_config(conf.distribution, script),
|
185
|
+
only_sys_conf=True)
|
186
|
+
copy_file(path, f"{conf.scripts_dir}/{os.path.basename(script)}", permissions=0o644)
|
187
|
+
# finally copy the ybox python module which may be used by distribution scripts
|
188
|
+
src_dir = files("ybox")
|
189
|
+
dest_dir = f"{conf.scripts_dir}/ybox"
|
190
|
+
os.makedirs(dest_dir, exist_ok=True)
|
191
|
+
# allow for read/execute permissions for all since non-root user needs access with docker
|
192
|
+
os.chmod(dest_dir, mode=0o755)
|
193
|
+
for resource in src_dir.iterdir():
|
194
|
+
if resource.is_file():
|
195
|
+
copy_file(resource, f"{dest_dir}/{resource.name}", permissions=0o644)
|
196
|
+
|
197
|
+
|
198
|
+
def write_ybox_version(conf: StaticConfiguration) -> None:
|
199
|
+
"""
|
200
|
+
Write the version file having the current product version to container scripts directory.
|
201
|
+
|
202
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
203
|
+
"""
|
204
|
+
version_file = f"{conf.scripts_dir}/version"
|
205
|
+
with open(version_file, "w", encoding="utf-8") as version_fd:
|
206
|
+
version_fd.write(product_version)
|
207
|
+
|
208
|
+
|
209
|
+
def get_ybox_version(conf: StaticConfiguration) -> str:
|
210
|
+
"""
|
211
|
+
Get the product version string recorded in the container or empty if no version was recorded.
|
212
|
+
|
213
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
214
|
+
:return: the version recorded in the container as a string, or empty if not present
|
215
|
+
"""
|
216
|
+
version_file = f"{conf.scripts_dir}/version"
|
217
|
+
if os.access(version_file, os.R_OK):
|
218
|
+
with open(version_file, "r", encoding="utf-8") as fd:
|
219
|
+
return fd.read().strip()
|
220
|
+
return ""
|
221
|
+
|
222
|
+
|
223
|
+
def wait_for_ybox_container(docker_cmd: str, conf: StaticConfiguration) -> None:
|
224
|
+
"""
|
225
|
+
Wait for container created with `create.start_container` to finish all its initialization.
|
226
|
+
This depends on the specific entrypoint script used by `create.start_container` to write
|
227
|
+
and update its status in a file bind mounted in a host directory readable from outside.
|
228
|
+
This waits for a maximum of 600 seconds which is hard-coded.
|
229
|
+
|
230
|
+
:param docker_cmd: the podman/docker executable to use
|
231
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
232
|
+
"""
|
233
|
+
sys.stdout.flush()
|
234
|
+
box_name = conf.box_name
|
235
|
+
max_wait_secs = 600
|
236
|
+
status_line = "" # keeps the last valid line read from status file
|
237
|
+
with open(conf.status_file, "r", encoding="utf-8") as status_fd:
|
238
|
+
|
239
|
+
def read_lines() -> bool:
|
240
|
+
"""
|
241
|
+
Read status file, clear it if container has finished starting or stopping and return
|
242
|
+
True for that case else return False.
|
243
|
+
"""
|
244
|
+
nonlocal status_line
|
245
|
+
while line := status_fd.readline():
|
246
|
+
status_line = line
|
247
|
+
if status_line.strip() in ("started", "stopped"):
|
248
|
+
# clear the status file and return
|
249
|
+
truncate_file(conf.status_file)
|
250
|
+
return True
|
251
|
+
print(line, end="") # line already includes the terminating newline
|
252
|
+
return False
|
253
|
+
|
254
|
+
for _ in range(max_wait_secs):
|
255
|
+
# check the container status first which may be running or stopping
|
256
|
+
# in which case sleep and retry (if stopped, then read_lines should succeed)
|
257
|
+
if get_ybox_state(docker_cmd, box_name, expected_states=("running", "stopping")):
|
258
|
+
if read_lines():
|
259
|
+
return
|
260
|
+
else:
|
261
|
+
time.sleep(1) # wait for sometime for file write to become visible
|
262
|
+
if read_lines():
|
263
|
+
return
|
264
|
+
print_error("FAILED waiting for container to be ready (last status: "
|
265
|
+
f"{status_line}).\nCheck 'ybox-logs {box_name}' for more details.")
|
266
|
+
sys.exit(1)
|
267
|
+
# using simple poll per second rather than inotify or similar because the
|
268
|
+
# initialization can take a good amount of time and second granularity is enough
|
269
|
+
time.sleep(1)
|
270
|
+
# reading did not end after max_wait_secs
|
271
|
+
print_error(f"TIMED OUT waiting for ready container after {max_wait_secs}secs (last status: "
|
272
|
+
f"{status_line}).\nCheck 'ybox-logs -f {box_name}' for more details.")
|
273
|
+
sys.exit(1)
|
274
|
+
|
275
|
+
|
276
|
+
def truncate_file(file: str) -> None:
|
277
|
+
"""truncate an existing file"""
|
278
|
+
with open(file, "a", encoding="utf-8") as file_fd:
|
279
|
+
file_fd.truncate(0)
|
280
|
+
|
281
|
+
|
282
|
+
def check_package(docker_cmd: str, check_cmd: str, package: str,
|
283
|
+
container_name: str) -> tuple[int, list[str]]:
|
284
|
+
"""
|
285
|
+
Check if a given package is installed in a container, or available in package repositories
|
286
|
+
and return the list of matching packages.
|
287
|
+
|
288
|
+
:param docker_cmd: the podman/docker executable to use
|
289
|
+
:param check_cmd: the command used to check the existence of the package
|
290
|
+
:param package: name of the package to check
|
291
|
+
:param container_name: name of the container
|
292
|
+
and name of matching package names which can be different for a virtual package
|
293
|
+
"""
|
294
|
+
check_result = subprocess.run(build_shell_command(
|
295
|
+
docker_cmd, container_name, check_cmd.format(package=package), enable_pty=False),
|
296
|
+
check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
297
|
+
output = check_result.stdout.decode("utf-8").splitlines()
|
298
|
+
return (check_result.returncode, output) if output else (1, output)
|
299
|
+
|
300
|
+
|
301
|
+
def select_item_from_menu(items: list[str]) -> Optional[str]:
|
302
|
+
"""
|
303
|
+
Display a list of items on terminal and allow user to select an item from it interactively
|
304
|
+
using arrow keys and all.
|
305
|
+
|
306
|
+
:param items: list of items to be displayed
|
307
|
+
:return: the chosen item, or None if user aborted the selection
|
308
|
+
"""
|
309
|
+
terminal_menu = TerminalMenu(items,
|
310
|
+
status_bar="Press <Enter> to select, <Esc> to exit")
|
311
|
+
selection = terminal_menu.show()
|
312
|
+
if isinstance(selection, int):
|
313
|
+
return items[selection]
|
314
|
+
print_warn("Aborted selection")
|
315
|
+
return None
|
316
|
+
|
317
|
+
|
318
|
+
@dataclass
|
319
|
+
class FormatTable:
|
320
|
+
"""
|
321
|
+
Format a given table of values as a table appropriate for display on a terminal.
|
322
|
+
|
323
|
+
Attributes:
|
324
|
+
table: an `Iterable` of `Iterable` values as accepted by :func:`tabulate.tabulate`
|
325
|
+
headers: a `Sequence` of header names corresponding to each column in the `table`
|
326
|
+
colors: a `Sequence` of color strings (e.g. :func:`fgcolor.red`) for each of the columns
|
327
|
+
fmt: formatting style of the table (e.g. `rounded_grid`) as accepted by `tabulate.tabulate`
|
328
|
+
col_width_ratios: ratios of widths of the columns as an `Iterable` of floats; the length
|
329
|
+
of this should match that of `table` and `headers_with_colors`
|
330
|
+
max_col_widths: calculated maximum widths of the columns from `col_width_ratios` as a
|
331
|
+
`Sequence` of integers
|
332
|
+
"""
|
333
|
+
table: Iterable[Iterable[Any]]
|
334
|
+
headers: Sequence[str]
|
335
|
+
colors: Sequence[str]
|
336
|
+
fmt: str
|
337
|
+
col_width_ratios: Iterable[float]
|
338
|
+
max_col_widths: Sequence[int] = field(init=False)
|
339
|
+
|
340
|
+
def __post_init__(self):
|
341
|
+
# reduce available width for borders and padding
|
342
|
+
available_width = get_terminal_width() - len(self.headers) * 4 - 1
|
343
|
+
ratio_sum = sum(self.col_width_ratios)
|
344
|
+
self.max_col_widths = [int(r * available_width / ratio_sum) for r in self.col_width_ratios]
|
345
|
+
|
346
|
+
def show(self) -> str:
|
347
|
+
"""return formatted table as a string appropriate for display in the current terminal"""
|
348
|
+
table = ((f"{c}{v}{fg.reset}" for v, c in zip(line, self.colors)) for line in self.table)
|
349
|
+
headers = [f"{c}{h}{fg.reset}" for h, c in zip(self.headers, self.colors)]
|
350
|
+
return tabulate(table, headers, tablefmt=self.fmt, disable_numparse=True,
|
351
|
+
maxcolwidths=self.max_col_widths)
|
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2024-2025 Sumedh Wale and contributors
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|