install bbp

This commit is contained in:
Nathan
2026-07-17 16:39:17 -06:00
parent b3cac30802
commit 2a9552dcaf
70 changed files with 8788 additions and 1687 deletions
+1 -1
View File
@@ -10,12 +10,12 @@ D:\Work\9 iClone\Amazon\
D:\Amazon\00_external-files\
N:\1. CHARACTERS\remapping\
[Recent]
C:\Users\Nathan\Downloads\
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Deliverable\
A:\1 Amazon_Active_Projects\260617_Dual_Induct\Blends\animations\
T:\Amazon\active\1 BlenderAssets\
F:\renders\
C:\Users\Nathan\Downloads\
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Deliverable\Stills\
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Deliverable\Scan Aware\
+2 -2
View File
@@ -1,3 +1,5 @@
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6A.blend
C:\Users\Nathan\Downloads\gembawalkEU_horizontal_animation_2A.blend
F:\jobs\Dual-induct_animation 2_vertical-tkev\Dual-induct_animation 2_vertical.flamenco.blend
F:\jobs\Dual-induct_animation 2_vertical-sq1g\Dual-induct_animation 2_vertical.flamenco.blend
F:\jobs\Dual-induct_animation 1_vertical-drth\Dual-induct_animation 1_vertical.flamenco.blend
@@ -16,7 +18,6 @@ A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horiz
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3c.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3a1.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6C.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6A.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2C.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2B.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2A_ins2.blend
@@ -197,4 +198,3 @@ C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-27_FlowCSA_360_1b_ADTA_A.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_12-55_FlowCSA_360_1b_ADTA_A.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-03_FlowCSA_360_1b_ADTA_A.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-11_FlowCSA_360_1b_ADTA_A.blend
A:\1 Amazon_Active_Projects\260420_FLOW_2026\Blends\animations\FlowCSA_360_1bA.blend
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -23,6 +23,31 @@
"archive_size": 129728,
"archive_hash": "sha256:d0ab366ff9d6e9341a0670ca7f16e62d6bd9c68974bc2ce93306b56dfbfef028"
},
{
"schema_version": "1.0.0",
"id": "based_blendfile_packer",
"name": "BasedBlendfilePacker",
"tagline": "Farm-agnostic blendfile packing for any render pipeline",
"version": "0.1.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.5.0",
"tags": [
"render",
"pack",
"utility",
"assets"
],
"python_versions": [
"3"
],
"archive_url": "https://github.com/RaincloudTheDragon/based-blendfile-packer/releases/download/0.1.0/BasedBlendfilePacker.v0.1.0.zip",
"archive_size": 211604,
"archive_hash": "sha256:6e21c32c8bd58f206d1596e5c8547153d805b6c3e25b41ffcd621f4aad54e7f6"
},
{
"schema_version": "1.0.0",
"id": "basedplayblast",
@@ -73,28 +98,6 @@
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/0.18.0/Rainys_Bulk_Scene_Tools.v0.18.0.zip",
"archive_size": 75599,
"archive_hash": "sha256:6653a01b9ac5822e51113609b5a2970102715ec5f7816a9dccd80ecb4c755ea5"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.8",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/based-blendfile-packer/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
"archive_size": 47667,
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
}
]
}
@@ -1,3 +1,18 @@
## [v0.1.0] - 2026-07-17
### Changed
- Rebranded to BasedBlendfilePacker; support target is Blender 4.5 LTS and 5.2 LTS (`blender_version_min` 4.5.0).
- Asset discovery uses official Blender Asset Tracer: BAT v1 (vendored) on 4.5 LTS, BAT v2 (bundled wheel) on 5.1+/5.2 LTS. Replaces the old `batter` fork.
- Dependabot tracks BAT v1/v2 pins and GitHub Actions; sync workflow refreshes wheels/vendor.
### Removed
- Pack Current Blend workflow (SheepIt-era leftover; #4).
### Fixed
- BAT v2 discovery during Pack as Blend with temp-file override (skip pack-path clustering; handle `Library | None` reference keys).
---
## [v0.0.8] - 2026-03-16
### Fixed
@@ -1,20 +1,20 @@
# SheepIt Project Submitter
# BasedBlendfilePacker
A Blender addon for packing projects for the SheepIt render farm with automatic asset packing and intelligent workflow management.
A farm-agnostic Blender addon for packing projects with automatic asset discovery, path remapping, and intelligent workflow management.
## Features
| Automatic Asset Packing | Frame Range Control | Multiple Packing Methods |
|--|--|--|
| Automatically packs all linked blend files, textures, images, and external assets into your project. Supports both ZIP and packed blend file workflows. | Configure custom frame ranges directly in Blender without saving your file. Frame ranges are automatically applied to packed files. | Pack as current blend file, packed ZIP archive, or packed blend file. Choose the method that best fits your project. |
| Automatically packs all linked blend files, textures, images, and external assets into your project. Supports both ZIP and packed blend file workflows. | Configure custom frame ranges directly in Blender without saving your file. Frame ranges are automatically applied to packed files. | Pack as ZIP archive or packed blend file. Choose the method that best fits your project. |
| Cache Management | Size Validation | Progress Tracking |
|--|--|--|
| Automatically truncates cache files to match your selected frame range, reducing file sizes significantly. | Validates file sizes before packing (2GB limit) with helpful suggestions for optimization. | Real-time progress bars and status messages for all operations. All steps are cancellable. |
| Automatically truncates cache files to match your selected frame range, reducing file sizes significantly. | Validates file sizes before packing with a configurable size limit and helpful suggestions for optimization. | Real-time progress bars and status messages for all operations. All steps are cancellable. |
| Path Remapping | Missing File Detection | Error Reporting |
|--|--|--|
| Intelligently remaps all asset paths to work correctly on the render farm. Handles textures, images, videos, and linked blend files. | Detects and reports missing linked files and oversized files (>2GB) that cannot be packed. | Comprehensive error messages with actionable suggestions for resolving issues. |
| Intelligently remaps all asset paths for portable handoff to any render farm or pipeline. Handles textures, images, videos, and linked blend files. | Detects and reports missing linked files and oversized files that cannot be packed. | Comprehensive error messages with actionable suggestions for resolving issues. |
### Additional Features:
- Works with unsaved blend files (operates on in-memory state)
@@ -24,24 +24,26 @@ A Blender addon for packing projects for the SheepIt render farm with automatic
## Installation
1. Download the latest release from [GitHub Releases](https://github.com/RaincloudTheDragon/sheepit-project-submitter/releases)
1. Download the latest release from [GitHub Releases](https://github.com/RaincloudTheDragon/based-blendfile-packer/releases)
2. In Blender, go to `Edit > Preferences > Add-ons`
3. Click `Install...` and select the downloaded ZIP file
4. Enable the addon by checking the box next to "SheepIt Project Submitter"
4. Enable the addon by checking the box next to "BasedBlendfilePacker"
## Usage
1. **Set Frame Range**: In the Output properties panel, configure your frame range (full range or custom)
2. **Pack Project**: Choose your packing method:
- **Pack Current Blend**: Saves the current blend file with frame range applied
- **Pack as ZIP**: Packs all assets and creates a ZIP archive (recommended for scenes with caches)
- **Pack as Blend**: Packs all assets directly into the blend file
3. **Select Output Location**: A file browser will open to select where to save the packed file
4. **Upload Manually**: Upload the packed file to SheepIt via the website and configure your project settings
4. **Hand off**: Upload or transfer the packed output to your render farm or pipeline of choice
## Requirements
- Blender 3.0.0 or later
- **Blender 4.5 LTS** or **Blender 5.2 LTS** (minimum: Blender 4.5.0)
- Asset tracing uses [Blender Asset Tracer](https://projects.blender.org/blender/blender-asset-tracer):
- **4.5 LTS** — BAT v1 (vendored in the extension)
- **5.2 LTS** — BAT v2 (bundled wheel)
## License
@@ -49,5 +51,4 @@ GPL-3.0-or-later
## Links
- **GitHub Repository**: [https://github.com/RaincloudTheDragon/sheepit-project-submitter](https://github.com/RaincloudTheDragon/sheepit-project-submitter)
- **SheepIt Render Farm**: [https://www.sheepit-renderfarm.com](https://www.sheepit-renderfarm.com)
- **GitHub Repository**: [https://github.com/RaincloudTheDragon/based-blendfile-packer](https://github.com/RaincloudTheDragon/based-blendfile-packer)
@@ -1,7 +1,7 @@
"""
SheepIt Project Submitter Addon
BasedBlendfilePacker Addon
A Blender addon for submitting projects to SheepIt render farm with automatic asset packing.
A farm-agnostic Blender addon for packing projects with automatic asset discovery and export.
"""
import bpy
@@ -21,11 +21,9 @@ def _update_output_path(self, context):
self.output_path = prefs.default_output_path
# SheepIt Submit Settings Property Group
class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
"""Property group for storing submit settings."""
class BBP_PG_pack_settings(bpy.types.PropertyGroup):
"""Property group for blend packing settings."""
# Frame range mode
frame_range_mode: bpy.props.EnumProperty(
name="Frame Range Mode",
description="Choose between full range or custom frame range",
@@ -36,7 +34,6 @@ class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
default='FULL',
)
# Custom frame range
frame_start: bpy.props.IntProperty(
name="Start Frame",
description="Start frame for rendering",
@@ -58,52 +55,12 @@ class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
min=1,
)
# Compute method
compute_method: bpy.props.EnumProperty(
name="Compute Method",
description="Choose CPU or GPU rendering",
items=[
('CPU', "CPU", "Use CPU for rendering"),
('GPU', "GPU", "Use GPU for rendering"),
],
default='CPU',
)
# Checkboxes
renderable_by_all: bpy.props.BoolProperty(
name="Renderable by all members",
description="Allow all SheepIt members to render this project",
default=True,
)
generate_mp4: bpy.props.BoolProperty(
name="Generate MP4 video",
description="Generate MP4 video from rendered frames",
default=False,
)
# Advanced options
memory_used_mb: bpy.props.StringProperty(
name="Memory used (MB)",
description="Memory limit in MB (leave empty for default)",
default="",
)
# Advanced options visibility
show_advanced: bpy.props.BoolProperty(
name="Show Advanced Options",
description="Show advanced submission options",
default=False,
)
# ZIP pack: exclude video/audio files
exclude_video_from_zip: bpy.props.BoolProperty(
name="Exclude video/audio from ZIP",
description="Exclude video and audio files (e.g. mp4, avi, mov, wav, mp3) from the ZIP pack",
default=False,
)
# Project size limit (GB); 0 = no limit (max 32-bit signed int for Blender C API)
project_size_limit_gb: bpy.props.IntProperty(
name="Project Size Limit (GB)",
description="Maximum project/ZIP/blend size in GB (0 = no limit)",
@@ -112,7 +69,6 @@ class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
max=(1 << 31) - 1,
)
# Pack output path (set by pack operators)
pack_output_path: bpy.props.StringProperty(
name="Pack Output Path",
description="Path to the packed output directory",
@@ -134,25 +90,24 @@ class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
update=_update_output_path,
)
# Progress tracking for submission operations
is_submitting: bpy.props.BoolProperty(
name="Is Submitting",
description="Whether a submission is currently in progress",
is_packing: bpy.props.BoolProperty(
name="Is Packing",
description="Whether a packing operation is currently in progress",
default=False,
)
submit_progress: bpy.props.FloatProperty(
name="Submit Progress",
description="Progress percentage for submission operations",
pack_progress: bpy.props.FloatProperty(
name="Pack Progress",
description="Progress percentage for packing operations",
default=0.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE',
)
submit_status_message: bpy.props.StringProperty(
name="Submit Status Message",
description="Current status message for submission operations",
pack_status_message: bpy.props.StringProperty(
name="Pack Status Message",
description="Current status message for packing operations",
default="",
)
@@ -161,14 +116,11 @@ def register():
"""Register the addon."""
from .utils import compat
compat.safe_register_class(SHEEPIT_PG_submit_settings)
bpy.types.Scene.sheepit_submit = bpy.props.PointerProperty(type=SHEEPIT_PG_submit_settings)
compat.safe_register_class(BBP_PG_pack_settings)
bpy.types.Scene.bbp_pack = bpy.props.PointerProperty(type=BBP_PG_pack_settings)
# Register operators and UI (preferences are registered in ui.register())
ops.register()
ui.register()
# Bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
@@ -176,13 +128,10 @@ def unregister():
"""Unregister the addon."""
from .utils import compat
# Bootstrap unregistration
rainys_repo_bootstrap.unregister()
# Unregister operators and UI
ui.unregister()
ops.unregister()
compat.safe_unregister_class(SHEEPIT_PG_submit_settings)
if hasattr(bpy.types.Scene, 'sheepit_submit'):
del bpy.types.Scene.sheepit_submit
compat.safe_unregister_class(BBP_PG_pack_settings)
if hasattr(bpy.types.Scene, 'bbp_pack'):
del bpy.types.Scene.bbp_pack
@@ -0,0 +1,23 @@
schema_version = "1.0.0"
id = "based_blendfile_packer"
name = "BasedBlendfilePacker"
version = "0.1.0"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
blender_version_min = "4.5.0"
license = ["GPL-3.0-or-later"]
description = "Pack Blender projects with automatic asset discovery, path remapping, and farm-ready export."
homepage = "https://github.com/RaincloudTheDragon/based-blendfile-packer/"
tagline = "Farm-agnostic blendfile packing for any render pipeline"
tags = ["render", "pack", "utility", "assets"]
# Python modules to load for this add-on
modules = ["based_blendfile_packer"]
# Blender Asset Tracer: v2 wheel for 5.2 LTS (5.1+); v1 vendored for 4.5 LTS.
wheels = [
"./wheels/blender_asset_tracer-2.1.0-py3-none-any.whl",
]
@@ -0,0 +1,23 @@
"""
Configuration constants for BasedBlendfilePacker addon.
"""
# Addon metadata
ADDON_NAME = "BasedBlendfilePacker"
ADDON_ID = "based_blendfile_packer"
# Supported Blender LTS releases (see blender_manifest.toml blender_version_min).
MIN_BLENDER_VERSION = (4, 5, 0)
SUPPORTED_LTS_TARGETS = ("4.5", "5.2")
# BAT v2 requires Blender 5.1+; 4.5 LTS uses vendored BAT v1.
BAT_V2_MIN_BLENDER_VERSION = (5, 1, 0)
# Debug mode
DEBUG = False
def debug_print(message: str) -> None:
"""Print debug message if DEBUG is enabled."""
if DEBUG:
print(f"[{ADDON_NAME}] {message}")
@@ -0,0 +1,21 @@
"""
Operators for BasedBlendfilePacker addon.
"""
def register():
"""Register all operators."""
from . import pack_ops
from . import export_ops
pack_ops.register()
export_ops.register()
def unregister():
"""Unregister all operators."""
from . import pack_ops
from . import export_ops
export_ops.unregister()
pack_ops.unregister()
@@ -0,0 +1,220 @@
"""
Export operations for BasedBlendfilePacker.
"""
import os
import zipfile
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple
import bpy
def apply_frame_range_to_blend(blend_path: Path, frame_start: int, frame_end: int, frame_step: int) -> None:
"""
Apply frame range settings to a blend file using subprocess.
Args:
blend_path: Path to the blend file to modify
frame_start: Start frame value
frame_end: End frame value
frame_step: Frame step value
"""
script = f"""
import bpy
for scene in bpy.data.scenes:
scene.frame_start = {frame_start}
scene.frame_end = {frame_end}
scene.frame_step = {frame_step}
bpy.ops.wm.save_mainfile(compress=True)
print(f'Applied frame range {frame_start}-{frame_end} (step {frame_step}) to all scenes')
"""
result = subprocess.run([
"blender", "--factory-startup", "-b", str(blend_path), "--python-expr", script
], capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"[BBP Export] WARNING: Failed to apply frame range to {blend_path.name}")
if result.stderr:
print(f"[BBP Export] Error: {result.stderr[:200]}")
else:
print(f"[BBP Export] Applied frame range {frame_start}-{frame_end} (step {frame_step}) to {blend_path.name}")
def save_current_blend_with_frame_range(pack_settings, temp_dir: Optional[Path] = None) -> Tuple[Path, int, int, int]:
"""
Save current blend state to a temporary file and apply frame range from pack_settings.
Args:
pack_settings: Submit settings containing frame range configuration
temp_dir: Optional temporary directory (if None, creates a new one)
Returns:
Tuple of (temp_blend_path, frame_start, frame_end, frame_step)
"""
# Determine frame range from pack_settings
if pack_settings.frame_range_mode == 'FULL':
frame_start = bpy.context.scene.frame_start
frame_end = bpy.context.scene.frame_end
frame_step = bpy.context.scene.frame_step
else:
frame_start = pack_settings.frame_start
frame_end = pack_settings.frame_end
frame_step = pack_settings.frame_step
# Create temp directory if not provided
if temp_dir is None:
temp_dir = Path(tempfile.mkdtemp(prefix="bbp_export_"))
# Generate temp blend filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
blend_name = Path(blend_name).stem if blend_name else "untitled"
temp_blend = temp_dir / f"{blend_name}.blend"
print(f"[BBP Export] Saving current blend state to: {temp_blend}")
print(f"[BBP Export] Frame range: {frame_start} - {frame_end} (step: {frame_step})")
# Save current blend state
try:
temp_dir.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend), copy=True, compress=True)
print(f"[BBP Export] Saved current blend state to temp file")
except Exception as e:
error_msg = f"Failed to save current blend state: {type(e).__name__}: {str(e)}"
print(f"[BBP Export] ERROR: {error_msg}")
raise RuntimeError(error_msg) from e
# Apply frame range to the saved file
apply_frame_range_to_blend(temp_blend, frame_start, frame_end, frame_step)
return temp_blend, frame_start, frame_end, frame_step
# Video and audio extensions to exclude when exclude_video=True
_MEDIA_EXTENSIONS = frozenset({
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.wmv', '.flv', '.ogv', '.mpg', '.mpeg', '.m2v',
'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
})
def create_zip_from_directory(directory: Path, output_zip: Path, progress_callback=None, cancel_check=None, exclude_video: bool = False) -> None:
"""Create a ZIP file from a directory.
Args:
directory: Directory to zip
output_zip: Output ZIP file path
progress_callback: Optional callback(progress_pct, message) for progress updates
cancel_check: Optional callback() -> bool to check for cancellation
exclude_video: If True, skip common video and audio file extensions
"""
import time
print(f"[BBP Export] Starting ZIP creation...")
print(f"[BBP Export] Directory: {directory}")
print(f"[BBP Export] Output: {output_zip}")
# Delete .blend1 through .blend32 backup files before zipping
if progress_callback:
progress_callback(0.0, "Removing backup files...")
backup_files = []
for i in range(1, 33): # blend1 through blend32
pattern = f"*.blend{i}"
backup_files.extend(directory.rglob(pattern))
if backup_files:
print(f"[BBP Export] Found {len(backup_files)} backup files (.blend1-.blend32), deleting...")
deleted_count = 0
for backup_file in backup_files:
try:
backup_file.unlink()
deleted_count += 1
except Exception as e:
print(f"[BBP Export] WARNING: Could not delete {backup_file.name}: {e}")
print(f"[BBP Export] Deleted {deleted_count}/{len(backup_files)} backup files")
else:
print(f"[BBP Export] No backup files (.blend1-.blend32) found")
if progress_callback:
progress_callback(0.0, "Counting files...")
# Collect all dirs (for empty-dir entries) and files
dir_arcs = set()
file_list = []
file_count = 0
total_size = 0
for root, dirs, files in os.walk(directory):
root_path = Path(root)
for d in dirs:
dir_arcs.add(root_path.joinpath(d).relative_to(directory))
for file in files:
file_path = root_path / file
if not file_path.exists():
continue
if exclude_video and file_path.suffix.lower() in _MEDIA_EXTENSIONS:
continue
file_count += 1
total_size += file_path.stat().st_size
file_list.append((file_path, file_path.relative_to(directory)))
print(f"[BBP Export] Found {file_count} files, total size: {total_size / (1024*1024):.2f} MB")
print(f"[BBP Export] Creating ZIP (this may take a while)...")
if progress_callback:
progress_callback(1.0, f"Creating ZIP archive ({file_count} files, {total_size / (1024*1024):.1f} MB)...")
start_time = time.time()
files_added = 0
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_STORED) as zipf:
for dir_arc in sorted(dir_arcs):
arcname = str(dir_arc).replace("\\", "/") + "/"
zipf.writestr(arcname, "")
for file_path, arcname in file_list:
if cancel_check and cancel_check():
raise InterruptedError("ZIP creation cancelled by user")
if not file_path.exists():
continue
try:
zipf.write(file_path, arcname)
files_added += 1
# Progress updates - more frequent for large files
if files_added == 1:
print(f"[BBP Export] Adding files to ZIP...")
if progress_callback:
progress_callback(2.0, f"Adding files to ZIP... (1/{file_count})")
elif files_added % 10 == 0 or (file_count > 0 and files_added % max(1, file_count // 100) == 0):
elapsed = time.time() - start_time
rate = files_added / elapsed if elapsed > 0 else 0
progress_pct = 2.0 + (files_added / file_count * 93.0) if file_count > 0 else 2.0
print(f"[BBP Export] Progress: {files_added}/{file_count} files ({files_added*100//file_count}%), {rate:.1f} files/sec")
if progress_callback:
progress_callback(progress_pct, f"Creating ZIP... ({files_added}/{file_count} files, {rate:.1f} files/sec)")
except Exception as e:
print(f"[BBP Export] WARNING: Failed to add {arcname}: {type(e).__name__}: {str(e)}")
elapsed = time.time() - start_time
print(f"[BBP Export] ZIP creation completed!")
print(f"[BBP Export] Files added: {files_added}/{file_count}")
print(f"[BBP Export] Time taken: {elapsed:.2f} seconds")
if elapsed > 0:
print(f"[BBP Export] Average rate: {files_added/elapsed:.1f} files/sec")
if progress_callback:
progress_callback(100.0, "ZIP archive created")
def register():
"""Register export helpers (no operators)."""
pass
def unregister():
"""Unregister export helpers (no operators)."""
pass
@@ -1,5 +1,5 @@
"""
UI modules for SheepIt Project Submitter addon.
UI modules for BasedBlendfilePacker addon.
"""
from . import output_panel
@@ -10,10 +10,7 @@ __all__ = ["output_panel", "preferences_ui"]
def register():
"""Register all UI classes."""
# Register preferences first so we can access variables in config.py
preferences_ui.register()
# Register other UI components
output_panel.register()
@@ -0,0 +1,78 @@
"""
Output panel UI for BasedBlendfilePacker.
Located in the Output tab, similar to Flamenco addon.
"""
import bpy
from bpy.types import Panel
from ..utils import compat
class BBP_PT_output_panel(Panel):
"""Blend packing panel in Output properties."""
bl_label = "BasedBlendfilePacker"
bl_idname = "BBP_PT_output_panel"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "output"
def draw(self, context):
layout = self.layout
scene = context.scene
pack_settings = scene.bbp_pack
box = layout.box()
box.label(text="Animation Setup:", icon='ACTION')
col = box.column()
col.operator("bbp.enable_nla", text="Disable Animation Layers", icon='ACTION')
layout.separator()
box = layout.box()
box.label(text="Frame Range:", icon='RENDER_ANIMATION')
row = box.row()
row.prop(pack_settings, "frame_range_mode", expand=True)
if pack_settings.frame_range_mode == 'CUSTOM':
col = box.column(align=True)
col.prop(pack_settings, "frame_start")
col.prop(pack_settings, "frame_end")
col.prop(pack_settings, "frame_step")
else:
row = box.row()
row.label(text=f"Scene Range: {scene.frame_start} - {scene.frame_end} (Step: {scene.frame_step})")
layout.separator()
if pack_settings.is_packing:
box = layout.box()
box.label(text=pack_settings.pack_status_message, icon='TIME')
box.prop(pack_settings, "pack_progress", text="Progress", slider=True)
layout.separator()
col = layout.column()
col.scale_y = 1.5
col.operator("bbp.pack_zip", text="Pack as ZIP (for scenes with caches)", icon='PACKAGE')
row = layout.row()
row.prop(pack_settings, "exclude_video_from_zip", text="Exclude video/audio from ZIP")
col.operator("bbp.pack_blend", text="Pack as Blend", icon='FILE_BLEND')
layout.separator()
box = layout.box()
box.label(text="Output Path:", icon='FILE_FOLDER')
row = box.row()
row.prop(pack_settings, "output_path", text="")
row = box.row()
row.prop(pack_settings, "project_size_limit_gb", text="Size limit (GB)")
def register():
"""Register panel."""
compat.safe_register_class(BBP_PT_output_panel)
def unregister():
"""Unregister panel."""
compat.safe_unregister_class(BBP_PT_output_panel)
@@ -0,0 +1,90 @@
"""
Preferences UI for BasedBlendfilePacker.
"""
import sys
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty
from .. import config
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
try:
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[BBP Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
config.debug_print(f"[BBP Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[BBP Debug] Could not get parent module name: {e}")
module_name = config.ADDON_ID
config.debug_print(f"[BBP Debug] Using fallback bl_idname: {module_name}")
return module_name
class BBP_AddonPreferences(AddonPreferences):
"""Addon preferences for BasedBlendfilePacker."""
bl_idname = _get_addon_module_name()
default_output_path: StringProperty(
name="Default Output Path",
description="Default directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=lambda self, context: _sync_default_output_path(self, context),
)
def draw(self, context):
layout = self.layout
box = layout.box()
box.label(text="Output Settings:", icon='FILE_FOLDER')
box.prop(self, "default_output_path")
layout.separator()
box = layout.box()
box.label(text="About:", icon='INFO')
box.label(text="BasedBlendfilePacker packs Blender projects for any render farm or pipeline.")
box.label(text="Upload or transfer packed output to your target environment manually.")
def _sync_default_output_path(prefs, context):
"""Sync default output path to all scenes' output_path if they're empty."""
if not prefs.default_output_path:
return
for scene in bpy.data.scenes:
if hasattr(scene, 'bbp_pack') and scene.bbp_pack:
if not scene.bbp_pack.output_path:
scene.bbp_pack.output_path = prefs.default_output_path
reg_list = [BBP_AddonPreferences]
def register():
"""Register preferences."""
for cls in reg_list:
try:
from bpy.utils import register_class
register_class(cls)
config.debug_print(f"[BBP Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[BBP Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
def unregister():
"""Unregister preferences."""
from ..utils import compat
for cls in reg_list:
compat.safe_unregister_class(cls)
@@ -0,0 +1,9 @@
"""
Utility modules for BasedBlendfilePacker addon.
"""
from . import bat_asset_usage
from . import compat
from . import version
__all__ = ["bat_asset_usage", "compat", "version"]
@@ -0,0 +1,322 @@
"""
Asset usage discovery via official Blender Asset Tracer (BAT).
Replaces the January 2026 fork of BAT v2 alpha (``batter/asset_usage.py``).
Supported Blender targets and BAT backends:
- **4.5 LTS** — BAT v1, vendored under ``vendor/bat_v1/`` (standalone blend parsing).
- **5.2 LTS** — BAT v2, bundled extension wheel (in-Blender ``file_usage`` API).
Both backends are always shipped: the v2 wheel is only installed on Blender 5.1+
(Python 3.13), so it cannot conflict with the vendored v1 tree on 4.5 LTS.
"""
from __future__ import annotations
import dataclasses
import functools
import importlib.util
import sys
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Literal
import bpy
from bpy.types import Library
from . import version
if TYPE_CHECKING:
from collections.abc import Iterable
BatBackend = Literal["v1", "v2"]
_ADDON_ROOT = Path(__file__).resolve().parent.parent
_BAT_V1_VENDOR = _ADDON_ROOT / "vendor" / "bat_v1"
_BAT_V1_INSTALLED = False
@dataclasses.dataclass
class AssetUsage:
"""A single asset referenced by a blend file in the current session."""
abspath: Path
reference_path: str
is_blendfile: bool
def __hash__(self) -> int:
return hash((self.abspath, self.reference_path, self.is_blendfile))
def __eq__(self, value: object) -> bool:
if not isinstance(value, AssetUsage):
return False
return (
self.abspath,
self.reference_path,
self.is_blendfile,
) == (
value.abspath,
value.reference_path,
value.is_blendfile,
)
def get_bat_backend() -> BatBackend:
"""Return which official BAT backend is active for this Blender session."""
return "v2" if uses_bat_v2() else "v1"
def uses_bat_v2() -> bool:
"""True when BAT v2 from the bundled wheel is active (Blender 5.1+ / 5.2 LTS)."""
if not version.uses_bat_v2_blender_version():
return False
try:
import blender_asset_tracer.file_usage # noqa: F401
except ImportError:
return False
return True
def uses_bat_v1() -> bool:
"""True when vendored BAT v1 is the active backend (Blender 4.5 LTS)."""
return not uses_bat_v2()
def _install_bat_v1_vendor() -> None:
"""Register vendored BAT v1 packages under ``blender_asset_tracer``."""
global _BAT_V1_INSTALLED
if _BAT_V1_INSTALLED:
return
existing = sys.modules.get("blender_asset_tracer")
if existing is not None and getattr(existing, "__version__", "").startswith("2."):
return
package_root = _BAT_V1_VENDOR / "blender_asset_tracer"
if not package_root.is_dir():
raise ImportError(
f"Vendored BAT v1 not found at {package_root}. "
"Run the Sync BAT wheels workflow or refresh vendor/bat_v1/."
)
py_files = sorted(package_root.rglob("*.py"), key=lambda path: len(path.parts))
for py_file in py_files:
rel = py_file.relative_to(_BAT_V1_VENDOR).with_suffix("")
module_name = ".".join(rel.parts)
if module_name in sys.modules:
continue
spec = importlib.util.spec_from_file_location(module_name, py_file)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load BAT v1 module spec for {py_file}")
module = importlib.util.module_from_spec(spec)
if py_file.name == "__init__.py":
module.__package__ = ".".join(module_name.split(".")[:-1]) or None
module.__file__ = str(py_file)
sys.modules[module_name] = module
spec.loader.exec_module(module)
_BAT_V1_INSTALLED = True
def _bat_v1_trace():
"""Return BAT v1 ``trace`` module after ensuring the vendor tree is loaded."""
_install_bat_v1_vendor()
import blender_asset_tracer.trace as bat_trace
return bat_trace
def _bat_v2_file_usage():
"""Return BAT v2 ``file_usage`` module (wheel must already be installed)."""
from blender_asset_tracer import file_usage as bat_fu
return bat_fu
@functools.lru_cache(maxsize=1024)
def library_abspath(lib: Library | None) -> Path:
"""Absolute path of a linked library, or the current blend file when ``lib`` is None."""
if lib is None:
filepath = bpy.data.filepath
else:
filepath = bpy.path.abspath(lib.filepath)
return Path(filepath).resolve()
def _bat_v2_project_root() -> Path:
"""Project root for BAT v2, aligned with ``library_abspath(None)`` (incl. temp override)."""
blend_path = library_abspath(None)
if blend_path.name:
return blend_path.resolve().parent
if bpy.data.filepath:
return Path(bpy.data.filepath).resolve().parent
return Path.cwd().resolve()
def _v2_dependency_repo():
"""Build a BAT v2 dependency repo for discovery only (skip pack-path clustering).
``dependencies_of_current_blendfile()`` also runs pack-path clustering, which
BBP does not need for ``find()`` and which fails when the temp-blend override
disagrees with ``bpy.data.filepath`` (``Could not shorten these paths: ['.']``).
"""
bat_fu = _bat_v2_file_usage()
root = _bat_v2_project_root()
with bat_fu.cache_autoclear():
deps_repo = bat_fu.FileDependencyRepository(root_path=root)
bat_fu._determine_dependencies(deps_repo, bat_fu.Options())
return deps_repo
def _library_for_blend_path(blend_path: Path) -> Library | None:
"""Map an on-disk blend path to a ``bpy.types.Library``, if loaded."""
blend_path = blend_path.resolve()
if blend_path == library_abspath(None).resolve():
return None
for lib in bpy.data.libraries:
try:
if library_abspath(lib).resolve() == blend_path:
return lib
except (OSError, ValueError):
continue
return None
def _repo_to_asset_usages(repo) -> dict[Library | None, set[AssetUsage]]:
"""Convert BAT v2 ``FileDependencyRepository`` to BBP's legacy grouping."""
usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for abs_path, info in repo.file_infoes.items():
if abs_path == repo.packed_source_file:
continue
is_blendfile = abs_path.suffix.lower() == ".blend"
reference_path = str(info.reported_path or abs_path)
if not info.references:
usages[None].add(
AssetUsage(
abspath=abs_path,
reference_path=reference_path,
is_blendfile=is_blendfile,
)
)
continue
# BAT stores references as BlendFile keys: Library | None (None = current blend).
for blend_ref in info.references:
if blend_ref is None or isinstance(blend_ref, Library):
lib = blend_ref
else:
lib = _library_for_blend_path(Path(blend_ref))
usages[lib].add(
AssetUsage(
abspath=abs_path,
reference_path=reference_path,
is_blendfile=is_blendfile,
)
)
return dict(usages)
def _iter_session_blend_paths() -> Iterable[tuple[Library | None, Path]]:
"""Yield each loaded blend file in the session as ``(library, abspath)``."""
yield None, library_abspath(None)
for lib in bpy.data.libraries:
yield lib, library_abspath(lib)
def _v1_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Discover non-blend assets with BAT v1 file tracing (4.5 LTS path)."""
bat_trace = _bat_v1_trace()
usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for lib, blend_path in _iter_session_blend_paths():
if not blend_path.exists() or blend_path.suffix.lower() != ".blend":
continue
seen: set[Path] = set()
for block_usage in bat_trace.deps(blend_path):
for asset_path in block_usage.files():
resolved = asset_path.resolve()
if resolved in seen or resolved.suffix.lower() == ".blend":
continue
seen.add(resolved)
usages[lib].add(
AssetUsage(
abspath=resolved,
reference_path=str(block_usage.asset_path),
is_blendfile=False,
)
)
return dict(usages)
def _v2_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Discover assets with BAT v2 in-Blender dependency tracing (5.2 LTS path)."""
try:
repo = _v2_dependency_repo()
except RuntimeError as exc:
print(f"[BBP BAT] v2 dependency discovery failed ({exc}); falling back to v1 trace")
return _v1_nonblend_asset_usage()
all_usages = _repo_to_asset_usages(repo)
nonblend: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for lib, items in all_usages.items():
for item in items:
if not item.is_blendfile:
nonblend[lib].add(item)
return dict(nonblend)
def find_blend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Map each blend file to the library blend files it references."""
libs_deps: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for _id, id_users in bpy.data.user_map().items():
id_lib = _id.library
libs_deps.setdefault(id_lib, set())
for id_user in id_users:
if id_user.library == id_lib:
continue
libs_deps[id_user.library].add(
AssetUsage(
abspath=library_abspath(id_lib),
reference_path=id_lib.filepath,
is_blendfile=True,
)
)
return dict(libs_deps)
def find_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Map each blend file to non-blend assets it references."""
if uses_bat_v2():
return _v2_nonblend_asset_usage()
return _v1_nonblend_asset_usage()
def find() -> dict[Library | None, set[AssetUsage]]:
"""Return all assets used by the current blend file and its linked libraries."""
return _merge_keys(find_blend_asset_usage(), find_nonblend_asset_usage())
def _merge_keys(
a: dict[Library | None, set[AssetUsage]],
b: dict[Library | None, set[AssetUsage]],
) -> dict[Library | None, set[AssetUsage]]:
merged: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for key, values in a.items():
merged[key].update(values)
for key, values in b.items():
merged[key].update(values)
return dict(merged)
@@ -1,7 +1,8 @@
"""
Compatibility layer for handling differences across Blender versions.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
Compatibility layer for handling differences across supported Blender versions.
Supported targets: Blender 4.5 LTS and Blender 5.2 LTS.
Minimum supported: Blender 4.5.0.
"""
import bpy
@@ -0,0 +1,83 @@
"""
Version detection and comparison utilities.
Supported targets: Blender 4.5 LTS (BAT v1) and Blender 5.2 LTS (BAT v2).
Minimum supported version: Blender 4.5.0.
"""
import bpy
from .. import config
def get_blender_version():
"""Return the current Blender version as ``(major, minor, patch)``."""
return bpy.app.version
def get_version_string():
"""Return the current Blender version as a string (e.g. ``4.5.2``)."""
version = get_blender_version()
return f"{version[0]}.{version[1]}.{version[2]}"
def is_version_at_least(major, minor=0, patch=0):
"""Return whether the current Blender version is at least the given version."""
current = get_blender_version()
target = (major, minor, patch)
if current[0] != target[0]:
return current[0] > target[0]
if current[1] != target[1]:
return current[1] > target[1]
return current[2] >= target[2]
def is_version_less_than(major, minor=0, patch=0):
"""Return whether the current Blender version is below the given version."""
return not is_version_at_least(major, minor, patch)
def is_supported_version():
"""Return whether the running Blender version meets the addon minimum."""
major, minor, patch = config.MIN_BLENDER_VERSION
return is_version_at_least(major, minor, patch)
def is_version_4_5_lts():
"""Return whether the running Blender is in the 4.5 LTS line (BAT v1 path)."""
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 1, 0)
def is_version_5_2_lts():
"""Return whether the running Blender is 5.2+ (primary 5.x LTS target)."""
return is_version_at_least(5, 2, 0)
def uses_bat_v2_blender_version():
"""Return whether this Blender version should load BAT v2 (5.1+)."""
major, minor, patch = config.BAT_V2_MIN_BLENDER_VERSION
return is_version_at_least(major, minor, patch)
def get_version_category():
"""
Return a short version label for the current Blender build.
Returns:
``4.5`` for the 4.5 LTS line, ``5.2+`` for 5.2 LTS and newer, or
``major.minor`` as a fallback.
"""
version = get_blender_version()
major, minor = version[0], version[1]
if major == 4 and minor >= 5:
return "4.5"
if major >= 5:
if minor >= 2:
return "5.2+"
if minor >= 1:
return "5.1+"
return "5.0"
return f"{major}.{minor}"
@@ -0,0 +1,23 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# Copyright (C) 2014-2018 Blender Foundation
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
__version__ = "1.23"
@@ -0,0 +1,3 @@
from blender_asset_tracer import cli
cli.cli_main()
@@ -0,0 +1,882 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import atexit
import collections
import functools
import gzip
import logging
import os
import struct
import pathlib
import shutil
import tempfile
import typing
from . import exceptions, dna, header, magic_compression
from blender_asset_tracer import bpathlib
log = logging.getLogger(__name__)
FILE_BUFFER_SIZE = 1024 * 1024
BFBList = typing.List["BlendFileBlock"]
_cached_bfiles = {} # type: typing.Dict[pathlib.Path, BlendFile]
def open_cached(
path: pathlib.Path, mode="rb", assert_cached: typing.Optional[bool] = None
) -> "BlendFile":
"""Open a blend file, ensuring it is only opened once."""
my_log = log.getChild("open_cached")
bfile_path = bpathlib.make_absolute(path)
if assert_cached is not None:
is_cached = bfile_path in _cached_bfiles
if assert_cached and not is_cached:
raise AssertionError("File %s was not cached" % bfile_path)
elif not assert_cached and is_cached:
raise AssertionError("File %s was cached" % bfile_path)
try:
bfile = _cached_bfiles[bfile_path]
except KeyError:
my_log.debug("Opening non-cached %s", path)
bfile = BlendFile(path, mode=mode)
_cached_bfiles[bfile_path] = bfile
else:
my_log.debug("Returning cached %s", path)
return bfile
@atexit.register
def close_all_cached() -> None:
if not _cached_bfiles:
# Don't even log anything when there is nothing to close
return
log.debug("Closing %d cached blend files", len(_cached_bfiles))
for bfile in list(_cached_bfiles.values()):
bfile.close()
_cached_bfiles.clear()
def _cache(path: pathlib.Path, bfile: "BlendFile"):
"""Add a BlendFile to the cache."""
bfile_path = bpathlib.make_absolute(path)
_cached_bfiles[bfile_path] = bfile
def _uncache(path: pathlib.Path):
"""Remove a BlendFile object from the cache."""
bfile_path = bpathlib.make_absolute(path)
_cached_bfiles.pop(bfile_path, None)
class BlendFile:
"""Representation of a blend file.
:ivar filepath: which file this object represents.
:ivar raw_filepath: which file is accessed; same as filepath for
uncompressed files, but a temporary file for compressed files.
:ivar fileobj: the file object that's being accessed.
"""
log = log.getChild("BlendFile")
strict_pointer_mode = True
"""Raise exceptions.SegmentationFault when dereferencing an unknown pointer.
Set to False to disable this exception, and to return None instead.
"""
def __init__(self, path: pathlib.Path, mode="rb") -> None:
"""Create a BlendFile instance for the blend file at the path.
Opens the file for reading or writing pending on the access. Compressed
blend files are uncompressed to a temporary location before opening.
:param path: the file to open
:param mode: see mode description of pathlib.Path.open()
"""
self.filepath = path
self.raw_filepath = path
self._is_modified = False
self.file_subversion = 0
self.fileobj = self._open_file(path, mode)
self.blocks = [] # type: BFBList
"""BlendFileBlocks of this file, in disk order."""
self.code_index = collections.defaultdict(
list
) # type: typing.Dict[bytes, BFBList]
self.structs = [] # type: typing.List[dna.Struct]
self.sdna_index_from_id = {} # type: typing.Dict[bytes, int]
self.block_from_addr = {} # type: typing.Dict[int, BlendFileBlock]
self.header = header.BlendFileHeader(self.fileobj, self.raw_filepath)
self.block_header_struct, self.block_header_fields = self.header.create_block_header_struct()
self._load_blocks()
def _open_file(self, path: pathlib.Path, mode: str) -> typing.IO[bytes]:
"""Open a blend file, decompressing if necessary.
This does not parse the blend file yet, just makes sure that
self.fileobj is opened and that self.filepath and self.raw_filepath
are set.
:raises exceptions.BlendFileError: when the blend file doesn't have the
correct magic bytes.
"""
decompressed = magic_compression.open(path, mode, FILE_BUFFER_SIZE)
self.filepath = path
self.is_compressed = decompressed.is_compressed
self.raw_filepath = decompressed.path
return decompressed.fileobj
def _load_blocks(self) -> None:
"""Read the blend file to load its DNA structure to memory."""
self.structs.clear()
self.sdna_index_from_id.clear()
while True:
block = BlendFileBlock(self)
if block.code == b"ENDB":
break
if block.code == b"DNA1":
self.decode_structs(block)
elif block.code == b"GLOB":
self.decode_glob(block)
else:
self.fileobj.seek(block.size, os.SEEK_CUR)
self.blocks.append(block)
self.code_index[block.code].append(block)
self.block_from_addr[block.addr_old] = block
if not self.structs:
raise exceptions.NoDNA1Block(
"No DNA1 block in file, not a valid .blend file", self.filepath
)
def __repr__(self) -> str:
clsname = self.__class__.__qualname__
if self.filepath == self.raw_filepath:
return "<%s %r>" % (clsname, self.filepath)
return "<%s %r reading from %r>" % (clsname, self.filepath, self.raw_filepath)
def __enter__(self) -> "BlendFile":
return self
def __exit__(self, exctype, excvalue, traceback) -> None:
self.close()
def copy_and_rebind(self, path: pathlib.Path, mode="rb") -> None:
"""Change which file is bound to this BlendFile.
This allows cloning a previously opened file, and rebinding it to reuse
the already-loaded DNA structs and data blocks.
"""
log.debug("Rebinding %r to %s", self, path)
self.close()
_uncache(self.filepath)
self.log.debug("Copying %s to %s", self.filepath, path)
# TODO(Sybren): remove str() calls when targeting Python 3.6+
# dst needs to be a file and not a directory
shutil.copyfile(str(self.filepath), str(path))
self.fileobj = self._open_file(path, mode=mode)
_cache(path, self)
@property
def is_modified(self) -> bool:
return self._is_modified
def mark_modified(self) -> None:
"""Recompess the file when it is closed."""
self.log.debug("Marking %s as modified", self.raw_filepath)
self._is_modified = True
def find_blocks_from_code(self, code: bytes) -> typing.List["BlendFileBlock"]:
assert isinstance(code, bytes)
return self.code_index[code]
def close(self) -> None:
"""Close the blend file.
Recompresses the blend file if it was compressed and changed.
"""
if not self.fileobj:
return
if self._is_modified:
log.debug("closing blend file %s after it was modified", self.raw_filepath)
if self._is_modified and self.is_compressed:
log.debug("GZip-recompressing modified blend file %s", self.raw_filepath)
self.fileobj.seek(os.SEEK_SET, 0)
with gzip.open(str(self.filepath), "wb") as gzfile:
while True:
data = self.fileobj.read(FILE_BUFFER_SIZE)
if not data:
break
gzfile.write(data)
log.debug("GZip-compression to %s finished", self.filepath)
# Close the file object after recompressing, as it may be a temporary
# file that'll disappear as soon as we close it.
self.fileobj.close()
self._is_modified = False
try:
del _cached_bfiles[self.filepath]
except KeyError:
pass
def ensure_subtype_smaller(self, sdna_index_curr, sdna_index_next) -> None:
# never refine to a smaller type
curr_struct = self.structs[sdna_index_curr]
next_struct = self.structs[sdna_index_next]
if curr_struct.size > next_struct.size:
raise RuntimeError(
"Can't refine to smaller type (%s -> %s)"
% (
curr_struct.dna_type_id.decode("utf-8"),
next_struct.dna_type_id.decode("utf-8"),
)
)
def decode_structs(self, block: "BlendFileBlock"):
"""
DNACatalog is a catalog of all information in the DNA1 file-block
"""
self.log.debug("building DNA catalog")
# Get some names in the local scope for faster access.
structs = self.structs
sdna_index_from_id = self.sdna_index_from_id
endian = self.header.endian
shortstruct = endian.USHORT
shortstruct2 = endian.USHORT2
intstruct = endian.UINT
assert intstruct.size == 4
def pad_up_4(off: int) -> int:
return (off + 3) & ~3
data = self.fileobj.read(block.size)
types = []
typenames = []
offset = 8
names_len = intstruct.unpack_from(data, offset)[0]
offset += 4
self.log.debug("building #%d names" % names_len)
for _ in range(names_len):
typename = endian.read_data0_offset(data, offset)
offset = offset + len(typename) + 1
typenames.append(dna.Name(typename))
offset = pad_up_4(offset)
offset += 4
types_len = intstruct.unpack_from(data, offset)[0]
offset += 4
self.log.debug("building #%d types" % types_len)
for _ in range(types_len):
dna_type_id = endian.read_data0_offset(data, offset)
types.append(dna.Struct(dna_type_id))
offset += len(dna_type_id) + 1
offset = pad_up_4(offset)
offset += 4
self.log.debug("building #%d type-lengths" % types_len)
for i in range(types_len):
typelen = shortstruct.unpack_from(data, offset)[0]
offset = offset + 2
types[i].size = typelen
offset = pad_up_4(offset)
offset += 4
structs_len = intstruct.unpack_from(data, offset)[0]
offset += 4
log.debug("building #%d structures" % structs_len)
pointer_size = self.header.pointer_size
for sdna_index in range(structs_len):
struct_type_index, fields_len = shortstruct2.unpack_from(data, offset)
offset += 4
dna_struct = types[struct_type_index]
sdna_index_from_id[dna_struct.dna_type_id] = sdna_index
structs.append(dna_struct)
dna_offset = 0
for field_index in range(fields_len):
field_type_index, field_name_index = shortstruct2.unpack_from(
data, offset
)
offset += 4
dna_type = types[field_type_index]
dna_name = typenames[field_name_index]
if dna_name.is_pointer or dna_name.is_method_pointer:
dna_size = pointer_size * dna_name.array_size
else:
dna_size = dna_type.size * dna_name.array_size
field = dna.Field(dna_type, dna_name, dna_size, dna_offset)
dna_struct.append_field(field)
dna_offset += dna_size
def decode_glob(self, block: "BlendFileBlock") -> None:
"""Partially decode the GLOB block to get the file sub-version."""
# Before this, the subversion didn't exist in 'FileGlobal'.
if self.header.version <= 242:
self.file_subversion = 0
return
# GLOB can appear in the file before DNA1, and so we cannot use DNA to
# parse the fields.
# The subversion is always the `short` at offset 4.
# block_data = io.BytesIO(block.raw_data())
endian = self.header.endian
self.fileobj.seek(4, os.SEEK_CUR) # Skip the next 4 bytes.
self.file_subversion = endian.read_short(self.fileobj)
# Skip to the next block.
self.fileobj.seek(block.file_offset + block.size, os.SEEK_SET)
def abspath(self, relpath: bpathlib.BlendPath) -> bpathlib.BlendPath:
"""Construct an absolute path from a blendfile-relative path."""
if relpath.is_absolute():
return relpath
bfile_dir = self.filepath.absolute().parent
root = bpathlib.BlendPath(bfile_dir)
abspath = relpath.absolute(root)
my_log = self.log.getChild("abspath")
my_log.debug(
"Resolved %s relative to %s to %s", relpath, self.filepath, abspath
)
return abspath
def dereference_pointer(self, address: int) -> typing.Optional["BlendFileBlock"]:
"""Return the pointed-to block, or raise SegmentationFault.
When BlendFile.strict_pointer_mode is False, the exception will not be
thrown, but None will be returned.
"""
try:
return self.block_from_addr[address]
except KeyError:
if self.strict_pointer_mode:
raise exceptions.SegmentationFault(
"address does not exist", address
) from None
log.warning(
"Silenced SegmentationFault caused by dereferencing invalid pointer"
" (0x%x) because strict_pointer_mode is off.",
address,
)
return None
def struct(self, name: bytes) -> dna.Struct:
index = self.sdna_index_from_id[name]
return self.structs[index]
@functools.total_ordering
class BlendFileBlock:
"""
Instance of a struct.
"""
# Due to the huge number of BlendFileBlock objects created for packing a
# production-size blend file, using slots here actually makes the
# dependency tracer significantly (p<0.001) faster. In my test case the
# speed improvement was 16% for a 'bam list' command.
__slots__ = (
"bfile",
"code",
"size",
"addr_old",
"sdna_index",
"count",
"file_offset",
"endian",
"_id_name",
)
log = log.getChild("BlendFileBlock")
old_structure = struct.Struct(b"4sI")
"""old blend files ENDB block structure"""
# Explicitly annotate to avoid `Any` from `.unpack()`.
size: int
addr_old: int
sdna_index: int
count: int
def __init__(self, bfile: BlendFile) -> None:
self.bfile = bfile
# Defaults; actual values are set by interpreting the block header.
self.code = b""
self.size = 0
self.addr_old = 0
self.sdna_index = 0
self.count = 0
self.file_offset = 0
"""Offset in bytes from start of file to beginning of the data block.
Points to the data after the block header.
"""
self.endian = bfile.header.endian
self._id_name = ... # type: typing.Union[None, ellipsis, bytes]
header_struct = bfile.block_header_struct
data = bfile.fileobj.read(header_struct.size)
if len(data) != header_struct.size:
self.log.warning(
"Blend file %s seems to be truncated, "
"expected %d bytes but could read only %d",
bfile.filepath,
header_struct.size,
len(data),
)
self.code = b"ENDB"
return
blockheader = bfile.block_header_fields(*header_struct.unpack(data))
self.code = self.endian.read_data0(blockheader.code)
if self.code != b"ENDB":
self.size = blockheader.len
self.addr_old = blockheader.old
self.sdna_index = blockheader.SDNAnr
self.count = blockheader.nr
self.file_offset = bfile.fileobj.tell()
def __repr__(self) -> str:
return "<%s.%s (%s), size=%d at %s>" % (
self.__class__.__name__,
self.dna_type_name,
self.code.decode(),
self.size,
hex(self.addr_old),
)
def __hash__(self) -> int:
return hash((self.code, self.addr_old, self.bfile.filepath))
def __eq__(self, other: object) -> bool:
if not isinstance(other, BlendFileBlock):
return False
return (
self.code == other.code
and self.addr_old == other.addr_old
and self.bfile.filepath == other.bfile.filepath
)
def __lt__(self, other: "BlendFileBlock") -> bool:
"""Order blocks by file path and offset within that file."""
if not isinstance(other, BlendFileBlock):
raise NotImplemented()
my_key = self.bfile.filepath, self.file_offset
other_key = other.bfile.filepath, other.file_offset
return my_key < other_key
def __bool__(self) -> bool:
"""Data blocks are always True."""
return True
@property
def dna_type(self) -> dna.Struct:
return self.bfile.structs[self.sdna_index]
@property
def dna_type_id(self) -> bytes:
return self.dna_type.dna_type_id
@property
def dna_type_name(self) -> str:
return self.dna_type_id.decode("ascii")
@property
def id_name(self) -> typing.Optional[bytes]:
"""Same as block[b'id', b'name']; None if there is no such field.
Evaluated only once, so safe to call multiple times without producing
excessive disk I/O.
"""
if self._id_name is ...:
try:
self._id_name = self[b"id", b"name"]
except KeyError:
self._id_name = None
# TODO(Sybren): figure out how to let mypy know self._id_name cannot
# be ellipsis at this point.
return self._id_name # type: ignore
def refine_type_from_index(self, sdna_index: int):
"""Change the DNA Struct associated with this block.
Use to make a block type more specific, for example when you have a
modifier but need to access it as SubSurfModifier.
:param sdna_index: the SDNA index of the DNA type.
"""
assert type(sdna_index) is int
sdna_index_curr = self.sdna_index
self.bfile.ensure_subtype_smaller(sdna_index_curr, sdna_index)
self.sdna_index = sdna_index
def refine_type(self, dna_type_id: bytes):
"""Change the DNA Struct associated with this block.
Use to make a block type more specific, for example when you have a
modifier but need to access it as SubSurfModifier.
:param dna_type_id: the name of the DNA type.
"""
assert isinstance(dna_type_id, bytes)
sdna_index = self.bfile.sdna_index_from_id[dna_type_id]
self.refine_type_from_index(sdna_index)
def abs_offset(self, path: dna.FieldPath) -> typing.Tuple[int, int]:
"""Compute the absolute file offset of the field.
:returns: tuple (offset in bytes, length of array in items)
"""
field, field_offset = self.dna_type.field_from_path(
self.bfile.header.pointer_size, path
)
return self.file_offset + field_offset, field.name.array_size
def get(
self,
path: dna.FieldPath,
default=...,
null_terminated=True,
as_str=False,
return_field=False,
array_index=0,
) -> typing.Any:
"""Read a property and return the value.
:param path: name of the property (like `b'loc'`), tuple of names
to read a sub-property (like `(b'id', b'name')`), or tuple of
name and index to read one item from an array (like
`(b'loc', 2)`)
:param default: The value to return when the field does not exist.
Use Ellipsis (the default value) to raise a KeyError instead.
:param null_terminated: Only used when reading bytes or strings. When
True, stops reading at the first zero byte; be careful with this
when reading binary data.
:param as_str: When True, automatically decode bytes to string
(assumes UTF-8 encoding).
:param return_field: When True, returns tuple (dna.Field, value).
Otherwise just returns the value.
:param array_index: If the property is an array, this determines the
index of the returned item from that array. Also see
`blendfile.iterators.dynamic_array()` for iterating such arrays.
"""
file_offset = self.file_offset
if array_index:
if not (0 <= array_index < self.count):
raise IndexError(
"Invalid 'array_index' for file-block. "
f"Expected int value in range 0-{self.count - 1}, got {array_index}."
)
file_offset += array_index * self.dna_type.size
self.bfile.fileobj.seek(file_offset, os.SEEK_SET)
dna_struct = self.bfile.structs[self.sdna_index]
field, value = dna_struct.field_get(
self.bfile.header,
self.bfile.fileobj,
path,
default=default,
null_terminated=null_terminated,
as_str=as_str,
)
if return_field:
return value, field
return value
def raw_data(self) -> bytes:
"""Read low-level raw data of this datablock."""
self.bfile.fileobj.seek(self.file_offset, os.SEEK_SET)
return self.bfile.fileobj.read(self.size)
def as_bytes_string(self) -> bytes:
"""Interpret the bytes of this datablock as null-terminated string of raw bytes."""
the_bytes = self.raw_data()
try:
first_null = the_bytes.index(0)
except ValueError:
pass
else:
the_bytes = the_bytes[:first_null]
return the_bytes
def as_string(self) -> str:
"""Interpret the bytes of this datablock as null-terminated utf8 string."""
the_bytes = self.as_bytes_string()
return the_bytes.decode()
def get_recursive_iter(
self,
path: dna.FieldPath,
path_root: dna.FieldPath = b"",
default=...,
null_terminated=True,
as_str=True,
) -> typing.Iterator[typing.Tuple[dna.FieldPath, typing.Any]]:
"""Generator, yields (path, property value) tuples.
If a property cannot be decoded, a string representing its DNA type
name is used as its value instead, between pointy brackets.
"""
path_full = path # type: dna.FieldPath
if path_root:
if isinstance(path_root, bytes):
path_root = (path_root,)
if isinstance(path, bytes):
path = (path,)
path_full = tuple(path_root) + tuple(path)
try:
# Try accessing as simple property
yield (path_full, self.get(path_full, default, null_terminated, as_str))
except exceptions.NoReaderImplemented as ex:
# This was not a simple property, so recurse into its DNA Struct.
dna_type = ex.dna_type
struct_index = self.bfile.sdna_index_from_id.get(dna_type.dna_type_id)
if struct_index is None:
yield (path_full, "<%s>" % dna_type.dna_type_id.decode("ascii"))
return
# Recurse through the fields.
for f in dna_type.fields:
yield from self.get_recursive_iter(
f.name.name_only,
path_full,
default=default,
null_terminated=null_terminated,
as_str=as_str,
)
def hash(self) -> int:
"""Generate a pointer-independent hash for the block.
Generates a 'hash' that can be used instead of addr_old as block id,
which should be 'stable' across .blend file load & save (i.e. it does
not changes due to pointer addresses variations).
"""
# TODO This implementation is most likely far from optimal... and CRC32
# is not kown as the best hashing algo either. But for now does the job!
import zlib
dna_type = self.dna_type
pointer_size = self.bfile.header.pointer_size
hsh = 1
for path, value in self.items_recursive():
field, _ = dna_type.field_from_path(pointer_size, path)
if field.name.is_pointer:
continue
hsh = zlib.adler32(str(value).encode(), hsh)
return hsh
def set(self, path: dna.FieldPath, value):
dna_struct = self.bfile.structs[self.sdna_index]
self.bfile.mark_modified()
self.bfile.fileobj.seek(self.file_offset, os.SEEK_SET)
return dna_struct.field_set(self.bfile.header, self.bfile.fileobj, path, value)
def get_pointer(
self,
path: dna.FieldPath,
default=...,
) -> typing.Union[None, "BlendFileBlock"]:
"""Same as get() but dereferences a pointer.
:raises exceptions.SegmentationFault: when there is no datablock with
the pointed-to address.
"""
result = self.get(path, default=default)
# If it's not an integer, we have no pointer to follow and this may
# actually be a non-pointer property.
if type(result) is not int:
return result
if result == 0:
return None
try:
return self.bfile.dereference_pointer(result)
except exceptions.SegmentationFault as ex:
ex.field_path = path
raise
def iter_array_of_pointers(
self, path: dna.FieldPath, array_size: int
) -> typing.Iterator["BlendFileBlock"]:
"""Dereference pointers from an array-of-pointers field.
Use this function when you have a field like Mesh materials:
`Mat **mat`
:param path: The array-of-pointers field.
:param array_size: Number of items in the array. If None, the
on-disk size of the DNA field is divided by the pointer size to
obtain the array size.
"""
if array_size == 0:
return
array = self.get_pointer(path)
assert array is not None
assert array.code == b"DATA", (
"Array data block should have code DATA, is %r" % array.code.decode()
)
file_offset = array.file_offset
endian = self.bfile.header.endian
ps = self.bfile.header.pointer_size
for i in range(array_size):
fileobj = self.bfile.fileobj
fileobj.seek(file_offset + ps * i, os.SEEK_SET)
address = endian.read_pointer(fileobj, ps)
if address == 0:
continue
dereferenced = self.bfile.dereference_pointer(address)
if dereferenced is None:
# This can happen when strict pointer mode is disabled.
continue
yield dereferenced
def iter_fixed_array_of_pointers(
self, path: dna.FieldPath
) -> typing.Iterator["BlendFileBlock"]:
"""Yield blocks from a fixed-size array field.
Use this function when you have a field like lamp textures:
`MTex *mtex[18]`
The size of the array is determined automatically by the size in bytes
of the field divided by the pointer size of the blend file.
:param path: The array field.
:raises KeyError: if the path does not exist.
"""
dna_struct = self.dna_type
ps = self.bfile.header.pointer_size
endian = self.bfile.header.endian
fileobj = self.bfile.fileobj
field, offset_in_struct = dna_struct.field_from_path(ps, path)
array_size = field.size // ps
for i in range(array_size):
fileobj.seek(self.file_offset + offset_in_struct + ps * i, os.SEEK_SET)
address = endian.read_pointer(fileobj, ps)
if not address:
# Fixed-size arrays contain 0-pointers.
continue
dereferenced = self.bfile.dereference_pointer(address)
if dereferenced is None:
# This can happen when strict pointer mode is disabled.
continue
yield dereferenced
def __getitem__(self, path: dna.FieldPath):
return self.get(path)
def __setitem__(self, item: dna.FieldPath, value) -> None:
self.set(item, value)
def has_field(self, name: bytes) -> bool:
dna_struct = self.bfile.structs[self.sdna_index]
return dna_struct.has_field(name)
def keys(self) -> typing.Iterator[bytes]:
"""Generator, yields all field names of this block."""
return (f.name.name_only for f in self.dna_type.fields)
def values(self) -> typing.Iterable[typing.Any]:
for k in self.keys():
try:
yield self[k]
except exceptions.NoReaderImplemented as ex:
yield "<%s>" % ex.dna_type.dna_type_id.decode("ascii")
def items(self) -> typing.Iterable[typing.Tuple[bytes, typing.Any]]:
for k in self.keys():
try:
yield (k, self[k])
except exceptions.NoReaderImplemented as ex:
yield (k, "<%s>" % ex.dna_type.dna_type_id.decode("ascii"))
def items_recursive(
self,
) -> typing.Iterator[typing.Tuple[dna.FieldPath, typing.Any]]:
"""Generator, yields (property path, property value) recursively for all properties."""
for k in self.keys():
yield from self.get_recursive_iter(k, as_str=False)
def set_strict_pointer_mode(strict_pointers: bool) -> None:
"""Control behaviour when a pointer to unknown memory is dereferenced.
Strict pointer mode raise exceptions.SegmentationFault when dereferencing an
unknown pointer. This is the default.
Set to False to disable this exception, and to return None instead, i.e. to
ignore such pointers. Note that this can cause None to be returned from a
non-nil pointer.
"""
BlendFile.strict_pointer_mode = strict_pointers
@@ -0,0 +1,354 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import logging
import os
import typing
from typing import Optional
from . import header, exceptions
# Either a simple path b'propname', or a tuple (b'parentprop', b'actualprop', arrayindex)
FieldPath = typing.Union[bytes, typing.Iterable[typing.Union[bytes, int]]]
log = logging.getLogger(__name__)
class Name:
"""dna.Name is a C-type name stored in the DNA as bytes."""
def __init__(self, name_full: bytes) -> None:
self.name_full = name_full
self.name_only = self.calc_name_only()
self.is_pointer = self.calc_is_pointer()
self.is_method_pointer = self.calc_is_method_pointer()
self.array_size = self.calc_array_size()
def __repr__(self):
return "%s(%r)" % (type(self).__qualname__, self.name_full)
def as_reference(self, parent) -> bytes:
if not parent:
return self.name_only
return parent + b"." + self.name_only
def calc_name_only(self) -> bytes:
result = self.name_full.strip(b"*()")
index = result.find(b"[")
if index == -1:
return result
return result[:index]
def calc_is_pointer(self) -> bool:
return b"*" in self.name_full
def calc_is_method_pointer(self):
return b"(*" in self.name_full
def calc_array_size(self):
result = 1
partial_name = self.name_full
while True:
idx_start = partial_name.find(b"[")
if idx_start < 0:
break
idx_stop = partial_name.find(b"]")
result *= int(partial_name[idx_start + 1 : idx_stop])
partial_name = partial_name[idx_stop + 1 :]
return result
class Field:
"""dna.Field is a coupled dna.Struct and dna.Name.
It also contains the file offset in bytes.
:ivar name: the name of the field.
:ivar dna_type: the type of the field.
:ivar size: size of the field on disk, in bytes.
:ivar offset: cached offset of the field, in bytes.
"""
def __init__(self, dna_type: "Struct", name: Name, size: int, offset: int) -> None:
self.dna_type = dna_type
self.name = name
self.size = size
self.offset = offset
def __repr__(self):
return "<%r %r (%s)>" % (type(self).__qualname__, self.name, self.dna_type)
class Struct:
"""dna.Struct is a C-type structure stored in the DNA."""
log = log.getChild("Struct")
def __init__(self, dna_type_id: bytes, size: Optional[int] = None) -> None:
"""
:param dna_type_id: name of the struct in C, like b'AlembicObjectPath'.
:param size: only for unit tests; typically set after construction by
BlendFile.decode_structs(). If not set, it is calculated on the fly
when struct.size is evaluated, based on the available fields.
"""
self.dna_type_id = dna_type_id
self._size = size
self._fields = [] # type: typing.List[Field]
self._fields_by_name = {} # type: typing.Dict[bytes, Field]
def __repr__(self):
return "%s(%r)" % (type(self).__qualname__, self.dna_type_id)
@property
def size(self) -> int:
if self._size is None:
if not self._fields:
raise ValueError("Unable to determine size of fieldless %r" % self)
last_field = max(self._fields, key=lambda f: f.offset)
self._size = last_field.offset + last_field.size
return self._size
@size.setter
def size(self, new_size: int):
self._size = new_size
def append_field(self, field: Field):
self._fields.append(field)
self._fields_by_name[field.name.name_only] = field
@property
def fields(self) -> typing.List[Field]:
"""Return the fields of this Struct.
Do not modify the returned list; use append_field() instead.
"""
return self._fields
def has_field(self, field_name: bytes) -> bool:
return field_name in self._fields_by_name
def field_from_path(
self, pointer_size: int, path: FieldPath
) -> typing.Tuple[Field, int]:
"""
Support lookups as bytes or a tuple of bytes and optional index.
C style 'id.name' --> (b'id', b'name')
C style 'array[4]' --> (b'array', 4)
:returns: the field itself, and its offset taking into account the
optional index. The offset is relative to the start of the struct,
i.e. relative to the BlendFileBlock containing the data.
:raises KeyError: if the field does not exist.
"""
if isinstance(path, tuple):
name = path[0]
if len(path) >= 2 and not isinstance(path[1], bytes):
name_tail = path[2:]
index = path[1]
assert isinstance(index, int)
else:
name_tail = path[1:]
index = 0
else:
name = path
name_tail = ()
index = 0
if not isinstance(name, bytes):
raise TypeError("name should be bytes, but is %r" % type(name))
field = self._fields_by_name.get(name)
if not field:
raise KeyError(
"%r has no field %r, only %r"
% (self, name, sorted(self._fields_by_name.keys()))
)
offset = field.offset
if index:
if field.name.is_pointer:
index_offset = pointer_size * index
else:
index_offset = field.dna_type.size * index
if index_offset >= field.size:
raise OverflowError(
"path %r is out of bounds of its DNA type %s"
% (path, field.dna_type)
)
offset += index_offset
if name_tail:
subval, suboff = field.dna_type.field_from_path(pointer_size, name_tail)
return subval, suboff + offset
return field, offset
def field_get(
self,
file_header: header.BlendFileHeader,
fileobj: typing.IO[bytes],
path: FieldPath,
default=...,
null_terminated=True,
as_str=True,
) -> typing.Tuple[typing.Optional[Field], typing.Any]:
"""Read the value of the field from the blend file.
Assumes the file pointer of `fileobj` is seek()ed to the start of the
struct on disk (e.g. the start of the BlendFileBlock containing the
data).
:param file_header:
:param fileobj:
:param path:
:param default: The value to return when the field does not exist.
Use Ellipsis (the default value) to raise a KeyError instead.
:param null_terminated: Only used when reading bytes or strings. When
True, stops reading at the first zero byte. Be careful with this
default when reading binary data.
:param as_str: When True, automatically decode bytes to string
(assumes UTF-8 encoding).
:returns: The field instance and the value. If a default value was passed
and the field was not found, (None, default) is returned.
"""
try:
field, offset = self.field_from_path(file_header.pointer_size, path)
except KeyError:
if default is ...:
raise
return None, default
fileobj.seek(offset, os.SEEK_CUR)
dna_type = field.dna_type
dna_name = field.name
endian = file_header.endian
# Some special cases (pointers, strings/bytes)
if dna_name.is_pointer:
return field, endian.read_pointer(fileobj, file_header.pointer_size)
if dna_type.dna_type_id == b"char":
return field, self._field_get_char(
file_header, fileobj, field, null_terminated, as_str
)
simple_readers = {
b"uchar": endian.read_char,
b"int": endian.read_int,
b"short": endian.read_short,
b"uint64_t": endian.read_ulong,
b"float": endian.read_float,
b"int8_t": endian.read_int8,
}
try:
simple_reader = simple_readers[dna_type.dna_type_id]
except KeyError:
raise exceptions.NoReaderImplemented(
"%r exists but not simple type (%r), can't resolve field %r"
% (path, dna_type.dna_type_id.decode(), dna_name.name_only),
dna_name,
dna_type,
) from None
if isinstance(path, tuple) and len(path) > 1 and isinstance(path[-1], int):
# The caller wants to get a single item from an array. The offset we seeked to already
# points to this item. In this case we do not want to look at dna_name.array_size,
# because we want a single item from that array.
return field, simple_reader(fileobj)
if dna_name.array_size > 1:
return field, [simple_reader(fileobj) for _ in range(dna_name.array_size)]
return field, simple_reader(fileobj)
def _field_get_char(
self,
file_header: header.BlendFileHeader,
fileobj: typing.IO[bytes],
field: "Field",
null_terminated: typing.Optional[bool],
as_str: bool,
) -> typing.Any:
dna_name = field.name
endian = file_header.endian
if field.size == 1:
# Single char, assume it's bitflag or int value, and not a string/bytes data...
return endian.read_char(fileobj)
if null_terminated or (null_terminated is None and as_str):
data = endian.read_bytes0(fileobj, dna_name.array_size)
else:
data = fileobj.read(dna_name.array_size)
if as_str:
return data.decode("utf8")
return data
def field_set(
self,
file_header: header.BlendFileHeader,
fileobj: typing.IO[bytes],
path: FieldPath,
value: typing.Any,
):
"""Write a value to the blend file.
Assumes the file pointer of `fileobj` is seek()ed to the start of the
struct on disk (e.g. the start of the BlendFileBlock containing the
data).
"""
field, offset = self.field_from_path(file_header.pointer_size, path)
dna_type = field.dna_type
dna_name = field.name
endian = file_header.endian
if dna_type.dna_type_id not in endian.accepted_types():
msg = "Setting type %r is not supported for %s.%s" % (
dna_type,
self.dna_type_id.decode(),
dna_name.name_full.decode(),
)
raise exceptions.NoWriterImplemented(msg, dna_name, dna_type)
fileobj.seek(offset, os.SEEK_CUR)
if self.log.isEnabledFor(logging.DEBUG):
filepos = fileobj.tell()
if isinstance(value, (int, float)):
thing = dna_type.dna_type_id.decode()
else:
thing = "string" if isinstance(value, str) else "bytes"
self.log.debug(
"writing %s %r at file offset %d / %x", thing, value, filepos, filepos
)
if isinstance(value, (int, float)):
return endian.accepted_types()[dna_type.dna_type_id](fileobj, value)
elif isinstance(value, str):
return endian.write_string(fileobj, value, dna_name.array_size)
else:
return endian.write_bytes(fileobj, value, dna_name.array_size)
@@ -0,0 +1,256 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Read-write utility functions."""
import struct
import typing
class EndianIO:
# TODO(Sybren): note as UCHAR: struct.Struct = None and move actual structs to LittleEndianTypes
UCHAR = struct.Struct(b"<B")
SINT8 = struct.Struct(b"<b")
USHORT = struct.Struct(b"<H")
USHORT2 = struct.Struct(b"<HH") # two shorts in a row
SSHORT = struct.Struct(b"<h")
UINT = struct.Struct(b"<I")
SINT = struct.Struct(b"<i")
FLOAT = struct.Struct(b"<f")
ULONG = struct.Struct(b"<Q")
@classmethod
def _read(cls, fileobj: typing.IO[bytes], typestruct: struct.Struct):
data = fileobj.read(typestruct.size)
try:
return typestruct.unpack(data)[0]
except struct.error as ex:
raise struct.error("%s (read %d bytes)" % (ex, len(data))) from None
@classmethod
def _write(
cls, fileobj: typing.IO[bytes], typestruct: struct.Struct, value: typing.Any
):
try:
data = typestruct.pack(value)
except struct.error as ex:
raise struct.error(f"{ex} (write '{value}')")
return fileobj.write(data)
@classmethod
def read_char(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.UCHAR)
@classmethod
def write_char(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.UCHAR, value)
@classmethod
def read_int8(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.SINT8)
@classmethod
def write_int8(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.SINT8, value)
@classmethod
def read_ushort(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.USHORT)
@classmethod
def write_ushort(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.USHORT, value)
@classmethod
def read_short(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.SSHORT)
@classmethod
def write_short(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.SSHORT, value)
@classmethod
def read_uint(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.UINT)
@classmethod
def write_uint(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.UINT, value)
@classmethod
def read_int(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.SINT)
@classmethod
def write_int(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.SINT, value)
@classmethod
def read_float(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.FLOAT)
@classmethod
def write_float(cls, fileobj: typing.IO[bytes], value: float):
return cls._write(fileobj, cls.FLOAT, value)
@classmethod
def read_ulong(cls, fileobj: typing.IO[bytes]):
return cls._read(fileobj, cls.ULONG)
@classmethod
def write_ulong(cls, fileobj: typing.IO[bytes], value: int):
return cls._write(fileobj, cls.ULONG, value)
@classmethod
def read_pointer(cls, fileobj: typing.IO[bytes], pointer_size: int):
"""Read a pointer from a file."""
if pointer_size == 4:
return cls.read_uint(fileobj)
if pointer_size == 8:
return cls.read_ulong(fileobj)
raise ValueError("unsupported pointer size %d" % pointer_size)
@classmethod
def parse_pointer(cls, pointer_data: bytes):
"""Parse bytes as a pointer value."""
pointer_size = len(pointer_data)
try:
typestruct = {
4: cls.UINT,
8: cls.ULONG,
}[pointer_size]
except KeyError:
raise ValueError("unsupported pointer size %d" % pointer_size)
return typestruct.unpack(pointer_data)[0]
@classmethod
def write_pointer(cls, fileobj: typing.IO[bytes], pointer_size: int, value: int):
"""Write a pointer to a file."""
if pointer_size == 4:
return cls.write_uint(fileobj, value)
if pointer_size == 8:
return cls.write_ulong(fileobj, value)
raise ValueError("unsupported pointer size %d" % pointer_size)
@classmethod
def write_string(
cls, fileobj: typing.IO[bytes], astring: str, fieldlen: int
) -> int:
"""Write a (truncated) string as UTF-8.
The string will always be written 0-terminated.
:param fileobj: the file to write to.
:param astring: the string to write.
:param fieldlen: the field length in bytes.
:returns: the number of bytes written.
"""
assert isinstance(astring, str)
encoded = astring.encode("utf-8")
# Take into account we also need space for a trailing 0-byte.
maxlen = fieldlen - 1
if len(encoded) >= maxlen:
encoded = encoded[:maxlen]
# Keep stripping off the last byte until the string
# is valid UTF-8 again.
while True:
try:
encoded.decode("utf8")
except UnicodeDecodeError:
encoded = encoded[:-1]
else:
break
return fileobj.write(encoded + b"\0")
@classmethod
def write_bytes(cls, fileobj: typing.IO[bytes], data: bytes, fieldlen: int) -> int:
"""Write (truncated) bytes.
When len(data) < fieldlen, a terminating b'\0' will be appended.
:returns: the number of bytes written.
"""
assert isinstance(data, (bytes, bytearray))
if len(data) >= fieldlen:
to_write = data[0:fieldlen]
else:
to_write = data + b"\0"
return fileobj.write(to_write)
@classmethod
def read_bytes0(cls, fileobj: typing.IO[bytes], length: int) -> bytes:
data = fileobj.read(length)
return cls.read_data0(data)
@classmethod
def read_data0_offset(cls, data: bytes, offset: int) -> bytes:
add = data.find(b"\0", offset) - offset
return data[offset : offset + add]
@classmethod
def read_data0(cls, data: bytes) -> bytes:
add = data.find(b"\0")
if add < 0:
return data
return data[:add]
@classmethod
def accepted_types(cls):
"""Return a mapping from type name to writer function.
This is mostly to make it easier to get the correct number write
function, given that Python's `int` and `float` can map to a whole range
of C types.
"""
return {
b"char": cls.write_char,
b"int8": cls.write_int8,
b"ushort": cls.write_ushort,
b"short": cls.write_short,
b"uint": cls.write_uint,
b"int": cls.write_int,
b"ulong": cls.write_ulong,
b"float": cls.write_float,
}
class LittleEndianTypes(EndianIO):
pass
class BigEndianTypes(LittleEndianTypes):
UCHAR = struct.Struct(b">B")
SINT8 = struct.Struct(b">b")
USHORT = struct.Struct(b">H")
USHORT2 = struct.Struct(b">HH") # two shorts in a row
SSHORT = struct.Struct(b">h")
UINT = struct.Struct(b">I")
SINT = struct.Struct(b">i")
FLOAT = struct.Struct(b">f")
ULONG = struct.Struct(b">Q")
@@ -0,0 +1,76 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import pathlib
class BlendFileError(Exception):
"""Raised when there was an error reading/parsing a blend file."""
def __init__(self, message: str, filepath: pathlib.Path) -> None:
super().__init__(message)
self.filepath = filepath
def __str__(self):
return "%s: %s" % (super().__str__(), self.filepath)
class NoDNA1Block(BlendFileError):
"""Raised when the blend file contains no DNA1 block."""
class NoReaderImplemented(NotImplementedError):
"""Raised when reading a property of a non-implemented type.
This indicates that the property should be read using some dna.Struct.
:type dna_name: blender_asset_tracer.blendfile.dna.Name
:type dna_type: blender_asset_tracer.blendfile.dna.Struct
"""
def __init__(self, message: str, dna_name, dna_type) -> None:
super().__init__(message)
self.dna_name = dna_name
self.dna_type = dna_type
class NoWriterImplemented(NotImplementedError):
"""Raised when writing a property of a non-implemented type.
:type dna_name: blender_asset_tracer.blendfile.dna.Name
:type dna_type: blender_asset_tracer.blendfile.dna.Struct
"""
def __init__(self, message: str, dna_name, dna_type) -> None:
super().__init__(message)
self.dna_name = dna_name
self.dna_type = dna_type
class SegmentationFault(Exception):
"""Raised when a pointer to a non-existant datablock was dereferenced."""
def __init__(self, message: str, address: int, field_path=None) -> None:
super().__init__(message)
self.address = address
self.field_path = field_path
@@ -0,0 +1,182 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
from dataclasses import dataclass
import logging
import os
import pathlib
import struct
import typing
from . import dna_io, exceptions
log = logging.getLogger(__name__)
@dataclass
class BHead4:
code: bytes
len: int
old: int
SDNAnr: int
nr: int
@dataclass
class SmallBHead8:
code: bytes
len: int
old: int
SDNAnr: int
nr: int
@dataclass
class LargeBHead8:
code: bytes
SDNAnr: int
old: int
len: int
nr: int
class BlendFileHeader:
"""
BlendFileHeader represents the first 12-17 bytes of a blend file.
It contains information about the hardware architecture, which is relevant
to the structure of the rest of the file.
"""
magic: bytes
file_format_version: int
pointer_size: int
is_little_endian: bool
endian: typing.Type[dna_io.EndianIO]
endian_str: bytes
def __init__(self, fileobj: typing.IO[bytes], path: pathlib.Path) -> None:
log.debug("reading blend-file-header %s", path)
fileobj.seek(0, os.SEEK_SET)
bytes_0_6 = fileobj.read(7)
if bytes_0_6 != b'BLENDER':
raise exceptions.BlendFileError("invalid first bytes %r" % bytes_0_6, path)
self.magic = bytes_0_6
byte_7 = fileobj.read(1)
is_legacy_header = byte_7 in (b'_', b'-')
if is_legacy_header:
self.file_format_version = 0
if byte_7 == b'_':
self.pointer_size = 4
elif byte_7 == b'-':
self.pointer_size = 8
else:
raise exceptions.BlendFileError("invalid pointer size %r" % byte_7, path)
byte_8 = fileobj.read(1)
if byte_8 == b'v':
self.is_little_endian = True
elif byte_8 == b'V':
self.is_little_endian = False
else:
raise exceptions.BlendFileError("invalid endian indicator %r" % byte_8, path)
bytes_9_11 = fileobj.read(3)
self.version = int(bytes_9_11)
else:
byte_8 = fileobj.read(1)
header_size = int(byte_7 + byte_8)
if header_size != 17:
raise exceptions.BlendFileError("unknown file header size %d" % header_size, path)
byte_9 = fileobj.read(1)
if byte_9 != b'-':
raise exceptions.BlendFileError("invalid file header", path)
self.pointer_size = 8
byte_10_11 = fileobj.read(2)
self.file_format_version = int(byte_10_11)
if self.file_format_version != 1:
raise exceptions.BlendFileError("unsupported file format version %r" % self.file_format_version, path)
byte_12 = fileobj.read(1)
if byte_12 != b'v':
raise exceptions.BlendFileError("invalid file header", path)
self.is_little_endian = True
byte_13_16 = fileobj.read(4)
self.version = int(byte_13_16)
if self.is_little_endian:
self.endian_str = b'<'
self.endian = dna_io.LittleEndianTypes
else:
self.endian_str = b'>'
self.endian = dna_io.BigEndianTypes
def create_block_header_struct(self) -> typing.Tuple[struct.Struct, typing.Type[typing.Union[BHead4, SmallBHead8, LargeBHead8]]]:
"""
Returns a Struct instance for parsing data block headers and a corresponding
Python class for accessing the right members. Ddepending on the .blend file,
the order of the data members in the block header may be different.
"""
assert self.file_format_version in (0, 1)
if self.file_format_version == 1:
header_struct = struct.Struct(b''.join((
self.endian_str,
# LargeBHead8.code
b'4s',
# LargeBHead8.SDNAnr
b'i',
# LargeBHead8.old
b'Q',
# LargeBHead8.len
b'q',
# LargeBHead8.nr
b'q',
)))
return header_struct, LargeBHead8
if self.pointer_size == 4:
header_struct = struct.Struct(b''.join((
self.endian_str,
# BHead4.code
b'4s',
# BHead4.len
b'i',
# BHead4.old
b'I',
# BHead4.SDNAnr
b'i',
# BHead4.nr
b'i',
)))
return header_struct, BHead4
assert self.pointer_size == 8
header_struct = struct.Struct(b''.join((
self.endian_str,
# SmallBHead8.code
b'4s',
# SmallBHead8.len
b'i',
# SmallBHead8.old
b'Q',
# SmallBHead8.SDNAnr
b'i',
# SmallBHead8.nr
b'i',
)))
return header_struct, SmallBHead8
@@ -0,0 +1,97 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2009, At Mind B.V. - Jeroen Bakker
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import typing
import copy
from blender_asset_tracer import cdefs
from . import BlendFileBlock
from .dna import FieldPath
def listbase(
block: typing.Optional[BlendFileBlock], next_path: FieldPath = b"next"
) -> typing.Iterator[BlendFileBlock]:
"""Generator, yields all blocks in the ListBase linked list."""
while block:
yield block
next_ptr = block[next_path]
if next_ptr == 0:
break
block = block.bfile.dereference_pointer(next_ptr)
def sequencer_strips(
sequence_editor: BlendFileBlock,
) -> typing.Iterator[typing.Tuple[BlendFileBlock, int]]:
"""Generator, yield all sequencer strip blocks with their type number.
Recurses into meta strips, yielding both the meta strip itself and the
strips contained within it.
See blender_asset_tracer.cdefs.SEQ_TYPE_xxx for the type numbers.
"""
def iter_seqbase(seqbase) -> typing.Iterator[typing.Tuple[BlendFileBlock, int]]:
for seq in listbase(seqbase):
seq.refine_type(b"Sequence")
seq_type = seq[b"type"]
yield seq, seq_type
if seq_type == cdefs.SEQ_TYPE_META:
# Recurse into this meta-sequence.
subseq = seq.get_pointer((b"seqbase", b"first"))
yield from iter_seqbase(subseq)
sbase = sequence_editor.get_pointer((b"seqbase", b"first"))
yield from iter_seqbase(sbase)
def modifiers(object_block: BlendFileBlock) -> typing.Iterator[BlendFileBlock]:
"""Generator, yield the object's modifiers."""
# 'ob->modifiers[...]'
mods = object_block.get_pointer((b"modifiers", b"first"))
yield from listbase(mods, next_path=(b"modifier", b"next"))
def dynamic_array(block: BlendFileBlock) -> typing.Iterator[BlendFileBlock]:
"""
Generator that yields each element of a dynamic array as a separate block.
Dynamic arrays are multiple contiguous elements accessed via a single
pointer. BAT interprets these as a single data block, making it hard to
access individual elements. This function divides the array into individual
blocks by creating modified copies of the original block.
See `some_block.get(b'name', array_index)` if you want to access elements by
index (instead of iterating).
"""
element_size = block.dna_type.size
sub_block = copy.copy(block)
sub_block.size = element_size
for i in range(block.count):
# When sub_block's data is read, it'll be read from this offset in the blend file.
sub_block.file_offset = block.file_offset + i * element_size
yield sub_block
@@ -0,0 +1,169 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2021, Blender Foundation
import collections
import enum
import gzip
import logging
import os
import pathlib
import tempfile
import typing
# Blender 3.0 replaces GZip with ZStandard compression.
# Since this is not a standard library package, be careful importing it and
# treat it as optional.
try:
import zstandard
has_zstandard = True
except ImportError:
has_zstandard = False
from . import exceptions
# Magic numbers, see https://en.wikipedia.org/wiki/List_of_file_signatures
BLENDFILE_MAGIC = b"BLENDER"
GZIP_MAGIC = b"\x1f\x8b"
# ZStandard has two magic numbers, the 2nd of which doesn't use the last nibble.
# See https://tools.ietf.org/id/draft-kucherawy-dispatch-zstd-00.html#rfc.section.2.1.1
# and https://tools.ietf.org/id/draft-kucherawy-dispatch-zstd-00.html#rfc.section.2.3
ZSTD_MAGIC = b"\x28\xB5\x2F\xFD"
ZSTD_MAGIC_SKIPPABLE = b"\x50\x2A\x4D\x18"
ZSTD_MAGIC_SKIPPABLE_MASK = b"\xF0\xFF\xFF\xFF"
log = logging.getLogger(__name__)
# @dataclasses.dataclass
DecompressedFileInfo = collections.namedtuple(
"DecompressedFileInfo", "is_compressed path fileobj"
)
# is_compressed: bool
# path: pathlib.Path
# """The path of the decompressed file, or the input path if the file is not compressed."""
# fileobj: BinaryIO
class Compression(enum.Enum):
UNRECOGNISED = -1
NONE = 0
GZIP = 1
ZSTD = 2
def open(path: pathlib.Path, mode: str, buffer_size: int) -> DecompressedFileInfo:
"""Open the file, decompressing it into a temporary file if necesssary."""
fileobj = path.open(mode, buffering=buffer_size) # typing.IO[bytes]
compression = find_compression_type(fileobj)
if compression == Compression.UNRECOGNISED:
fileobj.close()
raise exceptions.BlendFileError("File is not a blend file", path)
if compression == Compression.NONE:
return DecompressedFileInfo(
is_compressed=False,
path=path,
fileobj=fileobj,
)
log.debug("%s-compressed blendfile detected: %s", compression.name, path)
# Decompress to a temporary file.
tmpfile = tempfile.NamedTemporaryFile()
fileobj.seek(0, os.SEEK_SET)
decompressor = _decompressor(fileobj, mode, compression)
with decompressor as compressed_file:
magic = compressed_file.read(len(BLENDFILE_MAGIC))
if magic != BLENDFILE_MAGIC:
raise exceptions.BlendFileError("Compressed file is not a blend file", path)
data = magic
while data:
tmpfile.write(data)
data = compressed_file.read(buffer_size)
# Further interaction should be done with the uncompressed file.
fileobj.close()
return DecompressedFileInfo(
is_compressed=True,
path=pathlib.Path(tmpfile.name),
fileobj=tmpfile,
)
def find_compression_type(fileobj: typing.IO[bytes]) -> Compression:
fileobj.seek(0, os.SEEK_SET)
# This assumes that all magics are not longer than "BLENDER".
magic = fileobj.read(len(BLENDFILE_MAGIC))
if _matches_magic(magic, BLENDFILE_MAGIC):
return Compression.NONE
if _matches_magic(magic, GZIP_MAGIC):
return Compression.GZIP
if _matches_magic(magic, ZSTD_MAGIC):
return Compression.ZSTD
if _matches_magic_masked(magic, ZSTD_MAGIC_SKIPPABLE, ZSTD_MAGIC_SKIPPABLE_MASK):
return Compression.ZSTD
return Compression.UNRECOGNISED
def _matches_magic_masked(value: bytes, magic: bytes, mask: bytes) -> bool:
"""Returns True only if value & mask == magic & mask (ignoring trailing bytes in value)."""
assert len(magic) == len(mask)
int_value = int.from_bytes(value[: len(magic)], "little")
int_magic = int.from_bytes(magic, "little")
int_mask = int.from_bytes(mask, "little")
return int_value & int_mask == int_magic & int_mask
def _matches_magic(value: bytes, magic: bytes) -> bool:
"""Returns True only if value == magic (ignoring trailing bytes in value)."""
return value[: len(magic)] == magic
def _decompressor(
fileobj: typing.IO[bytes], mode: str, compression: Compression
) -> typing.IO[bytes]:
if compression == Compression.GZIP:
decompressor = gzip.GzipFile(fileobj=fileobj, mode=mode)
return typing.cast(typing.IO[bytes], decompressor)
if compression == Compression.ZSTD:
if not has_zstandard:
# The required module was not loaded, raise an exception about this.
raise EnvironmentError(
"File is compressed with ZStandard, install the `zstandard` module to support this."
)
dctx = zstandard.ZstdDecompressor()
return dctx.stream_reader(fileobj)
raise ValueError("Unsupported compression type: %s" % compression)
@@ -0,0 +1,225 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Blender path support.
Does not use pathlib, because we may have to handle POSIX paths on Windows
or vice versa.
"""
import os.path
import pathlib
import platform
import string
import sys
class BlendPath(bytes):
"""A path within Blender is always stored as bytes."""
def __new__(cls, path):
if isinstance(path, pathlib.PurePath):
path = str(path).encode("utf-8")
if not isinstance(path, bytes):
raise TypeError("path must be bytes or pathlib.Path, but is %r" % path)
return super().__new__(cls, path.replace(b"\\", b"/"))
@classmethod
def mkrelative(
cls, asset_path: pathlib.PurePath, bfile_path: pathlib.PurePath
) -> "BlendPath":
"""Construct a BlendPath to the asset relative to the blend file.
Assumes that bfile_path is absolute.
Note that this can return an absolute path on Windows when 'asset_path'
and 'bfile_path' are on different drives.
"""
from collections import deque
# Only compare absolute paths.
assert bfile_path.is_absolute(), (
"BlendPath().mkrelative(bfile_path=%r) should get absolute bfile_path"
% bfile_path
)
assert asset_path.is_absolute(), (
"BlendPath().mkrelative(asset_path=%r) should get absolute asset_path"
% asset_path
)
# There is no way to construct a relative path between drives.
if bfile_path.drive != asset_path.drive:
return cls(asset_path)
bdir_parts = deque(bfile_path.parent.parts)
asset_path = make_absolute(asset_path)
asset_parts = deque(asset_path.parts)
# Remove matching initial parts. What is left in bdir_parts represents
# the number of '..' we need. What is left in asset_parts represents
# what we need after the '../../../'.
while bdir_parts:
if bdir_parts[0] != asset_parts[0]:
break
bdir_parts.popleft()
asset_parts.popleft()
rel_asset = pathlib.PurePath(*asset_parts)
# TODO(Sybren): should we use sys.getfilesystemencoding() instead?
rel_bytes = str(rel_asset).encode("utf-8")
as_bytes = b"//" + len(bdir_parts) * b"../" + rel_bytes
return cls(as_bytes)
def __str__(self) -> str:
"""Decodes the path as UTF-8, replacing undecodable bytes.
Undecodable bytes are ignored so this function can be safely used
for reporting.
"""
return self.decode("utf8", errors="replace")
def __repr__(self) -> str:
return "BlendPath(%s)" % super().__repr__()
def __truediv__(self, subpath: bytes):
"""Slash notation like pathlib.Path."""
sub = BlendPath(subpath)
if sub.is_absolute():
raise ValueError("'a / b' only works when 'b' is a relative path")
return BlendPath(self.rstrip(b"/") + b"/" + sub)
def __rtruediv__(self, parentpath: bytes):
"""Slash notation like pathlib.Path."""
if self.is_absolute():
raise ValueError("'a / b' only works when 'b' is a relative path")
return BlendPath(parentpath.rstrip(b"/") + b"/" + self)
def to_path(self) -> pathlib.PurePath:
"""Convert this path to a pathlib.PurePath.
This path MUST NOT be a blendfile-relative path (e.g. it may not start
with `//`). For such paths, first use `.absolute()` to resolve the path.
Interprets the path as UTF-8, and if that fails falls back to the local
filesystem encoding.
The exact type returned is determined by the current platform.
"""
# TODO(Sybren): once we target Python 3.6, implement __fspath__().
try:
decoded = self.decode("utf8")
except UnicodeDecodeError:
decoded = self.decode(sys.getfilesystemencoding())
if self.is_blendfile_relative():
raise ValueError("to_path() cannot be used on blendfile-relative paths")
return pathlib.PurePath(decoded)
def is_blendfile_relative(self) -> bool:
return self[:2] == b"//"
def is_absolute(self) -> bool:
if self.is_blendfile_relative():
return False
if self[0:1] == b"/":
return True
# Windows style path starting with drive letter.
if (
len(self) >= 3
and (self.decode("utf8"))[0] in string.ascii_letters
and self[1:2] == b":"
and self[2:3] in {b"\\", b"/"}
):
return True
return False
def absolute(self, root: bytes = b"") -> "BlendPath":
"""Determine absolute path.
:param root: root directory to compute paths relative to.
For blendfile-relative paths, root should be the directory
containing the blendfile. If not given, blendfile-relative
paths cause a ValueError but filesystem-relative paths are
resolved based on the current working directory.
"""
if self.is_absolute():
return self
if self.is_blendfile_relative():
my_relpath = self[2:] # strip off leading //
else:
my_relpath = self
return BlendPath(os.path.join(root, my_relpath))
def make_absolute(path: pathlib.PurePath) -> pathlib.Path:
"""Make the path absolute without resolving symlinks or drive letters.
This function is an alternative to `Path.resolve()`. It make the path absolute,
and resolves `../../`, but contrary to `Path.resolve()` does NOT perform these
changes:
- Symlinks are NOT followed.
- Windows Network shares that are mapped to a drive letter are NOT resolved
to their UNC notation.
The type of the returned path is determined by the current platform.
"""
str_path = path.as_posix()
if len(str_path) >= 2 and str_path[0].isalpha() and str_path[1] == ":":
# This is an absolute Windows path. It must be handled with care on non-Windows platforms.
if platform.system() != "Windows":
# Normalize the POSIX-like part of the path, but leave out the drive letter.
non_drive_path = str_path[2:]
normalized = os.path.normpath(non_drive_path)
# Stick the drive letter back on the normalized path.
return pathlib.Path(str_path[:2] + normalized)
return pathlib.Path(os.path.abspath(str_path))
def strip_root(path: pathlib.PurePath) -> pathlib.PurePosixPath:
"""Turn the path into a relative path by stripping the root.
This also turns any drive letter into a normal path component.
This changes "C:/Program Files/Blender" to "C/Program Files/Blender",
and "/absolute/path.txt" to "absolute/path.txt", making it possible to
treat it as a relative path.
"""
if path.drive:
return pathlib.PurePosixPath(path.drive[0], *path.parts[1:])
if isinstance(path, pathlib.PurePosixPath):
# This happens when running on POSIX but still handling paths
# originating from a Windows machine.
parts = path.parts
if (
parts
and len(parts[0]) == 2
and parts[0][0].isalpha()
and parts[0][1] == ":"
):
# The first part is a drive letter.
return pathlib.PurePosixPath(parts[0][0], *path.parts[1:])
if path.is_absolute():
return pathlib.PurePosixPath(*path.parts[1:])
return pathlib.PurePosixPath(path)
@@ -0,0 +1,112 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
"""Constants defined in C."""
# DNA_sequence_types.h (Sequence.type)
SEQ_TYPE_IMAGE = 0
SEQ_TYPE_META = 1
SEQ_TYPE_SCENE = 2
SEQ_TYPE_MOVIE = 3
SEQ_TYPE_SOUND_RAM = 4
SEQ_TYPE_SOUND_HD = 5
SEQ_TYPE_MOVIECLIP = 6
SEQ_TYPE_MASK = 7
SEQ_TYPE_EFFECT = 8
IMA_SRC_FILE = 1
IMA_SRC_SEQUENCE = 2
IMA_SRC_MOVIE = 3
IMA_SRC_TILED = 6 # UDIM
# DNA_modifier_types.h
eModifierType_Wave = 7
eModifierType_Displace = 14
eModifierType_UVProject = 15
eModifierType_ParticleSystem = 19
eModifierType_Cloth = 22
eModifierType_Fluidsim = 26
eModifierType_Smokesim = 31
eModifierType_WeightVGEdit = 36
eModifierType_WeightVGMix = 37
eModifierType_WeightVGProximity = 38
eModifierType_Ocean = 39
eModifierType_DynamicPaint = 40
eModifierType_MeshCache = 46
eModifierType_MeshSequenceCache = 52
eModifierType_Fluid = 56
eModifierType_Nodes = 57
# NodesModifierBakeFlag
NODES_MODIFIER_BAKE_CUSTOM_PATH = 1 << 1
# NodesModifierBakeTarget
NODES_MODIFIER_BAKE_TARGET_INHERIT = 0
NODES_MODIFIER_BAKE_TARGET_PACKED = 1
NODES_MODIFIER_BAKE_TARGET_DISK = 2
# DNA_particle_types.h
PART_DRAW_OB = 7
PART_DRAW_GR = 8
# DNA_object_types.h
# Object.transflag
OB_DUPLIGROUP = 1 << 8
# DNA_object_force_types.h
PTCACHE_DISK_CACHE = 64
PTCACHE_EXTERNAL = 512
# DNA_ID_types.h
IDP_STRING = 0
IDP_INT = 1
IDP_FLOAT = 2
IDP_ARRAY = 5
IDP_GROUP = 6
IDP_ID = 7
IDP_DOUBLE = 8
IDP_IDPARRAY = 9
IDP_NUMTYPES = 10
# DNA_node_types.h
SOCK_CUSTOM = -1 # socket has no integer type
SOCK_FLOAT = 0
SOCK_VECTOR = 1
SOCK_RGBA = 2
SOCK_SHADER = 3
SOCK_BOOLEAN = 4
SOCK_MESH = 5 # deprecated
SOCK_INT = 6
SOCK_STRING = 7
SOCK_OBJECT = 8
SOCK_IMAGE = 9
SOCK_GEOMETRY = 10
SOCK_COLLECTION = 11
SOCK_TEXTURE = 12
SOCK_MATERIAL = 13
# BKE_pointcache.h
PTCACHE_FILE_PTCACHE = 0
PTCACHE_FILE_OPENVDB = 1
PTCACHE_EXT = b".bphys"
PTCACHE_EXT_VDB = b".vdb"
PTCACHE_PATH = b"blendcache_"
# BKE_node.h
SH_NODE_TEX_IMAGE = 143
CMP_NODE_R_LAYERS = 221
@@ -0,0 +1,137 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Commandline entry points."""
import argparse
import datetime
import logging
import time
from . import blocks, common, pack, list_deps, version
def cli_main():
from blender_asset_tracer import __version__
parser = argparse.ArgumentParser(
description="BAT: Blender Asset Tracer v%s" % __version__
)
common.add_flag(parser, "profile", help="Run the profiler, write to bam.prof")
# func is set by subparsers to indicate which function to run.
parser.set_defaults(func=None, loglevel=logging.WARNING)
loggroup = parser.add_mutually_exclusive_group()
loggroup.add_argument(
"-v",
"--verbose",
dest="loglevel",
action="store_const",
const=logging.INFO,
help="Log INFO level and higher",
)
loggroup.add_argument(
"-d",
"--debug",
dest="loglevel",
action="store_const",
const=logging.DEBUG,
help="Log everything",
)
loggroup.add_argument(
"-q",
"--quiet",
dest="loglevel",
action="store_const",
const=logging.ERROR,
help="Log at ERROR level and higher",
)
parser.add_argument(
"-S",
"--strict-pointers",
default=False,
action="store_true",
help="Crash on pointers to missing data; otherwise the missing data is just ignored.",
)
subparsers = parser.add_subparsers(
help="Choose a subcommand to actually make BAT do something. "
"Global options go before the subcommand, "
"whereas subcommand-specific options go after it. "
"Use --help after the subcommand to get more info."
)
blocks.add_parser(subparsers)
pack.add_parser(subparsers)
list_deps.add_parser(subparsers)
version.add_parser(subparsers)
args = parser.parse_args()
config_logging(args)
from blender_asset_tracer import __version__
log = logging.getLogger(__name__)
# Make sure the things we log in our local logger are visible
if args.profile and args.loglevel > logging.INFO:
log.setLevel(logging.INFO)
log.debug("Running BAT version %s", __version__)
if not args.func:
parser.error("No subcommand was given")
set_strict_pointer_mode(args.strict_pointers)
start_time = time.time()
if args.profile:
import cProfile
prof_fname = "bam.prof"
log.info("Running profiler")
cProfile.runctx(
"args.func(args)", globals=globals(), locals=locals(), filename=prof_fname
)
log.info("Profiler exported data to %s", prof_fname)
log.info(
'Run "pyprof2calltree -i %r -k" to convert and open in KCacheGrind',
prof_fname,
)
else:
retval = args.func(args)
duration = datetime.timedelta(seconds=time.time() - start_time)
log.info("Command took %s to complete", duration)
def config_logging(args):
"""Configures the logging system based on CLI arguments."""
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)-15s %(levelname)8s %(name)-40s %(message)s",
)
# Only set the log level on our own logger. Otherwise
# debug logging will be completely swamped.
logging.getLogger("blender_asset_tracer").setLevel(args.loglevel)
def set_strict_pointer_mode(strict_pointers: bool) -> None:
from blender_asset_tracer import blendfile
blendfile.set_strict_pointer_mode(strict_pointers)
@@ -0,0 +1,148 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""List count and total size of datablocks in a blend file."""
import collections
import logging
import pathlib
from blender_asset_tracer import blendfile
from . import common
log = logging.getLogger(__name__)
class BlockTypeInfo:
def __init__(self):
self.total_bytes = 0
self.num_blocks = 0
self.sizes = []
self.blocks = []
self.name = "unset"
def add_parser(subparsers):
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("blocks", help=__doc__)
parser.set_defaults(func=cli_blocks)
parser.add_argument("blendfile", type=pathlib.Path)
parser.add_argument(
"-d",
"--dump",
default=False,
action="store_true",
help="Hex-dump the biggest block",
)
parser.add_argument(
"-l",
"--limit",
default=10,
type=int,
help="Limit the number of DNA types shown, default is 10",
)
def by_total_bytes(info: BlockTypeInfo) -> int:
return info.total_bytes
def block_key(block: blendfile.BlendFileBlock) -> str:
return "%s-%s" % (block.dna_type_name, block.code.decode())
def cli_blocks(args):
bpath = args.blendfile
if not bpath.exists():
log.fatal("File %s does not exist", args.blendfile)
return 3
per_blocktype = collections.defaultdict(BlockTypeInfo)
print("Opening %s" % bpath)
bfile = blendfile.BlendFile(bpath)
print("Inspecting %s" % bpath)
for block in bfile.blocks:
if block.code == b"DNA1":
continue
index_as = block_key(block)
info = per_blocktype[index_as]
info.name = index_as
info.total_bytes += block.size
info.num_blocks += 1
info.sizes.append(block.size)
info.blocks.append(block)
fmt = "%-35s %10s %10s %10s %10s"
print(fmt % ("Block type", "Total Size", "Num blocks", "Avg Size", "Median"))
print(fmt % (35 * "-", 10 * "-", 10 * "-", 10 * "-", 10 * "-"))
infos = sorted(per_blocktype.values(), key=by_total_bytes, reverse=True)
for info in infos[: args.limit]:
median_size = sorted(info.sizes)[len(info.sizes) // 2]
print(
fmt
% (
info.name,
common.humanize_bytes(info.total_bytes),
info.num_blocks,
common.humanize_bytes(info.total_bytes // info.num_blocks),
common.humanize_bytes(median_size),
)
)
print(70 * "-")
# From the blocks of the most space-using category, the biggest block.
biggest_block = sorted(infos[0].blocks, key=lambda blck: blck.size, reverse=True)[0]
print(
"Biggest %s block is %s at address 0x%x"
% (
block_key(biggest_block),
common.humanize_bytes(biggest_block.size),
biggest_block.addr_old,
)
)
print("Finding what points there")
addr_to_find = biggest_block.addr_old
found_pointer = False
for block in bfile.blocks:
for prop_path, prop_value in block.items_recursive():
if not isinstance(prop_value, int) or prop_value != addr_to_find:
continue
print(" ", block, prop_path)
found_pointer = True
if not found_pointer:
print("Nothing points there")
if args.dump:
print("Hexdump:")
bfile.fileobj.seek(biggest_block.file_offset)
data = bfile.fileobj.read(biggest_block.size)
line_len_bytes = 32
import codecs
for offset in range(0, len(data), line_len_bytes):
line = codecs.encode(data[offset : offset + line_len_bytes], "hex").decode()
print(
"%6d -" % offset,
" ".join(line[i : i + 2] for i in range(0, len(line), 2)),
)
@@ -0,0 +1,102 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Common functionality for CLI parsers."""
import typing
import pathlib
def add_flag(argparser, flag_name: str, **kwargs):
"""Add a CLI argument for the flag.
The flag defaults to False, and when present on the CLI stores True.
"""
argparser.add_argument(
"-%s" % flag_name[0],
"--%s" % flag_name,
default=False,
action="store_true",
**kwargs
)
def shorten(cwd: pathlib.Path, somepath: pathlib.Path) -> pathlib.Path:
"""Return 'somepath' relative to CWD if possible."""
try:
return somepath.relative_to(cwd)
except ValueError:
return somepath
def humanize_bytes(size_in_bytes: int, precision: typing.Optional[int] = None):
"""Return a humanized string representation of a number of bytes.
Source: http://code.activestate.com/recipes/577081-humanized-representation-of-a-number-of-bytes
:param size_in_bytes: The size to humanize
:param precision: How many digits are shown after the comma. When None,
it defaults to 1 unless the entire number of bytes is shown, then
it will be 0.
>>> humanize_bytes(1)
'1 B'
>>> humanize_bytes(1024)
'1.0 kB'
>>> humanize_bytes(1024*123, 0)
'123 kB'
>>> humanize_bytes(1024*123)
'123.0 kB'
>>> humanize_bytes(1024*12342)
'12.1 MB'
>>> humanize_bytes(1024*12342,2)
'12.05 MB'
>>> humanize_bytes(1024*1234,2)
'1.21 MB'
>>> humanize_bytes(1024*1234*1111,2)
'1.31 GB'
>>> humanize_bytes(1024*1234*1111,1)
'1.3 GB'
"""
if precision is None:
precision = size_in_bytes >= 1024
abbrevs = (
(1 << 50, "PB"),
(1 << 40, "TB"),
(1 << 30, "GB"),
(1 << 20, "MB"),
(1 << 10, "kB"),
(1, "B"),
)
for factor, suffix in abbrevs:
if size_in_bytes >= factor:
break
else:
factor = 1
suffix = "B"
return "%.*f %s" % (precision, size_in_bytes / factor, suffix)
if __name__ == "__main__":
import doctest
doctest.testmod()
@@ -0,0 +1,157 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""List dependencies of a blend file."""
import functools
import hashlib
import json
import logging
import pathlib
import sys
import time
import typing
from blender_asset_tracer import trace, bpathlib
from . import common
log = logging.getLogger(__name__)
def add_parser(subparsers):
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("list", help=__doc__)
parser.set_defaults(func=cli_list)
parser.add_argument("blendfile", type=pathlib.Path)
common.add_flag(
parser, "json", help="Output as JSON instead of human-readable text"
)
common.add_flag(
parser,
"sha256",
help="Include SHA256sums in the output. Note that those may differ from the "
"SHA256sums in a BAT-pack when paths are rewritten.",
)
common.add_flag(parser, "timing", help="Include timing information in the output")
def cli_list(args):
bpath = args.blendfile
if not bpath.exists():
log.fatal("File %s does not exist", args.blendfile)
return 3
if args.json:
if args.sha256:
log.fatal("--sha256 can currently not be used in combination with --json")
if args.timing:
log.fatal("--timing can currently not be used in combination with --json")
report_json(bpath)
else:
report_text(bpath, include_sha256=args.sha256, show_timing=args.timing)
def calc_sha_sum(filepath: pathlib.Path) -> typing.Tuple[str, float]:
start = time.time()
if filepath.is_dir():
for subfile in filepath.rglob("*"):
calc_sha_sum(subfile)
duration = time.time() - start
return "-multiple-", duration
summer = hashlib.sha256()
with filepath.open("rb") as infile:
while True:
block = infile.read(32 * 1024)
if not block:
break
summer.update(block)
digest = summer.hexdigest()
duration = time.time() - start
return digest, duration
def report_text(bpath, *, include_sha256: bool, show_timing: bool):
reported_assets = set() # type: typing.Set[pathlib.Path]
last_reported_bfile = None
shorten = functools.partial(common.shorten, pathlib.Path.cwd())
time_spent_on_shasums = 0.0
start_time = time.time()
for usage in trace.deps(bpath):
filepath = usage.block.bfile.filepath.absolute()
if filepath != last_reported_bfile:
if include_sha256:
shasum, time_spent = calc_sha_sum(filepath)
time_spent_on_shasums += time_spent
print(shorten(filepath), shasum)
else:
print(shorten(filepath))
last_reported_bfile = filepath
for assetpath in usage.files():
assetpath = bpathlib.make_absolute(assetpath)
if assetpath in reported_assets:
log.debug("Already reported %s", assetpath)
continue
if include_sha256:
shasum, time_spent = calc_sha_sum(assetpath)
time_spent_on_shasums += time_spent
print(" ", shorten(assetpath), shasum)
else:
print(" ", shorten(assetpath))
reported_assets.add(assetpath)
if show_timing:
duration = time.time() - start_time
print("Spent %.2f seconds on producing this listing" % duration)
if include_sha256:
print("Spent %.2f seconds on calculating SHA sums" % time_spent_on_shasums)
percentage = time_spent_on_shasums / duration * 100
print(" (that is %d%% of the total time" % percentage)
class JSONSerialiser(json.JSONEncoder):
def default(self, o):
if isinstance(o, pathlib.Path):
return str(o)
if isinstance(o, set):
return sorted(o)
return super().default(o)
def report_json(bpath):
import collections
# Mapping from blend file to its dependencies.
report = collections.defaultdict(set)
for usage in trace.deps(bpath):
filepath = usage.block.bfile.filepath.absolute()
for assetpath in usage.files():
assetpath = assetpath.resolve()
report[str(filepath)].add(assetpath)
json.dump(report, sys.stdout, cls=JSONSerialiser, indent=4)
@@ -0,0 +1,254 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Create a BAT-pack for the given blend file."""
import logging
import pathlib
import sys
import typing
import blender_asset_tracer.pack.transfer
from blender_asset_tracer import pack, bpathlib
log = logging.getLogger(__name__)
def add_parser(subparsers):
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("pack", help=__doc__)
parser.set_defaults(func=cli_pack)
parser.add_argument("blendfile", type=pathlib.Path, help="The Blend file to pack.")
parser.add_argument(
"target",
type=str,
help="The target can be a directory, a ZIP file (does not have to exist "
"yet, just use 'something.zip' as target), "
"or a URL of S3 storage (s3://endpoint/path) "
"or Shaman storage (shaman://endpoint/#checkoutID).",
)
parser.add_argument(
"-p",
"--project",
type=pathlib.Path,
help="Root directory of your project. Paths to below this directory are "
"kept in the BAT Pack as well, whereas references to assets from "
"outside this directory will have to be rewitten. The blend file MUST "
"be inside the project directory. If this option is ommitted, the "
"directory containing the blend file is taken as the project "
"directoy.",
)
parser.add_argument(
"-n",
"--noop",
default=False,
action="store_true",
help="Don't copy files, just show what would be done.",
)
parser.add_argument(
"-e",
"--exclude",
nargs="*",
default="",
help="Space-separated list of glob patterns (like '*.abc *.vbo') to "
"exclude.",
)
parser.add_argument(
"-c",
"--compress",
default=False,
action="store_true",
help="Compress blend files while copying. This option is only valid when "
"packing into a directory (contrary to ZIP file or S3 upload). "
"Note that files will NOT be compressed when the destination file "
"already exists and has the same size as the original file.",
)
parser.add_argument(
"-r",
"--relative-only",
default=False,
action="store_true",
help="Only pack assets that are referred to with a relative path (e.g. "
"starting with `//`.",
)
def cli_pack(args):
bpath, ppath, tpath = paths_from_cli(args)
with create_packer(args, bpath, ppath, tpath) as packer:
packer.strategise()
try:
packer.execute()
except blender_asset_tracer.pack.transfer.FileTransferError as ex:
log.error(
"%d files couldn't be copied, starting with %s",
len(ex.files_remaining),
ex.files_remaining[0],
)
raise SystemExit(1)
def create_packer(
args, bpath: pathlib.Path, ppath: pathlib.Path, target: str
) -> pack.Packer:
if target.startswith("s3:/"):
if args.noop:
raise ValueError("S3 uploader does not support no-op.")
if args.compress:
raise ValueError("S3 uploader does not support on-the-fly compression")
if args.relative_only:
raise ValueError("S3 uploader does not support the --relative-only option")
packer = create_s3packer(bpath, ppath, pathlib.PurePosixPath(target))
elif (
target.startswith("shaman+http:/")
or target.startswith("shaman+https:/")
or target.startswith("shaman:/")
):
if args.noop:
raise ValueError("Shaman uploader does not support no-op.")
if args.compress:
raise ValueError("Shaman uploader does not support on-the-fly compression")
if args.relative_only:
raise ValueError(
"Shaman uploader does not support the --relative-only option"
)
packer = create_shamanpacker(bpath, ppath, target)
elif target.lower().endswith(".zip"):
from blender_asset_tracer.pack import zipped
if args.compress:
raise ValueError("ZIP packer does not support on-the-fly compression")
packer = zipped.ZipPacker(
bpath, ppath, target, noop=args.noop, relative_only=args.relative_only
)
else:
packer = pack.Packer(
bpath,
ppath,
target,
noop=args.noop,
compress=args.compress,
relative_only=args.relative_only,
)
if args.exclude:
# args.exclude is a list, due to nargs='*', so we have to split and flatten.
globs = [glob for globs in args.exclude for glob in globs.split()]
log.info("Excluding: %s", ", ".join(repr(g) for g in globs))
packer.exclude(*globs)
return packer
def create_s3packer(bpath, ppath, tpath) -> pack.Packer:
from blender_asset_tracer.pack import s3
# Split the target path into 's3:/', hostname, and actual target path
parts = tpath.parts
endpoint = "https://%s/" % parts[1]
tpath = pathlib.Path(*tpath.parts[2:])
log.info("Uploading to S3-compatible storage %s at %s", endpoint, tpath)
return s3.S3Packer(bpath, ppath, tpath, endpoint=endpoint)
def create_shamanpacker(
bpath: pathlib.Path, ppath: pathlib.Path, tpath: str
) -> pack.Packer:
"""Creates a package for sending files to a Shaman server.
URLs should have the form:
shaman://hostname/base/url#jobID
This uses HTTPS to connect to the server. To connect using HTTP, use:
shaman+http://hostname/base-url#jobID
"""
from blender_asset_tracer.pack import shaman
endpoint, checkout_id = shaman.parse_endpoint(tpath)
if not checkout_id:
log.warning(
"No checkout ID given on the URL. Going to send BAT pack to Shaman, "
"but NOT creating a checkout"
)
log.info("Uploading to Shaman server %s with job %s", endpoint, checkout_id)
return shaman.ShamanPacker(
bpath, ppath, "/", endpoint=endpoint, checkout_id=checkout_id
)
def paths_from_cli(args) -> typing.Tuple[pathlib.Path, pathlib.Path, str]:
"""Return paths to blendfile, project, and pack target.
Calls sys.exit() if anything is wrong.
"""
bpath = args.blendfile
if not bpath.exists():
log.critical("File %s does not exist", bpath)
sys.exit(3)
if bpath.is_dir():
log.critical("%s is a directory, should be a blend file")
sys.exit(3)
bpath = bpathlib.make_absolute(bpath)
tpath = args.target
if args.project is None:
ppath = bpathlib.make_absolute(bpath).parent
log.warning("No project path given, using %s", ppath)
else:
ppath = bpathlib.make_absolute(args.project)
if not ppath.exists():
log.critical("Project directory %s does not exist", ppath)
sys.exit(5)
if not ppath.is_dir():
log.warning(
"Project path %s is not a directory; using the parent %s",
ppath,
ppath.parent,
)
ppath = ppath.parent
try:
bpath.relative_to(ppath)
except ValueError:
log.critical(
"Project directory %s does not contain blend file %s",
args.project,
bpath.absolute(),
)
sys.exit(5)
log.info("Blend file to pack: %s", bpath)
log.info("Project path: %s", ppath)
log.info("Pack will be created in: %s", tpath)
return bpath, ppath, tpath
@@ -0,0 +1,32 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2021, Blender Foundation - Sybren A. Stüvel
"""Print version of BAT and exit."""
from blender_asset_tracer import __version__
def add_parser(subparsers):
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("version", help=__doc__)
parser.set_defaults(func=cli_version)
def cli_version(_):
print(__version__)
@@ -0,0 +1,83 @@
"""shutil-like functionality while compressing blendfiles on the fly."""
import gzip
import logging
import pathlib
import shutil
from blender_asset_tracer.blendfile import magic_compression
log = logging.getLogger(__name__)
# Arbitrarily chosen block size, in bytes.
BLOCK_SIZE = 256 * 2**10
def move(src: pathlib.Path, dest: pathlib.Path):
"""Move a file from src to dest, gzip-compressing if not compressed yet.
Only compresses files ending in .blend; others are moved as-is.
"""
my_log = log.getChild("move")
my_log.debug("Moving %s to %s", src, dest)
if src.suffix.lower() == ".blend":
_move_or_copy(src, dest, my_log, source_must_remain=False)
else:
shutil.move(str(src), str(dest))
def copy(src: pathlib.Path, dest: pathlib.Path):
"""Copy a file from src to dest, gzip-compressing if not compressed yet.
Only compresses files ending in .blend; others are copied as-is.
"""
my_log = log.getChild("copy")
my_log.debug("Copying %s to %s", src, dest)
if src.suffix.lower() == ".blend":
_move_or_copy(src, dest, my_log, source_must_remain=True)
else:
shutil.copy2(str(src), str(dest))
def _move_or_copy(
src: pathlib.Path,
dest: pathlib.Path,
my_log: logging.Logger,
*,
source_must_remain: bool
):
"""Either move or copy a file, gzip-compressing if not compressed yet.
:param src: File to copy/move.
:param dest: Path to copy/move to.
:source_must_remain: True to copy, False to move.
:my_log: Logger to use for logging.
"""
srcfile = src.open("rb")
try:
comp_type = magic_compression.find_compression_type(srcfile)
if comp_type != magic_compression.Compression.NONE:
# Either already compressed or not a blend file.
# Either way we shouldn't attempt compressing this file.
srcfile.close()
my_log.debug("Source file %s is compressed already", src)
if source_must_remain:
shutil.copy2(str(src), str(dest))
else:
shutil.move(str(src), str(dest))
return
my_log.debug("Compressing %s on the fly while copying to %s", src, dest)
srcfile.seek(0)
with gzip.open(str(dest), mode="wb") as destfile:
shutil.copyfileobj(srcfile, destfile, BLOCK_SIZE)
srcfile.close()
if not source_must_remain:
my_log.debug("Deleting source file %s", src)
src.unlink()
finally:
if not srcfile.closed:
srcfile.close()
@@ -0,0 +1,662 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import collections
import enum
import functools
import logging
import pathlib
import tempfile
import threading
import typing
from blender_asset_tracer import trace, bpathlib, blendfile
from blender_asset_tracer.trace import file_sequence, result
from . import filesystem, transfer, progress
log = logging.getLogger(__name__)
class PathAction(enum.Enum):
KEEP_PATH = 1
FIND_NEW_LOCATION = 2
class AssetAction:
"""All the info required to rewrite blend files and copy assets."""
def __init__(self) -> None:
self.path_action = PathAction.KEEP_PATH
self.usages = [] # type: typing.List[result.BlockUsage]
"""BlockUsage objects referring to this asset.
Those BlockUsage objects could refer to data blocks in this blend file
(if the asset is a blend file) or in another blend file.
"""
self.new_path = None # type: typing.Optional[pathlib.PurePath]
"""Absolute path to the asset in the BAT Pack.
This path may not exist on the local file system at all, for example
when uploading files to remote S3-compatible storage.
"""
self.read_from = None # type: typing.Optional[pathlib.Path]
"""Optional path from which to read the asset.
This is used when blend files have been rewritten. It is assumed that
when this property is set, the file can be moved instead of copied.
"""
self.rewrites = [] # type: typing.List[result.BlockUsage]
"""BlockUsage objects in this asset that may require rewriting.
Empty list if this AssetAction is not for a blend file.
"""
class Aborted(RuntimeError):
"""Raised by Packer to abort the packing process.
See the Packer.abort() function.
"""
class Packer:
"""Takes a blend file and bundle it with its dependencies.
The process is separated into two functions:
- strategise() finds all the dependencies and determines what to do
with them.
- execute() performs the actual packing operation, by rewriting blend
files to ensure the paths to moved files are correct and
transferring the files.
The file transfer is performed in a separate thread by a FileTransferer
instance.
"""
def __init__(
self,
bfile: pathlib.Path,
project: pathlib.Path,
target: str,
*,
noop=False,
compress=False,
relative_only=False,
) -> None:
self.blendfile = bfile
self.project = project
self.target = target
self._target_path = self._make_target_path(target)
self.noop = noop
self.compress = compress
self.relative_only = relative_only
self._aborted = threading.Event()
self._abort_lock = threading.RLock()
self._abort_reason = ""
# Set this to a custom Callback() subclass instance before calling
# strategise() to receive progress reports.
self._progress_cb = progress.Callback()
self._tscb = progress.ThreadSafeCallback(self._progress_cb)
self._exclude_globs = set() # type: typing.Set[str]
self._shorten = functools.partial(shorten_path, self.project)
if noop:
log.warning("Running in no-op mode, only showing what will be done.")
# Filled by strategise()
self._actions = collections.defaultdict(
AssetAction
) # type: typing.DefaultDict[pathlib.Path, AssetAction]
self.missing_files = set() # type: typing.Set[pathlib.Path]
self._new_location_paths = set() # type: typing.Set[pathlib.Path]
self._output_path = None # type: typing.Optional[pathlib.PurePath]
# Filled by execute()
self._file_transferer = None # type: typing.Optional[transfer.FileTransferer]
# Number of files we would copy, if not for --noop
self._file_count = 0
self._tmpdir = tempfile.TemporaryDirectory(prefix="bat-", suffix="-batpack")
self._rewrite_in = pathlib.Path(self._tmpdir.name)
def _make_target_path(self, target: str) -> pathlib.PurePath:
"""Return a Path for the given target.
This can be the target directory itself, but can also be a non-existent
directory if the target doesn't support direct file access. It should
only be used to perform path operations, and never for file operations.
"""
return pathlib.Path(target).absolute()
def close(self) -> None:
"""Clean up any temporary files."""
self._tscb.flush()
self._tmpdir.cleanup()
def __enter__(self) -> "Packer":
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
@property
def output_path(self) -> pathlib.PurePath:
"""The path of the packed blend file in the target directory."""
assert self._output_path is not None
return self._output_path
@property
def progress_cb(self) -> progress.Callback:
return self._progress_cb
@progress_cb.setter
def progress_cb(self, new_progress_cb: progress.Callback):
self._tscb.flush()
self._progress_cb = new_progress_cb
self._tscb = progress.ThreadSafeCallback(self._progress_cb)
def abort(self, reason="") -> None:
"""Aborts the current packing process.
Can be called from any thread. Aborts as soon as the running strategise
or execute function gets control over the execution flow, by raising
an Aborted exception.
"""
with self._abort_lock:
self._abort_reason = reason
if self._file_transferer:
self._file_transferer.abort()
self._aborted.set()
def _check_aborted(self) -> None:
"""Raises an Aborted exception when abort() was called."""
with self._abort_lock:
reason = self._abort_reason
if self._file_transferer is not None and self._file_transferer.has_error:
log.error("A transfer error occurred")
reason = self._file_transferer.error_message()
elif not self._aborted.is_set():
return
log.warning("Aborting")
self._tscb.flush()
self._progress_cb.pack_aborted(reason)
raise Aborted(reason)
def exclude(self, *globs: str):
"""Register glob-compatible patterns of files that should be ignored.
Must be called before calling strategise().
"""
if self._actions:
raise RuntimeError(
"%s.exclude() must be called before strategise()"
% self.__class__.__qualname__
)
self._exclude_globs.update(globs)
def strategise(self) -> None:
"""Determine what to do with the assets.
Places an asset into one of these categories:
- Can be copied as-is, nothing smart required.
- Blend files referring to this asset need to be rewritten.
This function does *not* expand globs. Globs are seen as single
assets, and are only evaluated when performing the actual transfer
in the execute() function.
"""
# The blendfile that we pack is generally not its own dependency, so
# we have to explicitly add it to the _packed_paths.
bfile_path = bpathlib.make_absolute(self.blendfile)
# Both paths have to be resolved first, because this also translates
# network shares mapped to Windows drive letters back to their UNC
# notation. Only resolving one but not the other (which can happen
# with the abosolute() call above) can cause errors.
bfile_pp = self._target_path / bfile_path.relative_to(
bpathlib.make_absolute(self.project)
)
self._output_path = bfile_pp
self._progress_cb.pack_start()
act = self._actions[bfile_path]
act.path_action = PathAction.KEEP_PATH
act.new_path = bfile_pp
self._check_aborted()
self._new_location_paths = set()
for usage in trace.deps(self.blendfile, self._progress_cb):
self._check_aborted()
asset_path = usage.abspath
if any(asset_path.match(glob) for glob in self._exclude_globs):
log.info("Excluding file: %s", asset_path)
continue
if self.relative_only and not usage.asset_path.startswith(b"//"):
log.info("Skipping absolute path: %s", usage.asset_path)
continue
if usage.is_sequence:
self._visit_sequence(asset_path, usage)
else:
self._visit_asset(asset_path, usage)
self._find_new_paths()
self._group_rewrites()
def _visit_sequence(self, asset_path: pathlib.Path, usage: result.BlockUsage):
assert usage.is_sequence
def handle_missing_file():
log.warning("Missing file: %s", asset_path)
self.missing_files.add(asset_path)
self._progress_cb.missing_file(asset_path)
try:
for file_path in file_sequence.expand_sequence(asset_path):
if file_path.exists():
break
else:
# At least some file of a sequence must exist.
handle_missing_file()
return
except file_sequence.DoesNotExist:
# The asset path should point to something existing.
handle_missing_file()
return
# Handle this sequence as an asset.
self._visit_asset(asset_path, usage)
def _visit_asset(self, asset_path: pathlib.Path, usage: result.BlockUsage):
"""Determine what to do with this asset.
Determines where this asset will be packed, whether it needs rewriting,
and records the blend file data block referring to it.
"""
# Sequences are allowed to not exist at this point.
if not usage.is_sequence and not asset_path.exists():
log.warning("Missing file: %s", asset_path)
self.missing_files.add(asset_path)
self._progress_cb.missing_file(asset_path)
return
bfile_path = usage.block.bfile.filepath.absolute()
self._progress_cb.trace_asset(asset_path)
# Needing rewriting is not a per-asset thing, but a per-asset-per-
# blendfile thing, since different blendfiles can refer to it in
# different ways (for example with relative and absolute paths).
if usage.is_sequence:
first_path = next(file_sequence.expand_sequence(asset_path))
else:
first_path = asset_path
path_in_project = self._path_in_project(first_path)
use_as_is = usage.asset_path.is_blendfile_relative() and path_in_project
needs_rewriting = not use_as_is
act = self._actions[asset_path]
assert isinstance(act, AssetAction)
act.usages.append(usage)
if needs_rewriting:
log.info("%s needs rewritten path to %s", bfile_path, usage.asset_path)
act.path_action = PathAction.FIND_NEW_LOCATION
self._new_location_paths.add(asset_path)
else:
log.debug("%s can keep using %s", bfile_path, usage.asset_path)
asset_pp = self._target_path / asset_path.relative_to(self.project)
act.new_path = asset_pp
def _find_new_paths(self):
"""Find new locations in the BAT Pack for the given assets."""
for path in self._new_location_paths:
act = self._actions[path]
assert isinstance(act, AssetAction)
relpath = bpathlib.strip_root(path)
act.new_path = pathlib.Path(self._target_path, "_outside_project", relpath)
def _group_rewrites(self) -> None:
"""For each blend file, collect which fields need rewriting.
This ensures that the execute() step has to visit each blend file
only once.
"""
# Take a copy so we can modify self._actions in the loop.
actions = set(self._actions.values())
while actions:
action = actions.pop()
if action.path_action != PathAction.FIND_NEW_LOCATION:
# This asset doesn't require a new location, so no rewriting necessary.
continue
for usage in action.usages:
bfile_path = bpathlib.make_absolute(usage.block.bfile.filepath)
insert_new_action = bfile_path not in self._actions
self._actions[bfile_path].rewrites.append(usage)
if insert_new_action:
actions.add(self._actions[bfile_path])
def _path_in_project(self, path: pathlib.Path) -> bool:
abs_path = bpathlib.make_absolute(path)
abs_project = bpathlib.make_absolute(self.project)
try:
abs_path.relative_to(abs_project)
except ValueError:
return False
return True
def execute(self) -> None:
"""Execute the strategy."""
assert self._actions, "Run strategise() first"
if not self.noop:
self._rewrite_paths()
self._start_file_transferrer()
self._perform_file_transfer()
self._progress_cb.pack_done(self.output_path, self.missing_files)
def _perform_file_transfer(self):
"""Use file transferrer to do the actual file transfer.
This is performed in a separate function, so that subclasses can
override this function to queue up copy/move actions first, and
then call this function.
"""
self._write_info_file()
self._copy_files_to_target()
def _create_file_transferer(self) -> transfer.FileTransferer:
"""Create a FileCopier(), can be overridden in a subclass."""
if self.compress:
return filesystem.CompressedFileCopier()
return filesystem.FileCopier()
def _start_file_transferrer(self):
"""Starts the file transferrer thread."""
self._file_transferer = self._create_file_transferer()
self._file_transferer.progress_cb = self._tscb
if not self.noop:
self._file_transferer.start()
def _copy_files_to_target(self) -> None:
"""Copy all assets to the target directoy.
This creates the BAT Pack but does not yet do any path rewriting.
"""
log.debug("Executing %d copy actions", len(self._actions))
assert self._file_transferer is not None
try:
for asset_path, action in self._actions.items():
self._check_aborted()
self._copy_asset_and_deps(asset_path, action)
if self.noop:
log.info("Would copy %d files to %s", self._file_count, self.target)
return
self._file_transferer.done_and_join()
self._on_file_transfer_finished(file_transfer_completed=True)
except KeyboardInterrupt:
log.info("File transfer interrupted with Ctrl+C, aborting.")
self._file_transferer.abort_and_join()
self._on_file_transfer_finished(file_transfer_completed=False)
raise
finally:
self._tscb.flush()
self._check_aborted()
# Make sure that the file transferer is no longer usable, for
# example to avoid it being involved in any following call to
# self.abort().
self._file_transferer = None
def _on_file_transfer_finished(self, *, file_transfer_completed: bool) -> None:
"""Called when the file transfer is finished.
This can be used in subclasses to perform cleanup on the file transferer,
or to obtain information from it before we destroy it.
"""
def _rewrite_paths(self) -> None:
"""Rewrite paths to the new location of the assets.
Writes the rewritten blend files to a temporary location.
"""
for bfile_path, action in self._actions.items():
if not action.rewrites:
continue
self._check_aborted()
assert isinstance(bfile_path, pathlib.Path)
# bfile_pp is the final path of this blend file in the BAT pack.
# It is used to determine relative paths to other blend files.
# It is *not* used for any disk I/O, since the file may not even
# exist on the local filesystem.
bfile_pp = action.new_path
assert (
bfile_pp is not None
), f"Action {action.path_action.name} on {bfile_path} has no final path set, unable to process"
# Use tempfile to create a unique name in our temporary directoy.
# The file should be deleted when self.close() is called, and not
# when the bfile_tmp object is GC'd.
bfile_tmp = tempfile.NamedTemporaryFile(
dir=str(self._rewrite_in),
prefix="bat-",
suffix="-" + bfile_path.name,
delete=False,
)
bfile_tp = pathlib.Path(bfile_tmp.name)
action.read_from = bfile_tp
log.info("Rewriting %s to %s", bfile_path, bfile_tp)
# The original blend file will have been cached, so we can use it
# to avoid re-parsing all data blocks in the to-be-rewritten file.
bfile = blendfile.open_cached(bfile_path, assert_cached=True)
bfile.copy_and_rebind(bfile_tp, mode="rb+")
try:
self._apply_rewrites_to_blendfile(bfile, action)
if bfile.is_modified:
self._progress_cb.rewrite_blendfile(bfile_path)
finally:
# Make sure we close the file, otherwise changes may not be
# flushed before it gets copied. Also this is necessary to clean
# up the temporary directory on Windows.
bfile.close()
def _apply_rewrites_to_blendfile(
self,
bfile: blendfile.BlendFile,
action: AssetAction,
):
bfile_pp = action.new_path
assert bfile_pp is not None
for usage in action.rewrites:
self._check_aborted()
assert isinstance(usage, result.BlockUsage)
asset_pp = self._actions[usage.abspath].new_path
assert isinstance(asset_pp, pathlib.Path)
log.debug(" - %s is packed at %s", usage.asset_path, asset_pp)
relpath = bpathlib.BlendPath.mkrelative(asset_pp, bfile_pp)
if relpath == usage.asset_path:
log.info(" - %s remained at %s", usage.asset_path, relpath)
continue
log.info(" - %s moved to %s", usage.asset_path, relpath)
# Find the same block in the newly copied file.
block = bfile.dereference_pointer(usage.block.addr_old)
# Pointers can point to a non-existing data block, in which case
# either a SegmentationFault exception is thrown, or None is
# returned, based on the strict pointer mode set on the
# BlendFile class. Since this block was already meant to be
# rewritten, it was found before.
assert block is not None
if usage.path_full_field is None:
dir_field = usage.path_dir_field
assert dir_field is not None
log.debug(
" - updating field %s of block %s",
dir_field.name.name_only,
block,
)
reldir = bpathlib.BlendPath.mkrelative(asset_pp.parent, bfile_pp)
written = block.set(dir_field.name.name_only, reldir)
log.debug(" - written %d bytes", written)
# BIG FAT ASSUMPTION that the filename (e.g. basename
# without path) does not change. This makes things much
# easier, as in the sequence editor the directory and
# filename fields are in different blocks. See the
# blocks2assets.scene() function for the implementation.
else:
log.debug(
" - updating field %s of block %s",
usage.path_full_field.name.name_only,
block,
)
written = block.set(usage.path_full_field.name.name_only, relpath)
log.debug(" - written %d bytes", written)
def _copy_asset_and_deps(self, asset_path: pathlib.Path, action: AssetAction):
asset_path_is_dir = asset_path.is_dir()
# Copy the asset itself, but only if it's not a sequence (sequences are
# handled below in the for-loop).
if (
"*" not in str(asset_path)
and "<UDIM>" not in asset_path.name
and not asset_path_is_dir
):
packed_path = action.new_path
assert packed_path is not None
read_path = action.read_from or asset_path
self._send_to_target(
read_path, packed_path, may_move=action.read_from is not None
)
if asset_path_is_dir: # like 'some/directory':
asset_base_path = asset_path
else: # like 'some/directory/prefix_*.bphys':
asset_base_path = asset_path.parent
# Copy its sequence dependencies.
for usage in action.usages:
if not usage.is_sequence:
continue
first_pp = self._actions[usage.abspath].new_path
assert first_pp is not None
log.info(f"first_pp = {first_pp}")
# In case of globbing, we only support globbing by filename,
# and not by directory.
assert "*" not in str(first_pp) or "*" in first_pp.name
if asset_path_is_dir:
packed_base_dir = first_pp
else:
packed_base_dir = first_pp.parent
for file_path in usage.files():
# Compute the relative path, to support cases where asset_path
# is `some/directory` and the to-be-copied file is in
# `some/directory/subdir/filename.txt`.
relpath = file_path.relative_to(asset_base_path)
packed_path = packed_base_dir / relpath
# Assumption: assets in a sequence are never blend files.
self._send_to_target(file_path, packed_path)
# Assumption: all data blocks using this asset use it the same way.
break
def _send_to_target(
self, asset_path: pathlib.Path, target: pathlib.PurePath, may_move=False
):
if self.noop:
print("%s -> %s" % (asset_path, target))
self._file_count += 1
return
verb = "move" if may_move else "copy"
log.debug("Queueing %s of %s", verb, asset_path)
self._tscb.flush()
assert self._file_transferer is not None
if may_move:
self._file_transferer.queue_move(asset_path, target)
else:
self._file_transferer.queue_copy(asset_path, target)
def _write_info_file(self):
"""Write a little text file with info at the top of the pack."""
infoname = "pack-info.txt"
infopath = self._rewrite_in / infoname
log.debug("Writing info to %s", infopath)
with infopath.open("wt", encoding="utf8") as infofile:
print("This is a Blender Asset Tracer pack.", file=infofile)
print("Start by opening the following blend file:", file=infofile)
print(
" %s" % self._output_path.relative_to(self._target_path).as_posix(),
file=infofile,
)
self._file_transferer.queue_move(infopath, self._target_path / infoname)
def shorten_path(cwd: pathlib.Path, somepath: pathlib.Path) -> pathlib.Path:
"""Return 'somepath' relative to CWD if possible."""
try:
return somepath.relative_to(cwd)
except ValueError:
return somepath
@@ -0,0 +1,282 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import logging
import multiprocessing.pool
import pathlib
import shutil
import typing
from .. import compressor
from . import transfer
log = logging.getLogger(__name__)
class AbortTransfer(Exception):
"""Raised when an error was detected and file transfer should be aborted."""
class FileCopier(transfer.FileTransferer):
"""Copies or moves files in source directory order."""
# When we don't compress the files, the process is I/O bound,
# and trashing the storage by using multiple threads will
# only slow things down.
transfer_threads = 1 # type: typing.Optional[int]
def __init__(self):
super().__init__()
self.files_transferred = 0
self.files_skipped = 0
self.already_copied = set()
# (is_dir, action)
self.transfer_funcs = {
(False, transfer.Action.COPY): self.copyfile,
(True, transfer.Action.COPY): self.copytree,
(False, transfer.Action.MOVE): self.move,
(True, transfer.Action.MOVE): self.move,
}
def run(self) -> None:
pool = multiprocessing.pool.ThreadPool(processes=self.transfer_threads)
dst = pathlib.Path()
for src, pure_dst, act in self.iter_queue():
try:
dst = pathlib.Path(pure_dst)
if self.has_error or self._abort.is_set():
raise AbortTransfer()
if self._skip_file(src, dst, act):
continue
# We want to do this in this thread, as it's not thread safe itself.
dst.parent.mkdir(parents=True, exist_ok=True)
pool.apply_async(self._thread, (src, dst, act))
except AbortTransfer:
# either self._error or self._abort is already set. We just have to
# let the system know we didn't handle those files yet.
self.queue.put((src, dst, act), timeout=1.0)
except Exception as ex:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
if self._abort.is_set():
log.debug("Error transferring %s to %s: %s", src, dst, ex)
else:
msg = "Error transferring %s to %s" % (src, dst)
log.exception(msg)
self.error_set(msg)
# Put the files to copy back into the queue, and abort. This allows
# the main thread to inspect the queue and see which files were not
# copied. The one we just failed (due to this exception) should also
# be reported there.
self.queue.put((src, dst, act), timeout=1.0)
break
log.debug("All transfer threads queued")
pool.close()
log.debug("Waiting for transfer threads to finish")
pool.join()
log.debug("All transfer threads finished")
if self.files_transferred:
log.info("Transferred %d files", self.files_transferred)
if self.files_skipped:
log.info("Skipped %d files", self.files_skipped)
def _thread(self, src: pathlib.Path, dst: pathlib.Path, act: transfer.Action):
try:
tfunc = self.transfer_funcs[src.is_dir(), act]
if self.has_error or self._abort.is_set():
raise AbortTransfer()
log.info("%s %s -> %s", act.name, src, dst)
tfunc(src, dst)
except AbortTransfer:
# either self._error or self._abort is already set. We just have to
# let the system know we didn't handle those files yet.
self.queue.put((src, dst, act), timeout=1.0)
except Exception as ex:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
if self._abort.is_set():
log.debug("Error transferring %s to %s: %s", src, dst, ex)
else:
msg = "Error transferring %s to %s" % (src, dst)
log.exception(msg)
self.error_set(msg)
# Put the files to copy back into the queue, and abort. This allows
# the main thread to inspect the queue and see which files were not
# copied. The one we just failed (due to this exception) should also
# be reported there.
self.queue.put((src, dst, act), timeout=1.0)
def _skip_file(
self, src: pathlib.Path, dst: pathlib.Path, act: transfer.Action
) -> bool:
"""Skip this file (return True) or not (return False)."""
st_src = src.stat() # must exist, or it wouldn't be queued.
if not dst.exists():
return False
st_dst = dst.stat()
if st_dst.st_size != st_src.st_size or st_dst.st_mtime < st_src.st_mtime:
return False
log.info("SKIP %s; already exists", src)
if act == transfer.Action.MOVE:
log.debug("Deleting %s", src)
src.unlink()
self.files_skipped += 1
return True
def _move(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
"""Low-level file move."""
shutil.move(str(srcpath), str(dstpath))
def _copy(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
"""Low-level file copy. dstpath needs to be a file and not a directory."""
shutil.copyfile(str(srcpath), str(dstpath))
def move(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
s_stat = srcpath.stat()
self._move(srcpath, dstpath)
self.files_transferred += 1
self.report_transferred(s_stat.st_size)
def copyfile(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
"""Copy a file, skipping when it already exists."""
if self._abort.is_set() or self.has_error:
return
if (srcpath, dstpath) in self.already_copied:
log.debug("SKIP %s; already copied", srcpath)
return
s_stat = srcpath.stat() # must exist, or it wouldn't be queued.
if dstpath.exists():
d_stat = dstpath.stat()
if d_stat.st_size == s_stat.st_size and d_stat.st_mtime >= s_stat.st_mtime:
log.info("SKIP %s; already exists", srcpath)
self.progress_cb.transfer_file_skipped(srcpath, dstpath)
self.files_skipped += 1
return
log.debug("Copying %s -> %s", srcpath, dstpath)
self._copy(srcpath, dstpath)
self.already_copied.add((srcpath, dstpath))
self.files_transferred += 1
self.report_transferred(s_stat.st_size)
def copytree(
self,
src: pathlib.Path,
dst: pathlib.Path,
symlinks=False,
ignore_dangling_symlinks=False,
):
"""Recursively copy a directory tree.
Copy of shutil.copytree() with some changes:
- Using pathlib
- The destination directory may already exist.
- Existing files with the same file size are skipped.
- Removed ability to ignore things.
"""
if (src, dst) in self.already_copied:
log.debug("SKIP %s; already copied", src)
return
if self.has_error or self._abort.is_set():
raise AbortTransfer()
dst.mkdir(parents=True, exist_ok=True)
errors = [] # type: typing.List[typing.Tuple[pathlib.Path, pathlib.Path, str]]
for srcpath in src.iterdir():
if self.has_error or self._abort.is_set():
raise AbortTransfer()
dstpath = dst / srcpath.name
try:
if srcpath.is_symlink():
linkto = srcpath.resolve()
if symlinks:
# We can't just leave it to `copy_function` because legacy
# code with a custom `copy_function` may rely on copytree
# doing the right thing.
linkto.symlink_to(dstpath)
shutil.copystat(
str(srcpath), str(dstpath), follow_symlinks=not symlinks
)
else:
# ignore dangling symlink if the flag is on
if not linkto.exists() and ignore_dangling_symlinks:
continue
# otherwise let the copy occurs. copy2 will raise an error
if srcpath.is_dir():
self.copytree(srcpath, dstpath, symlinks)
else:
self.copyfile(srcpath, dstpath)
elif srcpath.is_dir():
self.copytree(srcpath, dstpath, symlinks)
else:
# Will raise a SpecialFileError for unsupported file types
self.copyfile(srcpath, dstpath)
# catch the Error from the recursive copytree so that we can
# continue with other files
except shutil.Error as err:
errors.extend(err.args[0])
except OSError as why:
errors.append((srcpath, dstpath, str(why)))
try:
shutil.copystat(str(src), str(dst))
except OSError as why:
# Copying file access times may fail on Windows
if getattr(why, "winerror", None) is None:
errors.append((src, dst, str(why)))
if errors:
raise shutil.Error(errors)
self.already_copied.add((src, dst))
return dst
class CompressedFileCopier(FileCopier):
# When we compress the files on the fly, the process is CPU-bound
# so we benefit greatly by multi-threading (packing a Spring scene
# lighting file took 6m30s single-threaded and 2min13 multi-threaded.
transfer_threads = None # type: typing.Optional[int]
def _move(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
compressor.move(srcpath, dstpath)
def _copy(self, srcpath: pathlib.Path, dstpath: pathlib.Path):
compressor.copy(srcpath, dstpath)
@@ -0,0 +1,154 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Callback class definition for BAT Pack progress reporting."""
import threading
import functools
import logging
import pathlib
import queue
import typing
from typing import Optional
import blender_asset_tracer.trace.progress
log = logging.getLogger(__name__)
class Callback(blender_asset_tracer.trace.progress.Callback):
"""BAT Pack progress reporting."""
def pack_start(self) -> None:
"""Called when packing starts."""
def pack_done(
self,
output_blendfile: pathlib.PurePath,
missing_files: typing.Set[pathlib.Path],
) -> None:
"""Called when packing is done."""
def pack_aborted(self, reason: str):
"""Called when packing was aborted."""
def trace_blendfile(self, filename: pathlib.Path) -> None:
"""Called for every blendfile opened when tracing dependencies."""
def trace_asset(self, filename: pathlib.Path) -> None:
"""Called for every asset found when tracing dependencies.
Note that this can also be a blend file.
"""
def rewrite_blendfile(self, orig_filename: pathlib.Path) -> None:
"""Called for every rewritten blendfile."""
def transfer_file(self, src: pathlib.Path, dst: pathlib.PurePath) -> None:
"""Called when a file transfer starts."""
def transfer_file_skipped(self, src: pathlib.Path, dst: pathlib.PurePath) -> None:
"""Called when a file is skipped because it already exists."""
def transfer_progress(self, total_bytes: int, transferred_bytes: int) -> None:
"""Called during file transfer, with per-pack info (not per file).
:param total_bytes: The total amount of bytes to be transferred for
the current packing operation. This can increase while transfer
is happening, when more files are discovered (because transfer
starts in a separate thread before all files are found).
:param transferred_bytes: The total amount of bytes transfered for
the current packing operation.
"""
def missing_file(self, filename: pathlib.Path) -> None:
"""Called for every asset that does not exist on the filesystem."""
class ThreadSafeCallback(Callback):
"""Thread-safe wrapper for Callback instances.
Progress calls are queued until flush() is called. The queued calls are
called in the same thread as the one calling flush().
"""
def __init__(self, wrapped: Callback) -> None:
self.log = log.getChild("ThreadSafeCallback")
self.wrapped = wrapped
# Thread-safe queue for passing progress reports on the main thread.
self._reporting_queue = queue.Queue() # type: queue.Queue[typing.Callable]
self._main_thread_id = threading.get_ident()
def _queue(self, func: typing.Callable, *args, **kwargs):
partial = functools.partial(func, *args, **kwargs)
if self._main_thread_id == threading.get_ident():
partial()
else:
self._reporting_queue.put(partial)
def pack_start(self) -> None:
self._queue(self.wrapped.pack_start)
def pack_done(
self,
output_blendfile: pathlib.PurePath,
missing_files: typing.Set[pathlib.Path],
) -> None:
self._queue(self.wrapped.pack_done, output_blendfile, missing_files)
def pack_aborted(self, reason: str):
self._queue(self.wrapped.pack_aborted, reason)
def trace_blendfile(self, filename: pathlib.Path) -> None:
self._queue(self.wrapped.trace_blendfile, filename)
def trace_asset(self, filename: pathlib.Path) -> None:
self._queue(self.wrapped.trace_asset, filename)
def transfer_file(self, src: pathlib.Path, dst: pathlib.PurePath) -> None:
self._queue(self.wrapped.transfer_file, src, dst)
def transfer_file_skipped(self, src: pathlib.Path, dst: pathlib.PurePath) -> None:
self._queue(self.wrapped.transfer_file_skipped, src, dst)
def transfer_progress(self, total_bytes: int, transferred_bytes: int) -> None:
self._queue(self.wrapped.transfer_progress, total_bytes, transferred_bytes)
def missing_file(self, filename: pathlib.Path) -> None:
self._queue(self.wrapped.missing_file, filename)
def flush(self, timeout: Optional[float] = None) -> None:
"""Call the queued calls, call this in the main thread."""
while True:
try:
call = self._reporting_queue.get(
block=timeout is not None, timeout=timeout
)
except queue.Empty:
return
try:
call()
except Exception:
# Don't let the handling of one callback call
# block the entire flush process.
self.log.exception("Error calling %s", call)
@@ -0,0 +1,189 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Amazon S3-compatible uploader."""
import hashlib
import logging
import pathlib
import typing
import urllib.parse
from . import Packer, transfer
log = logging.getLogger(__name__)
# TODO(Sybren): compute MD5 sums of queued files in a separate thread, so that
# we can upload a file to S3 and compute an MD5 of another file simultaneously.
def compute_md5(filepath: pathlib.Path) -> str:
log.debug("Computing MD5sum of %s", filepath)
hasher = hashlib.md5()
with filepath.open("rb") as infile:
while True:
block = infile.read(102400)
if not block:
break
hasher.update(block)
md5 = hasher.hexdigest()
log.debug("MD5sum of %s is %s", filepath, md5)
return md5
class S3Packer(Packer):
"""Creates BAT Packs on S3-compatible storage."""
def __init__(self, *args, endpoint, **kwargs) -> None:
"""Constructor
:param endpoint: URL of the S3 storage endpoint
"""
super().__init__(*args, **kwargs)
import boto3
# Create a session so that credentials can be read from the [endpoint]
# section in ~/.aws/credentials.
# See https://boto3.readthedocs.io/en/latest/guide/configuration.html#guide-configuration
components = urllib.parse.urlparse(endpoint)
profile_name = components.netloc
endpoint = urllib.parse.urlunparse(components)
log.debug("Using Boto3 profile name %r for url %r", profile_name, endpoint)
self.session = boto3.Session(profile_name=profile_name)
self.client = self.session.client("s3", endpoint_url=endpoint)
def set_credentials(
self, endpoint: str, access_key_id: str, secret_access_key: str
):
"""Set S3 credentials."""
self.client = self.session.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
)
def _create_file_transferer(self) -> transfer.FileTransferer:
return S3Transferrer(self.client)
class S3Transferrer(transfer.FileTransferer):
"""Copies or moves files in source directory order."""
class AbortUpload(Exception):
"""Raised from the upload callback to abort an upload."""
def __init__(self, botoclient) -> None:
super().__init__()
self.client = botoclient
def run(self) -> None:
files_transferred = 0
files_skipped = 0
for src, dst, act in self.iter_queue():
try:
did_upload = self.upload_file(src, dst)
files_transferred += did_upload
files_skipped += not did_upload
if act == transfer.Action.MOVE:
self.delete_file(src)
except Exception:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
log.exception("Error transferring %s to %s", src, dst)
# Put the files to copy back into the queue, and abort. This allows
# the main thread to inspect the queue and see which files were not
# copied. The one we just failed (due to this exception) should also
# be reported there.
self.queue.put((src, dst, act))
return
if files_transferred:
log.info("Transferred %d files", files_transferred)
if files_skipped:
log.info("Skipped %d files", files_skipped)
def upload_file(self, src: pathlib.Path, dst: pathlib.PurePath) -> bool:
"""Upload a file to an S3 bucket.
The first part of 'dst' is used as the bucket name, the remained as the
path inside the bucket.
:returns: True if the file was uploaded, False if it was skipped.
"""
bucket = dst.parts[0]
dst_path = pathlib.Path(*dst.parts[1:])
md5 = compute_md5(src)
key = str(dst_path)
existing_md5, existing_size = self.get_metadata(bucket, key)
if md5 == existing_md5 and src.stat().st_size == existing_size:
log.debug(
"skipping %s, it already exists on the server with MD5 %s",
src,
existing_md5,
)
return False
log.info("Uploading %s", src)
try:
self.client.upload_file(
str(src),
Bucket=bucket,
Key=key,
Callback=self.report_transferred,
ExtraArgs={"Metadata": {"md5": md5}},
)
except self.AbortUpload:
return False
return True
def report_transferred(self, bytes_transferred: int):
if self._abort.is_set():
log.warning("Interrupting ongoing upload")
raise self.AbortUpload("interrupting ongoing upload")
super().report_transferred(bytes_transferred)
def get_metadata(self, bucket: str, key: str) -> typing.Tuple[str, int]:
"""Get MD5 sum and size on S3.
:returns: the MD5 hexadecimal hash and the file size in bytes.
If the file does not exist or has no known MD5 sum,
returns ('', -1)
"""
import botocore.exceptions
log.debug("Getting metadata of %s/%s", bucket, key)
try:
info = self.client.head_object(Bucket=bucket, Key=key)
except botocore.exceptions.ClientError as ex:
error_code = ex.response.get("Error").get("Code", "Unknown")
# error_code already is a string, but this makes the code forward
# compatible with a time where they use integer codes.
if str(error_code) == "404":
return "", -1
raise ValueError("error response:" % ex.response) from None
try:
return info["Metadata"]["md5"], info["ContentLength"]
except KeyError:
return "", -1
@@ -0,0 +1,145 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2019, Blender Foundation - Sybren A. Stüvel
"""Shaman Client interface.
Note that this supports the Shaman API of Flamenco Manager 2.x. Support for
Flamenco 3.x will be implemented in a new Flamenco Blender add-on, and not in
BAT itself.
"""
import logging
import os
import pathlib
import typing
import urllib.parse
import requests
import blender_asset_tracer.pack as bat_pack
import blender_asset_tracer.pack.transfer as bat_transfer
from .transfer import ShamanTransferrer
from .client import ShamanClient
log = logging.getLogger(__name__)
class ShamanPacker(bat_pack.Packer):
"""Creates BAT Packs on a Shaman server."""
def __init__(
self,
bfile: pathlib.Path,
project: pathlib.Path,
target: str,
endpoint: str,
checkout_id: str,
**kwargs
) -> None:
"""Constructor
:param target: mock target '/' to construct project-relative paths.
:param endpoint: URL of the Shaman endpoint.
"""
super().__init__(bfile, project, target, **kwargs)
self.checkout_id = checkout_id
self.shaman_endpoint = endpoint
self._checkout_location = ""
def _get_auth_token(self) -> str:
# TODO: get a token from the Flamenco Server.
token_from_env = os.environ.get("SHAMAN_JWT_TOKEN")
if token_from_env:
return token_from_env
log.warning(
"Using temporary hack to get auth token from Shaman, "
"set SHAMAN_JTW_TOKEN to prevent"
)
unauth_shaman = ShamanClient("", self.shaman_endpoint)
resp = unauth_shaman.get("get-token", timeout=10)
resp.raise_for_status()
return resp.text
def _create_file_transferer(self) -> bat_transfer.FileTransferer:
# TODO: pass self._get_auth_token itself, so that the Transferer will be able to
# decide when to get this token (and how many times).
auth_token = self._get_auth_token()
return ShamanTransferrer(
auth_token, self.project, self.shaman_endpoint, self.checkout_id
)
def _make_target_path(self, target: str) -> pathlib.PurePath:
return pathlib.PurePosixPath("/")
def _on_file_transfer_finished(self, *, file_transfer_completed: bool):
super()._on_file_transfer_finished(
file_transfer_completed=file_transfer_completed
)
assert isinstance(self._file_transferer, ShamanTransferrer)
self._checkout_location = self._file_transferer.checkout_location
@property
def checkout_location(self) -> str:
"""Return the checkout location of the packed blend file.
:return: the checkout location, or '' if no checkout was made.
"""
return self._checkout_location
@property
def output_path(self) -> pathlib.PurePath:
"""The path of the packed blend file in the target directory."""
assert self._output_path is not None
checkout_location = pathlib.PurePosixPath(self._checkout_location)
rel_output = self._output_path.relative_to(self._target_path)
return checkout_location / rel_output
def execute(self):
try:
super().execute()
except requests.exceptions.ConnectionError as ex:
log.exception("Error communicating with Shaman")
self.abort(str(ex))
self._check_aborted()
def parse_endpoint(shaman_url: str) -> typing.Tuple[str, str]:
"""Convert shaman://hostname/path#checkoutID into endpoint URL + checkout ID."""
urlparts = urllib.parse.urlparse(str(shaman_url))
if urlparts.scheme in {"shaman", "shaman+https"}:
scheme = "https"
elif urlparts.scheme == "shaman+http":
scheme = "http"
else:
raise ValueError(
"Invalid scheme %r, choose shaman:// or shaman+http://", urlparts.scheme
)
checkout_id = urllib.parse.unquote(urlparts.fragment)
path = urlparts.path or "/"
new_urlparts = (scheme, urlparts.netloc, path, *urlparts[3:-1], "")
endpoint = urllib.parse.urlunparse(new_urlparts)
return endpoint, checkout_id
@@ -0,0 +1,205 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2019, Blender Foundation - Sybren A. Stüvel
import base64
import hashlib
import json
import logging
import sys
import time
import typing
from collections import deque
from pathlib import Path
from . import time_tracker
CACHE_ROOT = Path().home() / ".cache/shaman-client/shasums"
MAX_CACHE_FILES_AGE_SECS = 3600 * 24 * 60 # 60 days
log = logging.getLogger(__name__)
class TimeInfo:
computing_checksums = 0.0
checksum_cache_handling = 0.0
def find_files(root: Path) -> typing.Iterable[Path]:
"""Recursively finds files in the given root path.
Directories are recursed into, and file paths are yielded.
Symlinks are yielded if they refer to a regular file.
"""
queue = deque([root])
while queue:
path = queue.popleft()
# Ignore hidden files/dirs; these can be things like '.svn' or '.git',
# which shouldn't be sent to Shaman.
if path.name.startswith("."):
continue
if path.is_dir():
for child in path.iterdir():
queue.append(child)
continue
# Only yield symlinks if they link to (a link to) a normal file.
if path.is_symlink():
symlinked = path.resolve()
if symlinked.is_file():
yield path
continue
if path.is_file():
yield path
def compute_checksum(filepath: Path) -> str:
"""Compute the SHA256 checksum for the given file."""
blocksize = 32 * 1024
log.debug("Computing checksum of %s", filepath)
with time_tracker.track_time(TimeInfo, "computing_checksums"):
hasher = hashlib.sha256()
with filepath.open("rb") as infile:
while True:
block = infile.read(blocksize)
if not block:
break
hasher.update(block)
checksum = hasher.hexdigest()
return checksum
def _cache_path(filepath: Path) -> Path:
"""Compute the cache file for the given file path."""
fs_encoding = sys.getfilesystemencoding()
filepath = filepath.absolute()
# Reverse the directory, because most variation is in the last bytes.
rev_dir = str(filepath.parent)[::-1]
encoded_path = filepath.stem + rev_dir + filepath.suffix
cache_key = (
base64.urlsafe_b64encode(encoded_path.encode(fs_encoding)).decode().rstrip("=")
)
cache_path = CACHE_ROOT / cache_key[:10] / cache_key[10:]
return cache_path
def compute_cached_checksum(filepath: Path) -> str:
"""Computes the SHA256 checksum.
The checksum is cached to disk. If the cache is still valid, it is used to
skip the actual SHA256 computation.
"""
with time_tracker.track_time(TimeInfo, "checksum_cache_handling"):
current_stat = filepath.stat()
cache_path = _cache_path(filepath)
try:
with cache_path.open("r") as cache_file:
payload = json.load(cache_file)
except (OSError, ValueError):
# File may not exist, or have invalid contents.
pass
else:
checksum = payload.get("checksum", "")
cached_mtime = payload.get("file_mtime", 0.0)
cached_size = payload.get("file_size", -1)
if (
checksum
and current_stat.st_size == cached_size
and abs(cached_mtime - current_stat.st_mtime) < 0.01
):
cache_path.touch()
return checksum
checksum = compute_checksum(filepath)
with time_tracker.track_time(TimeInfo, "checksum_cache_handling"):
payload = {
"checksum": checksum,
"file_mtime": current_stat.st_mtime,
"file_size": current_stat.st_size,
}
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("w") as cache_file:
json.dump(payload, cache_file)
except IOError as ex:
log.warning("Unable to write checksum cache file %s: %s", cache_path, ex)
return checksum
def cleanup_cache() -> None:
"""Remove all cache files that are older than MAX_CACHE_FILES_AGE_SECS."""
if not CACHE_ROOT.exists():
return
with time_tracker.track_time(TimeInfo, "checksum_cache_handling"):
queue = deque([CACHE_ROOT])
rmdir_queue = []
now = time.time()
num_removed_files = 0
num_removed_dirs = 0
while queue:
path = queue.popleft()
if path.is_dir():
queue.extend(path.iterdir())
rmdir_queue.append(path)
continue
assert path.is_file()
path.relative_to(CACHE_ROOT)
age = now - path.stat().st_mtime
# Don't trust files from the future either.
if 0 <= age <= MAX_CACHE_FILES_AGE_SECS:
continue
path.unlink()
num_removed_files += 1
for dirpath in reversed(rmdir_queue):
assert dirpath.is_dir()
dirpath.relative_to(CACHE_ROOT)
try:
dirpath.rmdir()
num_removed_dirs += 1
except OSError:
pass
if num_removed_dirs or num_removed_files:
log.info(
"Cache Cleanup: removed %d dirs and %d files",
num_removed_dirs,
num_removed_files,
)
@@ -0,0 +1,129 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2019, Blender Foundation - Sybren A. Stüvel
import urllib.parse
import requests.packages.urllib3.util.retry
import requests.adapters
class ShamanClient:
"""Thin wrapper around a Requests session to perform Shaman requests."""
def __init__(self, auth_token: str, base_url: str):
self._auth_token = auth_token
self._base_url = base_url
retries = requests.packages.urllib3.util.retry.Retry(
total=10,
backoff_factor=0.05,
)
http_adapter = requests.adapters.HTTPAdapter(max_retries=retries)
self._session = requests.session()
self._session.mount("https://", http_adapter)
self._session.mount("http://", http_adapter)
if auth_token:
self._session.headers["Authorization"] = "Bearer " + auth_token
def request(self, method: str, url: str, **kwargs) -> requests.Response:
kwargs.setdefault("timeout", 300)
full_url = urllib.parse.urljoin(self._base_url, url)
return self._session.request(method, full_url, **kwargs)
def get(self, url, **kwargs):
r"""Sends a GET request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
kwargs.setdefault("allow_redirects", True)
return self.request("GET", url, **kwargs)
def options(self, url, **kwargs):
r"""Sends a OPTIONS request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
kwargs.setdefault("allow_redirects", True)
return self.request("OPTIONS", url, **kwargs)
def head(self, url, **kwargs):
r"""Sends a HEAD request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
kwargs.setdefault("allow_redirects", False)
return self.request("HEAD", url, **kwargs)
def post(self, url, data=None, json=None, **kwargs):
r"""Sends a POST request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
object to send in the body of the :class:`Request`.
:param json: (optional) json to send in the body of the :class:`Request`.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
return self.request("POST", url, data=data, json=json, **kwargs)
def put(self, url, data=None, **kwargs):
r"""Sends a PUT request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
object to send in the body of the :class:`Request`.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
return self.request("PUT", url, data=data, **kwargs)
def patch(self, url, data=None, **kwargs):
r"""Sends a PATCH request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
object to send in the body of the :class:`Request`.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
return self.request("PATCH", url, data=data, **kwargs)
def delete(self, url, **kwargs):
r"""Sends a DELETE request. Returns :class:`Response` object.
:param url: URL for the new :class:`Request` object.
:param kwargs: Optional arguments that ``request`` takes.
:rtype: requests.Response
"""
return self.request("DELETE", url, **kwargs)
@@ -0,0 +1,32 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2019, Blender Foundation - Sybren A. Stüvel
import contextlib
import time
import typing
@contextlib.contextmanager
def track_time(tracker_object: typing.Any, attribute: str):
"""Context manager, tracks how long the context took to run."""
start_time = time.monotonic()
yield
duration = time.monotonic() - start_time
tracked_so_far = getattr(tracker_object, attribute, 0.0)
setattr(tracker_object, attribute, tracked_so_far + duration)
@@ -0,0 +1,404 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2019, Blender Foundation - Sybren A. Stüvel
import collections
import logging
import pathlib
import random
import typing
import requests
import blender_asset_tracer.pack.transfer as bat_transfer
from blender_asset_tracer import bpathlib
MAX_DEFERRED_PATHS = 8
MAX_FAILED_PATHS = 8
response_file_unknown = "file-unknown"
response_already_uploading = "already-uploading"
class FileInfo:
def __init__(self, checksum: str, filesize: int, abspath: pathlib.Path):
self.checksum = checksum
self.filesize = filesize
self.abspath = abspath
class ShamanTransferrer(bat_transfer.FileTransferer):
"""Sends files to a Shaman server."""
class AbortUpload(Exception):
"""Raised from the upload callback to abort an upload."""
def __init__(
self,
auth_token: str,
project_root: pathlib.Path,
shaman_endpoint: str,
checkout_id: str,
) -> None:
from . import client
super().__init__()
self.client = client.ShamanClient(auth_token, shaman_endpoint)
self.project_root = project_root
self.checkout_id = checkout_id
self.log = logging.getLogger(__name__)
self._file_info = {} # type: typing.Dict[str, FileInfo]
# When the Shaman creates a checkout, it'll return the location of that
# checkout. This can then be combined with the project-relative path
# of the to-be-rendered blend file (e.g. the one 'bat pack' was pointed
# at).
self._checkout_location = ""
self.uploaded_files = 0
self.uploaded_bytes = 0
# noinspection PyBroadException
def run(self) -> None:
try:
self.uploaded_files = 0
self.uploaded_bytes = 0
# Construct the Shaman Checkout Definition file.
# This blocks until we know the entire list of files to transfer.
(
definition_file,
allowed_relpaths,
delete_when_done,
) = self._create_checkout_definition()
if not definition_file:
# An error has already been logged.
return
self.log.info(
"Created checkout definition file of %d KiB",
len(definition_file) // 1024,
)
self.log.info("Feeding %d files to the Shaman", len(self._file_info))
if self.log.isEnabledFor(logging.INFO):
for path in self._file_info:
self.log.info(" - %s", path)
# Try to upload all the files.
failed_paths = set() # type: typing.Set[str]
max_tries = 50
for try_index in range(max_tries):
# Send the file to the Shaman and see what we still need to send there.
to_upload = self._send_checkout_def_to_shaman(
definition_file, allowed_relpaths
)
if to_upload is None:
# An error has already been logged.
return
if not to_upload:
break
# Send the files that still need to be sent.
self.log.info("Upload attempt %d", try_index + 1)
failed_paths = self._upload_files(to_upload)
if not failed_paths:
break
# Having failed paths at this point is expected when multiple
# clients are sending the same files. Instead of retrying on a
# file-by-file basis, we just re-send the checkout definition
# file to the Shaman and obtain a new list of files to upload.
if failed_paths:
self.log.error("Aborting upload due to too many failures")
self.error_set(
"Giving up after %d attempts to upload the files" % max_tries
)
return
self.log.info("All files uploaded succesfully")
self._request_checkout(definition_file)
# Delete the files that were supposed to be moved.
for src in delete_when_done:
self.delete_file(src)
except Exception as ex:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
self.log.exception("Error transferring files to Shaman")
self.error_set("Unexpected exception transferring files to Shaman: %s" % ex)
# noinspection PyBroadException
def _create_checkout_definition(
self,
) -> typing.Tuple[bytes, typing.Set[str], typing.List[pathlib.Path]]:
"""Create the checkout definition file for this BAT pack.
:returns: the checkout definition (as bytes), a set of paths in that file,
and list of paths to delete.
If there was an error and file transfer was aborted, the checkout
definition file will be empty.
"""
from . import cache
definition_lines = [] # type: typing.List[bytes]
delete_when_done = [] # type: typing.List[pathlib.Path]
# We keep track of the relative paths we want to send to the Shaman,
# so that the Shaman cannot ask us to upload files we didn't want to.
relpaths = set() # type: typing.Set[str]
for src, dst, act in self.iter_queue():
try:
checksum = cache.compute_cached_checksum(src)
filesize = src.stat().st_size
# relpath = dst.relative_to(self.project_root)
relpath = bpathlib.strip_root(dst).as_posix()
self._file_info[relpath] = FileInfo(
checksum=checksum,
filesize=filesize,
abspath=src,
)
line = "%s %s %s" % (checksum, filesize, relpath)
definition_lines.append(line.encode("utf8"))
relpaths.add(relpath)
if act == bat_transfer.Action.MOVE:
delete_when_done.append(src)
except Exception:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
msg = "Error transferring %s to %s" % (src, dst)
self.log.exception(msg)
# Put the files to copy back into the queue, and abort. This allows
# the main thread to inspect the queue and see which files were not
# copied. The one we just failed (due to this exception) should also
# be reported there.
self.queue.put((src, dst, act))
self.error_set(msg)
return b"", set(), delete_when_done
cache.cleanup_cache()
return b"\n".join(definition_lines), relpaths, delete_when_done
def _send_checkout_def_to_shaman(
self, definition_file: bytes, allowed_relpaths: typing.Set[str]
) -> typing.Optional[collections.deque]:
"""Send the checkout definition file to the Shaman.
:return: An iterable of paths (relative to the project root) that still
need to be uploaded, or None if there was an error.
"""
resp = self.client.post(
"checkout/requirements",
data=definition_file,
stream=True,
headers={"Content-Type": "text/plain"},
timeout=15,
)
if resp.status_code >= 300:
msg = "Error from Shaman, code %d: %s" % (resp.status_code, resp.text)
self.log.error(msg)
self.error_set(msg)
return None
to_upload = collections.deque() # type: collections.deque
for line in resp.iter_lines():
response, path = line.decode().split(" ", 1)
self.log.debug(" %s: %s", response, path)
if path not in allowed_relpaths:
msg = "Shaman requested path we did not intend to upload: %r" % path
self.log.error(msg)
self.error_set(msg)
return None
if response == response_file_unknown:
to_upload.appendleft(path)
elif response == response_already_uploading:
to_upload.append(path)
elif response == "ERROR":
msg = "Error from Shaman: %s" % path
self.log.error(msg)
self.error_set(msg)
return None
else:
msg = "Unknown response from Shaman for path %r: %r" % (path, response)
self.log.error(msg)
self.error_set(msg)
return None
return to_upload
def _upload_files(self, to_upload: collections.deque) -> typing.Set[str]:
"""Actually upload the files to Shaman.
Returns the set of files that we did not upload.
"""
failed_paths = set() # type: typing.Set[str]
deferred_paths = set()
def defer(some_path: str):
nonlocal to_upload
self.log.info(
" %s deferred (already being uploaded by someone else)", some_path
)
deferred_paths.add(some_path)
# Instead of deferring this one file, randomize the files to upload.
# This prevents multiple deferrals when someone else is uploading
# files from the same project (because it probably happens alphabetically).
all_files = list(to_upload)
random.shuffle(all_files)
to_upload = collections.deque(all_files)
if not to_upload:
self.log.info(
"All %d files are at the Shaman already", len(self._file_info)
)
self.report_transferred(0)
return failed_paths
self.log.info(
"Going to upload %d of %d files", len(to_upload), len(self._file_info)
)
while to_upload:
# After too many failures, just retry to get a fresh set of files to upload.
if len(failed_paths) > MAX_FAILED_PATHS:
self.log.info("Too many failures, going to abort this iteration")
failed_paths.update(to_upload)
return failed_paths
path = to_upload.popleft()
fileinfo = self._file_info[path]
self.log.info(" %s", path)
headers = {
"X-Shaman-Original-Filename": path,
}
# Let the Shaman know whether we can defer uploading this file or not.
can_defer = (
len(deferred_paths) < MAX_DEFERRED_PATHS
and path not in deferred_paths
and len(to_upload)
)
if can_defer:
headers["X-Shaman-Can-Defer-Upload"] = "true"
url = "files/%s/%d" % (fileinfo.checksum, fileinfo.filesize)
try:
with fileinfo.abspath.open("rb") as infile:
resp = self.client.post(url, data=infile, headers=headers)
except requests.ConnectionError as ex:
if can_defer:
# Closing the connection with an 'X-Shaman-Can-Defer-Upload: true' header
# indicates that we should defer the upload. Requests doesn't give us the
# reply, even though it was written by the Shaman before it closed the
# connection.
defer(path)
else:
self.log.info(
" %s could not be uploaded, might retry later: %s", path, ex
)
failed_paths.add(path)
continue
if resp.status_code == 208:
# For small files we get the 208 response, because the server closes the
# connection after we sent the entire request. For bigger files the server
# responds sooner, and Requests gives us the above ConnectionError.
if can_defer:
defer(path)
else:
self.log.info(" %s skipped (already existed on the server)", path)
continue
if resp.status_code >= 300:
msg = "Error from Shaman uploading %s, code %d: %s" % (
fileinfo.abspath,
resp.status_code,
resp.text,
)
self.log.error(msg)
self.error_set(msg)
return failed_paths
failed_paths.discard(path)
self.uploaded_files += 1
file_size = fileinfo.abspath.stat().st_size
self.uploaded_bytes += file_size
self.report_transferred(file_size)
if not failed_paths:
self.log.info(
"Done uploading %d bytes in %d files",
self.uploaded_bytes,
self.uploaded_files,
)
else:
self.log.info(
"Uploaded %d bytes in %d files so far",
self.uploaded_bytes,
self.uploaded_files,
)
return failed_paths
def report_transferred(self, bytes_transferred: int):
if self._abort.is_set():
self.log.warning("Interrupting ongoing upload")
raise self.AbortUpload("interrupting ongoing upload")
super().report_transferred(bytes_transferred)
def _request_checkout(self, definition_file: bytes):
"""Ask the Shaman to create a checkout of this BAT pack."""
if not self.checkout_id:
self.log.warning("NOT requesting checkout at Shaman")
return
self.log.info(
"Requesting checkout at Shaman for checkout_id=%r", self.checkout_id
)
resp = self.client.post(
"checkout/create/%s" % self.checkout_id,
data=definition_file,
headers={"Content-Type": "text/plain"},
)
if resp.status_code >= 300:
msg = "Error from Shaman, code %d: %s" % (resp.status_code, resp.text)
self.log.error(msg)
self.error_set(msg)
return
self._checkout_location = resp.text.strip()
self.log.info("Response from Shaman, code %d: %s", resp.status_code, resp.text)
@property
def checkout_location(self) -> str:
"""Returns the checkout location, or '' if no checkout was made."""
if not self._checkout_location:
return ""
return self._checkout_location
@@ -0,0 +1,242 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import abc
import enum
import logging
import pathlib
import queue
import threading
import time
import typing
from typing import Optional
from . import progress
log = logging.getLogger(__name__)
class FileTransferError(IOError):
"""Raised when one or more files could not be transferred."""
def __init__(self, message, files_remaining: typing.List[pathlib.Path]) -> None:
super().__init__(message)
self.files_remaining = files_remaining
class Action(enum.Enum):
COPY = 1
MOVE = 2
QueueItem = typing.Tuple[pathlib.Path, pathlib.PurePath, Action]
class FileTransferer(threading.Thread, metaclass=abc.ABCMeta):
"""Abstract superclass for file transfer classes.
Implement a run() function in a subclass that performs the actual file
transfer.
"""
def __init__(self) -> None:
super().__init__()
self.log = log.getChild("FileTransferer")
# For copying in a different process. By using a priority queue the files
# are automatically sorted alphabetically, which means we go through all files
# in a single directory at a time. This should be faster to copy than random
# access. The order isn't guaranteed, though, as we're not waiting around for
# all file paths to be known before copying starts.
# maxsize=100 is just a guess as to a reasonable upper limit. When this limit
# is reached, the main thread will simply block while waiting for this thread
# to finish copying a file.
self.queue = queue.PriorityQueue(
maxsize=100
) # type: queue.PriorityQueue[QueueItem]
self.done = threading.Event()
self._abort = threading.Event() # Indicates user-requested abort
self.__error_mutex = threading.Lock()
self.__error = threading.Event() # Indicates abort due to some error
self.__error_message = ""
# Instantiate a dummy progress callback so that we can call it
# without checking for None all the time.
self.progress_cb = progress.ThreadSafeCallback(progress.Callback())
self.total_queued_bytes = 0
self.total_transferred_bytes = 0
@abc.abstractmethod
def run(self):
"""Perform actual file transfer in a thread."""
def queue_copy(self, src: pathlib.Path, dst: pathlib.PurePath):
"""Queue a copy action from 'src' to 'dst'."""
if src.is_dir():
raise TypeError(f"only files can be copied, not directories: {src}")
assert (
not self.done.is_set()
), "Queueing not allowed after done_and_join() was called"
assert (
not self._abort.is_set()
), "Queueing not allowed after abort_and_join() was called"
if self.__error.is_set():
return
self.queue.put((src, dst, Action.COPY))
self.total_queued_bytes += src.stat().st_size
def queue_move(self, src: pathlib.Path, dst: pathlib.PurePath):
"""Queue a move action from 'src' to 'dst'."""
if src.is_dir():
raise TypeError(f"only files can be moved, not directories: {src}")
assert (
not self.done.is_set()
), "Queueing not allowed after done_and_join() was called"
assert (
not self._abort.is_set()
), "Queueing not allowed after abort_and_join() was called"
if self.__error.is_set():
return
self.queue.put((src, dst, Action.MOVE))
self.total_queued_bytes += src.stat().st_size
def report_transferred(self, bytes_transferred: int):
"""Report transfer of `block_size` bytes."""
self.total_transferred_bytes += bytes_transferred
self.progress_cb.transfer_progress(
self.total_queued_bytes, self.total_transferred_bytes
)
def done_and_join(self) -> None:
"""Indicate all files have been queued, and wait until done.
After this function has been called, the queue_xxx() methods should not
be called any more.
:raises FileTransferError: if there was an error transferring one or
more files.
"""
self.done.set()
self.join()
if not self.queue.empty():
# Flush the queue so that we can report which files weren't copied yet.
files_remaining = self._files_remaining()
assert files_remaining
raise FileTransferError(
"%d files couldn't be transferred" % len(files_remaining),
files_remaining,
)
def _files_remaining(self) -> typing.List[pathlib.Path]:
"""Source files that were queued but not transferred."""
files_remaining = []
while not self.queue.empty():
src, dst, act = self.queue.get_nowait()
files_remaining.append(src)
return files_remaining
def abort(self) -> None:
"""Abort the file transfer, immediately returns."""
log.info("Aborting")
self._abort.set()
def abort_and_join(self) -> None:
"""Abort the file transfer, and wait until done."""
self.abort()
self.join()
files_remaining = self._files_remaining()
if not files_remaining:
return
log.warning(
"%d files couldn't be transferred, starting with %s",
len(files_remaining),
files_remaining[0],
)
def iter_queue(self) -> typing.Iterable[QueueItem]:
"""Generator, yield queued items until the work is done."""
while True:
if self._abort.is_set() or self.__error.is_set():
return
try:
src, dst, action = self.queue.get(timeout=0.5)
self.progress_cb.transfer_file(src, dst)
yield src, dst, action
except queue.Empty:
if self.done.is_set():
return
def join(self, timeout: Optional[float] = None) -> None:
"""Wait for the transfer to finish/stop."""
if timeout:
run_until = time.time() + timeout
else:
run_until = float("inf")
# We can't simply block the thread, we have to keep watching the
# progress queue.
while self.is_alive():
if time.time() > run_until:
self.log.warning("Timeout while waiting for transfer to finish")
return
self.progress_cb.flush(timeout=0.5)
# Since Thread.join() neither returns anything nor raises any exception
# when timing out, we don't even have to call it any more.
def delete_file(self, path: pathlib.Path):
"""Deletes a file, only logging a warning if deletion fails."""
log.debug("Deleting %s, file has been transferred", path)
try:
path.unlink()
except IOError as ex:
log.warning("Unable to delete %s: %s", path, ex)
@property
def has_error(self) -> bool:
return self.__error.is_set()
def error_set(self, message: str):
"""Indicate an error occurred, and provide a message."""
with self.__error_mutex:
# Avoid overwriting previous error messages.
if self.__error.is_set():
return
self.__error.set()
self.__error_message = message
def error_message(self) -> str:
"""Retrieve the error messsage, or an empty string if no error occurred."""
with self.__error_mutex:
if not self.__error.is_set():
return ""
return self.__error_message
@@ -0,0 +1,91 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""ZIP file packer.
Note: There is no official file name encoding for ZIP files. Expect trouble
when you want to use the ZIP cross-platform and you have non-ASCII names.
"""
import logging
import pathlib
from . import Packer, transfer
log = logging.getLogger(__name__)
# Suffixes to store uncompressed in the zip.
STORE_ONLY = {".jpg", ".jpeg", ".exr"}
class ZipPacker(Packer):
"""Creates a zipped BAT Pack instead of a directory."""
def _create_file_transferer(self) -> transfer.FileTransferer:
target_path = pathlib.Path(self._target_path)
return ZipTransferrer(target_path.absolute())
class ZipTransferrer(transfer.FileTransferer):
"""Creates a ZIP file instead of writing to a directory.
Note: There is no official file name encoding for ZIP files. If you have
unicode file names, they will be encoded as UTF-8. WinZip interprets all
file names as encoded in CP437, also known as DOS Latin.
"""
def __init__(self, zippath: pathlib.Path) -> None:
super().__init__()
self.zippath = zippath
def run(self) -> None:
import zipfile
zippath = self.zippath.absolute()
with zipfile.ZipFile(str(zippath), "w") as outzip:
for src, dst, act in self.iter_queue():
assert src.is_absolute(), "expecting only absolute paths, not %r" % src
dst = pathlib.Path(dst).absolute()
try:
relpath = dst.relative_to(zippath)
# Don't bother trying to compress already-compressed files.
if src.suffix.lower() in STORE_ONLY:
compression = zipfile.ZIP_STORED
log.debug("ZIP %s -> %s (uncompressed)", src, relpath)
else:
compression = zipfile.ZIP_DEFLATED
log.debug("ZIP %s -> %s", src, relpath)
outzip.write(
str(src), arcname=str(relpath), compress_type=compression
)
if act == transfer.Action.MOVE:
self.delete_file(src)
except Exception:
# We have to catch exceptions in a broad way, as this is running in
# a separate thread, and exceptions won't otherwise be seen.
log.exception("Error transferring %s to %s", src, dst)
# Put the files to copy back into the queue, and abort. This allows
# the main thread to inspect the queue and see which files were not
# copied. The one we just failed (due to this exception) should also
# be reported there.
self.queue.put((src, dst, act))
return
@@ -0,0 +1,84 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import logging
import pathlib
import typing
from blender_asset_tracer import blendfile
from . import result, blocks2assets, file2blocks, progress
log = logging.getLogger(__name__)
codes_to_skip = {
# These blocks never have external assets:
b"ID",
b"WM",
b"SN",
# These blocks are skipped for now, until we have proof they point to
# assets otherwise missed:
b"GR",
b"WO",
b"BR",
b"LS",
}
def deps(
bfilepath: pathlib.Path, progress_cb: typing.Optional[progress.Callback] = None
) -> typing.Iterator[result.BlockUsage]:
"""Open the blend file and report its dependencies.
:param bfilepath: File to open.
:param progress_cb: Progress callback object.
"""
bi = file2blocks.BlockIterator()
if progress_cb:
bi.progress_cb = progress_cb
bfile = bi.open_blendfile(bfilepath)
# Remember which block usages we've reported already, without keeping the
# blocks themselves in memory.
seen_hashes = set() # type: typing.Set[int]
for block in asset_holding_blocks(bi.iter_blocks(bfile)):
for block_usage in blocks2assets.iter_assets(block):
usage_hash = hash(block_usage)
if usage_hash in seen_hashes:
continue
seen_hashes.add(usage_hash)
yield block_usage
def asset_holding_blocks(
blocks: typing.Iterable[blendfile.BlendFileBlock],
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield data blocks that could reference external assets."""
for block in blocks:
assert isinstance(block, blendfile.BlendFileBlock)
code = block.code
# The longer codes are either arbitrary data or data blocks that
# don't refer to external assets. The former data blocks will be
# visited when we hit the two-letter datablocks that use them.
if len(code) > 2 or code in codes_to_skip:
continue
yield block
@@ -0,0 +1,258 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Block walkers.
From a Blend file data block, iter_assts() yields all the referred-to assets.
"""
import functools
import logging
import typing
from blender_asset_tracer import blendfile, bpathlib, cdefs
from blender_asset_tracer.blendfile import iterators
from . import result, modifier_walkers
log = logging.getLogger(__name__)
_warned_about_types = set() # type: typing.Set[bytes]
_funcs_for_code = {} # type: typing.Dict[bytes, typing.Callable]
def iter_assets(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Generator, yield the assets used by this data block."""
assert block.code != b"DATA"
try:
block_reader = _funcs_for_code[block.code]
except KeyError:
if block.code in _warned_about_types:
return
blocktype = block.code.decode()
filepath = block.get(b"filepath", "", as_str=True)
if filepath:
log.debug(
"No reader implemented for block type %r (filepath=%r)",
blocktype,
filepath,
)
else:
log.debug("No reader implemented for block type %r", blocktype)
_warned_about_types.add(block.code)
return
log.debug("Tracing block %r", block)
yield from block_reader(block)
def dna_code(block_code: str):
"""Decorator, marks decorated func as handler for that DNA code."""
assert isinstance(block_code, str)
def decorator(wrapped):
_funcs_for_code[block_code.encode()] = wrapped
return wrapped
return decorator
def skip_packed(wrapped):
"""Decorator, skip blocks where 'packedfile' is set to true."""
@functools.wraps(wrapped)
def wrapper(block: blendfile.BlendFileBlock, *args, **kwargs):
if block.get(b"packedfile", default=False):
log.debug("Datablock %r is packed; skipping", block.id_name)
return
yield from wrapped(block, *args, **kwargs)
return wrapper
@dna_code("CF")
def cache_file(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Cache file data blocks."""
path, field = block.get(b"filepath", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code("IM")
@skip_packed
def image(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Image data blocks."""
# old files miss this
image_source = block.get(b"source", default=cdefs.IMA_SRC_FILE)
if image_source not in {
cdefs.IMA_SRC_FILE,
cdefs.IMA_SRC_SEQUENCE,
cdefs.IMA_SRC_MOVIE,
cdefs.IMA_SRC_TILED,
}:
log.debug("skiping image source type %s", image_source)
return
pathname, field = block.get(b"name", return_field=True)
is_sequence = image_source in {cdefs.IMA_SRC_SEQUENCE, cdefs.IMA_SRC_TILED}
yield result.BlockUsage(block, pathname, is_sequence, path_full_field=field)
@dna_code("LI")
@skip_packed
def library(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Library data blocks."""
path, field = block.get(b"name", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
# The 'filepath' also points to the blend file. However, this is set to the
# absolute path of the file by Blender (see BKE_library_filepath_set). This
# is thus not a property we have to report or rewrite.
@dna_code("ME")
def mesh(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Mesh data blocks."""
block_external = block.get_pointer((b"ldata", b"external"), None)
if block_external is None:
block_external = block.get_pointer((b"fdata", b"external"), None)
if block_external is None:
return
path, field = block_external.get(b"filename", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code("MC")
def movie_clip(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""MovieClip data blocks."""
path, field = block.get(b"name", return_field=True)
# TODO: The assumption that this is not a sequence may not be true for all modifiers.
yield result.BlockUsage(block, path, is_sequence=False, path_full_field=field)
@dna_code("OB")
def object_block(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Object data blocks."""
ctx = modifier_walkers.ModifierContext(owner=block)
# 'ob->modifiers[...].filepath'
for mod_idx, block_mod in enumerate(iterators.modifiers(block)):
block_name = b"%s.modifiers[%d]" % (block.id_name, mod_idx)
mod_type = block_mod[b"modifier", b"type"]
log.debug("Tracing modifier %s, type=%d", block_name.decode(), mod_type)
try:
mod_handler = modifier_walkers.modifier_handlers[mod_type]
except KeyError:
continue
yield from mod_handler(ctx, block_mod, block_name)
@dna_code("SC")
def scene(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Scene data blocks."""
# Sequence editor is the only interesting bit.
block_ed = block.get_pointer(b"ed")
if block_ed is None:
return
single_asset_types = {
cdefs.SEQ_TYPE_MOVIE,
cdefs.SEQ_TYPE_SOUND_RAM,
cdefs.SEQ_TYPE_SOUND_HD,
}
asset_types = single_asset_types.union({cdefs.SEQ_TYPE_IMAGE})
for seq, seq_type in iterators.sequencer_strips(block_ed):
if seq_type not in asset_types:
continue
seq_strip = seq.get_pointer(b"strip")
if seq_strip is None:
continue
seq_stripdata = seq_strip.get_pointer(b"stripdata")
if seq_stripdata is None:
continue
dirname, dn_field = seq_strip.get(b"dir", return_field=True)
basename, bn_field = seq_stripdata.get(b"name", return_field=True)
asset_path = bpathlib.BlendPath(dirname) / basename
is_sequence = seq_type not in single_asset_types
yield result.BlockUsage(
seq_strip,
asset_path,
is_sequence=is_sequence,
path_dir_field=dn_field,
path_base_field=bn_field,
)
@dna_code("SO")
@skip_packed
def sound(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Sound data blocks."""
path, field = block.get(b"name", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code("VF")
@skip_packed
def vector_font(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Vector Font data blocks."""
path, field = block.get(b"name", return_field=True)
if path == b"<builtin>": # builtin font
return
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code("LA")
@skip_packed
def lamp(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Lamp data blocks."""
block_ntree = block.get_pointer(b"nodetree", None)
if block_ntree is None:
return
for node in iterators.listbase(block_ntree.get_pointer((b"nodes", b"first"))):
storage = node.get_pointer(b"storage")
if not storage:
continue
# A storage block of `NodeShaderTexIES` type has a `filepath`, but not
# all types that can be pointed to by the `storage` pointer do.
path, field = storage.get(b"filepath", return_field=True, default=None)
if path is None:
continue
yield result.BlockUsage(storage, path, path_full_field=field)
@dna_code("VO")
@skip_packed
def open_vdb(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""OpenVDB data blocks."""
path, field = block.get(b"filepath", return_field=True)
is_sequence = bool(block.get(b"is_sequence"))
yield result.BlockUsage(block, path, path_full_field=field, is_sequence=is_sequence)
@@ -0,0 +1,368 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2014, Blender Foundation - Campbell Barton
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Low-level functions called by file2block.
Those can expand data blocks and yield their dependencies (e.g. other data
blocks necessary to render/display/work with the given data block).
"""
import logging
import typing
from blender_asset_tracer import blendfile, cdefs
from blender_asset_tracer.blendfile import iterators
# Don't warn about these types at all.
_warned_about_types = {b"LI", b"DATA"}
_funcs_for_code = {} # type: typing.Dict[bytes, typing.Callable]
log = logging.getLogger(__name__)
def expand_block(
block: blendfile.BlendFileBlock,
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield the data blocks used by this data block."""
try:
expander = _funcs_for_code[block.code]
except KeyError:
if block.code not in _warned_about_types:
log.debug("No expander implemented for block type %r", block.code.decode())
_warned_about_types.add(block.code)
return
log.debug("Expanding block %r", block)
for dependency in expander(block):
if not dependency:
# Filter out falsy blocks, i.e. None values.
# Allowing expanders to yield None makes them more consise.
continue
if dependency.code == b"DATA":
log.warn(
"expander yielded block %s which will be ignored in later iteration",
dependency,
)
yield dependency
def dna_code(block_code: str):
"""Decorator, marks decorated func as expander for that DNA code."""
assert isinstance(block_code, str)
def decorator(wrapped):
_funcs_for_code[block_code.encode()] = wrapped
return wrapped
return decorator
def _expand_generic_material(block: blendfile.BlendFileBlock):
array_len = block.get(b"totcol")
yield from block.iter_array_of_pointers(b"mat", array_len)
def _expand_generic_mtex(block: blendfile.BlendFileBlock):
if not block.dna_type.has_field(b"mtex"):
# mtex was removed in Blender 2.8
return
for mtex in block.iter_fixed_array_of_pointers(b"mtex"):
yield mtex.get_pointer(b"tex")
yield mtex.get_pointer(b"object")
def _expand_generic_nodetree(block: blendfile.BlendFileBlock):
if block.dna_type.dna_type_id == b"ID":
# This is a placeholder for a linked node tree.
yield block
return
assert (
block.dna_type.dna_type_id == b"bNodeTree"
), f"Expected bNodeTree, got {block.dna_type.dna_type_id.decode()})"
nodes = block.get_pointer((b"nodes", b"first"))
# See DNA_node_types.h
socket_types_with_value_pointer = {
cdefs.SOCK_OBJECT, # bNodeSocketValueObject
cdefs.SOCK_IMAGE, # bNodeSocketValueImage
cdefs.SOCK_COLLECTION, # bNodeSocketValueCollection
cdefs.SOCK_TEXTURE, # bNodeSocketValueTexture
cdefs.SOCK_MATERIAL, # bNodeSocketValueMaterial
}
for node in iterators.listbase(nodes):
if node[b"type"] == cdefs.CMP_NODE_R_LAYERS:
continue
# The 'id' property points to whatever is used by the node
# (like the image in an image texture node).
yield node.get_pointer(b"id")
# Default values of inputs can also point to ID datablocks.
inputs = node.get_pointer((b"inputs", b"first"))
for input in iterators.listbase(inputs):
if input[b"type"] not in socket_types_with_value_pointer:
continue
value_container = input.get_pointer(b"default_value")
if not value_container:
continue
value = value_container.get_pointer(b"value")
yield value
def _expand_generic_idprops(block: blendfile.BlendFileBlock):
"""Yield ID datablocks and their libraries referenced from ID properties."""
# TODO(@sybren): this code is very crude, and happens to work on ID
# properties of Geometry Nodes modifiers, which is what it was written for.
# It should probably be rewritten to properly iterate over & recurse into
# all groups.
settings_props = block.get_pointer((b"settings", b"properties"))
if not settings_props:
return
subprops = settings_props.get_pointer((b"data", b"group", b"first"))
for idprop in iterators.listbase(subprops):
if idprop[b"type"] != cdefs.IDP_ID:
continue
id_datablock = idprop.get_pointer((b"data", b"pointer"))
if not id_datablock:
continue
yield id_datablock
def _expand_generic_nodetree_id(block: blendfile.BlendFileBlock):
if block.bfile.header.version >= 500 and block.bfile.file_subversion >= 4:
# Introduced in Blender 5.0, commit bd61e69be5a7c96f1e5da1c86aafc17b839e049f
block_ntree = block.get_pointer(b"compositing_node_group", None)
else:
block_ntree = block.get_pointer(b"nodetree", None)
if block_ntree is not None:
yield from _expand_generic_nodetree(block_ntree)
def _expand_generic_animdata(block: blendfile.BlendFileBlock):
block_adt = block.get_pointer(b"adt")
if block_adt:
yield block_adt.get_pointer(b"action")
# TODO, NLA
@dna_code("AR")
def _expand_armature(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
@dna_code("CU")
def _expand_curve(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
for fieldname in (
b"vfont",
b"vfontb",
b"vfonti",
b"vfontbi",
b"bevobj",
b"taperobj",
b"textoncurve",
):
yield block.get_pointer(fieldname)
@dna_code("GR")
def _expand_group(block: blendfile.BlendFileBlock):
log.debug("Collection/group Block: %s (name=%s)", block, block.id_name)
objects = block.get_pointer((b"gobject", b"first"))
for item in iterators.listbase(objects):
yield item.get_pointer(b"ob")
# Recurse through child collections.
try:
children = block.get_pointer((b"children", b"first"))
except KeyError:
# 'children' was introduced in Blender 2.8 collections
pass
else:
for child in iterators.listbase(children):
subcoll = child.get_pointer(b"collection")
if subcoll is None:
continue
if subcoll.dna_type_id == b"ID":
# This issue happened while recursing a linked-in 'Hidden'
# collection in the Chimes set of the Spring project. Such
# collections named 'Hidden' were apparently created while
# converting files from Blender 2.79 to 2.80. This error
# isn't reproducible with just Blender 2.80.
yield subcoll
continue
log.debug(
"recursing into child collection %s (name=%r, type=%r)",
subcoll,
subcoll.id_name,
subcoll.dna_type_name,
)
yield from _expand_group(subcoll)
@dna_code("LA")
def _expand_lamp(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield from _expand_generic_mtex(block)
@dna_code("MA")
def _expand_material(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield from _expand_generic_mtex(block)
try:
yield block.get_pointer(b"group")
except KeyError:
# Groups were removed from Blender 2.8
pass
@dna_code("MB")
def _expand_metaball(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
@dna_code("ME")
def _expand_mesh(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
yield block.get_pointer(b"texcomesh")
# TODO, TexFace? - it will be slow, we could simply ignore :S
@dna_code("NT")
def _expand_node_tree(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree(block)
@dna_code("OB")
def _expand_object(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
yield block.get_pointer(b"data")
if block[b"transflag"] & cdefs.OB_DUPLIGROUP:
yield block.get_pointer(b"dup_group")
yield block.get_pointer(b"proxy")
yield block.get_pointer(b"proxy_group")
# 'ob->pose->chanbase[...].custom'
block_pose = block.get_pointer(b"pose")
if block_pose:
assert block_pose.dna_type.dna_type_id == b"bPose"
# sdna_index_bPoseChannel = block_pose.file.sdna_index_from_id[b'bPoseChannel']
channels = block_pose.get_pointer((b"chanbase", b"first"))
for pose_chan in iterators.listbase(channels):
yield pose_chan.get_pointer(b"custom")
# Expand the objects 'ParticleSettings' via 'ob->particlesystem[...].part'
# sdna_index_ParticleSystem = block.file.sdna_index_from_id.get(b'ParticleSystem')
# if sdna_index_ParticleSystem is not None:
psystems = block.get_pointer((b"particlesystem", b"first"))
for psystem in iterators.listbase(psystems):
yield psystem.get_pointer(b"part")
# Modifiers can also refer to other datablocks, which should also get expanded.
for block_mod in iterators.modifiers(block):
mod_type = block_mod[b"modifier", b"type"]
# Currently only node groups are supported. If the support should expand
# to more types, something more intelligent than this should be made.
if mod_type == cdefs.eModifierType_Nodes:
yield from _expand_generic_idprops(block_mod)
yield block_mod.get_pointer(b"node_group")
@dna_code("PA")
def _expand_particle_settings(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_mtex(block)
block_ren_as = block[b"ren_as"]
if block_ren_as == cdefs.PART_DRAW_GR:
yield block.get_pointer(b"dup_group")
elif block_ren_as == cdefs.PART_DRAW_OB:
yield block.get_pointer(b"dup_ob")
@dna_code("SC")
def _expand_scene(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield block.get_pointer(b"camera")
yield block.get_pointer(b"world")
yield block.get_pointer(b"set", default=None)
yield block.get_pointer(b"clip", default=None)
# sdna_index_Base = block.file.sdna_index_from_id[b'Base']
# for item in bf_utils.iter_ListBase(block.get_pointer((b'base', b'first'))):
# yield item.get_pointer(b'object', sdna_index_refine=sdna_index_Base)
bases = block.get_pointer((b"base", b"first"))
for base in iterators.listbase(bases):
yield base.get_pointer(b"object")
# Sequence Editor
block_ed = block.get_pointer(b"ed")
if not block_ed:
return
strip_type_to_field = {
cdefs.SEQ_TYPE_SCENE: b"scene",
cdefs.SEQ_TYPE_MOVIECLIP: b"clip",
cdefs.SEQ_TYPE_MASK: b"mask",
cdefs.SEQ_TYPE_SOUND_RAM: b"sound",
}
for strip, strip_type in iterators.sequencer_strips(block_ed):
try:
field_name = strip_type_to_field[strip_type]
except KeyError:
continue
yield strip.get_pointer(field_name)
@dna_code("TE")
def _expand_texture(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield block.get_pointer(b"ima")
@dna_code("WO")
def _expand_world(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield from _expand_generic_mtex(block)
@@ -0,0 +1,181 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Expand data blocks.
The expansion process follows pointers and library links to construct the full
set of actually-used data blocks. This set consists of all data blocks in the
initial blend file, and all *actually linked-to* data blocks in linked
blend files.
"""
import collections
import logging
import pathlib
import queue
import typing
from blender_asset_tracer import blendfile, bpathlib
from . import expanders, progress
_funcs_for_code = {} # type: typing.Dict[bytes, typing.Callable]
log = logging.getLogger(__name__)
# noinspection PyProtectedMember
class BlockQueue(queue.PriorityQueue):
"""PriorityQueue that sorts by filepath and file offset"""
def _put(self, item: blendfile.BlendFileBlock):
super()._put((item.bfile.filepath, item.file_offset, item))
def _get(self) -> blendfile.BlendFileBlock:
_, _, item = super()._get()
return item
class BlockIterator:
"""Expand blocks with dependencies from other libraries.
This class exists so that we have some context for the recursive expansion
without having to pass those variables to each recursive call.
"""
def __init__(self) -> None:
# Set of (blend file Path, block address) of already-reported blocks.
self.blocks_yielded = set() # type: typing.Set[typing.Tuple[pathlib.Path, int]]
# Queue of blocks to visit
self.to_visit = BlockQueue()
self.progress_cb = progress.Callback()
def open_blendfile(self, bfilepath: pathlib.Path) -> blendfile.BlendFile:
"""Open a blend file, sending notification about this to the progress callback."""
log.info("opening: %s", bfilepath)
self.progress_cb.trace_blendfile(bfilepath)
return blendfile.open_cached(bfilepath)
def iter_blocks(
self,
bfile: blendfile.BlendFile,
limit_to: typing.Set[blendfile.BlendFileBlock] = set(),
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Expand blocks with dependencies from other libraries."""
log.info("inspecting: %s", bfile.filepath)
if limit_to:
self._queue_named_blocks(bfile, limit_to)
else:
self._queue_all_blocks(bfile)
blocks_per_lib = yield from self._visit_blocks(bfile, limit_to)
yield from self._visit_linked_blocks(blocks_per_lib)
def _visit_blocks(self, bfile, limit_to):
bpath = bpathlib.make_absolute(bfile.filepath)
root_dir = bpathlib.BlendPath(bpath.parent)
# Mapping from library path to data blocks to expand.
blocks_per_lib = collections.defaultdict(set)
while not self.to_visit.empty():
block = self.to_visit.get()
assert isinstance(block, blendfile.BlendFileBlock)
if (bpath, block.addr_old) in self.blocks_yielded:
continue
if block.code == b"ID":
# ID blocks represent linked-in assets. Those are the ones that
# should be loaded from their own blend file and "expanded" to
# the entire set of data blocks required to render them. We
# defer the handling of those so that we can work with one
# blend file at a time.
lib = block.get_pointer(b"lib")
lib_bpath = bpathlib.BlendPath(lib[b"name"]).absolute(root_dir)
blocks_per_lib[lib_bpath].add(block)
# The library block itself should also be reported, because it
# represents a blend file that is a dependency as well.
self.to_visit.put(lib)
continue
self._queue_dependencies(block)
self.blocks_yielded.add((bpath, block.addr_old))
yield block
return blocks_per_lib
def _visit_linked_blocks(self, blocks_per_lib):
# We've gone through all the blocks in this file, now open the libraries
# and iterate over the blocks referred there.
for lib_bpath, idblocks in blocks_per_lib.items():
lib_path = bpathlib.make_absolute(lib_bpath.to_path())
if not lib_path.exists():
log.warning("Library %s does not exist", lib_path)
continue
log.debug("Expanding %d blocks in %s", len(idblocks), lib_path)
libfile = self.open_blendfile(lib_path)
yield from self.iter_blocks(libfile, idblocks)
def _queue_all_blocks(self, bfile: blendfile.BlendFile):
log.debug("Queueing all blocks from file %s", bfile.filepath)
for block in bfile.blocks:
# Don't bother visiting DATA blocks, as we won't know what
# to do with them anyway.
if block.code == b"DATA":
continue
self.to_visit.put(block)
def _queue_named_blocks(
self, bfile: blendfile.BlendFile, limit_to: typing.Set[blendfile.BlendFileBlock]
):
"""Queue only the blocks referred to in limit_to.
:param bfile:
:param limit_to: set of ID blocks that name the blocks to queue.
The queued blocks are loaded from the actual blend file, and
selected by name.
"""
for to_find in limit_to:
assert to_find.code == b"ID"
name_to_find = to_find[b"name"]
code = name_to_find[:2]
log.debug("Finding block %r with code %r", name_to_find, code)
same_code = bfile.find_blocks_from_code(code)
for block in same_code:
if block.id_name == name_to_find:
log.debug("Queueing %r from file %s", block, bfile.filepath)
self.to_visit.put(block)
def _queue_dependencies(self, block: blendfile.BlendFileBlock):
for block in expanders.expand_block(block):
assert isinstance(block, blendfile.BlendFileBlock), "unexpected %r" % block
self.to_visit.put(block)
def iter_blocks(
bfile: blendfile.BlendFile,
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield all blocks in this file + required blocks in libs."""
bi = BlockIterator()
yield from bi.iter_blocks(bfile)
@@ -0,0 +1,79 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import logging
import pathlib
import typing
log = logging.getLogger(__name__)
class DoesNotExist(OSError):
"""Indicates a path does not exist on the filesystem."""
def __init__(self, path: pathlib.Path) -> None:
super().__init__(path)
self.path = path
def expand_sequence(path: pathlib.Path) -> typing.Iterator[pathlib.Path]:
"""Expand a file sequence path into the actual file paths.
:param path: can be either a glob pattern (must contain a * character)
or the path of the first file in the sequence.
"""
if "<UDIM>" in path.name: # UDIM tiles
# Change <UDIM> marker to a glob pattern, then let the glob case handle it.
# This assumes that all files that match the glob are actually UDIM
# tiles; this could cause some false-positives.
path = path.with_name(path.name.replace("<UDIM>", "*"))
if "*" in str(path): # assume it is a glob
import glob
log.debug("expanding glob %s", path)
for fname in sorted(glob.glob(str(path), recursive=True)):
yield pathlib.Path(fname)
return
if not path.exists():
raise DoesNotExist(path)
if path.is_dir():
# Explode directory paths into separate files.
for subpath in path.rglob("*"):
if subpath.is_file():
yield subpath
return
log.debug("expanding file sequence %s", path)
import string
stem_no_digits = path.stem.rstrip(string.digits)
if stem_no_digits == path.stem:
# Just a single file, no digits here.
yield path
return
# Return everything start starts with 'stem_no_digits' and ends with the
# same suffix as the first file. This may result in more files than used
# by Blender, but at least it shouldn't miss any.
pattern = "%s*%s" % (stem_no_digits, path.suffix)
yield from sorted(path.parent.glob(pattern))
@@ -0,0 +1,414 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Modifier handling code used in blocks2assets.py
The modifier_xxx() functions all yield result.BlockUsage objects for external
files used by the modifiers.
"""
import logging
import typing
from blender_asset_tracer import blendfile, bpathlib, cdefs
from blender_asset_tracer.blendfile import iterators
from . import result
log = logging.getLogger(__name__)
modifier_handlers = {} # type: typing.Dict[int, typing.Callable]
class ModifierContext:
"""Meta-info for modifier expansion.
Currently just contains the object on which the modifier is defined.
"""
def __init__(self, owner: blendfile.BlendFileBlock) -> None:
assert owner.dna_type_name == "Object"
self.owner = owner
def mod_handler(dna_num: int):
"""Decorator, marks decorated func as handler for that modifier number."""
assert isinstance(dna_num, int)
def decorator(wrapped):
modifier_handlers[dna_num] = wrapped
return wrapped
return decorator
@mod_handler(cdefs.eModifierType_MeshCache)
def modifier_filepath(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Just yield the 'filepath' field."""
path, field = modifier.get(b"filepath", return_field=True)
yield result.BlockUsage(
modifier, path, path_full_field=field, block_name=block_name
)
@mod_handler(cdefs.eModifierType_MeshSequenceCache)
def modifier_mesh_sequence_cache(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Yield the Alembic file(s) used by this modifier"""
cache_file = modifier.get_pointer(b"cache_file")
if cache_file is None:
return
is_sequence = bool(cache_file[b"is_sequence"])
cache_block_name = cache_file.id_name
assert cache_block_name is not None
path, field = cache_file.get(b"filepath", return_field=True)
yield result.BlockUsage(
cache_file,
path,
path_full_field=field,
is_sequence=is_sequence,
block_name=cache_block_name,
)
@mod_handler(cdefs.eModifierType_Ocean)
def modifier_ocean(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
if not modifier[b"cached"]:
return
path, field = modifier.get(b"cachepath", return_field=True)
# The path indicates the directory containing the cached files.
yield result.BlockUsage(
modifier, path, is_sequence=True, path_full_field=field, block_name=block_name
)
def _get_texture(
prop_name: bytes, dblock: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Yield block usages from a texture propery.
Assumes dblock[prop_name] is a texture data block.
"""
if dblock is None:
return
tx = dblock.get_pointer(prop_name)
yield from _get_image(b"ima", tx, block_name)
def _get_image(
prop_name: bytes,
dblock: typing.Optional[blendfile.BlendFileBlock],
block_name: bytes,
) -> typing.Iterator[result.BlockUsage]:
"""Yield block usages from an image propery.
Assumes dblock[prop_name] is an image data block.
"""
if not dblock:
return
try:
ima = dblock.get_pointer(prop_name)
except KeyError as ex:
# No such property, just return.
log.debug("_get_image() called with non-existing property name: %s", ex)
return
if not ima:
return
path, field = ima.get(b"name", return_field=True)
yield result.BlockUsage(ima, path, path_full_field=field, block_name=block_name)
@mod_handler(cdefs.eModifierType_Displace)
@mod_handler(cdefs.eModifierType_Wave)
def modifier_texture(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
return _get_texture(b"texture", modifier, block_name)
@mod_handler(cdefs.eModifierType_WeightVGEdit)
@mod_handler(cdefs.eModifierType_WeightVGMix)
@mod_handler(cdefs.eModifierType_WeightVGProximity)
def modifier_mask_texture(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
return _get_texture(b"mask_texture", modifier, block_name)
@mod_handler(cdefs.eModifierType_UVProject)
def modifier_image(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
yield from _get_image(b"image", modifier, block_name)
def _walk_point_cache(
ctx: ModifierContext,
block_name: bytes,
bfile: blendfile.BlendFile,
pointcache: blendfile.BlendFileBlock,
extension: bytes,
):
flag = pointcache[b"flag"]
if flag & cdefs.PTCACHE_EXTERNAL:
path, field = pointcache.get(b"path", return_field=True)
log.info(" external cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(
pointcache,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
elif flag & cdefs.PTCACHE_DISK_CACHE:
# See ptcache_path() in pointcache.c
name, field = pointcache.get(b"name", return_field=True)
if not name:
# See ptcache_filename() in pointcache.c
idname = ctx.owner[b"id", b"name"]
name = idname[2:].hex().upper().encode()
path = b"//%b%b/%b_*%b" % (
cdefs.PTCACHE_PATH,
bfile.filepath.stem.encode(),
name,
extension,
)
log.info(" disk cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(
pointcache,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
@mod_handler(cdefs.eModifierType_ParticleSystem)
def modifier_particle_system(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
psys = modifier.get_pointer(b"psys")
if psys is None:
return
pointcache = psys.get_pointer(b"pointcache")
if pointcache is None:
return
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT
)
@mod_handler(cdefs.eModifierType_Fluidsim)
def modifier_fluid_sim(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_fluid_sim")
fss = modifier.get_pointer(b"fss")
if fss is None:
my_log.debug(
"Modifier %r (%r) has no fss", modifier[b"modifier", b"name"], block_name
)
return
path, field = fss.get(b"surfdataPath", return_field=True)
# This may match more than is used by Blender, but at least it shouldn't
# miss any files.
# The 'fluidsurface' prefix is defined in source/blender/makesdna/DNA_object_fluidsim_types.h
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(
fss, bpath, path_full_field=field, is_sequence=True, block_name=block_name
)
@mod_handler(cdefs.eModifierType_Smokesim)
def modifier_smoke_sim(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_smoke_sim")
domain = modifier.get_pointer(b"domain")
if domain is None:
my_log.debug(
"Modifier %r (%r) has no domain", modifier[b"modifier", b"name"], block_name
)
return
pointcache = domain.get_pointer(b"point_cache")
if pointcache is None:
return
format = domain.get(b"cache_file_format")
extensions = {
cdefs.PTCACHE_FILE_PTCACHE: cdefs.PTCACHE_EXT,
cdefs.PTCACHE_FILE_OPENVDB: cdefs.PTCACHE_EXT_VDB,
}
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, extensions[format]
)
@mod_handler(cdefs.eModifierType_Fluid)
def modifier_fluid(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_fluid")
domain = modifier.get_pointer(b"domain")
if domain is None:
my_log.debug(
"Modifier %r (%r) has no domain", modifier[b"modifier", b"name"], block_name
)
return
# See fluid_bake_startjob() in physics_fluid.c
path = domain[b"cache_directory"]
path, field = domain.get(b"cache_directory", return_field=True)
log.info(" fluid cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(
domain,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
@mod_handler(cdefs.eModifierType_Cloth)
def modifier_cloth(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
pointcache = modifier.get_pointer(b"point_cache")
if pointcache is None:
return
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT
)
@mod_handler(cdefs.eModifierType_DynamicPaint)
def modifier_dynamic_paint(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_dynamic_paint")
canvas_settings = modifier.get_pointer(b"canvas")
if canvas_settings is None:
my_log.debug(
"Modifier %r (%r) has no canvas_settings",
modifier[b"modifier", b"name"],
block_name,
)
return
surfaces = canvas_settings.get_pointer((b"surfaces", b"first"))
for surf_idx, surface in enumerate(iterators.listbase(surfaces)):
surface_block_name = block_name + b".canvas_settings.surfaces[%d]" % (surf_idx)
point_cache = surface.get_pointer(b"pointcache")
if point_cache is None:
my_log.debug(
"Surface %r (%r) has no pointcache",
surface[b"surface", b"name"],
surface_block_name,
)
continue
yield from _walk_point_cache(
ctx, surface_block_name, modifier.bfile, point_cache, cdefs.PTCACHE_EXT
)
@mod_handler(cdefs.eModifierType_Nodes)
def modifier_nodes(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
if not modifier.has_field(b"simulation_bake_directory"):
return
mod_directory_ptr, mod_directory_field = modifier.get(
b"simulation_bake_directory", return_field=True
)
bakes = modifier.get_pointer(b"bakes", default=None)
if not bakes:
return
mod_bake_target = modifier.get(b"bake_target", default=None)
for bake_idx, bake in enumerate(iterators.dynamic_array(bakes)):
# Check for packed data.
bake_target = bake.get(b"bake_target", default=None)
if bake_target == cdefs.NODES_MODIFIER_BAKE_TARGET_INHERIT:
bake_target = mod_bake_target
if bake_target == cdefs.NODES_MODIFIER_BAKE_TARGET_PACKED:
# This data is packed in the blend file, it's not a dependency to trace.
continue
flag = bake.get(b"flag")
use_custom_directory = bool(flag & cdefs.NODES_MODIFIER_BAKE_CUSTOM_PATH)
if use_custom_directory:
bake_directory_ptr, bake_directory_field = bake.get(
b"directory", return_field=True
)
directory_ptr = bake_directory_ptr
field = bake_directory_field
block = bake
else:
directory_ptr = mod_directory_ptr
field = mod_directory_field
block = modifier
if not directory_ptr:
continue
directory = bake.bfile.dereference_pointer(directory_ptr)
if not directory:
continue
bpath = bpathlib.BlendPath(directory.as_bytes_string())
bake_block_name = block_name + b".bakes[%d]" % bake_idx
yield result.BlockUsage(
block,
bpath,
block_name=bake_block_name,
path_full_field=field,
is_sequence=True,
)
@@ -0,0 +1,31 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Callback class definition for BAT Tracer progress reporting.
Mostly used to forward events to pack.progress.Callback.
"""
import pathlib
class Callback:
"""BAT Tracer progress reporting."""
def trace_blendfile(self, filename: pathlib.Path) -> None:
"""Called for every blendfile opened when tracing dependencies."""
@@ -0,0 +1,200 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# ***** END GPL LICENCE BLOCK *****
#
# (c) 2018, Blender Foundation - Sybren A. Stüvel
import functools
import logging
import pathlib
import typing
from typing import Optional
from blender_asset_tracer import blendfile, bpathlib
from blender_asset_tracer.blendfile import dna
from . import file_sequence
log = logging.getLogger(__name__)
@functools.total_ordering
class BlockUsage:
"""Represents the use of an asset by a data block.
:ivar block_name: an identifying name for this block. Defaults to the ID
name of the block.
:ivar block:
:ivar asset_path: The path of the asset, if is_sequence=False. Otherwise
it can be either a glob pattern (must contain a * byte) or the path of
the first file in the sequence.
:ivar is_sequence: Indicates whether this file is alone (False), the
first of a sequence (True, and the path points to a file), or a
directory containing a sequence (True, and path points to a directory).
In certain cases such files should be reported once (f.e. when
rewriting the source field to another path), and in other cases the
sequence should be expanded (f.e. when copying all assets to a BAT
Pack).
:ivar path_full_field: field containing the full path of this asset.
:ivar path_dir_field: field containing the parent path (i.e. the
directory) of this asset.
:ivar path_base_field: field containing the basename of this asset.
"""
def __init__(
self,
block: blendfile.BlendFileBlock,
asset_path: bpathlib.BlendPath,
is_sequence: bool = False,
path_full_field: Optional[dna.Field] = None,
path_dir_field: Optional[dna.Field] = None,
path_base_field: Optional[dna.Field] = None,
block_name: bytes = b"",
) -> None:
if block_name:
self.block_name = block_name
else:
self.block_name = self.guess_block_name(block)
assert isinstance(block, blendfile.BlendFileBlock)
assert isinstance(
asset_path, (bytes, bpathlib.BlendPath)
), "asset_path should be BlendPath, not %r" % type(asset_path)
if path_full_field is None:
assert isinstance(
path_dir_field, dna.Field
), "path_dir_field should be dna.Field, not %r" % type(path_dir_field)
assert isinstance(
path_base_field, dna.Field
), "path_base_field should be dna.Field, not %r" % type(path_base_field)
else:
assert isinstance(
path_full_field, dna.Field
), "path_full_field should be dna.Field, not %r" % type(path_full_field)
if isinstance(asset_path, bytes):
asset_path = bpathlib.BlendPath(asset_path)
self.block = block
self.asset_path = asset_path
self.is_sequence = bool(is_sequence)
self.path_full_field = path_full_field
self.path_dir_field = path_dir_field
self.path_base_field = path_base_field
# cached by __fspath__()
self._abspath = None # type: typing.Optional[pathlib.Path]
@staticmethod
def guess_block_name(block: blendfile.BlendFileBlock) -> bytes:
try:
return block[b"id", b"name"]
except KeyError:
pass
try:
return block[b"name"]
except KeyError:
pass
return b"-unnamed-"
def __repr__(self):
if self.path_full_field is None:
field_name = (
self.path_dir_field.name.name_full.decode()
+ "/"
+ self.path_base_field.name.name_full.decode()
)
else:
field_name = self.path_full_field.name.name_full.decode()
return "<BlockUsage name=%r type=%r field=%r asset=%r%s>" % (
self.block_name,
self.block.dna_type_name,
field_name,
self.asset_path,
" sequence" if self.is_sequence else "",
)
def files(self) -> typing.Iterator[pathlib.Path]:
"""Determine absolute path(s) of the asset file(s).
A relative path is interpreted relative to the blend file referring
to the asset. If this BlockUsage represents a sequence, the filesystem
is inspected and the actual files in the sequence are yielded.
It is assumed that paths are valid UTF-8.
"""
path = self.__fspath__()
if not self.is_sequence:
if not path.exists():
log.warning("Path %s does not exist for %s", path, self)
return
yield path
return
try:
yield from file_sequence.expand_sequence(path)
except file_sequence.DoesNotExist:
log.warning("Path %s does not exist for %s", path, self)
def __fspath__(self) -> pathlib.Path:
"""Determine the absolute path of the asset on the filesystem."""
if self._abspath is None:
bpath = self.block.bfile.abspath(self.asset_path)
log.info(
"Resolved %s rel to %s -> %s",
self.asset_path,
self.block.bfile.filepath,
bpath,
)
as_path = pathlib.Path(bpath.to_path())
# Windows cannot make a path that has a glob pattern in it absolute.
# Since globs are generally only on the filename part, we take that off,
# make the parent directory absolute, then put the filename back.
try:
abs_parent = bpathlib.make_absolute(as_path.parent)
except FileNotFoundError:
self._abspath = as_path
else:
self._abspath = abs_parent / as_path.name
log.info(
"Resolving %s rel to %s -> %s",
self.asset_path,
self.block.bfile.filepath,
self._abspath,
)
else:
log.info("Reusing abspath %s", self._abspath)
return self._abspath
abspath = property(__fspath__)
def __lt__(self, other: "BlockUsage"):
"""Allow sorting for repeatable and predictable unit tests."""
if not isinstance(other, BlockUsage):
raise NotImplemented()
return self.block_name < other.block_name and self.block < other.block
def __eq__(self, other: object):
if not isinstance(other, BlockUsage):
return False
return self.block_name == other.block_name and self.block == other.block
def __hash__(self):
return hash((self.block_name, hash(self.block)))
@@ -1,213 +0,0 @@
from __future__ import annotations
import dataclasses
import functools
from collections import defaultdict
from pathlib import Path
import bpy
from bpy.types import Library, ID
def find() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the assets it uses.
The None key indicates the direct dependencies of the currently-open blend file.
"""
_blend_asset_usage = find_blend_asset_usage()
_nonblend_asset_usage = find_nonblend_asset_usage()
return _merge_keys(_blend_asset_usage, _nonblend_asset_usage)
@dataclasses.dataclass
class AssetUsage:
"""The usage of an asset by a specific blend file."""
abspath: Path
"""Absolute path to the asset."""
# user: Path
# """Absolute path of whatever blend file uses this Asset."""
reference_path: str
"""The path by which this asset is referenced.
This is tracked so that the search & replace operation for path rewriting
knows what to search for.
NOTE: the above may not be true, as paths to assets from a library blend
file may already be rewritten by Blender upon loading; this process ensures
that all relative paths are relative to the main blend file, and so they may
not be the same in the library itself.
"""
is_blendfile: bool
"""Whether this asset is a blend file or not.
Blend files can refer to other assets.
"""
def __hash__(self) -> int:
return hash((self.abspath, self.reference_path, self.is_blendfile))
def __eq__(self, value: object) -> bool:
if not isinstance(value, AssetUsage):
return False
return (
self.abspath,
self.reference_path,
self.is_blendfile,
) == (
value.abspath,
value.reference_path,
value.is_blendfile,
)
@functools.lru_cache
def library_abspath(lib: Library | None) -> Path:
"""Return the absolute path to the library.
lib=None returns the absolute path of the current blend file.
"""
if lib is None:
filepath = bpy.data.filepath
else:
filepath = bpy.path.abspath(lib.filepath)
return Path(filepath).resolve()
def find_blend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the blend files it uses as libraries."""
# Find all dependencies between libraries.
# Keys: Library ID (or `None` for current blendfile)
# Values: Libraries used by the key one.
libs_deps: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for id, id_users in bpy.data.user_map().items():
id_lib = id.library
libs_deps.setdefault(id_lib, set())
for id_user in id_users:
if id_user.library == id_lib:
continue
asset_usage = AssetUsage(
abspath=library_abspath(id_lib),
reference_path=id_lib.filepath,
is_blendfile=True,
)
libs_deps[id_user.library].add(asset_usage)
return dict(libs_deps)
def find_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from a blend file to the non-blend asset files it uses."""
file_path_map = bpy.data.file_path_map(include_libraries=False)
asset_usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for asset_user, filepaths in file_path_map.items():
if not filepaths:
continue
if isinstance(asset_user, Library):
continue
assert isinstance(asset_user, ID)
lib = asset_user.library
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
for filepath in filepaths:
abspath = Path(bpy.path.abspath(filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
# Also check bpy.data.images directly, as file_path_map may miss some image references
# (e.g., PNG, EXR files in certain node setups)
for img in bpy.data.images:
if img.packed_file:
continue
if not img.filepath:
continue
# Skip generated images and sequences/movies
if getattr(img, 'source', 'FILE') not in {'FILE', 'TILED'}:
continue
lib = getattr(img, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(img.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=img.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
# Also check sounds and movie clips for completeness
for snd in getattr(bpy.data, 'sounds', []):
if snd.packed_file:
continue
if not snd.filepath:
continue
lib = getattr(snd, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(snd.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=snd.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
for mc in getattr(bpy.data, 'movieclips', []):
if not mc.filepath:
continue
lib = getattr(mc, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(mc.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=mc.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
return dict(asset_usages)
def _merge_keys(
a: dict[Library | None, set[AssetUsage]],
b: dict[Library | None, set[AssetUsage]],
) -> dict[Library | None, set[AssetUsage]]:
merged = defaultdict(set)
for key, values in a.items():
merged[key].update(values)
for key, values in b.items():
merged[key].update(values)
return merged
@@ -1,18 +0,0 @@
schema_version = "1.0.0"
id = "sheepit_project_submitter"
name = "SheepIt Project Submitter"
version = "0.0.8"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
blender_version_min = "3.0.0"
license = ["GPL-3.0-or-later"]
description = "Submit Blender projects to SheepIt render farm with automatic asset packing."
homepage = "https://github.com/RaincloudTheDragon/sheepit-project-submitter/"
tagline = "Submit projects to SheepIt render farm"
tags = ["render", "farm", "submission", "utility"]
# Python modules to load for this add-on
modules = ["sheepit_project_submitter"]
@@ -1,20 +0,0 @@
"""
Configuration constants for SheepIt Project Submitter addon.
"""
# Addon metadata
ADDON_NAME = "SheepIt Project Submitter"
ADDON_ID = "sheepit_project_submitter"
# SheepIt API endpoints (to be researched and updated)
SHEEPIT_API_BASE = "https://www.sheepit-renderfarm.com"
SHEEPIT_CLIENT_BASE = "https://client.sheepit-renderfarm.com"
# Debug mode
DEBUG = False
def debug_print(message: str) -> None:
"""Print debug message if DEBUG is enabled."""
if DEBUG:
print(f"[{ADDON_NAME}] {message}")
@@ -1,24 +0,0 @@
"""
Operators for SheepIt Project Submitter addon.
"""
def register():
"""Register all operators."""
# Lazy imports - these are only executed when register() is called
# This avoids circular import issues since imports happen at function call time
from . import pack_ops
from . import submit_ops
pack_ops.register()
submit_ops.register()
def unregister():
"""Unregister all operators."""
# Lazy imports - these are only executed when unregister() is called
from . import pack_ops
from . import submit_ops
submit_ops.unregister()
pack_ops.unregister()
@@ -1,460 +0,0 @@
"""
Submission operations for SheepIt render farm.
"""
import os
import zipfile
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple
import bpy
from bpy.types import Operator
from .. import config
def apply_frame_range_to_blend(blend_path: Path, frame_start: int, frame_end: int, frame_step: int) -> None:
"""
Apply frame range settings to a blend file using subprocess.
Args:
blend_path: Path to the blend file to modify
frame_start: Start frame value
frame_end: End frame value
frame_step: Frame step value
"""
script = f"""
import bpy
for scene in bpy.data.scenes:
scene.frame_start = {frame_start}
scene.frame_end = {frame_end}
scene.frame_step = {frame_step}
bpy.ops.wm.save_mainfile(compress=True)
print(f'Applied frame range {frame_start}-{frame_end} (step {frame_step}) to all scenes')
"""
result = subprocess.run([
"blender", "--factory-startup", "-b", str(blend_path), "--python-expr", script
], capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"[SheepIt Submit] WARNING: Failed to apply frame range to {blend_path.name}")
if result.stderr:
print(f"[SheepIt Submit] Error: {result.stderr[:200]}")
else:
print(f"[SheepIt Submit] Applied frame range {frame_start}-{frame_end} (step {frame_step}) to {blend_path.name}")
def save_current_blend_with_frame_range(submit_settings, temp_dir: Optional[Path] = None) -> Tuple[Path, int, int, int]:
"""
Save current blend state to a temporary file and apply frame range from submit_settings.
Args:
submit_settings: Submit settings containing frame range configuration
temp_dir: Optional temporary directory (if None, creates a new one)
Returns:
Tuple of (temp_blend_path, frame_start, frame_end, frame_step)
"""
# Determine frame range from submit_settings
if submit_settings.frame_range_mode == 'FULL':
frame_start = bpy.context.scene.frame_start
frame_end = bpy.context.scene.frame_end
frame_step = bpy.context.scene.frame_step
else:
frame_start = submit_settings.frame_start
frame_end = submit_settings.frame_end
frame_step = submit_settings.frame_step
# Create temp directory if not provided
if temp_dir is None:
temp_dir = Path(tempfile.mkdtemp(prefix="sheepit_submit_"))
# Generate temp blend filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
blend_name = Path(blend_name).stem if blend_name else "untitled"
temp_blend = temp_dir / f"{blend_name}.blend"
print(f"[SheepIt Submit] Saving current blend state to: {temp_blend}")
print(f"[SheepIt Submit] Frame range: {frame_start} - {frame_end} (step: {frame_step})")
# Save current blend state
try:
temp_dir.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend), copy=True, compress=True)
print(f"[SheepIt Submit] Saved current blend state to temp file")
except Exception as e:
error_msg = f"Failed to save current blend state: {type(e).__name__}: {str(e)}"
print(f"[SheepIt Submit] ERROR: {error_msg}")
raise RuntimeError(error_msg) from e
# Apply frame range to the saved file
apply_frame_range_to_blend(temp_blend, frame_start, frame_end, frame_step)
return temp_blend, frame_start, frame_end, frame_step
class SHEEPIT_OT_submit_current(Operator):
"""Pack current blend file to output location without packing assets."""
bl_idname = "sheepit.submit_current"
bl_label = "Pack Current Blend"
bl_description = "Save the current blend file with frame range applied to the specified output location"
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
"""Start the packing operation."""
submit_settings = context.scene.sheepit_submit
# Check if already packing
if submit_settings.is_submitting:
self.report({'WARNING'}, "A packing operation is already in progress.")
return {'CANCELLED'}
# Get output path from settings or preferences
output_dir = submit_settings.output_path
if not output_dir:
from ..utils.compat import get_addon_prefs
prefs = get_addon_prefs()
if prefs and prefs.default_output_path:
output_dir = prefs.default_output_path
submit_settings.output_path = output_dir
if not output_dir:
self.report({'ERROR'}, "Please specify an output path in the panel below.")
return {'CANCELLED'}
# Generate filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
if blend_name:
blend_name = Path(blend_name).stem
else:
blend_name = "untitled"
output_file = Path(output_dir) / f"{blend_name}.blend"
# Initialize progress properties
submit_settings.is_submitting = True
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
# Initialize phase tracking
self._phase = 'INIT'
self._output_path = output_file
self._temp_blend_path = None
self._temp_dir = None
self._success = False
self._message = ""
self._error = None
# Create timer for modal updates
self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
# Start modal operation
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
"""Legacy execute method - redirects to invoke for modal operation."""
return self.invoke(context, None)
def modal(self, context, event):
"""Handle modal events and update progress."""
submit_settings = context.scene.sheepit_submit
# Handle ESC key to cancel
if event.type == 'ESC':
self._cleanup(context, cancelled=True)
self.report({'INFO'}, "Packing cancelled.")
return {'CANCELLED'}
# Handle timer events
if event.type == 'TIMER':
try:
if self._phase == 'INIT':
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
self._phase = 'SAVING_BLEND'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_BLEND':
submit_settings.submit_progress = 10.0
submit_settings.submit_status_message = "Saving current blend state..."
# Save current blend state to temp file with frame range applied
try:
self._temp_blend_path, frame_start, frame_end, frame_step = save_current_blend_with_frame_range(submit_settings)
self._temp_dir = self._temp_blend_path.parent
print(f"[SheepIt Submit] Using temp blend file: {self._temp_blend_path}")
except Exception as e:
self._error = f"Failed to save current blend state: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'APPLYING_FRAME_RANGE'
return {'RUNNING_MODAL'}
elif self._phase == 'APPLYING_FRAME_RANGE':
submit_settings.submit_progress = 20.0
submit_settings.submit_status_message = "Frame range applied."
# Frame range is already applied in save_current_blend_with_frame_range
self._phase = 'VALIDATING_FILE_SIZE'
return {'RUNNING_MODAL'}
elif self._phase == 'VALIDATING_FILE_SIZE':
submit_settings.submit_progress = 30.0
submit_settings.submit_status_message = "Validating file size..."
# Check blend file size
if self._temp_blend_path and self._temp_blend_path.exists():
blend_size = self._temp_blend_path.stat().st_size
blend_size_gb = blend_size / (1024 * 1024 * 1024)
from .pack_ops import _get_project_size_limit_bytes
max_bytes = _get_project_size_limit_bytes(context)
if max_bytes is not None and blend_size > max_bytes:
limit_gb = max_bytes / (1024 * 1024 * 1024)
print(f"[SheepIt Pack] Blend file size: {blend_size_gb:.2f} GB")
error_msg = (
f"Blend file size ({blend_size_gb:.2f} GB) exceeds project limit ({limit_gb:.1f} GB).\n\n"
"To reduce file size, consider:\n"
"- Optimizing the scene (reduce geometry, simplify materials)\n"
"- Optimizing asset files (compress textures, reduce resolution)\n"
"- Splitting the frame range (render in smaller chunks)"
)
print(f"[SheepIt Pack] ERROR: {error_msg}")
self._error = error_msg
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'SAVING_FILE'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_FILE':
submit_settings.submit_progress = 50.0
submit_settings.submit_status_message = "Saving file to output location..."
try:
# Ensure output directory exists
self._output_path.parent.mkdir(parents=True, exist_ok=True)
# Copy temp file to output location
import shutil
shutil.copy2(self._temp_blend_path, self._output_path)
print(f"[SheepIt Pack] Saved blend file to: {self._output_path}")
self._success = True
self._message = f"Blend file saved to: {self._output_path}"
submit_settings.submit_progress = 90.0
submit_settings.submit_status_message = "File saved successfully!"
self._phase = 'CLEANUP'
except Exception as e:
self._error = f"Failed to save file: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
elif self._phase == 'CLEANUP':
submit_settings.submit_progress = 98.0
submit_settings.submit_status_message = "Cleaning up..."
# Clean up temp file on success
if self._temp_blend_path and self._temp_blend_path.exists():
try:
self._temp_blend_path.unlink()
if self._temp_dir and self._temp_dir.exists():
try:
self._temp_dir.rmdir()
except Exception:
pass # Directory may not be empty
print(f"[SheepIt Submit] Cleaned up temp file: {self._temp_blend_path}")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not clean up temp file: {e}")
self._phase = 'COMPLETE'
return {'RUNNING_MODAL'}
elif self._phase == 'COMPLETE':
submit_settings.submit_progress = 100.0
submit_settings.submit_status_message = "Packing complete!"
# Small delay to show completion
import time
time.sleep(0.2)
self._cleanup(context, cancelled=False)
self.report({'INFO'}, f"Blend file saved to: {self._output_path}")
return {'FINISHED'}
except Exception as e:
import traceback
traceback.print_exc()
self._error = f"Packing failed: {type(e).__name__}: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
def _cleanup(self, context, cancelled=False):
"""Clean up progress properties and timer."""
submit_settings = context.scene.sheepit_submit
# Remove timer
if hasattr(self, '_timer') and self._timer:
context.window_manager.event_timer_remove(self._timer)
# Reset progress properties
submit_settings.is_submitting = False
submit_settings.submit_progress = 0.0
if cancelled and self._error:
submit_settings.submit_status_message = self._error
else:
submit_settings.submit_status_message = ""
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
def execute(self, context):
"""Execute method - starts the modal operation."""
# This is called by invoke, so we don't redirect back to avoid recursion
# The actual implementation is in the invoke method above
pass
# Video and audio extensions to exclude when exclude_video=True
_MEDIA_EXTENSIONS = frozenset({
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.wmv', '.flv', '.ogv', '.mpg', '.mpeg', '.m2v',
'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
})
def create_zip_from_directory(directory: Path, output_zip: Path, progress_callback=None, cancel_check=None, exclude_video: bool = False) -> None:
"""Create a ZIP file from a directory.
Args:
directory: Directory to zip
output_zip: Output ZIP file path
progress_callback: Optional callback(progress_pct, message) for progress updates
cancel_check: Optional callback() -> bool to check for cancellation
exclude_video: If True, skip common video and audio file extensions
"""
import time
print(f"[SheepIt Submit] Starting ZIP creation...")
print(f"[SheepIt Submit] Directory: {directory}")
print(f"[SheepIt Submit] Output: {output_zip}")
# Delete .blend1 through .blend32 backup files before zipping
if progress_callback:
progress_callback(0.0, "Removing backup files...")
backup_files = []
for i in range(1, 33): # blend1 through blend32
pattern = f"*.blend{i}"
backup_files.extend(directory.rglob(pattern))
if backup_files:
print(f"[SheepIt Submit] Found {len(backup_files)} backup files (.blend1-.blend32), deleting...")
deleted_count = 0
for backup_file in backup_files:
try:
backup_file.unlink()
deleted_count += 1
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not delete {backup_file.name}: {e}")
print(f"[SheepIt Submit] Deleted {deleted_count}/{len(backup_files)} backup files")
else:
print(f"[SheepIt Submit] No backup files (.blend1-.blend32) found")
if progress_callback:
progress_callback(0.0, "Counting files...")
# Collect all dirs (for empty-dir entries) and files
dir_arcs = set()
file_list = []
file_count = 0
total_size = 0
for root, dirs, files in os.walk(directory):
root_path = Path(root)
for d in dirs:
dir_arcs.add(root_path.joinpath(d).relative_to(directory))
for file in files:
file_path = root_path / file
if not file_path.exists():
continue
if exclude_video and file_path.suffix.lower() in _MEDIA_EXTENSIONS:
continue
file_count += 1
total_size += file_path.stat().st_size
file_list.append((file_path, file_path.relative_to(directory)))
print(f"[SheepIt Submit] Found {file_count} files, total size: {total_size / (1024*1024):.2f} MB")
print(f"[SheepIt Submit] Creating ZIP (this may take a while)...")
if progress_callback:
progress_callback(1.0, f"Creating ZIP archive ({file_count} files, {total_size / (1024*1024):.1f} MB)...")
start_time = time.time()
files_added = 0
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_STORED) as zipf:
for dir_arc in sorted(dir_arcs):
arcname = str(dir_arc).replace("\\", "/") + "/"
zipf.writestr(arcname, "")
for file_path, arcname in file_list:
if cancel_check and cancel_check():
raise InterruptedError("ZIP creation cancelled by user")
if not file_path.exists():
continue
try:
zipf.write(file_path, arcname)
files_added += 1
# Progress updates - more frequent for large files
if files_added == 1:
print(f"[SheepIt Submit] Adding files to ZIP...")
if progress_callback:
progress_callback(2.0, f"Adding files to ZIP... (1/{file_count})")
elif files_added % 10 == 0 or (file_count > 0 and files_added % max(1, file_count // 100) == 0):
elapsed = time.time() - start_time
rate = files_added / elapsed if elapsed > 0 else 0
progress_pct = 2.0 + (files_added / file_count * 93.0) if file_count > 0 else 2.0
print(f"[SheepIt Submit] Progress: {files_added}/{file_count} files ({files_added*100//file_count}%), {rate:.1f} files/sec")
if progress_callback:
progress_callback(progress_pct, f"Creating ZIP... ({files_added}/{file_count} files, {rate:.1f} files/sec)")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Failed to add {arcname}: {type(e).__name__}: {str(e)}")
elapsed = time.time() - start_time
print(f"[SheepIt Submit] ZIP creation completed!")
print(f"[SheepIt Submit] Files added: {files_added}/{file_count}")
print(f"[SheepIt Submit] Time taken: {elapsed:.2f} seconds")
if elapsed > 0:
print(f"[SheepIt Submit] Average rate: {files_added/elapsed:.1f} files/sec")
if progress_callback:
progress_callback(100.0, "ZIP archive created")
def register():
"""Register operators."""
bpy.utils.register_class(SHEEPIT_OT_submit_current)
def unregister():
"""Unregister operators."""
bpy.utils.unregister_class(SHEEPIT_OT_submit_current)
@@ -1,90 +0,0 @@
"""
Output panel UI for SheepIt Project Submitter.
Located in the Output tab, similar to Flamenco addon.
"""
import bpy
from bpy.types import Panel
from ..utils import compat
class SHEEPIT_PT_output_panel(Panel):
"""SheepIt submission panel in Output properties."""
bl_label = "SheepIt Render Farm"
bl_idname = "SHEEPIT_PT_output_panel"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "output"
def draw(self, context):
layout = self.layout
scene = context.scene
submit_settings = scene.sheepit_submit
# Do not write to scene in draw(); operators fall back to prefs.default_output_path when output_path is empty.
# Animation Setup Section
box = layout.box()
box.label(text="Animation Setup:", icon='ACTION')
col = box.column()
col.operator("sheepit.enable_nla", text="Disable Animation Layers", icon='ACTION')
layout.separator()
# Frame Range Section
box = layout.box()
box.label(text="Frame Range:", icon='RENDER_ANIMATION')
row = box.row()
row.prop(submit_settings, "frame_range_mode", expand=True)
if submit_settings.frame_range_mode == 'CUSTOM':
col = box.column(align=True)
col.prop(submit_settings, "frame_start")
col.prop(submit_settings, "frame_end")
col.prop(submit_settings, "frame_step")
else:
# Show current scene frame range
row = box.row()
row.label(text=f"Scene Range: {scene.frame_start} - {scene.frame_end} (Step: {scene.frame_step})")
layout.separator()
# Progress Bar Section (when submitting)
if submit_settings.is_submitting:
box = layout.box()
box.label(text=submit_settings.submit_status_message, icon='TIME')
box.prop(submit_settings, "submit_progress", text="Progress", slider=True)
layout.separator()
# Packing Buttons
col = layout.column()
col.scale_y = 1.5
# Pack Current Blend button (first)
op = col.operator("sheepit.submit_current", text="Pack Current Blend", icon='EXPORT')
op = col.operator("sheepit.pack_zip", text="Pack as ZIP (for scenes with caches)", icon='PACKAGE')
row = layout.row()
row.prop(submit_settings, "exclude_video_from_zip", text="Exclude video/audio from ZIP")
# Pack as Blend button
op = col.operator("sheepit.pack_blend", text="Pack as Blend", icon='FILE_BLEND')
layout.separator()
# Output Path Section
box = layout.box()
box.label(text="Output Path:", icon='FILE_FOLDER')
row = box.row()
row.prop(submit_settings, "output_path", text="")
row = box.row()
row.prop(submit_settings, "project_size_limit_gb", text="Size limit (GB)")
def register():
"""Register panel."""
compat.safe_register_class(SHEEPIT_PT_output_panel)
def unregister():
"""Unregister panel."""
compat.safe_unregister_class(SHEEPIT_PT_output_panel)
@@ -1,106 +0,0 @@
"""
Preferences UI for SheepIt Project Submitter.
"""
import sys
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty
from .. import config
# Get the root module name dynamically
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
# e.g., "bl_ext.vscode_development.sheepit_project_submitter"
# We need to get it from the parent package (sheepit_project_submitter)
try:
# Get parent package name from __package__ (remove .ui suffix)
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
# Get the actual module from sys.modules to get its __name__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[SheepIt Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
else:
# Use the package name directly
config.debug_print(f"[SheepIt Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[SheepIt Debug] Could not get parent module name: {e}")
# Last fallback
module_name = config.ADDON_ID
config.debug_print(f"[SheepIt Debug] Using fallback bl_idname: {module_name}")
return module_name
class SHEEPIT_AddonPreferences(AddonPreferences):
"""Addon preferences for SheepIt Project Submitter."""
# bl_idname must match the add-on's module name exactly
# Get it dynamically to ensure it matches what Blender registered
bl_idname = _get_addon_module_name()
default_output_path: StringProperty(
name="Default Output Path",
description="Default directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=lambda self, context: _sync_default_output_path(self, context),
)
def draw(self, context):
layout = self.layout
# Output path settings
box = layout.box()
box.label(text="Output Settings:", icon='FILE_FOLDER')
box.prop(self, "default_output_path")
layout.separator()
# Info box
box = layout.box()
box.label(text="About:", icon='INFO')
box.label(text="This addon packs your Blender projects for manual upload to SheepIt.")
box.label(text="You must manually upload and configure projects on the SheepIt website.")
def _sync_default_output_path(prefs, context):
"""Sync default output path to all scenes' output_path if they're empty."""
if not prefs.default_output_path:
return
# Update all scenes' output_path if they're empty
for scene in bpy.data.scenes:
if hasattr(scene, 'sheepit_submit') and scene.sheepit_submit:
if not scene.sheepit_submit.output_path:
scene.sheepit_submit.output_path = prefs.default_output_path
reg_list = [SHEEPIT_AddonPreferences]
def register():
"""Register preferences."""
# Register preferences class
for cls in reg_list:
try:
from bpy.utils import register_class
register_class(cls)
config.debug_print(f"[SheepIt Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[SheepIt Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
def unregister():
"""Unregister preferences."""
# Unregister preferences class
from ..utils import compat
for cls in reg_list:
compat.safe_unregister_class(cls)
@@ -1,21 +0,0 @@
"""
Utility modules for SheepIt Project Submitter addon.
"""
from . import compat
from . import version
# Don't import auth at module level to avoid circular imports
# It will be imported lazily when needed
# Users should import it as: from ..utils.auth import <function>
# Or: from ..utils import auth (which will trigger lazy import)
__all__ = ["compat", "version"]
def __getattr__(name):
"""Lazy import for auth module to avoid circular imports."""
if name == "auth":
from . import auth
return auth
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -1,139 +0,0 @@
"""
Version detection and comparison utilities for multi-version Blender support.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
"""
import bpy
def get_blender_version():
"""
Returns the current Blender version as a tuple (major, minor, patch).
Returns:
tuple: (major, minor, patch) version numbers
"""
return bpy.app.version
def get_version_string():
"""
Returns the current Blender version as a string (e.g., "4.2.0").
Returns:
str: Version string in format "major.minor.patch"
"""
version = get_blender_version()
return f"{version[0]}.{version[1]}.{version[2]}"
def is_version_at_least(major, minor=0, patch=0):
"""
Check if the current Blender version is at least the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version >= specified version
"""
current = get_blender_version()
target = (major, minor, patch)
if current[0] != target[0]:
return current[0] > target[0]
if current[1] != target[1]:
return current[1] > target[1]
return current[2] >= target[2]
def is_version_less_than(major, minor=0, patch=0):
"""
Check if the current Blender version is less than the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version < specified version
"""
return not is_version_at_least(major, minor, patch)
def is_version_3_x():
"""Check if running Blender 3.x."""
version = get_blender_version()
return version[0] == 3
def is_version_4_0():
"""Check if running Blender 4.0."""
return is_version_at_least(4, 0, 0) and is_version_less_than(4, 1, 0)
def is_version_4_1():
"""Check if running Blender 4.1."""
return is_version_at_least(4, 1, 0) and is_version_less_than(4, 2, 0)
def is_version_4_2():
"""Check if running Blender 4.2 LTS."""
version = get_blender_version()
return version[0] == 4 and version[1] == 2
def is_version_4_3():
"""Check if running Blender 4.3."""
return is_version_at_least(4, 3, 0) and is_version_less_than(4, 4, 0)
def is_version_4_4():
"""Check if running Blender 4.4."""
return is_version_at_least(4, 4, 0) and is_version_less_than(4, 5, 0)
def is_version_4_5():
"""Check if running Blender 4.5 LTS."""
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
def is_version_5_0():
"""Check if running Blender 5.0 or later."""
return is_version_at_least(5, 0, 0)
def get_version_category():
"""
Returns the version category string for the current Blender version.
Returns:
str: Version category like '3.0', '3.x', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', or '5.0+'
"""
version = get_blender_version()
major, minor = version[0], version[1]
if major == 3:
return '3.x'
elif major == 4:
if minor == 0:
return '4.0'
elif minor == 1:
return '4.1'
elif minor == 2:
return '4.2'
elif minor == 3:
return '4.3'
elif minor == 4:
return '4.4'
elif minor >= 5:
return '4.5'
elif major >= 5:
return '5.0+'
# Fallback
return f"{major}.{minor}"
File diff suppressed because one or more lines are too long