Current solution
The current API for handling in-place and "update" hooks is as follows:
- For "update" context hooks that don't mutate contexts, but produce dicts that can be incorporated into those contexts, we define a class (with
update = True
being unnecessary, as it's a default of ContextHook
):
class MyHook(ContextHook):
def hook(self, context: dict[str, Any]) -> dict[str, Any]:
return {"this will": "be incorpored into *context*"}
Provided an instance of MyHook
named my_hook
, my_hook.hook(context)
(where context
is of the dict[str, Any]
type) is expected to produce a dictionary that will be merged into context
through context.update
:
|
if extension_self.update: # type: ignore[attr-defined] |
|
parent.update(extension_self.hook(parent)) # type: ignore[attr-defined] |
- For in-place context hooks that do mutate the context they receive, we define a class with
update = False
attribute, for example:
class MyInplaceHook(ContextHook):
update = False
def hook(self, context: dict[str, Any]) -> dict[str, Any]:
self.context.update({"this is": "incorporated into *context* immediately"})
But oh, wait. This produces a type-checking error, MyInplaceHook.hook()
doesn't return anything, but it's expected to return an object of type dict[str, Any]
.
But why would it be, if it operates in-place, and provided an instance of MyInplaceHook
named my_inplace_hook
, my_inplace_hook.hook(context)
return value is ignored โ and likely not expected to exist at all?
|
else: |
|
extension_self.hook(parent) # type: ignore[attr-defined] |
Ok, let's redefine our hook:
def hook(self, context: dict[str, Any]) -> None:
self.context.update({"this is": "incorporated into *context* immediately"})
Oh, there we go. This time we get an error that the MyInplaceHook.hook()
signature is inconsistent with the parent signature ContextHook.hook()
that is typed to always return dict[str, Any]
.
There are a few problems with this approach:
- The aforementioned typing toil that will usually end up in using
# type: ignore[override]
or, even worse, # type: ignore
(we really don't want it!).
- Context hooks become in-place implicitly if we subclass them, i.e. they inherit the
update
attribute set to False
, potentially without the user realizing that the hook they subclass is in-place because (1) the hook return types are not affected and (2) update
is True
by default in the root class ContextHook
.
- If someone is overall into inplace hooks, they have to set
update
to False
in every subclass or they end up using a boilerplate InplaceContextHook
class like
class InplaceContextHook(ContextHook):
update = False
@wraps(ContextHook.hook)
def hook(self, context: dict[str, Any]) -> None: # type: ignore[override]
raise NotImplementedError
(Good luck with reusing it among other copier extension modules without altering sys.path
with the current import resolution).
Desired solution
My first idea was to simplify the mechanism by bridging methods like apply()
->hook()
.
Instead of
|
if extension_self.update: # type: ignore[attr-defined] |
|
parent.update(extension_self.hook(parent)) # type: ignore[attr-defined] |
|
else: |
|
extension_self.hook(parent) # type: ignore[attr-defined] |
we would have
extension_self.apply(parent)
with the following ContextHook
methods:
def apply(self, context: dict[str, Any]) -> None:
context.update(self.hook(context))
def hook(self, context: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
It:
- gives more freedom,
- resolves all the typing toil, doesn't change the API drastically (a migration from
ContextHook.update
is however required),
- decouples every context hook into generation and application stages (uhhh, this sounds way too smart),
- enables reusability of the mutation behavior, e.g. one might want to reuse the logic of the
apply()
method in a custom implementation and may simply override hook()
of a relevant context hook class to achieve that.
If we need a deprecation strategy for this change, we can use this:
def apply(self, context: dict[str, Any]) -> None:
missing = object()
do_update = getattr(self, "update", missing)
if do_update is not missing and not callable(do_update):
warning = DeprecationWarning(
"`ContextHook.update` attribute is deprecated "
"and scheduled for removal in "
"copier-templates-extensions 0.5.0, "
+ (
"consider removing that attribute"
if do_update else
"override `ContextHook.apply()` instead"
)
)
warnings.warn(warning, stacklevel=0)
# TODO(#4): drop backwards compatibility
context.update(self.hook(context) or context)
def hook(self, context: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError