refactor: annotation with __future__.annotations
Adds `from __future__ import annotations` to the top of every module,
right below the module's docstring. Replaces any usages of t.List,
t.Dict, t.Set, t.Tuple, and t.Type with their built-in equivalents:
list, dict, set, tuple, and type. Ensures that make test still passes
under Python 3.7, 3.8 and 3.9.
2023-01-17 18:57:23 +00:00
|
|
|
from __future__ import annotations
|
2023-01-06 18:02:17 +00:00
|
|
|
|
2019-01-22 20:25:04 +00:00
|
|
|
import os
|
2022-05-30 19:41:06 +00:00
|
|
|
import re
|
2022-02-07 17:11:43 +00:00
|
|
|
import shutil
|
|
|
|
import typing as t
|
2021-04-06 10:09:00 +00:00
|
|
|
from copy import deepcopy
|
2019-01-22 20:25:04 +00:00
|
|
|
|
|
|
|
import jinja2
|
2024-01-11 13:20:04 +00:00
|
|
|
import importlib_resources
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
from tutor import exceptions, fmt, hooks, plugins, utils
|
|
|
|
from tutor.__about__ import __app__, __version__
|
|
|
|
from tutor.types import Config, ConfigValue
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2024-01-11 13:20:04 +00:00
|
|
|
TEMPLATES_ROOT = str(importlib_resources.files("tutor") / "templates")
|
2019-03-18 16:26:37 +00:00
|
|
|
VERSION_FILENAME = "version"
|
2024-01-22 12:18:29 +00:00
|
|
|
BIN_FILE_EXTENSIONS = [
|
|
|
|
".ico",
|
|
|
|
".jpg",
|
|
|
|
".otf",
|
|
|
|
".patch",
|
|
|
|
".png",
|
|
|
|
".ttf",
|
|
|
|
".webp",
|
|
|
|
".woff",
|
|
|
|
".woff2",
|
|
|
|
]
|
2022-02-07 17:11:43 +00:00
|
|
|
JinjaFilter = t.Callable[..., t.Any]
|
|
|
|
|
|
|
|
|
|
|
|
def _prepare_environment() -> None:
|
|
|
|
"""
|
|
|
|
Prepare environment by adding core data to filters.
|
|
|
|
"""
|
|
|
|
# Core template targets
|
|
|
|
hooks.Filters.ENV_TEMPLATE_TARGETS.add_items(
|
|
|
|
[
|
|
|
|
("apps/", ""),
|
|
|
|
("build/", ""),
|
|
|
|
("dev/", ""),
|
|
|
|
("k8s/", ""),
|
|
|
|
("local/", ""),
|
|
|
|
(VERSION_FILENAME, ""),
|
|
|
|
("kustomization.yml", ""),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
# Template filters
|
|
|
|
hooks.Filters.ENV_TEMPLATE_FILTERS.add_items(
|
|
|
|
[
|
|
|
|
("common_domain", utils.common_domain),
|
|
|
|
("encrypt", utils.encrypt),
|
|
|
|
("list_if", utils.list_if),
|
|
|
|
("long_to_base64", utils.long_to_base64),
|
|
|
|
("random_string", utils.random_string),
|
|
|
|
("reverse_host", utils.reverse_host),
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
("rsa_import_key", utils.rsa_import_key),
|
2022-02-07 17:11:43 +00:00
|
|
|
("rsa_private_key", utils.rsa_private_key),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
# Template variables
|
|
|
|
hooks.Filters.ENV_TEMPLATE_VARIABLES.add_items(
|
|
|
|
[
|
|
|
|
("HOST_USER_ID", utils.get_user_id()),
|
|
|
|
("TUTOR_APP", __app__.replace("-", "_")),
|
|
|
|
("TUTOR_VERSION", __version__),
|
2023-10-12 08:14:11 +00:00
|
|
|
("is_docker_rootless", utils.is_docker_rootless),
|
2022-02-07 17:11:43 +00:00
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_prepare_environment()
|
2019-03-18 16:26:37 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-06-02 13:16:32 +00:00
|
|
|
class JinjaEnvironment(jinja2.Environment):
|
2022-11-21 08:56:59 +00:00
|
|
|
loader: jinja2.FileSystemLoader
|
2021-06-02 13:16:32 +00:00
|
|
|
|
2022-11-21 08:56:59 +00:00
|
|
|
def __init__(self) -> None:
|
|
|
|
template_roots = hooks.Filters.ENV_TEMPLATE_ROOTS.apply([TEMPLATES_ROOT])
|
2021-06-02 13:16:32 +00:00
|
|
|
loader = jinja2.FileSystemLoader(template_roots)
|
|
|
|
super().__init__(loader=loader, undefined=jinja2.StrictUndefined)
|
|
|
|
|
2022-11-21 08:56:59 +00:00
|
|
|
def read_str(self, template_name: str) -> str:
|
|
|
|
return self.read_bytes(template_name).decode()
|
|
|
|
|
|
|
|
def read_bytes(self, template_name: str) -> bytes:
|
|
|
|
with open(self.find_os_path(template_name), "rb") as f:
|
|
|
|
return f.read()
|
|
|
|
|
|
|
|
def find_os_path(self, template_name: str) -> str:
|
|
|
|
path = template_name.replace("/", os.sep)
|
|
|
|
for templates_root in self.loader.searchpath:
|
|
|
|
full_path = os.path.join(templates_root, path)
|
|
|
|
if os.path.exists(full_path):
|
|
|
|
return full_path
|
|
|
|
raise ValueError("Template path does not exist")
|
|
|
|
|
2021-06-02 13:16:32 +00:00
|
|
|
|
2019-05-11 17:31:18 +00:00
|
|
|
class Renderer:
|
2022-05-30 19:41:06 +00:00
|
|
|
def __init__(self, config: t.Optional[Config] = None):
|
2022-10-18 14:57:07 +00:00
|
|
|
self.config = deepcopy(config or {})
|
2019-05-11 22:11:44 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
# Create environment with extra filters and globals
|
2022-11-21 08:56:59 +00:00
|
|
|
self.environment = JinjaEnvironment()
|
2022-02-07 17:11:43 +00:00
|
|
|
|
|
|
|
# Filters
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
plugin_filters = hooks.Filters.ENV_TEMPLATE_FILTERS.iterate()
|
2022-02-07 17:11:43 +00:00
|
|
|
for name, func in plugin_filters:
|
|
|
|
if name in self.environment.filters:
|
|
|
|
fmt.echo_alert(f"Found conflicting template filters named '{name}'")
|
|
|
|
self.environment.filters[name] = func
|
|
|
|
self.environment.filters["walk_templates"] = self.walk_templates
|
|
|
|
|
|
|
|
# Globals
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
for name, value in hooks.Filters.ENV_TEMPLATE_VARIABLES.iterate():
|
2022-02-07 17:11:43 +00:00
|
|
|
if name in self.environment.globals:
|
|
|
|
fmt.echo_alert(f"Found conflicting template variables named '{name}'")
|
|
|
|
self.environment.globals[name] = value
|
|
|
|
self.environment.globals["iter_values_named"] = self.iter_values_named
|
|
|
|
self.environment.globals["patch"] = self.patch
|
|
|
|
|
|
|
|
def iter_templates_in(self, *prefix: str) -> t.Iterable[str]:
|
2020-11-07 15:37:43 +00:00
|
|
|
"""
|
|
|
|
The elements of `prefix` must contain only "/", and not os.sep.
|
|
|
|
"""
|
2021-02-25 08:09:14 +00:00
|
|
|
full_prefix = "/".join(prefix)
|
refactor: annotation with __future__.annotations
Adds `from __future__ import annotations` to the top of every module,
right below the module's docstring. Replaces any usages of t.List,
t.Dict, t.Set, t.Tuple, and t.Type with their built-in equivalents:
list, dict, set, tuple, and type. Ensures that make test still passes
under Python 3.7, 3.8 and 3.9.
2023-01-17 18:57:23 +00:00
|
|
|
env_templates: list[str] = self.environment.loader.list_templates()
|
2021-04-06 10:09:00 +00:00
|
|
|
for template in env_templates:
|
2022-05-30 19:41:06 +00:00
|
|
|
if template.startswith(full_prefix):
|
|
|
|
# Exclude templates that match certain patterns
|
|
|
|
# Note that here we don't rely on the OS separator, as we are handling templates.
|
|
|
|
if is_rendered(template):
|
|
|
|
yield template
|
2020-01-16 10:52:53 +00:00
|
|
|
|
2021-04-13 20:14:43 +00:00
|
|
|
def iter_values_named(
|
|
|
|
self,
|
2022-02-07 17:11:43 +00:00
|
|
|
prefix: t.Optional[str] = None,
|
|
|
|
suffix: t.Optional[str] = None,
|
2021-04-13 20:14:43 +00:00
|
|
|
allow_empty: bool = False,
|
2022-02-07 17:11:43 +00:00
|
|
|
) -> t.Iterable[ConfigValue]:
|
2021-04-13 20:14:43 +00:00
|
|
|
"""
|
|
|
|
Iterate on all config values for which the name match the given pattern.
|
|
|
|
|
|
|
|
Note that here we only iterate on the values, not the key names. Empty
|
|
|
|
values (those that evaluate to boolean `false`) will not be yielded, unless
|
|
|
|
`allow_empty` is True.
|
|
|
|
"""
|
|
|
|
for var_name, value in self.config.items():
|
|
|
|
if prefix is not None and not var_name.startswith(prefix):
|
|
|
|
continue
|
|
|
|
if suffix is not None and not var_name.endswith(suffix):
|
|
|
|
continue
|
|
|
|
if not allow_empty and not value:
|
|
|
|
continue
|
|
|
|
yield value
|
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def walk_templates(self, subdir: str) -> t.Iterable[str]:
|
2020-01-16 10:52:53 +00:00
|
|
|
"""
|
|
|
|
Iterate on the template files from `templates/<subdir>`.
|
2019-05-11 22:11:44 +00:00
|
|
|
|
2020-01-16 10:52:53 +00:00
|
|
|
Yield:
|
|
|
|
path: template path relative to the template root
|
|
|
|
"""
|
2022-05-30 19:41:06 +00:00
|
|
|
yield from self.iter_templates_in(subdir)
|
2020-01-16 21:25:57 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def patch(self, name: str, separator: str = "\n", suffix: str = "") -> str:
|
2020-01-08 11:20:24 +00:00
|
|
|
"""
|
|
|
|
Render calls to {{ patch("...") }} in environment templates from plugin patches.
|
|
|
|
"""
|
2019-05-29 09:14:06 +00:00
|
|
|
patches = []
|
2022-02-07 17:11:43 +00:00
|
|
|
for patch in plugins.iter_patches(name):
|
2019-05-29 09:14:06 +00:00
|
|
|
try:
|
2021-04-13 20:14:43 +00:00
|
|
|
patches.append(self.render_str(patch))
|
|
|
|
except exceptions.TutorError:
|
2023-04-12 08:35:00 +00:00
|
|
|
fmt.echo_error(f"Error rendering patch '{name}':\n{patch}")
|
2021-04-13 20:14:43 +00:00
|
|
|
raise
|
2019-05-29 09:14:06 +00:00
|
|
|
rendered = separator.join(patches)
|
|
|
|
if rendered:
|
|
|
|
rendered += suffix
|
|
|
|
return rendered
|
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def render_str(self, text: str) -> str:
|
2023-04-12 08:35:00 +00:00
|
|
|
try:
|
|
|
|
template = self.environment.from_string(text)
|
|
|
|
except jinja2.exceptions.TemplateSyntaxError as e:
|
|
|
|
raise exceptions.TutorError(f"Template syntax error: {e.args[0]}")
|
2020-01-16 10:52:53 +00:00
|
|
|
return self.__render(template)
|
2019-05-11 17:31:18 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def render_template(self, template_name: str) -> t.Union[str, bytes]:
|
2020-02-27 16:14:00 +00:00
|
|
|
"""
|
|
|
|
Render a template file. Return the corresponding string. If it's a binary file
|
|
|
|
(as indicated by its path), return bytes.
|
2020-11-07 15:37:43 +00:00
|
|
|
|
|
|
|
The template_name *always* uses "/" separators, and is not os-dependent. Do not pass the result of
|
|
|
|
os.path.join(...) to this function.
|
2020-02-27 16:14:00 +00:00
|
|
|
"""
|
2020-11-07 15:37:43 +00:00
|
|
|
if is_binary_file(template_name):
|
2020-01-16 14:40:38 +00:00
|
|
|
# Don't try to render binary files
|
2022-11-21 08:56:59 +00:00
|
|
|
return self.environment.read_bytes(template_name)
|
2020-01-16 14:40:38 +00:00
|
|
|
|
2020-01-16 10:52:53 +00:00
|
|
|
try:
|
2020-11-07 15:37:43 +00:00
|
|
|
template = self.environment.get_template(template_name)
|
2020-01-16 10:52:53 +00:00
|
|
|
except Exception:
|
2020-11-07 15:37:43 +00:00
|
|
|
fmt.echo_error("Error loading template " + template_name)
|
2020-01-16 10:52:53 +00:00
|
|
|
raise
|
2020-01-16 14:40:38 +00:00
|
|
|
|
2020-01-16 10:52:53 +00:00
|
|
|
try:
|
|
|
|
return self.__render(template)
|
|
|
|
except (jinja2.exceptions.TemplateError, exceptions.TutorError):
|
2020-11-07 15:37:43 +00:00
|
|
|
fmt.echo_error("Error rendering template " + template_name)
|
2020-01-16 10:52:53 +00:00
|
|
|
raise
|
|
|
|
except Exception:
|
2020-11-07 15:37:43 +00:00
|
|
|
fmt.echo_error("Unknown error rendering template " + template_name)
|
2020-01-16 10:52:53 +00:00
|
|
|
raise
|
2019-06-05 13:43:51 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def render_all_to(self, dst: str, *prefix: str) -> None:
|
2020-11-07 15:37:43 +00:00
|
|
|
"""
|
|
|
|
`prefix` can be used to limit the templates to render.
|
|
|
|
"""
|
|
|
|
for template_name in self.iter_templates_in(*prefix):
|
|
|
|
rendered = self.render_template(template_name)
|
2022-02-07 17:11:43 +00:00
|
|
|
template_dst = os.path.join(dst, template_name.replace("/", os.sep))
|
|
|
|
write_to(rendered, template_dst)
|
2020-01-16 14:40:38 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def __render(self, template: jinja2.Template) -> str:
|
2020-01-16 10:52:53 +00:00
|
|
|
try:
|
|
|
|
return template.render(**self.config)
|
|
|
|
except jinja2.exceptions.UndefinedError as e:
|
2022-01-08 11:19:46 +00:00
|
|
|
raise exceptions.TutorError(f"Missing configuration value: {e.args[0]}")
|
2019-06-05 13:43:51 +00:00
|
|
|
|
2020-01-16 10:52:53 +00:00
|
|
|
|
2023-02-08 04:35:25 +00:00
|
|
|
class PatchRenderer(Renderer):
|
|
|
|
"""
|
|
|
|
Render patches for print it.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, config: t.Optional[Config] = None):
|
|
|
|
self.patches_locations: t.Dict[str, t.List[str]] = {}
|
|
|
|
self.current_template: str = ""
|
|
|
|
super().__init__(config)
|
|
|
|
|
|
|
|
def render_template(self, template_name: str) -> t.Union[str, bytes]:
|
|
|
|
"""
|
|
|
|
Set the current template and render template from Renderer.
|
|
|
|
"""
|
|
|
|
self.current_template = template_name
|
|
|
|
return super().render_template(self.current_template)
|
|
|
|
|
|
|
|
def patch(self, name: str, separator: str = "\n", suffix: str = "") -> str:
|
|
|
|
"""
|
|
|
|
Set the patches locations and render calls to {{ patch("...") }} from Renderer.
|
|
|
|
"""
|
|
|
|
if not self.patches_locations.get(name):
|
|
|
|
self.patches_locations.update({name: [self.current_template]})
|
|
|
|
else:
|
|
|
|
if self.current_template not in self.patches_locations[name]:
|
|
|
|
self.patches_locations[name].append(self.current_template)
|
|
|
|
|
|
|
|
# Store the template's name, and replace it with the name of this patch.
|
|
|
|
# This handles the case where patches themselves include patches.
|
|
|
|
original_template = self.current_template
|
|
|
|
self.current_template = f"within patch: {name}"
|
|
|
|
|
|
|
|
rendered_patch = super().patch(name, separator=separator, suffix=suffix)
|
|
|
|
self.current_template = (
|
|
|
|
original_template # Restore the template's name from before.
|
|
|
|
)
|
|
|
|
return rendered_patch
|
|
|
|
|
|
|
|
def render_all(self, *prefix: str) -> None:
|
|
|
|
"""
|
|
|
|
Render all templates.
|
|
|
|
"""
|
|
|
|
for template_name in self.iter_templates_in(*prefix):
|
|
|
|
self.render_template(template_name)
|
|
|
|
|
|
|
|
def print_patches_locations(self) -> None:
|
|
|
|
"""
|
|
|
|
Print patches locations.
|
|
|
|
"""
|
|
|
|
plugins_table: list[tuple[str, ...]] = [("PATCH", "LOCATIONS")]
|
|
|
|
self.render_all()
|
|
|
|
for patch, locations in sorted(self.patches_locations.items()):
|
|
|
|
n_locations = 0
|
|
|
|
for location in locations:
|
|
|
|
if n_locations < 1:
|
|
|
|
plugins_table.append((patch, location))
|
|
|
|
n_locations += 1
|
|
|
|
else:
|
|
|
|
plugins_table.append(("", location))
|
|
|
|
|
|
|
|
fmt.echo(utils.format_table(plugins_table))
|
|
|
|
|
|
|
|
|
2022-05-30 19:41:06 +00:00
|
|
|
def is_rendered(path: str) -> bool:
|
|
|
|
"""
|
|
|
|
Return whether the template should be rendered or not.
|
|
|
|
|
|
|
|
If the path matches an include pattern, it is rendered. If not and it matches an
|
|
|
|
ignore pattern, it is not rendered. By default, all files are rendered.
|
|
|
|
"""
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
for include_pattern in hooks.Filters.ENV_PATTERNS_INCLUDE.iterate():
|
2022-05-30 19:41:06 +00:00
|
|
|
if re.match(include_pattern, path):
|
|
|
|
return True
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
for ignore_pattern in hooks.Filters.ENV_PATTERNS_IGNORE.iterate():
|
2022-05-30 19:41:06 +00:00
|
|
|
if re.match(ignore_pattern, path):
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Skip rendering some files that follow commonly-ignored patterns:
|
|
|
|
#
|
|
|
|
# .*
|
|
|
|
# *.pyc
|
|
|
|
# __pycache__
|
|
|
|
# partials
|
|
|
|
hooks.Filters.ENV_PATTERNS_IGNORE.add_items(
|
|
|
|
[
|
|
|
|
# Skip all hidden files
|
|
|
|
r"(.*/)?\.",
|
|
|
|
# Skip compiled python files
|
|
|
|
r"(.*/)?__pycache__(/.*)?$",
|
|
|
|
r".*\.pyc$",
|
|
|
|
# Skip files from "partials" folders
|
|
|
|
r"(.*/)?partials(/.*)?$",
|
|
|
|
]
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-04-06 10:09:00 +00:00
|
|
|
def save(root: str, config: Config) -> None:
|
2019-03-18 16:26:37 +00:00
|
|
|
"""
|
2020-01-16 10:52:53 +00:00
|
|
|
Save the full environment, including version information.
|
|
|
|
"""
|
|
|
|
root_env = pathjoin(root)
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
for src, dst in hooks.Filters.ENV_TEMPLATE_TARGETS.iterate():
|
2022-02-07 17:11:43 +00:00
|
|
|
save_all_from(src, os.path.join(root_env, dst), config)
|
2020-01-16 10:52:53 +00:00
|
|
|
|
2020-03-12 10:59:50 +00:00
|
|
|
upgrade_obsolete(root)
|
2022-01-08 11:19:46 +00:00
|
|
|
fmt.echo_info(f"Environment generated in {base_dir(root)}")
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-11-08 14:41:35 +00:00
|
|
|
def upgrade_obsolete(_root: str) -> None:
|
2021-10-14 10:47:23 +00:00
|
|
|
"""
|
|
|
|
Add here ad-hoc commands to upgrade the environment.
|
|
|
|
"""
|
2020-03-12 10:59:50 +00:00
|
|
|
|
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def save_all_from(prefix: str, dst: str, config: Config) -> None:
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2020-01-16 10:52:53 +00:00
|
|
|
Render the templates that start with `prefix` and store them with the same
|
2022-02-07 17:11:43 +00:00
|
|
|
hierarchy at `dst`. Here, `prefix` can be the result of os.path.join(...).
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2022-05-30 16:12:00 +00:00
|
|
|
renderer = Renderer(config)
|
2022-02-07 17:11:43 +00:00
|
|
|
renderer.render_all_to(dst, prefix.replace(os.sep, "/"))
|
2019-07-02 20:16:44 +00:00
|
|
|
|
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def write_to(content: t.Union[str, bytes], path: str) -> None:
|
2020-01-16 14:40:38 +00:00
|
|
|
"""
|
2020-02-27 16:14:00 +00:00
|
|
|
Write some content to a path. Content can be either str or bytes.
|
2020-01-16 14:40:38 +00:00
|
|
|
"""
|
2021-02-25 08:09:14 +00:00
|
|
|
utils.ensure_file_directory_exists(path)
|
2020-01-16 14:40:38 +00:00
|
|
|
if isinstance(content, bytes):
|
2021-02-25 08:09:14 +00:00
|
|
|
with open(path, mode="wb") as of_binary:
|
|
|
|
of_binary.write(content)
|
2020-11-20 15:05:56 +00:00
|
|
|
else:
|
2021-02-25 08:09:14 +00:00
|
|
|
with open(path, mode="w", encoding="utf8", newline="\n") as of_text:
|
|
|
|
of_text.write(content)
|
2019-03-22 17:50:16 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def render_file(config: Config, *path: str) -> t.Union[str, bytes]:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Return the rendered contents of a template.
|
|
|
|
"""
|
2022-05-30 16:12:00 +00:00
|
|
|
renderer = Renderer(config)
|
2020-11-07 15:37:43 +00:00
|
|
|
template_name = "/".join(path)
|
|
|
|
return renderer.render_template(template_name)
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def render_unknown(config: Config, value: t.Any) -> t.Any:
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2021-04-13 20:14:43 +00:00
|
|
|
Render an unknown `value` object with the selected config.
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2021-04-13 20:14:43 +00:00
|
|
|
If `value` is a dict, its values are also rendered.
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2019-06-05 13:43:51 +00:00
|
|
|
if isinstance(value, str):
|
|
|
|
return render_str(config, value)
|
2022-01-08 11:19:46 +00:00
|
|
|
if isinstance(value, dict):
|
2021-04-13 20:14:43 +00:00
|
|
|
return {k: render_unknown(config, v) for k, v in value.items()}
|
2023-09-05 14:00:01 +00:00
|
|
|
if isinstance(value, list):
|
|
|
|
return [render_unknown(config, v) for v in value]
|
2019-06-05 13:43:51 +00:00
|
|
|
return value
|
|
|
|
|
2019-06-05 17:57:30 +00:00
|
|
|
|
2021-04-06 10:09:00 +00:00
|
|
|
def render_str(config: Config, text: str) -> str:
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
|
|
|
Args:
|
|
|
|
text (str)
|
|
|
|
config (dict)
|
|
|
|
|
|
|
|
Return:
|
|
|
|
substituted (str)
|
|
|
|
"""
|
2022-05-30 16:12:00 +00:00
|
|
|
return Renderer(config).render_str(text)
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def check_is_up_to_date(root: str) -> None:
|
2019-06-05 13:43:51 +00:00
|
|
|
if not is_up_to_date(root):
|
2020-02-27 16:14:00 +00:00
|
|
|
fmt.echo_alert(
|
2022-01-08 11:19:46 +00:00
|
|
|
f"The current environment stored at {base_dir(root)} is not up-to-date: it is at "
|
|
|
|
f"v{current_version(root)} while the 'tutor' binary is at v{__version__}. You should upgrade "
|
|
|
|
f"the environment by running:\n"
|
|
|
|
f"\n"
|
|
|
|
f" tutor config save"
|
2020-02-27 16:14:00 +00:00
|
|
|
)
|
2019-06-05 13:43:51 +00:00
|
|
|
|
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def is_up_to_date(root: str) -> bool:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Check if the currently rendered version is equal to the current tutor version.
|
|
|
|
"""
|
2022-01-08 11:19:46 +00:00
|
|
|
current = current_version(root)
|
|
|
|
return current is None or current == __version__
|
2019-03-18 16:26:37 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def should_upgrade_from_release(root: str) -> t.Optional[str]:
|
2019-12-24 16:22:12 +00:00
|
|
|
"""
|
2022-01-08 10:24:50 +00:00
|
|
|
Return the name of the currently installed release that we should upgrade from. Return None If we already run the
|
|
|
|
latest release.
|
2019-12-24 16:22:12 +00:00
|
|
|
"""
|
2022-01-08 11:19:46 +00:00
|
|
|
current = current_version(root)
|
|
|
|
if current is None:
|
2022-01-08 10:24:50 +00:00
|
|
|
return None
|
2022-01-08 11:19:46 +00:00
|
|
|
current_as_int = int(current.split(".")[0])
|
2022-01-08 10:24:50 +00:00
|
|
|
required_as_int = int(__version__.split(".", maxsplit=1)[0])
|
|
|
|
if current_as_int >= required_as_int:
|
|
|
|
return None
|
|
|
|
return get_release(current)
|
2019-12-24 16:22:12 +00:00
|
|
|
|
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def get_env_release(root: str) -> t.Optional[str]:
|
2019-12-24 16:22:12 +00:00
|
|
|
"""
|
2022-01-08 10:24:50 +00:00
|
|
|
Return the Open edX release name from the current environment.
|
|
|
|
|
|
|
|
If the current environment has no version, return None.
|
2019-12-24 16:22:12 +00:00
|
|
|
"""
|
2022-01-08 10:24:50 +00:00
|
|
|
version = current_version(root)
|
|
|
|
if version is None:
|
2022-01-08 11:19:46 +00:00
|
|
|
return None
|
2022-01-08 10:24:50 +00:00
|
|
|
return get_release(version)
|
|
|
|
|
|
|
|
|
2023-01-03 16:00:42 +00:00
|
|
|
def get_current_open_edx_release_name() -> str:
|
2022-01-08 10:24:50 +00:00
|
|
|
"""
|
|
|
|
Return the release name associated to this package.
|
|
|
|
"""
|
|
|
|
return get_release(__version__)
|
|
|
|
|
|
|
|
|
|
|
|
def get_release(version: str) -> str:
|
2021-10-18 09:43:40 +00:00
|
|
|
return {
|
|
|
|
"0": "ironwood",
|
|
|
|
"3": "ironwood",
|
|
|
|
"10": "juniper",
|
|
|
|
"11": "koa",
|
|
|
|
"12": "lilac",
|
|
|
|
"13": "maple",
|
2022-04-11 15:26:17 +00:00
|
|
|
"14": "nutmeg",
|
2022-11-22 12:53:29 +00:00
|
|
|
"15": "olive",
|
2023-04-12 08:35:00 +00:00
|
|
|
"16": "palm",
|
2023-10-11 07:39:55 +00:00
|
|
|
"17": "quince",
|
2022-01-08 10:24:50 +00:00
|
|
|
}[version.split(".", maxsplit=1)[0]]
|
2019-12-24 16:22:12 +00:00
|
|
|
|
|
|
|
|
2022-02-07 17:11:43 +00:00
|
|
|
def current_version(root: str) -> t.Optional[str]:
|
2019-03-18 16:26:37 +00:00
|
|
|
"""
|
2019-05-11 17:31:18 +00:00
|
|
|
Return the current environment version. If the current environment has no version,
|
2022-01-08 11:19:46 +00:00
|
|
|
return None.
|
2019-03-18 16:26:37 +00:00
|
|
|
"""
|
|
|
|
path = pathjoin(root, VERSION_FILENAME)
|
|
|
|
if not os.path.exists(path):
|
2022-01-08 11:19:46 +00:00
|
|
|
return None
|
|
|
|
with open(path, encoding="utf-8") as fi:
|
|
|
|
return fi.read().strip()
|
2019-03-18 16:26:37 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def read_template_file(*path: str) -> str:
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2019-05-11 17:31:18 +00:00
|
|
|
Read raw content of template located at `path`.
|
2022-11-21 08:56:59 +00:00
|
|
|
|
|
|
|
The template may be located inside any of the template root folders.
|
|
|
|
"""
|
|
|
|
return JinjaEnvironment().read_str("/".join(path))
|
|
|
|
|
|
|
|
|
|
|
|
def read_core_template_file(*path: str) -> str:
|
|
|
|
"""
|
|
|
|
Read raw content of template located in tutor core template directory.
|
2019-01-22 20:25:04 +00:00
|
|
|
"""
|
2022-11-21 08:56:59 +00:00
|
|
|
with open(os.path.join(TEMPLATES_ROOT, *path), encoding="utf-8") as fi:
|
2019-01-22 20:25:04 +00:00
|
|
|
return fi.read()
|
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def is_binary_file(path: str) -> bool:
|
2020-01-16 14:40:38 +00:00
|
|
|
ext = os.path.splitext(path)[1]
|
|
|
|
return ext in BIN_FILE_EXTENSIONS
|
|
|
|
|
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def data_path(root: str, *path: str) -> str:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Return the file's absolute path inside the data directory.
|
|
|
|
"""
|
|
|
|
return os.path.join(root_dir(root), "data", *path)
|
2019-01-22 20:25:04 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def pathjoin(root: str, *path: str) -> str:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Return the file's absolute path inside the environment.
|
|
|
|
"""
|
|
|
|
return os.path.join(base_dir(root), *path)
|
2019-03-18 16:26:37 +00:00
|
|
|
|
2019-04-19 22:02:47 +00:00
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def base_dir(root: str) -> str:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Return the environment base directory.
|
|
|
|
"""
|
|
|
|
return os.path.join(root_dir(root), "env")
|
|
|
|
|
|
|
|
|
2021-02-25 08:09:14 +00:00
|
|
|
def root_dir(root: str) -> str:
|
2019-05-11 17:31:18 +00:00
|
|
|
"""
|
|
|
|
Return the project root directory.
|
|
|
|
"""
|
|
|
|
return os.path.abspath(root)
|
2022-02-07 17:11:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
@hooks.Actions.PLUGIN_UNLOADED.add()
|
|
|
|
def _delete_plugin_templates(plugin: str, root: str, _config: Config) -> None:
|
|
|
|
"""
|
|
|
|
Delete plugin env files on unload.
|
|
|
|
"""
|
feat: strongly typed hooks
Now that the mypy bugs have been resolved, we are able to define more precisely
and cleanly the types of Actions and Filters.
Moreover, can now strongly type named actions and hooks (in consts.py). With
such a strong typing, we get early alerts of hooks called with incorrect
arguments, which is nothing short of awesome :)
This change breaks the hooks API by removing the `context=...` argument. The
reason for that is that we cannot insert arbitrary arguments between `P.args,
P.kwargs`: https://peps.python.org/pep-0612/#the-components-of-a-paramspec
> A function declared as def inner(a: A, b: B, *args: P.args, **kwargs:
> P.kwargs) -> R has type Callable[Concatenate[A, B, P], R]. Placing
> keyword-only parameters between the *args and **kwargs is forbidden.
Getting the documentation to build in nitpicky mode is quite difficult... We
need to add `nitpick_ignore` to the docs conf.py, otherwise sphinx complains
about many missing class references. This, despite upgrading almost all doc
requirements (except docutils).
2022-10-06 10:05:01 +00:00
|
|
|
targets = hooks.Filters.ENV_TEMPLATE_TARGETS.iterate_from_context(
|
2023-04-28 15:11:14 +00:00
|
|
|
hooks.Contexts.app(plugin).name
|
2022-02-07 17:11:43 +00:00
|
|
|
)
|
|
|
|
for src, dst in targets:
|
|
|
|
path = pathjoin(root, dst.replace("/", os.sep), src.replace("/", os.sep))
|
|
|
|
if os.path.exists(path):
|
|
|
|
fmt.echo_info(f" env - removing folder: {path}")
|
|
|
|
try:
|
|
|
|
shutil.rmtree(path)
|
|
|
|
except PermissionError as e:
|
|
|
|
raise exceptions.TutorError(
|
|
|
|
f"Could not delete file {e.filename} from plugin {plugin} in folder {path}"
|
|
|
|
)
|