pyppms.common
Common functions related to Stratocore's PPMS Utility Management API.
1"""Common functions related to Stratocore's PPMS Utility Management API.""" 2 3# pylint: disable-msg=fixme 4 5from datetime import datetime, timedelta 6import csv 7from io import StringIO 8 9from loguru import logger as log 10 11from .exceptions import NoDataError 12 13 14def process_response_values(values): 15 """Process (in-place) a list of strings, remove quotes, detect boolean etc. 16 17 Check all (str) elements of the given list, remove surrounding double-quotes 18 and convert 'true' / 'false' strings into Python booleans. 19 20 Parameters 21 ---------- 22 values : list(str) 23 The list of strings that should be processed. 24 25 Returns 26 ------- 27 None 28 Nothing is returned, the list's element are processed in-place. 29 """ 30 # tell pylint that there is no real gain using enumerate here: 31 # pylint: disable-msg=consider-using-enumerate 32 for i in range(len(values)): 33 values[i] = values[i].strip('"') 34 if values[i] == "true": 35 values[i] = True 36 if values[i] == "false": 37 values[i] = False 38 39 40def dict_from_single_response(text, graceful=True): 41 """Parse a two-line CSV response from PUMAPI and create a dict from it. 42 43 Parameters 44 ---------- 45 text : str 46 The PUMAPI response with two lines: a header line and one data line. 47 graceful : bool, optional 48 Whether to continue in case the response text is inconsistent, i.e. 49 having different number of fields in the header line and the data line, 50 by default True. In graceful mode, any inconsistency detected in the 51 data will be logged as a warning, in non-graceful mode they will raise 52 an Exception. 53 54 Returns 55 ------- 56 dict 57 A dict with the fields of the header line being the keys and the fields 58 of the data line being the values. Values are stripped from quotes 59 and converted to Python boolean values where applicable. 60 61 Raises 62 ------ 63 ValueError 64 Raised when the response text is inconsistent and the `graceful` 65 parameter has been set to false, or if parsing fails for any other 66 unforeseen reason. 67 """ 68 69 # check if we got an empty response (only two newlines) and return a dict 70 # with two empty strings only 71 # TODO: this should probably rather raise a ValueError but we need to test 72 # all effects on existing code first! 73 if text == "\n\n": 74 return {"": ""} 75 try: 76 lines = list(csv.reader(StringIO(text), delimiter=",")) 77 if len(lines) != 2: 78 log.warning("Response expected to have exactly two lines: {}", text) 79 if not graceful: 80 raise ValueError("Invalid response format!") 81 header = lines[0] 82 data = lines[1] 83 process_response_values(data) 84 if len(header) != len(data): 85 msg = "Parsing CSV failed, mismatch of header vs. data fields count" 86 log.warning("{} ({} vs. {})", msg, len(header), len(data)) 87 if not graceful: 88 raise ValueError(msg) 89 minimum = min(len(header), len(data)) 90 if minimum < len(header): 91 log.warning("Discarding header-fields: {}", header[minimum:]) 92 header = header[:minimum] 93 else: 94 log.warning("Discarding data-fields: {}", data[minimum:]) 95 data = data[:minimum] 96 97 except Exception as err: 98 msg = f"Unable to parse data returned by PUMAPI: {text} - ERROR: {err}" 99 log.error(msg) 100 raise ValueError(msg) from err 101 102 parsed = dict(zip(header, data)) 103 return parsed 104 105 106def parse_multiline_response(text, graceful=True): 107 """Parse a multi-line CSV response from PUMAPI. 108 109 Parameters 110 ---------- 111 text : str 112 The PUMAPI response with two or more lines, where the first line 113 contains the header field names and the subsequent lines contain data. 114 graceful : bool, optional 115 Whether to continue in case the response text is inconsistent, i.e. 116 having different number of fields in the header line and the data lines, 117 by default True. In graceful mode, any inconsistency detected in the 118 data will be logged as a warning, in non-graceful mode they will raise 119 an Exception. 120 121 Returns 122 ------- 123 list(dict) 124 A list with dicts where the latter ones have the same form as produced 125 by the dict_from_single_response() function. May be empty in case the 126 PUMAPI response didn't contain any useful data. Note that when graceful 127 mode is requested, consistency among the dicts is not guaranteed. 128 129 Raises 130 ------ 131 NoDataError 132 Raised when the response text was too short (less than two lines) and 133 the `graceful` parameter has been set to false. 134 ValueError 135 Raised when the response text is inconsistent and the `graceful` 136 parameter has been set to false, or if parsing fails for any other 137 unforeseen reason. 138 """ 139 parsed = [] 140 try: 141 lines = text.splitlines() 142 if len(lines) < 2: 143 log.debug("Response has less than TWO lines: >>>{}<<<", text) 144 if not graceful: 145 raise NoDataError("Invalid response format!") 146 return [] 147 148 header = lines[0].split(",") 149 for i, entry in enumerate(header): 150 header[i] = entry.strip() 151 152 lines_max = lines_min = len(header) 153 for line in lines[1:]: 154 data = line.split(",") 155 process_response_values(data) 156 lines_max = max(lines_max, len(data)) 157 lines_min = min(lines_min, len(data)) 158 if len(header) != len(data): 159 msg = "Parsing CSV failed, mismatch of header vs. data fields count" 160 log.warning("{} ({} vs. {})", msg, len(header), len(data)) 161 if not graceful: 162 raise ValueError(msg) 163 164 minimum = min(len(header), len(data)) 165 if minimum < len(header): 166 log.warning("Discarding header-fields: {}", header[minimum:]) 167 header = header[:minimum] 168 else: 169 log.warning("Discarding data-fields: {}", data[minimum:]) 170 data = data[:minimum] 171 172 details = dict(zip(header, data)) 173 # log.debug(details) 174 parsed.append(details) 175 176 if lines_min != lines_max: 177 msg = ( 178 "Inconsistent data detected, not all dicts will have the " 179 "same number of elements!" 180 ) 181 log.warning(msg) 182 183 except NoDataError as err: 184 raise err 185 186 except Exception as err: 187 msg = f"Unable to parse data returned by PUMAPI: {text} - ERROR: {err}" 188 log.error(msg) 189 raise ValueError(msg) from err 190 191 return parsed 192 193 194def time_rel_to_abs(minutes_from_now): 195 """Convert a relative time given in minutes from now to a datetime object. 196 197 Parameters 198 ---------- 199 minutes_from_now : int or int-like 200 The relative time in minutes to be converted. 201 202 Returns 203 ------- 204 datetime 205 The absolute time point as a datetime object. 206 """ 207 now = datetime.now().replace(second=0, microsecond=0) 208 abstime = now + timedelta(minutes=int(minutes_from_now)) 209 return abstime 210 211 212def fmt_time(time): 213 """Format a `datetime` or `None` object to string. 214 215 This is useful to apply it to booking times as they might be `None` e.g. in 216 case they have been created from a "nextbooking" response. 217 218 Parameters 219 ---------- 220 time : datetime.datetime or None 221 222 Returns 223 ------- 224 str 225 The formatted time, or a specific string in case the input was `None`. 226 """ 227 if time is None: 228 return "===UNDEFINED===" 229 return datetime.strftime(time, "%Y-%m-%d %H:%M")
def
process_response_values(values):
15def process_response_values(values): 16 """Process (in-place) a list of strings, remove quotes, detect boolean etc. 17 18 Check all (str) elements of the given list, remove surrounding double-quotes 19 and convert 'true' / 'false' strings into Python booleans. 20 21 Parameters 22 ---------- 23 values : list(str) 24 The list of strings that should be processed. 25 26 Returns 27 ------- 28 None 29 Nothing is returned, the list's element are processed in-place. 30 """ 31 # tell pylint that there is no real gain using enumerate here: 32 # pylint: disable-msg=consider-using-enumerate 33 for i in range(len(values)): 34 values[i] = values[i].strip('"') 35 if values[i] == "true": 36 values[i] = True 37 if values[i] == "false": 38 values[i] = False
Process (in-place) a list of strings, remove quotes, detect boolean etc.
Check all (str) elements of the given list, remove surrounding double-quotes and convert 'true' / 'false' strings into Python booleans.
Parameters
- values (list(str)): The list of strings that should be processed.
Returns
- None: Nothing is returned, the list's element are processed in-place.
def
dict_from_single_response(text, graceful=True):
41def dict_from_single_response(text, graceful=True): 42 """Parse a two-line CSV response from PUMAPI and create a dict from it. 43 44 Parameters 45 ---------- 46 text : str 47 The PUMAPI response with two lines: a header line and one data line. 48 graceful : bool, optional 49 Whether to continue in case the response text is inconsistent, i.e. 50 having different number of fields in the header line and the data line, 51 by default True. In graceful mode, any inconsistency detected in the 52 data will be logged as a warning, in non-graceful mode they will raise 53 an Exception. 54 55 Returns 56 ------- 57 dict 58 A dict with the fields of the header line being the keys and the fields 59 of the data line being the values. Values are stripped from quotes 60 and converted to Python boolean values where applicable. 61 62 Raises 63 ------ 64 ValueError 65 Raised when the response text is inconsistent and the `graceful` 66 parameter has been set to false, or if parsing fails for any other 67 unforeseen reason. 68 """ 69 70 # check if we got an empty response (only two newlines) and return a dict 71 # with two empty strings only 72 # TODO: this should probably rather raise a ValueError but we need to test 73 # all effects on existing code first! 74 if text == "\n\n": 75 return {"": ""} 76 try: 77 lines = list(csv.reader(StringIO(text), delimiter=",")) 78 if len(lines) != 2: 79 log.warning("Response expected to have exactly two lines: {}", text) 80 if not graceful: 81 raise ValueError("Invalid response format!") 82 header = lines[0] 83 data = lines[1] 84 process_response_values(data) 85 if len(header) != len(data): 86 msg = "Parsing CSV failed, mismatch of header vs. data fields count" 87 log.warning("{} ({} vs. {})", msg, len(header), len(data)) 88 if not graceful: 89 raise ValueError(msg) 90 minimum = min(len(header), len(data)) 91 if minimum < len(header): 92 log.warning("Discarding header-fields: {}", header[minimum:]) 93 header = header[:minimum] 94 else: 95 log.warning("Discarding data-fields: {}", data[minimum:]) 96 data = data[:minimum] 97 98 except Exception as err: 99 msg = f"Unable to parse data returned by PUMAPI: {text} - ERROR: {err}" 100 log.error(msg) 101 raise ValueError(msg) from err 102 103 parsed = dict(zip(header, data)) 104 return parsed
Parse a two-line CSV response from PUMAPI and create a dict from it.
Parameters
- text (str): The PUMAPI response with two lines: a header line and one data line.
- graceful (bool, optional): Whether to continue in case the response text is inconsistent, i.e. having different number of fields in the header line and the data line, by default True. In graceful mode, any inconsistency detected in the data will be logged as a warning, in non-graceful mode they will raise an Exception.
Returns
- dict: A dict with the fields of the header line being the keys and the fields of the data line being the values. Values are stripped from quotes and converted to Python boolean values where applicable.
Raises
- ValueError: Raised when the response text is inconsistent and the
graceful
parameter has been set to false, or if parsing fails for any other unforeseen reason.
def
parse_multiline_response(text, graceful=True):
107def parse_multiline_response(text, graceful=True): 108 """Parse a multi-line CSV response from PUMAPI. 109 110 Parameters 111 ---------- 112 text : str 113 The PUMAPI response with two or more lines, where the first line 114 contains the header field names and the subsequent lines contain data. 115 graceful : bool, optional 116 Whether to continue in case the response text is inconsistent, i.e. 117 having different number of fields in the header line and the data lines, 118 by default True. In graceful mode, any inconsistency detected in the 119 data will be logged as a warning, in non-graceful mode they will raise 120 an Exception. 121 122 Returns 123 ------- 124 list(dict) 125 A list with dicts where the latter ones have the same form as produced 126 by the dict_from_single_response() function. May be empty in case the 127 PUMAPI response didn't contain any useful data. Note that when graceful 128 mode is requested, consistency among the dicts is not guaranteed. 129 130 Raises 131 ------ 132 NoDataError 133 Raised when the response text was too short (less than two lines) and 134 the `graceful` parameter has been set to false. 135 ValueError 136 Raised when the response text is inconsistent and the `graceful` 137 parameter has been set to false, or if parsing fails for any other 138 unforeseen reason. 139 """ 140 parsed = [] 141 try: 142 lines = text.splitlines() 143 if len(lines) < 2: 144 log.debug("Response has less than TWO lines: >>>{}<<<", text) 145 if not graceful: 146 raise NoDataError("Invalid response format!") 147 return [] 148 149 header = lines[0].split(",") 150 for i, entry in enumerate(header): 151 header[i] = entry.strip() 152 153 lines_max = lines_min = len(header) 154 for line in lines[1:]: 155 data = line.split(",") 156 process_response_values(data) 157 lines_max = max(lines_max, len(data)) 158 lines_min = min(lines_min, len(data)) 159 if len(header) != len(data): 160 msg = "Parsing CSV failed, mismatch of header vs. data fields count" 161 log.warning("{} ({} vs. {})", msg, len(header), len(data)) 162 if not graceful: 163 raise ValueError(msg) 164 165 minimum = min(len(header), len(data)) 166 if minimum < len(header): 167 log.warning("Discarding header-fields: {}", header[minimum:]) 168 header = header[:minimum] 169 else: 170 log.warning("Discarding data-fields: {}", data[minimum:]) 171 data = data[:minimum] 172 173 details = dict(zip(header, data)) 174 # log.debug(details) 175 parsed.append(details) 176 177 if lines_min != lines_max: 178 msg = ( 179 "Inconsistent data detected, not all dicts will have the " 180 "same number of elements!" 181 ) 182 log.warning(msg) 183 184 except NoDataError as err: 185 raise err 186 187 except Exception as err: 188 msg = f"Unable to parse data returned by PUMAPI: {text} - ERROR: {err}" 189 log.error(msg) 190 raise ValueError(msg) from err 191 192 return parsed
Parse a multi-line CSV response from PUMAPI.
Parameters
- text (str): The PUMAPI response with two or more lines, where the first line contains the header field names and the subsequent lines contain data.
- graceful (bool, optional): Whether to continue in case the response text is inconsistent, i.e. having different number of fields in the header line and the data lines, by default True. In graceful mode, any inconsistency detected in the data will be logged as a warning, in non-graceful mode they will raise an Exception.
Returns
- list(dict): A list with dicts where the latter ones have the same form as produced by the dict_from_single_response() function. May be empty in case the PUMAPI response didn't contain any useful data. Note that when graceful mode is requested, consistency among the dicts is not guaranteed.
Raises
- NoDataError: Raised when the response text was too short (less than two lines) and
the
graceful
parameter has been set to false. - ValueError: Raised when the response text is inconsistent and the
graceful
parameter has been set to false, or if parsing fails for any other unforeseen reason.
def
time_rel_to_abs(minutes_from_now):
195def time_rel_to_abs(minutes_from_now): 196 """Convert a relative time given in minutes from now to a datetime object. 197 198 Parameters 199 ---------- 200 minutes_from_now : int or int-like 201 The relative time in minutes to be converted. 202 203 Returns 204 ------- 205 datetime 206 The absolute time point as a datetime object. 207 """ 208 now = datetime.now().replace(second=0, microsecond=0) 209 abstime = now + timedelta(minutes=int(minutes_from_now)) 210 return abstime
Convert a relative time given in minutes from now to a datetime object.
Parameters
- minutes_from_now (int or int-like): The relative time in minutes to be converted.
Returns
- datetime: The absolute time point as a datetime object.
def
fmt_time(time):
213def fmt_time(time): 214 """Format a `datetime` or `None` object to string. 215 216 This is useful to apply it to booking times as they might be `None` e.g. in 217 case they have been created from a "nextbooking" response. 218 219 Parameters 220 ---------- 221 time : datetime.datetime or None 222 223 Returns 224 ------- 225 str 226 The formatted time, or a specific string in case the input was `None`. 227 """ 228 if time is None: 229 return "===UNDEFINED===" 230 return datetime.strftime(time, "%Y-%m-%d %H:%M")
Format a datetime
or None
object to string.
This is useful to apply it to booking times as they might be None
e.g. in
case they have been created from a "nextbooking" response.
Parameters
- time (datetime.datetime or None):
Returns
- str: The formatted time, or a specific string in case the input was
None
.