html_compose.util_funcs
Library utility functions
Not intended to be used directly by library users.
1""" 2Library utility functions 3 4Not intended to be used directly by library users. 5""" 6 7import inspect 8import json 9from collections.abc import Iterable 10from functools import lru_cache 11from os import getenv 12from pathlib import PurePath 13from typing import Any, Generator 14 15 16def join_attrs(k, value_trusted): 17 """ 18 Join escaped value to key in form key="value" 19 """ 20 return f'{k}="{value_trusted}"' 21 22 23def is_iterable_but_not_str(input_iterable: Any) -> bool: 24 """ 25 Check if an iterable is not a string or bytes. 26 Which prevents some bugs. 27 """ 28 return isinstance(input_iterable, Iterable) and not isinstance( 29 input_iterable, (str, bytes) 30 ) 31 32 33def flatten_iterable(input_iterable: Iterable) -> Generator[Any, None, None]: 34 """ 35 Flatten an iterable of iterables into a single iterable 36 """ 37 stack = [iter(input_iterable)] 38 39 while stack: 40 try: 41 # Get next element from top iterator on the stack 42 current = next(stack[-1]) 43 if is_iterable_but_not_str(current): 44 stack.append( 45 iter(current) 46 ) # Push new iterator for the current iterable item 47 else: 48 # Item isn't iterator, yield it. 49 yield current 50 except StopIteration: 51 # The iterator was exhausted 52 stack.pop() 53 54 55@lru_cache(maxsize=500) 56def get_param_count(func): 57 return len(inspect.signature(func).parameters) 58 59 60def safe_name(name): 61 """ 62 Some names are reserved in Python, so we need to add an underscore 63 An underscore after was chosen so type hints match what user is looking for 64 """ 65 # Keywords 66 if name in ("class", "is", "for", "as", "async", "del"): 67 name = name + "_" 68 69 if "-" in name: 70 # Fixes for 'accept-charset' etc. 71 name = name.replace("-", "_") 72 73 return name 74 75 76def get_livereload_env() -> dict | None: 77 enabled = getenv("HTMLCOMPOSE_LIVERELOAD") == "1" 78 if not enabled: 79 return None 80 flags = getenv("HTMLCOMPOSE_LIVERELOAD_FLAGS") 81 if not flags: 82 return None 83 try: 84 return json.loads(flags) 85 except json.JSONDecodeError: 86 raise ValueError("Invalid JSON in HTMLCOMPOSE_LIVERELOAD_FLAGS") 87 88 89def generate_livereload_env( 90 host: str, port: int, proxy_host: str | None, proxy_uri: str | None = None 91) -> dict: 92 flags = { 93 "port": port, 94 "host": host, 95 "proxy_host": proxy_host, 96 "proxy_uri": proxy_uri, 97 } 98 return { 99 "HTMLCOMPOSE_LIVERELOAD_FLAGS": json.dumps(flags), 100 "HTMLCOMPOSE_LIVERELOAD": "1", 101 } 102 103 104def glob_matcher(pattern, path): 105 """ 106 Implementation of glob matcher which supports: 107 recursive globbing i.e. ** 108 dir name matching via trailing / 109 110 Notes: 111 In Python 3.13 PurePath implemented full_match, but we don't 112 have that in 3.10. 113 """ 114 pure_path = PurePath(path) 115 pure_pattern = PurePath(pattern) 116 path_parts = pure_path.parts 117 glob_parts = pure_pattern.parts 118 is_double_star = "**" in pattern 119 120 def _segment_match(pattern_segment, path_segment): 121 """Match a single path segment against a pattern segment.""" 122 # fnmatch doesn't handle asterisk matching quite the same, 123 # so we use PurePath.match for * and? patterns. 124 return PurePath(path_segment).match(pattern_segment) 125 126 def _section_match( 127 pattern_segment: tuple, path_section: tuple, terminates=False 128 ): 129 """ 130 Match a single path segment against a pattern segment. 131 Uses PurePath's match for * and ? patterns. 132 133 :param pattern_segment: A tuple of pattern segments. 134 :type pattern_segment: tuple 135 :param path_section: A tuple of path segments. 136 :type path_section: tuple 137 :return: Whether pattern_segment completely matches path_section. 138 If terminates is True, pattern_segment must match to the end 139 of path_section instead of just the size of the pattern 140 :rtype: bool 141 """ 142 if len(pattern_segment) > len(path_section): 143 return False 144 145 if terminates and (len(pattern_segment) != len(path_section)): 146 return False 147 148 # We match segment by segment because PurePath will do weird generalizations 149 # such as matching "*.txt" against dir/file.txt 150 for i, seg in enumerate(pattern_segment): 151 if not _segment_match(seg, path_section[i]): 152 return False 153 154 return True 155 156 # Simple: We can just match the path_parts against the glob_parts 157 if not is_double_star: 158 if pattern.endswith("/"): 159 last_section = path_parts[0 : len(glob_parts)] 160 return _section_match(glob_parts, last_section, terminates=False) 161 162 return _section_match(glob_parts, path_parts, terminates=True) 163 164 # Oops, there's a double star. 165 # Build lists split on ** 166 glob_sections = [] 167 glob_section = [] 168 169 for part in glob_parts: 170 if part == "**": 171 glob_sections.append(glob_section) 172 glob_section = [] 173 else: 174 glob_section.append(part) 175 176 if glob_section: 177 glob_sections.append(glob_section) 178 179 j = 0 180 for i, current in enumerate(glob_sections): 181 is_last_glob = i == len(glob_sections) - 1 182 term = is_last_glob and not pattern.endswith("/") 183 matched = False 184 while j < len(path_parts): 185 if _section_match(current, path_parts[j:], terminates=term): 186 matched = True 187 break 188 if i > 0: 189 j += 1 190 else: 191 break 192 if not matched: 193 return False 194 return True
def
join_attrs(k, value_trusted):
17def join_attrs(k, value_trusted): 18 """ 19 Join escaped value to key in form key="value" 20 """ 21 return f'{k}="{value_trusted}"'
Join escaped value to key in form key="value"
def
is_iterable_but_not_str(input_iterable: Any) -> bool:
24def is_iterable_but_not_str(input_iterable: Any) -> bool: 25 """ 26 Check if an iterable is not a string or bytes. 27 Which prevents some bugs. 28 """ 29 return isinstance(input_iterable, Iterable) and not isinstance( 30 input_iterable, (str, bytes) 31 )
Check if an iterable is not a string or bytes. Which prevents some bugs.
def
flatten_iterable(input_iterable: Iterable) -> Generator[Any, NoneType, NoneType]:
34def flatten_iterable(input_iterable: Iterable) -> Generator[Any, None, None]: 35 """ 36 Flatten an iterable of iterables into a single iterable 37 """ 38 stack = [iter(input_iterable)] 39 40 while stack: 41 try: 42 # Get next element from top iterator on the stack 43 current = next(stack[-1]) 44 if is_iterable_but_not_str(current): 45 stack.append( 46 iter(current) 47 ) # Push new iterator for the current iterable item 48 else: 49 # Item isn't iterator, yield it. 50 yield current 51 except StopIteration: 52 # The iterator was exhausted 53 stack.pop()
Flatten an iterable of iterables into a single iterable
@lru_cache(maxsize=500)
def
get_param_count(func):
def
safe_name(name):
61def safe_name(name): 62 """ 63 Some names are reserved in Python, so we need to add an underscore 64 An underscore after was chosen so type hints match what user is looking for 65 """ 66 # Keywords 67 if name in ("class", "is", "for", "as", "async", "del"): 68 name = name + "_" 69 70 if "-" in name: 71 # Fixes for 'accept-charset' etc. 72 name = name.replace("-", "_") 73 74 return name
Some names are reserved in Python, so we need to add an underscore An underscore after was chosen so type hints match what user is looking for
def
get_livereload_env() -> dict | None:
77def get_livereload_env() -> dict | None: 78 enabled = getenv("HTMLCOMPOSE_LIVERELOAD") == "1" 79 if not enabled: 80 return None 81 flags = getenv("HTMLCOMPOSE_LIVERELOAD_FLAGS") 82 if not flags: 83 return None 84 try: 85 return json.loads(flags) 86 except json.JSONDecodeError: 87 raise ValueError("Invalid JSON in HTMLCOMPOSE_LIVERELOAD_FLAGS")
def
generate_livereload_env( host: str, port: int, proxy_host: str | None, proxy_uri: str | None = None) -> dict:
90def generate_livereload_env( 91 host: str, port: int, proxy_host: str | None, proxy_uri: str | None = None 92) -> dict: 93 flags = { 94 "port": port, 95 "host": host, 96 "proxy_host": proxy_host, 97 "proxy_uri": proxy_uri, 98 } 99 return { 100 "HTMLCOMPOSE_LIVERELOAD_FLAGS": json.dumps(flags), 101 "HTMLCOMPOSE_LIVERELOAD": "1", 102 }
def
glob_matcher(pattern, path):
105def glob_matcher(pattern, path): 106 """ 107 Implementation of glob matcher which supports: 108 recursive globbing i.e. ** 109 dir name matching via trailing / 110 111 Notes: 112 In Python 3.13 PurePath implemented full_match, but we don't 113 have that in 3.10. 114 """ 115 pure_path = PurePath(path) 116 pure_pattern = PurePath(pattern) 117 path_parts = pure_path.parts 118 glob_parts = pure_pattern.parts 119 is_double_star = "**" in pattern 120 121 def _segment_match(pattern_segment, path_segment): 122 """Match a single path segment against a pattern segment.""" 123 # fnmatch doesn't handle asterisk matching quite the same, 124 # so we use PurePath.match for * and? patterns. 125 return PurePath(path_segment).match(pattern_segment) 126 127 def _section_match( 128 pattern_segment: tuple, path_section: tuple, terminates=False 129 ): 130 """ 131 Match a single path segment against a pattern segment. 132 Uses PurePath's match for * and ? patterns. 133 134 :param pattern_segment: A tuple of pattern segments. 135 :type pattern_segment: tuple 136 :param path_section: A tuple of path segments. 137 :type path_section: tuple 138 :return: Whether pattern_segment completely matches path_section. 139 If terminates is True, pattern_segment must match to the end 140 of path_section instead of just the size of the pattern 141 :rtype: bool 142 """ 143 if len(pattern_segment) > len(path_section): 144 return False 145 146 if terminates and (len(pattern_segment) != len(path_section)): 147 return False 148 149 # We match segment by segment because PurePath will do weird generalizations 150 # such as matching "*.txt" against dir/file.txt 151 for i, seg in enumerate(pattern_segment): 152 if not _segment_match(seg, path_section[i]): 153 return False 154 155 return True 156 157 # Simple: We can just match the path_parts against the glob_parts 158 if not is_double_star: 159 if pattern.endswith("/"): 160 last_section = path_parts[0 : len(glob_parts)] 161 return _section_match(glob_parts, last_section, terminates=False) 162 163 return _section_match(glob_parts, path_parts, terminates=True) 164 165 # Oops, there's a double star. 166 # Build lists split on ** 167 glob_sections = [] 168 glob_section = [] 169 170 for part in glob_parts: 171 if part == "**": 172 glob_sections.append(glob_section) 173 glob_section = [] 174 else: 175 glob_section.append(part) 176 177 if glob_section: 178 glob_sections.append(glob_section) 179 180 j = 0 181 for i, current in enumerate(glob_sections): 182 is_last_glob = i == len(glob_sections) - 1 183 term = is_last_glob and not pattern.endswith("/") 184 matched = False 185 while j < len(path_parts): 186 if _section_match(current, path_parts[j:], terminates=term): 187 matched = True 188 break 189 if i > 0: 190 j += 1 191 else: 192 break 193 if not matched: 194 return False 195 return True
Implementation of glob matcher which supports: recursive globbing i.e. ** dir name matching via trailing /
Notes: In Python 3.13 PurePath implemented full_match, but we don't have that in 3.10.