diff --git a/src/PythonMigrationViewExtension/MigrationAssistant/PythonMigrationAssistantViewModel.cs b/src/PythonMigrationViewExtension/MigrationAssistant/PythonMigrationAssistantViewModel.cs index dacf3422e06..6d6d051b5e9 100644 --- a/src/PythonMigrationViewExtension/MigrationAssistant/PythonMigrationAssistantViewModel.cs +++ b/src/PythonMigrationViewExtension/MigrationAssistant/PythonMigrationAssistantViewModel.cs @@ -24,6 +24,7 @@ internal class PythonMigrationAssistantViewModel : NotificationObject private readonly Version dynamoVersion; private PythonNode PythonNode; + private readonly Func codeConverter; private IDiffViewViewModel currentViewModel; private SideBySideDiffModel diffModel; @@ -43,7 +44,19 @@ public IDiffViewViewModel CurrentViewModel set { this.currentViewModel = value; RaisePropertyChanged(nameof(this.CurrentViewModel)); } } - public PythonMigrationAssistantViewModel(PythonNode pythonNode, WorkspaceModel workspace, IPathManager pathManager, Version dynamoVersion) + /// + /// Initializes the view model, runs the 2-to-3 code migration, and prepares the diff model. + /// If migration fails the view model is placed in an error state rather than propagating the exception. + /// + /// The Python node whose script will be migrated. + /// The workspace that contains the node. + /// Provides backup and application paths. + /// The running Dynamo version, used to scope disclaimer-dismiss files. + /// + /// Optional override for the migration function. Defaults to . + /// Intended for unit testing only. + /// + public PythonMigrationAssistantViewModel(PythonNode pythonNode, WorkspaceModel workspace, IPathManager pathManager, Version dynamoVersion, Func codeConverter = null) { PythonNode = pythonNode; OldCode = pythonNode.Script; @@ -51,6 +64,7 @@ public PythonMigrationAssistantViewModel(PythonNode pythonNode, WorkspaceModel w this.workspace = workspace; backupDirectory = pathManager.BackupDirectory; this.dynamoVersion = dynamoVersion; + this.codeConverter = codeConverter; try { @@ -58,12 +72,19 @@ public PythonMigrationAssistantViewModel(PythonNode pythonNode, WorkspaceModel w } catch (PythonException) { - var sidebyside = new SideBySideDiffBuilder(); - diffModel = sidebyside.BuildDiffModel(OldCode, OldCode, false); - - SetSideBySideViewModel(); - - CurrentViewModel.DiffState = State.Error; + // Python script error during migration (e.g. syntax the 2to3 tool cannot parse). + SetMigrationErrorState(); + return; + } + catch (Exception ex) when ( + ex is TypeLoadException || + ex is MissingMethodException || + ex is FileLoadException || + ex is BadImageFormatException) + { + // Python.Runtime assembly at runtime is incompatible (e.g. pythonnet 2.x instead of 3.x). + // Types or members such as PyModule may be absent in older assemblies. + SetMigrationErrorState(); return; } SetSideBySideViewModel(); @@ -74,12 +95,21 @@ public PythonMigrationAssistantViewModel(PythonNode pythonNode, WorkspaceModel w private void MigrateCode() { - NewCode = ScriptMigrator.MigrateCode(OldCode); + var converter = codeConverter ?? ScriptMigrator.MigrateCode; + NewCode = converter(OldCode); var sidebyside = new SideBySideDiffBuilder(); diffModel = sidebyside.BuildDiffModel(OldCode, NewCode, false); } + private void SetMigrationErrorState() + { + var sidebyside = new SideBySideDiffBuilder(); + diffModel = sidebyside.BuildDiffModel(OldCode, OldCode, false); + SetSideBySideViewModel(); + CurrentViewModel.DiffState = State.Error; + } + /// /// Replaces the code in the Python node with the code changes made by the Migration Assistant. /// diff --git a/test/Libraries/DynamoPythonTests/PythonMigrationAssistantViewModelTests.cs b/test/Libraries/DynamoPythonTests/PythonMigrationAssistantViewModelTests.cs new file mode 100644 index 00000000000..cf933918119 --- /dev/null +++ b/test/Libraries/DynamoPythonTests/PythonMigrationAssistantViewModelTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using Dynamo; +using Dynamo.Graph.Workspaces; +using Dynamo.PythonMigration.Differ; +using Dynamo.PythonMigration.MigrationAssistant; +using NUnit.Framework; +using PythonNodeModels; + +namespace DynamoPythonTests +{ + [TestFixture] + public class PythonMigrationAssistantViewModelTests : DynamoModelTestBase + { + /// + /// Regression test: when Python.Runtime is an incompatible version (e.g. pythonnet 2.x loaded + /// instead of 3.x), Py.CreateScope() throws TypeLoadException because PyModule does not exist + /// in that assembly. The ViewModel must catch this and show an error state rather than crashing. + /// + [Test] + public void WhenMigrationThrowsTypeLoadExceptionViewModelShowsErrorState() + { + // Arrange + var pyNode = new PythonNode(); + var workspace = CurrentDynamoModel.CurrentWorkspace as WorkspaceModel; + var pathManager = CurrentDynamoModel.PathManager; + + Func brokenMigrator = _ => + throw new TypeLoadException("Could not load type 'Python.Runtime.PyModule' from assembly 'Python.Runtime, Version=2.5.2.12086'"); + + // Act + PythonMigrationAssistantViewModel viewModel = null; + Assert.DoesNotThrow(() => + { + viewModel = new PythonMigrationAssistantViewModel( + pyNode, workspace, pathManager, new Version(3, 0), brokenMigrator); + }); + + // Assert + Assert.IsNotNull(viewModel); + Assert.AreEqual(State.Error, viewModel.CurrentViewModel.DiffState); + } + + /// + /// Verifies that a MissingMethodException (e.g. missing member in incompatible Python.Runtime) + /// during migration is handled gracefully and shows an error state. + /// + [Test] + public void WhenMigrationThrowsMissingMethodExceptionViewModelShowsErrorState() + { + var pyNode = new PythonNode(); + var workspace = CurrentDynamoModel.CurrentWorkspace as WorkspaceModel; + var pathManager = CurrentDynamoModel.PathManager; + + Func brokenMigrator = _ => + throw new MissingMethodException("Python.Runtime.Py", "CreateScope"); + + PythonMigrationAssistantViewModel viewModel = null; + Assert.DoesNotThrow(() => + { + viewModel = new PythonMigrationAssistantViewModel( + pyNode, workspace, pathManager, new Version(3, 0), brokenMigrator); + }); + + Assert.IsNotNull(viewModel); + Assert.AreEqual(State.Error, viewModel.CurrentViewModel.DiffState); + } + + /// + /// Verifies that a FileLoadException (e.g. assembly version conflict) during migration + /// is handled gracefully and shows an error state. + /// + [Test] + public void WhenMigrationThrowsFileLoadExceptionViewModelShowsErrorState() + { + var pyNode = new PythonNode(); + var workspace = CurrentDynamoModel.CurrentWorkspace as WorkspaceModel; + var pathManager = CurrentDynamoModel.PathManager; + + Func brokenMigrator = _ => + throw new FileLoadException("Could not load file or assembly 'Python.Runtime'"); + + PythonMigrationAssistantViewModel viewModel = null; + Assert.DoesNotThrow(() => + { + viewModel = new PythonMigrationAssistantViewModel( + pyNode, workspace, pathManager, new Version(3, 0), brokenMigrator); + }); + + Assert.IsNotNull(viewModel); + Assert.AreEqual(State.Error, viewModel.CurrentViewModel.DiffState); + } + + /// + /// Verifies that a BadImageFormatException (e.g. 32/64-bit or .NET target mismatch in + /// Python.Runtime) during migration is handled gracefully and shows an error state. + /// + [Test] + public void WhenMigrationThrowsBadImageFormatExceptionViewModelShowsErrorState() + { + var pyNode = new PythonNode(); + var workspace = CurrentDynamoModel.CurrentWorkspace as WorkspaceModel; + var pathManager = CurrentDynamoModel.PathManager; + + Func brokenMigrator = _ => + throw new BadImageFormatException("The format of the file 'Python.Runtime' is invalid."); + + PythonMigrationAssistantViewModel viewModel = null; + Assert.DoesNotThrow(() => + { + viewModel = new PythonMigrationAssistantViewModel( + pyNode, workspace, pathManager, new Version(3, 0), brokenMigrator); + }); + + Assert.IsNotNull(viewModel); + Assert.AreEqual(State.Error, viewModel.CurrentViewModel.DiffState); + } + } +}