resolved conflict

This commit is contained in:
Kayla Man 2023-10-23 15:58:51 +08:00
commit fe1fd463da
37 changed files with 2459 additions and 210 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.17.4-nightly.1
- 3.17.3
- 3.17.3-nightly.2
- 3.17.3-nightly.1
@ -134,7 +135,6 @@ body:
- 3.15.0
- 3.15.0-nightly.1
- 3.14.11-nightly.4
- 3.14.11-nightly.3
validations:
required: true
- type: dropdown

View file

@ -279,7 +279,7 @@ arguments and it will create zip file that OpenPype can use.
Building documentation
----------------------
Top build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation
To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation
from current sources in `.\docs\build`.
**Note that it needs existing virtual environment.**

View file

@ -3,11 +3,11 @@
import bpy
from openpype.pipeline import get_current_task_name
import openpype.hosts.blender.api.plugin
from openpype.hosts.blender.api import lib
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):
class CreatePointcache(plugin.Creator):
"""Polygonal static geometry"""
name = "pointcacheMain"
@ -16,20 +16,36 @@ class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):
icon = "gears"
def process(self):
""" Run the creator on Blender main thread"""
mti = ops.MainThreadItem(self._process)
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)
bpy.context.scene.collection.children.link(instances)
# Create instance object
asset = self.data["asset"]
subset = self.data["subset"]
name = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
name = plugin.asset_name(asset, subset)
asset_group = bpy.data.objects.new(name=name, object_data=None)
asset_group.empty_display_type = 'SINGLE_ARROW'
instances.objects.link(asset_group)
self.data['task'] = get_current_task_name()
lib.imprint(collection, self.data)
lib.imprint(asset_group, self.data)
# Add selected objects to instance
if (self.options or {}).get("useSelection"):
objects = lib.get_selection()
for obj in objects:
collection.objects.link(obj)
if obj.type == 'EMPTY':
objects.extend(obj.children)
bpy.context.view_layer.objects.active = asset_group
selected = lib.get_selection()
for obj in selected:
if obj.parent in selected:
obj.select_set(False)
continue
selected.append(asset_group)
bpy.ops.object.parent_set(keep_transform=True)
return collection
return asset_group

View file

@ -60,18 +60,29 @@ class CacheModelLoader(plugin.AssetLoader):
imported = lib.get_selection()
# Children must be linked before parents,
# otherwise the hierarchy will break
# Use first EMPTY without parent as container
container = next(
(obj for obj in imported
if obj.type == "EMPTY" and not obj.parent),
None
)
objects = []
if container:
nodes = list(container.children)
for obj in imported:
obj.parent = asset_group
for obj in nodes:
obj.parent = asset_group
for obj in imported:
objects.append(obj)
imported.extend(list(obj.children))
bpy.data.objects.remove(container)
objects.reverse()
objects.extend(nodes)
for obj in nodes:
objects.extend(obj.children_recursive)
else:
for obj in imported:
obj.parent = asset_group
objects = imported
for obj in objects:
# Unlink the object from all collections
@ -137,6 +148,7 @@ class CacheModelLoader(plugin.AssetLoader):
bpy.context.scene.collection.children.link(containers)
asset_group = bpy.data.objects.new(group_name, object_data=None)
asset_group.empty_display_type = 'SINGLE_ARROW'
containers.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)

View file

@ -19,85 +19,51 @@ class CollectInstances(pyblish.api.ContextPlugin):
@staticmethod
def get_asset_groups() -> Generator:
"""Return all 'model' collections.
Check if the family is 'model' and if it doesn't have the
representation set. If the representation is set, it is a loaded model
and we don't want to publish it.
"""Return all instances that are empty objects asset groups.
"""
instances = bpy.data.collections.get(AVALON_INSTANCES)
for obj in instances.objects:
avalon_prop = obj.get(AVALON_PROPERTY) or dict()
for obj in list(instances.objects) + list(instances.children):
avalon_prop = obj.get(AVALON_PROPERTY) or {}
if avalon_prop.get('id') == 'pyblish.avalon.instance':
yield obj
@staticmethod
def get_collections() -> Generator:
"""Return all 'model' collections.
Check if the family is 'model' and if it doesn't have the
representation set. If the representation is set, it is a loaded model
and we don't want to publish it.
"""
for collection in bpy.data.collections:
avalon_prop = collection.get(AVALON_PROPERTY) or dict()
if avalon_prop.get('id') == 'pyblish.avalon.instance':
yield collection
def create_instance(context, group):
avalon_prop = group[AVALON_PROPERTY]
asset = avalon_prop['asset']
family = avalon_prop['family']
subset = avalon_prop['subset']
task = avalon_prop['task']
name = f"{asset}_{subset}"
return context.create_instance(
name=name,
family=family,
families=[family],
subset=subset,
asset=asset,
task=task,
)
def process(self, context):
"""Collect the models from the current Blender scene."""
asset_groups = self.get_asset_groups()
collections = self.get_collections()
for group in asset_groups:
avalon_prop = group[AVALON_PROPERTY]
asset = avalon_prop['asset']
family = avalon_prop['family']
subset = avalon_prop['subset']
task = avalon_prop['task']
name = f"{asset}_{subset}"
instance = context.create_instance(
name=name,
family=family,
families=[family],
subset=subset,
asset=asset,
task=task,
)
objects = list(group.children)
members = set()
for obj in objects:
objects.extend(list(obj.children))
members.add(obj)
members.add(group)
instance[:] = list(members)
self.log.debug(json.dumps(instance.data, indent=4))
for obj in instance:
self.log.debug(obj)
instance = self.create_instance(context, group)
members = []
if isinstance(group, bpy.types.Collection):
members = list(group.objects)
family = instance.data["family"]
if family == "animation":
for obj in group.objects:
if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY):
members.extend(
child for child in obj.children
if child.type == 'ARMATURE')
else:
members = group.children_recursive
for collection in collections:
avalon_prop = collection[AVALON_PROPERTY]
asset = avalon_prop['asset']
family = avalon_prop['family']
subset = avalon_prop['subset']
task = avalon_prop['task']
name = f"{asset}_{subset}"
instance = context.create_instance(
name=name,
family=family,
families=[family],
subset=subset,
asset=asset,
task=task,
)
members = list(collection.objects)
if family == "animation":
for obj in collection.objects:
if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY):
for child in obj.children:
if child.type == 'ARMATURE':
members.append(child)
members.append(collection)
members.append(group)
instance[:] = members
self.log.debug(json.dumps(instance.data, indent=4))
for obj in instance:

View file

@ -12,8 +12,7 @@ class ExtractABC(publish.Extractor):
label = "Extract ABC"
hosts = ["blender"]
families = ["model", "pointcache"]
optional = True
families = ["pointcache"]
def process(self, instance):
# Define extract output file path
@ -62,3 +61,12 @@ class ExtractABC(publish.Extractor):
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)
class ExtractModelABC(ExtractABC):
"""Extract model as ABC."""
label = "Extract Model ABC"
hosts = ["blender"]
families = ["model"]
optional = True

View file

@ -10,7 +10,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
optional = True
hosts = ["blender"]
families = ["animation", "model", "rig", "action", "layout", "blendScene",
"render"]
"pointcache", "render"]
def process(self, context):

View file

@ -234,27 +234,40 @@ def reset_scene_resolution():
set_scene_resolution(width, height)
def get_frame_range() -> Union[Dict[str, Any], None]:
def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]:
"""Get the current assets frame range and handles.
Args:
asset_doc (dict): Asset Entity Data
Returns:
dict: with frame start, frame end, handle start, handle end.
"""
# Set frame start/end
asset = get_current_project_asset()
frame_start = asset["data"].get("frameStart")
frame_end = asset["data"].get("frameEnd")
if asset_doc is None:
asset_doc = get_current_project_asset()
data = asset_doc["data"]
frame_start = data.get("frameStart")
frame_end = data.get("frameEnd")
if frame_start is None or frame_end is None:
return
return {}
frame_start = int(frame_start)
frame_end = int(frame_end)
handle_start = int(data.get("handleStart", 0))
handle_end = int(data.get("handleEnd", 0))
frame_start_handle = frame_start - handle_start
frame_end_handle = frame_end + handle_end
handle_start = asset["data"].get("handleStart", 0)
handle_end = asset["data"].get("handleEnd", 0)
return {
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end
"handleEnd": handle_end,
"frameStartHandle": frame_start_handle,
"frameEndHandle": frame_end_handle,
}
@ -274,12 +287,11 @@ def reset_frame_range(fps: bool = True):
fps_number = float(data_fps["data"]["fps"])
rt.frameRate = fps_number
frame_range = get_frame_range()
frame_start_handle = frame_range["frameStart"] - int(
frame_range["handleStart"]
)
frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"])
set_timeline(frame_start_handle, frame_end_handle)
set_render_frame_range(frame_start_handle, frame_end_handle)
set_timeline(
frame_range["frameStartHandle"], frame_range["frameEndHandle"])
set_render_frame_range(
frame_range["frameStartHandle"], frame_range["frameEndHandle"])
def set_context_setting():

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
import pyblish.api
from pymxs import runtime as rt
class CollectFrameRange(pyblish.api.InstancePlugin):
"""Collect Frame Range."""
order = pyblish.api.CollectorOrder + 0.01
label = "Collect Frame Range"
hosts = ['max']
families = ["camera", "maxrender",
"pointcache", "pointcloud",
"review", "redshiftproxy"]
def process(self, instance):
if instance.data["family"] == "maxrender":
instance.data["frameStartHandle"] = int(rt.rendStart)
instance.data["frameEndHandle"] = int(rt.rendEnd)
else:
instance.data["frameStartHandle"] = int(rt.animationRange.start)
instance.data["frameEndHandle"] = int(rt.animationRange.end)

View file

@ -14,7 +14,7 @@ from openpype.client import get_last_version_by_subset_name
class CollectRender(pyblish.api.InstancePlugin):
"""Collect Render for Deadline"""
order = pyblish.api.CollectorOrder + 0.01
order = pyblish.api.CollectorOrder + 0.02
label = "Collect 3dsmax Render Layers"
hosts = ['max']
families = ["maxrender"]
@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin):
"renderer": renderer,
"source": filepath,
"plugin": "3dsmax",
"frameStart": int(rt.rendStart),
"frameEnd": int(rt.rendEnd),
"frameStart": instance.data["frameStartHandle"],
"frameEnd": instance.data["frameEndHandle"],
"version": version_int,
"farm": True
}

View file

@ -30,10 +30,10 @@ class CollectReview(pyblish.api.InstancePlugin,
general_preview_data = {
"review_camera": camera_name,
"frameStart": instance.data["frameStartHandle"],
"frameEnd": instance.data["frameEndHandle"],
"imageFormat": creator_attrs["imageFormat"],
"keepImages": creator_attrs["keepImages"],
"frameStart": instance.context.data["frameStart"],
"frameEnd": instance.context.data["frameEnd"],
"fps": instance.context.data["fps"],
"review_width": creator_attrs["review_width"],
"review_height": creator_attrs["review_height"],

View file

@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin):
def process(self, instance):
if not self.is_active(instance.data):
return
start = float(instance.data.get("frameStartHandle", 1))
end = float(instance.data.get("frameEndHandle", 1))
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
self.log.info("Extracting Camera ...")

View file

@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor):
families = ["pointcache"]
def process(self, instance):
start = float(instance.data.get("frameStartHandle", 1))
end = float(instance.data.get("frameEndHandle", 1))
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
self.log.debug("Extracting pointcache ...")

View file

@ -40,8 +40,8 @@ class ExtractPointCloud(publish.Extractor):
def process(self, instance):
self.settings = self.get_setting(instance)
start = int(instance.context.data.get("frameStart"))
end = int(instance.context.data.get("frameEnd"))
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
self.log.info("Extracting PRT...")
stagingdir = self.staging_dir(instance)

View file

@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor):
families = ["redshiftproxy"]
def process(self, instance):
start = int(instance.context.data.get("frameStart"))
end = int(instance.context.data.get("frameEnd"))
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
self.log.debug("Extracting Redshift Proxy...")
stagingdir = self.staging_dir(instance)

View file

@ -50,8 +50,8 @@ class ExtractReviewAnimation(publish.Extractor):
"ext": instance.data["imageFormat"],
"files": filenames,
"stagingDir": staging_dir,
"frameStart": instance.data["frameStart"],
"frameEnd": instance.data["frameEnd"],
"frameStart": instance.data["frameStartHandle"],
"frameEnd": instance.data["frameEndHandle"],
"tags": tags,
"preview": True,
"camera_name": review_camera

View file

@ -1,5 +1,4 @@
import os
import tempfile
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.max.api.preview_animation import render_preview_animation

View file

@ -1,48 +0,0 @@
import pyblish.api
from pymxs import runtime as rt
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishValidationError
)
from openpype.hosts.max.api.lib import get_frame_range, set_timeline
class ValidateAnimationTimeline(pyblish.api.InstancePlugin):
"""
Validates Animation Timeline for Preview Animation in Max
"""
label = "Animation Timeline for Review"
order = ValidateContentsOrder
families = ["review"]
hosts = ["max"]
actions = [RepairAction]
def process(self, instance):
frame_range = get_frame_range()
frame_start_handle = frame_range["frameStart"] - int(
frame_range["handleStart"]
)
frame_end_handle = frame_range["frameEnd"] + int(
frame_range["handleEnd"]
)
if rt.animationRange.start != frame_start_handle or (
rt.animationRange.end != frame_end_handle
):
raise PublishValidationError("Incorrect animation timeline "
"set for preview animation.. "
"\nYou can use repair action to "
"the correct animation timeline")
@classmethod
def repair(cls, instance):
frame_range = get_frame_range()
frame_start_handle = frame_range["frameStart"] - int(
frame_range["handleStart"]
)
frame_end_handle = frame_range["frameEnd"] + int(
frame_range["handleEnd"]
)
set_timeline(frame_start_handle, frame_end_handle)

View file

@ -7,8 +7,10 @@ from openpype.pipeline import (
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishValidationError
PublishValidationError,
KnownPublishError
)
from openpype.hosts.max.api.lib import get_frame_range, set_timeline
class ValidateFrameRange(pyblish.api.InstancePlugin,
@ -27,38 +29,60 @@ class ValidateFrameRange(pyblish.api.InstancePlugin,
label = "Validate Frame Range"
order = ValidateContentsOrder
families = ["maxrender"]
families = ["camera", "maxrender",
"pointcache", "pointcloud",
"review", "redshiftproxy"]
hosts = ["max"]
optional = True
actions = [RepairAction]
def process(self, instance):
if not self.is_active(instance.data):
self.log.info("Skipping validation...")
self.log.debug("Skipping Validate Frame Range...")
return
context = instance.context
frame_start = int(context.data.get("frameStart"))
frame_end = int(context.data.get("frameEnd"))
inst_frame_start = int(instance.data.get("frameStart"))
inst_frame_end = int(instance.data.get("frameEnd"))
frame_range = get_frame_range(
asset_doc=instance.data["assetEntity"])
inst_frame_start = instance.data.get("frameStartHandle")
inst_frame_end = instance.data.get("frameEndHandle")
if inst_frame_start is None or inst_frame_end is None:
raise KnownPublishError(
"Missing frame start and frame end on "
"instance to to validate."
)
frame_start_handle = frame_range["frameStartHandle"]
frame_end_handle = frame_range["frameEndHandle"]
errors = []
if frame_start != inst_frame_start:
if frame_start_handle != inst_frame_start:
errors.append(
f"Start frame ({inst_frame_start}) on instance does not match " # noqa
f"with the start frame ({frame_start}) set on the asset data. ") # noqa
if frame_end != inst_frame_end:
f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa
if frame_end_handle != inst_frame_end:
errors.append(
f"End frame ({inst_frame_end}) on instance does not match "
f"with the end frame ({frame_start}) from the asset data. ")
f"with the end frame ({frame_end_handle}) "
"from the asset data. ")
if errors:
errors.append("You can use repair action to fix it.")
raise PublishValidationError("\n".join(errors))
bullet_point_errors = "\n".join(
"- {}".format(error) for error in errors
)
report = (
"Frame range settings are incorrect.\n\n"
f"{bullet_point_errors}\n\n"
"You can use repair action to fix it."
)
raise PublishValidationError(report, title="Frame Range incorrect")
@classmethod
def repair(cls, instance):
rt.rendStart = instance.context.data.get("frameStart")
rt.rendEnd = instance.context.data.get("frameEnd")
frame_range = get_frame_range()
frame_start_handle = frame_range["frameStartHandle"]
frame_end_handle = frame_range["frameEndHandle"]
if instance.data["family"] == "maxrender":
rt.rendStart = frame_start_handle
rt.rendEnd = frame_end_handle
else:
set_timeline(frame_start_handle, frame_end_handle)

View file

@ -6,8 +6,6 @@ Requires:
import pyblish.api
from openpype.pipeline import legacy_io
class StartTimer(pyblish.api.ContextPlugin):
label = "Start Timer"
@ -25,9 +23,9 @@ class StartTimer(pyblish.api.ContextPlugin):
self.log.debug("Publish is not affecting running timers.")
return
project_name = legacy_io.active_project()
asset_name = legacy_io.Session.get("AVALON_ASSET")
task_name = legacy_io.Session.get("AVALON_TASK")
project_name = context.data["projectName"]
asset_name = context.data.get("asset")
task_name = context.data.get("task")
if not project_name or not asset_name or not task_name:
self.log.info((
"Current context does not contain all"

View file

@ -1,6 +1,6 @@
import os
from openpype import PACKAGE_DIR
from openpype import PACKAGE_DIR, AYON_SERVER_ENABLED
from openpype.lib import get_openpype_execute_args, run_detached_process
from openpype.pipeline import load
from openpype.pipeline.load import LoadError
@ -32,12 +32,22 @@ class PushToLibraryProject(load.SubsetLoaderPlugin):
raise LoadError("Please select only one item")
context = tuple(filtered_contexts)[0]
push_tool_script_path = os.path.join(
PACKAGE_DIR,
"tools",
"push_to_project",
"app.py"
)
if AYON_SERVER_ENABLED:
push_tool_script_path = os.path.join(
PACKAGE_DIR,
"tools",
"ayon_push_to_project",
"main.py"
)
else:
push_tool_script_path = os.path.join(
PACKAGE_DIR,
"tools",
"push_to_project",
"app.py"
)
project_doc = context["project"]
version_doc = context["version"]
project_name = project_doc["name"]

View file

@ -89,10 +89,10 @@
"optional": true,
"active": false
},
"ExtractABC": {
"ExtractModelABC": {
"enabled": true,
"optional": true,
"active": false
"active": true
},
"ExtractBlendAnimation": {
"enabled": true,

View file

@ -181,12 +181,12 @@
"name": "template_publish_plugin",
"template_data": [
{
"key": "ExtractFBX",
"label": "Extract FBX (model and rig)"
"key": "ExtractModelABC",
"label": "Extract ABC (model)"
},
{
"key": "ExtractABC",
"label": "Extract ABC (model and pointcache)"
"key": "ExtractFBX",
"label": "Extract FBX (model and rig)"
},
{
"key": "ExtractBlendAnimation",

View file

@ -0,0 +1,6 @@
from .control import PushToContextController
__all__ = (
"PushToContextController",
)

View file

@ -0,0 +1,344 @@
import threading
from openpype.client import (
get_asset_by_id,
get_subset_by_id,
get_version_by_id,
get_representations,
)
from openpype.settings import get_project_settings
from openpype.lib import prepare_template_data
from openpype.lib.events import QueuedEventSystem
from openpype.pipeline.create import get_subset_name_template
from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel
from .models import (
PushToProjectSelectionModel,
UserPublishValuesModel,
IntegrateModel,
)
class PushToContextController:
def __init__(self, project_name=None, version_id=None):
self._event_system = self._create_event_system()
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._integrate_model = IntegrateModel(self)
self._selection_model = PushToProjectSelectionModel(self)
self._user_values = UserPublishValuesModel(self)
self._src_project_name = None
self._src_version_id = None
self._src_asset_doc = None
self._src_subset_doc = None
self._src_version_doc = None
self._src_label = None
self._submission_enabled = False
self._process_thread = None
self._process_item_id = None
self.set_source(project_name, version_id)
# Events system
def emit_event(self, topic, data=None, source=None):
"""Use implemented event system to trigger event."""
if data is None:
data = {}
self._event_system.emit(topic, data, source)
def register_event_callback(self, topic, callback):
self._event_system.add_callback(topic, callback)
def set_source(self, project_name, version_id):
"""Set source project and version.
Args:
project_name (Union[str, None]): Source project name.
version_id (Union[str, None]): Source version id.
"""
if (
project_name == self._src_project_name
and version_id == self._src_version_id
):
return
self._src_project_name = project_name
self._src_version_id = version_id
self._src_label = None
asset_doc = None
subset_doc = None
version_doc = None
if project_name and version_id:
version_doc = get_version_by_id(project_name, version_id)
if version_doc:
subset_doc = get_subset_by_id(project_name, version_doc["parent"])
if subset_doc:
asset_doc = get_asset_by_id(project_name, subset_doc["parent"])
self._src_asset_doc = asset_doc
self._src_subset_doc = subset_doc
self._src_version_doc = version_doc
if asset_doc:
self._user_values.set_new_folder_name(asset_doc["name"])
variant = self._get_src_variant()
if variant:
self._user_values.set_variant(variant)
comment = version_doc["data"].get("comment")
if comment:
self._user_values.set_comment(comment)
self._emit_event(
"source.changed",
{
"project_name": project_name,
"version_id": version_id
}
)
def get_source_label(self):
"""Get source label.
Returns:
str: Label describing source project and version as path.
"""
if self._src_label is None:
self._src_label = self._prepare_source_label()
return self._src_label
def get_project_items(self, sender=None):
return self._projects_model.get_project_items(sender)
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, project_name, folder_id, sender=None):
return self._hierarchy_model.get_task_items(
project_name, folder_id, sender
)
def get_user_values(self):
return self._user_values.get_data()
def set_user_value_folder_name(self, folder_name):
self._user_values.set_new_folder_name(folder_name)
self._invalidate()
def set_user_value_variant(self, variant):
self._user_values.set_variant(variant)
self._invalidate()
def set_user_value_comment(self, comment):
self._user_values.set_comment(comment)
self._invalidate()
def set_selected_project(self, project_name):
self._selection_model.set_selected_project(project_name)
self._invalidate()
def set_selected_folder(self, folder_id):
self._selection_model.set_selected_folder(folder_id)
self._invalidate()
def set_selected_task(self, task_id, task_name):
self._selection_model.set_selected_task(task_id, task_name)
def get_process_item_status(self, item_id):
return self._integrate_model.get_item_status(item_id)
# Processing methods
def submit(self, wait=True):
if not self._submission_enabled:
return
if self._process_thread is not None:
return
item_id = self._integrate_model.create_process_item(
self._src_project_name,
self._src_version_id,
self._selection_model.get_selected_project_name(),
self._selection_model.get_selected_folder_id(),
self._selection_model.get_selected_task_name(),
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1
)
self._process_item_id = item_id
self._emit_event("submit.started")
if wait:
self._submit_callback()
self._process_item_id = None
return item_id
thread = threading.Thread(target=self._submit_callback)
self._process_thread = thread
thread.start()
return item_id
def wait_for_process_thread(self):
if self._process_thread is None:
return
self._process_thread.join()
self._process_thread = None
def _prepare_source_label(self):
if not self._src_project_name or not self._src_version_id:
return "Source is not defined"
asset_doc = self._src_asset_doc
if not asset_doc:
return "Source is invalid"
folder_path_parts = list(asset_doc["data"]["parents"])
folder_path_parts.append(asset_doc["name"])
folder_path = "/".join(folder_path_parts)
subset_doc = self._src_subset_doc
version_doc = self._src_version_doc
return "Source: {}/{}/{}/v{:0>3}".format(
self._src_project_name,
folder_path,
subset_doc["name"],
version_doc["name"]
)
def _get_task_info_from_repre_docs(self, asset_doc, repre_docs):
asset_tasks = asset_doc["data"].get("tasks") or {}
found_comb = []
for repre_doc in repre_docs:
context = repre_doc["context"]
task_info = context.get("task")
if task_info is None:
continue
task_name = None
task_type = None
if isinstance(task_info, str):
task_name = task_info
asset_task_info = asset_tasks.get(task_info) or {}
task_type = asset_task_info.get("type")
elif isinstance(task_info, dict):
task_name = task_info.get("name")
task_type = task_info.get("type")
if task_name and task_type:
return task_name, task_type
if task_name:
found_comb.append((task_name, task_type))
for task_name, task_type in found_comb:
return task_name, task_type
return None, None
def _get_src_variant(self):
project_name = self._src_project_name
version_doc = self._src_version_doc
asset_doc = self._src_asset_doc
repre_docs = get_representations(
project_name, version_ids=[version_doc["_id"]]
)
task_name, task_type = self._get_task_info_from_repre_docs(
asset_doc, repre_docs
)
project_settings = get_project_settings(project_name)
subset_doc = self._src_subset_doc
family = subset_doc["data"].get("family")
if not family:
family = subset_doc["data"]["families"][0]
template = get_subset_name_template(
self._src_project_name,
family,
task_name,
task_type,
None,
project_settings=project_settings
)
template_low = template.lower()
variant_placeholder = "{variant}"
if (
variant_placeholder not in template_low
or (not task_name and "{task" in template_low)
):
return ""
idx = template_low.index(variant_placeholder)
template_s = template[:idx]
template_e = template[idx + len(variant_placeholder):]
fill_data = prepare_template_data({
"family": family,
"task": task_name
})
try:
subset_s = template_s.format(**fill_data)
subset_e = template_e.format(**fill_data)
except Exception as exc:
print("Failed format", exc)
return ""
subset_name = self._src_subset_doc["name"]
if (
(subset_s and not subset_name.startswith(subset_s))
or (subset_e and not subset_name.endswith(subset_e))
):
return ""
if subset_s:
subset_name = subset_name[len(subset_s):]
if subset_e:
subset_name = subset_name[:len(subset_e)]
return subset_name
def _check_submit_validations(self):
if not self._user_values.is_valid:
return False
if not self._selection_model.get_selected_project_name():
return False
if (
not self._user_values.new_folder_name
and not self._selection_model.get_selected_folder_id()
):
return False
return True
def _invalidate(self):
submission_enabled = self._check_submit_validations()
if submission_enabled == self._submission_enabled:
return
self._submission_enabled = submission_enabled
self._emit_event(
"submission.enabled.changed",
{"enabled": submission_enabled}
)
def _submit_callback(self):
process_item_id = self._process_item_id
if process_item_id is None:
return
self._integrate_model.integrate_item(process_item_id)
self._emit_event("submit.finished", {})
if process_item_id == self._process_item_id:
self._process_item_id = None
def _emit_event(self, topic, data=None):
if data is None:
data = {}
self.emit_event(topic, data, "controller")
def _create_event_system(self):
return QueuedEventSystem()

View file

@ -0,0 +1,32 @@
import click
from openpype.tools.utils import get_openpype_qt_app
from openpype.tools.ayon_push_to_project.ui import PushToContextSelectWindow
def main_show(project_name, version_id):
app = get_openpype_qt_app()
window = PushToContextSelectWindow()
window.show()
window.set_source(project_name, version_id)
app.exec_()
@click.command()
@click.option("--project", help="Source project name")
@click.option("--version", help="Source version id")
def main(project, version):
"""Run PushToProject tool to integrate version in different project.
Args:
project (str): Source project name.
version (str): Version id.
"""
main_show(project, version)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,10 @@
from .selection import PushToProjectSelectionModel
from .user_values import UserPublishValuesModel
from .integrate import IntegrateModel
__all__ = (
"PushToProjectSelectionModel",
"UserPublishValuesModel",
"IntegrateModel",
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
class PushToProjectSelectionModel(object):
"""Model handling selection changes.
Triggering events:
- "selection.project.changed"
- "selection.folder.changed"
- "selection.task.changed"
"""
event_source = "push-to-project.selection.model"
def __init__(self, controller):
self._controller = controller
self._project_name = None
self._folder_id = None
self._task_name = None
self._task_id = None
def get_selected_project_name(self):
return self._project_name
def set_selected_project(self, project_name):
if project_name == self._project_name:
return
self._project_name = project_name
self._controller.emit_event(
"selection.project.changed",
{"project_name": project_name},
self.event_source
)
def get_selected_folder_id(self):
return self._folder_id
def set_selected_folder(self, folder_id):
if folder_id == self._folder_id:
return
self._folder_id = folder_id
self._controller.emit_event(
"selection.folder.changed",
{
"project_name": self._project_name,
"folder_id": folder_id,
},
self.event_source
)
def get_selected_task_name(self):
return self._task_name
def get_selected_task_id(self):
return self._task_id
def set_selected_task(self, task_id, task_name):
if task_id == self._task_id:
return
self._task_name = task_name
self._task_id = task_id
self._controller.emit_event(
"selection.task.changed",
{
"project_name": self._project_name,
"folder_id": self._folder_id,
"task_name": task_name,
"task_id": task_id,
},
self.event_source
)

View file

@ -0,0 +1,110 @@
import re
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
class UserPublishValuesModel:
"""Helper object to validate values required for push to different project.
Args:
controller (PushToContextController): Event system to catch
and emit events.
"""
folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$")
variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS))
def __init__(self, controller):
self._controller = controller
self._new_folder_name = None
self._variant = None
self._comment = None
self._is_variant_valid = False
self._is_new_folder_name_valid = False
self.set_new_folder_name("")
self.set_variant("")
self.set_comment("")
@property
def new_folder_name(self):
return self._new_folder_name
@property
def variant(self):
return self._variant
@property
def comment(self):
return self._comment
@property
def is_variant_valid(self):
return self._is_variant_valid
@property
def is_new_folder_name_valid(self):
return self._is_new_folder_name_valid
@property
def is_valid(self):
return self.is_variant_valid and self.is_new_folder_name_valid
def get_data(self):
return {
"new_folder_name": self._new_folder_name,
"variant": self._variant,
"comment": self._comment,
"is_variant_valid": self._is_variant_valid,
"is_new_folder_name_valid": self._is_new_folder_name_valid,
"is_valid": self.is_valid
}
def set_variant(self, variant):
if variant == self._variant:
return
self._variant = variant
is_valid = False
if variant:
is_valid = self.variant_regex.match(variant) is not None
self._is_variant_valid = is_valid
self._controller.emit_event(
"variant.changed",
{
"variant": variant,
"is_valid": self._is_variant_valid,
},
"user_values"
)
def set_new_folder_name(self, folder_name):
if self._new_folder_name == folder_name:
return
self._new_folder_name = folder_name
is_valid = True
if folder_name:
is_valid = (
self.folder_name_regex.match(folder_name) is not None
)
self._is_new_folder_name_valid = is_valid
self._controller.emit_event(
"new_folder_name.changed",
{
"new_folder_name": self._new_folder_name,
"is_valid": self._is_new_folder_name_valid,
},
"user_values"
)
def set_comment(self, comment):
if comment == self._comment:
return
self._comment = comment
self._controller.emit_event(
"comment.changed",
{"comment": comment},
"user_values"
)

View file

@ -0,0 +1,6 @@
from .window import PushToContextSelectWindow
__all__ = (
"PushToContextSelectWindow",
)

View file

@ -0,0 +1,432 @@
from qtpy import QtWidgets, QtGui, QtCore
from openpype.style import load_stylesheet, get_app_icon_path
from openpype.tools.utils import (
PlaceholderLineEdit,
SeparatorWidget,
set_style_property,
)
from openpype.tools.ayon_utils.widgets import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
)
from openpype.tools.ayon_push_to_project.control import (
PushToContextController,
)
class PushToContextSelectWindow(QtWidgets.QWidget):
def __init__(self, controller=None):
super(PushToContextSelectWindow, self).__init__()
if controller is None:
controller = PushToContextController()
self._controller = controller
self.setWindowTitle("Push to project (select context)")
self.setWindowIcon(QtGui.QIcon(get_app_icon_path()))
main_context_widget = QtWidgets.QWidget(self)
header_widget = QtWidgets.QWidget(main_context_widget)
header_label = QtWidgets.QLabel(
controller.get_source_label(),
header_widget
)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(header_label)
main_splitter = QtWidgets.QSplitter(
QtCore.Qt.Horizontal, main_context_widget
)
context_widget = QtWidgets.QWidget(main_splitter)
projects_combobox = ProjectsCombobox(controller, context_widget)
projects_combobox.set_select_item_visible(True)
projects_combobox.set_standard_filter_enabled(True)
context_splitter = QtWidgets.QSplitter(
QtCore.Qt.Vertical, context_widget
)
folders_widget = FoldersWidget(controller, context_splitter)
folders_widget.set_deselectable(True)
tasks_widget = TasksWidget(controller, context_splitter)
context_splitter.addWidget(folders_widget)
context_splitter.addWidget(tasks_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(projects_combobox, 0)
context_layout.addWidget(context_splitter, 1)
# --- Inputs widget ---
inputs_widget = QtWidgets.QWidget(main_splitter)
folder_name_input = PlaceholderLineEdit(inputs_widget)
folder_name_input.setPlaceholderText("< Name of new folder >")
folder_name_input.setObjectName("ValidatedLineEdit")
variant_input = PlaceholderLineEdit(inputs_widget)
variant_input.setPlaceholderText("< Variant >")
variant_input.setObjectName("ValidatedLineEdit")
comment_input = PlaceholderLineEdit(inputs_widget)
comment_input.setPlaceholderText("< Publish comment >")
inputs_layout = QtWidgets.QFormLayout(inputs_widget)
inputs_layout.setContentsMargins(0, 0, 0, 0)
inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow("Comment", comment_input)
main_splitter.addWidget(context_widget)
main_splitter.addWidget(inputs_widget)
# --- Buttons widget ---
btns_widget = QtWidgets.QWidget(self)
cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
publish_btn = QtWidgets.QPushButton("Publish", btns_widget)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(cancel_btn, 0)
btns_layout.addWidget(publish_btn, 0)
sep_1 = SeparatorWidget(parent=main_context_widget)
sep_2 = SeparatorWidget(parent=main_context_widget)
main_context_layout = QtWidgets.QVBoxLayout(main_context_widget)
main_context_layout.addWidget(header_widget, 0)
main_context_layout.addWidget(sep_1, 0)
main_context_layout.addWidget(main_splitter, 1)
main_context_layout.addWidget(sep_2, 0)
main_context_layout.addWidget(btns_widget, 0)
# NOTE This was added in hurry
# - should be reorganized and changed styles
overlay_widget = QtWidgets.QFrame(self)
overlay_widget.setObjectName("OverlayFrame")
overlay_label = QtWidgets.QLabel(overlay_widget)
overlay_label.setAlignment(QtCore.Qt.AlignCenter)
overlay_btns_widget = QtWidgets.QWidget(overlay_widget)
overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# Add try again button (requires changes in controller)
overlay_try_btn = QtWidgets.QPushButton(
"Try again", overlay_btns_widget
)
overlay_close_btn = QtWidgets.QPushButton(
"Close", overlay_btns_widget
)
overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget)
overlay_btns_layout.addStretch(1)
overlay_btns_layout.addWidget(overlay_try_btn, 0)
overlay_btns_layout.addWidget(overlay_close_btn, 0)
overlay_btns_layout.addStretch(1)
overlay_layout = QtWidgets.QVBoxLayout(overlay_widget)
overlay_layout.addWidget(overlay_label, 0)
overlay_layout.addWidget(overlay_btns_widget, 0)
overlay_layout.setAlignment(QtCore.Qt.AlignCenter)
main_layout = QtWidgets.QStackedLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(main_context_widget)
main_layout.addWidget(overlay_widget)
main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
main_layout.setCurrentWidget(main_context_widget)
show_timer = QtCore.QTimer()
show_timer.setInterval(0)
main_thread_timer = QtCore.QTimer()
main_thread_timer.setInterval(10)
user_input_changed_timer = QtCore.QTimer()
user_input_changed_timer.setInterval(200)
user_input_changed_timer.setSingleShot(True)
main_thread_timer.timeout.connect(self._on_main_thread_timer)
show_timer.timeout.connect(self._on_show_timer)
user_input_changed_timer.timeout.connect(self._on_user_input_timer)
folder_name_input.textChanged.connect(self._on_new_asset_change)
variant_input.textChanged.connect(self._on_variant_change)
comment_input.textChanged.connect(self._on_comment_change)
publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click)
overlay_close_btn.clicked.connect(self._on_close_click)
overlay_try_btn.clicked.connect(self._on_try_again_click)
controller.register_event_callback(
"new_folder_name.changed",
self._on_controller_new_asset_change
)
controller.register_event_callback(
"variant.changed", self._on_controller_variant_change
)
controller.register_event_callback(
"comment.changed", self._on_controller_comment_change
)
controller.register_event_callback(
"submission.enabled.changed", self._on_submission_change
)
controller.register_event_callback(
"source.changed", self._on_controller_source_change
)
controller.register_event_callback(
"submit.started", self._on_controller_submit_start
)
controller.register_event_callback(
"submit.finished", self._on_controller_submit_end
)
controller.register_event_callback(
"push.message.added", self._on_push_message
)
self._main_layout = main_layout
self._main_context_widget = main_context_widget
self._header_label = header_label
self._main_splitter = main_splitter
self._projects_combobox = projects_combobox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._variant_input = variant_input
self._folder_name_input = folder_name_input
self._comment_input = comment_input
self._publish_btn = publish_btn
self._overlay_widget = overlay_widget
self._overlay_close_btn = overlay_close_btn
self._overlay_try_btn = overlay_try_btn
self._overlay_label = overlay_label
self._user_input_changed_timer = user_input_changed_timer
# Store current value on input text change
# The value is unset when is passed to controller
# The goal is to have controll over changes happened during user change
# in UI and controller auto-changes
self._variant_input_text = None
self._new_folder_name_input_text = None
self._comment_input_text = None
self._first_show = True
self._show_timer = show_timer
self._show_counter = 0
self._main_thread_timer = main_thread_timer
self._main_thread_timer_can_stop = True
self._last_submit_message = None
self._process_item_id = None
self._variant_is_valid = None
self._folder_is_valid = None
publish_btn.setEnabled(False)
overlay_close_btn.setVisible(False)
overlay_try_btn.setVisible(False)
# Support of public api function of controller
def set_source(self, project_name, version_id):
"""Set source project and version.
Call the method on controller.
Args:
project_name (Union[str, None]): Name of project.
version_id (Union[str, None]): Version id.
"""
self._controller.set_source(project_name, version_id)
def showEvent(self, event):
super(PushToContextSelectWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
def refresh(self):
user_values = self._controller.get_user_values()
new_folder_name = user_values["new_folder_name"]
variant = user_values["variant"]
self._folder_name_input.setText(new_folder_name or "")
self._variant_input.setText(variant or "")
self._invalidate_variant(user_values["is_variant_valid"])
self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"]
)
self._projects_combobox.refresh()
def _on_first_show(self):
width = 740
height = 640
inputs_width = 360
self.setStyleSheet(load_stylesheet())
self.resize(width, height)
self._main_splitter.setSizes([width - inputs_width, inputs_width])
self._show_timer.start()
def _on_show_timer(self):
if self._show_counter < 3:
self._show_counter += 1
return
self._show_timer.stop()
self._show_counter = 0
self.refresh()
def _on_new_asset_change(self, text):
self._new_folder_name_input_text = text
self._user_input_changed_timer.start()
def _on_variant_change(self, text):
self._variant_input_text = text
self._user_input_changed_timer.start()
def _on_comment_change(self, text):
self._comment_input_text = text
self._user_input_changed_timer.start()
def _on_user_input_timer(self):
folder_name = self._new_folder_name_input_text
if folder_name is not None:
self._new_folder_name_input_text = None
self._controller.set_user_value_folder_name(folder_name)
variant = self._variant_input_text
if variant is not None:
self._variant_input_text = None
self._controller.set_user_value_variant(variant)
comment = self._comment_input_text
if comment is not None:
self._comment_input_text = None
self._controller.set_user_value_comment(comment)
def _on_controller_new_asset_change(self, event):
folder_name = event["new_folder_name"]
if (
self._new_folder_name_input_text is None
and folder_name != self._folder_name_input.text()
):
self._folder_name_input.setText(folder_name)
self._invalidate_new_folder_name(folder_name, event["is_valid"])
def _on_controller_variant_change(self, event):
is_valid = event["is_valid"]
variant = event["variant"]
if (
self._variant_input_text is None
and variant != self._variant_input.text()
):
self._variant_input.setText(variant)
self._invalidate_variant(is_valid)
def _on_controller_comment_change(self, event):
comment = event["comment"]
if (
self._comment_input_text is None
and comment != self._comment_input.text()
):
self._comment_input.setText(comment)
def _on_controller_source_change(self):
self._header_label.setText(self._controller.get_source_label())
def _invalidate_new_folder_name(self, folder_name, is_valid):
self._tasks_widget.setVisible(not folder_name)
if self._folder_is_valid is is_valid:
return
self._folder_is_valid = is_valid
state = ""
if folder_name:
if is_valid is True:
state = "valid"
elif is_valid is False:
state = "invalid"
set_style_property(
self._folder_name_input, "state", state
)
def _invalidate_variant(self, is_valid):
if self._variant_is_valid is is_valid:
return
self._variant_is_valid = is_valid
state = "valid" if is_valid else "invalid"
set_style_property(self._variant_input, "state", state)
def _on_submission_change(self, event):
self._publish_btn.setEnabled(event["enabled"])
def _on_close_click(self):
self.close()
def _on_select_click(self):
self._process_item_id = self._controller.submit(wait=False)
def _on_try_again_click(self):
self._process_item_id = None
self._last_submit_message = None
self._overlay_close_btn.setVisible(False)
self._overlay_try_btn.setVisible(False)
self._main_layout.setCurrentWidget(self._main_context_widget)
def _on_main_thread_timer(self):
if self._last_submit_message:
self._overlay_label.setText(self._last_submit_message)
self._last_submit_message = None
process_status = self._controller.get_process_item_status(
self._process_item_id
)
push_failed = process_status["failed"]
fail_traceback = process_status["full_traceback"]
if self._main_thread_timer_can_stop:
self._main_thread_timer.stop()
self._overlay_close_btn.setVisible(True)
if push_failed and not fail_traceback:
self._overlay_try_btn.setVisible(True)
if push_failed:
message = "Push Failed:\n{}".format(process_status["fail_reason"])
if fail_traceback:
message += "\n{}".format(fail_traceback)
self._overlay_label.setText(message)
set_style_property(self._overlay_close_btn, "state", "error")
if self._main_thread_timer_can_stop:
# Join thread in controller
self._controller.wait_for_process_thread()
# Reset process item to None
self._process_item_id = None
def _on_controller_submit_start(self):
self._main_thread_timer_can_stop = False
self._main_thread_timer.start()
self._main_layout.setCurrentWidget(self._overlay_widget)
self._overlay_label.setText("Submittion started")
def _on_controller_submit_end(self):
self._main_thread_timer_can_stop = True
def _on_push_message(self, event):
self._last_submit_message = event["message"]

View file

@ -1051,6 +1051,11 @@ class ProjectPushItemProcess:
repre_format_data["ext"] = ext[1:]
break
# Re-use 'output' from source representation
repre_output_name = repre_doc["context"].get("output")
if repre_output_name is not None:
repre_format_data["output"] = repre_output_name
template_obj = anatomy.templates_obj[template_name]["folder"]
folder_path = template_obj.format_strict(formatting_data)
repre_context = folder_path.used_values

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.17.3"
__version__ = "3.17.4-nightly.1"

View file

@ -103,7 +103,7 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=ValidatePluginModel,
title="Extract FBX"
)
ExtractABC: ValidatePluginModel = Field(
ExtractModelABC: ValidatePluginModel = Field(
default_factory=ValidatePluginModel,
title="Extract ABC"
)
@ -197,10 +197,10 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = {
"optional": True,
"active": False
},
"ExtractABC": {
"ExtractModelABC": {
"enabled": True,
"optional": True,
"active": False
"active": True
},
"ExtractBlendAnimation": {
"enabled": True,

View file

@ -8,7 +8,6 @@ aiohttp_json_rpc = "*" # TVPaint server
aiohttp-middlewares = "^2.0.0"
wsrpc_aiohttp = "^3.1.1" # websocket server
clique = "1.6.*"
shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"}
gazu = "^0.9.3"
google-api-python-client = "^1.12.8" # sync server google support (should be separate?)
jsonschema = "^2.6.0"