plain.templates 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ # plain-templates changelog
2
+
3
+ ## [0.1.0](https://github.com/dropseed/plain/releases/plain-templates@0.1.0) (2026-05-12)
4
+
5
+ ### What's changed
6
+
7
+ - First release. `plain.templates` is now a separate package, extracted from `plain` core ([19b622a7ca](https://github.com/dropseed/plain/commit/19b622a7ca)). It owns:
8
+ - The Jinja2 engine, `DefaultEnvironment`, the `TEMPLATES_JINJA_ENVIRONMENT` setting
9
+ - `Template` and `TemplateFileMissing`
10
+ - `register_template_global`, `register_template_filter`, `register_template_extension`
11
+ - The template-rendering view bases: `TemplateView`, `FormView`, `DetailView`, `CreateView`, `UpdateView`, `DeleteView`, `ListView` at `plain.templates.views`
12
+ - The `{status_code}.html` error rendering wired through `plain` core's framework default exception handler (lazy import — degrades to plain text if this package isn't installed)
13
+
14
+ ### Upgrade instructions
15
+
16
+ - Install the package and add it to `INSTALLED_PACKAGES` — see the [`plain` 0.143.0 release notes](../../../plain/plain/CHANGELOG.md) for the full migration.
@@ -0,0 +1,395 @@
1
+ # plain.templates
2
+
3
+ **Render HTML templates using Jinja2.**
4
+
5
+ - [Overview](#overview)
6
+ - [Template files](#template-files)
7
+ - [Template-rendering views](#template-rendering-views)
8
+ - [TemplateView](#templateview)
9
+ - [FormView](#formview)
10
+ - [DetailView, CreateView, UpdateView, DeleteView, ListView](#object-views)
11
+ - [Template context](#template-context)
12
+ - [Built-in globals](#built-in-globals)
13
+ - [Built-in filters](#built-in-filters)
14
+ - [Custom globals and filters](#custom-globals-and-filters)
15
+ - [Custom template extensions](#custom-template-extensions)
16
+ - [Rendering templates manually](#rendering-templates-manually)
17
+ - [Custom Jinja environment](#custom-jinja-environment)
18
+ - [Forms](#forms)
19
+ - [FAQs](#faqs)
20
+ - [Installation](#installation)
21
+
22
+ ## Overview
23
+
24
+ Plain uses Jinja2 for template rendering. You can refer to the [Jinja documentation](https://jinja.palletsprojects.com/en/stable/) for all of the features available.
25
+
26
+ Templates are typically used with [`TemplateView`](./views.py#TemplateView) or one of its subclasses (see [Template-rendering views](#template-rendering-views)).
27
+
28
+ ```python
29
+ # app/views.py
30
+ from plain.templates.views import TemplateView
31
+
32
+
33
+ class ExampleView(TemplateView):
34
+ template_name = "example.html"
35
+
36
+ def get_template_context(self):
37
+ context = super().get_template_context()
38
+ context["message"] = "Hello, world!"
39
+ return context
40
+ ```
41
+
42
+ ```html
43
+ <!-- app/templates/example.html -->
44
+ {% extends "base.html" %}
45
+
46
+ {% block content %}
47
+ <h1>{{ message }}</h1>
48
+ {% endblock %}
49
+ ```
50
+
51
+ ## Template files
52
+
53
+ Template files can live in two locations:
54
+
55
+ 1. **`app/templates/`** - Your app's templates (highest priority)
56
+ 2. **`{package}/templates/`** - Templates inside any installed package
57
+
58
+ All template directories are merged together, so you can override templates from installed packages by creating a file with the same name in `app/templates/`.
59
+
60
+ ## Template-rendering views
61
+
62
+ `plain.templates.views` ships the view classes that render templates. The base [`View`](../../../plain/plain/views/README.md) class lives in core `plain.views` and doesn't know about templates — install `plain.templates` to use any of these.
63
+
64
+ ### TemplateView
65
+
66
+ [`TemplateView`](./views.py#TemplateView) renders a Jinja template:
67
+
68
+ ```python
69
+ from plain.templates.views import TemplateView
70
+
71
+
72
+ class ExampleView(TemplateView):
73
+ template_name = "example.html"
74
+
75
+ def get_template_context(self):
76
+ context = super().get_template_context()
77
+ context["message"] = "Hello, world!"
78
+ return context
79
+ ```
80
+
81
+ For simple pages that don't need custom context, configure `TemplateView` directly in your URL routes:
82
+
83
+ ```python
84
+ from plain.templates.views import TemplateView
85
+ from plain.urls import path, Router
86
+
87
+
88
+ class AppRouter(Router):
89
+ routes = [
90
+ path("/example/", TemplateView.as_view(template_name="example.html")),
91
+ ]
92
+ ```
93
+
94
+ ### FormView
95
+
96
+ [`FormView`](./views.py#FormView) handles displaying and processing [forms](../../../plain/plain/forms/README.md). The form is automatically available in your template as `form`:
97
+
98
+ ```python
99
+ from plain.templates.views import FormView
100
+ from .forms import ExampleForm
101
+
102
+
103
+ class ExampleView(FormView):
104
+ template_name = "example.html"
105
+ form_class = ExampleForm
106
+ success_url = "."
107
+
108
+ def form_valid(self, form):
109
+ return super().form_valid(form)
110
+ ```
111
+
112
+ ### Object views
113
+
114
+ [`DetailView`](./views.py#DetailView), [`CreateView`](./views.py#CreateView), [`UpdateView`](./views.py#UpdateView), [`DeleteView`](./views.py#DeleteView), and [`ListView`](./views.py#ListView) provide standard CRUD scaffolding. Each requires you to implement `get_object()` or `get_objects()`:
115
+
116
+ ```python
117
+ from plain.templates.views import DetailView
118
+
119
+
120
+ class ExampleDetailView(DetailView):
121
+ template_name = "detail.html"
122
+
123
+ def get_object(self):
124
+ return MyObjectClass.query.get(
125
+ id=self.url_kwargs["id"],
126
+ user=self.request.user,
127
+ )
128
+ ```
129
+
130
+ The single object is exposed in templates as `object`; list views expose `objects`. Set `context_object_name` for a more descriptive name.
131
+
132
+ ## Template context
133
+
134
+ When using `TemplateView`, you pass data to templates by overriding `get_template_context()`:
135
+
136
+ ```python
137
+ from plain.templates.views import TemplateView
138
+
139
+
140
+ class ProductView(TemplateView):
141
+ template_name = "product.html"
142
+
143
+ def get_template_context(self):
144
+ context = super().get_template_context()
145
+ context["product"] = Product.objects.get(id=self.url_kwargs["id"])
146
+ context["related_products"] = Product.objects.filter(category=context["product"].category)[:5]
147
+ return context
148
+ ```
149
+
150
+ The context is then available in your template:
151
+
152
+ ```html
153
+ <h1>{{ product.name }}</h1>
154
+ <ul>
155
+ {% for item in related_products %}
156
+ <li>{{ item.name }}</li>
157
+ {% endfor %}
158
+ </ul>
159
+ ```
160
+
161
+ ## Built-in globals
162
+
163
+ Plain provides several [global functions](./jinja/globals.py) available in all templates:
164
+
165
+ | Global | Description |
166
+ | ---------------------------- | ---------------------------------- |
167
+ | `asset(path)` | Returns the URL for a static asset |
168
+ | `url(name, *args, **kwargs)` | Reverses a URL by name |
169
+ | `Paginator` | The Paginator class for pagination |
170
+ | `now()` | Returns the current datetime |
171
+ | `timedelta` | The timedelta class for date math |
172
+ | `localtime(dt)` | Converts a datetime to local time |
173
+
174
+ ```html
175
+ <link rel="stylesheet" href="{{ asset('css/style.css') }}">
176
+ <a href="{{ url('product_detail', id=product.id) }}">View</a>
177
+ <p>Generated at {{ now() }}</p>
178
+ ```
179
+
180
+ ## Built-in filters
181
+
182
+ Plain includes several [filters](./jinja/filters.py) for common operations:
183
+
184
+ | Filter | Description |
185
+ | ----------------------------- | ------------------------------------ |
186
+ | `strftime(format)` | Formats a datetime |
187
+ | `strptime(format)` | Parses a string to datetime |
188
+ | `fromtimestamp(ts)` | Creates datetime from timestamp |
189
+ | `fromisoformat(s)` | Creates datetime from ISO string |
190
+ | `localtime(tz)` | Converts to local timezone |
191
+ | `timeuntil` | Human-readable time until a date |
192
+ | `timesince` | Human-readable time since a date |
193
+ | `json_script(id)` | Outputs JSON safely in a script tag |
194
+ | `islice(stop)` | Slices iterables (useful for dicts) |
195
+ | `pluralize(singular, plural)` | Returns plural suffix based on count |
196
+
197
+ ```html
198
+ <p>Posted {{ post.created_at|timesince }} ago</p>
199
+ <p>{{ items|length }} item{{ items|length|pluralize }}</p>
200
+ <p>{{ 5 }} ox{{ 5|pluralize("en") }}</p>
201
+ {{ data|json_script("page-data") }}
202
+ ```
203
+
204
+ ## Custom globals and filters
205
+
206
+ You can register your own globals and filters in `app/templates.py` (or `{package}/templates.py`). These files are automatically imported when the template environment loads.
207
+
208
+ ```python
209
+ # app/templates.py
210
+ from plain.templates import register_template_filter, register_template_global
211
+
212
+
213
+ @register_template_filter
214
+ def camel_case(value):
215
+ """Convert a string to CamelCase."""
216
+ return value.replace("_", " ").title().replace(" ", "")
217
+
218
+
219
+ @register_template_global
220
+ def app_version():
221
+ """Return the current app version."""
222
+ return "1.0.0"
223
+ ```
224
+
225
+ Now you can use these in templates:
226
+
227
+ ```html
228
+ <p>{{ "my_variable"|camel_case }}</p> <!-- outputs: MyVariable -->
229
+ <footer>Version {{ app_version() }}</footer>
230
+ ```
231
+
232
+ You can also register non-callable values as globals by providing a name:
233
+
234
+ ```python
235
+ from plain.templates import register_template_global
236
+
237
+ register_template_global("1.0.0", name="APP_VERSION")
238
+ ```
239
+
240
+ ## Custom template extensions
241
+
242
+ For more complex template features, you can create Jinja extensions. The [`InclusionTagExtension`](./jinja/extensions.py#InclusionTagExtension) base class makes it easy to create custom tags that render their own templates.
243
+
244
+ ```python
245
+ # app/templates.py
246
+ from plain.templates import register_template_extension
247
+ from plain.templates.jinja.extensions import InclusionTagExtension
248
+ from plain.runtime import settings
249
+
250
+
251
+ @register_template_extension
252
+ class AlertExtension(InclusionTagExtension):
253
+ tags = {"alert"}
254
+ template_name = "components/alert.html"
255
+
256
+ def get_context(self, context, *args, **kwargs):
257
+ return {
258
+ "message": args[0] if args else "",
259
+ "type": kwargs.get("type", "info"),
260
+ }
261
+ ```
262
+
263
+ ```html
264
+ <!-- app/templates/components/alert.html -->
265
+ <div class="alert alert-{{ type }}">{{ message }}</div>
266
+ ```
267
+
268
+ ```html
269
+ <!-- Usage in any template -->
270
+ {% alert "Something happened!" type="warning" %}
271
+ ```
272
+
273
+ ## Rendering templates manually
274
+
275
+ You can render templates outside of views using the [`Template`](./core.py#Template) class.
276
+
277
+ ```python
278
+ from plain.templates import Template
279
+
280
+ html = Template("email/welcome.html").render({
281
+ "user_name": "Alice",
282
+ "activation_url": "https://example.com/activate/abc123",
283
+ })
284
+ ```
285
+
286
+ If the template file doesn't exist, a [`TemplateFileMissing`](./core.py#TemplateFileMissing) exception is raised.
287
+
288
+ ## Custom Jinja environment
289
+
290
+ By default, Plain uses a [`DefaultEnvironment`](./jinja/environments.py#DefaultEnvironment) that configures Jinja2 with sensible defaults:
291
+
292
+ - **Autoescaping** enabled for security
293
+ - **StrictUndefined** so undefined variables raise errors
294
+ - **Auto-reload** in debug mode
295
+ - **Loop controls** extension (`break`, `continue`)
296
+ - **Debug** extension
297
+
298
+ You can customize the environment by creating your own class and pointing to it in settings:
299
+
300
+ ```python
301
+ # app/jinja.py
302
+ from plain.templates.jinja.environments import DefaultEnvironment
303
+
304
+
305
+ class CustomEnvironment(DefaultEnvironment):
306
+ def __init__(self):
307
+ super().__init__()
308
+ # Add your customizations here
309
+ self.globals["CUSTOM_SETTING"] = "value"
310
+ ```
311
+
312
+ ```python
313
+ # app/settings.py
314
+ TEMPLATES_JINJA_ENVIRONMENT = "app.jinja.CustomEnvironment"
315
+ ```
316
+
317
+ ## FAQs
318
+
319
+ #### Why am I getting "undefined variable" errors?
320
+
321
+ Plain uses Jinja's `StrictUndefined` mode, which raises an error when you reference a variable that doesn't exist in the context. This helps catch typos and missing data early. Make sure you're passing all required variables in `get_template_context()`.
322
+
323
+ #### Why does my template show an error about a callable?
324
+
325
+ Plain's template environment prevents accidentally rendering callables (functions, methods) directly. If you see an error like "X is callable, did you forget parentheses?", you probably need to add `()` to call the function:
326
+
327
+ ```html
328
+ <!-- Wrong -->
329
+ {{ user.get_full_name }}
330
+
331
+ <!-- Correct -->
332
+ {{ user.get_full_name() }}
333
+ ```
334
+
335
+ #### How do I use Jinja's loop controls?
336
+
337
+ Plain enables the `loopcontrols` extension by default, so you can use `break` and `continue` in loops:
338
+
339
+ ```html
340
+ {% for item in items %}
341
+ {% if item.skip %}
342
+ {% continue %}
343
+ {% endif %}
344
+ {% if item.stop %}
345
+ {% break %}
346
+ {% endif %}
347
+ <p>{{ item.name }}</p>
348
+ {% endfor %}
349
+ ```
350
+
351
+ #### Where can I learn more about Jinja2?
352
+
353
+ The [Jinja2 documentation](https://jinja.palletsprojects.com/en/stable/) covers all the template syntax, including conditionals, loops, macros, and inheritance.
354
+
355
+ ## Forms
356
+
357
+ Forms are rendered manually using the bound field attributes:
358
+
359
+ ```html
360
+ <form method="post">
361
+ <div>
362
+ <label for="{{ form.email.html_id }}">Email</label>
363
+ <input
364
+ type="email"
365
+ name="{{ form.email.html_name }}"
366
+ id="{{ form.email.html_id }}"
367
+ value="{{ form.email.value }}"
368
+ >
369
+ {% for error in form.email.errors %}
370
+ <p>{{ error }}</p>
371
+ {% endfor %}
372
+ </div>
373
+ <button type="submit">Submit</button>
374
+ </form>
375
+ ```
376
+
377
+ Each bound field provides: `html_name`, `html_id`, `value`, `errors`, `field`, `initial`.
378
+
379
+ ## Installation
380
+
381
+ Install the `plain.templates` package:
382
+
383
+ ```bash
384
+ uv add plain.templates
385
+ ```
386
+
387
+ Then add it to `INSTALLED_PACKAGES`:
388
+
389
+ ```python
390
+ # app/settings.py
391
+ INSTALLED_PACKAGES = [
392
+ "plain.templates",
393
+ # ...
394
+ ]
395
+ ```
@@ -0,0 +1,17 @@
1
+ from .core import Template, TemplateFileMissing
2
+ from .jinja import (
3
+ register_template_extension,
4
+ register_template_filter,
5
+ register_template_global,
6
+ )
7
+
8
+ __all__ = [
9
+ "Template",
10
+ "TemplateFileMissing",
11
+ # Technically these are jinja-specific,
12
+ # but expected to be used pretty frequently so
13
+ # the shorter import is handy.
14
+ "register_template_extension",
15
+ "register_template_filter",
16
+ "register_template_global",
17
+ ]
@@ -0,0 +1,6 @@
1
+ from plain.packages import PackageConfig, register_config
2
+
3
+
4
+ @register_config
5
+ class Config(PackageConfig):
6
+ package_label = "plaintemplates"
@@ -0,0 +1,40 @@
1
+ import jinja2
2
+ from opentelemetry import trace
3
+ from opentelemetry.semconv.attributes.code_attributes import (
4
+ CODE_FUNCTION_NAME,
5
+ )
6
+
7
+ from .jinja import environment
8
+
9
+ tracer = trace.get_tracer("plain.templates")
10
+
11
+
12
+ class TemplateFileMissing(Exception):
13
+ def __str__(self) -> str:
14
+ if self.args:
15
+ return f"Template file {self.args[0]} not found"
16
+ else:
17
+ return "Template file not found"
18
+
19
+
20
+ class Template:
21
+ def __init__(self, filename: str) -> None:
22
+ self.filename = filename
23
+
24
+ try:
25
+ self._jinja_template = environment.get_template(filename)
26
+ except jinja2.TemplateNotFound:
27
+ raise TemplateFileMissing(filename)
28
+
29
+ def render(self, context: dict) -> str:
30
+ with tracer.start_as_current_span(
31
+ f"render {self.filename}",
32
+ kind=trace.SpanKind.INTERNAL,
33
+ attributes={
34
+ CODE_FUNCTION_NAME: f"{self.__class__.__module__}.{self.__class__.__qualname__}.render",
35
+ "template.filename": self.filename,
36
+ "template.engine": "jinja2",
37
+ },
38
+ ):
39
+ result = self._jinja_template.render(context)
40
+ return result
@@ -0,0 +1,2 @@
1
+ # Dotted path or callable returning a Jinja2 Environment used to render templates.
2
+ TEMPLATES_JINJA_ENVIRONMENT: str = "plain.templates.jinja.DefaultEnvironment"
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from plain.packages import packages_registry
7
+ from plain.runtime import settings
8
+ from plain.utils.functional import LazyObject
9
+ from plain.utils.module_loading import import_string
10
+
11
+ from .environments import DefaultEnvironment, get_template_dirs
12
+
13
+
14
+ class JinjaEnvironment(LazyObject):
15
+ def _setup(self) -> None:
16
+ environment_setting = settings.TEMPLATES_JINJA_ENVIRONMENT
17
+
18
+ if isinstance(environment_setting, str):
19
+ env = import_string(environment_setting)()
20
+ else:
21
+ env = environment_setting()
22
+
23
+ # We have to set _wrapped before we trigger the autoloading of "register" commands
24
+ self._wrapped = env
25
+
26
+ # Autoload template helpers using the registry method
27
+ packages_registry.autodiscover_modules("templates", include_app=True)
28
+
29
+
30
+ environment = JinjaEnvironment()
31
+
32
+
33
+ def register_template_extension(extension_class: type) -> type:
34
+ environment.add_extension(extension_class)
35
+ return extension_class
36
+
37
+
38
+ def register_template_global(value: Any, name: str | None = None) -> Any:
39
+ """
40
+ Adds a global to the Jinja environment.
41
+
42
+ Can be used as a decorator on a function:
43
+
44
+ @register_template_global
45
+ def my_global():
46
+ return "Hello, world!"
47
+
48
+ Or as a function:
49
+
50
+ register_template_global("Hello, world!", name="my_global")
51
+ """
52
+ if callable(value):
53
+ environment.globals[name or value.__name__] = value
54
+ elif name:
55
+ environment.globals[name] = value
56
+ else:
57
+ raise ValueError("name must be provided if value is not callable")
58
+
59
+ return value
60
+
61
+
62
+ def register_template_filter(
63
+ func: Callable[..., Any], name: str | None = None
64
+ ) -> Callable[..., Any]:
65
+ """Adds a filter to the Jinja environment."""
66
+ filter_name = name if name is not None else func.__name__ # ty: ignore[unresolved-attribute]
67
+ environment.filters[filter_name] = func
68
+ return func
69
+
70
+
71
+ __all__ = [
72
+ "environment",
73
+ "DefaultEnvironment",
74
+ "get_template_dirs",
75
+ "register_template_extension",
76
+ "register_template_filter",
77
+ "register_template_global",
78
+ ]
@@ -0,0 +1,64 @@
1
+ import functools
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from jinja2 import Environment, StrictUndefined
6
+ from jinja2.loaders import FileSystemLoader
7
+
8
+ from plain.packages import packages_registry
9
+ from plain.runtime import settings
10
+
11
+ from .filters import default_filters
12
+ from .globals import default_globals
13
+
14
+
15
+ def _finalize_callable_error(obj: Any) -> Any:
16
+ """Prevent direct rendering of a callable (likely just forgotten ()) by raising a TypeError"""
17
+ if callable(obj):
18
+ raise TypeError(f"{obj} is callable, did you forget parentheses?")
19
+
20
+ # TODO find a way to prevent <object representation> from being rendered
21
+ # if obj.__class__.__str__ is object.__str__:
22
+ # raise TypeError(f"{obj} does not have a __str__ method")
23
+
24
+ return obj
25
+
26
+
27
+ def get_template_dirs() -> tuple[Path, ...]:
28
+ jinja_templates = Path(__file__).parent / "templates"
29
+ app_templates = settings.path.parent / "templates"
30
+ return (jinja_templates, app_templates) + _get_app_template_dirs()
31
+
32
+
33
+ @functools.lru_cache
34
+ def _get_app_template_dirs() -> tuple[Path, ...]:
35
+ """
36
+ Return an iterable of paths of directories to load app templates from.
37
+
38
+ dirname is the name of the subdirectory containing templates inside
39
+ installed applications.
40
+ """
41
+ dirname = "templates"
42
+ template_dirs = [
43
+ Path(package_config.path) / dirname
44
+ for package_config in packages_registry.get_package_configs()
45
+ if package_config.path and (Path(package_config.path) / dirname).is_dir()
46
+ ]
47
+ # Immutable return value because it will be cached and shared by callers.
48
+ return tuple(template_dirs)
49
+
50
+
51
+ class DefaultEnvironment(Environment):
52
+ def __init__(self):
53
+ super().__init__(
54
+ loader=FileSystemLoader(get_template_dirs()),
55
+ autoescape=True,
56
+ auto_reload=settings.DEBUG,
57
+ undefined=StrictUndefined,
58
+ finalize=_finalize_callable_error,
59
+ extensions=["jinja2.ext.loopcontrols", "jinja2.ext.debug"],
60
+ )
61
+
62
+ # Load the top-level defaults
63
+ self.globals.update(default_globals) # ty: ignore[no-matching-overload]
64
+ self.filters.update(default_filters)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from jinja2 import nodes
6
+ from jinja2.ext import Extension
7
+ from jinja2.runtime import Context
8
+
9
+
10
+ class InclusionTagExtension(Extension):
11
+ """Intended to be subclassed"""
12
+
13
+ # tags = {'inclusion_tag'}
14
+ tags: set[str]
15
+ template_name: str
16
+
17
+ def parse(self, parser: Any) -> nodes.Node:
18
+ lineno = next(parser.stream).lineno
19
+ args: list[nodes.Expr] = [
20
+ nodes.DerivedContextReference(),
21
+ ]
22
+ kwargs = []
23
+ while parser.stream.current.type != "block_end":
24
+ if parser.stream.current.type == "name":
25
+ key = parser.stream.current.value
26
+ parser.stream.skip()
27
+ parser.stream.expect("assign")
28
+ value = parser.parse_expression()
29
+ kwargs.append(nodes.Keyword(key, value))
30
+ else:
31
+ args.append(parser.parse_expression())
32
+
33
+ call = self.call_method("_render", args=args, kwargs=kwargs, lineno=lineno)
34
+ return nodes.CallBlock(call, [], [], []).set_lineno(lineno)
35
+
36
+ def _render(self, context: Context, *args: Any, **kwargs: Any) -> str:
37
+ render_context = self.get_context(context, *args, **kwargs)
38
+ template = self.environment.get_template(self.template_name)
39
+ return template.render(render_context)
40
+
41
+ def get_context(
42
+ self, context: Context, *args: Any, **kwargs: Any
43
+ ) -> Context | dict[str, Any]:
44
+ raise NotImplementedError(
45
+ "You need to implement the `get_context` method in your subclass."
46
+ )