Coverage for phml\core.py: 99%

159 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-06 14:03 -0500

1from __future__ import annotations 

2 

3import os 

4import sys 

5from contextlib import contextmanager 

6from importlib import import_module 

7from pathlib import Path 

8from typing import TYPE_CHECKING, Any, overload 

9 

10if TYPE_CHECKING: 

11 from collections.abc import Iterator 

12 

13from .compiler import HypertextMarkupCompiler 

14from .components import ComponentManager, ComponentType 

15from .embedded import __FROM_IMPORTS__, __IMPORTS__, Module 

16from .helpers import PHMLTryCatch 

17from .nodes import AST, Node, Parent 

18from .parser import HypertextMarkupParser 

19 

20 

21class HypertextManager: 

22 parser: HypertextMarkupParser 

23 """PHML parser.""" 

24 compiler: HypertextMarkupCompiler 

25 """PHML compiler to HTML.""" 

26 components: ComponentManager 

27 """PHML component parser and manager.""" 

28 context: dict[str, Any] 

29 """PHML global variables to expose to each phml file compiled with this instance. 

30 This is the highest scope and is overridden by more specific scoped variables. 

31 """ 

32 

33 def __init__(self) -> None: 

34 self.parser = HypertextMarkupParser() 

35 self.compiler = HypertextMarkupCompiler() 

36 self.components = ComponentManager() 

37 self.context = {"Module": Module} 

38 self._ast: AST | None = None 

39 self._from_path = None 

40 self._from_file = None 

41 self._to_file = None 

42 

43 @staticmethod 

44 @contextmanager 

45 def open( 

46 _from: str | Path, 

47 _to: str | Path | None = None, 

48 ) -> Iterator[HypertextManager]: 

49 with PHMLTryCatch(): 

50 core = HypertextManager() 

51 core._from_file = Path(_from).open("r", encoding="utf-8") 

52 core._from_path = _from 

53 if _to is not None: 

54 core._to_file = Path(_to).open("+w", encoding="utf-8") 

55 yield core 

56 core._from_path = None 

57 core._from_file.close() 

58 if core._to_file is not None: 

59 core._to_file.close() 

60 

61 @property 

62 def imports(self) -> dict: 

63 return dict(__IMPORTS__) 

64 

65 @property 

66 def from_imports(self) -> dict: 

67 return dict(__FROM_IMPORTS__) 

68 

69 def add_module( 

70 self, 

71 module: str, 

72 *, 

73 name: str | None = None, 

74 imports: list[str] | None = None, 

75 ) -> NoReturn: 

76 """Pass and imported a python file as a module. The modules are imported and added 

77 to phml's cached imports. These modules are **ONLY** exposed to the python elements. 

78 To use them in the python elements or the other scopes in the files you must use the python 

79 import syntax `import <module>` or `from <module> import <...objects>`. PHML will parse 

80 the imports first and remove them from the python elements. It then checks it's cache of 

81 python modules that have been imported and adds those imported modules to the local context 

82 for each embedded python execution. 

83 

84 Note: 

85 - All imports will have a `.` prefixed to the module name. For example `current/file.py` gets the module 

86 name `.current.file`. This helps seperate and indicate what imports are injected with this method. 

87 Module import syntax will retain it's value, For example suppose the module `..module.name.here` 

88 is added. It is in directory `module/` which is in a sibling directory to `current/`. The path 

89 would look like `parent/ -> module/ -> name/ -> here.py` and the module would keep the name of 

90 `..module.name.here`. 

91 

92 - All paths are resolved with the cwd in mind. 

93 

94 Args: 

95 module (str): Absolute or relative path to a module, or module syntax reference to a module. 

96 name (str): Optional name for the module after it is imported. 

97 imports (list[str]): Optional list of objects to import from the module. Turns the import to 

98 `from <module> import <...objects>` from `import <module>`. 

99 

100 Returns: 

101 str: Name of the imported module. The key to use for indexing imported modules 

102 """ 

103 

104 if module.startswith("~"): 

105 module = module.replace("~", str(Path.home())) 

106 

107 mod = None 

108 file = Path(module).with_suffix(".py") 

109 cwd = os.getcwd() 

110 

111 if file.is_file(): 

112 current = Path(cwd).as_posix() 

113 path = file.resolve().as_posix() 

114 

115 cwd_p = current.split("/") 

116 path_p = path.split("/") 

117 index = 0 

118 for cp, pp in zip(cwd_p, path_p): 

119 if cp != pp: 

120 break 

121 index += 1 

122 

123 name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".") 

124 path = "/".join(path_p[:index]) 

125 

126 # Make the path that is imported form the only path in sys.path 

127 # this will prevent module conflicts and garuntees the correct module is imported 

128 sys_path = list(sys.path) 

129 sys.path = [path] 

130 mod = import_module(name) 

131 sys.path = sys_path 

132 

133 name = f".{name}" 

134 else: 

135 if module.startswith(".."): 

136 current = Path(os.getcwd()).as_posix() 

137 cwd_p = current.split("/") 

138 

139 path = "/".join(cwd_p[:-1]) 

140 

141 sys_path = list(sys.path) 

142 sys.path = [path] 

143 mod = import_module(module.lstrip("..")) 

144 sys.path = sys_path 

145 else: 

146 mod = import_module(module) 

147 name = f".{module.lstrip('..')}" 

148 

149 # Add imported module or module objects to appropriate collection 

150 if imports is not None and len(imports) > 0: 

151 for _import in imports: 

152 if name not in __FROM_IMPORTS__: 

153 __FROM_IMPORTS__[name] = {} 

154 __FROM_IMPORTS__[name].update({_import: getattr(mod, _import)}) 

155 else: 

156 __IMPORTS__[name] = mod 

157 

158 return name 

159 

160 def remove_module(self, module: str, imports: list[str] | None = None): 

161 if not module.startswith("."): 

162 module = f".{module}" 

163 if module in __IMPORTS__ and len(imports or []) == 0: 

164 __IMPORTS__.pop(module, None) 

165 if module in __FROM_IMPORTS__: 

166 if imports is not None and len(imports) > 0: 

167 for _import in imports: 

168 __FROM_IMPORTS__[module].pop(_import, None) 

169 if len(__FROM_IMPORTS__[module]) == 0: 

170 __FROM_IMPORTS__.pop(module, None) 

171 else: 

172 __FROM_IMPORTS__.pop(module, None) 

173 

174 return self 

175 

176 @property 

177 def ast(self) -> AST: 

178 """The current ast that has been parsed. Defaults to None.""" 

179 return self._ast or AST() 

180 

181 def load(self, path: str | Path): 

182 """Loads the contents of a file and sets the core objects ast 

183 to the results after parsing. 

184 """ 

185 with PHMLTryCatch(), Path(path).open("r", encoding="utf-8") as file: 

186 self._from_path = path 

187 self._ast = self.parser.parse(file.read()) 

188 return self 

189 

190 def parse(self, data: str | dict | None = None): 

191 """Parse a given phml string or dict into a phml ast. 

192 

193 Returns: 

194 Instance of the core object for method chaining. 

195 """ 

196 

197 if data is None and self._from_file is None: 

198 raise ValueError( 

199 "Must either provide a phml str/dict to parse or use parse in the open context manager", 

200 ) 

201 

202 with PHMLTryCatch(self._from_path, "phml:__parse__"): 

203 if isinstance(data, dict): 

204 ast = Node.from_dict(data) 

205 if not isinstance(ast, AST) and ast is not None: 

206 ast = AST([ast]) 

207 self._ast = ast 

208 elif data is not None: 

209 self._ast = self.parser.parse(data) 

210 elif self._from_file is not None: 

211 self._ast = self.parser.parse(self._from_file.read()) 

212 

213 return self 

214 

215 def format( 

216 self, 

217 *, 

218 code: str = "", 

219 file: str | Path | None = None, 

220 compress: bool = False, 

221 ) -> str | None: 

222 """Format a phml str or file. 

223 

224 Args: 

225 code (str, optional): The phml str to format. 

226 

227 Kwargs: 

228 file (str, optional): Path to a phml file. Can be used instead of 

229 `code` to parse and format a phml file. 

230 compress (bool, optional): Flag to compress the file and remove new lines. Defaults to False. 

231 

232 Note: 

233 If both `code` and `file` are passed in then both will be formatted with the formatted `code` 

234 bing returned as a string and the formatted `file` being written to the files original location. 

235 

236 Returns: 

237 str: When a phml str is passed in 

238 None: When a file path is passed in. Instead the resulting formatted string is written back to the file. 

239 """ 

240 

241 result = None 

242 if code != "": 

243 self.parse(code) 

244 result = self.compiler.render( 

245 self._ast, 

246 compress, 

247 ) 

248 

249 if file is not None: 

250 self.load(file) 

251 with Path(file).open("+w", encoding="utf-8") as phml_file: 

252 phml_file.write( 

253 self.compiler.render( 

254 self._ast, 

255 compress, 

256 ), 

257 ) 

258 

259 return result 

260 

261 def compile(self, **context: Any) -> Parent: 

262 """Compile the python blocks, python attributes, and phml components and return the resulting ast. 

263 The resulting ast replaces the core objects ast. 

264 """ 

265 context = {**self.context, **context} 

266 if self._ast is not None: 

267 with PHMLTryCatch(self._from_path, "phml:__compile__"): 

268 ast = self.compiler.compile(self._ast, self.components, **context) 

269 return ast 

270 raise ValueError("Must first parse a phml file before compiling to an AST") 

271 

272 def render(self, _compress: bool = False, **context: Any) -> str | None: 

273 """Renders the phml ast into an html string. If currently in a context manager 

274 the resulting string will also be output to an associated file. 

275 """ 

276 context = {**self.context, **context} 

277 if self._ast is not None: 

278 with PHMLTryCatch(self._from_path, "phml:__render"): 

279 result = self.compiler.render( 

280 self.compile(**context), 

281 _compress, 

282 ) 

283 if self._to_file is not None: 

284 self._to_file.write(result) 

285 elif self._from_file is not None and self._from_path is not None: 

286 self._to_file = ( 

287 Path(self._from_path) 

288 .with_suffix(".html") 

289 .open("+w", encoding="utf-8") 

290 ) 

291 self._to_file.write(result) 

292 return result 

293 raise ValueError("Must first parse a phml file before rendering a phml AST") 

294 

295 def write(self, _path: str | Path, _compress: bool = False, **context: Any): 

296 """Render and write the current ast to a file. 

297 

298 Args: 

299 path (str): The output path for the rendered html. 

300 compress (bool): Whether to compress the output. Defaults to False. 

301 """ 

302 with Path(_path).open("+w", encoding="utf-8") as file: 

303 file.write(self.compiler.render(self.compile(**context), _compress)) 

304 return self 

305 

306 @overload 

307 def add(self, file: str, *, ignore: str = ""): 

308 """Add a component to the component manager with a file path. Also, componetes can be added to 

309 the component manager with a name and str or an already parsed component dict. 

310 

311 Args: 

312 file (str): The file path to the component. 

313 ignore (str): The path prefix to remove before creating the comopnent name. 

314 name (str): The name of the component. This is the index/key in the component manager. 

315 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />` 

316 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string 

317 representation of the component, or an already parsed component type dict. 

318 """ 

319 ... 

320 

321 @overload 

322 def add(self, *, name: str, data: str | ComponentType): 

323 """Add a component to the component manager with a file path. Also, componetes can be added to 

324 the component manager with a name and str or an already parsed component dict. 

325 

326 Args: 

327 file (str): The file path to the component. 

328 ignore (str): The path prefix to remove before creating the comopnent name. 

329 name (str): The name of the component. This is the index/key in the component manager. 

330 This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />` 

331 data (str | ComponentType): This is the data that is assigned in the manager. It can be a string 

332 representation of the component, or an already parsed component type dict. 

333 """ 

334 ... 

335 

336 def add( 

337 self, 

338 file: str | None = None, 

339 *, 

340 name: str | None = None, 

341 data: ComponentType | None = None, 

342 ignore: str = "", 

343 ): 

344 """Add a component to the component manager. The components are used by the compiler 

345 when generating html files from phml. 

346 """ 

347 with PHMLTryCatch(file or name or "_cmpt_"): 

348 self.components.add(file, name=name, data=data, ignore=ignore) 

349 

350 def remove(self, key: str): 

351 """Remove a component from the component manager based on the components name/tag.""" 

352 self.components.remove(key) 

353 

354 def expose(self, _context: dict[str, Any] | None = None, **context: Any): 

355 """Expose global variables to each phml file compiled with this instance. 

356 This data is the highest scope and will be overridden by more specific 

357 scoped variables with equivelant names. 

358 """ 

359 

360 if _context: 

361 self.context.update(_context or {}) 

362 self.context.update(context) 

363 

364 def redact(self, *keys: str): 

365 """Remove global variable from this instance.""" 

366 for key in keys: 

367 self.context.pop(key, None)