Easy imports.

How we hacked TouchDesigner to make package management simple

December 10, 2025

by Wieland

December 10, 2025

by Wieland

Foreword

Importing third-party Python packages is still, to this day, a real pain in TouchDesigner. There have been many attempts, but none have managed to create a workflow that actually feels clean or enjoyable. Every existing approach is clunky, requires too much manual work, and forces you to bend your entire workflow around its limitations.

Well, we finally did it! Continue reading to learn how.

How it´s done, for now

The initial method was to set the external Python path globally. That meant all TouchDesigner projects pointed to the same Python environment - definitely not ideal.

Later, this was replaced by the approach of using an onStart callback to modify sys.path during startup. And even today, this is the official way Derivative suggests doing it, via the pyEnvManager.

But here's the problem: we now have to structure our whole project around this callback. There's no way to import packages before that code runs. It's a shame that there's no way to set sys.path early enough in the import cycle. (Hint: this is foreshadowing.)

TD_Pip can help, but is not the answer

With TD_Pip, we came up with two approaches to ensure that:

  1. sys.path is set (i.e. we mount the environment), and

  2. the package is actually installed.

The first approach involved calling ImportModule("qrcode") instead of import qrcode. That call would trigger TD_PIP initialization, ensuring the module exists and is installed.

The newer approach uses a context manager (with op("td_pip").Mount():) to allow regular import qrcode statements to benefit from code completion. While better, this still feels like an anti-pattern—having to wrap imports in a context manager isn't ideal, even if it worked fine for a while.

But TD_PIP is missing one crucial thing: dependency management. (To be fair, so does Conda and pyEnvManager. requirements.txt is not proper dependency management.) TD_PIP installs everything on demand, which can break things during deployment. In theory, every script needs to run once just to ensure all dependencies are installed. Again, we’re bending backwards for something that shouldn’t be a problem in the first place.

But first, a detour about imports in TD

TouchDesigner has a unique import system:

  1. The current component is searched first.

  2. If not found, it checks local/modules of the current component.

  3. Then local/modules of each parent component.

  4. If still not found, it searches in sys.path and runs the "regular" import

This lets us import DATs as Python modules - super handy for extensions and quick iteration without restarting the project.

It searches inside TouchDesigner first, then falls back to external modules. Ever named a DAT json or utils and seen weird behaviour? Exactly.

Let´s get to it!

Let’s talk about the real issue: setting sys.path.

This is the list of paths Python checks when importing packages. The problem is that onStart is too late. We need to modify sys.path before any import happens.

Thought experiment: when is the best time to set sys.path?

Right when sys is first imported.

We can place a Text DAT named sys in /local/modules, effectively overriding the built-in sys module. As long as we still satisfy the interface of the original sys, Python won’t complain.

Here’s what the override looks like:

import sys as __surely_not_even_close_to_sys

def _setup_path_from_packagefolder():

    # Import sys.path. We can do it safely here

    # as we are in the scope of our setup function.
    from sys import path
    
    site_packages_path = ".venv/Lib/site-packages".lower()

    if site_packages_path in path: return

    # modify it.
    path.insert(0, site_packages_path )


_setup_path_from_packagefolder()

# Leave no trace of us being here at all.
del _setup_path_from_packagefolder

# Set all members to the members of sys.
# We have to do this as from sys import * would 
# not import all members.
for module_name in dir( __surely_not_even_close_to_sys ):

    locals()[ module_name ] = getattr(
__surely_not_even_close_to_sys,

      module_name )

# Again, lets clean our traces.

del __surely_not_even_close_to_sys

What This Does:

  • The moment any part of the project imports any module, this script runs. (as importlib needs sys to get access to sys.path)

  • It adds .venv/Lib/site-packages to sys.path.

  • Then it mirrors the real sys module so nothing breaks.

Now, every import anywhere in the project works as expected - from the very first one.

Going the extra step

In our UvManagedPrefab repo, we took it a step further. The script now reads a .packagefolder file, fills in environment variables, and dynamically adds paths to sys.path.

import sys as __surely_not_even_close_to_sys

def _setup_path_from_packagefolder():
  import os, re
  from sys import path

  def replace_var(match):
    var_name = match.group(1)
    return os.environ[var_name]

  with open(".packagefolder", "a+t") as package_folder_file:
    package_folder_file.seek(0)
    for _line in reversed(package_folder_file.readlines()):
      line = _line.strip()
      if not line or line.startswith("#"): continue
      try:
        enved_line = re.sub(r"\$\{([^}]+)\}", replace_var, line)
      except KeyError:
        continue
      if enved_line in path: continue
      path.insert(0, enved_line)

_setup_path_from_packagefolder()
del _setup_path_from_packagefolder

for module_name in dir(__surely_not_even_close_to_sys):
  locals()[module_name] = getattr(__surely_not_even_close_to_sys, module_name)

del __surely_not_even_close_to_sys

The .packagefolder file could look like this:

# Lines starting with # are ignored
# ${...} gets replaced with ENV variables

This lets you not only mount external packages but also your own project-specific folders globally—ideal for larger projects, improving IDE support or package development (e.g. for code completion in VS Code).

The UVManagedPrefab repo even defines MonkeyBrain as a dependency, letting you run the command mb init to generate a good settings.json for VS Code based on your TouchDesigner version and .packagefolder.

Final words

We hope this makes your life with Python in TouchDesigner at least a bit better. Feedback, pull requests, and contributions are always welcome.

Note

With the official 2025 release, Derivative introduced the envManager, which aims to solve a similar use case but is quite strict in its structure. The approach shown here allows for an agnostic workflow without favoring any specific method. For now, we will continue using this approach.