Coverage for phml\core.py: 99%
159 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 14:03 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 14:03 -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, overload
10if TYPE_CHECKING:
11 from collections.abc import Iterator
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
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 """
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
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()
61 @property
62 def imports(self) -> dict:
63 return dict(__IMPORTS__)
65 @property
66 def from_imports(self) -> dict:
67 return dict(__FROM_IMPORTS__)
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.
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`.
92 - All paths are resolved with the cwd in mind.
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>`.
100 Returns:
101 str: Name of the imported module. The key to use for indexing imported modules
102 """
104 if module.startswith("~"):
105 module = module.replace("~", str(Path.home()))
107 mod = None
108 file = Path(module).with_suffix(".py")
109 cwd = os.getcwd()
111 if file.is_file():
112 current = Path(cwd).as_posix()
113 path = file.resolve().as_posix()
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
123 name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".")
124 path = "/".join(path_p[:index])
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
133 name = f".{name}"
134 else:
135 if module.startswith(".."):
136 current = Path(os.getcwd()).as_posix()
137 cwd_p = current.split("/")
139 path = "/".join(cwd_p[:-1])
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('..')}"
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
158 return name
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)
174 return self
176 @property
177 def ast(self) -> AST:
178 """The current ast that has been parsed. Defaults to None."""
179 return self._ast or AST()
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
190 def parse(self, data: str | dict | None = None):
191 """Parse a given phml string or dict into a phml ast.
193 Returns:
194 Instance of the core object for method chaining.
195 """
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 )
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())
213 return self
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.
224 Args:
225 code (str, optional): The phml str to format.
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.
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.
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 """
241 result = None
242 if code != "":
243 self.parse(code)
244 result = self.compiler.render(
245 self._ast,
246 compress,
247 )
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 )
259 return result
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")
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")
295 def write(self, _path: str | Path, _compress: bool = False, **context: Any):
296 """Render and write the current ast to a file.
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
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.
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 ...
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.
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 ...
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)
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)
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 """
360 if _context:
361 self.context.update(_context or {})
362 self.context.update(context)
364 def redact(self, *keys: str):
365 """Remove global variable from this instance."""
366 for key in keys:
367 self.context.pop(key, None)