Coverage for phml\components.py: 100%

114 statements  

« 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 

6 

7from .embedded import Embedded 

8from .helpers import iterate_nodes 

9from .nodes import Element, Literal 

10from .parser import HypertextMarkupParser 

11 

12__all__ = ["ComponentType", "ComponentManager", "tokenize_name"] 

13 

14 

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] 

22 

23 

24class ComponentCacheType(TypedDict): 

25 hash: str 

26 scripts: list[Element] 

27 styles: list[Element] 

28 

29 

30def DEFAULT_COMPONENT() -> ComponentType: 

31 return { 

32 "hash": "", 

33 "props": {}, 

34 "context": {}, 

35 "scripts": [], 

36 "styles": [], 

37 "elements": [], 

38 } 

39 

40 

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 

52 

53 Args: 

54 name (str): File name without extension 

55 normalize (bool): Make all tokens fully lowercase. Defaults to True 

56 

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() 

66 

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) 

76 

77 if normalize: 

78 result = result.lower() 

79 

80 if len(result) > 0: 

81 if title_case: 

82 result = result[0].upper() + result[1:] 

83 tokens.append(result) 

84 return tokens 

85 

86 

87def _parse_cmpt_name(name: str) -> str: 

88 tokens = tokenize_name(name.rsplit(".", 1)[0], normalize=True, title_case=True) 

89 return "".join(tokens) 

90 

91 

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 ) 

100 

101 

102class ComponentManager: 

103 components: dict[str, ComponentType] 

104 

105 def __init__(self) -> None: 

106 self.components = {} 

107 self._parser = HypertextMarkupParser() 

108 self._cache: dict[str, ComponentCacheType] = {} 

109 

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 """ 

114 

115 path = Path(os.path.relpath(path, ignore)).as_posix() 

116 parts = path.split("/") 

117 

118 return ".".join( 

119 [ 

120 *[part[0].upper() + part[1:].lower() for part in parts[:-1]], 

121 _parse_cmpt_name(parts[-1]), 

122 ], 

123 ) 

124 

125 def get_cache(self) -> dict[str, ComponentCacheType]: 

126 """Get the current cache of component scripts and styles""" 

127 return self._cache 

128 

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 } 

139 

140 def parse(self, content: str, path: str = "") -> ComponentType: 

141 ast = self._parser.parse(content) 

142 

143 component: ComponentType = DEFAULT_COMPONENT() 

144 context = Embedded("", path) 

145 

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) 

151 

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) 

164 

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)}" 

170 

171 return component 

172 

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. 

177 

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 ... 

187 

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. 

192 

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 ... 

202 

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. 

213 

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)) 

244 

245 self.validate(content) 

246 content["hash"] = name + content["hash"] 

247 self.components[name] = content 

248 

249 def __iter__(self) -> Iterator[tuple[str, ComponentType]]: 

250 yield from self.components.items() 

251 

252 def keys(self) -> Iterator[str]: 

253 yield from self.components.keys() 

254 

255 def values(self) -> Iterator[ComponentType]: 

256 yield from self.components.values() 

257 

258 def __contains__(self, key: str) -> bool: 

259 return key in self.components 

260 

261 def __getitem__(self, key: str) -> ComponentType: 

262 return self.components[key] 

263 

264 def __setitem__(self, key: str, value: ComponentType): 

265 # TODO: Custom error 

266 raise Exception("Cannot set components from slice assignment") 

267 

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] 

273 

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 ) 

279 

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 ) 

284 

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 ) 

296 

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 ) 

308 

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 )