diff --git a/stressanapi/stressanapi.py b/stressanapi/stressanapi.py new file mode 100755 index 0000000..88e9431 --- /dev/null +++ b/stressanapi/stressanapi.py @@ -0,0 +1,1889 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# -*- coding: utf-8 -*- +"""StressAnAPI v1.0.1 - An API stress-test tool""" +""" + ____ _ _ _ ____ ___ + / ___|| |_ _ __ ___ ___ ___ / \ _ __ / \ | _ \_ _| + \___ \| __| '__/ _ \/ __/ __| / _ \ | '_ \ / _ \ | |_) | | + ___) | |_| | | __/\__ \__ \/ ___ \| | | |/ ___ \| __/| | + |____/ \__|_| \___||___/___/_/ \_\_| |_/_/ \_\_| |___| + + Author.: Ricardo Abuchaim - ricardoabuchaim@gmail.com + License: MIT + Github.: https://github.com/rabuchaim/StressAnAPI + Issues.: https://github.com/rabuchaim/StressAnAPI/issues + PyPI...: https://pypi.org/project/stressanapi/ ( pip install stressanapi ) + +""" +import sys, os, warnings +os.environ['PYTHONWARNINGS'] ="ignore" +os.environ['PYTHONDONTWRITEBYTECODE'] ="ignore" +warnings.simplefilter("ignore") +sys.dont_write_bytecode = True +sys.tracebacklimit = 0 + +__appname__ = 'StressAnAPI' +__version__ = '1.0.1' +__release__ = '13/July/2024' +__descr__ = 'A stress-test tool for API servers' +__url__ = 'https://github.com/rabuchaim/StressAnAPI/' + +import socket, struct, binascii, itertools, math, gc +import tty, termios, subprocess, ctypes, shlex, signal, shutil +import urllib, urllib.request, urllib.response, urllib.parse, bisect +import re, argparse, threading, time, json, random, textwrap, functools +from typing import List,Dict +from urllib.parse import urlparse +from collections import defaultdict, deque +from datetime import timedelta, datetime as dt + +current_filename = sys.argv[0] +if not current_filename.endswith(".py"): + current_filename = os.path.basename(current_filename) + +lock = threading.Lock() +rlock = threading.RLock() + +################################################################################################################################## +################################################################################################################################## + + ### # ## ### ## # # # ## ### ### + # # # # # # # # # # # # # # # # + # ## # # # ### # # # # # # # ### ## + # # # # # # # #### # ## #### # # # + ### #### ## ### # # #### ## # # # # ### + +class classGlobal: + def __init__(self): + self.start_time = time.monotonic() + + DEBUG = os.getenv('STRESSANAPI_DEBUG','') != "" + + argsvars = {} + config = None + thread_list = [] + lock = threading.Lock() + event_pause = threading.Event() + event_pause_time = None + event_quit = threading.Event() + event_debug = threading.Event() + garbage_collector_interval = 300 + + middot = '\xb7' + doubleLine = "═" + singleLine = "─" + singleQuote = "'" + doubleQuote = '"' + bold_up, bold_down, bold_right, bold_left = '▲', '▼', '►', '◄' + light_up, light_down, light_right, light_left = '↑', '↓', '→', '←' + bold_square, light_square, bold_square_small = '■', '□', '▪' + bold_circle, light_circle = '●', '○' + + date_format = '%Y/%m/%d %H:%M:%S.%f' + date_format_no_milisec = '%Y/%m/%d %H:%M:%S' + date_format_no_date = '%H:%M:%S.%f' + date_format_no_date_no_milisec = '%H:%M:%S' + date_format_no_time = '%Y/%m/%d' + date_format_no_datetime = '' + + allowed_methods = ['GET','POST','PUT','PATCH','DELETE'] + default_wait_time = 0.5 + default_burst = 1 + default_threads = 1 + default_timeout = 1 + default_success_status_codes = [200,201,202,204] + default_user_agent = f"{__appname__} v{__version__}" + default_template = { + "url": "http://localhost:8000/api/v1/foo_action", + "method": "|".join(allowed_methods), + "post_data": {"id": "my_customer_id", "name": "spécial_çhärs", "token": "mysupertoken"}, + "headers": { + "User-Agent": f"{__appname__} v{__version__}", + "Host": "set_your_api_hostname_here", + "Content-Type": "application/json", + "X-Forwarded-For": "1.2.3.4", + "X-Forwarded-Host": "1.2.3.4", + "X-Real-IP": "1.2.3.4", + }, + # "max_redirects": 0, # not yet implemented + "timeout": default_timeout, + "success_status_codes": default_success_status_codes, + "user_agent": default_user_agent, + "start_interval": default_wait_time, + "start_burst": default_burst, + "start_threads": default_threads, + "cpu_affinity": [-1], + } + +G = classGlobal() + +################################################################################################################################## +################################################################################################################################## + + #### # # ### #### ### ##### ### ## # # ### + # # # # # # # # # # # ## # # + ### ## # ### ### # # # # # ## ## + # # # # # # # # # # # # # + #### # # ### #### # # ### ## # # ### + +class StressAnAPIException(BaseException): + def __init__ (self, message): + self.message = cException(message) + super().__init__(self.message) + def __str__(self): + return self.message + def __repr__(self): + return self.message + +class StressAnAPIConfigException(StressAnAPIException):... + +################################################################################################################################## +################################################################################################################################## + + ### ## # # #### ### ### + # # # ## # # # # + # # # # ## ### # # ## + # # # # # # # # # + ### ## # # # ### ### + +class configFile: + def __init__(self,filename,config_dict,url,method,post_data,headers,timeout,success_status_codes, + interval,burst,threads,cpu_affinity,start_time): + self.config_file = filename + self.config_dict = config_dict + self.url = url + self.method = str(method).upper() + self.post_data = post_data + self.headers = headers + self.timeout = timeout + self.success_status_codes = success_status_codes + self.interval = interval + self.burst = burst + self.threads = threads + self.cpu_affinity = cpu_affinity + self.elapsed_load_time = '%.6f'%(time.monotonic() - start_time) + +def validateConfigFile(config_file): + try: + start_time = time.monotonic() + new_config_dict = {} + + try: + with open(config_file,'r') as f: + config_dict = json.load(f) + except Exception as ERR: + raise StressAnAPIException(f"The json file appears to be invalid - {str(ERR)}") from None + + url = str(config_dict.get('url','')) + method = str(config_dict.get('method','')) + post_data = config_dict.get('post_data',{}) + headers = config_dict.get('headers',{}) + timeout = config_dict.get('timeout',G.default_timeout) + interval = config_dict.get('start_interval',G.default_wait_time) + burst = config_dict.get('start_burst',G.default_burst) + success_status_codes = config_dict.get('success_status_codes',G.default_success_status_codes) + threads = config_dict.get('start_threads',G.default_threads) + cpu_affinity = config_dict.get('cpu_affinity',[]) + + ##──── Check URL and METHOD - required data + if (config_dict.get('url',None) is None) or (config_dict.get('url',None) == ""): + raise StressAnAPIConfigException(f'Missing "url" information in configuration file "{config_file}"') from None + elif not str(config_dict.get('url','')).startswith(('http://','https://')): + raise StressAnAPIConfigException(f'Malformed URL - must starts with http:// or https://') from None + elif not isValidURL(url): + raise StressAnAPIConfigException(f'Malformed URL - "{url}"') from None + elif config_dict.get('method',None) is None: + raise StressAnAPIConfigException(f'Missing "method" information in configuration file "{config_file}"') from None + elif method.upper() not in G.allowed_methods: + allowed_methods_joined = ", ".join(G.allowed_methods) + allowed_methods_result = allowed_methods_joined.rsplit(", ", 1) + allowed_methods_string = " or ".join(allowed_methods_result) + raise StressAnAPIConfigException(f'The allowed methods are {allowed_methods_string}, not "{method}"') from None + new_config_dict['url'] = url + new_config_dict['method'] = method.upper() + + ##──── check post data + if not isinstance(post_data,Dict): + raise StressAnAPIConfigException(f'Error in "post_data", must be a Dict not {str(type(post_data))}') from None + for key,val in post_data.items(): + if len(re.findall('random:', val)) + len(re.findall('file:', val)) + len(re.findall('filerand:', val)) > 1: + raise StressAnAPIConfigException(f'Error in post_data "{key}" - Only 1 "random:", "file:" or "filerand:" instruction per value') from None + new_config_dict['post_data'] = post_data + + ##──── check headers + if not isinstance(headers,Dict): + raise StressAnAPIConfigException(f'Error in "headers", must be a Dict not {str(type(headers))}') from None + for key,val in headers.items(): + if (key.lower() == 'content-type') and (val.lower() not in CONTENT_TYPES.values()): + raise StressAnAPIConfigException(f'Invalid content-type: {val}') from None + if len(re.findall('random:', val)) + len(re.findall('file:', val)) + len(re.findall('filerand:', val)) > 1: + raise StressAnAPIConfigException(f'Error in header "{key}" - Only 1 "random:", "file:" or "filerand:" instruction per value') from None + new_config_dict['headers'] = headers + + ##──── validate timeout + try: + timeout = float(G.default_timeout) if float(timeout) < 0.001 else float(timeout) + except: + raise StressAnAPIConfigException(f'Invalid "timeout" value - "{timeout}"') from None + new_config_dict['timeout'] = timeout + + ##──── validate success status code list + if not isinstance(success_status_codes,List): + raise StressAnAPIConfigException(f'Error in "success_status_codes", must be a List not {str(type(success_status_codes))}') from None + try: + success_status_codes = list(map(int,success_status_codes)) + except Exception as ERR: + raise StressAnAPIConfigException(f'Error in "success_status_codes", values must be an integer - {str(ERR)}') from None + new_config_dict['success_status_codes'] = success_status_codes + + ##──── validate interval + try: + interval = float(interval) + assert interval > 0 + except: + raise StressAnAPIConfigException(f'Invalid "interval" value, must be float and greater than 0 - "{interval}"') from None + new_config_dict['interval'] = interval + + ##──── validate burst + try: + burst = int(burst) + assert burst > 0 + except: + raise StressAnAPIConfigException(f'Invalid "burst" value, must be an integer and greater than 0 - "{burst}"') from None + new_config_dict['burst'] = burst + + ##──── validate threads + try: + threads = int(threads) + assert threads > 0 + except: + raise StressAnAPIConfigException(f'Invalid "threads" value, must be an integer and greater than 0 - "{threads}"') from None + new_config_dict['threads'] = threads + + + ##──── validate cpu affinity + if not isinstance(cpu_affinity,List): + raise StressAnAPIConfigException(f'Error in "cpu_affinity", must be a List not {str(type(cpu_affinity))}') from None + try: + try: + cpu_affinity = [int(item) for item in cpu_affinity] + except: + raise StressAnAPIConfigException(f'Error in "cpu_affinity", cpu numbers must be an integer from 0 to {os.cpu_count()-1}') from None + if len(cpu_affinity) == 1 and cpu_affinity[0] == -1: + cpu_affinity = [os.cpu_count()-1] + else: + cpu_affinity_fail = [item for item in cpu_affinity if item > (os.cpu_count()-1)] + if len(cpu_affinity_fail) > 0: + raise StressAnAPIConfigException(f'Error in "cpu_affinity", invalid cpu number "{cpu_affinity_fail}" - valid cpu number are from 0 to {os.cpu_count()-1}') from None + if cpu_affinity != []: + setCPUAffinity(os.getpid(),cpu_affinity) + except Exception as ERR: + logDebug(f"validateConfigFile: {str(ERR)}") + raise StressAnAPIConfigException(f'{str(ERR)}') from None + new_config_dict['cpu_affinity'] = cpu_affinity + + ##──── set the values to the G.config global variable + G.config = configFile(os.path.realpath(config_file),new_config_dict,url,method,post_data,headers,timeout,success_status_codes, + interval,burst,threads,cpu_affinity,start_time) + except Exception as ERR: + logDebug(f"failed at validateConfigFile: {str(ERR)}") + return False + return True + +def displayConfig(): + logDebug(f"configuration: {json.dumps(G.config.config_dict,separators=(', ',':'))}") + log(f" - Request...: {cWhite(G.config.method)} at {cWhite(G.config.url)}") + + if G.config.method in ["GET","DELETE","PATCH"]: + if G.config.post_data != {}: + log(f' - Post Data.: {sFaint(f"")}') + else: + if G.config.post_data != {}: + lines_post_data = f" - Post Data.: {cWhite(json.dumps(G.config.post_data,sort_keys=False,ensure_ascii=False,separators=(', ',':'))[1:-1])}" + [log(cWhite(line)) if index > 0 else log(line) for index, line in + enumerate(textwrap.wrap(lines_post_data, classTerminal().max_width, break_long_words=True, break_on_hyphens=False, + subsequent_indent=' ' * 16))] + + if G.config.headers == {}: + log(f" - Headers...: {sFaint('')}") + else: + lines_headers = f" - Headers...: {cWhite(json.dumps(G.config.headers,sort_keys=False,separators=(', ',':'))[1:-1])}" + [log(cWhite(line)) if index > 0 else log(line) for index, line in + enumerate(textwrap.wrap(lines_headers, classTerminal().max_width, break_long_words=True, break_on_hyphens=False, + subsequent_indent=' ' * 16))] + + cpu_affinity = f'\t- CPU Affinity: {cWhite(str(G.config.cpu_affinity)[1:-1])}' if G.config.cpu_affinity != [] else '' + log(f" - Timeout...: {cWhite('%.6f'%(G.config.timeout))} {getPluralString(G.config.timeout,'second','seconds',show_value_string=False)}" + f"\t - Success Status Codes: {cWhite(','.join(map(str,G.config.success_status_codes)))}{cpu_affinity}") + + tab = "\t" if G.config.threads < 10 else "" + number_of_threads = G.config.threads if len(G.thread_list) == 0 else len(G.thread_list) + log(f" - Concurrent Threads: {cWhite(number_of_threads)} {tab}\t - Burst: {cWhite(G.config.burst)} \t\t- Interval between requests: {cWhite('%.6f'%(G.config.interval))} {getPluralString(G.config.interval,'second','seconds',show_value_string=False)}") + +################################################################################################################################## +################################################################################################################################## + + ### ##### ## ##### # # ### ### ## ### #### ### + # # # # # # # # # # # # # # # + ## # # # # # # ## # # # # # ### ## + # # #### # # # # # # # # # # # + ### # # # # ## ### ### ## ### #### ### + +HTTP_STATUS_CODES = {"100":"Continue","101":"Switching Protocols", + "200":"OK","201":"Created","202":"Accepted","203":"Non-Authoritative Information","204":"No Content", + "205":"Reset Content","206":"Partial Content", + "300":"Multiple Choices","301":"Moved Permanently","302":"Found","303":"See Other","304":"Not Modified", + "305":"Use Proxy","307":"Temporary Redirect", + ##──── Client Side Errors + "400":"Bad Request","401":"Unauthorized","402":"Payment Required","403":"Forbidden","404":"Not Found", + "405":"Method Not Allowed","406":"Not Acceptable","407":"Proxy Authentication Required","408":"Request Timeout", + "409":"Conflict","410":"Gone","411":"Length Required","412":"Precondition Failed","413":"Request Entity Too Large", + "414":"Request-URI Too Long","415":"Unsupported Media Type","416":"Requested Range Not Satisfiable", + "417":"Expectation Failed","426":"Upgrade Required","428":"Precondition Required","429":"Too Many Requests", + "431":"Request Header Fields Too Large", + ##──── Server Side Errors + "500":"Internal Server Error","501":"Not Implemented","502":"Bad Gateway","503":"Service Unavailable", + "504":"Gateway Timeout","505":"HTTP Version Not Supported","511":"Network Authentication Required", + ##──── Connection Errors (created internally just to handle connection failures to an api server) + "900":"Connection Refused", + "901":"Connection Timeout","902":"Connection reset by peer", + "903":"Remote end closed connection without response", + "904":"Exceeded maximum redirects", + "32":"Broken Pipe" + } + + ### ## # # ##### #### # # ##### ##### # # ### #### ### + # # # ## # # # ## # # # # # # # # # + # # # # ## # ### # ## # #### # # ### ### ## + # # # # # # # # # # # # # # # + ### ## # # # #### # # # # # # #### ### + +CONTENT_TYPES = {"javascript":"application/javascript", "json":"application/json", "zip":"application/zip", + "ogg":"application/ogg", "pdf":"application/pdf", "mpeg":"audio/mpeg", "wav":"audio/x-wav", + "gif":"image/gif", "jpeg":"image/jpeg", "png":"image/png", "form_data":"multipart/form-data", + "css":"text/css", "csv":"text/csv", "html":"text/html", "plain":"text/plain", "xml":"text/xml", + "video_mpeg":"video/mpeg", "mp4":"video/mp4", "quicktime":"video/quicktime", "webm":"video/webm"} + +################################################################################################################################## +################################################################################################################################## + + ### ##### ## ##### ### ### ##### ### ### ### + # # # # # # # # # # # + ## # # # # # ## # # # ## + # # #### # # # # # # # + ### # # # # ### ### # ### ### ### + +##──── class to store statistics about the elapsed time of the requests +class classTimeStats: + def __init__(self, window_size=10000): + self.window_size = window_size + self.__times = deque(maxlen=window_size) + self.__sorted_times = [] + + def save(self, time_in_seconds: float): + if len(self.__times) == self.window_size: + old_time = self.__times.popleft() + self.__sorted_times.pop(bisect.bisect_left(self.__sorted_times, old_time)) + + self.__times.append(time_in_seconds) + bisect.insort(self.__sorted_times, time_in_seconds) + + def reset(self): + self.__times.clear() + self.__sorted_times.clear() + + @property + def min_time(self): + return min(self.__times) if self.__times else None + @property + def max_time(self): + return max(self.__times) if self.__times else None + @property + def avg_time(self): + if not self.__times: + return None + return sum(self.__times) / len(self.__times) + + def percentile(self, percentile): + if not self.__times: + return None + k = (len(self.__sorted_times) - 1) * percentile / 100 + f = int(k) + c = f + 1 + if f == c or c >= len(self.__sorted_times): + return self.__sorted_times[f] + return self.__sorted_times[f] * (c - k) + self.__sorted_times[c] * (k - f) + + def stats(self): + return { + "min": '%.6f'%self.min_time, "avg": '%.6f'%self.avg_time, "max": '%.6f'%self.max_time, + "50pct": '%.6f'%self.percentile(50), "75pct": '%.6f'%self.percentile(75), + "90pct": '%.6f'%self.percentile(90), "99pct": '%.6f'%self.percentile(99) + } + +##──── class to store statistics about the returned status codes +class classHttpStats: + def __init__(self): + self._lock = threading.Lock() + self.default_error_codes = [200, 201, 202, 204, 400, 401, 403, 404, 405, 429, 500, 501, 502, 503, 504] + self._dict = defaultdict(int) + self.reset() + + def __repr__(self) -> Dict: + return str(dict(sorted({f"{k} {self.descr(k)}": v for k, v in self._dict.items()}.items(), key=lambda x: x[0]))) + + def save(self, status_code): + # with self._lock: + self._dict[status_code] = self._dict.get(status_code, 0) + 1 + + def reset(self): + with self._lock: + self._dict.clear() + self._dict = {item: 0 for item in self.default_error_codes} + return True + + def descr(self, error_code: int): + try: + if error_code in self._dict.keys(): + return HTTP_STATUS_CODES[str(error_code)] + except: + raise StressAnAPIException(f"'{type(self).__name__}' object has no attribute '{error_code}'") + + @property + def asdict(self) -> Dict: + return self._dict + @property + def asdict_desc(self) -> Dict: + return dict(sorted({f"{k} {self.descr(k)}": v for k, v in self._dict.items()}.items(), key=lambda x: x[0])) + @property + def values(self) -> Dict: + return {k: v for k, v in self._dict.items() if v > 0} + +################################################################################################################################## +################################################################################################################################## + + ## ### ### ### ### ### # # ## ### ##### + # # # # # # # # # # # # # # + # # ## # # # # #### # # ### # + #### # # # # # # # #### # # # + # # ### ### ### ### ### # # # # # # # + +##──── A modified version of https://pypi.org/project/chart/ +class statChart: + + class scaler: + def __init__(self, out_range=(0, 100), floor=None, round=True): + self.out_range = out_range + self.floor = floor + self.round = round + def scale(self,x, o=(0, 100), i=(0, 1)): + return (x - i[0]) / (i[1] - i[0]) * (o[1] - o[0]) + o[0] + def fit(self, y): + if not self.floor and self.floor != 0: + min_ = min(y) + else: + min_ = self.floor + max_ = max(y) + self.in_range_ = (min_, max_) + return self + def transform(self, y): + y = [self.scale(yi, self.out_range, self.in_range_) for yi in y] + if self.round: + y = [int(round(yi)) for yi in y] + return y + def fit_transform(self, y): + self.fit(y) + return self.transform(y) + + def __init__(self): + pass + + def __create_label(self,label, label_width): + '''Add right padding to a text label''' + label = label[:label_width] + label = label.rjust(label_width) + label += ': ' + return label + + def __build_row(self,value, label, width, mark): + '''Build a complete row of data''' + marks = value * mark + row = marks.ljust(width) + row = label + row + return row + + def __format_number(self,number): + return '{:,d}'.format(number).replace(',','.') + + def __percent(self,total,amount)->str: + return ('%.2f%%'%((amount * 100) / total)).rjust(8) + + def bar(self,x, y, width=30, label_width=None, mark='◼', auto_sort=True, sort_reverse=True, with_percent=False, with_value=False, print_chart=False): + total = sum(x) if len(x) > 0 else 0 + + if total == 0: return '' # to avoid division by zero error + + if auto_sort: + a_dict = dict(sorted(dict(zip(y,x)).items(),key=lambda x:int(x[1]), reverse=sort_reverse)) + x, y = list(a_dict.values()), list(a_dict.keys()) + + if not label_width: + label_width = max([len(l) for l in y]) + + labels = [self.__create_label(l, label_width) for l in y] + values = self.scaler((0, width), 0).fit_transform(x) + string_chart = '' + + for value, label, amount in zip(values,labels,x): + string_row = self.__build_row(value, label, width, mark) + string_chart += string_row + if with_percent: + string_chart += self.__percent(total,amount) + if with_value: + string_chart += f" ({amount})" + string_chart += '\n' + + if print_chart: + print(string_chart) + + return string_chart + +################################################################################################################################## +################################################################################################################################## + + ## ### ### ### ### ##### ## ### # #### + # # # # # # # # # # # # # + # # ## # # # # # # ### # ### + #### # # # # # #### # # # # + # # ### ### ### ### # # # ### #### #### + +##──── A modified version of https://pypi.org/project/print-table/ +class Table: + def __init__(self, cols: int = 0, max_size=None, with_border=True, border_size=1): + self.table = [] + self.rows = [] + self.cols = cols + self.max_size = max_size + self.with_border = with_border + self.border_size = border_size + self.line_h = "-" if with_border else " " + self.line_v = "|" if with_border else " " + self.cross = "+" if with_border else " " + self.colors = {'ENDC': '\033[0m', 'BOLD': '\033[1m'} + + def head(self, headers: list): + self.rows.insert(0, headers) + return self + + def row(self, row: list): + self.rows.append(row) + return self + + def get_terminal_size(self): + return shutil.get_terminal_size((80, 80)).columns if self.max_size is None else self.max_size + + def get_responsive_line(self, msg='', padding='-', space_above=True): + processed = msg.replace(self.colors['BOLD'], '').replace(self.colors['ENDC'], '') + terminal_size = self.get_terminal_size() + diff = max(0, (terminal_size - len(processed))) // 2 + if space_above: + self.table.append("") + self.table.append(padding * diff + msg + padding * diff) + + def get_colored_text(self, msg, color): + return f"{color}{msg}{self.colors['ENDC']}" + + def print_table(self): + for line in self.get_table(): + print(line) + + def get_table(self): + if self.cols and self.rows: + self._adjust_rows() + terminal_size = self.get_terminal_size() + per_col = (terminal_size - self.cols - 1) // self.cols + pattern = self.line_h * (per_col * self.cols + self.cols + 1) + self.get_responsive_line(pattern, padding=' ', space_above=False) + for i, row in enumerate(self.rows): + row = [self._adjust_cols(per_col, str(r)) for r in row] + if i == 0: + row = [self.get_colored_text(r, self.colors['BOLD']) for r in row] + self.get_responsive_line(self.line_v + self.line_v.join(row) + self.line_v, padding=' ', space_above=False) + if i != len(self.rows) - 1: + self._print_mid_line(terminal_size) + self.get_responsive_line(pattern, padding=' ', space_above=False) + if not self.with_border and self.border_size == 0: + self.table = [line for line in self.table if line.strip()] + return self.table + + def _adjust_rows(self): + for row in self.rows: + row.extend([' '] * (self.cols - len(row))) + del row[self.cols:] + + def _adjust_cols(self, per_col, col): + dif = per_col - len(col) + pad = ' ' * (dif // 2) + return f"{pad}{col}{pad if dif % 2 == 0 else pad + ' '}" + + def _print_mid_line(self, t_size): + per_col = (t_size - self.cols - 1) // self.cols + pattern = self.line_h * (per_col * self.cols + self.cols + 1) + mid_line = ''.join( + self.cross if i % (per_col + 1) == 0 else self.line_v if i == 0 or i == len(pattern) - 1 else self.line_h + for i in range(len(pattern)) + ) + self.get_responsive_line(mid_line, padding=' ', space_above=False) + +################################################################################################################################## +################################################################################################################################## + +##### ### # # #### ### ### + # # #### # # # # + # # #### ### ### ## + # # # # # # # # + # ### # # #### # # ### + +##──── with elapsedTimer() as elapsed: ─────────────────────────────────────────────────────────────────────────────────────────── +class elapsedTimer: + def __enter__(self): + self.start = time.monotonic() + self.start = time.monotonic() + self.time = None + return self + + def __exit__(self, type, value, traceback): + self.time = time.monotonic() - self.start + + def text(self,decimal_places:int=6,end_text:str="",with_brackets=True): + if self.time is None: + self.time = time.monotonic() - self.start + timer_string = f"[{f'%.{decimal_places}f'%(self.time)}{end_text}]" + self.time = None + return timer_string if with_brackets else timer_string[1:-1] + +##──── Decorator to get the elapsed time of a function ─────────────────────────────────────────────────────────────────────────── +def showElapsedTimeDecorator(method): + def decorated_method(*args, **kwargs): + try: + startTime = time.monotonic() + result = method(*args, **kwargs) + print('\033[33m[ELAPSED_TIME] [%.9f sec] '%(time.monotonic()-startTime)+str(method)+"\033[0m") + return result + except Exception as ERR: + print('\033[91m'+f"[ELAPSED_TIME] showElapsedTime {method} {str(ERR)}"+'\033[0m') + return decorated_method + +##──── a decorator to show the minimum_time, maximum_time and average_time time between requests ───────────────────────────────── +def showElapsedTimeAverageDecorator(window_size=1000): + history, max_history = [], [] + min_avg, max_avg = 1000000000, 0.0 + min_time, max_time = min_avg, max_avg + + def decorator(method): + nonlocal history, max_history, min_avg, max_avg, min_time, max_time + + def decorated_method(*args, **kwargs): + nonlocal history, max_history, min_avg, max_avg, min_time, max_time + startTime = time.monotonic() + try: + result = method(*args, **kwargs) + elapsedTime = time.monotonic()-startTime + min_time = elapsedTime if elapsedTime < min_time else min_time + max_time = elapsedTime if elapsedTime > max_time else max_time + history.append(elapsedTime) + if len(history) >= window_size: + averageTime = sum(history) / len(history) + history.clear() + log(f'\033[32;1mElapsed Time for {method.__name__}() - {window_size} calls Min/Avg/Max: {"%.9f"%(min_time)} / \033[32;4;1m{"%.9f"%(averageTime)}\033[0m\033[32;1m / {"%.9f"%(max_time)}\033[0m') + return result + except Exception as ERR: + print('\033[91m'+f"[AVERAGE_ELAPSED_TIME_EXCEPTION] {method.__name__}(): {str(ERR)}"+'\033[0m') + + return decorated_method + + return decorator + +################################################################################################################################## +################################################################################################################################## + + ## ##### ## # # ### ### ### ## # # # # ##### #### ### + # # # # # #### # # # # # # # ## # # # # # + # # # # # #### # # # # # # # # ## # ### ### + #### # # # # # # # # # # # # # # # # # # + # # # ## # # ### ### ### ## ## # # # #### # # + +##──── AN ELEGANT, FAST AND THREAD SAFE COUNTER +class AtomicCounter: + def __init__(self,start_number:int=0): + self.start_number = start_number + self._counter = itertools.count(self.start_number) + self._counter_access = itertools.count() + def incr(self): # Increment the counter +1 + return next(self._counter) + def reset(self): + self._counter = itertools.count(self.start_number) + self._counter_access = itertools.count() + @property + def value(self): # returns the current value of the counter + return next(self._counter) - next(self._counter_access) + +##──── AN ELEGANT, FAST AND THREAD SAFE COUNTER WITH AVERAGE +class AtomicAverageCounter: + def __init__(self, max_window_size:int=50000): + self.__max_window_size = max_window_size + self.__lock = threading.RLock() + self.__counter = itertools.count(0) + self.__counter_access = itertools.count(0) + self.__time_data = [] + self.__last_time = None + + ##──── Every 60 seconds this function truncates the list that stores the request times to avoid high memory consumption. ──── + def __thread_cut_list_window_size(self): + while True: + time.sleep(60) + with self.__lock: + ##──── Keep the last '__max_window_size' records and discard the rest + # logDebug(f"Current size..: {len(self.__time_data)} - First item: {self.__time_data[0]} - Last Item: {self.__time_data[-1]}") + self.__time_data = self.__time_data[-self.__max_window_size:] + # logDebug(f"After list cut: {len(self.__time_data)} - First item: {self.__time_data[0]} - Last Item: {self.__time_data[-1]}") + + def start(self): + self.__last_time = time.monotonic() + self.mark = self.__mark + self.get_average = self.__get_average + self.reset = self.__reset + threading.Thread(target=self.__thread_cut_list_window_size,daemon=True).start() + return True + + def mark(self):... + def __mark(self): + with self.__lock: + now = time.monotonic() + delta = now - self.__last_time + self.__last_time = now + self.__time_data.append(delta) + next(self.__counter) + + def get_average(self)->list:return [0.0, 0.0] + def __get_average(self)->list: + try: + value_counter, value_timesum = self.reset() + secs_per_time = value_timesum / value_counter + times_per_sec = value_counter / value_timesum + except Exception as ERR: + times_per_sec, secs_per_time = 0.0,0.0 + finally: + return [times_per_sec, secs_per_time] + + def reset_counter(self): + self.__counter = itertools.count(0) + self.__counter_access = itertools.count(0) + return True + + def reset(self)->list:return [0, 0.0] + def __reset(self)->list: + try: + value_counter, value_timesum = 0,0.0 + with self.__lock: + value_counter = len(self.__time_data) + value_timesum = math.fsum(self.__time_data) + self.__time_data.clear() + self.__counter = itertools.count(0) + self.__counter_access = itertools.count(0) + self.__last_time = time.monotonic() + except Exception as ERR: + value_counter, value_timesum = 0,0.0 + finally: + return [value_counter,value_timesum] + + @property + def value(self): + return next(self.__counter) - next(self.__counter_access) + @property + def value_timesum(self): + try: + with self.__lock: + value_sum = math.fsum(self.__time_data) + except Exception as ERR: + value_sum = 0.0 + finally: + return value_sum + +################################################################################################################################## +################################################################################################################################## + + ### ## ### ### ## ### #### ### ## # # #### ### ##### ## ### + # # # # # # # # # # # # # # # # # # # # # # # + # ## # # ### ### # # # ## ### # # # # # ### # # # # ### + # # #### # # # # #### # # # # # # # # # # # # # # # + ### # # # # ### # # ### #### ### ## #### #### #### ### # ## # # + +##──── Thread Function to run the garbage collector process to remove non-used data from memory ────────────────────────────────── +def threadGarbageCollector(): + logDebug(f"Starting Garbage Collector with an interval of {G.garbage_collector_interval} seconds") + gc.enable = True + while True: + time.sleep(G.garbage_collector_interval) + try: + start_time = time.monotonic() + count1 = gc.get_count() + collect1 = gc.collect(0) + collect2 = gc.collect(1) + collect3 = gc.collect(2) + count2 = gc.get_count() + if count1 != count2: + logDebug(f"[GARBAGECOLLECTOR] {count1} >> GC0:{collect1}, GC1:{collect2}, GC2:{collect3} >> {count2} " + f">> Memory Usage: {'%.03f'%(memoryInfo.rss())} MiB [{'%.6f'%(time.monotonic()-start_time)}]") + except Exception as ERR: + logDebug(f"An error ocurred in garbage collector: {str(ERR)}") + continue + +################################################################################################################################## +################################################################################################################################## + + ### ### #### # # # # ### ##### ### ## # # ### + # # # # # # ## # # # # # # ## # # + # ### ### # # # ## # # # # # # ## ## + # # # # # # # # # # # # # # # + ### # # ## # # ### # ### ## # # ### + +def int2ipv4(iplong): + """Convert an integer to IPv4""" + return socket.inet_ntoa(struct.pack('>L', iplong)) + +def int2ipv6(iplong): + """Convert an integer to IPv6""" + return socket.inet_ntop(socket.AF_INET6, binascii.unhexlify(hex(iplong)[2:].zfill(32))) + +def isValidIPv4(ipv4_address): + """Try to convert the given ipv4_address to integer, if it fails, is invalid ;-)""" + try: + struct.unpack('>L', socket.inet_aton(ipv4_address))[0] + return True + except: + return False + +def isValidIPv6(ipv6_address): + """Try to convert the given ipv6_address to integer, if it fails, is invalid ;-)""" + try: + int.from_bytes(socket.inet_pton(socket.AF_INET6, ipv6_address), byteorder='big') + return True + except: + return False + +def getRandomPrivateIPv4(num_ips=1): + """Generate an IP address from networks 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 + + If only 1 IP is requested, returns a string, otherwise returns a list. + + If fails for some reason, raise an error. + """ + return_list = [] + try: + while (len(return_list) < num_ips): + return_list.append(int2ipv4(random.choice([random.randint(167772160,184549375),random.randint(3232235520,3232301055),random.randint(2886729728,2887778303)]))) + except Exception as ERR: + raise Exception(ERR) + return return_list[0] if len(return_list) == 1 else return_list + +def getRandomIPv4(num_ips=1): + """Generate an IPv4 address from networks 1.0.0.0 until 223.255.255.255 + + If only 1 IP is requested, returns a string, otherwise returns a list. + + If fails for some reason, raise an error. + """ + return_list = [] + try: + while (len(return_list) < num_ips): + return_list.append(int2ipv4(random.randint(16777216,3758096383))) + except Exception as ERR: + raise Exception(ERR) + return return_list[0] if len(return_list) == 1 else return_list + +def getRandomIPv42(): # faster + return int2ipv4(random.randint(16777216,3758096383)) # from 1.0.0.0 to 223.255.255.255 + +def generatorRandomIPv4(): + while True: + yield int2ipv4(random.randint(16777216,3758096383)) # from 1.0.0.0 to 223.255.255.255 + +################################################################################################################################## +################################################################################################################################## + +##### #### ### # # ### # # ## # + # # # # #### # ## # # # # + # ### ### #### # # ## # # # + # # # # # # # # # #### # + # #### # # # # ### # # # # #### + +class cursor: + @staticmethod + def hide(): + sys.stdout.write("\033[?25l") + sys.stdout.flush() + @staticmethod + def show(): + sys.stdout.write("\033[?25h") + sys.stdout.flush() + +##──── Get the terminal width +class classTerminal: + def __init__(self): + try: + self.max_width = os.get_terminal_size().columns - 3 + except: + self.max_width = 135 + self.max_width = 135 if self.max_width > 135 else self.max_width + if ('--notime' in sys.argv and '--nodate' in sys.argv) or ('--nodatetime' in sys.argv): + self.width = self.max_width + 3 + elif '--nodate' in sys.argv: + self.width = self.max_width - len(G.date_format_no_date) + else: + self.width = self.max_width - len(G.date_format) - 1 + + +##──── Returns a line filled with a char ───────────────────────────────────────────────────────────────────────────────────────── +class line: + def max_width()->int: + return classTerminal().width + def spacedChar(char:str=G.middot,spaces:int=1): + x = '' + while len(x) <= classTerminal().width: + x += char+(' '*spaces) + return x[:classTerminal().width] + middot = G.middot * max_width() # ············································································· + middot1s = spacedChar(spaces=1) # · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · + middot2s = spacedChar(spaces=2) # · · · · · · · · · · · · · · · · · · · · · · · · · · + middot3s = spacedChar(spaces=3) # · · · · · · · · · · · · · · · · · · · · + single = G.singleLine * max_width() # ───────────────────────────────────────────────────────────────────────────── + double = G.doubleLine * max_width() # ═════════════════════════════════════════════════════════════════════════════ + dot = "." * max_width() # ............................................................................. + dash = "-" * max_width() # ----------------------------------------------------------------------------- + equal = "=" * max_width() # ============================================================================= + underline = "_" * max_width() # _____________________________________________________________________________ + greaterthan = ">" * max_width() # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + lowerthan = "<" * max_width() # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +################################################################################################################################## +################################################################################################################################## + + ## # # ### ### ### ## # ## ### ### + # # ## # # # # # # # # # # # # + # # # ## ## # # # # # # # ### ## + #### # # # # # # # # # # # # # + # # # # ### ### ### ## #### ## # # ### + +##──── COLOR FUNCTION NAMES ────────────────────────────────────────────────────────────────────────────────────────────────────── +def cDate(msg): return sFaint(cSilver(str(msg))); +def _cDateRaw(msg): return str(msg) +def cException(msg): return cTomato(str(msg)) +def cError(msg): return cRed(str(msg)) +def cDebug(msg): return cLime(str(msg)) +def cWarning(msg): return cYellow(str(msg)) +##──── COLOR NAMES ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── +def cSilver(msg): return '\033[38;2;192;192;192m'+str(msg)+'\033[0m' +def cTomato(msg): return '\033[38;2;255;99;71m'+str(msg)+'\033[0m' +def cRed(msg): return '\033[91;1m'+str(msg)+'\033[0m' +def cCyan(msg): return '\033[36;1m'+str(msg)+'\033[0m' +def cLime(msg): return '\033[38;2;0;255;0m'+str(msg)+'\033[0m' +def cYellow(msg): return '\033[93;1m'+str(msg)+'\033[0m' +def cDarkYellow(msg): return '\033[33m'+str(msg)+'\033[0m' +# def cGreen(msg): return '\033[32m'+str(msg)+'\033[0m' +def cGreen(msg): return '\033[38;2;0;250;154m'+str(msg)+'\033[0m' +def cSnow(msg): return '\033[38;2;245;245;245;1m'+str(msg)+'\033[0m' +def cWhite(msg): return cSnow(str(msg)) +def cDimGrey(msg): return '\033[38;2;105;105;105m'+str(msg)+'\033[0m' +def cGrey(msg): return '\033[38;2;128;128;128m'+str(msg)+'\033[0m' +def cBlue(msg): return '\033[38;2;135;206;235m'+str(msg)+'\033[0m' +##──── ANSI STYLES ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── +def sBold(msg): return '\033[1m'+str(msg)+'\033[0m' +def sFaint(msg): return '\033[2m'+str(msg)+'\033[0m' +def sItalic(msg): return '\033[3m'+str(msg)+'\033[0m' +def sUnderline(msg): return '\033[4m'+str(msg)+'\033[0m' +def sNegative(msg): return '\033[7m'+str(msg)+'\033[0m' +def sStrikeout(msg): return '\033[9m'+str(msg)+'\033[0m' +def sReset(msg): return '\033[0m'+str(msg) +##──── REMOVE ANSI COLOR CODES (USED BEFORE SEND TO SYSLOG SERVER) ─────────────────────────────────────────────────────────────── +def _stripColorEmpty(colored_string):return colored_string +def _stripColor(colored_string):return colored_string +def stripColor(colored_string): + return re.sub('\x1b\\[(K|.*?m)', '', colored_string) +##──── RETURNS THE REAL LENGTH OF A COLORED STRING ─────────────────────────────────────────────────────────────────────────────── +def ansiLen(s): + return len(stripColor(s)) + +################################################################################################################################## +################################################################################################################################## + + #### # # # # ### ##### ### ## # # ### + # # # ## # # # # # # ## # # + ### # # # ## # # # # # # ## ## + # # # # # # # # # # # # # + # ## # # ### # ### ## # # ### + +##──── To help me with debug ───────────────────────────────────────────────────────────────────────────────────────────────────── +def HERE(textString=""): + print(cRed("HERE ")+cDebug(str(textString))) +def here(textString=""): + print(cRed("here ")+cDebug(str(textString))) + +##──── Pretty print a dict as json ─────────────────────────────────────────────────────────────────────────────────────────────── +def ppJson(a_dict,indent:int=3,compact:bool=True,print_dump=True): + if compact: + text = json.dumps(a_dict,indent=indent,sort_keys=False,ensure_ascii=False,default=jsonSerial,separators=(',',':')) + else: + text = json.dumps(a_dict,indent=indent,sort_keys=False,ensure_ascii=False,default=jsonSerial) + if print_dump: + print(text) + return text +##──── Pretty print a dict as colored json ─────────────────────────────────────────────────────────────────────────────────────── +def ppColorJson(a_dict,indent:int=3,compact:bool=True,print_dump=True): + def colorize_json(json_str): + def colorize(match): + key, _, string, _, number, _, _, boolean, null, bracket, comma, colon = match.groups() + if key: + return '\033[38;2;70;130;180m'+str(key)+'\033[0m' + elif string: + return '\033[32m'+str(string)+'\033[0m' + elif number: + return '\033[93;1m'+str(number)+'\033[0m' + elif boolean: + return '\033[33m'+str(boolean)+'\033[0m' + elif null: + return '\033[38;2;128;128;128m'+str(null)+'\033[0m' + elif bracket: + return bracket + elif comma: + return comma + elif colon: + return colon + json_pattern = re.compile(r'(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"])*\"(?=\s*:))|(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"])*\")|(-?\d+(\.\d*)?([eE][+-]?\d+)?)|(true|false)|(null)|([{}\[\]])|([,])|(:)') + return json_pattern.sub(colorize, json_str) + text = colorize_json(ppJson(a_dict,indent,compact,print_dump=False)) + if print_dump: + print(text) + return text +##──── To use in json dump as 'default' parameter ──────────────────────────────────────────────────────────────────────────────── +def jsonSerial(obj): + import datetime + if isinstance(obj, (datetime, datetime.date)): + return obj.isoformat() + else: + return str(obj) +##──── Validates an URL ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def isValidURL(uri): + try: + result = urlparse(uri) + return all([result.scheme, result.netloc]) + except AttributeError: + return False +##──── Get a plural and singular strings for a value - like '1 request' '2 requests' ───────────────────────────────────────────── +def getPluralString(value=0,singularString="request",pluralString="requests",zeroString="",show_value_string=True): + zeroString = singularString if zeroString == "" else zeroString + if show_value_string: + return f"{value} {zeroString}" if value == 0 else f"{value} {singularString}" if (value > 0 and value <= 1) else f"{value} {pluralString}" + else: + return f"{zeroString}" if value == 0 else f"{singularString}" if (value > 0 and value <= 1) else f"{pluralString}" +##──── returns a time in 00h00m00s ─────────────────────────────────────────────────────────────────────────────────────────────── +def getTimeHumanReadable(start_time): + a = start_time #last epoch recorded + b = time.monotonic() #current epoch time + c = b - a #returns seconds + days = c // 86400 + hours = int(c // 3600 % 24) + minutes = int(c // 60 % 60) + seconds = int(c % 60) + return (f"{'%02d'%hours}h{'%02d'%minutes}m{'%02d'%seconds}s") +##──── Format a number like 123456789 into 123.456.789 ─────────────────────────────────────────────────────────────────────────── +def formatNumber(number): + return '{:,d}'.format(number).replace(',','.') +##──── To make asyncio/thread stop on first CTRL+C ─────────────────────────────────────────────────────────────────────────────── +def receivedSignalSTOP(signalSTOP, frame:str=''): + try: + logResponse.__code__ = logResponseEmpty.__code__ + G.event_quit.set() + finally: + cursor.show() + os.system('stty sane') + sys.exit(0) +##──── INTERCEPT THE BROKEN PIPE SIGNAL AND DON'T LET THE APPLICATION CRASH ────────────────────────────────────────────────────── +def received_SIGPIPE(signalPIPE,frame=""): + time.sleep(0.00001) + return True +################################################################################################################################## +################################################################################################################################## + + #### ### ### ## ### #### # # # # ### ##### ### ## # # ### + # # # # # # # # # # # # ## # # # # # # ## # # + ### ### ### # # ### ### # # # ## # # # # # # ## ## + # # # # # # # # # # # # # # # # # # # # # # + #### # # # # ## # # # ## # # ### # ### ## # # ### + +##──── Extract the http error code from a string ───────────────────────────────────────────────────────────────────────────────── +@functools.lru_cache(maxsize=128,typed=False) +def extractErrorCode(error_message): + try: + match = re.search(r"(\d+)", error_message) + if match: + return int(match.group(1)) + except Exception as ERR: + logDebug(f"extractErrorCode: {str(ERR)}") + return 0 + +##──── Return the text of a status code ────────────────────────────────────────────────────────────────────────────────────────── +@functools.lru_cache(maxsize=128,typed=False) +def getStatusCodeDescription(status_code:int): + try: + return HTTP_STATUS_CODES[str(status_code)] + except Exception as ERR: + logDebug(f"getStatusCodeDescription: {str(ERR)}") + return "" + +##──── Return the responde code as --- if is greater than 900 (StressAnAPI internal use) ───────────────────────────────────────── +# @functools.lru_cache(maxsize=128,typed=False) +def getFormattedStatusCode(status_code): + try: + new_status_code = '###' if str(status_code).startswith('9') else status_code + status_code_message = cGreen(f"{new_status_code} {HTTP_STATUS_CODES[str(status_code)]}") if status_code in G.config.success_status_codes else cRed(f"{new_status_code} {HTTP_STATUS_CODES[str(status_code)]}") + return f"{status_code_message}" + except Exception as ERR: + logDebug(f"getFormattedStatusCode: {str(ERR)}") + return "" + +##──── Return a internal response code based on given error_message ────────────────────────────────────────────────────────────── +@functools.lru_cache(maxsize=128,typed=False) +def getErrorResponseCode(error_message): + try: + if str(error_message).lower().find("connection refused") >= 0: + return 900 + elif str(error_message) == "timed out": + return 901 + elif str(error_message).lower().find("reset by peer") >= 0: + return 902 + elif str(error_message).lower().find("closed connection") >= 0: + return 903 + else: + return extractErrorCode(error_message) + except Exception as ERR: + logDebug(f"getErrorResponseCode: {str(ERR)}") + return 0 + +@functools.lru_cache(maxsize=128,typed=False) +def shortenErrorMessage(string:str,max_width:int=128,placeholder:str="(..)"): + if len(string)-len(placeholder) < max_width: + return string + else: + return string[:max_width-len(placeholder)]+placeholder + +################################################################################################################################## +################################################################################################################################## + + # ## ### ### ### # # ### + # # # # # # ## # # + # # # # ## # ## # # ## # ## + # # # # # # # # # # # # + #### ## ### ### ### # # ### + +#deflogging +def log(message:str="",end:str="\n"): + print(getLogDate()+str(message),end=end) + +def logDebug(message:str="",end:str="\n"): + print(getLogDate()+cDebug("[DEBUG] "+str(message)),end=end) + +def logEmpty(message:str="",end:str="\n"):pass +def logResponseEmpty(text_id,method,url,response_code,response_body,elapsed_time):pass + +# @showElapsedTimeAverageDecorator(window_size=5000) +def logResponse(text_id,method,url,response_code,response_body,elapsed_time):pass +# @showElapsedTimeAverageDecorator(window_size=5000) +def _logResponse(text_id,method,url,response_code,response_body,elapsed_time): + print(getLogDate()+f"{G.middot} {text_id} {method} {url} - {getFormattedStatusCode(response_code)} {elapsed_time}") +def _logResponseBody(text_id,method,url,response_code,response_body,elapsed_time): + print(getLogDate()+f"{G.middot} {text_id} {method} {url} - {getFormattedStatusCode(response_code)} {cDarkYellow(response_body.strip())} {elapsed_time}") + +##──── Returns the current date time to be used with log to stdout functions ───────────────────────────────────────────────────── +def getLogDateEmpty():return "" +def getLogDate(): + A = dt.now() + if A.microsecond%1000>=500:A=A+timedelta(milliseconds=1) + if G.date_format.endswith("f"): + D = A.strftime(G.date_format)[:-3] + else: + D = A.strftime(G.date_format) + return cDate(D+" ") + +##──── GetPID for logging multithreads ─────────────────────────────────────────────────────────────────────────────────────────── +def _logGetPID(): + return "["+str(os.getpid())+"] " +def logGetPID():return "" + +################################################################################################################################## +################################################################################################################################## + ### # # # # ### ## # # # # ## # # ### + # # # # ## # # # # #### #### # # ## # # # + ### # # # ## # # # #### #### # # # ## # # + # # # # # # # # # # # # # #### # # # # + # # ## # # ### ## # # # # # # # # ### + +#──── RUN THE COMMAND LINE AND RETURNS A LIST [(True|False),(output|error),elapsed_time] OR RAISE AN ERROR +def runCommand(command_to_run,command_to_send:str='',raise_on_errors:bool=False,command_timeout:int=2)->list: + """Returns: [bool(return_code == 0), result:str, elapsed_time:float] """ + try: + start_time = time.monotonic() + cmd2run = shlex.split(command_to_run) + cmd2send = shlex.split(command_to_send) + shell = True if '|' in cmd2run else False + # logDebug(cmd2run) + process = subprocess.Popen(cmd2run, universal_newlines=True,shell=shell, + stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.PIPE) + if cmd2send: + for command in cmd2send: + process.stdin.write(command) + result, error = process.communicate(timeout=command_timeout) + except subprocess.TimeoutExpired: + process.kill() + raise Exception(f"Command timeout ({str(cmd2run)})") + except FileNotFoundError: + raise Exception(f"File not found ({str(cmd2run)})") + if process.returncode != 0: + result = error + return [(process.returncode == 0),result,'%.6f'%(time.monotonic()-start_time)] + +################################################################################################################################## +################################################################################################################################## + + # # #### # # ## ### # # ### # # #### ## + #### # #### # # # # # # # ## # # # # + #### ### #### # # ### # # # ## ### # # + # # # # # # # # # # # # # # # # + # # #### # # ## # # # ### # # # ## + +class memoryInfo: + def rss(): + ''' Memory usage in MiB ''' + PROCESS_QUERY_INFORMATION = 0x0400 + PROCESS_VM_READ = 0x0010 + + class PROCESS_MEMORY_COUNTERS(ctypes.Structure): + _fields_ = [("cb", ctypes.c_ulong), + ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t)] + + try: + ##──── LINUX and MACOS + result = subprocess.check_output(['ps', '-p', str(os.getpid()), '-o', "rss="]) + return float('%0.2f'%(float(int(result.strip()) / 1024))) + except: + ##──── WINDOWS + try: + pid = ctypes.windll.kernel32.GetCurrentProcessId() + process_handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid) + counters = PROCESS_MEMORY_COUNTERS() + counters.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS) + if ctypes.windll.psapi.GetProcessMemoryInfo(process_handle, ctypes.byref(counters), ctypes.sizeof(counters)): + memory_usage = counters.WorkingSetSize + return float((int(memory_usage) / 1024) / 1024) + except: + return 0.0 + +class cpuInfo: + def usage(): + try: + time.sleep(0.2) + cmd2run = f'ps -o pid,%cpu,command ax' + result,output,elapsed_time = runCommand(cmd2run) + if result: + output = output.splitlines() + ps_line = [item for item in output if item.lstrip().startswith(str(os.getpid()))] + if len(ps_line) > 0: + cpu_percent = ps_line[0].split()[1] + return '%0.2f'%(float(cpu_percent)) + except Exception as ERR: + logDebug(f"cpuInfo: {str(ERR)}") + return 0.0 + def average_usage(): + CPU_Pct=str(round(float(os.popen('''grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$4+$5)} END {print usage }' ''').readline()),2)) + return CPU_Pct + + +################################################################################################################################## +################################################################################################################################## + + ### ### # # ## #### #### ### # # ### ##### # # + # # # # # # # # # # ## # # # # # + # ### # # # # ### ### # # ## # # # + # # # # #### # # # # # # # # + ### # ## # # # # ### # # ### # # + +def setCPUAffinity(proc_pid:int,cpu_affinity:list): + try: + cmd2run = f"taskset -cpa {','.join(map(str,cpu_affinity))} {proc_pid}" + result,output,elapsed_time = runCommand(cmd2run) + return result == 0 + except Exception as ERR: + raise StressAnAPIException(f"setCPUAffinity: {str(ERR)}") + +################################################################################################################################## +################################################################################################################################## + + ### #### ## ### # # #### # # + # # # # # # # # # # # # + ### ### # # # # ## ### # + # # # #### # # # # # # + # # #### # # ### # # #### # + +def readKey(): + old_settings = termios.tcgetattr(sys.stdin) + tty.setcbreak(sys.stdin.fileno()) + try: + while True: + b = os.read(sys.stdin.fileno(), 3).decode() + # print(repr(b)) + if len(b) == 3: + k = ord(b[2]) + else: + k = ord(b[:1]) + # print(k) + key_mapping = { + 113: 'q', + 27: 'esc', + 10: 'return', + 65: 'up', 66: 'down', + 67: 'right', 68: 'left', + 61: 'plus', 43: 'plus', # 61: 'equal', 43: 'plus', + 45: 'minus', 95: 'minus', # 45: 'minus', 95: 'underline', + 44: 'comma', 46: 'dot', + 62: 'greaterthan', 60: 'lowerthan', + 40: 'openparenthesis', 41: 'closeparenthesis', + 91: 'opensquarebracket', 93: 'closesquarebracket', + 123: 'opencurlybracket', 125: 'closecurlybracket', + } + return key_mapping.get(k, chr(k)) + except Exception as ERR: + logDebug(f"readKey: {str(ERR)}") + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) + +################################################################################################################################## +################################################################################################################################## + + # # #### # # ### #### # # # # ### ##### ### ## # # ### + # # # # # # # # # ## # # # # # # ## # # + ## ### # ## ### # # # ## # # # # # # ## ## + # # # # # # # # # # # # # # # # # # + # # #### # ### # ## # # ### # ### ## # # ### + +def pause(): + if G.event_pause.is_set(): + counterAverage.reset() + log(cGrey(line.middot2s)) + log(f">>> {cWhite(f'Resuming the application after {getTimeHumanReadable(G.event_pause_time)}')}") + log(cGrey(line.middot2s)) + G.event_pause.clear() + else: + counterAverage.reset() + G.event_pause_time = time.monotonic() + log(cGrey(line.middot2s)) + log(f">>> {cWhite('PAUSE requested!')}") + log(cGrey(line.middot2s)) + G.event_pause.set() + +def keyMemInfo(): + log(f">>> {__appname__} Memory usage: {memoryInfo.rss()} MiB - CPU usage: {cpuInfo.usage()}%") + +def increaseThreads(): + ThreadMakeRequests = threadMakeRequestsURLLib(timeStats=timeStats,httpStats=httpStats) + ThreadMakeRequests.setDaemon(False) + G.thread_list.append(ThreadMakeRequests) + ThreadMakeRequests.start() + log(f" {G.bold_right} Creating one more thread... {getPluralString(len(G.thread_list),'thread','threads')} currently running") + +def decreaseThreads(): + if len(G.thread_list) == 1: + unique_thread = G.thread_list[0] + thread_name,thread_id = unique_thread.identify() + log(f" {G.light_circle} There is only 1 thread running ({thread_name} pid:{thread_id})... can't join it.") + else: + thread_to_join = G.thread_list[0] + thread_name,thread_id = thread_to_join.join() + G.thread_list.pop(0) + log(f" {G.bold_left} Joining thread '{thread_name}' pid:{thread_id}... {getPluralString(len(G.thread_list),'thread','threads')} currently running") + +def increaseTimeout(): + G.config.timeout = G.config.timeout * 1.10 # +10% + log(" + Increasing the request timeout to %.6f sec"%(G.config.timeout)) + +def decreaseTimeout(): + if G.config.timeout <= 0.00001: + G.config.timeout = 0.00001 + timeout_limit = '(limit reached!)' + else: + G.config.timeout = G.config.timeout / 1.10 # +10% + timeout_limit = '' + log(f" - Decreasing the request timeout to %.6f sec {timeout_limit}"%(G.config.timeout)) + +def increaseInterval(): + G.config.interval = G.config.interval * 1.10 # +10% + if G.config.interval > 2.0: + G.config.interval = 2.0 + log(f" {G.light_up} Increasing the interval between requests to %.6f sec (slower)"%(G.config.interval)) + +def decreaseInterval(): + G.config.interval = G.config.interval / 1.10 # +10% + if G.config.interval < 0.000010: + G.config.interval = 0.000010 + log(f" {G.light_down} Decreasing the interval between requests to %.6f sec (faster)"%(G.config.interval)) + +def increaseBurst(): + G.config.burst += 1 + if G.config.burst > 300: + G.config.burst = 300 + log(f" {G.light_right} Increasing the burst of requests to {getPluralString(G.config.burst,'request','requests')} (faster)") + +def decreaseBurst(): + G.config.burst -= 1 + if G.config.burst <= 1: + G.config.burst = 1 + log(f" {G.light_left} Decreasing the burst of requests to {getPluralString(G.config.burst,'request','requests')} (slower)") + +def resetStats(): + httpStats.reset() + timeStats.reset() + counter.reset() + counterAverage.reset_counter() + log(f" > All statistics have been reset as requested") + +def displayCurrentConfiguration(): + log(line.single) + log(f">>> {cWhite(f'Current {__appname__} Configuration:')}") + log("") + displayConfig() + log(line.single) + +def displayAverageTimeStats(): + if G.event_pause.is_set(): + log(f">>> Average: 0 requests/sec (PAUSED)") + else: + lines_per_sec, sec_per_lines = counterAverage.get_average() + log(f">>> Average {'%.0f'%(lines_per_sec)} req/sec - Min/Avg/Max: {'%.6f'%(timeStats.min_time)}/{'%.6f'%(timeStats.avg_time)}/{'%.6f'%(timeStats.max_time)} - Total: {counter.value} reqs") + +def displayFullHttpStats(): + def remove9XXFromString(col_str): # remove errors 900 used by internal control + return "###"+col_str[3:] if col_str.startswith('9') else col_str + try: + with lock: + log(line.single) + log(f">>> {cWhite('Statistics: Success vs. Errors')}") + max_number_len = len(str(sorted(list(httpStats.asdict.values()))[-1])) + max_size = (classTerminal().max_width-ansiLen(getLogDate())-10) + max_col_size = max_size // 2 + stats = {key:val for key,val in httpStats.asdict_desc.items() if val > 0} + + ##──── Chart + success = [val for key,val in httpStats.asdict.items() if key in G.config.success_status_codes] + errors400 = [val for key,val in httpStats.asdict.items() if key >= 400 and key < 500] + errors500 = [val for key,val in httpStats.asdict.items() if key >= 500 and key < 600] + conn_errors = [val for key,val in httpStats.asdict.items() if key >= 900] + # print(sum(success),sum(errors),sum(conn_errors)) + x = [sum(success),sum(errors400),sum(errors500),sum(conn_errors)] + y = ['Success','HTTP 4XX errors','HTTP 5XX errors','Connection errors'] + a_chart = statChart().bar(x,y,width=(max_size//3)*2,with_percent=True, with_value=True).splitlines() + [log(f" {line}") for line in a_chart] + log(line.middot1s) + ##──── Table + keys = list(stats.keys()) + half_size = (len(keys) + 1) // 2 + col1 = keys[:half_size] + col2 = keys[half_size:] + for i in range(half_size): + col1_key = col1[i] if i < len(col1) else '' + col2_key = col2[i] if i < len(col2) else '' + col1_str = f"{col1_key}: {stats[col1_key]}" if col1_key else '' + col2_str = f"{col2_key}: {stats[col2_key]}" if col2_key else '' + log(f" {remove9XXFromString(col1_str):<{max_col_size}} {remove9XXFromString(col2_str)}") + log(line.middot1s) + + log(f">>> {cWhite(f'Statistics: Elapsed time of last {timeStats.window_size} requests')}") + stats = timeStats.stats() + + a = Table(cols=8,max_size=max_size+10,with_border=False,border_size=0).head(['Total Requests','Min','Avg','Max','50th pct','75th pct','90th pct','99th pct']).row([counter.value,*list(stats.values())]).get_table() + [log(f" {line}") for line in a] + + log(line.single) + except Exception as ERR: + logDebug(f"displayFullHttpStats: {str(ERR)}") + +def finishApplication(): + displayFullHttpStats() + quit() + +def displayHelp(): +# {line.double} +# {__doc__} + help_text = f"""{line.single} + Control keys available: + {line.middot[:23]} + {G.light_up} -> Increases the time interval between requests in +10% (slower) + {G.light_down} -> Decreases the time interval between requests in -10% (faster) + {G.light_right} -> Increase the burst of requests (faster) + {G.light_left} -> Reduces the burst of requests (slower) + +/- -> Increase/Decrease the timeout in +/- 10% + < > -> Increase/Decrease the number of threads + C -> Displays information about the current configuration + P -> Pause/Resume the application + M -> Shows Memory usage and CPU usage information + V -> Start/stop viewing each request sent and returned status code + B -> The same as V key, but also include the returned response body + S -> Displays a detailed report about success, failures, average time, etc + R -> Reset all statistics data + F -> Finish the application by displaying the statistics + ENTER -> Displays statistics of the current rate of requests per second + ESC/Q -> Exit the application + H -> Show this help message +{line.single} +""" + [log(line) for line in help_text.splitlines()] + +################################################################################################################################## +################################################################################################################################## + + # # ## # # #### ### #### ## # # #### ### ##### ### + #### # # # # # # # # # # # # # # # # + #### # # ## ### ### ### # # # # ### ## # ## + # # #### # # # # # # # # # # # # # # + # # # # # # #### # # #### ## ## #### ### # ### + ## +class threadMakeRequestsURLLib(threading.Thread): + def __init__(self,timeStats,httpStats): + threading.Thread.__init__(self) + self.stop = threading.Event() + self.timeStats = timeStats + self.httpStats = httpStats + zfill_len = len(str(len(G.thread_list))) if len(str(len(G.thread_list))) >= 2 else 2 + self.text_id = f"[#{self.name.split('-')[1].zfill(zfill_len)}]" + + def run(self): + method, url, timeout = G.config.method, G.config.url, G.config.timeout + data = str(json.dumps(G.config.post_data,sort_keys=False,ensure_ascii=False,separators=(",",":"))).encode() + if method == "GET": + req = urllib.request.Request(url=G.config.url,method=G.config.method) + req.full_url = G.config.url + else: + req = urllib.request.Request(url=G.config.url,data=data,method=G.config.method) + ##──── configure StressAnAPI useragent if the user has not configured any other + if 'user-agent' not in [key.lower() for key,val in G.config.headers.items()]: + req.add_header('User-Agent',G.default_user_agent) + ##───────────────────────────────────────────────────────────────────────────── + for header_key, header_value in G.config.headers.items(): + req.add_header(header_key,header_value) + while True: + if G.event_quit.is_set() or self.stop.is_set(): + break + while G.event_pause.is_set(): + time.sleep(1) + if self.stop.is_set(): + break + for I in range(G.config.burst): + if self.stop.is_set(): break + try: + with elapsedTimer() as elapsed: + response_code,response_body = self.urllib_open(req,timeout) + self.timeStats.save(elapsed.time) + except Exception as ERR: + logDebug(f"urllib_get error: {str(ERR)}") + finally: + counter.incr() + logResponse(self.text_id,method,url,response_code,response_body,elapsed.text()) + time.sleep(G.config.interval) + + # @showElapsedTimeAverageDecorator(5000) + def urllib_open(self,urllib_request,timeout): + try: + with urllib.request.urlopen(urllib_request,timeout=timeout) as response: + response_code = response.getcode() + response_text = response.readline().strip().decode() if response_code not in [202,204] else '\b' + except Exception as ERR: + logDebug(f"urllib_open: {str(ERR)}") + response_code,response_text = getErrorResponseCode(str(ERR)),shortenErrorMessage(str(ERR),128) + finally: + counterAverage.mark() + self.httpStats.save(response_code) + return response_code,response_text + + def identify(self): + return self.name,self.native_id + + def join(self): + try: + self.stop.set() + threading.Thread.join(self) + except Exception as ERR: + log(f"Unable to join thread {self.name} - {self.native_id}") + return self.name,self.native_id + +################################################################################################################################## +################################################################################################################################## + + ## ### ### ### ## ### ### #### # # #### # # # # + # # # # # # # # # # # # # #### # ## # # # + # # ### # ## ### # # ### ## ### #### ### # ## # # + #### # # # # # #### # # # # # # # # # # # + # # # # ### # # # # # ### #### # # #### # # ## + +class classArgparseHelpFormatter(argparse.HelpFormatter): + def format_help(self): + help = self._root_section.format_help() + if help: + help = self._long_break_matcher.sub('\n\n', help) + help = help.strip('\n') + '\n' + help = help.replace("
","\n") + return help + + def add_usage(B,usage,actions,groups,prefix=None): + A=prefix + if A is None:A='Usage: ' + return super(classArgparseHelpFormatter,B).add_usage(usage,actions,groups,A) + + def _format_action(self, action): + self._max_help_position = 40 + self._indent_increment = 2 + self._width = classTerminal().max_width + return super(classArgparseHelpFormatter, self)._format_action(action) + +class myArgumentParser(argparse.ArgumentParser): + def print_help(self, file=None): + if file is None: + file = sys.stdout + self._print_message(self.format_help()+"\n", file) + +def getArgParseMenu(): + parser = myArgumentParser( + formatter_class=classArgparseHelpFormatter, + description=cWhite(__appname__+" v"+__version__+" - "+__descr__+" - "+__url__), + add_help=False, + allow_abbrev=True, + prog=current_filename) + + main = parser.add_argument_group("Main Options") + main.add_argument('--conf',dest="configfile",metavar="",action="store",default="",help='Provide the file name of a configuration file. Use the option --template to see a template.') + main.add_argument('-c',dest="configfile",action="store",help=argparse.SUPPRESS) + + template = parser.add_argument_group("Configuration Template") + template.add_argument('--template',action="store_true", default=False, help="Displays a configuration template. Required values are 'url' and 'method'.") + + optional = parser.add_argument_group("More options") + optional.add_argument('--nodate','--nodatetime', action="store_true", default=False, help="Hide the date/time information at the begin of the output lines.") + optional.add_argument('--debug','-d', dest="debug", action='store_true', default=False, help="Print debug information do stdout/syslog. Or use 'export STRESSANAPI_DEBUG=1'.") + optional.add_argument('--help','-h', action='help', help='Show a help message about the allowed commands.') + optional.add_argument('--version',dest="version", action='store_true', default=False, help=argparse.SUPPRESS) + return parser + +################################################################################################################################## +################################################################################################################################## + + # # ## ### # # ### # ## ### ### + #### # # # ## # # # # # # # + #### # # # # ## # # # # ## ## + # # #### # # # # # #### # # + # # # # ### # # ### #### # # ### ### + +class classStressAnAPI: + def __init__(self): + pass + def __enter__(self): + pass + def __exit__(self, type, value, traceback): + try: + G.event_quit.set() + G.event_pause.clear() + logResponse.__code__ = logResponseEmpty.__code__ + log(line.middot) + log(cWhite(f">>> Exiting {__appname__} as requested - PID: {os.getpid()} - {dt.now().strftime(G.date_format_no_milisec)}")) + log(line.middot) + finally: + cursor.show() + os.system('stty sane') + sys.exit(0) + +################################################################################################################################## +################################################################################################################################## + + # # ## ### # # #### # # # # ### ##### ### ## # # + #### # # # ## # # # # ## # # # # # # ## # + #### # # # # ## ### # # # ## # # # # # # ## + # # #### # # # # # # # # # # # # # # # + # # # # ### # # # ## # # ### # ### ## # # + +################################################################################################################################## +################################################################################################################################## + +def startApp(): + global httpStats, timeStats, counter, counterAverage + log(line.middot) + log(cWhite(f">>> Starting {__appname__} v{__version__} - PID: {os.getpid()} - {dt.now().strftime(G.date_format_no_milisec)}")) + log(line.middot) + log(f">>> Loaded configuration from {cWhite(str(G.config.config_file))} [{G.config.elapsed_load_time}]") + displayConfig() + log(cGrey(line.middot1s)) + log(f">>> All done in {'%.6f'%(time.monotonic()-G.start_time)}'s! {cWhite(f'It{G.singleQuote}s Showtime!')}") + log(cGrey(line.middot1s)) + log(cWhite(f"Use the arrow keys to increase/decrease the speed. Press H for a quick help or ESC/Q to quit.".center(classTerminal().width))) + log(cGrey(line.middot1s)) + + threading.Thread(target=threadGarbageCollector,daemon=True).start() + + counter = AtomicCounter() + counterAverage = AtomicAverageCounter() + counterAverage.start() + + timeStats = classTimeStats() + httpStats = classHttpStats() + + for I in range(G.config.threads): + try: + ThreadMakeRequests = threadMakeRequestsURLLib(timeStats=timeStats,httpStats=httpStats) + except Exception as ERR: + logDebug(str(ERR)) + ThreadMakeRequests.setDaemon(True) + G.thread_list.append(ThreadMakeRequests) + ThreadMakeRequests.start() + + try: + view_response = False + view_response_body = False + cursor.hide() + while True: + k = readKey() + k = k.lower() + # logDebug(f"KEY PRESSED: {k}") + if k in ['esc','q']: + quit() + else: + cursor.hide() + if k == 'f': + finishApplication() + break + elif k == 'h': + displayHelp() + elif k == 'up': + increaseInterval() + counterAverage.reset() + elif k == 'down': + decreaseInterval() + counterAverage.reset() + elif k == 'right': + increaseBurst() + counterAverage.reset() + elif k == 'left': + decreaseBurst() + counterAverage.reset() + elif k == 'plus': + increaseTimeout() + elif k == 'minus': + decreaseTimeout() + elif k == 'greaterthan': + increaseThreads() + elif k == 'lowerthan': + decreaseThreads() + elif k == 'm': + keyMemInfo() + elif k == 'p': + pause() + elif k == 'c': + displayCurrentConfiguration() + elif k == 'return': + displayAverageTimeStats() + elif k == 's': + displayFullHttpStats() + elif k == 'r': + resetStats() + elif k == 'b': + view_response_body = not view_response_body + view_response = False + if view_response_body: + logResponse.__code__ = _logResponseBody.__code__ + else: + logResponse.__code__ = logResponseEmpty.__code__ + elif k == 'v': + view_response = not view_response + view_response_body = False + if view_response: + logResponse.__code__ = _logResponse.__code__ + else: + logResponse.__code__ = logResponseEmpty.__code__ + else: + continue + except (KeyboardInterrupt,SystemExit): + pass + except Exception as ERR: + logDebug(f"startApp: {str(ERR)}") + raise StressAnAPIException(str(ERR)) from None + finally: + quit() + +def main_function(): + if ('-d' in sys.argv) or ('--debug' in sys.argv): + G.DEBUG = True + if G.DEBUG: + sys.tracebacklimit = 3 + else: + logDebug.__code__ = logEmpty.__code__ + if (('--nodate' in sys.argv) and ('--notime' in sys.argv)) or ('--nodatetime' in sys.argv): + getLogDate.__code__ = getLogDateEmpty.__code__ + elif '--nodate' in sys.argv: + G.date_format = G.date_format_no_date + elif '--notime' in sys.argv: + G.date_format = G.date_format_no_time + + parser = getArgParseMenu() + args = parser.parse_args() + + if args.version: + print(f"\n{__appname__} v{__version__} ({__release__}) - {__descr__}") + print(f"Github.: {__url__}\n") + sys.exit(0) + + if G.DEBUG: + args.debug = True + + G.argsvars = vars(args) + logDebug(f"argparser vars: {G.argsvars}") + + if args.template and args.configfile == '': + ppJson(G.default_template,compact=False) + elif args.configfile == '' and not args.template: + parser.print_help() + elif args.configfile: + if not os.path.isfile(args.configfile): + raise StressAnAPIException(f'Cannot locate the file {args.configfile}') + else: + if not validateConfigFile(args.configfile): + raise StressAnAPIException(f'Invalid configuration found at {args.configfile}') from None + else: + try: + with classStressAnAPI() as StressAnAPI: + return startApp() + except Exception as ERR: + logDebug(f"Failed in classStressAnAPI() as StressAnAPI: {str(ERR)}") + else: + parser.print_help() + +################################################################################################################################## +################################################################################################################################## + +if __name__ == '__main__': + signal.signal(signal.SIGINT, receivedSignalSTOP) # to make asyncio/thread stop on first CTRL+C + signal.signal(signal.SIGTERM, receivedSignalSTOP) # to make asyncio/thread stop on first CTRL+C + signal.signal(signal.SIGPIPE, received_SIGPIPE) # intercept the broken pipe signal and avoid the application crash + sys.exit(main_function()) \ No newline at end of file