Coverage for phml\components.py: 100%

116 statements  

« 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 

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", "parse_cmpt_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 @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 """ 

115 

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

117 parts = path.split("/") 

118 

119 return ".".join( 

120 [ 

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

122 parse_cmpt_name(parts[-1]), 

123 ], 

124 ) 

125 

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

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

128 return self._cache 

129 

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 } 

140 

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

142 ast = self._parser.parse(content) 

143 

144 component: ComponentType = DEFAULT_COMPONENT() 

145 context = Embedded("", path) 

146 

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) 

152 

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) 

165 

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

171 

172 return component 

173 

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. 

178 

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

188 

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. 

193 

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

203 

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. 

214 

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

246 

247 self.validate(content) 

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

249 self.components[name] = content 

250 

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

252 yield from self.components.items() 

253 

254 def keys(self): 

255 return self.components.keys() 

256 

257 def values(self): 

258 return self.components.values() 

259 

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

261 return key in self.components 

262 

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

264 return self.components[key] 

265 

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

267 # TODO: Custom error 

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

269 

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] 

275 

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 ) 

281 

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 ) 

286 

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 ) 

298 

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 ) 

310 

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 )