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
« 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#
13from __future__ import annotations
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
22from pydantic import TypeAdapter, HttpUrl, ValidationError
24from flipdare.app_log import LOG
25from flipdare.constants import (
26 FIRESTORE_DOC_ID_LENGTH,
27 IS_TRACE,
28 MAX_ERROR_STRING_LENGTH,
29)
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]
52@dataclass
53class ValidatedEmailResult:
54 normalized: str
55 error: str | None = None
57 @property
58 def is_error(self) -> bool:
59 return self.error is not None
61 @property
62 def is_valid(self) -> bool:
63 return self.error is None
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
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
80def is_valid_url(text: str) -> bool:
81 try:
82 TypeAdapter(HttpUrl).validate_python(text)
83 return True
84 except ValidationError:
85 return False
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}")
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))
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))
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
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())
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)
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())
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 ""
146 if should_strip:
147 input_str = input_str.strip()
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)
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}"'
162def truncate_error(text: str, max_length: int = MAX_ERROR_STRING_LENGTH) -> str:
163 return _truncate(text, max_length)
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)
175 if len(sanitized_text) <= max_length:
176 return sanitized_text
178 return _truncate(sanitized_text, max_length=max_length, include_more=include_more)
181def truncate_string(text: str, max_length: int = 24) -> str:
182 return _truncate(text, max_length)
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
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 )
196 truncated = text[: max_length - more_len].rstrip() # reserve space for " ..."
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()
205 return f"{truncated} ..." if include_more else truncated
208def get_header_parameter(request: flask.Request, param: str) -> Any | None:
209 """Extract parameter from request headers."""
210 return request.headers.get(param)
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 = ""
222 entries = original.split(sep)
223 result = f"\n{sep}".join(sorted(entries))
224 if indent:
225 result = "\t" + result
226 return result
229def string_has_alpha(s: str) -> bool:
230 return any(char.isalpha() for char in s)