From 7a01c75c18a893ff86fd7171f65721ebc3927066 Mon Sep 17 00:00:00 2001 From: Jakob Pinterits Date: Wed, 18 Dec 2024 19:24:15 +0100 Subject: [PATCH] rework snippet infrastructure --- rio/snippets/__init__.py | 481 ++----------------------------- rio/snippets/project_template.py | 273 ++++++++++++++++++ rio/snippets/snippet_manager.py | 232 +++++++++++++++ tests/test_project_templates.py | 4 +- 4 files changed, 525 insertions(+), 465 deletions(-) create mode 100644 rio/snippets/project_template.py create mode 100644 rio/snippets/snippet_manager.py diff --git a/rio/snippets/__init__.py b/rio/snippets/__init__.py index 3362b69b..b025d101 100644 --- a/rio/snippets/__init__.py +++ b/rio/snippets/__init__.py @@ -1,463 +1,25 @@ from __future__ import annotations -import copy import functools -import json -import re import typing as t -from dataclasses import dataclass -from pathlib import Path - -import typing_extensions as te -import uniserde from .. import utils - -SECTION_PATTERN = re.compile(r" *#\s*<(\/?[\w-]+)>") - - -# Contains default values for the `meta.json` file -DEFAULT_META_DICT = { - "dependencies": {}, - "rootComponent": None, - "onAppStart": None, - "onSessionStart": None, - "defaultAttachments": None, - "theme": None, -} - - -# All Available templates, including the empty template -# -# THE ORDER MATTERS. `revel` will display the options in the same order as they -# appear in the literal -AvailableTemplatesLiteral: te.TypeAlias = t.Literal[ - # Keep the empty template first - "Empty", - # Sort the remainder alphabetically - "AI Chatbot", - "Authentication", - "Crypto Dashboard", - "Multipage Website", - "Simple CRUD", - "Tic-Tac-Toe", - "Todo App", -] - - -@dataclass -class _TemplateConfig(uniserde.Serde): - """ - Model for parsing the JSON file which comes along with each project - template. - """ - - # Allows displaying the templates in a structured way - level: t.Literal["beginner", "intermediate", "advanced"] - - # Very short, one or two line description of the template - summary: str - - # Any pypi packages this project depends on, in the same format as used by - # `pip`. - # - # Example: `{"numpy": ">=1.21.0", "pandas": "^1.3.0"}` - dependencies: dict[str, str] - - # Whether projects based on this template are ready to run out of the box, - # without any modifications needed. For example, a template that requires an - # API key to be set up is not ready to run. - ready_to_run: bool - - # Additional parameters to pass to the app instance - root_component: str | None - on_app_start: str | None - on_session_start: str | None - default_attachments: list[str] | None - theme: str | None - - -@dataclass -class Snippet: - # The group the snippet is in - group: str - - # The name the snippet can be accessed with - name: str - - # The path to the snippet file - file_path: Path - - # For text snippets, this is the raw file contents. `None` for binary - # snippets. - raw_code: str | None - - @property - def is_text_snippet(self) -> bool: - return self.raw_code is not None - - @property - def is_binary_snippet(self) -> bool: - return not self.is_text_snippet - - @staticmethod - def from_path(group: str, name: str, file_path: Path) -> Snippet: - # Read the contents if it's a text snippet - if file_path.suffix in (".txt", ".md", ".py", ".json"): - raw_code = file_path.read_text(encoding="utf-8") - - # The example projects contain a bunch of imports that can't be - # resolved, so those are marked with special `# type: ignore` - # comments that need to be removed - if file_path.suffix == ".py": - raw_code = raw_code.replace( - " # type: ignore (hidden from user)", "" - ) - else: - raw_code = None - - return Snippet( - group=group, - name=name, - file_path=file_path, - raw_code=raw_code, - ) - - def get_section(self, section_name: str) -> str: - assert self.is_text_snippet, self - assert self.raw_code is not None, self # For the type checker - - # Find the target section - lines = self.raw_code.splitlines() - section_found = False - result = [] - - for line in lines: - match = SECTION_PATTERN.match(line) - - # No match - if match is None: - result.append(line) - continue - - # Start of the target section? - if match.group(1) == section_name: - result = [] - section_found = True - continue - - # End of the target section? - if match.group(1) == f"/{section_name}": - return "\n".join(result) - - # Some other section, drop it - pass - - # The section was never found - if not section_found: - raise KeyError(f"There is no section named `{section_name}`") - - # The section was found, but never closed - raise ValueError(f"The section `{section_name}` was never closed") - - def stripped_code(self) -> str: - """ - Returns the given code with all section tags removed. - """ - assert self.is_text_snippet, self - assert self.raw_code is not None, self # For the type checker - - lines = self.raw_code.splitlines() - - # Remove all section tags - result = [] - for line in lines: - if SECTION_PATTERN.match(line) is None: - result.append(line) - - return "\n".join(result) - - -@functools.lru_cache(maxsize=None) -def _get_all_snippet_paths() -> dict[str, dict[str, Path]]: - """ - Returns a dictionary containing all available snippets. The snippets are - organized by group and name. - - The snippets are read from the `snippets` directory when the function is - first called. The result is cached after that. Because of this, the result - mustn't be modified. - """ - # Find all snippet files - result = {} - - def scan_dir_recursively(group_name: str, path: Path) -> None: - assert path.is_dir(), path - assert result is not None - - # Exclude some directories - if path.name == "__pycache__": - return - - for fpath in path.iterdir(): - # Directory - if fpath.is_dir(): - scan_dir_recursively(group_name, fpath) - - # Snippet file - else: - name = fpath.name - group = result.setdefault(group_name, {}) - group[name] = fpath - - # Scan all snippet directories. The first directory is used as a key, the - # rest just for organization. - for group_dir in utils.SNIPPETS_DIR.iterdir(): - assert group_dir.is_dir(), group_dir - scan_dir_recursively(group_dir.name, group_dir) - - return result - - -def get_snippet_groups() -> set[str]: - """ - Returns a set of all snippet available groups. - """ - all_groups = _get_all_snippet_paths() - return set(all_groups.keys()) - - -@functools.lru_cache(maxsize=None) -def all_snippets_in_group(group: str) -> t.Iterable[Snippet]: - """ - Returns all snippets in the given group. - - ## Raises - - `KeyError`: If there is no group with the given name. - """ - all_groups = _get_all_snippet_paths() - group_dict = all_groups.get(group, {}) - - return tuple( - Snippet.from_path(group, name, file_path) - for name, file_path in group_dict.items() - ) - - -@functools.lru_cache(maxsize=None) -def get_snippet(group: str, name: str) -> Snippet: - """ - Parses and returns the snippet with the given group and name. - - ## Raises - - `KeyError`: If there is no snippet with the given group and name. - """ - all_groups = _get_all_snippet_paths() - - group_dict = all_groups[group] - file_path = group_dict[name] - - return Snippet.from_path(group, name, file_path) - - -@dataclass -class ProjectTemplate: - """ - Project templates are stored as snippets. This class represents all - resources necessary for a single project template. - """ - - # Human-readable name of the project template - name: AvailableTemplatesLiteral - - # How difficult the project is - level: t.Literal["beginner", "intermediate", "advanced"] - - # A short description of the project template - summary: str - - # Description of the project template (Markdown) - description_markdown_source: str - - # The project thumbnail. This is a SVG file. - thumbnail: Snippet - - # Any pypi packages this project depends on, in the same format as used by - # `pip`. - # - # Example: `{"numpy": ">=1.21.0", "pandas": "^1.3.0"}` - dependencies: dict[str, str] - - # Whether projects based on this template are ready to run out of the box, - # without any modifications needed. For example, a template that requires an - # API key to be set up is not ready to run. - ready_to_run: bool - - # All snippets which should be included - asset_snippets: list[Snippet] - component_snippets: list[Snippet] - page_snippets: list[Snippet] - other_python_files: list[Snippet] - - root_init_snippet: Snippet - - # Additional configuration for the app instance - root_component: str | None - on_app_start: str | None - on_session_start: str | None - default_attachments: list[str] | None - theme: str | None = None - - @property - def slug(self) -> str: - return self.name.lower().replace(" ", "-") - - @staticmethod - def _from_snippet_group( - snippet_name: str, - snippets: t.Iterable[Snippet], - ) -> ProjectTemplate: - assert ( - snippet_name in t.get_args(AvailableTemplatesLiteral) - or snippet_name == "Empty" - ), snippet_name - name = t.cast(AvailableTemplatesLiteral, snippet_name) - - # Find all snippets needed for the project template - readme_snippet: Snippet | None = None - thumbnail_snippet: Snippet | None = None - metadata: _TemplateConfig | None = None - root_init_snippet: Snippet | None = None - - asset_snippets: list[Snippet] = [] - python_files: list[Snippet] = [] - - for snippet in snippets: - # README snippet can be recognized by its name - if snippet.name == "README.md": - assert readme_snippet is None - assert snippet.is_text_snippet, snippet.file_path - readme_snippet = snippet - continue - - # As is the thumbnail - if snippet.name == "thumbnail.svg": - assert thumbnail_snippet is None - thumbnail_snippet = snippet - continue - - # And the metadata - if snippet.name == "meta.json": - meta_dict: dict[str, t.Any] = copy.deepcopy(DEFAULT_META_DICT) - meta_dict.update(json.loads(snippet.stripped_code())) - metadata = _TemplateConfig.from_json(meta_dict) - continue - - # Others are categorized by the directory they're in - dir_name = snippet.file_path.parent.name - - if dir_name == "assets": - asset_snippets.append(snippet) - # if "assets" in snippet.file_path.parts: - # asset_snippets.append(snippet) - - elif snippet.file_path.name == "root_init.py": - assert root_init_snippet is None - assert snippet.is_text_snippet, snippet.file_path - root_init_snippet = snippet - - elif snippet.file_path.suffix == ".py": - python_files.append(snippet) - - elif snippet.file_path.suffix in [ - ".jpg", - ".png", - ".jpeg", - ".svg", - ".webp", - ]: - asset_snippets.append(snippet) - # else: - # assert False, f"Unrecognized snippet file `{snippet.file_path}`" - - else: - assert False, f"Unrecognized snippet file `{snippet.file_path}`" - - # Make sure everything was found - assert ( - readme_snippet is not None - ), f"`README.md` snippet not found for `{name}`" - assert ( - thumbnail_snippet is not None - ), f"`thumbnail.svg` snippet not found for {name}" - assert ( - metadata is not None - ), f"`meta.json` snippet not found for `{name}`" - assert ( - root_init_snippet is not None - ), f"`root_init.py` snippet not found for `{name}`" - - # Further split the Python files into components, pages, and other - # files. This cannot be done above, because it requires knowledge of - # where the snippet groups's root directory is placed. This directory in - # turn is only known once the snippets have been found. - template_root_dir = root_init_snippet.file_path.parent - - ii = 0 - component_snippets: list[Snippet] = [] - page_snippets: list[Snippet] = [] - - while ii < len(python_files): - snippet = python_files[ii] - rel_path = snippet.file_path.relative_to(template_root_dir) - - # Component? - if rel_path.parts[0] == "components": - del python_files[ii] - - if snippet.name == "__init__.py": - continue - - assert snippet.is_text_snippet, snippet.file_path - component_snippets.append(snippet) - - # Page? - elif rel_path.parts[0] == "pages": - del python_files[ii] - - assert snippet.is_text_snippet, snippet.file_path - page_snippets.append(snippet) - - # Plain old Python file - else: - ii += 1 - - assert len(page_snippets) > 0, f"No page snippets found for `{name}`" - - # Create the project template - return ProjectTemplate( - name=name, - level=metadata.level, - summary=metadata.summary, - description_markdown_source=readme_snippet.stripped_code(), - thumbnail=thumbnail_snippet, - dependencies=metadata.dependencies, - asset_snippets=asset_snippets, - component_snippets=component_snippets, - page_snippets=page_snippets, - other_python_files=python_files, - root_init_snippet=root_init_snippet, - root_component=metadata.root_component, - on_app_start=metadata.on_app_start, - on_session_start=metadata.on_session_start, - default_attachments=metadata.default_attachments, - theme=metadata.theme, - ready_to_run=metadata.ready_to_run, - ) - - +from .project_template import ( + AvailableTemplatesLiteral as AvailableTemplatesLiteral, +) +from .project_template import ( + ProjectTemplate, +) +from .snippet_manager import Snippet as Snippet +from .snippet_manager import SnippetManager + +# Expose a global instance of the snippet manager +MANAGER = SnippetManager( + snippet_directory=utils.SNIPPETS_DIR, +) + + +# Provide functionality to discover all available project templates @functools.lru_cache(maxsize=None) def get_project_templates(include_empty: bool) -> t.Iterable[ProjectTemplate]: """ @@ -468,7 +30,7 @@ def get_project_templates(include_empty: bool) -> t.Iterable[ProjectTemplate]: result = [] # Templates are just snippet groups starting with `project-template-` - for group_name, snippets in _get_all_snippet_paths().items(): + for group_name in MANAGER.get_all_snippet_groups(): # Is this a project template? template_name = group_name.removeprefix("project-template-") @@ -483,14 +45,7 @@ def get_project_templates(include_empty: bool) -> t.Iterable[ProjectTemplate]: result.append( ProjectTemplate._from_snippet_group( template_name, - [ - Snippet.from_path( - group_name, - name, - file_path, - ) - for name, file_path in snippets.items() - ], + MANAGER.get_all_snippets_in_group(group_name), ) ) diff --git a/rio/snippets/project_template.py b/rio/snippets/project_template.py new file mode 100644 index 00000000..293cb285 --- /dev/null +++ b/rio/snippets/project_template.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import copy +import json +import typing as t +from dataclasses import dataclass + +import typing_extensions as te +import uniserde + +from .snippet_manager import Snippet + +__all__ = [ + "ProjectTemplate", + "get_project_templates", +] + + +# Contains default values for the `meta.json` file +DEFAULT_META_DICT = { + "dependencies": {}, + "rootComponent": None, + "onAppStart": None, + "onSessionStart": None, + "defaultAttachments": None, + "theme": None, +} + + +# All Available templates, including the empty template +# +# THE ORDER MATTERS. `revel` will display the options in the same order as they +# appear in the literal +AvailableTemplatesLiteral: te.TypeAlias = t.Literal[ + # Keep the empty template first + "Empty", + # Sort the remainder alphabetically + "AI Chatbot", + "Authentication", + "Crypto Dashboard", + "Multipage Website", + "Simple CRUD", + "Tic-Tac-Toe", + "Todo App", +] + + +@dataclass +class _TemplateConfig(uniserde.Serde): + """ + Model for parsing the JSON file which comes along with each project + template. + """ + + # Allows displaying the templates in a structured way + level: t.Literal["beginner", "intermediate", "advanced"] + + # Very short, one or two line description of the template + summary: str + + # Any pypi packages this project depends on, in the same format as used by + # `pip`. + # + # Example: `{"numpy": ">=1.21.0", "pandas": "^1.3.0"}` + dependencies: dict[str, str] + + # Whether projects based on this template are ready to run out of the box, + # without any modifications needed. For example, a template that requires an + # API key to be set up is not ready to run. + ready_to_run: bool + + # Additional parameters to pass to the app instance + root_component: str | None + on_app_start: str | None + on_session_start: str | None + default_attachments: list[str] | None + theme: str | None + + +@dataclass +class ProjectTemplate: + """ + Project templates are stored as snippets. This class represents all + resources necessary for a single project template. + """ + + # Human-readable name of the project template + name: AvailableTemplatesLiteral + + # How difficult the project is + level: t.Literal["beginner", "intermediate", "advanced"] + + # A short description of the project template + summary: str + + # Description of the project template (Markdown) + description_markdown_source: str + + # The project thumbnail. This is a SVG file. + thumbnail: Snippet + + # Any pypi packages this project depends on, in the same format as used by + # `pip`. + # + # Example: `{"numpy": ">=1.21.0", "pandas": "^1.3.0"}` + dependencies: dict[str, str] + + # Whether projects based on this template are ready to run out of the box, + # without any modifications needed. For example, a template that requires an + # API key to be set up is not ready to run. + ready_to_run: bool + + # All snippets which should be included + asset_snippets: list[Snippet] + component_snippets: list[Snippet] + page_snippets: list[Snippet] + other_python_files: list[Snippet] + + root_init_snippet: Snippet + + # Additional configuration for the app instance + root_component: str | None + on_app_start: str | None + on_session_start: str | None + default_attachments: list[str] | None + theme: str | None = None + + @property + def slug(self) -> str: + return self.name.lower().replace(" ", "-") + + @staticmethod + def _from_snippet_group( + snippet_name: str, + snippets: t.Iterable[Snippet], + ) -> ProjectTemplate: + assert ( + snippet_name in t.get_args(AvailableTemplatesLiteral) + or snippet_name == "Empty" + ), snippet_name + name = t.cast(AvailableTemplatesLiteral, snippet_name) + + # Find all snippets needed for the project template + readme_snippet: Snippet | None = None + thumbnail_snippet: Snippet | None = None + metadata: _TemplateConfig | None = None + root_init_snippet: Snippet | None = None + + asset_snippets: list[Snippet] = [] + python_files: list[Snippet] = [] + + for snippet in snippets: + # README snippet can be recognized by its name + if snippet.name == "README.md": + assert readme_snippet is None + assert snippet.is_text_snippet, snippet.file_path + readme_snippet = snippet + continue + + # As is the thumbnail + if snippet.name == "thumbnail.svg": + assert thumbnail_snippet is None + thumbnail_snippet = snippet + continue + + # And the metadata + if snippet.name == "meta.json": + meta_dict: dict[str, t.Any] = copy.deepcopy(DEFAULT_META_DICT) + meta_dict.update(json.loads(snippet.stripped_code())) + metadata = _TemplateConfig.from_json(meta_dict) + continue + + # Others are categorized by the directory they're in + dir_name = snippet.file_path.parent.name + + if dir_name == "assets": + asset_snippets.append(snippet) + # if "assets" in snippet.file_path.parts: + # asset_snippets.append(snippet) + + elif snippet.file_path.name == "root_init.py": + assert root_init_snippet is None + assert snippet.is_text_snippet, snippet.file_path + root_init_snippet = snippet + + elif snippet.file_path.suffix == ".py": + python_files.append(snippet) + + elif snippet.file_path.suffix in [ + ".jpg", + ".png", + ".jpeg", + ".svg", + ".webp", + ]: + asset_snippets.append(snippet) + # else: + # assert False, f"Unrecognized snippet file `{snippet.file_path}`" + + else: + assert False, f"Unrecognized snippet file `{snippet.file_path}`" + + # Make sure everything was found + assert ( + readme_snippet is not None + ), f"`README.md` snippet not found for `{name}`" + assert ( + thumbnail_snippet is not None + ), f"`thumbnail.svg` snippet not found for {name}" + assert ( + metadata is not None + ), f"`meta.json` snippet not found for `{name}`" + assert ( + root_init_snippet is not None + ), f"`root_init.py` snippet not found for `{name}`" + + # Further split the Python files into components, pages, and other + # files. This cannot be done above, because it requires knowledge of + # where the snippet groups's root directory is placed. This directory in + # turn is only known once the snippets have been found. + template_root_dir = root_init_snippet.file_path.parent + + ii = 0 + component_snippets: list[Snippet] = [] + page_snippets: list[Snippet] = [] + + while ii < len(python_files): + snippet = python_files[ii] + rel_path = snippet.file_path.relative_to(template_root_dir) + + # Component? + if rel_path.parts[0] == "components": + del python_files[ii] + + if snippet.name == "__init__.py": + continue + + assert snippet.is_text_snippet, snippet.file_path + component_snippets.append(snippet) + + # Page? + elif rel_path.parts[0] == "pages": + del python_files[ii] + + assert snippet.is_text_snippet, snippet.file_path + page_snippets.append(snippet) + + # Plain old Python file + else: + ii += 1 + + assert len(page_snippets) > 0, f"No page snippets found for `{name}`" + + # Create the project template + return ProjectTemplate( + name=name, + level=metadata.level, + summary=metadata.summary, + description_markdown_source=readme_snippet.stripped_code(), + thumbnail=thumbnail_snippet, + dependencies=metadata.dependencies, + asset_snippets=asset_snippets, + component_snippets=component_snippets, + page_snippets=page_snippets, + other_python_files=python_files, + root_init_snippet=root_init_snippet, + root_component=metadata.root_component, + on_app_start=metadata.on_app_start, + on_session_start=metadata.on_session_start, + default_attachments=metadata.default_attachments, + theme=metadata.theme, + ready_to_run=metadata.ready_to_run, + ) diff --git a/rio/snippets/snippet_manager.py b/rio/snippets/snippet_manager.py new file mode 100644 index 00000000..d6ec9976 --- /dev/null +++ b/rio/snippets/snippet_manager.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import re +import typing as t +from dataclasses import dataclass +from pathlib import Path + +from .. import utils + +__all__ = [ + "Snippet", + "SnippetManager", +] + + +SECTION_PATTERN = re.compile(r" *#\s*<(\/?[\w-]+)>") + + +@dataclass +class Snippet: + # The group the snippet is in + group: str + + # The name the snippet can be accessed with + name: str + + # The path to the snippet file + file_path: Path + + # For text snippets, this is the raw file contents. `None` for binary + # snippets. + raw_code: str | None + + @property + def is_text_snippet(self) -> bool: + return self.raw_code is not None + + @property + def is_binary_snippet(self) -> bool: + return not self.is_text_snippet + + @staticmethod + def from_path(group: str, name: str, file_path: Path) -> Snippet: + # Read the contents if it's a text snippet + if file_path.suffix in (".txt", ".md", ".py", ".json"): + raw_code = file_path.read_text(encoding="utf-8") + + # The example projects contain a bunch of imports that can't be + # resolved, so those are marked with special `# type: ignore` + # comments that need to be removed + if file_path.suffix == ".py": + raw_code = raw_code.replace( + " # type: ignore (hidden from user)", "" + ) + else: + raw_code = None + + return Snippet( + group=group, + name=name, + file_path=file_path, + raw_code=raw_code, + ) + + def get_section(self, section_name: str) -> str: + assert self.is_text_snippet, self + assert self.raw_code is not None, self # For the type checker + + # Find the target section + lines = self.raw_code.splitlines() + section_found = False + result = [] + + for line in lines: + match = SECTION_PATTERN.match(line) + + # No match + if match is None: + result.append(line) + continue + + # Start of the target section? + if match.group(1) == section_name: + result = [] + section_found = True + continue + + # End of the target section? + if match.group(1) == f"/{section_name}": + return "\n".join(result) + + # Some other section, drop it + pass + + # The section was never found + if not section_found: + raise KeyError(f"There is no section named `{section_name}`") + + # The section was found, but never closed + raise ValueError(f"The section `{section_name}` was never closed") + + def stripped_code(self) -> str: + """ + Returns the given code with all section tags removed. + """ + assert self.is_text_snippet, self + assert self.raw_code is not None, self # For the type checker + + lines = self.raw_code.splitlines() + + # Remove all section tags + result = [] + for line in lines: + if SECTION_PATTERN.match(line) is None: + result.append(line) + + return "\n".join(result) + + +class SnippetManager: + """ + A class the helps load snippets from a directory, caching the results. + """ + + def __init__(self, snippet_directory: Path) -> None: + # The directory containing all snippet groups + self._directory = snippet_directory + + # Once initialized, stores all snippets by group and name. Instead of + # accessing this value directly, call `_get_snippet_cache()`. That + # function ensures that the cache has been initialized and returns it. + self._snippet_cache: dict[str, dict[str, Snippet]] | None = None + + def _get_all_snippet_paths(self) -> dict[str, dict[str, Path]]: + """ + Returns a dictionary containing all available snippets. The snippets are + organized by group and name. + """ + # Find all snippet files + result = {} + + def scan_dir_recursively(group_name: str, path: Path) -> None: + assert path.is_dir(), path + assert result is not None + + # Exclude some directories + if path.name == "__pycache__": + return + + for fpath in path.iterdir(): + # Directory + if fpath.is_dir(): + scan_dir_recursively(group_name, fpath) + + # Snippet file + else: + name = fpath.name + group = result.setdefault(group_name, {}) + group[name] = fpath + + # Scan all snippet directories. The first directory is used as a key, the + # rest just for organization. + for group_dir in utils.SNIPPETS_DIR.iterdir(): + assert group_dir.is_dir(), group_dir + scan_dir_recursively(group_dir.name, group_dir) + + return result + + def _get_snippet_cache(self) -> dict[str, dict[str, Snippet]]: + """ + Ensures that the snippet cache has been initialized. If it hasn't + snippets are loaded from disk first, then returned. + + This will always return the same dictionary instance. Thus the result + must not be modified. + """ + # If the cache has already been initialized, return it + if self._snippet_cache is not None: + return self._snippet_cache + + # Load all snippets + self._snippet_cache = {} + + for group_name, snippets in self._get_all_snippet_paths().items(): + group_dict = self._snippet_cache[group_name] = {} + + for name, file_path in snippets.items(): + group_dict[name] = Snippet.from_path( + group_name, + name, + file_path, + ) + + return self._snippet_cache + + def get_snippet(self, group: str, name: str) -> Snippet: + """ + Gets the snippet with the given group and name. + + This function always returns the same snippet instance for a given + group and name. Thus the result must not be modified. + + ## Raises + + `KeyError`: If there is no snippet with the given group and name. + """ + all_groups = self._get_snippet_cache() + + group_dict = all_groups[group] + return group_dict[name] + + def get_all_snippet_groups(self) -> set[str]: + """ + Returns a set of all available snippet groups. + """ + all_groups = self._get_snippet_cache() + return set(all_groups.keys()) + + def get_all_snippets_in_group(self, group: str) -> t.Iterable[Snippet]: + """ + Returns all snippets in the given group. + + This function always returns the same snippet instances for a given + group. Thus the result must not be modified. + + ## Raises + + `KeyError`: If there is no group with the given name. + """ + all_groups = self._get_snippet_cache() + group_dict = all_groups[group] + return tuple(group_dict.values()) diff --git a/tests/test_project_templates.py b/tests/test_project_templates.py index 5f8a6498..b6722c92 100644 --- a/tests/test_project_templates.py +++ b/tests/test_project_templates.py @@ -80,5 +80,5 @@ def test_instantiate_template(template: rio.snippets.ProjectTemplate) -> None: target_parent_directory=Path(project_directory_str), ) - # There is no further checks here. Just make sure there is no crash - # during the instantiation. + # There are no further checks here. The test simply makes sure there is + # no crash during the instantiation.