Source code for usdmanager.utils

# Copyright 2018 DreamWorks Animation L.L.C.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# See the License for the specific language governing permissions and
# limitations under the License.
Generic utility functions
import importlib
import logging
import os
import re
import sys
import math
import subprocess
import tempfile
from contextlib import contextmanager
from glob import glob
from pkg_resources import resource_filename

import Qt
from Qt import QtCore, QtWidgets
if Qt.IsPySide:
    import pysideuic as uic
elif Qt.IsPySide2:
    import pyside2uic as uic
    uic = Qt._uic


# Set up logging.
logger = logging.getLogger(__name__)

    from pxr import Ar
    resolver = Ar.GetResolver()
except ImportError:
    logger.warn("Unable to create AssetResolver - Asset links may not work correctly")
    resolver = None

[docs]def expandPath(path, parentPath=None, sdf_format_args=None, extractedDir=None): """ Expand and normalize a path that may have variables in it. Do not use this for URLs with query strings. :Parameters: path : `str` File path parentPath : `str` | None Parent file path this file is defined in relation to. Helps with asset resolution. sdf_format_args : `dict` | None Dictionary of key/value `str` pairs from a path's :SDF_FORMAT_ARGS: extractedDir: `str` | None If the file is part of an extracted usdz archive, this is the path to the extracted dir of the archive. :Returns: Normalized path with variables expanded. :Rtype: `str` """ # Expand the ~ part of any path first. The asset resolver doesn't understand it. path = os.path.expanduser(os.path.normpath(path)) if resolver is not None: try: # ConfigureResolverForAsset no longer exists under Ar 2.0; # this check is here for backwards compatibility with Ar 1.0 if hasattr(resolver, "ConfigureResolverForAsset"): resolver.ConfigureResolverForAsset(path) context = resolver.CreateDefaultContextForAsset(path) with Ar.ResolverContextBinder(context): anchoredPath = path if parentPath is None else resolver.AnchorRelativePath(parentPath, path) resolved = resolver.Resolve(anchoredPath) # # If resolving relative to the layer fails in a usdz archive, # try to resolve based on the archive's default layer path. if extractedDir and not os.path.exists(resolved): default_layer = os.path.join(extractedDir, 'defaultLayer.usd') anchoredPath = resolver.AnchorRelativePath(default_layer, path) resolved = resolver.Resolve(anchoredPath) except Exception: logger.warn("Failed to resolve Asset path %s with parent %s", path, parentPath) else: if resolved: return resolved # Return this best-attempt if all else fails. return QtCore.QDir.cleanPath(os.path.expandvars(path))
[docs]def expandUrl(path, parentPath=None): """ Expand and normalize a URL that may have variables in it and a query string after it. :Parameters: path : `str` File path parentPath : `str` | None Parent file path this file is defined in relation to. Helps with asset resolution. :Returns: URL with normalized path with variables expanded. :Rtype: `QtCore.QUrl` """ sdf_format_args = {} path = stripFileScheme(path) if "?" in path: sdf_format_args.update(sdfQuery(QtCore.QUrl.fromLocalFile(path))) path, query = path.split("?", 1) else: query = None url = QtCore.QUrl.fromLocalFile(os.path.abspath(expandPath(path, parentPath, sdf_format_args))) if query: url.setQuery(query) return url
[docs]def strToUrl(path): """ Properly set the query parameter of a URL, which doesn't seem to set QUrl.hasQuery properly unless using .setQuery (or .setQueryItems in Qt5). Use this when a path might have a query string after it or start with file://. In all other cases. QUrl.fromLocalFile should work fine. :Parameters: path : `str` URL string :Returns: URL object :Rtype: `QtCore.QUrl` """ if "?" in path: path, query = path.split("?", 1) else: query = None if path.startswith("file://"): url = QtCore.QUrl(path) else: url = QtCore.QUrl.fromLocalFile(path) if query: if Qt.IsPySide2 or Qt.IsPyQt5: url.setQuery(query) else: url.setQueryItems([x.split("=", 1) for x in query.split("&")]) return url
[docs]def stripFileScheme(path): """ Strip any file URI scheme from the beginning of a path. Parameters: path : `str` File path or file URL :Returns: File path :Rtype: `str` """ return path[7:] if path.startswith("file://") else path
[docs]def findModules(subdir): """ Find and import all modules in a subdirectory of this project. Ignores any files starting with an underscore or tilde. :Parameters: subdir : `str` Subdirectory :Returns: Imported modules :Rtype: `list` """ modules = [] pluginPath = resource_filename(__name__, subdir)"Searching for *.py plugins in %s", pluginPath) for f in glob(os.path.join(pluginPath, "*.py")): moduleName = os.path.splitext(os.path.basename(f))[0] if moduleName.startswith('_') or moduleName.startswith('~'): continue module = importlib.import_module("..{}.{}".format(subdir, moduleName), __name__) modules.append(module) return modules
[docs]def generateTemporaryUsdFile(usdFileName, tmpDir=None): """ Generate a temporary ASCII USD file that the user can edit. :Parameters: usdFileName : `str` Binary USD file path tmpDir : `str` | None Temp directory to create the new file within :Returns: Temporary file name :Rtype: `str` :Raises OSError: If usdcat fails """ fd, tmpFileName = tempfile.mkstemp(suffix="." + USD_AMBIGUOUS_EXTS[0], dir=tmpDir) os.close(fd) usdcat(QtCore.QDir.toNativeSeparators(usdFileName), tmpFileName, format="usda") return tmpFileName
[docs]def usdcat(inputFile, outputFile, format=None): """ Generate a temporary ASCII USD file that the user can edit. :Parameters: inputFile : `str` Input file name outputFile : `str` Output file name format : `str` | None Output USD format (e.g. usda or usdc) Only used if outputFile's extension is .usd :Raises OSError: If usdcat fails :Raises ValueError: If invalid format given compared to output file extension. """ if == "nt": # Files with spaces have to be double-quoted on Windows. cmd = 'usdcat "{}" -o "{}"'.format(inputFile, outputFile) else: cmd = 'usdcat {} -o {}'.format(inputFile, outputFile) if format and outputFile.endswith(".usd"): # For usdcat, use of --usdFormat requires output file end with '.usd' extension. cmd += " --usdFormat {}".format(format) logger.debug(cmd) try: subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True) except subprocess.CalledProcessError as e: raise OSError("Failed to convert file {}: {}".format(inputFile, e.output))
[docs]def usdzip(inputs, dest): """ Zip or unzip a usdz format file. :Parameters: inputs : `str` | `list` Input file name(s). String or list of strings dest : `str` Output directory (for unzip) or file name :Raises OSError: If usdzip fails """ if == "nt": # Files with spaces have to be double-quoted on Windows. if type(inputs) is list: inputs = '" "'.join(inputs) cmd = 'usdzip "{}" "{}"'.format(inputs, dest) logger.debug(cmd) else: cmd = ["usdzip"] if type(inputs) is list: cmd += inputs else: cmd.append(inputs) cmd.append(dest) logger.debug(subprocess.list2cmdline(cmd)) try: subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True) except subprocess.CalledProcessError as e: raise OSError("Failed to zip: {}".format(e.output))
[docs]def unzip(path, tmpDir=None): """ Unzip a usdz format file to a temporary directory. :Parameters: path : `str` Input .usdz file tmpDir : `str` | None Temp directory to create the new unzipped directory within :Returns: Absolute path to destination directory for unzipped usdz :Rtype: `str` :Raises zipfile.BadZipfile: For bad ZIP files :Raises zipfile.LargeZipFile: When a ZIP file would require ZIP64 functionality but that has not been enabled """ from zipfile import ZipFile destDir = tempfile.mkdtemp(prefix="usdmanager_usdz_", dir=tmpDir) logger.debug("Extracting %s to %s", path, destDir) with ZipFile(QtCore.QDir.toNativeSeparators(path), 'r') as zipRef: zipRef.extractall(destDir) return destDir
[docs]def getUsdzLayer(usdzDir, layer=None, usdz=None): """ Get a layer from an unzipped usdz archive. :Parameters: usdzDir : `str` Unzipped directory path layer : `str` Default layer within file (e.g. the portion within the square brackets here: @foo.usdz[path/to/file/within/package.usd]@) usdz : `str` Original usdz file path :Returns: Layer file path :Rtype: `str` :Raises ValueError: If default layer not found """ if layer is not None: destFile = os.path.join(usdzDir, layer) if os.path.exists(destFile): return destFile else: raise ValueError("Layer {} not found in usdz archive {}".format(layer, usdzDir)) if usdz is not None: try: from pxr import Usd except ImportError: logger.debug("Unable to import pxr.Usd to find usdz default layer.") else: zipFile = Usd.ZipFile.Open(usdz) if zipFile: for fileName in zipFile.GetFileNames(): return os.path.join(usdzDir, fileName) raise ValueError("Default layer not found in usdz archive!") # Fallback to checking the files on disk instead of using USD. destFile = os.path.join(usdzDir, "defaultLayer.usd") if os.path.exists(destFile): return destFile files = [] for ext in USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS + USD_CRATE_EXTS: files += glob(os.path.join(usdzDir, "*." + ext)) if files: if len(files) == 1: return files[0] raise ValueError("Ambiguous default layer in usdz archive!") raise ValueError("No default layer found in usdz archive!")
[docs]def humanReadableSize(size): """ Get a human-readable file size string from bytes. :Parameters: size : `int` File size, in bytes :Returns: Human-readable file size :Rtype: `str` """ for unit in ("bytes", "kB", "MB", "GB"): if abs(size) < 1024: return "{:.1f} {}".format(size, unit) size /= 1024.0 return "{:.1f} TB".format(size)
[docs]def isUsdCrate(path): """ Check if a file is a USD crate file by reading in the first line of the file. Doesn't check the file extension. :Parameters: path : `str` USD file path :Returns: If the USD file is a crate (binary) file. :Rtype: `bool` """ with open(path, "rb") as f: return"utf-8") == "PXR-USDC"
[docs]def isPy3(): """ Check if the application is running Python 3. :Returns: If the application is running Python 3. :Rtype: `bool` """ return sys.version_info[0] == 3
[docs]def round(value, decimals=0): """ Python 2/3 compatible rounding function. Lifted from :Parameters: value : `float` The value to perform the rounding operation on. decimals : `int` The number of decimal places to retain. :Returns: The rounded value. :Rtype: `float` """ p = 10 ** decimals if value > 0: return float(math.floor((value * p) + 0.5)) / p else: return float(math.ceil((value * p) - 0.5)) / p
[docs]def isUsdExt(ext): """ Check if the given extension is an expected USD file extension. :Parameters: ext : `str` :Returns: If the file extension is a valid USD extension :Rtype: `bool` """ return ext.lstrip('.') in USD_EXTS
[docs]def isUsdFile(path): """ Check if the given file is a USD file based on the file's extension. :Parameters: path : `str` :Returns: If the file extension is a valid USD extension :Rtype: `bool` """ return isUsdExt(os.path.splitext(path)[1])
[docs]def loadUiType(uiFile, sourceFile=None, className="DefaultWidgetClass"): """ Used to define a custom widget's class. :Parameters: uiFile : `str` UI file path. Can be relative if loading from the same directory as sourceFile. sourceFile : `str` File path of loading module. Used to help find embedded resources and to find uiFile when the file path is relative. className : `str` Class name :Returns: Class type :Rtype: `type` """ import sys import xml.etree.ElementTree as xml if isPy3(): from io import StringIO else: from StringIO import StringIO if not os.path.exists(uiFile) and not os.path.isabs(uiFile): if sourceFile is None: uiFile = resource_filename(__name__, uiFile) sourceDir = os.path.dirname(uiFile) else: sourceDir = os.path.dirname(sourceFile) uiFile = os.path.join(sourceDir, uiFile) else: sourceDir = os.path.dirname(uiFile) # Search for resources in this tool's directory. if sourceDir not in sys.path: sys.path.insert(0, sourceDir) parsed = xml.parse(uiFile) widget_class = parsed.find('widget').get('class') form_class = parsed.find('class').text with open(uiFile) as f: o = StringIO() frame = {} uic.compileUi(f, o, indent=0) pyc = compile(o.getvalue(), "<string>", "exec") exec(pyc) in frame # Fetch the base_class and form class based on their type. form_class = frame["Ui_{}".format(form_class)] base_class = eval("QtWidgets.{}".format(widget_class)) return type("{}Base".format(className), (form_class, base_class), {})
[docs]def loadUiWidget(path, parent=None, source_path=None): """ Load a Qt Designer .ui file and return an instance of the user interface :Parameters: path : `str` Absolute path to .ui file parent : `QtWidgets.QWidget` The widget into which UI widgets are loaded source_path : `str` File loading the UI file, if the UI file is relative and needs to be found in the same directory :Returns: The widget instance :Rtype: `QtWidgets.QWidget` """ from Qt import QtCompat if not os.path.exists(path) and not os.path.isabs(path): # Assume the .ui file lives in this directory. if source_path is None: path = resource_filename(__name__, path) else: path = os.path.join(os.path.dirname(os.path.realpath(source_path)), path) ui = QtCompat.loadUi(path, parent) if parent: #ui.setParent(parent) for member in dir(ui): if not member.startswith('__') and member is not 'staticMetaObject': setattr(parent, member, getattr(ui, member)) return ui
[docs]@contextmanager def overrideCursor(cursor=QtCore.Qt.WaitCursor): """ For use with the "with" keyword, so the override cursor is always restored via a try/finally block, even if the commands in-between fail. Example: with overrideCursor(): # do something that may raise an error """ from Qt.QtWidgets import QApplication QApplication.setOverrideCursor(cursor) try: yield finally: QApplication.restoreOverrideCursor()
[docs]def queryItemValue(url, key, default=None): """ compatibility, since Qt5 introduced QUrlQuery, but doesn't support that. PyQt4 just uses QUrl for everything, including hasQueryItem and queryItemValue. :Parameters: url : `QtCore.QUrl` Full URL with query string key : `str` Query key default Value if key not found :Returns: Query value, or None :Rtype: `str` | None :Raises ValueError: If an invalid query string is given """ if url.hasQuery(): query = url.toString().split("?", 1)[1] for item in query.split(url.queryPairDelimiter()): if item: try: k, v = item.split(url.queryValueDelimiter()) except ValueError: logger.error("Invalid query string: %s", query) else: if k == key: return v return default
[docs]def queryItemBoolValue(url, key, default=False): """ Get a boolean value from a query string. :Parameters: url : `QtCore.QUrl` Full URL with query string key : `str` Query key default Value if key not found :Returns: Query value :Rtype: `bool` """ value = queryItemValue(url, key, default) return value and value != "0"
[docs]def sdfQuery(link): """ Process a link's query items to see if it has our special sdf entry. This is used to pass along :SDF_FORMAT_ARGS: key/value pairs to downstream files. :Parameters: link : `QtCore.QUrl` Link :Returns: Sdf format args :Rtype: `dict` """ sdf_format_args = {} try: for kv in queryItemValue(link, "sdf", "").split("+"): # TODO: Figure out something that works better as key=value& separators. k, v = kv.split(":", 1) sdf_format_args[k] = v except ValueError: # No sdf query parameter. pass except Exception: logger.exception("Invalid sdf query parameter") return sdf_format_args
[docs]def urlFragmentToQuery(url): """ Convert a URL with a fragment (e.g. url#?foo=bar) to a URL with a query string. Normally, this app treats that as a file to NOT reload, using the query string as a mechanism to modify the currently loaded file, such as jumping to a line number. We instead convert this to a "normal" URL with a query string if the URL needs to load in a new tab or new window, for example. :Parameters: url : `QtCore.QUrl` URL :Returns: Converted URL :Rtype: `QtCore.QUrl` """ if url.hasFragment(): fragment = url.fragment() url.setFragment(None) if fragment.startswith("?"): url.setQuery(fragment[1:]) return url
[docs]def usdRegEx(exts): """ RegEx to find other file paths in USD-based text files. :Parameters: exts: Iterable of `str` file path extensions without the starting dot. """ return re.compile( r'(?:[\'"@]+)' # 1 or more single quote, double quote, or at symbol. r'(' # Group 1: Path. This is the main group we are looking for. Matches based on extension before the pipe, or variable after the pipe. r'[^\t\n\r\f\v\'"]*?' # 0 or more (greedy) non-whitespace characters (regular spaces are ok) and no quotes followed by a period, then 1 of the acceptable file extensions. NOTE: Backslash exclusion removed for Windows support; make sure this doesn't negatively affect other systems. r'\.(?:'+'|'.join(exts)+r')' # followed by a period, then 1 of the acceptable file extensions r'|\${[\w/${}:.-]+}' # One or more of these characters -- A-Za-z0-9_-/${}:. -- inside the variable curly brackets -- ${} r')' # end group 1 r'(?:\[(.*?)\])?' # Optional layer reference for a usdz file as group 2. TODO: Figure out how to only match this if the extension matched was .usdz (e.g. foo.usdz[path/to/file/within/package.usd]) r'(?::SDF_FORMAT_ARGS:(.*?))?' # Optional :SDF_FORMAT_ARGS:key=value&foo=bar, with the query string parameters as group 3 r'(?:[\'"@]|\\\")' # 1 of: single quote, double quote, backslash followed by double quote, or at symbol. )