rework snippet infrastructure

This commit is contained in:
Jakob Pinterits
2024-12-18 19:24:15 +01:00
parent 683157446b
commit 7a01c75c18
4 changed files with 525 additions and 465 deletions

View File

@@ -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),
)
)

View File

@@ -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,
)

View File

@@ -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())

View File

@@ -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.