phml.core

  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
 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_path = Path(_from)
 52            core._from_path.parent.mkdir(parents=True, exist_ok=True)
 53            core._from_file = Path(_from).open("r", encoding="utf-8")
 54            if _to is not None:
 55                output = Path(_to)
 56                output.parent.mkdir(parents=True, exist_ok=True)
 57                core._to_file = output.open("+w", encoding="utf-8")
 58
 59            core.parse()
 60
 61            yield core
 62
 63            core._from_path = None
 64            core._from_file.close()
 65            if core._to_file is not None:
 66                core._to_file.close()
 67
 68    @property
 69    def imports(self) -> dict:
 70        return dict(__IMPORTS__)
 71
 72    @property
 73    def from_imports(self) -> dict:
 74        return dict(__FROM_IMPORTS__)
 75
 76    def add_module(
 77        self,
 78        module: str,
 79        *,
 80        name: str | None = None,
 81        imports: list[str] | None = None,
 82    ) -> NoReturn:
 83        """Pass and imported a python file as a module. The modules are imported and added
 84        to phml's cached imports. These modules are **ONLY** exposed to the python elements.
 85        To use them in the python elements or the other scopes in the files you must use the python
 86        import syntax `import <module>` or `from <module> import <...objects>`. PHML will parse
 87        the imports first and remove them from the python elements. It then checks it's cache of
 88        python modules that have been imported and adds those imported modules to the local context
 89        for each embedded python execution.
 90
 91        Note:
 92            - All imports will have a `.` prefixed to the module name. For example `current/file.py` gets the module
 93            name `.current.file`. This helps seperate and indicate what imports are injected with this method.
 94            Module import syntax will retain it's value, For example suppose the module `..module.name.here`
 95            is added. It is in directory `module/` which is in a sibling directory to `current/`. The path
 96            would look like `parent/ -> module/ -> name/ -> here.py` and the module would keep the name of
 97            `..module.name.here`.
 98
 99            - All paths are resolved with the cwd in mind.
100
101        Args:
102            module (str): Absolute or relative path to a module, or module syntax reference to a module.
103            name (str): Optional name for the module after it is imported.
104            imports (list[str]): Optional list of objects to import from the module. Turns the import to
105                `from <module> import <...objects>` from `import <module>`.
106
107        Returns:
108            str: Name of the imported module. The key to use for indexing imported modules
109        """
110
111        if module.startswith("~"):
112            module = module.replace("~", str(Path.home()))
113
114        mod = None
115        file = Path(module).with_suffix(".py")
116        cwd = os.getcwd()
117
118        if file.is_file():
119            current = Path(cwd).as_posix()
120            path = file.resolve().as_posix()
121
122            cwd_p = current.split("/")
123            path_p = path.split("/")
124            index = 0
125            for cp, pp in zip(cwd_p, path_p):
126                if cp != pp:
127                    break
128                index += 1
129
130            name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".")
131            path = "/".join(path_p[:index])
132
133            # Make the path that is imported form the only path in sys.path
134            # this will prevent module conflicts and garuntees the correct module is imported
135            sys_path = list(sys.path)
136            sys.path = [path]
137            mod = import_module(name)
138            sys.path = sys_path
139
140            name = f".{name}"
141        else:
142            if module.startswith(".."):
143                current = Path(os.getcwd()).as_posix()
144                cwd_p = current.split("/")
145
146                path = "/".join(cwd_p[:-1])
147
148                sys_path = list(sys.path)
149                sys.path = [path]
150                mod = import_module(module.lstrip(".."))
151                sys.path = sys_path
152            else:
153                mod = import_module(module)
154            name = f".{module.lstrip('..')}"
155
156        # Add imported module or module objects to appropriate collection
157        if imports is not None and len(imports) > 0:
158            for _import in imports:
159                if name not in __FROM_IMPORTS__:
160                    __FROM_IMPORTS__[name] = {}
161                __FROM_IMPORTS__[name].update({_import: getattr(mod, _import)})
162        else:
163            __IMPORTS__[name] = mod
164
165        return name
166
167    def remove_module(self, module: str, imports: list[str] | None = None):
168        if not module.startswith("."):
169            module = f".{module}"
170        if module in __IMPORTS__ and len(imports or []) == 0:
171            __IMPORTS__.pop(module, None)
172        if module in __FROM_IMPORTS__:
173            if imports is not None and len(imports) > 0:
174                for _import in imports:
175                    __FROM_IMPORTS__[module].pop(_import, None)
176                if len(__FROM_IMPORTS__[module]) == 0:
177                    __FROM_IMPORTS__.pop(module, None)
178            else:
179                __FROM_IMPORTS__.pop(module, None)
180
181        return self
182
183    @property
184    def ast(self) -> AST:
185        """The current ast that has been parsed. Defaults to None."""
186        return self._ast or AST()
187
188    def load(self, path: str | Path):
189        """Loads the contents of a file and sets the core objects ast
190        to the results after parsing.
191        """
192        with PHMLTryCatch(), Path(path).open("r", encoding="utf-8") as file:
193            self._from_path = path
194            self._ast = self.parser.parse(file.read())
195        return self
196
197    def parse(self, data: str | dict | None = None):
198        """Parse a given phml string or dict into a phml ast.
199
200        Returns:
201            Instance of the core object for method chaining.
202        """
203
204        if data is None and self._from_file is None:
205            raise ValueError(
206                "Must either provide a phml str/dict to parse or use parse in the open context manager",
207            )
208
209        with PHMLTryCatch(self._from_path, "phml:__parse__"):
210            if isinstance(data, dict):
211                ast = Node.from_dict(data)
212                if not isinstance(ast, AST) and ast is not None:
213                    ast = AST([ast])
214                self._ast = ast
215            elif data is not None:
216                self._ast = self.parser.parse(data)
217            elif self._from_file is not None:
218                self._ast = self.parser.parse(self._from_file.read())
219                self._from_file.seek(0)
220
221        return self
222
223    def format(
224        self,
225        *,
226        code: str = "",
227        file: str | Path | None = None,
228        compress: bool = False,
229    ) -> str | None:
230        """Format a phml str or file.
231
232        Args:
233            code (str, optional): The phml str to format.
234
235        Kwargs:
236            file (str, optional): Path to a phml file. Can be used instead of
237                `code` to parse and format a phml file.
238            compress (bool, optional): Flag to compress the file and remove new lines. Defaults to False.
239
240        Note:
241            If both `code` and `file` are passed in then both will be formatted with the formatted `code`
242            bing returned as a string and the formatted `file` being written to the files original location.
243
244        Returns:
245            str: When a phml str is passed in
246            None: When a file path is passed in. Instead the resulting formatted string is written back to the file.
247        """
248
249        result = None
250        if code != "":
251            self.parse(code)
252            result = self.compiler.render(
253                self._ast,
254                compress,
255            )
256
257        if file is not None:
258            self.load(file)
259            with Path(file).open("+w", encoding="utf-8") as phml_file:
260                phml_file.write(
261                    self.compiler.render(
262                        self._ast,
263                        compress,
264                    ),
265                )
266
267        return result
268
269    def compile(self, **context: Any) -> Parent:
270        """Compile the python blocks, python attributes, and phml components and return the resulting ast.
271        The resulting ast replaces the core objects ast.
272        """
273        context = {**self.context, **context, "_phml_path_": self._from_path}
274        if self._ast is not None:
275            with PHMLTryCatch(self._from_path, "phml:__compile__"):
276                ast = self.compiler.compile(self._ast, self.components, **context)
277            return ast
278        raise ValueError("Must first parse a phml file before compiling to an AST")
279
280    def render(self, _compress: bool = False, **context: Any) -> str:
281        """Renders the phml ast into an html string. If currently in a context manager
282        the resulting string will also be output to an associated file.
283        """
284        context = {**self.context, **context, "_phml_path_": self._from_path}
285        if self._ast is not None:
286            with PHMLTryCatch(self._from_path, "phml:__render"):
287                result = self.compiler.render(
288                    self.compile(**context),
289                    _compress,
290                )
291
292                if self._to_file is not None:
293                    self._to_file.write(result)
294                elif self._from_file is not None and self._from_path is not None:
295                    self._to_file = (
296                        Path(self._from_path)
297                        .with_suffix(".html")
298                        .open("+w", encoding="utf-8")
299                    )
300                    self._to_file.write(result)
301            return result
302        raise ValueError("Must first parse a phml file before rendering a phml AST")
303
304    def write(self, _path: str | Path, _compress: bool = False, **context: Any):
305        """Render and write the current ast to a file.
306
307        Args:
308            path (str): The output path for the rendered html.
309            compress (bool): Whether to compress the output. Defaults to False.
310        """
311        path = Path(_path).with_suffix(".html")
312        path.parent.mkdir(parents=True, exist_ok=True)
313
314        with path.open("+w", encoding="utf-8") as file:
315            file.write(self.compiler.render(self.compile(**context), _compress))
316        return self
317
318    @overload
319    def add(self, file: str | Path, *, ignore: str = ""):
320        """Add a component to the component manager with a file path. Also, componetes can be added to
321        the component manager with a name and str or an already parsed component dict.
322
323        Args:
324            file (str): The file path to the component.
325            ignore (str): The path prefix to remove before creating the comopnent name.
326            name (str): The name of the component. This is the index/key in the component manager.
327                This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
328            data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
329                representation of the component, or an already parsed component type dict.
330        """
331        ...
332
333    @overload
334    def add(self, *, name: str, data: str | ComponentType):
335        """Add a component to the component manager with a file path. Also, componetes can be added to
336        the component manager with a name and str or an already parsed component dict.
337
338        Args:
339            file (str): The file path to the component.
340            ignore (str): The path prefix to remove before creating the comopnent name.
341            name (str): The name of the component. This is the index/key in the component manager.
342                This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
343            data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
344                representation of the component, or an already parsed component type dict.
345        """
346        ...
347
348    def add(
349        self,
350        file: str | Path | None = None,
351        *,
352        name: str | None = None,
353        data: ComponentType | str | None = None,
354        ignore: str = "",
355    ):
356        """Add a component to the component manager. The components are used by the compiler
357        when generating html files from phml.
358        """
359        with PHMLTryCatch(file or name or "_cmpt_"):
360            self.components.add(file, name=name, data=data, ignore=ignore)
361
362    def remove(self, key: str):
363        """Remove a component from the component manager based on the components name/tag."""
364        self.components.remove(key)
365
366    def expose(self, _context: dict[str, Any] | None = None, **context: Any):
367        """Expose global variables to each phml file compiled with this instance.
368        This data is the highest scope and will be overridden by more specific
369        scoped variables with equivelant names.
370        """
371
372        if _context:
373            self.context.update(_context or {})
374        self.context.update(context)
375
376    def redact(self, *keys: str):
377        """Remove global variable from this instance."""
378        for key in keys:
379            self.context.pop(key, None)
class HypertextManager:
 22class HypertextManager:
 23    parser: HypertextMarkupParser
 24    """PHML parser."""
 25    compiler: HypertextMarkupCompiler
 26    """PHML compiler to HTML."""
 27    components: ComponentManager
 28    """PHML component parser and manager."""
 29    context: dict[str, Any]
 30    """PHML global variables to expose to each phml file compiled with this instance.
 31    This is the highest scope and is overridden by more specific scoped variables.
 32    """
 33
 34    def __init__(self) -> None:
 35        self.parser = HypertextMarkupParser()
 36        self.compiler = HypertextMarkupCompiler()
 37        self.components = ComponentManager()
 38        self.context = {"Module": Module}
 39        self._ast: AST | None = None
 40        self._from_path = None
 41        self._from_file = None
 42        self._to_file = None
 43
 44    @staticmethod
 45    @contextmanager
 46    def open(
 47        _from: str | Path,
 48        _to: str | Path | None = None,
 49    ) -> Iterator[HypertextManager]:
 50        with PHMLTryCatch():
 51            core = HypertextManager()
 52            core._from_path = Path(_from)
 53            core._from_path.parent.mkdir(parents=True, exist_ok=True)
 54            core._from_file = Path(_from).open("r", encoding="utf-8")
 55            if _to is not None:
 56                output = Path(_to)
 57                output.parent.mkdir(parents=True, exist_ok=True)
 58                core._to_file = output.open("+w", encoding="utf-8")
 59
 60            core.parse()
 61
 62            yield core
 63
 64            core._from_path = None
 65            core._from_file.close()
 66            if core._to_file is not None:
 67                core._to_file.close()
 68
 69    @property
 70    def imports(self) -> dict:
 71        return dict(__IMPORTS__)
 72
 73    @property
 74    def from_imports(self) -> dict:
 75        return dict(__FROM_IMPORTS__)
 76
 77    def add_module(
 78        self,
 79        module: str,
 80        *,
 81        name: str | None = None,
 82        imports: list[str] | None = None,
 83    ) -> NoReturn:
 84        """Pass and imported a python file as a module. The modules are imported and added
 85        to phml's cached imports. These modules are **ONLY** exposed to the python elements.
 86        To use them in the python elements or the other scopes in the files you must use the python
 87        import syntax `import <module>` or `from <module> import <...objects>`. PHML will parse
 88        the imports first and remove them from the python elements. It then checks it's cache of
 89        python modules that have been imported and adds those imported modules to the local context
 90        for each embedded python execution.
 91
 92        Note:
 93            - All imports will have a `.` prefixed to the module name. For example `current/file.py` gets the module
 94            name `.current.file`. This helps seperate and indicate what imports are injected with this method.
 95            Module import syntax will retain it's value, For example suppose the module `..module.name.here`
 96            is added. It is in directory `module/` which is in a sibling directory to `current/`. The path
 97            would look like `parent/ -> module/ -> name/ -> here.py` and the module would keep the name of
 98            `..module.name.here`.
 99
100            - All paths are resolved with the cwd in mind.
101
102        Args:
103            module (str): Absolute or relative path to a module, or module syntax reference to a module.
104            name (str): Optional name for the module after it is imported.
105            imports (list[str]): Optional list of objects to import from the module. Turns the import to
106                `from <module> import <...objects>` from `import <module>`.
107
108        Returns:
109            str: Name of the imported module. The key to use for indexing imported modules
110        """
111
112        if module.startswith("~"):
113            module = module.replace("~", str(Path.home()))
114
115        mod = None
116        file = Path(module).with_suffix(".py")
117        cwd = os.getcwd()
118
119        if file.is_file():
120            current = Path(cwd).as_posix()
121            path = file.resolve().as_posix()
122
123            cwd_p = current.split("/")
124            path_p = path.split("/")
125            index = 0
126            for cp, pp in zip(cwd_p, path_p):
127                if cp != pp:
128                    break
129                index += 1
130
131            name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".")
132            path = "/".join(path_p[:index])
133
134            # Make the path that is imported form the only path in sys.path
135            # this will prevent module conflicts and garuntees the correct module is imported
136            sys_path = list(sys.path)
137            sys.path = [path]
138            mod = import_module(name)
139            sys.path = sys_path
140
141            name = f".{name}"
142        else:
143            if module.startswith(".."):
144                current = Path(os.getcwd()).as_posix()
145                cwd_p = current.split("/")
146
147                path = "/".join(cwd_p[:-1])
148
149                sys_path = list(sys.path)
150                sys.path = [path]
151                mod = import_module(module.lstrip(".."))
152                sys.path = sys_path
153            else:
154                mod = import_module(module)
155            name = f".{module.lstrip('..')}"
156
157        # Add imported module or module objects to appropriate collection
158        if imports is not None and len(imports) > 0:
159            for _import in imports:
160                if name not in __FROM_IMPORTS__:
161                    __FROM_IMPORTS__[name] = {}
162                __FROM_IMPORTS__[name].update({_import: getattr(mod, _import)})
163        else:
164            __IMPORTS__[name] = mod
165
166        return name
167
168    def remove_module(self, module: str, imports: list[str] | None = None):
169        if not module.startswith("."):
170            module = f".{module}"
171        if module in __IMPORTS__ and len(imports or []) == 0:
172            __IMPORTS__.pop(module, None)
173        if module in __FROM_IMPORTS__:
174            if imports is not None and len(imports) > 0:
175                for _import in imports:
176                    __FROM_IMPORTS__[module].pop(_import, None)
177                if len(__FROM_IMPORTS__[module]) == 0:
178                    __FROM_IMPORTS__.pop(module, None)
179            else:
180                __FROM_IMPORTS__.pop(module, None)
181
182        return self
183
184    @property
185    def ast(self) -> AST:
186        """The current ast that has been parsed. Defaults to None."""
187        return self._ast or AST()
188
189    def load(self, path: str | Path):
190        """Loads the contents of a file and sets the core objects ast
191        to the results after parsing.
192        """
193        with PHMLTryCatch(), Path(path).open("r", encoding="utf-8") as file:
194            self._from_path = path
195            self._ast = self.parser.parse(file.read())
196        return self
197
198    def parse(self, data: str | dict | None = None):
199        """Parse a given phml string or dict into a phml ast.
200
201        Returns:
202            Instance of the core object for method chaining.
203        """
204
205        if data is None and self._from_file is None:
206            raise ValueError(
207                "Must either provide a phml str/dict to parse or use parse in the open context manager",
208            )
209
210        with PHMLTryCatch(self._from_path, "phml:__parse__"):
211            if isinstance(data, dict):
212                ast = Node.from_dict(data)
213                if not isinstance(ast, AST) and ast is not None:
214                    ast = AST([ast])
215                self._ast = ast
216            elif data is not None:
217                self._ast = self.parser.parse(data)
218            elif self._from_file is not None:
219                self._ast = self.parser.parse(self._from_file.read())
220                self._from_file.seek(0)
221
222        return self
223
224    def format(
225        self,
226        *,
227        code: str = "",
228        file: str | Path | None = None,
229        compress: bool = False,
230    ) -> str | None:
231        """Format a phml str or file.
232
233        Args:
234            code (str, optional): The phml str to format.
235
236        Kwargs:
237            file (str, optional): Path to a phml file. Can be used instead of
238                `code` to parse and format a phml file.
239            compress (bool, optional): Flag to compress the file and remove new lines. Defaults to False.
240
241        Note:
242            If both `code` and `file` are passed in then both will be formatted with the formatted `code`
243            bing returned as a string and the formatted `file` being written to the files original location.
244
245        Returns:
246            str: When a phml str is passed in
247            None: When a file path is passed in. Instead the resulting formatted string is written back to the file.
248        """
249
250        result = None
251        if code != "":
252            self.parse(code)
253            result = self.compiler.render(
254                self._ast,
255                compress,
256            )
257
258        if file is not None:
259            self.load(file)
260            with Path(file).open("+w", encoding="utf-8") as phml_file:
261                phml_file.write(
262                    self.compiler.render(
263                        self._ast,
264                        compress,
265                    ),
266                )
267
268        return result
269
270    def compile(self, **context: Any) -> Parent:
271        """Compile the python blocks, python attributes, and phml components and return the resulting ast.
272        The resulting ast replaces the core objects ast.
273        """
274        context = {**self.context, **context, "_phml_path_": self._from_path}
275        if self._ast is not None:
276            with PHMLTryCatch(self._from_path, "phml:__compile__"):
277                ast = self.compiler.compile(self._ast, self.components, **context)
278            return ast
279        raise ValueError("Must first parse a phml file before compiling to an AST")
280
281    def render(self, _compress: bool = False, **context: Any) -> str:
282        """Renders the phml ast into an html string. If currently in a context manager
283        the resulting string will also be output to an associated file.
284        """
285        context = {**self.context, **context, "_phml_path_": self._from_path}
286        if self._ast is not None:
287            with PHMLTryCatch(self._from_path, "phml:__render"):
288                result = self.compiler.render(
289                    self.compile(**context),
290                    _compress,
291                )
292
293                if self._to_file is not None:
294                    self._to_file.write(result)
295                elif self._from_file is not None and self._from_path is not None:
296                    self._to_file = (
297                        Path(self._from_path)
298                        .with_suffix(".html")
299                        .open("+w", encoding="utf-8")
300                    )
301                    self._to_file.write(result)
302            return result
303        raise ValueError("Must first parse a phml file before rendering a phml AST")
304
305    def write(self, _path: str | Path, _compress: bool = False, **context: Any):
306        """Render and write the current ast to a file.
307
308        Args:
309            path (str): The output path for the rendered html.
310            compress (bool): Whether to compress the output. Defaults to False.
311        """
312        path = Path(_path).with_suffix(".html")
313        path.parent.mkdir(parents=True, exist_ok=True)
314
315        with path.open("+w", encoding="utf-8") as file:
316            file.write(self.compiler.render(self.compile(**context), _compress))
317        return self
318
319    @overload
320    def add(self, file: str | Path, *, ignore: str = ""):
321        """Add a component to the component manager with a file path. Also, componetes can be added to
322        the component manager with a name and str or an already parsed component dict.
323
324        Args:
325            file (str): The file path to the component.
326            ignore (str): The path prefix to remove before creating the comopnent name.
327            name (str): The name of the component. This is the index/key in the component manager.
328                This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
329            data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
330                representation of the component, or an already parsed component type dict.
331        """
332        ...
333
334    @overload
335    def add(self, *, name: str, data: str | ComponentType):
336        """Add a component to the component manager with a file path. Also, componetes can be added to
337        the component manager with a name and str or an already parsed component dict.
338
339        Args:
340            file (str): The file path to the component.
341            ignore (str): The path prefix to remove before creating the comopnent name.
342            name (str): The name of the component. This is the index/key in the component manager.
343                This is also the name of the element in phml. Ex: `Some.Component` == `<Some.Component />`
344            data (str | ComponentType): This is the data that is assigned in the manager. It can be a string
345                representation of the component, or an already parsed component type dict.
346        """
347        ...
348
349    def add(
350        self,
351        file: str | Path | None = None,
352        *,
353        name: str | None = None,
354        data: ComponentType | str | None = None,
355        ignore: str = "",
356    ):
357        """Add a component to the component manager. The components are used by the compiler
358        when generating html files from phml.
359        """
360        with PHMLTryCatch(file or name or "_cmpt_"):
361            self.components.add(file, name=name, data=data, ignore=ignore)
362
363    def remove(self, key: str):
364        """Remove a component from the component manager based on the components name/tag."""
365        self.components.remove(key)
366
367    def expose(self, _context: dict[str, Any] | None = None, **context: Any):
368        """Expose global variables to each phml file compiled with this instance.
369        This data is the highest scope and will be overridden by more specific
370        scoped variables with equivelant names.
371        """
372
373        if _context:
374            self.context.update(_context or {})
375        self.context.update(context)
376
377    def redact(self, *keys: str):
378        """Remove global variable from this instance."""
379        for key in keys:
380            self.context.pop(key, None)
HypertextManager()
34    def __init__(self) -> None:
35        self.parser = HypertextMarkupParser()
36        self.compiler = HypertextMarkupCompiler()
37        self.components = ComponentManager()
38        self.context = {"Module": Module}
39        self._ast: AST | None = None
40        self._from_path = None
41        self._from_file = None
42        self._to_file = None

PHML parser.

PHML compiler to HTML.

PHML component parser and manager.

context: dict[str, typing.Any]

PHML global variables to expose to each phml file compiled with this instance. This is the highest scope and is overridden by more specific scoped variables.

@staticmethod
@contextmanager
def open( _from: str | pathlib.Path, _to: str | pathlib.Path | None = None) -> collections.abc.Iterator[phml.core.HypertextManager]:
44    @staticmethod
45    @contextmanager
46    def open(
47        _from: str | Path,
48        _to: str | Path | None = None,
49    ) -> Iterator[HypertextManager]:
50        with PHMLTryCatch():
51            core = HypertextManager()
52            core._from_path = Path(_from)
53            core._from_path.parent.mkdir(parents=True, exist_ok=True)
54            core._from_file = Path(_from).open("r", encoding="utf-8")
55            if _to is not None:
56                output = Path(_to)
57                output.parent.mkdir(parents=True, exist_ok=True)
58                core._to_file = output.open("+w", encoding="utf-8")
59
60            core.parse()
61
62            yield core
63
64            core._from_path = None
65            core._from_file.close()
66            if core._to_file is not None:
67                core._to_file.close()
def add_module( self, module: str, *, name: str | None = None, imports: list[str] | None = None) -> NoReturn:
 77    def add_module(
 78        self,
 79        module: str,
 80        *,
 81        name: str | None = None,
 82        imports: list[str] | None = None,
 83    ) -> NoReturn:
 84        """Pass and imported a python file as a module. The modules are imported and added
 85        to phml's cached imports. These modules are **ONLY** exposed to the python elements.
 86        To use them in the python elements or the other scopes in the files you must use the python
 87        import syntax `import <module>` or `from <module> import <...objects>`. PHML will parse
 88        the imports first and remove them from the python elements. It then checks it's cache of
 89        python modules that have been imported and adds those imported modules to the local context
 90        for each embedded python execution.
 91
 92        Note:
 93            - All imports will have a `.` prefixed to the module name. For example `current/file.py` gets the module
 94            name `.current.file`. This helps seperate and indicate what imports are injected with this method.
 95            Module import syntax will retain it's value, For example suppose the module `..module.name.here`
 96            is added. It is in directory `module/` which is in a sibling directory to `current/`. The path
 97            would look like `parent/ -> module/ -> name/ -> here.py` and the module would keep the name of
 98            `..module.name.here`.
 99
100            - All paths are resolved with the cwd in mind.
101
102        Args:
103            module (str): Absolute or relative path to a module, or module syntax reference to a module.
104            name (str): Optional name for the module after it is imported.
105            imports (list[str]): Optional list of objects to import from the module. Turns the import to
106                `from <module> import <...objects>` from `import <module>`.
107
108        Returns:
109            str: Name of the imported module. The key to use for indexing imported modules
110        """
111
112        if module.startswith("~"):
113            module = module.replace("~", str(Path.home()))
114
115        mod = None
116        file = Path(module).with_suffix(".py")
117        cwd = os.getcwd()
118
119        if file.is_file():
120            current = Path(cwd).as_posix()
121            path = file.resolve().as_posix()
122
123            cwd_p = current.split("/")
124            path_p = path.split("/")
125            index = 0
126            for cp, pp in zip(cwd_p, path_p):
127                if cp != pp:
128                    break
129                index += 1
130
131            name = "/".join(path_p[index:]).rsplit(".", 1)[0].replace("/", ".")
132            path = "/".join(path_p[:index])
133
134            # Make the path that is imported form the only path in sys.path
135            # this will prevent module conflicts and garuntees the correct module is imported
136            sys_path = list(sys.path)
137            sys.path = [path]
138            mod = import_module(name)
139            sys.path = sys_path
140
141            name = f".{name}"
142        else:
143            if module.startswith(".."):
144                current = Path(os.getcwd()).as_posix()
145                cwd_p = current.split("/")
146
147                path = "/".join(cwd_p[:-1])
148
149                sys_path = list(sys.path)
150                sys.path = [path]
151                mod = import_module(module.lstrip(".."))
152                sys.path = sys_path
153            else:
154                mod = import_module(module)
155            name = f".{module.lstrip('..')}"
156
157        # Add imported module or module objects to appropriate collection
158        if imports is not None and len(imports) > 0:
159            for _import in imports:
160                if name not in __FROM_IMPORTS__:
161                    __FROM_IMPORTS__[name] = {}
162                __FROM_IMPORTS__[name].update({_import: getattr(mod, _import)})
163        else:
164            __IMPORTS__[name] = mod
165
166        return name

Pass and imported a python file as a module. The modules are imported and added to phml's cached imports. These modules are ONLY exposed to the python elements. To use them in the python elements or the other scopes in the files you must use the python import syntax import <module> or from <module> import <...objects>. PHML will parse the imports first and remove them from the python elements. It then checks it's cache of python modules that have been imported and adds those imported modules to the local context for each embedded python execution.

Note
  • All imports will have a . prefixed to the module name. For example current/file.py gets the module name .current.file. This helps seperate and indicate what imports are injected with this method. Module import syntax will retain it's value, For example suppose the module ..module.name.here is added. It is in directory module/ which is in a sibling directory to current/. The path would look like parent/ -> module/ -> name/ -> here.py and the module would keep the name of ..module.name.here.

  • All paths are resolved with the cwd in mind.

Args
  • module (str): Absolute or relative path to a module, or module syntax reference to a module.
  • name (str): Optional name for the module after it is imported.
  • imports (list[str]): Optional list of objects to import from the module. Turns the import to from <module> import <...objects> from import <module>.
Returns

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

def remove_module(self, module: str, imports: list[str] | None = None):
168    def remove_module(self, module: str, imports: list[str] | None = None):
169        if not module.startswith("."):
170            module = f".{module}"
171        if module in __IMPORTS__ and len(imports or []) == 0:
172            __IMPORTS__.pop(module, None)
173        if module in __FROM_IMPORTS__:
174            if imports is not None and len(imports) > 0:
175                for _import in imports:
176                    __FROM_IMPORTS__[module].pop(_import, None)
177                if len(__FROM_IMPORTS__[module]) == 0:
178                    __FROM_IMPORTS__.pop(module, None)
179            else:
180                __FROM_IMPORTS__.pop(module, None)
181
182        return self

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

def load(self, path: str | pathlib.Path):
189    def load(self, path: str | Path):
190        """Loads the contents of a file and sets the core objects ast
191        to the results after parsing.
192        """
193        with PHMLTryCatch(), Path(path).open("r", encoding="utf-8") as file:
194            self._from_path = path
195            self._ast = self.parser.parse(file.read())
196        return self

Loads the contents of a file and sets the core objects ast to the results after parsing.

def parse(self, data: str | dict | None = None):
198    def parse(self, data: str | dict | None = None):
199        """Parse a given phml string or dict into a phml ast.
200
201        Returns:
202            Instance of the core object for method chaining.
203        """
204
205        if data is None and self._from_file is None:
206            raise ValueError(
207                "Must either provide a phml str/dict to parse or use parse in the open context manager",
208            )
209
210        with PHMLTryCatch(self._from_path, "phml:__parse__"):
211            if isinstance(data, dict):
212                ast = Node.from_dict(data)
213                if not isinstance(ast, AST) and ast is not None:
214                    ast = AST([ast])
215                self._ast = ast
216            elif data is not None:
217                self._ast = self.parser.parse(data)
218            elif self._from_file is not None:
219                self._ast = self.parser.parse(self._from_file.read())
220                self._from_file.seek(0)
221
222        return self

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

Returns

Instance of the core object for method chaining.

def format( self, *, code: str = '', file: str | pathlib.Path | None = None, compress: bool = False) -> str | None:
224    def format(
225        self,
226        *,
227        code: str = "",
228        file: str | Path | None = None,
229        compress: bool = False,
230    ) -> str | None:
231        """Format a phml str or file.
232
233        Args:
234            code (str, optional): The phml str to format.
235
236        Kwargs:
237            file (str, optional): Path to a phml file. Can be used instead of
238                `code` to parse and format a phml file.
239            compress (bool, optional): Flag to compress the file and remove new lines. Defaults to False.
240
241        Note:
242            If both `code` and `file` are passed in then both will be formatted with the formatted `code`
243            bing returned as a string and the formatted `file` being written to the files original location.
244
245        Returns:
246            str: When a phml str is passed in
247            None: When a file path is passed in. Instead the resulting formatted string is written back to the file.
248        """
249
250        result = None
251        if code != "":
252            self.parse(code)
253            result = self.compiler.render(
254                self._ast,
255                compress,
256            )
257
258        if file is not None:
259            self.load(file)
260            with Path(file).open("+w", encoding="utf-8") as phml_file:
261                phml_file.write(
262                    self.compiler.render(
263                        self._ast,
264                        compress,
265                    ),
266                )
267
268        return result

Format a phml str or file.

Args
  • code (str, optional): The phml str to format.
Kwargs

file (str, optional): Path to a phml file. Can be used instead of code to parse and format a phml file. compress (bool, optional): Flag to compress the file and remove new lines. Defaults to False.

Note

If both code and file are passed in then both will be formatted with the formatted code bing returned as a string and the formatted file being written to the files original location.

Returns

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

def compile(self, **context: Any) -> phml.nodes.Parent:
270    def compile(self, **context: Any) -> Parent:
271        """Compile the python blocks, python attributes, and phml components and return the resulting ast.
272        The resulting ast replaces the core objects ast.
273        """
274        context = {**self.context, **context, "_phml_path_": self._from_path}
275        if self._ast is not None:
276            with PHMLTryCatch(self._from_path, "phml:__compile__"):
277                ast = self.compiler.compile(self._ast, self.components, **context)
278            return ast
279        raise ValueError("Must first parse a phml file before compiling to an AST")

Compile the python blocks, python attributes, and phml components and return the resulting ast. The resulting ast replaces the core objects ast.

def render(self, _compress: bool = False, **context: Any) -> str:
281    def render(self, _compress: bool = False, **context: Any) -> str:
282        """Renders the phml ast into an html string. If currently in a context manager
283        the resulting string will also be output to an associated file.
284        """
285        context = {**self.context, **context, "_phml_path_": self._from_path}
286        if self._ast is not None:
287            with PHMLTryCatch(self._from_path, "phml:__render"):
288                result = self.compiler.render(
289                    self.compile(**context),
290                    _compress,
291                )
292
293                if self._to_file is not None:
294                    self._to_file.write(result)
295                elif self._from_file is not None and self._from_path is not None:
296                    self._to_file = (
297                        Path(self._from_path)
298                        .with_suffix(".html")
299                        .open("+w", encoding="utf-8")
300                    )
301                    self._to_file.write(result)
302            return result
303        raise ValueError("Must first parse a phml file before rendering a phml AST")

Renders the phml ast into an html string. If currently in a context manager the resulting string will also be output to an associated file.

def write( self, _path: str | pathlib.Path, _compress: bool = False, **context: Any):
305    def write(self, _path: str | Path, _compress: bool = False, **context: Any):
306        """Render and write the current ast to a file.
307
308        Args:
309            path (str): The output path for the rendered html.
310            compress (bool): Whether to compress the output. Defaults to False.
311        """
312        path = Path(_path).with_suffix(".html")
313        path.parent.mkdir(parents=True, exist_ok=True)
314
315        with path.open("+w", encoding="utf-8") as file:
316            file.write(self.compiler.render(self.compile(**context), _compress))
317        return self

Render and write the current ast to a file.

Args
  • path (str): The output path for the rendered html.
  • compress (bool): Whether to compress the output. Defaults to False.
def add( self, file: str | pathlib.Path | None = None, *, name: str | None = None, data: phml.components.ComponentType | str | None = None, ignore: str = ''):
349    def add(
350        self,
351        file: str | Path | None = None,
352        *,
353        name: str | None = None,
354        data: ComponentType | str | None = None,
355        ignore: str = "",
356    ):
357        """Add a component to the component manager. The components are used by the compiler
358        when generating html files from phml.
359        """
360        with PHMLTryCatch(file or name or "_cmpt_"):
361            self.components.add(file, name=name, data=data, ignore=ignore)

Add a component to the component manager. The components are used by the compiler when generating html files from phml.

def remove(self, key: str):
363    def remove(self, key: str):
364        """Remove a component from the component manager based on the components name/tag."""
365        self.components.remove(key)

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

def expose(self, _context: dict[str, typing.Any] | None = None, **context: Any):
367    def expose(self, _context: dict[str, Any] | None = None, **context: Any):
368        """Expose global variables to each phml file compiled with this instance.
369        This data is the highest scope and will be overridden by more specific
370        scoped variables with equivelant names.
371        """
372
373        if _context:
374            self.context.update(_context or {})
375        self.context.update(context)

Expose global variables to each phml file compiled with this instance. This data is the highest scope and will be overridden by more specific scoped variables with equivelant names.

def redact(self, *keys: str):
377    def redact(self, *keys: str):
378        """Remove global variable from this instance."""
379        for key in keys:
380            self.context.pop(key, None)

Remove global variable from this instance.