Coverage for phml\components.py: 100%
114 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 14:53 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 14:53 -0500
1import os
2from pathlib import Path
3from re import finditer
4from time import time
5from typing import Any, Iterator, TypedDict, overload
7from .embedded import Embedded
8from .helpers import iterate_nodes
9from .nodes import Element, Literal
10from .parser import HypertextMarkupParser
12__all__ = ["ComponentType", "ComponentManager", "tokenize_name"]
15class ComponentType(TypedDict):
16 hash: str
17 props: dict[str, Any]
18 context: dict[str, Any]
19 scripts: list[Element]
20 styles: list[Element]
21 elements: list[Element | Literal]
24class ComponentCacheType(TypedDict):
25 hash: str
26 scripts: list[Element]
27 styles: list[Element]
30def DEFAULT_COMPONENT() -> ComponentType:
31 return {
32 "hash": "",
33 "props": {},
34 "context": {},
35 "scripts": [],
36 "styles": [],
37 "elements": [],
38 }
41def tokenize_name(
42 name: str,
43 *,
44 normalize: bool = False,
45 title_case: bool = False,
46) -> list[str]:
47 """Generates name tokens `some name tokanized` from a filename.
48 Assumes filenames is one of:
49 * snakecase - some_file_name
50 * camel case - someFileName
51 * pascal case - SomeFileName
53 Args:
54 name (str): File name without extension
55 normalize (bool): Make all tokens fully lowercase. Defaults to True
57 Returns:
58 list[str]: List of word tokens.
59 """
60 tokens = []
61 for token in finditer(
62 r"([A-Z])?([a-z]+)|([0-9]+)|([A-Z]+)(?=[^a-z])",
63 name.strip(),
64 ):
65 first, rest, nums, cap = token.groups()
67 result = ""
68 if rest is not None:
69 result = (first or "") + rest
70 elif cap is not None:
71 # Token is all caps. Set to full capture
72 result = cap
73 elif nums is not None:
74 # Token is all numbers. Set to full capture
75 result = str(nums)
77 if normalize:
78 result = result.lower()
80 if len(result) > 0:
81 if title_case:
82 result = result[0].upper() + result[1:]
83 tokens.append(result)
84 return tokens
87def _parse_cmpt_name(name: str) -> str:
88 tokens = tokenize_name(name.rsplit(".", 1)[0], normalize=True, title_case=True)
89 return "".join(tokens)
92def hash_component(cmpt: ComponentType):
93 """Hash a component for applying unique scope identifier"""
94 return (
95 sum(hash(element) for element in cmpt["elements"])
96 + sum(hash(style) for style in cmpt["styles"])
97 + sum(hash(script) for script in cmpt["scripts"])
98 - int(time()%1000)
99 )
102class ComponentManager:
103 components: dict[str, ComponentType]
105 def __init__(self) -> None:
106 self.components = {}
107 self._parser = HypertextMarkupParser()
108 self._cache: dict[str, ComponentCacheType] = {}
110 def generate_name(self, path: str, ignore: str = "") -> str:
111 """Generate a component name based on it's path. Optionally strip part of the path
112 from the beginning.
113 """
115 path = Path(os.path.relpath(path, ignore)).as_posix()
116 parts = path.split("/")
118 return ".".join(
119 [
120 *[part[0].upper() + part[1:].lower() for part in parts[:-1]],
121 _parse_cmpt_name(parts[-1]),
122 ],
123 )
125 def get_cache(self) -> dict[str, ComponentCacheType]:
126 """Get the current cache of component scripts and styles"""
127 return self._cache
129 def cache(self, key: str, value: ComponentType):
130 """Add a cache for a specific component. Will only add the cache if
131 the component is new and unique.
132 """
133 if key not in self._cache:
134 self._cache[key] = {
135 "hash": value["hash"],
136 "scripts": value["scripts"],
137 "styles": value["styles"],
138 }
140 def parse(self, content: str, path: str = "") -> ComponentType:
141 ast = self._parser.parse(content)
143 component: ComponentType = DEFAULT_COMPONENT()
144 context = Embedded("", path)
146 for node in iterate_nodes(ast):
147 if isinstance(node, Element) and node.tag == "python":
148 context += Embedded(node, path)
149 if node.parent is not None:
150 node.parent.remove(node)
152 for node in ast:
153 if isinstance(node, Element):
154 if node.tag == "script" and len(node) == 1 and Literal.is_text(node[0]):
155 component["scripts"].append(node)
156 elif (
157 node.tag == "style" and len(node) == 1 and Literal.is_text(node[0])
158 ):
159 component["styles"].append(node)
160 else:
161 component["elements"].append(node)
162 elif isinstance(node, Literal):
163 component["elements"].append(node)
165 component["props"] = context.context.pop("Props", {})
166 component["context"] = context.context
167 if len(component["elements"]) == 0:
168 raise ValueError("Must have at least one root element in component")
169 component["hash"] = f"~{hash_component(component)}"
171 return component
173 @overload
174 def add(self, file: str, *, ignore: str = ""):
175 """Add a component to the component manager with a file path. Also, componetes can be added to
176 the component manager with a name and str or an already parsed component dict.
178 Args:
179 file (str): The file path to the component.
180 ignore (str): The path prefix to remove before creating the comopnent name.
181 name (str): The name of the component. This is the index/key in the component manager.
182 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
183 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
184 representation of the component, or an already parsed component type dict.
185 """
186 ...
188 @overload
189 def add(self, *, name: str, data: str | ComponentType):
190 """Add a component to the component manager with a file path. Also, componetes can be added to
191 the component manager with a name and str or an already parsed component dict.
193 Args:
194 file (str): The file path to the component.
195 ignore (str): The path prefix to remove before creating the comopnent name.
196 name (str): The name of the component. This is the index/key in the component manager.
197 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
198 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
199 representation of the component, or an already parsed component type dict.
200 """
201 ...
203 def add(
204 self,
205 file: str | None = None,
206 *,
207 name: str | None = None,
208 data: str | ComponentType | None = None,
209 ignore: str = "",
210 ):
211 """Add a component to the component manager with a file path. Also, componetes can be added to
212 the component manager with a name and str or an already parsed component dict.
214 Args:
215 file (str): The file path to the component.
216 ignore (str): The path prefix to remove before creating the comopnent name.
217 name (str): The name of the component. This is the index/key in the component manager.
218 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
219 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
220 representation of the component, or an already parsed component type dict.
221 """
222 content: ComponentType = DEFAULT_COMPONENT()
223 if file is None:
224 if name is None:
225 raise ValueError(
226 "Expected both 'name' and 'data' kwargs to be used together",
227 )
228 if isinstance(data, str):
229 if data == "":
230 raise ValueError(
231 "Expected component data to be a string of length longer that 0",
232 )
233 content.update(self.parse(data, "_cmpt_"))
234 elif isinstance(data, dict):
235 content.update(data)
236 else:
237 raise ValueError(
238 "Expected component data to be a string or a ComponentType dict",
239 )
240 else:
241 with Path(file).open("r", encoding="utf-8") as c_file:
242 name = self.generate_name(file, ignore)
243 content.update(self.parse(c_file.read(), file))
245 self.validate(content)
246 content["hash"] = name + content["hash"]
247 self.components[name] = content
249 def __iter__(self) -> Iterator[tuple[str, ComponentType]]:
250 yield from self.components.items()
252 def keys(self) -> Iterator[str]:
253 yield from self.components.keys()
255 def values(self) -> Iterator[ComponentType]:
256 yield from self.components.values()
258 def __contains__(self, key: str) -> bool:
259 return key in self.components
261 def __getitem__(self, key: str) -> ComponentType:
262 return self.components[key]
264 def __setitem__(self, key: str, value: ComponentType):
265 # TODO: Custom error
266 raise Exception("Cannot set components from slice assignment")
268 def remove(self, key: str):
269 """Remove a comopnent from the manager with a specific tag/name."""
270 if key not in self.components:
271 raise KeyError(f"{key} is not a known component")
272 del self.components[key]
274 def validate(self, data: ComponentType):
275 if "props" not in data or not isinstance(data["props"], dict):
276 raise ValueError(
277 "Expected ComponentType 'props' that is a dict of str to any value",
278 )
280 if "context" not in data or not isinstance(data["context"], dict):
281 raise ValueError(
282 "Expected ComponentType 'context' that is a dict of str to any value",
283 )
285 if (
286 "scripts" not in data
287 or not isinstance(data["scripts"], list)
288 or not all(
289 isinstance(script, Element) and script.tag == "script"
290 for script in data["scripts"]
291 )
292 ):
293 raise ValueError(
294 "Expected ComponentType 'script' that is alist of phml elements with a tag of 'script'",
295 )
297 if (
298 "styles" not in data
299 or not isinstance(data["styles"], list)
300 or not all(
301 isinstance(style, Element) and style.tag == "style"
302 for style in data["styles"]
303 )
304 ):
305 raise ValueError(
306 "Expected ComponentType 'styles' that is a list of phml elements with a tag of 'style'",
307 )
309 if (
310 "elements" not in data
311 or not isinstance(data["elements"], list)
312 or len(data["elements"]) == 0
313 or not all(
314 isinstance(element, (Element, Literal)) for element in data["elements"]
315 )
316 ):
317 raise ValueError(
318 "Expected ComponentType 'elements' to be a list of at least one Element or Literal",
319 )