qBitrr2 3.7.1__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.
qBitrr/home_path.py ADDED
@@ -0,0 +1,23 @@
1
+ import pathlib
2
+
3
+ from jaraco.docker import is_docker
4
+
5
+ from qBitrr.env_config import ENVIRO_CONFIG
6
+
7
+ if (
8
+ ENVIRO_CONFIG.overrides.data_path is None
9
+ or not (p := pathlib.Path(ENVIRO_CONFIG.overrides.data_path)).exists()
10
+ ):
11
+ if is_docker():
12
+ ON_DOCKER = True
13
+ HOME_PATH = pathlib.Path("/config")
14
+ HOME_PATH.mkdir(parents=True, exist_ok=True)
15
+ else:
16
+ ON_DOCKER = False
17
+ HOME_PATH = pathlib.Path().absolute().joinpath(".config")
18
+ HOME_PATH.mkdir(parents=True, exist_ok=True)
19
+ else:
20
+ HOME_PATH = p
21
+
22
+ APPDATA_FOLDER = HOME_PATH.joinpath("qBitManager")
23
+ APPDATA_FOLDER.mkdir(parents=True, exist_ok=True)
qBitrr/logger.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from logging import Logger
6
+
7
+ import coloredlogs
8
+
9
+ from qBitrr.config import (
10
+ COMPLETED_DOWNLOAD_FOLDER,
11
+ CONFIG,
12
+ CONSOLE_LOGGING_LEVEL_STRING,
13
+ COPIED_TO_NEW_DIR,
14
+ FAILED_CATEGORY,
15
+ HOME_PATH,
16
+ IGNORE_TORRENTS_YOUNGER_THAN,
17
+ LOOP_SLEEP_TIMER,
18
+ NO_INTERNET_SLEEP_TIMER,
19
+ PING_URLS,
20
+ RECHECK_CATEGORY,
21
+ )
22
+
23
+ __all__ = ("run_logs",)
24
+
25
+ TRACE = 5
26
+ VERBOSE = 7
27
+ NOTICE = 23
28
+ HNOTICE = 24
29
+ SUCCESS = 25
30
+
31
+
32
+ class VerboseLogger(Logger):
33
+ def _init__(self, *args, **kwargs):
34
+ super().__init__(*args, **kwargs)
35
+ if self.name.startswith("qBitrr"):
36
+ self.set_config_level()
37
+
38
+ def success(self, message, *args, **kwargs):
39
+ if self.isEnabledFor(SUCCESS):
40
+ self._log(SUCCESS, message, args, **kwargs)
41
+
42
+ def hnotice(self, message, *args, **kwargs):
43
+ if self.isEnabledFor(HNOTICE):
44
+ self._log(HNOTICE, message, args, **kwargs)
45
+
46
+ def notice(self, message, *args, **kwargs):
47
+ if self.isEnabledFor(NOTICE):
48
+ self._log(NOTICE, message, args, **kwargs)
49
+
50
+ def verbose(self, message, *args, **kwargs):
51
+ if self.isEnabledFor(VERBOSE):
52
+ self._log(VERBOSE, message, args, **kwargs)
53
+
54
+ def trace(self, message, *args, **kwargs):
55
+ if self.isEnabledFor(TRACE):
56
+ self._log(TRACE, message, args, **kwargs)
57
+
58
+ def set_config_level(self):
59
+ self.setLevel(CONSOLE_LOGGING_LEVEL_STRING)
60
+
61
+
62
+ logging.addLevelName(SUCCESS, "SUCCESS")
63
+ logging.addLevelName(HNOTICE, "HNOTICE")
64
+ logging.addLevelName(NOTICE, "NOTICE")
65
+ logging.addLevelName(VERBOSE, "VERBOSE")
66
+ logging.addLevelName(TRACE, "TRACE")
67
+ logging.setLoggerClass(VerboseLogger)
68
+
69
+
70
+ def getLogger(name: str | None = None):
71
+ if name:
72
+ return VerboseLogger.manager.getLogger(name)
73
+ else:
74
+ return logging.root
75
+
76
+
77
+ logging.getLogger = getLogger
78
+
79
+
80
+ logger = logging.getLogger("qBitrr.Misc")
81
+
82
+
83
+ HAS_RUN = False
84
+
85
+
86
+ def run_logs(logger: Logger) -> None:
87
+ global HAS_RUN
88
+ try:
89
+ configkeys = {f"qBitrr.{i}" for i in CONFIG.sections()}
90
+ key_length = max(len(max(configkeys, key=len)), 10)
91
+ except BaseException:
92
+ key_length = 10
93
+ coloredlogs.install(
94
+ logger=logger,
95
+ level=logging._nameToLevel.get(CONSOLE_LOGGING_LEVEL_STRING),
96
+ fmt="[%(asctime)-15s] [pid:%(process)8d][tid:%(thread)8d] "
97
+ f"%(levelname)-8s: %(name)-{key_length}s: %(message)s",
98
+ level_styles=dict(
99
+ trace=dict(color="black", bold=True),
100
+ debug=dict(color="magenta", bold=True),
101
+ verbose=dict(color="blue", bold=True),
102
+ info=dict(color="white"),
103
+ notice=dict(color="cyan"),
104
+ hnotice=dict(color="cyan", bold=True),
105
+ warning=dict(color="yellow", bold=True),
106
+ success=dict(color="green", bold=True),
107
+ error=dict(color="red"),
108
+ critical=dict(color="red", bold=True),
109
+ ),
110
+ field_styles=dict(
111
+ asctime=dict(color="green"),
112
+ process=dict(color="magenta"),
113
+ levelname=dict(color="red", bold=True),
114
+ name=dict(color="blue", bold=True),
115
+ thread=dict(color="cyan"),
116
+ ),
117
+ reconfigure=True,
118
+ )
119
+ if HAS_RUN is False:
120
+ logger.debug("Log Level: %s", CONSOLE_LOGGING_LEVEL_STRING)
121
+ logger.debug("Ping URLs: %s", PING_URLS)
122
+ logger.debug("Script Config: FailedCategory=%s", FAILED_CATEGORY)
123
+ logger.debug("Script Config: RecheckCategory=%s", RECHECK_CATEGORY)
124
+ logger.debug("Script Config: CompletedDownloadFolder=%s", COMPLETED_DOWNLOAD_FOLDER)
125
+ logger.debug("Script Config: LoopSleepTimer=%s", LOOP_SLEEP_TIMER)
126
+ logger.debug(
127
+ "Script Config: NoInternetSleepTimer=%s",
128
+ NO_INTERNET_SLEEP_TIMER,
129
+ )
130
+ logger.debug(
131
+ "Script Config: IgnoreTorrentsYoungerThan=%s",
132
+ IGNORE_TORRENTS_YOUNGER_THAN,
133
+ )
134
+ HAS_RUN = True
135
+
136
+
137
+ if COPIED_TO_NEW_DIR is False and not HOME_PATH.joinpath("config.toml").exists():
138
+ logger.warning(
139
+ "Config.toml should exist in '%s', in a future update this will be a requirement.",
140
+ HOME_PATH,
141
+ )
142
+ time.sleep(5)
143
+ if COPIED_TO_NEW_DIR:
144
+ logger.warning("Config.toml new location is %s", HOME_PATH)
145
+ time.sleep(5)
146
+ run_logs(logger)
qBitrr/main.py ADDED
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ from multiprocessing import freeze_support
9
+
10
+ import pathos
11
+ import qbittorrentapi
12
+ import requests
13
+ from packaging import version as version_parser
14
+ from packaging.version import Version as VersionClass
15
+ from qbittorrentapi import APINames
16
+ from qbittorrentapi.decorators import login_required # , response_text
17
+
18
+ from qBitrr.arss import ArrManager
19
+ from qBitrr.bundled_data import patched_version
20
+ from qBitrr.config import APPDATA_FOLDER, CONFIG, QBIT_DISABLED, SEARCH_ONLY, process_flags
21
+ from qBitrr.env_config import ENVIRO_CONFIG
22
+ from qBitrr.ffprobe import FFprobeDownloader
23
+ from qBitrr.logger import run_logs
24
+ from qBitrr.utils import ExpiringSet
25
+
26
+ CHILD_PROCESSES = []
27
+
28
+ logger = logging.getLogger("qBitrr")
29
+ run_logs(logger)
30
+
31
+
32
+ class qBitManager:
33
+ min_supported_version = VersionClass("4.3.9")
34
+ soft_not_supported_supported_version = VersionClass("4.5")
35
+ max_supported_version = VersionClass("4.5.5")
36
+ _head_less_mode = False
37
+
38
+ def __init__(self):
39
+ self.qBit_Host = CONFIG.get("qBit.Host", fallback="localhost")
40
+ self.qBit_Port = CONFIG.get("qBit.Port", fallback=8105)
41
+ self.qBit_UserName = CONFIG.get("qBit.UserName", fallback=None)
42
+ self.qBit_Password = CONFIG.get("qBit.Password", fallback=None)
43
+ self.logger = logging.getLogger(
44
+ "qBitrr.Manager",
45
+ )
46
+ run_logs(self.logger)
47
+ self.logger.debug(
48
+ "qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s",
49
+ self.qBit_Host,
50
+ self.qBit_Port,
51
+ self.qBit_UserName,
52
+ self.qBit_Password,
53
+ )
54
+ self._validated_version = False
55
+ self.client = None
56
+ self.current_qbit_version = None
57
+ if not any([QBIT_DISABLED, SEARCH_ONLY]):
58
+ self.client = qbittorrentapi.Client(
59
+ host=self.qBit_Host,
60
+ port=self.qBit_Port,
61
+ username=self.qBit_UserName,
62
+ password=self.qBit_Password,
63
+ SIMPLE_RESPONSES=False,
64
+ )
65
+ try:
66
+ self.current_qbit_version = version_parser.parse(self.client.app_version())
67
+ self._validated_version = True
68
+ except BaseException:
69
+ self.current_qbit_version = self.min_supported_version
70
+ self.logger.error(
71
+ "Could not establish qBitTorrent version, "
72
+ "you may experience errors, please report this error."
73
+ )
74
+ self._version_validator()
75
+ self.expiring_bool = ExpiringSet(max_age_seconds=10)
76
+ self.cache = {}
77
+ self.name_cache = {}
78
+ self.should_delay_torrent_scan = False # If true torrent scan is delayed by 5 minutes.
79
+ self.child_processes = []
80
+ self.ffprobe_downloader = FFprobeDownloader()
81
+ try:
82
+ if not any([QBIT_DISABLED, SEARCH_ONLY]):
83
+ self.ffprobe_downloader.update()
84
+ except Exception as e:
85
+ self.logger.error(
86
+ "FFprobe manager error: %s while attempting to download/update FFprobe", e
87
+ )
88
+ self.arr_manager = ArrManager(self).build_arr_instances()
89
+ run_logs(self.logger)
90
+
91
+ def _version_validator(self):
92
+ if self.min_supported_version <= self.current_qbit_version <= self.max_supported_version:
93
+ if self._validated_version:
94
+ self.logger.info(
95
+ "Current qBitTorrent version is supported: %s",
96
+ self.current_qbit_version,
97
+ )
98
+ else:
99
+ self.logger.warning(
100
+ "Could not validate current qBitTorrent version, assuming: %s",
101
+ self.current_qbit_version,
102
+ )
103
+ time.sleep(10)
104
+ else:
105
+ self.logger.critical(
106
+ "You are currently running qBitTorrent version %s, "
107
+ "Supported version range is %s to < %s",
108
+ self.current_qbit_version,
109
+ self.min_supported_version,
110
+ self.max_supported_version,
111
+ )
112
+ sys.exit(1)
113
+
114
+ # @response_text(str)
115
+ @login_required
116
+ def app_version(self, **kwargs):
117
+ return self.client._get(
118
+ _name=APINames.Application,
119
+ _method="version",
120
+ _retries=0,
121
+ _retry_backoff_factor=0,
122
+ **kwargs,
123
+ )
124
+
125
+ @property
126
+ def is_alive(self) -> bool:
127
+ try:
128
+ if 1 in self.expiring_bool or self.client is None:
129
+ return True
130
+ self.client.app_version()
131
+ self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port)
132
+ self.expiring_bool.add(1)
133
+ return True
134
+ except requests.RequestException:
135
+ self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port)
136
+ self.should_delay_torrent_scan = True
137
+ return False
138
+
139
+ def get_child_processes(self) -> list[pathos.helpers.mp.Process]:
140
+ run_logs(self.logger)
141
+ self.logger.debug("Managing %s categories", len(self.arr_manager.managed_objects))
142
+ count = 0
143
+ procs = []
144
+ for arr in self.arr_manager.managed_objects.values():
145
+ numb, processes = arr.spawn_child_processes()
146
+ count += numb
147
+ procs.extend(processes)
148
+ return procs
149
+
150
+ def run(self):
151
+ try:
152
+ self.logger.debug("Starting %s child processes", len(self.child_processes))
153
+ [p.start() for p in self.child_processes]
154
+ [p.join() for p in self.child_processes]
155
+ except KeyboardInterrupt:
156
+ self.logger.info("Detected Ctrl+C - Terminating process")
157
+ sys.exit(0)
158
+ except BaseException as e:
159
+ self.logger.info("Detected Ctrl+C - Terminating process: %r", e)
160
+ sys.exit(1)
161
+
162
+
163
+ def run():
164
+ global CHILD_PROCESSES
165
+ early_exit = process_flags()
166
+ if early_exit is True:
167
+ sys.exit(0)
168
+ logger.info("Starting qBitrr: Version: %s.", patched_version)
169
+ manager = qBitManager()
170
+ run_logs(logger)
171
+ logger.debug("Environment variables: %r", ENVIRO_CONFIG)
172
+ try:
173
+ if CHILD_PROCESSES := manager.get_child_processes():
174
+ manager.run()
175
+ else:
176
+ logger.warning(
177
+ "No tasks to perform, if this is unintended double check your config file."
178
+ )
179
+ except KeyboardInterrupt:
180
+ logger.info("Detected Ctrl+C - Terminating process")
181
+ sys.exit(0)
182
+ except Exception:
183
+ logger.info("Attempting to terminate child processes, please wait a moment.")
184
+ for child in manager.child_processes:
185
+ child.kill()
186
+
187
+
188
+ def cleanup():
189
+ for p in CHILD_PROCESSES:
190
+ p.kill()
191
+ p.terminate()
192
+
193
+
194
+ atexit.register(cleanup)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ extensions = [".db", ".db-shm", ".db-wal"]
199
+ for file in os.listdir(APPDATA_FOLDER):
200
+ for ext in extensions:
201
+ if file.endswith(ext):
202
+ os.remove(os.path.join(APPDATA_FOLDER, file))
203
+ freeze_support()
204
+ run()
qBitrr/tables.py ADDED
@@ -0,0 +1,52 @@
1
+ from peewee import BooleanField, CharField, DateTimeField, IntegerField, Model, TextField
2
+
3
+
4
+ class FilesQueued(Model):
5
+ EntryId = IntegerField(primary_key=True, null=False, unique=True)
6
+
7
+
8
+ class MoviesFilesModel(Model):
9
+ Title = CharField()
10
+ Monitored = BooleanField()
11
+ TmdbId = IntegerField()
12
+ Year = IntegerField()
13
+ EntryId = IntegerField(unique=True)
14
+ Searched = BooleanField(default=False)
15
+ MovieFileId = IntegerField()
16
+ IsRequest = BooleanField(default=False)
17
+ QualityMet = BooleanField(default=False)
18
+
19
+
20
+ class EpisodeFilesModel(Model):
21
+ EntryId = IntegerField(primary_key=True)
22
+ SeriesTitle = TextField(null=True)
23
+ Title = TextField(null=True)
24
+ SeriesId = IntegerField(null=False)
25
+ EpisodeFileId = IntegerField(null=True)
26
+ EpisodeNumber = IntegerField(null=False)
27
+ SeasonNumber = IntegerField(null=False)
28
+ AbsoluteEpisodeNumber = IntegerField(null=True)
29
+ SceneAbsoluteEpisodeNumber = IntegerField(null=True)
30
+ LastSearchTime = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True)
31
+ AirDateUtc = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True)
32
+ Monitored = BooleanField(null=True)
33
+ Searched = BooleanField(default=False)
34
+ IsRequest = BooleanField(default=False)
35
+ QualityMet = BooleanField(default=False)
36
+
37
+
38
+ class SeriesFilesModel(Model):
39
+ EntryId = IntegerField(primary_key=True)
40
+ Title = TextField(null=True)
41
+ Monitored = BooleanField(null=True)
42
+ Searched = BooleanField(default=False)
43
+
44
+
45
+ class MovieQueueModel(Model):
46
+ EntryId = IntegerField(unique=True)
47
+ Completed = BooleanField(default=False)
48
+
49
+
50
+ class EpisodeQueueModel(Model):
51
+ EntryId = IntegerField(unique=True)
52
+ Completed = BooleanField(default=False)
qBitrr/utils.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import pathlib
5
+ import random
6
+ import socket
7
+ import time
8
+ from typing import Iterator
9
+
10
+ import ping3
11
+ from cachetools import TTLCache
12
+
13
+ ping3.EXCEPTIONS = True
14
+
15
+ logger = logging.getLogger("qBitrr.Utils")
16
+
17
+ CACHE = TTLCache(maxsize=50, ttl=60)
18
+
19
+
20
+ def absolute_file_paths(directory: pathlib.Path | str) -> Iterator[pathlib.Path]:
21
+ error = True
22
+ while error is True:
23
+ try:
24
+ yield from pathlib.Path(directory).glob("**/*")
25
+ error = False
26
+ except FileNotFoundError as e:
27
+ logger.warning("%s - %s", e.strerror, e.filename)
28
+
29
+
30
+ def validate_and_return_torrent_file(file: str) -> pathlib.Path:
31
+ path = pathlib.Path(file)
32
+ if path.is_file():
33
+ path = path.parent.absolute()
34
+ count = 9
35
+ while not path.exists():
36
+ logger.debug(
37
+ "Attempt %s/10: File does not yet exists! (Possibly being moved?) | "
38
+ "%s | Sleeping for 0.1s",
39
+ path,
40
+ 10 - count,
41
+ )
42
+ time.sleep(0.1)
43
+ if count == 0:
44
+ break
45
+ count -= 1
46
+ else:
47
+ count = 0
48
+ while str(path) == ".":
49
+ path = pathlib.Path(file)
50
+ if path.is_file():
51
+ path = path.parent.absolute()
52
+ while not path.exists():
53
+ logger.debug(
54
+ "Attempt %s/10:File does not yet exists! (Possibly being moved?) | "
55
+ "%s | Sleeping for 0.1s",
56
+ path,
57
+ 10 - count,
58
+ )
59
+ time.sleep(0.1)
60
+ if count == 0:
61
+ break
62
+ count -= 1
63
+ else:
64
+ count = 0
65
+ if count == 0:
66
+ break
67
+ count -= 1
68
+ return path
69
+
70
+
71
+ def has_internet():
72
+ from qBitrr.config import PING_URLS
73
+
74
+ url = random.choice(PING_URLS)
75
+ if not is_connected(url):
76
+ return False
77
+ logger.debug("Successfully connected to %s", url)
78
+ return True
79
+
80
+
81
+ def _basic_ping(hostname):
82
+ host = "N/A"
83
+ try:
84
+ # if this hostname was called within the last 10 seconds skip it
85
+ # if it was previous successful
86
+ # Reducing the number of call to it and the likelihood of rate-limits.
87
+ if hostname in CACHE:
88
+ return CACHE[hostname]
89
+ # see if we can resolve the host name -- tells us if there is
90
+ # a DNS listening
91
+ host = socket.gethostbyname(hostname)
92
+ # connect to the host -- tells us if the host is actually
93
+ # reachable
94
+ s = socket.create_connection((host, 80), 5)
95
+ s.close()
96
+ CACHE[hostname] = True
97
+ return True
98
+ except Exception as e:
99
+ logger.debug(
100
+ "Error when connecting to host: %s %s %s",
101
+ hostname,
102
+ host,
103
+ e,
104
+ )
105
+ return False
106
+
107
+
108
+ def is_connected(hostname):
109
+ try:
110
+ # if this hostname was called within the last 10 seconds skip it
111
+ # if it was previous successful
112
+ # Reducing the number of call to it and the likelihood of rate-limits.
113
+ if hostname in CACHE:
114
+ return CACHE[hostname]
115
+ ping3.ping(hostname, timeout=5)
116
+ CACHE[hostname] = True
117
+ return True
118
+ except ping3.errors.PingError as e: # All ping3 errors are subclasses of `PingError`.
119
+ logger.debug(
120
+ "Error when connecting to host: %s %s",
121
+ hostname,
122
+ e,
123
+ )
124
+ except (
125
+ Exception
126
+ ): # Ping3 is far more robust but may requite root access, if root access is not available then run the basic mode
127
+ return _basic_ping(hostname)
128
+
129
+
130
+ class ExpiringSet:
131
+ def __init__(self, *args: list, **kwargs):
132
+ max_age_seconds = kwargs.get("max_age_seconds", 0)
133
+ assert max_age_seconds > 0
134
+ self.age = max_age_seconds
135
+ self.container = {}
136
+ for arg in args:
137
+ self.add(arg)
138
+
139
+ def __repr__(self):
140
+ self.__update__()
141
+ return f"{self.__class__.__name__}({', '.join(self.container.keys())})"
142
+
143
+ def extend(self, args):
144
+ """Add several items at once."""
145
+ for arg in args:
146
+ self.add(arg)
147
+
148
+ def add(self, value):
149
+ self.container[value] = time.time()
150
+
151
+ def remove(self, item):
152
+ del self.container[item]
153
+
154
+ def contains(self, value):
155
+ if value not in self.container:
156
+ return False
157
+ if time.time() - self.container[value] > self.age:
158
+ del self.container[value]
159
+ return False
160
+ return True
161
+
162
+ __contains__ = contains
163
+
164
+ def __getitem__(self, index):
165
+ self.__update__()
166
+ return list(self.container.keys())[index]
167
+
168
+ def __iter__(self):
169
+ self.__update__()
170
+ return iter(self.container.copy())
171
+
172
+ def __len__(self):
173
+ self.__update__()
174
+ return len(self.container)
175
+
176
+ def __copy__(self):
177
+ self.__update__()
178
+ temp = ExpiringSet(max_age_seconds=self.age)
179
+ temp.container = self.container.copy()
180
+ return temp
181
+
182
+ def __update__(self):
183
+ for k, b in self.container.copy().items():
184
+ if time.time() - b > self.age:
185
+ del self.container[k]
186
+ return False
187
+
188
+ def __hash__(self):
189
+ return hash(*(self.container.keys()))
190
+
191
+ def __eq__(self, other):
192
+ return self.__hash__() == other.__hash__()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Feramance
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.