diff --git a/pype/hosts/harmony/js/PypeHarmony.js b/pype/hosts/harmony/js/PypeHarmony.js index 57137d98c6..504bcc9ba2 100644 --- a/pype/hosts/harmony/js/PypeHarmony.js +++ b/pype/hosts/harmony/js/PypeHarmony.js @@ -183,11 +183,11 @@ PypeHarmony.color = function(rgba) { /** * get all dependencies for given node. * @function - * @param {string} node node path. + * @param {string} _node node path. * @return {array} List of dependent nodes. */ -PypeHarmony.getDependencies = function(node) { - var target_node = node; +PypeHarmony.getDependencies = function(_node) { + var target_node = _node; var numInput = node.numberOfInputPorts(target_node); var dependencies = []; for (var i = 0 ; i < numInput; i++) { diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index d02d20ad4d..533d797982 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -34,7 +34,12 @@ from .applications import ( _subprocess ) -from .plugin_tools import filter_pyblish_plugins, source_hash +from .plugin_tools import ( + filter_pyblish_plugins, + source_hash, + get_unique_layer_name, + get_background_layers +) from .path_tools import ( version_up, @@ -74,6 +79,8 @@ __all__ = [ "PostLaunchHook", "filter_pyblish_plugins", + "get_unique_layer_name", + "get_background_layers", "version_up", "get_version_from_path", diff --git a/pype/lib/abstract_collect_render.py b/pype/lib/abstract_collect_render.py index 6bcef1ba90..19e7c37dba 100644 --- a/pype/lib/abstract_collect_render.py +++ b/pype/lib/abstract_collect_render.py @@ -73,14 +73,14 @@ class RenderInstance(object): @frameStart.validator def check_frame_start(self, _, value): """Validate if frame start is not larger then end.""" - if value >= self.frameEnd: + if value > self.frameEnd: raise ValueError("frameStart must be smaller " "or equal then frameEnd") @frameEnd.validator def check_frame_end(self, _, value): """Validate if frame end is not less then start.""" - if value <= self.frameStart: + if value < self.frameStart: raise ValueError("frameEnd must be smaller " "or equal then frameStart") diff --git a/pype/lib/plugin_tools.py b/pype/lib/plugin_tools.py index a78c9e525e..6d09dce2c7 100644 --- a/pype/lib/plugin_tools.py +++ b/pype/lib/plugin_tools.py @@ -3,6 +3,8 @@ import os import inspect import logging +import re +import json from ..api import get_project_settings @@ -78,3 +80,57 @@ def source_hash(filepath, *args): time = str(os.path.getmtime(filepath)) size = str(os.path.getsize(filepath)) return "|".join([file_name, time, size] + list(args)).replace(".", ",") + + +def get_unique_layer_name(layers, name): + """ + Gets all layer names and if 'name' is present in them, increases + suffix by 1 (eg. creates unique layer name - for Loader) + Args: + layers (list): of strings, names only + name (string): checked value + + Returns: + (string): name_00X (without version) + """ + names = {} + for layer in layers: + layer_name = re.sub(r'_\d{3}$', '', layer) + if layer_name in names.keys(): + names[layer_name] = names[layer_name] + 1 + else: + names[layer_name] = 1 + occurrences = names.get(name, 0) + + return "{}_{:0>3d}".format(name, occurrences + 1) + + +def get_background_layers(file_url): + """ + Pulls file name from background json file, enrich with folder url for + AE to be able import files. + + Order is important, follows order in json. + + Args: + file_url (str): abs url of background json + + Returns: + (list): of abs paths to images + """ + with open(file_url) as json_file: + data = json.load(json_file) + + layers = list() + bg_folder = os.path.dirname(file_url) + for child in data['children']: + if child.get("filename"): + layers.append(os.path.join(bg_folder, child.get("filename")). + replace("\\", "/")) + else: + for layer in child['children']: + if layer.get("filename"): + layers.append(os.path.join(bg_folder, + layer.get("filename")). + replace("\\", "/")) + return layers diff --git a/pype/modules/websocket_server/stubs/aftereffects_server_stub.py b/pype/modules/websocket_server/stubs/aftereffects_server_stub.py index 0b8c54e884..9449d0b378 100644 --- a/pype/modules/websocket_server/stubs/aftereffects_server_stub.py +++ b/pype/modules/websocket_server/stubs/aftereffects_server_stub.py @@ -4,13 +4,31 @@ from pype.modules.websocket_server import WebSocketServer Used anywhere solution is calling client methods. """ import json -from collections import namedtuple - +import attr import logging log = logging.getLogger(__name__) +@attr.s +class AEItem(object): + """ + Object denoting Item in AE. Each item is created in AE by any Loader, + but contains same fields, which are being used in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + item_type = attr.ib(default=None) # item type (footage, folder, comp) + # all imported elements, single for + # regular image, array for Backgrounds + members = attr.ib(factory=list) + workAreaStart = attr.ib(default=None) + workAreaDuration = attr.ib(default=None) + frameRate = attr.ib(default=None) + file_name = attr.ib(default=None) + + class AfterEffectsServerStub(): """ Stub for calling function on client (Photoshop js) side. @@ -34,22 +52,14 @@ class AfterEffectsServerStub(): ('AfterEffects.open', path=path) ) - def read(self, layer, layers_meta=None): - """ - Parses layer metadata from Label field of active document - Args: - layer: - res(string): - json representation + Returns: + res(string): - json representation """ if not res: return [] @@ -358,9 +484,19 @@ class AfterEffectsServerStub(): return [] ret = [] - # convert to namedtuple to use dot donation - if isinstance(layers_data, dict): # TODO refactore + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): layers_data = [layers_data] for d in layers_data: - ret.append(namedtuple('Layer', d.keys())(*d.values())) + # currently implemented and expected fields + item = AEItem(d.get('id'), + d.get('name'), + d.get('type'), + d.get('members'), + d.get('workAreaStart'), + d.get('workAreaDuration'), + d.get('frameRate'), + d.get('file_name')) + + ret.append(item) return ret diff --git a/pype/plugins/aftereffects/create/create_render.py b/pype/plugins/aftereffects/create/create_render.py index 1944cf9937..6d876e349d 100644 --- a/pype/plugins/aftereffects/create/create_render.py +++ b/pype/plugins/aftereffects/create/create_render.py @@ -35,7 +35,7 @@ class CreateRender(api.Creator): if self.name.lower() == item.name.lower(): self._show_msg(txt) return False - + self.data["members"] = [item.id] stub.imprint(item, self.data) stub.set_label_color(item.id, 14) # Cyan options 0 - 16 stub.rename_item(item, self.data["subset"]) diff --git a/pype/plugins/aftereffects/load/load_background.py b/pype/plugins/aftereffects/load/load_background.py new file mode 100644 index 0000000000..879734e4f9 --- /dev/null +++ b/pype/plugins/aftereffects/load/load_background.py @@ -0,0 +1,99 @@ +import re + +from avalon import api, aftereffects + +from pype.lib import get_background_layers, get_unique_layer_name + +stub = aftereffects.stub() + + +class BackgroundLoader(api.Loader): + """ + Load images from Background family + Creates for each background separate folder with all imported images + from background json AND automatically created composition with layers, + each layer for separate image. + + For each load container is created and stored in project (.aep) + metadata + """ + families = ["background"] + representations = ["json"] + + def load(self, context, name=None, namespace=None, data=None): + items = stub.get_items(comps=True) + existing_items = [layer.name for layer in items] + + comp_name = get_unique_layer_name( + existing_items, + "{}_{}".format(context["asset"]["name"], name)) + + layers = get_background_layers(self.fname) + comp = stub.import_background(None, comp_name, layers) + + if not comp: + self.log.warning( + "Import background failed.") + self.log.warning("Check host app for alert error.") + return + + self[:] = [comp] + namespace = namespace or comp_name + + return aftereffects.containerise( + name, + namespace, + comp, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + """ Switch asset or change version """ + context = representation.get("context", {}) + _ = container.pop("layer") + + # without iterator number (_001, 002...) + namespace_from_container = re.sub(r'_\d{3}$', '', + container["namespace"]) + comp_name = "{}_{}".format(context["asset"], context["subset"]) + + # switching assets + if namespace_from_container != comp_name: + items = stub.get_items(comps=True) + existing_items = [layer.name for layer in items] + comp_name = get_unique_layer_name( + existing_items, + "{}_{}".format(context["asset"], context["subset"])) + else: # switching version - keep same name + comp_name = container["namespace"] + + path = api.get_representation_path(representation) + + layers = get_background_layers(path) + comp = stub.reload_background(container["members"][1], + comp_name, + layers) + + # update container + container["representation"] = str(representation["_id"]) + container["name"] = context["subset"] + container["namespace"] = comp_name + container["members"] = comp.members + + stub.imprint(comp, container) + + def remove(self, container): + """ + Removes element from scene: deletes layer + removes from file + metadata. + Args: + container (dict): container to be removed - used to get layer_id + """ + print("!!!! container:: {}".format(container)) + layer = container.pop("layer") + stub.imprint(layer, {}) + stub.delete_item(layer.id) + + def switch(self, container, representation): + self.update(container, representation) diff --git a/pype/plugins/aftereffects/load/load_file.py b/pype/plugins/aftereffects/load/load_file.py index 0705dbd776..ba11856678 100644 --- a/pype/plugins/aftereffects/load/load_file.py +++ b/pype/plugins/aftereffects/load/load_file.py @@ -1,5 +1,5 @@ from avalon import api, aftereffects -from pype.plugins import lib +from pype import lib import re stub = aftereffects.stub() @@ -21,9 +21,10 @@ class FileLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - comp_name = lib.get_unique_layer_name(stub.get_items(comps=True), - context["asset"]["name"], - name) + layers = stub.get_items(comps=True, folders=True, footages=True) + existing_layers = [layer.name for layer in layers] + comp_name = lib.get_unique_layer_name( + existing_layers, "{}_{}".format(context["asset"]["name"], name)) import_options = {} @@ -77,9 +78,11 @@ class FileLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = lib.get_unique_layer_name(stub.get_items(comps=True), - context["asset"], - context["subset"]) + layers = stub.get_items(comps=True) + existing_layers = [layer.name for layer in layers] + layer_name = lib.get_unique_layer_name( + existing_layers, + "{}_{}".format(context["asset"], context["subset"])) else: # switching version - keep same name layer_name = container["namespace"] path = api.get_representation_path(representation) diff --git a/pype/plugins/aftereffects/publish/collect_render.py b/pype/plugins/aftereffects/publish/collect_render.py index fbe392d52b..7f7d5a52bc 100644 --- a/pype/plugins/aftereffects/publish/collect_render.py +++ b/pype/plugins/aftereffects/publish/collect_render.py @@ -33,12 +33,16 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): compositions = aftereffects.stub().get_items(True) compositions_by_id = {item.id: item for item in compositions} - for item_id, inst in aftereffects.stub().get_metadata().items(): + for inst in aftereffects.stub().get_metadata(): schema = inst.get('schema') # loaded asset container skip it if schema and 'container' in schema: continue + if not inst["members"]: + raise ValueError("Couldn't find id, unable to publish. " + + "Please recreate instance.") + item_id = inst["members"][0] work_area_info = aftereffects.stub().get_work_area(int(item_id)) frameStart = work_area_info.workAreaStart @@ -110,7 +114,10 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): # pull file name from Render Queue Output module render_q = aftereffects.stub().get_render_info() + if not render_q: + raise ValueError("No file extension set in Render Queue") _, ext = os.path.splitext(os.path.basename(render_q.file_name)) + base_dir = self._get_output_dir(render_instance) expected_files = [] if "#" not in render_q.file_name: # single frame (mov)W diff --git a/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py b/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py index 9414bdd39d..5e5c00dec1 100644 --- a/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py +++ b/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py @@ -105,3 +105,13 @@ class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline deadline_plugin_info.Output = render_path.replace("\\", "/") return attr.asdict(deadline_plugin_info) + + def from_published_scene(self): + """ Do not overwrite expected files. + + Use published is set to True, so rendering will be triggered + from published scene (in 'publish' folder). Default implementation + of abstract class renames expected (eg. rendered) files accordingly + which is not needed here. + """ + return super().from_published_scene(False) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index d08748209d..aa8d8accb5 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -348,6 +348,8 @@ class ExtractReview(pyblish.api.InstancePlugin): + 1 ) + duration_seconds = float(output_frames_len / temp_data["fps"]) + if temp_data["input_is_sequence"]: # Set start frame of input sequence (just frame in filename) # - definition of input filepath @@ -375,33 +377,39 @@ class ExtractReview(pyblish.api.InstancePlugin): # Change output's duration and start point if should not contain # handles + start_sec = 0 if temp_data["without_handles"] and temp_data["handles_are_set"]: # Set start time without handles # - check if handle_start is bigger than 0 to avoid zero division if temp_data["handle_start"] > 0: start_sec = float(temp_data["handle_start"]) / temp_data["fps"] - ffmpeg_input_args.append("-ss {:0.2f}".format(start_sec)) + ffmpeg_input_args.append("-ss {:0.10f}".format(start_sec)) # Set output duration inn seconds - duration_sec = float(output_frames_len / temp_data["fps"]) - ffmpeg_output_args.append("-t {:0.2f}".format(duration_sec)) + ffmpeg_output_args.append("-t {:0.10}".format(duration_seconds)) # Set frame range of output when input or output is sequence - elif temp_data["input_is_sequence"] or temp_data["output_is_sequence"]: + elif temp_data["output_is_sequence"]: ffmpeg_output_args.append("-frames:v {}".format(output_frames_len)) + # Add duration of an input sequence if output is video + if ( + temp_data["input_is_sequence"] + and not temp_data["output_is_sequence"] + ): + ffmpeg_input_args.append("-to {:0.10f}".format( + duration_seconds + start_sec + )) + # Add video/image input path ffmpeg_input_args.append( "-i \"{}\"".format(temp_data["full_input_path"]) ) - # Use shortest input - ffmpeg_output_args.append("-shortest") - # Add audio arguments if there are any. Skipped when output are images. if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: audio_in_args, audio_filters, audio_out_args = self.audio_args( - instance, temp_data + instance, temp_data, duration_seconds ) ffmpeg_input_args.extend(audio_in_args) ffmpeg_audio_filters.extend(audio_filters) @@ -616,7 +624,7 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("Input path {}".format(full_input_path)) self.log.debug("Output path {}".format(full_output_path)) - def audio_args(self, instance, temp_data): + def audio_args(self, instance, temp_data, duration_seconds): """Prepares FFMpeg arguments for audio inputs.""" audio_in_args = [] audio_filters = [] @@ -639,11 +647,19 @@ class ExtractReview(pyblish.api.InstancePlugin): audio_in_args.append( "-ss {}".format(offset_seconds) ) + elif offset_seconds < 0: audio_in_args.append( "-itsoffset {}".format(abs(offset_seconds)) ) + # Audio duration is offset from `-ss` + audio_duration = duration_seconds + offset_seconds + + # Set audio duration + audio_in_args.append("-to {:0.10f}".format(audio_duration)) + + # Add audio input path audio_in_args.append("-i \"{}\"".format(audio["filename"])) # NOTE: These were changed from input to output arguments. diff --git a/pype/plugins/harmony/publish/extract_palette.py b/pype/plugins/harmony/publish/extract_palette.py index 0996079dea..029a4f0f11 100644 --- a/pype/plugins/harmony/publish/extract_palette.py +++ b/pype/plugins/harmony/publish/extract_palette.py @@ -38,7 +38,7 @@ class ExtractPalette(pype.api.Extractor): os.path.basename(palette_file) .split(".plt")[0] + "_swatches.png" ) - self.log.info(f"Temporary humbnail path {tmp_thumb_path}") + self.log.info(f"Temporary thumbnail path {tmp_thumb_path}") palette_version = str(instance.data.get("version")).zfill(3) @@ -52,6 +52,11 @@ class ExtractPalette(pype.api.Extractor): palette_version, palette_file, tmp_thumb_path) + except OSError as e: + # FIXME: this happens on Mac where PIL cannot access fonts + # for some reason. + self.log.warning("Thumbnail generation failed") + self.log.warning(e) except ValueError: self.log.error("Unsupported palette type for thumbnail.") diff --git a/pype/plugins/harmony/publish/extract_template.py b/pype/plugins/harmony/publish/extract_template.py index 06c4c1efcf..b8437c85ea 100644 --- a/pype/plugins/harmony/publish/extract_template.py +++ b/pype/plugins/harmony/publish/extract_template.py @@ -31,7 +31,11 @@ class ExtractTemplate(pype.api.Extractor): for backdrop in self.get_backdrops(dependency): backdrops[backdrop["title"]["text"]] = backdrop unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] - + if not unique_backdrops: + self.log.error(("No backdrops detected for template. " + "Please move template instance node onto " + "some backdrop and try again.")) + raise AssertionError("No backdrop detected") # Get non-connected nodes within backdrops. all_nodes = instance.context.data.get("allNodes") for node in [x for x in all_nodes if x not in dependencies]: diff --git a/pype/plugins/lib.py b/pype/plugins/lib.py deleted file mode 100644 index d7d90af165..0000000000 --- a/pype/plugins/lib.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - - -def get_unique_layer_name(layers, asset_name, subset_name): - """ - Gets all layer names and if 'name' is present in them, increases - suffix by 1 (eg. creates unique layer name - for Loader) - Args: - layers (list): of namedtuples, expects 'name' field present - asset_name (string): in format asset_subset (Hero) - subset_name (string): (LOD) - - Returns: - (string): name_00X (without version) - """ - name = "{}_{}".format(asset_name, subset_name) - names = {} - for layer in layers: - layer_name = re.sub(r'_\d{3}$', '', layer.name) - if layer_name in names.keys(): - names[layer_name] = names[layer_name] + 1 - else: - names[layer_name] = 1 - occurrences = names.get(name, 0) - - return "{}_{:0>3d}".format(name, occurrences + 1) diff --git a/pype/plugins/tvpaint/load/load_reference_image.py b/pype/plugins/tvpaint/load/load_reference_image.py index b3cefee4c3..e0e8885d57 100644 --- a/pype/plugins/tvpaint/load/load_reference_image.py +++ b/pype/plugins/tvpaint/load/load_reference_image.py @@ -104,6 +104,7 @@ class LoadImage(pipeline.Loader): def _remove_layers(self, layer_ids, layers=None): if not layer_ids: + self.log.warning("Got empty layer ids list.") return if layers is None: @@ -117,6 +118,7 @@ class LoadImage(pipeline.Loader): layer_ids_to_remove.append(layer_id) if not layer_ids_to_remove: + self.log.warning("No layers to delete.") return george_script_lines = [] @@ -128,12 +130,14 @@ class LoadImage(pipeline.Loader): def remove(self, container): layer_ids = self.layer_ids_from_container(container) + self.log.warning("Layers to delete {}".format(layer_ids)) self._remove_layers(layer_ids) current_containers = pipeline.ls() pop_idx = None for idx, cur_con in enumerate(current_containers): - if cur_con["objectName"] == container["objectName"]: + cur_con_layer_ids = self.layer_ids_from_container(cur_con) + if cur_con_layer_ids == layer_ids: pop_idx = idx break diff --git a/pype/plugins/tvpaint/publish/collect_workfile_data.py b/pype/plugins/tvpaint/publish/collect_workfile_data.py index 31fd97ced4..c6179b76cf 100644 --- a/pype/plugins/tvpaint/publish/collect_workfile_data.py +++ b/pype/plugins/tvpaint/publish/collect_workfile_data.py @@ -1,6 +1,8 @@ +import os import json import pyblish.api +import avalon.api from avalon.tvpaint import pipeline, lib @@ -10,26 +12,64 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): hosts = ["tvpaint"] def process(self, context): + current_project_id = lib.execute_george("tv_projectcurrentid") + lib.execute_george("tv_projectselect {}".format(current_project_id)) + + # Collect and store current context to have reference + current_context = { + "project": avalon.api.Session["AVALON_PROJECT"], + "asset": avalon.api.Session["AVALON_ASSET"], + "task": avalon.api.Session["AVALON_TASK"] + } + context.data["previous_context"] = current_context + self.log.debug("Current context is: {}".format(current_context)) + + # Collect context from workfile metadata + self.log.info("Collecting workfile context") + workfile_context = pipeline.get_current_workfile_context() + if workfile_context: + # Change current context with context from workfile + key_map = ( + ("AVALON_ASSET", "asset"), + ("AVALON_TASK", "task") + ) + for env_key, key in key_map: + avalon.api.Session[env_key] = workfile_context[key] + os.environ[env_key] = workfile_context[key] + else: + # Handle older workfiles or workfiles without metadata + self.log.warning( + "Workfile does not contain information about context." + " Using current Session context." + ) + workfile_context = current_context.copy() + + context.data["workfile_context"] = workfile_context + self.log.info("Context changed to: {}".format(workfile_context)) + + # Collect instances self.log.info("Collecting instance data from workfile") instance_data = pipeline.list_instances() + context.data["workfileInstances"] = instance_data self.log.debug( "Instance data:\"{}".format(json.dumps(instance_data, indent=4)) ) - context.data["workfileInstances"] = instance_data + # Collect information about layers self.log.info("Collecting layers data from workfile") layers_data = lib.layers_data() + context.data["layersData"] = layers_data self.log.debug( "Layers data:\"{}".format(json.dumps(layers_data, indent=4)) ) - context.data["layersData"] = layers_data + # Collect information about groups self.log.info("Collecting groups data from workfile") group_data = lib.groups_data() + context.data["groupsData"] = group_data self.log.debug( "Group data:\"{}".format(json.dumps(group_data, indent=4)) ) - context.data["groupsData"] = group_data self.log.info("Collecting scene data from workfile") workfile_info_parts = lib.execute_george("tv_projectinfo").split(" ") diff --git a/pype/plugins/tvpaint/publish/validate_workfile_project_name.py b/pype/plugins/tvpaint/publish/validate_workfile_project_name.py new file mode 100644 index 0000000000..7c1032fcad --- /dev/null +++ b/pype/plugins/tvpaint/publish/validate_workfile_project_name.py @@ -0,0 +1,37 @@ +import os +import pyblish.api + + +class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): + """Validate project name stored in workfile metadata. + + It is not possible to publish from different project than is set in + environment variable "AVALON_PROJECT". + """ + + label = "Validate Workfile Project Name" + order = pyblish.api.ValidatorOrder + + def process(self, context): + workfile_context = context.data["workfile_context"] + workfile_project_name = workfile_context["project"] + env_project_name = os.environ["AVALON_PROJECT"] + if workfile_project_name == env_project_name: + self.log.info(( + "Both workfile project and environment project are same. {}" + ).format(env_project_name)) + return + + # Raise an error + raise AssertionError(( + # Short message + "Workfile from different Project ({})." + # Description what's wrong + " It is not possible to publish when TVPaint was launched in" + "context of different project. Current context project is \"{}\"." + " Launch TVPaint in context of project \"{}\" and then publish." + ).format( + workfile_project_name, + env_project_name, + workfile_project_name, + )) diff --git a/pype/version.py b/pype/version.py index e199aa5550..abe7e03a96 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.14.1" +__version__ = "2.14.2"