hltv-async-api 0.2.2__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,9 @@
1
+ """
2
+ :autor: akimerslys
3
+ :license: MIT
4
+ """
5
+
6
+ from .aiohltv import Hltv
7
+
8
+ __author__ = 'akimerslys'
9
+ __version__ = '0.2.2'
@@ -0,0 +1,604 @@
1
+ from typing import Any, List
2
+ from datetime import date, datetime, timedelta
3
+
4
+ from bs4 import BeautifulSoup
5
+ import asyncio
6
+ from asyncio import get_running_loop
7
+ from functools import partial
8
+
9
+ from aiohttp import ClientSession, ClientTimeout
10
+ from aiohttp.client_exceptions import ClientProxyConnectionError, ClientResponseError, ClientOSError, \
11
+ ServerDisconnectedError, ClientHttpProxyError
12
+
13
+ import logging
14
+
15
+
16
+ class Hltv:
17
+ def __init__(self, max_delay: int = 15,
18
+ timeout: int = 5,
19
+ use_proxy: bool = False,
20
+ proxy_path: str | None = None,
21
+ proxy_list: list | None = None,
22
+ debug: bool = False,
23
+ max_retries: int = 0,
24
+ proxy_protocol: str | None = None,
25
+ remove_proxy: bool = False
26
+ ):
27
+ self.headers = {
28
+ "referer": "https://www.hltv.org/stats",
29
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
30
+ "hltvTimeZone": "UTC"
31
+ }
32
+ self.MAX_DELAY = max_delay
33
+ self.timeout = timeout
34
+ self.max_retries = max_retries
35
+ self.USE_PROXY = use_proxy
36
+ self.PROXY_PATH = proxy_path
37
+ self.PROXY_LIST = proxy_list
38
+ self.PROXY_PROTOCOL = proxy_protocol
39
+ self.REMOVE_PROXY = remove_proxy
40
+ self.DEBUG = debug
41
+ self._configure_logging()
42
+ self.logger = logging.getLogger(__name__)
43
+
44
+ def _configure_logging(self):
45
+ level = logging.DEBUG if self.DEBUG else logging.INFO
46
+ logging.basicConfig(level=level, format="%(message)s")
47
+
48
+ def config(self, max_delay: int | None = None,
49
+ timeout: int | None = None,
50
+ use_proxy: bool | None = None,
51
+ proxy_file_path: str | None = None,
52
+ proxy_list: list | None = None,
53
+ debug: bool | None = None,
54
+ max_retries: int | None = None,
55
+ proxy_protocol: str | None = None,
56
+ remove_proxy: bool | None = None,
57
+ ):
58
+ if max_delay:
59
+ self.MAX_DELAY = max_delay
60
+ if timeout:
61
+ self.timeout = timeout
62
+ if use_proxy:
63
+ self.USE_PROXY = use_proxy
64
+ if proxy_file_path:
65
+ self.PROXY_PATH = proxy_file_path
66
+ if proxy_list:
67
+ self.PROXY_LIST = proxy_list
68
+ if max_retries:
69
+ self.max_retries = max_retries
70
+ if proxy_protocol:
71
+ self.PROXY_PROTOCOL = proxy_protocol
72
+ if remove_proxy:
73
+ self.REMOVE_PROXY = remove_proxy
74
+ if debug:
75
+ self.DEBUG = debug
76
+ self._configure_logging()
77
+
78
+ def get_proxy(self):
79
+ proxy = ''
80
+ if self.PROXY_PATH:
81
+ with open(self.PROXY_PATH, "r") as file:
82
+ new_proxy = file.readline().strip()
83
+ if new_proxy:
84
+ proxy = new_proxy
85
+
86
+ else:
87
+ proxy = self.PROXY_LIST[0]
88
+
89
+ if self.PROXY_PROTOCOL not in proxy and proxy != '':
90
+ proxy = self.PROXY_PROTOCOL + '://' + proxy
91
+
92
+ return proxy
93
+
94
+ async def switch_proxy(self, proxy):
95
+ if self.PROXY_PATH:
96
+ if self.PROXY_PROTOCOL:
97
+ proxy = proxy.replace(self.PROXY_PROTOCOL + '://', '')
98
+ with open(self.PROXY_PATH, "r+") as file:
99
+ proxies = file.readlines()
100
+ file.seek(0)
101
+ for line in proxies:
102
+ if line.strip() != proxy:
103
+ file.write(line)
104
+ if not self.REMOVE_PROXY:
105
+ file.write(proxy + "\n")
106
+ else:
107
+ self.PROXY_LIST = self.PROXY_LIST[1:] + [self.PROXY_LIST[0]]
108
+
109
+ def f(self, result):
110
+ return BeautifulSoup(result, "lxml")
111
+
112
+ async def cloudflare_check(self, result) -> bool:
113
+ page = self.f(result)
114
+ challenge_page = page.find(id="challenge-error-title")
115
+ if challenge_page is not None:
116
+ if "Enable JavaScript and cookies to continue" in challenge_page.get_text():
117
+ self.logger.debug("Got cloudflare challange page")
118
+ return True
119
+ return False
120
+
121
+ async def parse_error_handler(self, proxy, delay: int = 0) -> int:
122
+ if self.USE_PROXY:
123
+ self.logger.debug(f"Switching proxy {proxy}")
124
+ await self.switch_proxy(proxy)
125
+ self.logger.info(f"New proxy: {self.get_proxy()}")
126
+ else:
127
+ if delay < self.MAX_DELAY:
128
+ delay += 1
129
+ else:
130
+ self.logger.debug("Reached max delay limit, try to use proxy")
131
+ self.logger.info(f"Calling again, increasing delay to {delay}s")
132
+
133
+ return delay
134
+
135
+ async def parse(self, session, url, delay):
136
+ proxy = ''
137
+ # setup new proxy, cuz old one was switched
138
+ if self.USE_PROXY:
139
+ proxy = self.get_proxy()
140
+ else:
141
+ # delay, only for non proxy users. (default = 1-15s)
142
+ await asyncio.sleep(delay)
143
+ try:
144
+ async with session.get(url, headers=self.headers, proxy=proxy, timeout=self.timeout, ) as response:
145
+ self.logger.info(f"Fetching {url}, code: {response.status}")
146
+ if response.status == 403 or response.status == 404:
147
+ self.logger.debug("Got 403 forbitten")
148
+ return False, self.parse_error_handler(proxy, delay)
149
+
150
+ # checking for challenge page.
151
+ result = await response.text()
152
+ if await self.cloudflare_check(result):
153
+ return False, self.parse_error_handler(proxy, delay)
154
+
155
+ return True, result
156
+ except (ClientProxyConnectionError, ClientResponseError, ClientOSError,
157
+ ServerDisconnectedError, TimeoutError, ClientHttpProxyError) as e:
158
+ delay = await self.parse_error_handler(proxy, delay)
159
+ return False, delay
160
+
161
+ async def fetch(self, url, delay: int = 0):
162
+ async with ClientSession() as session:
163
+ status = False
164
+ try_ = 1
165
+ while not status and (try_ != self.max_retries):
166
+ self.logger.debug(f'Trying connect to {url}, try {try_}/{self.max_retries}')
167
+
168
+ # if status = True, result = page,
169
+ # if status = False result = delay (default=0)
170
+ status, result = await self.parse(session, url, delay)
171
+
172
+ if not status and result:
173
+ delay = result
174
+ try_ += 1
175
+
176
+ if status == True:
177
+ loop = get_running_loop()
178
+ parsed = await loop.run_in_executor(None, partial(self.f, result))
179
+ return parsed
180
+ else:
181
+ self.logger.error('Connection failed')
182
+ return None
183
+
184
+ def save_error(self, page):
185
+ with open("error.html", "w") as file:
186
+ file.write(page.prettify())
187
+
188
+ @staticmethod
189
+ def normalize_date(parts) -> str:
190
+ month_abbreviations = {
191
+ 'Jan': '1', 'Feb': '2', 'Mar': '3', 'Apr': '4',
192
+ 'May': '5', 'Jun': '6', 'Jul': '7', 'Aug': '8',
193
+ 'Sep': '9', 'Oct': '10', 'Nov': '11', 'Dec': '12'
194
+ }
195
+ month = month_abbreviations[parts[0]]
196
+ day = parts[1][:-2]
197
+ return day + '-' + month
198
+
199
+ async def get_live_matches(self):
200
+ """returns a list of all LIVE matches on HLTV along with the maps being played and the star ratings"""
201
+ r = await self.fetch("https://www.hltv.org/matches")
202
+ if not r:
203
+ return
204
+
205
+ live_matches = r.find("div", {'class', "liveMatchesContainer"})
206
+ if live_matches is None:
207
+ return []
208
+ else:
209
+ teams = [line.getText() for line in live_matches.find_all("div", {'class', "matchTeamName text-ellipsis"})]
210
+ matches = [(team1, team2) for team1, team2 in tuple(zip(teams, teams[1:]))[::2]]
211
+ liveMatchContainer = live_matches.find_all("div", {'class', "liveMatch-container"})
212
+ maps = [str(line.get('data-maps')).split(',') for line in liveMatchContainer]
213
+ stars = [line.get('stars') for line in liveMatchContainer]
214
+ return [{'teams': teams, 'maps': maps, 'stars': stars} for teams, maps, stars in zip(matches, maps, stars)]
215
+
216
+ async def get_upcoming_matches(self):
217
+ """returns a list of all upcoming matches on HLTV"""
218
+ r = await self.fetch("https://www.hltv.org/matches")
219
+ if not r:
220
+ return
221
+ try:
222
+ teams = [line.getText() for line in r.find("div",
223
+ {'class', "upcomingMatchesContainer"}).find_all(
224
+ class_=lambda v: v is not None and (v == "team text-ellipsis" or v == "matchTeamName text-ellipsis"))]
225
+ except AttributeError:
226
+ return None
227
+ return [(team1, team2) for team1, team2 in tuple(zip(teams, teams[1:]))[::2]]
228
+
229
+ async def get_important_upcoming_matches(self, star_rating=1):
230
+ """returns a list of all upcoming matches on HLTV with the star rating argument (should be between 0 and 5)"""
231
+ r = await self.fetch("https://www.hltv.org/matches")
232
+ if not r:
233
+ return
234
+
235
+ teams = [line.getText() for line in r.find("div",
236
+ {'class', "upcomingMatchesContainer"}).find_all(
237
+ class_=lambda v: v is not None and (v == "team text-ellipsis" or v == "matchTeamName text-ellipsis"))]
238
+ stars = [int(line.get('stars')) for line in r.find("div",
239
+ {'class', "upcomingMatchesContainer"}).find_all("div",
240
+ {"class",
241
+ "upcomingMatch "})
242
+ if line.get('team1') is not None]
243
+ matches = [(team1, team2) for team1, team2 in tuple(zip(teams, teams[1:]))[::2]]
244
+ # assert len(matches) == len(stars), "Internal Exception :: get_important_upcoming_matches() :: misMatches detected"
245
+ return [match for match, star in zip(matches, stars) if star == star_rating]
246
+
247
+ async def get_big_results(self, offset=0) -> list[dict[str, Any]] | None:
248
+ """returns a list of results from past 100 matches on HLTV starting from the offset param"""
249
+ r = await self.fetch("https://www.hltv.org/results?offset=" + str(offset))
250
+ if not r:
251
+ return
252
+
253
+ big_results = []
254
+ big_res = r.find("div", {'class', "big-results"}).find_all("div", {"class", "result-con"})
255
+ if not big_res:
256
+ return None
257
+ for res in big_res:
258
+ team1 = res.find("div", class_="team").text.strip()
259
+ team2 = res.find("div", class_="team team-won").text.strip()
260
+
261
+ scores = res.find("td", class_="result-score").text.strip().split('-')
262
+ s_t1 = scores[0].strip()
263
+ s_t2 = scores[1].strip()
264
+
265
+ big_results.append({
266
+ 'team1': team1,
267
+ 'team2': team2,
268
+ 'score1': s_t1,
269
+ 'score2': s_t2,
270
+ })
271
+
272
+ return big_results
273
+
274
+ async def get_event_results(self, event: int | str) -> list[dict[str, Any]] | None:
275
+ r = await self.fetch("https://www.hltv.org/results?event=" + str(event))
276
+ if not r:
277
+ return
278
+
279
+ match_results = []
280
+
281
+ for result in r.find("div", {'class', 'results-holder'}).find_all("div", {'class', 'results-sublist'}):
282
+ date = result.find("span", class_="standard-headline").text.strip()
283
+ matches = result.find_all("div", class_="result-con")
284
+
285
+ for match in matches:
286
+ teams = match.find_all("div", class_="team")
287
+ team1 = teams[0].text.strip()
288
+ team2 = teams[1].text.strip()
289
+
290
+ scores = match.find("td", class_="result-score").text.strip().split('-')
291
+ score_t1 = scores[0].strip()
292
+ score_t2 = scores[1].strip()
293
+
294
+ match_results.append({
295
+ 'date': date,
296
+ 'team1': team1,
297
+ 'team2': team2,
298
+ 'score1': score_t1,
299
+ 'score2': score_t2,
300
+ })
301
+
302
+ return match_results
303
+
304
+ async def get_event_matches(self, event_id: str | int):
305
+ r = await self.fetch("https://www.hltv.org/events/" + str(event_id) + "/matches")
306
+ if not r:
307
+ return
308
+
309
+ live_matches: List | Any
310
+ matches = []
311
+ try:
312
+ live_matches = r.find("div", {'class', 'liveMatchesSection'}).find_all("div", {'class', 'liveMatch'})
313
+ except AttributeError:
314
+ live_matches = []
315
+
316
+ for live in live_matches:
317
+ teams = live.find_all("div", class_="matchTeamName text-ellipsis")
318
+ team1 = teams[0].text.strip()
319
+ team2 = teams[1].text.strip()
320
+
321
+ # TODO FIX SCORES
322
+ try:
323
+ scores = live.find("td", class_="matchTeamScore").text.strip().split('-')
324
+ score_team1 = scores[0].strip()
325
+ score_team2 = scores[1].strip()
326
+ except AttributeError:
327
+ score_team1 = 0
328
+ score_team2 = 0
329
+
330
+ matches.append({
331
+ 'team1': team1,
332
+ 'team2': team2,
333
+ 'date': 'LIVE'
334
+ })
335
+
336
+ for date_sect in r.find_all('div', {'class': 'upcomingMatchesSection'}):
337
+ date_ = date_sect.find('span', {'class': 'matchDayHeadline'}).text.split(' ')[-1]
338
+ for match in date_sect.find_all('div', {'class': 'upcomingMatch'}):
339
+ teams = match.find_all("div", class_="matchTeamName text-ellipsis")
340
+ if teams or len(teams) > 1:
341
+
342
+ team1 = teams[0].text.strip()
343
+ team2 = teams[1].text.strip()
344
+
345
+ time_ = match.find('div', {'class', 'matchTime'}).text
346
+
347
+ matches.append({
348
+ 'team1': team1,
349
+ 'team2': team2,
350
+ 'date': date_ + " " + time_
351
+ })
352
+ else:
353
+ break
354
+
355
+ return matches
356
+
357
+ async def get_events(self, outgoing=True, future=True, max_events=10):
358
+ """Returns events
359
+ :params:
360
+ outgoing - include live tournaments
361
+ future - include future tournamets
362
+ max_events - use only if future=True
363
+ :return:
364
+ [('id', 'title', 'startdate', 'enddate')]
365
+ """
366
+
367
+ r = await self.fetch('https://www.hltv.org/events')
368
+ if not r:
369
+ return
370
+
371
+ events = []
372
+ if outgoing:
373
+ for event in r.find('div', {'class': 'tab-content', 'id': 'TODAY'}).find_all('a', {
374
+ 'class': 'a-reset ongoing-event'}):
375
+ event_name = event.find('div', {'class': 'text-ellipsis'}).text.strip()
376
+ event_start_date = self.normalize_date(
377
+ event.find('span', {'data-time-format': 'MMM do'}).text.strip().split())
378
+
379
+ event_end_date = self.normalize_date(
380
+ event.find_all('span', {'data-time-format': 'MMM do'})[1].text.strip().split())
381
+ event_id = event['href'].split('/')[-2]
382
+
383
+ events.append({
384
+ 'id': event_id,
385
+ 'name': event_name,
386
+ 'start_date': event_start_date,
387
+ 'end_date': event_end_date,
388
+ })
389
+
390
+ if future:
391
+ for i, big_event_div in enumerate(r.find_all('div', {'class': 'big-events'}, start=1)):
392
+ for event in big_event_div.find_all('a', {'class': 'a-reset standard-box big-event'}):
393
+
394
+ if i >= max_events:
395
+ break
396
+
397
+ event_id = event['href'].split('/')[-2]
398
+ event_name = event.find('div', {'class': 'big-event-name'}).text.strip()
399
+ # event_location = event.find('span', {'class': 'big-event-location'}).text.strip()
400
+ event_start_date = self.normalize_date(event.find('span', {'class': ''}).text.strip().split())
401
+ event_end_date = self.normalize_date(event.find('span', {'class': ''}).text.strip().split())
402
+
403
+ events.append({
404
+ 'id': event_id,
405
+ 'title': event_name,
406
+ 'start_date': event_start_date,
407
+ 'end_date': event_end_date
408
+ })
409
+
410
+ return events
411
+
412
+ async def get_event_info(self, event_id: str | int, event_title: str):
413
+ r = await self.fetch(f"https://www.hltv.org/events/{str(event_id)}/{event_title.replace(' ', '-')}")
414
+ if not r:
415
+ return
416
+
417
+ event_date_div = r.find('td', {'class', 'eventdate'}).find_all('span')
418
+
419
+ event_start = self.normalize_date(event_date_div[0].text.split())
420
+ event_end = self.normalize_date(event_date_div[1].text.split()[1:-1])
421
+
422
+ prize = r.find('td', {'class', 'prizepool text-ellipsis'}).text
423
+
424
+ team_num = r.find('td', {'class', 'teamsNumber'}).text
425
+
426
+ location = r.find('td', {'class', 'location gtSmartphone-only'}).get_text().replace('\n', '')
427
+
428
+ try:
429
+ group_div = r.find('div', {'class', 'groups-container'})
430
+ groups = []
431
+ for group in group_div.find_all('table', {'class': 'table standard-box'}):
432
+ group_name = group.find('td', {'class': 'table-header group-name'}).text
433
+ teams = []
434
+ for team in group.find_all('div', 'text-ellipsis'):
435
+ teams.append(team.find('a').text)
436
+ groups.append({group_name: teams})
437
+ except AttributeError:
438
+ groups = []
439
+
440
+ return event_id, event_title, event_start, event_end, prize, team_num, location, groups
441
+
442
+ async def get_top_teams(self, max_teams=30):
443
+ """
444
+ returns a list of the top 1-30 teams
445
+ :params:
446
+ max_teams: int = 30
447
+ :return:
448
+ [('rank','title','points', 'change', 'id')]
449
+ change - difference between last ranking update
450
+ """
451
+ today = date.today()
452
+ current_weekday = today.weekday()
453
+ last_monday = today - timedelta(days=current_weekday)
454
+
455
+ teams = []
456
+
457
+ r = await self.fetch("https://www.hltv.org/ranking/teams/" + last_monday.strftime('%Y/%B/%d').lower())
458
+
459
+ if not r:
460
+ return
461
+
462
+ try:
463
+ for i, team in enumerate(r.find_all("div", {'class': "ranked-team standard-box"}), start=1):
464
+ if i > max_teams:
465
+ break
466
+
467
+ rank = team.find('span', {'class': 'position'}).text[1:]
468
+ title_div: Any
469
+ if rank != '1':
470
+ title_div = team.find('div', {'class': 'teamLine sectionTeamPlayers'})
471
+ else:
472
+ title_div = team.find('div', {'class': 'teamLine sectionTeamPlayers teamLineExpanded'})
473
+
474
+ title = title_div.find('span', {'class': 'name'}).text
475
+ points = title_div.find('span', {'class': 'points'}).text.split(' ', 1)[0][1:]
476
+
477
+ id = team.find('a', {'class': 'details moreLink'})['href'].split('/')[-1]
478
+
479
+ changes = {'change positive', 'change neutral', 'change negative'}
480
+ change = ''
481
+ for change_ in changes:
482
+ try:
483
+ change = team.find('div', {'class', change_}).text
484
+ break
485
+ except AttributeError:
486
+ pass
487
+
488
+ teams.append({
489
+ 'rank': rank,
490
+ 'title': title,
491
+ 'points': points,
492
+ 'change': change,
493
+ 'id': id
494
+ })
495
+ except AttributeError:
496
+ raise AttributeError("Parsing error, probably page not fully loaded")
497
+
498
+ return teams
499
+
500
+ async def get_team_info(self, team_id: int | str, title: str) -> tuple | None:
501
+ """
502
+ Returns Information about team
503
+ :params:
504
+ team_id: int | str
505
+ title: str
506
+ :returns:
507
+ (team_id, title, rank, players, coach, age, weeks, last_trophy, total_trophys) | None
508
+ weeks - weeks in top 20
509
+ """
510
+ r = await self.fetch("https://www.hltv.org/team/" + str(team_id) + '/' + title.replace(' ', '-'))
511
+ players = []
512
+ try:
513
+ for player in r.find_all('span', {'class': 'text-ellipsis bold'}):
514
+ players.append(player.text)
515
+
516
+ rank = '0'
517
+ weeks = '0'
518
+ age = '0'
519
+ coach = ''
520
+
521
+ for i, stat in enumerate(r.find_all('div', {'class': 'profile-team-stat'}), start=1):
522
+ if i == 1:
523
+ rank = stat.find('a').text[1:]
524
+ elif i == 2:
525
+ weeks = stat.find('span', {'class': 'right'}).text
526
+ elif i == 3:
527
+ age = stat.find('span', {'class': 'right'}).text
528
+ elif i == 4:
529
+ coach = stat.find('span', {'class': 'bold a-default'}).text[1:-1]
530
+
531
+ last_trophy = None
532
+ total_trophys = None
533
+ try:
534
+ last_trophy = r.find('div', {'class': 'trophyHolder'}).find('span')['title']
535
+ total_trophys = len(r.find_all('div', {'class': 'trophyHolder'}))
536
+ except AttributeError:
537
+ pass
538
+
539
+ return (team_id, title, rank, players, coach, age, weeks, last_trophy, total_trophys)
540
+ except AttributeError:
541
+ raise AttributeError("Parsing error, probably page not fully loaded")
542
+
543
+ async def get_best_players(self, top=40):
544
+ """
545
+ returns a list of the top (1-40) players in top 20 at the year
546
+ :params:
547
+ top: int = 40
548
+ :returns:
549
+ ('rank', 'name', 'team', 'maps', 'rating')
550
+ maps - maps played
551
+ """
552
+ year = datetime.strftime(datetime.utcnow(), '%Y')
553
+ r = await self.fetch(
554
+ f"https://www.hltv.org/stats/players?startDate={year}-01-01&endDate={year}-12-31&rankingFilter=Top20")
555
+
556
+ if not r:
557
+ return
558
+
559
+ players = []
560
+ rank = 1
561
+ try:
562
+ for player in r.find('tbody').find_all('tr'):
563
+ name = player.find('td', {'class', 'playerCol'}).find('a').text
564
+ team = player.find('td', {'class', 'teamCol'})['data-sort']
565
+
566
+ maps = player.find('td', {'class', 'statsDetail'}).text
567
+
568
+ ratings = {'ratingCol ratingPositive', 'ratingCol ratingNeutral', 'ratingCol ratingNegative'}
569
+ rating = 'ERROR'
570
+ for rat in ratings:
571
+ try:
572
+ rating = player.find('td', {'class', rat}).text
573
+
574
+ break
575
+ except AttributeError:
576
+ pass
577
+
578
+ players.append({
579
+ 'rank': rank,
580
+ 'name': name,
581
+ 'team': team,
582
+ 'maps': maps,
583
+ 'rating': rating,
584
+ })
585
+ rank += 1
586
+ if rank > top:
587
+ break
588
+ except AttributeError:
589
+ raise AttributeError("Parsing error, probably page not fully loaded")
590
+
591
+ return players
592
+
593
+ # TODO WRITE
594
+ async def get_last_news(self):
595
+ return []
596
+
597
+
598
+ async def test():
599
+ hltv = Hltv()
600
+ print(await hltv.get_event_info(7148, 'pgl-cs2-major-copenhagen-2024'))
601
+
602
+
603
+ if __name__ == "__main__":
604
+ asyncio.run(test())
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2018 The Python Packaging Authority
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.