Coverage for phml\core.py: 99%

166 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-12 14:26 -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, NoReturn, overload 

9 

10if TYPE_CHECKING: 

11 from collections.abc import Iterator 

12 

13from .compiler import HypertextMarkupCompiler 

14from .components import ComponentManager, ComponentType 

15 

16# TODO: Optimize and extract manipulation of import dicts 

17from .embedded import __FROM_IMPORTS__, __IMPORTS__, Module 

18from .helpers import PHMLTryCatch 

19from .nodes import AST, Node, Parent 

20from .parser import HypertextMarkupParser 

21 

22 

23class HypertextManager: 

24 parser: HypertextMarkupParser 

25 """PHML parser.""" 

26 compiler: HypertextMarkupCompiler 

27 """PHML compiler to HTML.""" 

28 components: ComponentManager 

29 """PHML component parser and manager.""" 

30 context: dict[str, Any] 

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

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

33 """ 

34 

35 def __init__(self) -> None: 

36 self.parser = HypertextMarkupParser() 

37 self.compiler = HypertextMarkupCompiler() 

38 self.components = ComponentManager() 

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

40 self._ast: AST | None = None 

41 self._from_path = None 

42 self._from_file = None 

43 self._to_file = None 

44 

45 @staticmethod 

46 @contextmanager 

47 def open( 

48 _from: str | Path, 

49 _to: str | Path | None = None, 

50 ) -> Iterator[HypertextManager]: 

51 with PHMLTryCatch(): 

52 core = HypertextManager() 

53 core._from_path = Path(_from) 

54 core._from_path.parent.mkdir(parents=True, exist_ok=True) 

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

56 if _to is not None: 

57 output = Path(_to) 

58 output.parent.mkdir(parents=True, exist_ok=True) 

59 core._to_file = output.open("+w", encoding="utf-8") 

60 

61 core.parse() 

62 

63 yield core 

64 

65 core._from_path = None 

66 core._from_file.close() 

67 if core._to_file is not None: 

68 core._to_file.close() 

69 

70 @property 

71 def imports(self) -> dict: 

72 return dict(__IMPORTS__) 

73 

74 @property 

75 def from_imports(self) -> dict: 

76 return dict(__FROM_IMPORTS__) 

77 

78 def add_module( 

79 self, 

80 module: str, 

81 *, 

82 name: str | None = None, 

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

84 ) -> NoReturn: 

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

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

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

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

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

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

91 for each embedded python execution. 

92 

93 Note: 

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

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

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

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

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

99 `..module.name.here`. 

100 

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

102 

103 Args: 

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

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

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

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

108 

109 Returns: 

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

111 """ 

112 

113 if module.startswith("~"): 

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

115 

116 mod = None 

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

118 cwd = os.getcwd() 

119 

120 if file.is_file(): 

121 current = Path(cwd).as_posix() 

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

123 

124 cwd_p = current.split("/") 

125 path_p = path.split("/") 

126 index = 0 

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

128 if cp != pp: 

129 break 

130 index += 1 

131 

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

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

134 

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

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

137 sys_path = list(sys.path) 

138 sys.path = [path] 

139 mod = import_module(name) 

140 sys.path = sys_path 

141 

142 name = f".{name}" 

143 else: 

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

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

146 cwd_p = current.split("/") 

147 

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

149 

150 sys_path = list(sys.path) 

151 sys.path = [path] 

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

153 sys.path = sys_path 

154 else: 

155 mod = import_module(module) 

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

157 

158 # Add imported module or module objects to appropriate collection 

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

160 for _import in imports: 

161 if name not in __FROM_IMPORTS__: 

162 __FROM_IMPORTS__[name] = {} 

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

164 else: 

165 __IMPORTS__[name] = mod 

166 

167 return name 

168 

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

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

171 module = f".{module}" 

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

173 __IMPORTS__.pop(module, None) 

174 if module in __FROM_IMPORTS__: 

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

176 for _import in imports: 

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

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

179 __FROM_IMPORTS__.pop(module, None) 

180 else: 

181 __FROM_IMPORTS__.pop(module, None) 

182 

183 return self 

184 

185 @property 

186 def ast(self) -> AST: 

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

188 return self._ast or AST() 

189 

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

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

192 to the results after parsing. 

193 """ 

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

195 self._from_path = path 

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

197 return self 

198 

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

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

201 

202 Returns: 

203 Instance of the core object for method chaining. 

204 """ 

205 

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

207 raise ValueError( 

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

209 ) 

210 

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

212 if isinstance(data, dict): 

213 ast = Node.from_dict(data) 

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

215 ast = AST([ast]) 

216 self._ast = ast 

217 elif data is not None: 

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

219 elif self._from_file is not None: 

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

221 self._from_file.seek(0) 

222 

223 return self 

224 

225 def format( 

226 self, 

227 *, 

228 code: str = "", 

229 file: str | Path | None = None, 

230 compress: bool = False, 

231 ) -> str | None: 

232 """Format a phml str or file. 

233 

234 Args: 

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

236 

237 Kwargs: 

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

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

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

241 

242 Note: 

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

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

245 

246 Returns: 

247 str: When a phml str is passed in 

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

249 """ 

250 

251 result = None 

252 if code != "": 

253 self.parse(code) 

254 result = self.compiler.render( 

255 self._ast, 

256 compress, 

257 ) 

258 

259 if file is not None: 

260 self.load(file) 

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

262 phml_file.write( 

263 self.compiler.render( 

264 self._ast, 

265 compress, 

266 ), 

267 ) 

268 

269 return result 

270 

271 def compile(self, **context: Any) -> AST: 

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

273 The resulting ast replaces the core objects ast. 

274 """ 

275 context = {**self.context, **context, "_phml_path_": self._from_path} 

276 if self._ast is not None: 

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

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

279 return ast 

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

281 

282 def render( 

283 self, _compress: bool = False, _ast: AST | None = None, **context: Any 

284 ) -> str: 

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

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

287 """ 

288 context = {**self.context, **context, "_phml_path_": self._from_path} 

289 if self._ast is not None: 

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

291 result = self.compiler.render( 

292 _ast or self.compile(**context), 

293 _compress, 

294 ) 

295 

296 if self._to_file is not None: 

297 self._to_file.write(result) 

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

299 self._to_file = ( 

300 Path(self._from_path) 

301 .with_suffix(".html") 

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

303 ) 

304 self._to_file.write(result) 

305 return result 

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

307 

308 def write( 

309 self, 

310 _path: str | Path, 

311 _compress: bool = False, 

312 _ast: AST | None = None, 

313 **context: Any, 

314 ): 

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

316 

317 Args: 

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

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

320 """ 

321 path = Path(_path).with_suffix(".html") 

322 path.parent.mkdir(parents=True, exist_ok=True) 

323 

324 with path.open("+w", encoding="utf-8") as file: 

325 file.write(self.compiler.render(_ast or self.compile(**context), _compress)) 

326 return self 

327 

328 @overload 

329 def add(self, file: str | Path, *, name: str|None=None, ignore: str = ""): 

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

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

332 

333 Args: 

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

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

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

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

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

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

340 """ 

341 ... 

342 

343 @overload 

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

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

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

347 

348 Args: 

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

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

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

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

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

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

355 """ 

356 ... 

357 

358 def add( 

359 self, 

360 file: str | Path | None = None, 

361 *, 

362 name: str | None = None, 

363 data: ComponentType | str | None = None, 

364 ignore: str = "", 

365 ): 

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

367 when generating html files from phml. 

368 """ 

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

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

371 

372 def remove(self, key: str): 

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

374 self.components.remove(key) 

375 

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

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

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

379 scoped variables with equivelant names. 

380 """ 

381 

382 if _context: 

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

384 self.context.update(context) 

385 

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

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

388 for key in keys: 

389 self.context.pop(key, None)