copier icon indicating copy to clipboard operation
copier copied to clipboard

ContextHook called in more phases after #2023

Open ChristianRothQC opened this issue 8 months ago • 4 comments

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

Do not break my workflow

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

ChristianRothQC avatar May 04 '25 18:05 ChristianRothQC

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:

  1. Restore previous behavior with a deprecation warning, potentially provide a way to enable the new behavior.
  2. 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.

sisp avatar May 04 '25 19:05 sisp

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?

ChristianRothQC avatar May 04 '25 20:05 ChristianRothQC

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.

lkubb avatar May 05 '25 10:05 lkubb

@ChristianRothQC I'm open to updating copier-templates-extensions now that we have the phase, this is a good idea 🙂

pawamoy avatar May 05 '25 11:05 pawamoy