Coverage for phml\core.py: 99%
166 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
1from __future__ import annotations
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
10if TYPE_CHECKING:
11 from collections.abc import Iterator
13from .compiler import HypertextMarkupCompiler
14from .components import ComponentManager, ComponentType
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
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 """
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
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")
61 core.parse()
63 yield core
65 core._from_path = None
66 core._from_file.close()
67 if core._to_file is not None:
68 core._to_file.close()
70 @property
71 def imports(self) -> dict:
72 return dict(__IMPORTS__)
74 @property
75 def from_imports(self) -> dict:
76 return dict(__FROM_IMPORTS__)
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.
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`.
101 - All paths are resolved with the cwd in mind.
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>`.
109 Returns:
110 str: Name of the imported module. The key to use for indexing imported modules
111 """
113 if module.startswith("~"):
114 module = module.replace("~", str(Path.home()))
116 mod = None
117 file = Path(module).with_suffix(".py")
118 cwd = os.getcwd()
120 if file.is_file():
121 current = Path(cwd).as_posix()
122 path = file.resolve().as_posix()
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
132 name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".")
133 path = "/".join(path_p[:index])
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
142 name = f".{name}"
143 else:
144 if module.startswith(".."):
145 current = Path(os.getcwd()).as_posix()
146 cwd_p = current.split("/")
148 path = "/".join(cwd_p[:-1])
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('..')}"
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
167 return name
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)
183 return self
185 @property
186 def ast(self) -> AST:
187 """The current ast that has been parsed. Defaults to None."""
188 return self._ast or AST()
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
199 def parse(self, data: str | dict | None = None):
200 """Parse a given phml string or dict into a phml ast.
202 Returns:
203 Instance of the core object for method chaining.
204 """
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 )
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)
223 return self
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.
234 Args:
235 code (str, optional): The phml str to format.
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.
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.
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 """
251 result = None
252 if code != "":
253 self.parse(code)
254 result = self.compiler.render(
255 self._ast,
256 compress,
257 )
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 )
269 return result
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")
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 )
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")
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.
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)
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
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.
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 ...
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.
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 ...
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)
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)
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 """
382 if _context:
383 self.context.update(_context or {})
384 self.context.update(context)
386 def redact(self, *keys: str):
387 """Remove global variable from this instance."""
388 for key in keys:
389 self.context.pop(key, None)