Coverage for functions \ flipdare \ app_globals.py: 100%

0 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from __future__ import annotations 

14 

15from typing import Any, TypeGuard 

16from dataclasses import dataclass 

17import flask 

18from email.utils import formataddr 

19from html import escape 

20from email_validator import EmailNotValidError, validate_email 

21 

22from pydantic import TypeAdapter, HttpUrl, ValidationError 

23 

24from flipdare.app_log import LOG 

25from flipdare.constants import ( 

26 FIRESTORE_DOC_ID_LENGTH, 

27 IS_TRACE, 

28 MAX_ERROR_STRING_LENGTH, 

29) 

30 

31__all__ = [ 

32 "ValidatedEmailResult", 

33 "validate_test_clock", 

34 "is_valid_url", 

35 "is_valid_email", 

36 "format_email_address", 

37 "short_endpoint", 

38 "is_text_present", 

39 "is_letters_present", 

40 "is_valid_doc_id", 

41 "sanitize_input", 

42 "sanitize_search_input", 

43 "truncate_error", 

44 "truncate_string_exclude", 

45 "truncate_string", 

46 "get_header_parameter", 

47 "get_pretty_split_str", 

48 "string_has_alpha", 

49] 

50 

51 

52@dataclass 

53class ValidatedEmailResult: 

54 normalized: str 

55 error: str | None = None 

56 

57 @property 

58 def is_error(self) -> bool: 

59 return self.error is not None 

60 

61 @property 

62 def is_valid(self) -> bool: 

63 return self.error is None 

64 

65 

66def validate_test_clock(test_clock: str | None) -> str | None: 

67 """Validate test clock - only allowed in non-production environments.""" 

68 from flipdare.app_env import get_app_environment 

69 from flipdare.app_log import LOG 

70 

71 if get_app_environment().in_cloud and test_clock is not None: 

72 LOG().warning( 

73 "Test clock is not supported in production environment. " 

74 "Ignoring test clock for Stripe operation.", 

75 ) 

76 return None 

77 return test_clock 

78 

79 

80def is_valid_url(text: str) -> bool: 

81 try: 

82 TypeAdapter(HttpUrl).validate_python(text) 

83 return True 

84 except ValidationError: 

85 return False 

86 

87 

88def is_valid_email(email: str, check_deliverability: bool = False) -> ValidatedEmailResult: 

89 if IS_TRACE: 

90 LOG().debug(f"Checking email {email} with deliverability={check_deliverability}") 

91 

92 try: 

93 emailinfo = validate_email(email, check_deliverability=check_deliverability) 

94 email = emailinfo.normalized 

95 return ValidatedEmailResult(normalized=email) 

96 except EmailNotValidError as e: 

97 return ValidatedEmailResult(normalized=email, error=str(e)) 

98 

99 

100def format_email_address(name: str, email: str) -> str: 

101 """Format email address for sending (e.g., "John Doe <jon@test.com>").""" 

102 return formataddr((name, email)) 

103 

104 

105def short_endpoint(endpoint: str) -> str: 

106 # if the endpoint is a url/ path, we want to extract the last part for better error messages 

107 return endpoint.rsplit("/", maxsplit=1)[-1] if "/" in endpoint else endpoint 

108 

109 

110def is_text_present(text: str | None) -> TypeGuard[str]: 

111 """Returns True if the string is a valid, non-empty str.""" 

112 # If this returns True, the type checker knows 'text' is a 'str' 

113 return bool(text and text.strip()) 

114 

115 

116def is_letters_present(text: str) -> bool: 

117 """Returns True if the string contains at least one letter.""" 

118 return any(char.isalpha() for char in text) 

119 

120 

121def is_valid_doc_id(id_str: str) -> bool: 

122 """ 

123 Validate Firebase ID format. 

124 Firebase Authentication User UIDs: 

125 - DONT GENERATE IDs, to ensure they are unique and 28 chars. 

126 - These are typically 28 characters long when automatically generated. 

127 - When assigning a custom UID, it must be a string between 

128 1 and 128 characters long, inclusive. 

129 Cloud Firestore Document IDs: 

130 - DONT GENERATE IDs, to ensure they are unique and 20 chars. 

131 - When automatically generated, these IDs are 20 characters long. 

132 - If a custom string ID is used, its length can vary, 

133 but it is subject to the general Firestore limits for string fields, 

134 which include a maximum length of 1,500 bytes for query indexing purposes, 

135 and a total string size up to 1 MiB - 89 bytes. 

136 """ 

137 # NOTE: lengths are not reliable, so just sanity check 

138 return not (len(id_str) < 1 or len(id_str) > FIRESTORE_DOC_ID_LENGTH or not id_str.isalnum()) 

139 

140 

141def sanitize_input(input_str: str, max_length: int = 100, should_strip: bool = False) -> str: 

142 """Sanitize user input to prevent injection attacks.""" 

143 if not input_str or input_str.strip() == "": 

144 return "" 

145 

146 if should_strip: 

147 input_str = input_str.strip() 

148 

149 # Remove any potentially dangerous characters 

150 sanitized = str(escape(input_str)) 

151 # Truncate to max length 

152 return _truncate(sanitized, max_length=max_length) 

153 

154 

155def sanitize_search_input(value: str) -> str: 

156 """Sanitizes the input value for search queries.""" 

157 # strip, if spaces in between words wrap in quotes 

158 value = sanitize_input(value, should_strip=True) 

159 return value if " " not in value else f'"{value}"' 

160 

161 

162def truncate_error(text: str, max_length: int = MAX_ERROR_STRING_LENGTH) -> str: 

163 return _truncate(text, max_length) 

164 

165 

166def truncate_string_exclude( 

167 text: str, 

168 exclude: set[str], 

169 max_length: int = 24, 

170 include_more: bool = False, 

171) -> str: 

172 # remove all exclude chars from the text 

173 sanitized_text = "".join(char for char in text if char not in exclude) 

174 

175 if len(sanitized_text) <= max_length: 

176 return sanitized_text 

177 

178 return _truncate(sanitized_text, max_length=max_length, include_more=include_more) 

179 

180 

181def truncate_string(text: str, max_length: int = 24) -> str: 

182 return _truncate(text, max_length) 

183 

184 

185def _truncate(text: str, max_length: int, include_more: bool = True) -> str: 

186 """Truncate input string to a maximum length.""" 

187 if len(text) <= max_length: 

188 return text 

189 

190 more_len = 4 if include_more else 0 

191 if max_length <= more_len: 

192 raise ValueError( 

193 f"max_length must be greater than {more_len} when include_more is {include_more}." 

194 ) 

195 

196 truncated = text[: max_length - more_len].rstrip() # reserve space for " ..." 

197 

198 # edge cases: 

199 # 1. ' a' -> since char separated by space, remove the ' a' and add ' ...' 

200 # 2. 'a ' -> since char separated by space, remove the 'a ' and add ' ...' 

201 last_chars = truncated[max_length - more_len - 2 : max_length - more_len].strip() 

202 if last_chars == "" or len(last_chars) == 1: 

203 truncated = truncated[: max_length - more_len - 2].rstrip() 

204 

205 return f"{truncated} ..." if include_more else truncated 

206 

207 

208def get_header_parameter(request: flask.Request, param: str) -> Any | None: 

209 """Extract parameter from request headers.""" 

210 return request.headers.get(param) 

211 

212 

213def get_pretty_split_str( 

214 original: str, 

215 sep: str = ",", 

216 indent: bool = True, 

217) -> str: # pragma: no cover 

218 sep = "\t" 

219 if not indent: 

220 sep = "" 

221 

222 entries = original.split(sep) 

223 result = f"\n{sep}".join(sorted(entries)) 

224 if indent: 

225 result = "\t" + result 

226 return result 

227 

228 

229def string_has_alpha(s: str) -> bool: 

230 return any(char.isalpha() for char in s)