Merge pull request #877 from pypeclub/feature/653-store-workfiles-information-in-the-db

Store workfiles information in the db
This commit is contained in:
Milan Kolar 2021-01-08 12:14:02 +01:00 committed by GitHub
commit dd7dfd57da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1852 additions and 24 deletions

View file

@ -11,7 +11,9 @@ from pype.api import (
from pype.lib import (
env_value_to_bool,
PreLaunchHook,
ApplicationLaunchFailed
ApplicationLaunchFailed,
get_workdir_data,
get_workdir_with_workdir_data,
)
import acre
@ -140,17 +142,15 @@ class GlobalHostDataHook(PreLaunchHook):
)
return
workdir_data = self._prepare_workdir_data(
project_doc, asset_doc, task_name
workdir_data = get_workdir_data(
project_doc, asset_doc, task_name, self.host_name
)
self.data["workdir_data"] = workdir_data
hierarchy = workdir_data["hierarchy"]
anatomy = self.data["anatomy"]
try:
anatomy_filled = anatomy.format(workdir_data)
workdir = os.path.normpath(anatomy_filled["work"]["folder"])
workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
if not os.path.exists(workdir):
self.log.debug(
"Creating workdir folder: \"{}\"".format(workdir)
@ -168,7 +168,6 @@ class GlobalHostDataHook(PreLaunchHook):
"AVALON_TASK": task_name,
"AVALON_APP": self.host_name,
"AVALON_APP_NAME": self.app_name,
"AVALON_HIERARCHY": hierarchy,
"AVALON_WORKDIR": workdir
}
self.log.debug(
@ -180,21 +179,6 @@ class GlobalHostDataHook(PreLaunchHook):
self.prepare_last_workfile(workdir)
def _prepare_workdir_data(self, project_doc, asset_doc, task_name):
hierarchy = "/".join(asset_doc["data"]["parents"])
data = {
"project": {
"name": project_doc["name"],
"code": project_doc["data"].get("code")
},
"task": task_name,
"asset": asset_doc["name"],
"app": self.host_name,
"hierarchy": hierarchy
}
return data
def prepare_last_workfile(self, workdir):
"""last workfile workflow preparation.

View file

@ -13,12 +13,14 @@ self._menu = os.environ.get('PYPE_STUDIO_NAME') or "Pype"
log = logging.getLogger(__name__)
def _get_menu():
def _get_menu(menu_name=None):
"""Return the menu instance if it currently exists in Maya"""
if menu_name is None:
menu_name = self._menu
widgets = dict((
w.objectName(), w) for w in QtWidgets.QApplication.allWidgets())
menu = widgets.get(self._menu)
menu = widgets.get(menu_name)
return menu
@ -40,10 +42,51 @@ def deferred():
command=lambda *args: mayalookassigner.show()
)
def modify_workfiles():
from pype.tools import workfiles
def launch_workfiles_app(*_args, **_kwargs):
workfiles.show(
os.path.join(
cmds.workspace(query=True, rootDirectory=True),
cmds.workspace(fileRuleEntry="scene")
),
parent=pipeline._parent
)
# Find the pipeline menu
top_menu = _get_menu(pipeline._menu)
# Try to find workfile tool action in the menu
workfile_action = None
for action in top_menu.actions():
if action.text() == "Work Files":
workfile_action = action
break
# Add at the top of menu if "Work Files" action was not found
after_action = ""
if workfile_action:
# Use action's object name for `insertAfter` argument
after_action = workfile_action.objectName()
# Insert action to menu
cmds.menuItem(
"Work Files",
parent=pipeline._menu,
command=launch_workfiles_app,
insertAfter=after_action
)
# Remove replaced action
if workfile_action:
top_menu.removeAction(workfile_action)
log.info("Attempting to install scripts menu..")
add_build_workfiles_item()
add_look_assigner_item()
modify_workfiles()
try:
import scriptsmenu.launchformaya as launchformaya

View file

@ -1,9 +1,11 @@
import os
import nuke
from avalon.api import Session
from pype.hosts.nuke import lib
from ...lib import BuildWorkfile
from pype.api import Logger
from pype.tools import workfiles
log = Logger().get_logger(__name__)
@ -12,6 +14,24 @@ def install():
menubar = nuke.menu("Nuke")
menu = menubar.findItem(Session["AVALON_LABEL"])
workfile_settings = lib.WorkfileSettings
# replace reset resolution from avalon core to pype's
name = "Work Files..."
rm_item = [
(i, item) for i, item in enumerate(menu.items()) if name in item.name()
][0]
log.debug("Changing Item: {}".format(rm_item))
menu.removeItem(rm_item[1].name())
menu.addCommand(
name,
lambda: workfiles.show(
os.environ["AVALON_WORKDIR"]
),
index=(rm_item[0])
)
# replace reset resolution from avalon core to pype's
name = "Reset Resolution"
new_name = "Set Resolution"

View file

@ -35,6 +35,15 @@ from .avalon_context import (
get_hierarchy,
get_linked_assets,
get_latest_version,
get_workdir_data,
get_workdir,
get_workdir_with_workdir_data,
create_workfile_doc,
save_workfile_data_to_doc,
get_workfile_doc,
BuildWorkfile
)
@ -103,6 +112,15 @@ __all__ = [
"get_hierarchy",
"get_linked_assets",
"get_latest_version",
"get_workdir_data",
"get_workdir",
"get_workdir_with_workdir_data",
"create_workfile_doc",
"save_workfile_data_to_doc",
"get_workfile_doc",
"BuildWorkfile",
"ApplicationLaunchFailed",

View file

@ -1,11 +1,13 @@
import os
import json
import re
import copy
import logging
import collections
import functools
from pype.settings import get_project_settings
from .anatomy import Anatomy
# avalon module is not imported at the top
# - may not be in path at the time of pype.lib initialization
@ -246,6 +248,229 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None):
return version_doc
def get_workdir_data(project_doc, asset_doc, task_name, host_name):
"""Prepare data for workdir template filling from entered information.
Args:
project_doc (dict): Mongo document of project from MongoDB.
asset_doc (dict): Mongo document of asset from MongoDB.
task_name (str): Task name for which are workdir data preapred.
host_name (str): Host which is used to workdir. This is required
because workdir template may contain `{app}` key.
Returns:
dict: Data prepared for filling workdir template.
"""
hierarchy = "/".join(asset_doc["data"]["parents"])
data = {
"project": {
"name": project_doc["name"],
"code": project_doc["data"].get("code")
},
"task": task_name,
"asset": asset_doc["name"],
"app": host_name,
"hierarchy": hierarchy
}
return data
def get_workdir_with_workdir_data(
workdir_data, anatomy=None, project_name=None, template_key=None
):
"""Fill workdir path from entered data and project's anatomy.
It is possible to pass only project's name instead of project's anatomy but
one of them **must** be entered. It is preffered to enter anatomy if is
available as initialization of a new Anatomy object may be time consuming.
Args:
workdir_data (dict): Data to fill workdir template.
anatomy (Anatomy): Anatomy object for specific project. Optional if
`project_name` is entered.
project_name (str): Project's name. Optional if `anatomy` is entered
otherwise Anatomy object is created with using the project name.
template_key (str): Key of work templates in anatomy templates. By
default is seto to `"work"`.
Returns:
TemplateResult: Workdir path.
Raises:
ValueError: When both `anatomy` and `project_name` are set to None.
"""
if not anatomy and not project_name:
raise ValueError((
"Missing required arguments one of `project_name` or `anatomy`"
" must be entered."
))
if not anatomy:
anatomy = Anatomy(project_name)
if not template_key:
template_key = "work"
anatomy_filled = anatomy.format(workdir_data)
# Output is TemplateResult object which contain usefull data
return anatomy_filled[template_key]["folder"]
def get_workdir(
project_doc,
asset_doc,
task_name,
host_name,
anatomy=None,
template_key=None
):
"""Fill workdir path from entered data and project's anatomy.
Args:
project_doc (dict): Mongo document of project from MongoDB.
asset_doc (dict): Mongo document of asset from MongoDB.
task_name (str): Task name for which are workdir data preapred.
host_name (str): Host which is used to workdir. This is required
because workdir template may contain `{app}` key. In `Session`
is stored under `AVALON_APP` key.
anatomy (Anatomy): Optional argument. Anatomy object is created using
project name from `project_doc`. It is preffered to pass this
argument as initialization of a new Anatomy object may be time
consuming.
template_key (str): Key of work templates in anatomy templates. Default
value is defined in `get_workdir_with_workdir_data`.
Returns:
TemplateResult: Workdir path.
"""
if not anatomy:
anatomy = Anatomy(project_doc["name"])
workdir_data = get_workdir_data(
project_doc, asset_doc, task_name, host_name
)
# Output is TemplateResult object which contain usefull data
return get_workdir_with_workdir_data(workdir_data, anatomy, template_key)
@with_avalon
def get_workfile_doc(asset_id, task_name, filename, dbcon=None):
"""Return workfile document for entered context.
Do not use this method to get more than one document. In that cases use
custom query as this will return documents from database one by one.
Args:
asset_id (ObjectId): Mongo ID of an asset under which workfile belongs.
task_name (str): Name of task under which the workfile belongs.
filename (str): Name of a workfile.
dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and
`avalon.io` is used if not entered.
Returns:
dict: Workfile document or None.
"""
# Use avalon.io if dbcon is not entered
if not dbcon:
dbcon = avalon.io
return dbcon.find_one({
"type": "workfile",
"parent": asset_id,
"task_name": task_name,
"filename": filename
})
@with_avalon
def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None):
"""Creates or replace workfile document in mongo.
Do not use this method to update data. This method will remove all
additional data from existing document.
Args:
asset_doc (dict): Document of asset under which workfile belongs.
task_name (str): Name of task for which is workfile related to.
filename (str): Filename of workfile.
workdir (str): Path to directory where `filename` is located.
dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and
`avalon.io` is used if not entered.
"""
# Use avalon.io if dbcon is not entered
if not dbcon:
dbcon = avalon.io
# Filter of workfile document
doc_filter = {
"type": "workfile",
"parent": asset_doc["_id"],
"task_name": task_name,
"filename": filename
}
# Document data are copy of filter
doc_data = copy.deepcopy(doc_filter)
# Prepare project for workdir data
project_doc = dbcon.find_one({"type": "project"})
workdir_data = get_workdir_data(
project_doc, asset_doc, task_name, dbcon.Session["AVALON_APP"]
)
# Prepare anatomy
anatomy = Anatomy(project_doc["name"])
# Get workdir path (result is anatomy.TemplateResult)
template_workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
template_workdir_path = str(template_workdir).replace("\\", "/")
# Replace slashses in workdir path where workfile is located
mod_workdir = workdir.replace("\\", "/")
# Replace workdir from templates with rootless workdir
rootles_workdir = mod_workdir.replace(
template_workdir_path,
template_workdir.rootless.replace("\\", "/")
)
doc_data["schema"] = "pype:workfile-1.0"
doc_data["files"] = ["/".join([rootles_workdir, filename])]
doc_data["data"] = {}
dbcon.replace_one(
doc_filter,
doc_data,
upsert=True
)
@with_avalon
def save_workfile_data_to_doc(workfile_doc, data, dbcon=None):
if not workfile_doc:
# TODO add log message
return
if not data:
return
# Use avalon.io if dbcon is not entered
if not dbcon:
dbcon = avalon.io
# Convert data to mongo modification keys/values
# - this is naive implementation which does not expect nested
# dictionaries
set_data = {}
for key, value in data.items():
new_key = "data.{}".format(key)
set_data[new_key] = value
# Update workfile document with data
dbcon.update_one(
{"_id": workfile_doc["_id"]},
{"$set": set_data}
)
class BuildWorkfile:
"""Wrapper for build workfile process.

View file

@ -0,0 +1,143 @@
# Workfiles App
The Workfiles app facilitates easy saving, creation and launching of work files.
The current supported hosts are:
- Maya
- Houdini
- Fusion
The app is available inside hosts via. the ```Avalon > Work Files``` menu.
## Enabling Workfiles on launch
By default the Workfiles app will not launch on startup, so it has to be explicitly enabled in a config.
```python
workfiles.show()
```
## Naming Files
Workfiles app enables user to easily save and create new work files.
The user is presented with a two parameters; ```version``` and ```comment```. The name of the work file is determined from a template.
### ```Next Available Version```
Will search for the next version number that is not in use.
## Templates
The default template for work files is ```{task[name]}_v{version:0>4}<_{comment}>```. Launching Maya on an animation task and creating a version 1 will result in ```animation_v0001.ma```. Adding "blocking" to the optional comment input will result in ```animation_v0001_blocking.ma```.
This template can be customized per project with the ```workfile``` template.
There are other variables to customize the template with:
```python
{
"project": project, # The project data from the database.
"asset": asset, # The asset data from the database.
"task": {
"label": label, # Label of task chosen.
"name": name # Sanitize version of the label.
},
"user": user, # Name of the user on the machine.
"version": version, # Chosen version of the user.
"comment": comment, # Chosen comment of the user.
}
```
### Optional template groups
The default template contains an optional template group ```<_{comment}>```. If any template group (```{comment}```) within angle bracket ```<>``` does not exist, the whole optional group is discarded.
## Implementing a new host integration for Work Files
For the Work Files tool to work with a new host integration the host must
implement the following functions:
- `file_extensions()`: The files the host should allow to open and show in the Work Files view.
- `open_file(filepath)`: Open a file.
- `save_file(filepath)`: Save the current file. This should return None if it failed to save, and return the path if it succeeded
- `has_unsaved_changes()`: Return whether the current scene has unsaved changes.
- `current_file()`: The path to the current file. None if not saved.
- `work_root()`: The path to where the work files for this app should be saved.
Here's an example code layout:
```python
def file_extensions():
"""Return the filename extension formats that should be shown.
Note:
The first entry in the list will be used as the default file
format to save to when the current scene is not saved yet.
Returns:
list: A list of the file extensions supported by Work Files.
"""
return list()
def has_unsaved_changes():
"""Return whether current file has unsaved modifications."""
def save_file(filepath):
"""Save to filepath.
This should return None if it failed to save, and return the path if it
succeeded.
"""
pass
def open_file(filepath):
"""Open file"""
pass
def current_file():
"""Return path to currently open file or None if not saved.
Returns:
str or None: The full path to current file or None when not saved.
"""
pass
def work_root():
"""Return the default root for the Host to browse in for Work Files
Returns:
str: The path to look in.
"""
pass
```
#### Work Files Scenes root (AVALON_SCENEDIR)
Whenever the host application has no built-in implementation that defines
where scene files should be saved to then the Work Files API for that host
should fall back to the `AVALON_SCENEDIR` variable in `api.Session`.
When `AVALON_SCENEDIR` is set the directory is the relative folder inside the
`AVALON_WORKDIR`. Otherwise, when it is not set or empty it should fall back
to the Work Directory's root, `AVALON_WORKDIR`
```python
AVALON_WORKDIR="/path/to/work"
AVALON_SCENEDIR="scenes"
# Result: /path/to/work/scenes
AVALON_WORKDIR="/path/to/work"
AVALON_SCENEDIR=None
# Result: /path/to/work
```

View file

@ -0,0 +1,9 @@
from .app import (
show,
Window
)
__all__ = [
"show",
"Window"
]

1166
pype/tools/workfiles/app.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,153 @@
import os
import logging
from Qt import QtCore
from avalon import style
from avalon.vendor import qtawesome
from avalon.tools.models import TreeModel, Item
log = logging.getLogger(__name__)
class FilesModel(TreeModel):
"""Model listing files with specified extensions in a root folder"""
Columns = ["filename", "date"]
FileNameRole = QtCore.Qt.UserRole + 2
DateModifiedRole = QtCore.Qt.UserRole + 3
FilePathRole = QtCore.Qt.UserRole + 4
IsEnabled = QtCore.Qt.UserRole + 5
def __init__(self, file_extensions, parent=None):
super(FilesModel, self).__init__(parent=parent)
self._root = None
self._file_extensions = file_extensions
self._icons = {
"file": qtawesome.icon("fa.file-o", color=style.colors.default)
}
def set_root(self, root):
self._root = root
self.refresh()
def _add_empty(self):
item = Item()
item.update({
# Put a display message in 'filename'
"filename": "No files found.",
# Not-selectable
"enabled": False,
"date": None,
"filepath": None
})
self.add_child(item)
def refresh(self):
self.clear()
self.beginResetModel()
root = self._root
if not root:
self.endResetModel()
return
if not os.path.exists(root):
# Add Work Area does not exist placeholder
log.debug("Work Area does not exist: %s", root)
message = "Work Area does not exist. Use Save As to create it."
item = Item({
"filename": message,
"date": None,
"filepath": None,
"enabled": False,
"icon": qtawesome.icon("fa.times", color=style.colors.mid)
})
self.add_child(item)
self.endResetModel()
return
extensions = self._file_extensions
for filename in os.listdir(root):
path = os.path.join(root, filename)
if os.path.isdir(path):
continue
ext = os.path.splitext(filename)[1]
if extensions and ext not in extensions:
continue
modified = os.path.getmtime(path)
item = Item({
"filename": filename,
"date": modified,
"filepath": path
})
self.add_child(item)
if self.rowCount() == 0:
self._add_empty()
self.endResetModel()
def has_filenames(self):
for item in self._root_item.children():
if item.get("enabled", True):
return True
return False
def rowCount(self, parent=None):
if parent is None or not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
return parent_item.childCount()
def data(self, index, role):
if not index.isValid():
return
if role == QtCore.Qt.DecorationRole:
# Add icon to filename column
item = index.internalPointer()
if index.column() == 0:
if item["filepath"]:
return self._icons["file"]
return item.get("icon", None)
if role == self.FileNameRole:
item = index.internalPointer()
return item["filename"]
if role == self.DateModifiedRole:
item = index.internalPointer()
return item["date"]
if role == self.FilePathRole:
item = index.internalPointer()
return item["filepath"]
if role == self.IsEnabled:
item = index.internalPointer()
return item.get("enabled", True)
return super(FilesModel, self).data(index, role)
def headerData(self, section, orientation, role):
# Show nice labels in the header
if (
role == QtCore.Qt.DisplayRole
and orientation == QtCore.Qt.Horizontal
):
if section == 0:
return "Name"
elif section == 1:
return "Date modified"
return super(FilesModel, self).headerData(section, orientation, role)

View file

@ -0,0 +1,15 @@
from Qt import QtWidgets, QtCore
class FilesView(QtWidgets.QTreeView):
doubleClickedLeft = QtCore.Signal()
doubleClickedRight = QtCore.Signal()
def mouseDoubleClickEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.doubleClickedLeft.emit()
elif event.button() == QtCore.Qt.RightButton:
self.doubleClickedRight.emit()
return super(FilesView, self).mouseDoubleClickEvent(event)

52
schema/workfile-1.0.json Normal file
View file

@ -0,0 +1,52 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "pype:workfile-1.0",
"description": "Workfile additional information.",
"type": "object",
"additionalProperties": true,
"required": [
"schema",
"type",
"filename",
"task_name",
"parent"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string",
"enum": ["pype:workfile-1.0"],
"example": "pype:workfile-1.0"
},
"type": {
"description": "The type of document",
"type": "string",
"enum": ["workfile"],
"example": "workfile"
},
"parent": {
"description": "Unique identifier to parent document",
"example": "592c33475f8c1b064c4d1696"
},
"filename": {
"description": "Workfile's filename",
"type": "string",
"example": "kuba_each_case_Alpaca_01_animation_v001.ma"
},
"task_name": {
"description": "Task name",
"type": "string",
"example": "animation"
},
"data": {
"description": "Document metadata",
"type": "object",
"example": {"key": "value"}
}
}
}