diff --git a/dyn2py/files.py b/dyn2py/files.py index 3c82456..9fb3759 100644 --- a/dyn2py/files.py +++ b/dyn2py/files.py @@ -92,11 +92,19 @@ class File(): def write(self, options: Options) -> None: """Prepare writing file to the disk: create backup, process dry-run, call filetype specific write_file() methods + Should be called on subclasses! Args: options(Options): Run options. + + Raises: + TypeError: If called on a File object """ + # This should only work on subclasses: + if type(self).__name__ == "File": + raise TypeError("This method shouldn't be called on File objects!") + if not self.modified: logging.debug("File not modified, not saving") return @@ -126,16 +134,16 @@ class DynamoFile(File): """A Dynamo file, subclass of File()""" full_dict: dict - """The contents of the Dynamo file, as dict.""" + """The contents of the Dynamo file, as dict. Initialized with read()""" uuid: str - """The uuid of the graph""" + """The uuid of the graph. Initialized with read()""" name: str - """The name of the graph, read from the file. Not the name of the file""" + """The name of the graph, read from the file. Initialized with read()""" python_nodes: list["PythonNode"] - """Python node objects, read from this file""" + """Python node objects, read from this file. Initialized with get_python_nodes()""" open_files: set["DynamoFile"] = set() - """A set of open Dynamo files, before saving""" + """A set of open Dynamo files, before saving. Self added by read()""" def extract_python(self, options: Options | None = None) -> None: """Extract and write python files @@ -174,6 +182,7 @@ class DynamoFile(File): """Read Dynamo graph to parameters Raises: + FileNotFoundError: The file does not exist DynamoFileException: If the file is a Dynamo 1 file json.JSONDecodeError: If there are any other problem with the file """ @@ -204,6 +213,9 @@ class DynamoFile(File): Returns: list[PythonNode]: A list of PythonNodes in the file + + Raises: + DynamoFileException: If no Python nodes in the file """ if not self in self.open_files: self.read() @@ -211,19 +223,24 @@ class DynamoFile(File): full_python_nodes = [n for n in self.full_dict["Nodes"] if n["NodeType"] == "PythonScriptNode"] - self.python_nodes = [] - - for p_node in full_python_nodes: - # The name of the node is stored here: - node_views = self.full_dict["View"]["NodeViews"] - python_node = PythonNode( - node_dict_from_dyn=p_node, - full_nodeviews_dict_from_dyn=node_views, - source_dynamo_file=self) - self.python_nodes.append(python_node) + # Check if it was already read: + try: + self.python_nodes + except AttributeError: + self.python_nodes = [] if not self.python_nodes: - raise DynamoFileException("No python nodes in this file!") + for p_node in full_python_nodes: + # The name of the node is stored here: + node_views = self.full_dict["View"]["NodeViews"] + python_node = PythonNode( + node_dict_from_dyn=p_node, + full_nodeviews_dict_from_dyn=node_views, + source_dynamo_file=self) + self.python_nodes.append(python_node) + + if not self.python_nodes: + raise DynamoFileException("No python nodes in this file!") return self.python_nodes @@ -326,14 +343,14 @@ class PythonFile(File): """A Python file, subclass of File()""" code: str - """The python code as a string""" + """The python code as a string. Initialized with read()""" header_data: dict - """Parsed dict from the header of a python file""" + """Parsed dict from the header of a python file. Initialized with read()""" text: str - """Full contents of the file before writing""" + """Full contents of the file before writing. Initialized with generate_text()""" open_files: set["PythonFile"] = set() - """A set of open Python files""" + """A set of open Python files. Self added by read()""" def generate_text(self, dynamo_file: DynamoFile, python_node: "PythonNode") -> None: """Generate full text to write with header @@ -348,12 +365,17 @@ class PythonFile(File): Do not edit this section, if you want to update the Dynamo graph!\ """ + # Double escape path: + dyn_path_string = str(dynamo_file.realpath) + if "\\" in dyn_path_string: + dyn_path_string = dyn_path_string.replace("\\", "\\\\") + self.header_data = { "dyn2py_version": METADATA["Version"], "dyn2py_extracted": datetime.now().isoformat(), "dyn_uuid": dynamo_file.uuid, "dyn_name": dynamo_file.name, - "dyn_path": dynamo_file.realpath, + "dyn_path": dyn_path_string, "dyn_modified": dynamo_file.mtimeiso, "py_id": python_node.id, "py_engine": python_node.engine @@ -486,7 +508,7 @@ class PythonFile(File): def write_file(self) -> None: """Write this file to the disk. Should be called only from File.write()""" - with open(self.filepath, "w", encoding="utf-8") as output_file: + with open(self.filepath, "w", encoding="utf-8", newline='') as output_file: output_file.write(self.text) diff --git a/tests/input_files/no_python.dyn b/tests/input_files/no_python.dyn new file mode 100644 index 0000000..e951196 --- /dev/null +++ b/tests/input_files/no_python.dyn @@ -0,0 +1,200 @@ +{ + "Uuid": "76de5c79-17c5-4c74-9f90-ad99a213d339", + "IsCustomNode": false, + "Description": "", + "Name": "no_python", + "ElementResolver": { + "ResolutionMap": {} + }, + "Inputs": [], + "Outputs": [], + "Nodes": [ + { + "ConcreteType": "Dynamo.Graph.Nodes.ZeroTouch.DSFunction, DynamoCore", + "NodeType": "FunctionNode", + "FunctionSignature": "+@var[]..[],var[]..[]", + "Id": "f8f4d3310211429ea8f301284983849e", + "Inputs": [ + { + "Id": "264e2d2a3442445180048463a555399a", + "Name": "x", + "Description": "Integer value, double value or string\n\nvar[]..[]", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + }, + { + "Id": "f627c4088ee943aeba708e81f9945f1e", + "Name": "y", + "Description": "Integer value, double value or string\n\nvar[]..[]", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Outputs": [ + { + "Id": "72975df34c2542ff949630ef7253bed5", + "Name": "var", + "Description": "The sum of two input numbers, or the concatenation of two strings", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Replication": "Auto", + "Description": "Returns addition of x and y\n\n+ (x: var[]..[], y: var[]..[]): var[]..[]" + }, + { + "ConcreteType": "Dynamo.Graph.Nodes.CodeBlockNodeModel, DynamoCore", + "NodeType": "CodeBlockNode", + "Code": "1;", + "Id": "24cee4b3934f46309d34386a1b5a674a", + "Inputs": [], + "Outputs": [ + { + "Id": "dffa5271656d49e0b61ba8133b92a05e", + "Name": "", + "Description": "Value of expression at line 1", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Replication": "Disabled", + "Description": "Allows for DesignScript code to be authored directly" + }, + { + "ConcreteType": "CoreNodeModels.Watch, CoreNodeModels", + "NodeType": "ExtensionNode", + "Id": "26fc878922fd425a89112c6b14c9779b", + "Inputs": [ + { + "Id": "1587eb8d7d8f41e6833e2b90f95b63fb", + "Name": "", + "Description": "Node to show output from", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Outputs": [ + { + "Id": "d70e73be40114b9cb129a91cd4777c4a", + "Name": "", + "Description": "Node output", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Replication": "Disabled", + "Description": "Visualizes a node's output" + } + ], + "Connectors": [ + { + "Start": "72975df34c2542ff949630ef7253bed5", + "End": "1587eb8d7d8f41e6833e2b90f95b63fb", + "Id": "8d017b34cb5b47f1b900c1c6ed8fd8d0", + "IsHidden": "False" + }, + { + "Start": "dffa5271656d49e0b61ba8133b92a05e", + "End": "264e2d2a3442445180048463a555399a", + "Id": "46f9099ffb1a4e08ad78d7e13d36d8bc", + "IsHidden": "False" + }, + { + "Start": "dffa5271656d49e0b61ba8133b92a05e", + "End": "f627c4088ee943aeba708e81f9945f1e", + "Id": "f167bc9b5eed4d5c96bac03015664979", + "IsHidden": "False" + } + ], + "Dependencies": [], + "NodeLibraryDependencies": [], + "Thumbnail": "", + "GraphDocumentationURL": null, + "ExtensionWorkspaceData": [ + { + "ExtensionGuid": "28992e1d-abb9-417f-8b1b-05e053bee670", + "Name": "Properties", + "Version": "2.17", + "Data": {} + } + ], + "Author": "", + "Linting": { + "activeLinter": "None", + "activeLinterId": "7b75fb44-43fd-4631-a878-29f4d5d8399a", + "warningCount": 0, + "errorCount": 0 + }, + "Bindings": [], + "View": { + "Dynamo": { + "ScaleFactor": 1.0, + "HasRunWithoutCrash": true, + "IsVisibleInDynamoLibrary": true, + "Version": "2.17.0.3472", + "RunType": "Automatic", + "RunPeriod": "1000" + }, + "Camera": { + "Name": "_Background Preview", + "EyeX": -17.0, + "EyeY": 24.0, + "EyeZ": 50.0, + "LookX": 12.0, + "LookY": -13.0, + "LookZ": -58.0, + "UpX": 0.0, + "UpY": 1.0, + "UpZ": 0.0 + }, + "ConnectorPins": [], + "NodeViews": [ + { + "Name": "+", + "ShowGeometry": true, + "Id": "f8f4d3310211429ea8f301284983849e", + "IsSetAsInput": false, + "IsSetAsOutput": false, + "Excluded": false, + "X": 779.0, + "Y": 566.5 + }, + { + "Name": "Code Block", + "ShowGeometry": true, + "Id": "24cee4b3934f46309d34386a1b5a674a", + "IsSetAsInput": false, + "IsSetAsOutput": false, + "Excluded": false, + "X": 402.0, + "Y": 500.0 + }, + { + "Name": "Watch", + "ShowGeometry": true, + "Id": "26fc878922fd425a89112c6b14c9779b", + "IsSetAsInput": false, + "IsSetAsOutput": false, + "Excluded": false, + "X": 1102.0, + "Y": 587.0 + } + ], + "Annotations": [], + "X": 127.12026168573345, + "Y": 129.76719880218047, + "Zoom": 0.7658293928515516 + } +} \ No newline at end of file diff --git a/tests/input_files/single_node.dyn b/tests/input_files/single_node.dyn new file mode 100644 index 0000000..cd8eaba --- /dev/null +++ b/tests/input_files/single_node.dyn @@ -0,0 +1,106 @@ +{ + "Uuid": "76de5c79-17c5-4c74-9f90-ad99a213d339", + "IsCustomNode": false, + "Description": "", + "Name": "single_node", + "ElementResolver": { + "ResolutionMap": {} + }, + "Inputs": [], + "Outputs": [], + "Nodes": [ + { + "ConcreteType": "PythonNodeModels.PythonNode, PythonNodeModels", + "NodeType": "PythonScriptNode", + "Code": "# Load the Python Standard and DesignScript Libraries\r\nimport sys\r\nimport clr\r\nclr.AddReference('ProtoGeometry')\r\nfrom Autodesk.DesignScript.Geometry import *\r\n\r\n# The inputs to this node will be stored as a list in the IN variables.\r\ndataEnteringNode = IN\r\n\r\noutput = {\r\n \"python_version\": sys.version,\r\n \"asd_string\": \"asd_string\"\r\n}\r\n\r\n\r\n# Assign your output to the OUT variable.\r\nOUT = output", + "Engine": "CPython3", + "EngineName": "CPython3", + "VariableInputPorts": true, + "Id": "1c5d99792882409e97e132b3e9f814b0", + "Inputs": [ + { + "Id": "7a29134d5d35492b80cd49a3ebac1847", + "Name": "IN[0]", + "Description": "Input #0", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Outputs": [ + { + "Id": "a6a87605c777416bb759eaa87c976285", + "Name": "OUT", + "Description": "Result of the python script", + "UsingDefaultValue": false, + "Level": 2, + "UseLevels": false, + "KeepListStructure": false + } + ], + "Replication": "Disabled", + "Description": "Runs an embedded Python script." + } + ], + "Connectors": [], + "Dependencies": [], + "NodeLibraryDependencies": [], + "Thumbnail": "", + "GraphDocumentationURL": null, + "ExtensionWorkspaceData": [ + { + "ExtensionGuid": "28992e1d-abb9-417f-8b1b-05e053bee670", + "Name": "Properties", + "Version": "2.17", + "Data": {} + } + ], + "Author": "", + "Linting": { + "activeLinter": "None", + "activeLinterId": "7b75fb44-43fd-4631-a878-29f4d5d8399a", + "warningCount": 0, + "errorCount": 0 + }, + "Bindings": [], + "View": { + "Dynamo": { + "ScaleFactor": 1.0, + "HasRunWithoutCrash": true, + "IsVisibleInDynamoLibrary": true, + "Version": "2.17.0.3472", + "RunType": "Automatic", + "RunPeriod": "1000" + }, + "Camera": { + "Name": "_Background Preview", + "EyeX": -17.0, + "EyeY": 24.0, + "EyeZ": 50.0, + "LookX": 12.0, + "LookY": -13.0, + "LookZ": -58.0, + "UpX": 0.0, + "UpY": 1.0, + "UpZ": 0.0 + }, + "ConnectorPins": [], + "NodeViews": [ + { + "Name": "Python Script", + "ShowGeometry": true, + "Id": "1c5d99792882409e97e132b3e9f814b0", + "IsSetAsInput": false, + "IsSetAsOutput": false, + "Excluded": false, + "X": 208.0, + "Y": 367.0 + } + ], + "Annotations": [], + "X": 84.425000000000011, + "Y": -8.4125000000000227, + "Zoom": 1.1575 + } +} \ No newline at end of file diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..f8c6224 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,12 @@ +import pathlib + +INPUT_DIR = "tests/input_files" +OUTPUT_DIR = "tests/output_files" + +def cleanup_output_dir(): + output_dir = pathlib.Path(OUTPUT_DIR) + if output_dir.exists(): + for f in output_dir.iterdir(): + f.unlink() + else: + output_dir.mkdir() diff --git a/tests/test_CommandLine.py b/tests/test_CommandLine.py index ff42260..c3dc351 100644 --- a/tests/test_CommandLine.py +++ b/tests/test_CommandLine.py @@ -1,6 +1,5 @@ import unittest import subprocess -import platform class TestCommandLine(unittest.TestCase): diff --git a/tests/test_DynamoFile.py b/tests/test_DynamoFile.py index 6c78b29..2b61522 100644 --- a/tests/test_DynamoFile.py +++ b/tests/test_DynamoFile.py @@ -2,16 +2,8 @@ import unittest import dyn2py import pathlib -INPUT_DIR = "tests/input_files" -OUTPUT_DIR = "tests/output_files" +from tests.support import * -def cleanup_output_dir(): - output_dir = pathlib.Path(OUTPUT_DIR) - if output_dir.exists(): - for f in output_dir.iterdir(): - f.unlink() - else: - output_dir.mkdir() class TestDynamoFile(unittest.TestCase): @@ -21,7 +13,7 @@ class TestDynamoFile(unittest.TestCase): # update_python_node # write - def test_read(self): + def test_read_and_variables(self): dyn = dyn2py.DynamoFile(f"{INPUT_DIR}/python_nodes.dyn") dyn.read() @@ -38,7 +30,7 @@ class TestDynamoFile(unittest.TestCase): dyn2.read() - def test_get_python_node(self): + def test_get_python_nodes(self): dyn = dyn2py.DynamoFile(f"{INPUT_DIR}/python_nodes.dyn") py_nodes = dyn.get_python_nodes() py_node = dyn.get_python_node_by_id("d7704617c75e4bf1a5c387b7c3f001ea") @@ -46,11 +38,19 @@ class TestDynamoFile(unittest.TestCase): self.assertEqual(len(py_nodes), 6) self.assertTrue(py_node) self.assertTrue(py_node in py_nodes) + self.assertTrue(py_node in dyn.python_nodes) self.assertEqual(py_node.checksum, "1f3d9e6153804fe1ed37571a9cda8e26") with self.assertRaises(dyn2py.PythonNodeNotFoundException): dyn.get_python_node_by_id("wrongid") + dyn2 = dyn2py.DynamoFile(f"{INPUT_DIR}/no_python.dyn") + + # Raise error on file without python nodes: + with self.assertRaises(dyn2py.DynamoFileException): + dyn2.get_python_nodes() + + def test_extract_python(self): cleanup_output_dir() diff --git a/tests/test_File.py b/tests/test_File.py new file mode 100644 index 0000000..6884451 --- /dev/null +++ b/tests/test_File.py @@ -0,0 +1,92 @@ +import unittest +import dyn2py +import pathlib +import platform + +from tests.support import * + + +class TestFile(unittest.TestCase): + + # Methods to test: + # is_dynamo_file + # is_python_file + + def test_init(self): + paths = [ + f"{INPUT_DIR}/python_nodes.dyn", + pathlib.Path(f"{INPUT_DIR}/python_nodes.dyn") + ] + + if platform.system() == "Windows": + paths.extend([ + fr"{INPUT_DIR}\python_nodes.dyn", + pathlib.Path(fr"{INPUT_DIR}\python_nodes.dyn") + ]) + + for path in paths: + the_file = dyn2py.File(path) + + self.assertEqual(the_file.filepath, + pathlib.Path(f"{INPUT_DIR}/python_nodes.dyn")) + self.assertEqual(the_file.basename, "python_nodes") + self.assertEqual(the_file.dirpath, pathlib.Path(INPUT_DIR)) + self.assertEqual(the_file.realpath, pathlib.Path(path).resolve()) + + self.assertEqual(the_file.mtime, 1678287111.4672492) + self.assertEqual(the_file.mtimeiso, "2023-03-08T15:51:51.467249") + self.assertTrue(the_file.exists) + self.assertEqual(the_file.extension, ".dyn") + self.assertFalse(the_file.modified) + + def test_init_newfile(self): + paths = [ + f"{INPUT_DIR}/new_file.dyn", + pathlib.Path(f"{INPUT_DIR}/new_file.dyn") + ] + + if platform.system() == "Windows": + paths.extend([ + fr"{INPUT_DIR}\new_file.dyn", + pathlib.Path(fr"{INPUT_DIR}\new_file.dyn") + ]) + + for path in paths: + the_file = dyn2py.File(path) + + self.assertEqual(the_file.filepath, + pathlib.Path(f"{INPUT_DIR}/new_file.dyn")) + self.assertEqual(the_file.basename, "new_file") + self.assertEqual(the_file.dirpath, pathlib.Path(INPUT_DIR)) + self.assertEqual(the_file.realpath, pathlib.Path(path).resolve()) + + self.assertEqual(the_file.mtime, 0.0) + self.assertEqual(the_file.mtimeiso, "") + self.assertFalse(the_file.exists) + self.assertEqual(the_file.extension, ".dyn") + self.assertFalse(the_file.modified) + + def test_newer(self): + older_file = dyn2py.File(f"{INPUT_DIR}/python_nodes.dyn") + newer_file = dyn2py.File(f"{INPUT_DIR}/no_python.dyn") + nonexisting_file = dyn2py.File(f"{INPUT_DIR}/new_file.dyn") + + self.assertTrue(newer_file.is_newer(older_file)) + self.assertTrue(newer_file.is_newer(nonexisting_file)) + + self.assertFalse(older_file.is_newer(newer_file)) + self.assertTrue(older_file.is_newer(nonexisting_file)) + + self.assertFalse(nonexisting_file.is_newer(older_file)) + self.assertFalse(nonexisting_file.is_newer(newer_file)) + + def test_write(self): + existing_file = dyn2py.File(f"{INPUT_DIR}/python_nodes.dyn") + nonexisting_file = dyn2py.File(f"{INPUT_DIR}/new_file.dyn") + options = dyn2py.Options() + + with self.assertRaises(TypeError): + existing_file.write(options) + + with self.assertRaises(TypeError): + nonexisting_file.write(options) diff --git a/tests/test_PythonFile.py b/tests/test_PythonFile.py new file mode 100644 index 0000000..9e5c13e --- /dev/null +++ b/tests/test_PythonFile.py @@ -0,0 +1,19 @@ +import unittest + +from tests.support import * + +class TestPythonFile(unittest.TestCase): + pass + + # Missing variables: + # code + # header_data + # text + # open_files + + # Missing methods: + # generate_text() + # update_dynamo() + # get_source_dynamo_file() + # read() + # write() diff --git a/tests/test_PythonNode.py b/tests/test_PythonNode.py new file mode 100644 index 0000000..760111b --- /dev/null +++ b/tests/test_PythonNode.py @@ -0,0 +1,63 @@ +import unittest +import dyn2py +import hashlib +from tests.support import * + + +class TestPythonNode(unittest.TestCase): + + def test_init_from_dyn(self): + dyn = dyn2py.DynamoFile(f"{INPUT_DIR}/single_node.dyn") + dyn.read() + node_dict = next((n for n in dyn.full_dict["Nodes"] + if n["NodeType"] == "PythonScriptNode"), {}) + + # Found a node: + self.assertTrue(node_dict) + + node_views = dyn.full_dict["View"]["NodeViews"] + + node = dyn2py.PythonNode( + node_dict_from_dyn=node_dict, + full_nodeviews_dict_from_dyn=node_views, + source_dynamo_file=dyn + ) + + self.assertEqual(node.id, "1c5d99792882409e97e132b3e9f814b0") + self.assertEqual(node.engine, "CPython3") + self.assertEqual(node.checksum, "ec2c85a11ddbf8375da03f11272d427a") + self.assertEqual(node.name, "Python Script") + self.assertEqual( + node.filename, "single_node_1c5d99792882409e97e132b3e9f814b0.py") + + def test_init_from_py(self): + + cleanup_output_dir() + + # Extract py: + options = dyn2py.Options(python_folder=OUTPUT_DIR) + dyn = dyn2py.DynamoFile(f"{INPUT_DIR}/single_node.dyn") + dyn.extract_python(options) + + # Open the extracted file and replace a string: + with open(f"{OUTPUT_DIR}/single_node_1c5d99792882409e97e132b3e9f814b0.py") as orig_py, \ + open(f"{OUTPUT_DIR}/single_node_mod.py", "w") as mod_py: + for line in orig_py: + if "asd_string" in line: + line = line.replace("asd_string", "qwe_string") + mod_py.write(line) + + py = dyn2py.PythonFile(f"{OUTPUT_DIR}/single_node_mod.py") + py.read() + + node = dyn2py.PythonNode( + node_id=py.header_data["py_id"], + engine=py.header_data["py_engine"], + code=py.code, + checksum=hashlib.md5(py.code.encode()).hexdigest() + ) + + self.assertEqual(node.id, "1c5d99792882409e97e132b3e9f814b0") + self.assertEqual(node.engine, "CPython3") + self.assertEqual(node.checksum, "bf0f039ef6f11c3043e0821143801d48") + \ No newline at end of file