Skip to main content

purify

Pythonic object-mutator transforms as pure functions.

Rationale

This solves a longstanding complaint that I have about Python - there's no Pythonic way to write a pure function that is supposed to return a transformed version of an input instance of a class.

Specifically, Python heavily incentivizes (via its syntax and via utilities such as mypy) doing the following:

def rename_tree(name: str, tree: Tree) -> Tree:
    tree.name = name
    return tree

because the pure alternatives are either expensive:

def pure_rename_tree(name: str, tree: Tree) -> Tree:
    tree = deepcopy(tree)
    tree.name = name
    return tree

or not Pythonic and not type checkable:

def pure_rename_treedict(name: str, tree: dict) -> dict:
    return dict(tree, name=name)

This library is a result of noticing that the first option above is extremely common in our codebases at XOi and enough of a convention to be a best practice. While this usually works as long as we compose these "object transforms" as a linear pipeline where no function ever needs to read the pre-transformed object, it is nevertheless a convention which trades off actual purity for efficiency and readability.

A new decorator, @purify, can be applied to any function where a single one of the arguments will be modified, and by performing a behind-the-scenes shallow copy of that object, allows the object transform to become pure without further ado.

@purify
def rename_tree(name: str, tree: Tree) -> Tree:
    tree.name = tree
    return tree

tree = Tree('Felicity')
dtree = rename_tree('Daniella', tree)

print(tree.name) # 'Felicity'
print(dtree.name) # 'Daniella'

Shallow copy vs deepcopy

In rudimentary tests with objects of size < 400 KB, deepcopy was found to be, not surprisingly, 3 orders or magnitude slower than a shallow copy. Additionally, equality tests on deep-copied and not modified objects are 1-2 orders of magnitude slower than shallow copies, presumably because the shallow copy allows the comparison to do far more id equality checks.

So, deepcopy is expensive. But it's the only way to be sure that your function is actually pure. purify defaults to shallow copy. Why?

Because, as it turns out, it's frequently pretty simple to split mutating functions into 'levels' based on the actual object that they modify, and then decorate each level independently. In most cases, this will mean far fewer Python objects need actual copying, and it also gives you more reusable functions than you would have had if all the levels were present together.

Given:

class Nest:
    num_eggs: int

class Tree:
    # ... (lots of other attributes)
    nests: List[Nest]

This expensive deepcopy approach:

@purify(deep=True)
def lay_in_all_nests(add: int, tree: Tree) -> Tree:
    for nest in tree.nests:
        nest.num_eggs += add
    return tree

Could be replaced with the equally pure, and less copy-expensive:

@purify
def lay_in_nest(lay: int, nest: Nest) -> Nest:
    nest.num_eggs += add
    return nest

@purify
def lay_in_all_nests(lay: int, tree: Tree) -> Tree:
    tree.nests = [lay_in_nest(lay, nest) for nest in tree.nests]
    return tree

How to visually parse shallowly pure functions

Some effort is required to use shallow-copy functions properly, whereas deepcopy makes your function trivially pure. How to focus that effort?

A good rule of thumb is that the object being purified must only ever be referenced with a single dot (.), e.g. tree.nests, and usage of that dotted name must either be read-only or direct assignment to that name. E.g., tree.nests[i] = foo is a no-no, because the left-hand-side of the statement is not the bare name nests, but something that directs its activity into the list itself.

Advanced Usage:

Argument name

It's highly recommended to follow a convention where the object that you're mutating is the last positional argument to your function. This is generally better for the composition of many partially-applied functions transforming the same object.

That said, if you have a desire to specify which argument is to be shallow-copied, you may do so by calling the decorator with the first positional argument being the name of the function argument you want purified.

Deepcopy

As above, if you have a need for deepcopying, you need only to pass deep=True to the decorator.

Metadata

Release files for purify 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for purify 0.3.0
File Size Uploaded
purify-0.3.0.tar.gz 45.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for purify 0.3.0
File Interpreter ABI Platform
purify-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 57.3 kB

Release files / purify-0.3.0.tar.gz

Download URL purify-0.3.0.tar.gz
Size 45.2 kB
Tags Source
SHA-256 checksum
How to use checksums
2751528447bb36f5909e49c87a90f53f185f3bb8d75e24a1cf49d6abd5bcc743
BLAKE2b-256 checksum
How to use checksums
530942f473beb6ec3fdcab233e960aff67e31f8fed78e205e0681cf0f83d24f7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / purify-0.3.0-py3-none-any.whl

Download URL purify-0.3.0-py3-none-any.whl
Size 12.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
97a213db27bfe1728565f400b977d87728b2f5daf64e3d2fdd352c05edfaea09
BLAKE2b-256 checksum
How to use checksums
9516c90af9a985662320a60a97e3b57632554e222f0d12cbe917dbe6a762fa03
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.3

2 release files

0.2.2

1 release file

0.2.1

1 release file

0.2.0

1 release file

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page