Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate clashing sibling names when strip namespaces is enabled #112

Merged
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions client/ayon_maya/plugins/publish/validate_clashing_sibling_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from collections import defaultdict
import inspect

import ayon_maya.api.action
from ayon_core.pipeline.publish import (
OptionalPyblishPluginMixin,
PublishValidationError,
ValidateContentsOrder,
)
from ayon_maya.api import plugin, lib
import pyblish.api
BigRoy marked this conversation as resolved.
Show resolved Hide resolved


def remove_namespace(path: str) -> str:
"""Remove namespace from full path.

Example:
>>> remove_namespace("|aa:bb:foo|aa:bb:bar|cc:hello|dd:world")
'|foo|bar|hello|world'

Arguments:
path (str): Full node path.

Returns:
str: Node path with namespaces removed.
"""
return "|".join(
name.rsplit(":", 1)[-1] for name in path.split("|")
)


class ValidateClashingSiblingNames(plugin.MayaInstancePlugin,
OptionalPyblishPluginMixin):
"""Validate siblings have unique names when namespaces are stripped."""

order = ValidateContentsOrder
families = ["pointcache", "animation", "usd"]
label = "Validate clashing sibling names"
actions = [ayon_maya.api.action.SelectInvalidAction]

@staticmethod
def get_invalid(instance):
"""Return all nodes that have non-unique names with siblings when
namespaces are stripped.

Returns:
list[str]: Non-unique siblings
"""
stripped_name_to_full_path = defaultdict(set)
for node in instance:
stripped_name = remove_namespace(node)
stripped_name_to_full_path[stripped_name].add(node)

invalid: "list[str]" = []
for _stripped_name, nodes in stripped_name_to_full_path.items():
if len(nodes) > 1:
invalid.extend(nodes)

if invalid:
# We only care about the highest conflicts since child conflicts
# only occur due to the conflicts higher up anyway
invalid = lib.get_highest_in_hierarchy(invalid)

return invalid

def process(self, instance):
"""Process all the nodes in the instance "objectSet"""
if not self.is_active(instance.data):
return

if not self.is_strip_namespaces_enabled(instance):
return

invalid = self.get_invalid(instance)
if invalid:

report_list = "\n".join(f"- {node}" for node in sorted(invalid))

raise PublishValidationError(
"With stripped namespaces there are conflicting sibling names "
"that are not unique:\n"
f"{report_list}",
description=self.get_description())

def is_strip_namespaces_enabled(self, instance) -> bool:
Copy link
Contributor Author

@BigRoy BigRoy Sep 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iLLiCiTiT this really seems like madness - but I'm admittedly not entirely sure how to do this better. Many of these plugins can be enabled/disabled, they can be optional and hence disabled for the instance only, they can either have exposed publish attributes for stripNamespaces or have it locked down and defined by settings as defaults.

Only way to simplify that is maybe moving this to a singular 'toggle' to a dedicated Collector which puts it in instance.data? But it'd not be backwards compatible.

Copy link
Member

@iLLiCiTiT iLLiCiTiT Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That actually sounds more legit to me. Single plugin that is capable of showing all the input fields and based on the checked fields adds families (or different data) to instance. But that is a lot of work, so I don't know if should be handled in this PR.

OptionalPyblishPluginMixin was not really meant that it is dependent on other plugins. BTW shouldn't the other plugin add something to instance data (for now) instead of having this logic dependent on current state of instance (might not represent state of instance at the moment the other plugin was processed)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OptionalPyblishPluginMixin was not really meant that it is dependent on other plugins. BTW shouldn't the other plugin add something to instance data (for now) instead of having this logic dependent on current state of instance (might not represent state of instance at the moment the other plugin was processed)?

The other plugins are Extractors - they run after the validators and hence the data is not there yet, so I'd need to move those to a Collector instead - which means we get into the shitshow of backwards compatibilities, etc. :)

This logic here is the best I could think of to support it now without refactoring everything.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do agree "correct implementation" would require massive refactor to allow it. I'll let @antirotor to decide.

"""Return whether any extractor is enabled for instance that has
`stripNamespaces` enabled."""
# TODO: Preferably there would be a better way to detect whether the
# flag was enabled or not.

plugins = instance.context.data["create_context"].publish_plugins
plugins_by_name = {plugin.__name__: plugin for plugin in plugins}

def _is_plugin_active(plugin_name: str) -> bool:
"""Return whether plugin is active for instance"""
# Check if Plug-in is found
plugin = plugins_by_name.get(plugin_name)
if not plugin:
self.log.debug(f"Plugin {plugin_name} not found. "
"It may be disabled in settings")
return False

# Check if plug-in is globally enabled
if not getattr(plugin, "enabled", True):
self.log.debug(f"Plugin {plugin_name} is disabled. "
"It is disabled in settings")
return False

# Check if optional state has active state set to False
publish_attributes = instance.data["publish_attributes"]
default_active = getattr(plugin, "active", True)
active_for_instance = publish_attributes.get(
plugin_name, {}).get("active", default_active)
if not active_for_instance:
self.log.debug(
f"Plugin {plugin_name} is disabled for this instance.")
return False

# Check if the instance, according to pyblish is a match for the
# plug-in. This may e.g. be excluded due to different families
# or matching algorithm (e.g. ExtractMultiverseUsdAnim uses
# `pyblish.api.Subset`
if not pyblish.api.instances_by_plugin([instance], plugin):
self.log.debug(
f"Plugin {plugin_name} does not match for this instance.")
return False

return True

for plugin_name in [
"ExtractAlembic", # pointcache
"ExtractAnimation", # animation
"ExtractMayaUsd", # usd
"ExtractMayaUsdPointcache", # pointcache
"ExtractMayaUsdAnim", # animation
]:
if _is_plugin_active(plugin_name):
plugin = plugins_by_name[plugin_name]

# Use the value from the instance publish attributes
publish_attributes = instance.data["publish_attributes"]
strip_namespaces = publish_attributes.get(
plugin_name, {}).get("stripNamespaces")
if strip_namespaces:
return True

# Find some default on the plugin class, if any
default = getattr(plugin, "stripNamespaces", False)
if default:
self.log.debug(
f"{plugin_name} has strip namespaces enabled as "
"default value.")
return True
return False

def get_description(self):
return inspect.cleandoc("""
### Clashing sibling names with stripped namespaces

The export has **strip namespaces** enabled but a conflict on
sibling names are found where, without namespaces, they do not have
unique names and can not be exported.

To resolve this, either export with 'strip namespaces' disabled or
reorder the hierarchy so that nodes sharing the parent do not have
the same name.
""")