diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 82f9a6ae9d..60ce608b21 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -20,12 +20,12 @@ jobs: python-version: 3.7 - name: Install Python requirements - run: pip install gitpython semver + run: pip install gitpython semver PyGithub - name: πŸ”Ž Determine next version type id: version_type run: | - TYPE=$(python ./tools/ci_tools.py --bump) + TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.GITHUB_TOKEN }}) echo ::set-output name=type::$TYPE @@ -43,11 +43,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - breakingLabel: '**πŸ’₯ Breaking**' - enhancementLabel: '**πŸš€ Enhancements**' - bugsLabel: '**πŸ› Bug fixes**' - deprecatedLabel: '**⚠️ Deprecations**' - addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"### πŸ†• New features","labels":["feature"]},}' + addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" @@ -80,6 +76,7 @@ jobs: git add . git commit -m "[Automated] Bump version" tag_name="CI/${{ steps.version.outputs.next_tag }}" + echo $tag_name git tag -a $tag_name -m "nightly build" - name: Push to protected main branch diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37e1cb4b15..3f85525c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: with: python-version: 3.7 - name: Install Python requirements - run: pip install gitpython semver + run: pip install gitpython semver PyGithub - name: πŸ’‰ Inject new version into files id: version @@ -39,11 +39,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - breakingLabel: '**πŸ’₯ Breaking**' - enhancementLabel: '**πŸš€ Enhancements**' - bugsLabel: '**πŸ› Bug fixes**' - deprecatedLabel: '**⚠️ Deprecations**' - addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]}}' + addSections: '{"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]},"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" @@ -85,11 +81,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - breakingLabel: '**πŸ’₯ Breaking**' - enhancementLabel: '**πŸš€ Enhancements**' - bugsLabel: '**πŸ› Bug fixes**' - deprecatedLabel: '**⚠️ Deprecations**' - addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]}}' + addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' issues: false issuesWoLabels: false sinceTag: ${{ steps.version.outputs.last_release }} diff --git a/.gitmodules b/.gitmodules index 28f164726d..e1b0917e9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,4 +9,4 @@ url = https://github.com/arrow-py/arrow.git [submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api - url = https://bitbucket.org/ftrack/ftrack-python-api.git + url = https://bitbucket.org/ftrack/ftrack-python-api.git \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e1737458b2..32943834d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,92 +1,139 @@ # Changelog -## [3.4.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.5.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...HEAD) + +**πŸ†• New features** + +- Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) +- SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) +- Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) + +**πŸš€ Enhancements** + +- Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) +- Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) +- General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) +- Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) +- Tray UI: Show menu where first click happened [\#2079](https://github.com/pypeclub/OpenPype/pull/2079) +- Global: add global validators to settings [\#2078](https://github.com/pypeclub/OpenPype/pull/2078) +- Use CRF for burnin when available [\#2070](https://github.com/pypeclub/OpenPype/pull/2070) +- Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) +- Nuke: Adding `still` image family workflow [\#2064](https://github.com/pypeclub/OpenPype/pull/2064) +- Maya: validate authorized loaded plugins [\#2062](https://github.com/pypeclub/OpenPype/pull/2062) +- Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) +- SyncServer: Dropbox Provider [\#1979](https://github.com/pypeclub/OpenPype/pull/1979) + +**πŸ› Bug fixes** + +- TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) +- Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) +- Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) +- Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) +- General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) +- TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) +- Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) +- General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) +- Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) +- Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) **Merged pull requests:** +- Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) +- Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) +- Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) + +## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.1-nightly.1...3.4.1) + +**πŸ†• New features** + +- Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) + +**πŸš€ Enhancements** + +- General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) +- Nuke: proxy mode validator [\#2052](https://github.com/pypeclub/OpenPype/pull/2052) +- Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) +- Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) +- Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) +- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) +- Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) +- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) +- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) +- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) +- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) +- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) +- Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) + +**πŸ› Bug fixes** + +- Timers manger: Typo fix [\#2058](https://github.com/pypeclub/OpenPype/pull/2058) +- Hiero: Editorial fixes [\#2057](https://github.com/pypeclub/OpenPype/pull/2057) +- Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) +- FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) +- Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) +- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) + +**Merged pull requests:** + +- Bump prismjs from 1.24.0 to 1.25.0 in /website [\#2050](https://github.com/pypeclub/OpenPype/pull/2050) + +## [3.4.0](https://github.com/pypeclub/OpenPype/tree/3.4.0) (2021-09-17) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0) + +### πŸ“– Documentation + +- Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) + +**πŸ†• New features** + +- Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) + +**πŸš€ Enhancements** + +- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) +- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) +- Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) +- General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) +- Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) +- Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) +- Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) +- Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) +- Configurable items for providers without Settings [\#1987](https://github.com/pypeclub/OpenPype/pull/1987) +- Global: Example addons [\#1986](https://github.com/pypeclub/OpenPype/pull/1986) +- Standalone Publisher: Extract harmony zip handle workfile template [\#1982](https://github.com/pypeclub/OpenPype/pull/1982) +- Settings UI: Number sliders [\#1978](https://github.com/pypeclub/OpenPype/pull/1978) + +**πŸ› Bug fixes** + +- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) +- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) +- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) +- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) +- General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) +- Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) +- Nuke thumbnails generated from middle of the sequence [\#1992](https://github.com/pypeclub/OpenPype/pull/1992) +- Nuke: last version from path gets correct version [\#1990](https://github.com/pypeclub/OpenPype/pull/1990) +- nuke, resolve, hiero: precollector order lest then 0.5 [\#1984](https://github.com/pypeclub/OpenPype/pull/1984) +- Last workfile with multiple work templates [\#1981](https://github.com/pypeclub/OpenPype/pull/1981) +- Collectors order [\#1977](https://github.com/pypeclub/OpenPype/pull/1977) +- Stop timer was within validator order range. [\#1975](https://github.com/pypeclub/OpenPype/pull/1975) +- Ftrack: arrow submodule has https url source [\#1974](https://github.com/pypeclub/OpenPype/pull/1974) - Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) -- Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) -- Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964) -- Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963) -- Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962) -- Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960) -- Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958) -- Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949) -- Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948) -- Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947) -- Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942) -- Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933) -- \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915) -- Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910) -- Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888) -- Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876) -- Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872) -- Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821) +- Deadline: Houdini plugins in different hierarchy [\#1970](https://github.com/pypeclub/OpenPype/pull/1970) ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.1-nightly.1...3.3.1) -**Merged pull requests:** - -- TVPaint: Fixed rendered frame indexes [\#1946](https://github.com/pypeclub/OpenPype/pull/1946) -- Maya: Menu actions fix [\#1945](https://github.com/pypeclub/OpenPype/pull/1945) -- standalone: editorial shared object problem [\#1941](https://github.com/pypeclub/OpenPype/pull/1941) -- Bugfix nuke deadline app name [\#1928](https://github.com/pypeclub/OpenPype/pull/1928) - ## [3.3.0](https://github.com/pypeclub/OpenPype/tree/3.3.0) (2021-08-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.0-nightly.11...3.3.0) -**Merged pull requests:** - -- Python console interpreter [\#1940](https://github.com/pypeclub/OpenPype/pull/1940) -- Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) -- Fix - ftrack family was added incorrectly in some cases [\#1935](https://github.com/pypeclub/OpenPype/pull/1935) -- Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) -- Fix - Deadline publish on Linux started Tray instead of headless publishing [\#1930](https://github.com/pypeclub/OpenPype/pull/1930) -- Maya: Validate Model Name - repair accident deletion in settings defaults [\#1929](https://github.com/pypeclub/OpenPype/pull/1929) -- Global: Updated logos and Default settings [\#1927](https://github.com/pypeclub/OpenPype/pull/1927) -- Nuke: submit to farm failed due `ftrack` family remove [\#1926](https://github.com/pypeclub/OpenPype/pull/1926) -- Check for missing ✨ Python when using `pyenv` [\#1925](https://github.com/pypeclub/OpenPype/pull/1925) -- Maya: Scene patching 🩹on submission to Deadline [\#1923](https://github.com/pypeclub/OpenPype/pull/1923) -- Fix - validate takes repre\["files"\] as list all the time [\#1922](https://github.com/pypeclub/OpenPype/pull/1922) -- Settings: Default values for enum [\#1920](https://github.com/pypeclub/OpenPype/pull/1920) -- Settings UI: Modifiable dict view enhance [\#1919](https://github.com/pypeclub/OpenPype/pull/1919) -- standalone: validator asset parents [\#1917](https://github.com/pypeclub/OpenPype/pull/1917) -- Nuke: update video file crassing [\#1916](https://github.com/pypeclub/OpenPype/pull/1916) -- Fix - texture validators for workfiles triggers only for textures workfiles [\#1914](https://github.com/pypeclub/OpenPype/pull/1914) -- submodules: avalon-core update [\#1911](https://github.com/pypeclub/OpenPype/pull/1911) -- Settings UI: List order works as expected [\#1906](https://github.com/pypeclub/OpenPype/pull/1906) -- Add support for multiple Deadline β˜ οΈβž– servers [\#1905](https://github.com/pypeclub/OpenPype/pull/1905) -- Hiero: loaded clip was not set colorspace from version data [\#1904](https://github.com/pypeclub/OpenPype/pull/1904) -- Pyblish UI: Fix collecting stage processing [\#1903](https://github.com/pypeclub/OpenPype/pull/1903) -- Burnins: Use input's bitrate in h624 [\#1902](https://github.com/pypeclub/OpenPype/pull/1902) -- Feature AE local render [\#1901](https://github.com/pypeclub/OpenPype/pull/1901) -- Ftrack: Where I run action enhancement [\#1900](https://github.com/pypeclub/OpenPype/pull/1900) -- Ftrack: Private project server actions [\#1899](https://github.com/pypeclub/OpenPype/pull/1899) -- Support nested studio plugins paths. [\#1898](https://github.com/pypeclub/OpenPype/pull/1898) -- Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) -- Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892) -- Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891) -- global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890) -- publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889) -- Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) -- TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) -- Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) -- Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) -- Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) -- Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) -- Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) -- Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) -- Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space πŸš€ [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) -- Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) -- Maya: support for configurable `dirmap` πŸ—ΊοΈ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) -- Maya: don't add reference members as connections to the container set πŸ“¦ [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) -- Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) - ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.7...3.2.0) diff --git a/igniter/__init__.py b/igniter/__init__.py index 20bf9be106..defd45e233 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -12,6 +12,9 @@ from .version import __version__ as version def open_dialog(): """Show Igniter dialog.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) from Qt import QtWidgets, QtCore from .install_dialog import InstallDialog @@ -28,8 +31,31 @@ def open_dialog(): return d.result() +def open_update_window(openpype_version): + """Open update window.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .update_window import UpdateWindow + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication(sys.argv) + + d = UpdateWindow(version=openpype_version) + d.open() + + app.exec_() + version_path = d.get_version_path() + return version_path + + __all__ = [ "BootstrapRepos", "open_dialog", + "open_update_window", "version" ] diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b49a2f6e7f..f7f35824c8 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -9,6 +9,7 @@ import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple +import hashlib from zipfile import ZipFile, BadZipFile @@ -28,6 +29,25 @@ LOG_WARNING = 1 LOG_ERROR = 3 +def sha256sum(filename): + """Calculate sha256 for content of the file. + + Args: + filename (str): Path to file. + + Returns: + str: hex encoded sha256 + + """ + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. @@ -261,7 +281,8 @@ class BootstrapRepos: self.live_repo_dir = Path(Path(__file__).parent / ".." / "repos") @staticmethod - def get_version_path_from_list(version: str, version_list: list) -> Path: + def get_version_path_from_list( + version: str, version_list: list) -> Union[Path, None]: """Get path for specific version in list of OpenPype versions. Args: @@ -275,6 +296,7 @@ class BootstrapRepos: for v in version_list: if str(v) == version: return v.path + return None @staticmethod def get_local_live_version() -> str: @@ -487,6 +509,7 @@ class BootstrapRepos: openpype_root = openpype_path.resolve() # generate list of filtered paths dir_filter = [openpype_root / f for f in self.openpype_filter] + checksums = [] file: Path for file in openpype_list: @@ -508,12 +531,119 @@ class BootstrapRepos: processed_path = file self._print(f"- processing {processed_path}") - zip_file.write(file, file.resolve().relative_to(openpype_root)) + checksums.append( + ( + sha256sum(file.as_posix()), + file.resolve().relative_to(openpype_root) + ) + ) + zip_file.write( + file, file.resolve().relative_to(openpype_root)) + checksums_str = "" + for c in checksums: + checksums_str += "{}:{}\n".format(c[0], c[1]) + zip_file.writestr("checksums", checksums_str) # test if zip is ok zip_file.testzip() self._progress_callback(100) + def validate_openpype_version(self, path: Path) -> tuple: + """Validate version directory or zip file. + + This will load `checksums` file if present, calculate checksums + of existing files in given path and compare. It will also compare + lists of files together for missing files. + + Args: + path (Path): Path to OpenPype version to validate. + + Returns: + tuple(bool, str): with version validity as first item + and string with reason as second. + + """ + if not path.exists(): + return False, "Path doesn't exist" + + if path.is_file(): + return self._validate_zip(path) + return self._validate_dir(path) + + @staticmethod + def _validate_zip(path: Path) -> tuple: + """Validate content of zip file.""" + with ZipFile(path, "r") as zip_file: + # read checksums + try: + checksums_data = str(zip_file.read("checksums")) + except IOError: + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + + # split it to the list of tuples + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + + # calculate and compare checksums in the zip file + for file in checksums: + h = hashlib.sha256() + try: + h.update(zip_file.read(file[1])) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + if h.hexdigest() != file[0]: + return False, f"Invalid checksum on {file[1]}" + + # get list of files in zip minus `checksums` file itself + # and turn in to set to compare against list of files + # from checksum file. If difference exists, something is + # wrong + files_in_zip = zip_file.namelist() + files_in_zip.remove("checksums") + files_in_zip = set(files_in_zip) + files_in_checksum = set([file[1] for file in checksums]) + diff = files_in_zip.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + + @staticmethod + def _validate_dir(path: Path) -> tuple: + checksums_file = Path(path / "checksums") + if not checksums_file.exists(): + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + checksums_data = checksums_file.read_text() + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + files_in_dir = [ + file.relative_to(path).as_posix() + for file in path.iterdir() if file.is_file() + ] + files_in_dir.remove("checksums") + files_in_dir = set(files_in_dir) + files_in_checksum = set([file[1] for file in checksums]) + + for file in checksums: + try: + current = sha256sum((path / file[1]).as_posix()) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + + if file[0] != current: + return False, f"Invalid checksum on {file[1]}" + diff = files_in_dir.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + @staticmethod def add_paths_from_archive(archive: Path) -> None: """Add first-level directory and 'repos' as paths to :mod:`sys.path`. @@ -837,6 +967,7 @@ class BootstrapRepos: # test if destination directory already exist, if so lets delete it. if destination.exists() and force: + self._print("removing existing directory") try: shutil.rmtree(destination) except OSError as e: @@ -846,6 +977,7 @@ class BootstrapRepos: raise OpenPypeVersionIOError( f"cannot remove existing {destination}") from e elif destination.exists() and not force: + self._print("destination directory already exists") raise OpenPypeVersionExists(f"{destination} already exist.") else: # create destination parent directories even if they don't exist. @@ -855,6 +987,7 @@ class BootstrapRepos: if openpype_version.path.is_dir(): # create zip inside temporary directory. self._print("Creating zip from directory ...") + self._progress_callback(0) with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{openpype_version}.zip" @@ -880,13 +1013,16 @@ class BootstrapRepos: raise OpenPypeVersionInvalid("Invalid file format") if not self.is_inside_user_data(openpype_version.path): + self._progress_callback(35) openpype_version.path = self._copy_zip( openpype_version.path, destination) # extract zip there self._print("extracting zip to destination ...") with ZipFile(openpype_version.path, "r") as zip_ref: + self._progress_callback(75) zip_ref.extractall(destination) + self._progress_callback(100) return destination diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1ec8cc6768..1fe67e3397 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -14,21 +14,13 @@ from .tools import ( validate_mongo_connection, get_openpype_path_from_db ) + +from .nice_progress_bar import NiceProgressBar from .user_settings import OpenPypeSecureRegistry +from .tools import load_stylesheet from .version import __version__ -def load_stylesheet(): - stylesheet_path = os.path.join( - os.path.dirname(__file__), - "stylesheet.css" - ) - with open(stylesheet_path, "r") as file_stream: - stylesheet = file_stream.read() - - return stylesheet - - class ButtonWithOptions(QtWidgets.QFrame): option_clicked = QtCore.Signal(str) @@ -91,25 +83,6 @@ class ButtonWithOptions(QtWidgets.QFrame): self.option_clicked.emit(self._default_value) -class NiceProgressBar(QtWidgets.QProgressBar): - def __init__(self, parent=None): - super(NiceProgressBar, self).__init__(parent) - self._real_value = 0 - - def setValue(self, value): - self._real_value = value - if value != 0 and value < 11: - value = 11 - - super(NiceProgressBar, self).setValue(value) - - def value(self): - return self._real_value - - def text(self): - return "{} %".format(self._real_value) - - class ConsoleWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(ConsoleWidget, self).__init__(parent) diff --git a/igniter/nice_progress_bar.py b/igniter/nice_progress_bar.py new file mode 100644 index 0000000000..47d695a101 --- /dev/null +++ b/igniter/nice_progress_bar.py @@ -0,0 +1,20 @@ +from Qt import QtCore, QtGui, QtWidgets # noqa + + +class NiceProgressBar(QtWidgets.QProgressBar): + def __init__(self, parent=None): + super(NiceProgressBar, self).__init__(parent) + self._real_value = 0 + + def setValue(self, value): + self._real_value = value + if value != 0 and value < 11: + value = 11 + + super(NiceProgressBar, self).setValue(value) + + def value(self): + return self._real_value + + def text(self): + return "{} %".format(self._real_value) diff --git a/igniter/tools.py b/igniter/tools.py index 529d535c25..04d7451335 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -1,18 +1,12 @@ # -*- coding: utf-8 -*- -"""Tools used in **Igniter** GUI. - -Functions ``compose_url()`` and ``decompose_url()`` are the same as in -``openpype.lib`` and they are here to avoid importing OpenPype module before its -version is decided. - -""" -import sys +"""Tools used in **Igniter** GUI.""" import os -from typing import Dict, Union +from typing import Union from urllib.parse import urlparse, parse_qs from pathlib import Path import platform +import certifi from pymongo import MongoClient from pymongo.errors import ( ServerSelectionTimeoutError, @@ -22,89 +16,32 @@ from pymongo.errors import ( ) -def decompose_url(url: str) -> Dict: - """Decompose mongodb url to its separate components. - - Args: - url (str): Mongodb url. - - Returns: - dict: Dictionary of components. +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. """ - components = { - "scheme": None, - "host": None, - "port": None, - "username": None, - "password": None, - "auth_db": None - } + 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 - result = urlparse(url) - if result.scheme is None: - _url = "mongodb://{}".format(url) - result = urlparse(_url) + # Check if url contains 'mongodb+srv' + if not add_certificate and parsed.scheme == "mongodb+srv": + add_certificate = True - 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 compose_url(scheme: str = None, - host: str = None, - username: str = None, - password: str = None, - port: int = None, - auth_db: str = None) -> str: - """Compose mongodb url from its individual components. - - Args: - scheme (str, optional): - host (str, optional): - username (str, optional): - password (str, optional): - port (str, optional): - auth_db (str, optional): - - Returns: - str: mongodb url - - """ - - url = "{scheme}://" - - if username and password: - url += "{username}:{password}@" - - url += "{host}" - if port: - url += ":{port}" - - if auth_db: - url += "?authSource={auth_db}" - - return url.format(**{ - "scheme": scheme, - "host": host, - "username": username, - "password": password, - "port": port, - "auth_db": auth_db - }) + # 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(cnx: str) -> (bool, str): @@ -121,12 +58,18 @@ def validate_mongo_connection(cnx: str) -> (bool, str): if parsed.scheme not in ["mongodb", "mongodb+srv"]: return False, "Not mongodb schema" + kwargs = { + "serverSelectionTimeoutMS": 2000 + } + # Add certificate path if should be required + if should_add_certificate_path_to_mongo_url(cnx): + kwargs["ssl_ca_certs"] = certifi.where() + try: - client = MongoClient( - cnx, - serverSelectionTimeoutMS=2000 - ) + client = MongoClient(cnx, **kwargs) client.server_info() + with client.start_session(): + pass client.close() except ServerSelectionTimeoutError as e: return False, f"Cannot connect to server {cnx} - {e}" @@ -152,10 +95,7 @@ def validate_mongo_string(mongo: str) -> (bool, str): """ if not mongo: return True, "empty string" - parsed = urlparse(mongo) - if parsed.scheme in ["mongodb", "mongodb+srv"]: - return validate_mongo_connection(mongo) - return False, "not valid mongodb schema" + return validate_mongo_connection(mongo) def validate_path_string(path: str) -> (bool, str): @@ -195,21 +135,13 @@ def get_openpype_global_settings(url: str) -> dict: Returns: dict: With settings data. Empty dictionary is returned if not found. """ - try: - components = decompose_url(url) - except RuntimeError: - return {} - mongo_kwargs = { - "host": compose_url(**components), - "serverSelectionTimeoutMS": 2000 - } - port = components.get("port") - if port is not None: - mongo_kwargs["port"] = int(port) + kwargs = {} + if should_add_certificate_path_to_mongo_url(url): + kwargs["ssl_ca_certs"] = certifi.where() try: # Create mongo connection - client = MongoClient(**mongo_kwargs) + client = MongoClient(url, **kwargs) # Access settings collection col = client["openpype"]["settings"] # Query global settings @@ -248,3 +180,15 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: if os.path.exists(path): return path return None + + +def load_stylesheet() -> str: + """Load css style sheet. + + Returns: + str: content of the stylesheet + + """ + stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" + + return stylesheet_path.read_text() diff --git a/igniter/update_thread.py b/igniter/update_thread.py new file mode 100644 index 0000000000..f4fc729faf --- /dev/null +++ b/igniter/update_thread.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Working thread for update.""" +from Qt.QtCore import QThread, Signal, QObject # noqa + +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) + + +class UpdateThread(QThread): + """Install Worker thread. + + This class takes care of finding OpenPype version on user entered path + (or loading this path from database). If nothing is entered by user, + OpenPype will create its zip files from repositories that comes with it. + + If path contains plain repositories, they are zipped and installed to + user data dir. + + """ + progress = Signal(int) + message = Signal((str, bool)) + + def __init__(self, parent=None): + self._result = None + self._openpype_version = None + QThread.__init__(self, parent) + + def set_version(self, openpype_version: OpenPypeVersion): + self._openpype_version = openpype_version + + def result(self): + """Result of finished installation.""" + return self._result + + def _set_result(self, value): + if self._result is not None: + raise AssertionError("BUG: Result was set more than once!") + self._result = value + + def run(self): + """Thread entry point. + + Using :class:`BootstrapRepos` to either install OpenPype as zip files + or copy them from location specified by user or retrieved from + database. + """ + bs = BootstrapRepos( + progress_callback=self.set_progress, message=self.message) + version_path = bs.install_version(self._openpype_version) + self._set_result(version_path) + + def set_progress(self, progress: int) -> None: + """Helper to set progress bar. + + Args: + progress (int): Progress in percents. + + """ + self.progress.emit(progress) diff --git a/igniter/update_window.py b/igniter/update_window.py new file mode 100644 index 0000000000..d7908c240b --- /dev/null +++ b/igniter/update_window.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""Progress window to show when OpenPype is updating/installing locally.""" +import os +from .update_thread import UpdateThread +from Qt import QtCore, QtGui, QtWidgets # noqa +from .bootstrap_repos import OpenPypeVersion +from .nice_progress_bar import NiceProgressBar +from .tools import load_stylesheet + + +class UpdateWindow(QtWidgets.QDialog): + """OpenPype update window.""" + + _width = 500 + _height = 100 + + def __init__(self, version: OpenPypeVersion, parent=None): + super(UpdateWindow, self).__init__(parent) + self._openpype_version = version + self._result_version_path = None + + self.setWindowTitle( + f"OpenPype is updating ..." + ) + self.setModal(True) + self.setWindowFlags( + QtCore.Qt.WindowMinimizeButtonHint + ) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") + poppins_font_path = os.path.join(current_dir, "Poppins") + icon_path = os.path.join(current_dir, "openpype_icon.png") + + # Install roboto font + QtGui.QFontDatabase.addApplicationFont(roboto_font_path) + for filename in os.listdir(poppins_font_path): + if os.path.splitext(filename)[1] == ".ttf": + QtGui.QFontDatabase.addApplicationFont(filename) + + # Load logo + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + # Set logo as icon of window + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + self._pixmap_openpype_logo = pixmap_openpype_logo + + self._update_thread = None + + self.resize(QtCore.QSize(self._width, self._height)) + self._init_ui() + + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + self._run_update() + + def _init_ui(self): + + # Main info + # -------------------------------------------------------------------- + main_label = QtWidgets.QLabel( + f"OpenPype is updating to {self._openpype_version}", self) + main_label.setWordWrap(True) + main_label.setObjectName("MainLabel") + + # Progress bar + # -------------------------------------------------------------------- + progress_bar = NiceProgressBar(self) + progress_bar.setAlignment(QtCore.Qt.AlignCenter) + progress_bar.setTextVisible(False) + + # add all to main + main = QtWidgets.QVBoxLayout(self) + main.addSpacing(15) + main.addWidget(main_label, 0) + main.addSpacing(15) + main.addWidget(progress_bar, 0) + main.addSpacing(15) + + self._progress_bar = progress_bar + + def _run_update(self): + """Start install process. + + This will once again validate entered path and mongo if ok, start + working thread that will do actual job. + """ + # Check if install thread is not already running + if self._update_thread and self._update_thread.isRunning(): + return + self._progress_bar.setRange(0, 0) + update_thread = UpdateThread(self) + update_thread.set_version(self._openpype_version) + update_thread.message.connect(self.update_console) + update_thread.progress.connect(self._update_progress) + update_thread.finished.connect(self._installation_finished) + + self._update_thread = update_thread + + update_thread.start() + + def get_version_path(self): + return self._result_version_path + + def _installation_finished(self): + status = self._update_thread.result() + self._result_version_path = status + self._progress_bar.setRange(0, 1) + self._update_progress(100) + QtWidgets.QApplication.processEvents() + self.done(0) + + def _update_progress(self, progress: int): + # not updating progress as we are not able to determine it + # correctly now. Progress bar is set to un-deterministic mode + # until we are able to get progress in better way. + """ + self._progress_bar.setRange(0, 0) + self._progress_bar.setValue(progress) + text_visible = self._progress_bar.isTextVisible() + if progress == 0: + if text_visible: + self._progress_bar.setTextVisible(False) + elif not text_visible: + self._progress_bar.setTextVisible(True) + """ + return + + def update_console(self, msg: str, error: bool = False) -> None: + """Display message in console. + + Args: + msg (str): message. + error (bool): if True, print it red. + """ + print(msg) diff --git a/openpype/api.py b/openpype/api.py index ce18097eca..e4bbb104a3 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -24,7 +24,9 @@ from .lib import ( get_latest_version, get_global_environments, get_local_site_id, - change_openpype_mongo_url + change_openpype_mongo_url, + create_project_folders, + get_project_basic_paths ) from .lib.mongo import ( @@ -72,6 +74,7 @@ __all__ = [ "get_current_project_settings", "get_anatomy_settings", "get_environments", + "get_project_basic_paths", "SystemSettings", @@ -120,5 +123,9 @@ __all__ = [ "get_global_environments", "get_local_site_id", - "change_openpype_mongo_url" + "change_openpype_mongo_url", + + "get_project_basic_paths", + "create_project_folders" + ] diff --git a/openpype/cli.py b/openpype/cli.py index c446d5e443..c69407e295 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -18,6 +18,8 @@ from .pype_commands import PypeCommands @click.option("--list-versions", is_flag=True, expose_value=False, help=("list all detected versions. Use With `--use-staging " "to list staging versions.")) +@click.option("--validate-version", expose_value=False, + help="validate given version integrity") def main(ctx): """Pype is main command serving as entry point to pipeline system. @@ -281,3 +283,18 @@ def run(script): args_string = " ".join(args[1:]) print(f"... running: {script} {args_string}") runpy.run_path(script, run_name="__main__", ) + + +@main.command() +@click.argument("folder", nargs=-1) +@click.option("-m", + "--mark", + help="Run tests marked by", + default=None) +@click.option("-p", + "--pyargs", + help="Run tests from package", + default=None) +def runtests(folder, mark, pyargs): + """Run all automatic tests after proper initialization via start.py""" + PypeCommands().run_tests(folder, mark, pyargs) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index c669d91ad5..b32fb5e44a 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -43,6 +43,8 @@ class GlobalHostDataHook(PreLaunchHook): "env": self.launch_context.env, + "last_workfile_path": self.data.get("last_workfile_path"), + "log": self.log }) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index 5573c081e1..6062c293df 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -66,12 +66,16 @@ class BlendRigLoader(plugin.AssetLoader): objects = [] nodes = list(container.children) - for obj in nodes: - obj.parent = asset_group + allowed_types = ['ARMATURE', 'MESH'] for obj in nodes: - objects.append(obj) - nodes.extend(list(obj.children)) + if obj.type in allowed_types: + obj.parent = asset_group + + for obj in nodes: + if obj.type in allowed_types: + objects.append(obj) + nodes.extend(list(obj.children)) objects.reverse() @@ -107,7 +111,8 @@ class BlendRigLoader(plugin.AssetLoader): if action is not None: local_obj.animation_data.action = action - elif local_obj.animation_data.action is not None: + elif (local_obj.animation_data and + local_obj.animation_data.action is not None): plugin.prepare_data( local_obj.animation_data.action, group_name) @@ -126,7 +131,30 @@ class BlendRigLoader(plugin.AssetLoader): objects.reverse() - bpy.data.orphans_purge(do_local_ids=False) + curves = [obj for obj in data_to.objects if obj.type == 'CURVE'] + + for curve in curves: + local_obj = plugin.prepare_data(curve, group_name) + plugin.prepare_data(local_obj.data, group_name) + + local_obj.use_fake_user = True + + for mod in local_obj.modifiers: + mod_target_name = mod.object.name + mod.object = bpy.data.objects.get( + f"{group_name}:{mod_target_name}") + + if not local_obj.get(AVALON_PROPERTY): + local_obj[AVALON_PROPERTY] = dict() + + avalon_info = local_obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + local_obj.parent = asset_group + objects.append(local_obj) + + while bpy.data.orphans_purge(do_local_ids=False): + pass bpy.ops.object.select_all(action='DESELECT') diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 6687c9fe76..e880b1bde0 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -28,6 +28,16 @@ class ExtractBlend(openpype.api.Extractor): for obj in instance: data_blocks.add(obj) + # Pack used images in the blend files. + if obj.type == 'MESH': + for material_slot in obj.material_slots: + mat = material_slot.material + if mat and mat.use_nodes: + tree = mat.node_tree + if tree.type == 'SHADER': + for node in tree.nodes: + if node.bl_idname == 'ShaderNodeTexImage': + node.image.pack() bpy.data.libraries.write(filepath, data_blocks) diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 3f04bf839b..9093aa9e5e 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -10,8 +10,10 @@ from .pipeline import ( from avalon.tools import ( creator, - loader, sceneinventory, +) +from openpype.tools import ( + loader, libraryloader ) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 0095530087..688e75f6fe 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -3,7 +3,7 @@ Basic avalon integration """ import os -from avalon.tools import workfiles +from openpype.tools import workfiles from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index 8374a9427a..827b03443c 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -91,7 +91,8 @@ class ExtractRender(pyblish.api.InstancePlugin): thumbnail_path = os.path.join(path, "thumbnail.png") ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") args = [ - "{}".format(ffmpeg_path), "-y", + ffmpeg_path, + "-y", "-i", os.path.join(path, list(collections[0])[0]), "-vf", "scale=300:-1", "-vframes", "1", diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index ab49251093..bcd78aa5bb 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -41,7 +41,8 @@ def menu_install(): apply_colorspace_project, apply_colorspace_clips ) # here is the best place to add menu - from avalon.tools import cbloader, creator, sceneinventory + from avalon.tools import creator, sceneinventory + from openpype.tools import loader from avalon.vendor.Qt import QtGui menu_name = os.environ['AVALON_LABEL'] @@ -90,7 +91,7 @@ def menu_install(): loader_action = menu.addAction("Load ...") loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) - loader_action.triggered.connect(cbloader.show) + loader_action.triggered.connect(loader.show) sceneinventory_action = menu.addAction("Manage ...") sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index ab7e2bdabf..12f6923de7 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -4,10 +4,8 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.tools import ( - workfiles, - publish as _publish -) +from avalon.tools import publish as _publish +from openpype.tools import workfiles from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api as avalon from avalon import schema diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index d2502f3c71..68f8d35106 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -10,16 +10,16 @@ log = Logger().get_logger(__name__) def tag_data(): return { - "Retiming": { - "editable": "1", - "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa - "icon": "retiming.png", - "metadata": { - "family": "retiming", - "marginIn": 1, - "marginOut": 1 - } - }, + # "Retiming": { + # "editable": "1", + # "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa + # "icon": "retiming.png", + # "metadata": { + # "family": "retiming", + # "marginIn": 1, + # "marginOut": 1 + # } + # }, "[Lenses]": { "Set lense here": { "editable": "1", @@ -31,15 +31,15 @@ def tag_data(): } } }, - "NukeScript": { - "editable": "1", - "note": "Collecting track items to Nuke scripts.", - "icon": "icons:TagNuke.png", - "metadata": { - "family": "nukescript", - "subset": "main" - } - }, + # "NukeScript": { + # "editable": "1", + # "note": "Collecting track items to Nuke scripts.", + # "icon": "icons:TagNuke.png", + # "metadata": { + # "family": "nukescript", + # "subset": "main" + # } + # }, "Comment": { "editable": "1", "note": "Comment on a shot.", @@ -78,8 +78,7 @@ def update_tag(tag, data): # set icon if any available in input data if data.get("icon"): tag.setIcon(str(data["icon"])) - # set note description of tag - tag.setNote(data["note"]) + # get metadata of tag mtd = tag.metadata() # get metadata key from data @@ -97,6 +96,9 @@ def update_tag(tag, data): "tag.{}".format(str(k)), str(v) ) + + # set note description of tag + tag.setNote(str(data["note"])) return tag @@ -106,6 +108,26 @@ def add_tags_to_workfile(): """ from .lib import get_current_project + def add_tag_to_bin(root_bin, name, data): + # for Tags to be created in root level Bin + # at first check if any of input data tag is not already created + done_tag = next((t for t in root_bin.items() + if str(name) in t.name()), None) + + if not done_tag: + # create Tag + tag = create_tag(name, data) + tag.setName(str(name)) + + log.debug("__ creating tag: {}".format(tag)) + # adding Tag to Root Bin + root_bin.addItem(tag) + else: + # update only non hierarchy tags + update_tag(done_tag, data) + done_tag.setName(str(name)) + log.debug("__ updating tag: {}".format(done_tag)) + # get project and root bin object project = get_current_project() root_bin = project.tagsBin() @@ -125,10 +147,8 @@ def add_tags_to_workfile(): for task_type in tasks.keys(): nks_pres_tags["[Tasks]"][task_type.lower()] = { "editable": "1", - "note": "", - "icon": { - "path": "icons:TagGood.png" - }, + "note": task_type, + "icon": "icons:TagGood.png", "metadata": { "family": "task", "type": task_type @@ -157,10 +177,10 @@ def add_tags_to_workfile(): # check if key is not decorated with [] so it is defined as bin bin_find = None pattern = re.compile(r"\[(.*)\]") - bin_finds = pattern.findall(_k) + _bin_finds = pattern.findall(_k) # if there is available any then pop it to string - if bin_finds: - bin_find = bin_finds.pop() + if _bin_finds: + bin_find = _bin_finds.pop() # if bin was found then create or update if bin_find: @@ -168,7 +188,6 @@ def add_tags_to_workfile(): # first check if in root lever is not already created bins bins = [b for b in root_bin.items() if b.name() in str(bin_find)] - log.debug(">>> bins: {}".format(bins)) if bins: bin = bins.pop() @@ -178,49 +197,14 @@ def add_tags_to_workfile(): bin = hiero.core.Bin(str(bin_find)) # update or create tags in the bin - for k, v in _val.items(): - tags = [t for t in bin.items() - if str(k) in t.name() - if len(str(k)) == len(t.name())] - if not tags: - # create Tag obj - tag = create_tag(k, v) - - # adding Tag to Bin - bin.addItem(tag) - else: - update_tag(tags.pop(), v) + for __k, __v in _val.items(): + add_tag_to_bin(bin, __k, __v) # finally add the Bin object to the root level Bin if root_add: # adding Tag to Root Bin root_bin.addItem(bin) else: - # for Tags to be created in root level Bin - # at first check if any of input data tag is not already created - tags = None - tags = [t for t in root_bin.items() - if str(_k) in t.name()] - - if not tags: - # create Tag - tag = create_tag(_k, _val) - - # adding Tag to Root Bin - root_bin.addItem(tag) - else: - # update Tags if they already exists - for _t in tags: - # skip bin objects - if isinstance(_t, hiero.core.Bin): - continue - - # check if Hierarchy in name and skip it - # because hierarchy could be edited - if "hierarchy" in _t.name().lower(): - continue - - # update only non hierarchy tags - update_tag(_t, _val) + add_tag_to_bin(root_bin, _k, _val) log.info("Default Tags were set...") diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index ccc05d5fd7..af4322e3d9 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -378,6 +378,17 @@ def add_otio_metadata(otio_item, media_source, **kwargs): def create_otio_timeline(): + def set_prev_item(itemindex, track_item): + # Add Gap if needed + if itemindex == 0: + # if it is first track item at track then add + # it to previouse item + return track_item + + else: + # get previouse item + return track_item.parent().items()[itemindex - 1] + # get current timeline self.timeline = hiero.ui.activeSequence() self.project_fps = self.timeline.framerate().toFloat() @@ -396,14 +407,6 @@ def create_otio_timeline(): type(track), track.name()) for itemindex, track_item in enumerate(track): - # skip offline track items - if not track_item.isMediaPresent(): - continue - - # skip if track item is disabled - if not track_item.isEnabled(): - continue - # Add Gap if needed if itemindex == 0: # if it is first track item at track then add diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py index b0b171fb61..80c6abbaef 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py @@ -5,7 +5,7 @@ import pyblish.api class PreCollectClipEffects(pyblish.api.InstancePlugin): """Collect soft effects instances.""" - order = pyblish.api.CollectorOrder - 0.579 + order = pyblish.api.CollectorOrder - 0.479 label = "Precollect Clip Effects Instances" families = ["clip"] diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 9b529edf88..85b4e273d5 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -13,7 +13,7 @@ from pprint import pformat class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["hiero"] @@ -131,7 +131,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.create_shot_instance(context, **data) self.log.info("Creating instance: {}".format(instance)) - self.log.debug( + self.log.info( "_ instance.data: {}".format(pformat(instance.data))) if not with_audio: diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 530a433423..7db155048f 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -8,11 +8,12 @@ from openpype.hosts.hiero.otio import hiero_export from Qt.QtGui import QPixmap import tempfile + class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.6 + order = pyblish.api.CollectorOrder - 0.5 def process(self, context): diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 77ee182e7c..76585085e2 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -15,8 +15,8 @@ creator.show() diff --git a/openpype/hosts/houdini/startup/scripts/houdinicore.py b/openpype/hosts/houdini/startup/scripts/houdinicore.py new file mode 100644 index 0000000000..4233d68c15 --- /dev/null +++ b/openpype/hosts/houdini/startup/scripts/houdinicore.py @@ -0,0 +1,9 @@ +from avalon import api, houdini + + +def main(): + print("Installing OpenPype ...") + api.install(houdini) + + +main() diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 9219da407f..725a075417 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -8,7 +8,7 @@ from avalon import api as avalon from avalon import pipeline from avalon.maya import suspended_refresh from avalon.maya.pipeline import IS_HEADLESS -from avalon.tools import workfiles +from openpype.tools import workfiles from pyblish import api as pyblish from openpype.lib import any_outdated import openpype.hosts.maya @@ -35,6 +35,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info(PUBLISH_PATH) menu.install() @@ -63,14 +64,23 @@ def process_dirmap(project_settings): # type: (dict) -> None """Go through all paths in Settings and set them using `dirmap`. + If artists has Site Sync enabled, take dirmap mapping directly from + Local Settings when artist is syncing workfile locally. + Args: project_settings (dict): Settings for current project. """ - if not project_settings["maya"].get("maya-dirmap"): + local_mapping = _get_local_sync_dirmap(project_settings) + if not project_settings["maya"].get("maya-dirmap") and not local_mapping: return - mapping = project_settings["maya"]["maya-dirmap"]["paths"] or {} - mapping_enabled = project_settings["maya"]["maya-dirmap"]["enabled"] + + mapping = local_mapping or \ + project_settings["maya"]["maya-dirmap"]["paths"] \ + or {} + mapping_enabled = project_settings["maya"]["maya-dirmap"]["enabled"] \ + or bool(local_mapping) + if not mapping or not mapping_enabled: return if mapping.get("source-path") and mapping_enabled is True: @@ -93,10 +103,72 @@ def process_dirmap(project_settings): continue +def _get_local_sync_dirmap(project_settings): + """ + Returns dirmap if synch to local project is enabled. + + Only valid mapping is from roots of remote site to local site set in + Local Settings. + + Args: + project_settings (dict) + Returns: + dict : { "source-path": [XXX], "destination-path": [YYYY]} + """ + import json + mapping = {} + + if not project_settings["global"]["sync_server"]["enabled"]: + log.debug("Site Sync not enabled") + return mapping + + from openpype.settings.lib import get_site_local_overrides + from openpype.modules import ModulesManager + + manager = ModulesManager() + sync_module = manager.modules_by_name["sync_server"] + + project_name = os.getenv("AVALON_PROJECT") + sync_settings = sync_module.get_sync_project_setting( + os.getenv("AVALON_PROJECT"), exclude_locals=False, cached=False) + log.debug(json.dumps(sync_settings, indent=4)) + + active_site = sync_module.get_local_normalized_site( + sync_module.get_active_site(project_name)) + remote_site = sync_module.get_local_normalized_site( + sync_module.get_remote_site(project_name)) + log.debug("active {} - remote {}".format(active_site, remote_site)) + + if active_site == "local" \ + and project_name in sync_module.get_enabled_projects()\ + and active_site != remote_site: + overrides = get_site_local_overrides(os.getenv("AVALON_PROJECT"), + active_site) + for root_name, value in overrides.items(): + if os.path.isdir(value): + try: + mapping["destination-path"] = [value] + mapping["source-path"] = [sync_settings["sites"]\ + [remote_site]\ + ["root"]\ + [root_name]] + except IndexError: + # missing corresponding destination path + log.debug("overrides".format(overrides)) + log.error( + ("invalid dirmap mapping, missing corresponding" + " destination directory.")) + break + + log.debug("local sync mapping:: {}".format(mapping)) + return mapping + + def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) menu.uninstall() diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 22945471b7..a84412963b 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -79,7 +79,7 @@ def override_toolbox_ui(): log.warning("Could not import SceneInventory tool") try: - import avalon.tools.loader as loader + import openpype.tools.loader as loader except Exception: log.warning("Could not import Loader tool") diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index fb99584c5d..4983109d58 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -114,6 +114,8 @@ class RenderProduct(object): aov = attr.ib(default=None) # source aov driver = attr.ib(default=None) # source driver multipart = attr.ib(default=False) # multichannel file + camera = attr.ib(default=None) # used only when rendering + # from multiple cameras def get(layer, render_instance=None): @@ -183,6 +185,16 @@ class ARenderProducts: self.layer_data = self._get_layer_data() self.layer_data.products = self.get_render_products() + def has_camera_token(self): + # type: () -> bool + """Check if camera token is in image prefix. + + Returns: + bool: True/False if camera token is present. + + """ + return "" in self.layer_data.filePrefix.lower() + @abstractmethod def get_render_products(self): """To be implemented by renderer class. @@ -307,7 +319,7 @@ class ARenderProducts: # Deadline allows submitting renders with a custom frame list # to support those cases we might want to allow 'custom frames' # to be overridden to `ExpectFiles` class? - layer_data = LayerMetadata( + return LayerMetadata( frameStart=int(self.get_render_attribute("startFrame")), frameEnd=int(self.get_render_attribute("endFrame")), frameStep=int(self.get_render_attribute("byFrameStep")), @@ -321,7 +333,6 @@ class ARenderProducts: defaultExt=self._get_attr("defaultRenderGlobals.imfPluginKey"), filePrefix=file_prefix ) - return layer_data def _generate_file_sequence( self, layer_data, @@ -330,7 +341,7 @@ class ARenderProducts: force_cameras=None): # type: (LayerMetadata, str, str, list) -> list expected_files = [] - cameras = force_cameras if force_cameras else layer_data.cameras + cameras = force_cameras or layer_data.cameras ext = force_ext or layer_data.defaultExt for cam in cameras: file_prefix = layer_data.filePrefix @@ -361,8 +372,8 @@ class ARenderProducts: ) return expected_files - def get_files(self, product, camera): - # type: (RenderProduct, str) -> list + def get_files(self, product): + # type: (RenderProduct) -> list """Return list of expected files. It will translate render token strings ('', etc.) to @@ -373,7 +384,6 @@ class ARenderProducts: Args: product (RenderProduct): Render product to be used for file generation. - camera (str): Camera name. Returns: List of files @@ -383,7 +393,7 @@ class ARenderProducts: self.layer_data, force_aov_name=product.productName, force_ext=product.ext, - force_cameras=[camera] + force_cameras=[product.camera] ) def get_renderable_cameras(self): @@ -460,15 +470,21 @@ class RenderProductsArnold(ARenderProducts): return prefix - def _get_aov_render_products(self, aov): + def _get_aov_render_products(self, aov, cameras=None): """Return all render products for the AOV""" - products = list() + products = [] aov_name = self._get_attr(aov, "name") ai_drivers = cmds.listConnections("{}.outputs".format(aov), source=True, destination=False, type="aiAOVDriver") or [] + if not cameras: + cameras = [ + self.sanitize_camera_name( + self.get_renderable_cameras()[0] + ) + ] for ai_driver in ai_drivers: # todo: check aiAOVDriver.prefix as it could have @@ -497,30 +513,37 @@ class RenderProductsArnold(ARenderProducts): name = "beauty" # Support Arnold light groups for AOVs - # Global AOV: When disabled the main layer is not written: `{pass}` + # Global AOV: When disabled the main layer is + # not written: `{pass}` # All Light Groups: When enabled, a `{pass}_lgroups` file is - # written and is always merged into a single file - # Light Groups List: When set, a product per light group is written + # written and is always merged into a + # single file + # Light Groups List: When set, a product per light + # group is written # e.g. {pass}_front, {pass}_rim global_aov = self._get_attr(aov, "globalAov") if global_aov: - product = RenderProduct(productName=name, - ext=ext, - aov=aov_name, - driver=ai_driver) - products.append(product) + for camera in cameras: + product = RenderProduct(productName=name, + ext=ext, + aov=aov_name, + driver=ai_driver, + camera=camera) + products.append(product) all_light_groups = self._get_attr(aov, "lightGroups") if all_light_groups: # All light groups is enabled. A single multipart # Render Product - product = RenderProduct(productName=name + "_lgroups", - ext=ext, - aov=aov_name, - driver=ai_driver, - # Always multichannel output - multipart=True) - products.append(product) + for camera in cameras: + product = RenderProduct(productName=name + "_lgroups", + ext=ext, + aov=aov_name, + driver=ai_driver, + # Always multichannel output + multipart=True, + camera=camera) + products.append(product) else: value = self._get_attr(aov, "lightGroupsList") if not value: @@ -529,11 +552,15 @@ class RenderProductsArnold(ARenderProducts): for light_group in selected_light_groups: # Render Product per selected light group aov_light_group_name = "{}_{}".format(name, light_group) - product = RenderProduct(productName=aov_light_group_name, - aov=aov_name, - driver=ai_driver, - ext=ext) - products.append(product) + for camera in cameras: + product = RenderProduct( + productName=aov_light_group_name, + aov=aov_name, + driver=ai_driver, + ext=ext, + camera=camera + ) + products.append(product) return products @@ -556,17 +583,26 @@ class RenderProductsArnold(ARenderProducts): # anyway. return [] - default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey") - beauty_product = RenderProduct(productName="beauty", - ext=default_ext, - driver="defaultArnoldDriver") + # check if camera token is in prefix. If so, and we have list of + # renderable cameras, generate render product for each and every + # of them. + cameras = [ + self.sanitize_camera_name(c) + for c in self.get_renderable_cameras() + ] + default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey") + beauty_products = [RenderProduct( + productName="beauty", + ext=default_ext, + driver="defaultArnoldDriver", + camera=camera) for camera in cameras] # AOVs > Legacy > Maya Render View > Mode aovs_enabled = bool( self._get_attr("defaultArnoldRenderOptions.aovMode") ) if not aovs_enabled: - return [beauty_product] + return beauty_products # Common > File Output > Merge AOVs or # We don't need to check for Merge AOVs due to overridden @@ -575,8 +611,9 @@ class RenderProductsArnold(ARenderProducts): "" in self.layer_data.filePrefix.lower() ) if not has_renderpass_token: - beauty_product.multipart = True - return [beauty_product] + for product in beauty_products: + product.multipart = True + return beauty_products # AOVs are set to be rendered separately. We should expect # token in path. @@ -598,14 +635,14 @@ class RenderProductsArnold(ARenderProducts): continue # For now stick to the legacy output format. - aov_products = self._get_aov_render_products(aov) + aov_products = self._get_aov_render_products(aov, cameras) products.extend(aov_products) - if not any(product.aov == "RGBA" for product in products): + if all(product.aov != "RGBA" for product in products): # Append default 'beauty' as this is arnolds default. # However, it is excluded whenever a RGBA pass is enabled. # For legibility add the beauty layer as first entry - products.insert(0, beauty_product) + products += beauty_products # TODO: Output Denoising AOVs? @@ -670,6 +707,11 @@ class RenderProductsVray(ARenderProducts): # anyway. return [] + cameras = [ + self.sanitize_camera_name(c) + for c in self.get_renderable_cameras() + ] + image_format_str = self._get_attr("vraySettings.imageFormatStr") default_ext = image_format_str if default_ext in {"exr (multichannel)", "exr (deep)"}: @@ -680,13 +722,21 @@ class RenderProductsVray(ARenderProducts): # add beauty as default when not disabled dont_save_rgb = self._get_attr("vraySettings.dontSaveRgbChannel") if not dont_save_rgb: - products.append(RenderProduct(productName="", ext=default_ext)) + for camera in cameras: + products.append( + RenderProduct(productName="", + ext=default_ext, + camera=camera)) # separate alpha file separate_alpha = self._get_attr("vraySettings.separateAlpha") if separate_alpha: - products.append(RenderProduct(productName="Alpha", - ext=default_ext)) + for camera in cameras: + products.append( + RenderProduct(productName="Alpha", + ext=default_ext, + camera=camera) + ) if image_format_str == "exr (multichannel)": # AOVs are merged in m-channel file, only main layer is rendered @@ -716,19 +766,23 @@ class RenderProductsVray(ARenderProducts): # instead seems to output multiple Render Products, # specifically "Self_Illumination" and "Environment" product_names = ["Self_Illumination", "Environment"] - for name in product_names: - product = RenderProduct(productName=name, - ext=default_ext, - aov=aov) - products.append(product) + for camera in cameras: + for name in product_names: + product = RenderProduct(productName=name, + ext=default_ext, + aov=aov, + camera=camera) + products.append(product) # Continue as we've processed this special case AOV continue aov_name = self._get_vray_aov_name(aov) - product = RenderProduct(productName=aov_name, - ext=default_ext, - aov=aov) - products.append(product) + for camera in cameras: + product = RenderProduct(productName=aov_name, + ext=default_ext, + aov=aov, + camera=camera) + products.append(product) return products @@ -875,6 +929,11 @@ class RenderProductsRedshift(ARenderProducts): # anyway. return [] + cameras = [ + self.sanitize_camera_name(c) + for c in self.get_renderable_cameras() + ] + # For Redshift we don't directly return upon forcing multilayer # due to some AOVs still being written into separate files, # like Cryptomatte. @@ -933,11 +992,14 @@ class RenderProductsRedshift(ARenderProducts): for light_group in light_groups: aov_light_group_name = "{}_{}".format(aov_name, light_group) - product = RenderProduct(productName=aov_light_group_name, - aov=aov_name, - ext=ext, - multipart=aov_multipart) - products.append(product) + for camera in cameras: + product = RenderProduct( + productName=aov_light_group_name, + aov=aov_name, + ext=ext, + multipart=aov_multipart, + camera=camera) + products.append(product) if light_groups: light_groups_enabled = True @@ -945,11 +1007,13 @@ class RenderProductsRedshift(ARenderProducts): # Redshift AOV Light Select always renders the global AOV # even when light groups are present so we don't need to # exclude it when light groups are active - product = RenderProduct(productName=aov_name, - aov=aov_name, - ext=ext, - multipart=aov_multipart) - products.append(product) + for camera in cameras: + product = RenderProduct(productName=aov_name, + aov=aov_name, + ext=ext, + multipart=aov_multipart, + camera=camera) + products.append(product) # When a Beauty AOV is added manually, it will be rendered as # 'Beauty_other' in file name and "standard" beauty will have @@ -959,10 +1023,12 @@ class RenderProductsRedshift(ARenderProducts): return products beauty_name = "Beauty_other" if has_beauty_aov else "" - products.insert(0, - RenderProduct(productName=beauty_name, - ext=ext, - multipart=multipart)) + for camera in cameras: + products.insert(0, + RenderProduct(productName=beauty_name, + ext=ext, + multipart=multipart, + camera=camera)) return products @@ -987,6 +1053,16 @@ class RenderProductsRenderman(ARenderProducts): :func:`ARenderProducts.get_render_products()` """ + cameras = [ + self.sanitize_camera_name(c) + for c in self.get_renderable_cameras() + ] + + if not cameras: + cameras = [ + self.sanitize_camera_name( + self.get_renderable_cameras()[0]) + ] products = [] default_ext = "exr" @@ -1000,9 +1076,11 @@ class RenderProductsRenderman(ARenderProducts): if aov_name == "rmanDefaultDisplay": aov_name = "beauty" - product = RenderProduct(productName=aov_name, - ext=default_ext) - products.append(product) + for camera in cameras: + product = RenderProduct(productName=aov_name, + ext=default_ext, + camera=camera) + products.append(product) return products diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 121f7a08a7..448cb814d9 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -4,6 +4,53 @@ import avalon.maya from openpype.api import PypeCreatorMixin +def get_reference_node(members, log=None): + """Get the reference node from the container members + Args: + members: list of node names + + Returns: + str: Reference node name. + + """ + + from maya import cmds + + # Collect the references without .placeHolderList[] attributes as + # unique entries (objects only) and skipping the sharedReferenceNode. + references = set() + for ref in cmds.ls(members, exactType="reference", objectsOnly=True): + + # Ignore any `:sharedReferenceNode` + if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): + continue + + # Ignore _UNKNOWN_REF_NODE_ (PLN-160) + if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): + continue + + references.add(ref) + + assert references, "No reference node found in container" + + # Get highest reference node (least parents) + highest = min(references, + key=lambda x: len(get_reference_node_parents(x))) + + # Warn the user when we're taking the highest reference node + if len(references) > 1: + if not log: + from openpype.lib import PypeLogger + + log = PypeLogger().get_logger(__name__) + + log.warning("More than one reference node found in " + "container, using highest reference node: " + "%s (in: %s)", highest, list(references)) + + return highest + + def get_reference_node_parents(ref): """Return all parent reference nodes of reference node @@ -109,7 +156,7 @@ class ReferenceLoader(api.Loader): loader=self.__class__.__name__ )) else: - ref_node = self._get_reference_node(nodes) + ref_node = get_reference_node(nodes, self.log) loaded_containers.append(containerise( name=name, namespace=namespace, @@ -126,46 +173,6 @@ class ReferenceLoader(api.Loader): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") - def _get_reference_node(self, members): - """Get the reference node from the container members - Args: - members: list of node names - - Returns: - str: Reference node name. - - """ - - from maya import cmds - - # Collect the references without .placeHolderList[] attributes as - # unique entries (objects only) and skipping the sharedReferenceNode. - references = set() - for ref in cmds.ls(members, exactType="reference", objectsOnly=True): - - # Ignore any `:sharedReferenceNode` - if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): - continue - - # Ignore _UNKNOWN_REF_NODE_ (PLN-160) - if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): - continue - - references.add(ref) - - assert references, "No reference node found in container" - - # Get highest reference node (least parents) - highest = min(references, - key=lambda x: len(get_reference_node_parents(x))) - - # Warn the user when we're taking the highest reference node - if len(references) > 1: - self.log.warning("More than one reference node found in " - "container, using highest reference node: " - "%s (in: %s)", highest, list(references)) - - return highest def update(self, container, representation): @@ -178,7 +185,7 @@ class ReferenceLoader(api.Loader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, self.log) file_type = { "ma": "mayaAscii", @@ -274,7 +281,7 @@ class ReferenceLoader(api.Loader): # Assume asset has been referenced members = cmds.sets(node, query=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, self.log) assert reference_node, ("Imported container not supported; " "container must be referenced.") diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py index 73cc6246ab..ed425f4718 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -31,7 +31,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self.setObjectName("shaderDefinitionEditor") self.setWindowTitle("OpenPype shader name definition editor") - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) diff --git a/openpype/hosts/maya/plugins/create/create_mayaascii.py b/openpype/hosts/maya/plugins/create/create_mayaascii.py index f51e126c00..8bbdf107c6 100644 --- a/openpype/hosts/maya/plugins/create/create_mayaascii.py +++ b/openpype/hosts/maya/plugins/create/create_mayaascii.py @@ -1,11 +1,11 @@ from openpype.hosts.maya.api import plugin -class CreateMayaAscii(plugin.Creator): - """Raw Maya Ascii file export""" +class CreateMayaScene(plugin.Creator): + """Raw Maya Scene file export""" - name = "mayaAscii" - label = "Maya Ascii" - family = "mayaAscii" + name = "mayaScene" + label = "Maya Scene" + family = "mayaScene" icon = "file-archive-o" defaults = ['Main'] diff --git a/openpype/hosts/maya/plugins/create/create_setdress.py b/openpype/hosts/maya/plugins/create/create_setdress.py index 8274a6dd83..4246183fdb 100644 --- a/openpype/hosts/maya/plugins/create/create_setdress.py +++ b/openpype/hosts/maya/plugins/create/create_setdress.py @@ -9,3 +9,8 @@ class CreateSetDress(plugin.Creator): family = "setdress" icon = "cubes" defaults = ["Main", "Anim"] + + def __init__(self, *args, **kwargs): + super(CreateSetDress, self).__init__(*args, **kwargs) + + self.data["exactSetMembersOnly"] = True diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py new file mode 100644 index 0000000000..e3cad4cf2e --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -0,0 +1,92 @@ +from avalon import api, io + + +class ImportModelRender(api.InventoryAction): + + label = "Import Model Render Sets" + icon = "industry" + color = "#55DDAA" + + scene_type_regex = "meta.render.m[ab]" + look_data_type = "meta.render.json" + + @staticmethod + def is_compatible(container): + return ( + container.get("loader") == "ReferenceLoader" + and container.get("name", "").startswith("model") + ) + + def process(self, containers): + from maya import cmds + + for container in containers: + con_name = container["objectName"] + nodes = [] + for n in cmds.sets(con_name, query=True, nodesOnly=True) or []: + if cmds.nodeType(n) == "reference": + nodes += cmds.referenceQuery(n, nodes=True) + else: + nodes.append(n) + + repr_doc = io.find_one({ + "_id": io.ObjectId(container["representation"]), + }) + version_id = repr_doc["parent"] + + print("Importing render sets for model %r" % con_name) + self.assign_model_render_by_version(nodes, version_id) + + def assign_model_render_by_version(self, nodes, version_id): + """Assign nodes a specific published model render data version by id. + + This assumes the nodes correspond with the asset. + + Args: + nodes(list): nodes to assign render data to + version_id (bson.ObjectId): database id of the version of model + + Returns: + None + """ + import json + from maya import cmds + from avalon import maya, io, pipeline + from openpype.hosts.maya.api import lib + + # Get representations of shader file and relationships + look_repr = io.find_one({ + "type": "representation", + "parent": version_id, + "name": {"$regex": self.scene_type_regex}, + }) + if not look_repr: + print("No model render sets for this model version..") + return + + json_repr = io.find_one({ + "type": "representation", + "parent": version_id, + "name": self.look_data_type, + }) + + context = pipeline.get_representation_context(look_repr["_id"]) + maya_file = pipeline.get_representation_path_from_context(context) + + context = pipeline.get_representation_context(json_repr["_id"]) + json_file = pipeline.get_representation_path_from_context(context) + + # Import the look file + with maya.maintained_selection(): + shader_nodes = cmds.file(maya_file, + i=True, # import + returnNewNodes=True) + # imprint context data + + # Load relationships + shader_relation = json_file + with open(shader_relation, "r") as f: + relationships = json.load(f) + + # Assign relationships + lib.apply_shaders(relationships, shader_nodes, nodes) diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py new file mode 100644 index 0000000000..2fa132a867 --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -0,0 +1,29 @@ +from maya import cmds + +from avalon import api + +from openpype.hosts.maya.api.plugin import get_reference_node + + +class ImportReference(api.InventoryAction): + """Imports selected reference to inside of the file.""" + + label = "Import Reference" + icon = "download" + color = "#d8d8d8" + + def process(self, containers): + references = cmds.ls(type="reference") + for container in containers: + if container["loader"] != "ReferenceLoader": + print("Not a reference, skipping") + continue + + node = container["objectName"] + members = cmds.sets(node, query=True, nodesOnly=True) + ref_node = get_reference_node(members) + + ref_file = cmds.referenceQuery(ref_node, f=True) + cmds.file(ref_file, importReference=True) + + return True # return anything to trigger model refresh diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index d5952ed267..6dd161cf98 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -13,6 +13,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "pointcache", "animation", "mayaAscii", + "mayaScene", "setdress", "layout", "camera", @@ -71,7 +72,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except: # noqa: E722 pass - if family not in ["layout", "setdress", "mayaAscii"]: + if family not in ["layout", "setdress", "mayaAscii", "mayaScene"]: for root in roots: root.setParent(world=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 0dde52447d..9c047b252f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -223,8 +223,8 @@ class CollectLook(pyblish.api.InstancePlugin): def process(self, instance): """Collect the Look in the instance with the correct layer settings""" - - with lib.renderlayer(instance.data["renderlayer"]): + renderlayer = instance.data.get("renderlayer", "defaultRenderLayer") + with lib.renderlayer(renderlayer): self.collect(instance) def collect(self, instance): @@ -357,6 +357,23 @@ class CollectLook(pyblish.api.InstancePlugin): for vray_node in vray_plugin_nodes: history.extend(cmds.listHistory(vray_node)) + # handling render attribute sets + render_set_types = [ + "VRayDisplacement", + "VRayLightMesh", + "VRayObjectProperties", + "RedshiftObjectId", + "RedshiftMeshParameters", + ] + render_sets = cmds.ls(look_sets, type=render_set_types) + if render_sets: + history.extend( + cmds.listHistory(render_sets, + future=False, + pruneDagObjects=True) + or [] + ) + files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True)) files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) @@ -550,3 +567,45 @@ class CollectLook(pyblish.api.InstancePlugin): "source": source, # required for resources "files": files, "color_space": color_space} # required for resources + + +class CollectModelRenderSets(CollectLook): + """Collect render attribute sets for model instance. + + Collects additional render attribute sets so they can be + published with model. + + """ + + order = pyblish.api.CollectorOrder + 0.21 + families = ["model"] + label = "Collect Model Render Sets" + hosts = ["maya"] + maketx = True + + def collect_sets(self, instance): + """Collect all related objectSets except shadingEngines + + Args: + instance (list): all nodes to be published + + Returns: + dict + """ + + sets = {} + for node in instance: + related_sets = lib.get_related_sets(node) + if not related_sets: + continue + + for objset in related_sets: + if objset in sets: + continue + + if "shadingEngine" in cmds.nodeType(objset, inherited=True): + continue + + sets[objset] = {"uuid": lib.get_id(objset), "members": list()} + + return sets diff --git a/openpype/hosts/maya/plugins/publish/collect_mayaascii.py b/openpype/hosts/maya/plugins/publish/collect_maya_scene.py similarity index 82% rename from openpype/hosts/maya/plugins/publish/collect_mayaascii.py rename to openpype/hosts/maya/plugins/publish/collect_maya_scene.py index b02f61b7c6..eb21b17989 100644 --- a/openpype/hosts/maya/plugins/publish/collect_mayaascii.py +++ b/openpype/hosts/maya/plugins/publish/collect_maya_scene.py @@ -3,14 +3,14 @@ from maya import cmds import pyblish.api -class CollectMayaAscii(pyblish.api.InstancePlugin): - """Collect May Ascii Data +class CollectMayaScene(pyblish.api.InstancePlugin): + """Collect Maya Scene Data """ order = pyblish.api.CollectorOrder + 0.2 label = 'Collect Model Data' - families = ["mayaAscii"] + families = ["mayaScene"] def process(self, instance): # Extract only current frame (override) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 5049647ff9..575cc2456b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -174,10 +174,16 @@ class CollectMayaRender(pyblish.api.ContextPlugin): assert render_products, "no render products generated" exp_files = [] for product in render_products: - for camera in layer_render_products.layer_data.cameras: - exp_files.append( - {product.productName: layer_render_products.get_files( - product, camera)}) + product_name = product.productName + if product.camera and layer_render_products.has_camera_token(): + product_name = "{}{}".format( + product.camera, + "_" + product_name if product_name else "") + exp_files.append( + { + product_name: layer_render_products.get_files( + product) + }) self.log.info("multipart: {}".format( layer_render_products.multipart)) @@ -199,12 +205,14 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # replace relative paths with absolute. Render products are # returned as list of dictionaries. + publish_meta_path = None for aov in exp_files: full_paths = [] for file in aov[aov.keys()[0]]: full_path = os.path.join(workspace, "renders", file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) + publish_meta_path = os.path.dirname(full_path) aov_dict[aov.keys()[0]] = full_paths frame_start_render = int(self.get_render_attribute( @@ -230,6 +238,26 @@ class CollectMayaRender(pyblish.api.ContextPlugin): frame_end_handle = frame_end_render full_exp_files.append(aov_dict) + + # find common path to store metadata + # so if image prefix is branching to many directories + # metadata file will be located in top-most common + # directory. + # TODO: use `os.path.commonpath()` after switch to Python 3 + common_publish_meta_path = os.path.splitdrive( + publish_meta_path)[0] + if common_publish_meta_path: + common_publish_meta_path += os.path.sep + for part in publish_meta_path.split("/"): + common_publish_meta_path = os.path.join( + common_publish_meta_path, part) + if part == expected_layer_name: + break + common_publish_meta_path = common_publish_meta_path.replace( + "\\", "/") + self.log.info( + "Publish meta path: {}".format(common_publish_meta_path)) + self.log.info(full_exp_files) self.log.info("collecting layer: {}".format(layer_name)) # Get layer specific settings, might be overrides @@ -262,6 +290,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # which was submitted originally "source": filepath, "expectedFiles": full_exp_files, + "publishRenderMetadataFolder": common_publish_meta_path, "resolutionWidth": cmds.getAttr("defaultResolution.width"), "resolutionHeight": cmds.getAttr("defaultResolution.height"), "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), diff --git a/openpype/hosts/maya/plugins/publish/collect_scene.py b/openpype/hosts/maya/plugins/publish/collect_workfile.py similarity index 97% rename from openpype/hosts/maya/plugins/publish/collect_scene.py rename to openpype/hosts/maya/plugins/publish/collect_workfile.py index be2a294f26..ee676f50d0 100644 --- a/openpype/hosts/maya/plugins/publish/collect_scene.py +++ b/openpype/hosts/maya/plugins/publish/collect_workfile.py @@ -4,7 +4,7 @@ import os from maya import cmds -class CollectMayaScene(pyblish.api.ContextPlugin): +class CollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.01 diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index f09d50d714..bbf25ebdc7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -122,7 +122,7 @@ def no_workspace_dir(): class ExtractLook(openpype.api.Extractor): - """Extract Look (Maya Ascii + JSON) + """Extract Look (Maya Scene + JSON) Only extracts the sets (shadingEngines and alike) alongside a .json file that stores it relationships for the sets and "attribute" data for the @@ -130,11 +130,12 @@ class ExtractLook(openpype.api.Extractor): """ - label = "Extract Look (Maya ASCII + JSON)" + label = "Extract Look (Maya Scene + JSON)" hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 scene_type = "ma" + look_data_type = "json" @staticmethod def get_renderer_name(): @@ -176,6 +177,8 @@ class ExtractLook(openpype.api.Extractor): # no preset found pass + return "mayaAscii" if self.scene_type == "ma" else "mayaBinary" + def process(self, instance): """Plugin entry point. @@ -183,10 +186,12 @@ class ExtractLook(openpype.api.Extractor): instance: Instance to process. """ + _scene_type = self.get_maya_scene_type(instance) + # Define extract output file path dir_path = self.staging_dir(instance) maya_fname = "{0}.{1}".format(instance.name, self.scene_type) - json_fname = "{0}.json".format(instance.name) + json_fname = "{0}.{1}".format(instance.name, self.look_data_type) # Make texture dump folder maya_path = os.path.join(dir_path, maya_fname) @@ -196,11 +201,100 @@ class ExtractLook(openpype.api.Extractor): # Remove all members of the sets so they are not included in the # exported file by accident - self.log.info("Extract sets (Maya ASCII) ...") + self.log.info("Extract sets (%s) ..." % _scene_type) lookdata = instance.data["lookData"] relationships = lookdata["relationships"] sets = relationships.keys() + results = self.process_resources(instance, staging_dir=dir_path) + transfers = results["fileTransfers"] + hardlinks = results["fileHardlinks"] + hashes = results["fileHashes"] + remap = results["attrRemap"] + + # Extract in correct render layer + layer = instance.data.get("renderlayer", "defaultRenderLayer") + with lib.renderlayer(layer): + # TODO: Ensure membership edits don't become renderlayer overrides + with lib.empty_sets(sets, force=True): + # To avoid Maya trying to automatically remap the file + # textures relative to the `workspace -directory` we force + # it to a fake temporary workspace. This fixes textures + # getting incorrectly remapped. (LKD-17, PLN-101) + with no_workspace_dir(): + with lib.attribute_values(remap): + with avalon.maya.maintained_selection(): + cmds.select(sets, noExpand=True) + cmds.file( + maya_path, + force=True, + typ=_scene_type, + exportSelected=True, + preserveReferences=False, + channels=True, + constraints=True, + expressions=True, + constructionHistory=True, + ) + + # Write the JSON data + self.log.info("Extract json..") + data = { + "attributes": lookdata["attributes"], + "relationships": relationships + } + + with open(json_path, "w") as f: + json.dump(data, f) + + if "files" not in instance.data: + instance.data["files"] = [] + if "hardlinks" not in instance.data: + instance.data["hardlinks"] = [] + if "transfers" not in instance.data: + instance.data["transfers"] = [] + + instance.data["files"].append(maya_fname) + instance.data["files"].append(json_fname) + + if instance.data.get("representations") is None: + instance.data["representations"] = [] + + instance.data["representations"].append( + { + "name": self.scene_type, + "ext": self.scene_type, + "files": os.path.basename(maya_fname), + "stagingDir": os.path.dirname(maya_fname), + } + ) + instance.data["representations"].append( + { + "name": self.look_data_type, + "ext": self.look_data_type, + "files": os.path.basename(json_fname), + "stagingDir": os.path.dirname(json_fname), + } + ) + + # Set up the resources transfers/links for the integrator + instance.data["transfers"].extend(transfers) + instance.data["hardlinks"].extend(hardlinks) + + # Source hash for the textures + instance.data["sourceHashes"] = hashes + + """ + self.log.info("Returning colorspaces to their original values ...") + for attr, value in remap.items(): + self.log.info(" - {}: {}".format(attr, value)) + cmds.setAttr(attr, value, type="string") + """ + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + maya_path)) + + def process_resources(self, instance, staging_dir): + # Extract the textures to transfer, possibly convert with maketx and # remap the node paths to the destination path. Note that a source # might be included more than once amongst the resources as they could @@ -218,7 +312,6 @@ class ExtractLook(openpype.api.Extractor): color_space = resource.get("color_space") for f in resource["files"]: - files_metadata[os.path.normpath(f)] = { "color_space": color_space} # files.update(os.path.normpath(f)) @@ -244,7 +337,7 @@ class ExtractLook(openpype.api.Extractor): source, mode, texture_hash = self._process_texture( filepath, do_maketx, - staging=dir_path, + staging=staging_dir, linearize=linearize, force=force_copy ) @@ -299,85 +392,13 @@ class ExtractLook(openpype.api.Extractor): self.log.info("Finished remapping destinations ...") - # Extract in correct render layer - layer = instance.data.get("renderlayer", "defaultRenderLayer") - with lib.renderlayer(layer): - # TODO: Ensure membership edits don't become renderlayer overrides - with lib.empty_sets(sets, force=True): - # To avoid Maya trying to automatically remap the file - # textures relative to the `workspace -directory` we force - # it to a fake temporary workspace. This fixes textures - # getting incorrectly remapped. (LKD-17, PLN-101) - with no_workspace_dir(): - with lib.attribute_values(remap): - with avalon.maya.maintained_selection(): - cmds.select(sets, noExpand=True) - cmds.file( - maya_path, - force=True, - typ="mayaAscii", - exportSelected=True, - preserveReferences=False, - channels=True, - constraints=True, - expressions=True, - constructionHistory=True, - ) - - # Write the JSON data - self.log.info("Extract json..") - data = { - "attributes": lookdata["attributes"], - "relationships": relationships + return { + "fileTransfers": transfers, + "fileHardlinks": hardlinks, + "fileHashes": hashes, + "attrRemap": remap, } - with open(json_path, "w") as f: - json.dump(data, f) - - if "files" not in instance.data: - instance.data["files"] = [] - if "hardlinks" not in instance.data: - instance.data["hardlinks"] = [] - if "transfers" not in instance.data: - instance.data["transfers"] = [] - - instance.data["files"].append(maya_fname) - instance.data["files"].append(json_fname) - - instance.data["representations"] = [] - instance.data["representations"].append( - { - "name": "ma", - "ext": "ma", - "files": os.path.basename(maya_fname), - "stagingDir": os.path.dirname(maya_fname), - } - ) - instance.data["representations"].append( - { - "name": "json", - "ext": "json", - "files": os.path.basename(json_fname), - "stagingDir": os.path.dirname(json_fname), - } - ) - - # Set up the resources transfers/links for the integrator - instance.data["transfers"].extend(transfers) - instance.data["hardlinks"].extend(hardlinks) - - # Source hash for the textures - instance.data["sourceHashes"] = hashes - - """ - self.log.info("Returning colorspaces to their original values ...") - for attr, value in remap.items(): - self.log.info(" - {}: {}".format(attr, value)) - cmds.setAttr(attr, value, type="string") - """ - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - maya_path)) - def resource_destination(self, instance, filepath, do_maketx): """Get resource destination path. @@ -467,3 +488,26 @@ class ExtractLook(openpype.api.Extractor): return converted, COPY, texture_hash return filepath, COPY, texture_hash + + +class ExtractModelRenderSets(ExtractLook): + """Extract model render attribute sets as model metadata + + Only extracts the render attrib sets (NO shadingEngines) alongside + a .json file that stores it relationships for the sets and "attribute" + data for the instance members. + + """ + + label = "Model Render Sets" + hosts = ["maya"] + families = ["model"] + scene_type_prefix = "meta.render." + look_data_type = "meta.render.json" + + def get_maya_scene_type(self, instance): + typ = super(ExtractModelRenderSets, self).get_maya_scene_type(instance) + # add prefix + self.scene_type = self.scene_type_prefix + self.scene_type + + return typ diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 3c2b70900d..e7fb5bc8cb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -17,6 +17,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): label = "Maya Scene (Raw)" hosts = ["maya"] families = ["mayaAscii", + "mayaScene", "setdress", "layout", "camerarig", diff --git a/openpype/plugins/publish/validate_instance_in_context.py b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py similarity index 65% rename from openpype/plugins/publish/validate_instance_in_context.py rename to openpype/hosts/maya/plugins/publish/validate_instance_in_context.py index 61b4d82027..7b8c335062 100644 --- a/openpype/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py @@ -5,6 +5,8 @@ from __future__ import absolute_import import pyblish.api import openpype.api +from maya import cmds + class SelectInvalidInstances(pyblish.api.Action): """Select invalid instances in Outliner.""" @@ -18,13 +20,12 @@ class SelectInvalidInstances(pyblish.api.Action): # Get the errored instances failed = [] for result in context.data["results"]: - if result["error"] is None: - continue - if result["instance"] is None: - continue - if result["instance"] in failed: - continue - if result["plugin"] != plugin: + if ( + result["error"] is None + or result["instance"] is None + or result["instance"] in failed + or result["plugin"] != plugin + ): continue failed.append(result["instance"]) @@ -44,25 +45,10 @@ class SelectInvalidInstances(pyblish.api.Action): self.deselect() def select(self, instances): - if "nuke" in pyblish.api.registered_hosts(): - import avalon.nuke.lib - import nuke - avalon.nuke.lib.select_nodes( - [nuke.toNode(str(x)) for x in instances] - ) - - if "maya" in pyblish.api.registered_hosts(): - from maya import cmds - cmds.select(instances, replace=True, noExpand=True) + cmds.select(instances, replace=True, noExpand=True) def deselect(self): - if "nuke" in pyblish.api.registered_hosts(): - import avalon.nuke.lib - avalon.nuke.lib.reset_selection() - - if "maya" in pyblish.api.registered_hosts(): - from maya import cmds - cmds.select(deselect=True) + cmds.select(deselect=True) class RepairSelectInvalidInstances(pyblish.api.Action): @@ -92,23 +78,14 @@ class RepairSelectInvalidInstances(pyblish.api.Action): context_asset = context.data["assetEntity"]["name"] for instance in instances: - if "nuke" in pyblish.api.registered_hosts(): - import openpype.hosts.nuke.api as nuke_api - origin_node = instance[0] - nuke_api.lib.recreate_instance( - origin_node, avalon_data={"asset": context_asset} - ) - else: - self.set_attribute(instance, context_asset) + self.set_attribute(instance, context_asset) def set_attribute(self, instance, context_asset): - if "maya" in pyblish.api.registered_hosts(): - from maya import cmds - cmds.setAttr( - instance.data.get("name") + ".asset", - context_asset, - type="string" - ) + cmds.setAttr( + instance.data.get("name") + ".asset", + context_asset, + type="string" + ) class ValidateInstanceInContext(pyblish.api.InstancePlugin): @@ -124,7 +101,7 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin): order = openpype.api.ValidateContentsOrder label = "Instance in same Context" optional = True - hosts = ["maya", "nuke"] + hosts = ["maya"] actions = [SelectInvalidInstances, RepairSelectInvalidInstances] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/maya/plugins/publish/validate_loaded_plugin.py new file mode 100644 index 0000000000..9306d8ce15 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_loaded_plugin.py @@ -0,0 +1,47 @@ +import pyblish.api +import maya.cmds as cmds +import openpype.api +import os + + +class ValidateLoadedPlugin(pyblish.api.ContextPlugin): + """Ensure there are no unauthorized loaded plugins""" + + label = "Loaded Plugin" + order = pyblish.api.ValidatorOrder + host = ["maya"] + actions = [openpype.api.RepairContextAction] + + @classmethod + def get_invalid(cls, context): + + invalid = [] + loaded_plugin = cmds.pluginInfo(query=True, listPlugins=True) + # get variable from OpenPype settings + whitelist_native_plugins = cls.whitelist_native_plugins + authorized_plugins = cls.authorized_plugins or [] + + for plugin in loaded_plugin: + if not whitelist_native_plugins and os.getenv('MAYA_LOCATION') \ + in cmds.pluginInfo(plugin, query=True, path=True): + continue + if plugin not in authorized_plugins: + invalid.append(plugin) + + return invalid + + def process(self, context): + + invalid = self.get_invalid(context) + if invalid: + raise RuntimeError( + "Found forbidden plugin name: {}".format(", ".join(invalid)) + ) + + @classmethod + def repair(cls, context): + """Unload forbidden plugins""" + + for plugin in cls.get_invalid(context): + cmds.pluginInfo(plugin, edit=True, autoload=False) + cmds.unloadPlugin(plugin, force=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 7c795db43d..65ddacfc57 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -76,7 +76,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): r'%a||', re.IGNORECASE) R_LAYER_TOKEN = re.compile( r'%l||', re.IGNORECASE) - R_CAMERA_TOKEN = re.compile(r'%c|', re.IGNORECASE) + R_CAMERA_TOKEN = re.compile(r'%c|Camera>') R_SCENE_TOKEN = re.compile(r'%s|', re.IGNORECASE) DEFAULT_PADDING = 4 @@ -126,7 +126,9 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): if len(cameras) > 1 and not re.search(cls.R_CAMERA_TOKEN, prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " - "doesn't have: '' token".format(prefix)) + "doesn't have: '' token".format(prefix)) + cls.log.error( + "Note that to needs to have capital 'C' at the beginning") # renderer specific checks if renderer == "vray": diff --git a/openpype/hosts/maya/plugins/publish/validate_setdress_root.py b/openpype/hosts/maya/plugins/publish/validate_setdress_root.py new file mode 100644 index 0000000000..0b4842d208 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_setdress_root.py @@ -0,0 +1,25 @@ + +import pyblish.api +import openpype.api + + +class ValidateSetdressRoot(pyblish.api.InstancePlugin): + """ + """ + + order = openpype.api.ValidateContentsOrder + label = "SetDress Root" + hosts = ["maya"] + families = ["setdress"] + + def process(self, instance): + from maya import cmds + + if instance.data.get("exactSetMembersOnly"): + return + + set_member = instance.data["setMembers"] + root = cmds.ls(set_member, assemblies=True, long=True) + + if not root or root[0] not in set_member: + raise Exception("Setdress top root node is not being published.") diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index f1e81617e0..366f704dd8 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -21,6 +21,7 @@ def add_implementation_envs(env, _app): new_nuke_paths.append(norm_path) env["NUKE_PATH"] = os.pathsep.join(new_nuke_paths) + env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) # Try to add QuickTime to PATH quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 7e7cd27f90..6fe9af744b 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -7,7 +7,7 @@ from collections import OrderedDict from avalon import api, io, lib -from avalon.tools import workfiles +from openpype.tools import workfiles import avalon.nuke from avalon.nuke import lib as anlib from avalon.nuke import ( @@ -287,15 +287,16 @@ def script_name(): def add_button_write_to_read(node): name = "createReadNode" - label = "Cread Read From Rendered" - value = "import write_to_read;write_to_read.write_to_read(nuke.thisNode())" + label = "Create Read From Rendered" + value = "import write_to_read;\ + write_to_read.write_to_read(nuke.thisNode(), allow_relative=False)" knob = nuke.PyScript_Knob(name, label, value) knob.clearFlag(nuke.STARTLINE) node.addKnob(knob) def create_write_node(name, data, input=None, prenodes=None, - review=True, linked_knobs=None): + review=True, linked_knobs=None, farm=True): ''' Creating write node which is group node Arguments: @@ -421,7 +422,15 @@ def create_write_node(name, data, input=None, prenodes=None, )) continue - if knob and value: + if not knob and not value: + continue + + log.info((knob, value)) + + if isinstance(value, str): + if "[" in value: + now_node[knob].setExpression(value) + else: now_node[knob].setValue(value) # connect to previous node @@ -466,7 +475,7 @@ def create_write_node(name, data, input=None, prenodes=None, # imprinting group node anlib.set_avalon_knob_data(GN, data["avalon"]) anlib.add_publish_knob(GN) - add_rendering_knobs(GN) + add_rendering_knobs(GN, farm) if review: add_review_knob(GN) @@ -526,7 +535,7 @@ def create_write_node(name, data, input=None, prenodes=None, return GN -def add_rendering_knobs(node): +def add_rendering_knobs(node, farm=True): ''' Adds additional rendering knobs to given node Arguments: @@ -535,9 +544,13 @@ def add_rendering_knobs(node): Return: node (obj): with added knobs ''' + knob_options = [ + "Use existing frames", "Local"] + if farm: + knob_options.append("On farm") + if "render" not in node.knobs(): - knob = nuke.Enumeration_Knob("render", "", [ - "Use existing frames", "Local", "On farm"]) + knob = nuke.Enumeration_Knob("render", "", knob_options) knob.clearFlag(nuke.STARTLINE) node.addKnob(knob) return node @@ -727,7 +740,7 @@ class WorkfileSettings(object): log.error(msg) nuke.message(msg) - log.warning(">> root_dict: {}".format(root_dict)) + log.debug(">> root_dict: {}".format(root_dict)) # first set OCIO if self._root_node["colorManagement"].value() \ @@ -1277,6 +1290,7 @@ class ExporterReview: def clean_nodes(self): for node in self._temp_nodes: nuke.delete(node) + self._temp_nodes = [] self.log.info("Deleted nodes...") @@ -1301,6 +1315,7 @@ class ExporterReviewLut(ExporterReview): lut_style=None): # initialize parent class ExporterReview.__init__(self, klass, instance) + self._temp_nodes = [] # deal with now lut defined in viewer lut if hasattr(klass, "viewer_lut_raw"): diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py new file mode 100644 index 0000000000..eebb5613c3 --- /dev/null +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -0,0 +1,141 @@ +from collections import OrderedDict +from openpype.hosts.nuke.api import ( + plugin, + lib) +import nuke + + +class CreateWriteStill(plugin.PypeCreator): + # change this to template preset + name = "WriteStillFrame" + label = "Create Write Still Image" + hosts = ["nuke"] + n_class = "Write" + family = "still" + icon = "image" + defaults = [ + "ImageFrame{:0>4}".format(nuke.frame()), + "MPFrame{:0>4}".format(nuke.frame()), + "LayoutFrame{:0>4}".format(nuke.frame()) + ] + + def __init__(self, *args, **kwargs): + super(CreateWriteStill, self).__init__(*args, **kwargs) + + data = OrderedDict() + + data["family"] = self.family + data["families"] = self.n_class + + for k, v in self.data.items(): + if k not in data.keys(): + data.update({k: v}) + + self.data = data + self.nodes = nuke.selectedNodes() + self.log.debug("_ self.data: '{}'".format(self.data)) + + def process(self): + + inputs = [] + outputs = [] + instance = nuke.toNode(self.data["subset"]) + selected_node = None + + # use selection + if (self.options or {}).get("useSelection"): + nodes = self.nodes + + if not (len(nodes) < 2): + msg = ("Select only one node. " + "The node you want to connect to, " + "or tick off `Use selection`") + self.log.error(msg) + nuke.message(msg) + return + + if len(nodes) == 0: + msg = ( + "No nodes selected. Please select a single node to connect" + " to or tick off `Use selection`" + ) + self.log.error(msg) + nuke.message(msg) + return + + selected_node = nodes[0] + inputs = [selected_node] + outputs = selected_node.dependent() + + if instance: + if (instance.name() in selected_node.name()): + selected_node = instance.dependencies()[0] + + # if node already exist + if instance: + # collect input / outputs + inputs = instance.dependencies() + outputs = instance.dependent() + selected_node = inputs[0] + # remove old one + nuke.delete(instance) + + # recreate new + write_data = { + "nodeclass": self.n_class, + "families": [self.family], + "avalon": self.data + } + + # add creator data + creator_data = {"creator": self.__class__.__name__} + self.data.update(creator_data) + write_data.update(creator_data) + + self.log.info("Adding template path from plugin") + write_data.update({ + "fpath_template": ( + "{work}/renders/nuke/{subset}/{subset}.{ext}")}) + + _prenodes = [ + { + "name": "FrameHold01", + "class": "FrameHold", + "knobs": [ + ("first_frame", nuke.frame()) + ], + "dependent": None + } + ] + + write_node = lib.create_write_node( + self.name, + write_data, + input=selected_node, + review=False, + prenodes=_prenodes, + farm=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"]) + + # relinking to collected connections + for i, input in enumerate(inputs): + write_node.setInput(i, input) + + write_node.autoplace() + + for output in outputs: + output.setInput(0, write_node) + + # link frame hold to group node + write_node.begin() + for n in nuke.allNodes(): + # get write node + if n.Class() in "Write": + w_node = n + write_node.end() + + w_node["use_limit"].setValue(True) + w_node["first"].setValue(nuke.frame()) + w_node["last"].setValue(nuke.frame()) + + return write_node diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 8bc266f01b..9b8bc43d12 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -13,7 +13,7 @@ class LoadImage(api.Loader): """Load still image into Nuke""" families = ["render", "source", "plate", "review", "image"] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "psd"] + representations = ["exr", "dpx", "jpg", "jpeg", "png", "psd", "tiff"] label = "Load Image" order = -10 diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py index 5f2128b10f..003b406ee7 100644 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ b/openpype/hosts/nuke/plugins/load/load_sequence.py @@ -76,6 +76,8 @@ class LoadSequence(api.Loader): file = file.replace("\\", "/") repr_cont = context["representation"]["context"] + assert repr_cont.get("frame"), "Representation is not sequence" + if "#" not in file: frame = repr_cont.get("frame") if frame: @@ -170,6 +172,7 @@ class LoadSequence(api.Loader): assert read_node.Class() == "Read", "Must be Read" repr_cont = representation["context"] + assert repr_cont.get("frame"), "Representation is not sequence" file = api.get_representation_path(representation) diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index a144761e5f..c3a6a3b167 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -2,6 +2,7 @@ import nuke import pyblish.api from avalon.nuke import maintained_selection + class CreateOutputNode(pyblish.api.ContextPlugin): """Adding output node for each ouput write node So when latly user will want to Load .nk as LifeGroup or Precomp @@ -15,8 +16,8 @@ class CreateOutputNode(pyblish.api.ContextPlugin): def process(self, context): # capture selection state with maintained_selection(): - active_node = [node for inst in context[:] - for node in inst[:] + active_node = [node for inst in context + for node in inst if "ak:family" in node.knobs()] if active_node: diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index 49609f70e0..bc7b41c733 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -17,7 +17,7 @@ class NukeRenderLocal(openpype.api.Extractor): order = pyblish.api.ExtractorOrder label = "Render Local" hosts = ["nuke"] - families = ["render.local", "prerender.local"] + families = ["render.local", "prerender.local", "still.local"] def process(self, instance): families = instance.data["families"] @@ -66,13 +66,23 @@ class NukeRenderLocal(openpype.api.Extractor): instance.data["representations"] = [] collected_frames = os.listdir(out_dir) - repre = { - 'name': ext, - 'ext': ext, - 'frameStart': "%0{}d".format(len(str(last_frame))) % first_frame, - 'files': collected_frames, - "stagingDir": out_dir - } + + if len(collected_frames) == 1: + repre = { + 'name': ext, + 'ext': ext, + 'files': collected_frames.pop(), + "stagingDir": out_dir + } + else: + repre = { + 'name': ext, + 'ext': ext, + 'frameStart': "%0{}d".format( + len(str(last_frame))) % first_frame, + 'files': collected_frames, + "stagingDir": out_dir + } instance.data["representations"].append(repre) self.log.info("Extracted instance '{0}' to: {1}".format( @@ -89,6 +99,9 @@ class NukeRenderLocal(openpype.api.Extractor): instance.data['family'] = 'prerender' families.remove('prerender.local') families.insert(0, "prerender") + elif "still.local" in families: + instance.data['family'] = 'image' + families.remove('still.local') instance.data["families"] = families collections, remainder = clique.assemble(collected_frames) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index b0d3ec6241..a0f1c9a087 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -3,6 +3,12 @@ import pyblish.api from avalon.nuke import lib as anlib from openpype.hosts.nuke.api import lib as pnlib import openpype + +try: + from __builtin__ import reload +except ImportError: + from importlib import reload + reload(pnlib) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index cea7d86c26..f4fbc2d0e4 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -4,6 +4,13 @@ from avalon.nuke import lib as anlib from openpype.hosts.nuke.api import lib as pnlib import openpype +try: + from __builtin__ import reload +except ImportError: + from importlib import reload + +reload(pnlib) + class ExtractReviewDataMov(openpype.api.Extractor): """Extracts movie and thumbnail with baked in luts diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index da30dcc632..0c9af66435 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -1,3 +1,4 @@ +import sys import os import nuke from avalon.nuke import lib as anlib @@ -5,6 +6,10 @@ import pyblish.api import openpype +if sys.version_info[0] >= 3: + unicode = str + + class ExtractThumbnail(openpype.api.Extractor): """Extracts movie and thumbnail with baked in luts @@ -112,24 +117,26 @@ class ExtractThumbnail(openpype.api.Extractor): # create write node write_node = nuke.createNode("Write") - file = fhead + "jpeg" + file = fhead + "jpg" name = "thumbnail" path = os.path.join(staging_dir, file).replace("\\", "/") instance.data["thumbnail"] = path write_node["file"].setValue(path) - write_node["file_type"].setValue("jpeg") + write_node["file_type"].setValue("jpg") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) tags = ["thumbnail", "publish_on_farm"] # retime for + mid_frame = int((int(last_frame) - int(first_frame)) / 2) \ + + int(first_frame) first_frame = int(last_frame) / 2 last_frame = int(last_frame) / 2 repre = { 'name': name, - 'ext': "jpeg", + 'ext': "jpg", "outputName": "thumb", 'files': file, "stagingDir": staging_dir, @@ -140,7 +147,7 @@ class ExtractThumbnail(openpype.api.Extractor): instance.data["representations"].append(repre) # Render frames - nuke.execute(write_node.name(), int(first_frame), int(last_frame)) + nuke.execute(write_node.name(), int(mid_frame), int(mid_frame)) self.log.debug( "representations: {}".format(instance.data["representations"])) diff --git a/openpype/hosts/nuke/plugins/publish/increment_script_version.py b/openpype/hosts/nuke/plugins/publish/increment_script_version.py index 47fccb9125..f55ed21ee2 100644 --- a/openpype/hosts/nuke/plugins/publish/increment_script_version.py +++ b/openpype/hosts/nuke/plugins/publish/increment_script_version.py @@ -9,7 +9,7 @@ class IncrementScriptVersion(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Script Version" optional = True - families = ["workfile", "render", "render.local", "render.farm"] + families = ["workfile"] hosts = ['nuke'] def process(self, context): diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py index c2c25d0627..5c30df9a62 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py @@ -8,12 +8,12 @@ from avalon.nuke import lib as anlib class PreCollectNukeInstances(pyblish.api.ContextPlugin): """Collect all nodes with Avalon knob.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Pre-collect Instances" hosts = ["nuke", "nukeassist"] # presets - sync_workfile_version = False + sync_workfile_version_on_families = [] def process(self, context): asset_data = io.find_one({ @@ -120,11 +120,12 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): # sync workfile version _families_test = [family] + families self.log.debug("__ _families_test: `{}`".format(_families_test)) - if not next((f for f in _families_test - if "prerender" in f), - None) and self.sync_workfile_version: - # get version to instance for integration - instance.data['version'] = instance.context.data['version'] + for family_test in _families_test: + if family_test in self.sync_workfile_version_on_families: + self.log.debug("Syncing version with workfile for '{}'" + .format(family_test)) + # get version to instance for integration + instance.data['version'] = instance.context.data['version'] instance.data.update({ "subset": subset, diff --git a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py index 5d3eb5f609..0e27273ceb 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py @@ -3,13 +3,12 @@ import pyblish.api import os import openpype.api as pype from avalon.nuke import lib as anlib -reload(anlib) class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" - order = pyblish.api.CollectorOrder - 0.60 + order = pyblish.api.CollectorOrder - 0.50 label = "Pre-collect Workfile" hosts = ['nuke'] diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py index 0b5fbc0479..189f28f7c6 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py @@ -11,7 +11,7 @@ from avalon import io, api class CollectNukeWrites(pyblish.api.InstancePlugin): """Collect all write nodes.""" - order = pyblish.api.CollectorOrder - 0.58 + order = pyblish.api.CollectorOrder - 0.48 label = "Pre-collect Writes" hosts = ["nuke", "nukeassist"] families = ["write"] @@ -64,7 +64,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): ) if [fm for fm in _families_test - if fm in ["render", "prerender"]]: + if fm in ["render", "prerender", "still"]]: if "representations" not in instance.data: instance.data["representations"] = list() @@ -100,7 +100,13 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): frame_start_str, frame_slate_str) collected_frames.insert(0, slate_frame) - representation['files'] = collected_frames + if collected_frames_len == 1: + representation['files'] = collected_frames.pop() + if "still" in _families_test: + instance.data['family'] = 'image' + instance.data["families"].remove('still') + else: + representation['files'] = collected_frames instance.data["representations"].append(representation) except Exception: instance.data["representations"].append(representation) diff --git a/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py new file mode 100644 index 0000000000..ddf46a0873 --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Validate if instance asset is the same as context asset.""" +from __future__ import absolute_import + +import nuke + +import pyblish.api +import openpype.api +import avalon.nuke.lib +import openpype.hosts.nuke.api as nuke_api + + +class SelectInvalidInstances(pyblish.api.Action): + """Select invalid instances in Outliner.""" + + label = "Select Instances" + icon = "briefcase" + on = "failed" + + def process(self, context, plugin): + """Process invalid validators and select invalid instances.""" + # Get the errored instances + failed = [] + for result in context.data["results"]: + if ( + result["error"] is None + or result["instance"] is None + or result["instance"] in failed + or result["plugin"] != plugin + ): + continue + + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + + if instances: + self.log.info( + "Selecting invalid nodes: %s" % ", ".join( + [str(x) for x in instances] + ) + ) + self.select(instances) + else: + self.log.info("No invalid nodes found.") + self.deselect() + + def select(self, instances): + avalon.nuke.lib.select_nodes( + [nuke.toNode(str(x)) for x in instances] + ) + + def deselect(self): + avalon.nuke.lib.reset_selection() + + +class RepairSelectInvalidInstances(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + # Get the errored instances + failed = [] + for result in context.data["results"]: + if ( + result["error"] is None + or result["instance"] is None + or result["instance"] in failed + or result["plugin"] != plugin + ): + continue + + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + + context_asset = context.data["assetEntity"]["name"] + for instance in instances: + origin_node = instance[0] + nuke_api.lib.recreate_instance( + origin_node, avalon_data={"asset": context_asset} + ) + + +class ValidateInstanceInContext(pyblish.api.InstancePlugin): + """Validator to check if instance asset match context asset. + + When working in per-shot style you always publish data in context of + current asset (shot). This validator checks if this is so. It is optional + so it can be disabled when needed. + + Action on this validator will select invalid instances in Outliner. + """ + + order = openpype.api.ValidateContentsOrder + label = "Instance in same Context" + hosts = ["nuke"] + actions = [SelectInvalidInstances, RepairSelectInvalidInstances] + optional = True + + def process(self, instance): + asset = instance.data.get("asset") + context_asset = instance.context.data["assetEntity"]["name"] + msg = "{} has asset {}".format(instance.name, asset) + assert asset == context_asset, msg diff --git a/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py b/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py new file mode 100644 index 0000000000..9c6ca03ffd --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py @@ -0,0 +1,33 @@ +import pyblish +import nuke + + +class FixProxyMode(pyblish.api.Action): + """ + Togger off proxy switch OFF + """ + + label = "Proxy toggle to OFF" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + rootNode = nuke.root() + rootNode["proxy"].setValue(False) + + +@pyblish.api.log +class ValidateProxyMode(pyblish.api.ContextPlugin): + """Validate active proxy mode""" + + order = pyblish.api.ValidatorOrder + label = "Validate Proxy Mode" + hosts = ["nuke"] + actions = [FixProxyMode] + + def process(self, context): + + rootNode = nuke.root() + isProxy = rootNode["proxy"].value() + + assert not isProxy, "Proxy mode should be toggled OFF" diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 0c88014649..29faf867d2 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -55,7 +55,7 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): """ Validates file output. """ order = pyblish.api.ValidatorOrder + 0.1 - families = ["render", "prerender"] + families = ["render", "prerender", "still"] label = "Validate rendered frame" hosts = ["nuke", "nukestudio"] @@ -71,6 +71,9 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): self.log.error(msg) raise ValidationException(msg) + if isinstance(repre["files"], str): + return + collections, remainder = clique.assemble(repre["files"]) self.log.info("collections: {}".format(str(collections))) self.log.info("remainder: {}".format(str(remainder))) diff --git a/openpype/hosts/nuke/startup/write_to_read.py b/openpype/hosts/nuke/startup/write_to_read.py index deb5ce1b82..f5cf66b357 100644 --- a/openpype/hosts/nuke/startup/write_to_read.py +++ b/openpype/hosts/nuke/startup/write_to_read.py @@ -9,7 +9,9 @@ SINGLE_FILE_FORMATS = ['avi', 'mp4', 'mxf', 'mov', 'mpg', 'mpeg', 'wmv', 'm4v', 'm2v'] -def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): +def evaluate_filepath_new( + k_value, k_eval, project_dir, first_frame, allow_relative): + # get combined relative path combined_relative_path = None if k_eval is not None and project_dir is not None: @@ -26,8 +28,9 @@ def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): combined_relative_path = None try: - k_value = k_value % first_frame - if os.path.exists(k_value): + # k_value = k_value % first_frame + if os.path.isdir(os.path.basename(k_value)): + # doesn't check for file, only parent dir filepath = k_value elif os.path.exists(k_eval): filepath = k_eval @@ -37,10 +40,12 @@ def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): filepath = os.path.abspath(filepath) except Exception as E: - log.error("Cannot create Read node. Perhaps it needs to be rendered first :) Error: `{}`".format(E)) + log.error("Cannot create Read node. Perhaps it needs to be \ + rendered first :) Error: `{}`".format(E)) return None filepath = filepath.replace('\\', '/') + # assumes last number is a sequence counter current_frame = re.findall(r'\d+', filepath)[-1] padding = len(current_frame) basename = filepath[: filepath.rfind(current_frame)] @@ -51,11 +56,13 @@ def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): pass else: # Image sequence needs hashes + # to do still with no number not handled filepath = basename + '#' * padding + '.' + filetype # relative path? make it relative again - if not isinstance(project_dir, type(None)): - filepath = filepath.replace(project_dir, '.') + if allow_relative: + if (not isinstance(project_dir, type(None))) and project_dir != "": + filepath = filepath.replace(project_dir, '.') # get first and last frame from disk frames = [] @@ -69,7 +76,8 @@ def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): frames = sorted(frames) firstframe = frames[0] lastframe = frames[len(frames) - 1] - if lastframe < 0: + + if int(lastframe) < 0: lastframe = firstframe return filepath, firstframe, lastframe @@ -94,41 +102,40 @@ def create_read_node(ndata, comp_start): return -def write_to_read(gn): +def write_to_read(gn, + allow_relative=False): + comp_start = nuke.Root().knob('first_frame').value() - comp_end = nuke.Root().knob('last_frame').value() project_dir = nuke.Root().knob('project_directory').getValue() if not os.path.exists(project_dir): project_dir = nuke.Root().knob('project_directory').evaluate() group_read_nodes = [] - with gn: height = gn.screenHeight() # get group height and position new_xpos = int(gn.knob('xpos').value()) new_ypos = int(gn.knob('ypos').value()) + height + 20 group_writes = [n for n in nuke.allNodes() if n.Class() == "Write"] - print("__ group_writes: {}".format(group_writes)) if group_writes != []: # there can be only 1 write node, taking first n = group_writes[0] if n.knob('file') is not None: - file_path_new = evaluate_filepath_new( + myfile, firstFrame, lastFrame = evaluate_filepath_new( n.knob('file').getValue(), n.knob('file').evaluate(), project_dir, - comp_start + comp_start, + allow_relative ) - if not file_path_new: + if not myfile: return - myfiletranslated, firstFrame, lastFrame = file_path_new # get node data ndata = { - 'filepath': myfiletranslated, - 'firstframe': firstFrame, - 'lastframe': lastFrame, + 'filepath': myfile, + 'firstframe': int(firstFrame), + 'lastframe': int(lastFrame), 'new_xpos': new_xpos, 'new_ypos': new_ypos, 'colorspace': n.knob('colorspace').getValue(), @@ -138,7 +145,6 @@ def write_to_read(gn): } group_read_nodes.append(ndata) - # create reads in one go for oneread in group_read_nodes: # create read node diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index b52078fd5f..1c53c3a2ef 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -60,7 +60,8 @@ class ExtractReview(openpype.api.Extractor): # Generate thumbnail. thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") args = [ - "{}".format(ffmpeg_path), "-y", + ffmpeg_path, + "-y", "-i", output_image_path, "-vf", "scale=300:-1", "-vframes", "1", @@ -78,7 +79,8 @@ class ExtractReview(openpype.api.Extractor): # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") args = [ - ffmpeg_path, "-y", + ffmpeg_path, + "-y", "-i", output_image_path, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", "1", diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index e7be3fc963..c639fd2db8 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -10,11 +10,13 @@ from .pipeline import ( from avalon.tools import ( creator, - loader, sceneinventory, - libraryloader, subsetmanager ) +from openpype.tools import ( + loader, + libraryloader, +) def load_stylesheet(): diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index a659ac7e51..80249310e8 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -4,7 +4,7 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.tools import workfiles +from openpype.tools import workfiles from avalon import api as avalon from avalon import schema from avalon.pipeline import AVALON_CONTAINER_ID diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py index 95b891d95a..8f1a13a4e5 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py @@ -8,7 +8,7 @@ from pprint import pformat class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["resolve"] diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index ee05fb6f13..1333516177 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -13,7 +13,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): """Precollect the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.6 + order = pyblish.api.CollectorOrder - 0.5 def process(self, context): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py index f7f96c7d03..adbac6ef09 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py @@ -11,6 +11,7 @@ import zipfile import pyblish.api from avalon import api, io import openpype.api +from openpype.lib import get_workfile_template_key_from_context class ExtractHarmonyZip(openpype.api.Extractor): @@ -65,10 +66,10 @@ class ExtractHarmonyZip(openpype.api.Extractor): # Get Task types and Statuses for creation if needed self.task_types = self._get_all_task_types(project_entity) - self.task_statuses = self.get_all_task_statuses(project_entity) + self.task_statuses = self._get_all_task_statuses(project_entity) # Get Statuses of AssetVersions - self.assetversion_statuses = self.get_all_assetversion_statuses( + self.assetversion_statuses = self._get_all_assetversion_statuses( project_entity ) @@ -233,18 +234,28 @@ class ExtractHarmonyZip(openpype.api.Extractor): "version": 1, "ext": "zip", } + host_name = "harmony" + template_name = get_workfile_template_key_from_context( + instance.data["asset"], + instance.data.get("task"), + host_name, + project_name=project_entity["name"], + dbcon=io + ) # Get a valid work filename first with version 1 - file_template = anatomy.templates["work"]["file"] + file_template = anatomy.templates[template_name]["file"] anatomy_filled = anatomy.format(data) - work_path = anatomy_filled["work"]["path"] + work_path = anatomy_filled[template_name]["path"] # Get the final work filename with the proper version data["version"] = api.last_workfile_with_version( - os.path.dirname(work_path), file_template, data, [".zip"] + os.path.dirname(work_path), + file_template, + data, + api.HOST_WORKFILE_EXTENSIONS[host_name] )[1] - work_path = anatomy_filled["work"]["path"] base_name = os.path.splitext(os.path.basename(work_path))[0] staging_work_path = os.path.join(os.path.dirname(staging_scene), diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 0792254716..23f0b104c8 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -58,7 +58,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): # use first frame as thumbnail if is sequence of jpegs full_thumbnail_path = os.path.join( thumbnail_repre["stagingDir"], file - ) + ) self.log.info( "For thumbnail is used file: {}".format(full_thumbnail_path) ) @@ -116,7 +116,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): # create new thumbnail representation representation = { - 'name': 'jpg', + 'name': 'thumbnail', 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py index 059ac9603c..c18de5bc1c 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py @@ -59,32 +59,35 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): if "trimming" not in fml ] - args = [ - f"\"{ffmpeg_path}\"", + ffmpeg_args = [ + ffmpeg_path, "-ss", str(start / fps), - "-i", f"\"{video_file_path}\"", + "-i", video_file_path, "-t", str(dur / fps) ] if ext in [".mov", ".mp4"]: - args.extend([ + ffmpeg_args.extend([ "-crf", "18", - "-pix_fmt", "yuv420p"]) + "-pix_fmt", "yuv420p" + ]) elif ext in ".wav": - args.extend([ - "-vn -acodec pcm_s16le", - "-ar 48000 -ac 2" + ffmpeg_args.extend([ + "-vn", + "-acodec", "pcm_s16le", + "-ar", "48000", + "-ac", "2" ]) # add output path - args.append(f"\"{clip_trimed_path}\"") + ffmpeg_args.append(clip_trimed_path) - self.log.info(f"Processing: {args}") - ffmpeg_args = " ".join(args) + joined_args = " ".join(ffmpeg_args) + self.log.info(f"Processing: {joined_args}") openpype.api.run_subprocess( - ffmpeg_args, shell=True, logger=self.log + ffmpeg_args, logger=self.log ) - repr = { + repre = { "name": ext[1:], "ext": ext[1:], "files": os.path.basename(clip_trimed_path), @@ -97,10 +100,10 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): } if ext in [".mov", ".mp4"]: - repr.update({ + repre.update({ "thumbnail": True, "tags": ["review", "ftrackreview", "delete"]}) - instance.data["representations"].append(repr) + instance.data["representations"].append(repre) self.log.debug(f"Instance data: {pformat(instance.data)}") diff --git a/openpype/hosts/tvpaint/api/__init__.py b/openpype/hosts/tvpaint/api/__init__.py index 57a03d38b7..1c50987d6d 100644 --- a/openpype/hosts/tvpaint/api/__init__.py +++ b/openpype/hosts/tvpaint/api/__init__.py @@ -1,6 +1,8 @@ import os import logging +import requests + import avalon.api import pyblish.api from avalon.tvpaint import pipeline @@ -8,6 +10,7 @@ from avalon.tvpaint.communication_server import register_localization_file from .lib import set_context_settings from openpype.hosts import tvpaint +from openpype.api import get_current_project_settings log = logging.getLogger(__name__) @@ -51,6 +54,19 @@ def initial_launch(): set_context_settings() +def application_exit(): + data = get_current_project_settings() + stop_timer = data["tvpaint"]["stop_timer_on_application_exit"] + + if not stop_timer: + return + + # Stop application timer. + webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") + rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) + requests.post(rest_api_url) + + def install(): log.info("OpenPype - Installing TVPaint integration") localization_file = os.path.join(HOST_DIR, "resources", "avalon.loc") @@ -67,6 +83,7 @@ def install(): pyblish.api.register_callback("instanceToggled", on_instance_toggle) avalon.api.on("application.launched", initial_launch) + avalon.api.on("application.exit", application_exit) def uninstall(): diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index 3eb9a5be31..e148e44a27 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -3,4 +3,17 @@ from avalon.tvpaint import pipeline class Creator(PypeCreatorMixin, pipeline.Creator): - pass + @classmethod + def get_dynamic_data(cls, *args, **kwargs): + dynamic_data = super(Creator, cls).get_dynamic_data(*args, **kwargs) + + # Change asset and name by current workfile context + workfile_context = pipeline.get_current_workfile_context() + asset_name = workfile_context.get("asset") + task_name = workfile_context.get("task") + if "asset" not in dynamic_data and asset_name: + dynamic_data["asset"] = asset_name + + if "task" not in dynamic_data and task_name: + dynamic_data["task"] = task_name + return dynamic_data diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index e496b144cd..dfa8f17ee9 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -9,7 +9,7 @@ from openpype.lib import get_subset_name class CollectInstances(pyblish.api.ContextPlugin): label = "Collect Instances" - order = pyblish.api.CollectorOrder - 1 + order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] def process(self, context): diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index b61fec895f..65e38ea258 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -8,7 +8,7 @@ from openpype.lib import get_subset_name class CollectWorkfile(pyblish.api.ContextPlugin): label = "Collect Workfile" - order = pyblish.api.CollectorOrder - 1 + order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] def process(self, context): diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 79cc01740a..f4259f1b5f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -39,7 +39,7 @@ class ResetTVPaintWorkfileMetadata(pyblish.api.Action): class CollectWorkfileData(pyblish.api.ContextPlugin): label = "Collect Workfile Data" - order = pyblish.api.CollectorOrder - 1.01 + order = pyblish.api.CollectorOrder - 0.45 hosts = ["tvpaint"] actions = [ResetTVPaintWorkfileMetadata] diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 36f0b0c954..c45ff53c3c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -606,7 +606,7 @@ class ExtractSequence(pyblish.api.Extractor): self._copy_image(eq_frame_filepath, new_filepath) layer_files_by_frame[frame_idx] = new_filepath - elif pre_behavior == "loop": + elif pre_behavior in ("loop", "repeat"): # Loop backwards from last frame of layer for frame_idx in reversed(range(mark_in_index, frame_start_index)): eq_frame_idx_offset = ( @@ -678,7 +678,7 @@ class ExtractSequence(pyblish.api.Extractor): self._copy_image(eq_frame_filepath, new_filepath) layer_files_by_frame[frame_idx] = new_filepath - elif post_behavior == "loop": + elif post_behavior in ("loop", "repeat"): # Loop backwards from last frame of layer for frame_idx in range(frame_end_index + 1, mark_out_index + 1): eq_frame_idx = frame_idx % frame_count diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 6584120d97..7e9b98956a 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -10,6 +10,7 @@ Provides: import os import json import clique +import tempfile import pyblish.api from avalon import io @@ -94,7 +95,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["families"] = families instance.data["version"] = \ self._get_last_version(asset, subset) + 1 - instance.data["stagingDir"] = task_dir + instance.data["stagingDir"] = tempfile.mkdtemp() instance.data["source"] = "webpublisher" # to store logging info into DB openpype.webpublishes @@ -113,6 +114,8 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["frameEnd"] = \ instance.data["representations"][0]["frameEnd"] else: + instance.data["frameStart"] = 0 + instance.data["frameEnd"] = 1 instance.data["representations"] = self._get_single_repre( task_dir, task_data["files"], tags ) @@ -174,7 +177,11 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): (family, [families], subset_template_name, tags) tuple AssertionError if not matching family found """ - task_obj = settings.get(task_type) + task_type = task_type.lower() + lower_cased_task_types = {} + for t_type, task in settings.items(): + lower_cased_task_types[t_type.lower()] = task + task_obj = lower_cased_task_types.get(task_type) assert task_obj, "No family configuration for '{}'".format(task_type) found_family = None diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 3d392dc745..74004a1239 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -27,6 +27,7 @@ from .execute import ( get_pype_execute_args, execute, run_subprocess, + path_to_subprocess_arg, CREATE_NO_WINDOW ) from .log import PypeLogger, timeit @@ -59,6 +60,11 @@ from .python_module_tools import ( import_module_from_dirpath ) +from .profiles_filtering import ( + compile_list_of_regexes, + filter_profiles +) + from .avalon_context import ( CURRENT_DOC_SCHEMAS, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -118,13 +124,9 @@ from .applications import ( prepare_host_environments, prepare_context_environments, get_app_environments_for_context, - apply_project_environments_value, - - compile_list_of_regexes + apply_project_environments_value ) -from .profiles_filtering import filter_profiles - from .plugin_tools import ( TaskNotSetError, get_subset_name, @@ -143,7 +145,9 @@ from .plugin_tools import ( from .path_tools import ( version_up, get_version_from_path, - get_last_version_from_path + get_last_version_from_path, + create_project_folders, + get_project_basic_paths ) from .editorial import ( @@ -158,12 +162,19 @@ from .editorial import ( make_sequence_collection ) +from .pype_info import ( + get_openpype_version, + get_build_version +) + terminal = Terminal __all__ = [ "get_pype_execute_args", "execute", "run_subprocess", + "path_to_subprocess_arg", + "CREATE_NO_WINDOW", "env_value_to_bool", "get_paths_from_environ", @@ -276,5 +287,10 @@ __all__ = [ "range_from_frames", "frames_to_secons", "frames_to_timecode", - "make_sequence_collection" + "make_sequence_collection", + "create_project_folders", + "get_project_basic_paths", + + "get_openpype_version", + "get_build_version", ] diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fbf991a32e..cc8cb8e7be 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -25,11 +25,12 @@ from . import ( PypeLogger, Anatomy ) +from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, get_workdir_with_workdir_data, - get_workfile_template_key_from_context + get_workfile_template_key ) from .python_module_tools import ( @@ -1161,8 +1162,12 @@ def prepare_host_environments(data, implementation_envs=True): if final_env is None: final_env = loaded_env + keys_to_remove = set(data["env"].keys()) - set(final_env.keys()) + # Update env data["env"].update(final_env) + for key in keys_to_remove: + data["env"].pop(key, None) def apply_project_environments_value(project_name, env, project_settings=None): @@ -1226,8 +1231,12 @@ def prepare_context_environments(data): # Load project specific environments project_name = project_doc["name"] + project_settings = get_project_settings(project_name) + data["project_settings"] = project_settings # Apply project specific environments on current env value - apply_project_environments_value(project_name, data["env"]) + apply_project_environments_value( + project_name, data["env"], project_settings + ) app = data["app"] workdir_data = get_workdir_data( @@ -1237,17 +1246,22 @@ def prepare_context_environments(data): anatomy = data["anatomy"] - template_key = get_workfile_template_key_from_context( - asset_doc["name"], - task_name, + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + # Temp solution how to pass task type to `_prepare_last_workfile` + data["task_type"] = task_type + + workfile_template_key = get_workfile_template_key( + task_type, app.host_name, project_name=project_name, - dbcon=data["dbcon"] + project_settings=project_settings ) try: workdir = get_workdir_with_workdir_data( - workdir_data, anatomy, template_key=template_key + workdir_data, anatomy, template_key=workfile_template_key ) except Exception as exc: @@ -1281,10 +1295,10 @@ def prepare_context_environments(data): ) data["env"].update(context_env) - _prepare_last_workfile(data, workdir) + _prepare_last_workfile(data, workdir, workfile_template_key) -def _prepare_last_workfile(data, workdir): +def _prepare_last_workfile(data, workdir, workfile_template_key): """last workfile workflow preparation. Function check if should care about last workfile workflow and tries @@ -1314,13 +1328,14 @@ def _prepare_last_workfile(data, workdir): workdir_data = copy.deepcopy(_workdir_data) project_name = data["project_name"] task_name = data["task_name"] + task_type = data["task_type"] start_last_workfile = should_start_last_workfile( - project_name, app.host_name, task_name + project_name, app.host_name, task_name, task_type ) data["start_last_workfile"] = start_last_workfile workfile_startup = should_workfile_tool_start( - project_name, app.host_name, task_name + project_name, app.host_name, task_name, task_type ) data["workfile_startup"] = workfile_startup @@ -1338,23 +1353,23 @@ def _prepare_last_workfile(data, workdir): ) # Last workfile path - last_workfile_path = "" - extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get( - app.host_name - ) - if extensions: - anatomy = data["anatomy"] - # Find last workfile - file_template = anatomy.templates["work"]["file"] - workdir_data.update({ - "version": 1, - "user": get_openpype_username(), - "ext": extensions[0] - }) + last_workfile_path = data.get("last_workfile_path") or "" + if not last_workfile_path: + extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name) - last_workfile_path = avalon.api.last_workfile( - workdir, file_template, workdir_data, extensions, True - ) + if extensions: + anatomy = data["anatomy"] + # Find last workfile + file_template = anatomy.templates["work"]["file"] + workdir_data.update({ + "version": 1, + "user": get_openpype_username(), + "ext": extensions[0] + }) + + last_workfile_path = avalon.api.last_workfile( + workdir, file_template, workdir_data, extensions, True + ) if os.path.exists(last_workfile_path): log.debug(( @@ -1369,54 +1384,8 @@ def _prepare_last_workfile(data, workdir): data["last_workfile_path"] = last_workfile_path -def get_option_from_settings( - startup_presets, host_name, task_name, default_output -): - host_name_lowered = host_name.lower() - task_name_lowered = task_name.lower() - - max_points = 2 - matching_points = -1 - matching_item = None - for item in startup_presets: - hosts = item.get("hosts") or tuple() - tasks = item.get("tasks") or tuple() - - hosts_lowered = tuple(_host_name.lower() for _host_name in hosts) - # Skip item if has set hosts and current host is not in - if hosts_lowered and host_name_lowered not in hosts_lowered: - continue - - tasks_lowered = tuple(_task_name.lower() for _task_name in tasks) - # Skip item if has set tasks and current task is not in - if tasks_lowered: - task_match = False - for task_regex in compile_list_of_regexes(tasks_lowered): - if re.match(task_regex, task_name_lowered): - task_match = True - break - - if not task_match: - continue - - points = int(bool(hosts_lowered)) + int(bool(tasks_lowered)) - if points > matching_points: - matching_item = item - matching_points = points - - if matching_points == max_points: - break - - if matching_item is not None: - output = matching_item.get("enabled") - if output is None: - output = default_output - return output - return default_output - - def should_start_last_workfile( - project_name, host_name, task_name, default_output=False + project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start last version workfile if possible. @@ -1438,7 +1407,7 @@ def should_start_last_workfile( """ project_settings = get_project_settings(project_name) - startup_presets = ( + profiles = ( project_settings ["global"] ["tools"] @@ -1446,15 +1415,27 @@ def should_start_last_workfile( ["last_workfile_on_startup"] ) - if not startup_presets: + if not profiles: return default_output - return get_option_from_settings( - startup_presets, host_name, task_name, default_output) + filter_data = { + "tasks": task_name, + "task_types": task_type, + "hosts": host_name + } + matching_item = filter_profiles(profiles, filter_data) + + output = None + if matching_item: + output = matching_item.get("enabled") + + if output is None: + return default_output + return output def should_workfile_tool_start( - project_name, host_name, task_name, default_output=False + project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start workfile tool at host launch. @@ -1476,7 +1457,7 @@ def should_workfile_tool_start( """ project_settings = get_project_settings(project_name) - startup_presets = ( + profiles = ( project_settings ["global"] ["tools"] @@ -1484,27 +1465,20 @@ def should_workfile_tool_start( ["open_workfile_tool_on_startup"] ) - if not startup_presets: + if not profiles: return default_output - return get_option_from_settings( - startup_presets, host_name, task_name, default_output) + filter_data = { + "tasks": task_name, + "task_types": task_type, + "hosts": host_name + } + matching_item = filter_profiles(profiles, filter_data) + output = None + if matching_item: + output = matching_item.get("enabled") -def compile_list_of_regexes(in_list): - """Convert strings in entered list to compiled regex objects.""" - regexes = list() - if not in_list: - return regexes - - for item in in_list: - if not item: - continue - try: - regexes.append(re.compile(item)) - except TypeError: - print(( - "Invalid type \"{}\" value \"{}\"." - " Expected string based object. Skipping." - ).format(str(type(item)), str(item))) - return regexes + if output is None: + return default_output + return output diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 497348af33..b043cbfdb4 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -10,6 +10,7 @@ import functools from openpype.settings import get_project_settings from .anatomy import Anatomy +from .profiles_filtering import filter_profiles # avalon module is not imported at the top # - may not be in path at the time of pype.lib initialization @@ -453,8 +454,6 @@ def get_workfile_template_key( if not profiles: return default - from .profiles_filtering import filter_profiles - profile_filter = { "task_types": task_type, "hosts": host_name @@ -791,7 +790,9 @@ class BuildWorkfile: current_task_name = avalon.io.Session["AVALON_TASK"] # Load workfile presets for task - self.build_presets = self.get_build_presets(current_task_name) + self.build_presets = self.get_build_presets( + current_task_name, current_asset_entity + ) # Skip if there are any presets for task if not self.build_presets: @@ -875,7 +876,7 @@ class BuildWorkfile: return loaded_containers @with_avalon - def get_build_presets(self, task_name): + def get_build_presets(self, task_name, asset_doc): """ Returns presets to build workfile for task name. Presets are loaded for current project set in @@ -889,30 +890,33 @@ class BuildWorkfile: (dict): preset per entered task name """ host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] - presets = get_project_settings(avalon.io.Session["AVALON_PROJECT"]) + project_settings = get_project_settings( + avalon.io.Session["AVALON_PROJECT"] + ) + host_settings = project_settings.get(host_name) or {} # Get presets for host - wb_settings = presets.get(host_name, {}).get("workfile_builder") - + wb_settings = host_settings.get("workfile_builder") if not wb_settings: # backward compatibility - wb_settings = presets.get(host_name, {}).get("workfile_build") + wb_settings = host_settings.get("workfile_build") or {} - builder_presets = wb_settings.get("profiles") + builder_profiles = wb_settings.get("profiles") + if not builder_profiles: + return None - if not builder_presets: - return - - task_name_low = task_name.lower() - per_task_preset = None - for preset in builder_presets: - preset_tasks = preset.get("tasks") or [] - preset_tasks_low = [task.lower() for task in preset_tasks] - if task_name_low in preset_tasks_low: - per_task_preset = preset - break - - return per_task_preset + task_type = ( + asset_doc + .get("data", {}) + .get("tasks", {}) + .get(task_name, {}) + .get("type") + ) + filter_data = { + "task_types": task_type, + "tasks": task_name + } + return filter_profiles(builder_profiles, filter_data) def _filter_build_profiles(self, build_profiles, loaders_by_name): """ Filter build profiles by loaders and prepare process data. diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 943cd9fcaf..5735cbc99d 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -1,9 +1,11 @@ """Functions useful for delivery action or loader""" import os import shutil +import glob import clique import collections + def collect_frames(files): """ Returns dict of source path and its frame, if from sequence @@ -228,7 +230,16 @@ def process_sequence( Returns: (collections.defaultdict , int) """ - if not os.path.exists(src_path): + + def hash_path_exist(myPath): + res = myPath.replace('#', '*') + glob_search_results = glob.glob(res) + if len(glob_search_results) > 0: + return True + else: + return False + + if not hash_path_exist(src_path): msg = "{} doesn't exist for {}".format(src_path, repre["_id"]) report_items["Source file was not found"].append(msg) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 12fba23e82..a1111fba29 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,11 +1,10 @@ -import logging import os +import shlex import subprocess +import platform from .log import PypeLogger as Logger -log = logging.getLogger(__name__) - # MSDN process creation flag (Windows only) CREATE_NO_WINDOW = 0x08000000 @@ -100,7 +99,9 @@ def run_subprocess(*args, **kwargs): filtered_env = {str(k): str(v) for k, v in env.items()} # Use lib's logger if was not passed with kwargs. - logger = kwargs.pop("logger", log) + logger = kwargs.pop("logger", None) + if logger is None: + logger = Logger.get_logger("run_subprocess") # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) @@ -138,6 +139,14 @@ def run_subprocess(*args, **kwargs): return full_output +def path_to_subprocess_arg(path): + """Prepare path for subprocess arguments. + + Returned path can be wrapped with quotes or kept as is. + """ + return subprocess.list2cmdline([path]) + + def get_pype_execute_args(*args): """Arguments to run pype command. diff --git a/openpype/lib/mongo.py b/openpype/lib/mongo.py index 8bfaba75d6..0fd4517b5b 100644 --- a/openpype/lib/mongo.py +++ b/openpype/lib/mongo.py @@ -3,6 +3,7 @@ import sys import time import logging import pymongo +import certifi if sys.version_info[0] == 2: from urlparse import urlparse, parse_qs @@ -85,12 +86,33 @@ def get_default_components(): return decompose_url(mongo_url) -def extract_port_from_url(url): - parsed_url = urlparse(url) - if parsed_url.scheme is None: - _url = "mongodb://{}".format(url) - parsed_url = urlparse(_url) - return parsed_url.port +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): @@ -106,26 +128,9 @@ def validate_mongo_connection(mongo_uri): passed so probably couldn't connect to mongo server. """ - parsed = urlparse(mongo_uri) - # 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://'" - )) - # we have mongo connection string. Let's try if we can connect. - components = decompose_url(mongo_uri) - mongo_args = { - "host": compose_url(**components), - "serverSelectionTimeoutMS": 1000 - } - port = components.get("port") - if port is not None: - mongo_args["port"] = int(port) - - # Create connection - client = pymongo.MongoClient(**mongo_args) - client.server_info() + client = OpenPypeMongoConnection.create_connection( + mongo_uri, retry_attempts=1 + ) client.close() @@ -151,6 +156,8 @@ class OpenPypeMongoConnection: # Naive validation of existing connection try: connection.server_info() + with connection.start_session(): + pass except Exception: connection = None @@ -162,38 +169,53 @@ class OpenPypeMongoConnection: return connection @classmethod - def create_connection(cls, mongo_url, timeout=None): + 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 = { - "host": mongo_url, "serverSelectionTimeoutMS": timeout } + if should_add_certificate_path_to_mongo_url(mongo_url): + kwargs["ssl_ca_certs"] = certifi.where() - port = extract_port_from_url(mongo_url) - if port is not None: - kwargs["port"] = int(port) + mongo_client = pymongo.MongoClient(mongo_url, **kwargs) - mongo_client = pymongo.MongoClient(**kwargs) + if retry_attempts is None: + retry_attempts = 3 - for _retry in range(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: - t1 = time.time() mongo_client.server_info() - - except Exception: - cls.log.warning("Retrying...") - time.sleep(1) - timeout *= 1.5 - - else: + with mongo_client.start_session(): + pass + valid = True break - else: - raise IOError(( - "ERROR: Couldn't connect to {} in less than {:.3f}ms" - ).format(mongo_url, timeout)) + 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 diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index e1dd1e7f10..048bf0eda0 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -1,6 +1,11 @@ +import json +import logging import os import re -import logging + + +from .anatomy import Anatomy +from openpype.settings import get_project_settings log = logging.getLogger(__name__) @@ -77,7 +82,7 @@ def get_version_from_path(file): """ pattern = re.compile(r"[\._]v([0-9]+)", re.IGNORECASE) try: - return pattern.findall(file)[0] + return pattern.findall(file)[-1] except IndexError: log.error( "templates:get_version_from_workfile:" @@ -119,3 +124,75 @@ def get_last_version_from_path(path_dir, filter): return filtred_files[-1] return None + + +def compute_paths(basic_paths_items, project_root): + pattern_array = re.compile(r"\[.*\]") + project_root_key = "__project_root__" + output = [] + for path_items in basic_paths_items: + clean_items = [] + for path_item in path_items: + matches = re.findall(pattern_array, path_item) + if len(matches) > 0: + path_item = path_item.replace(matches[0], "") + if path_item == project_root_key: + path_item = project_root + clean_items.append(path_item) + output.append(os.path.normpath(os.path.sep.join(clean_items))) + return output + + +def create_project_folders(basic_paths, project_name): + anatomy = Anatomy(project_name) + roots_paths = [] + if isinstance(anatomy.roots, dict): + for root in anatomy.roots.values(): + roots_paths.append(root.value) + else: + roots_paths.append(anatomy.roots.value) + + for root_path in roots_paths: + project_root = os.path.join(root_path, project_name) + full_paths = compute_paths(basic_paths, project_root) + # Create folders + for path in full_paths: + full_path = path.format(project_root=project_root) + if os.path.exists(full_path): + log.debug( + "Folder already exists: {}".format(full_path) + ) + else: + log.debug("Creating folder: {}".format(full_path)) + os.makedirs(full_path) + + +def _list_path_items(folder_structure): + output = [] + for key, value in folder_structure.items(): + if not value: + output.append(key) + else: + paths = _list_path_items(value) + for path in paths: + if not isinstance(path, (list, tuple)): + path = [path] + + item = [key] + item.extend(path) + output.append(item) + + return output + + +def get_project_basic_paths(project_name): + project_settings = get_project_settings(project_name) + folder_structure = ( + project_settings["global"]["project_folder_structure"] + ) + if not folder_structure: + return [] + + if isinstance(folder_structure, str): + folder_structure = json.loads(folder_structure) + return _list_path_items(folder_structure) diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 1f2fb7a46e..9dccadc44e 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -35,7 +35,8 @@ def get_subset_name( project_name=None, host_name=None, default_template=None, - dynamic_data=None + dynamic_data=None, + dbcon=None ): if not family: return "" @@ -46,13 +47,42 @@ def get_subset_name( # Use only last part of class family value split by dot (`.`) family = family.rsplit(".", 1)[-1] + if project_name is None: + import avalon.api + + project_name = avalon.api.Session["AVALON_PROJECT"] + + # Function should expect asset document instead of asset id + # - that way `dbcon` is not needed + if dbcon is None: + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + dbcon.install() + + asset_doc = dbcon.find_one( + { + "type": "asset", + "_id": asset_id + }, + { + "data.tasks": True + } + ) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + # Get settings tools_settings = get_project_settings(project_name)["global"]["tools"] profiles = tools_settings["creator"]["subset_name_profiles"] filtering_criteria = { "families": family, "hosts": host_name, - "tasks": task_name + "tasks": task_name, + "task_types": task_type } matching_profile = filter_profiles(profiles, filtering_criteria) diff --git a/openpype/lib/profiles_filtering.py b/openpype/lib/profiles_filtering.py index c4410204dd..0bb901aff8 100644 --- a/openpype/lib/profiles_filtering.py +++ b/openpype/lib/profiles_filtering.py @@ -1,10 +1,28 @@ import re import logging -from .applications import compile_list_of_regexes log = logging.getLogger(__name__) +def compile_list_of_regexes(in_list): + """Convert strings in entered list to compiled regex objects.""" + regexes = list() + if not in_list: + return regexes + + for item in in_list: + if not item: + continue + try: + regexes.append(re.compile(item)) + except TypeError: + print(( + "Invalid type \"{}\" value \"{}\"." + " Expected string based object. Skipping." + ).format(str(type(item)), str(item))) + return regexes + + def _profile_exclusion(matching_profiles, logger): """Find out most matching profile byt host, task and family match. @@ -165,7 +183,8 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): if match == -1: profile_value = profile.get(key) or [] logger.debug( - "\"{}\" not found in {}".format(key, profile_value) + "\"{}\" not found in \"{}\": {}".format(value, key, + profile_value) ) profile_points = -1 break @@ -192,13 +211,13 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): ]) if not matching_profiles: - logger.warning( + logger.info( "None of profiles match your setup. {}".format(log_parts) ) return None if len(matching_profiles) > 1: - logger.warning( + logger.info( "More than one profile match your setup. {}".format(log_parts) ) diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index c56782be9e..33715e369d 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -9,23 +9,76 @@ import openpype.version from openpype.settings.lib import get_local_settings from .execute import get_pype_execute_args from .local_settings import get_local_site_id +from .python_module_tools import import_filepath + + +def get_openpype_version(): + """Version of pype that is currently used.""" + return openpype.version.__version__ def get_pype_version(): - """Version of pype that is currently used.""" - return openpype.version.__version__ + """Backwards compatibility. Remove when 100% not used.""" + print(( + "Using deprecated function 'openpype.lib.pype_info.get_pype_version'" + " replace with 'openpype.lib.pype_info.get_openpype_version'." + )) + return get_openpype_version() + + +def get_build_version(): + """OpenPype version of build.""" + # Return OpenPype version if is running from code + if not is_running_from_build(): + return get_openpype_version() + + # Import `version.py` from build directory + version_filepath = os.path.join( + os.environ["OPENPYPE_ROOT"], + "openpype", + "version.py" + ) + if not os.path.exists(version_filepath): + return None + + module = import_filepath(version_filepath, "openpype_build_version") + return getattr(module, "__version__", None) + + +def is_running_from_build(): + """Determine if current process is running from build or code. + + Returns: + bool: True if running from build. + """ + executable_path = os.environ["OPENPYPE_EXECUTABLE"] + executable_filename = os.path.basename(executable_path) + if "python" in executable_filename.lower(): + return False + return True + + +def is_running_staging(): + """Currently used OpenPype is staging version. + + Returns: + bool: True if openpype version containt 'staging'. + """ + if "staging" in get_openpype_version(): + return True + return False def get_pype_info(): """Information about currently used Pype process.""" executable_args = get_pype_execute_args() - if len(executable_args) == 1: + if is_running_from_build(): version_type = "build" else: version_type = "code" return { - "version": get_pype_version(), + "version": get_openpype_version(), "version_type": version_type, "executable": executable_args[-1], "pype_root": os.environ["OPENPYPE_REPOS_ROOT"], @@ -73,7 +126,7 @@ def extract_pype_info_to_file(dirpath): filepath (str): Full path to file where data were extracted. """ filename = "{}_{}_{}.json".format( - get_pype_version(), + get_openpype_version(), get_local_site_id(), datetime.datetime.now().strftime("%y%m%d%H%M%S") ) diff --git a/openpype/modules/README.md b/openpype/modules/README.md index a3733518ac..5716324365 100644 --- a/openpype/modules/README.md +++ b/openpype/modules/README.md @@ -1,125 +1,143 @@ # OpenPype modules/addons -OpenPype modules should contain separated logic of specific kind of implementation, like Ftrack connection and usage code or Deadline farm rendering or may contain only special plugins. Addons work the same way currently there is no difference in module and addon. +OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its usage code, Deadline farm rendering or may contain only special plugins. Addons work the same way currently, there is no difference between module and addon functionality. ## Modules concept -- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the modulo located -- modules or addons should never be imported directly even if you know possible full import path - - it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts +- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the module located +- modules or addons should never be imported directly, even if you know possible full import path + - it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts ### TODOs - add module/addon manifest - - definition of module (not 100% defined content e.g. minimum require OpenPype version etc.) - - defying that folder is content of a module or an addon -- module/addon have it's settings schemas and default values outside OpenPype -- add general setting of paths to modules + - definition of module (not 100% defined content e.g. minimum required OpenPype version etc.) + - defining a folder as a content of a module or an addon ## Base class `OpenPypeModule` - abstract class as base for each module -- implementation should be module's api withou GUI parts -- may implement `get_global_environments` method which should return dictionary of environments that are globally appliable and value is the same for whole studio if launched at any workstation (except os specific paths) +- implementation should contain module's api without GUI parts +- may implement `get_global_environments` method which should return dictionary of environments that are globally applicable and value is the same for whole studio if launched at any workstation (except os specific paths) - abstract parts: - - `name` attribute - name of a module - - `initialize` method - method for own initialization of a module (should not override `__init__`) - - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules -- `__init__` should not be overriden and `initialize` should not do time consuming part but only prepare base data about module - - also keep in mind that they may be initialized in headless mode + - `name` attribute - name of a module + - `initialize` method - method for own initialization of a module (should not override `__init__`) + - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules +- `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module + - also keep in mind that they may be initialized in headless mode - connection with other modules is made with help of interfaces +## Addon class `OpenPypeAddOn` +- inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods + - that is because it is expected that addons don't need to have system settings and `enabled` value on it (but it is possible...) + +## How to add addons/modules +- in System settings go to `modules/addon_paths` (`Modules/OpenPype AddOn Paths`) where you have to add path to addon root folder +- for openpype example addons use `{OPENPYPE_REPOS_ROOT}/openpype/modules/example_addons` + +## Addon/module settings +- addons/modules may have defined custom settings definitions with default values +- it is based on settings type `dynamic_schema` which has `name` + - that item defines that it can be replaced dynamically with any schemas from module or module which won't be saved to openpype core defaults + - they can't be added to any schema hierarchy + - item must not be in settings group (under overrides) or in dynamic item (e.g. `list` of `dict-modifiable`) + - addons may define it's dynamic schema items +- they can be defined with class which inherits from `BaseModuleSettingsDef` + - it is recommended to use pre implemented `JsonFilesSettingsDef` which defined structure and use json files to define dynamic schemas, schemas and default values + - check it's docstring and check for `example_addon` in example addons +- settings definition returns schemas by dynamic schemas names + # Interfaces -- interface is class that has defined abstract methods to implement and may contain preimplemented helper methods +- interface is class that has defined abstract methods to implement and may contain pre implemented helper methods - module that inherit from an interface must implement those abstract methods otherwise won't be initialized -- it is easy to find which module object inherited from which interfaces withh 100% chance they have implemented required methods +- it is easy to find which module object inherited from which interfaces with 100% chance they have implemented required methods - interfaces can be defined in `interfaces.py` inside module directory - - the file can't use relative imports or import anything from other parts - of module itself at the header of file - - this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation + - the file can't use relative imports or import anything from other parts + of module itself at the header of file + - this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation ## Base class `OpenPypeInterface` - has nothing implemented - has ABCMeta as metaclass - is defined to be able find out classes which inherit from this base to be - able tell this is an Interface + able tell this is an Interface ## Global interfaces - few interfaces are implemented for global usage ### IPluginPaths -- module want to add directory path/s to avalon or publish plugins +- module wants to add directory path/s to avalon or publish plugins - module must implement `get_plugin_paths` which must return dictionary with possible keys `"publish"`, `"load"`, `"create"` or `"actions"` - - each key may contain list or string with path to directory with plugins + - each key may contain list or string with a path to directory with plugins ### ITrayModule -- module has more logic when used in tray - - it is possible that module can be used only in tray +- module has more logic when used in a tray + - it is possible that module can be used only in the tray - abstract methods - - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` - - `tray_menu` - add actions to tray widget's menu that represent the module - - `tray_start` - start of module's login in tray - - module is initialized and connected with other modules - - `tray_exit` - module's cleanup like stop and join threads etc. - - order of calling is based on implementation this order is how it works with `TrayModulesManager` - - it is recommended to import and use GUI implementaion only in these methods + - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` + - `tray_menu` - add actions to tray widget's menu that represent the module + - `tray_start` - start of module's login in tray + - module is initialized and connected with other modules + - `tray_exit` - module's cleanup like stop and join threads etc. + - order of calling is based on implementation this order is how it works with `TrayModulesManager` + - it is recommended to import and use GUI implementation only in these methods - has attribute `tray_initialized` (bool) which is set to False by default and is set by `TrayModulesManager` to True after `tray_init` - - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations + - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations ### ITrayService -- inherit from `ITrayModule` and implement `tray_menu` method for you - - add action to submenu "Services" in tray widget menu with icon and label -- abstract atttribute `label` - - label shown in menu -- interface has preimplemented methods to change icon color - - `set_service_running` - green icon - - `set_service_failed` - red icon - - `set_service_idle` - orange icon - - these states must be set by module itself `set_service_running` is default state on initialization +- inherits from `ITrayModule` and implements `tray_menu` method for you + - adds action to submenu "Services" in tray widget menu with icon and label +- abstract attribute `label` + - label shown in menu +- interface has pre implemented methods to change icon color + - `set_service_running` - green icon + - `set_service_failed` - red icon + - `set_service_idle` - orange icon + - these states must be set by module itself `set_service_running` is default state on initialization ### ITrayAction -- inherit from `ITrayModule` and implement `tray_menu` method for you - - add action to tray widget menu with label -- abstract atttribute `label` - - label shown in menu +- inherits from `ITrayModule` and implements `tray_menu` method for you + - adds action to tray widget menu with label +- abstract attribute `label` + - label shown in menu - abstract method `on_action_trigger` - - what should happen when action is triggered -- NOTE: It is good idea to implement logic in `on_action_trigger` to api method and trigger that methods on callbacks this gives ability to trigger that method outside tray + - what should happen when an action is triggered +- NOTE: It is a good idea to implement logic in `on_action_trigger` to the api method and trigger that method on callbacks. This gives ability to trigger that method outside tray ## Modules interfaces -- modules may have defined their interfaces to be able recognize other modules that would want to use their features -- -### Example: -- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which of other modules want to add paths to server/user event handlers - - Clockify module use `IFtrackEventHandlerPaths` and return paths to clockify ftrack synchronizers +- modules may have defined their own interfaces to be able to recognize other modules that would want to use their features -- Clockify has more inharitance it's class definition looks like +### Example: +- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which other modules want to add paths to server/user event handlers + - Clockify module use `IFtrackEventHandlerPaths` and returns paths to clockify ftrack synchronizers + +- Clockify inherits from more interfaces. It's class definition looks like: ``` class ClockifyModule( - OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize. - ITrayModule, # Says has special implementation when used in tray. - IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). - IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. - ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. + OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize. + ITrayModule, # Says has special implementation when used in tray. + IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). + IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. + ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. ): ``` ### ModulesManager -- collect module classes and tries to initialize them +- collects module classes and tries to initialize them - important attributes - - `modules` - list of available attributes - - `modules_by_id` - dictionary of modules mapped by their ids - - `modules_by_name` - dictionary of modules mapped by their names - - all these attributes contain all found modules even if are not enabled + - `modules` - list of available attributes + - `modules_by_id` - dictionary of modules mapped by their ids + - `modules_by_name` - dictionary of modules mapped by their names + - all these attributes contain all found modules even if are not enabled - helper methods - - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them - - `collect_plugin_paths` collect plugin paths from all enabled modules - - output is always dictionary with all keys and values as list - ``` - { - "publish": [], - "create": [], - "load": [], - "actions": [] - } - ``` + - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them + - `collect_plugin_paths` collects plugin paths from all enabled modules + - output is always dictionary with all keys and values as an list + ``` + { + "publish": [], + "create": [], + "load": [], + "actions": [] + } + ``` ### TrayModulesManager -- inherit from `ModulesManager` -- has specific implementations for Pype Tray tool and handle `ITrayModule` methods +- inherits from `ModulesManager` +- has specific implementation for Pype Tray tool and handle `ITrayModule` methods \ No newline at end of file diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 583480b049..68b5f6c247 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -1,21 +1,35 @@ # -*- coding: utf-8 -*- from .base import ( OpenPypeModule, + OpenPypeAddOn, OpenPypeInterface, load_modules, ModulesManager, - TrayModulesManager + TrayModulesManager, + + BaseModuleSettingsDef, + ModuleSettingsDef, + JsonFilesSettingsDef, + + get_module_settings_defs ) __all__ = ( "OpenPypeModule", + "OpenPypeAddOn", "OpenPypeInterface", "load_modules", "ModulesManager", - "TrayModulesManager" + "TrayModulesManager", + + "BaseModuleSettingsDef", + "ModuleSettingsDef", + "JsonFilesSettingsDef", + + "get_module_settings_defs" ) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d43d5635d1..748c7857a9 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -2,9 +2,11 @@ """Base class for Pype Modules.""" import os import sys +import json import time import inspect import logging +import platform import threading import collections from uuid import uuid4 @@ -12,7 +14,18 @@ from abc import ABCMeta, abstractmethod import six import openpype -from openpype.settings import get_system_settings +from openpype.settings import ( + get_system_settings, + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS +) + +from openpype.settings.lib import ( + get_studio_system_settings_overrides, + load_json_file +) from openpype.lib import PypeLogger @@ -115,11 +128,51 @@ def get_default_modules_dir(): return os.path.join(current_dir, "default_modules") +def get_dynamic_modules_dirs(): + """Possible paths to OpenPype Addons of Modules. + + Paths are loaded from studio settings under: + `modules -> addon_paths -> {platform name}` + + Path may contain environment variable as a formatting string. + + They are not validated or checked their existence. + + Returns: + list: Paths loaded from studio overrides. + """ + output = [] + value = get_studio_system_settings_overrides() + for key in ("modules", "addon_paths", platform.system().lower()): + if key not in value: + return output + value = value[key] + + for path in value: + if not path: + continue + + try: + path = path.format(**os.environ) + except Exception: + pass + output.append(path) + return output + + def get_module_dirs(): """List of paths where OpenPype modules can be found.""" - dirpaths = [ - get_default_modules_dir() - ] + _dirpaths = [] + _dirpaths.append(get_default_modules_dir()) + _dirpaths.extend(get_dynamic_modules_dirs()) + + dirpaths = [] + for path in _dirpaths: + if not path: + continue + normalized = os.path.normpath(path) + if normalized not in dirpaths: + dirpaths.append(normalized) return dirpaths @@ -165,6 +218,9 @@ def _load_interfaces(): os.path.join(get_default_modules_dir(), "interfaces.py") ) for dirpath in dirpaths: + if not os.path.exists(dirpath): + continue + for filename in os.listdir(dirpath): if filename in ("__pycache__", ): continue @@ -272,12 +328,19 @@ def _load_modules(): # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest - if os.path.isdir(fullpath): - import_module_from_dirpath(dirpath, filename, modules_key) + try: + if os.path.isdir(fullpath): + import_module_from_dirpath(dirpath, filename, modules_key) - elif ext in (".py", ): - module = import_filepath(fullpath) - setattr(openpype_modules, basename, module) + elif ext in (".py", ): + module = import_filepath(fullpath) + setattr(openpype_modules, basename, module) + + except Exception: + log.error( + "Failed to import '{}'.".format(fullpath), + exc_info=True + ) class _OpenPypeInterfaceMeta(ABCMeta): @@ -354,7 +417,6 @@ class OpenPypeModule: """ pass - @abstractmethod def connect_with_modules(self, enabled_modules): """Connect with other enabled modules.""" pass @@ -368,7 +430,12 @@ class OpenPypeModule: class OpenPypeAddOn(OpenPypeModule): - pass + # Enable Addon by default + enabled = True + + def initialize(self, module_settings): + """Initialization is not be required for most of addons.""" + pass class ModulesManager: @@ -423,6 +490,7 @@ class ModulesManager: if ( not inspect.isclass(modules_item) or modules_item is OpenPypeModule + or modules_item is OpenPypeAddOn or not issubclass(modules_item, OpenPypeModule) ): continue @@ -920,3 +988,424 @@ class TrayModulesManager(ModulesManager): ), exc_info=True ) + + +def get_module_settings_defs(): + """Check loaded addons/modules for existence of thei settings definition. + + Check if OpenPype addon/module as python module has class that inherit + from `ModuleSettingsDef` in python module variables (imported + in `__init__py`). + + Returns: + list: All valid and not abstract settings definitions from imported + openpype addons and modules. + """ + # Make sure modules are loaded + load_modules() + + import openpype_modules + + settings_defs = [] + + log = PypeLogger.get_logger("ModuleSettingsLoad") + + for raw_module in openpype_modules: + for attr_name in dir(raw_module): + attr = getattr(raw_module, attr_name) + if ( + not inspect.isclass(attr) + or attr is ModuleSettingsDef + or not issubclass(attr, ModuleSettingsDef) + ): + continue + + if inspect.isabstract(attr): + # Find missing implementations by convetion on `abc` module + not_implemented = [] + for attr_name in dir(attr): + attr = getattr(attr, attr_name, None) + abs_method = getattr( + attr, "__isabstractmethod__", None + ) + if attr and abs_method: + not_implemented.append(attr_name) + + # Log missing implementations + log.warning(( + "Skipping abstract Class: {} in module {}." + " Missing implementations: {}" + ).format( + attr_name, raw_module.__name__, ", ".join(not_implemented) + )) + continue + + settings_defs.append(attr) + + return settings_defs + + +@six.add_metaclass(ABCMeta) +class BaseModuleSettingsDef: + """Definition of settings for OpenPype module or AddOn.""" + _id = None + + @property + def id(self): + """ID created on initialization. + + ID should be per created object. Helps to store objects. + """ + if self._id is None: + self._id = uuid4() + return self._id + + @abstractmethod + def get_settings_schemas(self, schema_type): + """Setting schemas for passed schema type. + + These are main schemas by dynamic schema keys. If they're using + sub schemas or templates they should be loaded with + `get_dynamic_schemas`. + + Returns: + dict: Schema by `dynamic_schema` keys. + """ + pass + + @abstractmethod + def get_dynamic_schemas(self, schema_type): + """Settings schemas and templates that can be used anywhere. + + It is recommended to add prefix specific for addon/module to keys + (e.g. "my_addon/real_schema_name"). + + Returns: + dict: Schemas and templates by their keys. + """ + pass + + @abstractmethod + def get_defaults(self, top_key): + """Default values for passed top key. + + Top keys are (currently) "system_settings" or "project_settings". + + Should return exactly what was passed with `save_defaults`. + + Returns: + dict: Default values by path to first key in OpenPype defaults. + """ + pass + + @abstractmethod + def save_defaults(self, top_key, data): + """Save default values for passed top key. + + Top keys are (currently) "system_settings" or "project_settings". + + Passed data are by path to first key defined in main schemas. + """ + pass + + +class ModuleSettingsDef(BaseModuleSettingsDef): + """Settings definiton with separated system and procect settings parts. + + Reduce conditions that must be checked and adds predefined methods for + each case. + """ + def get_defaults(self, top_key): + """Split method into 2 methods by top key.""" + if top_key == SYSTEM_SETTINGS_KEY: + return self.get_default_system_settings() or {} + elif top_key == PROJECT_SETTINGS_KEY: + return self.get_default_project_settings() or {} + return {} + + def save_defaults(self, top_key, data): + """Split method into 2 methods by top key.""" + if top_key == SYSTEM_SETTINGS_KEY: + self.save_system_defaults(data) + elif top_key == PROJECT_SETTINGS_KEY: + self.save_project_defaults(data) + + def get_settings_schemas(self, schema_type): + """Split method into 2 methods by schema type.""" + if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: + return self.get_system_settings_schemas() or {} + elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: + return self.get_project_settings_schemas() or {} + return {} + + def get_dynamic_schemas(self, schema_type): + """Split method into 2 methods by schema type.""" + if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: + return self.get_system_dynamic_schemas() or {} + elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: + return self.get_project_dynamic_schemas() or {} + return {} + + @abstractmethod + def get_system_settings_schemas(self): + """Schemas and templates usable in system settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + pass + + @abstractmethod + def get_project_settings_schemas(self): + """Schemas and templates usable in project settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + pass + + @abstractmethod + def get_system_dynamic_schemas(self): + """System schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + pass + + @abstractmethod + def get_project_dynamic_schemas(self): + """Project schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + pass + + @abstractmethod + def get_default_system_settings(self): + """Default system settings values. + + Returns: + dict: Default values by path to first key. + """ + pass + + @abstractmethod + def get_default_project_settings(self): + """Default project settings values. + + Returns: + dict: Default values by path to first key. + """ + pass + + @abstractmethod + def save_system_defaults(self, data): + """Save default system settings values. + + Passed data are by path to first key defined in main schemas. + """ + pass + + @abstractmethod + def save_project_defaults(self, data): + """Save default project settings values. + + Passed data are by path to first key defined in main schemas. + """ + pass + + +class JsonFilesSettingsDef(ModuleSettingsDef): + """Preimplemented settings definition using json files and file structure. + + Expected file structure: + β”• root + β”‚ + β”‚ # Default values + ┝ defaults + β”‚ ┝ system_settings.json + β”‚ β”• project_settings.json + β”‚ + β”‚ # Schemas for `dynamic_template` type + ┝ dynamic_schemas + β”‚ ┝ system_dynamic_schemas.json + β”‚ β”• project_dynamic_schemas.json + β”‚ + β”‚ # Schemas that can be used anywhere (enhancement for `dynamic_schemas`) + β”• schemas + ┝ system_schemas + β”‚ ┝ # Any schema or template files + β”‚ β”• ... + β”• project_schemas + ┝ # Any schema or template files + β”• ... + + Schemas can be loaded with prefix to avoid duplicated schema/template names + across all OpenPype addons/modules. Prefix can be defined with class + attribute `schema_prefix`. + + Only think which must be implemented in `get_settings_root_path` which + should return directory path to `root` (in structure graph above). + """ + # Possible way how to define `schemas` prefix + schema_prefix = "" + + @abstractmethod + def get_settings_root_path(self): + """Directory path where settings and it's schemas are located.""" + pass + + def __init__(self): + settings_root_dir = self.get_settings_root_path() + defaults_dir = os.path.join( + settings_root_dir, "defaults" + ) + dynamic_schemas_dir = os.path.join( + settings_root_dir, "dynamic_schemas" + ) + schemas_dir = os.path.join( + settings_root_dir, "schemas" + ) + + self.system_defaults_filepath = os.path.join( + defaults_dir, "system_settings.json" + ) + self.project_defaults_filepath = os.path.join( + defaults_dir, "project_settings.json" + ) + + self.system_dynamic_schemas_filepath = os.path.join( + dynamic_schemas_dir, "system_dynamic_schemas.json" + ) + self.project_dynamic_schemas_filepath = os.path.join( + dynamic_schemas_dir, "project_dynamic_schemas.json" + ) + + self.system_schemas_dir = os.path.join( + schemas_dir, "system_schemas" + ) + self.project_schemas_dir = os.path.join( + schemas_dir, "project_schemas" + ) + + def _load_json_file_data(self, path): + if os.path.exists(path): + return load_json_file(path) + return {} + + def get_default_system_settings(self): + """Default system settings values. + + Returns: + dict: Default values by path to first key. + """ + return self._load_json_file_data(self.system_defaults_filepath) + + def get_default_project_settings(self): + """Default project settings values. + + Returns: + dict: Default values by path to first key. + """ + return self._load_json_file_data(self.project_defaults_filepath) + + def _save_data_to_filepath(self, path, data): + dirpath = os.path.dirname(path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + with open(path, "w") as file_stream: + json.dump(data, file_stream, indent=4) + + def save_system_defaults(self, data): + """Save default system settings values. + + Passed data are by path to first key defined in main schemas. + """ + self._save_data_to_filepath(self.system_defaults_filepath, data) + + def save_project_defaults(self, data): + """Save default project settings values. + + Passed data are by path to first key defined in main schemas. + """ + self._save_data_to_filepath(self.project_defaults_filepath, data) + + def get_system_dynamic_schemas(self): + """System schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + return self._load_json_file_data(self.system_dynamic_schemas_filepath) + + def get_project_dynamic_schemas(self): + """Project schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + return self._load_json_file_data(self.project_dynamic_schemas_filepath) + + def _load_files_from_path(self, path): + output = {} + if not path or not os.path.exists(path): + return output + + if os.path.isfile(path): + filename = os.path.basename(path) + basename, ext = os.path.splitext(filename) + if ext == ".json": + if self.schema_prefix: + key = "{}/{}".format(self.schema_prefix, basename) + else: + key = basename + output[key] = self._load_json_file_data(path) + return output + + path = os.path.normpath(path) + for root, _, files in os.walk(path, topdown=False): + for filename in files: + basename, ext = os.path.splitext(filename) + if ext != ".json": + continue + + json_path = os.path.join(root, filename) + store_key = os.path.join( + root.replace(path, ""), basename + ).replace("\\", "/") + if self.schema_prefix: + store_key = "{}/{}".format(self.schema_prefix, store_key) + output[store_key] = self._load_json_file_data(json_path) + + return output + + def get_system_settings_schemas(self): + """Schemas and templates usable in system settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + return self._load_files_from_path(self.system_schemas_dir) + + def get_project_settings_schemas(self): + """Schemas and templates usable in project settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + return self._load_files_from_path(self.project_schemas_dir) diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/default_modules/avalon_apps/avalon_app.py index 53e06ec90a..d21b37e520 100644 --- a/openpype/modules/default_modules/avalon_apps/avalon_app.py +++ b/openpype/modules/default_modules/avalon_apps/avalon_app.py @@ -2,13 +2,10 @@ import os import openpype from openpype import resources from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayModule, - IWebServerRoutes -) +from openpype_interfaces import ITrayModule -class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): +class AvalonModule(OpenPypeModule, ITrayModule): name = "avalon" def initialize(self, modules_settings): @@ -55,12 +52,12 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): def tray_init(self): # Add library tool try: - from avalon.tools.libraryloader import app - from avalon import style from Qt import QtGui + from avalon import style + from openpype.tools.libraryloader import LibraryLoaderWindow - self.libraryloader = app.Window( - icon=QtGui.QIcon(resources.pype_icon_filepath()), + self.libraryloader = LibraryLoaderWindow( + icon=QtGui.QIcon(resources.get_openpype_icon_filepath()), show_projects=True, show_libraries=True ) @@ -71,16 +68,6 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): exc_info=True ) - def connect_with_modules(self, _enabled_modules): - return - - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - - if self.tray_initialized: - from .rest_api import AvalonRestApiResource - self.rest_api_obj = AvalonRestApiResource(self, server_manager) - # Definition of Tray menu def tray_menu(self, tray_menu): from Qt import QtWidgets @@ -108,3 +95,10 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): # for Windows self.libraryloader.activateWindow() self.libraryloader.refresh() + + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for webserver.""" + if self.tray_initialized: + from .rest_api import AvalonRestApiResource + self.rest_api_obj = AvalonRestApiResource(self, server_manager) diff --git a/openpype/modules/default_modules/clockify/clockify_module.py b/openpype/modules/default_modules/clockify/clockify_module.py index a9e989f4ec..932ce87c36 100644 --- a/openpype/modules/default_modules/clockify/clockify_module.py +++ b/openpype/modules/default_modules/clockify/clockify_module.py @@ -10,18 +10,14 @@ from .constants import ( from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITrayModule, - IPluginPaths, - IFtrackEventHandlerPaths, - ITimersManager + IPluginPaths ) class ClockifyModule( OpenPypeModule, ITrayModule, - IPluginPaths, - IFtrackEventHandlerPaths, - ITimersManager + IPluginPaths ): name = "clockify" @@ -39,6 +35,11 @@ class ClockifyModule( self.clockapi = ClockifyAPI(master_parent=self) + # TimersManager attributes + # - set `timers_manager_connector` only in `tray_init` + self.timers_manager_connector = None + self._timers_manager_module = None + def get_global_environments(self): return { "CLOCKIFY_WORKSPACE": self.workspace_name @@ -61,6 +62,9 @@ class ClockifyModule( self.bool_timer_run = False self.bool_api_key_set = self.clockapi.set_api() + # Define itself as TimersManager connector + self.timers_manager_connector = self + def tray_start(self): if self.bool_api_key_set is False: self.show_settings() @@ -87,16 +91,13 @@ class ClockifyModule( "actions": [actions_path] } - def get_event_handler_paths(self): - """Implementaton of IFtrackEventHandlerPaths to get plugin paths.""" + def get_ftrack_event_handler_paths(self): + """Function for Ftrack module to add ftrack event handler paths.""" return { "user": [CLOCKIFY_FTRACK_USER_PATH], "server": [CLOCKIFY_FTRACK_SERVER_PATH] } - def connect_with_modules(self, *_a, **_kw): - return - def clockify_timer_stopped(self): self.bool_timer_run = False # Call `ITimersManager` method @@ -165,10 +166,6 @@ class ClockifyModule( self.set_menu_visibility() time.sleep(5) - def stop_timer(self): - """Implementation of ITimersManager.""" - self.clockapi.finish_time_entry() - def signed_in(self): if not self.timer_manager: return @@ -179,8 +176,60 @@ class ClockifyModule( if self.timer_manager.is_running: self.start_timer_manager(self.timer_manager.last_task) + def on_message_widget_close(self): + self.message_widget = None + + # Definition of Tray menu + def tray_menu(self, parent_menu): + # Menu for Tray App + from Qt import QtWidgets + menu = QtWidgets.QMenu("Clockify", parent_menu) + menu.setProperty("submenu", "on") + + # Actions + action_show_settings = QtWidgets.QAction("Settings", menu) + action_stop_timer = QtWidgets.QAction("Stop timer", menu) + + menu.addAction(action_show_settings) + menu.addAction(action_stop_timer) + + action_show_settings.triggered.connect(self.show_settings) + action_stop_timer.triggered.connect(self.stop_timer) + + self.action_stop_timer = action_stop_timer + + self.set_menu_visibility() + + parent_menu.addMenu(menu) + + def show_settings(self): + self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) + self.widget_settings.show() + + def set_menu_visibility(self): + self.action_stop_timer.setVisible(self.bool_timer_run) + + # --- TimersManager connection methods --- + def register_timers_manager(self, timer_manager_module): + """Store TimersManager for future use.""" + self._timers_manager_module = timer_manager_module + + def timer_started(self, data): + """Tell TimersManager that timer started.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self._module.id, data) + + def timer_stopped(self): + """Tell TimersManager that timer stopped.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self._module.id) + + def stop_timer(self): + """Called from TimersManager to stop timer.""" + self.clockapi.finish_time_entry() + def start_timer(self, input_data): - """Implementation of ITimersManager.""" + """Called from TimersManager to start timer.""" # If not api key is not entered then skip if not self.clockapi.get_api_key(): return @@ -237,36 +286,3 @@ class ClockifyModule( self.clockapi.start_time_entry( description, project_id, tag_ids=tag_ids ) - - def on_message_widget_close(self): - self.message_widget = None - - # Definition of Tray menu - def tray_menu(self, parent_menu): - # Menu for Tray App - from Qt import QtWidgets - menu = QtWidgets.QMenu("Clockify", parent_menu) - menu.setProperty("submenu", "on") - - # Actions - action_show_settings = QtWidgets.QAction("Settings", menu) - action_stop_timer = QtWidgets.QAction("Stop timer", menu) - - menu.addAction(action_show_settings) - menu.addAction(action_stop_timer) - - action_show_settings.triggered.connect(self.show_settings) - action_stop_timer.triggered.connect(self.stop_timer) - - self.action_stop_timer = action_stop_timer - - self.set_menu_visibility() - - parent_menu.addMenu(menu) - - def show_settings(self): - self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) - self.widget_settings.show() - - def set_menu_visibility(self): - self.action_stop_timer.setVisible(self.bool_timer_run) diff --git a/openpype/modules/default_modules/clockify/widgets.py b/openpype/modules/default_modules/clockify/widgets.py index fc8e7fa8a3..d58df3c067 100644 --- a/openpype/modules/default_modules/clockify/widgets.py +++ b/openpype/modules/default_modules/clockify/widgets.py @@ -13,7 +13,7 @@ class MessageWidget(QtWidgets.QWidget): super(MessageWidget, self).__init__() # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( @@ -90,7 +90,7 @@ class ClockifySettings(QtWidgets.QWidget): self.validated = False # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("Clockify settings") diff --git a/openpype/modules/default_modules/deadline/deadline_module.py b/openpype/modules/default_modules/deadline/deadline_module.py index ada5e8225a..1a179e9aaf 100644 --- a/openpype/modules/default_modules/deadline/deadline_module.py +++ b/openpype/modules/default_modules/deadline/deadline_module.py @@ -26,9 +26,6 @@ class DeadlineModule(OpenPypeModule, IPluginPaths): "not specified. Disabling module.")) return - def connect_with_modules(self, *_a, **_kw): - return - def get_plugin_paths(self): """Deadline plugin paths.""" current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 784616615d..968bffd890 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -11,7 +11,7 @@ import pyblish.api class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): """Collect Deadline Webservice URL from instance.""" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.01 label = "Deadline Webservice from the Instance" families = ["rendering"] @@ -46,24 +46,25 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): ["deadline"] ) - try: - default_servers = deadline_settings["deadline_urls"] - project_servers = ( - render_instance.context.data - ["project_settings"] - ["deadline"] - ["deadline_servers"] - ) - deadline_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers - } - - except AttributeError: - # Handle situation were we had only one url for deadline. - return render_instance.context.data["defaultDeadline"] + default_server = render_instance.context.data["defaultDeadline"] + instance_server = render_instance.data.get("deadlineServers") + if not instance_server: + return default_server + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + render_instance.context.data + ["project_settings"] + ["deadline"] + ["deadline_servers"] + ) + deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + # This is Maya specific and may not reflect real selection of deadline + # url as dictionary keys in Python 2 are not ordered return deadline_servers[ list(deadline_servers.keys())[ int(render_instance.data.get("deadlineServers")) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py index 53231bd7e4..afb8583069 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -6,7 +6,7 @@ import pyblish.api class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): """Collect default Deadline Webservice URL.""" - order = pyblish.api.CollectorOrder + 0.01 + order = pyblish.api.CollectorOrder label = "Default Deadline Webservice" def process(self, context): diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py index 1ab3dc2554..2d43b0d085 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py @@ -351,6 +351,11 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): f.replace(orig_scene, new_scene) ) instance.data["expectedFiles"] = [new_exp] + + if instance.data.get("publishRenderMetadataFolder"): + instance.data["publishRenderMetadataFolder"] = \ + instance.data["publishRenderMetadataFolder"].replace( + orig_scene, new_scene) self.log.info("Scene name was switched {} -> {}".format( orig_scene, new_scene )) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py index 19e3174384..6b07749819 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py @@ -385,6 +385,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): """ task = os.environ["AVALON_TASK"] subset = instance_data["subset"] + cameras = instance_data.get("cameras", []) instances = [] # go through aovs in expected files for aov, files in exp_files[0].items(): @@ -410,7 +411,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): task[0].upper(), task[1:], subset[0].upper(), subset[1:]) - subset_name = '{}_{}'.format(group_name, aov) + cam = [c for c in cameras if c in col.head] + if cam: + subset_name = '{}_{}_{}'.format(group_name, cam, aov) + else: + subset_name = '{}_{}'.format(group_name, aov) if isinstance(col, (list, tuple)): staging = os.path.dirname(col[0]) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py index d449c4b7df..58f79e8a2b 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py @@ -1,4 +1,6 @@ import time +import sys +import json import traceback from openpype_modules.ftrack.lib import ServerAction @@ -52,17 +54,80 @@ class SyncToAvalonServer(ServerAction): return False def launch(self, session, in_entities, event): + self.log.debug("{}: Creating job".format(self.label)) + + user_entity = session.query( + "User where id is {}".format(event["source"]["user"]["id"]) + ).one() + job_entity = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Sync to avalon is running..." + }) + }) + session.commit() + + project_entity = self.get_project_from_entity(in_entities[0]) + project_name = project_entity["full_name"] + + try: + result = self.synchronization(event, project_name) + + except Exception: + self.log.error( + "Synchronization failed due to code error", exc_info=True + ) + + description = "Sync to avalon Crashed (Download traceback)" + self.add_traceback_to_job( + job_entity, session, sys.exc_info(), description + ) + + msg = "An error has happened during synchronization" + title = "Synchronization report ({}):".format(project_name) + items = [] + items.append({ + "type": "label", + "value": "# {}".format(msg) + }) + items.append({ + "type": "label", + "value": ( + "

Download report from job for more information.

" + ) + }) + + report = {} + try: + report = self.entities_factory.report() + except Exception: + pass + + _items = report.get("items") or [] + if _items: + items.append(self.entities_factory.report_splitter) + items.extend(_items) + + self.show_interface(items, title, event, submit_btn_label="Ok") + + return {"success": True, "message": msg} + + job_entity["status"] = "done" + job_entity["data"] = json.dumps({ + "description": "Sync to avalon finished." + }) + session.commit() + + return result + + def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) - # Get ftrack project - if in_entities[0].entity_type.lower() == "project": - ft_project_name = in_entities[0]["full_name"] - else: - ft_project_name = in_entities[0]["project"]["full_name"] try: - output = self.entities_factory.launch_setup(ft_project_name) + output = self.entities_factory.launch_setup(project_name) if output is not None: return output @@ -72,7 +137,7 @@ class SyncToAvalonServer(ServerAction): time_2 = time.time() # This must happen before all filtering!!! - self.entities_factory.prepare_avalon_entities(ft_project_name) + self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() @@ -118,7 +183,7 @@ class SyncToAvalonServer(ServerAction): report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( - ft_project_name + project_name ) self.show_interface( items=report["items"], @@ -130,46 +195,6 @@ class SyncToAvalonServer(ServerAction): "message": "Synchronization Finished" } - except Exception: - self.log.error( - "Synchronization failed due to code error", exc_info=True - ) - msg = "An error has happened during synchronization" - title = "Synchronization report ({}):".format(ft_project_name) - items = [] - items.append({ - "type": "label", - "value": "# {}".format(msg) - }) - items.append({ - "type": "label", - "value": "## Traceback of the error" - }) - items.append({ - "type": "label", - "value": "

{}

".format( - str(traceback.format_exc()).replace( - "\n", "
").replace( - " ", " " - ) - ) - }) - - report = {"items": []} - try: - report = self.entities_factory.report() - except Exception: - pass - - _items = report.get("items", []) - if _items: - items.append(self.entities_factory.report_splitter) - items.extend(_items) - - self.show_interface(items, title, event) - - return {"success": True, "message": msg} - finally: try: self.entities_factory.dbcon.uninstall() diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py index 3869d8ad08..0bd243ab4c 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -10,6 +10,7 @@ from openpype_modules.ftrack.lib import ( CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, + CUST_ATTR_INTENT, default_custom_attributes_definition, app_definitions_from_app_manager, @@ -431,7 +432,7 @@ class CustomAttributes(BaseAction): intent_custom_attr_data = { "label": "Intent", - "key": "intent", + "key": CUST_ATTR_INTENT, "type": "enumerator", "entity_type": "assetversion", "group": CUST_ATTR_GROUP, diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py index 121c9f652b..94f359c317 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py @@ -3,7 +3,7 @@ import re import json from openpype_modules.ftrack.lib import BaseAction, statics_icon -from openpype.api import Anatomy, get_project_settings +from openpype.api import get_project_basic_paths, create_project_folders class CreateProjectFolders(BaseAction): @@ -72,25 +72,18 @@ class CreateProjectFolders(BaseAction): def launch(self, session, entities, event): # Get project entity project_entity = self.get_project_from_entity(entities[0]) - # Load settings for project project_name = project_entity["full_name"] - project_settings = get_project_settings(project_name) - project_folder_structure = ( - project_settings["global"]["project_folder_structure"] - ) - if not project_folder_structure: - return { - "success": False, - "message": "Project structure is not set." - } - try: - if isinstance(project_folder_structure, str): - project_folder_structure = json.loads(project_folder_structure) - # Get paths based on presets - basic_paths = self.get_path_items(project_folder_structure) - self.create_folders(basic_paths, project_entity) + basic_paths = get_project_basic_paths(project_name) + if not basic_paths: + return { + "success": False, + "message": "Project structure is not set." + } + + # Invoking OpenPype API to create the project folders + create_project_folders(basic_paths, project_name) self.create_ftrack_entities(basic_paths, project_entity) except Exception as exc: @@ -195,58 +188,6 @@ class CreateProjectFolders(BaseAction): self.session.commit() return new_ent - def get_path_items(self, in_dict): - output = [] - for key, value in in_dict.items(): - if not value: - output.append(key) - else: - paths = self.get_path_items(value) - for path in paths: - if not isinstance(path, (list, tuple)): - path = [path] - - output.append([key, *path]) - - return output - - def compute_paths(self, basic_paths_items, project_root): - output = [] - for path_items in basic_paths_items: - clean_items = [] - for path_item in path_items: - matches = re.findall(self.pattern_array, path_item) - if len(matches) > 0: - path_item = path_item.replace(matches[0], "") - if path_item == self.project_root_key: - path_item = project_root - clean_items.append(path_item) - output.append(os.path.normpath(os.path.sep.join(clean_items))) - return output - - def create_folders(self, basic_paths, project): - anatomy = Anatomy(project["full_name"]) - roots_paths = [] - if isinstance(anatomy.roots, dict): - for root in anatomy.roots.values(): - roots_paths.append(root.value) - else: - roots_paths.append(anatomy.roots.value) - - for root_path in roots_paths: - project_root = os.path.join(root_path, project["full_name"]) - full_paths = self.compute_paths(basic_paths, project_root) - # Create folders - for path in full_paths: - full_path = path.format(project_root=project_root) - if os.path.exists(full_path): - self.log.debug( - "Folder already exists: {}".format(full_path) - ) - else: - self.log.debug("Creating folder: {}".format(full_path)) - os.makedirs(full_path) - def register(session): CreateProjectFolders(session).register() diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py index 063f086e9c..c66d1819ac 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -23,6 +23,8 @@ class DeleteOldVersions(BaseAction): ) icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") + settings_key = "delete_old_versions" + dbcon = AvalonMongoDB() inteface_title = "Choose your preferences" diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py index d6ca561bbe..cd2f371f38 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py @@ -1,4 +1,6 @@ import time +import sys +import json import traceback from openpype_modules.ftrack.lib import BaseAction, statics_icon @@ -30,17 +32,10 @@ class SyncToAvalonLocal(BaseAction): - or do it manually (Not recommended) """ - #: Action identifier. identifier = "sync.to.avalon.local" - #: Action label. label = "OpenPype Admin" - #: Action variant variant = "- Sync To Avalon (Local)" - #: Action description. - description = "Send data from Ftrack to Avalon" - #: priority priority = 200 - #: roles that are allowed to register this action icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "sync_to_avalon_local" @@ -63,17 +58,80 @@ class SyncToAvalonLocal(BaseAction): return is_valid def launch(self, session, in_entities, event): + self.log.debug("{}: Creating job".format(self.label)) + + user_entity = session.query( + "User where id is {}".format(event["source"]["user"]["id"]) + ).one() + job_entity = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Sync to avalon is running..." + }) + }) + session.commit() + + project_entity = self.get_project_from_entity(in_entities[0]) + project_name = project_entity["full_name"] + + try: + result = self.synchronization(event, project_name) + + except Exception: + self.log.error( + "Synchronization failed due to code error", exc_info=True + ) + + description = "Sync to avalon Crashed (Download traceback)" + self.add_traceback_to_job( + job_entity, session, sys.exc_info(), description + ) + + msg = "An error has happened during synchronization" + title = "Synchronization report ({}):".format(project_name) + items = [] + items.append({ + "type": "label", + "value": "# {}".format(msg) + }) + items.append({ + "type": "label", + "value": ( + "

Download report from job for more information.

" + ) + }) + + report = {} + try: + report = self.entities_factory.report() + except Exception: + pass + + _items = report.get("items") or [] + if _items: + items.append(self.entities_factory.report_splitter) + items.extend(_items) + + self.show_interface(items, title, event, submit_btn_label="Ok") + + return {"success": True, "message": msg} + + job_entity["status"] = "done" + job_entity["data"] = json.dumps({ + "description": "Sync to avalon finished." + }) + session.commit() + + return result + + def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) - # Get ftrack project - if in_entities[0].entity_type.lower() == "project": - ft_project_name = in_entities[0]["full_name"] - else: - ft_project_name = in_entities[0]["project"]["full_name"] try: - output = self.entities_factory.launch_setup(ft_project_name) + output = self.entities_factory.launch_setup(project_name) if output is not None: return output @@ -83,7 +141,7 @@ class SyncToAvalonLocal(BaseAction): time_2 = time.time() # This must happen before all filtering!!! - self.entities_factory.prepare_avalon_entities(ft_project_name) + self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() @@ -129,7 +187,7 @@ class SyncToAvalonLocal(BaseAction): report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( - ft_project_name + project_name ) self.show_interface( items=report["items"], @@ -141,46 +199,6 @@ class SyncToAvalonLocal(BaseAction): "message": "Synchronization Finished" } - except Exception: - self.log.error( - "Synchronization failed due to code error", exc_info=True - ) - msg = "An error occurred during synchronization" - title = "Synchronization report ({}):".format(ft_project_name) - items = [] - items.append({ - "type": "label", - "value": "# {}".format(msg) - }) - items.append({ - "type": "label", - "value": "## Traceback of the error" - }) - items.append({ - "type": "label", - "value": "

{}

".format( - str(traceback.format_exc()).replace( - "\n", "
").replace( - " ", " " - ) - ) - }) - - report = {"items": []} - try: - report = self.entities_factory.report() - except Exception: - pass - - _items = report.get("items", []) - if _items: - items.append(self.entities_factory.report_splitter) - items.extend(_items) - - self.show_interface(items, title, event) - - return {"success": True, "message": msg} - finally: try: self.entities_factory.dbcon.uninstall() diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 0258b0ea1e..5a1fdbc276 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -6,10 +6,8 @@ from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITrayModule, IPluginPaths, - ITimersManager, ILaunchHookPaths, - ISettingsChangeListener, - IFtrackEventHandlerPaths + ISettingsChangeListener ) from openpype.settings import SaveWarningExc @@ -20,7 +18,6 @@ class FtrackModule( OpenPypeModule, ITrayModule, IPluginPaths, - ITimersManager, ILaunchHookPaths, ISettingsChangeListener ): @@ -60,6 +57,10 @@ class FtrackModule( self.user_event_handlers_paths = user_event_handlers_paths self.tray_module = None + # TimersManager connection + self.timers_manager_connector = None + self._timers_manager_module = None + def get_global_environments(self): """Ftrack's global environments.""" return { @@ -78,9 +79,17 @@ class FtrackModule( def connect_with_modules(self, enabled_modules): for module in enabled_modules: - if not isinstance(module, IFtrackEventHandlerPaths): + if not hasattr(module, "get_ftrack_event_handler_paths"): continue - paths_by_type = module.get_event_handler_paths() or {} + + try: + paths_by_type = module.get_ftrack_event_handler_paths() + except Exception: + continue + + if not isinstance(paths_by_type, dict): + continue + for key, value in paths_by_type.items(): if not value: continue @@ -101,16 +110,6 @@ class FtrackModule( elif key == "user": self.user_event_handlers_paths.extend(value) - def start_timer(self, data): - """Implementation of ITimersManager interface.""" - if self.tray_module: - self.tray_module.start_timer_manager(data) - - def stop_timer(self): - """Implementation of ITimersManager interface.""" - if self.tray_module: - self.tray_module.stop_timer_manager() - def on_system_settings_save( self, old_value, new_value, changes, new_value_metadata ): @@ -230,7 +229,13 @@ class FtrackModule( return import ftrack_api - from openpype_modules.ftrack.lib import get_openpype_attr + from openpype_modules.ftrack.lib import ( + get_openpype_attr, + default_custom_attributes_definition, + CUST_ATTR_TOOLS, + CUST_ATTR_APPLICATIONS, + CUST_ATTR_INTENT + ) try: session = self.create_ftrack_session() @@ -255,6 +260,15 @@ class FtrackModule( project_id = project_entity["id"] + ca_defs = default_custom_attributes_definition() + hierarchical_attrs = ca_defs.get("is_hierarchical") or {} + project_attrs = ca_defs.get("show") or {} + ca_keys = ( + set(hierarchical_attrs.keys()) + | set(project_attrs.keys()) + | {CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT} + ) + cust_attr, hier_attr = get_openpype_attr(session) cust_attr_by_key = {attr["key"]: attr for attr in cust_attr} hier_attrs_by_key = {attr["key"]: attr for attr in hier_attr} @@ -262,6 +276,9 @@ class FtrackModule( failed = {} missing = {} for key, value in attributes_changes.items(): + if key not in ca_keys: + continue + configuration = hier_attrs_by_key.get(key) if not configuration: configuration = cust_attr_by_key.get(key) @@ -342,7 +359,10 @@ class FtrackModule( def tray_init(self): from .tray import FtrackTrayWrapper + self.tray_module = FtrackTrayWrapper(self) + # Module is it's own connector to TimersManager + self.timers_manager_connector = self def tray_menu(self, parent_menu): return self.tray_module.tray_menu(parent_menu) @@ -357,6 +377,26 @@ class FtrackModule( os.environ["FTRACK_API_USER"] = username or "" os.environ["FTRACK_API_KEY"] = api_key or "" + # --- TimersManager connection methods --- + def start_timer(self, data): + if self.tray_module: + self.tray_module.start_timer_manager(data) + + def stop_timer(self): + if self.tray_module: + self.tray_module.stop_timer_manager() + + def register_timers_manager(self, timer_manager_module): + self._timers_manager_module = timer_manager_module + + def timer_started(self, data): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self.id, data) + + def timer_stopped(self): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self.id) + def get_task_time(self, project_name, asset_name, task_name): session = self.create_ftrack_session() query = ( diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index d8e4d05580..1a76905b38 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -6,7 +6,6 @@ import subprocess import socket import json import platform -import argparse import getpass import atexit import time @@ -16,7 +15,10 @@ import ftrack_api import pymongo from openpype.lib import ( get_pype_execute_args, - OpenPypeMongoConnection + 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 @@ -35,11 +37,15 @@ class MongoPermissionsError(Exception): def check_mongo_url(mongo_uri, log_error=False): """Checks if mongo server is responding""" try: - client = pymongo.MongoClient(mongo_uri) - # Force connection on a request as the connect=True parameter of - # MongoClient seems to be useless here - client.server_info() - client.close() + validate_mongo_connection(mongo_uri) + + except pymongo.errors.InvalidURI as err: + if log_error: + print("Can't connect to MongoDB at {} because: {}".format( + mongo_uri, err + )) + return False + except pymongo.errors.ServerSelectionTimeoutError as err: if log_error: print("Can't connect to MongoDB at {} because: {}".format( @@ -236,14 +242,16 @@ def main_loop(ftrack_url): statuser_thread=statuser_thread ) - system_name, pc_name = platform.uname()[:2] host_name = socket.gethostname() - main_info = { - "created_at": datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S"), - "Username": getpass.getuser(), - "Host Name": host_name, - "Host IP": socket.gethostbyname(host_name) - } + main_info = [ + ["created_at", datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S")], + ["Username", getpass.getuser()], + ["Host Name", host_name], + ["Host IP", socket.gethostbyname(host_name)], + ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] main_info_str = json.dumps(main_info) # Main loop while True: diff --git a/openpype/modules/default_modules/ftrack/interfaces.py b/openpype/modules/default_modules/ftrack/interfaces.py deleted file mode 100644 index 16ce0d2e62..0000000000 --- a/openpype/modules/default_modules/ftrack/interfaces.py +++ /dev/null @@ -1,12 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IFtrackEventHandlerPaths(OpenPypeInterface): - """Other modules interface to return paths to ftrack event handlers. - - Expected output is dictionary with "server" and "user" keys. - """ - @abstractmethod - def get_event_handler_paths(self): - pass diff --git a/openpype/modules/default_modules/ftrack/lib/__init__.py b/openpype/modules/default_modules/ftrack/lib/__init__.py index 9dc2d67279..80b4db9dd6 100644 --- a/openpype/modules/default_modules/ftrack/lib/__init__.py +++ b/openpype/modules/default_modules/ftrack/lib/__init__.py @@ -3,10 +3,10 @@ from .constants import ( CUST_ATTR_AUTO_SYNC, CUST_ATTR_GROUP, CUST_ATTR_TOOLS, - CUST_ATTR_APPLICATIONS + CUST_ATTR_APPLICATIONS, + CUST_ATTR_INTENT ) -from . settings import ( - get_ftrack_url_from_settings, +from .settings import ( get_ftrack_event_mongo_info ) from .custom_attributes import ( @@ -31,7 +31,6 @@ __all__ = ( "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", - "get_ftrack_url_from_settings", "get_ftrack_event_mongo_info", "default_custom_attributes_definition", diff --git a/openpype/modules/default_modules/ftrack/lib/constants.py b/openpype/modules/default_modules/ftrack/lib/constants.py index 73d5112e6d..e6e2013d2b 100644 --- a/openpype/modules/default_modules/ftrack/lib/constants.py +++ b/openpype/modules/default_modules/ftrack/lib/constants.py @@ -10,3 +10,5 @@ CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" CUST_ATTR_APPLICATIONS = "applications" # Environment tools custom attribute CUST_ATTR_TOOLS = "tools_env" +# Intent custom attribute name +CUST_ATTR_INTENT = "intent" diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py index 7027154d86..a457b886ac 100644 --- a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py @@ -384,8 +384,8 @@ class BaseHandler(object): ) def show_interface( - self, items, title='', - event=None, user=None, username=None, user_id=None + self, items, title="", event=None, user=None, + username=None, user_id=None, submit_btn_label=None ): """ Shows interface to user @@ -428,14 +428,18 @@ class BaseHandler(object): 'applicationId=ftrack.client.web and user.id="{0}"' ).format(user_id) + event_data = { + "type": "widget", + "items": items, + "title": title + } + if submit_btn_label: + event_data["submit_button_label"] = submit_btn_label + self.session.event_hub.publish( ftrack_api.event.base.Event( topic='ftrack.action.trigger-user-interface', - data=dict( - type='widget', - items=items, - title=title - ), + data=event_data, target=target ), on_error='ignore' @@ -443,7 +447,7 @@ class BaseHandler(object): def show_interface_from_dict( self, messages, title="", event=None, - user=None, username=None, user_id=None + user=None, username=None, user_id=None, submit_btn_label=None ): if not messages: self.log.debug("No messages to show! (messages dict is empty)") @@ -469,7 +473,9 @@ class BaseHandler(object): message = {'type': 'label', 'value': '

{}

'.format(value)} items.append(message) - self.show_interface(items, title, event, user, username, user_id) + self.show_interface( + items, title, event, user, username, user_id, submit_btn_label + ) def trigger_action( self, action_name, event=None, session=None, diff --git a/openpype/modules/default_modules/ftrack/lib/settings.py b/openpype/modules/default_modules/ftrack/lib/settings.py index 027356edc6..bf44981de0 100644 --- a/openpype/modules/default_modules/ftrack/lib/settings.py +++ b/openpype/modules/default_modules/ftrack/lib/settings.py @@ -1,13 +1,4 @@ import os -from openpype.api import get_system_settings - - -def get_ftrack_settings(): - return get_system_settings()["modules"]["ftrack"] - - -def get_ftrack_url_from_settings(): - return get_ftrack_settings()["ftrack_server"] def get_ftrack_event_mongo_info(): diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py index cc2a5b7d37..70030acad9 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -68,9 +68,6 @@ class CollectFtrackFamily(pyblish.api.InstancePlugin): instance.data["families"].append("ftrack") else: instance.data["families"] = ["ftrack"] - else: - self.log.debug("Instance '{}' doesn't match any profile".format( - instance.data.get("family"))) def _get_add_ftrack_f_from_addit_filters(self, additional_filters, diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py index 51b45eb93b..d1e2e3aaeb 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py @@ -13,6 +13,11 @@ from openpype_modules.ftrack.ftrack_server.lib import ( from openpype.modules import ModulesManager from openpype.api import Logger +from openpype.lib import ( + get_openpype_version, + get_build_version +) + import ftrack_api @@ -40,9 +45,11 @@ def send_status(event): new_event_data = { "subprocess_id": subprocess_id, "source": "processor", - "status_info": { - "created_at": subprocess_started.strftime("%Y.%m.%d %H:%M:%S") - } + "status_info": [ + ["created_at", subprocess_started.strftime("%Y.%m.%d %H:%M:%S")], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] } new_event = ftrack_api.event.base.Event( diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py index 8a2733b635..004f61338c 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py @@ -2,6 +2,7 @@ import os import sys import json import threading +import collections import signal import socket import datetime @@ -165,7 +166,7 @@ class StatusFactory: return source = event["data"]["source"] - data = event["data"]["status_info"] + data = collections.OrderedDict(event["data"]["status_info"]) self.update_status_info(source, data) @@ -348,7 +349,7 @@ def heartbeat(): def main(args): port = int(args[-1]) - server_info = json.loads(args[-2]) + server_info = collections.OrderedDict(json.loads(args[-2])) # Create a TCP/IP socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py index a8649e0ccc..5543ed74e2 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py @@ -14,7 +14,11 @@ from openpype_modules.ftrack.ftrack_server.lib import ( TOPIC_STATUS_SERVER_RESULT ) from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info -from openpype.lib import OpenPypeMongoConnection +from openpype.lib import ( + OpenPypeMongoConnection, + get_openpype_version, + get_build_version +) from openpype.api import Logger log = Logger.get_logger("Event storer") @@ -153,9 +157,11 @@ def send_status(event): new_event_data = { "subprocess_id": os.environ["FTRACK_EVENT_SUB_ID"], "source": "storer", - "status_info": { - "created_at": subprocess_started.strftime("%Y.%m.%d %H:%M:%S") - } + "status_info": [ + ["created_at", subprocess_started.strftime("%Y.%m.%d %H:%M:%S")], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] } new_event = ftrack_api.event.base.Event( diff --git a/openpype/modules/default_modules/ftrack/tray/login_dialog.py b/openpype/modules/default_modules/ftrack/tray/login_dialog.py index 6384621c8e..05d9226ca4 100644 --- a/openpype/modules/default_modules/ftrack/tray/login_dialog.py +++ b/openpype/modules/default_modules/ftrack/tray/login_dialog.py @@ -25,7 +25,7 @@ class CredentialsDialog(QtWidgets.QDialog): self._is_logged = False self._in_advance_mode = False - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/modules/default_modules/idle_manager/__init__.py b/openpype/modules/default_modules/idle_manager/__init__.py deleted file mode 100644 index 9d6e10bf39..0000000000 --- a/openpype/modules/default_modules/idle_manager/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .idle_module import ( - IdleManager -) - - -__all__ = ( - "IdleManager", -) diff --git a/openpype/modules/default_modules/idle_manager/idle_module.py b/openpype/modules/default_modules/idle_manager/idle_module.py deleted file mode 100644 index 1a6d71a961..0000000000 --- a/openpype/modules/default_modules/idle_manager/idle_module.py +++ /dev/null @@ -1,79 +0,0 @@ -import platform -import collections - -from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayService, - IIdleManager -) - - -class IdleManager(OpenPypeModule, ITrayService): - """ Measure user's idle time in seconds. - Idle time resets on keyboard/mouse input. - Is able to emit signals at specific time idle. - """ - label = "Idle Service" - name = "idle_manager" - - def initialize(self, module_settings): - enabled = True - # Ignore on MacOs - # - pynput need root permissions and enabled access for application - if platform.system().lower() == "darwin": - enabled = False - self.enabled = enabled - - self.time_callbacks = collections.defaultdict(list) - self.idle_thread = None - - def tray_init(self): - return - - def tray_start(self): - if self.time_callbacks: - self.start_thread() - - def tray_exit(self): - self.stop_thread() - try: - self.time_callbacks = {} - except Exception: - pass - - def connect_with_modules(self, enabled_modules): - for module in enabled_modules: - if not isinstance(module, IIdleManager): - continue - - module.idle_manager = self - callbacks_items = module.callbacks_by_idle_time() or {} - for emit_time, callbacks in callbacks_items.items(): - if not isinstance(callbacks, (tuple, list, set)): - callbacks = [callbacks] - self.time_callbacks[emit_time].extend(callbacks) - - @property - def idle_time(self): - if self.idle_thread and self.idle_thread.is_running: - return self.idle_thread.idle_time - - def _create_thread(self): - from .idle_threads import IdleManagerThread - - return IdleManagerThread(self) - - def start_thread(self): - if self.idle_thread: - self.idle_thread.stop() - self.idle_thread.join() - self.idle_thread = self._create_thread() - self.idle_thread.start() - - def stop_thread(self): - if self.idle_thread: - self.idle_thread.stop() - self.idle_thread.join() - - def on_thread_stop(self): - self.set_service_failed_icon() diff --git a/openpype/modules/default_modules/idle_manager/idle_threads.py b/openpype/modules/default_modules/idle_manager/idle_threads.py deleted file mode 100644 index f19feddb77..0000000000 --- a/openpype/modules/default_modules/idle_manager/idle_threads.py +++ /dev/null @@ -1,97 +0,0 @@ -import time -import threading - -from pynput import mouse, keyboard - -from openpype.lib import PypeLogger - - -class MouseThread(mouse.Listener): - """Listens user's mouse movement.""" - - def __init__(self, callback): - super(MouseThread, self).__init__(on_move=self.on_move) - self.callback = callback - - def on_move(self, posx, posy): - self.callback() - - -class KeyboardThread(keyboard.Listener): - """Listens user's keyboard input.""" - - def __init__(self, callback): - super(KeyboardThread, self).__init__(on_press=self.on_press) - - self.callback = callback - - def on_press(self, key): - self.callback() - - -class IdleManagerThread(threading.Thread): - def __init__(self, module, *args, **kwargs): - super(IdleManagerThread, self).__init__(*args, **kwargs) - self.log = PypeLogger.get_logger(self.__class__.__name__) - self.module = module - self.threads = [] - self.is_running = False - self.idle_time = 0 - - def stop(self): - self.is_running = False - - def reset_time(self): - self.idle_time = 0 - - @property - def time_callbacks(self): - return self.module.time_callbacks - - def on_stop(self): - self.is_running = False - self.log.info("IdleManagerThread has stopped") - self.module.on_thread_stop() - - def run(self): - self.log.info("IdleManagerThread has started") - self.is_running = True - thread_mouse = MouseThread(self.reset_time) - thread_keyboard = KeyboardThread(self.reset_time) - thread_mouse.start() - thread_keyboard.start() - try: - while self.is_running: - if self.idle_time in self.time_callbacks: - for callback in self.time_callbacks[self.idle_time]: - thread = threading.Thread(target=callback) - thread.start() - self.threads.append(thread) - - for thread in tuple(self.threads): - if not thread.isAlive(): - thread.join() - self.threads.remove(thread) - - self.idle_time += 1 - time.sleep(1) - - except Exception: - self.log.warning( - 'Idle Manager service has failed', exc_info=True - ) - - # Threads don't have their attrs when Qt application already finished - try: - thread_mouse.stop() - thread_mouse.join() - except AttributeError: - pass - - try: - thread_keyboard.stop() - thread_keyboard.join() - except AttributeError: - pass - - self.on_stop() diff --git a/openpype/modules/default_modules/idle_manager/interfaces.py b/openpype/modules/default_modules/idle_manager/interfaces.py deleted file mode 100644 index 71cd17a64a..0000000000 --- a/openpype/modules/default_modules/idle_manager/interfaces.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IIdleManager(OpenPypeInterface): - """Other modules interface to return callbacks by idle time in seconds. - - Expected output is dictionary with seconds as keys and callback/s - as value, value may be callback of list of callbacks. - EXAMPLE: - ``` - { - 60: self.on_minute_idle - } - ``` - """ - idle_manager = None - - @abstractmethod - def callbacks_by_idle_time(self): - pass - - @property - def idle_time(self): - if self.idle_manager: - return self.idle_manager.idle_time diff --git a/openpype/modules/default_modules/log_viewer/log_view_module.py b/openpype/modules/default_modules/log_viewer/log_view_module.py index bc1a98f4ad..14be6b392e 100644 --- a/openpype/modules/default_modules/log_viewer/log_view_module.py +++ b/openpype/modules/default_modules/log_viewer/log_view_module.py @@ -40,10 +40,6 @@ class LogViewModule(OpenPypeModule, ITrayModule): def tray_exit(self): return - def connect_with_modules(self, _enabled_modules): - """Nothing special.""" - return - def _show_logs_gui(self): if self.window: self.window.show() diff --git a/openpype/modules/default_modules/muster/muster.py b/openpype/modules/default_modules/muster/muster.py index a0e72006af..6e26ad2d7b 100644 --- a/openpype/modules/default_modules/muster/muster.py +++ b/openpype/modules/default_modules/muster/muster.py @@ -3,13 +3,10 @@ import json import appdirs import requests from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayModule, - IWebServerRoutes -) +from openpype_interfaces import ITrayModule -class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): +class MusterModule(OpenPypeModule, ITrayModule): """ Module handling Muster Render credentials. This will display dialog asking for user credentials for Muster if not already specified. @@ -54,9 +51,6 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): """Nothing special for Muster.""" return - def connect_with_modules(self, *_a, **_kw): - return - # Definition of Tray menu def tray_menu(self, parent): """Add **change credentials** option to tray menu.""" @@ -76,13 +70,6 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): parent.addMenu(menu) - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - if self.tray_initialized: - from .rest_api import MusterModuleRestApi - - self.rest_api_obj = MusterModuleRestApi(self, server_manager) - def load_credentials(self): """ Get credentials from JSON file @@ -142,6 +129,14 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): if self.widget_login: self.widget_login.show() + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for Muster login.""" + if self.tray_initialized: + from .rest_api import MusterModuleRestApi + + self.rest_api_obj = MusterModuleRestApi(self, server_manager) + def _requests_post(self, *args, **kwargs): """ Wrapper for requests, disabling SSL certificate validation if DONT_VERIFY_SSL environment variable is found. This is useful when diff --git a/openpype/modules/default_modules/muster/widget_login.py b/openpype/modules/default_modules/muster/widget_login.py index 231b52c6bd..ae838c6cea 100644 --- a/openpype/modules/default_modules/muster/widget_login.py +++ b/openpype/modules/default_modules/muster/widget_login.py @@ -17,7 +17,7 @@ class MusterLogin(QtWidgets.QWidget): self.module = module # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/modules/default_modules/project_manager_action.py b/openpype/modules/default_modules/project_manager_action.py index c1f984a8cb..251964a059 100644 --- a/openpype/modules/default_modules/project_manager_action.py +++ b/openpype/modules/default_modules/project_manager_action.py @@ -17,9 +17,6 @@ class ProjectManagerAction(OpenPypeModule, ITrayAction): # Tray attributes self.project_manager_window = None - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_project_manager_window() diff --git a/openpype/modules/default_modules/python_console_interpreter/module.py b/openpype/modules/default_modules/python_console_interpreter/module.py index f4df3fb6d8..8c4a2fba73 100644 --- a/openpype/modules/default_modules/python_console_interpreter/module.py +++ b/openpype/modules/default_modules/python_console_interpreter/module.py @@ -18,9 +18,6 @@ class PythonInterpreterAction(OpenPypeModule, ITrayAction): if self._interpreter_window is not None: self._interpreter_window.save_registry() - def connect_with_modules(self, *args, **kwargs): - pass - def create_interpreter_window(self): """Initializa Settings Qt window.""" if self._interpreter_window: diff --git a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py index 975decf4f4..0e8dd2fb9b 100644 --- a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py +++ b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py @@ -331,7 +331,7 @@ class PythonInterpreterWidget(QtWidgets.QWidget): super(PythonInterpreterWidget, self).__init__(parent) self.setWindowTitle("OpenPype Console") - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) self.ansi_escape = re.compile( r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]" @@ -387,8 +387,6 @@ class PythonInterpreterWidget(QtWidgets.QWidget): self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - self._init_from_registry() if self._tab_widget.count() < 1: @@ -396,16 +394,23 @@ class PythonInterpreterWidget(QtWidgets.QWidget): def _init_from_registry(self): setting_registry = PythonInterpreterRegistry() - + width = None + height = None try: width = setting_registry.get_item("width") height = setting_registry.get_item("height") - if width is not None and height is not None: - self.resize(width, height) except ValueError: pass + if width is None or width < 200: + width = self.default_width + + if height is None or height < 200: + height = self.default_height + + self.resize(width, height) + try: sizes = setting_registry.get_item("splitter_sizes") if len(sizes) == len(self._widgets_splitter.sizes()): diff --git a/openpype/modules/default_modules/settings_module/settings_action.py b/openpype/modules/default_modules/settings_module/settings_action.py index 7140c57bab..2b4b51e3ad 100644 --- a/openpype/modules/default_modules/settings_module/settings_action.py +++ b/openpype/modules/default_modules/settings_module/settings_action.py @@ -19,9 +19,6 @@ class SettingsAction(OpenPypeModule, ITrayAction): # Tray attributes self.settings_window = None - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_settings_window() @@ -84,9 +81,6 @@ class LocalSettingsAction(OpenPypeModule, ITrayAction): self.settings_window = None self._first_trigger = True - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_settings_window() diff --git a/openpype/modules/default_modules/slack/slack_module.py b/openpype/modules/default_modules/slack/slack_module.py index e3f7b4ad19..9b2976d766 100644 --- a/openpype/modules/default_modules/slack/slack_module.py +++ b/openpype/modules/default_modules/slack/slack_module.py @@ -17,10 +17,6 @@ class SlackIntegrationModule(OpenPypeModule, IPluginPaths, ILaunchHookPaths): slack_settings = modules_settings[self.name] self.enabled = slack_settings["enabled"] - def connect_with_modules(self, _enabled_modules): - """Nothing special.""" - return - def get_launch_hook_paths(self): """Implementation of `ILaunchHookPaths`.""" return os.path.join(SLACK_MODULE_DIR, "launch_hooks") diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py index 2e9632134c..8ae0ceed79 100644 --- a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py @@ -29,13 +29,35 @@ class AbstractProvider: @classmethod @abc.abstractmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties + Returns dict for editable properties on system settings level Returns: - (dict) + (list) of dict + """ + + @classmethod + @abc.abstractmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + + @classmethod + @abc.abstractmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + + + Returns: + (list) of dict """ @abc.abstractmethod @@ -58,7 +80,8 @@ class AbstractProvider: representation (dict): complete repre containing 'file' site (str): site name Returns: - (string) file_id of created file, raises exception + (string) file_id of created/modified file , + throws FileExistsError, FileNotFoundError exceptions """ pass @@ -81,7 +104,8 @@ class AbstractProvider: representation (dict): complete repre containing 'file' site (str): site name Returns: - None + (string) file_id of created/modified file , + throws FileExistsError, FileNotFoundError exceptions """ pass diff --git a/openpype/modules/default_modules/sync_server/providers/dropbox.py b/openpype/modules/default_modules/sync_server/providers/dropbox.py new file mode 100644 index 0000000000..0d735a0b59 --- /dev/null +++ b/openpype/modules/default_modules/sync_server/providers/dropbox.py @@ -0,0 +1,423 @@ +import os + +import dropbox + +from openpype.api import Logger +from .abstract_provider import AbstractProvider +from ..utils import EditableScopes + +log = Logger().get_logger("SyncServer") + + +class DropboxHandler(AbstractProvider): + CODE = 'dropbox' + LABEL = 'Dropbox' + + def __init__(self, project_name, site_name, tree=None, presets=None): + self.active = False + self.site_name = site_name + self.presets = presets + + if not self.presets: + log.info( + "Sync Server: There are no presets for {}.".format(site_name) + ) + return + + provider_presets = self.presets.get(self.CODE) + if not provider_presets: + msg = "Sync Server: No provider presets for {}".format(self.CODE) + log.info(msg) + return + + token = self.presets[self.CODE].get("token", "") + if not token: + msg = "Sync Server: No access token for dropbox provider" + log.info(msg) + return + + team_folder_name = self.presets[self.CODE].get("team_folder_name", "") + if not team_folder_name: + msg = "Sync Server: No team folder name for dropbox provider" + log.info(msg) + return + + acting_as_member = self.presets[self.CODE].get("acting_as_member", "") + if not acting_as_member: + msg = ( + "Sync Server: No acting member for dropbox provider" + ) + log.info(msg) + return + + self.dbx = None + try: + self.dbx = self._get_service( + token, acting_as_member, team_folder_name + ) + except Exception as e: + log.info("Could not establish dropbox object: {}".format(e)) + return + + super(AbstractProvider, self).__init__() + + @classmethod + def get_system_settings_schema(cls): + """ + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned + return [ + { + "type": "text", + "key": "token", + "label": "Access Token" + }, + { + "type": "text", + "key": "team_folder_name", + "label": "Team Folder Name" + }, + { + "type": "text", + "key": "acting_as_member", + "label": "Acting As Member" + }, + # roots could be overriden only on Project level, User cannot + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + + + Returns: + (dict) + """ + return [] + + def _get_service(self, token, acting_as_member, team_folder_name): + dbx = dropbox.DropboxTeam(token) + + # Getting member id. + member_id = None + member_names = [] + for member in dbx.team_members_list().members: + member_names.append(member.profile.name.display_name) + if member.profile.name.display_name == acting_as_member: + member_id = member.profile.team_member_id + + if member_id is None: + raise ValueError( + "Could not find member \"{}\". Available members: {}".format( + acting_as_member, member_names + ) + ) + + # Getting team folder id. + team_folder_id = None + team_folder_names = [] + for entry in dbx.team_team_folder_list().team_folders: + team_folder_names.append(entry.name) + if entry.name == team_folder_name: + team_folder_id = entry.team_folder_id + + if team_folder_id is None: + raise ValueError( + "Could not find team folder \"{}\". Available folders: " + "{}".format( + team_folder_name, team_folder_names + ) + ) + + # Establish dropbox object. + path_root = dropbox.common.PathRoot.namespace_id(team_folder_id) + return dropbox.DropboxTeam( + token + ).with_path_root(path_root).as_user(member_id) + + def is_active(self): + """ + Returns True if provider is activated, eg. has working credentials. + Returns: + (boolean) + """ + return self.dbx is not None + + @classmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties + + + Returns: + (dict) + """ + editable = { + 'token': { + 'scope': [EditableScopes.PROJECT], + 'label': "Access Token", + 'type': 'text', + 'namespace': ( + '{project_settings}/global/sync_server/sites/{site}/token' + ) + }, + 'team_folder_name': { + 'scope': [EditableScopes.PROJECT], + 'label': "Team Folder Name", + 'type': 'text', + 'namespace': ( + '{project_settings}/global/sync_server/sites/{site}' + '/team_folder_name' + ) + }, + 'acting_as_member': { + 'scope': [EditableScopes.PROJECT, EditableScopes.LOCAL], + 'label': "Acting As Member", + 'type': 'text', + 'namespace': ( + '{project_settings}/global/sync_server/sites/{site}' + '/acting_as_member' + ) + } + } + return editable + + def _path_exists(self, path): + try: + entries = self.dbx.files_list_folder( + path=os.path.dirname(path) + ).entries + except dropbox.exceptions.ApiError: + return False + + for entry in entries: + if entry.name == os.path.basename(path): + return True + + return False + + def upload_file(self, source_path, path, + server, collection, file, representation, site, + overwrite=False): + """ + Copy file from 'source_path' to 'target_path' on provider. + Use 'overwrite' boolean to rewrite existing file on provider + + Args: + source_path (string): + path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name + Returns: + (string) file_id of created file, raises exception + """ + # Check source path. + if not os.path.exists(source_path): + raise FileNotFoundError( + "Source file {} doesn't exist.".format(source_path) + ) + + if self._path_exists(path) and not overwrite: + raise FileExistsError( + "File already exists, use 'overwrite' argument" + ) + + mode = dropbox.files.WriteMode("add", None) + if overwrite: + mode = dropbox.files.WriteMode.overwrite + + with open(source_path, "rb") as f: + self.dbx.files_upload(f.read(), path, mode=mode) + + server.update_db( + collection=collection, + new_file_id=None, + file=file, + representation=representation, + site=site, + progress=100 + ) + + return path + + def download_file(self, source_path, local_path, + server, collection, file, representation, site, + overwrite=False): + """ + Download file from provider into local system + + Args: + source_path (string): absolute path on provider + local_path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name + Returns: + None + """ + # Check source path. + if not self._path_exists(source_path): + raise FileNotFoundError( + "Source file {} doesn't exist.".format(source_path) + ) + + if os.path.exists(local_path) and not overwrite: + raise FileExistsError( + "File already exists, use 'overwrite' argument" + ) + + if os.path.exists(local_path) and overwrite: + os.unlink(local_path) + + self.dbx.files_download_to_file(local_path, source_path) + + server.update_db( + collection=collection, + new_file_id=None, + file=file, + representation=representation, + site=site, + progress=100 + ) + + return os.path.basename(source_path) + + def delete_file(self, path): + """ + Deletes file from 'path'. Expects path to specific file. + + Args: + path (string): absolute path to particular file + + Returns: + None + """ + if not self._path_exists(path): + raise FileExistsError("File {} doesn't exist".format(path)) + + self.dbx.files_delete(path) + + def list_folder(self, folder_path): + """ + List all files and subfolders of particular path non-recursively. + Args: + folder_path (string): absolut path on provider + + Returns: + (list) + """ + if not self._path_exists(folder_path): + raise FileExistsError( + "Folder \"{}\" does not exist".format(folder_path) + ) + + entry_names = [] + for entry in self.dbx.files_list_folder(path=folder_path).entries: + entry_names.append(entry.name) + return entry_names + + def create_folder(self, folder_path): + """ + Create all nonexistent folders and subfolders in 'path'. + + Args: + path (string): absolute path + + Returns: + (string) folder id of lowest subfolder from 'path' + """ + if self._path_exists(folder_path): + return folder_path + + self.dbx.files_create_folder_v2(folder_path) + + return folder_path + + def get_tree(self): + """ + Creates folder structure for providers which do not provide + tree folder structure (GDrive has no accessible tree structure, + only parents and their parents) + """ + pass + + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Takes value from Anatomy which takes values from Settings + overridden by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + return self.presets['root'] + + def resolve_path(self, path, root_config=None, anatomy=None): + """ + Replaces all root placeholders with proper values + + Args: + path(string): root[work]/folder... + root_config (dict): {'work': "c:/..."...} + anatomy (Anatomy): object of Anatomy + Returns: + (string): proper url + """ + if not root_config: + root_config = self.get_roots_config(anatomy) + + if root_config and not root_config.get("root"): + root_config = {"root": root_config} + + try: + if not root_config: + raise KeyError + + path = path.format(**root_config) + except KeyError: + try: + path = anatomy.fill_root(path) + except KeyError: + msg = "Error in resolving local root from anatomy" + log.error(msg) + raise ValueError(msg) + + return path diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/default_modules/sync_server/providers/gdrive.py index 18d679b833..0aabd9fbcd 100644 --- a/openpype/modules/default_modules/sync_server/providers/gdrive.py +++ b/openpype/modules/default_modules/sync_server/providers/gdrive.py @@ -8,7 +8,7 @@ import platform from openpype.api import Logger from openpype.api import get_system_settings from .abstract_provider import AbstractProvider -from ..utils import time_function, ResumableError, EditableScopes +from ..utils import time_function, ResumableError log = Logger().get_logger("SyncServer") @@ -61,7 +61,6 @@ class GDriveHandler(AbstractProvider): CHUNK_SIZE = 2097152 # must be divisible by 256! used for upload chunks def __init__(self, project_name, site_name, tree=None, presets=None): - self.presets = None self.active = False self.project_name = project_name self.site_name = site_name @@ -74,7 +73,13 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - cred_path = self.presets.get("credentials_url", {}).\ + provider_presets = self.presets.get(self.CODE) + if not provider_presets: + msg = "Sync Server: No provider presets for {}".format(self.CODE) + log.info(msg) + return + + cred_path = self.presets[self.CODE].get("credentials_url", {}).\ get(platform.system().lower()) or '' if not os.path.exists(cred_path): msg = "Sync Server: No credentials for gdrive provider " + \ @@ -96,30 +101,61 @@ class GDriveHandler(AbstractProvider): return self.service is not None @classmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties. + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned + editable = [ + # credentials could be overriden on Project or User level + { + 'key': "credentials_url", + 'label': "Credentials url", + 'type': 'text' + }, + # roots could be overriden only on Project leve, User cannot + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + return editable + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level Returns: (dict) """ - # {platform} tells that value is multiplatform and only specific OS - # should be returned - editable = { + editable = [ # credentials could be override on Project or User level - 'credentials_url': { - 'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], + { + 'key': "credentials_url", 'label': "Credentials url", 'type': 'text', 'namespace': '{project_settings}/global/sync_server/sites/{site}/credentials_url/{platform}' # noqa: E501 - }, - # roots could be override only on Project leve, User cannot - 'root': {'scope': [EditableScopes.PROJECT], - 'label': "Roots", - 'type': 'dict'} - } + } + ] return editable def get_roots_config(self, anatomy=None): diff --git a/openpype/modules/default_modules/sync_server/providers/lib.py b/openpype/modules/default_modules/sync_server/providers/lib.py index 816ccca981..3daee366cf 100644 --- a/openpype/modules/default_modules/sync_server/providers/lib.py +++ b/openpype/modules/default_modules/sync_server/providers/lib.py @@ -1,5 +1,7 @@ from .gdrive import GDriveHandler +from .dropbox import DropboxHandler from .local_drive import LocalDriveHandler +from .sftp import SFTPHandler class ProviderFactory: @@ -76,6 +78,14 @@ class ProviderFactory: return provider_info[0].get_configurable_items() + def get_provider_cls(self, provider_code): + """ + Returns class object for 'provider_code' to run class methods on. + """ + provider_info = self._get_creator_info(provider_code) + + return provider_info[0] + def _get_creator_info(self, provider): """ Collect all necessary info for provider. Currently only creator @@ -103,4 +113,6 @@ factory = ProviderFactory() # 7 denotes number of files that could be synced in single loop - learned by # trial and error factory.register_provider(GDriveHandler.CODE, GDriveHandler, 7) +factory.register_provider(DropboxHandler.CODE, DropboxHandler, 10) factory.register_provider(LocalDriveHandler.CODE, LocalDriveHandler, 50) +factory.register_provider(SFTPHandler.CODE, SFTPHandler, 20) diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/default_modules/sync_server/providers/local_drive.py index 4b80ed44f2..8e5f170bc9 100644 --- a/openpype/modules/default_modules/sync_server/providers/local_drive.py +++ b/openpype/modules/default_modules/sync_server/providers/local_drive.py @@ -7,8 +7,6 @@ import time from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider -from ..utils import EditableScopes - log = Logger().get_logger("SyncServer") @@ -30,18 +28,51 @@ class LocalDriveHandler(AbstractProvider): return True @classmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + # for non 'studio' sites, 'studio' is configured in Anatomy + editable = [ + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + return editable + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + Returns: (dict) """ - editable = { - 'root': {'scope': [EditableScopes.LOCAL], - 'label': "Roots", - 'type': 'dict'} - } + editable = [ + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] return editable def upload_file(self, source_path, target_path, diff --git a/openpype/modules/default_modules/sync_server/providers/resources/dropbox.png b/openpype/modules/default_modules/sync_server/providers/resources/dropbox.png new file mode 100644 index 0000000000..6f56e3335b Binary files /dev/null and b/openpype/modules/default_modules/sync_server/providers/resources/dropbox.png differ diff --git a/openpype/modules/default_modules/sync_server/providers/resources/sftp.png b/openpype/modules/default_modules/sync_server/providers/resources/sftp.png new file mode 100644 index 0000000000..56c7a5cca3 Binary files /dev/null and b/openpype/modules/default_modules/sync_server/providers/resources/sftp.png differ diff --git a/openpype/modules/default_modules/sync_server/providers/sftp.py b/openpype/modules/default_modules/sync_server/providers/sftp.py new file mode 100644 index 0000000000..07450265e2 --- /dev/null +++ b/openpype/modules/default_modules/sync_server/providers/sftp.py @@ -0,0 +1,461 @@ +import os +import os.path +import time +import sys +import six +import threading +import platform + +from openpype.api import Logger +from openpype.api import get_system_settings +from .abstract_provider import AbstractProvider +log = Logger().get_logger("SyncServer") + +pysftp = None +try: + import pysftp +except (ImportError, SyntaxError): + pass + + # handle imports from Python 2 hosts - in those only basic methods are used + log.warning("Import failed, imported from Python 2, operations will fail.") + + +class SFTPHandler(AbstractProvider): + """ + Implementation of SFTP API. + + Authentication could be done in 2 ways: + - user and password + - ssh key file for user (optionally password for ssh key) + + Settings could be overwritten per project. + + """ + CODE = 'sftp' + LABEL = 'SFTP' + + def __init__(self, project_name, site_name, tree=None, presets=None): + self.presets = None + self.active = False + self.project_name = project_name + self.site_name = site_name + self.root = None + self._conn = None + + self.presets = presets + if not self.presets: + log.warning("Sync Server: There are no presets for {}.". + format(site_name)) + return + + provider_presets = self.presets.get(self.CODE) + if not provider_presets: + msg = "Sync Server: No provider presets for {}".format(self.CODE) + log.warning(msg) + return + + # store to instance for reconnect + self.sftp_host = provider_presets["sftp_host"] + self.sftp_port = provider_presets["sftp_port"] + self.sftp_user = provider_presets["sftp_user"] + self.sftp_pass = provider_presets["sftp_pass"] + self.sftp_key = provider_presets["sftp_key"] + self.sftp_key_pass = provider_presets["sftp_key_pass"] + + self._tree = None + self.active = True + + @property + def conn(self): + """SFTP connection, cannot be used in all places though.""" + if not self._conn: + self._conn = self._get_conn() + + return self._conn + + def is_active(self): + """ + Returns True if provider is activated, eg. has working credentials. + Returns: + (boolean) + """ + return self.conn is not None + + @classmethod + def get_system_settings_schema(cls): + """ + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + Currently not implemented in Settings yet! + + Returns: + (list) of dict + """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned + editable = [ + # credentials could be overriden on Project or User level + { + 'key': "sftp_server", + 'label': "SFTP host name", + 'type': 'text' + }, + { + "type": "number", + "key": "sftp_port", + "label": "SFTP port" + }, + { + 'key': "sftp_user", + 'label': "SFTP user name", + 'type': 'text' + }, + { + 'key': "sftp_pass", + 'label': "SFTP password", + 'type': 'text' + }, + { + 'key': "sftp_key", + 'label': "SFTP user ssh key", + 'type': 'path' + }, + { + 'key': "sftp_key_pass", + 'label': "SFTP user ssh key password", + 'type': 'text' + }, + # roots could be overriden only on Project leve, User cannot + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + return editable + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + + Currently not implemented in Settings yet! + + Returns: + (dict) + """ + editable = [ + # credentials could be override on Project or User level + { + 'key': "sftp_user", + 'label': "SFTP user name", + 'type': 'text' + }, + { + 'key': "sftp_pass", + 'label': "SFTP password", + 'type': 'text' + }, + { + 'key': "sftp_key", + 'label': "SFTP user ssh key", + 'type': 'path' + }, + { + 'key': "sftp_key_pass", + 'label': "SFTP user ssh key password", + 'type': 'text' + } + ] + return editable + + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Use only Settings as GDrive cannot be modified by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + # roots cannot be locally overridden + return self.presets['root'] + + def get_tree(self): + """ + Building of the folder tree could be potentially expensive, + constructor provides argument that could inject previously created + tree. + Tree structure must be handled in thread safe fashion! + Returns: + (dictionary) - url to id mapping + """ + # not needed in this provider + pass + + def create_folder(self, path): + """ + Create all nonexistent folders and subfolders in 'path'. + Updates self._tree structure with new paths + + Args: + path (string): absolute path, starts with GDrive root, + without filename + Returns: + (string) folder id of lowest subfolder from 'path' + """ + self.conn.makedirs(path) + + return os.path.basename(path) + + def upload_file(self, source_path, target_path, + server, collection, file, representation, site, + overwrite=False): + """ + Uploads single file from 'source_path' to destination 'path'. + It creates all folders on the path if are not existing. + + Args: + source_path (string): + target_path (string): absolute path with or without name of a file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name + + Returns: + (string) file_id of created/modified file , + throws FileExistsError, FileNotFoundError exceptions + """ + if not os.path.isfile(source_path): + raise FileNotFoundError("Source file {} doesn't exist." + .format(source_path)) + + if self.file_path_exists(target_path): + if not overwrite: + raise ValueError("File {} exists, set overwrite". + format(target_path)) + + thread = threading.Thread(target=self._upload, + args=(source_path, target_path)) + thread.start() + self._mark_progress(collection, file, representation, server, + site, source_path, target_path, "upload") + + return os.path.basename(target_path) + + def _upload(self, source_path, target_path): + print("copying {}->{}".format(source_path, target_path)) + conn = self._get_conn() + conn.put(source_path, target_path) + + def download_file(self, source_path, target_path, + server, collection, file, representation, site, + overwrite=False): + """ + Downloads single file from 'source_path' (remote) to 'target_path'. + It creates all folders on the local_path if are not existing. + By default existing file on 'target_path' will trigger an exception + + Args: + source_path (string): absolute path on provider + target_path (string): absolute path with or without name of a file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name + + Returns: + (string) file_id of created/modified file , + throws FileExistsError, FileNotFoundError exceptions + """ + if not self.file_path_exists(source_path): + raise FileNotFoundError("Source file {} doesn't exist." + .format(source_path)) + + if os.path.isfile(target_path): + if not overwrite: + raise ValueError("File {} exists, set overwrite". + format(target_path)) + + thread = threading.Thread(target=self._download, + args=(source_path, target_path)) + thread.start() + self._mark_progress(collection, file, representation, server, + site, source_path, target_path, "download") + + return os.path.basename(target_path) + + def _download(self, source_path, target_path): + print("downloading {}->{}".format(source_path, target_path)) + conn = self._get_conn() + conn.get(source_path, target_path) + + def delete_file(self, path): + """ + Deletes file from 'path'. Expects path to specific file. + + Args: + path: absolute path to particular file + + Returns: + None + """ + if not self.file_path_exists(path): + raise FileNotFoundError("File {} to be deleted doesn't exist." + .format(path)) + + self.conn.remove(path) + + def list_folder(self, folder_path): + """ + List all files and subfolders of particular path non-recursively. + + Args: + folder_path (string): absolut path on provider + Returns: + (list) + """ + return list(pysftp.path_advance(folder_path)) + + def folder_path_exists(self, file_path): + """ + Checks if path from 'file_path' exists. If so, return its + folder id. + Args: + file_path (string): path with / as a separator + Returns: + (string) folder id or False + """ + if not file_path: + return False + + return self.conn.isdir(file_path) + + def file_path_exists(self, file_path): + """ + Checks if 'file_path' exists on GDrive + + Args: + file_path (string): separated by '/', from root, with file name + Returns: + (dictionary|boolean) file metadata | False if not found + """ + if not file_path: + return False + + return self.conn.isfile(file_path) + + @classmethod + def get_presets(cls): + """ + Get presets for this provider + Returns: + (dictionary) of configured sites + """ + provider_presets = None + try: + provider_presets = ( + get_system_settings()["modules"] + ["sync_server"] + ["providers"] + ["sftp"] + ) + except KeyError: + log.info(("Sync Server: There are no presets for SFTP " + + "provider."). + format(str(provider_presets))) + return + return provider_presets + + def _get_conn(self): + """ + Returns fresh sftp connection. + + It seems that connection cannot be cached into self.conn, at least + for get and put which run in separate threads. + + Returns: + pysftp.Connection + """ + if not pysftp: + raise ImportError + + cnopts = pysftp.CnOpts() + cnopts.hostkeys = None + + conn_params = { + 'host': self.sftp_host, + 'port': self.sftp_port, + 'username': self.sftp_user, + 'cnopts': cnopts + } + if self.sftp_pass and self.sftp_pass.strip(): + conn_params['password'] = self.sftp_pass + if self.sftp_key: # expects .pem format, not .ppk! + conn_params['private_key'] = \ + self.sftp_key[platform.system().lower()] + if self.sftp_key_pass: + conn_params['private_key_pass'] = self.sftp_key_pass + + return pysftp.Connection(**conn_params) + + def _mark_progress(self, collection, file, representation, server, site, + source_path, target_path, direction): + """ + Updates progress field in DB by values 0-1. + + Compares file sizes of source and target. + """ + pass + if direction == "upload": + source_file_size = os.path.getsize(source_path) + else: + source_file_size = self.conn.stat(source_path).st_size + + target_file_size = 0 + last_tick = status_val = None + while source_file_size != target_file_size: + if not last_tick or \ + time.time() - last_tick >= server.LOG_PROGRESS_SEC: + status_val = target_file_size / source_file_size + last_tick = time.time() + log.debug(direction + "ed %d%%." % int(status_val * 100)) + server.update_db(collection=collection, + new_file_id=None, + file=file, + representation=representation, + site=site, + progress=status_val + ) + try: + if direction == "upload": + target_file_size = self.conn.stat(target_path).st_size + else: + target_file_size = os.path.getsize(target_path) + except FileNotFoundError: + pass + time.sleep(0.5) diff --git a/openpype/modules/default_modules/sync_server/sync_server.py b/openpype/modules/default_modules/sync_server/sync_server.py index 638a4a367f..2227ec9366 100644 --- a/openpype/modules/default_modules/sync_server/sync_server.py +++ b/openpype/modules/default_modules/sync_server/sync_server.py @@ -221,6 +221,7 @@ def _get_configured_sites_from_setting(module, project_name, project_setting): return configured_sites + class SyncServerThread(threading.Thread): """ Separate thread running synchronization server with asyncio loop. diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index e65a410551..f2e9237542 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -16,14 +16,13 @@ from openpype.api import ( get_local_site_id) from openpype.lib import PypeLogger from openpype.settings.lib import ( - get_default_project_settings, get_default_anatomy_settings, get_anatomy_settings) from .providers.local_drive import LocalDriveHandler from .providers import lib -from .utils import time_function, SyncStatus, EditableScopes +from .utils import time_function, SyncStatus log = PypeLogger().get_logger("SyncServer") @@ -399,204 +398,251 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return remote_site - def get_local_settings_schema(self): - """Wrapper for Local settings - all projects incl. Default""" - return self.get_configurable_items(EditableScopes.LOCAL) - - def get_configurable_items(self, scope=None): + def get_local_normalized_site(self, site_name): """ - Returns list of sites that could be configurable for all projects. + Return 'site_name' or 'local' if 'site_name' is local id. - Could be filtered by 'scope' argument (list) - - Args: - scope (list of utils.EditableScope) - - Returns: - (dict of list of dict) - { - siteA : [ - { - key:"root", label:"root", - "value":"{'work': 'c:/projects'}", - "type": "dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, - { - key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text", - "namespace": "{project_setting}/global/sync_server/ - sites" - } - ] - } + In some places Settings or Local Settings require 'local' instead + of real site name. """ - editable = {} - applicable_projects = list(self.connection.projects()) - applicable_projects.append(None) - for project in applicable_projects: - project_name = None - if project: - project_name = project["name"] + if site_name == get_local_site_id(): + site_name = self.LOCAL_SITE - items = self.get_configurable_items_for_project(project_name, - scope) - editable.update(items) + return site_name - return editable + # Methods for Settings UI to draw appropriate forms + @classmethod + def get_system_settings_schema(cls): + """ Gets system level schema of configurable items - def get_local_settings_schema_for_project(self, project_name): - """Wrapper for Local settings - for specific 'project_name'""" - return self.get_configurable_items_for_project(project_name, - EditableScopes.LOCAL) - - def get_configurable_items_for_project(self, project_name=None, - scope=None): + Used for Setting UI to provide forms. """ - Returns list of items that could be configurable for specific - 'project_name' + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_system_settings_schema() - Args: - project_name (str) - None > default project, - scope (list of utils.EditableScope) - (optional, None is all scopes, default is LOCAL) + return ret_dict - Returns: - (dict of list of dict) - { - siteA : [ - { - key:"root", label:"root", - "type": "dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, - { - key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text", - "namespace": "{project_setting}/global/sync_server/ - sites" - } - ] - } + @classmethod + def get_project_settings_schema(cls): + """ Gets project level schema of configurable items. + + It is not using Setting! Used for Setting UI to provide forms. """ - allowed_sites = set() - sites = self.get_all_site_configs(project_name) - if project_name: - # Local Settings can select only from allowed sites for project - allowed_sites.update(set(self.get_active_sites(project_name))) - allowed_sites.update(set(self.get_remote_sites(project_name))) + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_project_settings_schema() - editable = {} - for site_name in sites.keys(): - if allowed_sites and site_name not in allowed_sites: - continue + return ret_dict - items = self.get_configurable_items_for_site(project_name, - site_name, - scope) - # Local Settings need 'local' instead of real value - site_name = site_name.replace(get_local_site_id(), 'local') - editable[site_name] = items + @classmethod + def get_local_settings_schema(cls): + """ Gets local level schema of configurable items. - return editable - - def get_local_settings_schema_for_site(self, project_name, site_name): - """Wrapper for Local settings - for particular 'site_name and proj.""" - return self.get_configurable_items_for_site(project_name, - site_name, - EditableScopes.LOCAL) - - def get_configurable_items_for_site(self, project_name=None, - site_name=None, - scope=None): + It is not using Setting! Used for Setting UI to provide forms. """ - Returns list of items that could be configurable. + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_local_settings_schema() - Args: - project_name (str) - None > default project - site_name (str) - scope (list of utils.EditableScope) - (optional, None is all scopes) + return ret_dict - Returns: - (list) - [ - { - key:"root", label:"root", type:"dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, ... - ] - """ - provider_name = self.get_provider_for_site(site=site_name) - items = lib.factory.get_provider_configurable_items(provider_name) - - if project_name: - sync_s = self.get_sync_project_setting(project_name, - exclude_locals=True, - cached=False) - else: - sync_s = get_default_project_settings(exclude_locals=True) - sync_s = sync_s["global"]["sync_server"] - sync_s["sites"].update( - self._get_default_site_configs(self.enabled)) - - editable = [] - if type(scope) is not list: - scope = [scope] - scope = set(scope) - for key, properties in items.items(): - if scope is None or scope.intersection(set(properties["scope"])): - val = sync_s.get("sites", {}).get(site_name, {}).get(key) - - item = { - "key": key, - "label": properties["label"], - "type": properties["type"] - } - - if properties.get("namespace"): - item["namespace"] = properties.get("namespace") - if "platform" in item["namespace"]: - try: - if val: - val = val[platform.system().lower()] - except KeyError: - st = "{}'s field value {} should be".format(key, val) # noqa: E501 - log.error(st + " multiplatform dict") - - item["namespace"] = item["namespace"].replace('{site}', - site_name) - children = [] - if properties["type"] == "dict": - if val: - for val_key, val_val in val.items(): - child = { - "type": "text", - "key": val_key, - "value": val_val - } - children.append(child) - - if properties["type"] == "dict": - item["children"] = children - else: - item["value"] = val - - editable.append(item) - - return editable + # Needs to be refactored after Settings are updated + # # Methods for Settings to get appriate values to fill forms + # def get_configurable_items(self, scope=None): + # """ + # Returns list of sites that could be configurable for all projects + # + # Could be filtered by 'scope' argument (list) + # + # Args: + # scope (list of utils.EditableScope) + # + # Returns: + # (dict of list of dict) + # { + # siteA : [ + # { + # key:"root", label:"root", + # "value":"{'work': 'c:/projects'}", + # "type": "dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, + # { + # key:"credentials_url", label:"Credentials url", + # "value":"'c:/projects/cred.json'", "type": "text", # noqa: E501 + # "namespace": "{project_setting}/global/sync_server/ # noqa: E501 + # sites" + # } + # ] + # } + # """ + # editable = {} + # applicable_projects = list(self.connection.projects()) + # applicable_projects.append(None) + # for project in applicable_projects: + # project_name = None + # if project: + # project_name = project["name"] + # + # items = self.get_configurable_items_for_project(project_name, + # scope) + # editable.update(items) + # + # return editable + # + # def get_local_settings_schema_for_project(self, project_name): + # """Wrapper for Local settings - for specific 'project_name'""" + # return self.get_configurable_items_for_project(project_name, + # EditableScopes.LOCAL) + # + # def get_configurable_items_for_project(self, project_name=None, + # scope=None): + # """ + # Returns list of items that could be configurable for specific + # 'project_name' + # + # Args: + # project_name (str) - None > default project, + # scope (list of utils.EditableScope) + # (optional, None is all scopes, default is LOCAL) + # + # Returns: + # (dict of list of dict) + # { + # siteA : [ + # { + # key:"root", label:"root", + # "type": "dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, + # { + # key:"credentials_url", label:"Credentials url", + # "value":"'c:/projects/cred.json'", "type": "text", + # "namespace": "{project_setting}/global/sync_server/ + # sites" + # } + # ] + # } + # """ + # allowed_sites = set() + # sites = self.get_all_site_configs(project_name) + # if project_name: + # # Local Settings can select only from allowed sites for project + # allowed_sites.update(set(self.get_active_sites(project_name))) + # allowed_sites.update(set(self.get_remote_sites(project_name))) + # + # editable = {} + # for site_name in sites.keys(): + # if allowed_sites and site_name not in allowed_sites: + # continue + # + # items = self.get_configurable_items_for_site(project_name, + # site_name, + # scope) + # # Local Settings need 'local' instead of real value + # site_name = site_name.replace(get_local_site_id(), 'local') + # editable[site_name] = items + # + # return editable + # + # def get_configurable_items_for_site(self, project_name=None, + # site_name=None, + # scope=None): + # """ + # Returns list of items that could be configurable. + # + # Args: + # project_name (str) - None > default project + # site_name (str) + # scope (list of utils.EditableScope) + # (optional, None is all scopes) + # + # Returns: + # (list) + # [ + # { + # key:"root", label:"root", type:"dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, ... + # ] + # """ + # provider_name = self.get_provider_for_site(site=site_name) + # items = lib.factory.get_provider_configurable_items(provider_name) + # + # if project_name: + # sync_s = self.get_sync_project_setting(project_name, + # exclude_locals=True, + # cached=False) + # else: + # sync_s = get_default_project_settings(exclude_locals=True) + # sync_s = sync_s["global"]["sync_server"] + # sync_s["sites"].update( + # self._get_default_site_configs(self.enabled)) + # + # editable = [] + # if type(scope) is not list: + # scope = [scope] + # scope = set(scope) + # for key, properties in items.items(): + # if scope is None or scope.intersection(set(properties["scope"])): + # val = sync_s.get("sites", {}).get(site_name, {}).get(key) + # + # item = { + # "key": key, + # "label": properties["label"], + # "type": properties["type"] + # } + # + # if properties.get("namespace"): + # item["namespace"] = properties.get("namespace") + # if "platform" in item["namespace"]: + # try: + # if val: + # val = val[platform.system().lower()] + # except KeyError: + # st = "{}'s field value {} should be".format(key, val) # noqa: E501 + # log.error(st + " multiplatform dict") + # + # item["namespace"] = item["namespace"].replace('{site}', + # site_name) + # children = [] + # if properties["type"] == "dict": + # if val: + # for val_key, val_val in val.items(): + # child = { + # "type": "text", + # "key": val_key, + # "value": val_val + # } + # children.append(child) + # + # if properties["type"] == "dict": + # item["children"] = children + # else: + # item["value"] = val + # + # editable.append(item) + # + # return editable def reset_timer(self): """ @@ -611,7 +657,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): enabled_projects = [] if self.enabled: - for project in self.connection.projects(): + for project in self.connection.projects(projection={"name": 1}): project_name = project["name"] project_settings = self.get_sync_project_setting(project_name) if project_settings and project_settings.get("enabled"): @@ -646,9 +692,6 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return sites - def connect_with_modules(self, *_a, **kw): - return - def tray_init(self): """ Actual initialization of Sync Server. @@ -781,17 +824,22 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def _prepare_sync_project_settings(self, exclude_locals): sync_project_settings = {} system_sites = self.get_all_site_configs() - for collection in self.connection.database.collection_names(False): + project_docs = self.connection.projects( + projection={"name": 1}, + only_active=True + ) + for project_doc in project_docs: + project_name = project_doc["name"] sites = copy.deepcopy(system_sites) # get all configured sites proj_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection, + get_project_settings(project_name, exclude_locals=exclude_locals)) sites.update(self._get_default_site_configs( - proj_settings["enabled"], collection)) + proj_settings["enabled"], project_name)) sites.update(proj_settings['sites']) proj_settings["sites"] = sites - sync_project_settings[collection] = proj_settings + sync_project_settings[project_name] = proj_settings if not sync_project_settings: log.info("No enabled and configured projects for sync.") return sync_project_settings diff --git a/openpype/modules/default_modules/sync_server/tray/app.py b/openpype/modules/default_modules/sync_server/tray/app.py index 106076d81c..fc8558bdbc 100644 --- a/openpype/modules/default_modules/sync_server/tray/app.py +++ b/openpype/modules/default_modules/sync_server/tray/app.py @@ -26,7 +26,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) self.resize(1450, 700) self.timer = QtCore.QTimer() @@ -77,19 +77,36 @@ class SyncServerWindow(QtWidgets.QDialog): self.setWindowTitle("Sync Queue") self.projects.project_changed.connect( - lambda: repres.table_view.model().set_project( - self.projects.current_project)) + self._on_project_change + ) self.pause_btn.clicked.connect(self._pause) self.pause_btn.setAutoDefault(False) self.pause_btn.setDefault(False) repres.message_generated.connect(self._update_message) + self.projects.message_generated.connect(self._update_message) self.representationWidget = repres + def _on_project_change(self): + if self.projects.current_project is None: + return + + self.representationWidget.table_view.model().set_project( + self.projects.current_project + ) + + project_name = self.projects.current_project + if not self.sync_server.get_sync_project_setting(project_name): + self.projects.message_generated.emit( + "Project {} not active anymore".format(project_name)) + self.projects.refresh() + return + def showEvent(self, event): self.representationWidget.model.set_project( self.projects.current_project) + self.projects.refresh() self._set_running(True) super().showEvent(event) diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/default_modules/sync_server/tray/models.py index 8c86d3b98f..5642c5b34a 100644 --- a/openpype/modules/default_modules/sync_server/tray/models.py +++ b/openpype/modules/default_modules/sync_server/tray/models.py @@ -5,7 +5,7 @@ from bson.objectid import ObjectId from Qt import QtCore from Qt.QtCore import Qt -from avalon.tools.delegates import pretty_timestamp +from openpype.tools.utils.delegates import pretty_timestamp from avalon.vendor import qtawesome from openpype.lib import PypeLogger @@ -17,25 +17,6 @@ from . import lib log = PypeLogger().get_logger("SyncServer") -class ProjectModel(QtCore.QAbstractListModel): - def __init__(self, *args, projects=None, **kwargs): - super(ProjectModel, self).__init__(*args, **kwargs) - self.projects = projects or [] - - def data(self, index, role): - if role == Qt.DisplayRole: - # See below for the data structure. - status, text = self.projects[index.row()] - # Return the todo text only. - return text - - def rowCount(self, _index): - return len(self.todos) - - def columnCount(self, _index): - return len(self._header) - - class _SyncRepresentationModel(QtCore.QAbstractTableModel): COLUMN_LABELS = [] @@ -320,6 +301,10 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): """ self._project = project self.sync_server.set_sync_project_settings() + # project might have been deactivated in the meantime + if not self.sync_server.get_sync_project_setting(project): + return + self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) self.refresh() diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py index c9160733a0..45537c1c2e 100644 --- a/openpype/modules/default_modules/sync_server/tray/widgets.py +++ b/openpype/modules/default_modules/sync_server/tray/widgets.py @@ -6,15 +6,12 @@ from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt -from openpype.tools.settings import ( - ProjectListWidget, - style -) +from openpype.tools.settings import style from openpype.api import get_local_site_id from openpype.lib import PypeLogger -from avalon.tools.delegates import pretty_timestamp +from openpype.tools.utils.delegates import pretty_timestamp from avalon.vendor import qtawesome from .models import ( @@ -28,28 +25,58 @@ from . import delegates log = PypeLogger().get_logger("SyncServer") -class SyncProjectListWidget(ProjectListWidget): +class SyncProjectListWidget(QtWidgets.QWidget): """ Lists all projects that are synchronized to choose from """ + project_changed = QtCore.Signal() + message_generated = QtCore.Signal(str) def __init__(self, sync_server, parent): super(SyncProjectListWidget, self).__init__(parent) + self.setObjectName("ProjectListWidget") + + self._parent = parent + + label_widget = QtWidgets.QLabel("Projects", self) + project_list = QtWidgets.QListView(self) + project_model = QtGui.QStandardItemModel() + project_list.setModel(project_model) + project_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + # Do not allow editing + project_list.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + layout.addWidget(label_widget, 0) + layout.addWidget(project_list, 1) + + project_list.customContextMenuRequested.connect(self._on_context_menu) + project_list.selectionModel().currentChanged.connect( + self._on_index_change + ) + + self.project_model = project_model + self.project_list = project_list self.sync_server = sync_server - self.project_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.project_list.customContextMenuRequested.connect( - self._on_context_menu) + self.current_project = None self.project_name = None self.local_site = None + self.remote_site = None self.icons = {} - self.layout().setContentsMargins(0, 0, 0, 0) + def _on_index_change(self, new_idx, _old_idx): + project_name = new_idx.data(QtCore.Qt.DisplayRole) - def validate_context_change(self): - return True + self.current_project = project_name + self.project_changed.emit() def refresh(self): - model = self.project_list.model() + model = self.project_model model.clear() project_name = None @@ -70,11 +97,15 @@ class SyncProjectListWidget(ProjectListWidget): QtCore.Qt.DisplayRole ) if not self.current_project: - self.current_project = self.project_list.model().item(0). \ - data(QtCore.Qt.DisplayRole) + self.current_project = model.item(0).data(QtCore.Qt.DisplayRole) if project_name: self.local_site = self.sync_server.get_active_site(project_name) + self.remote_site = self.sync_server.get_remote_site(project_name) + + def _can_edit(self): + """Returns true if some site is user local site, eg. could edit""" + return get_local_site_id() in (self.local_site, self.remote_site) def _get_icon(self, status): if not self.icons.get(status): @@ -98,9 +129,7 @@ class SyncProjectListWidget(ProjectListWidget): menu = QtWidgets.QMenu(self) actions_mapping = {} - can_edit = self.model.can_edit - - if can_edit: + if self._can_edit(): if self.sync_server.is_project_paused(self.project_name): action = QtWidgets.QAction("Unpause") actions_mapping[action] = self._unpause diff --git a/openpype/modules/default_modules/sync_server/utils.py b/openpype/modules/default_modules/sync_server/utils.py index d4fc29ff8a..85e4e03f77 100644 --- a/openpype/modules/default_modules/sync_server/utils.py +++ b/openpype/modules/default_modules/sync_server/utils.py @@ -29,7 +29,6 @@ def time_function(method): kw['log_time'][name] = int((te - ts) * 1000) else: log.debug('%r %2.2f ms' % (method.__name__, (te - ts) * 1000)) - print('%r %2.2f ms' % (method.__name__, (te - ts) * 1000)) return result return timed diff --git a/openpype/modules/default_modules/timers_manager/idle_threads.py b/openpype/modules/default_modules/timers_manager/idle_threads.py new file mode 100644 index 0000000000..9ec27e659b --- /dev/null +++ b/openpype/modules/default_modules/timers_manager/idle_threads.py @@ -0,0 +1,160 @@ +import time +from Qt import QtCore +from pynput import mouse, keyboard + +from openpype.lib import PypeLogger + + +class IdleItem: + """Python object holds information if state of idle changed. + + This item is used to be independent from Qt objects. + """ + def __init__(self): + self.changed = False + + def reset(self): + self.changed = False + + def set_changed(self, changed=True): + self.changed = changed + + +class IdleManager(QtCore.QThread): + """ Measure user's idle time in seconds. + Idle time resets on keyboard/mouse input. + Is able to emit signals at specific time idle. + """ + time_signals = {} + idle_time = 0 + signal_reset_timer = QtCore.Signal() + + def __init__(self): + super(IdleManager, self).__init__() + self.log = PypeLogger.get_logger(self.__class__.__name__) + self.signal_reset_timer.connect(self._reset_time) + + self.idle_item = IdleItem() + + self._is_running = False + self._mouse_thread = None + self._keyboard_thread = None + + def add_time_signal(self, emit_time, signal): + """ If any module want to use IdleManager, need to use add_time_signal + + Args: + emit_time(int): Time when signal will be emitted. + signal(QtCore.Signal): Signal that will be emitted + (without objects). + """ + if emit_time not in self.time_signals: + self.time_signals[emit_time] = [] + self.time_signals[emit_time].append(signal) + + @property + def is_running(self): + return self._is_running + + def _reset_time(self): + self.idle_time = 0 + + def stop(self): + self._is_running = False + + def _on_mouse_destroy(self): + self._mouse_thread = None + + def _on_keyboard_destroy(self): + self._keyboard_thread = None + + def run(self): + self.log.info('IdleManager has started') + self._is_running = True + + thread_mouse = MouseThread(self.idle_item) + thread_keyboard = KeyboardThread(self.idle_item) + + thread_mouse.destroyed.connect(self._on_mouse_destroy) + thread_keyboard.destroyed.connect(self._on_keyboard_destroy) + + self._mouse_thread = thread_mouse + self._keyboard_thread = thread_keyboard + + thread_mouse.start() + thread_keyboard.start() + + # Main loop here is each second checked if idle item changed state + while self._is_running: + if self.idle_item.changed: + self.idle_item.reset() + self.signal_reset_timer.emit() + else: + self.idle_time += 1 + + if self.idle_time in self.time_signals: + for signal in self.time_signals[self.idle_time]: + signal.emit() + time.sleep(1) + + self._post_run() + self.log.info('IdleManager has stopped') + + def _post_run(self): + # Stop threads if still exist + if self._mouse_thread is not None: + self._mouse_thread.signal_stop.emit() + self._mouse_thread.terminate() + self._mouse_thread.wait() + + if self._keyboard_thread is not None: + self._keyboard_thread.signal_stop.emit() + self._keyboard_thread.terminate() + self._keyboard_thread.wait() + + +class MouseThread(QtCore.QThread): + """Listens user's mouse movement.""" + signal_stop = QtCore.Signal() + + def __init__(self, idle_item): + super(MouseThread, self).__init__() + self.signal_stop.connect(self.stop) + self.m_listener = None + self.idle_item = idle_item + + def stop(self): + if self.m_listener is not None: + self.m_listener.stop() + + def on_move(self, *args, **kwargs): + self.idle_item.set_changed() + + def run(self): + self.m_listener = mouse.Listener(on_move=self.on_move) + self.m_listener.start() + + +class KeyboardThread(QtCore.QThread): + """Listens user's keyboard input + """ + signal_stop = QtCore.Signal() + + def __init__(self, idle_item): + super(KeyboardThread, self).__init__() + self.signal_stop.connect(self.stop) + self.k_listener = None + self.idle_item = idle_item + + def stop(self): + if self.k_listener is not None: + listener = self.k_listener + self.k_listener = None + listener.stop() + + def on_press(self, *args, **kwargs): + self.idle_item.set_changed() + + def run(self): + self.k_listener = keyboard.Listener(on_press=self.on_press) + self.k_listener.start() diff --git a/openpype/modules/default_modules/timers_manager/interfaces.py b/openpype/modules/default_modules/timers_manager/interfaces.py deleted file mode 100644 index 179013cffe..0000000000 --- a/openpype/modules/default_modules/timers_manager/interfaces.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class ITimersManager(OpenPypeInterface): - timer_manager_module = None - - @abstractmethod - def stop_timer(self): - pass - - @abstractmethod - def start_timer(self, data): - pass - - def timer_started(self, data): - if not self.timer_manager_module: - return - - self.timer_manager_module.timer_started(self.id, data) - - def timer_stopped(self): - if not self.timer_manager_module: - return - - self.timer_manager_module.timer_stopped(self.id) diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/default_modules/timers_manager/timers_manager.py index 829a6badb4..1199db0611 100644 --- a/openpype/modules/default_modules/timers_manager/timers_manager.py +++ b/openpype/modules/default_modules/timers_manager/timers_manager.py @@ -1,37 +1,107 @@ import os -import collections +import platform from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITimersManager, - ITrayService, - IIdleManager, - IWebServerRoutes + ITrayService ) from avalon.api import AvalonMongoDB -class TimersManager( - OpenPypeModule, ITrayService, IIdleManager, IWebServerRoutes -): +class ExampleTimersManagerConnector: + """Timers manager can handle timers of multiple modules/addons. + + Module must have object under `timers_manager_connector` attribute with + few methods. This is example class of the object that could be stored under + module. + + Required methods are 'stop_timer' and 'start_timer'. + + # TODO pass asset document instead of `hierarchy` + Example of `data` that are passed during changing timer: + ``` + data = { + "project_name": project_name, + "task_name": task_name, + "task_type": task_type, + "hierarchy": hierarchy + } + ``` + """ + # Not needed at all + def __init__(self, module): + # Store timer manager module to be able call it's methods when needed + self._timers_manager_module = None + + # Store module which want to use timers manager to have access + self._module = module + + # Required + def stop_timer(self): + """Called by timers manager when module should stop timer.""" + self._module.stop_timer() + + # Required + def start_timer(self, data): + """Method called by timers manager when should start timer.""" + self._module.start_timer(data) + + # Optional + def register_timers_manager(self, timer_manager_module): + """Method called by timers manager where it's object is passed. + + This is moment when timers manager module can be store to be able + call it's callbacks (e.g. timer started). + """ + self._timers_manager_module = timer_manager_module + + # Custom implementation + def timer_started(self, data): + """This is example of possibility to trigger callbacks on manager.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self._module.id, data) + + # Custom implementation + def timer_stopped(self): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self._module.id) + + +class TimersManager(OpenPypeModule, ITrayService): """ Handles about Timers. Should be able to start/stop all timers at once. - If IdleManager is imported then is able to handle about stop timers - when user idles for a long time (set in presets). + + To be able use this advantage module has to have attribute with name + `timers_manager_connector` which has two methods 'stop_timer' + and 'start_timer'. Optionally may have `register_timers_manager` where + object of TimersManager module is passed to be able call it's callbacks. + + See `ExampleTimersManagerConnector`. """ name = "timers_manager" label = "Timers Service" + _required_methods = ( + "stop_timer", + "start_timer" + ) + def initialize(self, modules_settings): timers_settings = modules_settings[self.name] self.enabled = timers_settings["enabled"] - auto_stop = timers_settings["auto_stop"] + # When timer will stop if idle manager is running (minutes) full_time = int(timers_settings["full_time"] * 60) # How many minutes before the timer is stopped will popup the message message_time = int(timers_settings["message_time"] * 60) + auto_stop = timers_settings["auto_stop"] + # Turn of auto stop on MacOs because pynput requires root permissions + if platform.system().lower() == "darwin" or full_time <= 0: + auto_stop = False + self.auto_stop = auto_stop self.time_show_message = full_time - message_time self.time_stop_timer = full_time @@ -40,30 +110,46 @@ class TimersManager( self.last_task = None # Tray attributes - self.signal_handler = None - self.widget_user_idle = None - self.signal_handler = None + self._signal_handler = None + self._widget_user_idle = None + self._idle_manager = None - self.modules = [] + self._connectors_by_module_id = {} + self._modules_by_id = {} def tray_init(self): + if not self.auto_stop: + return + + from .idle_threads import IdleManager from .widget_user_idle import WidgetUserIdle, SignalHandler - self.widget_user_idle = WidgetUserIdle(self) - self.signal_handler = SignalHandler(self) + + signal_handler = SignalHandler(self) + idle_manager = IdleManager() + widget_user_idle = WidgetUserIdle(self) + widget_user_idle.set_countdown_start(self.time_show_message) + + idle_manager.signal_reset_timer.connect( + widget_user_idle.reset_countdown + ) + idle_manager.add_time_signal( + self.time_show_message, signal_handler.signal_show_message + ) + idle_manager.add_time_signal( + self.time_stop_timer, signal_handler.signal_stop_timers + ) + + self._signal_handler = signal_handler + self._widget_user_idle = widget_user_idle + self._idle_manager = idle_manager def tray_start(self, *_a, **_kw): - return + if self._idle_manager: + self._idle_manager.start() def tray_exit(self): - """Nothing special for TimersManager.""" - return - - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - if self.tray_initialized: - from .rest_api import TimersManagerModuleRestApi - self.rest_api_obj = TimersManagerModuleRestApi(self, - server_manager) + if self._idle_manager: + self._idle_manager.stop() def start_timer(self, project_name, asset_name, task_name, hierarchy): """ @@ -116,17 +202,35 @@ class TimersManager( return times def timer_started(self, source_id, data): - for module in self.modules: - if module.id != source_id: - module.start_timer(data) + for module_id, connector in self._connectors_by_module_id.items(): + if module_id == source_id: + continue + + try: + connector.start_timer(data) + except Exception: + self.log.info( + "Failed to start timer on connector {}".format( + str(connector) + ) + ) self.last_task = data self.is_running = True def timer_stopped(self, source_id): - for module in self.modules: - if module.id != source_id: - module.stop_timer() + for module_id, connector in self._connectors_by_module_id.items(): + if module_id == source_id: + continue + + try: + connector.stop_timer() + except Exception: + self.log.info( + "Failed to stop timer on connector {}".format( + str(connector) + ) + ) def restart_timers(self): if self.last_task is not None: @@ -136,84 +240,60 @@ class TimersManager( if self.is_running is False: return - self.widget_user_idle.bool_not_stopped = False - self.widget_user_idle.refresh_context() + if self._widget_user_idle is not None: + self._widget_user_idle.set_timer_stopped() self.is_running = False - for module in self.modules: - module.stop_timer() + self.timer_stopped(None) def connect_with_modules(self, enabled_modules): for module in enabled_modules: - if not isinstance(module, ITimersManager): + connector = getattr(module, "timers_manager_connector", None) + if connector is None: continue - module.timer_manager_module = self - self.modules.append(module) - def callbacks_by_idle_time(self): - """Implementation of IIdleManager interface.""" - # Time when message is shown - if not self.auto_stop: - return {} + missing_methods = set() + for method_name in self._required_methods: + if not hasattr(connector, method_name): + missing_methods.add(method_name) - callbacks = collections.defaultdict(list) - callbacks[self.time_show_message].append(lambda: self.time_callback(0)) + if missing_methods: + joined = ", ".join( + ['"{}"'.format(name for name in missing_methods)] + ) + self.log.info(( + "Module \"{}\" has missing required methods {}." + ).format(module.name, joined)) + continue - # Times when idle is between show widget and stop timers - show_to_stop_range = range( - self.time_show_message - 1, self.time_stop_timer - ) - for num in show_to_stop_range: - callbacks[num].append(lambda: self.time_callback(1)) + self._connectors_by_module_id[module.id] = connector + self._modules_by_id[module.id] = module - # Times when widget is already shown and user restart idle - shown_and_moved_range = range( - self.time_stop_timer - self.time_show_message - ) - for num in shown_and_moved_range: - callbacks[num].append(lambda: self.time_callback(1)) - - # Time when timers are stopped - callbacks[self.time_stop_timer].append(lambda: self.time_callback(2)) - - return callbacks - - def time_callback(self, int_def): - if not self.signal_handler: - return - - if int_def == 0: - self.signal_handler.signal_show_message.emit() - elif int_def == 1: - self.signal_handler.signal_change_label.emit() - elif int_def == 2: - self.signal_handler.signal_stop_timers.emit() - - def change_label(self): - if self.is_running is False: - return - - if ( - not self.idle_manager - or self.widget_user_idle.bool_is_showed is False - ): - return - - if self.idle_manager.idle_time > self.time_show_message: - value = self.time_stop_timer - self.idle_manager.idle_time - else: - value = 1 + ( - self.time_stop_timer - - self.time_show_message - - self.idle_manager.idle_time - ) - self.widget_user_idle.change_count_widget(value) + # Optional method + if hasattr(connector, "register_timers_manager"): + try: + connector.register_timers_manager(self) + except Exception: + self.log.info(( + "Failed to register timers manager" + " for connector of module \"{}\"." + ).format(module.name)) def show_message(self): if self.is_running is False: return - if self.widget_user_idle.bool_is_showed is False: - self.widget_user_idle.show() + if not self._widget_user_idle.is_showed(): + self._widget_user_idle.reset_countdown() + self._widget_user_idle.show() + + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for timers to be able start/stop with rest api.""" + if self.tray_initialized: + from .rest_api import TimersManagerModuleRestApi + self.rest_api_obj = TimersManagerModuleRestApi( + self, server_manager + ) def change_timer_from_host(self, project_name, asset_name, task_name): """Prepared method for calling change timers on REST api""" diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/default_modules/timers_manager/widget_user_idle.py index 25b4e56650..1ecea74440 100644 --- a/openpype/modules/default_modules/timers_manager/widget_user_idle.py +++ b/openpype/modules/default_modules/timers_manager/widget_user_idle.py @@ -3,168 +3,193 @@ from openpype import resources, style class WidgetUserIdle(QtWidgets.QWidget): - SIZE_W = 300 SIZE_H = 160 def __init__(self, module): - super(WidgetUserIdle, self).__init__() - self.bool_is_showed = False - self.bool_not_stopped = True + self.setWindowTitle("OpenPype - Stop timers") - self.module = module - - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) + self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) - self._translate = QtCore.QCoreApplication.translate + self._is_showed = False + self._timer_stopped = False + self._countdown = 0 + self._countdown_start = 0 - self.font = QtGui.QFont() - self.font.setFamily("DejaVu Sans Condensed") - self.font.setPointSize(9) - self.font.setBold(True) - self.font.setWeight(50) - self.font.setKerning(True) + self.module = module + + msg_info = "You didn't work for a long time." + msg_question = "Would you like to stop Timers?" + msg_stopped = ( + "Your Timers were stopped. Do you want to start them again?" + ) + + lbl_info = QtWidgets.QLabel(msg_info, self) + lbl_info.setTextFormat(QtCore.Qt.RichText) + lbl_info.setWordWrap(True) + + lbl_question = QtWidgets.QLabel(msg_question, self) + lbl_question.setTextFormat(QtCore.Qt.RichText) + lbl_question.setWordWrap(True) + + lbl_stopped = QtWidgets.QLabel(msg_stopped, self) + lbl_stopped.setTextFormat(QtCore.Qt.RichText) + lbl_stopped.setWordWrap(True) + + lbl_rest_time = QtWidgets.QLabel(self) + lbl_rest_time.setTextFormat(QtCore.Qt.RichText) + lbl_rest_time.setWordWrap(True) + lbl_rest_time.setAlignment(QtCore.Qt.AlignCenter) + + form = QtWidgets.QFormLayout() + form.setContentsMargins(10, 15, 10, 5) + + form.addRow(lbl_info) + form.addRow(lbl_question) + form.addRow(lbl_stopped) + form.addRow(lbl_rest_time) + + btn_stop = QtWidgets.QPushButton("Stop timer", self) + btn_stop.setToolTip("Stop's All timers") + + btn_continue = QtWidgets.QPushButton("Continue", self) + btn_continue.setToolTip("Timer won't stop") + + btn_close = QtWidgets.QPushButton("Close", self) + btn_close.setToolTip("Close window") + + btn_restart = QtWidgets.QPushButton("Start timers", self) + btn_restart.setToolTip("Timer will be started again") + + group_layout = QtWidgets.QHBoxLayout() + group_layout.addStretch(1) + group_layout.addWidget(btn_continue) + group_layout.addWidget(btn_stop) + group_layout.addWidget(btn_restart) + group_layout.addWidget(btn_close) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(form) + layout.addLayout(group_layout) + + count_timer = QtCore.QTimer() + count_timer.setInterval(1000) + + btn_stop.clicked.connect(self._on_stop_clicked) + btn_continue.clicked.connect(self._on_continue_clicked) + btn_close.clicked.connect(self._close_widget) + btn_restart.clicked.connect(self._on_restart_clicked) + count_timer.timeout.connect(self._on_count_timeout) + + self.lbl_info = lbl_info + self.lbl_question = lbl_question + self.lbl_stopped = lbl_stopped + self.lbl_rest_time = lbl_rest_time + + self.btn_stop = btn_stop + self.btn_continue = btn_continue + self.btn_close = btn_close + self.btn_restart = btn_restart + + self._count_timer = count_timer self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) self.setStyleSheet(style.load_stylesheet()) - self.setLayout(self._main()) - self.refresh_context() - self.setWindowTitle('Pype - Stop timers') + def set_countdown_start(self, countdown): + self._countdown_start = countdown + if not self.is_showed(): + self.reset_countdown() - def _main(self): - self.main = QtWidgets.QVBoxLayout() - self.main.setObjectName('main') + def reset_countdown(self): + self._countdown = self._countdown_start + self._update_countdown_label() - self.form = QtWidgets.QFormLayout() - self.form.setContentsMargins(10, 15, 10, 5) - self.form.setObjectName('form') + def is_showed(self): + return self._is_showed - msg_info = 'You didn\'t work for a long time.' - msg_question = 'Would you like to stop Timers?' - msg_stopped = ( - 'Your Timers were stopped. Do you want to start them again?' - ) + def set_timer_stopped(self): + self._timer_stopped = True + self._refresh_context() - self.lbl_info = QtWidgets.QLabel(msg_info) - self.lbl_info.setFont(self.font) - self.lbl_info.setTextFormat(QtCore.Qt.RichText) - self.lbl_info.setObjectName("lbl_info") - self.lbl_info.setWordWrap(True) + def _update_countdown_label(self): + self.lbl_rest_time.setText(str(self._countdown)) - self.lbl_question = QtWidgets.QLabel(msg_question) - self.lbl_question.setFont(self.font) - self.lbl_question.setTextFormat(QtCore.Qt.RichText) - self.lbl_question.setObjectName("lbl_question") - self.lbl_question.setWordWrap(True) + def _on_count_timeout(self): + if self._timer_stopped or not self._is_showed: + self._count_timer.stop() + return - self.lbl_stopped = QtWidgets.QLabel(msg_stopped) - self.lbl_stopped.setFont(self.font) - self.lbl_stopped.setTextFormat(QtCore.Qt.RichText) - self.lbl_stopped.setObjectName("lbl_stopped") - self.lbl_stopped.setWordWrap(True) - - self.lbl_rest_time = QtWidgets.QLabel("") - self.lbl_rest_time.setFont(self.font) - self.lbl_rest_time.setTextFormat(QtCore.Qt.RichText) - self.lbl_rest_time.setObjectName("lbl_rest_time") - self.lbl_rest_time.setWordWrap(True) - self.lbl_rest_time.setAlignment(QtCore.Qt.AlignCenter) - - self.form.addRow(self.lbl_info) - self.form.addRow(self.lbl_question) - self.form.addRow(self.lbl_stopped) - self.form.addRow(self.lbl_rest_time) - - self.group_btn = QtWidgets.QHBoxLayout() - self.group_btn.addStretch(1) - self.group_btn.setObjectName("group_btn") - - self.btn_stop = QtWidgets.QPushButton("Stop timer") - self.btn_stop.setToolTip('Stop\'s All timers') - self.btn_stop.clicked.connect(self.stop_timer) - - self.btn_continue = QtWidgets.QPushButton("Continue") - self.btn_continue.setToolTip('Timer won\'t stop') - self.btn_continue.clicked.connect(self.continue_timer) - - self.btn_close = QtWidgets.QPushButton("Close") - self.btn_close.setToolTip('Close window') - self.btn_close.clicked.connect(self.close_widget) - - self.btn_restart = QtWidgets.QPushButton("Start timers") - self.btn_restart.setToolTip('Timer will be started again') - self.btn_restart.clicked.connect(self.restart_timer) - - self.group_btn.addWidget(self.btn_continue) - self.group_btn.addWidget(self.btn_stop) - self.group_btn.addWidget(self.btn_restart) - self.group_btn.addWidget(self.btn_close) - - self.main.addLayout(self.form) - self.main.addLayout(self.group_btn) - - return self.main - - def refresh_context(self): - self.lbl_question.setVisible(self.bool_not_stopped) - self.lbl_rest_time.setVisible(self.bool_not_stopped) - self.lbl_stopped.setVisible(not self.bool_not_stopped) - - self.btn_continue.setVisible(self.bool_not_stopped) - self.btn_stop.setVisible(self.bool_not_stopped) - self.btn_restart.setVisible(not self.bool_not_stopped) - self.btn_close.setVisible(not self.bool_not_stopped) - - def change_count_widget(self, time): - str_time = str(time) - self.lbl_rest_time.setText(str_time) - - def stop_timer(self): - self.module.stop_timers() - self.close_widget() - - def restart_timer(self): - self.module.restart_timers() - self.close_widget() - - def continue_timer(self): - self.close_widget() - - def closeEvent(self, event): - event.ignore() - if self.bool_not_stopped is True: - self.continue_timer() + if self._countdown <= 0: + self._stop_timers() + self.set_timer_stopped() else: - self.close_widget() + self._countdown -= 1 + self._update_countdown_label() - def close_widget(self): - self.bool_is_showed = False - self.bool_not_stopped = True - self.refresh_context() + def _refresh_context(self): + self.lbl_question.setVisible(not self._timer_stopped) + self.lbl_rest_time.setVisible(not self._timer_stopped) + self.lbl_stopped.setVisible(self._timer_stopped) + + self.btn_continue.setVisible(not self._timer_stopped) + self.btn_stop.setVisible(not self._timer_stopped) + self.btn_restart.setVisible(self._timer_stopped) + self.btn_close.setVisible(self._timer_stopped) + + def _stop_timers(self): + self.module.stop_timers() + + def _on_stop_clicked(self): + self._stop_timers() + self._close_widget() + + def _on_restart_clicked(self): + self.module.restart_timers() + self._close_widget() + + def _on_continue_clicked(self): + self._close_widget() + + def _close_widget(self): + self._is_showed = False + self._timer_stopped = False + self._refresh_context() self.hide() def showEvent(self, event): - self.bool_is_showed = True + if not self._is_showed: + self._is_showed = True + self._refresh_context() + + if not self._count_timer.isActive(): + self._count_timer.start() + super(WidgetUserIdle, self).showEvent(event) + + def closeEvent(self, event): + event.ignore() + if self._timer_stopped: + self._close_widget() + else: + self._on_continue_clicked() class SignalHandler(QtCore.QObject): signal_show_message = QtCore.Signal() - signal_change_label = QtCore.Signal() signal_stop_timers = QtCore.Signal() def __init__(self, module): super(SignalHandler, self).__init__() self.module = module self.signal_show_message.connect(module.show_message) - self.signal_change_label.connect(module.change_label) self.signal_stop_timers.connect(module.stop_timers) diff --git a/openpype/modules/default_modules/webserver/interfaces.py b/openpype/modules/default_modules/webserver/interfaces.py deleted file mode 100644 index 779361a9ec..0000000000 --- a/openpype/modules/default_modules/webserver/interfaces.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IWebServerRoutes(OpenPypeInterface): - """Other modules interface to register their routes.""" - @abstractmethod - def webserver_initialization(self, server_manager): - pass diff --git a/openpype/modules/default_modules/webserver/webserver_module.py b/openpype/modules/default_modules/webserver/webserver_module.py index 5bfb2d6390..686bd27bfd 100644 --- a/openpype/modules/default_modules/webserver/webserver_module.py +++ b/openpype/modules/default_modules/webserver/webserver_module.py @@ -1,12 +1,31 @@ +"""WebServerModule spawns aiohttp server in asyncio loop. + +Main usage of the module is in OpenPype tray where make sense to add ability +of other modules to add theirs routes. Module which would want use that +option must have implemented method `webserver_initialization` which must +expect `WebServerManager` object where is possible to add routes or paths +with handlers. + +WebServerManager is by default created only in tray. + +It is possible to create server manager without using module logic at all +using `create_new_server_manager`. That can be handy for standalone scripts +with predefined host and port and separated routes and logic. + +Running multiple servers in one process is not recommended and probably won't +work as expected. It is because of few limitations connected to asyncio module. + +When module's `create_server_manager` is called it is also set environment +variable "OPENPYPE_WEBSERVER_URL". Which should lead to root access point +of server. +""" + import os import socket from openpype import resources from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayService, - IWebServerRoutes -) +from openpype_interfaces import ITrayService class WebServerModule(OpenPypeModule, ITrayService): @@ -28,8 +47,15 @@ class WebServerModule(OpenPypeModule, ITrayService): return for module in enabled_modules: - if isinstance(module, IWebServerRoutes): + if not hasattr(module, "webserver_initialization"): + continue + + try: module.webserver_initialization(self.server_manager) + except Exception: + self.log.warning(( + "Failed to connect module \"{}\" to webserver." + ).format(module.name)) def tray_init(self): self.create_server_manager() diff --git a/openpype/modules/example_addons/example_addon/__init__.py b/openpype/modules/example_addons/example_addon/__init__.py new file mode 100644 index 0000000000..721d924436 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/__init__.py @@ -0,0 +1,15 @@ +""" Addon class definition and Settings definition must be imported here. + +If addon class or settings definition won't be here their definition won't +be found by OpenPype discovery. +""" + +from .addon import ( + AddonSettingsDef, + ExampleAddon +) + +__all__ = ( + "AddonSettingsDef", + "ExampleAddon" +) diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py new file mode 100644 index 0000000000..5573e33cc1 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -0,0 +1,132 @@ +"""Addon definition is located here. + +Import of python packages that may not be available should not be imported +in global space here until are required or used. +- Qt related imports +- imports of Python 3 packages + - we still support Python 2 hosts where addon definition should available +""" + +import os + +from openpype.modules import ( + JsonFilesSettingsDef, + OpenPypeAddOn +) +# Import interface defined by this addon to be able find other addons using it +from openpype_interfaces import ( + IExampleInterface, + IPluginPaths, + ITrayAction +) + + +# Settings definition of this addon using `JsonFilesSettingsDef` +# - JsonFilesSettingsDef is prepared settings definition using json files +# to define settings and store default values +class AddonSettingsDef(JsonFilesSettingsDef): + # This will add prefixes to every schema and template from `schemas` + # subfolder. + # - it is not required to fill the prefix but it is highly + # recommended as schemas and templates may have name clashes across + # multiple addons + # - it is also recommended that prefix has addon name in it + schema_prefix = "example_addon" + + def get_settings_root_path(self): + """Implemented abstract class of JsonFilesSettingsDef. + + Return directory path where json files defying addon settings are + located. + """ + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "settings" + ) + + +class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): + """This Addon has defined it's settings and interface. + + This example has system settings with an enabled option. And use + few other interfaces: + - `IPluginPaths` to define custom plugin paths + - `ITrayAction` to be shown in tray tool + """ + label = "Example Addon" + name = "example_addon" + + def initialize(self, settings): + """Initialization of addon.""" + module_settings = settings[self.name] + # Enabled by settings + self.enabled = module_settings.get("enabled", False) + + # Prepare variables that can be used or set afterwards + self._connected_modules = None + # UI which must not be created at this time + self._dialog = None + + def tray_init(self): + """Implementation of abstract method for `ITrayAction`. + + We're definitely in tray tool so we can pre create dialog. + """ + + self._create_dialog() + + def connect_with_modules(self, enabled_modules): + """Method where you should find connected modules. + + It is triggered by OpenPype modules manager at the best possible time. + Some addons and modules may required to connect with other modules + before their main logic is executed so changes would require to restart + whole process. + """ + self._connected_modules = [] + for module in enabled_modules: + if isinstance(module, IExampleInterface): + self._connected_modules.append(module) + + def _create_dialog(self): + # Don't recreate dialog if already exists + if self._dialog is not None: + return + + from .widgets import MyExampleDialog + + self._dialog = MyExampleDialog() + + def show_dialog(self): + """Show dialog with connected modules. + + This can be called from anywhere but can also crash in headless mode. + There is no way to prevent addon to do invalid operations if he's + not handling them. + """ + # Make sure dialog is created + self._create_dialog() + # Change value of dialog by current state + self._dialog.set_connected_modules(self.get_connected_modules()) + # Show dialog + self._dialog.open() + + def get_connected_modules(self): + """Custom implementation of addon.""" + names = set() + if self._connected_modules is not None: + for module in self._connected_modules: + names.add(module.name) + return names + + def on_action_trigger(self): + """Implementation of abstract method for `ITrayAction`.""" + self.show_dialog() + + def get_plugin_paths(self): + """Implementation of abstract method for `IPluginPaths`.""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + + return { + "publish": [os.path.join(current_dir, "plugins", "publish")] + } diff --git a/openpype/modules/example_addons/example_addon/interfaces.py b/openpype/modules/example_addons/example_addon/interfaces.py new file mode 100644 index 0000000000..371536efc7 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/interfaces.py @@ -0,0 +1,28 @@ +""" Using interfaces is one way of connecting multiple OpenPype Addons/Modules. + +Interfaces must be in `interfaces.py` file (or folder). Interfaces should not +import module logic or other module in global namespace. That is because +all of them must be imported before all OpenPype AddOns and Modules. + +Ideally they should just define abstract and helper methods. If interface +require any logic or connection it should be defined in module. + +Keep in mind that attributes and methods will be added to other addon +attributes and methods so they should be unique and ideally contain +addon name in it's name. +""" + +from abc import abstractmethod +from openpype.modules import OpenPypeInterface + + +class IExampleInterface(OpenPypeInterface): + """Example interface of addon.""" + _example_module = None + + def get_example_module(self): + return self._example_module + + @abstractmethod + def example_method_of_example_interface(self): + pass diff --git a/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py new file mode 100644 index 0000000000..695120e93b --- /dev/null +++ b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py @@ -0,0 +1,9 @@ +import pyblish.api + + +class CollectExampleAddon(pyblish.api.ContextPlugin): + order = pyblish.api.CollectorOrder + 0.4 + label = "Collect Example Addon" + + def process(self, context): + self.log.info("I'm in example addon's plugin!") diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json new file mode 100644 index 0000000000..0a01fa8977 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json @@ -0,0 +1,15 @@ +{ + "project_settings/example_addon": { + "number": 0, + "color_1": [ + 0.0, + 0.0, + 0.0 + ], + "color_2": [ + 0.0, + 0.0, + 0.0 + ] + } +} \ No newline at end of file diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json new file mode 100644 index 0000000000..1e77356373 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json @@ -0,0 +1,5 @@ +{ + "modules/example_addon": { + "enabled": true + } +} \ No newline at end of file diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json new file mode 100644 index 0000000000..1f3da7b37f --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json @@ -0,0 +1,6 @@ +{ + "project_settings/global": { + "type": "schema", + "name": "example_addon/main" + } +} diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json new file mode 100644 index 0000000000..6faa48ba74 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json @@ -0,0 +1,6 @@ +{ + "system_settings/modules": { + "type": "schema", + "name": "example_addon/main" + } +} diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json new file mode 100644 index 0000000000..ba692d860e --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json @@ -0,0 +1,30 @@ +{ + "type": "dict", + "key": "example_addon", + "label": "Example addon", + "collapsible": true, + "children": [ + { + "type": "number", + "key": "number", + "label": "This is your lucky number:", + "minimum": 7, + "maximum": 7, + "decimals": 0 + }, + { + "type": "template", + "name": "example_addon/the_template", + "template_data": [ + { + "name": "color_1", + "label": "Color 1" + }, + { + "name": "color_2", + "label": "Color 2" + } + ] + } + ] +} diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json new file mode 100644 index 0000000000..af8fd9dae4 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json @@ -0,0 +1,30 @@ +[ + { + "type": "list-strict", + "key": "{name}", + "label": "{label}", + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + } + ] + } +] diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json new file mode 100644 index 0000000000..0fb0a7c1be --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json @@ -0,0 +1,14 @@ +{ + "type": "dict", + "key": "example_addon", + "label": "Example addon", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] +} diff --git a/openpype/modules/example_addons/example_addon/widgets.py b/openpype/modules/example_addons/example_addon/widgets.py new file mode 100644 index 0000000000..0acf238409 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/widgets.py @@ -0,0 +1,39 @@ +from Qt import QtWidgets + +from openpype.style import load_stylesheet + + +class MyExampleDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super(MyExampleDialog, self).__init__(parent) + + self.setWindowTitle("Connected modules") + + label_widget = QtWidgets.QLabel(self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget) + layout.addLayout(btns_layout) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + + self.setStyleSheet(load_stylesheet()) + + def _on_ok_clicked(self): + self.done(1) + + def set_connected_modules(self, connected_modules): + if connected_modules: + message = "\n".join(connected_modules) + else: + message = ( + "Other enabled modules/addons are not using my interface." + ) + self._label_widget.setText(message) diff --git a/openpype/modules/example_addons/tiny_addon.py b/openpype/modules/example_addons/tiny_addon.py new file mode 100644 index 0000000000..62962954f5 --- /dev/null +++ b/openpype/modules/example_addons/tiny_addon.py @@ -0,0 +1,9 @@ +from openpype.modules import OpenPypeAddOn + + +class TinyAddon(OpenPypeAddOn): + """This is tiniest possible addon. + + This addon won't do much but will exist in OpenPype modules environment. + """ + name = "tiniest_addon_ever" diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index 3753f1bfc9..a8cb0070ee 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -71,7 +71,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self._set_representations(contexts) self.setWindowTitle("OpenPype - Deliver versions") - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/plugins/load/open_djv.py b/openpype/plugins/load/open_djv.py index 39b54364d9..5b49bb58d0 100644 --- a/openpype/plugins/load/open_djv.py +++ b/openpype/plugins/load/open_djv.py @@ -1,26 +1,28 @@ import os -import subprocess from avalon import api +from openpype.api import ApplicationManager def existing_djv_path(): - djv_paths = os.environ.get("DJV_PATH") or "" - for path in djv_paths.split(os.pathsep): - if os.path.exists(path): - return path - return None + app_manager = ApplicationManager() + djv_list = [] + for app_name, app in app_manager.applications.items(): + if 'djv' in app_name and app.find_executable(): + djv_list.append(app_name) + + return djv_list class OpenInDJV(api.Loader): """Open Image Sequence with system default""" - djv_path = existing_djv_path() - families = ["*"] if djv_path else [] + djv_list = existing_djv_path() + families = ["*"] if djv_list else [] representations = [ "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg", "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut", "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf", - "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" + "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img", "h264", ] label = "Open in DJV" @@ -41,20 +43,18 @@ class OpenInDJV(api.Loader): ) if not remainder: - seqeunce = collections[0] - first_image = list(seqeunce)[0] + sequence = collections[0] + first_image = list(sequence)[0] else: first_image = self.fname filepath = os.path.normpath(os.path.join(directory, first_image)) self.log.info("Opening : {}".format(filepath)) - cmd = [ - # DJV path - os.path.normpath(self.djv_path), - # PATH TO COMPONENT - os.path.normpath(filepath) - ] + last_djv_version = sorted(self.djv_list)[-1] - # Run DJV with these commands - subprocess.Popen(cmd) + app_manager = ApplicationManager() + djv = app_manager.applications.get(last_djv_version) + djv.arguments.append(filepath) + + app_manager.launch(last_djv_version) diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_avalon_entities.py index 0b6423818e..a6120d42fe 100644 --- a/openpype/plugins/publish/collect_avalon_entities.py +++ b/openpype/plugins/publish/collect_avalon_entities.py @@ -22,6 +22,7 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): io.install() project_name = api.Session["AVALON_PROJECT"] asset_name = api.Session["AVALON_ASSET"] + task_name = api.Session["AVALON_TASK"] project_entity = io.find_one({ "type": "project", @@ -48,6 +49,12 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): data = asset_entity['data'] + # Task type + asset_tasks = data.get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + context.data["taskType"] = task_type + frame_start = data.get("frameStart") if frame_start is None: frame_start = 1 diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 1aa10fcb9b..f7d1c6b4be 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -13,7 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): """ label = "Collect Hierarchy" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["shot"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_host_name.py b/openpype/plugins/publish/collect_host_name.py index 41d9cc3a5a..b731e3ed26 100644 --- a/openpype/plugins/publish/collect_host_name.py +++ b/openpype/plugins/publish/collect_host_name.py @@ -14,7 +14,7 @@ class CollectHostName(pyblish.api.ContextPlugin): """Collect avalon host name to context.""" label = "Collect Host Name" - order = pyblish.api.CollectorOrder - 1 + order = pyblish.api.CollectorOrder - 0.5 def process(self, context): host_name = context.data.get("hostName") diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index e1b8b95a46..a35ef47e79 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -18,7 +18,7 @@ class CollectOcioFrameRanges(pyblish.api.InstancePlugin): Adding timeline and source ranges to instance data""" label = "Collect OTIO Frame Ranges" - order = pyblish.api.CollectorOrder - 0.58 + order = pyblish.api.CollectorOrder - 0.48 families = ["shot", "clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index e78ccc032c..10ceafdcca 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -20,7 +20,7 @@ class CollectOcioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" label = "Collect OTIO Review" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 010430a303..dd670ff850 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -18,7 +18,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" label = "Collect OTIO Subset Resources" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index 98b59332da..c21f09ab8d 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -26,6 +26,7 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "animation", "model", "mayaAscii", + "mayaScene", "setdress", "layout", "ass", diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 625125321c..207e696fb1 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -1,6 +1,5 @@ import os import re -import subprocess import json import copy import tempfile @@ -158,6 +157,11 @@ class ExtractBurnin(openpype.api.Extractor): filled_anatomy = anatomy.format_all(burnin_data) burnin_data["anatomy"] = filled_anatomy.get_solved() + # Add context data burnin_data. + burnin_data["custom"] = ( + instance.data.get("custom_burnin_data") or {} + ) + # Add source camera name to burnin data camera_name = repre.get("camera_name") if camera_name: @@ -226,7 +230,8 @@ class ExtractBurnin(openpype.api.Extractor): "options": copy.deepcopy(burnin_options), "values": burnin_values, "full_input_path": temp_data["full_input_paths"][0], - "first_frame": temp_data["first_frame"] + "first_frame": temp_data["first_frame"], + "ffmpeg_cmd": new_repre.get("ffmpeg_cmd", "") } self.log.debug( diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index ae691285b5..3c08c1862d 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -1,10 +1,16 @@ import os import pyblish.api -import openpype.api -import openpype.lib -from openpype.lib import should_decompress, \ - get_decompress_dir, decompress +from openpype.lib import ( + get_ffmpeg_tool_path, + + run_subprocess, + path_to_subprocess_arg, + + should_decompress, + get_decompress_dir, + decompress +) import shutil @@ -85,17 +91,19 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.info("output {}".format(full_output_path)) - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} jpeg_items = [] - jpeg_items.append("\"{}\"".format(ffmpeg_path)) + jpeg_items.append(path_to_subprocess_arg(ffmpeg_path)) # override file if already exists jpeg_items.append("-y") # use same input args like with mov jpeg_items.extend(ffmpeg_args.get("input") or []) # input file - jpeg_items.append("-i \"{}\"".format(full_input_path)) + jpeg_items.append("-i {}".format( + path_to_subprocess_arg(full_input_path) + )) # output arguments from presets jpeg_items.extend(ffmpeg_args.get("output") or []) @@ -104,21 +112,22 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): jpeg_items.append("-vframes 1") # output file - jpeg_items.append("\"{}\"".format(full_output_path)) + jpeg_items.append(path_to_subprocess_arg(full_output_path)) - subprocess_jpeg = " ".join(jpeg_items) + subprocess_command = " ".join(jpeg_items) # run subprocess - self.log.debug("{}".format(subprocess_jpeg)) + self.log.debug("{}".format(subprocess_command)) try: # temporary until oiiotool is supported cross platform - openpype.api.run_subprocess( - subprocess_jpeg, shell=True, logger=self.log + run_subprocess( + subprocess_command, shell=True, logger=self.log ) except RuntimeError as exp: if "Compression" in str(exp): self.log.debug("Unsupported compression on input files. " + "Skipping!!!") return + self.log.warning("Conversion crashed", exc_info=True) raise if "representations" not in instance.data: diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 2dc822fb0e..be0bae5cdc 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -2,7 +2,8 @@ import os import pyblish import openpype.api from openpype.lib import ( - get_ffmpeg_tool_path + get_ffmpeg_tool_path, + path_to_subprocess_arg ) import tempfile import opentimelineio as otio @@ -56,14 +57,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): audio_inputs.insert(0, empty) # create cmd - cmd = '"{}"'.format(self.ffmpeg_path) + " " + cmd = path_to_subprocess_arg(self.ffmpeg_path) + " " cmd += self.create_cmd(audio_inputs) - cmd += "\"{}\"".format(audio_temp_fpath) + cmd += path_to_subprocess_arg(audio_temp_fpath) # run subprocess self.log.debug("Executing: {}".format(cmd)) openpype.api.run_subprocess( - cmd, logger=self.log + cmd, shell=True, logger=self.log ) # remove empty @@ -99,16 +100,16 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): # temp audio file audio_fpath = self.create_temp_file(name) - cmd = " ".join([ - '"{}"'.format(self.ffmpeg_path), - "-ss {}".format(start_sec), - "-t {}".format(duration_sec), - "-i \"{}\"".format(audio_file), + cmd = [ + self.ffmpeg_path, + "-ss", str(start_sec), + "-t", str(duration_sec), + "-i", audio_file, audio_fpath - ]) + ] # run subprocess - self.log.debug("Executing: {}".format(cmd)) + self.log.debug("Executing: {}".format(" ".join(cmd))) openpype.api.run_subprocess( cmd, logger=self.log ) @@ -220,17 +221,17 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): max_duration_sec = max(end_secs) # create empty cmd - cmd = " ".join([ - '"{}"'.format(self.ffmpeg_path), - "-f lavfi", - "-i anullsrc=channel_layout=stereo:sample_rate=48000", - "-t {}".format(max_duration_sec), - "\"{}\"".format(empty_fpath) - ]) + cmd = [ + self.ffmpeg_path, + "-f", "lavfi", + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-t", str(max_duration_sec), + empty_fpath + ] # generate empty with ffmpeg # run subprocess - self.log.debug("Executing: {}".format(cmd)) + self.log.debug("Executing: {}".format(" ".join(cmd))) openpype.api.run_subprocess( cmd, logger=self.log @@ -261,10 +262,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): for index, input in enumerate(inputs): input_format = input.copy() input_format.update({"i": index}) + input_format["mediaPath"] = path_to_subprocess_arg( + input_format["mediaPath"] + ) + _inputs += ( "-ss {startSec} " "-t {durationSec} " - "-i \"{mediaPath}\" " + "-i {mediaPath} " ).format(**input_format) _filters += "[{i}]adelay={delayMilSec}:all=1[r{i}]; ".format( diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 818903b54b..ed2ba017d5 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -312,7 +312,7 @@ class ExtractOTIOReview(openpype.api.Extractor): out_frame_start += end_offset # start command list - command = ['"{}"'.format(ffmpeg_path)] + command = [ffmpeg_path] if sequence: input_dir, collection = sequence @@ -324,8 +324,8 @@ class ExtractOTIOReview(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-start_number {}".format(in_frame_start), - "-i \"{}\"".format(input_path) + "-start_number", str(in_frame_start), + "-i", input_path ]) elif video: @@ -334,13 +334,15 @@ class ExtractOTIOReview(openpype.api.Extractor): input_fps = otio_range.start_time.rate frame_duration = otio_range.duration.value sec_start = openpype.lib.frames_to_secons(frame_start, input_fps) - sec_duration = openpype.lib.frames_to_secons(frame_duration, input_fps) + sec_duration = openpype.lib.frames_to_secons( + frame_duration, input_fps + ) # form command for rendering gap files command.extend([ - "-ss {}".format(sec_start), - "-t {}".format(sec_duration), - "-i \"{}\"".format(video_path) + "-ss", str(sec_start), + "-t", str(sec_duration), + "-i", video_path ]) elif gap: @@ -349,22 +351,24 @@ class ExtractOTIOReview(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-t {} -r {}".format(sec_duration, self.actual_fps), - "-f lavfi", - "-i color=c=black:s={}x{}".format(self.to_width, - self.to_height), - "-tune stillimage" + "-t", str(sec_duration), + "-r", str(self.actual_fps), + "-f", "lavfi", + "-i", "color=c=black:s={}x{}".format( + self.to_width, self.to_height + ), + "-tune", "stillimage" ]) # add output attributes command.extend([ - "-start_number {}".format(out_frame_start), - "\"{}\"".format(output_path) + "-start_number", str(out_frame_start), + output_path ]) # execute self.log.debug("Executing: {}".format(" ".join(command))) output = openpype.api.run_subprocess( - " ".join(command), logger=self.log + command, logger=self.log ) self.log.debug("Output: {}".format(output)) diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index fdb7c4b096..3e2d39c99c 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -75,7 +75,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): output_path = self._get_ffmpeg_output(input_file_path) # start command list - command = ['"{}"'.format(ffmpeg_path)] + command = [ffmpeg_path] video_path = input_file_path frame_start = otio_range.start_time.value @@ -86,17 +86,17 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-ss {}".format(sec_start), - "-t {}".format(sec_duration), - "-i \"{}\"".format(video_path), - "-c copy", + "-ss", str(sec_start), + "-t", str(sec_duration), + "-i", video_path, + "-c", "copy", output_path ]) # execute self.log.debug("Executing: {}".format(" ".join(command))) output = openpype.api.run_subprocess( - " ".join(command), logger=self.log + command, logger=self.log ) self.log.debug("Output: {}".format(output)) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 78cbea10be..7284483f5f 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -13,6 +13,9 @@ import openpype.api from openpype.lib import ( get_ffmpeg_tool_path, ffprobe_streams, + + path_to_subprocess_arg, + should_decompress, get_decompress_dir, decompress @@ -27,8 +30,8 @@ class ExtractReview(pyblish.api.InstancePlugin): otherwise the representation is ignored. All new representations are created and encoded by ffmpeg following - presets found in `pype-config/presets/plugins/global/ - publish.json:ExtractReview:outputs`. + presets found in OpenPype Settings interface at + `project_settings/global/publish/ExtractReview/profiles:outputs`. """ label = "Extract Review" @@ -238,7 +241,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "outputName": output_name, "outputDef": output_def, "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"] + "frameEndFtrack": temp_data["output_frame_end"], + "ffmpeg_cmd": subprcs_cmd }) # Force to pop these key if are in new repre @@ -480,7 +484,9 @@ class ExtractReview(pyblish.api.InstancePlugin): # Add video/image input path ffmpeg_input_args.append( - "-i \"{}\"".format(temp_data["full_input_path"]) + "-i {}".format( + path_to_subprocess_arg(temp_data["full_input_path"]) + ) ) # Add audio arguments if there are any. Skipped when output are images. @@ -538,7 +544,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - "\"{}\"".format(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data["full_output_path"]) ) return self.ffmpeg_full_args( @@ -607,7 +613,7 @@ class ExtractReview(pyblish.api.InstancePlugin): audio_filters.append(arg) all_args = [] - all_args.append("\"{}\"".format(self.ffmpeg_path)) + all_args.append(path_to_subprocess_arg(self.ffmpeg_path)) all_args.extend(input_args) if video_filters: all_args.append("-filter:v") @@ -854,7 +860,9 @@ class ExtractReview(pyblish.api.InstancePlugin): audio_in_args.append("-to {:0.10f}".format(audio_duration)) # Add audio input path - audio_in_args.append("-i \"{}\"".format(audio["filename"])) + audio_in_args.append("-i {}".format( + path_to_subprocess_arg(audio["filename"]) + )) # NOTE: These were changed from input to output arguments. # NOTE: value in "-ac" was hardcoded to 2, changed to audio inputs len. diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2b07d7db74..7002168cdb 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -117,11 +117,13 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.extend(repre["_profile"].get('input', [])) else: input_args.extend(repre["outputDef"].get('input', [])) - input_args.append("-loop 1 -i {}".format(slate_path)) + input_args.append("-loop 1 -i {}".format( + openpype.lib.path_to_subprocess_arg(slate_path) + )) input_args.extend([ "-r {}".format(fps), - "-t 0.04"] - ) + "-t 0.04" + ]) if use_legacy_code: codec_args = repre["_profile"].get('codec', []) @@ -188,20 +190,24 @@ class ExtractReviewSlate(openpype.api.Extractor): output_args.append("-y") slate_v_path = slate_path.replace(".png", ext) - output_args.append(slate_v_path) + output_args.append( + openpype.lib.path_to_subprocess_arg(slate_v_path) + ) _remove_at_end.append(slate_v_path) slate_args = [ - "\"{}\"".format(ffmpeg_path), + openpype.lib.path_to_subprocess_arg(ffmpeg_path), " ".join(input_args), " ".join(output_args) ] - slate_subprcs_cmd = " ".join(slate_args) + slate_subprocess_cmd = " ".join(slate_args) # run slate generation subprocess - self.log.debug("Slate Executing: {}".format(slate_subprcs_cmd)) + self.log.debug( + "Slate Executing: {}".format(slate_subprocess_cmd) + ) openpype.api.run_subprocess( - slate_subprcs_cmd, shell=True, logger=self.log + slate_subprocess_cmd, shell=True, logger=self.log ) # create ffmpeg concat text file path @@ -221,23 +227,22 @@ class ExtractReviewSlate(openpype.api.Extractor): ]) # concat slate and videos together - conc_input_args = ["-y", "-f concat", "-safe 0"] - conc_input_args.append("-i {}".format(conc_text_path)) - - conc_output_args = ["-c copy"] - conc_output_args.append(output_path) - concat_args = [ ffmpeg_path, - " ".join(conc_input_args), - " ".join(conc_output_args) + "-y", + "-f", "concat", + "-safe", "0", + "-i", conc_text_path, + "-c", "copy", + output_path ] - concat_subprcs_cmd = " ".join(concat_args) # ffmpeg concat subprocess - self.log.debug("Executing concat: {}".format(concat_subprcs_cmd)) + self.log.debug( + "Executing concat: {}".format(" ".join(concat_args)) + ) openpype.api.run_subprocess( - concat_subprcs_cmd, shell=True, logger=self.log + concat_args, logger=self.log ) self.log.debug("__ repre[tags]: {}".format(repre["tags"])) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index f9e9b43f08..451ea1d80d 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -63,6 +63,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "animation", "model", "mayaAscii", + "mayaScene", "setdress", "layout", "ass", @@ -106,12 +107,16 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "family", "hierarchy", "task", "username" ] default_template_name = "publish" - template_name_profiles = None + + # suffix to denote temporary files, use without '.' + TMP_FILE_EXT = 'tmp' # file_url : file_size of all published and uploaded files integrated_file_sizes = {} - TMP_FILE_EXT = 'tmp' # suffix to denote temporary files, use without '.' + # Attributes set by settings + template_name_profiles = None + subset_grouping_profiles = None def process(self, instance): self.integrated_file_sizes = {} @@ -165,10 +170,24 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): hierarchy = "/".join(parents) anatomy_data["hierarchy"] = hierarchy + # Make sure task name in anatomy data is same as on instance.data task_name = instance.data.get("task") if task_name: anatomy_data["task"] = task_name + else: + # Just set 'task_name' variable to context task + task_name = anatomy_data["task"] + # Find task type for current task name + # - this should be already prepared on instance + asset_tasks = ( + asset_entity.get("data", {}).get("tasks") + ) or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + instance.data["task_type"] = task_type + + # Fill family in anatomy data anatomy_data["family"] = instance.data.get("family") stagingdir = instance.data.get("stagingDir") @@ -298,14 +317,19 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): else: orig_transfers = list(instance.data['transfers']) - task_name = io.Session.get("AVALON_TASK") family = self.main_family_from_instance(instance) - key_values = {"families": family, - "tasks": task_name, - "hosts": instance.data["anatomyData"]["app"]} - profile = filter_profiles(self.template_name_profiles, key_values, - logger=self.log) + key_values = { + "families": family, + "tasks": task_name, + "hosts": instance.context.data["hostName"], + "task_types": task_type + } + profile = filter_profiles( + self.template_name_profiles, + key_values, + logger=self.log + ) template_name = "publish" if profile: @@ -730,6 +754,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): subset = io.find_one({"_id": _id}) + # QUESTION Why is changing of group and updating it's + # families in 'get_subset'? self._set_subset_group(instance, subset["_id"]) # Update families on subset. @@ -753,54 +779,74 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): subset_id (str): DB's subset _id """ - # add group if available - integrate_new_sett = (instance.context.data["project_settings"] - ["global"] - ["publish"] - ["IntegrateAssetNew"]) - - profiles = integrate_new_sett["subset_grouping_profiles"] - - filtering_criteria = { - "families": instance.data["family"], - "hosts": instance.data["anatomyData"]["app"], - "tasks": instance.data["anatomyData"]["task"] or - io.Session["AVALON_TASK"] - } - matching_profile = filter_profiles(profiles, filtering_criteria) - - filled_template = None - if matching_profile: - template = matching_profile["template"] - fill_pairs = ( - ("family", filtering_criteria["families"]), - ("task", filtering_criteria["tasks"]), - ("host", filtering_criteria["hosts"]), - ("subset", instance.data["subset"]), - ("renderlayer", instance.data.get("renderlayer")) - ) - fill_pairs = prepare_template_data(fill_pairs) - - try: - filled_template = \ - format_template_with_optional_keys(fill_pairs, template) - except KeyError: - keys = [] - if fill_pairs: - keys = fill_pairs.keys() - - msg = "Subset grouping failed. " \ - "Only {} are expected in Settings".format(','.join(keys)) - self.log.warning(msg) - - if instance.data.get("subsetGroup") or filled_template: - subset_group = instance.data.get('subsetGroup') or filled_template + # Fist look into instance data + subset_group = instance.data.get("subsetGroup") + if not subset_group: + subset_group = self._get_subset_group(instance) + if subset_group: io.update_many({ 'type': 'subset', '_id': io.ObjectId(subset_id) }, {'$set': {'data.subsetGroup': subset_group}}) + def _get_subset_group(self, instance): + """Look into subset group profiles set by settings. + + Attribute 'subset_grouping_profiles' is defined by OpenPype settings. + """ + # Skip if 'subset_grouping_profiles' is empty + if not self.subset_grouping_profiles: + return None + + # QUESTION + # - is there a chance that task name is not filled in anatomy + # data? + # - should we use context task in that case? + task_name = ( + instance.data["anatomyData"]["task"] + or io.Session["AVALON_TASK"] + ) + task_type = instance.data["task_type"] + filtering_criteria = { + "families": instance.data["family"], + "hosts": instance.context.data["hostName"], + "tasks": task_name, + "task_types": task_type + } + matching_profile = filter_profiles( + self.subset_grouping_profiles, + filtering_criteria + ) + # Skip if there is not matchin profile + if not matching_profile: + return None + + filled_template = None + template = matching_profile["template"] + fill_pairs = ( + ("family", filtering_criteria["families"]), + ("task", filtering_criteria["tasks"]), + ("host", filtering_criteria["hosts"]), + ("subset", instance.data["subset"]), + ("renderlayer", instance.data.get("renderlayer")) + ) + fill_pairs = prepare_template_data(fill_pairs) + + try: + filled_template = \ + format_template_with_optional_keys(fill_pairs, template) + except KeyError: + keys = [] + if fill_pairs: + keys = fill_pairs.keys() + + msg = "Subset grouping failed. " \ + "Only {} are expected in Settings".format(','.join(keys)) + self.log.warning(msg) + + return filled_template + def create_version(self, subset, version_number, data=None): """ Copy given source to destination diff --git a/openpype/plugins/publish/start_timer.py b/openpype/plugins/publish/start_timer.py index 6312294bf1..112d92bef0 100644 --- a/openpype/plugins/publish/start_timer.py +++ b/openpype/plugins/publish/start_timer.py @@ -1,6 +1,5 @@ import pyblish.api -from openpype.api import get_system_settings from openpype.lib import change_timer_to_current_context @@ -10,6 +9,6 @@ class StartTimer(pyblish.api.ContextPlugin): hosts = ["*"] def process(self, context): - modules_settings = get_system_settings()["modules"] + modules_settings = context.data["system_settings"]["modules"] if modules_settings["timers_manager"]["disregard_publishing"]: change_timer_to_current_context() diff --git a/openpype/plugins/publish/stop_timer.py b/openpype/plugins/publish/stop_timer.py index 81afd16378..414e43a3c4 100644 --- a/openpype/plugins/publish/stop_timer.py +++ b/openpype/plugins/publish/stop_timer.py @@ -3,16 +3,14 @@ import requests import pyblish.api -from openpype.api import get_system_settings - class StopTimer(pyblish.api.ContextPlugin): label = "Stop Timer" - order = pyblish.api.ExtractorOrder - 0.5 + order = pyblish.api.ExtractorOrder - 0.49 hosts = ["*"] def process(self, context): - modules_settings = get_system_settings()["modules"] + modules_settings = context.data["system_settings"]["modules"] if modules_settings["timers_manager"]["disregard_publishing"]: webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py index 52df493451..784221c3b6 100644 --- a/openpype/plugins/publish/validate_containers.py +++ b/openpype/plugins/publish/validate_containers.py @@ -1,7 +1,5 @@ import pyblish.api - import openpype.lib -from avalon.tools import cbsceneinventory class ShowInventory(pyblish.api.Action): @@ -11,7 +9,9 @@ class ShowInventory(pyblish.api.Action): on = "failed" def process(self, context, plugin): - cbsceneinventory.show() + from avalon.tools import sceneinventory + + sceneinventory.show() class ValidateContainers(pyblish.api.ContextPlugin): diff --git a/openpype/plugins/publish/validate_ffmpeg_installed.py b/openpype/plugins/publish/validate_ffmpeg_installed.py deleted file mode 100644 index a5390a07b2..0000000000 --- a/openpype/plugins/publish/validate_ffmpeg_installed.py +++ /dev/null @@ -1,34 +0,0 @@ -import pyblish.api -import os -import subprocess -import openpype.lib -try: - import os.errno as errno -except ImportError: - import errno - - -class ValidateFFmpegInstalled(pyblish.api.ContextPlugin): - """Validate availability of ffmpeg tool in PATH""" - - order = pyblish.api.ValidatorOrder - label = 'Validate ffmpeg installation' - optional = True - - def is_tool(self, name): - try: - devnull = open(os.devnull, "w") - subprocess.Popen( - [name], stdout=devnull, stderr=devnull - ).communicate() - except OSError as e: - if e.errno == errno.ENOENT: - return False - return True - - def process(self, context): - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - self.log.info("ffmpeg path: `{}`".format(ffmpeg_path)) - if self.is_tool("{}".format(ffmpeg_path)) is False: - self.log.error("ffmpeg not found in PATH") - raise RuntimeError('ffmpeg not installed.') diff --git a/openpype/plugins/publish/validate_intent.py b/openpype/plugins/publish/validate_intent.py index 80bcb0e164..23d57bb2b7 100644 --- a/openpype/plugins/publish/validate_intent.py +++ b/openpype/plugins/publish/validate_intent.py @@ -1,5 +1,7 @@ -import pyblish.api import os +import pyblish.api + +from openpype.lib import filter_profiles class ValidateIntent(pyblish.api.ContextPlugin): @@ -12,20 +14,49 @@ class ValidateIntent(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Intent" - # TODO: this should be off by default and only activated viac config - tasks = ["animation"] - hosts = ["harmony"] - if os.environ.get("AVALON_TASK") not in tasks: - active = False + enabled = False + + # Can be modified by settings + profiles = [{ + "hosts": [], + "task_types": [], + "tasks": [], + "validate": False + }] def process(self, context): + # Skip if there are no profiles + validate = True + if self.profiles: + # Collect data from context + task_name = context.data.get("task") + task_type = context.data.get("taskType") + host_name = context.data.get("hostName") + + filter_data = { + "hosts": host_name, + "task_types": task_type, + "tasks": task_name + } + matching_profile = filter_profiles( + self.profiles, filter_data, logger=self.log + ) + if matching_profile: + validate = matching_profile["validate"] + + if not validate: + self.log.debug(( + "Validation of intent was skipped." + " Matching profile for current context disabled validation." + )) + return + msg = ( "Please make sure that you select the intent of this publish." ) - intent = context.data.get("intent") - self.log.debug(intent) - assert intent, msg - + intent = context.data.get("intent") or {} + self.log.debug(str(intent)) intent_value = intent.get("value") - assert intent is not "", msg + if not intent_value: + raise AssertionError(msg) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index 6701041541..927e024476 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -12,6 +12,9 @@ class ValidateVersion(pyblish.api.InstancePlugin): label = "Validate Version" hosts = ["nuke", "maya", "blender", "standalonepublisher"] + optional = False + active = True + def process(self, instance): version = instance.data.get("version") latest_version = instance.data.get("latestVersion") diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index c18fe36667..5288749e8b 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -257,3 +257,30 @@ class PypeCommands: def validate_jsons(self): pass + def run_tests(self, folder, mark, pyargs): + """ + Runs tests from 'folder' + + Args: + folder (str): relative path to folder with tests + mark (str): label to run tests marked by it (slow etc) + pyargs (str): package path to test + """ + print("run_tests") + import subprocess + + if folder: + folder = " ".join(list(folder)) + else: + folder = "../tests" + + mark_str = pyargs_str = '' + if mark: + mark_str = "-m {}".format(mark) + + if pyargs: + pyargs_str = "--pyargs {}".format(pyargs) + + cmd = "pytest {} {} {}".format(folder, mark_str, pyargs_str) + print("Running {}".format(cmd)) + subprocess.run(cmd) diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index ef4ed73974..c6886fea73 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -1,5 +1,5 @@ import os - +from openpype.lib.pype_info import is_running_staging RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -30,22 +30,22 @@ def get_liberation_font_path(bold=False, italic=False): return font_path -def pype_icon_filepath(debug=None): - if debug is None: - debug = bool(os.getenv("OPENPYPE_DEV")) +def get_openpype_icon_filepath(staging=None): + if staging is None: + staging = is_running_staging() - if debug: + if staging: icon_file_name = "openpype_icon_staging.png" else: icon_file_name = "openpype_icon.png" return get_resource("icons", icon_file_name) -def pype_splash_filepath(debug=None): - if debug is None: - debug = bool(os.getenv("OPENPYPE_DEV")) +def get_openpype_splash_filepath(staging=None): + if staging is None: + staging = is_running_staging() - if debug: + if staging: splash_file_name = "openpype_splash_staging.png" else: splash_file_name = "openpype_splash.png" diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index dc8d60cb37..184d7689e3 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -69,7 +69,7 @@ def get_fps(str_value): return str(fps) -def _prores_codec_args(ffprobe_data): +def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): output = [] tags = ffprobe_data.get("tags") or {} @@ -108,14 +108,24 @@ def _prores_codec_args(ffprobe_data): return output -def _h264_codec_args(ffprobe_data): +def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): output = [] output.extend(["-codec:v", "h264"]) - bit_rate = ffprobe_data.get("bit_rate") - if bit_rate: - output.extend(["-b:v", bit_rate]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-crf", + "-b:v", "-vb", + "-minrate", "-minrate:", + "-maxrate", "-maxrate:", + "-bufsize", "-bufsize:" + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) pix_fmt = ffprobe_data.get("pix_fmt") if pix_fmt: @@ -127,15 +137,15 @@ def _h264_codec_args(ffprobe_data): return output -def get_codec_args(ffprobe_data): +def get_codec_args(ffprobe_data, source_ffmpeg_cmd): codec_name = ffprobe_data.get("codec_name") # Codec "prores" if codec_name == "prores": - return _prores_codec_args(ffprobe_data) + return _prores_codec_args(ffprobe_data, source_ffmpeg_cmd) # Codec "h264" if codec_name == "h264": - return _h264_codec_args(ffprobe_data) + return _h264_codec_args(ffprobe_data, source_ffmpeg_cmd) output = [] if codec_name: @@ -469,7 +479,7 @@ def example(input_path, output_path): def burnins_from_data( input_path, output_path, data, codec_data=None, options=None, burnin_values=None, overwrite=True, - full_input_path=None, first_frame=None + full_input_path=None, first_frame=None, source_ffmpeg_cmd=None ): """This method adds burnins to video/image file based on presets setting. @@ -647,7 +657,7 @@ def burnins_from_data( else: ffprobe_data = burnin._streams[0] - ffmpeg_args.extend(get_codec_args(ffprobe_data)) + ffmpeg_args.extend(get_codec_args(ffprobe_data, source_ffmpeg_cmd)) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) @@ -670,6 +680,7 @@ if __name__ == "__main__": options=in_data.get("options"), burnin_values=in_data.get("values"), full_input_path=in_data.get("full_input_path"), - first_frame=in_data.get("first_frame") + first_frame=in_data.get("first_frame"), + source_ffmpeg_cmd=in_data.get("ffmpeg_cmd") ) print("* Burnin script has finished") diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index b5810deef4..9d7598a948 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -1,7 +1,21 @@ +from .constants import ( + GLOBAL_SETTINGS_KEY, + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + PROJECT_ANATOMY_KEY, + LOCAL_SETTING_KEY, + + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS, + + KEY_ALLOWED_SYMBOLS, + KEY_REGEX +) from .exceptions import ( SaveWarningExc ) from .lib import ( + get_general_environments, get_system_settings, get_project_settings, get_current_project_settings, @@ -11,13 +25,27 @@ from .lib import ( ) from .entities import ( SystemSettings, - ProjectSettings + ProjectSettings, + DefaultsNotDefined ) __all__ = ( + "GLOBAL_SETTINGS_KEY", + "SYSTEM_SETTINGS_KEY", + "PROJECT_SETTINGS_KEY", + "PROJECT_ANATOMY_KEY", + "LOCAL_SETTING_KEY", + + "SCHEMA_KEY_SYSTEM_SETTINGS", + "SCHEMA_KEY_PROJECT_SETTINGS", + + "KEY_ALLOWED_SYMBOLS", + "KEY_REGEX", + "SaveWarningExc", + "get_general_environments", "get_system_settings", "get_project_settings", "get_current_project_settings", @@ -26,5 +54,6 @@ __all__ = ( "get_local_settings", "SystemSettings", - "ProjectSettings" + "ProjectSettings", + "DefaultsNotDefined" ) diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index a53e88a91e..2ea19ead4b 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -14,13 +14,17 @@ METADATA_KEYS = ( M_DYNAMIC_KEY_LABEL ) -# File where studio's system overrides are stored +# Keys where studio's system overrides are stored GLOBAL_SETTINGS_KEY = "global_settings" SYSTEM_SETTINGS_KEY = "system_settings" PROJECT_SETTINGS_KEY = "project_settings" PROJECT_ANATOMY_KEY = "project_anatomy" LOCAL_SETTING_KEY = "local_settings" +# Schema hub names +SCHEMA_KEY_SYSTEM_SETTINGS = "system_schema" +SCHEMA_KEY_PROJECT_SETTINGS = "projects_schema" + DEFAULT_PROJECT_KEY = "__default_project__" KEY_ALLOWED_SYMBOLS = "a-zA-Z0-9-_ " @@ -39,6 +43,9 @@ __all__ = ( "PROJECT_ANATOMY_KEY", "LOCAL_SETTING_KEY", + "SCHEMA_KEY_SYSTEM_SETTINGS", + "SCHEMA_KEY_PROJECT_SETTINGS", + "DEFAULT_PROJECT_KEY", "KEY_ALLOWED_SYMBOLS", diff --git a/openpype/settings/defaults/project_anatomy/attributes.json b/openpype/settings/defaults/project_anatomy/attributes.json index 387e12bcea..983ac603f9 100644 --- a/openpype/settings/defaults/project_anatomy/attributes.json +++ b/openpype/settings/defaults/project_anatomy/attributes.json @@ -22,5 +22,6 @@ "aftereffects/2021", "unreal/4-26" ], - "tools_env": [] + "tools_env": [], + "active": true } \ No newline at end of file diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index fcebc876f5..38313a3d84 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -124,9 +124,47 @@ "value": "True" } ] + }, + { + "plugins": [ + "CreateWriteStill" + ], + "nukeNodeClass": "Write", + "knobs": [ + { + "name": "file_type", + "value": "tiff" + }, + { + "name": "datatype", + "value": "16 bit" + }, + { + "name": "compression", + "value": "Deflate" + }, + { + "name": "tile_color", + "value": "0x23ff00ff" + }, + { + "name": "channels", + "value": "rgb" + }, + { + "name": "colorspace", + "value": "sRGB" + }, + { + "name": "create_directories", + "value": "True" + } + ] } ], - "customNodes": [] + "customNodes": [ + + ] }, "regexInputs": { "inputs": [ diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 692176a585..b3ea77a584 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -209,6 +209,7 @@ "standalonepublisher" ], "families": [], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -221,6 +222,7 @@ "matchmove", "shot" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [] @@ -232,6 +234,7 @@ "families": [ "plate" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [ @@ -256,6 +259,7 @@ "rig", "camera" ], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -267,6 +271,7 @@ "families": [ "renderPass" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [] @@ -276,6 +281,7 @@ "tvpaint" ], "families": [], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -288,6 +294,7 @@ "write", "render" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [ @@ -307,6 +314,7 @@ "render", "workfile" ], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index a53ae14914..45c1a59d17 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -6,7 +6,12 @@ }, "ValidateVersion": { "enabled": true, - "optional": false + "optional": false, + "active": true + }, + "ValidateIntent": { + "enabled": false, + "profiles": [] }, "IntegrateHeroVersion": { "enabled": true, @@ -19,7 +24,7 @@ "animation", "setdress", "layout", - "mayaAscii" + "mayaScene" ] }, "ExtractJpegEXR": { @@ -152,6 +157,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template_name": "publish" }, @@ -162,6 +168,7 @@ "prerender" ], "hosts": [], + "task_types": [], "tasks": [], "template_name": "render" } @@ -170,6 +177,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template": "" } @@ -205,6 +213,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template": "{family}{Variant}" }, @@ -213,6 +222,7 @@ "render" ], "hosts": [], + "task_types": [], "tasks": [], "template": "{family}{Task}{Variant}" }, @@ -224,6 +234,7 @@ "hosts": [ "tvpaint" ], + "task_types": [], "tasks": [], "template": "{family}{Task}_{Render_layer}_{Render_pass}" }, @@ -235,6 +246,7 @@ "hosts": [ "tvpaint" ], + "task_types": [], "tasks": [], "template": "{family}{Task}" }, @@ -245,6 +257,7 @@ "hosts": [ "aftereffects" ], + "task_types": [], "tasks": [], "template": "render{Task}{Variant}" } @@ -261,6 +274,7 @@ "last_workfile_on_startup": [ { "hosts": [], + "task_types": [], "tasks": [], "enabled": true } @@ -268,6 +282,7 @@ "open_workfile_tool_on_startup": [ { "hosts": [], + "task_types": [], "tasks": [], "enabled": false } @@ -287,6 +302,15 @@ "textures" ] } + }, + "loader": { + "family_filter_profiles": [ + { + "hosts": [], + "task_types": [], + "filter_families": [] + } + ] } }, "project_folder_structure": "{\"__project_root__\": {\"prod\": {}, \"resources\": {\"footage\": {\"plates\": {}, \"offline\": {}}, \"audio\": {}, \"art_dept\": {}}, \"editorial\": {}, \"assets[ftrack.Library]\": {\"characters[ftrack]\": {}, \"locations[ftrack]\": {}}, \"shots[ftrack.Sequence]\": {\"scripts\": {}, \"editorial[ftrack.Folder]\": {}}}}", diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f9911897d7..c592d74350 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -156,6 +156,11 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "ValidateInstanceInContext": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateContainers": { "enabled": true, "optional": true, @@ -169,6 +174,11 @@ "enabled": false, "attributes": {} }, + "ValidateLoadedPlugin": { + "enabled": false, + "whitelist_native_plugins": false, + "authorized_plugins": [] + }, "ValidateRenderSettings": { "arnold_render_attributes": [], "vray_render_attributes": [], @@ -479,6 +489,12 @@ 255, 255 ], + "mayaScene": [ + 67, + 174, + 255, + 255 + ], "setdress": [ 255, 250, @@ -520,6 +536,7 @@ "workfile_build": { "profiles": [ { + "task_types": [], "tasks": [ "Lighting" ], diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 136f1d6b42..dd65df02e5 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -30,7 +30,18 @@ }, "publish": { "PreCollectNukeInstances": { - "sync_workfile_version": true + "sync_workfile_version_on_families": [ + "nukenodes", + "camera", + "gizmo", + "source", + "render" + ] + }, + "ValidateInstanceInContext": { + "enabled": true, + "optional": true, + "active": true }, "ValidateContainers": { "enabled": true, @@ -96,6 +107,11 @@ }, "ExtractSlateFrame": { "viewer_lut_raw": false + }, + "IncrementScriptVersion": { + "enabled": true, + "optional": true, + "active": true } }, "load": { @@ -116,7 +132,8 @@ "jpg", "jpeg", "png", - "psd" + "psd", + "tiff" ], "node_name_template": "{class_name}_{ext}" }, @@ -163,6 +180,7 @@ "builder_on_start": false, "profiles": [ { + "task_types": [], "tasks": [], "current_context": [ { diff --git a/openpype/settings/defaults/project_settings/slack.json b/openpype/settings/defaults/project_settings/slack.json index e70ef77fd2..2d10bd173d 100644 --- a/openpype/settings/defaults/project_settings/slack.json +++ b/openpype/settings/defaults/project_settings/slack.json @@ -7,8 +7,9 @@ "profiles": [ { "families": [], - "tasks": [], "hosts": [], + "task_types": [], + "tasks": [], "channel_messages": [] } ] diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 47f486aa98..528bf6de8e 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -1,4 +1,5 @@ { + "stop_timer_on_application_exit": false, "publish": { "ExtractSequence": { "review_bg": [ diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 842c294599..cfdeca4b87 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -195,7 +195,7 @@ "environment": {} }, "__dynamic_keys_labels__": { - "13-0": "13.0 (Testing only)", + "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", @@ -331,7 +331,7 @@ "environment": {} }, "__dynamic_keys_labels__": { - "13-0": "13.0 (Testing only)", + "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index d03fedf3c9..f54e8b2b16 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -7,6 +7,11 @@ "global": [] } }, + "disk_mapping": { + "windows": [], + "linux": [], + "darwin": [] + }, "openpype_path": { "windows": [], "darwin": [], diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index a0ba607edc..beb1eb4f24 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -1,4 +1,9 @@ { + "addon_paths": { + "windows": [], + "darwin": [], + "linux": [] + }, "avalon": { "AVALON_TIMEOUT": 1000, "AVALON_THUMBNAIL_ROOT": { @@ -174,4 +179,4 @@ "slack": { "enabled": false } -} +} \ No newline at end of file diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 8c30d5044c..aae2d1fa89 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -105,7 +105,6 @@ from .enum_entity import ( AppsEnumEntity, ToolsEnumEntity, TaskTypeEnumEntity, - ProvidersEnum, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity ) @@ -113,7 +112,10 @@ from .enum_entity import ( from .list_entity import ListEntity from .dict_immutable_keys_entity import DictImmutableKeysEntity from .dict_mutable_keys_entity import DictMutableKeysEntity -from .dict_conditional import DictConditionalEntity +from .dict_conditional import ( + DictConditionalEntity, + SyncServerProviders +) from .anatomy_entities import AnatomyEntity @@ -161,7 +163,6 @@ __all__ = ( "AppsEnumEntity", "ToolsEnumEntity", "TaskTypeEnumEntity", - "ProvidersEnum", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", @@ -172,6 +173,7 @@ __all__ = ( "DictMutableKeysEntity", "DictConditionalEntity", + "SyncServerProviders", "AnatomyEntity" ) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 851684520b..0e8274d374 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -104,6 +104,12 @@ class BaseItemEntity(BaseEntity): self.is_group = False # Entity's value will be stored into file with name of it's key self.is_file = False + # Default values are not stored to an openpype file + # - these must not be set through schemas directly + self.dynamic_schema_id = None + self.is_dynamic_schema_node = False + self.is_in_dynamic_schema_node = False + # Reference to parent entity which has `is_group` == True # - stays as None if none of parents is group self.group_item = None @@ -255,13 +261,22 @@ class BaseItemEntity(BaseEntity): ) # Group item can be only once in on hierarchy branch. - if self.is_group and self.group_item: + if self.is_group and self.group_item is not None: raise SchemeGroupHierarchyBug(self) + # Group item can be only once in on hierarchy branch. + if self.group_item is not None and self.is_dynamic_schema_node: + reason = ( + "Dynamic schema is inside grouped item {}." + " Change group hierarchy or remove dynamic" + " schema to be able work properly." + ).format(self.group_item.path) + raise EntitySchemaError(self, reason) + # Validate that env group entities will be stored into file. # - env group entities must store metadata which is not possible if # metadata would be outside of file - if not self.file_item and self.is_env_group: + if self.file_item is None and self.is_env_group: reason = ( "Environment item is not inside file" " item so can't store metadata for defaults." @@ -478,7 +493,15 @@ class BaseItemEntity(BaseEntity): @abstractmethod def settings_value(self): - """Value of an item without key.""" + """Value of an item without key without dynamic items.""" + pass + + @abstractmethod + def collect_dynamic_schema_entities(self): + """Collect entities that are on top of dynamically added schemas. + + This method make sence only when defaults are saved. + """ pass @abstractmethod @@ -808,6 +831,12 @@ class ItemEntity(BaseItemEntity): self.is_dynamic_item = is_dynamic_item self.is_file = self.schema_data.get("is_file", False) + # These keys have underscore as they must not be set in schemas + self.dynamic_schema_id = self.schema_data.get( + "_dynamic_schema_id", None + ) + self.is_dynamic_schema_node = self.dynamic_schema_id is not None + self.is_group = self.schema_data.get("is_group", False) self.is_in_dynamic_item = bool( not self.is_dynamic_item @@ -837,10 +866,20 @@ class ItemEntity(BaseItemEntity): self._require_restart_on_change = require_restart_on_change # File item reference - if self.parent.is_file: - self.file_item = self.parent - elif self.parent.file_item: - self.file_item = self.parent.file_item + if not self.is_dynamic_schema_node: + self.is_in_dynamic_schema_node = ( + self.parent.is_dynamic_schema_node + or self.parent.is_in_dynamic_schema_node + ) + + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + ): + if self.parent.is_file: + self.file_item = self.parent + elif self.parent.file_item: + self.file_item = self.parent.file_item # Group item reference if self.parent.is_group: @@ -891,6 +930,18 @@ class ItemEntity(BaseItemEntity): def root_key(self): return self.root_item.root_key + @abstractmethod + def collect_dynamic_schema_entities(self, collector): + """Collect entities that are on top of dynamically added schemas. + + This method make sence only when defaults are saved. + + Args: + collector(DynamicSchemaValueCollector): Object where dynamic + entities are stored. + """ + pass + def schema_validations(self): if not self.label and self.use_label_wrap: reason = ( @@ -899,7 +950,12 @@ class ItemEntity(BaseItemEntity): ) raise EntitySchemaError(self, reason) - if self.is_file and self.file_item is not None: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and self.is_file + and self.file_item is not None + ): reason = ( "Entity has set `is_file` to true but" " it's parent is already marked as file item." diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 988464d059..6f27760570 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -469,6 +469,10 @@ class DictConditionalEntity(ItemEntity): return True return False + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET @@ -482,13 +486,7 @@ class DictConditionalEntity(ItemEntity): output = {} for key, child_obj in children_items: - child_value = child_obj.settings_value() - if not child_obj.is_file and not child_obj.file_item: - for _key, _value in child_value.items(): - new_key = "/".join([key, _key]) - output[new_key] = _value - else: - output[key] = child_value + output[key] = child_obj.settings_value() return output if self.is_group: @@ -726,3 +724,49 @@ class DictConditionalEntity(ItemEntity): for children in self.children.values(): for child_entity in children: child_entity.reset_callbacks() + + +class SyncServerProviders(DictConditionalEntity): + schema_types = ["sync-server-providers"] + + def _add_children(self): + self.enum_key = "provider" + self.enum_label = "Provider" + + enum_children = self._get_enum_children() + if not enum_children: + enum_children.append({ + "key": None, + "label": "< Nothing >" + }) + self.enum_children = enum_children + + super(SyncServerProviders, self)._add_children() + + def _get_enum_children(self): + from openpype_modules import sync_server + + from openpype_modules.sync_server.providers import lib as lib_providers + + provider_code_to_label = {} + providers = lib_providers.factory.providers + for provider_code, provider_info in providers.items(): + provider, _ = provider_info + provider_code_to_label[provider_code] = provider.LABEL + + system_settings_schema = ( + sync_server + .SyncServerModule + .get_system_settings_schema() + ) + + enum_children = [] + for provider_code, configurables in system_settings_schema.items(): + label = provider_code_to_label.get(provider_code) or provider_code + + enum_children.append({ + "key": provider_code, + "label": label, + "children": configurables + }) + return enum_children diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 73b08f101a..57e21ff5f3 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -330,15 +330,32 @@ class DictImmutableKeysEntity(ItemEntity): return True return False + def collect_dynamic_schema_entities(self, collector): + for child_obj in self.non_gui_children.values(): + child_obj.collect_dynamic_schema_entities(collector) + + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET if self._override_state is OverrideState.DEFAULTS: + is_dynamic_schema_node = ( + self.is_dynamic_schema_node or self.is_in_dynamic_schema_node + ) output = {} for key, child_obj in self.non_gui_children.items(): + if child_obj.is_dynamic_schema_node: + continue + child_value = child_obj.settings_value() - if not child_obj.is_file and not child_obj.file_item: + if ( + not is_dynamic_schema_node + and not child_obj.is_file + and not child_obj.file_item + ): for _key, _value in child_value.items(): new_key = "/".join([key, _key]) output[new_key] = _value diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index c3df935269..f75fb23d82 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -261,7 +261,7 @@ class DictMutableKeysEntity(EndpointEntity): raise EntitySchemaError(self, reason) # TODO Ability to store labels should be defined with different key - if self.collapsible_key and not self.file_item: + if self.collapsible_key and self.file_item is None: reason = ( "Modifiable dictionary with collapsible keys is not under" " file item so can't store metadata." diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index cb532c5ae0..a5e734f039 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -376,11 +376,16 @@ class TaskTypeEnumEntity(BaseEnumEntity): schema_types = ["task-types-enum"] def _item_initalization(self): - self.multiselection = True - self.value_on_not_set = [] + self.multiselection = self.schema_data.get("multiselection", True) + if self.multiselection: + self.valid_value_types = (list, ) + self.value_on_not_set = [] + else: + self.valid_value_types = (STRING_TYPE, ) + self.value_on_not_set = "" + self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list, ) self.placeholder = None def _get_enum_values(self): @@ -396,53 +401,51 @@ class TaskTypeEnumEntity(BaseEnumEntity): return enum_items, valid_keys + def _convert_value_for_current_state(self, source_value): + if self.multiselection: + output = [] + for key in source_value: + if key in self.valid_keys: + output.append(key) + return output + + if source_value not in self.valid_keys: + # Take first item from enum items + for item in self.enum_items: + for key in item.keys(): + source_value = key + break + return source_value + def set_override_state(self, *args, **kwargs): super(TaskTypeEnumEntity, self).set_override_state(*args, **kwargs) self.enum_items, self.valid_keys = self._get_enum_values() - new_value = [] - for key in self._current_value: - if key in self.valid_keys: - new_value.append(key) - self._current_value = new_value + if self.multiselection: + new_value = [] + for key in self._current_value: + if key in self.valid_keys: + new_value.append(key) -class ProvidersEnum(BaseEnumEntity): - schema_types = ["providers-enum"] + if self._current_value != new_value: + self.set(new_value) + else: + if not self.enum_items: + self.valid_keys.add("") + self.enum_items.append({"": "< Empty >"}) - def _item_initalization(self): - self.multiselection = False - self.value_on_not_set = "" - self.enum_items = [] - self.valid_keys = set() - self.valid_value_types = (str, ) - self.placeholder = None + for item in self.enum_items: + for key in item.keys(): + value_on_not_set = key + break - def _get_enum_values(self): - from openpype_modules.sync_server.providers import lib as lib_providers - - providers = lib_providers.factory.providers - - valid_keys = set() - valid_keys.add('') - enum_items = [{'': 'Choose Provider'}] - for provider_code, provider_info in providers.items(): - provider, _ = provider_info - enum_items.append({provider_code: provider.LABEL}) - valid_keys.add(provider_code) - - return enum_items, valid_keys - - def set_override_state(self, *args, **kwargs): - super(ProvidersEnum, self).set_override_state(*args, **kwargs) - - self.enum_items, self.valid_keys = self._get_enum_values() - - value_on_not_set = list(self.valid_keys)[0] - if self._current_value is NOT_SET: - self._current_value = value_on_not_set - - self.value_on_not_set = value_on_not_set + self.value_on_not_set = value_on_not_set + if ( + self._current_value is NOT_SET + or self._current_value not in self.valid_keys + ): + self.set(value_on_not_set) class DeadlineUrlEnumEntity(BaseEnumEntity): diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 336d1f5c1e..0ded3ab7e5 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -49,6 +49,10 @@ class EndpointEntity(ItemEntity): super(EndpointEntity, self).schema_validations() + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + @abstractmethod def _settings_value(self): pass @@ -121,7 +125,11 @@ class InputEntity(EndpointEntity): def schema_validations(self): # Input entity must have file parent. - if not self.file_item: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and self.file_item is None + ): raise EntitySchemaError(self, "Missing parent file entity.") super(InputEntity, self).schema_validations() @@ -369,6 +377,14 @@ class NumberEntity(InputEntity): self.valid_value_types = valid_value_types self.value_on_not_set = value_on_not_set + # UI specific attributes + self.show_slider = self.schema_data.get("show_slider", False) + steps = self.schema_data.get("steps", None) + # Make sure that steps are not set to `0` + if steps == 0: + steps = None + self.steps = steps + def _convert_to_valid_type(self, value): if isinstance(value, str): new_value = None diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index ac6b3e76dd..c7c9c3097e 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -115,6 +115,9 @@ class PathEntity(ItemEntity): def set(self, value): self.child_obj.set(value) + def collect_dynamic_schema_entities(self, *args, **kwargs): + self.child_obj.collect_dynamic_schema_entities(*args, **kwargs) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET @@ -236,7 +239,12 @@ class ListStrictEntity(ItemEntity): def schema_validations(self): # List entity must have file parent. - if not self.file_item and not self.is_file: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and not self.is_file + and self.file_item is None + ): raise EntitySchemaError( self, "Missing file entity in hierarchy." ) @@ -279,6 +287,10 @@ class ListStrictEntity(ItemEntity): for idx, item in enumerate(new_value): self.children[idx].set(item) + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 01f61d8bdf..bf3868c08d 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -3,6 +3,7 @@ import re import json import copy import inspect +import collections import contextlib from .exceptions import ( @@ -10,6 +11,12 @@ from .exceptions import ( SchemaDuplicatedEnvGroupKeys ) +from openpype.settings.constants import ( + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS +) try: STRING_TYPE = basestring except Exception: @@ -24,6 +31,10 @@ TEMPLATE_METADATA_KEYS = ( DEFAULT_VALUES_KEY, ) +SCHEMA_EXTEND_TYPES = ( + "schema", "template", "schema_template", "dynamic_schema" +) + template_key_pattern = re.compile(r"(\{.*?[^{0]*\})") @@ -102,8 +113,8 @@ class OverrideState: class SchemasHub: - def __init__(self, schema_subfolder, reset=True): - self._schema_subfolder = schema_subfolder + def __init__(self, schema_type, reset=True): + self._schema_type = schema_type self._loaded_types = {} self._gui_types = tuple() @@ -112,25 +123,60 @@ class SchemasHub: self._loaded_templates = {} self._loaded_schemas = {} + # Attributes for modules settings + self._dynamic_schemas_defs_by_id = {} + self._dynamic_schemas_by_id = {} + # Store validating and validated dynamic template or schemas self._validating_dynamic = set() self._validated_dynamic = set() - # It doesn't make sence to reload types on each reset as they can't be - # changed - self._load_types() - # Trigger reset if reset: self.reset() + @property + def schema_type(self): + return self._schema_type + def reset(self): + self._load_modules_settings_defs() + self._load_types() self._load_schemas() + def _load_modules_settings_defs(self): + from openpype.modules import get_module_settings_defs + + module_settings_defs = get_module_settings_defs() + for module_settings_def_cls in module_settings_defs: + module_settings_def = module_settings_def_cls() + def_id = module_settings_def.id + self._dynamic_schemas_defs_by_id[def_id] = module_settings_def + @property def gui_types(self): return self._gui_types + def resolve_dynamic_schema(self, dynamic_key): + output = [] + for def_id, def_keys in self._dynamic_schemas_by_id.items(): + if dynamic_key in def_keys: + def_schema = def_keys[dynamic_key] + if not def_schema: + continue + + if isinstance(def_schema, dict): + def_schema = [def_schema] + + all_def_schema = [] + for item in def_schema: + items = self.resolve_schema_data(item) + for _item in items: + _item["_dynamic_schema_id"] = def_id + all_def_schema.extend(items) + output.extend(all_def_schema) + return output + def get_template_name(self, item_def, default=None): """Get template name from passed item definition. @@ -260,7 +306,7 @@ class SchemasHub: list: Resolved schema data. """ schema_type = schema_data["type"] - if schema_type not in ("schema", "template", "schema_template"): + if schema_type not in SCHEMA_EXTEND_TYPES: return [schema_data] if schema_type == "schema": @@ -268,6 +314,9 @@ class SchemasHub: self.get_schema(schema_data["name"]) ) + if schema_type == "dynamic_schema": + return self.resolve_dynamic_schema(schema_data["name"]) + template_name = schema_data["name"] template_def = self.get_template(template_name) @@ -368,14 +417,16 @@ class SchemasHub: self._crashed_on_load = {} self._loaded_templates = {} self._loaded_schemas = {} + self._dynamic_schemas_by_id = {} dirpath = os.path.join( os.path.dirname(os.path.abspath(__file__)), "schemas", - self._schema_subfolder + self.schema_type ) loaded_schemas = {} loaded_templates = {} + dynamic_schemas_by_id = {} for root, _, filenames in os.walk(dirpath): for filename in filenames: basename, ext = os.path.splitext(filename) @@ -425,8 +476,34 @@ class SchemasHub: ) loaded_schemas[basename] = schema_data + defs_iter = self._dynamic_schemas_defs_by_id.items() + for def_id, module_settings_def in defs_iter: + dynamic_schemas_by_id[def_id] = ( + module_settings_def.get_dynamic_schemas(self.schema_type) + ) + module_schemas = module_settings_def.get_settings_schemas( + self.schema_type + ) + for key, schema_data in module_schemas.items(): + if isinstance(schema_data, list): + if key in loaded_templates: + raise KeyError( + "Duplicated template key \"{}\"".format(key) + ) + loaded_templates[key] = schema_data + else: + if key in loaded_schemas: + raise KeyError( + "Duplicated schema key \"{}\"".format(key) + ) + loaded_schemas[key] = schema_data + self._loaded_templates = loaded_templates self._loaded_schemas = loaded_schemas + self._dynamic_schemas_by_id = dynamic_schemas_by_id + + def get_dynamic_modules_settings_defs(self, schema_def_id): + return self._dynamic_schemas_defs_by_id.get(schema_def_id) def _fill_template(self, child_data, template_def): """Fill template based on schema definition and template definition. @@ -660,3 +737,38 @@ class SchemasHub: if found_idx is not None: metadata_item = template_def.pop(found_idx) return metadata_item + + +class DynamicSchemaValueCollector: + # Map schema hub type to store keys + schema_hub_type_map = { + SCHEMA_KEY_SYSTEM_SETTINGS: SYSTEM_SETTINGS_KEY, + SCHEMA_KEY_PROJECT_SETTINGS: PROJECT_SETTINGS_KEY + } + + def __init__(self, schema_hub): + self._schema_hub = schema_hub + self._dynamic_entities = [] + + def add_entity(self, entity): + self._dynamic_entities.append(entity) + + def create_hierarchy(self): + output = collections.defaultdict(dict) + for entity in self._dynamic_entities: + output[entity.dynamic_schema_id][entity.path] = ( + entity.settings_value() + ) + return output + + def save_values(self): + hierarchy = self.create_hierarchy() + + for schema_def_id, schema_def_value in hierarchy.items(): + schema_def = self._schema_hub.get_dynamic_modules_settings_defs( + schema_def_id + ) + top_key = self.schema_hub_type_map.get( + self._schema_hub.schema_type + ) + schema_def.save_defaults(top_key, schema_def_value) diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 4a06d2d591..05d20ee60b 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -9,8 +9,11 @@ from .base_entity import BaseItemEntity from .lib import ( NOT_SET, WRAPPER_TYPES, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS, OverrideState, - SchemasHub + SchemasHub, + DynamicSchemaValueCollector ) from .exceptions import ( SchemaError, @@ -28,6 +31,7 @@ from openpype.settings.lib import ( DEFAULTS_DIR, get_default_settings, + reset_default_settings, get_studio_system_settings_overrides, save_studio_settings, @@ -265,6 +269,16 @@ class RootEntity(BaseItemEntity): output[key] = child_obj.value return output + def collect_dynamic_schema_entities(self): + output = DynamicSchemaValueCollector(self.schema_hub) + if self._override_state is not OverrideState.DEFAULTS: + return output + + for child_obj in self.non_gui_children.values(): + child_obj.collect_dynamic_schema_entities(output) + + return output + def settings_value(self): """Value for current override state with metadata. @@ -276,6 +290,8 @@ class RootEntity(BaseItemEntity): if self._override_state is not OverrideState.DEFAULTS: output = {} for key, child_obj in self.non_gui_children.items(): + if child_obj.is_dynamic_schema_node: + continue value = child_obj.settings_value() if value is not NOT_SET: output[key] = value @@ -374,6 +390,7 @@ class RootEntity(BaseItemEntity): if self._override_state is OverrideState.DEFAULTS: self._save_default_values() + reset_default_settings() elif self._override_state is OverrideState.STUDIO: self._save_studio_values() @@ -421,6 +438,9 @@ class RootEntity(BaseItemEntity): with open(output_path, "w") as file_stream: json.dump(value, file_stream, indent=4) + dynamic_values_item = self.collect_dynamic_schema_entities() + dynamic_values_item.save_values() + @abstractmethod def _save_studio_values(self): """Save studio override values.""" @@ -476,7 +496,7 @@ class SystemSettings(RootEntity): ): if schema_hub is None: # Load system schemas - schema_hub = SchemasHub("system_schema") + schema_hub = SchemasHub(SCHEMA_KEY_SYSTEM_SETTINGS) super(SystemSettings, self).__init__(schema_hub, reset) @@ -607,7 +627,7 @@ class ProjectSettings(RootEntity): if schema_hub is None: # Load system schemas - schema_hub = SchemasHub("projects_schema") + schema_hub = SchemasHub(SCHEMA_KEY_PROJECT_SETTINGS) super(ProjectSettings, self).__init__(schema_hub, reset) diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 05605f8ce1..c8432f0f2e 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -112,6 +112,22 @@ ``` - It is possible to define default values for unfilled fields to do so one of items in list must be dictionary with key `"__default_values__"` and value as dictionary with default key: values (as in example above). +### dynamic_schema +- dynamic templates that can be defined by class of `ModuleSettingsDef` +- example: +``` +{ + "type": "dynamic_schema", + "name": "project_settings/global" +} +``` +- all valid `ModuleSettingsDef` classes where calling of `get_settings_schemas` + will return dictionary where is key "project_settings/global" with schemas + will extend and replace this item +- works almost the same way as templates + - one item can be replaced by multiple items (or by 0 items) +- goal is to dynamically loaded settings of OpenPype addons without having + their schemas or default values in main repository ## Basic Dictionary inputs - these inputs wraps another inputs into {key: value} relation @@ -300,6 +316,8 @@ How output of the schema could look like on save: - key `"decimal"` defines how many decimal places will be used, 0 is for integer input (Default: `0`) - key `"minimum"` as minimum allowed number to enter (Default: `-99999`) - key `"maxium"` as maximum allowed number to enter (Default: `99999`) +- key `"steps"` will change single step value of UI inputs (using arrows and wheel scroll) +- for UI it is possible to show slider to enable this option set `show_slider` to `true` ``` { "type": "number", @@ -311,6 +329,18 @@ How output of the schema could look like on save: } ``` +``` +{ + "type": "number", + "key": "ratio", + "label": "Ratio" + "decimal": 3, + "minimum": 0, + "maximum": 1, + "show_slider": true +} +``` + ### text - simple text input - key `"multiline"` allows to enter multiple lines of text (Default: `False`) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 575cfc9e72..c9eca5dedd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -125,6 +125,10 @@ { "type": "schema", "name": "schema_project_unreal" + }, + { + "type": "dynamic_schema", + "name": "project_settings/global" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 1cc08b96f8..e50e269695 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -650,6 +650,11 @@ "type": "list", "object_type": "text" }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 170de7c8a2..9ca4e443bd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -52,18 +52,23 @@ "type": "list", "object_type": "text" }, - { - "key": "tasks", - "label": "Task names", - "type": "list", - "object_type": "text" - }, { "type": "hosts-enum", "key": "hosts", "label": "Host names", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "tasks", + "label": "Task names", + "type": "list", + "object_type": "text" + }, { "type": "separator" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index cb2cc9c9d1..3211babd43 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -5,64 +5,134 @@ "collapsible": true, "checkbox_key": "enabled", "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "dict", - "key": "config", - "label": "Config", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "retry_cnt", - "label": "Retry Count" - }, - { - "type": "text", - "key": "loop_delay", - "label": "Loop Delay" - }, - { - "type": "text", - "key": "active_site", - "label": "Active Site" - }, - { - "type": "text", - "key": "remote_site", - "label": "Remote Site" - } - ] - }, { - "type": "dict-modifiable", - "collapsible": true, - "key": "sites", - "label": "Sites", - "collapsible_key": false, - "object_type": + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { "type": "dict", + "key": "config", + "label": "Config", + "collapsible": true, "children": [ - { - "type": "path", - "key": "credentials_url", - "label": "Credentials url", - "multiplatform": true - }, - { - "type": "dict-modifiable", - "key": "root", - "label": "Roots", - "collapsable": false, - "collapsable_key": false, - "object_type": "text" - } + { + "type": "text", + "key": "retry_cnt", + "label": "Retry Count" + }, + { + "type": "text", + "key": "loop_delay", + "label": "Loop Delay" + }, + { + "type": "text", + "key": "active_site", + "label": "Active Site" + }, + { + "type": "text", + "key": "remote_site", + "label": "Remote Site" + } ] + }, + { + "type": "dict-modifiable", + "collapsible": true, + "key": "sites", + "label": "Sites", + "collapsible_key": false, + "object_type": { + "type": "dict", + "children": [ + { + "type": "dict", + "key": "gdrive", + "label": "Google Drive", + "collapsible": true, + "children": [ + { + "type": "path", + "key": "credentials_url", + "label": "Credentials url", + "multiplatform": true + } + ] + }, + { + "type": "dict", + "key": "dropbox", + "label": "Dropbox", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "token", + "label": "Access Token" + }, + { + "type": "text", + "key": "team_folder_name", + "label": "Team Folder Name" + }, + { + "type": "text", + "key": "acting_as_member", + "label": "Acting As Member" + } + ] + }, + { + "type": "dict", + "key": "sftp", + "label": "SFTP", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "sftp_host", + "label": "SFTP host" + }, + { + "type": "number", + "key": "sftp_port", + "label": "SFTP port" + }, + { + "type": "text", + "key": "sftp_user", + "label": "SFTP user" + }, + { + "type": "text", + "key": "sftp_pass", + "label": "SFTP pass" + }, + { + "type": "path", + "key": "sftp_key", + "label": "SFTP user ssh key", + "multiplatform": true + }, + { + "type": "text", + "key": "sftp_key_pass", + "label": "SFTP user ssh key password" + } + ] + }, + { + "type": "dict-modifiable", + "key": "root", + "label": "Roots", + "collapsable": false, + "collapsable_key": false, + "object_type": "text" + } + ] + } } - } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index 368141813f..8286ed1193 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -5,6 +5,11 @@ "label": "TVPaint", "is_file": true, "children": [ + { + "type": "boolean", + "key": "stop_timer_on_application_exit", + "label": "Stop timer on application exit" + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json index 7391108a02..a2a566da0e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json @@ -69,6 +69,11 @@ "type": "tools-enum", "key": "tools_env", "label": "Tools" + }, + { + "type": "boolean", + "key": "active", + "label": "Active Project" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 4b91072eb6..c50f383f02 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -24,13 +24,22 @@ } ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateVersion", + "label": "Validate Version" + } + ] + }, { "type": "dict", - "collapsible": true, - "checkbox_key": "enabled", - "key": "ValidateVersion", - "label": "Validate Version", + "label": "Validate Intent", + "key": "ValidateIntent", "is_group": true, + "checkbox_key": "enabled", "children": [ { "type": "boolean", @@ -38,9 +47,43 @@ "label": "Enabled" }, { - "type": "boolean", - "key": "optional", - "label": "Optional" + "type": "label", + "label": "Validate if Publishing intent was selected. It is possible to disable validation for specific publishing context with profiles." + }, + { + "type": "list", + "collapsible": true, + "key": "profiles", + "object_type": { + "type": "dict", + "children": [ + { + "key": "hosts", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "tasks", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "validate", + "label": "Validate", + "type": "boolean" + } + ] + } } ] }, @@ -502,6 +545,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", @@ -543,6 +591,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 245560f115..26d3771d8a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -40,6 +40,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", @@ -126,9 +131,14 @@ "unreal" ] }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -161,9 +171,15 @@ "nuke" ] }, + { + "key": "task_types", + "label": "Task types", + "type": "list", + "object_type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -190,6 +206,48 @@ } } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "loader", + "label": "Loader", + "children": [ + { + "type": "list", + "key": "family_filter_profiles", + "label": "Family filtering", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "type": "task-types-enum", + "key": "task_types", + "label": "Task types" + }, + { + "type": "splitter" + }, + { + "type": "template", + "name": "template_publish_families", + "template_data": { + "key": "filter_families", + "label": "Filter families", + "multiselection": true + } + } + ] + } + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 0b09d08700..7c87644817 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -47,9 +47,14 @@ }, { "type": "color", - "label": "Maya Scene:", + "label": "Maya Ascii:", "key": "mayaAscii" }, + { + "type": "color", + "label": "Maya Scene:", + "key": "mayaScene" + }, { "type": "color", "label": "Set Dress:", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 89cd30aed0..26ebfb2bd7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -28,6 +28,16 @@ "type": "label", "label": "Validators" }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateInstanceInContext", + "label": "Validate Instance In Context" + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", @@ -82,6 +92,32 @@ ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateLoadedPlugin", + "label": "Validate Loaded Plugin", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "whitelist_native_plugins", + "label": "Whitelist Maya Native Plugins" + }, + { + "type": "list", + "key": "authorized_plugins", + "label": "Authorized plugins", + "object_type": "text" + } + ] + }, + { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 782179cfd1..74b2592d29 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -16,9 +16,30 @@ "is_group": true, "children": [ { - "type": "boolean", - "key": "sync_workfile_version", - "label": "Sync Version from workfile" + "type": "enum", + "key": "sync_workfile_version_on_families", + "label": "Sync workfile version for families", + "multiselection": true, + "enum_items": [ + { + "nukenodes": "nukenodes" + }, + { + "camera": "camera" + }, + { + "gizmo": "gizmo" + }, + { + "source": "source" + }, + { + "prerender": "prerender" + }, + { + "render": "render" + } + ] } ] }, @@ -29,6 +50,16 @@ "type": "label", "label": "Validators" }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateInstanceInContext", + "label": "Validate Instance In Context" + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", @@ -152,6 +183,38 @@ "label": "Viewer LUT raw" } ] + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Integrators" + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "IncrementScriptVersion", + "label": "IncrementScriptVersion", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json index 078bb81bba..2a3f0ae136 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json @@ -11,9 +11,14 @@ "object_type": { "type": "dict", "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -94,4 +99,4 @@ } } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json new file mode 100644 index 0000000000..9db1427562 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json @@ -0,0 +1,32 @@ +[ + { + "__default_values__": { + "multiselection": true + } + }, + { + "key": "{key}", + "label": "{label}", + "multiselection": "{multiselection}", + "type": "enum", + "enum_items": [ + {"action": "action"}, + {"animation": "animation"}, + {"audio": "audio"}, + {"camera": "camera"}, + {"editorial": "editorial"}, + {"layout": "layout"}, + {"look": "look"}, + {"mayaAscii": "mayaAscii"}, + {"model": "model"}, + {"pointcache": "pointcache"}, + {"reference": "reference"}, + {"render": "render"}, + {"review": "review"}, + {"rig": "rig"}, + {"setdress": "setdress"}, + {"workfile": "workfile"}, + {"xgen": "xgen"} + ] + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json index 815df85879..90fc4fbdd0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json @@ -55,9 +55,14 @@ "object_type": { "type": "dict", "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index f633d5cb1a..af6a2d49f4 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -183,6 +183,15 @@ "minimum": -10, "maximum": -5 }, + { + "type": "number", + "key": "number_with_slider", + "label": "Number with slider", + "decimal": 2, + "minimum": 0.0, + "maximum": 1.0, + "show_slider": true + }, { "type": "text", "key": "singleline_text", diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index fe5a8d8203..31cd997d14 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -40,6 +40,75 @@ { "type": "splitter" }, + { + "type": "dict", + "key": "disk_mapping", + "label": "Disk mapping", + "use_label_wrap": false, + "collapsible": false, + "children": [ + { + "key": "windows", + "label": "Windows", + "type": "list", + "object_type": { + "type": "list-strict", + "key": "item", + "object_types": [ + { + "label": "Source", + "type": "path" + }, + { + "label": "Destination", + "type": "path" + } + ] + } + }, + { + "key": "linux", + "label": "Linux", + "type": "list", + "object_type": { + "type": "list-strict", + "key": "item", + "object_types": [ + { + "label": "Source", + "type": "path" + }, + { + "label": "Destination", + "type": "path" + } + ] + } + }, + { + "key": "darwin", + "label": "MacOS", + "type": "list", + "object_type": { + "type": "list-strict", + "key": "item", + "object_types": [ + { + "label": "Source", + "type": "path" + }, + { + "label": "Destination", + "type": "path" + } + ] + } + } + ] + }, + { + "type": "splitter" + }, { "type": "path", "key": "openpype_path", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index dd85f9351a..a2b31772e9 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -5,6 +5,18 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "path", + "key": "addon_paths", + "label": "OpenPype AddOn Paths", + "use_label_wrap": true, + "multiplatform": true, + "multipath": true, + "require_restart": true + }, + { + "type": "separator" + }, { "type": "dict", "key": "avalon", @@ -16,7 +28,8 @@ "type": "number", "key": "AVALON_TIMEOUT", "minimum": 0, - "label": "Avalon Mongo Timeout (ms)" + "label": "Avalon Mongo Timeout (ms)", + "steps": 100 }, { "type": "path", @@ -109,14 +122,7 @@ "collapsible_key": false, "object_type": { - "type": "dict", - "children": [ - { - "type": "providers-enum", - "key": "provider", - "label": "Provider" - } - ] + "type": "sync-server-providers" } } ] @@ -230,6 +236,10 @@ "label": "Enabled" } ] + }, + { + "type": "dynamic_schema", + "name": "system_settings/modules" } ] } diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index 288fc76801..c59e2bc542 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -168,7 +168,7 @@ class CacheValues: class MongoSettingsHandler(SettingsHandler): """Settings handler that use mongo for storing and loading of settings.""" - global_general_keys = ("openpype_path", "admin_password") + global_general_keys = ("openpype_path", "admin_password", "disk_mapping") def __init__(self): # Get mongo connection diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 4a363910b8..60ed54bd4a 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -329,6 +329,45 @@ def reset_default_settings(): _DEFAULT_SETTINGS = None +def _get_default_settings(): + from openpype.modules import get_module_settings_defs + + defaults = load_openpype_default_settings() + + module_settings_defs = get_module_settings_defs() + for module_settings_def_cls in module_settings_defs: + module_settings_def = module_settings_def_cls() + system_defaults = module_settings_def.get_defaults( + SYSTEM_SETTINGS_KEY + ) or {} + for path, value in system_defaults.items(): + if not path: + continue + + subdict = defaults["system_settings"] + path_items = list(path.split("/")) + last_key = path_items.pop(-1) + for key in path_items: + subdict = subdict[key] + subdict[last_key] = value + + project_defaults = module_settings_def.get_defaults( + PROJECT_SETTINGS_KEY + ) or {} + for path, value in project_defaults.items(): + if not path: + continue + + subdict = defaults + path_items = list(path.split("/")) + last_key = path_items.pop(-1) + for key in path_items: + subdict = subdict[key] + subdict[last_key] = value + + return defaults + + def get_default_settings(): """Get default settings. @@ -338,12 +377,10 @@ def get_default_settings(): Returns: dict: Loaded default settings. """ - # TODO add cacher - return load_openpype_default_settings() - # global _DEFAULT_SETTINGS - # if _DEFAULT_SETTINGS is None: - # _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR) - # return copy.deepcopy(_DEFAULT_SETTINGS) + global _DEFAULT_SETTINGS + if _DEFAULT_SETTINGS is None: + _DEFAULT_SETTINGS = _get_default_settings() + return copy.deepcopy(_DEFAULT_SETTINGS) def load_json_file(fpath): @@ -380,8 +417,8 @@ def load_jsons_from_dir(path, *args, **kwargs): "data1": "CONTENT OF FILE" }, "folder2": { - "data1": { - "subfolder1": "CONTENT OF FILE" + "subfolder1": { + "data2": "CONTENT OF FILE" } } } diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index 87547b1a90..0d7904d133 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -91,4 +91,4 @@ def load_stylesheet(): def app_icon_path(): - return resources.pype_icon_filepath() + return resources.get_openpype_icon_filepath() diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 14c6aff4ad..4d86970f9c 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -84,7 +84,7 @@ class ApplicationAction(api.Action): def _show_message_box(self, title, message, details=None): dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) dialog.setWindowIcon(icon) dialog.setStyleSheet(style.load_stylesheet()) dialog.setWindowTitle(title) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 4988829c11..f87871409e 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -326,8 +326,6 @@ class ProjectModel(QtGui.QStandardItemModel): super(ProjectModel, self).__init__(parent=parent) self.dbcon = dbcon - - self.hide_invisible = False self.project_icon = qtawesome.icon("fa.map", color="white") self._project_names = set() @@ -380,16 +378,5 @@ class ProjectModel(QtGui.QStandardItemModel): self.invisibleRootItem().insertRows(row, items) def get_projects(self): - project_docs = [] - - for project_doc in sorted( - self.dbcon.projects(), key=lambda x: x["name"] - ): - if ( - self.hide_invisible - and not project_doc["data"].get("visible", True) - ): - continue - project_docs.append(project_doc) - - return project_docs + return sorted(self.dbcon.projects(only_active=True), + key=lambda x: x["name"]) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index bd37a9b89c..9b839fb2bc 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -261,7 +261,7 @@ class LauncherWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setStyleSheet(style.load_stylesheet()) @@ -271,7 +271,6 @@ class LauncherWindow(QtWidgets.QDialog): ) project_model = ProjectModel(self.dbcon) - project_model.hide_invisible = True project_handler = ProjectHandler(self.dbcon, project_model) project_panel = ProjectsPanel(project_handler) diff --git a/openpype/tools/libraryloader/__init__.py b/openpype/tools/libraryloader/__init__.py new file mode 100644 index 0000000000..bbf4a1087d --- /dev/null +++ b/openpype/tools/libraryloader/__init__.py @@ -0,0 +1,11 @@ +from .app import ( + LibraryLoaderWindow, + show, + cli +) + +__all__ = [ + "LibraryLoaderWindow", + "show", + "cli", +] diff --git a/openpype/tools/libraryloader/__main__.py b/openpype/tools/libraryloader/__main__.py new file mode 100644 index 0000000000..d77bc585c5 --- /dev/null +++ b/openpype/tools/libraryloader/__main__.py @@ -0,0 +1,5 @@ +from . import cli + +if __name__ == '__main__': + import sys + sys.exit(cli(sys.argv[1:])) diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py new file mode 100644 index 0000000000..8080c547c9 --- /dev/null +++ b/openpype/tools/libraryloader/app.py @@ -0,0 +1,589 @@ +import sys + +from Qt import QtWidgets, QtCore, QtGui + +from avalon import style +from avalon.api import AvalonMongoDB +from openpype.tools.utils import lib as tools_lib +from openpype.tools.loader.widgets import ( + ThumbnailWidget, + VersionWidget, + FamilyListView, + RepresentationWidget +) +from openpype.tools.utils.widgets import AssetWidget + +from openpype.modules import ModulesManager + +from . import lib +from .widgets import LibrarySubsetWidget + +module = sys.modules[__name__] +module.window = None + + +class LibraryLoaderWindow(QtWidgets.QDialog): + """Asset library loader interface""" + + tool_title = "Library Loader 0.5" + tool_name = "library_loader" + + def __init__( + self, parent=None, icon=None, show_projects=False, show_libraries=True + ): + super(LibraryLoaderWindow, self).__init__(parent) + + self._initial_refresh = False + self._ignore_project_change = False + + # Enable minimize and maximize for app + self.setWindowTitle(self.tool_title) + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + if icon is not None: + self.setWindowIcon(icon) + # self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + body = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + footer.setFixedHeight(20) + + container = QtWidgets.QWidget() + + self.dbcon = AvalonMongoDB() + self.dbcon.install() + self.dbcon.Session["AVALON_PROJECT"] = None + + self.show_projects = show_projects + self.show_libraries = show_libraries + + # Groups config + self.groups_config = tools_lib.GroupsConfig(self.dbcon) + self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon) + + assets = AssetWidget( + self.dbcon, multiselection=True, parent=self + ) + families = FamilyListView( + self.dbcon, self.family_config_cache, parent=self + ) + subsets = LibrarySubsetWidget( + self.dbcon, + self.groups_config, + self.family_config_cache, + tool_name=self.tool_name, + parent=self + ) + + version = VersionWidget(self.dbcon) + thumbnail = ThumbnailWidget(self.dbcon) + + # Project + self.combo_projects = QtWidgets.QComboBox() + + # Create splitter to show / hide family filters + asset_filter_splitter = QtWidgets.QSplitter() + asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) + asset_filter_splitter.addWidget(self.combo_projects) + asset_filter_splitter.addWidget(assets) + asset_filter_splitter.addWidget(families) + asset_filter_splitter.setStretchFactor(1, 65) + asset_filter_splitter.setStretchFactor(2, 35) + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + representations = RepresentationWidget(self.dbcon) + thumb_ver_splitter = QtWidgets.QSplitter() + thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) + thumb_ver_splitter.addWidget(thumbnail) + thumb_ver_splitter.addWidget(version) + if sync_server.enabled: + thumb_ver_splitter.addWidget(representations) + thumb_ver_splitter.setStretchFactor(0, 30) + thumb_ver_splitter.setStretchFactor(1, 35) + + container_layout = QtWidgets.QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + split = QtWidgets.QSplitter() + split.addWidget(asset_filter_splitter) + split.addWidget(subsets) + split.addWidget(thumb_ver_splitter) + split.setSizes([180, 950, 200]) + container_layout.addWidget(split) + + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) + + message = QtWidgets.QLabel() + message.hide() + + footer_layout = QtWidgets.QVBoxLayout(footer) + footer_layout.addWidget(message) + footer_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.data = { + "widgets": { + "families": families, + "assets": assets, + "subsets": subsets, + "version": version, + "thumbnail": thumbnail, + "representations": representations + }, + "label": { + "message": message, + }, + "state": { + "assetIds": None + } + } + + families.active_changed.connect(subsets.set_family_filters) + assets.selection_changed.connect(self.on_assetschanged) + assets.refresh_triggered.connect(self.on_assetschanged) + assets.view.clicked.connect(self.on_assetview_click) + subsets.active_changed.connect(self.on_subsetschanged) + subsets.version_changed.connect(self.on_versionschanged) + subsets.refreshed.connect(self._on_subset_refresh) + self.combo_projects.currentTextChanged.connect(self.on_project_change) + + self.sync_server = sync_server + + # Set default thumbnail on start + thumbnail.set_thumbnail(None) + + # Defaults + if sync_server.enabled: + split.setSizes([250, 1000, 550]) + self.resize(1800, 900) + else: + split.setSizes([250, 850, 200]) + self.resize(1300, 700) + + def showEvent(self, event): + super(LibraryLoaderWindow, self).showEvent(event) + if not self._initial_refresh: + self.refresh() + + def on_assetview_click(self, *args): + subsets_widget = self.data["widgets"]["subsets"] + selection_model = subsets_widget.view.selectionModel() + if selection_model.selectedIndexes(): + selection_model.clearSelection() + + def _set_projects(self): + # Store current project + old_project_name = self.current_project + + self._ignore_project_change = True + + # Cleanup + self.combo_projects.clear() + + # Fill combobox with projects + select_project_item = QtGui.QStandardItem("< Select project >") + select_project_item.setData(None, QtCore.Qt.UserRole + 1) + + combobox_items = [select_project_item] + + project_names = self.get_filtered_projects() + + for project_name in sorted(project_names): + item = QtGui.QStandardItem(project_name) + item.setData(project_name, QtCore.Qt.UserRole + 1) + combobox_items.append(item) + + root_item = self.combo_projects.model().invisibleRootItem() + root_item.appendRows(combobox_items) + + index = 0 + self._ignore_project_change = False + + if old_project_name: + index = self.combo_projects.findText( + old_project_name, QtCore.Qt.MatchFixedString + ) + + self.combo_projects.setCurrentIndex(index) + + def get_filtered_projects(self): + projects = list() + for project in self.dbcon.projects(): + is_library = project.get("data", {}).get("library_project", False) + if ( + (is_library and self.show_libraries) or + (not is_library and self.show_projects) + ): + projects.append(project["name"]) + + return projects + + def on_project_change(self): + if self._ignore_project_change: + return + + row = self.combo_projects.currentIndex() + index = self.combo_projects.model().index(row, 0) + project_name = index.data(QtCore.Qt.UserRole + 1) + + self.dbcon.Session["AVALON_PROJECT"] = project_name + + _config = lib.find_config() + if hasattr(_config, "install"): + _config.install() + else: + print( + "Config `%s` has no function `install`" % _config.__name__ + ) + + subsets = self.data["widgets"]["subsets"] + representations = self.data["widgets"]["representations"] + + subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + + self.family_config_cache.refresh() + self.groups_config.refresh() + + self._refresh_assets() + self._assetschanged() + + project_name = self.dbcon.active_project() or "No project selected" + title = "{} - {}".format(self.tool_title, project_name) + self.setWindowTitle(title) + + @property + def current_project(self): + if ( + not self.dbcon.active_project() or + self.dbcon.active_project() == "" + ): + return None + + return self.dbcon.active_project() + + # ------------------------------- + # Delay calling blocking methods + # ------------------------------- + + def refresh(self): + self.echo("Fetching results..") + tools_lib.schedule(self._refresh, 50, channel="mongo") + + def on_assetschanged(self, *args): + self.echo("Fetching asset..") + tools_lib.schedule(self._assetschanged, 50, channel="mongo") + + def on_subsetschanged(self, *args): + self.echo("Fetching subset..") + tools_lib.schedule(self._subsetschanged, 50, channel="mongo") + + def on_versionschanged(self, *args): + self.echo("Fetching version..") + tools_lib.schedule(self._versionschanged, 150, channel="mongo") + + def _on_subset_refresh(self, has_item): + subsets_widget = self.data["widgets"]["subsets"] + families_view = self.data["widgets"]["families"] + + subsets_widget.set_loading_state(loading=False, empty=not has_item) + families = subsets_widget.get_subsets_families() + families_view.set_enabled_families(families) + + def set_context(self, context, refresh=True): + self.echo("Setting context: {}".format(context)) + lib.schedule( + lambda: self._set_context(context, refresh=refresh), + 50, channel="mongo" + ) + + # ------------------------------ + def _refresh(self): + if not self._initial_refresh: + self._initial_refresh = True + self._set_projects() + + def _refresh_assets(self): + """Load assets from database""" + if self.current_project is not None: + # Ensure a project is loaded + project_doc = self.dbcon.find_one( + {"type": "project"}, + {"type": 1} + ) + assert project_doc, "This is a bug" + + assets_widget = self.data["widgets"]["assets"] + families_view = self.data["widgets"]["families"] + families_view.set_enabled_families(set()) + families_view.refresh() + + assets_widget.model.stop_fetch_thread() + assets_widget.refresh() + assets_widget.setFocus() + + def clear_assets_underlines(self): + last_asset_ids = self.data["state"]["assetIds"] + if not last_asset_ids: + return + + assets_widget = self.data["widgets"]["assets"] + id_role = assets_widget.model.ObjectIdRole + + for index in tools_lib.iter_model_rows(assets_widget.model, 0): + if index.data(id_role) not in last_asset_ids: + continue + + assets_widget.model.setData( + index, [], assets_widget.model.subsetColorsRole + ) + + def _assetschanged(self): + """Selected assets have changed""" + assets_widget = self.data["widgets"]["assets"] + subsets_widget = self.data["widgets"]["subsets"] + subsets_model = subsets_widget.model + + subsets_model.clear() + self.clear_assets_underlines() + + if not self.dbcon.Session.get("AVALON_PROJECT"): + subsets_widget.set_loading_state( + loading=False, + empty=True + ) + return + + # filter None docs they are silo + asset_docs = assets_widget.get_selected_assets() + if len(asset_docs) == 0: + return + + asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] + # Start loading + subsets_widget.set_loading_state( + loading=bool(asset_ids), + empty=True + ) + + subsets_model.set_assets(asset_ids) + subsets_widget.view.setColumnHidden( + subsets_model.Columns.index("asset"), + len(asset_ids) < 2 + ) + + # Clear the version information on asset change + self.data["widgets"]["version"].set_version(None) + self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + + self.data["state"]["assetIds"] = asset_ids + + representations = self.data["widgets"]["representations"] + # reset repre list + representations.set_version_ids([]) + + def _subsetschanged(self): + asset_ids = self.data["state"]["assetIds"] + # Skip setting colors if not asset multiselection + if not asset_ids or len(asset_ids) < 2: + self._versionschanged() + return + + subsets = self.data["widgets"]["subsets"] + selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + + asset_models = {} + asset_ids = [] + for subset_node in selected_subsets: + asset_ids.extend(subset_node.get("assetIds", [])) + asset_ids = set(asset_ids) + + for subset_node in selected_subsets: + for asset_id in asset_ids: + if asset_id not in asset_models: + asset_models[asset_id] = [] + + color = None + if asset_id in subset_node.get("assetIds", []): + color = subset_node["subsetColor"] + + asset_models[asset_id].append(color) + + self.clear_assets_underlines() + + assets_widget = self.data["widgets"]["assets"] + indexes = assets_widget.view.selectionModel().selectedRows() + + for index in indexes: + id = index.data(assets_widget.model.ObjectIdRole) + if id not in asset_models: + continue + + assets_widget.model.setData( + index, asset_models[id], assets_widget.model.subsetColorsRole + ) + # Trigger repaint + assets_widget.view.updateGeometries() + # Set version in Version Widget + self._versionschanged() + + def _versionschanged(self): + + subsets = self.data["widgets"]["subsets"] + selection = subsets.view.selectionModel() + + # Active must be in the selected rows otherwise we + # assume it's not actually an "active" current index. + version_docs = None + version_doc = None + active = selection.currentIndex() + rows = selection.selectedRows(column=active.column()) + if active and active in rows: + item = active.data(subsets.model.ItemRole) + if ( + item is not None + and not (item.get("isGroup") or item.get("isMerged")) + ): + version_doc = item["version_document"] + + if rows: + version_docs = [] + for index in rows: + if not index or not index.isValid(): + continue + item = index.data(subsets.model.ItemRole) + if ( + item is None + or item.get("isGroup") + or item.get("isMerged") + ): + continue + version_docs.append(item["version_document"]) + + self.data["widgets"]["version"].set_version(version_doc) + + thumbnail_docs = version_docs + if not thumbnail_docs: + assets_widget = self.data["widgets"]["assets"] + asset_docs = assets_widget.get_selected_assets() + if len(asset_docs) > 0: + thumbnail_docs = asset_docs + + self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + + representations = self.data["widgets"]["representations"] + version_ids = [doc["_id"] for doc in version_docs or []] + representations.set_version_ids(version_ids) + + def _set_context(self, context, refresh=True): + """Set the selection in the interface using a context. + The context must contain `asset` data by name. + Note: Prior to setting context ensure `refresh` is triggered so that + the "silos" are listed correctly, aside from that setting the + context will force a refresh further down because it changes + the active silo and asset. + Args: + context (dict): The context to apply. + Returns: + None + """ + + asset = context.get("asset", None) + if asset is None: + return + + if refresh: + # Workaround: + # Force a direct (non-scheduled) refresh prior to setting the + # asset widget's silo and asset selection to ensure it's correctly + # displaying the silo tabs. Calling `window.refresh()` and directly + # `window.set_context()` the `set_context()` seems to override the + # scheduled refresh and the silo tabs are not shown. + self._refresh_assets() + + asset_widget = self.data["widgets"]["assets"] + asset_widget.select_assets(asset) + + def echo(self, message): + widget = self.data["label"]["message"] + widget.setText(str(message)) + widget.show() + print(message) + + tools_lib.schedule(widget.hide, 5000, channel="message") + + def closeEvent(self, event): + # Kill on holding SHIFT + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + shift_pressed = QtCore.Qt.ShiftModifier & modifiers + + if shift_pressed: + print("Force quitted..") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + print("Good bye") + return super(LibraryLoaderWindow, self).closeEvent(event) + + +def show( + debug=False, parent=None, icon=None, + show_projects=False, show_libraries=True +): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): The Qt object to parent to. + use_context (bool): Whether to apply the current context upon launch + + """ + # Remember window + if module.window is not None: + try: + module.window.show() + + # If the window is minimized then unminimize it. + if module.window.windowState() & QtCore.Qt.WindowMinimized: + module.window.setWindowState(QtCore.Qt.WindowActive) + + # Raise and activate the window + module.window.raise_() # for MacOS + module.window.activateWindow() # for Windows + module.window.refresh() + return + except RuntimeError as e: + if not e.message.rstrip().endswith("already deleted."): + raise + + # Garbage collected + module.window = None + + if debug: + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + with tools_lib.application(): + window = LibraryLoaderWindow( + parent, icon, show_projects, show_libraries + ) + window.setStyleSheet(style.load_stylesheet()) + window.show() + + module.window = window + + +def cli(args): + + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("project") + + show(show_projects=True, show_libraries=True) diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py new file mode 100644 index 0000000000..6a497a6a16 --- /dev/null +++ b/openpype/tools/libraryloader/lib.py @@ -0,0 +1,33 @@ +import os +import importlib +import logging +from openpype.api import Anatomy + +log = logging.getLogger(__name__) + + +# `find_config` from `pipeline` +def find_config(): + log.info("Finding configuration for project..") + + config = os.environ["AVALON_CONFIG"] + + if not config: + raise EnvironmentError( + "No configuration found in " + "the project nor environment" + ) + + log.info("Found %s, loading.." % config) + return importlib.import_module(config) + + +class RegisteredRoots: + roots_per_project = {} + + @classmethod + def registered_root(cls, project_name): + if project_name not in cls.roots_per_project: + cls.roots_per_project[project_name] = Anatomy(project_name).roots + + return cls.roots_per_project[project_name] diff --git a/openpype/tools/libraryloader/widgets.py b/openpype/tools/libraryloader/widgets.py new file mode 100644 index 0000000000..45f9ea2048 --- /dev/null +++ b/openpype/tools/libraryloader/widgets.py @@ -0,0 +1,18 @@ +from Qt import QtWidgets + +from .lib import RegisteredRoots +from openpype.tools.loader.widgets import SubsetWidget + + +class LibrarySubsetWidget(SubsetWidget): + def on_copy_source(self): + """Copy formatted source path to clipboard""" + source = self.data.get("source", None) + if not source: + return + + project_name = self.dbcon.Session["AVALON_PROJECT"] + root = RegisteredRoots.registered_root(project_name) + path = source.format(root=root) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(path) diff --git a/openpype/tools/loader/__init__.py b/openpype/tools/loader/__init__.py new file mode 100644 index 0000000000..a5fda8f018 --- /dev/null +++ b/openpype/tools/loader/__init__.py @@ -0,0 +1,11 @@ +from .app import ( + LoaderWindow, + show, + cli, +) + +__all__ = ( + "LoaderWindow", + "show", + "cli", +) diff --git a/openpype/tools/loader/__main__.py b/openpype/tools/loader/__main__.py new file mode 100644 index 0000000000..146ba7fd10 --- /dev/null +++ b/openpype/tools/loader/__main__.py @@ -0,0 +1,33 @@ +"""Main entrypoint for standalone debugging + + Used for running 'avalon.tool.loader.__main__' as a module (-m), useful for + debugging without need to start host. + + Modify AVALON_MONGO accordingly +""" +import os +import sys +from . import cli + + +def my_exception_hook(exctype, value, traceback): + # Print the error and traceback + print(exctype, value, traceback) + # Call the normal Exception hook after + sys._excepthook(exctype, value, traceback) + sys.exit(1) + + +if __name__ == '__main__': + os.environ["AVALON_MONGO"] = "mongodb://localhost:27017" + os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:27017" + os.environ["AVALON_DB"] = "avalon" + os.environ["AVALON_TIMEOUT"] = "1000" + os.environ["OPENPYPE_DEBUG"] = "1" + os.environ["AVALON_CONFIG"] = "pype" + os.environ["AVALON_ASSET"] = "Jungle" + + # Set the exception hook to our wrapping function + sys.excepthook = my_exception_hook + + sys.exit(cli(sys.argv[1:])) diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py new file mode 100644 index 0000000000..c18b6e798a --- /dev/null +++ b/openpype/tools/loader/app.py @@ -0,0 +1,676 @@ +import sys + +from Qt import QtWidgets, QtCore +from avalon import api, io, style, pipeline + +from openpype.tools.utils.widgets import AssetWidget + +from openpype.tools.utils import lib + +from .widgets import ( + SubsetWidget, + VersionWidget, + FamilyListView, + ThumbnailWidget, + RepresentationWidget, + OverlayFrame +) + +from openpype.modules import ModulesManager + +module = sys.modules[__name__] +module.window = None + + +# Register callback on task change +# - callback can't be defined in Window as it is weak reference callback +# so `WeakSet` will remove it immidiatelly +def on_context_task_change(*args, **kwargs): + if module.window: + module.window.on_context_task_change(*args, **kwargs) + + +pipeline.on("taskChanged", on_context_task_change) + + +class LoaderWindow(QtWidgets.QDialog): + """Asset loader interface""" + + tool_name = "loader" + + def __init__(self, parent=None): + super(LoaderWindow, self).__init__(parent) + title = "Asset Loader 2.1" + project_name = api.Session.get("AVALON_PROJECT") + if project_name: + title += " - {}".format(project_name) + self.setWindowTitle(title) + + # Groups config + self.groups_config = lib.GroupsConfig(io) + self.family_config_cache = lib.FamilyConfigCache(io) + + # Enable minimize and maximize for app + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + body = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + footer.setFixedHeight(20) + + container = QtWidgets.QWidget() + + assets = AssetWidget(io, multiselection=True, parent=self) + assets.set_current_asset_btn_visibility(True) + + families = FamilyListView(io, self.family_config_cache, self) + subsets = SubsetWidget( + io, + self.groups_config, + self.family_config_cache, + tool_name=self.tool_name, + parent=self + ) + version = VersionWidget(io) + thumbnail = ThumbnailWidget(io) + representations = RepresentationWidget(io, self.tool_name) + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + thumb_ver_splitter = QtWidgets.QSplitter() + thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) + thumb_ver_splitter.addWidget(thumbnail) + thumb_ver_splitter.addWidget(version) + if sync_server.enabled: + thumb_ver_splitter.addWidget(representations) + thumb_ver_splitter.setStretchFactor(0, 30) + thumb_ver_splitter.setStretchFactor(1, 35) + + # Create splitter to show / hide family filters + asset_filter_splitter = QtWidgets.QSplitter() + asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) + asset_filter_splitter.addWidget(assets) + asset_filter_splitter.addWidget(families) + asset_filter_splitter.setStretchFactor(0, 65) + asset_filter_splitter.setStretchFactor(1, 35) + + container_layout = QtWidgets.QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + split = QtWidgets.QSplitter() + split.addWidget(asset_filter_splitter) + split.addWidget(subsets) + split.addWidget(thumb_ver_splitter) + + container_layout.addWidget(split) + + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) + + message = QtWidgets.QLabel() + message.hide() + + footer_layout = QtWidgets.QVBoxLayout(footer) + footer_layout.addWidget(message) + footer_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.data = { + "widgets": { + "families": families, + "assets": assets, + "subsets": subsets, + "version": version, + "thumbnail": thumbnail, + "representations": representations + }, + "label": { + "message": message, + }, + "state": { + "assetIds": None + } + } + + overlay_frame = OverlayFrame("Loading...", self) + overlay_frame.setVisible(False) + + families.active_changed.connect(subsets.set_family_filters) + assets.selection_changed.connect(self.on_assetschanged) + assets.refresh_triggered.connect(self.on_assetschanged) + assets.view.clicked.connect(self.on_assetview_click) + subsets.active_changed.connect(self.on_subsetschanged) + subsets.version_changed.connect(self.on_versionschanged) + subsets.refreshed.connect(self._on_subset_refresh) + + subsets.load_started.connect(self._on_load_start) + subsets.load_ended.connect(self._on_load_end) + representations.load_started.connect(self._on_load_start) + representations.load_ended.connect(self._on_load_end) + + self._overlay_frame = overlay_frame + + self.family_config_cache.refresh() + self.groups_config.refresh() + + self._refresh() + self._assetschanged() + + # Defaults + if sync_server.enabled: + split.setSizes([250, 1000, 550]) + self.resize(1800, 900) + else: + split.setSizes([250, 850, 200]) + self.resize(1300, 700) + + def resizeEvent(self, event): + super(LoaderWindow, self).resizeEvent(event) + self._overlay_frame.resize(self.size()) + + def moveEvent(self, event): + super(LoaderWindow, self).moveEvent(event) + self._overlay_frame.move(0, 0) + + # ------------------------------- + # Delay calling blocking methods + # ------------------------------- + + def on_assetview_click(self, *args): + subsets_widget = self.data["widgets"]["subsets"] + selection_model = subsets_widget.view.selectionModel() + if selection_model.selectedIndexes(): + selection_model.clearSelection() + + def refresh(self): + self.echo("Fetching results..") + lib.schedule(self._refresh, 50, channel="mongo") + + def on_assetschanged(self, *args): + self.echo("Fetching asset..") + lib.schedule(self._assetschanged, 50, channel="mongo") + + def on_subsetschanged(self, *args): + self.echo("Fetching subset..") + lib.schedule(self._subsetschanged, 50, channel="mongo") + + def on_versionschanged(self, *args): + self.echo("Fetching version..") + lib.schedule(self._versionschanged, 150, channel="mongo") + + def set_context(self, context, refresh=True): + self.echo("Setting context: {}".format(context)) + lib.schedule(lambda: self._set_context(context, refresh=refresh), + 50, channel="mongo") + + def _on_load_start(self): + # Show overlay and process events so it's repainted + self._overlay_frame.setVisible(True) + QtWidgets.QApplication.processEvents() + + def _hide_overlay(self): + self._overlay_frame.setVisible(False) + + def _on_subset_refresh(self, has_item): + subsets_widget = self.data["widgets"]["subsets"] + families_view = self.data["widgets"]["families"] + + subsets_widget.set_loading_state(loading=False, empty=not has_item) + families = subsets_widget.get_subsets_families() + families_view.set_enabled_families(families) + + def _on_load_end(self): + # Delay hiding as click events happened during loading should be + # blocked + QtCore.QTimer.singleShot(100, self._hide_overlay) + + # ------------------------------ + + def on_context_task_change(self, *args, **kwargs): + assets_widget = self.data["widgets"]["assets"] + families_view = self.data["widgets"]["families"] + # Refresh families config + families_view.refresh() + # Change to context asset on context change + assets_widget.select_assets(io.Session["AVALON_ASSET"]) + + def _refresh(self): + """Load assets from database""" + + # Ensure a project is loaded + project = io.find_one({"type": "project"}, {"type": 1}) + assert project, "Project was not found! This is a bug" + + assets_widget = self.data["widgets"]["assets"] + assets_widget.refresh() + assets_widget.setFocus() + + families_view = self.data["widgets"]["families"] + families_view.refresh() + + def clear_assets_underlines(self): + """Clear colors from asset data to remove colored underlines + When multiple assets are selected colored underlines mark which asset + own selected subsets. These colors must be cleared from asset data + on selection change so they match current selection. + """ + last_asset_ids = self.data["state"]["assetIds"] + if not last_asset_ids: + return + + assets_widget = self.data["widgets"]["assets"] + id_role = assets_widget.model.ObjectIdRole + + for index in lib.iter_model_rows(assets_widget.model, 0): + if index.data(id_role) not in last_asset_ids: + continue + + assets_widget.model.setData( + index, [], assets_widget.model.subsetColorsRole + ) + + def _assetschanged(self): + """Selected assets have changed""" + assets_widget = self.data["widgets"]["assets"] + subsets_widget = self.data["widgets"]["subsets"] + subsets_model = subsets_widget.model + + subsets_model.clear() + self.clear_assets_underlines() + + # filter None docs they are silo + asset_docs = assets_widget.get_selected_assets() + + asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] + # Start loading + subsets_widget.set_loading_state( + loading=bool(asset_ids), + empty=True + ) + + subsets_model.set_assets(asset_ids) + subsets_widget.view.setColumnHidden( + subsets_model.Columns.index("asset"), + len(asset_ids) < 2 + ) + + # Clear the version information on asset change + self.data["widgets"]["version"].set_version(None) + self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + + self.data["state"]["assetIds"] = asset_ids + + representations = self.data["widgets"]["representations"] + # reset repre list + representations.set_version_ids([]) + + def _subsetschanged(self): + asset_ids = self.data["state"]["assetIds"] + # Skip setting colors if not asset multiselection + if not asset_ids or len(asset_ids) < 2: + self._versionschanged() + return + + subsets = self.data["widgets"]["subsets"] + selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + + asset_models = {} + asset_ids = [] + for subset_node in selected_subsets: + asset_ids.extend(subset_node.get("assetIds", [])) + asset_ids = set(asset_ids) + + for subset_node in selected_subsets: + for asset_id in asset_ids: + if asset_id not in asset_models: + asset_models[asset_id] = [] + + color = None + if asset_id in subset_node.get("assetIds", []): + color = subset_node["subsetColor"] + + asset_models[asset_id].append(color) + + self.clear_assets_underlines() + + assets_widget = self.data["widgets"]["assets"] + indexes = assets_widget.view.selectionModel().selectedRows() + + for index in indexes: + id = index.data(assets_widget.model.ObjectIdRole) + if id not in asset_models: + continue + + assets_widget.model.setData( + index, asset_models[id], assets_widget.model.subsetColorsRole + ) + # Trigger repaint + assets_widget.view.updateGeometries() + # Set version in Version Widget + self._versionschanged() + + def _versionschanged(self): + subsets = self.data["widgets"]["subsets"] + selection = subsets.view.selectionModel() + + # Active must be in the selected rows otherwise we + # assume it's not actually an "active" current index. + version_docs = None + version_doc = None + active = selection.currentIndex() + rows = selection.selectedRows(column=active.column()) + if active: + if active in rows: + item = active.data(subsets.model.ItemRole) + if ( + item is not None and + not (item.get("isGroup") or item.get("isMerged")) + ): + version_doc = item["version_document"] + + if rows: + version_docs = [] + for index in rows: + if not index or not index.isValid(): + continue + item = index.data(subsets.model.ItemRole) + if item is None: + continue + if item.get("isGroup") or item.get("isMerged"): + for child in item.children(): + version_docs.append(child["version_document"]) + else: + version_docs.append(item["version_document"]) + + self.data["widgets"]["version"].set_version(version_doc) + + thumbnail_docs = version_docs + assets_widget = self.data["widgets"]["assets"] + asset_docs = assets_widget.get_selected_assets() + if not thumbnail_docs: + if len(asset_docs) > 0: + thumbnail_docs = asset_docs + + self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + + representations = self.data["widgets"]["representations"] + version_ids = [doc["_id"] for doc in version_docs or []] + representations.set_version_ids(version_ids) + + # representations.change_visibility("subset", len(rows) > 1) + # representations.change_visibility("asset", len(asset_docs) > 1) + + def _set_context(self, context, refresh=True): + """Set the selection in the interface using a context. + + The context must contain `asset` data by name. + + Note: Prior to setting context ensure `refresh` is triggered so that + the "silos" are listed correctly, aside from that setting the + context will force a refresh further down because it changes + the active silo and asset. + + Args: + context (dict): The context to apply. + + Returns: + None + + """ + + asset = context.get("asset", None) + if asset is None: + return + + if refresh: + # Workaround: + # Force a direct (non-scheduled) refresh prior to setting the + # asset widget's silo and asset selection to ensure it's correctly + # displaying the silo tabs. Calling `window.refresh()` and directly + # `window.set_context()` the `set_context()` seems to override the + # scheduled refresh and the silo tabs are not shown. + self._refresh() + + asset_widget = self.data["widgets"]["assets"] + asset_widget.select_assets(asset) + + def echo(self, message): + widget = self.data["label"]["message"] + widget.setText(str(message)) + widget.show() + print(message) + + lib.schedule(widget.hide, 5000, channel="message") + + def closeEvent(self, event): + # Kill on holding SHIFT + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + shift_pressed = QtCore.Qt.ShiftModifier & modifiers + + if shift_pressed: + print("Force quitted..") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + print("Good bye") + return super(LoaderWindow, self).closeEvent(event) + + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping subsets on pressing Ctrl + G + if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and + not event.isAutoRepeat()): + self.show_grouping_dialog() + return + + super(LoaderWindow, self).keyPressEvent(event) + event.setAccepted(True) # Avoid interfering other widgets + + def show_grouping_dialog(self): + subsets = self.data["widgets"]["subsets"] + if not subsets.is_groupable(): + self.echo("Grouping not enabled.") + return + + selected = [] + merged_items = [] + for item in subsets.selected_subsets(_merged=True): + if item.get("isMerged"): + merged_items.append(item) + else: + selected.append(item) + + for merged_item in merged_items: + for child_item in merged_item.children(): + selected.append(child_item) + + if not selected: + self.echo("No selected subset.") + return + + dialog = SubsetGroupingDialog( + items=selected, groups_config=self.groups_config, parent=self + ) + dialog.grouped.connect(self._assetschanged) + dialog.show() + + +class SubsetGroupingDialog(QtWidgets.QDialog): + grouped = QtCore.Signal() + + def __init__(self, items, groups_config, parent=None): + super(SubsetGroupingDialog, self).__init__(parent=parent) + self.setWindowTitle("Grouping Subsets") + self.setMinimumWidth(250) + self.setModal(True) + + self.items = items + self.groups_config = groups_config + self.subsets = parent.data["widgets"]["subsets"] + self.asset_ids = parent.data["state"]["assetIds"] + + name = QtWidgets.QLineEdit() + name.setPlaceholderText("Remain blank to ungroup..") + + # Menu for pre-defined subset groups + name_button = QtWidgets.QPushButton() + name_button.setFixedWidth(18) + name_button.setFixedHeight(20) + name_menu = QtWidgets.QMenu(name_button) + name_button.setMenu(name_menu) + + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(name) + name_layout.addWidget(name_button) + name_layout.setContentsMargins(0, 0, 0, 0) + + group_btn = QtWidgets.QPushButton("Apply") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Group Name")) + layout.addLayout(name_layout) + layout.addWidget(group_btn) + + group_btn.clicked.connect(self.on_group) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + self.name = name + self.name_menu = name_menu + + self._build_menu() + + def _build_menu(self): + menu = self.name_menu + button = menu.parent() + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + active_groups = self.groups_config.active_groups(self.asset_ids) + + # Build new action group + group = QtWidgets.QActionGroup(button) + group_names = list() + for data in sorted(active_groups, key=lambda x: x["order"]): + name = data["name"] + if name in group_names: + continue + group_names.append(name) + icon = data["icon"] + + action = group.addAction(name) + action.setIcon(icon) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + button.setEnabled(not menu.isEmpty()) + + def _on_action_clicked(self, action): + self.name.setText(action.text()) + + def on_group(self): + name = self.name.text().strip() + self.subsets.group_subsets(name, self.asset_ids, self.items) + + with lib.preserve_selection(tree_view=self.subsets.view, + current_index=False): + self.grouped.emit() + self.close() + + +def show(debug=False, parent=None, use_context=False): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): The Qt object to parent to. + use_context (bool): Whether to apply the current context upon launch + + """ + + # Remember window + if module.window is not None: + try: + module.window.show() + + # If the window is minimized then unminimize it. + if module.window.windowState() & QtCore.Qt.WindowMinimized: + module.window.setWindowState(QtCore.Qt.WindowActive) + + # Raise and activate the window + module.window.raise_() # for MacOS + module.window.activateWindow() # for Windows + module.window.refresh() + return + except (AttributeError, RuntimeError): + # Garbage collected + module.window = None + + if debug: + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + io.install() + + any_project = next( + project for project in io.projects() + if project.get("active", True) is not False + ) + + api.Session["AVALON_PROJECT"] = any_project["name"] + module.project = any_project["name"] + + with lib.application(): + window = LoaderWindow(parent) + window.setStyleSheet(style.load_stylesheet()) + window.show() + + if use_context: + context = {"asset": api.Session["AVALON_ASSET"]} + window.set_context(context, refresh=True) + else: + window.refresh() + + module.window = window + + # Pull window to the front. + module.window.raise_() + module.window.activateWindow() + + +def cli(args): + + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("project") + + args = parser.parse_args(args) + project = args.project + + print("Entering Project: %s" % project) + + io.install() + + # Store settings + api.Session["AVALON_PROJECT"] = project + + from avalon import pipeline + + # Find the set config + _config = pipeline.find_config() + if hasattr(_config, "install"): + _config.install() + else: + print("Config `%s` has no function `install`" % + _config.__name__) + + show() diff --git a/openpype/tools/loader/images/default_thumbnail.png b/openpype/tools/loader/images/default_thumbnail.png new file mode 100644 index 0000000000..97bd958e0d Binary files /dev/null and b/openpype/tools/loader/images/default_thumbnail.png differ diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py new file mode 100644 index 0000000000..14ebab6c85 --- /dev/null +++ b/openpype/tools/loader/lib.py @@ -0,0 +1,190 @@ +import inspect +from Qt import QtGui + +from avalon.vendor import qtawesome +from openpype.tools.utils.widgets import ( + OptionalAction, + OptionDialog +) + + +def change_visibility(model, view, column_name, visible): + """ + Hides or shows particular 'column_name'. + + "asset" and "subset" columns should be visible only in multiselect + """ + index = model.Columns.index(column_name) + view.setColumnHidden(index, not visible) + + +def get_selected_items(rows, item_role): + items = [] + for row_index in rows: + item = row_index.data(item_role) + if item.get("isGroup"): + continue + + elif item.get("isMerged"): + for idx in range(row_index.model().rowCount(row_index)): + child_index = row_index.child(idx, 0) + item = child_index.data(item_role) + if item not in items: + items.append(item) + + else: + if item not in items: + items.append(item) + return items + + +def get_options(action, loader, parent, repre_contexts): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + qargparse variants. + + Args: + action (OptionalAction) - action in menu + loader (cls of api.Loader) - not initilized yet + parent (Qt element to parent dialog to) + repre_contexts (list) of dict with full info about selected repres + Returns: + (dict) - selected value from OptionDialog + None when dialog was closed or cancelled, in all other cases {} + if no options + """ + # Pop option dialog + options = {} + loader_options = loader.get_options(repre_contexts) + if getattr(action, "optioned", False) and loader_options: + dialog = OptionDialog(parent) + dialog.setWindowTitle(action.label + " Options") + dialog.create(loader_options) + + if not dialog.exec_(): + return None + + # Get option + options = dialog.parse() + + return options + + +def add_representation_loaders_to_menu(loaders, menu, repre_contexts): + """ + Loops through provider loaders and adds them to 'menu'. + + Expects loaders sorted in requested order. + Expects loaders de-duplicated if wanted. + + Args: + loaders(tuple): representation - loader + menu (OptionalMenu): + repre_contexts (dict): full info about representations (contains + their repre_doc, asset_doc, subset_doc, version_doc), + keys are repre_ids + + Returns: + menu (OptionalMenu): with new items + """ + # List the available loaders + for representation, loader in loaders: + label = None + repre_context = None + if representation: + label = representation.get("custom_label") + repre_context = repre_contexts[representation["_id"]] + + if not label: + label = get_label_from_loader(loader, representation) + + icon = get_icon_from_loader(loader) + + loader_options = loader.get_options([repre_context]) + + use_option = bool(loader_options) + action = OptionalAction(label, icon, use_option, menu) + if use_option: + # Add option box tip + action.set_option_tip(loader_options) + + action.setData((representation, loader)) + + # Add tooltip and statustip from Loader docstring + tip = inspect.getdoc(loader) + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + menu.addAction(action) + + return menu + + +def remove_tool_name_from_loaders(available_loaders, tool_name): + if not tool_name: + return available_loaders + filtered_loaders = [] + for loader in available_loaders: + if hasattr(loader, "tool_names"): + if not ("*" in loader.tool_names or + tool_name in loader.tool_names): + continue + filtered_loaders.append(loader) + return filtered_loaders + + +def get_icon_from_loader(loader): + """Pull icon info from loader class""" + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + icon = qtawesome.icon(key, color=color) + except Exception as e: + print("Unable to set icon for loader " + "{}: {}".format(loader, e)) + icon = None + return icon + + +def get_label_from_loader(loader, representation=None): + """Pull label info from loader class""" + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + if representation: + # Add the representation as suffix + label = "{0} ({1})".format(label, representation['name']) + return label + + +def get_no_loader_action(menu, one_item_selected=False): + """Creates dummy no loader option in 'menu'""" + submsg = "your selection." + if one_item_selected: + submsg = "this version." + msg = "No compatible loaders for {}".format(submsg) + print(msg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + action = OptionalAction(("*" + msg), icon, False, menu) + return action + + +def sort_loaders(loaders, custom_sorter=None): + def sorter(value): + """Sort the Loaders by their order and then their name""" + Plugin = value[1] + return Plugin.order, Plugin.__name__ + + if not custom_sorter: + custom_sorter = sorter + + return sorted(loaders, key=custom_sorter) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py new file mode 100644 index 0000000000..6e9c7bf220 --- /dev/null +++ b/openpype/tools/loader/model.py @@ -0,0 +1,1195 @@ +import copy +import re +import math + +from avalon import ( + style, + schema +) +from Qt import QtCore, QtGui + +from avalon.vendor import qtawesome +from avalon.lib import HeroVersionType + +from openpype.tools.utils.models import TreeModel, Item +from openpype.tools.utils import lib + +from openpype.modules import ModulesManager + + +def is_filtering_recursible(): + """Does Qt binding support recursive filtering for QSortFilterProxyModel? + + (NOTE) Recursive filtering was introduced in Qt 5.10. + + """ + return hasattr(QtCore.QSortFilterProxyModel, + "setRecursiveFilteringEnabled") + + +class BaseRepresentationModel(object): + """Methods for SyncServer useful in multiple models""" + + def reset_sync_server(self, project_name=None): + """Sets/Resets sync server vars after every change (refresh.)""" + repre_icons = {} + sync_server = None + active_site = active_provider = None + remote_site = remote_provider = None + + if not project_name: + project_name = self.dbcon.Session["AVALON_PROJECT"] + else: + self.dbcon.Session["AVALON_PROJECT"] = project_name + + if project_name: + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + if project_name in sync_server.get_enabled_projects(): + active_site = sync_server.get_active_site(project_name) + active_provider = sync_server.get_provider_for_site( + project_name, active_site) + if active_site == 'studio': # for studio use explicit icon + active_provider = 'studio' + + remote_site = sync_server.get_remote_site(project_name) + remote_provider = sync_server.get_provider_for_site( + project_name, remote_site) + if remote_site == 'studio': # for studio use explicit icon + remote_provider = 'studio' + + repre_icons = lib.get_repre_icons() + + self.repre_icons = repre_icons + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + + +class SubsetsModel(TreeModel, BaseRepresentationModel): + doc_fetched = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + Columns = [ + "subset", + "asset", + "family", + "version", + "time", + "author", + "frames", + "duration", + "handles", + "step", + "repre_info" + ] + + column_labels_mapping = { + "subset": "Subset", + "asset": "Asset", + "family": "Family", + "version": "Version", + "time": "Time", + "author": "Author", + "frames": "Frames", + "duration": "Duration", + "handles": "Handles", + "step": "Step", + "repre_info": "Availability" + } + + SortAscendingRole = QtCore.Qt.UserRole + 2 + SortDescendingRole = QtCore.Qt.UserRole + 3 + merged_subset_colors = [ + (55, 161, 222), # Light Blue + (231, 176, 0), # Yellow + (154, 13, 255), # Purple + (130, 184, 30), # Light Green + (211, 79, 63), # Light Red + (179, 181, 182), # Grey + (194, 57, 179), # Pink + (0, 120, 215), # Dark Blue + (0, 204, 106), # Dark Green + (247, 99, 12), # Orange + ] + not_last_hero_brush = QtGui.QBrush(QtGui.QColor(254, 121, 121)) + + # Should be minimum of required asset document keys + asset_doc_projection = { + "name": 1, + "label": 1 + } + # Should be minimum of required subset document keys + subset_doc_projection = { + "name": 1, + "parent": 1, + "schema": 1, + "data.families": 1, + "data.subsetGroup": 1 + } + + def __init__( + self, + dbcon, + groups_config, + family_config_cache, + grouping=True, + parent=None, + asset_doc_projection=None, + subset_doc_projection=None + ): + super(SubsetsModel, self).__init__(parent=parent) + + self.dbcon = dbcon + + # Projections for Mongo queries + # - let ability to modify them if used in tools that require more than + # defaults + if asset_doc_projection: + self.asset_doc_projection = asset_doc_projection + + if subset_doc_projection: + self.subset_doc_projection = subset_doc_projection + + self.asset_doc_projection = asset_doc_projection + self.subset_doc_projection = subset_doc_projection + + self.repre_icons = {} + self.sync_server = None + self.active_site = self.active_provider = None + + self.columns_index = dict( + (key, idx) for idx, key in enumerate(self.Columns) + ) + self._asset_ids = None + + self.groups_config = groups_config + self.family_config_cache = family_config_cache + self._sorter = None + self._grouping = grouping + self._icons = { + "subset": qtawesome.icon("fa.file-o", color=style.colors.default) + } + + self._doc_fetching_thread = None + self._doc_fetching_stop = False + self._doc_payload = {} + + self.doc_fetched.connect(self.on_doc_fetched) + + self.refresh() + + def set_assets(self, asset_ids): + self._asset_ids = asset_ids + self.refresh() + + def set_grouping(self, state): + self._grouping = state + self.on_doc_fetched() + + def get_subsets_families(self): + return self._doc_payload.get("subset_families") or set() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + # Trigger additional edit when `version` column changed + # because it also updates the information in other columns + if index.column() == self.columns_index["version"]: + item = index.internalPointer() + parent = item["_id"] + if isinstance(value, HeroVersionType): + versions = list(self.dbcon.find({ + "type": {"$in": ["version", "hero_version"]}, + "parent": parent + }, sort=[("name", -1)])) + + version = None + last_version = None + for __version in versions: + if __version["type"] == "hero_version": + version = __version + elif last_version is None: + last_version = __version + + if version is not None and last_version is not None: + break + + _version = None + for __version in versions: + if __version["_id"] == version["version_id"]: + _version = __version + break + + version["data"] = _version["data"] + version["name"] = _version["name"] + version["is_from_latest"] = ( + last_version["_id"] == _version["_id"] + ) + + else: + version = self.dbcon.find_one({ + "name": value, + "type": "version", + "parent": parent + }) + + # update availability on active site when version changes + if self.sync_server.enabled and version: + site = self.active_site + query = self._repre_per_version_pipeline([version["_id"]], + site) + docs = list(self.dbcon.aggregate(query)) + if docs: + repre = docs.pop() + version["data"].update(self._get_repre_dict(repre)) + + self.set_version(index, version) + + return super(SubsetsModel, self).setData(index, value, role) + + def set_version(self, index, version): + """Update the version data of the given index. + + Arguments: + index (QtCore.QModelIndex): The model index. + version (dict) Version document in the database. + + """ + + assert isinstance(index, QtCore.QModelIndex) + if not index.isValid(): + return + + item = index.internalPointer() + + assert version["parent"] == item["_id"], ( + "Version does not belong to subset" + ) + + # Get the data from the version + version_data = version.get("data", dict()) + + # Compute frame ranges (if data is present) + frame_start = version_data.get( + "frameStart", + # backwards compatibility + version_data.get("startFrame", None) + ) + frame_end = version_data.get( + "frameEnd", + # backwards compatibility + version_data.get("endFrame", None) + ) + + handle_start = version_data.get("handleStart", None) + handle_end = version_data.get("handleEnd", None) + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(str(handle_start), str(handle_end)) + else: + handles = version_data.get("handles", None) + + if frame_start is not None and frame_end is not None: + # Remove superfluous zeros from numbers (3.0 -> 3) to improve + # readability for most frame ranges + start_clean = ("%f" % frame_start).rstrip("0").rstrip(".") + end_clean = ("%f" % frame_end).rstrip("0").rstrip(".") + frames = "{0}-{1}".format(start_clean, end_clean) + duration = frame_end - frame_start + 1 + else: + frames = None + duration = None + + schema_maj_version, _ = schema.get_schema_version(item["schema"]) + if schema_maj_version < 3: + families = version_data.get("families", [None]) + else: + families = item["data"]["families"] + + family = None + if families: + family = families[0] + + family_config = self.family_config_cache.family_config(family) + + item.update({ + "version": version["name"], + "version_document": version, + "author": version_data.get("author", None), + "time": version_data.get("time", None), + "family": family, + "familyLabel": family_config.get("label", family), + "familyIcon": family_config.get("icon", None), + "families": set(families), + "frameStart": frame_start, + "frameEnd": frame_end, + "duration": duration, + "handles": handles, + "frames": frames, + "step": version_data.get("step", None), + }) + + repre_info = version_data.get("repre_info") + if repre_info: + item["repre_info"] = repre_info + item["repre_icon"] = version_data.get("repre_icon") + + def _fetch(self): + asset_docs = self.dbcon.find( + { + "type": "asset", + "_id": {"$in": self._asset_ids} + }, + self.asset_doc_projection + ) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + subset_docs_by_id = {} + subset_docs = self.dbcon.find( + { + "type": "subset", + "parent": {"$in": self._asset_ids} + }, + self.subset_doc_projection + ) + subset_families = set() + for subset_doc in subset_docs: + if self._doc_fetching_stop: + return + + families = subset_doc.get("data", {}).get("families") + if families: + subset_families.add(families[0]) + + subset_docs_by_id[subset_doc["_id"]] = subset_doc + + subset_ids = list(subset_docs_by_id.keys()) + _pipeline = [ + # Find all versions of those subsets + {"$match": { + "type": "version", + "parent": {"$in": subset_ids} + }}, + # Sorting versions all together + {"$sort": {"name": 1}}, + # Group them by "parent", but only take the last + {"$group": { + "_id": "$parent", + "_version_id": {"$last": "$_id"}, + "name": {"$last": "$name"}, + "type": {"$last": "$type"}, + "data": {"$last": "$data"}, + "locations": {"$last": "$locations"}, + "schema": {"$last": "$schema"} + }} + ] + last_versions_by_subset_id = dict() + for doc in self.dbcon.aggregate(_pipeline): + if self._doc_fetching_stop: + return + doc["parent"] = doc["_id"] + doc["_id"] = doc.pop("_version_id") + last_versions_by_subset_id[doc["parent"]] = doc + + hero_versions = self.dbcon.find({ + "type": "hero_version", + "parent": {"$in": subset_ids} + }) + missing_versions = [] + for hero_version in hero_versions: + version_id = hero_version["version_id"] + if version_id not in last_versions_by_subset_id: + missing_versions.append(version_id) + + missing_versions_by_id = {} + if missing_versions: + missing_version_docs = self.dbcon.find({ + "type": "version", + "_id": {"$in": missing_versions} + }) + missing_versions_by_id = { + missing_version_doc["_id"]: missing_version_doc + for missing_version_doc in missing_version_docs + } + + for hero_version in hero_versions: + version_id = hero_version["version_id"] + subset_id = hero_version["parent"] + + version_doc = last_versions_by_subset_id.get(subset_id) + if version_doc is None: + version_doc = missing_versions_by_id.get(version_id) + if version_doc is None: + continue + + hero_version["data"] = version_doc["data"] + hero_version["name"] = HeroVersionType(version_doc["name"]) + # Add information if hero version is from latest version + hero_version["is_from_latest"] = version_id == version_doc["_id"] + + last_versions_by_subset_id[subset_id] = hero_version + + self._doc_payload = { + "asset_docs_by_id": asset_docs_by_id, + "subset_docs_by_id": subset_docs_by_id, + "subset_families": subset_families, + "last_versions_by_subset_id": last_versions_by_subset_id + } + + if self.sync_server.enabled: + version_ids = set() + for _subset_id, doc in last_versions_by_subset_id.items(): + version_ids.add(doc["_id"]) + + site = self.active_site + query = self._repre_per_version_pipeline(list(version_ids), site) + + repre_info = {} + for doc in self.dbcon.aggregate(query): + if self._doc_fetching_stop: + return + doc["provider"] = self.active_provider + repre_info[doc["_id"]] = doc + + self._doc_payload["repre_info_by_version_id"] = repre_info + + self.doc_fetched.emit() + + def fetch_subset_and_version(self): + """Query all subsets and latest versions from aggregation + (NOTE) The returned version documents are NOT the real version + document, it's generated from the MongoDB's aggregation so + some of the first level field may not be presented. + """ + self._doc_payload = {} + self._doc_fetching_stop = False + self._doc_fetching_thread = lib.create_qthread(self._fetch) + self._doc_fetching_thread.start() + + def stop_fetch_thread(self): + if self._doc_fetching_thread is not None: + self._doc_fetching_stop = True + while self._doc_fetching_thread.isRunning(): + pass + + def refresh(self): + self.stop_fetch_thread() + self.clear() + + self.reset_sync_server() + + if not self._asset_ids: + self.doc_fetched.emit() + return + + self.fetch_subset_and_version() + + def on_doc_fetched(self): + self.clear() + self.beginResetModel() + + asset_docs_by_id = self._doc_payload.get( + "asset_docs_by_id" + ) + subset_docs_by_id = self._doc_payload.get( + "subset_docs_by_id" + ) + last_versions_by_subset_id = self._doc_payload.get( + "last_versions_by_subset_id" + ) + + repre_info_by_version_id = self._doc_payload.get( + "repre_info_by_version_id" + ) + + if ( + asset_docs_by_id is None + or subset_docs_by_id is None + or last_versions_by_subset_id is None + or len(self._asset_ids) == 0 + ): + self.endResetModel() + self.refreshed.emit(False) + return + + self._fill_subset_items( + asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, + repre_info_by_version_id + ) + + def create_multiasset_group( + self, subset_name, asset_ids, subset_counter, parent_item=None + ): + subset_color = self.merged_subset_colors[ + subset_counter % len(self.merged_subset_colors) + ] + merge_group = Item() + merge_group.update({ + "subset": "{} ({})".format(subset_name, len(asset_ids)), + "isMerged": True, + "childRow": 0, + "subsetColor": subset_color, + "assetIds": list(asset_ids), + "icon": qtawesome.icon( + "fa.circle", + color="#{0:02x}{1:02x}{2:02x}".format(*subset_color) + ) + }) + + subset_counter += 1 + self.add_child(merge_group, parent_item) + + return merge_group + + def _fill_subset_items( + self, asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, + repre_info_by_version_id + ): + _groups_tuple = self.groups_config.split_subsets_for_groups( + subset_docs_by_id.values(), self._grouping + ) + groups, subset_docs_without_group, subset_docs_by_group = _groups_tuple + + group_item_by_name = {} + for group_data in groups: + group_name = group_data["name"] + group_item = Item() + group_item.update({ + "subset": group_name, + "isGroup": True, + "childRow": 0 + }) + group_item.update(group_data) + + self.add_child(group_item) + + group_item_by_name[group_name] = { + "item": group_item, + "index": self.index(group_item.row(), 0) + } + + subset_counter = 0 + for group_name, subset_docs_by_name in subset_docs_by_group.items(): + parent_item = group_item_by_name[group_name]["item"] + parent_index = group_item_by_name[group_name]["index"] + for subset_name in sorted(subset_docs_by_name.keys()): + subset_docs = subset_docs_by_name[subset_name] + asset_ids = [ + subset_doc["parent"] for subset_doc in subset_docs + ] + if len(subset_docs) > 1: + _parent_item = self.create_multiasset_group( + subset_name, asset_ids, subset_counter, parent_item + ) + _parent_index = self.index( + _parent_item.row(), 0, parent_index + ) + subset_counter += 1 + else: + _parent_item = parent_item + _parent_index = parent_index + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + + data = copy.deepcopy(subset_doc) + data["subset"] = subset_name + data["asset"] = asset_docs_by_id[asset_id]["name"] + + last_version = last_versions_by_subset_id.get( + subset_doc["_id"] + ) + data["last_version"] = last_version + + # do not show subset without version + if not last_version: + continue + + data.update( + self._get_last_repre_info(repre_info_by_version_id, + last_version["_id"])) + + item = Item() + item.update(data) + self.add_child(item, _parent_item) + + index = self.index(item.row(), 0, _parent_index) + self.set_version(index, last_version) + + for subset_name in sorted(subset_docs_without_group.keys()): + subset_docs = subset_docs_without_group[subset_name] + asset_ids = [subset_doc["parent"] for subset_doc in subset_docs] + parent_item = None + parent_index = None + if len(subset_docs) > 1: + parent_item = self.create_multiasset_group( + subset_name, asset_ids, subset_counter + ) + parent_index = self.index(parent_item.row(), 0) + subset_counter += 1 + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + + data = copy.deepcopy(subset_doc) + data["subset"] = subset_name + data["asset"] = asset_docs_by_id[asset_id]["name"] + + last_version = last_versions_by_subset_id.get( + subset_doc["_id"] + ) + data["last_version"] = last_version + + # do not show subset without version + if not last_version: + continue + + data.update( + self._get_last_repre_info(repre_info_by_version_id, + last_version["_id"])) + + item = Item() + item.update(data) + self.add_child(item, parent_item) + + index = self.index(item.row(), 0, parent_index) + self.set_version(index, last_version) + + self.endResetModel() + self.refreshed.emit(True) + + def data(self, index, role): + if not index.isValid(): + return + + if role == self.SortDescendingRole: + item = index.internalPointer() + if item.get("isGroup"): + # Ensure groups be on top when sorting by descending order + prefix = "2" + order = item["order"] + else: + if item.get("isMerged"): + prefix = "1" + else: + prefix = "0" + order = str(super(SubsetsModel, self).data( + index, QtCore.Qt.DisplayRole + )) + return prefix + order + + if role == self.SortAscendingRole: + item = index.internalPointer() + if item.get("isGroup"): + # Ensure groups be on top when sorting by ascending order + prefix = "0" + order = item["order"] + else: + if item.get("isMerged"): + prefix = "1" + else: + prefix = "2" + order = str(super(SubsetsModel, self).data( + index, QtCore.Qt.DisplayRole + )) + return prefix + order + + if role == QtCore.Qt.DisplayRole: + if index.column() == self.columns_index["family"]: + # Show familyLabel instead of family + item = index.internalPointer() + return item.get("familyLabel", None) + + elif role == QtCore.Qt.DecorationRole: + + # Add icon to subset column + if index.column() == self.columns_index["subset"]: + item = index.internalPointer() + if item.get("isGroup") or item.get("isMerged"): + return item["icon"] + else: + return self._icons["subset"] + + # Add icon to family column + if index.column() == self.columns_index["family"]: + item = index.internalPointer() + return item.get("familyIcon", None) + + if index.column() == self.columns_index.get("repre_info"): + item = index.internalPointer() + return item.get("repre_icon", None) + + elif role == QtCore.Qt.ForegroundRole: + item = index.internalPointer() + version_doc = item.get("version_document") + if version_doc and version_doc.get("type") == "hero_version": + if not version_doc["is_from_latest"]: + return self.not_last_hero_brush + + return super(SubsetsModel, self).data(index, role) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + # Make the version column editable + if index.column() == self.columns_index["version"]: + flags |= QtCore.Qt.ItemIsEditable + + return flags + + def headerData(self, section, orientation, role): + """Remap column names to labels""" + if role == QtCore.Qt.DisplayRole: + if section < len(self.Columns): + key = self.Columns[section] + return self.column_labels_mapping.get(key) or key + + super(TreeModel, self).headerData(section, orientation, role) + + def _get_last_repre_info(self, repre_info_by_version_id, last_version_id): + data = {} + if repre_info_by_version_id: + repre_info = repre_info_by_version_id.get(last_version_id) + return self._get_repre_dict(repre_info) + + return data + + def _get_repre_dict(self, repre_info): + """Returns icon and str representation of availability""" + data = {} + if repre_info: + repres_str = "{}/{}".format( + int(math.floor(float(repre_info['avail_repre']))), + int(math.floor(float(repre_info['repre_count'])))) + + data["repre_info"] = repres_str + data["repre_icon"] = self.repre_icons.get(self.active_provider) + + return data + + def _repre_per_version_pipeline(self, version_ids, site): + query = [ + {"$match": {"parent": {"$in": version_ids}, + "type": "representation", + "files.sites.name": {"$exists": 1}}}, + {"$unwind": "$files"}, + {'$addFields': { + 'order_local': { + '$filter': {'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', site]} + }} + }}, + {'$addFields': { + 'progress_local': {"$arrayElemAt": [{ + '$cond': [{'$size': "$order_local.progress"}, + "$order_local.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_local.created_dt"}, + [1], + [0] + ]} + ]}, 0]} + }}, + {'$group': { # first group by repre + '_id': '$_id', + 'parent': {'$first': '$parent'}, + 'files_count': {'$sum': 1}, + 'files_avail': {'$sum': "$progress_local"}, + 'avail_ratio': {'$first': { + '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}} + }}, + {'$group': { # second group by parent, eg version_id + '_id': '$parent', + 'repre_count': {'$sum': 1}, # total representations + # fully available representation for site + 'avail_repre': {'$sum': "$avail_ratio"} + }}, + ] + return query + + +class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel): + """Provide the feature of filtering group by the acceptance of members + + The subset group nodes will not be filtered directly, the group node's + acceptance depends on it's child subsets' acceptance. + + """ + + if is_filtering_recursible(): + def _is_group_acceptable(self, index, node): + # (NOTE) With the help of `RecursiveFiltering` feature from + # Qt 5.10, group always not be accepted by default. + return False + filter_accepts_group = _is_group_acceptable + + else: + # Patch future function + setRecursiveFilteringEnabled = (lambda *args: None) + + def _is_group_acceptable(self, index, model): + # (NOTE) This is not recursive. + for child_row in range(model.rowCount(index)): + if self.filterAcceptsRow(child_row, index): + return True + return False + filter_accepts_group = _is_group_acceptable + + def __init__(self, *args, **kwargs): + super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs) + self.setRecursiveFilteringEnabled(True) + + +class SubsetFilterProxyModel(GroupMemberFilterProxyModel): + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent) + item = index.internalPointer() + if item.get("isGroup"): + return self.filter_accepts_group(index, model) + return super( + SubsetFilterProxyModel, self + ).filterAcceptsRow(row, parent) + + +class FamiliesFilterProxyModel(GroupMemberFilterProxyModel): + """Filters to specified families""" + + def __init__(self, *args, **kwargs): + super(FamiliesFilterProxyModel, self).__init__(*args, **kwargs) + self._families = set() + + def familyFilter(self): + return self._families + + def setFamiliesFilter(self, values): + """Set the families to include""" + assert isinstance(values, (tuple, list, set)) + self._families = set(values) + self.invalidateFilter() + + def filterAcceptsRow(self, row=0, parent=None): + if not self._families: + return False + + model = self.sourceModel() + index = model.index(row, 0, parent=parent or QtCore.QModelIndex()) + + # Ensure index is valid + if not index.isValid() or index is None: + return True + + # Get the item data and validate + item = model.data(index, TreeModel.ItemRole) + + if item.get("isGroup"): + return self.filter_accepts_group(index, model) + + family = item.get("family") + if not family: + return True + + # We want to keep the families which are not in the list + return family in self._families + + def sort(self, column, order): + proxy = self.sourceModel() + model = proxy.sourceModel() + # We need to know the sorting direction for pinning groups on top + if order == QtCore.Qt.AscendingOrder: + self.setSortRole(model.SortAscendingRole) + else: + self.setSortRole(model.SortDescendingRole) + + super(FamiliesFilterProxyModel, self).sort(column, order) + + +class RepresentationSortProxyModel(GroupMemberFilterProxyModel): + """To properly sort progress string""" + def lessThan(self, left, right): + source_model = self.sourceModel() + progress_indexes = [source_model.Columns.index("active_site"), + source_model.Columns.index("remote_site")] + if left.column() in progress_indexes: + left_data = self.sourceModel().data(left, QtCore.Qt.DisplayRole) + right_data = self.sourceModel().data(right, QtCore.Qt.DisplayRole) + left_val = re.sub("[^0-9]", '', left_data) + right_val = re.sub("[^0-9]", '', right_data) + + return int(left_val) < int(right_val) + + return super(RepresentationSortProxyModel, self).lessThan(left, right) + + +class RepresentationModel(TreeModel, BaseRepresentationModel): + + doc_fetched = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + SiteNameRole = QtCore.Qt.UserRole + 2 + ProgressRole = QtCore.Qt.UserRole + 3 + SiteSideRole = QtCore.Qt.UserRole + 4 + IdRole = QtCore.Qt.UserRole + 5 + ContextRole = QtCore.Qt.UserRole + 6 + + Columns = [ + "name", + "subset", + "asset", + "active_site", + "remote_site" + ] + + column_labels_mapping = { + "name": "Name", + "subset": "Subset", + "asset": "Asset", + "active_site": "Active", + "remote_site": "Remote" + } + + def __init__(self, dbcon, header, version_ids): + super(RepresentationModel, self).__init__() + self.dbcon = dbcon + self._data = [] + self._header = header + self.version_ids = version_ids + + manager = ModulesManager() + sync_server = active_site = remote_site = None + active_provider = remote_provider = None + + project = dbcon.Session["AVALON_PROJECT"] + if project: + sync_server = manager.modules_by_name["sync_server"] + active_site = sync_server.get_active_site(project) + remote_site = sync_server.get_remote_site(project) + + # TODO refactor + active_provider = \ + sync_server.get_provider_for_site(project, + active_site) + if active_site == 'studio': + active_provider = 'studio' + + remote_provider = \ + sync_server.get_provider_for_site(project, + remote_site) + + if remote_site == 'studio': + remote_provider = 'studio' + + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + + self.doc_fetched.connect(self.on_doc_fetched) + + self._docs = {} + self._icons = lib.get_repre_icons() + self._icons["repre"] = qtawesome.icon("fa.file-o", + color=style.colors.default) + + def set_version_ids(self, version_ids): + self.version_ids = version_ids + self.refresh() + + def data(self, index, role): + item = index.internalPointer() + + if role == self.IdRole: + return item.get("_id") + + if role == QtCore.Qt.DecorationRole: + # Add icon to subset column + if index.column() == self.Columns.index("name"): + if item.get("isMerged"): + return item["icon"] + else: + return self._icons["repre"] + + active_index = self.Columns.index("active_site") + remote_index = self.Columns.index("remote_site") + if role == QtCore.Qt.DisplayRole: + progress = None + label = '' + if index.column() == active_index: + progress = item.get("active_site_progress", 0) + elif index.column() == remote_index: + progress = item.get("remote_site_progress", 0) + + if progress is not None: + # site added, sync in progress + progress_str = "not avail." + if progress >= 0: + # progress == 0 for isMerged is unavailable + if progress == 0 and item.get("isMerged"): + progress_str = "not avail." + else: + progress_str = "{}% {}".format(int(progress * 100), + label) + + return progress_str + + if role == QtCore.Qt.DecorationRole: + if index.column() == active_index: + return item.get("active_site_icon", None) + if index.column() == remote_index: + return item.get("remote_site_icon", None) + + if role == self.SiteNameRole: + if index.column() == active_index: + return item.get("active_site_name", None) + if index.column() == remote_index: + return item.get("remote_site_name", None) + + if role == self.SiteSideRole: + if index.column() == active_index: + return "active" + if index.column() == remote_index: + return "remote" + + if role == self.ProgressRole: + if index.column() == active_index: + return item.get("active_site_progress", 0) + if index.column() == remote_index: + return item.get("remote_site_progress", 0) + + return super(RepresentationModel, self).data(index, role) + + def on_doc_fetched(self): + self.clear() + self.beginResetModel() + subsets = set() + assets = set() + repre_groups = {} + repre_groups_items = {} + group = None + self._items_by_id = {} + for doc in self._docs: + if len(self.version_ids) > 1: + group = repre_groups.get(doc["name"]) + if not group: + group_item = Item() + group_item.update({ + "_id": doc["_id"], + "name": doc["name"], + "isMerged": True, + "childRow": 0, + "active_site_name": self.active_site, + "remote_site_name": self.remote_site, + "icon": qtawesome.icon( + "fa.folder", + color=style.colors.default + ) + }) + self.add_child(group_item, None) + repre_groups[doc["name"]] = group_item + repre_groups_items[doc["name"]] = 0 + group = group_item + + progress = lib.get_progress_for_repre(doc, + self.active_site, + self.remote_site) + + active_site_icon = self._icons.get(self.active_provider) + remote_site_icon = self._icons.get(self.remote_provider) + + data = { + "_id": doc["_id"], + "name": doc["name"], + "subset": doc["context"]["subset"], + "asset": doc["context"]["asset"], + "isMerged": False, + + "active_site_icon": active_site_icon, + "remote_site_icon": remote_site_icon, + "active_site_name": self.active_site, + "remote_site_name": self.remote_site, + "active_site_progress": progress[self.active_site], + "remote_site_progress": progress[self.remote_site] + } + subsets.add(doc["context"]["subset"]) + assets.add(doc["context"]["subset"]) + + item = Item() + item.update(data) + + current_progress = { + 'active_site_progress': progress[self.active_site], + 'remote_site_progress': progress[self.remote_site] + } + if group: + group = self._sum_group_progress(doc["name"], group, + current_progress, + repre_groups_items) + + self.add_child(item, group) + + # finalize group average progress + for group_name, group in repre_groups.items(): + items_cnt = repre_groups_items[group_name] + active_progress = group.get("active_site_progress", 0) + group["active_site_progress"] = active_progress / items_cnt + remote_progress = group.get("remote_site_progress", 0) + group["remote_site_progress"] = remote_progress / items_cnt + + self.endResetModel() + self.refreshed.emit(False) + + def refresh(self): + docs = [] + session_project = self.dbcon.Session['AVALON_PROJECT'] + if not session_project: + return + + if self.version_ids: + # Simple find here for now, expected to receive lower number of + # representations and logic could be in Python + docs = list(self.dbcon.find( + {"type": "representation", "parent": {"$in": self.version_ids}, + "files.sites.name": {"$exists": 1}}, self.projection())) + self._docs = docs + + self.doc_fetched.emit() + + @classmethod + def projection(cls): + return { + "_id": 1, + "name": 1, + "context.subset": 1, + "context.asset": 1, + "context.version": 1, + "context.representation": 1, + 'files.sites': 1 + } + + def _sum_group_progress(self, repre_name, group, current_item_progress, + repre_groups_items): + """ + Update final group progress + Called after every item in group is added + + Args: + repre_name(string) + group(dict): info about group of selected items + current_item_progress(dict): {'active_site_progress': XX, + 'remote_site_progress': YY} + repre_groups_items(dict) + Returns: + (dict): updated group info + """ + repre_groups_items[repre_name] += 1 + + for key, progress in current_item_progress.items(): + group[key] = (group.get(key, 0) + max(progress, 0)) + + return group diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py new file mode 100644 index 0000000000..6b94fc6e44 --- /dev/null +++ b/openpype/tools/loader/widgets.py @@ -0,0 +1,1591 @@ +import os +import sys +import inspect +import datetime +import pprint +import traceback +import collections + +from Qt import QtWidgets, QtCore, QtGui + +from avalon import api, pipeline +from avalon.lib import HeroVersionType + +from openpype.tools.utils import lib as tools_lib +from openpype.tools.utils.delegates import ( + VersionDelegate, + PrettyTimeDelegate +) +from openpype.tools.utils.widgets import OptionalMenu +from openpype.tools.utils.views import ( + TreeViewSpinner, + DeselectableTreeView +) + +from .model import ( + SubsetsModel, + SubsetFilterProxyModel, + FamiliesFilterProxyModel, + RepresentationModel, + RepresentationSortProxyModel +) +from . import lib + + +class OverlayFrame(QtWidgets.QFrame): + def __init__(self, label, parent): + super(OverlayFrame, self).__init__(parent) + + label_widget = QtWidgets.QLabel(label, self) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + self.label_widget = label_widget + + label_widget.setStyleSheet("background: transparent;") + self.setStyleSheet(( + "background: rgba(0, 0, 0, 127);" + "font-size: 60pt;" + )) + + def set_label(self, label): + self.label_widget.setText(label) + + +class LoadErrorMessageBox(QtWidgets.QDialog): + def __init__(self, messages, parent=None): + super(LoadErrorMessageBox, self).__init__(parent) + self.setWindowTitle("Loading failed") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + body_layout = QtWidgets.QVBoxLayout(self) + + main_label = ( + "Failed to load items" + ) + main_label_widget = QtWidgets.QLabel(main_label, self) + body_layout.addWidget(main_label_widget) + + item_name_template = ( + "Subset: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb, repre, subset, version in messages: + line = self._create_line() + body_layout.addWidget(line) + + item_name = item_name_template.format(subset, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + body_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + body_layout.addWidget(message_label_widget) + + if tb: + tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self) + tb_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + body_layout.addWidget(tb_widget) + + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) + buttonBox.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Ok + ) + buttonBox.accepted.connect(self._on_accept) + footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight) + body_layout.addWidget(footer_widget) + + def _on_accept(self): + self.close() + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setFixedHeight(2) + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +class SubsetWidget(QtWidgets.QWidget): + """A widget that lists the published subsets for an asset""" + + active_changed = QtCore.Signal() # active index changed + version_changed = QtCore.Signal() # version state changed for a subset + load_started = QtCore.Signal() + load_ended = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + default_widths = ( + ("subset", 200), + ("asset", 130), + ("family", 90), + ("version", 60), + ("time", 125), + ("author", 75), + ("frames", 75), + ("duration", 60), + ("handles", 55), + ("step", 10), + ("repre_info", 65) + ) + + def __init__( + self, + dbcon, + groups_config, + family_config_cache, + enable_grouping=True, + tool_name=None, + parent=None + ): + super(SubsetWidget, self).__init__(parent=parent) + + self.dbcon = dbcon + self.tool_name = tool_name + + model = SubsetsModel( + dbcon, + groups_config, + family_config_cache, + grouping=enable_grouping + ) + proxy = SubsetFilterProxyModel() + family_proxy = FamiliesFilterProxyModel() + family_proxy.setSourceModel(proxy) + + subset_filter = QtWidgets.QLineEdit() + subset_filter.setPlaceholderText("Filter subsets..") + + groupable = QtWidgets.QCheckBox("Enable Grouping") + groupable.setChecked(enable_grouping) + + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(subset_filter) + top_bar_layout.addWidget(groupable) + + view = TreeViewSpinner() + view.setObjectName("SubsetView") + view.setIndentation(20) + view.setStyleSheet(""" + QTreeView::item{ + padding: 5px 1px; + border: 0px; + } + """) + view.setAllColumnsShowFocus(True) + + # Set view delegates + version_delegate = VersionDelegate(self.dbcon) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + time_delegate = PrettyTimeDelegate() + column = model.Columns.index("time") + view.setItemDelegateForColumn(column, time_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(top_bar_layout) + layout.addWidget(view) + + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + view.setSortingEnabled(True) + view.sortByColumn(1, QtCore.Qt.AscendingOrder) + view.setAlternatingRowColors(True) + + self.data = { + "delegates": { + "version": version_delegate, + "time": time_delegate + }, + "state": { + "groupable": groupable + } + } + + self.proxy = proxy + self.model = model + self.view = view + self.filter = subset_filter + self.family_proxy = family_proxy + + # settings and connections + self.proxy.setSourceModel(self.model) + self.proxy.setDynamicSortFilter(True) + self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + self.view.setModel(self.family_proxy) + self.view.customContextMenuRequested.connect(self.on_context_menu) + + for column_name, width in self.default_widths: + idx = model.Columns.index(column_name) + view.setColumnWidth(idx, width) + + actual_project = dbcon.Session["AVALON_PROJECT"] + self.on_project_change(actual_project) + + selection = view.selectionModel() + selection.selectionChanged.connect(self.active_changed) + + version_delegate.version_changed.connect(self.version_changed) + + groupable.stateChanged.connect(self.set_grouping) + + self.filter.textChanged.connect(self.proxy.setFilterRegExp) + self.filter.textChanged.connect(self.view.expandAll) + model.refreshed.connect(self.refreshed) + + self.model.refresh() + + def get_subsets_families(self): + return self.model.get_subsets_families() + + def set_family_filters(self, families): + self.family_proxy.setFamiliesFilter(families) + + def is_groupable(self): + return self.data["state"]["groupable"].checkState() + + def set_grouping(self, state): + with tools_lib.preserve_selection(tree_view=self.view, + current_index=False): + self.model.set_grouping(state) + + def set_loading_state(self, loading, empty): + view = self.view + + if view.is_loading != loading: + if loading: + view.spinner.repaintNeeded.connect(view.viewport().update) + else: + view.spinner.repaintNeeded.disconnect() + + view.is_loading = loading + view.is_empty = empty + + def _repre_contexts_for_loaders_filter(self, items): + version_docs_by_id = { + item["version_document"]["_id"]: item["version_document"] + for item in items + } + version_docs_by_subset_id = collections.defaultdict(list) + for item in items: + subset_id = item["version_document"]["parent"] + version_docs_by_subset_id[subset_id].append( + item["version_document"] + ) + + subset_docs = list(self.dbcon.find( + { + "_id": {"$in": list(version_docs_by_subset_id.keys())}, + "type": "subset" + }, + { + "schema": 1, + "data.families": 1 + } + )) + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + version_ids = list(version_docs_by_id.keys()) + repre_docs = self.dbcon.find( + # Query all representations for selected versions at once + { + "type": "representation", + "parent": {"$in": version_ids} + }, + # Query only name and parent from representation + { + "name": 1, + "parent": 1 + } + ) + repre_docs_by_version_id = { + version_id: [] + for version_id in version_ids + } + repre_context_by_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + repre_docs_by_version_id[version_id].append(repre_doc) + + version_doc = version_docs_by_id[version_id] + repre_context_by_id[repre_doc["_id"]] = { + "representation": repre_doc, + "version": version_doc, + "subset": subset_docs_by_id[version_doc["parent"]] + } + return repre_context_by_id, repre_docs_by_version_id + + def on_project_change(self, project_name): + """ + Called on each project change in parent widget. + + Checks if Sync Server is enabled for a project, pushes changes to + model. + """ + enabled = False + if project_name: + self.model.reset_sync_server(project_name) + if self.model.sync_server: + enabled_proj = self.model.sync_server.get_enabled_projects() + enabled = project_name in enabled_proj + + lib.change_visibility(self.model, self.view, "repre_info", enabled) + + def on_context_menu(self, point): + """Shows menu with loader actions on Right-click. + + Registered actions are filtered by selection and help of + `loaders_from_representation` from avalon api. Intersection of actions + is shown when more subset is selected. When there are not available + actions for selected subsets then special action is shown (works as + info message to user): "*No compatible loaders for your selection" + + """ + + point_index = self.view.indexAt(point) + if not point_index.isValid(): + return + + # Get selected subsets without groups + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + items = lib.get_selected_items(rows, self.model.ItemRole) + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = api.discover(api.Loader) + if self.tool_name: + available_loaders = lib.remove_tool_name_from_loaders( + available_loaders, self.tool_name + ) + + repre_loaders = [] + subset_loaders = [] + for loader in available_loaders: + # Skip if its a SubsetLoader. + if api.SubsetLoader in inspect.getmro(loader): + subset_loaders.append(loader) + else: + repre_loaders.append(loader) + + loaders = list() + + # Bool if is selected only one subset + one_item_selected = (len(items) == 1) + + # Prepare variables for multiple selected subsets + first_loaders = [] + found_combinations = None + + is_first = True + repre_context_by_id, repre_docs_by_version_id = ( + self._repre_contexts_for_loaders_filter(items) + ) + for item in items: + _found_combinations = [] + version_id = item["version_document"]["_id"] + repre_docs = repre_docs_by_version_id[version_id] + for repre_doc in repre_docs: + repre_context = repre_context_by_id[repre_doc["_id"]] + for loader in pipeline.loaders_from_repre_context( + repre_loaders, + repre_context + ): + # do not allow download whole repre, select specific repre + if tools_lib.is_sync_loader(loader): + continue + + # skip multiple select variant if one is selected + if one_item_selected: + loaders.append((repre_doc, loader)) + continue + + # store loaders of first subset + if is_first: + first_loaders.append((repre_doc, loader)) + + # store combinations to compare with other subsets + _found_combinations.append( + (repre_doc["name"].lower(), loader) + ) + + # skip multiple select variant if one is selected + if one_item_selected: + continue + + is_first = False + # Store first combinations to compare + if found_combinations is None: + found_combinations = _found_combinations + # Intersect found combinations with all previous subsets + else: + found_combinations = list( + set(found_combinations) & set(_found_combinations) + ) + + if not one_item_selected: + # Filter loaders from first subset by intersected combinations + for repre, loader in first_loaders: + if (repre["name"], loader) not in found_combinations: + continue + + loaders.append((repre, loader)) + + # Subset Loaders. + for loader in subset_loaders: + loaders.append((None, loader)) + + loaders = lib.sort_loaders(loaders) + + # Prepare menu content based on selected items + menu = OptionalMenu(self) + if not loaders: + action = lib.get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + else: + repre_contexts = pipeline.get_repres_contexts( + repre_context_by_id.keys(), self.dbcon) + + menu = lib.add_representation_loaders_to_menu( + loaders, menu, repre_contexts) + + # Show the context action menu + global_point = self.view.mapToGlobal(point) + action = menu.exec_(global_point) + if not action or not action.data(): + return + + # Find the representation name and loader to trigger + action_representation, loader = action.data() + + self.load_started.emit() + + if api.SubsetLoader in inspect.getmro(loader): + subset_ids = [] + subset_version_docs = {} + for item in items: + subset_id = item["version_document"]["parent"] + subset_ids.append(subset_id) + subset_version_docs[subset_id] = item["version_document"] + + # get contexts only for selected menu option + subset_contexts_by_id = pipeline.get_subset_contexts(subset_ids, + self.dbcon) + subset_contexts = list(subset_contexts_by_id.values()) + options = lib.get_options(action, loader, self, subset_contexts) + + error_info = _load_subsets_by_loader( + loader, subset_contexts, options, subset_version_docs + ) + + else: + representation_name = action_representation["name"] + + # Run the loader for all selected indices, for those that have the + # same representation available + + # Trigger + repre_ids = [] + for item in items: + representation = self.dbcon.find_one( + { + "type": "representation", + "name": representation_name, + "parent": item["version_document"]["_id"] + }, + {"_id": 1} + ) + if not representation: + self.echo("Subset '{}' has no representation '{}'".format( + item["subset"], representation_name + )) + continue + repre_ids.append(representation["_id"]) + + # get contexts only for selected menu option + repre_contexts = pipeline.get_repres_contexts(repre_ids, + self.dbcon) + options = lib.get_options(action, loader, self, + list(repre_contexts.values())) + + error_info = _load_representations_by_loader( + loader, repre_contexts, options=options + ) + + self.load_ended.emit() + + if error_info: + box = LoadErrorMessageBox(error_info) + box.show() + + def selected_subsets(self, _groups=False, _merged=False, _other=True): + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + subsets = list() + if not any([_groups, _merged, _other]): + self.echo(( + "This is a BUG: Selected_subsets args must contain" + " at least one value set to True" + )) + return subsets + + for row in rows: + item = row.data(self.model.ItemRole) + if item.get("isGroup"): + if not _groups: + continue + + elif item.get("isMerged"): + if not _merged: + continue + else: + if not _other: + continue + + subsets.append(item) + + return subsets + + def group_subsets(self, name, asset_ids, items): + field = "data.subsetGroup" + + if name: + update = {"$set": {field: name}} + self.echo("Group subsets to '%s'.." % name) + else: + update = {"$unset": {field: ""}} + self.echo("Ungroup subsets..") + + subsets = list() + for item in items: + subsets.append(item["subset"]) + + for asset_id in asset_ids: + filtr = { + "type": "subset", + "parent": asset_id, + "name": {"$in": subsets}, + } + self.dbcon.update_many(filtr, update) + + def echo(self, message): + print(message) + + +class VersionTextEdit(QtWidgets.QTextEdit): + """QTextEdit that displays version specific information. + + This also overrides the context menu to add actions like copying + source path to clipboard or copying the raw data of the version + to clipboard. + + """ + def __init__(self, dbcon, parent=None): + super(VersionTextEdit, self).__init__(parent=parent) + self.dbcon = dbcon + + self.data = { + "source": None, + "raw": None + } + + # Reset + self.set_version(None) + + def set_version(self, version_doc=None, version_id=None): + # TODO expect only filling data (do not query them here!) + if not version_doc and not version_id: + # Reset state to empty + self.data = { + "source": None, + "raw": None, + } + self.setText("") + self.setEnabled(True) + return + + self.setEnabled(True) + + print("Querying..") + + if not version_doc: + version_doc = self.dbcon.find_one({ + "_id": version_id, + "type": {"$in": ["version", "hero_version"]} + }) + assert version_doc, "Not a valid version id" + + if version_doc["type"] == "hero_version": + _version_doc = self.dbcon.find_one({ + "_id": version_doc["version_id"], + "type": "version" + }) + version_doc["data"] = _version_doc["data"] + version_doc["name"] = HeroVersionType( + _version_doc["name"] + ) + + subset = self.dbcon.find_one({ + "_id": version_doc["parent"], + "type": "subset" + }) + assert subset, "No valid subset parent for version" + + # Define readable creation timestamp + created = version_doc["data"]["time"] + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_doc["data"].get("comment", None) or "No comment" + + source = version_doc["data"].get("source", None) + source_label = source if source else "No source" + + # Store source and raw data + self.data["source"] = source + self.data["raw"] = version_doc + + if version_doc["type"] == "hero_version": + version_name = "hero" + else: + version_name = tools_lib.format_version(version_doc["name"]) + + data = { + "subset": subset["name"], + "version": version_name, + "comment": comment, + "created": created, + "source": source_label + } + + self.setHtml(( + "

{subset}

" + "

{version}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format(**data)) + + def contextMenuEvent(self, event): + """Context menu with additional actions""" + menu = self.createStandardContextMenu() + + # Add additional actions when any text so we can assume + # the version is set. + if self.toPlainText().strip(): + + menu.addSeparator() + action = QtWidgets.QAction("Copy source path to clipboard", + menu) + action.triggered.connect(self.on_copy_source) + menu.addAction(action) + + action = QtWidgets.QAction("Copy raw data to clipboard", + menu) + action.triggered.connect(self.on_copy_raw) + menu.addAction(action) + + menu.exec_(event.globalPos()) + del menu + + def on_copy_source(self): + """Copy formatted source path to clipboard""" + source = self.data.get("source", None) + if not source: + return + + path = source.format(root=api.registered_root()) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(path) + + def on_copy_raw(self): + """Copy raw version data to clipboard + + The data is string formatted with `pprint.pformat`. + + """ + raw = self.data.get("raw", None) + if not raw: + return + + raw_text = pprint.pformat(raw) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(raw_text) + + +class ThumbnailWidget(QtWidgets.QLabel): + + aspect_ratio = (16, 9) + max_width = 300 + + def __init__(self, dbcon, parent=None): + super(ThumbnailWidget, self).__init__(parent) + self.dbcon = dbcon + + self.current_thumb_id = None + self.current_thumbnail = None + + self.setAlignment(QtCore.Qt.AlignCenter) + + # TODO get res path much better way + default_pix_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "default_thumbnail.png" + ) + self.default_pix = QtGui.QPixmap(default_pix_path) + + def height(self): + width = self.width() + asp_w, asp_h = self.aspect_ratio + + return (width / asp_w) * asp_h + + def width(self): + width = super(ThumbnailWidget, self).width() + if width > self.max_width: + width = self.max_width + return width + + def set_pixmap(self, pixmap=None): + if not pixmap: + pixmap = self.default_pix + self.current_thumb_id = None + + self.current_thumbnail = pixmap + + pixmap = self.scale_pixmap(pixmap) + self.setPixmap(pixmap) + + def resizeEvent(self, _event): + if not self.current_thumbnail: + return + cur_pix = self.scale_pixmap(self.current_thumbnail) + self.setPixmap(cur_pix) + + def scale_pixmap(self, pixmap): + return pixmap.scaled( + self.width(), self.height(), QtCore.Qt.KeepAspectRatio + ) + + def set_thumbnail(self, entity=None): + if not entity: + self.set_pixmap() + return + + if isinstance(entity, (list, tuple)): + if len(entity) == 1: + entity = entity[0] + else: + self.set_pixmap() + return + + thumbnail_id = entity.get("data", {}).get("thumbnail_id") + if thumbnail_id == self.current_thumb_id: + if self.current_thumbnail is None: + self.set_pixmap() + return + + self.current_thumb_id = thumbnail_id + if not thumbnail_id: + self.set_pixmap() + return + + thumbnail_ent = self.dbcon.find_one( + {"type": "thumbnail", "_id": thumbnail_id} + ) + if not thumbnail_ent: + return + + thumbnail_bin = pipeline.get_thumbnail_binary( + thumbnail_ent, "thumbnail", self.dbcon + ) + if not thumbnail_bin: + self.set_pixmap() + return + + thumbnail = QtGui.QPixmap() + thumbnail.loadFromData(thumbnail_bin) + + self.set_pixmap(thumbnail) + + +class VersionWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, dbcon, parent=None): + super(VersionWidget, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + label = QtWidgets.QLabel("Version", self) + data = VersionTextEdit(dbcon, self) + data.setReadOnly(True) + + layout.addWidget(label) + layout.addWidget(data) + + self.data = data + + def set_version(self, version_doc): + self.data.set_version(version_doc) + + +class FamilyModel(QtGui.QStandardItemModel): + def __init__(self, dbcon, family_config_cache): + super(FamilyModel, self).__init__() + + self.dbcon = dbcon + self.family_config_cache = family_config_cache + + self._items_by_family = {} + + def refresh(self): + families = set() + if self.dbcon.Session.get("AVALON_PROJECT"): + result = list(self.dbcon.aggregate([ + {"$match": { + "type": "subset" + }}, + {"$project": { + "family": {"$arrayElemAt": ["$data.families", 0]} + }}, + {"$group": { + "_id": "family_group", + "families": {"$addToSet": "$family"} + }} + ])) + if result: + families = set(result[0]["families"]) + + root_item = self.invisibleRootItem() + + for family in tuple(self._items_by_family.keys()): + if family not in families: + item = self._items_by_family.pop(family) + root_item.removeRow(item.row()) + + self.family_config_cache.refresh() + + new_items = [] + for family in families: + family_config = self.family_config_cache.family_config(family) + label = family_config.get("label", family) + icon = family_config.get("icon", None) + + if family_config.get("state", True): + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + if family not in self._items_by_family: + item = QtGui.QStandardItem(label) + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + new_items.append(item) + self._items_by_family[family] = item + + else: + item = self._items_by_family[label] + item.setData(label, QtCore.Qt.DisplayRole) + + item.setCheckState(state) + + if icon: + item.setIcon(icon) + + if new_items: + root_item.appendRows(new_items) + + +class FamilyProxyFiler(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(FamilyProxyFiler, self).__init__(*args, **kwargs) + + self._filtering_enabled = False + self._enabled_families = set() + + def set_enabled_families(self, families): + if self._enabled_families == families: + return + + self._enabled_families = families + if self._filtering_enabled: + self.invalidateFilter() + + def is_filter_enabled(self): + return self._filtering_enabled + + def set_filter_enabled(self, enabled=None): + if enabled is None: + enabled = not self._filtering_enabled + elif self._filtering_enabled == enabled: + return + + self._filtering_enabled = enabled + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + if not self._filtering_enabled: + return True + + if not self._enabled_families: + return False + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + if index.data(QtCore.Qt.DisplayRole) in self._enabled_families: + return True + return False + + +class FamilyListView(QtWidgets.QListView): + active_changed = QtCore.Signal(list) + + def __init__(self, dbcon, family_config_cache, parent=None): + super(FamilyListView, self).__init__(parent=parent) + + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + family_model = FamilyModel(dbcon, family_config_cache) + proxy_model = FamilyProxyFiler() + proxy_model.setDynamicSortFilter(True) + proxy_model.setSourceModel(family_model) + + self.setModel(proxy_model) + + family_model.dataChanged.connect(self._on_data_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + self._family_model = family_model + self._proxy_model = proxy_model + + def set_enabled_families(self, families): + self._proxy_model.set_enabled_families(families) + + self.set_enabled_family_filtering(True) + + def set_enabled_family_filtering(self, enabled=None): + self._proxy_model.set_filter_enabled(enabled) + + def refresh(self): + self._family_model.refresh() + + self.active_changed.emit(self.get_enabled_families()) + + def get_enabled_families(self): + """Return the checked family items""" + model = self._family_model + checked_families = [] + for row in range(model.rowCount()): + index = model.index(row, 0) + if index.data(QtCore.Qt.CheckStateRole) == QtCore.Qt.Checked: + family = index.data(QtCore.Qt.DisplayRole) + checked_families.append(family) + + return checked_families + + def set_all_unchecked(self): + self._set_checkstates(False, self._get_all_indexes()) + + def set_all_checked(self): + self._set_checkstates(True, self._get_all_indexes()) + + def _get_all_indexes(self): + indexes = [] + model = self._family_model + for row in range(model.rowCount()): + index = model.index(row, 0) + indexes.append(index) + return indexes + + def _set_checkstates(self, checked, indexes): + if not indexes: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self.blockSignals(True) + + for index in indexes: + index_state = index.data(QtCore.Qt.CheckStateRole) + if index_state == state: + continue + + new_state = state + if new_state is None: + if index_state == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + + index.model().setData(index, new_state, QtCore.Qt.CheckStateRole) + + self.blockSignals(False) + + self.active_changed.emit(self.get_enabled_families()) + + def _change_selection_state(self, checked): + indexes = self.selectionModel().selectedIndexes() + self._set_checkstates(checked, indexes) + + def _on_data_change(self, *_args): + self.active_changed.emit(self.get_enabled_families()) + + def _on_context_menu(self, pos): + """Build RMB menu under mouse at current position (within widget)""" + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self.set_all_checked) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self.set_all_unchecked) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if not event.type() == QtCore.QEvent.KeyPress: + pass + + elif event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + elif event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + elif event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(FamilyListView, self).event(event) + + +class RepresentationWidget(QtWidgets.QWidget): + load_started = QtCore.Signal() + load_ended = QtCore.Signal() + + default_widths = ( + ("name", 120), + ("subset", 125), + ("asset", 125), + ("active_site", 85), + ("remote_site", 85) + ) + + commands = {'active': 'Download', 'remote': 'Upload'} + + def __init__(self, dbcon, tool_name=None, parent=None): + super(RepresentationWidget, self).__init__(parent=parent) + self.dbcon = dbcon + self.tool_name = tool_name + + headers = [item[0] for item in self.default_widths] + + model = RepresentationModel(self.dbcon, headers, []) + + proxy_model = RepresentationSortProxyModel(self) + proxy_model.setSourceModel(model) + + label = QtWidgets.QLabel("Representations", self) + + tree_view = DeselectableTreeView() + tree_view.setModel(proxy_model) + tree_view.setAllColumnsShowFocus(True) + tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + tree_view.setSortingEnabled(True) + tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder) + tree_view.setAlternatingRowColors(True) + tree_view.setIndentation(20) + tree_view.setStyleSheet(""" + QTreeView::item{ + padding: 5px 1px; + border: 0px; + } + """) + tree_view.collapseAll() + + for column_name, width in self.default_widths: + idx = model.Columns.index(column_name) + tree_view.setColumnWidth(idx, width) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label) + layout.addWidget(tree_view) + + # self.itemChanged.connect(self._on_item_changed) + tree_view.customContextMenuRequested.connect(self.on_context_menu) + + self.tree_view = tree_view + self.model = model + self.proxy_model = proxy_model + + self.sync_server_enabled = False + actual_project = dbcon.Session["AVALON_PROJECT"] + self.on_project_change(actual_project) + + self.model.refresh() + + def on_project_change(self, project_name): + """ + Called on each project change in parent widget. + + Checks if Sync Server is enabled for a project, pushes changes to + model. + """ + enabled = False + if project_name: + self.model.reset_sync_server(project_name) + if self.model.sync_server: + enabled_proj = self.model.sync_server.get_enabled_projects() + enabled = project_name in enabled_proj + + self.sync_server_enabled = enabled + lib.change_visibility(self.model, self.tree_view, + "active_site", enabled) + lib.change_visibility(self.model, self.tree_view, + "remote_site", enabled) + + def _repre_contexts_for_loaders_filter(self, items): + repre_ids = [] + for item in items: + repre_ids.append(item["_id"]) + + repre_docs = list(self.dbcon.find( + { + "type": "representation", + "_id": {"$in": repre_ids} + }, + { + "name": 1, + "parent": 1 + } + )) + version_ids = [ + repre_doc["parent"] + for repre_doc in repre_docs + ] + version_docs = self.dbcon.find({ + "_id": {"$in": version_ids} + }) + + version_docs_by_id = {} + version_docs_by_subset_id = collections.defaultdict(list) + for version_doc in version_docs: + version_id = version_doc["_id"] + subset_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_subset_id[subset_id].append(version_doc) + + subset_docs = list(self.dbcon.find( + { + "_id": {"$in": list(version_docs_by_subset_id.keys())}, + "type": "subset" + }, + { + "schema": 1, + "data.families": 1 + } + )) + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + repre_context_by_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + + version_doc = version_docs_by_id[version_id] + repre_context_by_id[repre_doc["_id"]] = { + "representation": repre_doc, + "version": version_doc, + "subset": subset_docs_by_id[version_doc["parent"]] + } + return repre_context_by_id + + def on_context_menu(self, point): + """Shows menu with loader actions on Right-click. + + Registered actions are filtered by selection and help of + `loaders_from_representation` from avalon api. Intersection of actions + is shown when more subset is selected. When there are not available + actions for selected subsets then special action is shown (works as + info message to user): "*No compatible loaders for your selection" + + """ + point_index = self.tree_view.indexAt(point) + if not point_index.isValid(): + return + + # Get selected subsets without groups + selection = self.tree_view.selectionModel() + rows = selection.selectedRows(column=0) + + items = lib.get_selected_items(rows, self.model.ItemRole) + + selected_side = self._get_selected_side(point_index, rows) + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = api.discover(api.Loader) + + filtered_loaders = [] + for loader in available_loaders: + # Skip subset loaders + if api.SubsetLoader in inspect.getmro(loader): + continue + + if ( + tools_lib.is_sync_loader(loader) + and not self.sync_server_enabled + ): + continue + + filtered_loaders.append(loader) + + if self.tool_name: + filtered_loaders = lib.remove_tool_name_from_loaders( + filtered_loaders, self.tool_name + ) + + loaders = list() + already_added_loaders = set() + label_already_in_menu = set() + + repre_context_by_id = ( + self._repre_contexts_for_loaders_filter(items) + ) + + for item in items: + repre_context = repre_context_by_id[item["_id"]] + for loader in pipeline.loaders_from_repre_context( + filtered_loaders, + repre_context + ): + if tools_lib.is_sync_loader(loader): + both_unavailable = ( + item["active_site_progress"] <= 0 + and item["remote_site_progress"] <= 0 + ) + if both_unavailable: + continue + + for selected_side in self.commands.keys(): + item = item.copy() + item["custom_label"] = None + label = None + selected_site_progress = item.get( + "{}_site_progress".format(selected_side), -1) + + # only remove if actually present + if tools_lib.is_remove_site_loader(loader): + label = "Remove {}".format(selected_side) + if selected_site_progress < 1: + continue + + if tools_lib.is_add_site_loader(loader): + label = self.commands[selected_side] + if selected_site_progress >= 0: + label = 'Re-{} {}'.format(label, selected_side) + + if not label: + continue + + item["selected_side"] = selected_side + item["custom_label"] = label + + if label not in label_already_in_menu: + loaders.append((item, loader)) + already_added_loaders.add(loader) + label_already_in_menu.add(label) + + else: + item = item.copy() + item["custom_label"] = None + + if loader not in already_added_loaders: + loaders.append((item, loader)) + already_added_loaders.add(loader) + + loaders = lib.sort_loaders(loaders) + + menu = OptionalMenu(self) + if not loaders: + action = lib.get_no_loader_action(menu) + menu.addAction(action) + else: + repre_contexts = pipeline.get_repres_contexts( + repre_context_by_id.keys(), self.dbcon) + menu = lib.add_representation_loaders_to_menu(loaders, menu, + repre_contexts) + + self._process_action(items, menu, point) + + def _process_action(self, items, menu, point): + """ + Show the context action menu and process selected + + Args: + items(dict): menu items + menu(OptionalMenu) + point(PointIndex) + """ + global_point = self.tree_view.mapToGlobal(point) + action = menu.exec_(global_point) + + if not action or not action.data(): + return + + self.load_started.emit() + + # Find the representation name and loader to trigger + action_representation, loader = action.data() + repre_ids = [] + data_by_repre_id = {} + selected_side = action_representation.get("selected_side") + + for item in items: + if tools_lib.is_sync_loader(loader): + site_name = "{}_site_name".format(selected_side) + data = { + "_id": item.get("_id"), + "site_name": item.get(site_name), + "project_name": self.dbcon.Session["AVALON_PROJECT"] + } + + if not data["site_name"]: + continue + + data_by_repre_id[data["_id"]] = data + + repre_ids.append(item.get("_id")) + + repre_contexts = pipeline.get_repres_contexts(repre_ids, + self.dbcon) + options = lib.get_options(action, loader, self, + list(repre_contexts.values())) + + errors = _load_representations_by_loader( + loader, repre_contexts, + options=options, data_by_repre_id=data_by_repre_id) + + self.model.refresh() + + self.load_ended.emit() + + if errors: + box = LoadErrorMessageBox(errors) + box.show() + + def _get_optional_labels(self, loaders, selected_side): + """Each loader could have specific label + + Args: + loaders (tuple of dict, dict): (item, loader) + selected_side(string): active or remote + + Returns: + (dict) {loader: string} + """ + optional_labels = {} + if selected_side: + if selected_side == 'active': + txt = "Localize" + else: + txt = "Sync to Remote" + optional_labels = {loader: txt for _, loader in loaders + if tools_lib.is_sync_loader(loader)} + return optional_labels + + def _get_selected_side(self, point_index, rows): + """Returns active/remote label according to column in 'point_index'""" + selected_side = None + if self.sync_server_enabled: + if rows: + source_index = self.proxy_model.mapToSource(point_index) + selected_side = self.model.data(source_index, + self.model.SiteSideRole) + return selected_side + + def set_version_ids(self, version_ids): + self.model.set_version_ids(version_ids) + + def _set_download(self): + pass + + def change_visibility(self, column_name, visible): + """ + Hides or shows particular 'column_name'. + + "asset" and "subset" columns should be visible only in multiselect + """ + lib.change_visibility(self.model, self.tree_view, column_name, visible) + + +def _load_representations_by_loader(loader, repre_contexts, + options, + data_by_repre_id=None): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (cls of api.Loader) - not initialized yet + repre_contexts (dicts) - full info about selected representations + (containing repre_doc, version_doc, subset_doc, project info) + options (dict) - qargparse arguments to fill OptionDialog + data_by_repre_id (dict) - additional data applicable on top of + options to provide dynamic values + """ + error_info = [] + + if options is None: # not load when cancelled + return + + for repre_context in repre_contexts.values(): + try: + if data_by_repre_id: + _id = repre_context["representation"]["_id"] + data = data_by_repre_id.get(_id) + options.update(data) + pipeline.load_with_repre_context( + loader, + repre_context, + options=options + ) + except pipeline.IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + repre_context["version"]["name"] + )) + + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( + str(exc), + formatted_traceback, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + repre_context["version"]["name"] + )) + return error_info + + +def _load_subsets_by_loader(loader, subset_contexts, options, + subset_version_docs=None): + """ + Triggers load with SubsetLoader type of loaders + + Args: + loader (SubsetLoder): + subset_contexts (list): + options (dict): + subset_version_docs (dict): {subset_id: version_doc} + """ + error_info = [] + + if options is None: # not load when cancelled + return + + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in subset_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + + context["version"] = subset_version_docs[context["subset"]["_id"]] + try: + pipeline.load_with_subset_contexts( + loader, + subset_contexts, + options=options + ) + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + error_info.append(( + str(exc), + formatted_traceback, + None, + ", ".join(subset_names), + None + )) + else: + for subset_context in subset_contexts: + subset_name = subset_context.get("subset", {}).get("name") or "N/A" + + version_doc = subset_version_docs[subset_context["subset"]["_id"]] + subset_context["version"] = version_doc + try: + pipeline.load_with_subset_context( + loader, + subset_context, + options=options + ) + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "\n".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + error_info.append(( + str(exc), + formatted_traceback, + None, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index 49ade4a989..6e44afd841 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -1,9 +1,11 @@ __all__ = ( "IDENTIFIER_ROLE", + "PROJECT_NAME_ROLE", "HierarchyView", "ProjectModel", + "ProjectProxyFilter", "CreateProjectDialog", "HierarchyModel", @@ -20,12 +22,14 @@ __all__ = ( from .constants import ( - IDENTIFIER_ROLE + IDENTIFIER_ROLE, + PROJECT_NAME_ROLE ) from .widgets import CreateProjectDialog from .view import HierarchyView from .model import ( ProjectModel, + ProjectProxyFilter, HierarchyModel, HierarchySelectionModel, diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 67dea79e59..7ca4aa9492 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -17,6 +17,9 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 # Item has opened editor (per column) EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 +# Role for project model +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 7 + # Allowed symbols for any name NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7ee43a6b61..5b6ed78b50 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -9,7 +9,8 @@ from .constants import ( DUPLICATED_ROLE, HIERARCHY_CHANGE_ABLE_ROLE, REMOVED_ROLE, - EDITOR_OPENED_ROLE + EDITOR_OPENED_ROLE, + PROJECT_NAME_ROLE ) from .style import ResourceCache @@ -29,7 +30,7 @@ class ProjectModel(QtGui.QStandardItemModel): def __init__(self, dbcon, *args, **kwargs): self.dbcon = dbcon - self._project_names = set() + self._items_by_name = {} super(ProjectModel, self).__init__(*args, **kwargs) @@ -37,33 +38,62 @@ class ProjectModel(QtGui.QStandardItemModel): """Reload projects.""" self.dbcon.Session["AVALON_PROJECT"] = None - project_items = [] + new_project_items = [] - none_project = QtGui.QStandardItem("< Select Project >") - none_project.setData(None) - project_items.append(none_project) + if None not in self._items_by_name: + none_project = QtGui.QStandardItem("< Select Project >") + self._items_by_name[None] = none_project + new_project_items.append(none_project) - database = self.dbcon.database + project_docs = self.dbcon.projects( + projection={"name": 1}, + only_active=True + ) project_names = set() - for project_name in database.collection_names(): - # Each collection will have exactly one project document - project_doc = database[project_name].find_one( - {"type": "project"}, - {"name": 1} - ) - if not project_doc: + for project_doc in project_docs: + project_name = project_doc.get("name") + if not project_name: continue - project_name = project_doc.get("name") - if project_name: - project_names.add(project_name) - project_items.append(QtGui.QStandardItem(project_name)) + project_names.add(project_name) + if project_name not in self._items_by_name: + project_item = QtGui.QStandardItem(project_name) + project_item.setData(project_name, PROJECT_NAME_ROLE) - self.clear() + self._items_by_name[project_name] = project_item + new_project_items.append(project_item) - self._project_names = project_names + root_item = self.invisibleRootItem() + for project_name in tuple(self._items_by_name.keys()): + if project_name is None or project_name in project_names: + continue + project_item = self._items_by_name.pop(project_name) + root_item.removeRow(project_item.row()) - self.invisibleRootItem().appendRows(project_items) + if new_project_items: + root_item.appendRows(new_project_items) + + +class ProjectProxyFilter(QtCore.QSortFilterProxyModel): + """Filters default project item.""" + def __init__(self, *args, **kwargs): + super(ProjectProxyFilter, self).__init__(*args, **kwargs) + self._filter_default = False + + def set_filter_default(self, enabled=True): + """Set if filtering of default item is enabled.""" + if enabled == self._filter_default: + return + self._filter_default = enabled + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + if not self._filter_default: + return True + + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + return source_index.data(PROJECT_NAME_ROLE) is not None class HierarchySelectionModel(QtCore.QItemSelectionModel): diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 7c71f4b451..a19031ceda 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -2,19 +2,26 @@ from Qt import QtWidgets, QtCore, QtGui from . import ( ProjectModel, + ProjectProxyFilter, HierarchyModel, HierarchySelectionModel, HierarchyView, - CreateProjectDialog + CreateProjectDialog, + PROJECT_NAME_ROLE ) -from openpype.style import load_stylesheet from .style import ResourceCache +from openpype.style import load_stylesheet from openpype.lib import is_admin_password_required from openpype.widgets import PasswordDialog from openpype import resources +from openpype.api import ( + get_project_basic_paths, + create_project_folders, + Logger +) from avalon.api import AvalonMongoDB @@ -24,12 +31,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): def __init__(self, parent=None): super(ProjectManagerWindow, self).__init__(parent) + self.log = Logger.get_logger(self.__class__.__name__) + self._initial_reset = False self._password_dialog = None self._user_passed = False self.setWindowTitle("OpenPype Project Manager") - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) # Top part of window top_part_widget = QtWidgets.QWidget(self) @@ -40,11 +49,15 @@ class ProjectManagerWindow(QtWidgets.QWidget): dbcon = AvalonMongoDB() project_model = ProjectModel(dbcon) + project_proxy = ProjectProxyFilter() + project_proxy.setSourceModel(project_model) + project_proxy.setDynamicSortFilter(True) + project_combobox = QtWidgets.QComboBox(project_widget) project_combobox.setSizeAdjustPolicy( QtWidgets.QComboBox.AdjustToContents ) - project_combobox.setModel(project_model) + project_combobox.setModel(project_proxy) project_combobox.setRootModelIndex(QtCore.QModelIndex()) style_delegate = QtWidgets.QStyledItemDelegate() project_combobox.setItemDelegate(style_delegate) @@ -57,12 +70,19 @@ class ProjectManagerWindow(QtWidgets.QWidget): create_project_btn = QtWidgets.QPushButton( "Create project...", project_widget ) + create_folders_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("asset", "default"), + "Create Starting Folders", + project_widget + ) + create_folders_btn.setEnabled(False) project_layout = QtWidgets.QHBoxLayout(project_widget) project_layout.setContentsMargins(0, 0, 0, 0) project_layout.addWidget(project_combobox, 0) project_layout.addWidget(refresh_projects_btn, 0) project_layout.addWidget(create_project_btn, 0) + project_layout.addWidget(create_folders_btn) project_layout.addStretch(1) # Helper buttons @@ -124,12 +144,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): refresh_projects_btn.clicked.connect(self._on_project_refresh) create_project_btn.clicked.connect(self._on_project_create) + create_folders_btn.clicked.connect(self._on_create_folders) project_combobox.currentIndexChanged.connect(self._on_project_change) save_btn.clicked.connect(self._on_save_click) add_asset_btn.clicked.connect(self._on_add_asset) add_task_btn.clicked.connect(self._on_add_task) self._project_model = project_model + self._project_proxy_model = project_proxy self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model @@ -139,6 +161,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): self._refresh_projects_btn = refresh_projects_btn self._project_combobox = project_combobox self._create_project_btn = create_project_btn + self._create_folders_btn = create_folders_btn self._add_asset_btn = add_asset_btn self._add_task_btn = add_task_btn @@ -147,8 +170,17 @@ class ProjectManagerWindow(QtWidgets.QWidget): self.setStyleSheet(load_stylesheet()) def _set_project(self, project_name=None): + self._create_folders_btn.setEnabled(project_name is not None) + self._project_proxy_model.set_filter_default(project_name is not None) self.hierarchy_view.set_project(project_name) + def _current_project(self): + row = self._project_combobox.currentIndex() + if row < 0: + return None + index = self._project_proxy_model.index(row, 0) + return index.data(PROJECT_NAME_ROLE) + def showEvent(self, event): super(ProjectManagerWindow, self).showEvent(event) @@ -167,6 +199,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): project_name = self._project_combobox.currentText() self._project_model.refresh() + self._project_proxy_model.sort(0, QtCore.Qt.AscendingOrder) if self._project_combobox.count() == 0: return self._set_project() @@ -176,10 +209,12 @@ class ProjectManagerWindow(QtWidgets.QWidget): if row >= 0: self._project_combobox.setCurrentIndex(row) - self._set_project(self._project_combobox.currentText()) + selected_project = self._current_project() + self._set_project(selected_project) def _on_project_change(self): - self._set_project(self._project_combobox.currentText()) + selected_project = self._current_project() + self._set_project(selected_project) def _on_project_refresh(self): self.refresh_projects() @@ -193,6 +228,30 @@ class ProjectManagerWindow(QtWidgets.QWidget): def _on_add_task(self): self.hierarchy_view.add_task() + def _on_create_folders(self): + project_name = self._current_project() + if not project_name: + return + + qm = QtWidgets.QMessageBox + ans = qm.question(self, + "OpenPype Project Manager", + "Confirm to create starting project folders?", + qm.Yes | qm.No) + if ans == qm.Yes: + try: + # Get paths based on presets + basic_paths = get_project_basic_paths(project_name) + if not basic_paths: + pass + # Invoking OpenPype API to create the project folders + create_project_folders(basic_paths, project_name) + except Exception as exc: + self.log.warning( + "Cannot create starting folders: {}".format(exc), + exc_info=True + ) + def show_message(self, message): # TODO add nicer message pop self.message_label.setText(message) diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index a48c504d59..9cd3b9a38e 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -809,7 +809,7 @@ class ProjectSettingsWidget(QtWidgets.QWidget): self.modules_manager = modules_manager - projects_widget = _ProjectListWidget(self) + projects_widget = _ProjectListWidget(self, only_active=True) roos_site_widget = RootSiteWidget( modules_manager, project_settings, self ) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index 8235cf8642..ab6b27bdaf 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -3,6 +3,7 @@ import json from Qt import QtWidgets, QtGui, QtCore from openpype.tools.settings import CHILD_OFFSET from .widgets import ExpandingWidget +from .lib import create_deffered_value_change_timer class BaseWidget(QtWidgets.QWidget): @@ -329,6 +330,20 @@ class BaseWidget(QtWidgets.QWidget): class InputWidget(BaseWidget): + def __init__(self, *args, **kwargs): + super(InputWidget, self).__init__(*args, **kwargs) + + # Input widgets have always timer available (but may not be used). + self._value_change_timer = create_deffered_value_change_timer( + self._on_value_change_timer + ) + + def start_value_timer(self): + self._value_change_timer.start() + + def _on_value_change_timer(self): + pass + def create_ui(self): if self.entity.use_label_wrap: label = None diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index c420a8cdc5..be2264340b 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -609,14 +609,23 @@ class ProjectWidget(SettingsCategoryWidget): self.project_list_widget.refresh() def _on_reset_crash(self): - self.project_list_widget.setEnabled(False) + self._set_enabled_project_list(False) super(ProjectWidget, self)._on_reset_crash() def _on_reset_success(self): - if not self.project_list_widget.isEnabled(): - self.project_list_widget.setEnabled(True) + self._set_enabled_project_list(True) super(ProjectWidget, self)._on_reset_success() + def _set_enabled_project_list(self, enabled): + if ( + enabled + and self.modify_defaults_checkbox + and self.modify_defaults_checkbox.isChecked() + ): + enabled = False + if self.project_list_widget.isEnabled() != enabled: + self.project_list_widget.setEnabled(enabled) + def _create_root_entity(self): self.entity = ProjectSettings(change_state=False) self.entity.on_change_callbacks.append(self._on_entity_change) @@ -637,7 +646,8 @@ class ProjectWidget(SettingsCategoryWidget): if self.modify_defaults_checkbox: self.modify_defaults_checkbox.setEnabled(True) - self.project_list_widget.setEnabled(True) + + self._set_enabled_project_list(True) except DefaultsNotDefined: if not self.modify_defaults_checkbox: @@ -646,7 +656,7 @@ class ProjectWidget(SettingsCategoryWidget): self.entity.set_defaults_state() self.modify_defaults_checkbox.setChecked(True) self.modify_defaults_checkbox.setEnabled(False) - self.project_list_widget.setEnabled(False) + self._set_enabled_project_list(False) except StudioDefaultsNotDefined: self.select_default_project() @@ -666,8 +676,10 @@ class ProjectWidget(SettingsCategoryWidget): def _on_modify_defaults(self): if self.modify_defaults_checkbox.isChecked(): + self._set_enabled_project_list(False) if not self.entity.is_in_defaults_state(): self.reset() else: + self._set_enabled_project_list(True) if not self.entity.is_in_studio_state(): self.reset() diff --git a/openpype/tools/settings/settings/constants.py b/openpype/tools/settings/settings/constants.py new file mode 100644 index 0000000000..5c20bf1afe --- /dev/null +++ b/openpype/tools/settings/settings/constants.py @@ -0,0 +1,16 @@ +from Qt import QtCore + + +DEFAULT_PROJECT_LABEL = "< Default >" +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 +PROJECT_IS_SELECTED_ROLE = QtCore.Qt.UserRole + 3 + + +__all__ = ( + "DEFAULT_PROJECT_LABEL", + + "PROJECT_NAME_ROLE", + "PROJECT_IS_ACTIVE_ROLE", + "PROJECT_IS_SELECTED_ROLE" +) diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index ba86fe82dd..cfb9d4a4b1 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -3,6 +3,7 @@ from uuid import uuid4 from Qt import QtWidgets, QtCore, QtGui from .base import BaseWidget +from .lib import create_deffered_value_change_timer from .widgets import ( ExpandingWidget, IconButton @@ -284,6 +285,10 @@ class ModifiableDictItem(QtWidgets.QWidget): self.confirm_btn = None + self._key_change_timer = create_deffered_value_change_timer( + self._on_timeout + ) + if collapsible_key: self.create_collapsible_ui() else: @@ -516,6 +521,10 @@ class ModifiableDictItem(QtWidgets.QWidget): if self.ignore_input_changes: return + self._key_change_timer.start() + + def _on_timeout(self): + key = self.key_value() is_key_duplicated = self.entity_widget.validate_key_duplication( self.temp_key, key, self ) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index b2b129da86..a28bee8d36 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -21,6 +21,7 @@ from .base import ( BaseWidget, InputWidget ) +from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET @@ -399,26 +400,60 @@ class TextWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + def _on_value_change_timer(self): self.entity.set(self.input_value()) class NumberWidget(InputWidget): + _slider_widget = None + def _add_inputs_to_layout(self): kwargs = { "minimum": self.entity.minimum, "maximum": self.entity.maximum, - "decimal": self.entity.decimal + "decimal": self.entity.decimal, + "steps": self.entity.steps } self.input_field = NumberSpinBox(self.content_widget, **kwargs) + input_field_stretch = 1 + + slider_multiplier = 1 + if self.entity.show_slider: + # Slider can't handle float numbers so all decimals are converted + # to integer range. + slider_multiplier = 10 ** self.entity.decimal + slider_widget = NiceSlider(QtCore.Qt.Horizontal, self) + slider_widget.setRange( + int(self.entity.minimum * slider_multiplier), + int(self.entity.maximum * slider_multiplier) + ) + if self.entity.steps is not None: + slider_widget.setSingleStep( + self.entity.steps * slider_multiplier + ) + + self.content_layout.addWidget(slider_widget, 1) + + slider_widget.valueChanged.connect(self._on_slider_change) + + self._slider_widget = slider_widget + + input_field_stretch = 0 + + self._slider_multiplier = slider_multiplier self.setFocusProxy(self.input_field) - self.content_layout.addWidget(self.input_field, 1) + self.content_layout.addWidget(self.input_field, input_field_stretch) self.input_field.valueChanged.connect(self._on_value_change) self.input_field.focused_in.connect(self._on_input_focus) + self._ignore_slider_change = False + self._ignore_input_change = False + def _on_input_focus(self): self.focused_in() @@ -429,10 +464,28 @@ class NumberWidget(InputWidget): def set_entity_value(self): self.input_field.setValue(self.entity.value) + def _on_slider_change(self, new_value): + if self._ignore_slider_change: + return + + self._ignore_input_change = True + self.input_field.setValue(new_value / self._slider_multiplier) + self._ignore_input_change = False + def _on_value_change(self): if self.ignore_input_changes: return - self.entity.set(self.input_field.value()) + + self.start_value_timer() + + def _on_value_change_timer(self): + value = self.input_field.value() + if self._slider_widget is not None and not self._ignore_input_change: + self._ignore_slider_change = True + self._slider_widget.setValue(value * self._slider_multiplier) + self._ignore_slider_change = False + + self.entity.set(value) class RawJsonInput(SettingsPlainTextEdit): @@ -523,7 +576,9 @@ class RawJsonWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + def _on_value_change_timer(self): self._is_invalid = self.input_field.has_invalid_value() if not self.is_invalid: self.entity.set(self.input_field.json_value()) @@ -738,4 +793,7 @@ class PathInputWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + + def _on_value_change_timer(self): self.entity.set(self.input_value()) diff --git a/openpype/tools/settings/settings/lib.py b/openpype/tools/settings/settings/lib.py new file mode 100644 index 0000000000..577aaa5671 --- /dev/null +++ b/openpype/tools/settings/settings/lib.py @@ -0,0 +1,18 @@ +from Qt import QtCore + +# Offset of value change trigger in ms +VALUE_CHANGE_OFFSET_MS = 300 + + +def create_deffered_value_change_timer(callback): + """Deffer value change callback. + + UI won't trigger all callbacks on each value change but after predefined + time. Timer is reset on each start so callback is triggered after user + finish editing. + """ + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.setInterval(VALUE_CHANGE_OFFSET_MS) + timer.timeout.connect(callback) + return timer diff --git a/openpype/tools/settings/settings/style/__init__.py b/openpype/tools/settings/settings/style/__init__.py index 5a57642ee1..f1d9829a04 100644 --- a/openpype/tools/settings/settings/style/__init__.py +++ b/openpype/tools/settings/settings/style/__init__.py @@ -10,4 +10,4 @@ def load_stylesheet(): def app_icon_path(): - return resources.pype_icon_filepath() + return resources.get_openpype_icon_filepath() diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css index 250c15063f..b77b575204 100644 --- a/openpype/tools/settings/settings/style/style.css +++ b/openpype/tools/settings/settings/style/style.css @@ -114,6 +114,30 @@ QPushButton[btn-type="expand-toggle"] { background: #21252B; } +/* SLider */ +QSlider::groove { + border: 1px solid #464b54; + border-radius: 0.3em; +} +QSlider::groove:horizontal { + height: 8px; +} +QSlider::groove:vertical { + width: 8px; +} +QSlider::handle { + width: 10px; + height: 10px; + + border-radius: 5px; +} +QSlider::handle:horizontal { + margin: -2px 0; +} +QSlider::handle:vertical { + margin: 0 -2px; +} + #GroupWidget { border-bottom: 1px solid #21252B; } @@ -122,6 +146,15 @@ QPushButton[btn-type="expand-toggle"] { border: 1px solid #464b54; background: #21252B; } + +#ProjectListWidget QListView:disabled { + background: #282C34; +} + +#ProjectListWidget QListView::item:disabled { + color: #4e5254; +} + #ProjectListWidget QLabel { background: transparent; font-weight: bold; @@ -225,8 +258,6 @@ QTabBar::tab:!selected:hover { background: #333840; } - - QTabBar::tab:first:selected { margin-left: 0; } @@ -381,12 +412,15 @@ QHeaderView::section { font-weight: bold; } -QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { +QAbstractItemView::item:pressed { background: #78879b; color: #FFFFFF; } -QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { +QAbstractItemView::item:selected:active { + background: #3d8ec9; +} +QAbstractItemView::item:selected:!active { background: #3d8ec9; } diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b821c3bb2c..710884e9e5 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -7,6 +7,12 @@ from avalon.mongodb import ( ) from openpype.settings.lib import get_system_settings +from .constants import ( + DEFAULT_PROJECT_LABEL, + PROJECT_NAME_ROLE, + PROJECT_IS_ACTIVE_ROLE, + PROJECT_IS_SELECTED_ROLE +) class SettingsLineEdit(QtWidgets.QLineEdit): @@ -92,11 +98,15 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): min_value = kwargs.pop("minimum", -99999) max_value = kwargs.pop("maximum", 99999) decimals = kwargs.pop("decimal", 0) + steps = kwargs.pop("steps", None) + super(NumberSpinBox, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setDecimals(decimals) self.setMinimum(min_value) self.setMaximum(max_value) + if steps is not None: + self.setSingleStep(steps) def focusInEvent(self, event): super(NumberSpinBox, self).focusInEvent(event) @@ -598,6 +608,65 @@ class NiceCheckbox(QtWidgets.QFrame): return super(NiceCheckbox, self).mouseReleaseEvent(event) +class ProjectModel(QtGui.QStandardItemModel): + def __init__(self, only_active, *args, **kwargs): + super(ProjectModel, self).__init__(*args, **kwargs) + + self.dbcon = None + + self._only_active = only_active + self._default_item = None + self._items_by_name = {} + + def set_dbcon(self, dbcon): + self.dbcon = dbcon + + def refresh(self): + new_items = [] + if self._default_item is None: + item = QtGui.QStandardItem(DEFAULT_PROJECT_LABEL) + item.setData(None, PROJECT_NAME_ROLE) + item.setData(True, PROJECT_IS_ACTIVE_ROLE) + item.setData(False, PROJECT_IS_SELECTED_ROLE) + new_items.append(item) + self._default_item = item + + project_names = set() + if self.dbcon is not None: + for project_doc in self.dbcon.projects( + projection={"name": 1, "data.active": 1}, + only_active=self._only_active + ): + project_name = project_doc["name"] + project_names.add(project_name) + if project_name in self._items_by_name: + item = self._items_by_name[project_name] + else: + item = QtGui.QStandardItem(project_name) + + self._items_by_name[project_name] = item + new_items.append(item) + + is_active = project_doc.get("data", {}).get("active", True) + item.setData(project_name, PROJECT_NAME_ROLE) + item.setData(is_active, PROJECT_IS_ACTIVE_ROLE) + item.setData(False, PROJECT_IS_SELECTED_ROLE) + + if not is_active: + font = item.font() + font.setItalic(True) + item.setFont(font) + + root_item = self.invisibleRootItem() + for project_name in tuple(self._items_by_name.keys()): + if project_name not in project_names: + item = self._items_by_name.pop(project_name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + class ProjectListView(QtWidgets.QListView): left_mouse_released_at = QtCore.Signal(QtCore.QModelIndex) @@ -608,11 +677,51 @@ class ProjectListView(QtWidgets.QListView): super(ProjectListView, self).mouseReleaseEvent(event) +class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + self._enable_filter = True + + def lessThan(self, left_index, right_index): + if left_index.data(PROJECT_NAME_ROLE) is None: + return True + + if right_index.data(PROJECT_NAME_ROLE) is None: + return False + + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active == left_is_active: + return super(ProjectSortFilterProxy, self).lessThan( + left_index, right_index + ) + + if left_is_active: + return True + return False + + def filterAcceptsRow(self, source_row, source_parent): + if not self._enable_filter: + return True + + index = self.sourceModel().index(source_row, 0, source_parent) + is_active = bool(index.data(self.filterRole())) + is_selected = bool(index.data(PROJECT_IS_SELECTED_ROLE)) + + return is_active or is_selected + + def is_filter_enabled(self): + return self._enable_filter + + def set_filter_enabled(self, value): + self._enable_filter = value + self.invalidateFilter() + + class ProjectListWidget(QtWidgets.QWidget): - default = "< Default >" project_changed = QtCore.Signal() - def __init__(self, parent): + def __init__(self, parent, only_active=False): self._parent = parent self.current_project = None @@ -621,8 +730,14 @@ class ProjectListWidget(QtWidgets.QWidget): self.setObjectName("ProjectListWidget") label_widget = QtWidgets.QLabel("Projects") + project_list = ProjectListView(self) - project_list.setModel(QtGui.QStandardItemModel()) + project_model = ProjectModel(only_active) + project_proxy = ProjectSortFilterProxy() + + project_proxy.setFilterRole(PROJECT_IS_ACTIVE_ROLE) + project_proxy.setSourceModel(project_model) + project_list.setModel(project_proxy) # Do not allow editing project_list.setEditTriggers( @@ -636,9 +751,26 @@ class ProjectListWidget(QtWidgets.QWidget): layout.addWidget(label_widget, 0) layout.addWidget(project_list, 1) + if only_active: + inactive_chk = None + else: + inactive_chk = QtWidgets.QCheckBox(" Show Inactive Projects ") + inactive_chk.setChecked(not project_proxy.is_filter_enabled()) + + layout.addSpacing(5) + layout.addWidget(inactive_chk, 0) + layout.addSpacing(5) + + inactive_chk.stateChanged.connect(self.on_inactive_vis_changed) + project_list.left_mouse_released_at.connect(self.on_item_clicked) + self._default_project_item = None + self.project_list = project_list + self.project_proxy = project_proxy + self.project_model = project_model + self.inactive_chk = inactive_chk self.dbcon = None @@ -675,24 +807,38 @@ class ProjectListWidget(QtWidgets.QWidget): else: self.select_project(self.current_project) + def on_inactive_vis_changed(self): + if self.inactive_chk is None: + # should not happen. + return + + enable_filter = not self.inactive_chk.isChecked() + self.project_proxy.set_filter_enabled(enable_filter) + def validate_context_change(self): return not self._parent.entity.has_unsaved_changes def project_name(self): - if self.current_project == self.default: + if self.current_project == DEFAULT_PROJECT_LABEL: return None return self.current_project def select_default_project(self): - self.select_project(self.default) + self.select_project(DEFAULT_PROJECT_LABEL) def select_project(self, project_name): - model = self.project_list.model() + model = self.project_model + proxy = self.project_proxy + found_items = model.findItems(project_name) if not found_items: - found_items = model.findItems(self.default) + found_items = model.findItems(DEFAULT_PROJECT_LABEL) index = model.indexFromItem(found_items[0]) + model.setData(index, True, PROJECT_IS_SELECTED_ROLE) + + index = proxy.mapFromSource(index) + self.project_list.selectionModel().clear() self.project_list.selectionModel().setCurrentIndex( index, QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent @@ -704,11 +850,6 @@ class ProjectListWidget(QtWidgets.QWidget): selected_project = index.data(QtCore.Qt.DisplayRole) break - model = self.project_list.model() - model.clear() - - items = [self.default] - mongo_url = os.environ["OPENPYPE_MONGO"] # Force uninstall of whole avalon connection if url does not match @@ -726,17 +867,10 @@ class ProjectListWidget(QtWidgets.QWidget): self.dbcon = None self.current_project = None - if self.dbcon: - database = self.dbcon.database - for project_name in database.collection_names(): - project_doc = database[project_name].find_one( - {"type": "project"}, - {"name": 1} - ) - if project_doc: - items.append(project_doc["name"]) - for item in items: - model.appendRow(QtGui.QStandardItem(item)) + self.project_model.set_dbcon(self.dbcon) + self.project_model.refresh() + + self.project_proxy.sort(0) self.select_project(selected_project) diff --git a/openpype/tools/standalonepublish/app.py b/openpype/tools/standalonepublish/app.py index 81a53c52b8..2ce757f773 100644 --- a/openpype/tools/standalonepublish/app.py +++ b/openpype/tools/standalonepublish/app.py @@ -231,7 +231,7 @@ def main(): qt_app = QtWidgets.QApplication([]) # app.setQuitOnLastWindowClosed(False) qt_app.setStyleSheet(style.load_stylesheet()) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) qt_app.setWindowIcon(icon) def signal_handler(sig, frame): diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index c39d71b055..eb22883c11 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -273,8 +273,11 @@ class AssetWidget(QtWidgets.QWidget): def _set_projects(self): project_names = list() - for project in self.dbcon.projects(): - project_name = project.get("name") + + for doc in self.dbcon.projects(projection={"name": 1}, + only_active=True): + + project_name = doc.get("name") if project_name: project_names.append(project_name) @@ -299,7 +302,9 @@ class AssetWidget(QtWidgets.QWidget): def on_project_change(self): projects = list() - for project in self.dbcon.projects(): + + for project in self.dbcon.projects(projection={"name": 1}, + only_active=True): projects.append(project['name']) project_name = self.combo_projects.currentText() if project_name in projects: diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py index 2965463c37..2ca625f307 100644 --- a/openpype/tools/tray/pype_info_widget.py +++ b/openpype/tools/tray/pype_info_widget.py @@ -214,7 +214,7 @@ class PypeInfoWidget(QtWidgets.QWidget): self.setStyleSheet(style.load_stylesheet()) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("OpenPype info") diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index ed66f1a80f..3050e206ce 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -17,6 +17,11 @@ from openpype.api import ( from openpype.lib import get_pype_execute_args from openpype.modules import TrayModulesManager from openpype import style +from openpype.settings import ( + SystemSettings, + ProjectSettings, + DefaultsNotDefined +) from .pype_info_widget import PypeInfoWidget @@ -114,6 +119,54 @@ class TrayManager: self.main_thread_timer = main_thread_timer + # For storing missing settings dialog + self._settings_validation_dialog = None + + self.execute_in_main_thread(self._startup_validations) + + def _startup_validations(self): + """Run possible startup validations.""" + self._validate_settings_defaults() + + def _validate_settings_defaults(self): + valid = True + try: + SystemSettings() + ProjectSettings() + + except DefaultsNotDefined: + valid = False + + if valid: + return + + title = "Settings miss default values" + msg = ( + "Your OpenPype will not work as expected! \n" + "Some default values in settigs are missing. \n\n" + "Please contact OpenPype team." + ) + msg_box = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + title, + msg, + QtWidgets.QMessageBox.Ok, + flags=QtCore.Qt.Dialog + ) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + msg_box.setWindowIcon(icon) + msg_box.setStyleSheet(style.load_stylesheet()) + msg_box.buttonClicked.connect(self._post_validate_settings_defaults) + + self._settings_validation_dialog = msg_box + + msg_box.show() + + def _post_validate_settings_defaults(self): + widget = self._settings_validation_dialog + self._settings_validation_dialog = None + widget.deleteLater() + def show_tray_message(self, title, message, icon=None, msecs=None): """Show tray message. @@ -200,7 +253,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): doubleclick_time_ms = 100 def __init__(self, parent): - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) super(SystemTrayIcon, self).__init__(icon, parent) @@ -236,6 +289,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): self._click_timer = click_timer self._doubleclick = False + self._click_pos = None def _click_timer_timeout(self): self._click_timer.stop() @@ -248,13 +302,17 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): self._show_context_menu() def _show_context_menu(self): - pos = QtGui.QCursor().pos() + pos = self._click_pos + self._click_pos = None + if pos is None: + pos = QtGui.QCursor().pos() self.contextMenu().popup(pos) def on_systray_activated(self, reason): # show contextMenu if left click if reason == QtWidgets.QSystemTrayIcon.Trigger: if self.tray_man.doubleclick_callback: + self._click_pos = QtGui.QCursor().pos() self._click_timer.start() else: self._show_context_menu() @@ -308,7 +366,7 @@ class PypeTrayApplication(QtWidgets.QApplication): splash_widget.hide() def set_splash(self): - splash_pix = QtGui.QPixmap(resources.pype_splash_filepath()) + splash_pix = QtGui.QPixmap(resources.get_openpype_splash_filepath()) splash = QtWidgets.QSplashScreen(splash_pix) splash.setMask(splash_pix.mask()) splash.setEnabled(False) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py new file mode 100644 index 0000000000..1827bc7e9b --- /dev/null +++ b/openpype/tools/utils/delegates.py @@ -0,0 +1,449 @@ +import time +from datetime import datetime +import logging +import numbers + +import Qt +from Qt import QtWidgets, QtGui, QtCore + +from avalon.lib import HeroVersionType +from .models import ( + AssetModel, + TreeModel +) +from . import lib + +if Qt.__binding__ == "PySide": + from PySide.QtGui import QStyleOptionViewItemV4 +elif Qt.__binding__ == "PyQt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +log = logging.getLogger(__name__) + + +class AssetDelegate(QtWidgets.QItemDelegate): + bar_height = 3 + + def sizeHint(self, option, index): + result = super(AssetDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + # Qt4 compat + if Qt.__binding__ in ("PySide", "PyQt4"): + option = QStyleOptionViewItemV4(option) + + painter.save() + + item_rect = QtCore.QRect(option.rect) + item_rect.setHeight(option.rect.height() - self.bar_height) + + subset_colors = index.data(AssetModel.subsetColorsRole) + subset_colors_width = 0 + if subset_colors: + subset_colors_width = option.rect.width() / len(subset_colors) + + subset_rects = [] + counter = 0 + for subset_c in subset_colors: + new_color = None + new_rect = None + if subset_c: + new_color = QtGui.QColor(*subset_c) + + new_rect = QtCore.QRect( + option.rect.left() + (counter * subset_colors_width), + option.rect.top() + ( + option.rect.height() - self.bar_height + ), + subset_colors_width, + self.bar_height + ) + subset_rects.append((new_color, new_rect)) + counter += 1 + + # Background + bg_color = QtGui.QColor(60, 60, 60) + if option.state & QtWidgets.QStyle.State_Selected: + if len(subset_colors) == 0: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color.setRgb(70, 70, 70) + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color.setAlpha(100) + else: + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + # painter.fillRect( + # item_rect, + # QtGui.QBrush(bg_color) + # ) + pen = painter.pen() + pen.setStyle(QtCore.Qt.NoPen) + pen.setWidth(0) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(bg_color)) + painter.drawRoundedRect(option.rect, 3, 3) + + if option.state & QtWidgets.QStyle.State_Selected: + for color, subset_rect in subset_rects: + if not color or not subset_rect: + continue + painter.fillRect(subset_rect, QtGui.QBrush(color)) + + painter.restore() + painter.save() + + # Icon + icon_index = index.model().index( + index.row(), index.column(), index.parent() + ) + # - Default icon_rect if not icon + icon_rect = QtCore.QRect( + item_rect.left(), + item_rect.top(), + # To make sure it's same size all the time + option.rect.height() - self.bar_height, + option.rect.height() - self.bar_height + ) + icon = index.model().data(icon_index, QtCore.Qt.DecorationRole) + + if icon: + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + + if isinstance(icon, QtGui.QPixmap): + icon = QtGui.QIcon(icon) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QColor): + pixmap = QtGui.QPixmap(option.decorationSize) + pixmap.fill(icon) + icon = QtGui.QIcon(pixmap) + + elif isinstance(icon, QtGui.QImage): + icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon)) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QIcon): + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + actualSize = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actualSize.width()), + min(option.decorationSize.height(), actualSize.height()) + ) + + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + icon.paint( + painter, icon_rect, + QtCore.Qt.AlignLeft, mode, state + ) + + # Text + text_rect = QtCore.QRect( + icon_rect.left() + icon_rect.width() + 2, + item_rect.top(), + item_rect.width(), + item_rect.height() + ) + + painter.drawText( + text_rect, QtCore.Qt.AlignVCenter, + index.data(QtCore.Qt.DisplayRole) + ) + + painter.restore() + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + first_run = False + lock = False + + def __init__(self, dbcon, *args, **kwargs): + self.dbcon = dbcon + super(VersionDelegate, self).__init__(*args, **kwargs) + + def displayText(self, value, locale): + if isinstance(value, HeroVersionType): + return lib.format_version(value, True) + assert isinstance(value, numbers.Integral), ( + "Version is not integer. \"{}\" {}".format(value, str(type(value))) + ) + return lib.format_version(value) + + def paint(self, painter, option, index): + fg_color = index.data(QtCore.Qt.ForegroundRole) + if fg_color: + if isinstance(fg_color, QtGui.QBrush): + fg_color = fg_color.color() + elif isinstance(fg_color, QtGui.QColor): + pass + else: + fg_color = None + + if not fg_color: + return super(VersionDelegate, self).paint(painter, option, index) + + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + style.drawControl( + style.CE_ItemViewItem, option, painter, option.widget + ) + + painter.save() + + text = self.displayText( + index.data(QtCore.Qt.DisplayRole), option.locale + ) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + text_rect = style.subElementRect(style.SE_ItemViewItemText, option) + text_margin = style.proxy().pixelMetric( + style.PM_FocusFrameHMargin, option, option.widget + ) + 1 + + painter.drawText( + text_rect.adjusted(text_margin, 0, - text_margin, 0), + option.displayAlignment, + text + ) + + painter.restore() + + def createEditor(self, parent, option, index): + item = index.data(TreeModel.ItemRole) + if item.get("isGroup") or item.get("isMerged"): + return + + editor = QtWidgets.QComboBox(parent) + + def commit_data(): + if not self.first_run: + self.commitData.emit(editor) # Update model data + self.version_changed.emit() # Display model data + editor.currentIndexChanged.connect(commit_data) + + self.first_run = True + self.lock = False + + return editor + + def setEditorData(self, editor, index): + if self.lock: + # Only set editor data once per delegation + return + + editor.clear() + + # Current value of the index + item = index.data(TreeModel.ItemRole) + value = index.data(QtCore.Qt.DisplayRole) + if item["version_document"]["type"] != "hero_version": + assert isinstance(value, numbers.Integral), ( + "Version is not integer" + ) + + # Add all available versions to the editor + parent_id = item["version_document"]["parent"] + version_docs = list(self.dbcon.find( + { + "type": "version", + "parent": parent_id + }, + sort=[("name", 1)] + )) + + hero_version_doc = self.dbcon.find_one( + { + "type": "hero_version", + "parent": parent_id + }, { + "name": 1, + "data.tags": 1, + "version_id": 1 + } + ) + + doc_for_hero_version = None + + selected = None + items = [] + for version_doc in version_docs: + version_tags = version_doc["data"].get("tags") or [] + if "deleted" in version_tags: + continue + + if ( + hero_version_doc + and doc_for_hero_version is None + and hero_version_doc["version_id"] == version_doc["_id"] + ): + doc_for_hero_version = version_doc + + label = lib.format_version(version_doc["name"]) + item = QtGui.QStandardItem(label) + item.setData(version_doc, QtCore.Qt.UserRole) + items.append(item) + + if version_doc["name"] == value: + selected = item + + if hero_version_doc and doc_for_hero_version: + version_name = doc_for_hero_version["name"] + label = lib.format_version(version_name, True) + if isinstance(value, HeroVersionType): + index = len(version_docs) + hero_version_doc["name"] = HeroVersionType(version_name) + + item = QtGui.QStandardItem(label) + item.setData(hero_version_doc, QtCore.Qt.UserRole) + items.append(item) + + # Reverse items so latest versions be upper + items = list(reversed(items)) + for item in items: + editor.model().appendRow(item) + + index = 0 + if selected: + index = selected.row() + + # Will trigger index-change signal + editor.setCurrentIndex(index) + self.first_run = False + self.lock = True + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + version = editor.itemData(editor.currentIndex()) + model.setData(index, version["name"]) + + +def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"): + """Parse datetime to readable timestamp + + Within first ten seconds: + - "just now", + Within first minute ago: + - "%S seconds ago" + Within one hour ago: + - "%M minutes ago". + Within one day ago: + - "%H:%M hours ago" + Else: + "%Y-%m-%d %H:%M:%S" + + """ + + assert isinstance(t, datetime) + if now is None: + now = datetime.now() + assert isinstance(now, datetime) + diff = now - t + + second_diff = diff.seconds + day_diff = diff.days + + # future (consider as just now) + if day_diff < 0: + return "just now" + + # history + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(second_diff) + " seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(second_diff // 60) + " minutes ago" + if second_diff < 86400: + minutes = (second_diff % 3600) // 60 + hours = second_diff // 3600 + return "{0}:{1:02d} hours ago".format(hours, minutes) + + return t.strftime(strftime) + + +def pretty_timestamp(t, now=None): + """Parse timestamp to user readable format + + >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z") + 'just now' + + >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z") + '2:01 hours ago' + + Args: + t (str): The time string to parse. + now (str, optional) + + Returns: + str: human readable "recent" date. + + """ + + if now is not None: + try: + now = time.strptime(now, "%Y%m%dT%H%M%SZ") + now = datetime.fromtimestamp(time.mktime(now)) + except ValueError as e: + log.warning("Can't parse 'now' time format: {0} {1}".format(t, e)) + return None + + if isinstance(t, float): + dt = datetime.fromtimestamp(t) + else: + # Parse the time format as if it is `str` result from + # `pyblish.lib.time()` which usually is stored in Avalon database. + try: + t = time.strptime(t, "%Y%m%dT%H%M%SZ") + except ValueError as e: + log.warning("Can't parse time format: {0} {1}".format(t, e)) + return None + dt = datetime.fromtimestamp(time.mktime(t)) + + # prettify + return pretty_date(dt, now=now) + + +class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that displays a timestamp as a pretty date. + + This displays dates like `pretty_date`. + + """ + + def displayText(self, value, locale): + + if value is None: + # Ignore None value + return + + return pretty_timestamp(value) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py new file mode 100644 index 0000000000..d01dbbd169 --- /dev/null +++ b/openpype/tools/utils/lib.py @@ -0,0 +1,625 @@ +import os +import sys +import contextlib +import collections + +from Qt import QtWidgets, QtCore, QtGui + +import avalon.api +from avalon import style +from avalon.vendor import qtawesome + +from openpype.api import get_project_settings +from openpype.lib import filter_profiles + + +def format_version(value, hero_version=False): + """Formats integer to displayable version name""" + label = "v{0:03d}".format(value) + if not hero_version: + return label + return "[{}]".format(label) + + +@contextlib.contextmanager +def application(): + app = QtWidgets.QApplication.instance() + + if not app: + print("Starting new QApplication..") + app = QtWidgets.QApplication(sys.argv) + yield app + app.exec_() + else: + print("Using existing QApplication..") + yield app + + +def defer(delay, func): + """Append artificial delay to `func` + + This aids in keeping the GUI responsive, but complicates logic + when producing tests. To combat this, the environment variable ensures + that every operation is synchonous. + + Arguments: + delay (float): Delay multiplier; default 1, 0 means no delay + func (callable): Any callable + + """ + + delay *= float(os.getenv("PYBLISH_DELAY", 1)) + if delay > 0: + return QtCore.QTimer.singleShot(delay, func) + else: + return func() + + +class SharedObjects: + jobs = {} + + +def schedule(func, time, channel="default"): + """Run `func` at a later `time` in a dedicated `channel` + + Given an arbitrary function, call this function after a given + timeout. It will ensure that only one "job" is running within + the given channel at any one time and cancel any currently + running job if a new job is submitted before the timeout. + + """ + + try: + SharedObjects.jobs[channel].stop() + except (AttributeError, KeyError, RuntimeError): + pass + + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(func) + timer.start(time) + + SharedObjects.jobs[channel] = timer + + +@contextlib.contextmanager +def dummy(): + """Dummy context manager + + Usage: + >> with some_context() if False else dummy(): + .. pass + + """ + yield + + +def iter_model_rows(model, column, include_root=False): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +@contextlib.contextmanager +def preserve_states(tree_view, + column=0, + role=None, + preserve_expanded=True, + preserve_selection=True, + expanded_role=QtCore.Qt.DisplayRole, + selection_role=QtCore.Qt.DisplayRole): + """Preserves row selection in QTreeView by column's data role. + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + Returns: + None + """ + # When `role` is set then override both expanded and selection roles + if role: + expanded_role = role + selection_role = role + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + expanded = set() + + if preserve_expanded: + for index in iter_model_rows( + model, column=column, include_root=False + ): + if tree_view.isExpanded(index): + value = index.data(expanded_role) + expanded.add(value) + + selected = None + + if preserve_selection: + selected_rows = selection_model.selectedRows() + if selected_rows: + selected = set(row.data(selection_role) for row in selected_rows) + + try: + yield + finally: + if expanded: + for index in iter_model_rows( + model, column=0, include_root=False + ): + value = index.data(expanded_role) + is_expanded = value in expanded + # skip if new index was created meanwhile + if is_expanded is None: + continue + tree_view.setExpanded(index, is_expanded) + + if selected: + # Go through all indices, select the ones with similar data + for index in iter_model_rows( + model, column=column, include_root=False + ): + value = index.data(selection_role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + +@contextlib.contextmanager +def preserve_expanded_rows(tree_view, column=0, role=None): + """Preserves expanded row in QTreeView by column's data role. + + This function is created to maintain the expand vs collapse status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + Arguments: + tree_view (QWidgets.QTreeView): the tree view which is + nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + if role is None: + role = QtCore.Qt.DisplayRole + model = tree_view.model() + + expanded = set() + + for index in iter_model_rows(model, column=column, include_root=False): + if tree_view.isExpanded(index): + value = index.data(role) + expanded.add(value) + + try: + yield + finally: + if not expanded: + return + + for index in iter_model_rows(model, column=column, include_root=False): + value = index.data(role) + state = value in expanded + if state: + tree_view.expand(index) + else: + tree_view.collapse(index) + + +@contextlib.contextmanager +def preserve_selection(tree_view, column=0, role=None, current_index=True): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + if role is None: + role = QtCore.Qt.DisplayRole + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + if current_index: + current_index_value = tree_view.currentIndex().data(role) + else: + current_index_value = None + + selected_rows = selection_model.selectedRows() + if not selected_rows: + yield + return + + selected = set(row.data(role) for row in selected_rows) + try: + yield + finally: + if not selected: + return + + # Go through all indices, select the ones with similar data + for index in iter_model_rows(model, column=column, include_root=False): + value = index.data(role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + if current_index_value and value == current_index_value: + selection_model.setCurrentIndex( + index, selection_model.NoUpdate + ) + + +class FamilyConfigCache: + default_color = "#0091B2" + _default_icon = None + + def __init__(self, dbcon): + self.dbcon = dbcon + self.family_configs = {} + self._family_filters_set = False + self._require_refresh = True + + @classmethod + def default_icon(cls): + if cls._default_icon is None: + cls._default_icon = qtawesome.icon( + "fa.folder", color=cls.default_color + ) + return cls._default_icon + + def family_config(self, family_name): + """Get value from config with fallback to default""" + if self._require_refresh: + self._refresh() + + item = self.family_configs.get(family_name) + if not item: + item = { + "icon": self.default_icon() + } + if self._family_filters_set: + item["state"] = False + return item + + def refresh(self, force=False): + self._require_refresh = True + + if force: + self._refresh() + + def _refresh(self): + """Get the family configurations from the database + + The configuration must be stored on the project under `config`. + For example: + + {"config": { + "families": [ + {"name": "avalon.camera", label: "Camera", "icon": "photo"}, + {"name": "avalon.anim", label: "Animation", "icon": "male"}, + ] + }} + + It is possible to override the default behavior and set specific + families checked. For example we only want the families imagesequence + and camera to be visible in the Loader. + """ + self._require_refresh = False + self._family_filters_set = False + + self.family_configs.clear() + # Skip if we're not in host context + if not avalon.api.registered_host(): + return + + # Update the icons from the project configuration + project_name = os.environ.get("AVALON_PROJECT") + asset_name = os.environ.get("AVALON_ASSET") + task_name = os.environ.get("AVALON_TASK") + if not all((project_name, asset_name, task_name)): + return + + matching_item = None + project_settings = get_project_settings(project_name) + profiles = ( + project_settings + ["global"] + ["tools"] + ["loader"] + ["family_filter_profiles"] + ) + if profiles: + asset_doc = self.dbcon.find_one( + {"type": "asset", "name": asset_name}, + {"data.tasks": True} + ) + tasks_info = asset_doc.get("data", {}).get("tasks") or {} + task_type = tasks_info.get(task_name, {}).get("type") + profiles_filter = { + "task_types": task_type, + "hosts": os.environ["AVALON_APP"] + } + matching_item = filter_profiles(profiles, profiles_filter) + + families = [] + if matching_item: + families = matching_item["filter_families"] + + if not families: + return + + self._family_filters_set = True + + # Replace icons with a Qt icon we can use in the user interfaces + for family in families: + family_info = { + "name": family, + "icon": self.default_icon(), + "state": True + } + + self.family_configs[family] = family_info + + +class GroupsConfig: + # Subset group item's default icon and order + _default_group_config = None + + def __init__(self, dbcon): + self.dbcon = dbcon + self.groups = {} + + @classmethod + def default_group_config(cls): + if cls._default_group_config is None: + cls._default_group_config = { + "icon": qtawesome.icon( + "fa.object-group", + color=style.colors.default + ), + "order": 0 + } + return cls._default_group_config + + def refresh(self): + """Get subset group configurations from the database + + The 'group' configuration must be stored in the project `config` field. + See schema `config-1.0.json` + + """ + # Clear cached groups + self.groups.clear() + + group_configs = [] + project_name = self.dbcon.Session.get("AVALON_PROJECT") + if project_name: + # Get pre-defined group name and apperance from project config + project_doc = self.dbcon.find_one( + {"type": "project"}, + projection={"config.groups": True} + ) + + if project_doc: + group_configs = project_doc["config"].get("groups") or [] + else: + print("Project not found! \"{}\"".format(project_name)) + + # Build pre-defined group configs + for config in group_configs: + name = config["name"] + icon = "fa." + config.get("icon", "object-group") + color = config.get("color", style.colors.default) + order = float(config.get("order", 0)) + + self.groups[name] = { + "icon": qtawesome.icon(icon, color=color), + "order": order + } + + return self.groups + + def ordered_groups(self, group_names): + # default order zero included + _orders = set([0]) + for config in self.groups.values(): + _orders.add(config["order"]) + + # Remap order to list index + orders = sorted(_orders) + + _groups = list() + for name in group_names: + # Get group config + config = self.groups.get(name) or self.default_group_config() + # Base order + remapped_order = orders.index(config["order"]) + + data = { + "name": name, + "icon": config["icon"], + "_order": remapped_order, + } + + _groups.append(data) + + # Sort by tuple (base_order, name) + # If there are multiple groups in same order, will sorted by name. + ordered_groups = sorted( + _groups, key=lambda _group: (_group.pop("_order"), _group["name"]) + ) + + total = len(ordered_groups) + order_temp = "%0{}d".format(len(str(total))) + + # Update sorted order to config + for index, group_data in enumerate(ordered_groups): + order = index + inverse_order = total - index + + # Format orders into fixed length string for groups sorting + group_data["order"] = order_temp % order + group_data["inverseOrder"] = order_temp % inverse_order + + return ordered_groups + + def active_groups(self, asset_ids, include_predefined=True): + """Collect all active groups from each subset""" + # Collect groups from subsets + group_names = set( + self.dbcon.distinct( + "data.subsetGroup", + {"type": "subset", "parent": {"$in": asset_ids}} + ) + ) + if include_predefined: + # Ensure all predefined group configs will be included + group_names.update(self.groups.keys()) + + return self.ordered_groups(group_names) + + def split_subsets_for_groups(self, subset_docs, grouping): + """Collect all active groups from each subset""" + subset_docs_without_group = collections.defaultdict(list) + subset_docs_by_group = collections.defaultdict(dict) + for subset_doc in subset_docs: + subset_name = subset_doc["name"] + if grouping: + group_name = subset_doc["data"].get("subsetGroup") + if group_name: + if subset_name not in subset_docs_by_group[group_name]: + subset_docs_by_group[group_name][subset_name] = [] + + subset_docs_by_group[group_name][subset_name].append( + subset_doc + ) + continue + + subset_docs_without_group[subset_name].append(subset_doc) + + ordered_groups = self.ordered_groups(subset_docs_by_group.keys()) + + return ordered_groups, subset_docs_without_group, subset_docs_by_group + + +def create_qthread(func, *args, **kwargs): + class Thread(QtCore.QThread): + def run(self): + func(*args, **kwargs) + return Thread() + + +def get_repre_icons(): + try: + from openpype_modules import sync_server + except Exception: + # Backwards compatibility + from openpype.modules import sync_server + + resource_path = os.path.join( + os.path.dirname(sync_server.sync_server_module.__file__), + "providers", "resources" + ) + icons = {} + # TODO get from sync module + for provider in ['studio', 'local_drive', 'gdrive']: + pix_url = "{}/{}.png".format(resource_path, provider) + icons[provider] = QtGui.QIcon(pix_url) + + return icons + + +def get_progress_for_repre(doc, active_site, remote_site): + """ + Calculates average progress for representation. + + If site has created_dt >> fully available >> progress == 1 + + Could be calculated in aggregate if it would be too slow + Args: + doc(dict): representation dict + Returns: + (dict) with active and remote sites progress + {'studio': 1.0, 'gdrive': -1} - gdrive site is not present + -1 is used to highlight the site should be added + {'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not + uploaded yet + """ + progress = {active_site: -1, + remote_site: -1} + if not doc: + return progress + + files = {active_site: 0, remote_site: 0} + doc_files = doc.get("files") or [] + for doc_file in doc_files: + if not isinstance(doc_file, dict): + continue + + sites = doc_file.get("sites") or [] + for site in sites: + if ( + # Pype 2 compatibility + not isinstance(site, dict) + # Check if site name is one of progress sites + or site["name"] not in progress + ): + continue + + files[site["name"]] += 1 + norm_progress = max(progress[site["name"]], 0) + if site.get("created_dt"): + progress[site["name"]] = norm_progress + 1 + elif site.get("progress"): + progress[site["name"]] = norm_progress + site["progress"] + else: # site exists, might be failed, do not add again + progress[site["name"]] = 0 + + # for example 13 fully avail. files out of 26 >> 13/26 = 0.5 + avg_progress = {} + avg_progress[active_site] = \ + progress[active_site] / max(files[active_site], 1) + avg_progress[remote_site] = \ + progress[remote_site] / max(files[remote_site], 1) + return avg_progress + + +def is_sync_loader(loader): + return is_remove_site_loader(loader) or is_add_site_loader(loader) + + +def is_remove_site_loader(loader): + return hasattr(loader, "remove_site_on_representation") + + +def is_add_site_loader(loader): + return hasattr(loader, "add_site_to_representation") diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py new file mode 100644 index 0000000000..c5e1ce1b12 --- /dev/null +++ b/openpype/tools/utils/models.py @@ -0,0 +1,500 @@ +import re +import time +import logging +import collections + +import Qt +from Qt import QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import style, io +from . import lib + +log = logging.getLogger(__name__) + + +class TreeModel(QtCore.QAbstractItemModel): + + Columns = list() + ItemRole = QtCore.Qt.UserRole + 1 + item_class = None + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + self._root_item = self.ItemClass() + + @property + def ItemClass(self): + if self.item_class is not None: + return self.item_class + return Item + + def rowCount(self, parent=None): + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + return parent_item.childCount() + + def columnCount(self, parent): + return len(self.Columns) + + def data(self, index, role): + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + item = index.internalPointer() + column = index.column() + + key = self.Columns[column] + return item.get(key, None) + + if role == self.ItemRole: + return index.internalPointer() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Change the data on the items. + + Returns: + bool: Whether the edit was successful + """ + + if index.isValid(): + if role == QtCore.Qt.EditRole: + + item = index.internalPointer() + column = index.column() + key = self.Columns[column] + item[key] = value + + # passing `list()` for PyQt5 (see PYSIDE-462) + if Qt.__binding__ in ("PyQt4", "PySide"): + self.dataChanged.emit(index, index) + else: + self.dataChanged.emit(index, index, [role]) + + # must return true if successful + return True + + return False + + def setColumns(self, keys): + assert isinstance(keys, (list, tuple)) + self.Columns = keys + + def headerData(self, section, orientation, role): + + if role == QtCore.Qt.DisplayRole: + if section < len(self.Columns): + return self.Columns[section] + + super(TreeModel, self).headerData(section, orientation, role) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled + + item = index.internalPointer() + if item.get("enabled", True): + flags |= QtCore.Qt.ItemIsSelectable + + return flags + + def parent(self, index): + + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if parent_item == self._root_item or not parent_item: + return QtCore.QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent=None): + """Return index for row/column under parent""" + + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + + child_item = parent_item.child(row) + if child_item: + return self.createIndex(row, column, child_item) + else: + return QtCore.QModelIndex() + + def add_child(self, item, parent=None): + if parent is None: + parent = self._root_item + + parent.add_child(item) + + def column_name(self, column): + """Return column key by index""" + + if column < len(self.Columns): + return self.Columns[column] + + def clear(self): + self.beginResetModel() + self._root_item = self.ItemClass() + self.endResetModel() + + +class Item(dict): + """An item that can be represented in a tree view using `TreeModel`. + + The item can store data just like a regular dictionary. + + >>> data = {"name": "John", "score": 10} + >>> item = Item(data) + >>> assert item["name"] == "John" + + """ + + def __init__(self, data=None): + super(Item, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this item under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + return -1 + + def add_child(self, child): + """Add a child to this item""" + child._parent = self + self._children.append(child) + + +class AssetModel(TreeModel): + """A model listing assets in the silo in the active project. + + The assets are displayed in a treeview, they are visually parented by + a `visualParent` field in the database containing an `_id` to a parent + asset. + + """ + + Columns = ["label"] + Name = 0 + Deprecated = 2 + ObjectId = 3 + + DocumentRole = QtCore.Qt.UserRole + 2 + ObjectIdRole = QtCore.Qt.UserRole + 3 + subsetColorsRole = QtCore.Qt.UserRole + 4 + + doc_fetched = QtCore.Signal(bool) + refreshed = QtCore.Signal(bool) + + # Asset document projection + asset_projection = { + "type": 1, + "schema": 1, + "name": 1, + "silo": 1, + "data.visualParent": 1, + "data.label": 1, + "data.tags": 1, + "data.icon": 1, + "data.color": 1, + "data.deprecated": 1 + } + + def __init__(self, dbcon=None, parent=None, asset_projection=None): + super(AssetModel, self).__init__(parent=parent) + if dbcon is None: + dbcon = io + self.dbcon = dbcon + self.asset_colors = {} + + # Projections for Mongo queries + # - let ability to modify them if used in tools that require more than + # defaults + if asset_projection: + self.asset_projection = asset_projection + + self.asset_projection = asset_projection + + self._doc_fetching_thread = None + self._doc_fetching_stop = False + self._doc_payload = {} + + self.doc_fetched.connect(self.on_doc_fetched) + + self.refresh() + + def _add_hierarchy(self, assets, parent=None, silos=None): + """Add the assets that are related to the parent as children items. + + This method does *not* query the database. These instead are queried + in a single batch upfront as an optimization to reduce database + queries. Resulting in up to 10x speed increase. + + Args: + assets (dict): All assets in the currently active silo stored + by key/value + + Returns: + None + + """ + # Reset colors + self.asset_colors = {} + + if silos: + # WARNING: Silo item "_id" is set to silo value + # mainly because GUI issue with perserve selection and expanded row + # and because of easier hierarchy parenting (in "assets") + for silo in silos: + item = Item({ + "_id": silo, + "name": silo, + "label": silo, + "type": "silo" + }) + self.add_child(item, parent=parent) + self._add_hierarchy(assets, parent=item) + + parent_id = parent["_id"] if parent else None + current_assets = assets.get(parent_id, list()) + + for asset in current_assets: + # get label from data, otherwise use name + data = asset.get("data", {}) + label = data.get("label", asset["name"]) + tags = data.get("tags", []) + + # store for the asset for optimization + deprecated = "deprecated" in tags + + item = Item({ + "_id": asset["_id"], + "name": asset["name"], + "label": label, + "type": asset["type"], + "tags": ", ".join(tags), + "deprecated": deprecated, + "_document": asset + }) + self.add_child(item, parent=parent) + + # Add asset's children recursively if it has children + if asset["_id"] in assets: + self._add_hierarchy(assets, parent=item) + + self.asset_colors[asset["_id"]] = [] + + def on_doc_fetched(self, was_stopped): + if was_stopped: + self.stop_fetch_thread() + return + + self.beginResetModel() + + assets_by_parent = self._doc_payload.get("assets_by_parent") + silos = self._doc_payload.get("silos") + if assets_by_parent is not None: + # Build the hierarchical tree items recursively + self._add_hierarchy( + assets_by_parent, + parent=None, + silos=silos + ) + + self.endResetModel() + + has_content = bool(assets_by_parent) or bool(silos) + self.refreshed.emit(has_content) + + self.stop_fetch_thread() + + def fetch(self): + self._doc_payload = self._fetch() or {} + # Emit doc fetched only if was not stopped + self.doc_fetched.emit(self._doc_fetching_stop) + + def _fetch(self): + if not self.dbcon.Session.get("AVALON_PROJECT"): + return + + project_doc = self.dbcon.find_one( + {"type": "project"}, + {"_id": True} + ) + if not project_doc: + return + + # Get all assets sorted by name + db_assets = self.dbcon.find( + {"type": "asset"}, + self.asset_projection + ).sort("name", 1) + + # Group the assets by their visual parent's id + assets_by_parent = collections.defaultdict(list) + for asset in db_assets: + if self._doc_fetching_stop: + return + parent_id = asset.get("data", {}).get("visualParent") + assets_by_parent[parent_id].append(asset) + + return { + "assets_by_parent": assets_by_parent, + "silos": None + } + + def stop_fetch_thread(self): + if self._doc_fetching_thread is not None: + self._doc_fetching_stop = True + while self._doc_fetching_thread.isRunning(): + time.sleep(0.001) + self._doc_fetching_thread = None + + def refresh(self, force=False): + """Refresh the data for the model.""" + # Skip fetch if there is already other thread fetching documents + if self._doc_fetching_thread is not None: + if not force: + return + self.stop_fetch_thread() + + # Clear model items + self.clear() + + # Fetch documents from mongo + # Restart payload + self._doc_payload = {} + self._doc_fetching_stop = False + self._doc_fetching_thread = lib.create_qthread(self.fetch) + self._doc_fetching_thread.start() + + def flags(self, index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if not index.isValid(): + return False + + if role == self.subsetColorsRole: + asset_id = index.data(self.ObjectIdRole) + self.asset_colors[asset_id] = value + + if Qt.__binding__ in ("PyQt4", "PySide"): + self.dataChanged.emit(index, index) + else: + self.dataChanged.emit(index, index, [role]) + + return True + + return super(AssetModel, self).setData(index, value, role) + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + if role == QtCore.Qt.DecorationRole: + column = index.column() + if column == self.Name: + # Allow a custom icon and custom icon color to be defined + data = item.get("_document", {}).get("data", {}) + icon = data.get("icon", None) + if icon is None and item.get("type") == "silo": + icon = "database" + color = data.get("color", style.colors.default) + + if icon is None: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + has_children = self.rowCount(index) > 0 + icon = "folder" if has_children else "folder-o" + + # Make the color darker when the asset is deprecated + if item.get("deprecated", False): + color = QtGui.QColor(color).darker(250) + + try: + key = "fa.{0}".format(icon) # font-awesome key + icon = qtawesome.icon(key, color=color) + return icon + except Exception as exception: + # Log an error message instead of erroring out completely + # when the icon couldn't be created (e.g. invalid name) + log.error(exception) + + return + + if role == QtCore.Qt.ForegroundRole: # font color + if "deprecated" in item.get("tags", []): + return QtGui.QColor(style.colors.light).darker(250) + + if role == self.ObjectIdRole: + return item.get("_id", None) + + if role == self.DocumentRole: + return item.get("_document", None) + + if role == self.subsetColorsRole: + asset_id = item.get("_id", None) + return self.asset_colors.get(asset_id) or [] + + return super(AssetModel, self).data(index, role) + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + regex = self.filterRegExp() + if not regex.isEmpty(): + pattern = regex.pattern() + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if source_index.isValid(): + # Check current index itself + key = model.data(source_index, self.filterRole()) + if re.search(pattern, key, re.IGNORECASE): + return True + + # Check children + rows = model.rowCount(source_index) + for i in range(rows): + if self.filterAcceptsRow(i, source_index): + return True + + # Otherwise filter it + return False + + return super( + RecursiveSortFilterProxyModel, self + ).filterAcceptsRow(row, parent) diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py new file mode 100644 index 0000000000..bed5655647 --- /dev/null +++ b/openpype/tools/utils/views.py @@ -0,0 +1,86 @@ +import os +from avalon import style +from Qt import QtWidgets, QtCore, QtGui, QtSvg + + +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + QtWidgets.QTreeView.mousePressEvent(self, event) + + +class TreeViewSpinner(QtWidgets.QTreeView): + size = 160 + + def __init__(self, parent=None): + super(TreeViewSpinner, self).__init__(parent=parent) + + loading_image_path = os.path.join( + os.path.dirname(os.path.abspath(style.__file__)), + "svg", + "spinner-200.svg" + ) + self.spinner = QtSvg.QSvgRenderer(loading_image_path) + + self.is_loading = False + self.is_empty = True + + def paint_loading(self, event): + rect = event.rect() + rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) + rect.moveTo( + rect.x() + rect.width() / 2 - self.size / 2, + rect.y() + rect.height() / 2 - self.size / 2 + ) + rect.setSize(QtCore.QSizeF(self.size, self.size)) + painter = QtGui.QPainter(self.viewport()) + self.spinner.render(painter, rect) + + def paint_empty(self, event): + painter = QtGui.QPainter(self.viewport()) + rect = event.rect() + rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) + qtext_opt = QtGui.QTextOption( + QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter + ) + painter.drawText(rect, "No Data", qtext_opt) + + def paintEvent(self, event): + if self.is_loading: + self.paint_loading(event) + elif self.is_empty: + self.paint_empty(event) + else: + super(TreeViewSpinner, self).paintEvent(event) + + +class AssetsView(TreeViewSpinner, DeselectableTreeView): + """Item view. + This implements a context menu. + """ + + def __init__(self): + super(AssetsView, self).__init__() + self.setIndentation(15) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setHeaderHidden(True) + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + if not index.isValid(): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + return + elif modifiers == QtCore.Qt.ControlModifier: + return + + super(AssetsView, self).mousePressEvent(event) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py new file mode 100644 index 0000000000..b9b542c123 --- /dev/null +++ b/openpype/tools/utils/widgets.py @@ -0,0 +1,499 @@ +import logging +import time + +from . import lib + +from Qt import QtWidgets, QtCore, QtGui +from avalon.vendor import qtawesome, qargparse + +from avalon import style + +from .models import AssetModel, RecursiveSortFilterProxyModel +from .views import AssetsView +from .delegates import AssetDelegate + +log = logging.getLogger(__name__) + + +class AssetWidget(QtWidgets.QWidget): + """A Widget to display a tree of assets with filter + + To list the assets of the active project: + >>> # widget = AssetWidget() + >>> # widget.refresh() + >>> # widget.show() + + """ + + refresh_triggered = QtCore.Signal() # on model refresh + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() # on view selection change + current_changed = QtCore.Signal() # on view current index change + + def __init__(self, dbcon, multiselection=False, parent=None): + super(AssetWidget, self).__init__(parent=parent) + + self.dbcon = dbcon + + self.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Tree View + model = AssetModel(dbcon=self.dbcon, parent=self) + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = AssetsView() + view.setModel(proxy) + if multiselection: + asset_delegate = AssetDelegate() + view.setSelectionMode(view.ExtendedSelection) + view.setItemDelegate(asset_delegate) + + # Header + header = QtWidgets.QHBoxLayout() + + icon = qtawesome.icon("fa.arrow-down", color=style.colors.light) + set_current_asset_btn = QtWidgets.QPushButton(icon, "") + set_current_asset_btn.setToolTip("Go to Asset from current Session") + # Hide by default + set_current_asset_btn.setVisible(False) + + icon = qtawesome.icon("fa.refresh", color=style.colors.light) + refresh = QtWidgets.QPushButton(icon, "") + refresh.setToolTip("Refresh items") + + filter = QtWidgets.QLineEdit() + filter.textChanged.connect(proxy.setFilterFixedString) + filter.setPlaceholderText("Filter assets..") + + header.addWidget(filter) + header.addWidget(set_current_asset_btn) + header.addWidget(refresh) + + # Layout + layout.addLayout(header) + layout.addWidget(view) + + # Signals/Slots + selection = view.selectionModel() + selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.current_changed) + refresh.clicked.connect(self.refresh) + set_current_asset_btn.clicked.connect(self.set_current_session_asset) + + self.set_current_asset_btn = set_current_asset_btn + self.model = model + self.proxy = proxy + self.view = view + + self.model_selection = {} + + def set_current_asset_btn_visibility(self, visible=None): + """Hide set current asset button. + + Not all tools support using of current context asset. + """ + if visible is None: + visible = not self.set_current_asset_btn.isVisible() + self.set_current_asset_btn.setVisible(visible) + + def _refresh_model(self): + # Store selection + self._store_model_selection() + time_start = time.time() + + self.set_loading_state( + loading=True, + empty=True + ) + + def on_refreshed(has_item): + self.set_loading_state(loading=False, empty=not has_item) + self._restore_model_selection() + self.model.refreshed.disconnect() + self.refreshed.emit() + print("Duration: %.3fs" % (time.time() - time_start)) + + # Connect to signal + self.model.refreshed.connect(on_refreshed) + # Trigger signal before refresh is called + self.refresh_triggered.emit() + # Refresh model + self.model.refresh() + + def refresh(self): + self._refresh_model() + + def get_active_asset(self): + """Return the asset item of the current selection.""" + current = self.view.currentIndex() + return current.data(self.model.ItemRole) + + def get_active_asset_document(self): + """Return the asset document of the current selection.""" + current = self.view.currentIndex() + return current.data(self.model.DocumentRole) + + def get_active_index(self): + return self.view.currentIndex() + + def get_selected_assets(self): + """Return the documents of selected assets.""" + selection = self.view.selectionModel() + rows = selection.selectedRows() + assets = [row.data(self.model.DocumentRole) for row in rows] + + # NOTE: skip None object assumed they are silo (backwards comp.) + return [asset for asset in assets if asset] + + def select_assets(self, assets, expand=True, key="name"): + """Select assets by item key. + + Args: + assets (list): List of asset values that can be found under + specified `key` + expand (bool): Whether to also expand to the asset in the view + key (string): Key that specifies where to look for `assets` values + + Returns: + None + + Default `key` is "name" in that case `assets` should contain single + asset name or list of asset names. (It is good idea to use "_id" key + instead of name in that case `assets` must contain `ObjectId` object/s) + It is expected that each value in `assets` will be found only once. + If the filters according to the `key` and `assets` correspond to + the more asset, only the first found will be selected. + + """ + + if not isinstance(assets, (tuple, list)): + assets = [assets] + + # convert to list - tuple cant be modified + assets = set(assets) + + # Clear selection + selection_model = self.view.selectionModel() + selection_model.clearSelection() + + # Select + mode = selection_model.Select | selection_model.Rows + for index in lib.iter_model_rows( + self.proxy, column=0, include_root=False + ): + # stop iteration if there are no assets to process + if not assets: + break + + value = index.data(self.model.ItemRole).get(key) + if value not in assets: + continue + + # Remove processed asset + assets.discard(value) + + selection_model.select(index, mode) + if expand: + # Expand parent index + self.view.expand(self.proxy.parent(index)) + + # Set the currently active index + self.view.setCurrentIndex(index) + + def set_loading_state(self, loading, empty): + if self.view.is_loading != loading: + if loading: + self.view.spinner.repaintNeeded.connect( + self.view.viewport().update + ) + else: + self.view.spinner.repaintNeeded.disconnect() + + self.view.is_loading = loading + self.view.is_empty = empty + + def _store_model_selection(self): + index = self.view.currentIndex() + current = None + if index and index.isValid(): + current = index.data(self.model.ObjectIdRole) + + expanded = set() + model = self.view.model() + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + if self.view.isExpanded(index): + value = index.data(self.model.ObjectIdRole) + expanded.add(value) + + selection_model = self.view.selectionModel() + + selected = None + selected_rows = selection_model.selectedRows() + if selected_rows: + selected = set( + row.data(self.model.ObjectIdRole) + for row in selected_rows + ) + + self.model_selection = { + "expanded": expanded, + "selected": selected, + "current": current + } + + def _restore_model_selection(self): + model = self.view.model() + not_set = object() + expanded = self.model_selection.pop("expanded", not_set) + selected = self.model_selection.pop("selected", not_set) + current = self.model_selection.pop("current", not_set) + + if ( + expanded is not_set + or selected is not_set + or current is not_set + ): + return + + if expanded: + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + is_expanded = index.data(self.model.ObjectIdRole) in expanded + self.view.setExpanded(index, is_expanded) + + if not selected and not current: + self.set_current_session_asset() + return + + current_index = None + selected_indexes = [] + # Go through all indices, select the ones with similar data + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + object_id = index.data(self.model.ObjectIdRole) + if object_id in selected: + selected_indexes.append(index) + + if not current_index and object_id == current: + current_index = index + + if current_index: + self.view.setCurrentIndex(current_index) + + if not selected_indexes: + return + selection_model = self.view.selectionModel() + flags = selection_model.Select | selection_model.Rows + for index in selected_indexes: + # Ensure item is visible + self.view.scrollTo(index) + selection_model.select(index, flags) + + def set_current_session_asset(self): + asset_name = self.dbcon.Session.get("AVALON_ASSET") + if asset_name: + self.select_assets([asset_name]) + + +class OptionalMenu(QtWidgets.QMenu): + """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` + + This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and + `leaveEvent` to provide better action hightlighting and triggering for + actions that were instances of `QtWidgets.QWidgetAction`. + + """ + + def mouseReleaseEvent(self, event): + """Emit option clicked signal if mouse released on it""" + active = self.actionAt(event.pos()) + if active and active.use_option: + option = active.widget.option + if option.is_hovered(event.globalPos()): + option.clicked.emit() + super(OptionalMenu, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + """Add highlight to active action""" + active = self.actionAt(event.pos()) + for action in self.actions(): + action.set_highlight(action is active, event.globalPos()) + super(OptionalMenu, self).mouseMoveEvent(event) + + def leaveEvent(self, event): + """Remove highlight from all actions""" + for action in self.actions(): + action.set_highlight(False) + super(OptionalMenu, self).leaveEvent(event) + + +class OptionalAction(QtWidgets.QWidgetAction): + """Menu action with option box + + A menu action like Maya's menu item with option box, implemented by + subclassing `QtWidgets.QWidgetAction`. + + """ + + def __init__(self, label, icon, use_option, parent): + super(OptionalAction, self).__init__(parent) + self.label = label + self.icon = icon + self.use_option = use_option + self.option_tip = "" + self.optioned = False + + def createWidget(self, parent): + widget = OptionalActionWidget(self.label, parent) + self.widget = widget + + if self.icon: + widget.setIcon(self.icon) + + if self.use_option: + widget.option.clicked.connect(self.on_option) + widget.option.setToolTip(self.option_tip) + else: + widget.option.setVisible(False) + + return widget + + def set_option_tip(self, options): + sep = "\n\n" + mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) + self.option_tip = sep.join(mak(opt) for opt in options) + + def on_option(self): + self.optioned = True + + def set_highlight(self, state, global_pos=None): + body = self.widget.body + option = self.widget.option + + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + body.setBackgroundRole(role) + body.setAutoFillBackground(state) + + if not self.use_option: + return + + state = option.is_hovered(global_pos) + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + option.setBackgroundRole(role) + option.setAutoFillBackground(state) + + +class OptionalActionWidget(QtWidgets.QWidget): + """Main widget class for `OptionalAction`""" + + def __init__(self, label, parent=None): + super(OptionalActionWidget, self).__init__(parent) + + body = QtWidgets.QWidget() + body.setStyleSheet("background: transparent;") + + icon = QtWidgets.QLabel() + label = QtWidgets.QLabel(label) + option = OptionBox(body) + + icon.setFixedSize(24, 16) + option.setFixedSize(30, 30) + + layout = QtWidgets.QHBoxLayout(body) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(icon) + layout.addWidget(label) + layout.addSpacing(6) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 1, 2, 1) + layout.setSpacing(0) + layout.addWidget(body) + layout.addWidget(option) + + body.setMouseTracking(True) + label.setMouseTracking(True) + option.setMouseTracking(True) + self.setMouseTracking(True) + self.setFixedHeight(32) + + self.icon = icon + self.label = label + self.option = option + self.body = body + + # (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke. + # See https://stackoverflow.com/q/52838690/4145300 + label.setStyle(QtWidgets.QStyleFactory.create("Plastique")) + + def setIcon(self, icon): + pixmap = icon.pixmap(16, 16) + self.icon.setPixmap(pixmap) + + +class OptionBox(QtWidgets.QLabel): + """Option box widget class for `OptionalActionWidget`""" + + clicked = QtCore.Signal() + + def __init__(self, parent): + super(OptionBox, self).__init__(parent) + + self.setAlignment(QtCore.Qt.AlignCenter) + + icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6") + pixmap = icon.pixmap(18, 18) + self.setPixmap(pixmap) + + self.setStyleSheet("background: transparent;") + + def is_hovered(self, global_pos): + if global_pos is None: + return False + pos = self.mapFromGlobal(global_pos) + return self.rect().contains(pos) + + +class OptionDialog(QtWidgets.QDialog): + """Option dialog shown by option box""" + + def __init__(self, parent=None): + super(OptionDialog, self).__init__(parent) + self.setModal(True) + self._options = dict() + + def create(self, options): + parser = qargparse.QArgumentParser(arguments=options) + + decision = QtWidgets.QWidget() + accept = QtWidgets.QPushButton("Accept") + cancel = QtWidgets.QPushButton("Cancel") + + layout = QtWidgets.QHBoxLayout(decision) + layout.addWidget(accept) + layout.addWidget(cancel) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(parser) + layout.addWidget(decision) + + accept.clicked.connect(self.accept) + cancel.clicked.connect(self.reject) + parser.changed.connect(self.on_changed) + + def on_changed(self, argument): + self._options[argument["name"]] = argument.read() + + def parse(self): + return self._options.copy() diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index b542e6e718..6fff0d0278 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -376,6 +376,9 @@ class TasksWidget(QtWidgets.QWidget): task (str): Name of the task to select. """ + task_view_model = self._tasks_view.model() + if not task_view_model: + return # Clear selection selection_model = self._tasks_view.selectionModel() @@ -383,8 +386,8 @@ class TasksWidget(QtWidgets.QWidget): # Select the task mode = selection_model.Select | selection_model.Rows - for row in range(self._tasks_model.rowCount()): - index = self._tasks_model.index(row, 0) + for row in range(task_view_model.rowCount()): + index = task_view_model.index(row, 0) name = index.data(TASK_NAME_ROLE) if name == task_name: selection_model.select(index, mode) @@ -430,7 +433,6 @@ class FilesWidget(QtWidgets.QWidget): # Pype's anatomy object for current project self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) # Template key used to get work template from anatomy templates - # TODO change template key based on task self.template_key = "work" # This is not root but workfile directory diff --git a/openpype/version.py b/openpype/version.py index 17bd0ff892..99ba3fd543 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.4.0-nightly.4" +__version__ = "3.5.0-nightly.4" diff --git a/openpype/widgets/sliders.py b/openpype/widgets/sliders.py new file mode 100644 index 0000000000..32ade58af5 --- /dev/null +++ b/openpype/widgets/sliders.py @@ -0,0 +1,139 @@ +from Qt import QtWidgets, QtCore, QtGui + + +class NiceSlider(QtWidgets.QSlider): + def __init__(self, *args, **kwargs): + super(NiceSlider, self).__init__(*args, **kwargs) + self._mouse_clicked = False + self._handle_size = 0 + + self._bg_brush = QtGui.QBrush(QtGui.QColor("#21252B")) + self._fill_brush = QtGui.QBrush(QtGui.QColor("#5cadd6")) + + def mousePressEvent(self, event): + self._mouse_clicked = True + if event.button() == QtCore.Qt.LeftButton: + self._set_value_to_pos(event.pos()) + return event.accept() + return super(NiceSlider, self).mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._mouse_clicked: + self._set_value_to_pos(event.pos()) + + super(NiceSlider, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._mouse_clicked = True + super(NiceSlider, self).mouseReleaseEvent(event) + + def _set_value_to_pos(self, pos): + if self.orientation() == QtCore.Qt.Horizontal: + self._set_value_to_pos_x(pos.x()) + else: + self._set_value_to_pos_y(pos.y()) + + def _set_value_to_pos_x(self, pos_x): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_x -= half_handle + width = self.width() - handle_size + value = ((_range * pos_x) / width) + self.minimum() + self.setValue(value) + + def _set_value_to_pos_y(self, pos_y): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_y = self.height() - pos_y - half_handle + height = self.height() - handle_size + value = (_range * pos_y / height) + self.minimum() + self.setValue(value) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + painter.fillRect(event.rect(), QtCore.Qt.transparent) + + painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + + horizontal = self.orientation() == QtCore.Qt.Horizontal + + rect = self.style().subControlRect( + QtWidgets.QStyle.CC_Slider, + opt, + QtWidgets.QStyle.SC_SliderGroove, + self + ) + + _range = self.maximum() - self.minimum() + _offset = self.value() - self.minimum() + if horizontal: + _handle_half = rect.height() / 2 + _handle_size = _handle_half * 2 + width = rect.width() - _handle_size + pos_x = ((width / _range) * _offset) + pos_y = rect.center().y() - _handle_half + 1 + else: + _handle_half = rect.width() / 2 + _handle_size = _handle_half * 2 + height = rect.height() - _handle_size + pos_x = rect.center().x() - _handle_half + 1 + pos_y = height - ((height / _range) * _offset) + + handle_rect = QtCore.QRect( + pos_x, pos_y, _handle_size, _handle_size + ) + + self._handle_size = _handle_size + _offset = 2 + _size = _handle_size - _offset + if horizontal: + if rect.height() > _size: + new_rect = QtCore.QRect(0, 0, rect.width(), _size) + center_point = QtCore.QPoint( + rect.center().x(), handle_rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.height() / 2 + fill_rect = QtCore.QRect( + rect.x(), + rect.y(), + handle_rect.right() - rect.x(), + rect.height() + ) + + else: + if rect.width() > _size: + new_rect = QtCore.QRect(0, 0, _size, rect.height()) + center_point = QtCore.QPoint( + handle_rect.center().x(), rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.width() / 2 + fill_rect = QtCore.QRect( + rect.x(), + handle_rect.y(), + rect.width(), + rect.height() - handle_rect.y(), + ) + + painter.save() + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(self._bg_brush) + painter.drawRoundedRect(rect, ratio, ratio) + + painter.setBrush(self._fill_brush) + painter.drawRoundedRect(fill_rect, ratio, ratio) + + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(self._fill_brush) + painter.drawEllipse(handle_rect) + painter.restore() diff --git a/poetry.lock b/poetry.lock index e011b781c9..10b049cd0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -144,6 +144,22 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" +[[package]] +name = "bcrypt" +version = "3.2.0" +description = "Modern password hashing for your software and your servers" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "blessed" version = "1.18.0" @@ -313,6 +329,19 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "dropbox" +version = "11.20.0" +description = "Official Dropbox API Client" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.16.2" +six = ">=1.12.0" +stone = ">=2" + [[package]] name = "enlighten" version = "1.10.1" @@ -704,6 +733,25 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" +[[package]] +name = "paramiko" +version = "2.7.2" +description = "SSH2 protocol library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +bcrypt = ">=3.1.3" +cryptography = ">=2.5" +pynacl = ">=1.0.1" + +[package.extras] +all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] +ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"] +gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=1.3)"] + [[package]] name = "parso" version = "0.8.2" @@ -749,6 +797,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +[[package]] +name = "ply" +version = "3.11" +description = "Python Lex & Yacc" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "prefixed" version = "0.3.2" @@ -888,6 +944,22 @@ srv = ["dnspython (>=1.16.0,<1.17.0)"] tls = ["ipaddress"] zstd = ["zstandard"] +[[package]] +name = "pynacl" +version = "1.4.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +cffi = ">=1.4.1" +six = "*" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"] + [[package]] name = "pynput" version = "1.7.3" @@ -977,6 +1049,17 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "pysftp" +version = "0.2.9" +description = "A friendly face on SFTP" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +paramiko = ">=1.17" + [[package]] name = "pytest" version = "6.2.4" @@ -1069,7 +1152,7 @@ python-versions = "*" [[package]] name = "pywin32" -version = "300" +version = "301" description = "Python for Window Extensions" category = "main" optional = false @@ -1341,6 +1424,18 @@ sphinxcontrib-serializinghtml = "*" lint = ["flake8"] test = ["pytest", "sqlalchemy", "whoosh", "sphinx"] +[[package]] +name = "stone" +version = "3.2.1" +description = "Stone is an interface description language (IDL) for APIs." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ply = ">=3.4" +six = ">=1.3.0" + [[package]] name = "termcolor" version = "1.1.0" @@ -1466,7 +1561,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "8875d530ae66f9763b5b0cb84d9d35edc184ef5c141b63d38bf1ff5a1226e556" +content-hash = "ff2bfa35a7304378917a0c25d7d7af9f81a130288d95789bdf7429f071e80b69" [metadata.files] acre = [] @@ -1553,6 +1648,15 @@ babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] +bcrypt = [ + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"}, + {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"}, + {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, + {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, +] blessed = [ {file = "blessed-1.18.0-py2.py3-none-any.whl", hash = "sha256:5b5e2f0563d5a668c282f3f5946f7b1abb70c85829461900e607e74d7725106e"}, {file = "blessed-1.18.0.tar.gz", hash = "sha256:1312879f971330a1b7f2c6341f2ae7e2cbac244bfc9d0ecfbbecd4b0293bc755"}, @@ -1582,24 +1686,36 @@ cffi = [ {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55"}, {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc"}, {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76"}, {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7"}, {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, @@ -1692,8 +1808,10 @@ cryptography = [ {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b01fd6f2737816cb1e08ed4807ae194404790eac7ad030b34f2ce72b332f5586"}, {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:bf40af59ca2465b24e54f671b2de2c59257ddc4f7e5706dbd6930e26823668d3"}, {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, ] cx-freeze = [ @@ -1730,6 +1848,11 @@ docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] +dropbox = [ + {file = "dropbox-11.20.0-py2-none-any.whl", hash = "sha256:0926aab25445fe78b0284e0b86f4126ec4e5e2bf6cd2ac8562002008a21073b8"}, + {file = "dropbox-11.20.0-py3-none-any.whl", hash = "sha256:f2106aa566f9e3c175879c226c60b7089a39099b228061acbb7258670f6b859c"}, + {file = "dropbox-11.20.0.tar.gz", hash = "sha256:1aa351ec8bbb11cf3560e731b81d25f39c7edcb5fa92c06c5d68866cb9f90d54"}, +] enlighten = [ {file = "enlighten-1.10.1-py2.py3-none-any.whl", hash = "sha256:3d6c3eec8cf3eb626ee7b65eddc1b3e904d01f4547a2b9fe7f1da8892a0297e8"}, {file = "enlighten-1.10.1.tar.gz", hash = "sha256:3391916586364aedced5d6926482b48745e4948f822de096d32258ba238ea984"}, @@ -1852,12 +1975,22 @@ log4mongo = [ {file = "log4mongo-1.7.0.tar.gz", hash = "sha256:dc374617206162a0b14167fbb5feac01dbef587539a235dadba6200362984a68"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -1866,14 +1999,21 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -1883,6 +2023,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -1935,6 +2078,10 @@ packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] +paramiko = [ + {file = "paramiko-2.7.2-py2.py3-none-any.whl", hash = "sha256:4f3e316fef2ac628b05097a637af35685183111d4bc1b5979bd397c2ab7b5898"}, + {file = "paramiko-2.7.2.tar.gz", hash = "sha256:7f36f4ba2c0d81d219f4595e35f70d56cc94f9ac40a6acdf51d6ca210ce65035"}, +] parso = [ {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, @@ -1983,6 +2130,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +ply = [ + {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, + {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, +] prefixed = [ {file = "prefixed-0.3.2-py2.py3-none-any.whl", hash = "sha256:5e107306462d63f2f03c529dbf11b0026fdfec621a9a008ca639d71de22995c3"}, {file = "prefixed-0.3.2.tar.gz", hash = "sha256:ca48277ba5fa8346dd4b760847da930c7b84416387c39e93affef086add2c029"}, @@ -2006,9 +2157,13 @@ protobuf = [ {file = "protobuf-3.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2ae692bb6d1992afb6b74348e7bb648a75bb0d3565a3f5eea5bec8f62bd06d87"}, {file = "protobuf-3.17.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:99938f2a2d7ca6563c0ade0c5ca8982264c484fdecf418bd68e880a7ab5730b1"}, {file = "protobuf-3.17.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6902a1e4b7a319ec611a7345ff81b6b004b36b0d2196ce7a748b3493da3d226d"}, + {file = "protobuf-3.17.3-cp38-cp38-win32.whl", hash = "sha256:59e5cf6b737c3a376932fbfb869043415f7c16a0cf176ab30a5bbc419cd709c1"}, + {file = "protobuf-3.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ebcb546f10069b56dc2e3da35e003a02076aaa377caf8530fe9789570984a8d2"}, {file = "protobuf-3.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4ffbd23640bb7403574f7aff8368e2aeb2ec9a5c6306580be48ac59a6bac8bde"}, {file = "protobuf-3.17.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:26010f693b675ff5a1d0e1bdb17689b8b716a18709113288fead438703d45539"}, {file = "protobuf-3.17.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76d9686e088fece2450dbc7ee905f9be904e427341d289acbe9ad00b78ebd47"}, + {file = "protobuf-3.17.3-cp39-cp39-win32.whl", hash = "sha256:a38bac25f51c93e4be4092c88b2568b9f407c27217d3dd23c7a57fa522a17554"}, + {file = "protobuf-3.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:85d6303e4adade2827e43c2b54114d9a6ea547b671cb63fafd5011dc47d0e13d"}, {file = "protobuf-3.17.3-py2.py3-none-any.whl", hash = "sha256:2bfb815216a9cd9faec52b16fd2bfa68437a44b67c56bee59bc3926522ecb04e"}, {file = "protobuf-3.17.3.tar.gz", hash = "sha256:72804ea5eaa9c22a090d2803813e280fb273b62d5ae497aaf3553d141c4fdd7b"}, ] @@ -2144,6 +2299,26 @@ pymongo = [ {file = "pymongo-3.11.4-py2.7-macosx-10.14-intel.egg", hash = "sha256:506a6dab4c7ffdcacdf0b8e70bd20eb2e77fa994519547c9d88d676400fcad58"}, {file = "pymongo-3.11.4.tar.gz", hash = "sha256:539d4cb1b16b57026999c53e5aab857fe706e70ae5310cc8c232479923f932e6"}, ] +pynacl = [ + {file = "PyNaCl-1.4.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"}, + {file = "PyNaCl-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7"}, + {file = "PyNaCl-1.4.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122"}, + {file = "PyNaCl-1.4.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d"}, + {file = "PyNaCl-1.4.0-cp35-abi3-win32.whl", hash = "sha256:4e10569f8cbed81cb7526ae137049759d2a8d57726d52c1a000a3ce366779634"}, + {file = "PyNaCl-1.4.0-cp35-abi3-win_amd64.whl", hash = "sha256:c914f78da4953b33d4685e3cdc7ce63401247a21425c16a39760e282075ac4a6"}, + {file = "PyNaCl-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4"}, + {file = "PyNaCl-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25"}, + {file = "PyNaCl-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4"}, + {file = "PyNaCl-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6"}, + {file = "PyNaCl-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f"}, + {file = "PyNaCl-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f"}, + {file = "PyNaCl-1.4.0-cp38-cp38-win32.whl", hash = "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96"}, + {file = "PyNaCl-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420"}, + {file = "PyNaCl-1.4.0.tar.gz", hash = "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505"}, +] pynput = [ {file = "pynput-1.7.3-py2.py3-none-any.whl", hash = "sha256:fea5777454f896bd79d35393088cd29a089f3b2da166f0848a922b1d5a807d4f"}, {file = "pynput-1.7.3-py3.8.egg", hash = "sha256:6626e8ea9ca482bb5628a7169e1193824e382c4ad3053e40f4f24f41ee7b41c9"}, @@ -2151,6 +2326,7 @@ pynput = [ ] pyobjc-core = [ {file = "pyobjc-core-7.3.tar.gz", hash = "sha256:5081aedf8bb40aac1a8ad95adac9e44e148a882686ded614adf46bb67fd67574"}, + {file = "pyobjc_core-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a1f1e6b457127cbf2b5bd2b94520a7c89fb590b739911eadb2b0499a3a5b0e6f"}, {file = "pyobjc_core-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e93ad769a20b908778fe950f62a843a6d8f0fa71996e5f3cc9fab5ae7d17771"}, {file = "pyobjc_core-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f63fd37bbf3785af4ddb2f86cad5ca81c62cfc7d1c0099637ca18343c3656c1"}, {file = "pyobjc_core-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9b1311f72f2e170742a7ee3a8149f52c35158dc024a21e88d6f1e52ba5d718b"}, @@ -2159,6 +2335,7 @@ pyobjc-core = [ ] pyobjc-framework-cocoa = [ {file = "pyobjc-framework-Cocoa-7.3.tar.gz", hash = "sha256:b18d05e7a795a3455ad191c3e43d6bfa673c2a4fd480bb1ccf57191051b80b7e"}, + {file = "pyobjc_framework_Cocoa-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1e31376806e5de883a1d7c7c87d9ff2a8b09fc05d267e0dfce6e42409fb70c67"}, {file = "pyobjc_framework_Cocoa-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9edffdfa6dd1f71f21b531c3e61fdd3e4d5d3bf6c5a528c98e88828cd60bac11"}, {file = "pyobjc_framework_Cocoa-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:35a6340437a4e0109a302150b7d1f6baf57004ccf74834f9e6062fcafe2fd8d7"}, {file = "pyobjc_framework_Cocoa-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7c3886f2608ab3ed02482f8b2ebf9f782b324c559e84b52cfd92dba8a1109872"}, @@ -2167,6 +2344,7 @@ pyobjc-framework-cocoa = [ ] pyobjc-framework-quartz = [ {file = "pyobjc-framework-Quartz-7.3.tar.gz", hash = "sha256:98812844c34262def980bdf60923a875cd43428a8375b6fd53bd2cd800eccf0b"}, + {file = "pyobjc_framework_Quartz-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1139bc6874c0f8b58f0b8602015e0994198bc506a6bcec1071208de32b55ed26"}, {file = "pyobjc_framework_Quartz-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ef18f5a16511ded65980bf4f5983ea5d35c88224dbad1b3112abd29c60413ea"}, {file = "pyobjc_framework_Quartz-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b41eec8d4b10c7c7e011e2f9051367f5499ef315ba52dfbae573c3a2e05469c"}, {file = "pyobjc_framework_Quartz-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c65456ed045dfe1711d0298734e5a3ad670f8c770f7eb3b19979256c388bdd2"}, @@ -2212,6 +2390,9 @@ pyqt5-sip = [ pyrsistent = [ {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"}, ] +pysftp = [ + {file = "pysftp-0.2.9.tar.gz", hash = "sha256:fbf55a802e74d663673400acd92d5373c1c7ee94d765b428d9f977567ac4854a"}, +] pytest = [ {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, @@ -2240,16 +2421,16 @@ pytz = [ {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] pywin32 = [ - {file = "pywin32-300-cp35-cp35m-win32.whl", hash = "sha256:1c204a81daed2089e55d11eefa4826c05e604d27fe2be40b6bf8db7b6a39da63"}, - {file = "pywin32-300-cp35-cp35m-win_amd64.whl", hash = "sha256:350c5644775736351b77ba68da09a39c760d75d2467ecec37bd3c36a94fbed64"}, - {file = "pywin32-300-cp36-cp36m-win32.whl", hash = "sha256:a3b4c48c852d4107e8a8ec980b76c94ce596ea66d60f7a697582ea9dce7e0db7"}, - {file = "pywin32-300-cp36-cp36m-win_amd64.whl", hash = "sha256:27a30b887afbf05a9cbb05e3ffd43104a9b71ce292f64a635389dbad0ed1cd85"}, - {file = "pywin32-300-cp37-cp37m-win32.whl", hash = "sha256:d7e8c7efc221f10d6400c19c32a031add1c4a58733298c09216f57b4fde110dc"}, - {file = "pywin32-300-cp37-cp37m-win_amd64.whl", hash = "sha256:8151e4d7a19262d6694162d6da85d99a16f8b908949797fd99c83a0bfaf5807d"}, - {file = "pywin32-300-cp38-cp38-win32.whl", hash = "sha256:fbb3b1b0fbd0b4fc2a3d1d81fe0783e30062c1abed1d17c32b7879d55858cfae"}, - {file = "pywin32-300-cp38-cp38-win_amd64.whl", hash = "sha256:60a8fa361091b2eea27f15718f8eb7f9297e8d51b54dbc4f55f3d238093d5190"}, - {file = "pywin32-300-cp39-cp39-win32.whl", hash = "sha256:638b68eea5cfc8def537e43e9554747f8dee786b090e47ead94bfdafdb0f2f50"}, - {file = "pywin32-300-cp39-cp39-win_amd64.whl", hash = "sha256:b1609ce9bd5c411b81f941b246d683d6508992093203d4eb7f278f4ed1085c3f"}, + {file = "pywin32-301-cp35-cp35m-win32.whl", hash = "sha256:93367c96e3a76dfe5003d8291ae16454ca7d84bb24d721e0b74a07610b7be4a7"}, + {file = "pywin32-301-cp35-cp35m-win_amd64.whl", hash = "sha256:9635df6998a70282bd36e7ac2a5cef9ead1627b0a63b17c731312c7a0daebb72"}, + {file = "pywin32-301-cp36-cp36m-win32.whl", hash = "sha256:c866f04a182a8cb9b7855de065113bbd2e40524f570db73ef1ee99ff0a5cc2f0"}, + {file = "pywin32-301-cp36-cp36m-win_amd64.whl", hash = "sha256:dafa18e95bf2a92f298fe9c582b0e205aca45c55f989937c52c454ce65b93c78"}, + {file = "pywin32-301-cp37-cp37m-win32.whl", hash = "sha256:98f62a3f60aa64894a290fb7494bfa0bfa0a199e9e052e1ac293b2ad3cd2818b"}, + {file = "pywin32-301-cp37-cp37m-win_amd64.whl", hash = "sha256:fb3b4933e0382ba49305cc6cd3fb18525df7fd96aa434de19ce0878133bf8e4a"}, + {file = "pywin32-301-cp38-cp38-win32.whl", hash = "sha256:88981dd3cfb07432625b180f49bf4e179fb8cbb5704cd512e38dd63636af7a17"}, + {file = "pywin32-301-cp38-cp38-win_amd64.whl", hash = "sha256:8c9d33968aa7fcddf44e47750e18f3d034c3e443a707688a008a2e52bbef7e96"}, + {file = "pywin32-301-cp39-cp39-win32.whl", hash = "sha256:595d397df65f1b2e0beaca63a883ae6d8b6df1cdea85c16ae85f6d2e648133fe"}, + {file = "pywin32-301-cp39-cp39-win_amd64.whl", hash = "sha256:87604a4087434cd814ad8973bd47d6524bd1fa9e971ce428e76b62a5e0860fdf"}, ] pywin32-ctypes = [ {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, @@ -2339,6 +2520,11 @@ sphinxcontrib-websupport = [ {file = "sphinxcontrib-websupport-1.2.4.tar.gz", hash = "sha256:4edf0223a0685a7c485ae5a156b6f529ba1ee481a1417817935b20bde1956232"}, {file = "sphinxcontrib_websupport-1.2.4-py2.py3-none-any.whl", hash = "sha256:6fc9287dfc823fe9aa432463edd6cea47fa9ebbf488d7f289b322ffcfca075c7"}, ] +stone = [ + {file = "stone-3.2.1-py2-none-any.whl", hash = "sha256:2a50866528f60cc7cedd010def733e8ae9d581d17f967278a08059bffaea3c57"}, + {file = "stone-3.2.1-py3-none-any.whl", hash = "sha256:76235137c09ee88aa53e8c1e666819f6c20ac8064c4ac6c4ee4194eac0e3b7af"}, + {file = "stone-3.2.1.tar.gz", hash = "sha256:9bc78b40143b4ef33bf569e515408c2996ffebefbb1a897616ebe8aa6f2d7e75"}, +] termcolor = [ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, ] diff --git a/pyproject.toml b/pyproject.toml index e376986606..fb47c7eff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ speedcopy = "^2.1" six = "^1.15" semver = "^2.13.0" # for version resolution wsrpc_aiohttp = "^3.1.1" # websocket server -pywin32 = { version = "300", markers = "sys_platform == 'win32'" } +pywin32 = { version = "301", markers = "sys_platform == 'win32'" } jinxed = [ { version = "^1.0.1", markers = "sys_platform == 'darwin'" }, { version = "^1.0.1", markers = "sys_platform == 'linux'" } @@ -63,6 +63,8 @@ jinxed = [ python3-xlib = { version="*", markers = "sys_platform == 'linux'"} enlighten = "^1.9.0" slack-sdk = "^3.6.0" +pysftp = "^0.2.9" +dropbox = "^11.20.0" [tool.poetry.dev-dependencies] flake8 = "^3.7" diff --git a/repos/avalon-core b/repos/avalon-core index f48fce09c0..4b80f81e66 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5 +Subproject commit 4b80f81e66aca593784be8b299110a0b6541276f diff --git a/start.py b/start.py index 6473a926d0..ada613b4eb 100644 --- a/start.py +++ b/start.py @@ -96,12 +96,12 @@ Attributes: import os import re import sys +import platform import traceback import subprocess import site from pathlib import Path - # OPENPYPE_ROOT is variable pointing to build (or code) directory # WARNING `OPENPYPE_ROOT` must be defined before igniter import # - igniter changes cwd which cause that filepath of this script won't lead @@ -179,10 +179,17 @@ else: ssl_cert_file = certifi.where() os.environ["SSL_CERT_FILE"] = ssl_cert_file +if "--headless" in sys.argv: + os.environ["OPENPYPE_HEADLESS_MODE"] = "1" + sys.argv.remove("--headless") +else: + if os.getenv("OPENPYPE_HEADLESS_MODE") != "1": + os.environ.pop("OPENPYPE_HEADLESS_MODE", None) import igniter # noqa: E402 from igniter import BootstrapRepos # noqa: E402 from igniter.tools import ( + get_openpype_global_settings, get_openpype_path_from_db, validate_mongo_connection ) # noqa @@ -268,6 +275,35 @@ def run(arguments: list, env: dict = None) -> int: return p.returncode +def run_disk_mapping_commands(mongo_url): + """ Run disk mapping command + + Used to map shared disk for OP to pull codebase. + """ + settings = get_openpype_global_settings(mongo_url) + + low_platform = platform.system().lower() + disk_mapping = settings.get("disk_mapping") + if not disk_mapping: + return + + for mapping in disk_mapping.get(low_platform): + source, destination = mapping + + args = ["subst", destination.rstrip('/'), source.rstrip('/')] + _print("disk mapping args:: {}".format(args)) + try: + output = subprocess.Popen(args) + if output.returncode and output.returncode != 0: + exc_msg = "Executing args was not successful: \"{}\"".format( + args) + + raise RuntimeError(exc_msg) + except TypeError: + _print("Error in mapping drive") + raise + + def set_avalon_environments(): """Set avalon specific environments. @@ -333,6 +369,89 @@ def set_modules_environments(): os.environ.update(env) +def is_tool(name): + try: + import os.errno as errno + except ImportError: + import errno + + try: + devnull = open(os.devnull, "w") + subprocess.Popen( + [name], stdout=devnull, stderr=devnull + ).communicate() + except OSError as exc: + if exc.errno == errno.ENOENT: + return False + return True + + +def _startup_validations(): + """Validations before OpenPype starts.""" + try: + _validate_thirdparty_binaries() + except Exception as exc: + if os.environ.get("OPENPYPE_HEADLESS_MODE"): + raise + + import tkinter + from tkinter.messagebox import showerror + + root = tkinter.Tk() + root.attributes("-alpha", 0.0) + root.wm_state("iconic") + if platform.system().lower() != "windows": + root.withdraw() + + showerror( + "Startup validations didn't pass", + str(exc) + ) + root.withdraw() + sys.exit(1) + + +def _validate_thirdparty_binaries(): + """Check existence of thirdpart executables.""" + low_platform = platform.system().lower() + binary_vendors_dir = os.path.join( + os.environ["OPENPYPE_ROOT"], + "vendor", + "bin" + ) + + error_msg = ( + "Missing binary dependency {}. Please fetch thirdparty dependencies." + ) + # Validate existence of FFmpeg + ffmpeg_dir = os.path.join(binary_vendors_dir, "ffmpeg", low_platform) + if low_platform == "windows": + ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") + ffmpeg_executable = os.path.join(ffmpeg_dir, "ffmpeg") + if not is_tool(ffmpeg_executable): + raise RuntimeError(error_msg.format("FFmpeg")) + + # Validate existence of OpenImageIO (not on MacOs) + oiio_tool_path = None + if low_platform == "linux": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "bin", + "oiiotool" + ) + elif low_platform == "windows": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "oiiotool" + ) + if oiio_tool_path is not None and not is_tool(oiio_tool_path): + raise RuntimeError(error_msg.format("OpenImageIO")) + + def _process_arguments() -> tuple: """Process command line arguments. @@ -343,7 +462,7 @@ def _process_arguments() -> tuple: # check for `--use-version=3.0.0` argument and `--use-staging` use_version = None use_staging = False - print_versions = False + commands = [] for arg in sys.argv: if arg == "--use-version": _print("!!! Please use option --use-version like:") @@ -366,17 +485,38 @@ def _process_arguments() -> tuple: " proper version string.")) sys.exit(1) + if arg == "--validate-version": + _print("!!! Please use option --validate-version like:") + _print(" --validate-version=3.0.0") + sys.exit(1) + + if arg.startswith("--validate-version="): + m = re.search( + r"--validate-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) + if m and m.group('version'): + use_version = m.group('version') + sys.argv.remove(arg) + commands.append("validate") + else: + _print("!!! Requested version isn't in correct format.") + _print((" Use --list-versions to find out" + " proper version string.")) + sys.exit(1) + if "--use-staging" in sys.argv: use_staging = True sys.argv.remove("--use-staging") if "--list-versions" in sys.argv: - print_versions = True + commands.append("print_versions") sys.argv.remove("--list-versions") # handle igniter # this is helper to run igniter before anything else if "igniter" in sys.argv: + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + sys.exit(1) import igniter return_code = igniter.open_dialog() @@ -389,7 +529,7 @@ def _process_arguments() -> tuple: sys.argv.pop(idx) sys.argv.insert(idx, "tray") - return use_version, use_staging, print_versions + return use_version, use_staging, commands def _determine_mongodb() -> str: @@ -424,6 +564,11 @@ def _determine_mongodb() -> str: if not openpype_mongo: _print("*** No DB connection string specified.") + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + _print( + "!!! Please use `OPENPYPE_MONGO` to specify server address.") + sys.exit(1) _print("--- launching setup UI ...") result = igniter.open_dialog() @@ -527,6 +672,9 @@ def _find_frozen_openpype(use_version: str = None, except IndexError: # no OpenPype version found, run Igniter and ask for them. _print('*** No OpenPype versions found.') + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + sys.exit(1) _print("--- launching setup UI ...") import igniter return_code = igniter.open_dialog() @@ -590,8 +738,16 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - version_path = bootstrap.install_version( - openpype_version, force=True) + if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": + import igniter + version_path = igniter.open_update_window(openpype_version) + else: + version_path = bootstrap.install_version( + openpype_version, force=True) + + openpype_version.path = version_path + _initialize_environment(openpype_version) + return openpype_version.path if openpype_version.path.is_file(): _print(">>> Extracting zip file ...") @@ -724,6 +880,11 @@ def boot(): # ------------------------------------------------------------------------ os.environ["OPENPYPE_ROOT"] = OPENPYPE_ROOT + # ------------------------------------------------------------------------ + # Do necessary startup validations + # ------------------------------------------------------------------------ + _startup_validations() + # ------------------------------------------------------------------------ # Play animation # ------------------------------------------------------------------------ @@ -738,7 +899,7 @@ def boot(): # Process arguments # ------------------------------------------------------------------------ - use_version, use_staging, print_versions = _process_arguments() + use_version, use_staging, commands = _process_arguments() if os.getenv("OPENPYPE_VERSION"): if use_version: @@ -763,16 +924,53 @@ def boot(): os.environ["OPENPYPE_MONGO"] = openpype_mongo os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" # name of Pype database + _print(">>> run disk mapping command ...") + run_disk_mapping_commands(openpype_mongo) + # Get openpype path from database and set it to environment so openpype can # find its versions there and bootstrap them. openpype_path = get_openpype_path_from_db(openpype_mongo) + + if getattr(sys, 'frozen', False): + local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) + else: + local_version = bootstrap.get_local_live_version() + + if "validate" in commands: + _print(f">>> Validating version [ {use_version} ]") + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=True) + openpype_versions += bootstrap.find_openpype(include_zips=True, + staging=False) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if not found: + _print(f"!!! Version [ {use_version} ] not found.") + list_versions(openpype_versions, local_version) + sys.exit(1) + + # print result + result = bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions)) + + _print("{}{}".format( + ">>> " if result[0] else "!!! ", + bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions) + )[1]) + ) + sys.exit(1) + + if not openpype_path: _print("*** Cannot get OpenPype path from database.") if not os.getenv("OPENPYPE_PATH") and openpype_path: os.environ["OPENPYPE_PATH"] = openpype_path - if print_versions: + if "print_versions" in commands: if not use_staging: _print("--- This will list only non-staging versions detected.") _print(" To see staging versions, use --use-staging argument.") @@ -803,6 +1001,13 @@ def boot(): # no version to run _print(f"!!! {e}") sys.exit(1) + # validate version + _print(f">>> Validating version [ {str(version_path)} ]") + result = bootstrap.validate_openpype_version(version_path) + if not result[0]: + _print(f"!!! Invalid version: {result[1]}") + sys.exit(1) + _print(f"--- version is valid") else: version_path = _bootstrap_from_code(use_version, use_staging) diff --git a/tests/README.md b/tests/README.md index e69de29bb2..6317b2ab3c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -0,0 +1,25 @@ +Automatic tests for OpenPype +============================ +Structure: +- integration - end to end tests, slow (see README.md in the integration folder for more info) + - openpype/modules/MODULE_NAME - structure follow directory structure in code base + - fixture - sample data `(MongoDB dumps, test files etc.)` + - `tests.py` - single or more pytest files for MODULE_NAME +- unit - quick unit test + - MODULE_NAME + - fixture + - `tests.py` + +How to run: +---------- +- single test class could be run by PyCharm and its pytest runner directly +- OR +- use Openpype command 'runtests' from command line +-- `${OPENPYPE_ROOT}/start.py runtests` + +By default, this command will run all tests in ${OPENPYPE_ROOT}/tests. + +Specific location could be provided to this command as an argument, either as absolute path, or relative path to ${OPENPYPE_ROOT}. +(eg. `${OPENPYPE_ROOT}/start.py runtests ../tests/integration`) will trigger only tests in `integration` folder. + +See `${OPENPYPE_ROOT}/cli.py:runtests` for other arguments. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000000..81c07ec50c --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,37 @@ +Integration test for OpenPype +============================= +Contains end-to-end tests for automatic testing of OP. + +Should run headless publish on all hosts to check basic publish use cases automatically +to limit regression issues. + +How to create test for publishing from host +------------------------------------------ +- Extend PublishTest +- Use `resources\test_data.zip` skeleton file as a template for testing input data +- Put workfile into `test_data.zip/input/workfile` +- If you require other than base DB dumps provide them to `test_data.zip/input/dumps` +-- (Check commented code in `db_handler.py` how to dump specific DB. Currently all collections will be dumped.) +- Implement `last_workfile_path` +- `startup_scripts` - must contain pointing host to startup script saved into `test_data.zip/input/startup` + -- Script must contain something like +``` +import openpype +from avalon import api, HOST + +api.install(HOST) +pyblish.util.publish() + +EXIT_APP (command to exit host) +``` +(Install and publish methods must be triggered only AFTER host app is fully initialized!) +- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id +- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded +file, provide md5 value of zipped file. +- Implement any assert checks you need in extended class +- Run test class manually (via Pycharm or pytest runner (TODO)) +- If you want test to compare expected files to published one, set PERSIST to True, run test manually + -- Locate temporary `publish` subfolder of temporary folder (found in debugging console log) + -- Copy whole folder content into .zip file into `expected` subfolder + -- By default tests are comparing only structure of `expected` and published format (eg. if you want to save space, replace published files with empty files, but with expected names!) + -- Zip and upload again, change PERSIST to False \ No newline at end of file diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py new file mode 100644 index 0000000000..1babf30029 --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya.py @@ -0,0 +1,103 @@ +import pytest +import os +import shutil + +from tests.lib.testing_classes import PublishTest + + +class TestPublishInMaya(PublishTest): + """Basic test case for publishing in Maya + + Shouldnt be running standalone only via 'runtests' pype command! (??) + + Uses generic TestCase to prepare fixtures for test data, testing DBs, + env vars. + + Opens Maya, run publish on prepared workile. + + Then checks content of DB (if subset, version, representations were + created. + Checks tmp folder if all expected files were published. + + """ + PERSIST = True + + TEST_FILES = [ + ("1pOwjA_VVBc6ooTZyFxtAwLS2KZHaBlkY", "test_maya_publish.zip", "") + ] + + APP = "maya" + APP_VARIANT = "2019" + + APP_NAME = "{}/{}".format(APP, APP_VARIANT) + + TIMEOUT = 120 # publish timeout + + @pytest.fixture(scope="module") + def last_workfile_path(self, download_test_data): + """Get last_workfile_path from source data. + + Maya expects workfile in proper folder, so copy is done first. + """ + src_path = os.path.join(download_test_data, + "input", + "workfile", + "test_project_test_asset_TestTask_v001.mb") + dest_folder = os.path.join(download_test_data, + self.PROJECT, + self.ASSET, + "work", + self.TASK) + os.makedirs(dest_folder) + dest_path = os.path.join(dest_folder, + "test_project_test_asset_TestTask_v001.mb") + shutil.copy(src_path, dest_path) + + yield dest_path + + @pytest.fixture(scope="module") + def startup_scripts(self, monkeypatch_session, download_test_data): + """Points Maya to userSetup file from input data""" + startup_path = os.path.join(download_test_data, + "input", + "startup") + original_pythonpath = os.environ.get("PYTHONPATH") + monkeypatch_session.setenv("PYTHONPATH", + "{}{}{}".format(startup_path, + os.pathsep, + original_pythonpath)) + + def test_db_asserts(self, dbcon, publish_finished): + """Host and input data dependent expected results in DB.""" + print("test_db_asserts") + assert 5 == dbcon.count_documents({"type": "version"}), \ + "Not expected no of versions" + + assert 0 == dbcon.count_documents({"type": "version", + "name": {"$ne": 1}}), \ + "Only versions with 1 expected" + + assert 1 == dbcon.count_documents({"type": "subset", + "name": "modelMain"}), \ + "modelMain subset must be present" + + assert 1 == dbcon.count_documents({"type": "subset", + "name": "workfileTest_task"}), \ + "workfileTest_task subset must be present" + + assert 11 == dbcon.count_documents({"type": "representation"}), \ + "Not expected no of representations" + + assert 2 == dbcon.count_documents({"type": "representation", + "context.subset": "modelMain", + "context.ext": "abc"}), \ + "Not expected no of representations with ext 'abc'" + + assert 2 == dbcon.count_documents({"type": "representation", + "context.subset": "modelMain", + "context.ext": "ma"}), \ + "Not expected no of representations with ext 'abc'" + + +if __name__ == "__main__": + test_case = TestPublishInMaya() diff --git a/tests/lib/README.md b/tests/lib/README.md new file mode 100644 index 0000000000..0384cd2ff0 --- /dev/null +++ b/tests/lib/README.md @@ -0,0 +1,46 @@ +Automatic testing +----------------- +Folder for libs and tooling for automatic testing. + +- db_handler.py - class for preparation of test DB + - dumps DB(s) to BSON (mongodump) + - loads dump(s) to new DB (mongorestore) + - loads sql file(s) to DB (mongoimport) + - deletes test DB + +- file_handler.py - class to download test data from GDrive + - downloads data from (list) of files from GDrive + - check file integrity with MD5 hash + - unzips if zip + +- testing_wrapper.py - base class to use for testing + - all env var necessary for running (OPENPYPE_MONGO ...) + - implements reusable fixtures to: + - load test data (uses `file_handler`) + - prepare DB (uses `db_handler`) + - modify temporarily env vars for testing + + Should be used as a skeleton to create new test cases. + + +Test data +--------- +Each class implementing `TestCase` can provide test file(s) by adding them to +TEST_FILES ('GDRIVE_FILE_ID', 'ACTUAL_FILE_NAME', 'MD5HASH') + +GDRIVE_FILE_ID can be pulled from shareable link from Google Drive app. + +Currently it is expected that test file will be zip file with structure: +- expected - expected files (not implemented yet) +- input + - data - test data (workfiles, images etc) + - dumps - folder for BSON dumps from (`mongodump`) + - env_vars + env_vars.json - dictionary with environment variables {key:value} + + - json - json files to load with `mongoimport` (human readable) + + +Example +------- +See `tests\unit\openpype\modules\sync_server\test_site_operations.py` for example usage of implemented classes. \ No newline at end of file diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/db_handler.py b/tests/lib/db_handler.py new file mode 100644 index 0000000000..9be70895da --- /dev/null +++ b/tests/lib/db_handler.py @@ -0,0 +1,231 @@ +""" + Helper class for automatic testing, provides dump and restore via command + line utilities. + + Expect mongodump, mongoimport and mongorestore present at PATH +""" +import os +import pymongo +import subprocess + + +class DBHandler: + + def __init__(self, uri=None, host=None, port=None, + user=None, password=None): + """'uri' or rest of separate credentials""" + if uri: + self.uri = uri + if host: + if all([user, password]): + host = "{}:{}@{}".format(user, password, host) + self.uri = 'mongodb://{}:{}'.format(host, port or 27017) + + assert self.uri, "Must have uri to MongoDB" + self.client = pymongo.MongoClient(uri) + self.db = None + + def setup_empty(self, name): + # not much sense + self.db = self.client[name] + + def setup_from_sql(self, db_name, sql_dir, collection=None, + drop=True, mode=None): + """ + Restores 'db_name' from 'sql_url'. + + Works with directory with .json files, + if 'collection' arg is empty, name + of .json file is used as name of target collection. + + Args: + db_name (str): source DB name + sql_dir (str): folder with json files + collection (str): if all sql files are meant for single coll. + drop (bool): True if drop whole collection + mode (str): "insert" - fails on duplicates + "upsert" - modifies existing + "merge" - updates existing + "delete" - removes in DB present if file + """ + if not os.path.exists(sql_dir): + raise RuntimeError( + "Backup folder {} doesn't exist".format(sql_dir)) + + for (dirpath, _dirnames, filenames) in os.walk(sql_dir): + for file_name in filenames: + sql_url = os.path.join(dirpath, file_name) + query = self._import_query(self.uri, sql_url, + db_name=db_name, + collection=collection, + drop=drop, + mode=mode) + + print("mongoimport query:: {}".format(query)) + subprocess.run(query) + + def setup_from_sql_file(self, db_name, sql_url, + collection=None, drop=True, mode=None): + """ + Restores 'db_name' from 'sql_url'. + + Works with single .json file. + If 'collection' arg is empty, name + of .json file is used as name of target collection. + + Args: + db_name (str): source DB name + sql_file (str): folder with json files + collection (str): name of target collection + drop (bool): True if drop collection + mode (str): "insert" - fails on duplicates + "upsert" - modifies existing + "merge" - updates existing + "delete" - removes in DB present if file + """ + if not os.path.exists(sql_url): + raise RuntimeError( + "Sql file {} doesn't exist".format(sql_url)) + + query = self._import_query(self.uri, sql_url, + db_name=db_name, + collection=collection, + drop=drop, + mode=mode) + + print("mongoimport query:: {}".format(query)) + subprocess.run(query) + + def setup_from_dump(self, db_name, dump_dir, overwrite=False, + collection=None, db_name_out=None): + """ + Restores 'db_name' from 'dump_dir'. + + Works with BSON folders exported by mongodump + + Args: + db_name (str): source DB name + dump_dir (str): folder with dumped subfolders + overwrite (bool): True if overwrite target + collection (str): name of source project + db_name_out (str): name of target DB, if empty restores to + source 'db_name' + """ + db_name_out = db_name_out or db_name + if self._db_exists(db_name) and not overwrite: + raise RuntimeError("DB {} already exists".format(db_name_out) + + "Run with overwrite=True") + + dir_path = os.path.join(dump_dir, db_name) + if not os.path.exists(dir_path): + raise RuntimeError( + "Backup folder {} doesn't exist".format(dir_path)) + + query = self._restore_query(self.uri, dump_dir, + db_name=db_name, db_name_out=db_name_out, + collection=collection) + print("mongorestore query:: {}".format(query)) + subprocess.run(query) + + def teardown(self, db_name): + """Drops 'db_name' if exists.""" + if not self._db_exists(db_name): + print("{} doesn't exist".format(db_name)) + return + + print("Dropping {} database".format(db_name)) + self.client.drop_database(db_name) + + def backup_to_dump(self, db_name, dump_dir, overwrite=False): + """ + Helper method for running mongodump for specific 'db_name' + """ + if not self._db_exists(db_name) and not overwrite: + raise RuntimeError("DB {} doesn't exists".format(db_name)) + + dir_path = os.path.join(dump_dir, db_name) + if os.path.exists(dir_path) and not overwrite: + raise RuntimeError("Backup already exists, " + "run with overwrite=True") + + query = self._dump_query(self.uri, dump_dir, db_name=db_name) + print("Mongodump query:: {}".format(query)) + subprocess.run(query) + + def _db_exists(self, db_name): + return db_name in self.client.list_database_names() + + def _dump_query(self, uri, output_path, db_name=None, collection=None): + """Prepares dump query based on 'db_name' or 'collection'.""" + db_part = coll_part = "" + if db_name: + db_part = "--db={}".format(db_name) + if collection: + if not db_name: + raise ValueError("db_name must be present") + coll_part = "--nsInclude={}.{}".format(db_name, collection) + query = "\"{}\" --uri=\"{}\" --out={} {} {}".format( + "mongodump", uri, output_path, db_part, coll_part + ) + + return query + + def _restore_query(self, uri, dump_dir, + db_name=None, db_name_out=None, + collection=None, drop=True): + """Prepares query for mongorestore base on arguments""" + db_part = coll_part = drop_part = "" + if db_name: + db_part = "--nsInclude={}.* --nsFrom={}.*".format(db_name, db_name) + if collection: + assert db_name, "Must provide db name too" + db_part = "--nsInclude={}.{} --nsFrom={}.{}".format(db_name, + collection, + db_name, + collection) + if drop: + drop_part = "--drop" + + if db_name_out: + db_part += " --nsTo={}.*".format(db_name_out) + + query = "\"{}\" --uri=\"{}\" --dir=\"{}\" {} {} {}".format( + "mongorestore", uri, dump_dir, db_part, coll_part, drop_part + ) + + return query + + def _import_query(self, uri, sql_url, + db_name=None, + collection=None, drop=True, mode=None): + + db_part = coll_part = drop_part = mode_part = "" + if db_name: + db_part = "--db {}".format(db_name) + if collection: + assert db_name, "Must provide db name too" + coll_part = "--collection {}".format(collection) + if drop: + drop_part = "--drop" + if mode: + mode_part = "--mode {}".format(mode) + + query = \ + "\"{}\" --legacy --uri=\"{}\" --file=\"{}\" {} {} {} {}".format( + "mongoimport", uri, sql_url, + db_part, coll_part, drop_part, mode_part) + + return query + +# handler = DBHandler(uri="mongodb://localhost:27017") +# +# backup_dir = "c:\\projects\\dumps" +# # +# handler.backup_to_dump("openpype", backup_dir, True) +# # handler.setup_from_dump("test_db", backup_dir, True) +# # handler.setup_from_sql_file("test_db", "c:\\projects\\sql\\item.sql", +# # collection="test_project", +# # drop=False, mode="upsert") +# handler.setup_from_sql("test_db", "c:\\projects\\sql", +# collection="test_project", +# drop=False, mode="upsert") diff --git a/tests/lib/file_handler.py b/tests/lib/file_handler.py new file mode 100644 index 0000000000..ee3abc6ecb --- /dev/null +++ b/tests/lib/file_handler.py @@ -0,0 +1,260 @@ +import enlighten +import os +import re +import urllib +from urllib.parse import urlparse +import urllib.request +import urllib.error +import itertools +import hashlib +import tarfile +import zipfile + + +USER_AGENT = "openpype" + + +class RemoteFileHandler: + """Download file from url, might be GDrive shareable link""" + + IMPLEMENTED_ZIP_FORMATS = ['zip', 'tar', 'tgz', + 'tar.gz', 'tar.xz', 'tar.bz2'] + + @staticmethod + def calculate_md5(fpath, chunk_size): + md5 = hashlib.md5() + with open(fpath, 'rb') as f: + for chunk in iter(lambda: f.read(chunk_size), b''): + md5.update(chunk) + return md5.hexdigest() + + @staticmethod + def check_md5(fpath, md5, **kwargs): + return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) + + @staticmethod + def check_integrity(fpath, md5=None): + if not os.path.isfile(fpath): + return False + if md5 is None: + return True + return RemoteFileHandler.check_md5(fpath, md5) + + @staticmethod + def download_url( + url, root, filename=None, + md5=None, max_redirect_hops=3 + ): + """Download a file from a url and place it in root. + Args: + url (str): URL to download file from + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. + If None, use the basename of the URL + md5 (str, optional): MD5 checksum of the download. + If None, do not check + max_redirect_hops (int, optional): Maximum number of redirect + hops allowed + """ + root = os.path.expanduser(root) + if not filename: + filename = os.path.basename(url) + fpath = os.path.join(root, filename) + + os.makedirs(root, exist_ok=True) + + # check if file is already present locally + if RemoteFileHandler.check_integrity(fpath, md5): + print('Using downloaded and verified file: ' + fpath) + return + + # expand redirect chain if needed + url = RemoteFileHandler._get_redirect_url(url, + max_hops=max_redirect_hops) + + # check if file is located on Google Drive + file_id = RemoteFileHandler._get_google_drive_file_id(url) + if file_id is not None: + return RemoteFileHandler.download_file_from_google_drive( + file_id, root, filename, md5) + + # download the file + try: + print('Downloading ' + url + ' to ' + fpath) + RemoteFileHandler._urlretrieve(url, fpath) + except (urllib.error.URLError, IOError) as e: + if url[:5] == 'https': + url = url.replace('https:', 'http:') + print('Failed download. Trying https -> http instead.' + ' Downloading ' + url + ' to ' + fpath) + RemoteFileHandler._urlretrieve(url, fpath) + else: + raise e + + # check integrity of downloaded file + if not RemoteFileHandler.check_integrity(fpath, md5): + raise RuntimeError("File not found or corrupted.") + + @staticmethod + def download_file_from_google_drive(file_id, root, + filename=None, + md5=None): + """Download a Google Drive file from and place it in root. + Args: + file_id (str): id of file to be downloaded + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. + If None, use the id of the file. + md5 (str, optional): MD5 checksum of the download. + If None, do not check + """ + # Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa + import requests + url = "https://docs.google.com/uc?export=download" + + root = os.path.expanduser(root) + if not filename: + filename = file_id + fpath = os.path.join(root, filename) + + os.makedirs(root, exist_ok=True) + + if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath, + md5): + print('Using downloaded and verified file: ' + fpath) + else: + session = requests.Session() + + response = session.get(url, params={'id': file_id}, stream=True) + token = RemoteFileHandler._get_confirm_token(response) + + if token: + params = {'id': file_id, 'confirm': token} + response = session.get(url, params=params, stream=True) + + response_content_generator = response.iter_content(32768) + first_chunk = None + while not first_chunk: # filter out keep-alive new chunks + first_chunk = next(response_content_generator) + + if RemoteFileHandler._quota_exceeded(first_chunk): + msg = ( + f"The daily quota of the file {filename} is exceeded and " + f"it can't be downloaded. This is a limitation of " + f"Google Drive and can only be overcome by trying " + f"again later." + ) + raise RuntimeError(msg) + + RemoteFileHandler._save_response_content( + itertools.chain((first_chunk, ), + response_content_generator), fpath) + response.close() + + @staticmethod + def unzip(path, destination_path=None): + if not destination_path: + destination_path = os.path.dirname(path) + + _, archive_type = os.path.splitext(path) + archive_type = archive_type.lstrip('.') + + if archive_type in ['zip']: + print("Unzipping {}->{}".format(path, destination_path)) + zip_file = zipfile.ZipFile(path) + zip_file.extractall(destination_path) + zip_file.close() + + elif archive_type in [ + 'tar', 'tgz', 'tar.gz', 'tar.xz', 'tar.bz2' + ]: + print("Unzipping {}->{}".format(path, destination_path)) + if archive_type == 'tar': + tar_type = 'r:' + elif archive_type.endswith('xz'): + tar_type = 'r:xz' + elif archive_type.endswith('gz'): + tar_type = 'r:gz' + elif archive_type.endswith('bz2'): + tar_type = 'r:bz2' + else: + tar_type = 'r:*' + try: + tar_file = tarfile.open(path, tar_type) + except tarfile.ReadError: + raise SystemExit("corrupted archive") + tar_file.extractall(destination_path) + tar_file.close() + + @staticmethod + def _urlretrieve(url, filename, chunk_size): + with open(filename, "wb") as fh: + with urllib.request.urlopen( + urllib.request.Request(url, + headers={"User-Agent": USER_AGENT})) \ + as response: + for chunk in iter(lambda: response.read(chunk_size), ""): + if not chunk: + break + fh.write(chunk) + + @staticmethod + def _get_redirect_url(url, max_hops): + initial_url = url + headers = {"Method": "HEAD", "User-Agent": USER_AGENT} + + for _ in range(max_hops + 1): + with urllib.request.urlopen( + urllib.request.Request(url, headers=headers)) as response: + if response.url == url or response.url is None: + return url + + url = response.url + else: + raise RecursionError( + f"Request to {initial_url} exceeded {max_hops} redirects. " + f"The last redirect points to {url}." + ) + + @staticmethod + def _get_confirm_token(response): + for key, value in response.cookies.items(): + if key.startswith('download_warning'): + return value + + return None + + @staticmethod + def _save_response_content( + response_gen, destination, + ): + with open(destination, "wb") as f: + pbar = enlighten.Counter( + total=None, desc="Save content", units="%", color="green") + progress = 0 + for chunk in response_gen: + if chunk: # filter out keep-alive new chunks + f.write(chunk) + progress += len(chunk) + + pbar.close() + + @staticmethod + def _quota_exceeded(first_chunk): + try: + return "Google Drive - Quota exceeded" in first_chunk.decode() + except UnicodeDecodeError: + return False + + @staticmethod + def _get_google_drive_file_id(url): + parts = urlparse(url) + + if re.match(r"(drive|docs)[.]google[.]com", parts.netloc) is None: + return None + + match = re.match(r"/file/d/(?P[^/]*)", parts.path) + if match is None: + return None + + return match.group("id") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py new file mode 100644 index 0000000000..1832efb7ed --- /dev/null +++ b/tests/lib/testing_classes.py @@ -0,0 +1,263 @@ +"""Testing classes for module testing and publishing in hosts.""" +import os +import sys +import six +import json +import pytest +import tempfile +import shutil +import glob + +from tests.lib.db_handler import DBHandler +from tests.lib.file_handler import RemoteFileHandler + + +class BaseTest: + """Empty base test class""" + + +class ModuleUnitTest(BaseTest): + """Generic test class for testing modules + + Use PERSIST==True to keep temporary folder and DB prepared for + debugging or preparation of test files. + + Implemented fixtures: + monkeypatch_session - fixture for env vars with session scope + download_test_data - tmp folder with extracted data from GDrive + env_var - sets env vars from input file + db_setup - prepares avalon AND openpype DBs for testing from + binary dumps from input data + dbcon - returns DBConnection to AvalonDB + dbcon_openpype - returns DBConnection for OpenpypeMongoDB + + """ + PERSIST = False # True to not purge temporary folder nor test DB + + TEST_OPENPYPE_MONGO = "mongodb://localhost:27017" + TEST_DB_NAME = "test_db" + TEST_PROJECT_NAME = "test_project" + TEST_OPENPYPE_NAME = "test_openpype" + + TEST_FILES = [] + + PROJECT = "test_project" + ASSET = "test_asset" + TASK = "test_task" + + @pytest.fixture(scope='session') + def monkeypatch_session(self): + """Monkeypatch couldn't be used with module or session fixtures.""" + from _pytest.monkeypatch import MonkeyPatch + m = MonkeyPatch() + yield m + m.undo() + + @pytest.fixture(scope="module") + def download_test_data(self): + tmpdir = tempfile.mkdtemp() + for test_file in self.TEST_FILES: + file_id, file_name, md5 = test_file + + f_name, ext = os.path.splitext(file_name) + + RemoteFileHandler.download_file_from_google_drive(file_id, + str(tmpdir), + file_name) + + if ext.lstrip('.') in RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS: + RemoteFileHandler.unzip(os.path.join(tmpdir, file_name)) + print("Temporary folder created:: {}".format(tmpdir)) + yield tmpdir + + if not self.PERSIST: + print("Removing {}".format(tmpdir)) + shutil.rmtree(tmpdir) + + @pytest.fixture(scope="module") + def env_var(self, monkeypatch_session, download_test_data): + """Sets temporary env vars from json file.""" + env_url = os.path.join(download_test_data, "input", + "env_vars", "env_var.json") + if not os.path.exists(env_url): + raise ValueError("Env variable file {} doesn't exist". + format(env_url)) + + env_dict = {} + try: + with open(env_url) as json_file: + env_dict = json.load(json_file) + except ValueError: + print("{} doesn't contain valid JSON") + six.reraise(*sys.exc_info()) + + for key, value in env_dict.items(): + all_vars = globals() + all_vars.update(vars(ModuleUnitTest)) # TODO check + value = value.format(**all_vars) + print("Setting {}:{}".format(key, value)) + monkeypatch_session.setenv(key, str(value)) + import openpype + + openpype_root = os.path.dirname(os.path.dirname(openpype.__file__)) + # ?? why 2 of those + monkeypatch_session.setenv("OPENPYPE_ROOT", openpype_root) + monkeypatch_session.setenv("OPENPYPE_REPOS_ROOT", openpype_root) + + @pytest.fixture(scope="module") + def db_setup(self, download_test_data, env_var, monkeypatch_session): + """Restore prepared MongoDB dumps into selected DB.""" + backup_dir = os.path.join(download_test_data, "input", "dumps") + + uri = os.environ.get("OPENPYPE_MONGO") + db_handler = DBHandler(uri) + db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, True, + db_name_out=self.TEST_DB_NAME) + + db_handler.setup_from_dump("openpype", backup_dir, True, + db_name_out=self.TEST_OPENPYPE_NAME) + + yield db_handler + + if not self.PERSIST: + db_handler.teardown(self.TEST_DB_NAME) + db_handler.teardown(self.TEST_OPENPYPE_NAME) + + @pytest.fixture(scope="module") + def dbcon(self, db_setup): + """Provide test database connection. + + Database prepared from dumps with 'db_setup' fixture. + """ + from avalon.api import AvalonMongoDB + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = self.TEST_PROJECT_NAME + yield dbcon + + @pytest.fixture(scope="module") + def dbcon_openpype(self, db_setup): + """Provide test database connection for OP settings. + + Database prepared from dumps with 'db_setup' fixture. + """ + from openpype.lib import OpenPypeMongoConnection + mongo_client = OpenPypeMongoConnection.get_mongo_client() + yield mongo_client[self.TEST_OPENPYPE_NAME]["settings"] + + +class PublishTest(ModuleUnitTest): + """Test class for publishing in hosts. + + Implemented fixtures: + launched_app - launches APP with last_workfile_path + publish_finished - waits until publish is finished, host must + kill its process when finished publishing. Includes timeout + which raises ValueError + + Not implemented: + last_workfile_path - returns path to testing workfile + startup_scripts - provide script for setup in host + + Implemented tests: + test_folder_structure_same - compares published and expected + subfolders if they contain same files. Compares only on file + presence + + TODO: implement test on file size, file content + """ + + APP = "" + APP_VARIANT = "" + + APP_NAME = "{}/{}".format(APP, APP_VARIANT) + + TIMEOUT = 120 # publish timeout + + @pytest.fixture(scope="module") + def last_workfile_path(self, download_test_data): + raise NotImplementedError + + @pytest.fixture(scope="module") + def startup_scripts(self, monkeypatch_session, download_test_data): + raise NotImplementedError + + @pytest.fixture(scope="module") + def launched_app(self, dbcon, download_test_data, last_workfile_path, + startup_scripts): + """Launch host app""" + # set publishing folders + root_key = "config.roots.work.{}".format("windows") # TEMP + dbcon.update_one( + {"type": "project"}, + {"$set": + { + root_key: download_test_data + }} + ) + + # set schema - for integrate_new + from openpype import PACKAGE_DIR + # Path to OpenPype's schema + schema_path = os.path.join( + os.path.dirname(PACKAGE_DIR), + "schema" + ) + os.environ["AVALON_SCHEMA"] = schema_path + + import openpype + openpype.install() + os.environ["OPENPYPE_EXECUTABLE"] = sys.executable + from openpype.lib import ApplicationManager + + application_manager = ApplicationManager() + data = { + "last_workfile_path": last_workfile_path, + "start_last_workfile": True, + "project_name": self.PROJECT, + "asset_name": self.ASSET, + "task_name": self.TASK + } + + yield application_manager.launch(self.APP_NAME, **data) + + @pytest.fixture(scope="module") + def publish_finished(self, dbcon, launched_app, download_test_data): + """Dummy fixture waiting for publish to finish""" + import time + time_start = time.time() + while launched_app.poll() is None: + time.sleep(0.5) + if time.time() - time_start > self.TIMEOUT: + raise ValueError("Timeout reached") + + # some clean exit test possible? + print("Publish finished") + yield True + + def test_folder_structure_same(self, dbcon, publish_finished, + download_test_data): + """Check if expected and published subfolders contain same files. + + Compares only presence, not size nor content! + """ + published_dir_base = download_test_data + published_dir = os.path.join(published_dir_base, + self.PROJECT, + self.TASK, + "**") + expected_dir_base = os.path.join(published_dir_base, + "expected") + expected_dir = os.path.join(expected_dir_base, + self.PROJECT, + self.TASK, + "**") + + published = set(f.replace(published_dir_base, '') for f in + glob.glob(published_dir, recursive=True) if + f != published_dir_base and os.path.exists(f)) + expected = set(f.replace(expected_dir_base, '') for f in + glob.glob(expected_dir, recursive=True) if + f != expected_dir_base and os.path.exists(f)) + + not_matched = expected.difference(published) + assert not not_matched, "Missing {} files".format(not_matched) diff --git a/tests/resources/test_data.zip b/tests/resources/test_data.zip new file mode 100644 index 0000000000..0faab86b37 Binary files /dev/null and b/tests/resources/test_data.zip differ diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/unit/igniter/test_bootstrap_repos.py similarity index 100% rename from tests/igniter/test_bootstrap_repos.py rename to tests/unit/igniter/test_bootstrap_repos.py diff --git a/tests/igniter/test_tools.py b/tests/unit/igniter/test_tools.py similarity index 100% rename from tests/igniter/test_tools.py rename to tests/unit/igniter/test_tools.py diff --git a/tests/openpype/lib/test_user_settings.py b/tests/unit/openpype/lib/test_user_settings.py similarity index 100% rename from tests/openpype/lib/test_user_settings.py rename to tests/unit/openpype/lib/test_user_settings.py diff --git a/tests/unit/openpype/modules/sync_server/test_site_operations.py b/tests/unit/openpype/modules/sync_server/test_site_operations.py new file mode 100644 index 0000000000..6a861100a4 --- /dev/null +++ b/tests/unit/openpype/modules/sync_server/test_site_operations.py @@ -0,0 +1,134 @@ +"""Test file for Sync Server, tests site operations add_site, remove_site. + + File: + creates temporary directory and downloads .zip file from GDrive + unzips .zip file + uses content of .zip file (MongoDB's dumps) to import to new databases + with use of 'monkeypatch_session' modifies required env vars + temporarily + runs battery of tests checking that site operation for Sync Server + module are working + removes temporary folder + removes temporary databases (?) +""" +import pytest + +from tests.lib.testing_classes import ModuleUnitTest +from bson.objectid import ObjectId + + +class TestSiteOperation(ModuleUnitTest): + + REPRESENTATION_ID = "60e578d0c987036c6a7b741d" + + TEST_FILES = [("1eCwPljuJeOI8A3aisfOIBKKjcmIycTEt", + "test_site_operations.zip", '')] + + @pytest.fixture(scope="module") + def setup_sync_server_module(self, dbcon): + """Get sync_server_module from ModulesManager""" + from openpype.modules import ModulesManager + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + yield sync_server + + @pytest.mark.usefixtures("dbcon") + def test_project_created(self, dbcon): + assert ['test_project'] == dbcon.database.collection_names(False) + + @pytest.mark.usefixtures("dbcon") + def test_objects_imported(self, dbcon): + count_obj = len(list(dbcon.database[self.TEST_PROJECT_NAME].find({}))) + assert 15 == count_obj + + @pytest.mark.usefixtures("setup_sync_server_module") + def test_add_site(self, dbcon, setup_sync_server_module): + """Adds 'test_site', checks that added, + checks that doesn't duplicate.""" + query = { + "_id": ObjectId(self.REPRESENTATION_ID) + } + + ret = dbcon.database[self.TEST_PROJECT_NAME].find(query) + + assert 1 == len(list(ret)), \ + "Single {} must be in DB".format(self.REPRESENTATION_ID) + + setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, + self.REPRESENTATION_ID, + site_name='test_site') + + ret = list(dbcon.database[self.TEST_PROJECT_NAME].find(query)) + + assert 1 == len(ret), \ + "Single {} must be in DB".format(self.REPRESENTATION_ID) + + ret = ret.pop() + site_names = [site["name"] for site in ret["files"][0]["sites"]] + assert 'test_site' in site_names, "Site name wasn't added" + + @pytest.mark.usefixtures("setup_sync_server_module") + def test_add_site_again(self, dbcon, setup_sync_server_module): + """Depends on test_add_site, must throw exception.""" + with pytest.raises(ValueError): + setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, + self.REPRESENTATION_ID, + site_name='test_site') + + @pytest.mark.usefixtures("setup_sync_server_module") + def test_add_site_again_force(self, dbcon, setup_sync_server_module): + """Depends on test_add_site, must not throw exception.""" + setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, + self.REPRESENTATION_ID, + site_name='test_site', force=True) + + query = { + "_id": ObjectId(self.REPRESENTATION_ID) + } + + ret = list(dbcon.database[self.TEST_PROJECT_NAME].find(query)) + + assert 1 == len(ret), \ + "Single {} must be in DB".format(self.REPRESENTATION_ID) + + @pytest.mark.usefixtures("setup_sync_server_module") + def test_remove_site(self, dbcon, setup_sync_server_module): + """Depends on test_add_site, must remove 'test_site'.""" + setup_sync_server_module.remove_site(self.TEST_PROJECT_NAME, + self.REPRESENTATION_ID, + site_name='test_site') + + query = { + "_id": ObjectId(self.REPRESENTATION_ID) + } + + ret = list(dbcon.database[self.TEST_PROJECT_NAME].find(query)) + + assert 1 == len(ret), \ + "Single {} must be in DB".format(self.REPRESENTATION_ID) + + ret = ret.pop() + site_names = [site["name"] for site in ret["files"][0]["sites"]] + + assert 'test_site' not in site_names, "Site name wasn't removed" + + @pytest.mark.usefixtures("setup_sync_server_module") + def test_remove_site_again(self, dbcon, setup_sync_server_module): + """Depends on test_add_site, must trow exception""" + with pytest.raises(ValueError): + setup_sync_server_module.remove_site(self.TEST_PROJECT_NAME, + self.REPRESENTATION_ID, + site_name='test_site') + + query = { + "_id": ObjectId(self.REPRESENTATION_ID) + } + + ret = list(dbcon.database[self.TEST_PROJECT_NAME].find(query)) + + assert 1 == len(ret), \ + "Single {} must be in DB".format(self.REPRESENTATION_ID) + + +test_case = TestSiteOperation() diff --git a/tools/build_win_installer.ps1 b/tools/build_win_installer.ps1 index a0832e0135..49fa803742 100644 --- a/tools/build_win_installer.ps1 +++ b/tools/build_win_installer.ps1 @@ -105,6 +105,46 @@ $env:BUILD_VERSION = $openpype_version iscc +Write-Host ">>> " -NoNewline -ForegroundColor green +Write-Host "Detecting host Python ... " -NoNewline +$python = "python" +if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { + $pyenv_python = & pyenv which python + if (Test-Path -PathType Leaf -Path "$($pyenv_python)") { + $python = $pyenv_python + } +} +if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { + Write-Host "!!! Python not detected" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} +$version_command = @' +import sys +print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) +'@ + +$p = & $python -c $version_command +$env:PYTHON_VERSION = $p +$m = $p -match '(\d+)\.(\d+)' +if(-not $m) { + Write-Host "!!! Cannot determine version" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} +# We are supporting python 3.7 only +if (($matches[1] -lt 3) -or ($matches[2] -lt 7)) { + Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} elseif (($matches[1] -eq 3) -and ($matches[2] -gt 7)) { + Write-Host "WARNING Version [ $p ] is unsupported, use at your own risk." -ForegroundColor yellow + Write-Host "*** " -NoNewline -ForegroundColor yellow + Write-Host "OpenPype supports only Python 3.7" -ForegroundColor white +} else { + Write-Host "OK [ $p ]" -ForegroundColor green +} + Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Creating OpenPype installer ... " -ForegroundColor white @@ -114,7 +154,7 @@ from distutils.util import get_platform print('exe.{}-{}'.format(get_platform(), sys.version[0:3])) "@ -$build_dir = & python -c $build_dir_command +$build_dir = & $python -c $build_dir_command Write-Host "Build directory ... ${build_dir}" -ForegroundColor white $env:BUILD_DIR = $build_dir diff --git a/tools/ci_tools.py b/tools/ci_tools.py index 3c1aaae991..337b19a346 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -3,7 +3,34 @@ import sys from semver import VersionInfo from git import Repo from optparse import OptionParser +from github import Github +import os +def get_release_type_github(Log, github_token): + # print(Log) + minor_labels = ["type: feature", "type: deprecated"] + patch_labels = ["type: enhancement", "type: bug"] + + g = Github(github_token) + repo = g.get_repo("pypeclub/OpenPype") + + labels = set() + for line in Log.splitlines(): + match = re.search("pull request #(\d+)", line) + if match: + pr_number = match.group(1) + pr = repo.get_pull(int(pr_number)) + for label in pr.labels: + labels.add(label.name) + + if any(label in labels for label in minor_labels): + return "minor" + + if any(label in labels for label in patch_labels): + return "path" + + return None + def remove_prefix(text, prefix): return text[text.startswith(prefix) and len(prefix):] @@ -36,7 +63,7 @@ def get_log_since_tag(version): def release_type(log): regex_minor = ["feature/", "(feat)"] - regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"] + regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/", "update"] for reg in regex_minor: if re.search(reg, log): return "minor" @@ -69,7 +96,7 @@ def bump_file_versions(version): file_regex_replace(filename, regex, pyproject_version) -def calculate_next_nightly(token="nightly"): +def calculate_next_nightly(type="nightly", github_token=None): last_prerelease, last_pre_tag = get_last_version("CI") last_pre_v = VersionInfo.parse(last_prerelease) last_pre_v_finalized = last_pre_v.finalize_version() @@ -78,7 +105,10 @@ def calculate_next_nightly(token="nightly"): last_release, last_release_tag = get_last_version("release") last_release_v = VersionInfo.parse(last_release) - bump_type = release_type(get_log_since_tag(last_release)) + bump_type = get_release_type_github( + get_log_since_tag(last_release_tag), + github_token + ) if not bump_type: return None @@ -86,10 +116,10 @@ def calculate_next_nightly(token="nightly"): # print(next_release_v) if next_release_v > last_pre_v_finalized: - next_tag = next_release_v.bump_prerelease(token=token).__str__() + next_tag = next_release_v.bump_prerelease(token=type).__str__() return next_tag elif next_release_v == last_pre_v_finalized: - next_tag = last_pre_v.bump_prerelease(token=token).__str__() + next_tag = last_pre_v.bump_prerelease(token=type).__str__() return next_tag def finalize_latest_nightly(): @@ -125,30 +155,36 @@ def main(): help="finalize latest prerelease to a release") parser.add_option("-p", "--prerelease", dest="prerelease", action="store", - help="define prerelease token") + help="define prerelease type") parser.add_option("-f", "--finalize", dest="finalize", action="store", - help="define prerelease token") + help="define prerelease type") parser.add_option("-v", "--version", dest="version", action="store", help="work with explicit version") parser.add_option("-l", "--lastversion", dest="lastversion", action="store", help="work with explicit version") + parser.add_option("-g", "--github_token", + dest="github_token", action="store", + help="github token") (options, args) = parser.parse_args() if options.bump: - last_CI, last_CI_tag = get_last_version("CI") last_release, last_release_tag = get_last_version("release") - bump_type_CI = release_type(get_log_since_tag(last_CI_tag)) - bump_type_release = release_type(get_log_since_tag(last_release_tag)) - if bump_type_CI is None or bump_type_release is None: + bump_type_release = get_release_type_github( + get_log_since_tag(last_release_tag), + options.github_token + ) + if bump_type_release is None: print("skip") + else: + print(bump_type_release) if options.nightly: - next_tag_v = calculate_next_nightly() + next_tag_v = calculate_next_nightly(github_token=options.github_token) print(next_tag_v) bump_file_versions(next_tag_v) diff --git a/website/docs/admin_openpype_commands.md b/website/docs/admin_openpype_commands.md index 1a91e2e7fe..7a46ee7906 100644 --- a/website/docs/admin_openpype_commands.md +++ b/website/docs/admin_openpype_commands.md @@ -18,11 +18,14 @@ Running OpenPype without any commands will default to `tray`. ```shell openpype_console --use-version=3.0.0-foo+bar ``` +`--headless` - to run OpenPype in headless mode (without using graphical UI) `--use-staging` - to use staging versions of OpenPype. `--list-versions [--use-staging]` - to list available versions. +`--validate-version` to validate integrity of given version + For more information [see here](admin_use#run-openpype). ## Commands @@ -52,7 +55,7 @@ openpype_console tray --debug --- ### `launch` arguments {#eventserver-arguments} You have to set either proper environment variables to provide URL and credentials or use -option to specify them. If you use `--store_credentials` provided credentials will be stored for later use. +option to specify them. | Argument | Description | | --- | --- | @@ -60,16 +63,13 @@ option to specify them. If you use `--store_credentials` provided credentials wi | `--ftrack-url` | URL to ftrack server (can be set with `FTRACK_SERVER`) | | `--ftrack-user` |user name to log in to ftrack (can be set with `FTRACK_API_USER`) | | `--ftrack-api-key` | ftrack api key (can be set with `FTRACK_API_KEY`) | -| `--ftrack-events-path` | path to event server plugins (can be set with `FTRACK_EVENTS_PATH`) | -| `--no-stored-credentials` | will use credential specified with options above | -| `--store-credentials` | will store credentials to file for later use | | `--legacy` | run event server without mongo storing | | `--clockify-api-key` | Clockify API key (can be set with `CLOCKIFY_API_KEY`) | | `--clockify-workspace` | Clockify workspace (can be set with `CLOCKIFY_WORKSPACE`) | To run ftrack event server: ```shell -openpype_console eventserver --ftrack-url= --ftrack-user= --ftrack-api-key= --ftrack-events-path= --no-stored-credentials --store-credentials +openpype_console eventserver --ftrack-url= --ftrack-user= --ftrack-api-key= ``` --- diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index 80154356af..6057ed0830 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -21,6 +21,9 @@ as a naive barier to prevent artists from accidental setting changes. **`Environment`** - Globally applied environment variables that will be appended to any OpenPype process in the studio. +**`Disk mapping`** - Platform dependent configuration for mapping of virtual disk(s) on an artist's OpenPype machines before OP starts up. +Uses `subst` command, if configured volume character in `Destination` field already exists, no re-mapping is done for that character(volume). + **`Versions Repository`** - Location where automatic update mechanism searches for zip files with OpenPype update packages. To read more about preparing OpenPype for automatic updates go to [Admin Distribute docs](admin_distribute#2-openpype-codebase) diff --git a/website/docs/admin_use.md b/website/docs/admin_use.md index 4ad08a0174..178241ad19 100644 --- a/website/docs/admin_use.md +++ b/website/docs/admin_use.md @@ -56,6 +56,19 @@ openpype_console --list-versions You can add `--use-staging` to list staging versions. ::: +If you want to validate integrity of some available version, you can use: + +```shell +openpype_console --validate-version=3.3.0 +``` + +This will go through the version and validate file content against sha 256 hashes +stored in `checksums` file. + +:::tip Headless mode +Add `--headless` to run OpenPype without graphical UI (useful on server or on automated tasks, etc.) +::: + ### Details When you run OpenPype from executable, few check are made: diff --git a/website/docs/artist_hosts_nuke_tut.md b/website/docs/artist_hosts_nuke_tut.md new file mode 100644 index 0000000000..4d116bd958 --- /dev/null +++ b/website/docs/artist_hosts_nuke_tut.md @@ -0,0 +1,337 @@ +--- +id: artist_hosts_nuke_tut +title: Nuke +sidebar_label: Nuke +--- + +:::note +OpenPype supports Nuke version **`11.0`** and above. +::: + +## OpenPype global tools + +- [Set Context](artist_tools.md#set-context) +- [Work Files](artist_tools.md#workfiles) +- [Create](artist_tools.md#creator) +- [Load](artist_tools.md#loader) +- [Manage (Inventory)](artist_tools.md#inventory) +- [Publish](artist_tools.md#publisher) +- [Library Loader](artist_tools.md#library-loader) + +## Nuke specific tools + +
+
+ +### Set Frame Ranges + +Use this feature in case you are not sure the frame range is correct. + +##### Result + +- setting Frame Range in script settings +- setting Frame Range in viewers (timeline) + +
+
+ +![Set Frame Ranges](assets/nuke_setFrameRanges.png) + +
+
+ + +
+ +![Set Frame Ranges Timeline](assets/nuke_setFrameRanges_timeline.png) + +
+ +1. limiting to Frame Range without handles +2. **Input** handle on start +3. **Output** handle on end + +
+
+ +### Set Resolution + +
+
+ + +This menu item will set correct resolution format for you defined by your production. + +##### Result + +- creates new item in formats with project name +- sets the new format as used + +
+
+ +![Set Resolution](assets/nuke_setResolution.png) + +
+
+ + +### Set Colorspace + +
+
+ +This menu item will set correct Colorspace definitions for you. All has to be configured by your production (Project coordinator). + +##### Result + +- set Colorspace in your script settings +- set preview LUT to your viewers +- set correct colorspace to all discovered Read nodes (following expression set in settings) + +
+
+ +![Set Colorspace](assets/nuke_setColorspace.png) + +
+
+ + +### Apply All Settings + +
+
+ +It is usually enough if you once per while use this option just to make yourself sure the workfile is having set correct properties. + +##### Result + +- set Frame Ranges +- set Colorspace +- set Resolution + +
+
+ +![Apply All Settings](assets/nuke_applyAllSettings.png) + +
+
+ +### Build Workfile + +
+
+ +This tool will append all available subsets into an actual node graph. It will look into database and get all last [versions](artist_concepts.md#version) of available [subsets](artist_concepts.md#subset). + + +##### Result + +- adds all last versions of subsets (rendered image sequences) as read nodes +- ~~adds publishable write node as `renderMain` subset~~ + +
+
+ +![Build First Work File](assets/nuke_buildFirstWorkfile.png) + +
+
+ +## Nuke QuickStart + +This QuickStart is short introduction to what OpenPype can do for you. It attempts to make an overview for compositing artists, and simplifies processes that are better described in specific parts of the documentation. + +### Launch Nuke - Shot and Task Context +OpenPype has to know what shot and task you are working on. You need to run Nuke in context of the task, using Ftrack Action or OpenPype Launcher to select the task and run Nuke. + +![Run Nuke From Ftrack](assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png) +![Run Nuke From Launcher](assets/nuke_tut/nuke_RunNukeLauncher_p2.png) + +:::tip Admin Tip - Nuke version +You can [configure](admin_settings_project_anatomy.md#Attributes) which DCC version(s) will be available for current project in **Studio Settings β†’ Project β†’ Anatomy β†’ Attributes β†’ Applications** +::: + +### Nuke Initial setup +Nuke OpenPype menu shows the current context + +![Context](assets/nuke_tut/nuke_Context.png) + +Launching Nuke with context stops your timer, and starts the clock on the shot and task you picked. + +Openpype makes initial setup for your Nuke script. It is the same as running [Apply All Settings](artist_hosts_nuke.md#apply-all-settings) from the OpenPype menu. + +- Reads frame range and resolution from Avalon database, sets it in Nuke Project Settings, +Creates Viewer node, sets it’s range and indicates handles by In and Out points. + +- Reads Color settings from the project configuration, and sets it in Nuke Project Settings and Viewer. + +- Sets project directory in the Nuke Project Settings to the Nuke Script Directory + +:::tip Tip - Project Settings +After Nuke starts it will automatically **Apply All Settings** for you. If you are sure the settings are wrong just contact your supervisor and he will set them correctly for you in project database. +::: + +### Save Nuke script – the Work File +Use OpenPype - Work files menu to create a new Nuke script. Openpype offers you the preconfigured naming. +![Context](assets/nuke_tut/nuke_WorkFileSaveAs.png) + +The Next Available Version checks the work folder for already used versions and offers the lowest unused version number automatically. + +Subversion can be used to distinguish or name versions. For example used to add shortened artist name. + +More about [workfiles](artist_tools#workfiles). + + +:::tip Admin Tips +- **Workfile Naming** + + - The [workfile naming](admin_settings_project_anatomy#templates) is configured in anatomy, see **Studio Settings β†’ Project β†’ Anatomy β†’ Templates β†’ Work** + +- **Open Workfile** + + - You can [configure](project_settings/settings_project_nuke#create-first-workfile) Nuke to automatically open the last version, or create a file on startup. See **Studio Settings β†’ Project β†’ Global β†’ Tools β†’ Workfiles** + +- **Nuke Color Settings** + + - [Color setting](project_settings/settings_project_nuke) for Nuke can be found in **Studio Settings β†’ Project β†’ Anatomy β†’ Color Management and Output Formats β†’ Nuke** +::: + +### Load plate +Use Load from OpenPype menu to load any plates or renders available. + +![Asset Load](assets/nuke_tut/nuke_AssetLoader.png) + +Pick the plate asset, right click and choose Load Image Sequence to create a Read node in Nuke. + +Note that the Read node created by OpenPype is green. Green color indicates the highest version of asset is loaded. Asset versions could be easily changed by [Manage](#managing-versions). Lower versions will be highlighted by orange color on the read node. + +![Asset Load](assets/nuke_tut/nuke_AssetLoadOutOfDate.png) + +More about [Asset loader](artist_tools#loader). + +### Create Write Node +To create OpenPype managed Write node, select the Read node you just created, from OpenPype menu, pick Create. +In the Instance Creator, pick Create Write Render, and Create. + +![OpenPype Create](assets/nuke_tut/nuke_Creator.png) + +This will create a Group with a Write node inside. + +![OpenPype Create](assets/nuke_tut/nuke_WriteNodeCreated.png) + +:::tip Admin Tip - Configuring write node +You can configure write node parameters in **Studio Settings β†’ Project β†’ Anatomy β†’ Color Management and Output Formats β†’ Nuke β†’ Nodes** +::: + +#### What Nuke Publish Does +From Artist perspective, Nuke publish gathers all the stuff found in the Nuke script with Publish checkbox set to on, exports stuff and raises the Nuke script (workfile) version. + +The Pyblish dialog shows the progress of the process. + +The left column of the dialog shows what will be published. Typically it is one or more renders or prerenders, plus work file. + +![OpenPype Publish](assets/nuke_tut/nuke_PyblishDialogNuke.png) + +The right column shows the publish steps + +##### Publish steps +1. Gathers all the stuff found in the Nuke script with Publish checkbox set to on +2. Collects all the info (from the script, database…) +3. Validates components to be published (checks render range and resolution...) +4. Extracts data from the script + - generates thumbnail + - creates review(s) like h264 + - adds burnins to review(s) + - Copies and renames components like render(s), review(s), Nuke script... to publish folder +5. Integrates components (writes to database, sends preview of the render to Ftrack ... +6. Increments Nuke script version, cleans up the render directory + +Gathering all the info and validating usually takes just a few seconds. Creating reviews for long, high resolution shots can however take significant amount of time when publishing locally. + +##### Pyblish Note and Intent +![Note and Intent](assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png) + +Artist can add Note and Intent before firing the publish button. The Note and Intent is ment for easy communication between artist and supervisor. After publish, Note and Intent can be seen in Ftrack notes. + +##### Pyblish Checkbox + +![Note and Intent](assets/nuke_tut/nuke_PyblishCheckBox.png) + +Pyblish Dialog tries to pack a lot of info in a small area. One of the more tricky parts is that it uses non-standard checkboxes. Some squares can be turned on and off by the artist, some are mandatory. + +If you run the publish and decide to not publish the Nuke script, you can turn it off right in the Pyblish dialog by clicking on the checkbox. If you decide to render and publish the shot in lower resolution to speed up the turnaround, you have to turn off the Write Resolution validator. If you want to use an older version of the asset (older version of the plate...), you have to turn off the Validate containers, and so on. + +More info about [Using Pyblish](artist_tools#publisher) + +:::tip Admin Tip - Configuring validators +You can configure Nuke validators like Output Resolution in **Studio Settings β†’ Project β†’ Nuke β†’ Publish plugins** +::: + +### Review +![Write Node Review](assets/nuke_tut/nuke_WriteNodeReview.png) + +When you turn the review checkbox on in your OpenPype write node, here is what happens: +- OpenPype uses the current Nuke script to + - Load the render + - Optionally apply LUT + - Render Prores 4444 with the same resolution as your render +- Use Ffmpeg to convert the Prores to whatever review(s) you defined +- Use Ffmpeg to add (optional) burnin to the review(s) from previous step + +Creating reviews is a part of the publishing process. If you choose to do a local publish or to use existing frames, review will be processed also on the artist's machine. +If you choose to publish on the farm, you will render and do reviews on the farm. + +So far there is no option for using existing frames (from your local / check render) and just do the review on the farm. + +More info about [configuring reviews](pype2/admin_presets_plugins#extractreview). + +:::tip Admin Tip - Configuring Reviews +You can configure reviewsin **Studio Settings β†’ Project β†’ Global β†’ Publish plugins β†’ ExtractReview / ExtractBurnin** +Reviews can be configured separately for each host, task, or family. For example Maya can produce different review to Nuke, animation task can have different burnin then modelling, and plate can have different review then model. +::: + +### Render and Publish + +![OpenPype Create](assets/nuke_tut/nuke_WriteNode.png) + +Let’s say you want to render and publish the shot right now, with only a Read and Write node. You need to decide if you want to render, check the render and then publish it, or you want to execute the render and publish in one go. + +If you wish to check your render before publishing, you can use your local machine or your farm to render the write node as you would do without OpenPype, load and check your render (OpenPype Write has a convenience button for that), and if happy, use publish with Use existing frames option selected in the write node to generate the review on your local machine. + +If you want to render and publish on the farm in one go, run publish with On farm option selected in the write node to render and make the review on farm. + +![Versionless](assets/nuke_tut/nuke_RenderLocalFarm.png) + +### Version-less Render + +![Versionless](assets/nuke_tut/nuke_versionless.png) + +OpenPype is configured so your render file names have no version number until the render is fully finished and published. The main advantage is that you can keep the render from the previous version and re-render only part of the shot. With care, this is handy. + +Main disadvantage of this approach is that you can render only one version of your shot at one time. Otherwise you risk to partially overwrite your shot render before publishing copies and renames the rendered files to the properly versioned publish folder. + +When making quick farm publishes, like making two versions with different color correction, care must be taken to let the first job (first version) completely finish before the second version starts rendering. + +### Managing Versions + +![Versionless](assets/nuke_tut/nuke_ManageVersion.png) + +OpenPype checks all the assets loaded to Nuke on script open. All out of date assets are colored orange, up to date assets are colored green. + +Use Manage to switch versions for loaded assets. + +## Troubleshooting + +### Fixing Validate Containers + +![Versionless](assets/nuke_tut/nuke_ValidateContainers.png) + +If your Pyblish dialog fails on Validate Containers, you might have an old asset loaded. Use OpenPype - Manage... to switch the asset(s) to the latest version. + +### Fixing Validate Version +If your Pyblish dialog fails on Validate Version, you might be trying to publish already published version. Rise your version in the OpenPype WorkFiles SaveAs. + +Or maybe you accidentaly copied write node from different shot to your current one. Check the write publishes on the left side of the Pyblish dialog. Typically you publish only one write. Locate and delete the stray write from other shot. \ No newline at end of file diff --git a/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png new file mode 100644 index 0000000000..92e1b4dad7 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png differ diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png new file mode 100644 index 0000000000..f7f807a94f Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png differ diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoader.png b/website/docs/assets/nuke_tut/nuke_AssetLoader.png new file mode 100644 index 0000000000..e52abdc428 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoader.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Context.png b/website/docs/assets/nuke_tut/nuke_Context.png new file mode 100644 index 0000000000..65bb288764 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Context.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Create.png b/website/docs/assets/nuke_tut/nuke_Create.png new file mode 100644 index 0000000000..2c843c05df Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Create.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Creator.png b/website/docs/assets/nuke_tut/nuke_Creator.png new file mode 100644 index 0000000000..454777574a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Creator.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Load.png b/website/docs/assets/nuke_tut/nuke_Load.png new file mode 100644 index 0000000000..2a345dc69f Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Load.png differ diff --git a/website/docs/assets/nuke_tut/nuke_ManageVersion.png b/website/docs/assets/nuke_tut/nuke_ManageVersion.png new file mode 100644 index 0000000000..c9f2091347 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ManageVersion.png differ diff --git a/website/docs/assets/nuke_tut/nuke_NukeColor.png b/website/docs/assets/nuke_tut/nuke_NukeColor.png new file mode 100644 index 0000000000..5c4f9a15e0 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_NukeColor.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Publish.png b/website/docs/assets/nuke_tut/nuke_Publish.png new file mode 100644 index 0000000000..b53b6cc06c Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Publish.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png new file mode 100644 index 0000000000..2c5d59c9d5 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png new file mode 100644 index 0000000000..e98a4b9553 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png new file mode 100644 index 0000000000..3519ecc22d Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png new file mode 100644 index 0000000000..4c4c8977a0 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png new file mode 100644 index 0000000000..75faaec572 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png new file mode 100644 index 0000000000..27fec32ae4 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png new file mode 100644 index 0000000000..a42ee6d7b9 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png new file mode 100644 index 0000000000..2a36cad380 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png differ diff --git a/website/docs/assets/nuke_tut/nuke_ValidateContainers.png b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png new file mode 100644 index 0000000000..78e0f2edd7 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png new file mode 100644 index 0000000000..115a321285 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png new file mode 100644 index 0000000000..661f44632a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png new file mode 100644 index 0000000000..450589ee3a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNode.png b/website/docs/assets/nuke_tut/nuke_WriteNode.png new file mode 100644 index 0000000000..5ce3e81aab Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNode.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png new file mode 100644 index 0000000000..b283593d6a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png new file mode 100644 index 0000000000..68651cdd6c Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteSettings.png b/website/docs/assets/nuke_tut/nuke_WriteSettings.png new file mode 100644 index 0000000000..cf00adbee6 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteSettings.png differ diff --git a/website/docs/assets/nuke_tut/nuke_versionless.png b/website/docs/assets/nuke_tut/nuke_versionless.png new file mode 100644 index 0000000000..fbb98c55e2 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_versionless.png differ diff --git a/website/docs/assets/settings/settings_system_general.png b/website/docs/assets/settings/settings_system_general.png index 4c1452d0d4..d04586205d 100644 Binary files a/website/docs/assets/settings/settings_system_general.png and b/website/docs/assets/settings/settings_system_general.png differ diff --git a/website/docs/assets/site_sync_project_sftp_settings.png b/website/docs/assets/site_sync_project_sftp_settings.png new file mode 100644 index 0000000000..79267ba1d7 Binary files /dev/null and b/website/docs/assets/site_sync_project_sftp_settings.png differ diff --git a/website/docs/assets/site_sync_sftp_project_setting_not_forced.png b/website/docs/assets/site_sync_sftp_project_setting_not_forced.png new file mode 100644 index 0000000000..7bd735b46c Binary files /dev/null and b/website/docs/assets/site_sync_sftp_project_setting_not_forced.png differ diff --git a/website/docs/assets/site_sync_sftp_settings_local.png b/website/docs/assets/site_sync_sftp_settings_local.png new file mode 100644 index 0000000000..45125da1a8 Binary files /dev/null and b/website/docs/assets/site_sync_sftp_settings_local.png differ diff --git a/website/docs/assets/site_sync_sftp_system.png b/website/docs/assets/site_sync_sftp_system.png new file mode 100644 index 0000000000..6e8e125f95 Binary files /dev/null and b/website/docs/assets/site_sync_sftp_system.png differ diff --git a/website/docs/module_ftrack.md b/website/docs/module_ftrack.md index 005270b3b9..8e3806828d 100644 --- a/website/docs/module_ftrack.md +++ b/website/docs/module_ftrack.md @@ -51,10 +51,7 @@ There are specific launch arguments for event server. With `openpype_console eve - **`--ftrack-user "your.username"`** : Ftrack Username - **`--ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee"`** : User's API key -- **`--store-crededentials`** : Entered credentials will be stored for next launch with this argument _(It is not needed to enter **ftrackuser** and **ftrackapikey** args on next launch)_ -- **`--no-stored-credentials`** : Stored credentials are loaded first so if you want to change credentials use this argument - `--ftrack-url "https://yourdomain.ftrackapp.com/"` : Ftrack server URL _(it is not needed to enter if you have set `FTRACK_SERVER` in OpenPype' environments)_ -- `--ftrack-events-path "//Paths/To/Events/"` : Paths to events folder. May contain multiple paths separated by `;`. _(it is not needed to enter if you have set `FTRACK_EVENTS_PATH` in OpenPype' environments)_ So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `openpype_console.exe eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `openpype_console.exe eventserver`. @@ -64,8 +61,6 @@ So if you want to use OpenPype's environments then you can launch event server f - `FTRACK_API_USER` - Username _("your.username")_ - `FTRACK_API_KEY` - User's API key _("00000aaa-11bb-22cc-33dd-444444eeeee")_ - `FTRACK_SERVER` - Ftrack server url _(")_ -- `FTRACK_EVENTS_PATH` - Paths to events _("//Paths/To/Events/")_ - We do not recommend you this way. @@ -103,10 +98,12 @@ Event server should **not** run more than once! It may cause major issues. `sudo vi /opt/openpype/run_event_server.sh` - add content to the file: ```sh -#!/usr/bin/env -export OPENPYPE_DEBUG=3 -pushd /mnt/pipeline/prod/openpype-setup -. openpype_console eventserver --ftrack-user --ftrack-api-key +#!/usr/bin/env bash +export OPENPYPE_DEBUG=1 +export OPENPYPE_MONGO= + +pushd /mnt/path/to/openpype +./openpype_console eventserver --ftrack-user --ftrack-api-key ``` - change file permission: `sudo chmod 0755 /opt/openpype/run_event_server.sh` @@ -146,9 +143,11 @@ WantedBy=multi-user.target - add content to the service file: ```sh @echo off -set OPENPYPE_DEBUG=3 -pushd \\path\to\file\ -openpype_console.exe eventserver --ftrack-user --ftrack-api-key +set OPENPYPE_DEBUG=1 +set OPENPYPE_MONGO= + +pushd \\path\to\openpype +openpype_console.exe eventserver --ftrack-user --ftrack-api-key ``` - download and install `nssm.cc` - create Windows service according to nssm.cc manual diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md index 6ee6660048..b0604ed3cf 100644 --- a/website/docs/module_site_sync.md +++ b/website/docs/module_site_sync.md @@ -106,6 +106,36 @@ To get working connection to Google Drive there are some necessary steps: - add new site back in OpenPype Settings, name as you want, provider needs to be 'gdrive' - distribute credentials file via shared mounted disk location +### SFTP + +SFTP provider is used to connect to SFTP server. Currently authentication with `user:password` or `user:ssh key` is implemented. +Please provide only one combination, don't forget to provide password for ssh key if ssh key was created with a passphrase. + +(SFTP connection could be a bit finicky, use FileZilla or WinSCP for testing connection, it will be mush faster.) + +Beware that ssh key expects OpenSSH format (`.pem`) not a Putty format (`.ppk`)! + +#### How to set SFTP site + +- Enable Site Sync module in Settings +- Add side with SFTP provider + +![Enable synching and create site](assets/site_sync_sftp_system.png) + +- In Projects setting enable Site Sync (on default project - all project will be synched, or on specific project) +- Configure SFTP connection and destination folder on a SFTP server (in screenshot `/upload`) + +![SFTP connection](assets/site_sync_project_sftp_settings.png) + +- if you want to force synching between local and sftp site for all users, use combination `active site: local`, `remote site: NAME_OF_SFTP_SITE` +- if you want to allow only specific users to use SFTP synching (external users, not located in the office), use `active site: studio`, `remote site: studio`. + +![Select active and remote site on a project](assets/site_sync_sftp_project_setting_not_forced.png) + +- Each artist can decide and configure synching from his/her local to SFTP via `Local Settings` + +![Select active and remote site on a project](assets/site_sync_sftp_settings_local.png) + ### Custom providers If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template. diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index 797995d2b7..9c838d4a64 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -469,6 +469,7 @@ maya outliner colours for various families "camera": [0.447, 0.312, 1.0], "fbx": [1.0, 0.931, 0.312], "mayaAscii": [0.312, 1.0, 0.747], + "mayaScene": [0.312, 1.0, 0.747], "setdress": [0.312, 1.0, 0.747], "layout": [0.312, 1.0, 0.747], "vdbcache": [0.312, 1.0, 0.428], diff --git a/website/sidebars.js b/website/sidebars.js index 3a4b933b9a..38e4206b84 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -18,7 +18,7 @@ module.exports = { label: "Integrations", items: [ "artist_hosts_hiero", - "artist_hosts_nuke", + "artist_hosts_nuke_tut", "artist_hosts_maya", "artist_hosts_blender", "artist_hosts_harmony", diff --git a/website/yarn.lock b/website/yarn.lock index b4c12edeb6..066d156d97 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6594,9 +6594,9 @@ prism-react-renderer@^1.1.1: integrity sha512-GHqzxLYImx1iKN1jJURcuRoA/0ygCcNhfGw1IT8nPIMzarmKQ3Nc+JcG0gi8JXQzuh0C5ShE4npMIoqNin40hg== prismjs@^1.23.0: - version "1.24.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.24.0.tgz#0409c30068a6c52c89ef7f1089b3ca4de56be2ac" - integrity sha512-SqV5GRsNqnzCL8k5dfAjCNhUrF3pR0A9lTDSCUZeh/LIshheXJEaP0hwLz2t4XHivd2J/v2HR+gRnigzeKe3cQ== + version "1.25.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@~2.0.0: version "2.0.1"