Revising signatures (i.e. forging a signature)¶
The basic unit of work with forge
is the revision
.
A revision
is an instance of Revision
(or a specialized subclass) that provides two interfaces:
- a method
revise()
that takes aFSignature
and returns it unchanged or returns a newFSignature
, or - the
__call__()
interface that allows the revision to be used as either a decorator or a function receiving a callable to be wrapped.
Revision
subclasses often take initialization arguments that are used during the revision process.
For users, the most practical use of a revision
is as a decorator.
While not very useful, Revision
provides the identity revision:
import forge
@forge.Revision()
def func():
pass
The specialized subclasses are incredibly useful for surgically revising signatures.
Group revisions¶
compose¶
The compose
revision allows for imperatively describing the changes to a signature from top-to-bottom.
import forge
func = lambda a, b, c: None
@forge.compose(
forge.copy(func),
forge.modify('c', default=None)
)
def func2(**kwargs):
pass
assert forge.repr_callable(func2) == 'func2(a, b, c=None)'
If we were to define recreate this without compose
, and instead use multiple signatures, the sequence would look like:
import forge
func = lambda a, b, c: None
@forge.modify('c', default=None)
@forge.copy(func)
def func2(**kwargs):
pass
assert forge.repr_callable(func2) == 'func2(a, b, c=None)'
Notice how modify
comes before copy
in this latter example?
That’s because the Python interpreter builds func2
, passes it to the the instance of copy
, and then passes that return value to modify
.
compose
is therefore as a useful tool to reason about your code top-to-bottom, rather than in an inverted manner.
However, the resulting call stack and underlying Mapper
in the above example are identical.
Unlike applying multiple decorators, compose
does not validate the resulting FSignature
during internmediate steps.
This is useful when you want to change either the kind of a parameter or supply a default value - either of which often require a parameter to be moved within the signature.
import forge
func = lambda a, b, c: None
@forge.compose(
forge.copy(func),
forge.modify('a', default=None),
forge.move('a', after='c'),
)
def func2(**kwargs):
pass
assert forge.repr_callable(func2) == 'func2(b, c, a=None)'
After the modify
revision, but before the move
revisions, the signature appears to be func2(a=None, b, c)
.
Of course this is an invalid signature, as a positional-only or positional-or-keyword parameter with a default must follow parameters of the same kind without defaults.
copy¶
The copy
revision is straightforward: use it when you want to copy the signature from another callable.
import forge
func = lambda a, b, c: None
@forge.copy(func)
def func2(**kwargs):
pass
assert forge.repr_callable(func2) == 'func2(a, b, c)'
As you can see, the signature of func
is copied in its entirety to func2
.
Note
In order to copy
a signature, the receiving callable must either have a var-keyword parameter which collects the extra keyword arguments (as demonstrated above), or be pre-defined with all the same parameters:
import forge
func = lambda a, b, c: None
@forge.copy(func)
def func2(a=1, b=2, c=3):
pass
assert forge.repr_callable(func2) == 'func2(a, b, c)'
The exception is the var-positional parameter.
If the new signature takes a var-positional parameter (e.g. *args
), then the receiving function must also accept a var-positional parameter.
manage¶
The manage
revision lets you supply your own function that receives an instance of FSignature
, and returns a new instance. Because FSignature
is immutable, consider using replace()
to create a new FSignature
with updated attribute values or an altered return_annotation
import forge
reverse = lambda prev: prev.replace(parameters=prev[::-1])
@forge.manage(reverse)
def func(a, b, c):
pass
assert forge.repr_callable(func) == 'func(c, b, a)'
returns¶
The returns
revision alters the return type annotation of the receiving function.
In the case that there are no other revisions, returns
updates the receiving signature without wrapping it.
import forge
@forge.returns(int)
def func():
pass
assert forge.repr_callable(func) == 'func() -> int'
Of course, if you’ve defined a return type annotation on a function that has a forged signature, it’s return type annotation will stay in place:
import forge
@forge.compose()
def func() -> int:
pass
assert forge.repr_callable(func) == 'func() -> int'
sort¶
By default, the sort
revision sorts the parameters by kind, by whether they have a default value, and then by the name (lexicographically).
import forge
@forge.sort()
def func(c, b, a, *, f=None, e, d):
pass
assert forge.repr_callable(func) == 'func(a, b, c, *, d, e, f=None)'
sort
also accepts a user-defined function (sortkey
) that receives the signature’s FParameter
instances and emits a key for sorting.
The underlying implementation relies on builtins.sorted()
, so head on over to the Python docs to jog your memory on how to use sortkey
.
synthesize (sign)¶
The synthesize
revision (also known as sign
) allows you to construct a signature by hand.
import forge
@forge.sign(
forge.pos('a'),
forge.arg('b'),
*forge.args,
c=forge.kwarg(),
**forge.kwargs,
)
def func(*args, **kwargs):
pass
assert forge.repr_callable(func) == 'func(a, /, b, *args, c, **kwargs)'
Warning
When supplying parameters to synthesize
or sign
, unnamed parameter arguments are ordered by the order they were supplied, whereas named parameter arguments are ordered by their createion_order
This design decision is a consequence of Python <= 3.6 not guaranteeing insertion-order for dictionaries (and thus an unorderd var-keyword argument).
It is therefore recommended that when supplying pre-created parameters to sign()
, that they are specified only as positional arguments:
import forge
param_b = forge.arg('b')
param_a = forge.arg('a')
@forge.sign(a=param_a, b=param_b)
def func1(**kwargs):
pass
@forge.sign(param_a, param_b)
def func2(**kwargs):
pass
assert forge.repr_callable(func1) == 'func1(b, a)'
assert forge.repr_callable(func2) == 'func2(a, b)'
Unit revisions¶
delete¶
The delete
revision removes a parameter from the signature.
This revision requires the receiving function’s parameter to have a default
value.
If no default
value is provided, a TypeError
will be raised.
import forge
@forge.delete('a')
def func(a=1, b=2, c=3):
pass
assert forge.repr_callable(func) == 'func(b=2, c=3)'
insert¶
The insert
revision adds a parameter or a sequence of parameters into a signature.
This revision takes the FParameter
to insert, and one of the following: index
, before
, or after
.
If index
is supplied, it must be an integer, whereas before
and after
must be the name
of a parameter, an iterable of parameter names, or a function that receives a parameter and returns True
if the parameter matches.
import forge
@forge.insert(forge.arg('a'), index=0)
def func(b, c, **kwargs):
pass
assert forge.repr_callable(func) == 'func(a, b, c, **kwargs)'
Or, to insert multiple parameters using after
with a parameter name:
import forge
@forge.insert([forge.arg('b'), forge.arg('c')], after='a')
def func(a, **kwargs):
pass
assert forge.repr_callable(func) == 'func(a, b, c, **kwargs)'
modify¶
The modify
revision modifies one or more of the receiving function’s parameters.
It takes a selector
argument (a parameter name, an iterable of names, or a callable that takes a parameter and returns True
if matched), (optionally) a multiple
argument (whether to apply the modification to all matching parameters), and keyword-arguments that map to the attributes of the underlying FParameter
to modify.
import forge
@forge.modify('c', default=None)
def func(a, b, c):
pass
assert forge.repr_callable(func) == 'func(a, b, c=None)'
Warning
When using modify
to alter a signature’s parameters, keep an eye on the parameter kind of surrounding parameters and whether other parameters of the same parameter kind lack default values.
In Python, positional-only parameters are followed by positional-or-keyword parameters. After that comes the var-positional parameter, then any keyword-only parameters, and finally an optional var-keyword parameter.
Using compose
and sort
can be helpful here to ensure that your parameters are properly ordered.
import forge
@forge.compose(
forge.modify('b', kind=forge.FParameter.POSITIONAL_ONLY),
forge.sort(),
)
def func(a, b, c):
pass
assert forge.repr_callable(func) == 'func(b, /, a, c)'
replace¶
The replace
revision replaces a parameter outright.
This is a helpful alternative to modify
when it’s easier to replace a parameter outright than to alter its state.
replace
takes a selector
argument (a string for matching parameter names, an iterable of strings that contain a parameter’s name, or a function that is passed the signature’s FSignature
parameters and returns True
upon a match) and a new FParameter
instance.
import forge
@forge.replace('a', forge.pos('a'))
def func(a=0, b=1, c=2):
pass
assert forge.repr_callable(func) == 'func(a, /, b=1, c=2)'
translocate (move)¶
The translocate
revision (also known as move
) moves a parameter to another location in the signature.
selector
, before
and after
take a string for matching parameter names, an iterable of strings that contain a parameter’s name, or a function that is passed the signature’s FSignature
parameters and returns True
upon a match.
One (and only one) of index
, before
, or after
, must be provided.
import forge
@forge.move('a', after='c')
def func(a, b, c):
pass
assert forge.repr_callable(func) == 'func(b, c, a)'
Mapper¶
The Mapper
is the glue that connects the FSignature
to an underlying callable.
You shouldn’t need to create a Mapper
yourself, but it’s helpful to know that you can inspect the Mapper
and it’s underlying strategy by looking at the __mapper__
attribute on the function returned from a Revision
.