Basic Usage¶
Forging signatures¶
forge
’s primary function is to allow users to revise and refine callable signatures.
This functionality is achieved on builtins, functions, and class instances with the special __call__ dunder method by wrapping a callable with the special wrapping factory forge.sign()
.
The minimal example is to wrap a function that takes no arguments (has no parameters) with a function that also takes no arguments (and has no parameters).
import forge
@forge.sign()
def func():
pass
assert forge.stringify_callable(func) == 'func()'
Forging a signature works as expected with staticmethod
, classmethod
, the instance method
, as well as property
and __call__
.
The following example is a bit tedious, but its relevance is that it demonstrates that forge.sign()
is transparent to underlying code.
import random
import forge
smin = 0
smax = 10
class Klass:
cmin = 11
cmax = 20
def __init__(self):
self.imin = 21
self.imax = 30
@staticmethod
@forge.sign()
def srandom():
return random.randint(smin, smax)
@classmethod
@forge.sign(forge.cls)
def crandom(cls):
return random.randint(cls.cmin, cls.cmax)
@property
@forge.sign(forge.self)
def irange(self):
return range(self.imin, self.imax)
@forge.sign(forge.self)
def irandom(self):
return random.randint(self.imin, self.imax)
@forge.sign(forge.self)
def __call__(self):
return (self.imin, self.imax)
klass = Klass()
# Check signatures
assert forge.stringify_callable(Klass.srandom) == 'srandom()'
assert forge.stringify_callable(Klass.crandom) == 'crandom()'
assert forge.stringify_callable(klass.irandom) == 'irandom()'
assert forge.stringify_callable(klass) == '{}()'.format(klass)
assert smin <= Klass.srandom() <= smax
assert Klass.cmin <= Klass.crandom() <= Klass.cmax
assert klass.imin <= klass.irandom() <= klass.imax
assert klass.irange == range(klass.imin, klass.imax)
assert klass() == (klass.imin, klass.imax)
The original function is available, unmodified at func.__wrapped__
.
In addition, there are two additional attributes on the function, an instance of inspect.Signature
, and a Mapper
instance available at __mapper__
that holds information about the new signature, the wrapped callable, and how to map arguments between the old and new signatures.
Function authors don’t need to worry about their code signatures being altered as it’s an implementation detail.
This expands the dynamic functionality of Python upwards.
This is exciting because while we’ve been able to dynamically create class
objects by calling :func:type(name, bases, namespace)
, we’ve been unable to dynamically define function parameters at runtime.
Note
Sometimes you’ll want to further simplify the forged signature, and to help there is a convenience function forge.resign()
that revises a signature further without providing a second-level of nesting.
Take a look at the API Reference for more information.
Adding a parameter¶
forge
allows function signatures to be extended – that is for additional parameters to be added to a signature – if a signature has a var-keyword argument (e.g. **kwargs
).
The additional parameter is mapped into the var-keyword parameter, and will be available there within the function. Users may add postiional-only, positional-or-keyword or keyword-only arguments with this method.
import forge
@forge.sign(forge.arg('myparam', default=0))
def func(**kwargs):
return kwargs['myparam']
assert forge.stringify_callable(func) == 'func(myparam=0)'
assert func() == 0
assert func(myparam=1) == 1
Warning
variadic
parameters (var-positional and var-keyword) cannot be added to a signature, as there is nowhere to map those parameters.
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- keyword-only: via
forge.kwarg()
andforge.kwo()
Removing a parameter¶
forge
expects the underlying function to rely on a parameter, so only parameters with default values (or variadic parameters var-positional and var-keyword) can be removed from the signature.
For example, if a function has a parameter with a default:
import forge
@forge.sign()
def func(myparam=0):
return myparam
assert forge.stringify_callable(func) == 'func()'
assert func() == 0
And removing a variadic parameter:
import forge
@forge.sign()
def func(*args):
return args
assert forge.stringify_callable(func) == 'func()'
assert func() == ()
If a callable’s parameter doesn’t have a default value, you can still remove it, but you must set the parameter’s default and bind
the argument value:
import forge
@forge.sign(forge.arg('myparam', default=0, bound=True))
def func(myparam):
return myparam
assert forge.stringify_callable(func) == 'func()'
assert func() == 0
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
Renaming a parameter¶
forge
allows parameters to be mapped to a different name.
This is useful when a callable’s parameter names are generic, uninformative, or deceptively named.
To rename a non-variadic
parameter, FParameter
takes a second positional argument, interface_name
which is the name of the underlying parameter to map an argument value to:
import forge
@forge.sign(
forge.arg('value'),
forge.arg('increment_by', 'other_value'),
)
def func(value, other_value):
return value + other_value
assert forge.stringify_callable(func) == 'func(value, increment_by)'
assert func(3, increment_by=5) == 8
Variadic
parameter helpers forge.args
and forge.kwargs
(and their constructor counterparts forge.vpo()
and forge.vkw()
don’t take an interface_name
parameter, as functions can only have one var-positional and one var-keyword parameter.
import forge
@forge.sign(*forge.args, **forge.kwargs)
def func(*myargs, **mykwargs):
return myargs, mykwargs
assert forge.stringify_callable(func) == 'func(*args, **kwargs)'
assert func(0, a=1, b=2, c=3) == ((0,), {'a': 1, 'b': 2, 'c': 3})
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
Type annotation¶
forge
allows type annotations (i.e. type-hints
) to be added to parameters by providing a type
keyword-argument to a FParameter
constructor:
import forge
@forge.sign(forge.arg('myparam', type=int))
def func(myparam):
return myparam
assert forge.stringify_callable(func) == 'func(myparam:int)'
forge
doesn’t do anything with these type-hints, but there are a number of third party frameworks and packages out there that perform validation.
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
To provide a return-type annotation for a callable, use returns()
:
import forge
@forge.returns(int)
def func():
return 42
assert forge.stringify_callable(func) == 'func() -> int'
Callables wrapped with forge.sign()
or forge.resign()
preserve the underlying return-type annotation if it’s provided:
import forge
@forge.sign()
def func() -> int:
# signature remains the same: func() -> int
return 42
assert forge.stringify_callable(func) == 'func() -> int'
Argument defaults¶
forge
allows default values to be provided for parameters by providing a default
keyword-argument to FParameter
constructor:
import forge
@forge.sign(forge.arg('myparam', default=5))
def func(myparam):
return myparam
assert forge.stringify_callable(func) == 'func(myparam=5)'
assert func() == 5
To generate default values using a function, rather than providing a constant value, provide a factory
keyword-argument to FParameter
:
from datetime import datetime
import forge
@forge.sign(forge.arg('when', factory=datetime.now))
def func(when):
return when
assert forge.stringify_callable(func) == 'func(when=<Factory datetime.now>)'
func_ts = func()
assert (datetime.now() - func_ts).seconds < 1
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
Argument conversion¶
forge
supports argument value conversion by providing a keyword-argument converter
to a FParameter
constructor.
converter
must be a callable, or an iterable of callables, which accept three positional arguments: ctx
, name
and value
:
def limit_to_max(ctx, name, value):
if value > ctx.maximum:
return ctx.maximum
return value
class MaxNumber:
def __init__(self, maximum, capacity=0):
self.maximum = maximum
self.capacity = capacity
@forge.sign(forge.self, forge.arg('value', converter=limit_to_max))
def set_capacity(self, value):
self.capacity = value
maxn = MaxNumber(1000)
maxn.set_capacity(500)
assert maxn.capacity == 500
maxn.set_capacity(1500)
assert maxn.capacity == 1000
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
Argument validation¶
forge
supports argument value validation by providing a keyword-argument validator
to a FParameter
constructor.
validator
must be a callable, or an iterable of callables, which accept three positional arguments: ctx
, name
and value
:
def validate_lte_max(ctx, name, value):
if value > ctx.maximum:
raise ValueError('{} is greater than {}'.format(value, ctx.maximum))
class MaxNumber:
def __init__(self, maximum, capacity=0):
self.maximum = maximum
self.capacity = capacity
@forge.sign(forge.self, forge.arg('value', validator=validate_lte_max))
def set_capacity(self, value):
self.capacity = value
maxn = MaxNumber(1000)
maxn.set_capacity(500)
assert maxn.capacity == 500
raised = None
try:
maxn.set_capacity(1500)
except ValueError as exc:
raised = exc
assert raised.args[0] == '1500 is greater than 1000'
To use multiple validators, specify them in a list
or tuple
:
import forge
def validate_startswith_id(ctx, name, value):
if not value.startswith('id'):
raise ValueError("expected value beggining with 'id'")
def validate_endswith_0(ctx, name, value):
if not value.endswith('0'):
raise ValueError("expected value ending with '0'")
@forge.sign(
forge.arg(
'id',
validator=[validate_startswith_id, validate_endswith_0],
)
)
def stringify_id(id):
return 'Your id is {}'.format(id)
assert stringify_id('id100') == 'Your id is id100'
raised = None
try:
stringify_id('id101')
except ValueError as exc:
raised = exc
assert raised.args[0] == "expected value ending with '0'"
Supported by:
- positional-only: via
forge.pos()
- positional-or-keyword: via
forge.arg()
andforge.pok()
- var-positional: via
forge.args
andforge.vpo()
- keyword-only: via
forge.kwarg()
andforge.kwo()
- var-keyword: via
forge.kwargs
andforge.vkw()
Parameter metadata¶
If you’re the author of a third-party library with forge
integration, you may want to take advantage of parameter metadata.
Here are some tips for effective use of metadata:
- Try making your metadata immutable.
This keeps the entire
Parameter
instance immutable.FParameter.metdata
is exposed as aMappingProxyView
, helping enforce immutability.
To avoid metadata key collisions, provide namespaced keys:
import forge MY_PREFIX = '__my_prefix' MY_KEY = '{}_mykey'.format(MY_PREFIX) @forge.sign(forge.arg('param', metadata={MY_KEY: 'value'})) def func(param): pass param = func.__mapper__.fsignature['param'] assert param.metadata == {MY_KEY: 'value'}
Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways.
- Expose
FParameter
wrappers for your specific metadata. This can be more challenging because of the special-use value
forge.void
, but a template functionwith_md
is provided below:import forge MY_PREFIX = '__my_prefix' MY_KEY = '{}_mykey'.format(MY_PREFIX) def update_metadata(ctx, name, value): return dict(value or {}, **{MY_KEY: 'myvalue'}) def with_md(constructor): fparams = dict(forge.FSignature.from_callable(constructor)) for k in ('default', 'factory', 'type'): if k not in fparams: continue fparams[k] = fparams[k].replace( converter=lambda ctx, name, value: forge.empty, factory=lambda: forge.empty, ) fparams['metadata'] = fparams['metadata'].\ replace(converter=update_metadata) return forge.sign(**fparams)(constructor) md_arg = with_md(forge.arg) param = md_arg('x') assert param.metadata == {'__my_prefix_mykey': 'myvalue'}
- Expose
Signature context¶
As mentioned in Argument conversion and Argument validation, a FSignature
can have a special first parameter known as a context
parameter (a special positional-or-keyword FParameter
).
Typically, context
variables are useful for method``s and ``forge
ships with two convenience context
variables for convenience: forge.self
(for use with instance methods) and forge.cls
(available for classmethods
).
The value proposition for the context
variable is that other FParameter
instances on the FSignature
that have a converter
or validator
, receive the context
argument value as the first positional argument.
import forge
def with_prefix(ctx, name, value):
return '{}{}'.format(ctx.prefix, value)
class Prefixer:
def __init__(self, prefix):
self.prefix = prefix
@forge.sign(forge.self, forge.arg('text', converter=with_prefix))
def apply(self, text):
return text
prefixer = Prefixer('banana')
assert prefixer.apply('berry') == 'bananaberry'
If you want to define an additional context
variable for your signature, you can use forge.ctx()
to create a positional-or-keyword FParameter
.
However, note that it has a more limited API than forge.arg()
.