1740 lines
39 KiB
Python
1740 lines
39 KiB
Python
# Copyright 2020 by Kurt Rathjen. All Rights Reserved.
|
|
#
|
|
# This library is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Lesser General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version. This library 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 Lesser General Public License for more details.
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import re
|
|
import os
|
|
import sys
|
|
import json
|
|
import uuid
|
|
import errno
|
|
import ctypes
|
|
import shutil
|
|
import locale
|
|
import logging
|
|
import getpass
|
|
import random
|
|
import tempfile
|
|
import platform
|
|
import threading
|
|
import traceback
|
|
import collections
|
|
import distutils.version
|
|
|
|
from datetime import datetime
|
|
|
|
try:
|
|
from collections import Mapping
|
|
except ImportError:
|
|
from collections.abc import Mapping
|
|
|
|
# Use the built-in version of scandir/walk if possible,
|
|
# otherwise use the scandir module version
|
|
try:
|
|
from scandir import walk
|
|
except ImportError:
|
|
from os import walk
|
|
|
|
import studiolibrary
|
|
|
|
from studiovendor import six
|
|
from studiovendor.six.moves import urllib
|
|
|
|
|
|
__all__ = [
|
|
"reload",
|
|
"user",
|
|
"isMac",
|
|
"isMaya",
|
|
"isLinux",
|
|
"isWindows",
|
|
"addLibrary",
|
|
"setLibraries",
|
|
"removeLibrary",
|
|
"defaultLibrary",
|
|
"checkForUpdates",
|
|
"read",
|
|
"write",
|
|
"update",
|
|
"saveJson",
|
|
"readJson",
|
|
"updateJson",
|
|
"replaceJson",
|
|
"readSettings",
|
|
"saveSettings",
|
|
"updateSettings",
|
|
"settingsPath",
|
|
"relPath",
|
|
"absPath",
|
|
"tempPath",
|
|
"realPath",
|
|
"normPath",
|
|
"normPaths",
|
|
"copyPath",
|
|
"movePath",
|
|
"movePaths",
|
|
"listPaths",
|
|
"splitPath",
|
|
"localPath",
|
|
"removePath",
|
|
"renamePath",
|
|
"formatPath",
|
|
"pathsFromUrls",
|
|
"resolveModule",
|
|
"createTempPath",
|
|
"renamePathInFile",
|
|
"walkup",
|
|
"generateUniquePath",
|
|
"MovePathError",
|
|
"RenamePathError",
|
|
"timeAgo",
|
|
"modules",
|
|
"setDebugMode",
|
|
"showInFolder",
|
|
"stringToList",
|
|
"listToString",
|
|
"registerItem",
|
|
"registerItems",
|
|
"registeredItems",
|
|
"runTests",
|
|
"findItemsInFolders",
|
|
"isVersionPath",
|
|
"latestVersionPath",
|
|
]
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_itemClasses = collections.OrderedDict()
|
|
|
|
|
|
class PathError(IOError):
|
|
"""
|
|
Exception that supports unicode escape characters.
|
|
"""
|
|
def __init__(self, msg):
|
|
"""
|
|
:type: str or unicode
|
|
"""
|
|
msg = six.text_type(msg)
|
|
super(PathError, self).__init__(msg)
|
|
self._msg = msg
|
|
|
|
def __unicode__(self):
|
|
"""
|
|
Return the decoded message using 'unicode_escape'
|
|
|
|
:rtype: unicode
|
|
"""
|
|
return six.text_type(self._msg)
|
|
|
|
|
|
class MovePathError(PathError):
|
|
""""""
|
|
|
|
|
|
class RenamePathError(PathError):
|
|
""""""
|
|
|
|
|
|
def reload():
|
|
"""
|
|
Removes all Studio Library modules from the Python cache.
|
|
|
|
You can use this function for developing within DCC applications, however,
|
|
it should not be used in production.
|
|
|
|
Example:
|
|
import studiolibrary
|
|
studiolibrary.reload()
|
|
|
|
import studiolibrary
|
|
studiolibrary.main()
|
|
"""
|
|
os.environ["STUDIO_LIBRARY_RELOADED"] = "1"
|
|
|
|
from studiolibrary import librarywindow
|
|
librarywindow.LibraryWindow.destroyInstances()
|
|
|
|
names = modules()
|
|
|
|
for mod in list(sys.modules.keys()):
|
|
for name in names:
|
|
if mod in sys.modules and mod.startswith(name):
|
|
logger.info('Removing module: %s', mod)
|
|
del sys.modules[mod]
|
|
|
|
|
|
def defaultLibrary():
|
|
"""
|
|
Get the name of the default library.
|
|
|
|
:rtype: str
|
|
"""
|
|
libraries = readSettings()
|
|
|
|
# Try to get the library that has been set to default
|
|
for name in libraries:
|
|
if libraries[name].get("default"):
|
|
return name
|
|
|
|
# Try to get the library named Default
|
|
if "Default" in libraries:
|
|
return "Default"
|
|
|
|
# Try to get a library
|
|
for name in libraries:
|
|
return name
|
|
|
|
# Otherwise just return the name "Default"
|
|
return "Default"
|
|
|
|
|
|
def addLibrary(name, path, **settings):
|
|
"""
|
|
Add a new library with the given name, path and settings.
|
|
|
|
:type name: str
|
|
:type path: path
|
|
:type settings: dict
|
|
"""
|
|
libraries = readSettings()
|
|
libraries.setdefault(name, {})
|
|
libraries[name]["path"] = path
|
|
|
|
update(libraries[name], settings)
|
|
|
|
saveSettings(libraries)
|
|
|
|
|
|
def removeLibrary(name):
|
|
"""
|
|
Remove a library by name.
|
|
|
|
:type name: str
|
|
"""
|
|
libraries = readSettings()
|
|
if name in libraries:
|
|
del libraries[name]
|
|
saveSettings(libraries)
|
|
|
|
|
|
def setLibraries(libraries):
|
|
"""
|
|
Remove existing libraries and set the new.
|
|
|
|
Example:
|
|
import studiolibrary
|
|
|
|
libraries = [
|
|
{"name":"test1", "path":r"D:\LibraryData", "default":True}},
|
|
{"name":"test2", "path":r"D:\LibraryData2"},
|
|
{"name":"Temp", "path":r"C:\temp"},
|
|
]
|
|
|
|
studiolibrary.setLibraries(libraries)
|
|
|
|
:type libraries: list[dict]
|
|
"""
|
|
for library in libraries:
|
|
addLibrary(**library)
|
|
|
|
old = readSettings().keys()
|
|
new = [library["name"] for library in libraries]
|
|
|
|
remove = set(old) - set(new)
|
|
for name in remove:
|
|
removeLibrary(name)
|
|
|
|
|
|
def modules():
|
|
"""
|
|
Get all the module names for the package.
|
|
|
|
:rtype: list[str]
|
|
"""
|
|
names = []
|
|
dirname = os.path.dirname(os.path.dirname(__file__))
|
|
for filename in os.listdir(dirname):
|
|
names.append(filename)
|
|
return names
|
|
|
|
|
|
def setDebugMode(level):
|
|
"""
|
|
Set the logging level to debug.
|
|
|
|
:type level: int
|
|
:rtype: None
|
|
"""
|
|
if level:
|
|
level = logging.DEBUG
|
|
else:
|
|
level = logging.INFO
|
|
|
|
for name in modules():
|
|
logger_ = logging.getLogger(name)
|
|
logger_.setLevel(level)
|
|
|
|
|
|
def resolveModule(name):
|
|
"""Resolve a dotted name to a global object."""
|
|
name = name.split('.')
|
|
used = name.pop(0)
|
|
found = __import__(used)
|
|
for n in name:
|
|
used = used + '.' + n
|
|
try:
|
|
found = getattr(found, n)
|
|
except AttributeError:
|
|
__import__(used)
|
|
found = getattr(found, n)
|
|
return found
|
|
|
|
|
|
def registerItems():
|
|
"""Register all the items from the config file."""
|
|
for name in studiolibrary.config.get("itemRegistry"):
|
|
cls = resolveModule(name)
|
|
studiolibrary.registerItem(cls)
|
|
|
|
|
|
def registerItem(cls):
|
|
"""
|
|
Register the given item class to the given extension.
|
|
|
|
:type cls: studiolibrary.LibraryItem
|
|
:rtype: None
|
|
"""
|
|
global _itemClasses
|
|
_itemClasses[cls.__name__] = cls
|
|
|
|
|
|
def registeredItems():
|
|
"""
|
|
Return all registered library item classes.
|
|
|
|
:rtype: list[studiolibrary.LibraryItem]
|
|
"""
|
|
return _itemClasses.values()
|
|
|
|
|
|
def clearRegisteredItems():
|
|
"""
|
|
Remove all registered item classes.
|
|
|
|
:rtype: None
|
|
"""
|
|
global _itemClasses
|
|
_itemClasses = collections.OrderedDict()
|
|
|
|
|
|
def tempPath(*args):
|
|
"""
|
|
Get the temp directory set in the config.
|
|
|
|
:rtype: str
|
|
"""
|
|
temp = studiolibrary.config.get("tempPath")
|
|
return normPath(os.path.join(formatPath(temp), *args))
|
|
|
|
|
|
def createTempPath(name, clean=True, makedirs=True):
|
|
"""
|
|
Create a temp directory with the given name.
|
|
|
|
:type name: str
|
|
:type clean: bool
|
|
:type makedirs: bool
|
|
|
|
:rtype: bool
|
|
"""
|
|
path = tempPath(name)
|
|
|
|
if clean and os.path.exists(path):
|
|
if os.path.exists(path):
|
|
shutil.rmtree(path)
|
|
|
|
if makedirs and not os.path.exists(path):
|
|
os.makedirs(path)
|
|
|
|
return path
|
|
|
|
|
|
def pathsFromUrls(urls):
|
|
"""
|
|
Return the local file paths from the given QUrls
|
|
|
|
:type urls: list[QtGui.QUrl]
|
|
:rtype: collections.Iterable[str]
|
|
"""
|
|
for url in urls:
|
|
path = url.toLocalFile()
|
|
|
|
# Fixes a bug when dragging from windows explorer on windows 10
|
|
if isWindows():
|
|
if path.startswith("/"):
|
|
path = path[1:]
|
|
|
|
yield path
|
|
|
|
|
|
def findItems(path, depth=3, **kwargs):
|
|
"""
|
|
Find and create items by walking the given path.
|
|
|
|
:type path: str
|
|
:type depth: int
|
|
|
|
:rtype: collections.Iterable[studiolibrary.LibraryItem]
|
|
"""
|
|
path = normPath(path)
|
|
|
|
maxDepth = depth
|
|
startDepth = path.count(os.path.sep)
|
|
|
|
for root, dirs, files in walk(path, followlinks=True):
|
|
|
|
files.extend(dirs)
|
|
|
|
for filename in files:
|
|
remove = False
|
|
|
|
# Normalise the path for consistent matching
|
|
path = os.path.join(root, filename)
|
|
item = itemFromPath(path, **kwargs)
|
|
|
|
if item:
|
|
# Yield the item that matches/supports the current path
|
|
yield item
|
|
|
|
# Stop walking the dir if the item doesn't support nested items
|
|
if not item.ENABLE_NESTED_ITEMS:
|
|
remove = True
|
|
|
|
if remove and filename in dirs:
|
|
dirs.remove(filename)
|
|
|
|
if depth == 1:
|
|
break
|
|
|
|
# Stop walking the directory if the maximum depth has been reached
|
|
currentDepth = root.count(os.path.sep)
|
|
if (currentDepth - startDepth) >= maxDepth:
|
|
del dirs[:]
|
|
|
|
|
|
def findItemsInFolders(folders, depth=3, **kwargs):
|
|
"""
|
|
Find and create new item instances by walking the given paths.
|
|
|
|
:type folders: list[str]
|
|
:type depth: int
|
|
|
|
:rtype: collections.Iterable[studiolibrary.LibraryItem]
|
|
"""
|
|
for folder in folders:
|
|
for item in findItems(folder, depth=depth, **kwargs):
|
|
yield item
|
|
|
|
|
|
def user():
|
|
"""
|
|
Return the current user name in lowercase.
|
|
|
|
:rtype: str
|
|
"""
|
|
return getpass.getuser().lower()
|
|
|
|
|
|
def system():
|
|
"""
|
|
Return the current platform in lowercase.
|
|
|
|
:rtype: str
|
|
"""
|
|
return platform.system().lower()
|
|
|
|
|
|
def isMaya():
|
|
"""
|
|
Return True if the current python session is in Maya.
|
|
|
|
:rtype: bool
|
|
"""
|
|
try:
|
|
import maya.cmds
|
|
maya.cmds.about(batch=True)
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def isMac():
|
|
"""
|
|
Return True if the current OS is Mac.
|
|
|
|
:rtype: bool
|
|
"""
|
|
return system().startswith("os") or \
|
|
system().startswith("mac") or \
|
|
system().startswith("darwin")
|
|
|
|
|
|
def isWindows():
|
|
"""
|
|
Return True if the current OS is windows.
|
|
|
|
:rtype: bool
|
|
"""
|
|
return system().startswith("win")
|
|
|
|
|
|
def isLinux():
|
|
"""
|
|
Return True if the current OS is linux.
|
|
|
|
:rtype: bool
|
|
"""
|
|
return system().startswith("lin")
|
|
|
|
|
|
def localPath(*args):
|
|
"""
|
|
Return the users preferred disc location.
|
|
|
|
:rtype: str
|
|
"""
|
|
path = os.getenv('APPDATA') or os.getenv('HOME')
|
|
path = os.path.join(path, "StudioLibrary", *args)
|
|
|
|
return path
|
|
|
|
|
|
def formatPath(formatString, path="", **kwargs):
|
|
"""
|
|
Resolve the given string with the given path and kwargs.
|
|
|
|
Example:
|
|
print formatPath("{dirname}/meta.json", path="C:/hello/world.json")
|
|
# "C:/hello/meta.json"
|
|
|
|
:type formatString: str
|
|
:type path: str
|
|
:type kwargs: dict
|
|
:rtype: str
|
|
"""
|
|
logger.debug("Format String: %s", formatString)
|
|
|
|
dirname, name, extension = splitPath(path)
|
|
|
|
encoding = locale.getpreferredencoding()
|
|
|
|
# Environment variables return raw strings so we need to convert them to
|
|
# unicode using the preferred system encoding
|
|
|
|
temp = tempfile.gettempdir()
|
|
if temp:
|
|
|
|
temp = six.text_type(temp)
|
|
|
|
username = user()
|
|
if username:
|
|
username = six.text_type(username)
|
|
|
|
local = os.getenv('APPDATA') or os.getenv('HOME')
|
|
if local:
|
|
local = six.text_type(local)
|
|
|
|
kwargs.update(os.environ)
|
|
|
|
labels = {
|
|
"name": name,
|
|
"path": path,
|
|
"root": path, # legacy
|
|
"user": username,
|
|
"temp": temp,
|
|
"home": local, # legacy
|
|
"local": local,
|
|
"dirname": dirname,
|
|
"extension": extension,
|
|
}
|
|
|
|
kwargs.update(labels)
|
|
|
|
resolvedString = six.text_type(formatString).format(**kwargs)
|
|
|
|
logger.debug("Resolved String: %s", resolvedString)
|
|
|
|
return normPath(resolvedString)
|
|
|
|
|
|
def copyPath(src, dst, force=False):
|
|
"""
|
|
Make a copy of the given src path to the given destination path.
|
|
|
|
:type src: str
|
|
:type dst: str
|
|
:type force: bool
|
|
:rtype: str
|
|
"""
|
|
dirname = os.path.dirname(src)
|
|
|
|
if "/" not in dst:
|
|
dst = os.path.join(dirname, dst)
|
|
|
|
src = normPath(src)
|
|
dst = normPath(dst)
|
|
|
|
logger.info(u'Copying path "{0}" -> "{1}"'.format(src, dst))
|
|
|
|
if src == dst:
|
|
msg = u'The source path and destination path are the same: {0}'
|
|
raise IOError(msg.format(src))
|
|
|
|
if not force and os.path.exists(dst):
|
|
msg = u'Cannot copy over an existing path: "{0}"'
|
|
raise IOError(msg.format(dst))
|
|
|
|
if force and os.path.exists(dst):
|
|
if os.path.isdir(dst):
|
|
shutil.rmtree(dst)
|
|
else:
|
|
os.remove(dst)
|
|
|
|
# Make sure the destination directory exists
|
|
dstDir = os.path.dirname(dst)
|
|
if not os.path.exists(dstDir):
|
|
os.makedirs(dstDir)
|
|
|
|
if os.path.isfile(src):
|
|
shutil.copy(src, dst)
|
|
else:
|
|
shutil.copytree(src, dst)
|
|
|
|
logger.info("Copied path!")
|
|
|
|
return dst
|
|
|
|
|
|
def movePath(src, dst):
|
|
"""
|
|
Move the given source path to the given destination path.
|
|
|
|
:type src: str
|
|
:type dst: str
|
|
:rtype: str
|
|
"""
|
|
src = six.text_type(src)
|
|
dirname, name, extension = splitPath(src)
|
|
|
|
if not os.path.exists(src):
|
|
raise MovePathError(u'No such file or directory: {0}'.format(src))
|
|
|
|
if os.path.isdir(src):
|
|
dst = u'{0}/{1}{2}'.format(dst, name, extension)
|
|
dst = generateUniquePath(dst)
|
|
|
|
shutil.move(src, dst)
|
|
return dst
|
|
|
|
|
|
def movePaths(srcPaths, dst):
|
|
"""
|
|
Move the given src paths to the given dst path.
|
|
|
|
:type srcPaths: list[str]
|
|
:type dst: str
|
|
"""
|
|
if not os.path.exists(dst):
|
|
os.makedirs(dst)
|
|
|
|
for src in srcPaths or []:
|
|
|
|
if not src:
|
|
continue
|
|
|
|
basename = os.path.basename(src)
|
|
|
|
dst_ = os.path.join(dst, basename)
|
|
dst_ = normPath(dst_)
|
|
|
|
logger.info(u'Moving Content: {0} => {1}'.format(src, dst_))
|
|
shutil.move(src, dst_)
|
|
|
|
|
|
def silentRemove(filename):
|
|
"""
|
|
Silently remove a file, ignore if it doesn't exist.
|
|
|
|
Workaround for #237 where `os.path.exists` gave false
|
|
positives and removal of files failed because of it.
|
|
|
|
:type filename: str
|
|
:rtype: None
|
|
|
|
"""
|
|
try:
|
|
os.remove(filename)
|
|
except OSError as e:
|
|
# Ignore case of no such file or directory
|
|
if e.errno != errno.ENOENT:
|
|
raise
|
|
|
|
|
|
def removePath(path):
|
|
"""
|
|
Delete the given path from disc.
|
|
|
|
:type path: str
|
|
:rtype: None
|
|
"""
|
|
if os.path.isfile(path):
|
|
os.remove(path)
|
|
elif os.path.isdir(path):
|
|
shutil.rmtree(path)
|
|
|
|
|
|
def renamePath(src, dst, extension=None, force=False):
|
|
"""
|
|
Rename the given source path to the given destination path.
|
|
|
|
:type src: str
|
|
:type dst: str
|
|
:type extension: str
|
|
:type force: bool
|
|
:rtype: str
|
|
"""
|
|
dirname = os.path.dirname(src)
|
|
|
|
if "/" not in dst:
|
|
dst = os.path.join(dirname, dst)
|
|
|
|
if extension and extension not in dst:
|
|
dst += extension
|
|
|
|
src = normPath(src)
|
|
dst = normPath(dst)
|
|
|
|
logger.debug(u'Renaming: {0} => {1}'.format(src, dst))
|
|
|
|
if src == dst and not force:
|
|
msg = u'The source path and destination path are the same: {0}'
|
|
raise RenamePathError(msg.format(src))
|
|
|
|
if os.path.exists(dst) and not force:
|
|
msg = u'Cannot save over an existing path: "{0}"'
|
|
raise RenamePathError(msg.format(dst))
|
|
|
|
if not os.path.exists(dirname):
|
|
msg = u'The system cannot find the specified path: "{0}".'
|
|
raise RenamePathError(msg.format(dirname))
|
|
|
|
if not os.path.exists(os.path.dirname(dst)) and force:
|
|
os.mkdir(os.path.dirname(dst))
|
|
|
|
if not os.path.exists(src):
|
|
msg = u'The system cannot find the specified path: "{0}"'
|
|
raise RenamePathError(msg.format(src))
|
|
|
|
os.rename(src, dst)
|
|
|
|
logger.debug(u'Renamed: {0} => {1}'.format(src, dst))
|
|
|
|
return dst
|
|
|
|
|
|
def read(path):
|
|
"""
|
|
Return the contents of the given file.
|
|
|
|
:type path: str
|
|
:rtype: str
|
|
"""
|
|
data = ""
|
|
path = normPath(path)
|
|
|
|
if os.path.isfile(path):
|
|
with open(path) as f:
|
|
data = f.read() or data
|
|
|
|
data = absPath(data, path)
|
|
|
|
return data
|
|
|
|
|
|
def write(path, data):
|
|
if six.PY2:
|
|
write2(path, data)
|
|
else:
|
|
write3(path, data)
|
|
|
|
|
|
def write2(path, data):
|
|
"""
|
|
Write the given data to the given file on disc.
|
|
|
|
:type path: str
|
|
:type data: str
|
|
:rtype: None
|
|
"""
|
|
path = normPath(path)
|
|
data = relPath(data, path)
|
|
|
|
tmp = path + ".tmp"
|
|
bak = path + ".bak"
|
|
|
|
# Create the directory if it doesn't exists
|
|
dirname = os.path.dirname(path)
|
|
if not os.path.exists(dirname):
|
|
os.makedirs(dirname)
|
|
|
|
# Use the tmp file to check for concurrent writes
|
|
if os.path.exists(tmp):
|
|
msg = "The path is locked for writing and cannot be accessed {}"
|
|
msg = msg.format(tmp)
|
|
raise IOError(msg)
|
|
|
|
# Safely write the data to a tmp file and then rename to the given path
|
|
try:
|
|
# Create and write the new data
|
|
# to the path.tmp file
|
|
with open(tmp, "w") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
|
|
# Remove any existing path.bak files
|
|
silentRemove(bak)
|
|
|
|
# Rename the existing path to path.bak
|
|
if os.path.exists(path):
|
|
os.rename(path, bak)
|
|
|
|
# Rename the tmp path to the given path
|
|
if os.path.exists(tmp):
|
|
os.rename(tmp, path)
|
|
|
|
# Clean up the bak file only if the given path exists
|
|
if os.path.exists(path) and os.path.exists(bak):
|
|
silentRemove(bak)
|
|
|
|
except:
|
|
# Remove the tmp file if there are any issues
|
|
silentRemove(tmp)
|
|
|
|
# Restore the path from the current .bak file
|
|
if not os.path.exists(path) and os.path.exists(bak):
|
|
os.rename(bak, path)
|
|
|
|
raise
|
|
|
|
|
|
def write3(path, data):
|
|
"""
|
|
Writes the given data to a file atomically by first writing to a
|
|
temp file and then renaming it.
|
|
|
|
This approach avoids using the tempfile module to keep permissions
|
|
consistent with the write2 function.
|
|
"""
|
|
path = normPath(path)
|
|
data = relPath(data, path)
|
|
|
|
dirname = os.path.dirname(path)
|
|
if not os.path.exists(dirname):
|
|
os.makedirs(dirname)
|
|
|
|
tmp = None
|
|
|
|
try:
|
|
|
|
# Create a temporary file with a random name
|
|
characters = "abcdefghijklmnopqrstuvwxyz0123456789_"
|
|
name = ''.join(random.choice(characters) for _ in range(8))
|
|
tmp = os.path.join(dirname, name + ".delete")
|
|
|
|
with open(tmp, "w") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
|
|
# Introduced in python 3.3
|
|
os.replace(tmp, path)
|
|
|
|
finally:
|
|
if tmp and os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
|
|
|
|
def update(data, other):
|
|
"""
|
|
Update the value of a nested dictionary of varying depth.
|
|
|
|
:type data: dict
|
|
:type other: dict
|
|
:rtype: dict
|
|
"""
|
|
for key, value in other.items():
|
|
if isinstance(value, Mapping):
|
|
data[key] = update(data.get(key, {}), value)
|
|
else:
|
|
data[key] = value
|
|
return data
|
|
|
|
|
|
def updateJson(path, data):
|
|
"""
|
|
Update a json file with the given data.
|
|
|
|
:type path: str
|
|
:type data: dict
|
|
:rtype: None
|
|
"""
|
|
data_ = readJson(path)
|
|
data_ = update(data_, data)
|
|
saveJson(path, data_)
|
|
|
|
|
|
def saveJson(path, data):
|
|
"""
|
|
Serialize the data to a JSON string and write it to the given path.
|
|
|
|
:type path: str
|
|
:type data: dict
|
|
:rtype: None
|
|
"""
|
|
path = normPath(path)
|
|
|
|
data = collections.OrderedDict(sorted(data.items(), key=lambda t: t[0]))
|
|
data = json.dumps(data, indent=4)
|
|
write(path, data)
|
|
|
|
|
|
def readJson(path):
|
|
"""
|
|
Read the given JSON file and deserialize to a Python object.
|
|
|
|
:type path: str
|
|
:rtype: dict
|
|
"""
|
|
path = normPath(path)
|
|
|
|
logger.debug(u'Reading json file: {0}'.format(path))
|
|
|
|
data = read(path) or "{}"
|
|
data = json.loads(data)
|
|
|
|
return data
|
|
|
|
|
|
def settingsPath():
|
|
"""
|
|
Get the settings path from the config file.
|
|
|
|
:rtype: str
|
|
"""
|
|
formatString = studiolibrary.config.get('settingsPath')
|
|
return studiolibrary.formatPath(formatString)
|
|
|
|
|
|
def updateSettings(data):
|
|
"""
|
|
Update the existing settings with the given data.
|
|
|
|
:type data: dict
|
|
"""
|
|
settings = studiolibrary.readSettings()
|
|
update(settings, data)
|
|
studiolibrary.saveSettings(settings)
|
|
|
|
|
|
def readSettings():
|
|
"""
|
|
Get all the user settings.
|
|
|
|
:rtype: dict
|
|
"""
|
|
path = settingsPath()
|
|
|
|
logger.debug(u'Reading settings from "%s"', path)
|
|
|
|
data = {}
|
|
|
|
try:
|
|
data = studiolibrary.readJson(path)
|
|
except Exception as error:
|
|
logger.exception('Cannot read settings from "%s"', path)
|
|
|
|
return data
|
|
|
|
|
|
def saveSettings(data):
|
|
"""
|
|
Save the given data to the settings path.
|
|
|
|
:type data:
|
|
"""
|
|
path = settingsPath()
|
|
|
|
logger.debug(u'Saving settings to "%s"', path)
|
|
|
|
try:
|
|
studiolibrary.saveJson(path, data)
|
|
except Exception:
|
|
logger.exception(u'Cannot save settings to "%s"', path)
|
|
|
|
|
|
def replaceJson(path, old, new, count=-1):
|
|
"""
|
|
Replace the old value with the new value in the given json file.
|
|
|
|
:type path: str
|
|
:type old: str
|
|
:type new: str
|
|
:type count: int
|
|
:rtype: dict
|
|
"""
|
|
old = six.text_type(old)
|
|
new = six.text_type(new)
|
|
|
|
data = read(path) or "{}"
|
|
data = data.replace(old, new, count)
|
|
data = json.loads(data)
|
|
|
|
saveJson(path, data)
|
|
|
|
return data
|
|
|
|
|
|
def renamePathInFile(path, src, dst):
|
|
"""
|
|
Rename the given src path to the given dst path.
|
|
|
|
:type path: str
|
|
:type src: str
|
|
:type dst: str
|
|
:rtype: None
|
|
"""
|
|
src = normPath(src)
|
|
dst = normPath(dst)
|
|
|
|
src1 = '"' + src + '"'
|
|
dst2 = '"' + dst + '"'
|
|
|
|
# Replace paths that match exactly the given src and dst strings
|
|
replaceJson(path, src1, dst2)
|
|
|
|
src2 = '"' + src
|
|
dst2 = '"' + dst
|
|
|
|
# Add a slash as a suffix for better directory matching
|
|
if not src2.endswith("/"):
|
|
src2 += "/"
|
|
|
|
if not dst2.endswith("/"):
|
|
dst2 += "/"
|
|
|
|
# Replace all paths that start with the src path with the dst path
|
|
replaceJson(path, src2, dst2)
|
|
|
|
|
|
def relPath(data, start):
|
|
"""
|
|
Return a relative version of all the paths in data from the start path.
|
|
|
|
:type data: str
|
|
:type start: str
|
|
:rtype: str
|
|
"""
|
|
rpath = start
|
|
|
|
for i in range(0, 3):
|
|
|
|
rpath = os.path.dirname(rpath)
|
|
token = os.path.relpath(rpath, start)
|
|
|
|
rpath = normPath(rpath)
|
|
token = normPath(token)
|
|
|
|
if rpath.endswith("/"):
|
|
rpath = rpath[:-1]
|
|
|
|
data = data.replace(rpath, token)
|
|
|
|
return data
|
|
|
|
|
|
def absPath(data, start, depth=3):
|
|
"""
|
|
Return an absolute version of all the paths in data using the start path.
|
|
|
|
:type data: str
|
|
:type start: str
|
|
:type depth: int
|
|
:rtype: str
|
|
"""
|
|
token = ".."
|
|
pairs = []
|
|
path = normPath(os.path.dirname(start))
|
|
|
|
# First create a list of tokens and paths.
|
|
for i in range(1, depth+1):
|
|
rel = ((token + "/") * i)
|
|
pairs.append((rel, path))
|
|
path = normPath(os.path.dirname(path))
|
|
|
|
# Second replace the token with the paths.
|
|
for pair in reversed(pairs):
|
|
rel, path = pair
|
|
|
|
# Replace with the trailing slash
|
|
# '../../', 'P:/LibraryData/'
|
|
if not rel.endswith("/"):
|
|
rel += "/"
|
|
|
|
if not path.endswith("/"):
|
|
path += "/"
|
|
|
|
data = data.replace(rel, path)
|
|
|
|
# Replace without the trailing slash
|
|
# '../..', 'P:/LibraryData'
|
|
if rel.endswith("/"):
|
|
rel = rel[:-1]
|
|
|
|
if path.endswith("/"):
|
|
path = path[:-1]
|
|
|
|
data = data.replace(rel, path)
|
|
|
|
return data
|
|
|
|
|
|
def realPath(path):
|
|
"""
|
|
Return the given path eliminating any symbolic link.
|
|
|
|
:type path: str
|
|
:rtype: str
|
|
"""
|
|
path = os.path.realpath(path)
|
|
path = os.path.expanduser(path)
|
|
return normPath(path)
|
|
|
|
|
|
def normPath(path):
|
|
"""
|
|
Return a normalized path containing only forward slashes.
|
|
|
|
:type path: str
|
|
:rtype: unicode
|
|
"""
|
|
# Check and support the UNC path structure
|
|
unc = path.startswith("//") or path.startswith("\\\\")
|
|
|
|
path = path.replace("//", "/")
|
|
path = path.replace("\\", "/")
|
|
|
|
if path.endswith("/") and not path.endswith(":/"):
|
|
path = path.rstrip("/")
|
|
|
|
# Make sure we retain the UNC path structure
|
|
if unc and not path.startswith("//") and path.startswith("/"):
|
|
path = "/" + path
|
|
|
|
return path
|
|
|
|
|
|
def normPaths(paths):
|
|
"""
|
|
Normalize all the given paths to a consistent format.
|
|
|
|
:type paths: list[str]
|
|
:rtype: list[str]
|
|
"""
|
|
return [normPath(path) for path in paths]
|
|
|
|
|
|
def splitPath(path):
|
|
"""
|
|
Split the given path into directory, basename and extension.
|
|
|
|
Example:
|
|
print splitPath("P:/production/rigs/character/mario.ma
|
|
|
|
# (u'P:/production/rigs/character', u'mario', u'.ma')
|
|
|
|
:type path: str
|
|
:rtype: list[str]
|
|
"""
|
|
path = normPath(path)
|
|
filename, extension = os.path.splitext(path)
|
|
return os.path.dirname(filename), os.path.basename(filename), extension
|
|
|
|
|
|
def listToString(data):
|
|
"""
|
|
Return a string from the given list.
|
|
|
|
Example:
|
|
print listToString(['apple', 'pear', 'cherry'])
|
|
|
|
# apple,pear,cherry
|
|
|
|
:type data: list
|
|
:rtype: str
|
|
"""
|
|
# Convert all items to string and remove 'u'
|
|
data = [str(item) for item in data]
|
|
data = str(data).replace("[", "").replace("]", "")
|
|
data = data.replace("'", "").replace('"', "")
|
|
return data
|
|
|
|
|
|
def stringToList(data):
|
|
"""
|
|
Return a list from the given string.
|
|
|
|
Example:
|
|
print listToString('apple, pear, cherry')
|
|
|
|
# ['apple', 'pear', 'cherry']
|
|
|
|
:type data: str
|
|
:rtype: list
|
|
"""
|
|
data = '["' + str(data) + '"]'
|
|
data = data.replace(' ', '')
|
|
data = data.replace(',', '","')
|
|
return eval(data)
|
|
|
|
|
|
def isVersionPath(path):
|
|
basename = path.rstrip('/').split('/')[-1]
|
|
if re.match(r'^v\d+$', basename):
|
|
return True
|
|
return False
|
|
|
|
|
|
def latestVersionPath(path):
|
|
version = ""
|
|
|
|
for name in sorted(os.listdir(path), reverse=True):
|
|
if name.startswith("v"):
|
|
version = name
|
|
break
|
|
|
|
if version:
|
|
return "{}/{}".format(path, version)
|
|
|
|
|
|
def listPaths(path):
|
|
"""
|
|
Return a list of paths that are in the given directory.
|
|
|
|
:type path: str
|
|
:rtype: collections.Iterable[str]
|
|
"""
|
|
for name in os.listdir(path):
|
|
value = path + "/" + name
|
|
yield value
|
|
|
|
|
|
def generateUniquePath(path, attempts=1000):
|
|
"""
|
|
Generate a unique path on disc.
|
|
|
|
Example:
|
|
# If the following files exist then the next unique path will be 3.
|
|
# C:/tmp/file.text
|
|
# C:/tmp/file (2).text
|
|
|
|
print generateUniquePath("C:/tmp/file.text")
|
|
# C:/tmp/file (3).text
|
|
|
|
:type path: str
|
|
:type attempts: int
|
|
:rtype: str
|
|
"""
|
|
attempt = 1 # We start at one so that the first unique name is actually 2.
|
|
dirname, name, extension = splitPath(path)
|
|
path_ = u'{dirname}/{name} ({number}){extension}'
|
|
|
|
while os.path.exists(path):
|
|
attempt += 1
|
|
|
|
path = path_.format(
|
|
name=name,
|
|
number=attempt,
|
|
dirname=dirname,
|
|
extension=extension
|
|
)
|
|
|
|
if attempt >= attempts:
|
|
msg = u'Cannot generate unique name for path {path}'
|
|
msg = msg.format(path=path)
|
|
raise ValueError(msg)
|
|
|
|
return path
|
|
|
|
|
|
def walkup(path, match=None, depth=3, sep="/"):
|
|
"""
|
|
:type path: str
|
|
:type match: func
|
|
:type depth: int
|
|
:type sep: str
|
|
:rtype: collections.Iterable[str]
|
|
"""
|
|
path = normPath(path)
|
|
|
|
if not path.endswith(sep):
|
|
path += sep
|
|
|
|
folders = path.split(sep)
|
|
depthCount = 0
|
|
|
|
for i, folder in enumerate(folders):
|
|
if not folder:
|
|
continue
|
|
|
|
if depthCount > depth:
|
|
break
|
|
depthCount += 1
|
|
|
|
folder = os.path.sep.join(folders[:i*-1])
|
|
if not os.path.isdir(folder):
|
|
continue
|
|
try:
|
|
filenames = os.listdir(folder)
|
|
except PermissionError:
|
|
continue # expected on network shares
|
|
except OSError as e:
|
|
if getattr(e, 'winerror', None) == 59:
|
|
continue # "An unexpected network error occurred"
|
|
if getattr(e, 'winerror', None) == 6:
|
|
# "The handle is invalid" - something weird on network share
|
|
continue
|
|
raise
|
|
for filename in filenames:
|
|
path = os.path.join(folder, filename)
|
|
if match is None or match(path):
|
|
yield normPath(path)
|
|
|
|
|
|
def timeAgo(timeStamp):
|
|
"""
|
|
Return a pretty string for how long ago the given timeStamp was.
|
|
|
|
Example:
|
|
print timeAgo("2015-04-27 22:29:55"
|
|
# 2 years ago
|
|
|
|
:type timeStamp: str
|
|
:rtype: str
|
|
"""
|
|
t1 = int(timeStamp)
|
|
t1 = datetime.fromtimestamp(t1)
|
|
|
|
t2 = datetime.now()
|
|
diff = t2 - t1
|
|
|
|
dayDiff = diff.days
|
|
secondsDiff = diff.seconds
|
|
|
|
if dayDiff < 0:
|
|
return ''
|
|
|
|
if dayDiff == 0:
|
|
if secondsDiff < 10:
|
|
return "just now"
|
|
if secondsDiff < 60:
|
|
return "{:.0f} seconds ago".format(secondsDiff)
|
|
if secondsDiff < 120:
|
|
return "a minute ago"
|
|
if secondsDiff < 3600:
|
|
return "{:.0f} minutes ago".format(secondsDiff / 60)
|
|
if secondsDiff < 7200:
|
|
return "an hour ago"
|
|
if secondsDiff < 86400:
|
|
return "{:.0f} hours ago".format(secondsDiff / 3600)
|
|
|
|
if dayDiff == 1:
|
|
return "yesterday"
|
|
|
|
if dayDiff < 7:
|
|
return "{:.0f} days ago".format(dayDiff)
|
|
|
|
if dayDiff < 31:
|
|
v = dayDiff / 7
|
|
if v == 1:
|
|
return "{:.0f} week ago".format(v)
|
|
return "{:.0f} weeks ago".format(dayDiff / 7)
|
|
|
|
if dayDiff < 365:
|
|
v = dayDiff / 30
|
|
if v == 1:
|
|
return "{:.0f} month ago".format(v)
|
|
return "{:.0f} months ago".format(v)
|
|
|
|
v = dayDiff / 365
|
|
if v == 1:
|
|
return "{:.0f} year ago".format(v)
|
|
return "{:.0f} years ago".format(v)
|
|
|
|
|
|
def userUuid():
|
|
"""
|
|
Return a unique uuid4 for each user.
|
|
|
|
This does not compromise privacy as it generates a random uuid4 string
|
|
for the current user.
|
|
|
|
:rtype: str
|
|
"""
|
|
path = os.path.join(localPath(), "settings.json")
|
|
userUuid_ = readJson(path).get("userUuid")
|
|
|
|
if not userUuid_:
|
|
updateJson(path, {"userUuid": str(uuid.uuid4())})
|
|
|
|
# Read the uuid again to make sure its persistent
|
|
userUuid_ = readJson(path).get("userUuid")
|
|
|
|
return userUuid_
|
|
|
|
|
|
def showInFolder(path):
|
|
"""
|
|
Show the given path in the system file explorer.
|
|
|
|
:type path: unicode
|
|
:rtype: None
|
|
"""
|
|
if isWindows():
|
|
# os.system() and subprocess.call() can't pass command with
|
|
# non ascii symbols, use ShellExecuteW directly
|
|
cmd = ctypes.windll.shell32.ShellExecuteW
|
|
else:
|
|
cmd = os.system
|
|
|
|
args = studiolibrary.config.get('showInFolderCmd')
|
|
|
|
if args:
|
|
if isinstance(args, six.string_types):
|
|
args = [args]
|
|
|
|
elif isLinux():
|
|
args = [u'xdg-open "{path}"&']
|
|
|
|
elif isWindows():
|
|
args = [None, u'open', u'explorer', u'/select, "{path}"', None, 1]
|
|
|
|
elif isMac():
|
|
args = [u'open -R "{path}"']
|
|
|
|
# Normalize the pathname for windows
|
|
path = os.path.normpath(path)
|
|
|
|
for i, a in enumerate(args):
|
|
if isinstance(a, six.string_types) and '{path}' in a:
|
|
args[i] = a.format(path=path)
|
|
|
|
logger.info("Call: '%s' with arguments: %s", cmd.__name__, args)
|
|
cmd(*args)
|
|
|
|
|
|
global DCC_INFO
|
|
|
|
try:
|
|
import maya.cmds
|
|
DCC_INFO = {
|
|
"name": "maya",
|
|
"version": maya.cmds.about(q=True, version=True)
|
|
}
|
|
except Exception as error:
|
|
DCC_INFO = {
|
|
"name": "undefined",
|
|
"version": "undefined",
|
|
}
|
|
|
|
|
|
def osVersion():
|
|
try:
|
|
# Fix for Windows 11 returning the wrong version
|
|
if platform.system().lower() == "windows" and platform.release() == "10" and sys.getwindowsversion().build >= 22000:
|
|
return "11"
|
|
finally:
|
|
return platform.release().replace(' ', '%20')
|
|
|
|
|
|
def checkForUpdates():
|
|
"""
|
|
This function should only be used once every session unless specified by the user.
|
|
|
|
Returns True if a newer release is found for the given platform.
|
|
|
|
:rtype: dict
|
|
"""
|
|
if os.environ.get("STUDIO_LIBRARY_RELOADED") == "1":
|
|
return {}
|
|
|
|
if not studiolibrary.config.get('checkForUpdatesEnabled', True):
|
|
return {}
|
|
|
|
# In python 2.7 the getdefaultlocale function could return a None "ul"
|
|
try:
|
|
ul, _ = locale.getdefaultlocale()
|
|
ul = ul or "undefined"
|
|
ul = ul.replace("_", "-").lower()
|
|
except Exception as error:
|
|
ul = "undefined"
|
|
|
|
try:
|
|
uid = userUuid() or "undefined"
|
|
url = "https://app.studiolibrary.com/releases?uid={uid}&v={v}&dv={dv}&dn={dn}&os={os}&ov={ov}&pv={pv}&ul={ul}"
|
|
url = url.format(
|
|
uid=uid,
|
|
v=studiolibrary.__version__,
|
|
dn=DCC_INFO.get("name").replace(' ', '%20'),
|
|
dv=DCC_INFO.get("version").replace(' ', '%20'),
|
|
os=platform.system().lower().replace(' ', '%20'),
|
|
ov=osVersion(),
|
|
pv=platform.python_version().replace(' ', '%20'),
|
|
ul=ul,
|
|
)
|
|
|
|
response = urllib.request.urlopen(url)
|
|
|
|
# Check the HTTP status code
|
|
if response.getcode() == 200:
|
|
json_content = response.read().decode('utf-8')
|
|
data = json.loads(json_content)
|
|
return data
|
|
|
|
except Exception as error:
|
|
logger.debug("Exception occurred:\n%s", traceback.format_exc())
|
|
pass
|
|
|
|
return {}
|
|
|
|
|
|
def testNormPath():
|
|
"""Test the norm path utility function. """
|
|
|
|
assert normPath("//win-q9lu/Library Data") == "//win-q9lu/Library Data"
|
|
|
|
assert normPath("////win-q9lu/Library Data/") == "//win-q9lu/Library Data"
|
|
|
|
assert normPath("\\\\win-q9l\\Library Data\\") == "//win-q9l/Library Data"
|
|
|
|
assert normPath(r"C:\folder//Library Data/") == "C:/folder/Library Data"
|
|
|
|
assert normPath(r"\folder//Library Data/") == "/folder/Library Data"
|
|
|
|
assert normPath("C:\\folder//Library Data/") == "C:/folder/Library Data"
|
|
|
|
assert normPath("\\folder//Library Data/") == "/folder/Library Data"
|
|
|
|
assert normPath("C:/") == "C:/"
|
|
|
|
|
|
def testUpdate():
|
|
"""
|
|
Test the update dictionary command
|
|
|
|
:rtype: None
|
|
"""
|
|
testData1 = {
|
|
"../../images/beach.jpg": {
|
|
"Custom Order": "00001"
|
|
},
|
|
"../../images/sky.jpg": {
|
|
"Custom Order": "00019",
|
|
"Other": {"Paths": "../../images/bird2.mb"}
|
|
}
|
|
}
|
|
|
|
testData2 = {
|
|
"../../images/sky.jpg": {
|
|
"Labels": ["head", "face"],
|
|
},
|
|
}
|
|
|
|
expected = {
|
|
"../../images/beach.jpg": {
|
|
"Custom Order": "00001"
|
|
},
|
|
"../../images/sky.jpg": {
|
|
"Custom Order": "00019",
|
|
"Labels": ["head", "face"],
|
|
"Other": {"Paths": "../../images/bird2.mb"}
|
|
}
|
|
}
|
|
|
|
# Test updating/inserting a value in a dictionary.
|
|
result = update(testData1, testData2)
|
|
|
|
msg = "Data does not match {} {}".format(expected, result)
|
|
assert expected == result, msg
|
|
|
|
# Test the update command with an empty dictionary.
|
|
testData2 = {
|
|
"../../images/sky.jpg": {},
|
|
}
|
|
|
|
result = update(testData1, testData2)
|
|
|
|
msg = "Data does not match {} {}".format(expected, result)
|
|
assert expected == result, msg
|
|
|
|
|
|
def testSplitPath():
|
|
"""
|
|
Test he splitPath command.
|
|
|
|
:rtype: None
|
|
"""
|
|
path = "P:/production/rigs/character/mario.ma"
|
|
|
|
result = splitPath(path)
|
|
expected = (u'P:/production/rigs/character', u'mario', u'.ma')
|
|
|
|
msg = "Data does not match {} {}".format(expected, result)
|
|
assert expected == result, msg
|
|
|
|
|
|
def testFormatPath():
|
|
"""
|
|
Test the formatPath command.
|
|
|
|
:rtype: None
|
|
"""
|
|
formatString = "{dirname}/versions/{name}{extension}"
|
|
|
|
result = formatPath(formatString, path="P:/production/rigs/database.json")
|
|
expected = "P:/production/rigs/versions/database.json"
|
|
|
|
msg = "Data does not match {} {}".format(expected, result)
|
|
assert expected == result, msg
|
|
|
|
|
|
def testRelativePaths():
|
|
"""
|
|
Test absolute and relative paths.
|
|
|
|
:rtype: None
|
|
"""
|
|
data = """
|
|
{
|
|
"P:/path/head.anim": {},
|
|
"P:/test/path/face.anim": {},
|
|
"P:/test/relative/path/hand.anim": {},
|
|
}
|
|
"""
|
|
|
|
expected = """
|
|
{
|
|
"../../../path/head.anim": {},
|
|
"../../path/face.anim": {},
|
|
"../path/hand.anim": {},
|
|
}
|
|
"""
|
|
|
|
data_ = relPath(data, "P:/test/relative/file.database")
|
|
msg = "Data does not match {} {}".format(expected, data_)
|
|
assert data_ == expected, msg
|
|
|
|
data = """
|
|
{
|
|
"P:/": {},
|
|
"P:/head.anim": {},
|
|
"P:/path/head.anim": {},
|
|
"P:/test/path/face.anim": {},
|
|
"P:/test/relative/path/hand.anim": {},
|
|
}
|
|
"""
|
|
|
|
expected = """
|
|
{
|
|
"../../": {},
|
|
"../../head.anim": {},
|
|
"../../path/head.anim": {},
|
|
"../../test/path/face.anim": {},
|
|
"../../test/relative/path/hand.anim": {},
|
|
}
|
|
"""
|
|
|
|
data_ = relPath(data, "P:/.studiolibrary/database.json")
|
|
msg = "Data does not match {} {}".format(expected, data_)
|
|
assert data_ == expected, msg
|
|
|
|
path = "P:/path/head.anim"
|
|
start = "P:/test/relative/file.database"
|
|
expected = "../../../path/head.anim"
|
|
|
|
result = relPath(path, start)
|
|
msg = 'Data does not match "{}" "{}"'.format(result, expected)
|
|
assert result == expected, msg
|
|
|
|
result = absPath(result, start)
|
|
msg = 'Data does not match "{}" "{}"'.format(result, path)
|
|
assert result == path, msg
|
|
|
|
data = """
|
|
{
|
|
"P:/LibraryData": {},
|
|
}
|
|
"""
|
|
|
|
expected = """
|
|
{
|
|
"../..": {},
|
|
}
|
|
"""
|
|
|
|
data_ = relPath(data, "P:/LibraryData/.studiolibrary/database.json")
|
|
|
|
print(data_)
|
|
msg = "Data does not match {} {}".format(expected, data_)
|
|
assert data_ == expected, msg
|
|
|
|
data = """
|
|
{
|
|
"../..": {},
|
|
}
|
|
"""
|
|
|
|
expected = """
|
|
{
|
|
"P:/LibraryData": {},
|
|
}
|
|
"""
|
|
|
|
data_ = absPath(data, "P:/LibraryData/.studiolibrary/database.json")
|
|
|
|
print(data_)
|
|
msg = "Data does not match {} {}".format(expected, data_)
|
|
assert data_ == expected, msg
|
|
|
|
|
|
def runTests():
|
|
"""Run all the tests for this file."""
|
|
testUpdate()
|
|
testSplitPath()
|
|
testFormatPath()
|
|
testRelativePaths()
|
|
testNormPath()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
runTests()
|