Coverage for phml\components.py: 100%
116 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-12 14:26 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-12 14:26 -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", "parse_cmpt_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 @staticmethod
111 def generate_name(path: str, ignore: str = "") -> str:
112 """Generate a component name based on it's path. Optionally strip part of the path
113 from the beginning.
114 """
116 path = Path(os.path.relpath(path, ignore)).as_posix()
117 parts = path.split("/")
119 return ".".join(
120 [
121 *[part[0].upper() + part[1:].lower() for part in parts[:-1]],
122 parse_cmpt_name(parts[-1]),
123 ],
124 )
126 def get_cache(self) -> dict[str, ComponentCacheType]:
127 """Get the current cache of component scripts and styles"""
128 return self._cache
130 def cache(self, key: str, value: ComponentType):
131 """Add a cache for a specific component. Will only add the cache if
132 the component is new and unique.
133 """
134 if key not in self._cache:
135 self._cache[key] = {
136 "hash": value["hash"],
137 "scripts": value["scripts"],
138 "styles": value["styles"],
139 }
141 def parse(self, content: str, path: str = "") -> ComponentType:
142 ast = self._parser.parse(content)
144 component: ComponentType = DEFAULT_COMPONENT()
145 context = Embedded("", path)
147 for node in iterate_nodes(ast):
148 if isinstance(node, Element) and node.tag == "python":
149 context += Embedded(node, path)
150 if node.parent is not None:
151 node.parent.remove(node)
153 for node in ast:
154 if isinstance(node, Element):
155 if node.tag == "script" and len(node) == 1 and Literal.is_text(node[0]):
156 component["scripts"].append(node)
157 elif (
158 node.tag == "style" and len(node) == 1 and Literal.is_text(node[0])
159 ):
160 component["styles"].append(node)
161 else:
162 component["elements"].append(node)
163 elif isinstance(node, Literal):
164 component["elements"].append(node)
166 component["props"] = context.context.pop("Props", {})
167 component["context"] = context.context
168 if len(component["elements"]) == 0:
169 raise ValueError("Must have at least one root element in component")
170 component["hash"] = f"~{hash_component(component)}"
172 return component
174 @overload
175 def add(self, file: str | Path, *, name: str|None = None, ignore: str = ""):
176 """Add a component to the component manager with a file path. Also, componetes can be added to
177 the component manager with a name and str or an already parsed component dict.
179 Args:
180 file (str): The file path to the component.
181 ignore (str): The path prefix to remove before creating the comopnent name.
182 name (str): The name of the component. This is the index/key in the component manager.
183 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
184 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
185 representation of the component, or an already parsed component type dict.
186 """
187 ...
189 @overload
190 def add(self, *, name: str, data: str | ComponentType):
191 """Add a component to the component manager with a file path. Also, componetes can be added to
192 the component manager with a name and str or an already parsed component dict.
194 Args:
195 file (str): The file path to the component.
196 ignore (str): The path prefix to remove before creating the comopnent name.
197 name (str): The name of the component. This is the index/key in the component manager.
198 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
199 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
200 representation of the component, or an already parsed component type dict.
201 """
202 ...
204 def add(
205 self,
206 file: str | Path | None = None,
207 *,
208 name: str | None = None,
209 data: str | ComponentType | None = None,
210 ignore: str = "",
211 ):
212 """Add a component to the component manager with a file path. Also, componetes can be added to
213 the component manager with a name and str or an already parsed component dict.
215 Args:
216 file (str): The file path to the component.
217 ignore (str): The path prefix to remove before creating the comopnent name.
218 name (str): The name of the component. This is the index/key in the component manager.
219 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
220 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
221 representation of the component, or an already parsed component type dict.
222 """
223 content: ComponentType = DEFAULT_COMPONENT()
224 if file is None:
225 if name is None:
226 raise ValueError(
227 "Expected both 'name' and 'data' kwargs to be used together",
228 )
229 if isinstance(data, str):
230 if data == "":
231 raise ValueError(
232 "Expected component data to be a string of length longer that 0",
233 )
234 content.update(self.parse(data, "_cmpt_"))
235 elif isinstance(data, dict):
236 content.update(data)
237 else:
238 raise ValueError(
239 "Expected component data to be a string or a ComponentType dict",
240 )
241 else:
242 file = Path(file)
243 with file.open("r", encoding="utf-8") as c_file:
244 name = name or self.generate_name(file.as_posix(), ignore)
245 content.update(self.parse(c_file.read(), file.as_posix()))
247 self.validate(content)
248 content["hash"] = name + content["hash"]
249 self.components[name] = content
251 def __iter__(self) -> Iterator[tuple[str, ComponentType]]:
252 yield from self.components.items()
254 def keys(self):
255 return self.components.keys()
257 def values(self):
258 return self.components.values()
260 def __contains__(self, key: str) -> bool:
261 return key in self.components
263 def __getitem__(self, key: str) -> ComponentType:
264 return self.components[key]
266 def __setitem__(self, key: str, value: ComponentType):
267 # TODO: Custom error
268 raise Exception("Cannot set components from slice assignment")
270 def remove(self, key: str):
271 """Remove a comopnent from the manager with a specific tag/name."""
272 if key not in self.components:
273 raise KeyError(f"{key} is not a known component")
274 del self.components[key]
276 def validate(self, data: ComponentType):
277 if "props" not in data or not isinstance(data["props"], dict):
278 raise ValueError(
279 "Expected ComponentType 'props' that is a dict of str to any value",
280 )
282 if "context" not in data or not isinstance(data["context"], dict):
283 raise ValueError(
284 "Expected ComponentType 'context' that is a dict of str to any value",
285 )
287 if (
288 "scripts" not in data
289 or not isinstance(data["scripts"], list)
290 or not all(
291 isinstance(script, Element) and script.tag == "script"
292 for script in data["scripts"]
293 )
294 ):
295 raise ValueError(
296 "Expected ComponentType 'script' that is alist of phml elements with a tag of 'script'",
297 )
299 if (
300 "styles" not in data
301 or not isinstance(data["styles"], list)
302 or not all(
303 isinstance(style, Element) and style.tag == "style"
304 for style in data["styles"]
305 )
306 ):
307 raise ValueError(
308 "Expected ComponentType 'styles' that is a list of phml elements with a tag of 'style'",
309 )
311 if (
312 "elements" not in data
313 or not isinstance(data["elements"], list)
314 or len(data["elements"]) == 0
315 or not all(
316 isinstance(element, (Element, Literal)) for element in data["elements"]
317 )
318 ):
319 raise ValueError(
320 "Expected ComponentType 'elements' to be a list of at least one Element or Literal",
321 )