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:

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:

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:

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:

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

Warning

default and factory mutually exclusive. Passing both will raise an TypeError.

Supported by:

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:

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:

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 a MappingProxyView, 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 function with_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'}
    

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().