From 16f0811ef365a9cee1f630e99204fbb5cb0d612f Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Fri, 7 Jun 2024 20:41:20 +0300 Subject: [PATCH] Initial commit --- .gitignore | 16 + VBAMake.txt | 40 ++ script/Build.ps1 | 41 ++ script/DeployServer.ps1 | 12 + script/PythonManager.txt | 68 +++ src/manager/Declarations.bas | 4 + src/manager/DevHelper.bas | 30 ++ src/manager/Main.bas | 78 +++ src/manager/MainImpl.bas | 21 + src/manager/z_UIMessages.bas | 50 ++ src/manager/z_UIRibbon.bas | 10 + src/test/data/testFuncs.py | 38 ++ src/test/data/testWrap.bas | 34 ++ src/test/data/testWrap.py | 14 + src/test/s_Python.cls | 305 ++++++++++++ src/vbatopy/.pylintrc | 635 +++++++++++++++++++++++++ src/vbatopy/LICENSE | 21 + src/vbatopy/README.md | 1 + src/vbatopy/VERSION | 1 + src/vbatopy/mypy.ini | 7 + src/vbatopy/pyproject.toml | 3 + src/vbatopy/requirements-build.txt | 4 + src/vbatopy/requirements.txt | 1 + src/vbatopy/setup.cfg | 16 + src/vbatopy/setup.py | 4 + src/vbatopy/vbatopy/__init__.py | 7 + src/vbatopy/vbatopy/comwrappers.py | 58 +++ src/vbatopy/vbatopy/decorators.py | 95 ++++ src/vbatopy/vbatopy/python_api.py | 131 +++++ src/vbatopy/vbatopy/server.py | 66 +++ src/vbatopy/vbatopy/writer.py | 22 + src/vbatopydll/.clang-tidy | 43 ++ src/vbatopydll/header/server.h | 42 ++ src/vbatopydll/header/utils.h | 32 ++ src/vbatopydll/include/vbatopy.h | 18 + src/vbatopydll/pch.h | 12 + src/vbatopydll/src/pch.cpp | 1 + src/vbatopydll/src/server.cpp | 151 ++++++ src/vbatopydll/src/utils.cpp | 59 +++ src/vbatopydll/src/vbatopy.cpp | 95 ++++ src/vbatopydll/vbatopy.def | 7 + src/vbatopydll/vbatopy.sln | 28 ++ src/vbatopydll/vbatopy.vcxproj | 209 ++++++++ src/vbatopydll/vbatopy.vcxproj.filters | 37 ++ test/s_Python.cls | 305 ++++++++++++ ui/.rels | 2 + ui/customUI.xml | 21 + 47 files changed, 2895 insertions(+) create mode 100644 .gitignore create mode 100644 VBAMake.txt create mode 100644 script/Build.ps1 create mode 100644 script/DeployServer.ps1 create mode 100644 script/PythonManager.txt create mode 100644 src/manager/Declarations.bas create mode 100644 src/manager/DevHelper.bas create mode 100644 src/manager/Main.bas create mode 100644 src/manager/MainImpl.bas create mode 100644 src/manager/z_UIMessages.bas create mode 100644 src/manager/z_UIRibbon.bas create mode 100644 src/test/data/testFuncs.py create mode 100644 src/test/data/testWrap.bas create mode 100644 src/test/data/testWrap.py create mode 100644 src/test/s_Python.cls create mode 100644 src/vbatopy/.pylintrc create mode 100644 src/vbatopy/LICENSE create mode 100644 src/vbatopy/README.md create mode 100644 src/vbatopy/VERSION create mode 100644 src/vbatopy/mypy.ini create mode 100644 src/vbatopy/pyproject.toml create mode 100644 src/vbatopy/requirements-build.txt create mode 100644 src/vbatopy/requirements.txt create mode 100644 src/vbatopy/setup.cfg create mode 100644 src/vbatopy/setup.py create mode 100644 src/vbatopy/vbatopy/__init__.py create mode 100644 src/vbatopy/vbatopy/comwrappers.py create mode 100644 src/vbatopy/vbatopy/decorators.py create mode 100644 src/vbatopy/vbatopy/python_api.py create mode 100644 src/vbatopy/vbatopy/server.py create mode 100644 src/vbatopy/vbatopy/writer.py create mode 100644 src/vbatopydll/.clang-tidy create mode 100644 src/vbatopydll/header/server.h create mode 100644 src/vbatopydll/header/utils.h create mode 100644 src/vbatopydll/include/vbatopy.h create mode 100644 src/vbatopydll/pch.h create mode 100644 src/vbatopydll/src/pch.cpp create mode 100644 src/vbatopydll/src/server.cpp create mode 100644 src/vbatopydll/src/utils.cpp create mode 100644 src/vbatopydll/src/vbatopy.cpp create mode 100644 src/vbatopydll/vbatopy.def create mode 100644 src/vbatopydll/vbatopy.sln create mode 100644 src/vbatopydll/vbatopy.vcxproj create mode 100644 src/vbatopydll/vbatopy.vcxproj.filters create mode 100644 test/s_Python.cls create mode 100644 ui/.rels create mode 100644 ui/customUI.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70d6e1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.vscode +.vs +*.user +*.aps +~* +*.pch.tmp +*.clang.pch +*.dll +__pycache__ + +build/ +whl/ +venv/ +output/ + +*egg-info \ No newline at end of file diff --git a/VBAMake.txt b/VBAMake.txt new file mode 100644 index 0000000..b11ef31 --- /dev/null +++ b/VBAMake.txt @@ -0,0 +1,40 @@ +# == Properties Section == +# configuration properties +# use .ini format to define properties +# mandatory properties: name, artifact_home, source_home + +id = PyRunner +name = PyRunner +description = Инструменты взаимодействия с Python +artifact_home = PyRunner +source_home = PyRunner +install_home = \\fs1.concept.ru\projects\10 Автоматизация деятельности\02 Офисная автоматизация\80 PyRunner + +%% +# === Build section === +# Available commands: +# build LOCAL_MANIFEST +# copy LOCAL_SOURCE -> [LOCAL_ARTIFACT] +# save_as LOCAL_ARTIFACT -> LOCAL_ARTIFACT +# run LOCAL_SOURCE.bat + +copy script\DeployServer.ps1 -> script\DeployServer.ps1 +copy src\test\data -> test +run script\Build.ps1 +run script\DeployLocal.ps1 + +copy src\vbatopydll\dll -> src\vbatopydll\dll +copy src\vbatopy -> src\vbatopy + +build script\PythonManager.txt + +%% +# === Install section == +# Available commands: +# install LOCAL_ARTIFACT -> [INSTALL_PATH] +# add_template LOCAL_ARTIFACT -> [LOCAL_TEMPLATE] +# run LOCAL_ARTIFACT.bat <- [PARAMETERS] +# run APPLICATION <- [PARAMETERS] + +run script\DeployServer.ps1 +install PythonManager.xlsm \ No newline at end of file diff --git a/script/Build.ps1 b/script/Build.ps1 new file mode 100644 index 0000000..d6be3db --- /dev/null +++ b/script/Build.ps1 @@ -0,0 +1,41 @@ +# ====== Build Python ========== +Set-Location $PSScriptRoot\..\src\vbatopy +Write-Information 'Prepare Python environemnt...' + +$packageName = 'vbatopy' +$output = "$PSScriptRoot\..\output" +$python = '.\venv\Scripts\python.exe' +if (-not (Test-Path -Path $python -PathType Leaf)) { + & 'python' -m venv .\venv + & $python -m pip install -r requirements.txt + & $python -m pip install -r requirements-build.txt +} + +if (Test-Path -Path $output\$packageName) { + Remove-Item $output\$packageName -Recurse -Force +} + +& $python -m build --outdir=$output\$packageName + +# ======= Build CPP =========== +Set-Location $PSScriptRoot\..\src\vbatopydll +Write-Information 'Building cpp libraries...' + +$solution = 'vbatopy.sln' +# TODO: find MSBuild instead - Implement in ConceptDeploy +$msbuild32 = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Msbuild\Current\Bin\amd64\MSBuild.exe' +$msbuild64 = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Msbuild\Current\Bin\MSBuild.exe' +if (-not (Test-Path -Path $msbuild32 -PathType Leaf) -or -not (Test-Path -Path $msbuild64 -PathType Leaf)) { + Write-Error "Missing MSBuild, please correct MSBuild path in $MyInvocation.MyCommand.Name" + Exit 1 +} + +& $msbuild32 @($solution, '/target:Clean', '/target:Build', '/property:Configuration=Release', '/property:Platform=Win32') | Write-Verbose +if ($LASTEXITCODE -ne 0) { + Write-Error 'Failed to build x86 dll...' +} + +& $msbuild64 @($solution, '/target:Clean', '/target:Build', '/property:Configuration=Release', '/property:Platform=x64') | Write-Verbose +if ($LASTEXITCODE -ne 0) { + Write-Error 'Failed to build x64 dll...' +} \ No newline at end of file diff --git a/script/DeployServer.ps1 b/script/DeployServer.ps1 new file mode 100644 index 0000000..d4d6d79 --- /dev/null +++ b/script/DeployServer.ps1 @@ -0,0 +1,12 @@ +# # Deploy VBAtoPY package to server storage and distribution +Set-Variable -Name PACKAGE_NAME -Value 'vbatopy' -Option Constant +Set-Variable -Name PROJECT_ROOT -Value "$PSScriptRoot\.." -Option Constant + +Import-Module -Name "ConceptDeploy" -Force + +# ======= Distribute Python ========== +Push-PythonPackage -PackageName $PACKAGE_NAME -PackageRoot $PROJECT_ROOT + +# ======= Distribute Dll ========== +Push-DLL -dllName 'vbatopy-connector32' -dllPath "$PROJECT_ROOT\output\dll" +Push-DLL -dllName 'vbatopy-connector64' -dllPath "$PROJECT_ROOT\output\dll" \ No newline at end of file diff --git a/script/PythonManager.txt b/script/PythonManager.txt new file mode 100644 index 0000000..6714707 --- /dev/null +++ b/script/PythonManager.txt @@ -0,0 +1,68 @@ +# == Properties Section == +# configuration properties +# use .ini format to define properties +# mandatory properties: name, artifact + +name = PythonManager.xlsm +artifact = PythonManager.xlsm + +%% +# === Imports Section === +# Hierarchy of folders and files +# Use Tabulator to mark next level in hierarchy +# All folders are nested into SharedHome path + +api + API_Python.cls + API_UserInteraction.cls + + ex_Python.bas + ex_WinAPI.bas + +utility + API_Config.cls + API_JSON.cls + API_Timer.cls + ex_VBA.bas + +dev + DevTester.bas + +%% +# === Source Code Section == +# Hierarchy of folders and files +# Use Tabulator to mark next level in hierarchy +# All folders are nested into SourceHome path + +src + manager + DevHelper.bas + Declarations.bas + Main.bas + MainImpl.bas + z_UIRibbon.bas + z_UIMessages.bas + +test + s_Python.cls + + +%% +# ===== UI Section ======= +# Pairs of path to UI elements, use " -> " delimiter +# First component is a path relative to SourceHome\ui folders +# Second component is internal path inside project file + +.rels -> _rels\.rels +customUI.xml -> customUI\customUI.xml + +%% +# === References Section === +# List dependencies in one of the formats +# global : GLOBAL_NAME +# guid : {REGISTERED_GUID} +# file : PATH_TO_LIBRARY + +global : Scripting +global : ADODB +global : Shell32 \ No newline at end of file diff --git a/src/manager/Declarations.bas b/src/manager/Declarations.bas new file mode 100644 index 0000000..c8583e3 --- /dev/null +++ b/src/manager/Declarations.bas @@ -0,0 +1,4 @@ +Attribute VB_Name = "Declarations" +Option Private Module +Option Explicit + diff --git a/src/manager/DevHelper.bas b/src/manager/DevHelper.bas new file mode 100644 index 0000000..3cc4131 --- /dev/null +++ b/src/manager/DevHelper.bas @@ -0,0 +1,30 @@ +Attribute VB_Name = "DevHelper" +'Option Private Module +Option Explicit + +Public Function Dev_PrepareSkeleton() + ' Do nothing +End Function + +Public Sub Dev_ManualRunTest() + Dim sSuite$: sSuite = "s_Python" + Dim sTest$: sTest = "t_StartServer" + Dim sMsg$: sMsg = Dev_RunTestDebug(sSuite, sTest) + Debug.Print sMsg + Call MsgBox(sMsg) +End Sub + +Public Function Dev_GetTestSuite(sName$) As Object + Select Case sName + Case "s_Python": Set Dev_GetTestSuite = New s_Python + ' Case "s_ParseDate": Set Dev_GetTestSuite = New s_ParseDate + End Select +End Function + +Public Function Dev_GetTestFolder() As String + Static sFolder$ + If sFolder = vbNullString Then + sFolder = ThisWorkbook.Path & "\test" + End If + Dev_GetTestFolder = sFolder +End Function diff --git a/src/manager/Main.bas b/src/manager/Main.bas new file mode 100644 index 0000000..6830d16 --- /dev/null +++ b/src/manager/Main.bas @@ -0,0 +1,78 @@ +Attribute VB_Name = "Main" +Option Explicit + +Public Sub RunGlobalConfig() + Dim fso As New Scripting.FileSystemObject + Dim sConfig$: sConfig = ConceptConfigPath + If Not fso.FileExists(sConfig) Then + Call UserInteraction.ShowMessage(EM_MISSING_CONFIG, sConfig) + Exit Sub + End If + + Dim oShell As New Shell32.Shell + Call oShell.Open(sConfig) +End Sub + +Public Sub RunGenerateWrapper() + Dim sSource$: sSource = UserInteraction.PromptFileFilter(ThisWorkbook.Path & "\", " Python", "*.py", " ") + If sSource = vbNullString Then _ + Exit Sub + + Dim fso As New Scripting.FileSystemObject + Dim sDestination$: sDestination = ThisWorkbook.Path & "\" & fso.GetBaseName(sSource) & ".bas" + + Dim sResult$: sResult = CreatePythonWrapper(sSource, sDestination) + If sResult <> vbNullString Then _ + Call UserInteraction.ShowMessage(IM_WRAPPER_OK, sResult) +End Sub + +' ======== +Sub BasicTest() + Dim iPython As API_Python: Set iPython = AccessPython +' Call iPython.Py.Exec("print(1)") + Debug.Print Py_double_sum(1, 2) + 'Call iPython.UnloadDLL + ' Call iPython.KillServer + ' Call iPython.ResetCache +' Dim a +' a = iPython.Py.Bool(True) +' Call iPython.KillServer + ' Debug.Print Py_hello("World") + ' Call AccessPython.KillServer +' Call iPython.Py.ImportModule("test", 2) + ' Call AccessPython.ResetCache +End Sub + +Public Function Py_hello(pyArg_name) + Dim iPython As API_Python: Set iPython = AccessPython + On Error GoTo FAILED_CALL + Py_hello = iPython.CallFunction("standalone", "hello", Array(pyArg_name)) + On Error GoTo 0 + Exit Function +FAILED_CALL: + Py_hello = Err.Description +End Function + +Public Function Py_double_sum(pyArg_x, pyArg_y) + Dim iPython As API_Python: Set iPython = AccessPython + On Error GoTo FAILED_CALL + Py_double_sum = iPython.CallFunction("standalone", "double_sum", Array(pyArg_x, pyArg_y)) + On Error GoTo 0 + Exit Function +FAILED_CALL: + Py_double_sum = Err.Description +End Function + +Sub TestPythonCall() + Dim theTimer As New API_Timer + Call Py_double_sum(1, 2) + + Call theTimer.Start + Dim n& + Dim nTest& + For n = 1 To 1000 Step 1 + nTest = Py_double_sum(n, n + 1) + Next n + + Debug.Print "Python test: " & theTimer.TimeStr +End Sub diff --git a/src/manager/MainImpl.bas b/src/manager/MainImpl.bas new file mode 100644 index 0000000..69a2b32 --- /dev/null +++ b/src/manager/MainImpl.bas @@ -0,0 +1,21 @@ +Attribute VB_Name = "MainImpl" +Option Private Module +Option Explicit + +Public Function CreatePythonWrapper(sSourceModule$, sDestination$) As String + Dim iPython As API_Python: Set iPython = AccessPython + + On Error GoTo PROCESS_ERROR + CreatePythonWrapper = iPython.WrapPython(sSourceModule, sDestination) + On Error GoTo 0 + + Call iPython.KillServer + Exit Function + +PROCESS_ERROR: + Debug.Print Err.Description + Call MsgBox(Err.Description & " " & Err.Number, vbCritical, "Error") + Call iPython.KillServer + On Error GoTo 0 +End Function + diff --git a/src/manager/z_UIMessages.bas b/src/manager/z_UIMessages.bas new file mode 100644 index 0000000..312d2a1 --- /dev/null +++ b/src/manager/z_UIMessages.bas @@ -0,0 +1,50 @@ +Attribute VB_Name = "z_UIMessages" +' Messaging module +Option Private Module +Option Explicit + +Public Enum MsgCode + EM_MISSING_CONFIG + + IM_WRAPPER_OK +End Enum + +Private g_UI As API_UserInteraction + +Public Function UserInteraction() As API_UserInteraction + If g_UI Is Nothing Then _ + Set g_UI = New API_UserInteraction + Set UserInteraction = g_UI +End Function + +Public Function SetUserInteraction(newUI As API_UserInteraction) + Set g_UI = newUI +End Function + +Public Function UIShowMessage(theCode As MsgCode, ParamArray params() As Variant) + Dim unwrapped As Variant: unwrapped = params + unwrapped = FixForwardedParams(unwrapped) + + Select Case theCode + Case IM_WRAPPER_OK: Call MsgBox(Fmt(" VBA API: {1}", unwrapped), vbInformation) + + Case EM_MISSING_CONFIG: Call MsgBox(Fmt(" : {1}", unwrapped), vbExclamation) + + Case Else: Call MsgBox(" ", vbCritical) + End Select +End Function + +Public Function UIAskQuestion(theCode As MsgCode, ParamArray params() As Variant) As Boolean + Dim unwrapped As Variant: unwrapped = params + unwrapped = FixForwardedParams(unwrapped) + + Dim answer&: answer = vbNo + Select Case theCode + 'Case QM_CODE_DELETE_CONFIRM + ' answer = MsgBox("Are you sure you want to delete ALL macros from target file?", vbYesNo + vbQuestion) + + Case Else + Call MsgBox(" ", vbCritical) + End Select + UIAskQuestion = answer = vbYes +End Function diff --git a/src/manager/z_UIRibbon.bas b/src/manager/z_UIRibbon.bas new file mode 100644 index 0000000..a96c0a9 --- /dev/null +++ b/src/manager/z_UIRibbon.bas @@ -0,0 +1,10 @@ +Attribute VB_Name = "z_UIRibbon" +Option Explicit +Option Private Module + +Public Sub OnRibbonBtn(iControl As IRibbonControl) + Select Case iControl.ID + Case "GlobalConfig": Call RunGlobalConfig + Case "GenerateWrapper": Call RunGenerateWrapper + End Select +End Sub diff --git a/src/test/data/testFuncs.py b/src/test/data/testFuncs.py new file mode 100644 index 0000000..a0596bd --- /dev/null +++ b/src/test/data/testFuncs.py @@ -0,0 +1,38 @@ +def no_return(): + pass + +def get_42(): + return 42 + +def hello(name): + return f"Hello {name}!" + +def double_sum(x, y): + return 2 * (x + y) + +def sum_array(x): + return sum(x) + +def optional_arg(x=42): + return x + +def return_tuple(): + return (42, 'test') + +def return_list(): + return [42, 'test', 11] + +def return_2dlist(): + return [[1, 2, 42], ['test', 13], [11]] + +def return_dict(): + return {'a':42, 'b':'test', 'c':11} + +def process_dict(arg): + return len(arg) + +def extract_range_text(worksheet): + return worksheet.Cells(1,1).Text + +def return_range_object(worksheet): + return worksheet.Cells(1,1) \ No newline at end of file diff --git a/src/test/data/testWrap.bas b/src/test/data/testWrap.bas new file mode 100644 index 0000000..80d882d --- /dev/null +++ b/src/test/data/testWrap.bas @@ -0,0 +1,34 @@ +Attribute VB_Name = "testWrap" +Option Private Module +Option Explicit +'Generated code by python_api - changes will be lost with next generation! + +Public Sub Py_get_42() + Dim iPython as API_Python: Set iPython = AccessPython + On Error GoTo FAILED_CALL + Py_get_42 = iPython.CallFunction("testWrap", "get_42", Array()) + On Error GoTo 0 + Exit Sub +FAILED_CALL: + Py_get_42 = Err.Description +End Sub + +Public Function Py_get_a_cell(pyArg_worksheet) + Dim iPython as API_Python: Set iPython = AccessPython + On Error GoTo FAILED_CALL + Set Py_get_a_cell = iPython.CallFunctionReturnObject("testWrap", "get_a_cell", Array(pyArg_worksheet)) + On Error GoTo 0 + Exit Function +FAILED_CALL: + Py_get_a_cell = Err.Description +End Function + +Public Function Py_hello(pyArg_name) + Dim iPython as API_Python: Set iPython = AccessPython + On Error GoTo FAILED_CALL + Py_hello = iPython.CallFunction("testWrap", "hello", Array(pyArg_name)) + On Error GoTo 0 + Exit Function +FAILED_CALL: + Py_hello = Err.Description +End Function diff --git a/src/test/data/testWrap.py b/src/test/data/testWrap.py new file mode 100644 index 0000000..d2c95f5 --- /dev/null +++ b/src/test/data/testWrap.py @@ -0,0 +1,14 @@ +import vbatopy as vba + +@vba.func +def hello(name): + return f"Hello {name}!" + +@vba.sub +def get_42(): + return 42 + +@vba.obj +@vba.func +def get_a_cell(worksheet): + return worksheet.Cells(1,1) \ No newline at end of file diff --git a/src/test/s_Python.cls b/src/test/s_Python.cls new file mode 100644 index 0000000..c336519 --- /dev/null +++ b/src/test/s_Python.cls @@ -0,0 +1,305 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "s_Python" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = False +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +Option Explicit + +' TODO: +' Public Function RunPyStandalone(sPyCommand$, Optional sWorkDir$ = "") As Long +' Public Function RunPyFrozen(iPython$, Optional sArgs$ = "") As Long + +' Public Function API_Python.Execute(sPyCommand$) As Boolean +' Public Function API_Python.Evaluate(sPyStatement$) As Variant + +#If Win64 Then + Private Declare PtrSafe Function GetServer Lib "vbatopy-connector64.dll" (ByRef vResult As Variant) As Long + Private Declare PtrSafe Function KillPythonServer Lib "vbatopy-connector64.dll" Alias "KillServer" () As Long +#Else + Private Declare PtrSafe Function GetServer Lib "vbatopy-connector32.dll" (ByRef vResult As Variant) As Long + Private Declare PtrSafe Function KillPythonServer Lib "vbatopy-connector32.dll" Alias "KillServer" () As Long +#End If + +Dim python_ As API_Python + +Private Const TEST_FUNCS_FILE = "testFuncs.py" +Private Const TEST_WRAP_SOURCE = "testWrap.py" +Private Const TEST_WRAP_RESULT = "testWrap.bas" + +Public Function Setup() + ' Mandatory setup function + Set python_ = New API_Python + Call python_.Init(iPython:="python", sModules:=ConceptSourcePath, bDoDebug:=False) + Call python_.LoadDLL +End Function + +Public Function Teardown() + ' Mandatory teardown function + Call python_.KillServer + Set python_ = Nothing +End Function + +Public Function t_StartServer() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Invalid python") + Dim iTest As New API_Python + Call iTest.Init("invalid_python", ConceptSourcePath) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Missing server module") + Call iTest.Init("python", "", bDoDebug:=False) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Too long command") + Call iTest.Init("python", String$(10000, "A")) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Start valid") + Dim iServer As Variant: Call GetServer(iServer) + Call Dev_ExpectTrue(iServer Is Nothing, "Not started") + Call Dev_ExpectFalse(python_.Validate, "Not started") + Call python_.StartServer + Call GetServer(iServer) + Call Dev_ExpectTrue(python_.Validate, "After start") + Call Dev_ExpectFalse(iServer Is Nothing, "After start") + On Error Resume Next + Call python_.StartServer + Call Dev_ExpectNoError("Double start") + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Double python object") + Call iTest.Init("python", ConceptSourcePath) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_KillServer() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Not started") + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Valid kill") + Call python_.StartServer + Call python_.KillServer + Dim iServer As Variant: Call GetServer(iServer) + Call Dev_ExpectTrue(iServer Is Nothing) + Call Dev_ExpectFalse(python_.Validate) + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError("Dobule kill") + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Hijack and kill first") + Call python_.StartServer + Call KillPythonServer + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_RunPython() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Not started") + Call Dev_ExpectFalse(python_.Validate) + Call Dev_ExpectTrue(python_.Execute("print(1)")) + Call Dev_ExpectTrue(python_.Validate) + + Call Dev_NewCase("Already started") + Call Dev_ExpectTrue(python_.Execute("print(2)")) + + Call Dev_NewCase("Invalid code") + Call Dev_ExpectFalse(python_.Execute("invalid_func_call(42, '42')")) + Call Dev_ExpectFalse(python_.Execute("do_something_illegal42")) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_LoadModule() + On Error GoTo PROPAGATE_ERROR + + Dim sModule$: sModule = Dev_GetTestFolder & "\" & TEST_FUNCS_FILE + Call python_.StartServer + + Call Dev_ExpectFalse(python_.LoadModule(""), "Empty input") + + Call Dev_NewCase("Missing file") + Call Dev_ExpectFalse(python_.LoadModule(Dev_GetTestFolder & "\missingFile.py")) + + Call Dev_NewCase("Valid load") + Call Dev_ExpectTrue(python_.LoadModule(sModule)) + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world"))) + + Call Dev_NewCase("Double load") + Call Dev_ExpectTrue(python_.LoadModule(sModule)) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionInvalid() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("Invalid module") + Call Dev_ExpectLike(python_.CallFunction("invalid_module", "get_42"), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("", "get_42"), "Unexpected Python Error*") + + Call Dev_NewCase("Invalid function") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "invalid_function_name42"), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", ""), "Unexpected Python Error*") + + Call Dev_NewCase("Invalid arguments") + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world")), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello"), "Unexpected Python Error*", "Missing argument") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", "world"), "Unexpected Python Error*", "Invalid argument type - string") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", ThisWorkbook), "Unexpected Python Error*", "Invalid argument type - object") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", Array("world", "big")), "Unexpected Python Error*", "Invalid argument count") + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionBasic() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("No return") + Call Dev_ExpectTrue(IsNull(python_.CallFunction("testFuncs", "no_return"))) + + Call Dev_NewCase("No arguments") + Call Dev_ExpectEQ(42, python_.CallFunction("testFuncs", "get_42")) + + Call Dev_NewCase("Strings") + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world"))) + Call Dev_ExpectEQ("Hello 42!", python_.CallFunction("testFuncs", "hello", Array(42))) + + Call Dev_NewCase("Multiple args") + Call Dev_ExpectEQ((13 + 5) * 2, python_.CallFunction("testFuncs", "double_sum", Array(13, 5))) + + Call Dev_NewCase("Array arg") + Call Dev_ExpectEQ(42 + 1337 + 1, python_.CallFunction("testFuncs", "sum_array", Array(Array(42, 1337, 1)))) + + Call Dev_NewCase("Dictionary argument") + Call Dev_ExpectEQ(3, python_.CallFunction("testFuncs", "process_dict", Array(CSet(1, 2, 43)))) + + Call Dev_NewCase("Optional arg") + Call Dev_ExpectEQ(42, python_.CallFunction("testFuncs", "optional_arg")) + Call Dev_ExpectEQ(1337, python_.CallFunction("testFuncs", "optional_arg", Array(1337))) + + Call Dev_NewCase("Multiple return") + Call Dev_ExpectEQ(Array(42, "test"), python_.CallFunction("testFuncs", "return_tuple")) + + Call Dev_NewCase("Return list") + Call Dev_ExpectEQ(Array(42, "test", 11), python_.CallFunction("testFuncs", "return_list")) + + Call Dev_NewCase("Return 2dlist") + Call Dev_ExpectEQ(Array(Array(1, 2, 42), Array("test", 13), Array(11)), python_.CallFunction("testFuncs", "return_2dlist")) + + Call Dev_NewCase("Return dict") + Dim iTest As New Scripting.Dictionary + iTest("a") = 42 + iTest("b") = "test" + iTest("c") = 11 + Call Dev_ExpectEQ(iTest, python_.CallFunctionReturnObject("testFuncs", "return_dict")) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionObjects() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("Access VBA COM from Python") + Dim iCell As Excel.Range: Set iCell = ThisWorkbook.Sheets(1).Cells(1, 1) + iCell = "test" + Call Dev_ExpectEQ("test", python_.CallFunction("testFuncs", "extract_range_text", Array(ThisWorkbook.Sheets(1)))) + iCell = "" + + Call Dev_NewCase("Return VBA object from Python") + Dim iResult As Object + Set iResult = python_.CallFunctionReturnObject("testFuncs", "return_range_object", Array(ThisWorkbook.Sheets(1))) + iResult = "test" + Call Dev_ExpectEQ("test", iCell.Text) + iCell = "" + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_WrapPython() + On Error GoTo PROPAGATE_ERROR + + Dim fso As New Scripting.FileSystemObject + Dim sSource$: sSource = Dev_GetTestFolder & "\" & TEST_WRAP_SOURCE + Dim sDestination$: sDestination = ThisWorkbook.Path & "\" & TEST_WRAP_RESULT + Dim sTest$: sTest = Dev_GetTestFolder & "\" & TEST_WRAP_RESULT + Call python_.StartServer + Call Dev_AssertEQ(sDestination, python_.WrapPython(sSource, sDestination)) + Call Dev_ExpectEQ(ReadFile(sTest), ReadFile(sDestination)) + + Call fso.DeleteFile(sDestination) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +' ===== +Private Function ReadFile(sFile$) As String + Dim adoStream As New ADODB.Stream + adoStream.Charset = "utf-8" + Call adoStream.Open + + On Error GoTo ERROR_FILE + Call adoStream.LoadFromFile(sFile) + On Error GoTo 0 + + ReadFile = adoStream.ReadText + +ERROR_FILE: + Call adoStream.Close + On Error GoTo 0 +End Function diff --git a/src/vbatopy/.pylintrc b/src/vbatopy/.pylintrc new file mode 100644 index 0000000..831045e --- /dev/null +++ b/src/vbatopy/.pylintrc @@ -0,0 +1,635 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list=pyconcept + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths=t_.*,.*migrations.* + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=t_.*?py + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.9 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=too-many-public-methods, + invalid-name, + no-else-break, + no-else-continue, + no-else-return, + no-member, + too-many-ancestors, + too-many-return-statements, + too-many-locals, + too-many-instance-attributes, + too-few-public-methods, + unused-argument, + missing-function-docstring, + attribute-defined-outside-init, + ungrouped-imports, + abstract-method + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request +max-line-length=120 + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/src/vbatopy/LICENSE b/src/vbatopy/LICENSE new file mode 100644 index 0000000..4db4709 --- /dev/null +++ b/src/vbatopy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 CIHT CONCEPT, IRBorisov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/vbatopy/README.md b/src/vbatopy/README.md new file mode 100644 index 0000000..10bc995 --- /dev/null +++ b/src/vbatopy/README.md @@ -0,0 +1 @@ +Long description goes here \ No newline at end of file diff --git a/src/vbatopy/VERSION b/src/vbatopy/VERSION new file mode 100644 index 0000000..867e524 --- /dev/null +++ b/src/vbatopy/VERSION @@ -0,0 +1 @@ +1.2.0 \ No newline at end of file diff --git a/src/vbatopy/mypy.ini b/src/vbatopy/mypy.ini new file mode 100644 index 0000000..f6ab397 --- /dev/null +++ b/src/vbatopy/mypy.ini @@ -0,0 +1,7 @@ +# Global options: + +[mypy] +warn_return_any = True +warn_unused_configs = True + +# Per-module options: diff --git a/src/vbatopy/pyproject.toml b/src/vbatopy/pyproject.toml new file mode 100644 index 0000000..07de284 --- /dev/null +++ b/src/vbatopy/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/src/vbatopy/requirements-build.txt b/src/vbatopy/requirements-build.txt new file mode 100644 index 0000000..4aeb37e --- /dev/null +++ b/src/vbatopy/requirements-build.txt @@ -0,0 +1,4 @@ +wheel +pylint +mypy +build diff --git a/src/vbatopy/requirements.txt b/src/vbatopy/requirements.txt new file mode 100644 index 0000000..ba2e8ea --- /dev/null +++ b/src/vbatopy/requirements.txt @@ -0,0 +1 @@ +pywin32>=304 \ No newline at end of file diff --git a/src/vbatopy/setup.cfg b/src/vbatopy/setup.cfg new file mode 100644 index 0000000..d33f2f2 --- /dev/null +++ b/src/vbatopy/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = vbatopy +version = file: VERSION +author = CIHT CONCEPT, IRBorisov +author_email = iborisov@acconcept.ru +description = Python launcher for VBA +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT +classifiers = + Programming Language :: Python :: 3 + +[options] +packages = find: +install_requires = + pywin32 \ No newline at end of file diff --git a/src/vbatopy/setup.py b/src/vbatopy/setup.py new file mode 100644 index 0000000..eda61c2 --- /dev/null +++ b/src/vbatopy/setup.py @@ -0,0 +1,4 @@ +''' vbatopy package installer ''' +from setuptools import setup + +setup() diff --git a/src/vbatopy/vbatopy/__init__.py b/src/vbatopy/vbatopy/__init__.py new file mode 100644 index 0000000..2023c15 --- /dev/null +++ b/src/vbatopy/vbatopy/__init__.py @@ -0,0 +1,7 @@ +from .server import start_server + +from .decorators import ( + vba_func as func, + vba_sub as sub, + vba_object as obj +) diff --git a/src/vbatopy/vbatopy/comwrappers.py b/src/vbatopy/vbatopy/comwrappers.py new file mode 100644 index 0000000..6a2facd --- /dev/null +++ b/src/vbatopy/vbatopy/comwrappers.py @@ -0,0 +1,58 @@ +import pythoncom +from win32com.client import Dispatch + +from .python_api import reload_module, preload_module, call_function, wrap_python + + +# Note: change encoding here if needed. Warning: VBA doesn't support UTF-8 encoded modules! +_windows_cyrillic_encoding = 'cp1251' + + +class ComPython: + _public_methods_ = ['CallFunction', 'ReloadModule', 'ImportModule', 'WrapPython', 'Eval', 'Exec'] + + def CallFunction(self, module_name, func_name: str, args=None): + """Call python function""" + unwrapped_args = None if args is None else tuple(_unwrap_com(arg) for arg in args) + result = call_function(module_name, func_name, unwrapped_args) + return _wrap_com(result) + + def ReloadModule(self, module_name: str): + """Reload imported module""" + return reload_module(module_name) + + def ImportModule(self, module_path: str): + """Load module""" + return preload_module(module_path) + + def WrapPython(self, source_module: str, destination_file: str): + """"Create wrapper for decorated python module functions""" + return wrap_python(source_module, destination_file, encoding=_windows_cyrillic_encoding) + + def Eval(self, expression: str): + """Evaluate expression in current context""" + result = eval(expression) + return _wrap_com(result) + + def Exec(self, statement: str): + """Execute statement in current context""" + exec(statement) + + +def _unwrap_com(com_value): + value_type = type(com_value) + if value_type is pythoncom.TypeIIDs[pythoncom.IID_IDispatch]: + return Dispatch(com_value) + return com_value + + +def _wrap_com(py_value): + value_type = type(py_value) + if value_type is tuple: + return [element for element in py_value] + elif value_type is dict: + result = Dispatch('Scripting.Dictionary') + for (key, value) in py_value.items(): + result.Add(key, value) + return result + return py_value diff --git a/src/vbatopy/vbatopy/decorators.py b/src/vbatopy/vbatopy/decorators.py new file mode 100644 index 0000000..c8f46f5 --- /dev/null +++ b/src/vbatopy/vbatopy/decorators.py @@ -0,0 +1,95 @@ +import functools +import inspect + + +def vba_func(func): + @functools.wraps(func) + def inner(func): + if not hasattr(func, '__vba__'): + func.__vba__ = describe_function(func) + func.__vba__['sub'] = False + return func + return inner(func) + + +def vba_sub(func): + @functools.wraps(func) + def inner(func): + if not hasattr(func, '__vba__'): + func.__vba__ = describe_function(func) + func.__vba__['sub'] = True + return func + return inner(func) + + +def vba_object(func): + @functools.wraps(func) + def inner(func): + func.__vba__['object'] = True + return func + return inner(func) + + +def describe_function(func): + info = {} + info['name'] = func.__name__ + info['sub'] = False + info['object'] = False + (args, argsmap) = _describe_function_args(func) + info['args'] = args + info['argsmap'] = argsmap + return info + + +def extract_function_description(func): + if hasattr(func, '__vba__'): + return func.__vba__ + else: + return describe_function(func) + + +def _describe_function_args(func): + func_sig = _extract_function_args(func) + nArgs = len(func_sig['args']) + nDefaults = len(func_sig['defaults']) + nRequiredArgs = nArgs - nDefaults + if func_sig['vararg'] and nDefaults > 0: + raise Exception("Not supported: function with both optional and variable length arguments") + + args = [] + argsmap = {} + for (vpos, vname) in enumerate(func_sig['args']): + arg_info = { + 'name': vname, + 'pos': vpos, + 'vararg': vname == func_sig['vararg'], + 'options': {} + } + if vpos >= nRequiredArgs: + arg_info['optional'] = func_sig['defaults'][vpos - nRequiredArgs] + args.append(arg_info) + argsmap[vname] = args[-1] + + return (args, argsmap) + + +def _extract_function_args(func): + signature = inspect.signature(func) + vararg = None + args = [] + defaults = [] + for arg in signature.parameters.values(): + if arg.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: + args.append(arg.name) + if arg.default is not inspect.Signature.empty: + defaults.append(arg.default) + elif arg.kind is inspect.Parameter.VAR_POSITIONAL: + args.append(arg.name) + vararg = arg.name + else: + raise Exception("Not supported: keyword arguments") + return { + 'args': args, + 'defaults': defaults, + 'vararg': vararg + } diff --git a/src/vbatopy/vbatopy/python_api.py b/src/vbatopy/vbatopy/python_api.py new file mode 100644 index 0000000..b86b4de --- /dev/null +++ b/src/vbatopy/vbatopy/python_api.py @@ -0,0 +1,131 @@ +import sys +import os + +from importlib import import_module, reload + +from .writer import VBAWriter +from .decorators import extract_function_description + + +def reload_module(module_name: str): + reload(_get_module(module_name)) + + +def preload_module(module_path: str) -> str: + (folder, file) = os.path.split(module_path) + if folder not in sys.path: + sys.path.append(folder) + module_name = os.path.splitext(file)[0] + _get_module(module_name) + return module_name + + +def call_function(module_name, func_name, args=None): + module = _get_module(module_name) + func = getattr(module, func_name) + if args is not None: + result = func(*args) + else: + result = func() + return result + + +def wrap_python(source: str, destination: str, encoding: str) -> str: + source_module = preload_module(source) + + out_file = open(destination, 'w+', encoding=encoding) + (folder, file) = os.path.split(destination) + + vba = VBAWriter(out_file) + vba.line(f'Attribute VB_Name = "{os.path.splitext(file)[0]}"') + vba.line('Option Private Module') + vba.line('Option Explicit') + vba.line("'Generated code by python_api - changes will be lost with next generation!") + + _wrap_module(source_module, vba) + + out_file.close() + return destination + + +def _wrap_module(module_name: str, vba: VBAWriter): + module = _get_module(module_name) + for attribute_item in map(lambda attr: getattr(module, attr), dir(module)): + if not hasattr(attribute_item, '__vba__'): + continue + info = extract_function_description(attribute_item) + _wrap_function(module_name, info, vba) + + +def _wrap_function(module_name, info, vba: VBAWriter): + fname = f"Py_{info['name']}" + ftype = 'Sub' if info['sub'] else 'Function' + (func_sig, vararg) = _generate_function_signature(ftype, fname, info) + args_vba = _wrap_vararg(vararg, info['args'], vba) + + vba.line('') + vba.start_block(func_sig) + + vba.line("Dim iPython as API_Python: Set iPython = AccessPython") + vba.line("On Error GoTo FAILED_CALL") + if info['object']: + vba.line(f"""Set {fname} = iPython.CallFunctionReturnObject("{module_name}", "{info['name']}", {args_vba})""") + else: + vba.line(f"""{fname} = iPython.CallFunction("{module_name}", "{info['name']}", {args_vba})""") + vba.line("On Error GoTo 0") + vba.line(f"Exit {ftype}") + vba.label("FAILED_CALL") + vba.line(f"{fname} = Err.Description") + + vba.end_block('End ' + ftype) + + +def _wrap_vararg(vararg, args, vba: VBAWriter) -> str: + if vararg == '': + return 'Array(' + ', '.join(arg['name'] for arg in args) + ')' + + vba.line("Dim argsArray() As Variant") + non_varargs = [arg['name'] for arg in args if not arg['vararg']] + vba.line("argsArray = Array(%s)" % tuple({', '.join(non_varargs)})) + vba.line(f"ReDim Preserve argsArray(0 to UBound({vararg}) - LBound({vararg}) + {len(non_varargs)})") + vba.line("Dim k&") + vba.line(f"For k = LBound({vararg}) To UBound({vararg})") + vba.line(f"argsArray({len(non_varargs)} + k - LBound({vararg})) = {vararg}(k)") + vba.line("Next k") + return 'argsArray' + + +def _generate_function_signature(ftype: str, fname: str, info): + args = '' + first = True + vararg = '' + for arg in info['args']: + argname = f"pyArg_{arg['name']}" + arg['name'] = argname + if not first: + args += ', ' + if 'optional' in arg: + args += 'Optional ' + elif arg['vararg']: + args += 'ParamArray ' + vararg = argname + args += argname + if arg['vararg']: + args += '()' + first = False + return (f'Public {ftype} {fname}({args})', vararg) + + +# Imported module caching +_loaded_modules = {} + + +def _get_module(module_name): + module_info = _loaded_modules.get(module_name, None) + if module_info is not None: + module = module_info['module'] + else: + module = import_module(module_name) + filename = os.path.normcase(module.__file__.lower()) + _loaded_modules[module_name] = {'filename': filename, 'module': module} + return module \ No newline at end of file diff --git a/src/vbatopy/vbatopy/server.py b/src/vbatopy/vbatopy/server.py new file mode 100644 index 0000000..3a67071 --- /dev/null +++ b/src/vbatopy/vbatopy/server.py @@ -0,0 +1,66 @@ +import logging +import win32api +import win32event +import pythoncom +import pywintypes +import win32com.server.util as server_util +import win32com.server.policy as server_policy + +from .comwrappers import ComPython + +logger = logging.getLogger(__name__) + + +def start_server(clsid_str): + """Start the COM server, clsid_str is the dispatcher object GUID created in DLL module""" + class_id = pywintypes.IID(clsid_str) + _override_com_policy(class_id) + (factory, revokeId) = _register_class_object(class_id) + + pythoncom.EnableQuitMessage(win32api.GetCurrentThreadId()) + pythoncom.CoInitialize() + pythoncom.CoResumeClassObjects() + + _run_python_loop(class_id) + + pythoncom.CoRevokeClassObject(revokeId) + pythoncom.CoUninitialize() + + +def _override_com_policy(class_id): + class WrapPythonPolicy(server_policy.DefaultPolicy): + def _CreateInstance_(self, request_clsid, dispatch_id): + if request_clsid == class_id: + return server_util.wrap(ComPython(), dispatch_id) + else: + return server_policy.DefaultPolicy._CreateInstance_(self, request_clsid, dispatch_id) + server_policy.DefaultPolicy = WrapPythonPolicy + + +def _register_class_object(class_id): + factory = pythoncom.MakePyFactory(class_id) + revokeId = pythoncom.CoRegisterClassObject( + class_id, factory, + pythoncom.CLSCTX_LOCAL_SERVER, + pythoncom.REGCLS_MULTIPLEUSE | pythoncom.REGCLS_SUSPENDED + ) + return (factory, revokeId) + + +def _run_python_loop(class_id): + msg = f"Python server running, class_id={class_id}" + if logger.hasHandlers(): + logger.info(msg) + else: + print(msg) + + while True: + rc = win32event.MsgWaitForMultipleObjects( + (), # list of objects + 0, # wait for all = False + win32event.INFINITE, # no timeout + win32event.QS_ALLEVENTS # accept any input + ) + if rc == win32event.WAIT_OBJECT_0: + if pythoncom.PumpWaitingMessages(): + break # wm_quit diff --git a/src/vbatopy/vbatopy/writer.py b/src/vbatopy/vbatopy/writer.py new file mode 100644 index 0000000..39c15f7 --- /dev/null +++ b/src/vbatopy/vbatopy/writer.py @@ -0,0 +1,22 @@ +class VBAWriter: + ''' Class for creating VBA code ''' + def __init__(self, out_file): + self._output = out_file + self._indent = 0 + + def start_block(self, block_header): + self.line(block_header) + self._indent += 1 + + def end_block(self, block_footer): + self._indent -= 1 + self.line(block_footer) + + def label(self, label_name): + indent = self._indent + self._indent = 0 + self.line(label_name + ':') + self._indent = indent + + def line(self, text_line): + self._output.write((' ' * self._indent) + text_line + '\n') diff --git a/src/vbatopydll/.clang-tidy b/src/vbatopydll/.clang-tidy new file mode 100644 index 0000000..6feb063 --- /dev/null +++ b/src/vbatopydll/.clang-tidy @@ -0,0 +1,43 @@ +Checks: "*,\ + -llvmlibc-implementation-in-namespace,\ + -llvmlibc-callee-namespace,\ + -llvmlibc-restrict-system-libc-headers,\ + -llvm-else-after-return,\ + -bugprone-branch-clone,\ + -bugprone-suspicious-include,\ + -modernize-use-trailing-return-type,\ + -modernize-avoid-c-arrays,\ + -hicpp-special-member-functions,\ + -hicpp-multiway-paths-covered,\ + -hicpp-signed-bitwise,\ + -hicpp-avoid-c-arrays,\ + -hicpp-vararg,\ + -llvm-header-guard,\ + -google-runtime-references,\ + -google-runtime-int,\ + -llvm-include-order,\ + -fuchsia-overloaded-operator,\ + -fuchsia-default-arguments,\ + -google-readability-todo,\ + -google-global-names-in-headers,\ + -readability-redundant-access-specifiers,\ + -readability-else-after-return,\ + -readability-implicit-bool-conversion,\ + -readability-use-anyofallof,\ + -performance-no-int-to-ptr,\ + -performance-inefficient-string-concatenation,\ + -performance-unnecessary-value-param,\ + -fuchsia-default-arguments-declarations,\ + -fuchsia-trailing-return,\ + -fuchsia-multiple-inheritance,\ + -fuchsia-default-arguments-calls,\ + -misc-non-private-member-variables-in-classes,\ + -misc-no-recursion,\ + -cppcoreguidelines-pro-type-union-access,\ + -cppcoreguidelines-pro-type-reinterpret-cast,\ + -cppcoreguidelines-macro-usage,\ + -cppcoreguidelines-pro-type-vararg,\ + -cppcoreguidelines-special-member-functions,\ + -cppcoreguidelines-avoid-c-arrays,\ + -cppcoreguidelines-pro-type-cstyle-cast,\ + -cppcoreguidelines-non-private-member-variables-in-classes" \ No newline at end of file diff --git a/src/vbatopydll/header/server.h b/src/vbatopydll/header/server.h new file mode 100644 index 0000000..70aa664 --- /dev/null +++ b/src/vbatopydll/header/server.h @@ -0,0 +1,42 @@ +#pragma once + +namespace vbatopy { + +class Server { +public: + // Dispatcher interface is provided by server process through COM CoCreateInstance + IDispatch* dispatcher{ nullptr }; + GUID clsid{}; + +private: + HANDLE jobHandle{}; + HANDLE pyProcess{}; + HANDLE pyThread{}; + + bool showConsole{ false }; + std::string execCommand{}; + + Server(); + ~Server(); + +public: + static Server& Instance(); + + void Setup(std::string command, bool showConsole_); + [[nodiscard]] bool CheckStatus() const; + void Start(); + void Kill(); + void ForceKill(); + +private: + void ReleaseDispatch(); + void ReleaseKillerJob(); + + void CreateInternalProcess(); + void CreateKillerJob(); + void ActivateDispatcher(); + + void SubstituteMacro(std::string& path) const; +}; + +} // namespace vbatopy \ No newline at end of file diff --git a/src/vbatopydll/header/utils.h b/src/vbatopydll/header/utils.h new file mode 100644 index 0000000..d28e86b --- /dev/null +++ b/src/vbatopydll/header/utils.h @@ -0,0 +1,32 @@ +#pragma once + +namespace vbatopy::utils { + +void ToVariant(const char* str, VARIANT* var); +std::string GUIDToStr(GUID guid); + +std::string GetLastErrorMessage(); + +struct AutoCloseHandle { + HANDLE handle{ nullptr }; + + explicit AutoCloseHandle(HANDLE handle) { + this->handle = handle; + } + ~AutoCloseHandle() { + if (handle != nullptr && handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } + } + + HANDLE Release() { + HANDLE res = handle; + handle = nullptr; + return res; + } +}; + +void ApplyPythonEscaping(std::string& target); +std::string GeneratePythonCommand(std::string pythonExe, std::string sources, GUID clsid); + +} // namespace vbatopy::utils \ No newline at end of file diff --git a/src/vbatopydll/include/vbatopy.h b/src/vbatopydll/include/vbatopy.h new file mode 100644 index 0000000..dc7e23f --- /dev/null +++ b/src/vbatopydll/include/vbatopy.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef VBATOPY_EXPORTS + #define DLLEXPORT __declspec(dllexport) +#else + #define DLLEXPORT __declspec(dllimport) +#endif + +DLLEXPORT HRESULT __stdcall GetServer(VARIANT* pyResult); + +// Start server using @command argument. $(CLSID) macro will be expanded into dispatcher GUID +DLLEXPORT HRESULT __stdcall StartServer(VARIANT* pyResult, const char* command, int showConsole); + +// Start server for @python executable and add @sources to os.path +// Requires: @vbatopy should be available for import +DLLEXPORT HRESULT __stdcall StartPythonServer(VARIANT* pyResult, const char* python, const char* sources, int showConsole); + +DLLEXPORT HRESULT __stdcall KillServer(); \ No newline at end of file diff --git a/src/vbatopydll/pch.h b/src/vbatopydll/pch.h new file mode 100644 index 0000000..bbf57ec --- /dev/null +++ b/src/vbatopydll/pch.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +// Note: using std::format increases dll filesize by ~150 KB, but makes code more readable +// Using barebone string concatenation for error messages and cmd construction doesnt impact perfomance +// Consider not using std::format if dll size is critical +#include + +#include "utils.h" +#include "server.h" +#include "vbatopy.h" \ No newline at end of file diff --git a/src/vbatopydll/src/pch.cpp b/src/vbatopydll/src/pch.cpp new file mode 100644 index 0000000..1d9f38c --- /dev/null +++ b/src/vbatopydll/src/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/vbatopydll/src/server.cpp b/src/vbatopydll/src/server.cpp new file mode 100644 index 0000000..471805b --- /dev/null +++ b/src/vbatopydll/src/server.cpp @@ -0,0 +1,151 @@ +#include "pch.h" + +namespace vbatopy { + +Server::Server() { + if (FAILED(::CoCreateGuid(&clsid))) { + throw std::runtime_error("CoCreateGuid failed."); + } +} + +Server::~Server() { + this->ForceKill(); +} + +Server& Server::Instance() { + static Server py{}; + return py; +} + +void Server::Setup(std::string command, const bool showConsole_) { + this->Kill(); + showConsole = showConsole_; + execCommand = std::move(command); + SubstituteMacro(execCommand); +} + +bool Server::CheckStatus() const { + if (dispatcher == nullptr) { + return false; + } + UINT info{}; + return dispatcher->GetTypeInfoCount(&info) == S_OK; +} + +void Server::Kill() { + ReleaseDispatch(); + ReleaseKillerJob(); + pyThread = nullptr; + pyProcess = nullptr; +} + +void Server::ForceKill() { + ReleaseKillerJob(); + dispatcher = nullptr; + pyThread = nullptr; + pyProcess = nullptr; +} + +void Server::Start() { + CreateInternalProcess(); + utils::AutoCloseHandle hPiThread(pyThread); + utils::AutoCloseHandle hPiProcess(pyProcess); + + CreateKillerJob(); + utils::AutoCloseHandle hJobAuto(jobHandle); + + ActivateDispatcher(); + + jobHandle = hJobAuto.Release(); +} + +void Server::ReleaseDispatch() { + if (dispatcher != nullptr) { + dispatcher->Release(); + dispatcher = nullptr; + } +} + +void Server::ReleaseKillerJob() { + if (jobHandle != nullptr) { + ::CloseHandle(jobHandle); + jobHandle = nullptr; + } +} + +void Server::SubstituteMacro(std::string& path) const { + static const auto MACRO_LEN = 8U; + const auto position = path.find("$(CLSID)", 0); + if (position == std::string::npos) { + return; + } + path = path.substr(0, position) + utils::GUIDToStr(clsid) + path.substr(position + MACRO_LEN, path.length() - position - MACRO_LEN); +} + +void Server::CreateInternalProcess() { + static constexpr auto CMD_INPUT_MAX_LENGTH = 8000U; + + const DWORD windowFlags = showConsole ? NORMAL_PRIORITY_CLASS | CREATE_BREAKAWAY_FROM_JOB : + NORMAL_PRIORITY_CLASS | CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW; + + if (execCommand.length() > CMD_INPUT_MAX_LENGTH) { + throw std::length_error("Start server command is too long"); + } + + char cmdLine[CMD_INPUT_MAX_LENGTH] = ""; + strcat_s(cmdLine, execCommand.c_str()); + + STARTUPINFOA si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + + if (!::CreateProcessA( + nullptr, + cmdLine, // NOLINT(cppcoreguidelines-pro-bounds-array-to-pointer-decay, hicpp-no-array-decay) + nullptr, nullptr, TRUE, windowFlags, nullptr, nullptr, + &si, &pi)) { + throw std::runtime_error(std::format("Could not create Python: {}\nCommand{}", utils::GetLastErrorMessage(), execCommand)); + } + pyProcess = pi.hProcess; + pyThread = pi.hThread; +} + +void Server::CreateKillerJob() { + utils::AutoCloseHandle hJobAuto{ CreateJobObject(nullptr, nullptr) }; + if (hJobAuto.handle == nullptr) { + throw std::runtime_error("CreateJobObject failed: " + utils::GetLastErrorMessage()); + } + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jxli; + ZeroMemory(&jxli, sizeof(jxli)); + jxli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!::SetInformationJobObject(hJobAuto.handle, JobObjectExtendedLimitInformation, &jxli, sizeof(jxli))) { + throw std::runtime_error("SetInformationJobObject failed: " + utils::GetLastErrorMessage()); + } + if (!::AssignProcessToJobObject(hJobAuto.handle, pyProcess)) { + throw std::runtime_error("AssignProcessToJobObject failed: " + utils::GetLastErrorMessage()); + } + jobHandle = hJobAuto.Release(); +} + +void Server::ActivateDispatcher() { + static constexpr auto RETRY_COUNT = 600U; + static constexpr auto RETRY_PERIOD_MS = 100U; + for (uint16_t attempt = 0; attempt < RETRY_COUNT; ++attempt) { + if (::CoCreateInstance(clsid, nullptr, CLSCTX_LOCAL_SERVER, IID_IDispatch, reinterpret_cast(&(dispatcher))) != REGDB_E_CLASSNOTREG) { + return; + } + DWORD dwExitCode{}; + if (!::GetExitCodeProcess(pyProcess, &dwExitCode)) { + throw std::runtime_error("GetExitCodeProcess failed: " + utils::GetLastErrorMessage()); + } + if (dwExitCode != STILL_ACTIVE) { + throw std::runtime_error("Cmd process exited before it was possible to create the interface object.\nCommand: " + execCommand); + } + ::Sleep(RETRY_PERIOD_MS); + } + throw std::runtime_error("Could not activate Python COM server"); +} + +} // namespace vbatopy \ No newline at end of file diff --git a/src/vbatopydll/src/utils.cpp b/src/vbatopydll/src/utils.cpp new file mode 100644 index 0000000..a323cb5 --- /dev/null +++ b/src/vbatopydll/src/utils.cpp @@ -0,0 +1,59 @@ +#include "pch.h" + +namespace vbatopy::utils { + +std::string GUIDToStr(const GUID guid) { + return std::format("{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + guid.Data1, guid.Data2, guid.Data3, + guid.Data4[0], guid.Data4[1], + guid.Data4[2], guid.Data4[3], guid.Data4[4], + guid.Data4[5], guid.Data4[6], guid.Data4[7]); // NOLINT(cppcoreguidelines-avoid-magic-numbers, readability-magic-numbers) +} + +void ToVariant(const char* str, VARIANT* var) { + ::VariantClear(var); + + int sz = static_cast(strlen(str)) + 1; + auto wideStr = std::make_unique(sz); + ::MultiByteToWideChar(CP_ACP, 0, str, static_cast(sz * sizeof(OLECHAR)), wideStr.get(), sz); + var->vt = VT_BSTR; + var->bstrVal = SysAllocString(wideStr.get()); +} + +std::string GetLastErrorMessage() { + DWORD dwError = ::GetLastError(); + char* buffer{}; + if (0 == ::FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, dwError, 0, reinterpret_cast(&buffer), 0, nullptr)) { + return "Could not get error message: FormatMessage failed."; + } + auto errorMessage = std::string{ buffer }; + ::LocalFree(buffer); + return errorMessage; +} + +void ApplyPythonEscaping(std::string& target) { + std::string result{}; + result.reserve(size(target)); + for (const auto& c : target) { + if (c == '\\') { + result += R"(\\)"; + } else if (c == '&') { + result += "^&"; + } else { + result += c; + } + } + if (size(target) != size(result)) { + target = result; + } +} + +std::string GeneratePythonCommand(std::string pythonExe, std::string sources, const GUID clsid) { + ApplyPythonEscaping(sources); + return std::format(R"(cmd.exe /C {} -B -c )" + R"("import sys, os; sys.path[0:0]=os.path.normcase(os.path.expandvars(\"{}\")).split(';'); )" + R"(import vbatopy; vbatopy.server.start_server('{}') ")", pythonExe, sources, GUIDToStr(clsid)); +} + +} // namespace vbatopy::utils \ No newline at end of file diff --git a/src/vbatopydll/src/vbatopy.cpp b/src/vbatopydll/src/vbatopy.cpp new file mode 100644 index 0000000..81f4645 --- /dev/null +++ b/src/vbatopydll/src/vbatopy.cpp @@ -0,0 +1,95 @@ +#include "pch.h" + +using vbatopy::Server; + +// DLL entry point +BOOL WINAPI DllMain(HINSTANCE /*hinstDLL*/, DWORD fdwReason, LPVOID /*lpvReserved*/) { + switch (fdwReason) { + case DLL_PROCESS_ATTACH: + return TRUE; + + case DLL_PROCESS_DETACH: + Server::Instance().ForceKill(); + return TRUE; + + default: + return TRUE; + } +} + +DLLEXPORT HRESULT __stdcall GetServer(VARIANT* pyResult) { + try { + ::VariantClear(pyResult); + auto& server = Server::Instance(); + + if (server.CheckStatus()) { + pyResult->vt = VT_DISPATCH; + server.dispatcher->QueryInterface(&pyResult->pdispVal); + return S_OK; + } else { + pyResult->vt = VT_DISPATCH; + pyResult->pdispVal = nullptr; + return S_OK; + } + } catch (const std::exception& e) { + vbatopy::utils::ToVariant(e.what(), pyResult); + return E_FAIL; + } +} + +DLLEXPORT HRESULT __stdcall StartServer(VARIANT* pyResult, const char* command, int showConsole) { + try { + ::VariantClear(pyResult); + + auto& server = Server::Instance(); + if (server.CheckStatus()) { + throw std::runtime_error("Server already running"); + } + + server.Setup(command, showConsole != 0); + server.Start(); + + pyResult->vt = VT_DISPATCH; + server.dispatcher->QueryInterface(&pyResult->pdispVal); + + return S_OK; + } catch (const std::exception& e) { + vbatopy::utils::ToVariant(e.what(), pyResult); + return E_FAIL; + } +} + +DLLEXPORT HRESULT __stdcall StartPythonServer(VARIANT* pyResult, const char* python, const char* sources, int showConsole) { + try { + ::VariantClear(pyResult); + if (python == nullptr || strlen(python) == 0) { + throw std::invalid_argument("Python command missing"); + } + + auto& server = Server::Instance(); + if (server.CheckStatus()) { + throw std::runtime_error("Server already running"); + } + + server.Setup(vbatopy::utils::GeneratePythonCommand(python, sources, server.clsid), showConsole != 0); + server.Start(); + + pyResult->vt = VT_DISPATCH; + server.dispatcher->QueryInterface(&pyResult->pdispVal); + + return S_OK; + } catch (const std::exception& e) { + vbatopy::utils::ToVariant(e.what(), pyResult); + return E_FAIL; + } +} + +DLLEXPORT HRESULT __stdcall KillServer() { + try { + auto& server = Server::Instance(); + server.Kill(); + return S_OK; + } catch (const std::exception& /*e*/) { + return E_FAIL; + } +} \ No newline at end of file diff --git a/src/vbatopydll/vbatopy.def b/src/vbatopydll/vbatopy.def new file mode 100644 index 0000000..32bdba4 --- /dev/null +++ b/src/vbatopydll/vbatopy.def @@ -0,0 +1,7 @@ +LIBRARY + +EXPORTS + GetServer + StartPythonServer + StartServer + KillServer \ No newline at end of file diff --git a/src/vbatopydll/vbatopy.sln b/src/vbatopydll/vbatopy.sln new file mode 100644 index 0000000..59ac4eb --- /dev/null +++ b/src/vbatopydll/vbatopy.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Express 2013 for Windows Desktop +VisualStudioVersion = 12.0.31101.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "vbatopy", "vbatopy.vcxproj", "{2959049E-520D-4930-8408-C578B3302724}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Win32 = Debug|Win32 + Debug|x64 = Debug|x64 + Release|Win32 = Release|Win32 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2959049E-520D-4930-8408-C578B3302724}.Debug|Win32.ActiveCfg = Debug|Win32 + {2959049E-520D-4930-8408-C578B3302724}.Debug|Win32.Build.0 = Debug|Win32 + {2959049E-520D-4930-8408-C578B3302724}.Debug|x64.ActiveCfg = Debug|x64 + {2959049E-520D-4930-8408-C578B3302724}.Debug|x64.Build.0 = Debug|x64 + {2959049E-520D-4930-8408-C578B3302724}.Release|Win32.ActiveCfg = Release|Win32 + {2959049E-520D-4930-8408-C578B3302724}.Release|Win32.Build.0 = Release|Win32 + {2959049E-520D-4930-8408-C578B3302724}.Release|x64.ActiveCfg = Release|x64 + {2959049E-520D-4930-8408-C578B3302724}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/vbatopydll/vbatopy.vcxproj b/src/vbatopydll/vbatopy.vcxproj new file mode 100644 index 0000000..e38f1df --- /dev/null +++ b/src/vbatopydll/vbatopy.vcxproj @@ -0,0 +1,209 @@ + + + + + Debug + Win32 + + + Debug + x64 + + + Release + Win32 + + + Release + x64 + + + + {2959049E-520D-4930-8408-C578B3302724} + Win32Proj + vbatopy + 10.0 + + + + DynamicLibrary + true + Unicode + v143 + + + DynamicLibrary + true + Unicode + v143 + + + DynamicLibrary + false + true + Unicode + v143 + + + DynamicLibrary + false + true + Unicode + v143 + + + + + + + + + + + + + + + + + + + true + build\x86\$(Configuration)\dll\ + build\x86\$(Configuration)\ + vbatopy-connector32 + + + true + build\x64\$(Configuration)\dll\ + build\x64\$(Configuration)\ + vbatopy-connector64 + + + false + ..\..\output\dll\ + build\x86\$(Configuration)\ + vbatopy-connector32 + + + false + ..\..\output\dll\ + build\x64\$(Configuration)\ + vbatopy-connector64 + + + + Level4 + Disabled + .\;include;header + _DEBUG;_WINDOWS;_USRDLL;VBATOPY_EXPORTS;%(PreprocessorDefinitions) + MultiThreadedDebugDLL + stdcpplatest + true + false + Use + pch.h + + + Windows + true + vbatopy.def + + + + + Level4 + Disabled + .\;include;header + _DEBUG;_WINDOWS;_USRDLL;VBATOPY_EXPORTS;%(PreprocessorDefinitions) + MultiThreadedDebugDLL + stdcpplatest + true + false + Use + pch.h + + + Windows + true + vbatopy.def + + + + + Level4 + MaxSpeed + .\;include;header + true + true + NDEBUG;_WINDOWS;_USRDLL;VBATOPY_EXPORTS;%(PreprocessorDefinitions) + MultiThreadedDLL + stdcpplatest + true + false + Use + pch.h + + + Windows + true + true + true + vbatopy.def + + + + + + + + + Level4 + MaxSpeed + .\;include;header + true + true + NDEBUG;_WINDOWS;_USRDLL;VBATOPY_EXPORTS;%(PreprocessorDefinitions) + MultiThreadedDLL + stdcpplatest + true + false + Use + pch.h + + + Windows + true + true + true + vbatopy.def + + + + + + + + + Create + Create + Create + Create + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/vbatopydll/vbatopy.vcxproj.filters b/src/vbatopydll/vbatopy.vcxproj.filters new file mode 100644 index 0000000..56004c2 --- /dev/null +++ b/src/vbatopydll/vbatopy.vcxproj.filters @@ -0,0 +1,37 @@ + + + + + src + + + src + + + src + + + src + + + + + {874e5ad6-697b-4d2c-a192-cf4b49cb244c} + + + + + src + + + src + + + src + + + + + + + \ No newline at end of file diff --git a/test/s_Python.cls b/test/s_Python.cls new file mode 100644 index 0000000..cddf622 --- /dev/null +++ b/test/s_Python.cls @@ -0,0 +1,305 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "s_Python" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = False +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +Option Explicit + +' TODO: +' Public Function RunPyStandalone(sPyCommand$, Optional sWorkDir$ = "") As Long +' Public Function RunPyFrozen(iPython$, Optional sArgs$ = "") As Long + +' Public Function API_Python.Execute(sPyCommand$) As Boolean +' Public Function API_Python.Evaluate(sPyStatement$) As Variant + +#If Win64 Then + Private Declare PtrSafe Function GetServer Lib "vbatopy-connector64.dll" (ByRef vResult As Variant) As Long + Private Declare PtrSafe Function KillPythonServer Lib "vbatopy-connector64.dll" Alias "KillServer" () As Long +#Else + Private Declare PtrSafe Function GetServer Lib "vbatopy-connector32.dll" (ByRef vResult As Variant) As Long + Private Declare PtrSafe Function KillPythonServer Lib "vbatopy-connector32.dll" Alias "KillServer" () As Long +#End If + +Dim python_ As API_Python + +Private Const TEST_FUNCS_FILE = "testFuncs.py" +Private Const TEST_WRAP_SOURCE = "testWrap.py" +Private Const TEST_WRAP_RESULT = "testWrap.bas" + +Public Function Setup() + ' Mandatory setup function + Set python_ = New API_Python + Call python_.Init(iPython:=PY_DEFVALUE_INTERPRETER, sModules:="", bDoDebug:=False) + Call python_.LoadDLL +End Function + +Public Function Teardown() + ' Mandatory teardown function + Call python_.KillServer + Set python_ = Nothing +End Function + +Public Function t_StartServer() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Invalid python") + Dim iTest As New API_Python + Call iTest.Init("invalid_python", "") + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Missing server module") + Call iTest.Init("python", "", bDoDebug:=False) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Too long command") + Call iTest.Init("python", String$(10000, "A")) + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectAnyError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Start valid") + Dim iServer As Variant: Call GetServer(iServer) + Call Dev_ExpectTrue(iServer Is Nothing, "Not started") + Call Dev_ExpectFalse(python_.Validate, "Not started") + Call python_.StartServer + Call GetServer(iServer) + Call Dev_ExpectTrue(python_.Validate, "After start") + Call Dev_ExpectFalse(iServer Is Nothing, "After start") + On Error Resume Next + Call python_.StartServer + Call Dev_ExpectNoError("Double start") + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Double python object") + Call iTest.Init("python", "") + On Error Resume Next + Call iTest.StartServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_KillServer() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Not started") + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Valid kill") + Call python_.StartServer + Call python_.KillServer + Dim iServer As Variant: Call GetServer(iServer) + Call Dev_ExpectTrue(iServer Is Nothing) + Call Dev_ExpectFalse(python_.Validate) + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError("Dobule kill") + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Hijack and kill first") + Call python_.StartServer + Call KillPythonServer + On Error Resume Next + Call python_.KillServer + Call Dev_ExpectNoError + On Error GoTo PROPAGATE_ERROR + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_RunPython() + On Error GoTo PROPAGATE_ERROR + + Call Dev_NewCase("Not started") + Call Dev_ExpectFalse(python_.Validate) + Call Dev_ExpectTrue(python_.Execute("print(1)")) + Call Dev_ExpectTrue(python_.Validate) + + Call Dev_NewCase("Already started") + Call Dev_ExpectTrue(python_.Execute("print(2)")) + + Call Dev_NewCase("Invalid code") + Call Dev_ExpectFalse(python_.Execute("invalid_func_call(42, '42')")) + Call Dev_ExpectFalse(python_.Execute("do_something_illegal42")) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_LoadModule() + On Error GoTo PROPAGATE_ERROR + + Dim sModule$: sModule = Dev_GetTestFolder & "\" & TEST_FUNCS_FILE + Call python_.StartServer + + Call Dev_ExpectFalse(python_.LoadModule(""), "Empty input") + + Call Dev_NewCase("Missing file") + Call Dev_ExpectFalse(python_.LoadModule(Dev_GetTestFolder & "\missingFile.py")) + + Call Dev_NewCase("Valid load") + Call Dev_ExpectTrue(python_.LoadModule(sModule)) + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world"))) + + Call Dev_NewCase("Double load") + Call Dev_ExpectTrue(python_.LoadModule(sModule)) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionInvalid() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("Invalid module") + Call Dev_ExpectLike(python_.CallFunction("invalid_module", "get_42"), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("", "get_42"), "Unexpected Python Error*") + + Call Dev_NewCase("Invalid function") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "invalid_function_name42"), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", ""), "Unexpected Python Error*") + + Call Dev_NewCase("Invalid arguments") + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world")), "Unexpected Python Error*") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello"), "Unexpected Python Error*", "Missing argument") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", "world"), "Unexpected Python Error*", "Invalid argument type - string") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", ThisWorkbook), "Unexpected Python Error*", "Invalid argument type - object") + Call Dev_ExpectLike(python_.CallFunction("testFuncs", "hello", Array("world", "big")), "Unexpected Python Error*", "Invalid argument count") + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionBasic() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("No return") + Call Dev_ExpectTrue(IsNull(python_.CallFunction("testFuncs", "no_return"))) + + Call Dev_NewCase("No arguments") + Call Dev_ExpectEQ(42, python_.CallFunction("testFuncs", "get_42")) + + Call Dev_NewCase("Strings") + Call Dev_ExpectEQ("Hello world!", python_.CallFunction("testFuncs", "hello", Array("world"))) + Call Dev_ExpectEQ("Hello 42!", python_.CallFunction("testFuncs", "hello", Array(42))) + + Call Dev_NewCase("Multiple args") + Call Dev_ExpectEQ((13 + 5) * 2, python_.CallFunction("testFuncs", "double_sum", Array(13, 5))) + + Call Dev_NewCase("Array arg") + Call Dev_ExpectEQ(42 + 1337 + 1, python_.CallFunction("testFuncs", "sum_array", Array(Array(42, 1337, 1)))) + + Call Dev_NewCase("Dictionary argument") + Call Dev_ExpectEQ(3, python_.CallFunction("testFuncs", "process_dict", Array(CSet(1, 2, 43)))) + + Call Dev_NewCase("Optional arg") + Call Dev_ExpectEQ(42, python_.CallFunction("testFuncs", "optional_arg")) + Call Dev_ExpectEQ(1337, python_.CallFunction("testFuncs", "optional_arg", Array(1337))) + + Call Dev_NewCase("Multiple return") + Call Dev_ExpectEQ(Array(42, "test"), python_.CallFunction("testFuncs", "return_tuple")) + + Call Dev_NewCase("Return list") + Call Dev_ExpectEQ(Array(42, "test", 11), python_.CallFunction("testFuncs", "return_list")) + + Call Dev_NewCase("Return 2dlist") + Call Dev_ExpectEQ(Array(Array(1, 2, 42), Array("test", 13), Array(11)), python_.CallFunction("testFuncs", "return_2dlist")) + + Call Dev_NewCase("Return dict") + Dim iTest As New Scripting.Dictionary + iTest("a") = 42 + iTest("b") = "test" + iTest("c") = 11 + Call Dev_ExpectEQ(iTest, python_.CallFunctionReturnObject("testFuncs", "return_dict")) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_CallFunctionObjects() + On Error GoTo PROPAGATE_ERROR + + Call python_.StartServer + Call Dev_ExpectTrue(python_.LoadModule(Dev_GetTestFolder & "\" & TEST_FUNCS_FILE)) + + Call Dev_NewCase("Access VBA COM from Python") + Dim iCell As Excel.Range: Set iCell = ThisWorkbook.Sheets(1).Cells(1, 1) + iCell = "test" + Call Dev_ExpectEQ("test", python_.CallFunction("testFuncs", "extract_range_text", Array(ThisWorkbook.Sheets(1)))) + iCell = "" + + Call Dev_NewCase("Return VBA object from Python") + Dim iResult As Object + Set iResult = python_.CallFunctionReturnObject("testFuncs", "return_range_object", Array(ThisWorkbook.Sheets(1))) + iResult = "test" + Call Dev_ExpectEQ("test", iCell.Text) + iCell = "" + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +Public Function t_WrapPython() + On Error GoTo PROPAGATE_ERROR + + Dim fso As New Scripting.FileSystemObject + Dim sSource$: sSource = Dev_GetTestFolder & "\" & TEST_WRAP_SOURCE + Dim sDestination$: sDestination = ThisWorkbook.Path & "\" & TEST_WRAP_RESULT + Dim sTest$: sTest = Dev_GetTestFolder & "\" & TEST_WRAP_RESULT + Call python_.StartServer + Call Dev_AssertEQ(sDestination, python_.WrapPython(sSource, sDestination)) + Call Dev_ExpectEQ(ReadFile(sTest), ReadFile(sDestination)) + + Call fso.DeleteFile(sDestination) + + Exit Function +PROPAGATE_ERROR: + Call Dev_LogError(Err.Number, Err.Description) +End Function + +' ===== +Private Function ReadFile(sFile$) As String + Dim adoStream As New ADODB.Stream + adoStream.Charset = "utf-8" + Call adoStream.Open + + On Error GoTo ERROR_FILE + Call adoStream.LoadFromFile(sFile) + On Error GoTo 0 + + ReadFile = adoStream.ReadText + +ERROR_FILE: + Call adoStream.Close + On Error GoTo 0 +End Function diff --git a/ui/.rels b/ui/.rels new file mode 100644 index 0000000..3107a8e --- /dev/null +++ b/ui/.rels @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ui/customUI.xml b/ui/customUI.xml new file mode 100644 index 0000000..048103a --- /dev/null +++ b/ui/customUI.xml @@ -0,0 +1,21 @@ + + + + + + +