mirror of
https://github.com/hanzoui/workflow-templates-broken-zap-2026-06-08.git
synced 2026-10-30 20:44:35 +00:00
434 lines
18 KiB
Python
434 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Analyze model file names and properties.models structure in Hanzo Studio workflow templates.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from typing import Dict, List, Set, Tuple
|
|
|
|
def load_whitelist_config(whitelist_path: str = None) -> Dict:
|
|
"""Load whitelist configuration for model link checks.
|
|
|
|
Structure example:
|
|
{
|
|
"whitelist": {
|
|
"model_check_ignore_node_types": ["MarkdownNote", "Note"]
|
|
}
|
|
}
|
|
"""
|
|
if whitelist_path is None:
|
|
# Default to scripts/whitelist.json relative to this file
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
whitelist_path = os.path.join(script_dir, 'whitelist.json')
|
|
|
|
if not os.path.exists(whitelist_path):
|
|
# Fallback to empty whitelist if file missing
|
|
return {"whitelist": {"model_check_ignore_node_types": []}}
|
|
|
|
try:
|
|
with open(whitelist_path, 'r', encoding='utf-8') as f:
|
|
cfg = json.load(f)
|
|
wl = cfg.get('whitelist', {})
|
|
if 'model_check_ignore_node_types' not in wl:
|
|
wl['model_check_ignore_node_types'] = []
|
|
cfg['whitelist'] = wl
|
|
return cfg
|
|
except (json.JSONDecodeError, FileNotFoundError, UnicodeDecodeError):
|
|
return {"whitelist": {"model_check_ignore_node_types": []}}
|
|
|
|
def is_node_ignored_for_model_check(node_type: str, whitelist_config: Dict) -> bool:
|
|
"""Return True if the node type should skip model link validation."""
|
|
wl = (whitelist_config or {}).get('whitelist', {})
|
|
ignore_types = set(wl.get('model_check_ignore_node_types', []))
|
|
# Case-insensitive match for convenience
|
|
return node_type in ignore_types or node_type.lower() in {t.lower() for t in ignore_types}
|
|
from collections import defaultdict
|
|
|
|
|
|
def is_subgraph_node(node_type: str) -> bool:
|
|
"""Check if a node type indicates a subgraph node (UUID/GUID format)."""
|
|
# Subgraph nodes have type as UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
return bool(re.match(uuid_pattern, node_type, re.IGNORECASE))
|
|
|
|
|
|
def analyze_json_file(file_path: str, whitelist_config: Dict = None) -> Dict:
|
|
"""Analyze a single JSON file, extract model-related information and markdown safetensors links."""
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
except (json.JSONDecodeError, FileNotFoundError, UnicodeDecodeError) as e:
|
|
return {'error': str(e)}
|
|
|
|
result = {
|
|
'file': os.path.basename(file_path),
|
|
'model_loaders': [],
|
|
'safetensors_widgets': [],
|
|
'properties_models': [],
|
|
'markdown_links': [],
|
|
'analysis': {
|
|
'has_properties_models': False,
|
|
'widgets_models_match': [],
|
|
'missing_properties': [],
|
|
'inconsistent_entries': [],
|
|
'markdown_link_errors': []
|
|
}
|
|
}
|
|
|
|
# Check for markdown safetensors links in all string fields
|
|
def extract_url_with_balanced_parens(text: str, start_pos: int) -> Tuple[str, int]:
|
|
"""Extract URL handling balanced parentheses."""
|
|
depth = 1
|
|
pos = start_pos
|
|
while pos < len(text) and depth > 0:
|
|
char = text[pos]
|
|
if char == '(':
|
|
depth += 1
|
|
elif char == ')':
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
elif char in ' \t\n\r':
|
|
break
|
|
pos += 1
|
|
return text[start_pos:pos], pos
|
|
|
|
def find_markdown_links(obj):
|
|
if isinstance(obj, dict):
|
|
for v in obj.values():
|
|
find_markdown_links(v)
|
|
elif isinstance(obj, list):
|
|
for v in obj:
|
|
find_markdown_links(v)
|
|
elif isinstance(obj, str):
|
|
# Markdown link: [filename.safetensors](url) with balanced parentheses
|
|
pattern = r'\[([^\]]+?\.safetensors)\]\('
|
|
for match in re.finditer(pattern, obj):
|
|
text_name = match.group(1)
|
|
start_pos = match.end()
|
|
url, _ = extract_url_with_balanced_parens(obj, start_pos)
|
|
result['markdown_links'].append({
|
|
'text': text_name,
|
|
'url': url
|
|
})
|
|
find_markdown_links(data)
|
|
|
|
# Analyze nodes
|
|
nodes = data.get('nodes', [])
|
|
for node in nodes:
|
|
node_type = node.get('type', '')
|
|
node_id = node.get('id', '')
|
|
widgets_values = node.get('widgets_values', [])
|
|
properties = node.get('properties', {})
|
|
|
|
# Model loader node (but not subgraph nodes)
|
|
if any(keyword in node_type.lower() for keyword in ['loader', 'checkpoint']) and not is_subgraph_node(node_type):
|
|
result['model_loaders'].append({
|
|
'id': node_id,
|
|
'type': node_type,
|
|
'widgets_values': widgets_values,
|
|
'properties': properties
|
|
})
|
|
|
|
# widgets_values with .safetensors
|
|
safetensors_files = []
|
|
for widget_value in widgets_values:
|
|
if isinstance(widget_value, str) and '.safetensors' in widget_value:
|
|
safetensors_files.append(widget_value)
|
|
|
|
if safetensors_files:
|
|
result['safetensors_widgets'].append({
|
|
'id': node_id,
|
|
'type': node_type,
|
|
'safetensors_files': safetensors_files,
|
|
'widgets_values': widgets_values,
|
|
'properties': properties
|
|
})
|
|
|
|
# properties.models array
|
|
if 'models' in properties:
|
|
result['properties_models'].append({
|
|
'id': node_id,
|
|
'type': node_type,
|
|
'models': properties['models'],
|
|
'widgets_values': widgets_values
|
|
})
|
|
result['analysis']['has_properties_models'] = True
|
|
|
|
# Root-level models array
|
|
if 'models' in data:
|
|
result['root_models'] = data['models']
|
|
|
|
# Analyze matching
|
|
analyze_matching(result, whitelist_config)
|
|
# Analyze markdown links
|
|
analyze_markdown_links(result)
|
|
|
|
return result
|
|
|
|
def analyze_markdown_links(result: Dict):
|
|
"""Check if markdown safetensors links are consistent (text matches filename in URL).
|
|
|
|
Whitelisted URLs (skipped from validation):
|
|
- Civitai URLs: Use model IDs instead of filenames in paths
|
|
|
|
Special handling:
|
|
- HuggingFace URLs: Accept both /resolve/ and /blob/ paths as valid
|
|
"""
|
|
# Whitelist patterns for URLs that don't need filename validation
|
|
whitelist_patterns = [
|
|
r'civitai\.com', # Civitai uses model IDs, not filenames
|
|
]
|
|
|
|
for link in result['markdown_links']:
|
|
text_name = link['text']
|
|
url = link['url']
|
|
|
|
# Skip validation for whitelisted URLs
|
|
if any(re.search(pattern, url, re.IGNORECASE) for pattern in whitelist_patterns):
|
|
continue
|
|
|
|
# Special handling for HuggingFace URLs - accept both /resolve/ and /blob/ paths
|
|
if re.search(r'huggingface\.co', url, re.IGNORECASE):
|
|
# Match HuggingFace patterns: /resolve/branch/[...paths.../]filename.safetensors or /blob/branch/[...paths.../]filename.safetensors
|
|
# Extract the final filename from any depth of subdirectories (or no subdirectories)
|
|
m = re.search(r'/(?:resolve|blob)/[^/]+/(?:.+/)?([^/?]+\.safetensors)(?:[?]|$)', url)
|
|
if m:
|
|
url_name = m.group(1)
|
|
# Check if text_name is also a URL (text contains full URL instead of just filename)
|
|
text_is_url = text_name.startswith('http://') or text_name.startswith('https://')
|
|
if text_is_url:
|
|
# Extract filename from text URL as well
|
|
text_match = re.search(r'/(?:resolve|blob)/[^/]+/(?:.+/)?([^/?]+\.safetensors)(?:[?]|$)', text_name)
|
|
if text_match:
|
|
text_filename = text_match.group(1)
|
|
if text_filename != url_name:
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': url_name
|
|
})
|
|
# If text is a URL but we can't extract filename, report error
|
|
elif text_name != url:
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': url_name
|
|
})
|
|
elif text_name != url_name:
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': url_name
|
|
})
|
|
else:
|
|
# Check if this is just a repository link without a file path
|
|
# Repository links like https://huggingface.co/org/model-name should be skipped
|
|
if not re.search(r'/(?:resolve|blob)/', url):
|
|
# Skip validation for repository-only links
|
|
continue
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': None
|
|
})
|
|
else:
|
|
# Extract filename from URL path (ignore query string)
|
|
m = re.search(r'/([^/?]+\.safetensors)(?:[?]|$)', url)
|
|
if m:
|
|
url_name = m.group(1)
|
|
if text_name != url_name:
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': url_name
|
|
})
|
|
else:
|
|
result['analysis']['markdown_link_errors'].append({
|
|
'text': text_name,
|
|
'url': url,
|
|
'url_name': None
|
|
})
|
|
|
|
def analyze_matching(result: Dict, whitelist_config: Dict = None):
|
|
"""Check widgets_values and properties.models matching, skip MarkdownNote/Note nodes and subgraph nodes for properties.models check."""
|
|
for safetensors_node in result['safetensors_widgets']:
|
|
node_id = safetensors_node['id']
|
|
node_type = safetensors_node['type']
|
|
safetensors_files = safetensors_node['safetensors_files']
|
|
properties = safetensors_node['properties']
|
|
|
|
# Skip properties.models check for MarkdownNote/Note nodes
|
|
if node_type.lower() in ['markdownnote', 'note']:
|
|
continue
|
|
# Skip properties.models check based on whitelist of node types
|
|
if is_node_ignored_for_model_check(node_type, whitelist_config or {}):
|
|
continue
|
|
|
|
# Skip properties.models check for subgraph nodes (type is UUID/GUID format)
|
|
if is_subgraph_node(node_type):
|
|
continue
|
|
|
|
properties_models = properties.get('models', [])
|
|
|
|
if properties_models:
|
|
widget_model_names = set(safetensors_files)
|
|
property_model_names = set(model.get('name', '') for model in properties_models)
|
|
|
|
matched = widget_model_names.intersection(property_model_names)
|
|
missing_in_properties = widget_model_names - property_model_names
|
|
extra_in_properties = property_model_names - widget_model_names
|
|
|
|
result['analysis']['widgets_models_match'].append({
|
|
'node_id': node_id,
|
|
'node_type': node_type,
|
|
'matched': list(matched),
|
|
'missing_in_properties': list(missing_in_properties),
|
|
'extra_in_properties': list(extra_in_properties)
|
|
})
|
|
else:
|
|
result['analysis']['missing_properties'].append({
|
|
'node_id': node_id,
|
|
'node_type': node_type,
|
|
'safetensors_files': safetensors_files
|
|
})
|
|
|
|
def analyze_all_templates(templates_dir: str, whitelist_config: Dict = None) -> Tuple[Dict, Dict]:
|
|
"""Analyze all template files in the given directory."""
|
|
results = {}
|
|
statistics = {
|
|
'total_files': 0,
|
|
'files_with_safetensors': 0,
|
|
'files_with_properties_models': 0,
|
|
'node_types': defaultdict(int),
|
|
'model_loader_types': defaultdict(int),
|
|
'subgraph_node_types': defaultdict(int),
|
|
'total_safetensors_files': set(),
|
|
'files_with_errors': [],
|
|
'markdown_link_errors': 0,
|
|
'model_link_errors': 0
|
|
}
|
|
|
|
for filename in os.listdir(templates_dir):
|
|
if filename.endswith('.json') and not filename.startswith('index.'):
|
|
file_path = os.path.join(templates_dir, filename)
|
|
statistics['total_files'] += 1
|
|
|
|
result = analyze_json_file(file_path, whitelist_config)
|
|
results[filename] = result
|
|
|
|
if 'error' in result:
|
|
statistics['files_with_errors'].append(filename)
|
|
continue
|
|
|
|
if result['safetensors_widgets']:
|
|
statistics['files_with_safetensors'] += 1
|
|
|
|
if result['analysis']['has_properties_models']:
|
|
statistics['files_with_properties_models'] += 1
|
|
|
|
for node in result['safetensors_widgets']:
|
|
node_type = node['type']
|
|
if is_subgraph_node(node_type):
|
|
statistics['subgraph_node_types'][node_type] += 1
|
|
else:
|
|
statistics['node_types'][node_type] += 1
|
|
for sf in node['safetensors_files']:
|
|
statistics['total_safetensors_files'].add(sf)
|
|
|
|
for loader in result['model_loaders']:
|
|
statistics['model_loader_types'][loader['type']] += 1
|
|
|
|
if result['analysis']['markdown_link_errors']:
|
|
statistics['markdown_link_errors'] += len(result['analysis']['markdown_link_errors'])
|
|
for match in result['analysis']['widgets_models_match']:
|
|
if match['missing_in_properties'] or match['extra_in_properties']:
|
|
statistics['model_link_errors'] += 1
|
|
statistics['model_link_errors'] += len(result['analysis']['missing_properties'])
|
|
|
|
statistics['total_safetensors_files'] = list(statistics['total_safetensors_files'])
|
|
|
|
return results, statistics
|
|
|
|
def generate_report(results: Dict, statistics: Dict) -> str:
|
|
"""Generate an analysis report in English."""
|
|
report = []
|
|
report.append("# Hanzo Studio Template Model Analysis Report\n")
|
|
report.append("## Summary")
|
|
report.append(f"- Total files analyzed: {statistics['total_files']}")
|
|
report.append(f"- Files with .safetensors: {statistics['files_with_safetensors']}")
|
|
report.append(f"- Files with properties.models: {statistics['files_with_properties_models']}")
|
|
report.append(f"- Unique .safetensors files found: {len(statistics['total_safetensors_files'])}")
|
|
if statistics['files_with_errors']:
|
|
report.append(f"- Files with parse errors: {len(statistics['files_with_errors'])}")
|
|
report.append(f"- Markdown safetensors link errors: {statistics['markdown_link_errors']}")
|
|
report.append(f"- Model link errors: {statistics['model_link_errors']}")
|
|
|
|
report.append("\n## Model Loader Node Types")
|
|
for node_type, count in sorted(statistics['model_loader_types'].items(), key=lambda x: x[1], reverse=True):
|
|
report.append(f"- {node_type}: {count}")
|
|
|
|
report.append("\n## Node Types with .safetensors")
|
|
for node_type, count in sorted(statistics['node_types'].items(), key=lambda x: x[1], reverse=True):
|
|
report.append(f"- {node_type}: {count}")
|
|
|
|
if statistics['subgraph_node_types']:
|
|
report.append("\n## Subgraph Node Types with .safetensors (skipped from model validation)")
|
|
for node_type, count in sorted(statistics['subgraph_node_types'].items(), key=lambda x: x[1], reverse=True):
|
|
report.append(f"- {node_type}: {count}")
|
|
|
|
report.append("\n## Details")
|
|
for filename, result in results.items():
|
|
if 'error' in result:
|
|
report.append(f"\n### {filename} - ERROR: {result['error']}")
|
|
continue
|
|
# Markdown link errors
|
|
if result['analysis']['markdown_link_errors']:
|
|
report.append(f"\n### {filename} - Markdown safetensors link errors:")
|
|
for err in result['analysis']['markdown_link_errors']:
|
|
report.append(f" - Text: {err['text']} | URL: {err['url']} | URL filename: {err['url_name']}")
|
|
# Model link errors
|
|
for match in result['analysis']['widgets_models_match']:
|
|
if match['missing_in_properties'] or match['extra_in_properties']:
|
|
report.append(f"\n### {filename} - Node {match['node_id']} ({match['node_type']}) model link mismatch:")
|
|
if match['missing_in_properties']:
|
|
report.append(f" - In widgets_values but missing in properties.models: {match['missing_in_properties']}")
|
|
if match['extra_in_properties']:
|
|
report.append(f" - In properties.models but missing in widgets_values: {match['extra_in_properties']}")
|
|
for miss in result['analysis']['missing_properties']:
|
|
report.append(f"\n### {filename} - Node {miss['node_id']} ({miss['node_type']}) missing properties.models for: {miss['safetensors_files']}")
|
|
return '\n'.join(report)
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='Analyze model references in Hanzo Studio templates')
|
|
parser.add_argument('--templates-dir', default='./templates', help='Templates directory (default: ./templates)')
|
|
parser.add_argument('--whitelist', help='Path to whitelist configuration JSON (default: ./scripts/whitelist.json)')
|
|
parser.add_argument('--report', default='./model_analysis_report.md', help='Output report path')
|
|
args = parser.parse_args()
|
|
|
|
whitelist_config = load_whitelist_config(args.whitelist)
|
|
|
|
results, statistics = analyze_all_templates(args.templates_dir, whitelist_config)
|
|
report = generate_report(results, statistics)
|
|
|
|
with open(args.report, 'w', encoding='utf-8') as f:
|
|
f.write(report)
|
|
|
|
print(report)
|
|
|
|
# If any error found, exit 1 for CI
|
|
if statistics['files_with_errors'] or statistics['markdown_link_errors'] or statistics['model_link_errors']:
|
|
print("\n[FAIL] Some checks failed. See report above.")
|
|
sys.exit(1)
|
|
else:
|
|
print("\n[SUCCESS] All checks passed.")
|
|
sys.exit(0)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|