mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Merge pull request #5698 from ynput/bugfix/OP-6806_Houdini-wrong-frame-calculation-with-handles
This commit is contained in:
commit
46ee9cf7af
15 changed files with 302 additions and 125 deletions
|
|
@ -568,29 +568,64 @@ def get_template_from_value(key, value):
|
|||
return parm
|
||||
|
||||
|
||||
def get_frame_data(node):
|
||||
"""Get the frame data: start frame, end frame and steps.
|
||||
def get_frame_data(node, handle_start=0, handle_end=0, log=None):
|
||||
"""Get the frame data: start frame, end frame, steps,
|
||||
start frame with start handle and end frame with end handle.
|
||||
|
||||
This function uses Houdini node's `trange`, `t1, `t2` and `t3`
|
||||
parameters as the source of truth for the full inclusive frame
|
||||
range to render, as such these are considered as the frame
|
||||
range including the handles.
|
||||
|
||||
The non-inclusive frame start and frame end without handles
|
||||
are computed by subtracting the handles from the inclusive
|
||||
frame range.
|
||||
|
||||
Args:
|
||||
node(hou.Node)
|
||||
node (hou.Node): ROP node to retrieve frame range from,
|
||||
the frame range is assumed to be the frame range
|
||||
*including* the start and end handles.
|
||||
handle_start (int): Start handles.
|
||||
handle_end (int): End handles.
|
||||
log (logging.Logger): Logger to log to.
|
||||
|
||||
Returns:
|
||||
dict: frame data for star, end and steps.
|
||||
dict: frame data for start, end, steps,
|
||||
start with handle and end with handle
|
||||
|
||||
"""
|
||||
|
||||
if log is None:
|
||||
log = self.log
|
||||
|
||||
data = {}
|
||||
|
||||
if node.parm("trange") is None:
|
||||
|
||||
log.debug(
|
||||
"Node has no 'trange' parameter: {}".format(node.path())
|
||||
)
|
||||
return data
|
||||
|
||||
if node.evalParm("trange") == 0:
|
||||
self.log.debug("trange is 0")
|
||||
return data
|
||||
data["frameStartHandle"] = hou.intFrame()
|
||||
data["frameEndHandle"] = hou.intFrame()
|
||||
data["byFrameStep"] = 1.0
|
||||
|
||||
data["frameStart"] = node.evalParm("f1")
|
||||
data["frameEnd"] = node.evalParm("f2")
|
||||
data["steps"] = node.evalParm("f3")
|
||||
log.info(
|
||||
"Node '{}' has 'Render current frame' set.\n"
|
||||
"Asset Handles are ignored.\n"
|
||||
"frameStart and frameEnd are set to the "
|
||||
"current frame.".format(node.path())
|
||||
)
|
||||
else:
|
||||
data["frameStartHandle"] = int(node.evalParm("f1"))
|
||||
data["frameEndHandle"] = int(node.evalParm("f2"))
|
||||
data["byFrameStep"] = node.evalParm("f3")
|
||||
|
||||
data["handleStart"] = handle_start
|
||||
data["handleEnd"] = handle_end
|
||||
data["frameStart"] = data["frameStartHandle"] + data["handleStart"]
|
||||
data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"]
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
"""
|
||||
|
||||
label = "Arnold ROP Render Products"
|
||||
order = pyblish.api.CollectorOrder + 0.4
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectRopFrameRange
|
||||
order = pyblish.api.CollectorOrder + 0.4999
|
||||
hosts = ["houdini"]
|
||||
families = ["arnold_rop"]
|
||||
|
||||
|
|
@ -126,8 +128,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
return path
|
||||
|
||||
expected_files = []
|
||||
start = instance.data["frameStart"]
|
||||
end = instance.data["frameEnd"]
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
|
||||
for i in range(int(start), (int(end) + 1)):
|
||||
expected_files.append(
|
||||
os.path.join(dir, (file % i)).replace("\\", "/"))
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import hou
|
||||
|
||||
import pyblish.api
|
||||
|
||||
|
||||
class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin):
|
||||
"""Collect time range frame data for the instance node."""
|
||||
|
||||
order = pyblish.api.CollectorOrder + 0.001
|
||||
label = "Instance Node Frame Range"
|
||||
hosts = ["houdini"]
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
node_path = instance.data.get("instance_node")
|
||||
node = hou.node(node_path) if node_path else None
|
||||
if not node_path or not node:
|
||||
self.log.debug("No instance node found for instance: "
|
||||
"{}".format(instance))
|
||||
return
|
||||
|
||||
frame_data = self.get_frame_data(node)
|
||||
if not frame_data:
|
||||
return
|
||||
|
||||
self.log.info("Collected time data: {}".format(frame_data))
|
||||
instance.data.update(frame_data)
|
||||
|
||||
def get_frame_data(self, node):
|
||||
"""Get the frame data: start frame, end frame and steps
|
||||
Args:
|
||||
node(hou.Node)
|
||||
|
||||
Returns:
|
||||
dict
|
||||
|
||||
"""
|
||||
|
||||
data = {}
|
||||
|
||||
if node.parm("trange") is None:
|
||||
self.log.debug("Node has no 'trange' parameter: "
|
||||
"{}".format(node.path()))
|
||||
return data
|
||||
|
||||
if node.evalParm("trange") == 0:
|
||||
# Ignore 'render current frame'
|
||||
self.log.debug("Node '{}' has 'Render current frame' set. "
|
||||
"Time range data ignored.".format(node.path()))
|
||||
return data
|
||||
|
||||
data["frameStart"] = node.evalParm("f1")
|
||||
data["frameEnd"] = node.evalParm("f2")
|
||||
data["byFrameStep"] = node.evalParm("f3")
|
||||
|
||||
return data
|
||||
|
|
@ -91,27 +91,3 @@ class CollectInstances(pyblish.api.ContextPlugin):
|
|||
context[:] = sorted(context, key=sort_by_family)
|
||||
|
||||
return context
|
||||
|
||||
def get_frame_data(self, node):
|
||||
"""Get the frame data: start frame, end frame and steps
|
||||
Args:
|
||||
node(hou.Node)
|
||||
|
||||
Returns:
|
||||
dict
|
||||
|
||||
"""
|
||||
|
||||
data = {}
|
||||
|
||||
if node.parm("trange") is None:
|
||||
return data
|
||||
|
||||
if node.evalParm("trange") == 0:
|
||||
return data
|
||||
|
||||
data["frameStart"] = node.evalParm("f1")
|
||||
data["frameEnd"] = node.evalParm("f2")
|
||||
data["byFrameStep"] = node.evalParm("f3")
|
||||
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
"""
|
||||
|
||||
label = "Karma ROP Render Products"
|
||||
order = pyblish.api.CollectorOrder + 0.4
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectRopFrameRange
|
||||
order = pyblish.api.CollectorOrder + 0.4999
|
||||
hosts = ["houdini"]
|
||||
families = ["karma_rop"]
|
||||
|
||||
|
|
@ -95,8 +97,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
return path
|
||||
|
||||
expected_files = []
|
||||
start = instance.data["frameStart"]
|
||||
end = instance.data["frameEnd"]
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
|
||||
for i in range(int(start), (int(end) + 1)):
|
||||
expected_files.append(
|
||||
os.path.join(dir, (file % i)).replace("\\", "/"))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
"""
|
||||
|
||||
label = "Mantra ROP Render Products"
|
||||
order = pyblish.api.CollectorOrder + 0.4
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectRopFrameRange
|
||||
order = pyblish.api.CollectorOrder + 0.4999
|
||||
hosts = ["houdini"]
|
||||
families = ["mantra_rop"]
|
||||
|
||||
|
|
@ -118,8 +120,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
return path
|
||||
|
||||
expected_files = []
|
||||
start = instance.data["frameStart"]
|
||||
end = instance.data["frameEnd"]
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
|
||||
for i in range(int(start), (int(end) + 1)):
|
||||
expected_files.append(
|
||||
os.path.join(dir, (file % i)).replace("\\", "/"))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
"""
|
||||
|
||||
label = "Redshift ROP Render Products"
|
||||
order = pyblish.api.CollectorOrder + 0.4
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectRopFrameRange
|
||||
order = pyblish.api.CollectorOrder + 0.4999
|
||||
hosts = ["houdini"]
|
||||
families = ["redshift_rop"]
|
||||
|
||||
|
|
@ -132,8 +134,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
return path
|
||||
|
||||
expected_files = []
|
||||
start = instance.data["frameStart"]
|
||||
end = instance.data["frameEnd"]
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
|
||||
for i in range(int(start), (int(end) + 1)):
|
||||
expected_files.append(
|
||||
os.path.join(dir, (file % i)).replace("\\", "/"))
|
||||
|
|
|
|||
|
|
@ -2,40 +2,106 @@
|
|||
"""Collector plugin for frames data on ROP instances."""
|
||||
import hou # noqa
|
||||
import pyblish.api
|
||||
from openpype.lib import BoolDef
|
||||
from openpype.hosts.houdini.api import lib
|
||||
from openpype.pipeline import OpenPypePyblishPluginMixin
|
||||
|
||||
|
||||
class CollectRopFrameRange(pyblish.api.InstancePlugin):
|
||||
class CollectRopFrameRange(pyblish.api.InstancePlugin,
|
||||
OpenPypePyblishPluginMixin):
|
||||
|
||||
"""Collect all frames which would be saved from the ROP nodes"""
|
||||
|
||||
order = pyblish.api.CollectorOrder
|
||||
hosts = ["houdini"]
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectAnatomyInstanceData
|
||||
order = pyblish.api.CollectorOrder + 0.499
|
||||
label = "Collect RopNode Frame Range"
|
||||
use_asset_handles = True
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
node_path = instance.data.get("instance_node")
|
||||
if node_path is None:
|
||||
# Instance without instance node like a workfile instance
|
||||
self.log.debug(
|
||||
"No instance node found for instance: {}".format(instance)
|
||||
)
|
||||
return
|
||||
|
||||
ropnode = hou.node(node_path)
|
||||
frame_data = lib.get_frame_data(ropnode)
|
||||
|
||||
if "frameStart" in frame_data and "frameEnd" in frame_data:
|
||||
attr_values = self.get_attr_values_from_data(instance.data)
|
||||
|
||||
# Log artist friendly message about the collected frame range
|
||||
message = (
|
||||
"Frame range {0[frameStart]} - {0[frameEnd]}"
|
||||
).format(frame_data)
|
||||
if frame_data.get("step", 1.0) != 1.0:
|
||||
message += " with step {0[step]}".format(frame_data)
|
||||
self.log.info(message)
|
||||
if attr_values.get("use_handles", self.use_asset_handles):
|
||||
asset_data = instance.data["assetEntity"]["data"]
|
||||
handle_start = asset_data.get("handleStart", 0)
|
||||
handle_end = asset_data.get("handleEnd", 0)
|
||||
else:
|
||||
handle_start = 0
|
||||
handle_end = 0
|
||||
|
||||
instance.data.update(frame_data)
|
||||
frame_data = lib.get_frame_data(
|
||||
ropnode, handle_start, handle_end, self.log
|
||||
)
|
||||
|
||||
# Add frame range to label if the instance has a frame range.
|
||||
label = instance.data.get("label", instance.data["name"])
|
||||
instance.data["label"] = (
|
||||
"{0} [{1[frameStart]} - {1[frameEnd]}]".format(label,
|
||||
frame_data)
|
||||
if not frame_data:
|
||||
return
|
||||
|
||||
# Log debug message about the collected frame range
|
||||
frame_start = frame_data["frameStart"]
|
||||
frame_end = frame_data["frameEnd"]
|
||||
|
||||
if attr_values.get("use_handles", self.use_asset_handles):
|
||||
self.log.debug(
|
||||
"Full Frame range with Handles "
|
||||
"[{frame_start_handle} - {frame_end_handle}]"
|
||||
.format(
|
||||
frame_start_handle=frame_data["frameStartHandle"],
|
||||
frame_end_handle=frame_data["frameEndHandle"]
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.log.debug(
|
||||
"Use handles is deactivated for this instance, "
|
||||
"start and end handles are set to 0."
|
||||
)
|
||||
|
||||
# Log collected frame range to the user
|
||||
message = "Frame range [{frame_start} - {frame_end}]".format(
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end
|
||||
)
|
||||
if handle_start or handle_end:
|
||||
message += " with handles [{handle_start}]-[{handle_end}]".format(
|
||||
handle_start=handle_start,
|
||||
handle_end=handle_end
|
||||
)
|
||||
self.log.info(message)
|
||||
|
||||
if frame_data.get("byFrameStep", 1.0) != 1.0:
|
||||
self.log.info("Frame steps {}".format(frame_data["byFrameStep"]))
|
||||
|
||||
instance.data.update(frame_data)
|
||||
|
||||
# Add frame range to label if the instance has a frame range.
|
||||
label = instance.data.get("label", instance.data["name"])
|
||||
instance.data["label"] = (
|
||||
"{label} [{frame_start} - {frame_end}]"
|
||||
.format(
|
||||
label=label,
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_attribute_defs(cls):
|
||||
return [
|
||||
BoolDef("use_handles",
|
||||
tooltip="Disable this if you want the publisher to"
|
||||
" ignore start and end handles specified in the"
|
||||
" asset data for this publish instance",
|
||||
default=cls.use_asset_handles,
|
||||
label="Use asset handles")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
"""
|
||||
|
||||
label = "VRay ROP Render Products"
|
||||
order = pyblish.api.CollectorOrder + 0.4
|
||||
# This specific order value is used so that
|
||||
# this plugin runs after CollectRopFrameRange
|
||||
order = pyblish.api.CollectorOrder + 0.4999
|
||||
hosts = ["houdini"]
|
||||
families = ["vray_rop"]
|
||||
|
||||
|
|
@ -115,8 +117,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
|
|||
return path
|
||||
|
||||
expected_files = []
|
||||
start = instance.data["frameStart"]
|
||||
end = instance.data["frameEnd"]
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
|
||||
for i in range(int(start), (int(end) + 1)):
|
||||
expected_files.append(
|
||||
os.path.join(dir, (file % i)).replace("\\", "/"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pyblish.api
|
||||
from openpype.pipeline import PublishValidationError
|
||||
from openpype.pipeline.publish import RepairAction
|
||||
from openpype.hosts.houdini.api.action import SelectInvalidAction
|
||||
|
||||
import hou
|
||||
|
||||
|
||||
class DisableUseAssetHandlesAction(RepairAction):
|
||||
label = "Disable use asset handles"
|
||||
icon = "mdi.toggle-switch-off"
|
||||
|
||||
|
||||
class ValidateFrameRange(pyblish.api.InstancePlugin):
|
||||
"""Validate Frame Range.
|
||||
|
||||
Due to the usage of start and end handles,
|
||||
then Frame Range must be >= (start handle + end handle)
|
||||
which results that frameEnd be smaller than frameStart
|
||||
"""
|
||||
|
||||
order = pyblish.api.ValidatorOrder - 0.1
|
||||
hosts = ["houdini"]
|
||||
label = "Validate Frame Range"
|
||||
actions = [DisableUseAssetHandlesAction, SelectInvalidAction]
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
invalid = self.get_invalid(instance)
|
||||
if invalid:
|
||||
raise PublishValidationError(
|
||||
title="Invalid Frame Range",
|
||||
message=(
|
||||
"Invalid frame range because the instance "
|
||||
"start frame ({0[frameStart]}) is higher than "
|
||||
"the end frame ({0[frameEnd]})"
|
||||
.format(instance.data)
|
||||
),
|
||||
description=(
|
||||
"## Invalid Frame Range\n"
|
||||
"The frame range for the instance is invalid because "
|
||||
"the start frame is higher than the end frame.\n\nThis "
|
||||
"is likely due to asset handles being applied to your "
|
||||
"instance or the ROP node's start frame "
|
||||
"is set higher than the end frame.\n\nIf your ROP frame "
|
||||
"range is correct and you do not want to apply asset "
|
||||
"handles make sure to disable Use asset handles on the "
|
||||
"publish instance."
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_invalid(cls, instance):
|
||||
|
||||
if not instance.data.get("instance_node"):
|
||||
return
|
||||
|
||||
rop_node = hou.node(instance.data["instance_node"])
|
||||
if instance.data["frameStart"] > instance.data["frameEnd"]:
|
||||
cls.log.info(
|
||||
"The ROP node render range is set to "
|
||||
"{0[frameStartHandle]} - {0[frameEndHandle]} "
|
||||
"The asset handles applied to the instance are start handle "
|
||||
"{0[handleStart]} and end handle {0[handleEnd]}"
|
||||
.format(instance.data)
|
||||
)
|
||||
return [rop_node]
|
||||
|
||||
@classmethod
|
||||
def repair(cls, instance):
|
||||
|
||||
if not cls.get_invalid(instance):
|
||||
# Already fixed
|
||||
return
|
||||
|
||||
# Disable use asset handles
|
||||
context = instance.context
|
||||
create_context = context.data["create_context"]
|
||||
instance_id = instance.data.get("instance_id")
|
||||
if not instance_id:
|
||||
cls.log.debug("'{}' must have instance id"
|
||||
.format(instance))
|
||||
return
|
||||
|
||||
created_instance = create_context.get_instance_by_id(instance_id)
|
||||
if not instance_id:
|
||||
cls.log.debug("Unable to find instance '{}' by id"
|
||||
.format(instance))
|
||||
return
|
||||
|
||||
created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa
|
||||
|
||||
create_context.save_changes()
|
||||
cls.log.debug("use asset handles is turned off for '{}'"
|
||||
.format(instance))
|
||||
|
|
@ -65,9 +65,11 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline):
|
|||
job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S")
|
||||
|
||||
# Deadline requires integers in frame range
|
||||
start = instance.data["frameStartHandle"]
|
||||
end = instance.data["frameEndHandle"]
|
||||
frames = "{start}-{end}x{step}".format(
|
||||
start=int(instance.data["frameStart"]),
|
||||
end=int(instance.data["frameEnd"]),
|
||||
start=int(start),
|
||||
end=int(end),
|
||||
step=int(instance.data["byFrameStep"]),
|
||||
)
|
||||
job_info.Frames = frames
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@
|
|||
}
|
||||
},
|
||||
"publish": {
|
||||
"CollectRopFrameRange": {
|
||||
"use_asset_handles": true
|
||||
},
|
||||
"ValidateWorkfilePaths": {
|
||||
"enabled": true,
|
||||
"optional": true,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,27 @@
|
|||
"key": "publish",
|
||||
"label": "Publish plugins",
|
||||
"children": [
|
||||
{
|
||||
"type":"label",
|
||||
"label":"Collectors"
|
||||
},
|
||||
{
|
||||
"type": "dict",
|
||||
"collapsible": true,
|
||||
"key": "CollectRopFrameRange",
|
||||
"label": "Collect Rop Frame Range",
|
||||
"children": [
|
||||
{
|
||||
"type": "label",
|
||||
"label": "Disable this if you want the publisher to ignore start and end handles specified in the asset data for publish instances"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"key": "use_asset_handles",
|
||||
"label": "Use asset handles"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dict",
|
||||
"collapsible": true,
|
||||
|
|
|
|||
|
|
@ -151,6 +151,17 @@ class ValidateWorkfilePathsModel(BaseSettingsModel):
|
|||
)
|
||||
|
||||
|
||||
class CollectRopFrameRangeModel(BaseSettingsModel):
|
||||
"""Collect Frame Range
|
||||
|
||||
Disable this if you want the publisher to
|
||||
ignore start and end handles specified in the
|
||||
asset data for publish instances
|
||||
"""
|
||||
use_asset_handles: bool = Field(
|
||||
title="Use asset handles")
|
||||
|
||||
|
||||
class BasicValidateModel(BaseSettingsModel):
|
||||
enabled: bool = Field(title="Enabled")
|
||||
optional: bool = Field(title="Optional")
|
||||
|
|
@ -158,6 +169,11 @@ class BasicValidateModel(BaseSettingsModel):
|
|||
|
||||
|
||||
class PublishPluginsModel(BaseSettingsModel):
|
||||
CollectRopFrameRange: CollectRopFrameRangeModel = Field(
|
||||
default_factory=CollectRopFrameRangeModel,
|
||||
title="Collect Rop Frame Range.",
|
||||
section="Collectors"
|
||||
)
|
||||
ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field(
|
||||
default_factory=ValidateWorkfilePathsModel,
|
||||
title="Validate workfile paths settings.")
|
||||
|
|
@ -179,6 +195,9 @@ class PublishPluginsModel(BaseSettingsModel):
|
|||
|
||||
|
||||
DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
|
||||
"CollectRopFrameRange": {
|
||||
"use_asset_handles": True
|
||||
},
|
||||
"ValidateWorkfilePaths": {
|
||||
"enabled": True,
|
||||
"optional": True,
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue