Merge branch 'develop' into release/3.13.x

This commit is contained in:
Milan Kolar 2022-07-27 10:38:35 +02:00
commit ebb38aa4fc
30 changed files with 507 additions and 372 deletions

View file

@ -1,8 +1,8 @@
# Changelog
## [3.12.2-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.12.2](https://github.com/pypeclub/OpenPype/tree/3.12.2) (2022-07-27)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.1...HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.1...3.12.2)
### 📖 Documentation
@ -11,21 +11,21 @@
**🚀 Enhancements**
- General: Global thumbnail extractor is ready for more cases [\#3561](https://github.com/pypeclub/OpenPype/pull/3561)
- Maya: add additional validators to Settings [\#3540](https://github.com/pypeclub/OpenPype/pull/3540)
- General: Interactive console in cli [\#3526](https://github.com/pypeclub/OpenPype/pull/3526)
- Ftrack: Automatic daily review session creation can define trigger hour [\#3516](https://github.com/pypeclub/OpenPype/pull/3516)
- Ftrack: add source into Note [\#3509](https://github.com/pypeclub/OpenPype/pull/3509)
- Ftrack: Trigger custom ftrack topic of project structure creation [\#3506](https://github.com/pypeclub/OpenPype/pull/3506)
- Settings UI: Add extract to file action on project view [\#3505](https://github.com/pypeclub/OpenPype/pull/3505)
- Add pack and unpack convenience scripts [\#3502](https://github.com/pypeclub/OpenPype/pull/3502)
- General: Event system [\#3499](https://github.com/pypeclub/OpenPype/pull/3499)
- NewPublisher: Keep plugins with mismatch target in report [\#3498](https://github.com/pypeclub/OpenPype/pull/3498)
- Nuke: load clip with options from settings [\#3497](https://github.com/pypeclub/OpenPype/pull/3497)
- TrayPublisher: implemented render\_mov\_batch [\#3486](https://github.com/pypeclub/OpenPype/pull/3486)
- Migrate basic families to the new Tray Publisher [\#3469](https://github.com/pypeclub/OpenPype/pull/3469)
**🐛 Bug fixes**
- Maya: fix Review image plane attribute [\#3569](https://github.com/pypeclub/OpenPype/pull/3569)
- Maya: Fix animated attributes \(ie. overscan\) on loaded cameras breaking review publishing. [\#3562](https://github.com/pypeclub/OpenPype/pull/3562)
- NewPublisher: Python 2 compatible html escape [\#3559](https://github.com/pypeclub/OpenPype/pull/3559)
- Remove invalid submodules from `/vendor` [\#3557](https://github.com/pypeclub/OpenPype/pull/3557)
- General: Remove hosts filter on integrator plugins [\#3556](https://github.com/pypeclub/OpenPype/pull/3556)
- Settings: Clean default values of environments [\#3550](https://github.com/pypeclub/OpenPype/pull/3550)
@ -44,13 +44,19 @@
**🔀 Refactored code**
- General: Use query functions in integrator [\#3563](https://github.com/pypeclub/OpenPype/pull/3563)
- General: Mongo core connection moved to client [\#3531](https://github.com/pypeclub/OpenPype/pull/3531)
- Refactor Integrate Asset [\#3530](https://github.com/pypeclub/OpenPype/pull/3530)
- General: Client docstrings cleanup [\#3529](https://github.com/pypeclub/OpenPype/pull/3529)
- General: Move load related functions into pipeline [\#3527](https://github.com/pypeclub/OpenPype/pull/3527)
- General: Get current context document functions [\#3522](https://github.com/pypeclub/OpenPype/pull/3522)
- Kitsu: Use query function from client [\#3496](https://github.com/pypeclub/OpenPype/pull/3496)
- TimersManager: Use query functions [\#3495](https://github.com/pypeclub/OpenPype/pull/3495)
- Deadline: Use query functions [\#3466](https://github.com/pypeclub/OpenPype/pull/3466)
**Merged pull requests:**
- Maya: fix active pane loss [\#3566](https://github.com/pypeclub/OpenPype/pull/3566)
## [3.12.1](https://github.com/pypeclub/OpenPype/tree/3.12.1) (2022-07-13)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.12.1-nightly.6...3.12.1)
@ -59,10 +65,6 @@
- Docs: Added minimal permissions for MongoDB [\#3441](https://github.com/pypeclub/OpenPype/pull/3441)
**🆕 New features**
- Maya: Add VDB to Arnold loader [\#3433](https://github.com/pypeclub/OpenPype/pull/3433)
**🚀 Enhancements**
- TrayPublisher: Added more options for grouping of instances [\#3494](https://github.com/pypeclub/OpenPype/pull/3494)
@ -72,8 +74,6 @@
- General: Better arguments order in creator init [\#3475](https://github.com/pypeclub/OpenPype/pull/3475)
- Ftrack: Trigger custom ftrack events on project creation and preparation [\#3465](https://github.com/pypeclub/OpenPype/pull/3465)
- Windows installer: Clean old files and add version subfolder [\#3445](https://github.com/pypeclub/OpenPype/pull/3445)
- Blender: Bugfix - Set fps properly on open [\#3426](https://github.com/pypeclub/OpenPype/pull/3426)
- Hiero: Add custom scripts menu [\#3425](https://github.com/pypeclub/OpenPype/pull/3425)
**🐛 Bug fixes**
@ -92,7 +92,6 @@
- Nuke: prerender reviewable fails [\#3450](https://github.com/pypeclub/OpenPype/pull/3450)
- Maya: fix hashing in Python 3 for tile rendering [\#3447](https://github.com/pypeclub/OpenPype/pull/3447)
- LogViewer: Escape html characters in log message [\#3443](https://github.com/pypeclub/OpenPype/pull/3443)
- Nuke: Slate frame is integrated [\#3427](https://github.com/pypeclub/OpenPype/pull/3427)
**🔀 Refactored code**
@ -111,20 +110,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.12.0-nightly.3...3.12.0)
**🚀 Enhancements**
- Webserver: Added CORS middleware [\#3422](https://github.com/pypeclub/OpenPype/pull/3422)
**🐛 Bug fixes**
- NewPublisher: Fix subset name change on change of creator plugin [\#3420](https://github.com/pypeclub/OpenPype/pull/3420)
- Bug: fix invalid avalon import [\#3418](https://github.com/pypeclub/OpenPype/pull/3418)
**🔀 Refactored code**
- Unreal: Use client query functions [\#3421](https://github.com/pypeclub/OpenPype/pull/3421)
- General: Move editorial lib to pipeline [\#3419](https://github.com/pypeclub/OpenPype/pull/3419)
## [3.11.1](https://github.com/pypeclub/OpenPype/tree/3.11.1) (2022-06-20)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.11.1-nightly.1...3.11.1)

View file

@ -1,3 +1,7 @@
from .mongo import (
OpenPypeMongoConnection,
)
from .entities import (
get_projects,
get_project,
@ -42,6 +46,8 @@ from .entities import (
)
__all__ = (
"OpenPypeMongoConnection",
"get_projects",
"get_project",
"get_whole_project",

View file

@ -12,7 +12,7 @@ import collections
import six
from bson.objectid import ObjectId
from openpype.lib.mongo import OpenPypeMongoConnection
from .mongo import OpenPypeMongoConnection
def _get_project_database():
@ -20,7 +20,21 @@ def _get_project_database():
return OpenPypeMongoConnection.get_mongo_client()[db_name]
def _get_project_connection(project_name):
def get_project_connection(project_name):
"""Direct access to mongo collection.
We're trying to avoid using direct access to mongo. This should be used
only for Create, Update and Remove operations until there are implemented
api calls for that.
Args:
project_name(str): Project name for which collection should be
returned.
Returns:
pymongo.Collection: Collection realated to passed project.
"""
if not project_name:
raise ValueError("Invalid project name {}".format(str(project_name)))
return _get_project_database()[project_name]
@ -93,7 +107,7 @@ def get_project(project_name, active=True, inactive=False, fields=None):
{"data.active": False},
]
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -108,7 +122,7 @@ def get_whole_project(project_name):
project collection.
"""
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find({})
@ -131,7 +145,7 @@ def get_asset_by_id(project_name, asset_id, fields=None):
return None
query_filter = {"type": "asset", "_id": asset_id}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -153,7 +167,7 @@ def get_asset_by_name(project_name, asset_name, fields=None):
return None
query_filter = {"type": "asset", "name": asset_name}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -223,7 +237,7 @@ def _get_assets(
return []
query_filter["data.visualParent"] = {"$in": parent_ids}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find(query_filter, _prepare_fields(fields))
@ -323,7 +337,7 @@ def get_asset_ids_with_subsets(project_name, asset_ids=None):
return []
subset_query["parent"] = {"$in": asset_ids}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
result = conn.aggregate([
{
"$match": subset_query
@ -363,7 +377,7 @@ def get_subset_by_id(project_name, subset_id, fields=None):
return None
query_filters = {"type": "subset", "_id": subset_id}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filters, _prepare_fields(fields))
@ -394,7 +408,7 @@ def get_subset_by_name(project_name, subset_name, asset_id, fields=None):
"name": subset_name,
"parent": asset_id
}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filters, _prepare_fields(fields))
@ -467,7 +481,7 @@ def get_subsets(
return []
query_filter["$or"] = or_query
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find(query_filter, _prepare_fields(fields))
@ -491,7 +505,7 @@ def get_subset_families(project_name, subset_ids=None):
return set()
subset_filter["_id"] = {"$in": list(subset_ids)}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
result = list(conn.aggregate([
{"$match": subset_filter},
{"$project": {
@ -529,7 +543,7 @@ def get_version_by_id(project_name, version_id, fields=None):
"type": {"$in": ["version", "hero_version"]},
"_id": version_id
}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -552,7 +566,7 @@ def get_version_by_name(project_name, version, subset_id, fields=None):
if not subset_id:
return None
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
query_filter = {
"type": "version",
"parent": subset_id,
@ -642,7 +656,7 @@ def _get_versions(
else:
query_filter["name"] = {"$in": versions}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find(query_filter, _prepare_fields(fields))
@ -801,7 +815,7 @@ def get_output_link_versions(project_name, version_id, fields=None):
if not version_id:
return []
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
# Does make sense to look for hero versions?
query_filter = {
"type": "version",
@ -866,7 +880,7 @@ def get_last_versions(project_name, subset_ids, fields=None):
{"$group": group_item}
]
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
aggregate_result = conn.aggregate(aggregation_pipeline)
if limit_query:
output = {}
@ -984,7 +998,7 @@ def get_representation_by_id(project_name, representation_id, fields=None):
if representation_id is not None:
query_filter["_id"] = _convert_id(representation_id)
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -1017,7 +1031,7 @@ def get_representation_by_name(
"parent": version_id
}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -1080,7 +1094,7 @@ def _get_representations(
return []
query_filter["$or"] = or_query
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find(query_filter, _prepare_fields(fields))
@ -1291,7 +1305,7 @@ def get_thumbnail_id_from_source(project_name, src_type, src_id):
query_filter = {"_id": _convert_id(src_id)}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
src_doc = conn.find_one(query_filter, {"data.thumbnail_id"})
if src_doc:
return src_doc.get("data", {}).get("thumbnail_id")
@ -1324,7 +1338,7 @@ def get_thumbnails(project_name, thumbnail_ids, fields=None):
"type": "thumbnail",
"_id": {"$in": thumbnail_ids}
}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find(query_filter, _prepare_fields(fields))
@ -1345,7 +1359,7 @@ def get_thumbnail(project_name, thumbnail_id, fields=None):
if not thumbnail_id:
return None
query_filter = {"type": "thumbnail", "_id": _convert_id(thumbnail_id)}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))
@ -1376,7 +1390,7 @@ def get_workfile_info(
"task_name": task_name,
"filename": filename
}
conn = _get_project_connection(project_name)
conn = get_project_connection(project_name)
return conn.find_one(query_filter, _prepare_fields(fields))

210
openpype/client/mongo.py Normal file
View file

@ -0,0 +1,210 @@
import os
import sys
import time
import logging
import pymongo
import certifi
if sys.version_info[0] == 2:
from urlparse import urlparse, parse_qs
else:
from urllib.parse import urlparse, parse_qs
class MongoEnvNotSet(Exception):
pass
def _decompose_url(url):
"""Decompose mongo url to basic components.
Used for creation of MongoHandler which expect mongo url components as
separated kwargs. Components are at the end not used as we're setting
connection directly this is just a dumb components for MongoHandler
validation pass.
"""
# Use first url from passed url
# - this is because it is possible to pass multiple urls for multiple
# replica sets which would crash on urlparse otherwise
# - please don't use comma in username of password
url = url.split(",")[0]
components = {
"scheme": None,
"host": None,
"port": None,
"username": None,
"password": None,
"auth_db": None
}
result = urlparse(url)
if result.scheme is None:
_url = "mongodb://{}".format(url)
result = urlparse(_url)
components["scheme"] = result.scheme
components["host"] = result.hostname
try:
components["port"] = result.port
except ValueError:
raise RuntimeError("invalid port specified")
components["username"] = result.username
components["password"] = result.password
try:
components["auth_db"] = parse_qs(result.query)['authSource'][0]
except KeyError:
# no auth db provided, mongo will use the one we are connecting to
pass
return components
def get_default_components():
mongo_url = os.environ.get("OPENPYPE_MONGO")
if mongo_url is None:
raise MongoEnvNotSet(
"URL for Mongo logging connection is not set."
)
return _decompose_url(mongo_url)
def should_add_certificate_path_to_mongo_url(mongo_url):
"""Check if should add ca certificate to mongo url.
Since 30.9.2021 cloud mongo requires newer certificates that are not
available on most of workstation. This adds path to certifi certificate
which is valid for it. To add the certificate path url must have scheme
'mongodb+srv' or has 'ssl=true' or 'tls=true' in url query.
"""
parsed = urlparse(mongo_url)
query = parse_qs(parsed.query)
lowered_query_keys = set(key.lower() for key in query.keys())
add_certificate = False
# Check if url 'ssl' or 'tls' are set to 'true'
for key in ("ssl", "tls"):
if key in query and "true" in query["ssl"]:
add_certificate = True
break
# Check if url contains 'mongodb+srv'
if not add_certificate and parsed.scheme == "mongodb+srv":
add_certificate = True
# Check if url does already contain certificate path
if add_certificate and "tlscafile" in lowered_query_keys:
add_certificate = False
return add_certificate
def validate_mongo_connection(mongo_uri):
"""Check if provided mongodb URL is valid.
Args:
mongo_uri (str): URL to validate.
Raises:
ValueError: When port in mongo uri is not valid.
pymongo.errors.InvalidURI: If passed mongo is invalid.
pymongo.errors.ServerSelectionTimeoutError: If connection timeout
passed so probably couldn't connect to mongo server.
"""
client = OpenPypeMongoConnection.create_connection(
mongo_uri, retry_attempts=1
)
client.close()
class OpenPypeMongoConnection:
"""Singleton MongoDB connection.
Keeps MongoDB connections by url.
"""
mongo_clients = {}
log = logging.getLogger("OpenPypeMongoConnection")
@staticmethod
def get_default_mongo_url():
return os.environ["OPENPYPE_MONGO"]
@classmethod
def get_mongo_client(cls, mongo_url=None):
if mongo_url is None:
mongo_url = cls.get_default_mongo_url()
connection = cls.mongo_clients.get(mongo_url)
if connection:
# Naive validation of existing connection
try:
connection.server_info()
with connection.start_session():
pass
except Exception:
connection = None
if not connection:
cls.log.debug("Creating mongo connection to {}".format(mongo_url))
connection = cls.create_connection(mongo_url)
cls.mongo_clients[mongo_url] = connection
return connection
@classmethod
def create_connection(cls, mongo_url, timeout=None, retry_attempts=None):
parsed = urlparse(mongo_url)
# Force validation of scheme
if parsed.scheme not in ["mongodb", "mongodb+srv"]:
raise pymongo.errors.InvalidURI((
"Invalid URI scheme:"
" URI must begin with 'mongodb://' or 'mongodb+srv://'"
))
if timeout is None:
timeout = int(os.environ.get("AVALON_TIMEOUT") or 1000)
kwargs = {
"serverSelectionTimeoutMS": timeout
}
if should_add_certificate_path_to_mongo_url(mongo_url):
kwargs["ssl_ca_certs"] = certifi.where()
mongo_client = pymongo.MongoClient(mongo_url, **kwargs)
if retry_attempts is None:
retry_attempts = 3
elif not retry_attempts:
retry_attempts = 1
last_exc = None
valid = False
t1 = time.time()
for attempt in range(1, retry_attempts + 1):
try:
mongo_client.server_info()
with mongo_client.start_session():
pass
valid = True
break
except Exception as exc:
last_exc = exc
if attempt < retry_attempts:
cls.log.warning(
"Attempt {} failed. Retrying... ".format(attempt)
)
time.sleep(1)
if not valid:
raise last_exc
cls.log.info("Connected to {}, delay {:.3f}s".format(
mongo_url, time.time() - t1
))
return mongo_client

View file

@ -6,7 +6,7 @@ Shader names are stored as simple text file over GridFS in mongodb.
"""
import os
from Qt import QtWidgets, QtCore, QtGui
from openpype.lib.mongo import OpenPypeMongoConnection
from openpype.client.mongo import OpenPypeMongoConnection
from openpype import resources
import gridfs

View file

@ -128,8 +128,10 @@ class ExtractPlayblast(openpype.api.Extractor):
# Update preset with current panel setting
# if override_viewport_options is turned off
if not override_viewport_options:
panel = cmds.getPanel(with_focus=True)
panel_preset = capture.parse_active_view()
preset.update(panel_preset)
cmds.setFocus(panel)
path = capture.capture(**preset)

View file

@ -100,6 +100,13 @@ class ExtractThumbnail(openpype.api.Extractor):
# camera.
if preset.pop("isolate_view", False) and instance.data.get("isolate"):
preset["isolate"] = instance.data["setMembers"]
# Show or Hide Image Plane
image_plane = instance.data.get("imagePlane", True)
if "viewport_options" in preset:
preset["viewport_options"]["imagePlane"] = image_plane
else:
preset["viewport_options"] = {"imagePlane": image_plane}
with lib.maintained_time():
# Force viewer to False in call to capture because we have our own
@ -110,14 +117,17 @@ class ExtractThumbnail(openpype.api.Extractor):
# Update preset with current panel setting
# if override_viewport_options is turned off
if not override_viewport_options:
panel = cmds.getPanel(with_focus=True)
panel_preset = capture.parse_active_view()
preset.update(panel_preset)
cmds.setFocus(panel)
path = capture.capture(**preset)
playblast = self._fix_playblast_output_path(path)
_, thumbnail = os.path.split(playblast)
self.log.info("file list {}".format(thumbnail))
if "representations" not in instance.data:

View file

@ -10,7 +10,7 @@ from openpype.pipeline import legacy_io
import openpype.hosts.maya.api.action
from openpype.hosts.maya.api.shader_definition_editor import (
DEFINITION_FILENAME)
from openpype.lib.mongo import OpenPypeMongoConnection
from openpype.client.mongo import OpenPypeMongoConnection
import gridfs

View file

@ -94,6 +94,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin):
# Farm rendering
self.log.info("flagged for farm render")
instance.data["transfer"] = False
instance.data["farm"] = True
families.append("{}.farm".format(family))
family = families_ak.lower()

View file

@ -10,9 +10,9 @@ from aiohttp.web_response import Response
from openpype.client import (
get_projects,
get_assets,
OpenPypeMongoConnection,
)
from openpype.lib import (
OpenPypeMongoConnection,
PypeLogger,
)
from openpype.lib.remote_publish import (

View file

@ -6,6 +6,7 @@ import requests
import json
import subprocess
from openpype.client import OpenPypeMongoConnection
from openpype.lib import PypeLogger
from .webpublish_routes import (
@ -121,8 +122,6 @@ def run_webserver(*args, **kwargs):
def reprocess_failed(upload_dir, webserver_url):
# log.info("check_reprocesable_records")
from openpype.lib import OpenPypeMongoConnection
mongo_client = OpenPypeMongoConnection.get_mongo_client()
database_name = os.environ["OPENPYPE_DATABASE_NAME"]
dbcon = mongo_client[database_name]["webpublishes"]

View file

@ -34,7 +34,7 @@ from openpype.settings import (
get_system_settings
)
from .import validate_mongo_connection
from openpype.client.mongo import validate_mongo_connection
_PLACEHOLDER = object()

View file

@ -24,12 +24,13 @@ import traceback
import threading
import copy
from . import Terminal
from .mongo import (
from openpype.client.mongo import (
MongoEnvNotSet,
get_default_components,
OpenPypeMongoConnection
OpenPypeMongoConnection,
)
from . import Terminal
try:
import log4mongo
from log4mongo.handlers import MongoHandler

View file

@ -1,206 +1,61 @@
import os
import sys
import time
import logging
import pymongo
import certifi
if sys.version_info[0] == 2:
from urlparse import urlparse, parse_qs
else:
from urllib.parse import urlparse, parse_qs
import warnings
import functools
from openpype.client.mongo import (
MongoEnvNotSet,
OpenPypeMongoConnection,
)
class MongoEnvNotSet(Exception):
class MongoDeprecatedWarning(DeprecationWarning):
pass
def _decompose_url(url):
"""Decompose mongo url to basic components.
def mongo_deprecated(func):
"""Mark functions as deprecated.
Used for creation of MongoHandler which expect mongo url components as
separated kwargs. Components are at the end not used as we're setting
connection directly this is just a dumb components for MongoHandler
validation pass.
It will result in a warning being emitted when the function is used.
"""
# Use first url from passed url
# - this is because it is possible to pass multiple urls for multiple
# replica sets which would crash on urlparse otherwise
# - please don't use comma in username of password
url = url.split(",")[0]
components = {
"scheme": None,
"host": None,
"port": None,
"username": None,
"password": None,
"auth_db": None
}
result = urlparse(url)
if result.scheme is None:
_url = "mongodb://{}".format(url)
result = urlparse(_url)
components["scheme"] = result.scheme
components["host"] = result.hostname
try:
components["port"] = result.port
except ValueError:
raise RuntimeError("invalid port specified")
components["username"] = result.username
components["password"] = result.password
try:
components["auth_db"] = parse_qs(result.query)['authSource'][0]
except KeyError:
# no auth db provided, mongo will use the one we are connecting to
pass
return components
def get_default_components():
mongo_url = os.environ.get("OPENPYPE_MONGO")
if mongo_url is None:
raise MongoEnvNotSet(
"URL for Mongo logging connection is not set."
@functools.wraps(func)
def new_func(*args, **kwargs):
warnings.simplefilter("always", MongoDeprecatedWarning)
warnings.warn(
(
"Call to deprecated function '{}'."
" Function was moved to 'openpype.client.mongo'."
).format(func.__name__),
category=MongoDeprecatedWarning,
stacklevel=2
)
return _decompose_url(mongo_url)
return func(*args, **kwargs)
return new_func
@mongo_deprecated
def get_default_components():
from openpype.client.mongo import get_default_components
return get_default_components()
@mongo_deprecated
def should_add_certificate_path_to_mongo_url(mongo_url):
"""Check if should add ca certificate to mongo url.
from openpype.client.mongo import should_add_certificate_path_to_mongo_url
Since 30.9.2021 cloud mongo requires newer certificates that are not
available on most of workstation. This adds path to certifi certificate
which is valid for it. To add the certificate path url must have scheme
'mongodb+srv' or has 'ssl=true' or 'tls=true' in url query.
"""
parsed = urlparse(mongo_url)
query = parse_qs(parsed.query)
lowered_query_keys = set(key.lower() for key in query.keys())
add_certificate = False
# Check if url 'ssl' or 'tls' are set to 'true'
for key in ("ssl", "tls"):
if key in query and "true" in query["ssl"]:
add_certificate = True
break
# Check if url contains 'mongodb+srv'
if not add_certificate and parsed.scheme == "mongodb+srv":
add_certificate = True
# Check if url does already contain certificate path
if add_certificate and "tlscafile" in lowered_query_keys:
add_certificate = False
return add_certificate
return should_add_certificate_path_to_mongo_url(mongo_url)
@mongo_deprecated
def validate_mongo_connection(mongo_uri):
"""Check if provided mongodb URL is valid.
from openpype.client.mongo import validate_mongo_connection
Args:
mongo_uri (str): URL to validate.
Raises:
ValueError: When port in mongo uri is not valid.
pymongo.errors.InvalidURI: If passed mongo is invalid.
pymongo.errors.ServerSelectionTimeoutError: If connection timeout
passed so probably couldn't connect to mongo server.
"""
client = OpenPypeMongoConnection.create_connection(
mongo_uri, retry_attempts=1
)
client.close()
return validate_mongo_connection(mongo_uri)
class OpenPypeMongoConnection:
"""Singleton MongoDB connection.
Keeps MongoDB connections by url.
"""
mongo_clients = {}
log = logging.getLogger("OpenPypeMongoConnection")
@staticmethod
def get_default_mongo_url():
return os.environ["OPENPYPE_MONGO"]
@classmethod
def get_mongo_client(cls, mongo_url=None):
if mongo_url is None:
mongo_url = cls.get_default_mongo_url()
connection = cls.mongo_clients.get(mongo_url)
if connection:
# Naive validation of existing connection
try:
connection.server_info()
with connection.start_session():
pass
except Exception:
connection = None
if not connection:
cls.log.debug("Creating mongo connection to {}".format(mongo_url))
connection = cls.create_connection(mongo_url)
cls.mongo_clients[mongo_url] = connection
return connection
@classmethod
def create_connection(cls, mongo_url, timeout=None, retry_attempts=None):
parsed = urlparse(mongo_url)
# Force validation of scheme
if parsed.scheme not in ["mongodb", "mongodb+srv"]:
raise pymongo.errors.InvalidURI((
"Invalid URI scheme:"
" URI must begin with 'mongodb://' or 'mongodb+srv://'"
))
if timeout is None:
timeout = int(os.environ.get("AVALON_TIMEOUT") or 1000)
kwargs = {
"serverSelectionTimeoutMS": timeout
}
if should_add_certificate_path_to_mongo_url(mongo_url):
kwargs["ssl_ca_certs"] = certifi.where()
mongo_client = pymongo.MongoClient(mongo_url, **kwargs)
if retry_attempts is None:
retry_attempts = 3
elif not retry_attempts:
retry_attempts = 1
last_exc = None
valid = False
t1 = time.time()
for attempt in range(1, retry_attempts + 1):
try:
mongo_client.server_info()
with mongo_client.start_session():
pass
valid = True
break
except Exception as exc:
last_exc = exc
if attempt < retry_attempts:
cls.log.warning(
"Attempt {} failed. Retrying... ".format(attempt)
)
time.sleep(1)
if not valid:
raise last_exc
cls.log.info("Connected to {}, delay {:.3f}s".format(
mongo_url, time.time() - t1
))
return mongo_client
__all__ = (
"MongoEnvNotSet",
"OpenPypeMongoConnection",
"get_default_components",
"should_add_certificate_path_to_mongo_url",
"validate_mongo_connection",
)

View file

@ -7,7 +7,7 @@ from bson.objectid import ObjectId
import pyblish.util
import pyblish.api
from openpype.lib.mongo import OpenPypeMongoConnection
from openpype.client.mongo import OpenPypeMongoConnection
from openpype.lib.plugin_tools import parse_json
ERROR_STATUS = "error"

View file

@ -1,11 +1,9 @@
import os
import sys
import signal
import datetime
import subprocess
import socket
import json
import platform
import getpass
import atexit
import time
@ -13,12 +11,14 @@ import uuid
import ftrack_api
import pymongo
from openpype.client.mongo import (
OpenPypeMongoConnection,
validate_mongo_connection,
)
from openpype.lib import (
get_openpype_execute_args,
OpenPypeMongoConnection,
get_openpype_version,
get_build_version,
validate_mongo_connection
)
from openpype_modules.ftrack import FTRACK_MODULE_DIR
from openpype_modules.ftrack.lib import credentials

View file

@ -24,7 +24,7 @@ except ImportError:
from ftrack_api._weakref import WeakMethod
from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info
from openpype.lib import OpenPypeMongoConnection
from openpype.client import OpenPypeMongoConnection
from openpype.api import Logger
TOPIC_STATUS_SERVER = "openpype.event.server.status"

View file

@ -6,6 +6,8 @@ import socket
import pymongo
import ftrack_api
from openpype.client import OpenPypeMongoConnection
from openpype_modules.ftrack.ftrack_server.ftrack_server import FtrackServer
from openpype_modules.ftrack.ftrack_server.lib import (
SocketSession,
@ -15,7 +17,6 @@ from openpype_modules.ftrack.ftrack_server.lib import (
)
from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info
from openpype.lib import (
OpenPypeMongoConnection,
get_openpype_version,
get_build_version
)

View file

@ -4,8 +4,8 @@ import pyblish.api
import copy
from datetime import datetime
from openpype.client import OpenPypeMongoConnection
from openpype.lib.plugin_tools import prepare_template_data
from openpype.lib import OpenPypeMongoConnection
class IntegrateSlackAPI(pyblish.api.InstancePlugin):

View file

@ -5,6 +5,8 @@ import logging
import pymongo
from uuid import uuid4
from openpype.client import OpenPypeMongoConnection
from . import schema
@ -156,8 +158,6 @@ class AvalonMongoDB:
@property
def mongo_client(self):
from openpype.lib import OpenPypeMongoConnection
return OpenPypeMongoConnection.get_mongo_client()
@property

View file

@ -1,4 +1,5 @@
import os
import tempfile
import pyblish.api
from openpype.lib import (
@ -8,8 +9,6 @@ from openpype.lib import (
run_subprocess,
path_to_subprocess_arg,
execute,
)
@ -29,7 +28,27 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
ffmpeg_args = None
def process(self, instance):
self.log.info("subset {}".format(instance.data['subset']))
subset_name = instance.data["subset"]
instance_repres = instance.data.get("representations")
if not instance_repres:
self.log.debug((
"Instance {} does not have representations. Skipping"
).format(subset_name))
return
self.log.info(
"Processing instance with subset name {}".format(subset_name)
)
# Skip if instance have 'review' key in data set to 'False'
if not self._is_review_instance(instance):
self.log.info("Skipping - no review set on instance.")
return
# Check if already has thumbnail created
if self._already_has_thumbnail(instance_repres):
self.log.info("Thumbnail representation already present.")
return
# skip crypto passes.
# TODO: This is just a quick fix and has its own side-effects - it is
@ -37,20 +56,29 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# This must be solved properly, maybe using tags on
# representation that can be determined much earlier and
# with better precision.
if 'crypto' in instance.data['subset'].lower():
if "crypto" in subset_name.lower():
self.log.info("Skipping crypto passes.")
return
# Skip if review not set.
if not instance.data.get("review", True):
self.log.info("Skipping - no review set on instance.")
return
if self._already_has_thumbnail(instance):
self.log.info("Thumbnail representation already present.")
return
filtered_repres = self._get_filtered_repres(instance)
if not filtered_repres:
self.log.info((
"Instance don't have representations"
" that can be used as source for thumbnail. Skipping"
))
return
# Create temp directory for thumbnail
# - this is to avoid "override" of source file
dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_")
self.log.debug(
"Create temp directory {} for thumbnail".format(dst_staging)
)
# Store new staging to cleanup paths
instance.context.data["cleanupFullPaths"].append(dst_staging)
thumbnail_created = False
oiio_supported = is_oiio_supported()
for repre in filtered_repres:
repre_files = repre["files"]
if not isinstance(repre_files, (list, tuple)):
@ -59,41 +87,43 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
file_index = int(float(len(repre_files)) * 0.5)
input_file = repre_files[file_index]
stagingdir = os.path.normpath(repre["stagingDir"])
full_input_path = os.path.join(stagingdir, input_file)
src_staging = os.path.normpath(repre["stagingDir"])
full_input_path = os.path.join(src_staging, input_file)
self.log.info("input {}".format(full_input_path))
filename = os.path.splitext(input_file)[0]
if not filename.endswith('.'):
filename += "."
jpeg_file = filename + "jpg"
full_output_path = os.path.join(stagingdir, jpeg_file)
jpeg_file = filename + ".jpg"
full_output_path = os.path.join(dst_staging, jpeg_file)
thumbnail_created = False
# Try to use FFMPEG if OIIO is not supported (for cases when
# oiiotool isn't available)
if not is_oiio_supported():
thumbnail_created = self.create_thumbnail_ffmpeg(full_input_path, full_output_path) # noqa
else:
if oiio_supported:
self.log.info("Trying to convert with OIIO")
# If the input can read by OIIO then use OIIO method for
# conversion otherwise use ffmpeg
self.log.info("Trying to convert with OIIO") # noqa
thumbnail_created = self.create_thumbnail_oiio(full_input_path, full_output_path) # noqa
thumbnail_created = self.create_thumbnail_oiio(
full_input_path, full_output_path
)
if not thumbnail_created:
self.log.info("Converting with FFMPEG because input can't be read by OIIO.") # noqa
thumbnail_created = self.create_thumbnail_ffmpeg(full_input_path, full_output_path) # noqa
# Skip the rest of the process if the thumbnail wasn't created
# Try to use FFMPEG if OIIO is not supported or for cases when
# oiiotool isn't available
if not thumbnail_created:
self.log.warning("Thumbanil has not been created.")
return
if oiio_supported:
self.log.info((
"Converting with FFMPEG because input"
" can't be read by OIIO."
))
thumbnail_created = self.create_thumbnail_ffmpeg(
full_input_path, full_output_path
)
# Skip representation and try next one if wasn't created
if not thumbnail_created:
continue
new_repre = {
"name": "thumbnail",
"ext": "jpg",
"files": jpeg_file,
"stagingDir": stagingdir,
"stagingDir": dst_staging,
"thumbnail": True,
"tags": ["thumbnail"]
}
@ -106,12 +136,21 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# There is no need to create more then one thumbnail
break
def _already_has_thumbnail(self, instance):
for repre in instance.data.get("representations", []):
if not thumbnail_created:
self.log.warning("Thumbanil has not been created.")
def _is_review_instance(self, instance):
# TODO: We should probably handle "not creating" of thumbnail
# other way then checking for "review" key on instance data?
if instance.data.get("review", True):
return True
return False
def _already_has_thumbnail(self, repres):
for repre in repres:
self.log.info("repre {}".format(repre))
if repre["name"] == "thumbnail":
return True
return False
def _get_filtered_repres(self, instance):
@ -136,12 +175,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
def create_thumbnail_oiio(self, src_path, dst_path):
self.log.info("outputting {}".format(dst_path))
oiio_tool_path = get_oiio_tools_path()
oiio_cmd = [oiio_tool_path, "-a",
src_path, "-o",
dst_path
]
subprocess_exr = " ".join(oiio_cmd)
self.log.info(f"running: {subprocess_exr}")
oiio_cmd = [
oiio_tool_path,
"-a", src_path,
"-o", dst_path
]
self.log.info("running: {}".format(" ".join(oiio_cmd)))
try:
run_subprocess(oiio_cmd, logger=self.log)
return True

View file

@ -10,6 +10,11 @@ from pymongo import DeleteMany, ReplaceOne, InsertOne, UpdateOne
import pyblish.api
import openpype.api
from openpype.client import (
get_representations,
get_subset_by_name,
get_version_by_name,
)
from openpype.lib.profiles_filtering import filter_profiles
from openpype.lib.file_transaction import FileTransaction
from openpype.pipeline import legacy_io
@ -156,7 +161,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"mvUsdOverride",
"simpleUnrealTexture"
]
exclude_families = ["clip", "render.farm"]
default_template_name = "publish"
# Representation context keys that should always be written to
@ -190,14 +195,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
).format(instance.data["family"]))
return
# Exclude instances that also contain families from exclude families
families = set(get_instance_families(instance))
exclude = families & set(self.exclude_families)
if exclude:
self.log.debug("Instance not integrated due to exclude "
"families found: {}".format(", ".join(exclude)))
return
file_transactions = FileTransaction(log=self.log)
try:
self.register(instance, file_transactions, filtered_repres)
@ -274,6 +271,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
return filtered_repres
def register(self, instance, file_transactions, filtered_repres):
project_name = legacy_io.active_project()
instance_stagingdir = instance.data.get("stagingDir")
if not instance_stagingdir:
self.log.info((
@ -289,19 +288,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
template_name = self.get_template_name(instance)
subset, subset_writes = self.prepare_subset(instance)
version, version_writes = self.prepare_version(instance, subset)
subset, subset_writes = self.prepare_subset(instance, project_name)
version, version_writes = self.prepare_version(
instance, subset, project_name
)
instance.data["versionEntity"] = version
# Get existing representations (if any)
existing_repres_by_name = {
repres["name"].lower(): repres for repres in legacy_io.find(
{
"parent": version["_id"],
"type": "representation"
},
# Only care about id and name of existing representations
projection={"_id": True, "name": True}
repre_doc["name"].lower(): repre_doc
for repre_doc in get_representations(
project_name,
version_ids=[version["_id"]],
fields=["_id", "name"]
)
}
@ -426,17 +425,15 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
self.log.info("Registered {} representations"
"".format(len(prepared_representations)))
def prepare_subset(self, instance):
asset = instance.data.get("assetEntity")
def prepare_subset(self, instance, project_name):
asset_doc = instance.data.get("assetEntity")
subset_name = instance.data["subset"]
self.log.debug("Subset: {}".format(subset_name))
# Get existing subset if it exists
subset = legacy_io.find_one({
"type": "subset",
"parent": asset["_id"],
"name": subset_name
})
subset_doc = get_subset_by_name(
project_name, subset_name, asset_doc["_id"]
)
# Define subset data
data = {
@ -448,68 +445,68 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
data["subsetGroup"] = subset_group
bulk_writes = []
if subset is None:
if subset_doc is None:
# Create a new subset
self.log.info("Subset '%s' not found, creating ..." % subset_name)
subset = {
subset_doc = {
"_id": ObjectId(),
"schema": "openpype:subset-3.0",
"type": "subset",
"name": subset_name,
"data": data,
"parent": asset["_id"]
"parent": asset_doc["_id"]
}
bulk_writes.append(InsertOne(subset))
bulk_writes.append(InsertOne(subset_doc))
else:
# Update existing subset data with new data and set in database.
# We also change the found subset in-place so we don't need to
# re-query the subset afterwards
subset["data"].update(data)
subset_doc["data"].update(data)
bulk_writes.append(UpdateOne(
{"type": "subset", "_id": subset["_id"]},
{"type": "subset", "_id": subset_doc["_id"]},
{"$set": {
"data": subset["data"]
"data": subset_doc["data"]
}}
))
self.log.info("Prepared subset: {}".format(subset_name))
return subset, bulk_writes
def prepare_version(self, instance, subset):
return subset_doc, bulk_writes
def prepare_version(self, instance, subset_doc, project_name):
version_number = instance.data["version"]
version = {
version_doc = {
"schema": "openpype:version-3.0",
"type": "version",
"parent": subset["_id"],
"parent": subset_doc["_id"],
"name": version_number,
"data": self.create_version_data(instance)
}
existing_version = legacy_io.find_one({
'type': 'version',
'parent': subset["_id"],
'name': version_number
}, projection={"_id": True})
existing_version = get_version_by_name(
project_name,
version_number,
subset_doc["_id"],
fields=["_id"]
)
if existing_version:
self.log.debug("Updating existing version ...")
version["_id"] = existing_version["_id"]
version_doc["_id"] = existing_version["_id"]
else:
self.log.debug("Creating new version ...")
version["_id"] = ObjectId()
version_doc["_id"] = ObjectId()
bulk_writes = [ReplaceOne(
filter={"_id": version["_id"]},
replacement=version,
filter={"_id": version_doc["_id"]},
replacement=version_doc,
upsert=True
)]
self.log.info("Prepared version: v{0:03d}".format(version["name"]))
self.log.info("Prepared version: v{0:03d}".format(version_doc["name"]))
return version, bulk_writes
return version_doc, bulk_writes
def prepare_representation(self, repre,
template_name,

View file

@ -7,6 +7,8 @@ from abc import ABCMeta, abstractmethod
import six
import openpype.version
from openpype.client.mongo import OpenPypeMongoConnection
from openpype.client.entities import get_project_connection, get_project
from .constants import (
GLOBAL_SETTINGS_KEY,
@ -337,9 +339,6 @@ class MongoSettingsHandler(SettingsHandler):
def __init__(self):
# Get mongo connection
from openpype.lib import OpenPypeMongoConnection
from openpype.pipeline import AvalonMongoDB
settings_collection = OpenPypeMongoConnection.get_mongo_client()
self._anatomy_keys = None
@ -362,7 +361,6 @@ class MongoSettingsHandler(SettingsHandler):
self.collection_name = collection_name
self.collection = settings_collection[database_name][collection_name]
self.avalon_db = AvalonMongoDB()
self.system_settings_cache = CacheValues()
self.project_settings_cache = collections.defaultdict(CacheValues)
@ -607,16 +605,14 @@ class MongoSettingsHandler(SettingsHandler):
new_data = data_cache.data_copy()
# Prepare avalon project document
collection = self.avalon_db.database[project_name]
project_doc = collection.find_one({
"type": "project"
})
project_doc = get_project(project_name)
if not project_doc:
raise ValueError((
"Project document of project \"{}\" does not exists."
" Create project first."
).format(project_name))
collection = get_project_connection(project_name)
# Project's data
update_dict_data = {}
project_doc_data = project_doc.get("data") or {}
@ -1145,8 +1141,7 @@ class MongoSettingsHandler(SettingsHandler):
document, version
)
else:
collection = self.avalon_db.database[project_name]
project_doc = collection.find_one({"type": "project"})
project_doc = get_project(project_name)
self.project_anatomy_cache[project_name].update_data(
self.project_doc_to_anatomy_data(project_doc),
self._current_version

View file

@ -1,9 +1,9 @@
import uuid
import html
from Qt import QtCore, QtGui
import pyblish.api
from openpype.tools.utils.lib import html_escape
from .constants import (
ITEM_ID_ROLE,
ITEM_IS_GROUP_ROLE,
@ -46,7 +46,7 @@ class InstancesModel(QtGui.QStandardItemModel):
all_removed = True
for instance_item in instance_items:
item = QtGui.QStandardItem(instance_item.label)
instance_label = html.escape(instance_item.label)
instance_label = html_escape(instance_item.label)
item.setData(instance_label, ITEM_LABEL_ROLE)
item.setData(instance_item.errored, ITEM_ERRORED_ROLE)
item.setData(instance_item.id, ITEM_ID_ROLE)

View file

@ -22,13 +22,13 @@ Only one item can be selected at a time.
import re
import collections
import html
from Qt import QtWidgets, QtCore
from openpype.widgets.nice_checkbox import NiceCheckbox
from openpype.tools.utils import BaseClickableFrame
from openpype.tools.utils.lib import html_escape
from .widgets import (
AbstractInstanceView,
ContextWarningLabel,
@ -308,7 +308,7 @@ class InstanceCardWidget(CardWidget):
self._last_variant = variant
self._last_subset_name = subset_name
# Make `variant` bold
label = html.escape(self.instance.label)
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:

View file

@ -23,12 +23,12 @@ selection can be enabled disabled using checkbox or keyboard key presses:
```
"""
import collections
import html
from Qt import QtWidgets, QtCore, QtGui
from openpype.style import get_objected_colors
from openpype.widgets.nice_checkbox import NiceCheckbox
from openpype.tools.utils.lib import html_escape
from .widgets import AbstractInstanceView
from ..constants import (
INSTANCE_ID_ROLE,
@ -114,7 +114,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self.instance = instance
instance_label = html.escape(instance.label)
instance_label = html_escape(instance.label)
subset_name_label = QtWidgets.QLabel(instance_label, self)
subset_name_label.setObjectName("ListViewSubsetName")
@ -181,7 +181,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
# Check subset name
label = self.instance.label
if label != self._instance_label_widget.text():
self._instance_label_widget.setText(html.escape(label))
self._instance_label_widget.setText(html_escape(label))
# Check active state
self.set_active(self.instance["active"])
# Check valid states

View file

@ -37,6 +37,19 @@ def center_window(window):
window.move(geo.topLeft())
def html_escape(text):
"""Basic escape of html syntax symbols in text."""
return (
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#x27;")
)
def set_style_property(widget, property_name, property_value):
"""Set widget's property that may affect style.

View file

@ -665,7 +665,10 @@ def _applied_camera_options(options, panel):
_iteritems = getattr(options, "iteritems", options.items)
for opt, value in _iteritems():
_safe_setAttr(camera + "." + opt, value)
if cmds.getAttr(camera + "." + opt, lock=True):
continue
else:
_safe_setAttr(camera + "." + opt, value)
try:
yield
@ -673,7 +676,11 @@ def _applied_camera_options(options, panel):
if old_options:
_iteritems = getattr(old_options, "iteritems", old_options.items)
for opt, value in _iteritems():
_safe_setAttr(camera + "." + opt, value)
#
if cmds.getAttr(camera + "." + opt, lock=True):
continue
else:
_safe_setAttr(camera + "." + opt, value)
@contextlib.contextmanager

View file

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

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
version = "3.12.2-nightly.3" # OpenPype
version = "3.12.2" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team <info@openpype.io>"]
license = "MIT License"