Style Guide

Explains the code style and conventions used in this SDK.

The ZBrush SDK follows the PEP 8 - Style Guide for Python Code in its code examples and documentation. This guide documents the most important conventions and deviations from PEP 8 used in the SDK that might not be obvious to users.

Conventions

Line Length

The ZBrush SDK follows the PEP 8 guidelines but relaxes their line length limit from 79 characters to 100 characters, as this is more practical for modern documentation needs. For users, we recommend even a more relaxed limit of 120 characters, as this is more practical when writing code on modern wide screens where printing the code on paper is not a concern (one of the original reasons for the 79 characters limit). We however do recommend adhering to a finite line length, as code interoperability is still important. Just because your code looks good on your screen with lines of 200 characters and more, it does not mean it will look good on your colleague’s smaller mobile device screen or when published on GitHub. A fixed length ensures that your code looks good everywhere.

Type Hinting

Type hinting is a feature of Python that allows authors to specify the expected types of variables, function parameters, and return values. The ZBrush SDK uses the most recent type hinting features available in Python 3.10 and 3.11 (i.e., PEP 484, PEP 585, and PEP 604).

Type hinting is entirely optional, and code without type hints is functionally equivalent to the same code with type hints. The major purpose of type hinting is to convey the intent of the code author to users and tools (e.g., linters, IDEs, type checkers). It can help catch certain types of errors early, improve code readability, and enhance the development experience with better autocompletion and type checking in editors. E.g., the following two code blocks are functionally equivalent, but the second code block uses type hints to specify the expected types of variables and function parameters:

# Example without type hints
import my_lib

def uppercase(item):
    ...

data = my_lib.get_data()
print(uppercase(data))
# Example with type hints
import my_lib

def uppercase(item: str | list[str]) -> str | list[str]:
    ...

data: list[str] = my_lib.get_data()
print(uppercase(data))

With its type hints, the second code block hints at the fact that the function uppercase can take either a single string or a list of strings as input, and will return either a single uppercase string or a list of uppercase strings. The variable data is also explicitly declared to be a list of strings. Note that type hints are by default not checked at runtime, which for once means that they do not impact performance or behavior of the code but also that they can be incorrect or misleading. uppercase could for example also hint at the fact that it takes an integer as input, and the code would still run without errors, even though actually passing an integer would result in a runtime error.

But typing information can help users understand how to use the function correctly, and can also help tools catch potential type-related errors. The following table summarizes the most important syntax elements used in type hinting in the ZBrush SDK. See the Python Type Annotations documentation for more information about type hinting syntax and conventions.

Syntax

Description

type

Class names are used to denote types. E.g., str, int, float, bool, bytes, list, dict, tuple are all primitive classes in Python and commonly used in type hinting. However, user-defined classes such as MyClass are also valid types for type hinting.

class Person:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

# Here we declare that #name is a string, #age is an integer, and #item is of type Person.
name: str = "Bob"
age: int = 30
item: Person = Person(name, age)

:

The semicolon is used to denote the start of a type hint for variables and function arguments. In rare cases, type hints can also be used without a variable definition, e.g., item: str declares that the variable item is of type str but does not define its value. This is for example often used in loops, as loop variables cannot be type hinted in the loop definition itself.

# Here we declare that #data is a list of strings and define its value at the same time.
data: list[str] = ["Alice", "Bob", "Charlie"]
# Here we just declare that #name will be a string, but do not yet define its value.
name: str

# This loop will then actually define the variable. In this case it is a bit pointless as
# both a human and linter would know by the declaration of #data of what type #name is.
for name in data:
    print(f"Hello, {name}!")

# But in cases where we loop over function return values of some opaquely typed function,
# this can be useful.
item: foo.Person
for item in foo.get_people():
    print(f"Hello, {item.name}!")

->

The arrow is used to denote the return type of a function. For functions that do not return a value, i.e., where the return type is None, the return type annotation can be omitted. E.g., def foo(): and def foo() -> None: are equivalent.

# Here we declare that the function #greet takes a string as argument and returns a string.
def greet(name: str) -> str:
    return f"Hello, {name}!"

message: str = greet("Alice")

|

The pipe symbol is used to denote a union of types, that a variable or argument can be of multiple types. We do not use the older Union syntax from PEP 484 as it is less readable.

# Data can be either a single string or a list of strings.
data: str | list[str] = "Hello"

# This function can take either a single string or a list of strings as its argument and
# will return either a single uppercase string or a list of uppercase strings.
def uppercase(item: str | list[str]) -> str | list[str]:
    if isinstance(item, str):
        return item.upper()
    elif isinstance(item, list):
        return [s.upper() for s in item]
    else:
        raise ValueError("Invalid input type")

[]

Square brackets are used to denote collection types, i.e., a type that contains multiple elements of another type.

# #names is a list of string.
names: list[str] = ["Alice", "Bob", "Charlie"]
# #employees is a list of strings or Person objects.
employees: list[str | Person] = ["Alice", Person("Bob", 30)]
# #ages is a dictionary that maps strings to integers.
ages: dict[str, int] = {"Alice": 30, "Bob": 25, "Charlie": 35}
# #person is a tuple of exactly two elements, a string and an integer.
person: tuple[str, int] = ("Alice", 30)
# Tuples can also be of variable length: #data is a tuple of the pattern
# (float, int, float, int, ...), of arbitrary length.
data: tuple[float, int ...] = (1.0, 2, 3.5, 4, 5.0)

Any

Any denotes that a variable or argument can be of any type. It is basically the typing hinting way of not typing hinting something. It is often used in cases where the type of a variable is not known or can vary widely.

import typing

# Here we declare that #value can be of any type. typing.Any should be used sparingly, as it
# defeats the purpose of type hinting. Usually there is a more specific annotation that can be used.
def print_value(value: typing.Any) -> None:
    print(value)

Iterator

Iterator denotes values and return types that yield values. We do not use the more complex Generator type hinting syntax.

import typing

# Here we declare that #count_up_to is an iterator that yields integers. #Iterator effectively hints
# at the fact that a function not only returns but also yields values.
def count_up_to(n: int) -> typing.Iterator[int]:
    count: int = 1
    while count <= n:
        yield count
        count += 1

for number in count_up_to(5):
    print(number)

Callable

Callable denotes a variable or argument that is a function and what its signature is. The general logic is that the outer brackets are the full signature, were the first element - the inner brackets - are the argument type list, and the second element is the return type.

import typing

# Here we declare that #compute is a function which takes a function #f as its first argument,
# and two integers as its second and third argument. # must have the signature [int, int], int,
# i.e., must take two integers as arguments and return an integer.
def compute(f: typing.Callable[[int, int], int], a: int, b: int) -> int:
    return f(a, b)

# Here we define a function #f_add which matches the signature of #f required by #compute.
def f_add(x: int, y: int) -> int:
    return x + y

# And here we call #compute.
result: int = compute(f_add, 5, 10)

Comprehensions

List comprehensions are a short-hand syntax for assembling collections without a traditionally written loop. They can make code more readable and shorter but can also be confusing to beginners, especially when nested or combined with other complex expressions. The ZBrush SDK uses list and other comprehensions quite frequently, but strives for limiting their usage to simple cases. The following code block with a loop:

# Build a list of powers of two from 0 to 9.
result: list[int] = []
for n in range(10):
    result.append(2 ** n)

Can be rewritten with a list comprehension and with that reduces the code from 4 lines to a single line:

# Build a list of powers of two from 0 to 9.
result: list[int] = [2 ** n for n in range(10)]

# Comprehensions also work with other collection types, e.g., dictionaries. Here we build a dictionary
# That maps the numbers from 0 to 9 to their squares.
squares: dict[int, int] = {n: n ** 2 for n in range(10)}

Comprehensions can be used for lists, sets, tuples, dictionaries. See the Python Comprehensions documentation for more information about the subject.

Walrus Operator

The walrus operator (:=) allows for assigning values to variables within an expression, most notably in conditional statements and loops. This can be useful for reducing code duplication and improving code readability. The following code block without the walrus operator:

value: int | None = compute_value()
if value is not None:
    data.append(value)

Can be rewritten with the walrus operator as follows; the two blocks are absolutely equivalent, and value will be accessible after the if scope in both cases. Just as for loop variables, walrus operator variables cannot be type hinted in the expression itself but must be type hinted before (which can defeat the purpose of the walrus operator in some cases).

if (value := compute_value()) is not None:
    data.append(value)

Best Practices

ZBrush is a shared python environment, meaning that all scripts and plugins running in ZBrush share the same Python interpreter and the same global state. This has some implications for writing code that collides with what one might be used to from volatile Python environments, such as scripts running in a standard CPython VM.

Shared Modules State

Avoid altering the state of shared builtin module objects, such as sys, math, or random. In a vanilla Python environment, you might overwrite module attributes or call functions that change a module’s behavior. And there this is fine, because such Python VM only runs for your code. But in a shared environment, this will not only affect your code, but also all other scripts and plugins running in ZBrush.

import sys
import math
import random

# We clear all modules. This will not directly break all plugins, as they still hold a local reference to their
# module objects. But this is still a terrible idea, as any code running after this will create new module objects
# when importing modules, causing these modules to exist twice in memory.
sys.modules.clear()

# We overwrite a module attribute and call a function that changes the module's behavior. This will affect
# all code running after this, not only our own code.
math.pi = 3
random.seed(42)

# A side effect of this is also, that when you accidentally made this common mistake, random.seed is a function
# and not an attribute, you have now permanently bricked random.seed until ZBrush is restarted, as just running
# the corrected code again, will not change the fact that you have before overwritten random.seed function.
random.seed = 42

Importing Libraries

Another cause for issues can be colliding dependencies in a shared environment. In a standard CPython VM, the path of the executed script is automatically added to the module search paths (i.e., sys.path) when the script is executed. This is not the case for a shared Python VM such as ZBrush, as adding all found scripts to the module search paths would lead to unpredictable importing behavior. This means that you must manually add the script directory to sys.path if you want to import modules from the same directory as your script. But there are more things to consider due the shared nature of the ZBrush python VM. See Importing Libraries for more information.

import sys
import os

# This is a really bad idea, as it will not only expose your search paths to all other scripts and plugins running
# in ZBrush, but can also cause module name collisions.
libs: str = os.path.join(os.path.dirname(__file__), "libs")
if libs not in sys.path:
    sys.path.insert(0, libs)

# The local math library collides with the builtin module of the same name.
from libs import math

StdIn/StdOut/StdErr

Avoid overwriting sys.stdin, sys.stdout, or sys.stderr. The ZBrush Python VM uses a custom stream handler to redirect standard output and error streams to the ZBrush console. This means that you cannot easily overwrite the stream handlers, as this will break the output redirection from and to the ZBrush console.

import sys

# This will break the output redirection to the ZBrush console. There are currently no ways to reestablish the
# redirection to the ZBrush console with a custom stream handler.
sys.stdout = open("output.txt", "w")
print("This will not appear in the ZBrush console.")

Python Processes

Do not attempt to spawn new Python processes (e.g., via multiprocessing or subprocess). The ZBrush Python VM is the ZBrush executable itself and neither does it understand Python-specific command line arguments, nor will it be able to communicate with the other running ZBrush instances.

import multiprocessing
import runpy
import subprocess
import sys

# This will not work as expected, as sys.executable points to ZBrush.exe or ZBrushMac.app.
subprocess.run([sys.executable, "my_script.py"], check=True)

# This is a valid alternative to subprocess, but it will run in the same Python process and therefore
# will not have the performance and isolation benefits of a separate process.
runpy.run_path("my_script.py", run_name="__main__")

# multiprocessing is not supported at all for the same reason, as multiprocessing just spawns new
# Python processes and then handles their inter-process communication. The code below will just
# open four more ZBrush instances. There is no alternative or workaround for this.

def worker():
    print("Worker process")

pool: list[multiprocessing.Process] = [
    multiprocessing.Process(target=worker) for _ in range(4)
]

Working Directory

The working directory of the ZBrush Python VM is always the ZBrush application directory (i.e., where ZBrush.exe or ZBrushMac.app is located). This differs from a common CPython instance, where the working directory is the directory where the executed script is located or where the Python interpreter was started. This becomes especially relevant when using relative paths in your scripts. It is also not recommend to change the current working directory of the ZBrush Python VM, as this violates the rule of not modifying the state of shared builtin module objects. Other plugins and scripts might rely on the working directory being the ZBrush application directory. When working with files, always use absolute paths or paths relative to the script directory. The following code block shows a bad and a good example:

import os

# This will try to open the file "my_file.txt" in the ZBrush application directory, not in the directory of this script.
with open("my_file.txt", "r") as file:
    data = file.read()

# Do this instead.
script_dir = os.path.dirname(__file__)
with open(os.path.join(script_dir, "my_file.txt"), "r") as file:
    data = file.read()

See also

Editor Configuration

Explains how to configure a code editor for ZBrush script development.

Python Environment

Explains the features and limitations of the ZBrush Python environment.