Python For TAs
Apply Python to real tech art problems - file path handling, batch processing, JSON configs, CLI tools, and PySide UIs for your pipeline.
On this page
Why Python For Tech Art Specifically?Common TA Scripting PatternsWorking With File Paths And The os ModuleBatch Processing FilesWorking With JSON And XML DataBuilding Simple CLI Tools With argparseIntro To PySide2/Qt For UIsNext Steps2.1Why Python For Tech Art Specifically?
File handling, data conversion, and repetitive scene tasks are useful places to start with Python. This tutorial works through those patterns, then adds a command-line interface and a small UI. Pick one task that would help your workflow and try it with sample data first.
The examples focus on practical pipeline work. You don’t need to learn every Python feature before building a useful script; start with a small problem you can check.
This tutorial assumes you know Python basics (variables, functions, loops, file I/O). If you're new to Python, start with Intro to Python first.
2.2Common TA Scripting Patterns
These are common patterns to keep in mind while working through the examples:
"""Common patterns you'll use in every TA script."""
import os
import logging
# 1. Set up logging instead of print() - makes debugging in production easy
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)
# 2. Use constants for magic values
TEXTURE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tga", ".tiff", ".exr"}
MAX_TEXTURE_SIZE_MB = 50
ASSET_ROOT = os.environ.get("ASSET_ROOT", "/projects/my_project/assets")
# 3. Guard your main entry point
def main():
log.info("Tool started")
log.info(f"Asset root: {ASSET_ROOT}")
# Your tool logic here...
log.info("Tool finished")
if __name__ == "__main__":
main()In production, print() statements get lost. The logging module gives you timestamps, severity levels, and the ability to write to files. Get in the habit now - your pipeline TDs will thank you.
2.3Working With File Paths And The os Module
Working with paths is a common part of pipeline scripting. The os.path module and pathlib make this cross-platform and reliable.
os.path - The Classic Approach
import os
# Building paths (cross-platform - handles / vs \ automatically)
project_root = "/projects/my_game"
asset_dir = os.path.join(project_root, "assets", "characters", "hero")
print(asset_dir) # /projects/my_game/assets/characters/hero
# Splitting paths apart
filepath = "/assets/weapons/hero_sword_v003.fbx"
directory = os.path.dirname(filepath) # /assets/weapons
filename = os.path.basename(filepath) # hero_sword_v003.fbx
name, ext = os.path.splitext(filename) # ('hero_sword_v003', '.fbx')
print(f"Directory: {directory}")
print(f"Filename: {filename}")
print(f"Name: {name}")
print(f"Extension: {ext}")
# Check if paths exist
if os.path.exists(asset_dir):
print("Directory exists")
if os.path.isfile(filepath):
print("File exists")
if os.path.isdir(asset_dir):
print("Is a directory")
# Get file size
size = os.path.getsize(filepath)
print(f"Size: {size / 1024:.1f} KB")pathlib - The Modern Approach
from pathlib import Path
# Building paths with the / operator
project = Path("/projects/my_game")
asset_dir = project / "assets" / "characters" / "hero"
# Path properties (no function calls needed)
filepath = Path("/assets/weapons/hero_sword_v003.fbx")
print(filepath.parent) # /assets/weapons
print(filepath.name) # hero_sword_v003.fbx
print(filepath.stem) # hero_sword_v003
print(filepath.suffix) # .fbx
# Glob for finding files (recursive with **)
textures_dir = Path("/projects/my_game/textures")
all_pngs = list(textures_dir.glob("**/*.png"))
print(f"Found {len(all_pngs)} PNG files")
# Read/write files in one line
config_path = Path("config.txt")
config_path.write_text("render_quality=high\nresolution=4096\n")
content = config_path.read_text()
# Create directories (parents=True makes intermediate dirs)
output = Path("/export/characters/hero/textures")
output.mkdir(parents=True, exist_ok=True)For new code, prefer pathlib - it's cleaner and more Pythonic. But plenty of DCC APIs still hand you strings, so you'll convert a lot: Path(maya_path_string). Know both, use pathlib when you can.
2.4Batch Processing Files
One of the most common TA tasks: "Take these 500 files and do X to all of them." Here's a complete batch renaming tool.
"""Batch rename tool - rename texture files to match a naming convention.
Converts files like:
HeroSword_Albedo.png -> hero_sword_albedo.png
HeroSword_Normal.PNG -> hero_sword_normal.png
HeroSword Roughness.tga -> hero_sword_roughness.tga
"""
import os
import re
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
def sanitize_filename(name):
"""Convert a filename to a clean, pipeline-friendly format.
Rules: - Lowercase everything - Replace spaces and camelCase boundaries with underscores - Collapse multiple underscores - Strip leading/trailing underscores
"""
# Insert underscore before uppercase letters (CamelCase -> Camel_Case)
name = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
# Replace spaces and hyphens with underscores
name = re.sub(r"[\s\-]+", "_", name)
# Lowercase
name = name.lower()
# Collapse multiple underscores
name = re.sub(r"_+", "_", name)
# Strip leading/trailing underscores
name = name.strip("_")
return name
def batch_rename(directory, dry_run=True):
"""Rename all files in a directory to follow the naming convention.
Args:
directory: Path to the directory to process.
dry_run: If True, only preview changes without renaming.
Returns:
List of (old_name, new_name) tuples for files that were renamed.
"""
renamed = []
for filename in sorted(os.listdir(directory)):
filepath = os.path.join(directory, filename)
if not os.path.isfile(filepath):
continue
name, ext = os.path.splitext(filename)
new_name = sanitize_filename(name) + ext.lower()
if new_name == filename:
continue # already clean
new_path = os.path.join(directory, new_name)
if os.path.exists(new_path):
log.warning(f"SKIP - target already exists: {new_name}")
continue
if dry_run:
log.info(f"[DRY RUN] {filename} -> {new_name}")
else:
os.rename(filepath, new_path)
log.info(f"Renamed: {filename} -> {new_name}")
renamed.append((filename, new_name))
return renamed
if __name__ == "__main__":
target_dir = "./textures"
# Preview first
print("=== DRY RUN (preview only) ===")
changes = batch_rename(target_dir, dry_run=True)
print(f"\n{len(changes)} files would be renamed.")
# Uncomment to actually rename:
# print("\n=== RENAMING ===")
# batch_rename(target_dir, dry_run=False)Batch operations are destructive. Always implement a dry_run mode that previews changes before executing. This is a professional habit that will save you from painful mistakes.
Batch File Copying With Progress
"""Copy and organize exported assets into a publish directory."""
import shutil
from pathlib import Path
import logging
log = logging.getLogger(__name__)
def publish_assets(source_dir, publish_dir, extensions=None):
"""Copy assets from source to a structured publish directory.
Organizes files into subdirectories by extension:
publish/
fbx/
png/
jpg/
"""
if extensions is None:
extensions = {".fbx", ".png", ".jpg", ".tga", ".obj"}
source = Path(source_dir)
publish = Path(publish_dir)
if not source.exists():
log.error(f"Source directory not found: {source}")
return
files = [f for f in source.rglob("*") if f.is_file() and f.suffix.lower() in extensions]
total = len(files)
log.info(f"Publishing {total} files from {source} -> {publish}")
for i, filepath in enumerate(files, start=1):
ext_dir = publish / filepath.suffix.lower().lstrip(".")
ext_dir.mkdir(parents=True, exist_ok=True)
dest = ext_dir / filepath.name
shutil.copy2(filepath, dest) # copy2 preserves metadata
progress = (i / total) * 100
log.info(f" [{progress:5.1f}%] {filepath.name} -> {ext_dir.name}/")
log.info(f"Published {total} files successfully.")2.5Working With JSON And XML Data
JSON is the go-to format for configs, asset metadata, and tool settings. XML still appears in older pipelines and some DCC export formats.
JSON - Reading And Writing
import json
from pathlib import Path
def load_asset_manifest(path):
"""Load an asset manifest from a JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_asset_manifest(data, path):
"""Save an asset manifest to a JSON file (human-readable)."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"Saved manifest to {path}")
# Create a sample asset manifest
manifest = {
"project": "MyGame",
"version": "1.0.0",
"assets": [
{
"name": "hero_sword",
"type": "weapon",
"lod_count": 3,
"poly_counts": [15432, 7200, 2100],
"textures": [
"hero_sword_albedo.png",
"hero_sword_normal.png",
"hero_sword_roughness.png",
],
"tags": ["weapon", "melee", "hero"],
},
{
"name": "health_potion",
"type": "consumable",
"lod_count": 2,
"poly_counts": [3200, 800],
"textures": [
"health_potion_albedo.png",
"health_potion_emissive.png",
],
"tags": ["consumable", "item", "glowing"],
},
],
}
# Save it
save_asset_manifest(manifest, "asset_manifest.json")
# Load it back
loaded = load_asset_manifest("asset_manifest.json")
# Query the data
for asset in loaded["assets"]:
total_polys = asset["poly_counts"][0] # LOD0
tex_count = len(asset["textures"])
print(f"{asset['name']:<20s} | {total_polys:>6,} polys | {tex_count} textures")XML - Parsing Pipeline Configs
"""Parse and modify an XML pipeline config file."""
import xml.etree.ElementTree as ET
def parse_render_config(xml_path):
"""Parse a render settings XML file and return a dict of settings."""
tree = ET.parse(xml_path)
root = tree.getroot()
settings = {}
for setting in root.findall(".//setting"):
name = setting.get("name")
value = setting.text
dtype = setting.get("type", "string")
# Convert to the right Python type
if dtype == "int":
value = int(value)
elif dtype == "float":
value = float(value)
elif dtype == "bool":
value = value.lower() in ("true", "1", "yes")
settings[name] = value
return settings
def create_sample_config(output_path):
"""Create a sample render config XML file."""
root = ET.Element("render_config")
root.set("version", "2.0")
settings_elem = ET.SubElement(root, "settings")
configs = [
("resolution_x", "1920", "int"),
("resolution_y", "1080", "int"),
("samples", "256", "int"),
("use_denoiser", "true", "bool"),
("gamma", "2.2", "float"),
("output_format", "exr", "string"),
]
for name, value, dtype in configs:
setting = ET.SubElement(settings_elem, "setting")
setting.set("name", name)
setting.set("type", dtype)
setting.text = value
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
tree.write(output_path, encoding="utf-8", xml_declaration=True)
print(f"Config written to {output_path}")
# Create and parse
create_sample_config("render_config.xml")
settings = parse_render_config("render_config.xml")
for key, value in settings.items():
print(f" {key}: {value} ({type(value).__name__})")Prefer JSON for new configs and tools - it's simpler and maps directly to Python dicts and lists. Use XML only when you need to interface with legacy systems or tools that specifically require it (some game engines, older pipeline tools).
2.6Building Simple CLI Tools With argparse
Once your script works, turn it into a proper command-line tool. The argparse module gives you argument parsing, help text, and validation for free.
#!/usr/bin/env python3
"""texture_audit.py - Audit texture files in a project directory.
Usage:
python texture_audit.py /path/to/textures
python texture_audit.py /path/to/textures --max-size 25 --format png tga
python texture_audit.py /path/to/textures --output report.json
"""
import argparse
import json
import os
import sys
def get_texture_info(directory, extensions):
"""Scan directory for texture files and collect metadata."""
textures = []
for root, dirs, files in os.walk(directory):
for filename in files:
_, ext = os.path.splitext(filename)
if ext.lower() not in extensions:
continue
filepath = os.path.join(root, filename)
size_mb = os.path.getsize(filepath) / (1024 * 1024)
textures.append({
"path": filepath,
"name": filename,
"size_mb": round(size_mb, 2),
"extension": ext.lower(),
})
return textures
def run_audit(args):
"""Main audit logic."""
extensions = {f".{fmt.lstrip('.')}" for fmt in args.format}
textures = get_texture_info(args.directory, extensions)
if not textures:
print("No textures found.")
return
# Find oversized files
oversized = [t for t in textures if t["size_mb"] > args.max_size]
# Print summary
total_size = sum(t["size_mb"] for t in textures)
print(f"\nTexture Audit Report")
print(f"{'=' * 50}")
print(f"Directory: {args.directory}")
print(f"Files found: {len(textures)}")
print(f"Total size: {total_size:.1f} MB")
print(f"Oversized: {len(oversized)} (over {args.max_size} MB)")
if oversized:
print(f"\n Oversized textures:")
for t in sorted(oversized, key=lambda x: x["size_mb"], reverse=True):
print(f" {t['size_mb']:8.1f} MB {t['name']}")
# Optionally write JSON output
if args.output:
report = {
"directory": args.directory,
"total_files": len(textures),
"total_size_mb": round(total_size, 2),
"oversized_count": len(oversized),
"max_size_mb": args.max_size,
"files": textures,
}
with open(args.output, "w") as f:
json.dump(report, f, indent=2)
print(f"\nFull report saved to {args.output}")
def main():
parser = argparse.ArgumentParser(
description="Audit texture files in a project directory.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"directory",
help="Path to the directory to scan",
)
parser.add_argument(
"--max-size",
type=float,
default=50.0,
help="Maximum texture size in MB before flagging (default: 50)",
)
parser.add_argument(
"--format",
nargs="+",
default=["png", "jpg", "jpeg", "tga", "tiff", "exr", "psd"],
help="Texture formats to scan (default: png jpg jpeg tga tiff exr psd)",
)
parser.add_argument(
"--output",
help="Path to save a JSON report (optional)",
)
args = parser.parse_args()
if not os.path.isdir(args.directory):
print(f"Error: Directory not found: {args.directory}", file=sys.stderr)
sys.exit(1)
run_audit(args)
if __name__ == "__main__":
main()Running python texture_audit.py --help automatically generates usage documentation from your argument definitions. That gives someone running the tool a quick way to check its arguments.
2.7Intro To PySide2/Qt For UIs
Eventually, you'll need to build graphical tools for artists. PySide2 (the Python binding for Qt) is the industry standard - it works standalone and inside Maya, Houdini, and other DCC apps.
"""A simple asset renaming tool with a PySide2 GUI.
Install PySide2: pip install PySide2
Or PySide6 for newer Qt: pip install PySide6
"""
import sys
try:
from PySide2 import QtWidgets, QtCore
except ImportError:
from PySide6 import QtWidgets, QtCore
class AssetRenamerUI(QtWidgets.QWidget):
"""A simple GUI for batch-renaming assets."""
def __init__(self):
super().__init__()
self.setWindowTitle("Asset Renamer")
self.setMinimumWidth(500)
self.setup_ui()
def setup_ui(self):
layout = QtWidgets.QVBoxLayout(self)
# Directory picker
dir_layout = QtWidgets.QHBoxLayout()
self.dir_input = QtWidgets.QLineEdit()
self.dir_input.setPlaceholderText("Select a directory...")
browse_btn = QtWidgets.QPushButton("Browse...")
browse_btn.clicked.connect(self.browse_directory)
dir_layout.addWidget(self.dir_input)
dir_layout.addWidget(browse_btn)
layout.addLayout(dir_layout)
# Options
options_group = QtWidgets.QGroupBox("Options")
options_layout = QtWidgets.QVBoxLayout(options_group)
self.lowercase_cb = QtWidgets.QCheckBox("Convert to lowercase")
self.lowercase_cb.setChecked(True)
self.replace_spaces_cb = QtWidgets.QCheckBox("Replace spaces with underscores")
self.replace_spaces_cb.setChecked(True)
prefix_layout = QtWidgets.QHBoxLayout()
prefix_layout.addWidget(QtWidgets.QLabel("Add prefix:"))
self.prefix_input = QtWidgets.QLineEdit()
self.prefix_input.setPlaceholderText("e.g., hero_")
prefix_layout.addWidget(self.prefix_input)
options_layout.addWidget(self.lowercase_cb)
options_layout.addWidget(self.replace_spaces_cb)
options_layout.addLayout(prefix_layout)
layout.addWidget(options_group)
# Buttons
btn_layout = QtWidgets.QHBoxLayout()
preview_btn = QtWidgets.QPushButton("Preview")
preview_btn.clicked.connect(self.preview_rename)
rename_btn = QtWidgets.QPushButton("Rename")
rename_btn.clicked.connect(self.execute_rename)
btn_layout.addWidget(preview_btn)
btn_layout.addWidget(rename_btn)
layout.addLayout(btn_layout)
# Results log
self.log_output = QtWidgets.QTextEdit()
self.log_output.setReadOnly(True)
layout.addWidget(self.log_output)
def browse_directory(self):
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Directory")
if directory:
self.dir_input.setText(directory)
def preview_rename(self):
self.log_output.clear()
self.log_output.append("=== PREVIEW (no files changed) ===\n")
self.log_output.append("Preview would show renamed files here...")
def execute_rename(self):
self.log_output.clear()
self.log_output.append("=== RENAMING ===\n")
self.log_output.append("Rename logic would execute here...")
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
window = AssetRenamerUI()
window.show()
sys.exit(app.exec_())When running inside Maya or Houdini, you don't create your own QApplication - the host app already has one. Instead, parent your widget to the main application window. We'll cover DCC-specific UI integration in the Maya and Houdini tutorials.
2.8Next Steps
Try extending one of these scripts before moving on. These pages cover useful next steps:
- Data Structures - Learn the data structures behind asset registries, metadata systems, and pipeline logic.
- Maya Scripting - Apply your Python skills inside Maya with PyMEL and the Python API.
- Houdini - Explore procedural workflows with Python and VEX in Houdini.
Pick one of the scripts from this tutorial and expand it into a complete tool. Add error handling, logging, a config file, and maybe a simple UI. Explain the problem it solves, how you tested it, and what still needs work. That makes a useful portfolio breakdown.
