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):
56@lru_cache(maxsize=500)
57def get_param_count(func):
58    return len(inspect.signature(func).parameters)
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.