diff --git a/avalon/nuke/__init__.py b/avalon/nuke/__init__.py index 6c36d8568..dbda4d26e 100644 --- a/avalon/nuke/__init__.py +++ b/avalon/nuke/__init__.py @@ -5,12 +5,15 @@ """ from .lib import ( - maintained_selection, - imprint, - read, - add_publish_knob, + ls_img_sequence, + maintained_selection, get_node_path, + get_avalon_knob_data, + set_avalon_knob_data, + imprint, + find_copies, + sync_copies ) from .workio import ( @@ -25,32 +28,30 @@ from .pipeline import ( install, uninstall, - ls, publish, - Creator, - containerise, parse_container, update_container, get_handles, - - # Experimental viewer_update_and_undo_stop, reload_pipeline, ) __all__ = [ - "reload_pipeline", - "install", - "uninstall", - - "ls", - "publish", - - "Creator", + # Lib API. + "add_publish_knob", + "ls_img_sequence", + "maintained_selection", + "get_node_path", + "get_avalon_knob_data", + "set_avalon_knob_data", + "imprint", + "find_copies", + "sync_copies", + # Workfiles API "file_extensions", "has_unsaved_changes", "save_file", @@ -58,20 +59,20 @@ "current_file", "work_root", + # Pipeline API. + "install", + "uninstall", + "ls", + "publish", + "Creator", "containerise", "parse_container", "update_container", "get_handles", - "imprint", - "read", - # Experimental "viewer_update_and_undo_stop", - - "add_publish_knob", - "maintained_selection", - "get_node_path", + "reload_pipeline" ] # Backwards API compatibility diff --git a/avalon/nuke/lib.py b/avalon/nuke/lib.py index 2a8a5b170..1cb16f778 100644 --- a/avalon/nuke/lib.py +++ b/avalon/nuke/lib.py @@ -5,11 +5,21 @@ import logging from collections import OrderedDict +from .. import io from ..vendor import six, clique +from .vendor import knobby log = logging.getLogger(__name__) +Knobby = knobby.util.Knobby +imprint = knobby.util.imprint +read = knobby.util.read +mold = knobby.util.mold + +AVALON_TAB = "avalon" + + @contextlib.contextmanager def maintained_selection(): """Maintain selection during context @@ -36,7 +46,7 @@ def reset_selection(): """Deselect all selected nodes """ for node in nuke.selectedNodes(): - node["selected"] = False + node["selected"].setValue(False) def select_nodes(nodes): @@ -51,239 +61,209 @@ def select_nodes(nodes): node["selected"].setValue(True) -def imprint(node, data, tab=None): - """Store attributes with value on node +def lsattr(attr, value=None, type=None, group=None, recursive=False): + """Return nodes matching `key` and `value` - Parse user data into Node knobs. - Use `collections.OrderedDict` to ensure knob order. + Arguments: + attr (str): Name of Node knob + value (object, optional): Value of attribute. If none + is provided, return all nodes with this attribute. + type (str, optional): Node class name (Not work with `recursive`) + group (nuke.Node, optional): Listing nodes from `group`, default `root` + recursive (bool, optional): Whether to look into child groups. - Args: - node(nuke.Node): node object from Nuke - data(dict): collection of attributes and their value + Return: + list: matching nodes. - Returns: - None + """ + group = group or nuke.toNode("root") - Examples: - ``` - import nuke - from avalon.nuke import lib + if value is None: + args = (type, ) if type else () + nodes = nuke.allNodes(*args, group=group, recurseGroups=recursive) + return [n for n in nodes if n.knob(attr)] + return lsattrs({attr: value}, type=type, group=group, recursive=recursive) - node = nuke.createNode("NoOp") - data = { - # Regular type of attributes - "myList": ["x", "y", "z"], - "myBool": True, - "myFloat": 0.1, - "myInt": 5, - - # Creating non-default imprint type of knob - "MyFilePath": lib.Knobby("File_Knob", "/file/path"), - "divider": lib.Knobby("Text_Knob", ""), - - # Manual nice knob naming - ("my_knob", "Nice Knob Name"): "some text", - - # dict type will be created as knob group - "KnobGroup": { - "knob1": 5, - "knob2": "hello", - "knob3": ["a", "b"], - }, - - # Nested dict will be created as tab group - "TabGroup": { - "tab1": {"count": 5}, - "tab2": {"isGood": True}, - "tab3": {"direction": ["Left", "Right"]}, - }, - } - lib.imprint(node, data, tab="Demo") - ``` +def lsattrs(attrs, type=None, group=None, recursive=False): + """Return nodes with the given attribute(s). + + Arguments: + attrs (dict): Name and value pairs of expected matches + type (str, optional): Node class name (Not work with `recursive`) + group (nuke.Node, optional): Listing nodes from `group`, default `root` + recursive (bool, optional): Whether to look into child groups. + + Return: + list: matching nodes. """ - for knob in create_knobs(data, tab): - node.addKnob(knob) + matches = set() + args = (type, ) if type else () + group = group or nuke.toNode("root") + nodes = nuke.allNodes(*args, group=group, recurseGroups=recursive) + for node in nodes: + for attr in attrs: + knob = node.knob(attr) + if not knob: + continue + elif knob.getValue() != attrs[attr]: + continue + else: + matches.add(node) + return list(matches) -class Knobby(object): - """For creating knob which it's type isn't mapped in `create_knobs` +def set_id(node, container_id=None): + """Set 'avalonId' to `node` Args: - type (string): Nuke knob type name - value: Value to be set with `Knob.setValue`, put `None` if not required - flags (list, optional): Knob flags to be set with `Knob.setFlag` - *args: Args other than knob name for initializing knob class + node (nuke.Node): Node that id to apply to + container_id (str, optional): `node` container's id """ + data = OrderedDict() + data["avalonId"] = str(io.ObjectId()) + if container_id: + data["containerId"] = container_id - def __init__(self, type, value, flags=None, *args): - self.type = type - self.value = value - self.flags = flags or [] - self.args = args + set_avalon_knob_data(node, data) - def create(self, name, nice=None): - knob_cls = getattr(nuke, self.type) - knob = knob_cls(name, nice, *self.args) - if self.value is not None: - knob.setValue(self.value) - for flag in self.flags: - knob.setFlag(flag) - return knob + return data["avalonId"] -def create_knobs(data, tab=None): - """Create knobs by data +def get_id(node, container_id=False): + """Get 'avalonId' of `node` - Depending on the type of each dict value and creates the correct Knob. + Args: + node (nuke.Node): Node that id to apply to + container_id (bool, optional): Whether change to get `node` + container's id instead of `node` avalonId. Default False. - Mapped types: - bool: nuke.Boolean_Knob - int: nuke.Int_Knob - float: nuke.Double_Knob - list: nuke.Enumeration_Knob - six.string_types: nuke.String_Knob + """ + knob = "containerId" if container_id else "avalonId" + id_knob = node.knobs().get("avalon:" + knob) + return id_knob.value() if id_knob else None - dict: If it's a nested dict (all values are dict), will turn into - A tabs group. Or just a knobs group. - Args: - data (dict): collection of attributes and their value - tab (string, optional): Knobs' tab name +def find_copies(source, group=None, recursive=True): + """Return nodes that has same 'avalonId' as `source` - Returns: - list: A list of `nuke.Knob` objects + Args: + source (nuke.Node): The node to find copies from + group (nuke.Node, optional): Find copies from `group`, default `root` + recursive (bool, optional): Whether to look into child groups, + default True. """ - def nice_naming(key): - """Convert camelCase name into UI Display Name""" - words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) - return " ".join(words) + assert isinstance(source, nuke.Node), "`Source` needs to be a nuke node." - # Turn key-value pairs into knobs - knobs = list() + copies = list() + source_id = get_id(source) + if source_id: + copies = lsattrs({"avalon:avalonId": source_id}, + # (NOTE) Cannot specify node `type` to `lsattrs()` if + # `recursive=True`. Because the arg `filter` + # in command `nuke.allNodes` doesn't work with + # `recurseGroups=True`. + group=group, + recursive=recursive) - if tab: - knobs.append(nuke.Tab_Knob(tab)) + # Dont return the source. + if source in copies: + copies.remove(source) - for key, value in data.items(): - # Knob name - if isinstance(key, tuple): - name, nice = key - else: - name, nice = key, nice_naming(key) - - # Create knob by value type - if isinstance(value, Knobby): - knobby = value - knob = knobby.create(name, nice) - - elif isinstance(value, float): - knob = nuke.Double_Knob(name, nice) - knob.setValue(value) - - elif isinstance(value, bool): - knob = nuke.Boolean_Knob(name, nice) - knob.setValue(value) - knob.setFlag(nuke.STARTLINE) - - elif isinstance(value, int): - knob = nuke.Int_Knob(name, nice) - knob.setValue(value) - - elif isinstance(value, six.string_types): - knob = nuke.String_Knob(name, nice) - knob.setValue(value) - - elif isinstance(value, list): - knob = nuke.Enumeration_Knob(name, nice, value) - - elif isinstance(value, dict): - if all(isinstance(v, dict) for v in value.values()): - # Create a group of tabs - begain = nuke.BeginTabGroup_Knob() - end = nuke.EndTabGroup_Knob() - begain.setName(name) - end.setName(name + "_End") - knobs.append(begain) - for k, v in value.items(): - knobs += create_knobs(v, tab=k) - knobs.append(end) - else: - # Create a group of knobs - knobs.append(nuke.Tab_Knob(name, nice, nuke.TABBEGINGROUP)) - knobs += create_knobs(value) - knobs.append(nuke.Tab_Knob(name, nice, nuke.TABENDGROUP)) - continue + return copies - else: - raise TypeError("Unsupported type: %r" % type(value)) - knobs.append(knob) +@contextlib.contextmanager +def sync_copies(nodes, force=False): + """Context manager for Syncing nodes' knobs - return knobs + When updating subset by `Loader.update`, use this context to auto sync all + copies of the subset. + By default, only knobs that haven't been modified, compares to the original + one inside the "AVALON_CONTAINERS". But if `force` set to True, all knobs + will be updated. -EXCLUDED_KNOB_TYPE_ON_READ = ( - 20, # Tab Knob - 26, # Text Knob (But for backward compatibility, still be read - # if value is not an empty string.) -) + Example: + ``` + class Loader(avalon.api.Loader): + def update(self, container, representation): -def read(node): - """Return user-defined knobs from given `node` + with lib.sync_copies(nodes): + # Update subset + ... + # All copies of `nodes` updated - Args: - node (nuke.Node): Nuke node object + with lib.sync_copies([container_node], force=True): + # Update container data + ... + # All copies of `container_node` updated - Returns: - list: A list of nuke.Knob object + ``` + + Args: + nodes (list): Nodes to sync + force (bool, optional): Whether to force updating all knobs """ - def compat_prefixed(knob_name): - if knob_name.startswith("avalon:"): - return knob_name[len("avalon:"):] - elif knob_name.startswith("ak:"): - return knob_name[len("ak:"):] - else: - return knob_name - - data = dict() - - pattern = ("(?<=addUserKnob {)" - "([0-9]*) (\\S*)" # Matching knob type and knob name - "(?=[ |}])") - tcl_script = node.writeKnobs(nuke.WRITE_USER_KNOB_DEFS) - result = re.search(pattern, tcl_script) - - if result: - first_user_knob = result.group(2) - # Collect user knobs from the end of the knob list - for knob in reversed(node.allKnobs()): - knob_name = knob.name() - if not knob_name: - # Ignore unnamed knob - continue + def is_knob_eq(knob_a, knob_b): + return knob_a.toScript() == knob_b.toScript() - knob_type = nuke.knob(knob.fullyQualifiedName(), type=True) - value = knob.value() + def sync_knob(knob_a, knob_b): + script = knob_a.toScript() + knob_b.fromScript(script) - if ( - knob_type not in EXCLUDED_KNOB_TYPE_ON_READ or - # For compating read-only string data that imprinted - # by `nuke.Text_Knob`. - (knob_type == 26 and value) - ): - key = compat_prefixed(knob_name) - data[key] = value + staged = dict() + origin = dict() - if knob_name == first_user_knob: - break + # Collect knobs for updating + for node in nodes: + targets = list() - return data + sources = node.knobs() + origin[node] = sources + + for copy in find_copies(node): + copy_name = copy.fullName() + + for name, knob in copy.knobs().items(): + if name not in sources: + continue + + knob_name = knob.name() + if knob_name == "name": # Node name should never be synced + continue + + # Only update knob that hasn't been modified + if force or is_knob_eq(sources[name], knob): + # (NOTE) If current Nuke session has multiple views, like + # working on stereo shot, `knob.fullyQualifiedName` will + # append current view at the end of the knob name, for + # example "renderlayer.Read1.first.left". + # Assemble from node's full name and knob name to avoid + # that. + targets.append("%s.%s" % (copy_name, knob_name)) + + if targets: + staged[node] = targets + + try: + yield # Update `nodes` + + finally: + # Sync update result to all copies + for node, targets in staged.items(): + updates = origin[node] + + for knob in targets: + copied, knob = knob.rsplit(".", 1) + copied = nuke.toNode(copied) + + sync_knob(updates[knob], copied[knob]) def add_publish_knob(node): @@ -300,11 +280,11 @@ def add_publish_knob(node): body = OrderedDict() body[("divd", "")] = Knobby("Text_Knob", "") body["publish"] = True - imprint(node, body) + imprint(node, body, tab=AVALON_TAB) return node -def set_avalon_knob_data(node, data=None, prefix="avalon:"): +def set_avalon_knob_data(node, data=None): """ Sets data into nodes's avalon knob Arguments: @@ -323,49 +303,30 @@ def set_avalon_knob_data(node, data=None, prefix="avalon:"): } """ data = data or dict() - create = OrderedDict() - tab_name = "AvalonTab" - editable = ["asset", "subset", "name", "namespace"] + warn = Knobby("Text_Knob", "Warning! Do not change following data!") + divd = Knobby("Text_Knob", "") + knobs = OrderedDict([ + (("warn", ""), warn), + (("divd", ""), divd), + ]) - existed_knobs = node.knobs() + editable = ["asset", "subset", "name", "namespace"] for key, value in data.items(): - knob_name = prefix + key - gui_name = key - - if knob_name in existed_knobs: - # Set value - node[knob_name].setValue(value) + if key in editable: + knobs[key] = value else: - # New knob - name = (knob_name, gui_name) # Hide prefix on GUI - if key in editable: - create[name] = value - else: - create[name] = Knobby("String_Knob", - str(value), - flags=[nuke.READ_ONLY]) + knobs[key] = Knobby("String_Knob", + str(value), + flags=[nuke.READ_ONLY]) - if tab_name in existed_knobs: - tab_name = None - else: - tab = OrderedDict() - warn = Knobby("Text_Knob", "Warning! Do not change following data!") - divd = Knobby("Text_Knob", "") - head = [ - (("warn", ""), warn), - (("divd", ""), divd), - ] - tab["avalonDataGroup"] = OrderedDict(head + create.items()) - create = tab - - imprint(node, create, tab=tab_name) + imprint(node, knobs, tab=AVALON_TAB) return node -def get_avalon_knob_data(node, prefix="avalon:"): +def get_avalon_knob_data(node): """ Get data from nodes's avalon knob Arguments: @@ -375,12 +336,15 @@ def get_avalon_knob_data(node, prefix="avalon:"): Returns: data (dict) """ - data = { - knob[len(prefix):]: node[knob].value() - for knob in node.knobs().keys() - if knob.startswith(prefix) - } - return data + def compat_prefixed(knob_name): + if knob_name.startswith("avalon:"): + return knob_name[len("avalon:"):] + elif knob_name.startswith("ak:"): + return knob_name[len("ak:"):] + else: + return None + + return read(node, filter=compat_prefixed) def fix_data_for_node_create(data): diff --git a/avalon/nuke/pipeline.py b/avalon/nuke/pipeline.py index 149675c15..46eb2e2e0 100644 --- a/avalon/nuke/pipeline.py +++ b/avalon/nuke/pipeline.py @@ -9,6 +9,7 @@ from pyblish import api as pyblish from . import lib, command +from ..lib import find_submodule from .. import api from ..vendor.Qt import QtWidgets from ..pipeline import AVALON_CONTAINER_ID @@ -18,8 +19,11 @@ self = sys.modules[__name__] self._parent = None # Main Window cache +AVALON_CONTAINERS = "AVALON_CONTAINERS" AVALON_CONFIG = os.environ["AVALON_CONFIG"] +USE_OLD_CONTAINER = os.getenv("AVALON_NUKE_OLD_CONTAINER") + def reload_pipeline(): """Attempt to reload pipeline at run-time. @@ -50,31 +54,44 @@ def reload_pipeline(): import avalon.nuke api.install(avalon.nuke) - _register_events() - -def containerise(node, - name, +def containerise(name, namespace, + nodes, context, loader=None, - data=None): + data=None, + no_backdrop=True, + suffix="CON"): """Bundle `node` into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: - node (nuke.Node): Nuke's node object to imprint as container name (str): Name of resulting assembly namespace (str): Namespace under which to host container + nodes (list): A list of `nuke.Node` object to containerise context (dict): Asset information loader (str, optional): Name of node used to produce this container. + data (dict, optional): Additional data to imprint. + no_backdrop (bool, optional): No container(backdrop) node presented. + suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: node (nuke.Node): containerised nuke's node object """ + from nukescripts import autoBackdrop + + if isinstance(name, nuke.Node): + # For compatibling with old style args + # containerise(node, name, namespace, context, ...) + _ = nodes + nodes = [name] + name = namespace + namespace = _ + data = OrderedDict( [ ("schema", "avalon-core:container-2.0"), @@ -82,15 +99,64 @@ def containerise(node, ("name", name), ("namespace", namespace), ("loader", str(loader)), - ("representation", context["representation"]["_id"]), + ("representation", str(context["representation"]["_id"])), ], **data or dict() ) - lib.set_avalon_knob_data(node, data) + if USE_OLD_CONTAINER: + node = nodes[0] + lib.set_avalon_knob_data(node, data) + return node - return node + # New style + + container_color = data.pop("color", int("0x7A7A7AFF", 16)) + container_name = "%s_%s_%s" % (namespace, name, suffix) + + lib.reset_selection() + lib.select_nodes(nodes) + + container = autoBackdrop() + container.setName(container_name) + container["label"].setValue(container_name) + container["tile_color"].setValue(container_color) + container["selected"].setValue(True) + # (NOTE) Backdrop may not fully cover if there's only one node, so we + # expand backdrop a bit ot ensure that. + container["bdwidth"].setValue(container["bdwidth"].value() + 100) + container["bdheight"].setValue(container["bdheight"].value() + 100) + container["xpos"].setValue(container["xpos"].value() - 50) + + lib.set_avalon_knob_data(container, data) + + container_id = lib.set_id(container) + for node in nodes: + lib.set_id(node, container_id=container_id) + + # Containerising + + nuke.nodeCopy("_containerizing_") + + main_container = nuke.toNode(AVALON_CONTAINERS) + if main_container is None: + main_container = nuke.createNode("Group") + main_container.setName(AVALON_CONTAINERS) + main_container["postage_stamp"].setValue(True) + main_container["note_font_size"].setValue(40) + main_container["tile_color"].setValue(int("0x283648FF", 16)) + main_container["xpos"].setValue(-500) + main_container["ypos"].setValue(-500) + + main_container.begin() + nuke.nodePaste("_containerizing_") + main_container.end() + + if no_backdrop: + nuke.delete(container) + + return container def parse_container(node): @@ -105,20 +171,18 @@ def parse_container(node): dict: The container schema data for this container node. """ - data = lib.read(node) - - # (TODO) Remove key validation when `ls` has re-implemented. - # - # If not all required data return the empty container - required = ["schema", "id", "name", - "namespace", "loader", "representation"] - if not all(key in data for key in required): - return + data = lib.get_avalon_knob_data(node) # Store the node's name data["objectName"] = node["name"].value() # Store reference to the node object data["_node"] = node + # Get containerized nodes + if node.fullName() == "%s.%s" % (AVALON_CONTAINERS, node.name()): + data["_members"] = lib.lsattr("avalon:containerId", + value=data["avalonId"], + group=nuke.toNode(AVALON_CONTAINERS), + recursive=True) return data @@ -143,6 +207,10 @@ def update_container(node, keys=None): if not container: raise TypeError("Not a valid container node.") + # Remove unprintable entries + container.pop("_node", None) + container.pop("_members", None) + container.update(keys) node = lib.set_avalon_knob_data(node, container) @@ -187,6 +255,24 @@ def process(self): return instance +def _ls1(): + """Yields all nodes for listing Avalon containers""" + for node in nuke.allNodes(recurseGroups=False): + yield node + + +def _ls2(): + """Yields nodes that has 'avalon:id' knob from AVALON_CONTAINERS""" + for node in nuke.allNodes("BackdropNode", + group=nuke.toNode(AVALON_CONTAINERS)): + knob = node.fullName() + ".avalon:id" + if nuke.exists(knob) and nuke.knob(knob) == AVALON_CONTAINER_ID: + yield node + + +_ls = _ls1 if USE_OLD_CONTAINER else _ls2 + + def ls(): """List available containers. @@ -197,16 +283,22 @@ def ls(): See the `container.json` schema for details on how it should look, and the Maya equivalent, which is in `avalon.maya.pipeline` """ - all_nodes = nuke.allNodes(recurseGroups=False) + config_host = find_submodule(api.registered_config(), "nuke") + has_metadata_collector = hasattr(config_host, "collect_container_metadata") + + container_nodes = _ls() - # TODO: add readgeo, readcamera, readimage - nodes = [n for n in all_nodes] + for container in container_nodes: + data = parse_container(container) + if data is None: + continue - for n in nodes: - log.debug("name: `{}`".format(n.name())) - container = parse_container(n) - if container: - yield container + # Collect custom data if attribute is present + if has_metadata_collector: + metadata = config_host.collect_container_metadata(container) + data.update(metadata) + + yield data def install(): @@ -266,18 +358,14 @@ def _install_menu(): publish, workfiles, loader, - sceneinventory + sceneinventory, ) # Create menu menubar = nuke.menu("Nuke") menu = menubar.addMenu(api.Session["AVALON_LABEL"]) - label = "{0}, {1}".format( - api.Session["AVALON_ASSET"], api.Session["AVALON_TASK"] - ) - context_action = menu.addCommand(label) - context_action.setEnabled(False) + _add_contextmanager_menu(menu) menu.addSeparator() menu.addCommand("Create...", @@ -314,6 +402,22 @@ def _uninstall_menu(): menubar.removeItem(api.Session["AVALON_LABEL"]) +def _add_contextmanager_menu(menu): + label = "{0}, {1}".format( + api.Session["AVALON_ASSET"], api.Session["AVALON_TASK"] + ) + context_action = menu.addCommand(label) + context_action.setEnabled(False) + + +def _update_menu_task_label(): + menubar = nuke.menu("Nuke") + menu = menubar.findItem(api.Session["AVALON_LABEL"]) + + menu.removeItem(menu.items()[0].name()) # Assume it is the first item + _add_contextmanager_menu(menu) + + def publish(): """Shorthand to publish from within host""" import pyblish.util @@ -329,8 +433,7 @@ def _register_events(): def _on_task_changed(*args): # Update menu - _uninstall_menu() - _install_menu() + _update_menu_task_label() # Backwards compatibility diff --git a/avalon/nuke/vendor/__init__.py b/avalon/nuke/vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/avalon/nuke/vendor/knobby/__init__.py b/avalon/nuke/vendor/knobby/__init__.py new file mode 100644 index 000000000..dccad1f0e --- /dev/null +++ b/avalon/nuke/vendor/knobby/__init__.py @@ -0,0 +1,15 @@ +__version__ = "0.1.5" + +from . import parser + +try: + from . import util +except ImportError: + # No nuke module + util = None + + +__all__ = [ + "parser", + "util", +] diff --git a/avalon/nuke/vendor/knobby/parser.py b/avalon/nuke/vendor/knobby/parser.py new file mode 100644 index 000000000..1ee581288 --- /dev/null +++ b/avalon/nuke/vendor/knobby/parser.py @@ -0,0 +1,142 @@ +import re +from collections import deque + + +def parse(script): + """Parse Nuke node's TCL script string into nested list structure + + Args: + script (str): Node knobs TCL script string + + Returns: + Tablet: A list containing knob scripts or tab knobs that has parsed + into list + + """ + queue = deque(script.split("\n")) + tab = Tablet() + tab.consume(queue) + return tab + + +TYPE_NODE = 0 +TYPE_KNOBS = 1 +TYPE_GROUP = -2 +TYPE_KNOBS_CLOSE = -1 +TYPE_GROUP_CLOSE = -3 + +TAB_PATTERN = re.compile( + 'addUserKnob {20 ' + '(?P\\S+)' + '(| l (?P