ContextHook called in more phases after #2023
Describe the problem
The changes introduced in #2023 seem to lead to additional jinja rendering - which are also triggering copier_templates_extensions.ContextHook. In my particular case this lead to v.9.7.x breaking my setup because my hook did not distinguish between calls from different phases.
extensions/context_hooks.py
from copier_templates_extensions import ContextHook
class ContextUpdater(ContextHook):
def hook(self, context: dict) -> dict:
context["hello_world"] = context["hello"] + ", " + context["world"] + '!'
return context
copier.yml
_subdirectory: template
_preserve_symlinks: True
_min_copier_version: "9.0.1"
_jinja_extensions:
- copier_templates_extensions.TemplateExtensionLoader
- extensions/context_hooks.py:ContextUpdater
hello:
type: str
help: "Say hello, please!"
default: "Hello"
world:
when: false
default: "world"
The behavior under <9.7.x can be restored by
from copier import Phase
from copier_templates_extensions import ContextHook
class ContextUpdater(ContextHook):
def hook(self, context: dict) -> dict:
if context['_copier_phase'] == Phase.RENDER:
context["hello_world"] = context["hello"] + ", " + context["world"] + '!'
return context
... but it seems a bit of a breaking change which the docs also do not seem to cover yet: https://copier.readthedocs.io/en/stable/faq/#how-can-i-alter-the-context-before-rendering-the-project
Template
Not relevant for this one :)
To Reproduce
copier copy --trust . /tmp/foo
Traceback (most recent call last):
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/runpy.py", line 198, in _run_module_as_main
return _run_code(code, main_globals, None,
"__main__", mod_spec)
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/runpy.py", line 88, in _run_code
exec(code, run_globals)
~~~~^^^^^^^^^^^^^^^^^^^
File "/Users/christianroth/.vscode/extensions/ms-python.debugpy-2025.6.0-darwin-arm64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/__main__.py", line 71, in <module>
cli.main()
~~~~~~~~^^
File "/Users/christianroth/.vscode/extensions/ms-python.debugpy-2025.6.0-darwin-arm64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 501, in main
run()
~~~^^
File "/Users/christianroth/.vscode/extensions/ms-python.debugpy-2025.6.0-darwin-arm64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 384, in run_module
run_module_as_main(options.target, alter_argv=True)
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/christianroth/.vscode/extensions/ms-python.debugpy-2025.6.0-darwin-arm64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 228, in _run_module_as_main
return _run_code(code, main_globals, None, "__main__", mod_spec)
File "/Users/christianroth/.vscode/extensions/ms-python.debugpy-2025.6.0-darwin-arm64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 118, in _run_code
exec(code, run_globals)
~~~~^^^^^^^^^^^^^^^^^^^
File "/Users/christianroth/repos/copier/copier/__main__.py", line 6, in <module>
CopierApp.run()
~~~~~~~~~~~~~^^
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/plumbum/cli/application.py", line 640, in run
inst, retcode = subapp.run(argv, exit=False)
~~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/plumbum/cli/application.py", line 635, in run
retcode = inst.main(*tailargs)
File "/Users/christianroth/repos/copier/copier/_cli.py", line 282, in main
return _handle_exceptions(inner)
File "/Users/christianroth/repos/copier/copier/_cli.py", line 71, in _handle_exceptions
method()
~~~~~~^^
File "/Users/christianroth/repos/copier/copier/_cli.py", line 273, in inner
with self._worker(
~~~~~~~~~~~~^
template_src,
^^^^^^^^^^^^^
...<3 lines>...
overwrite=self.force or self.overwrite,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
) as worker:
^
File "/Users/christianroth/repos/copier/copier/_main.py", line 267, in __exit__
raise value
File "/Users/christianroth/repos/copier/copier/_cli.py", line 280, in inner
worker.run_copy()
~~~~~~~~~~~~~~~^^
File "/Users/christianroth/repos/copier/copier/_main.py", line 94, in _wrapper
return func(*args, **kwargs)
File "/Users/christianroth/repos/copier/copier/_main.py", line 1015, in run_copy
self._ask()
~~~~~~~~~^^
File "/Users/christianroth/repos/copier/copier/_main.py", line 601, in _ask
[question.get_questionary_structure()],
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "/Users/christianroth/repos/copier/copier/_user_data.py", line 390, in get_questionary_structure
"message": self.get_message(),
~~~~~~~~~~~~~~~~^^
File "/Users/christianroth/repos/copier/copier/_user_data.py", line 365, in get_message
if rendered_help := self.render_value(self.help):
~~~~~~~~~~~~~~~~~^^^^^^^^^^^
File "/Users/christianroth/repos/copier/copier/_user_data.py", line 472, in render_value
return template.render({**self.context, **(extra_answers or {})})
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/jinja2/environment.py", line 1290, in render
ctx = self.new_context(dict(*args, **kwargs))
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/jinja2/environment.py", line 1388, in new_context
return new_context(
self.environment, self.name, self.blocks, vars, shared, self.globals, locals
)
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/jinja2/runtime.py", line 117, in new_context
return environment.context_class(
~~~~~~~~~~~~~~~~~~~~~~~~~^
environment, parent, template_name, blocks, globals=globals
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/christianroth/repos/copier-repro/.pixi/envs/default/lib/python3.13/site-packages/copier_templates_extensions/extensions/context.py", line 23, in __init__
parent.update(extension_self.hook(parent))
~~~~~~~~~~~~~~~~~~~^^^^^^^^
File "/Users/christianroth/repos/copier-repro/extensions/context_hooks.py", line 8, in hook
context["hello_world"] = context["hello"] + ", " + context["world"] + '!'
~~~~~~~^^^^^^^^^
KeyError: 'hello'
Logs
Expected behavior
Screenshots/screencasts/logs
No response
Operating system
macOS
Operating system distribution and version
15.4.1
Copier version
copier 9.7.2.dev21+g711b32b
Python version
3.13
Installation method
pip+git
Additional context
No response
That's true. In fact, the context hook wasn't called in the prompt phase until recently, which to my knowledge wasn't documented exactly either. I'd have expected a context hook to be called for any rendering context and was surprised (because I haven't used them myself) to learn otherwise.
I see two options:
- Restore previous behavior with a deprecation warning, potentially provide a way to enable the new behavior.
- Treat the previous behavior as a bug and consider it fixed now.
See also #2113 and #2114 for a relevant discussion and userland solutions to deal with the current behavior.
Either way is fine for me - in case of (2) the FAQ in the documentation would probably have to be adapted slightly to modify the context only in the Render Phase.
Generally my gut-feeling right now is that one rarely would want to write a hook that simultaneously handles prompt phase and render phase at the same time, so maybe distinct classes PromptRenderContextHook or TemplateRenderContextHook or separate methods such as on_prompt_render and on_template_render could remove a tiny bit of boilerplate and make it easier to expand the rendering hooks later?
Faced the same issue.
This also breaks updates from template versions that don't account for the changed behavior btw. I fixed them by backporting the necessary changes into separate hotfix branches and modifying the corresponding tags.
Treat the previous behavior as a bug and consider it fixed now.
Imho this is the correct solution (not a strong preference though). Copier 9.5.0 already introduced this backwards-incompatible change, 9.6.0 reverted it, 9.7.0 brought it back. Reverting it again could lead to more confusion.
could remove a tiny bit of boilerplate
For reference, I assume this refers to inspecting _copier_phase in the hook.
@ChristianRothQC I'm open to updating copier-templates-extensions now that we have the phase, this is a good idea 🙂