Wednesday 27 May 2020

VBA, ADODB - Asynchronous Query Execution with ADODB.Connection Events

VBA doesn't have multiple threads but that's ok because network latent operations such as running queries are packed into libraries which do the multi-threading for you. The ADODB.Connection object that is used to connect to a database can run queries in asynchronous mode with notification of completion implemented with an event if you declare the Connection object with the WithEvents keyword and and supply adAsyncExecute to the Connection's Execute method.

What follows is a code pattern not actual code because I do not know what databases you have installed on your computer dear reader. But what must be stressed is that this is to be placed into a class module (not a standard module). I called my class AsyncQuery

Option Explicit

Private WithEvents cnAsynchronousConnection As ADODB.Connection

Public Sub RunAsyncQuery()
    
    Set cnAsynchronousConnection = New ADODB.Connection

    cnAsynchronousConnection.connectionString = "" '<---- Insert your connection string

    
    cnAsynchronousConnection.Open
    
    Debug.Print "Preparing to execute asynchronously: " & Now
    cnAsynchronousConnection.Execute "<select query>", adAsyncExecute  '<----- Insert you own query

    Debug.Print "Has begun executing asynchronously: " & Now
End Sub

Private Sub cnAsynchronousConnection_ExecuteComplete(ByVal RecordsAffected As Long, _
        ByVal pError As ADODB.Error, adStatus As ADODB.EventStatusEnum, ByVal pCommand As ADODB.Command, _
        ByVal pRecordset As ADODB.Recordset, ByVal pConnection As ADODB.Connection)
    Debug.Print "The query has completed asynchronously: " & Now
End Sub

Then in a standard module place the following code.

Option Explicit

Sub Test()
    Dim oAsyncQuery As AsyncQuery
    Set oAsyncQuery = New AsyncQuery

    oAsyncQuery.RunAsyncQuery

End Sub

So without a database we can't take this any further. There are two key points working here, firstly there is the WithEvents keyword in the variable declaration which is only valid in a class module. Secondly there is the flag adAsyncExecute which must be passed to the Connection's Execute method. I have highlighted these key points in bold red.

Monday 25 May 2020

A rather neat piece of plumbing, Chrome extension pushes byte array of jobs data to Excel via Python

Transcript

The United States is suffering from extremely high unemployment and in this post I give an application that harvests job leads from a leading jobs website. The application has numerous technical components, (i) a Chrome extension, (ii) a Python webserver housed as a COM component and (iii) a VBA deserialization component. Taken together they demonstrate transmitting binary data from the browser through to the Excel worksheet.

In the US, initial jobless claims are running at a 4-week average of 3 million and the non-farm payrolls are currently at 20 million. These figures are both depressing and staggering. Europe can expect suffering on similar terms. Hopefully the code in this post can assist some to find work.

Co-browsing vs Web-scraping

Websites depend upon ad revenue to survive and so they need humans to see the adverts placed. Every time a human sees an advert it is known as an impression. Web-scraping is the process of running code to fetch a web page and to scrape data from the HTML; this typically involves the automation of a hidden web browser and as such any adverts on a hidden web page are no longer viewable but rendering ad impression statistics false. Eventually, this means that ad revenue is debased and devalued. As such, I disapprove of web scraping.

Instead, I give a ‘co-browsing’ application where code captures job leads from a web page that a human user is browsing. So this application is only active when a human browses a web page. This means any advert impressions are genuine and website’s revenue is not threatened.

The code

There are three separate parts to this application, (i) the chrome extension, (ii) the Python web server (housed as a COM component) and (iii) the VBA deserialization component. They are all in Github, https://github.com/smeaden/ExcelDevelopmentPlatform/tree/master/PythonWebSeverCallsBackToExcel/

The Chrome Extension

https://github.com/smeaden/ExcelDevelopmentPlatform/tree/master/PythonWebSeverCallsBackToExcel/Chrome%20Extension/

The chrome extension will wait for a jobs page to load and then read the jobs data, it builds a JavaScript array of jobs and when complete it will convert the single dimensioned array of jobs into a two-dimensional grid array where each row is one job and the attributes are spread across the columns.

I convert to a grid because ultimately it will be sent to an Excel session where it is to be pasted onto a worksheet. The grid is then persisted to a byte array instead of JSON to take advantage of a data interchange format native to VB6, VBA that I have re-discovered and that allows a byte array to be deserialized to a VBA (OLE Automation) Variant (two dimensional).

Once converted to a byte array we make an XMLHttpRequest() to the Python web server (see next component). If you are experimenting then you might need to change port number in the code here.

There are two main JavaScript files, content.js and JavaScriptToVBAVariantArray.js. The former houses logic specific to this application whilst the latter is the array conversion code library file which I intend to use across a number of projects.

Python Web Server housed as a COM component

https://github.com/smeaden/ExcelDevelopmentPlatform/tree/master/PythonWebSeverCallsBackToExcel/PythonWebSeverCallsBackToExcel

I have previously written about and given code as to how to write a Python web server housed as a COM component and instantiable from VBA. I have also previously written about and given code as to how to call back into Excel VBA from a Python class.

But there is something new in this Python web server which needs detailing, in short one cannot simply call back into Excel with an interface pointer passed in a different threading apartment; instead the interface pointer has first to be ‘marshalled’. I have encapsulated the plain vanilla callback code in the Python class CallbackInfo and the special marshalling case in its derived class MarshalledCallbackInfo.

In the context of the application, the Python web server is part of the pipeline that passes the byte array from the Chrome extension into Excel VBA. It calls into Excel VBA by calling Application.Run on a (marshalled) Excel.Application pointer. The name of the procedure called by Application.Run is configurable, and passed in. Time to look into the VBA code.

Excel VBA

https://github.com/smeaden/ExcelDevelopmentPlatform/tree/master/PythonWebSeverCallsBackToExcel/ExcelVBA

I do not check into whole workbooks, I check in the individual code modules instead. Thus to build the Excel VBA workbook code base one needs to import the modules. Luckily, I wrote one module called devBuild to import the rest of them. I intend to follow this pattern when placing code in GitHub. Look at the README.md file for more detail. From here, I’ll assume you’ve built a workbook codebase.

I have written about the serialization and deserialization of Variants to byte arrays and back again so I’ll refer you to that post for the details. In short we take the byte array passed from the Chrome extension via the Python web server and deserialize this to a two dimensional variant array which can then be pasted onto the worksheet.

I guess I could write some more code to build a cumulative list but the point of this project was to show binary data being passed from browser to Excel, to demonstrate (a) the plumbing and (b) the binary data interface format (i.e. no JSON).

Monday 18 May 2020

Returning to GitHub - this time at the command line

So my blog posts have become large and full of code, best to start using GitHub to store the code. Last time I used to use the Win32 desktop application but this time around I'm going to learn to use the command line. This post is about my efforts to configure and check in code from the command line. I hope to abstract a cheat sheet at some point.

So I started with the Hello World and execute Step 1. Create a Repository (this is still web-page based) but after that I was presented with a new web page of steps reproduced below and so I started from the command line to follow these instructions...

echo "# ExcelDevelopmentPlatform" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/smeaden/ExcelDevelopmentPlatform.git
git push -u origin master

So I executed the first three lines and whilst there is not much feedback at the command line, the first command git init creates a .get subfolder in the current selected folder, this is the local git repository.

C:\Users\Simon>git init
Initialized empty Git repository in C:/Users/Simon/.git/

C:\Users\Simon>echo "# ExcelDevelopmentPlatform" >> README.md

C:\Users\Simon>git add README.md

After I executed the first three lines I got slowed up on the fourth. Initially, I got the error below but it turned out that I had put a space between the dash and the em.

C:\Users\Simon>git commit - m "first commit"
error: pathspec '-' did not match any file(s) known to git
error: pathspec 'm' did not match any file(s) known to git
error: pathspec 'first commit' did not match any file(s) known to git

So trying again with corrected line I then got

C:\Users\Simon>git commit -m "first commit"

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'Simon@xxxx.(none)')

So it seems I have to configure credentials; so I do but not globally ...

C:\Users\Simon>git config user.email "xxxx@xxxx.com"

C:\Users\Simon>git config user.name "S Meaden"

then I try (yet) again with the commit which gives...

C:\Users\Simon>git commit -m "first commit"
[master (root-commit) adca8f5] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md

So now over that hurdle I can continue with the instructions, the next line below is what ties the local repository to the one on GitHub...

C:\Users\Simon>git remote add origin https://github.com/smeaden/ExcelDevelopmentPlatform.git

C:\Users\Simon>git push -u origin master

Upon executing the second line from above there was a delay and then the following dialog box appeared which I then completed and clicked login.

which then progressed that line command's execution thus ...

C:\Users\Simon>git push -u origin master
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 245 bytes | 245.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To https://github.com/smeaden/ExcelDevelopmentPlatform.git
 * [new branch]      master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.

And now checking the web page I have a ReadMe.Md file checked in.

Moving the Git Repository

Next I ran a command git status which listed all the files not yet tracked and there were too many and also made me realise I could locate the git repository better such as in the directory where Microsoft Visual Studio creates projects. After a quick StackOverflow surf it looked like I could just move the directory and I tried at the command line with no success but then used Windows Explorer with drag and drop to take it to it new location. The command git status then failed which meant I needed to change the current working directory to where I relocated the .git folder. Once there I could run git status and git log again so it looks like this move succeeded.

C:\Users\Simon>git status
warning: could not open directory 'Application Data/': Permission denied
...
On branch master
Your branch is up to date with 'origin/master'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        .Neo4jDesktop/
        .cargo/
        .conda/
    ...
nothing added to commit but untracked files present (use "git add" to track)

C:\Users\Simon>git log
commit adca8f54ee85ed4395b1df6b35eae5ecb70d7c5e (HEAD -> master, origin/master)
Author: S Meaden <simonmeaden@xxxx.com>
Date:   Fri May 15 12:31:29 2020 +0100

    first commit


C:\Users\Simon>move README.md C:\Users\Simon\source\repos
        1 file(s) moved.

C:\Users\Simon>move \.git C:\Users\Simon\source\repos
The system cannot find the file specified.

C:\Users\Simon>explorer .

At this point I used Windows explorer to drag the .git folder down to .\source\repos

C:\Users\Simon>git log
fatal: not a git repository (or any of the parent directories): .git

C:\Users\Simon>cd source

C:\Users\Simon\source>cd repos

C:\Users\Simon\source\repos>git log
commit adca8f54ee85ed4395b1df6b35eae5ecb70d7c5e (HEAD -> master, origin/master)
Author: S Meaden <simonmeaden@xxxx.com>
Date:   Fri May 15 12:31:29 2020 +0100

    first commit

C:\Users\Simon\source\repos>git status
On branch master
Your branch is up to date with 'origin/master'.

Untracked files:
  (use "git add ..." to include in what will be committed)
        ATLProject1/
        ATLProject2/
        ...
nothing added to commit but untracked files present (use "git add" to track)

C:\Users\Simon\source\repos>

I found the following video and discovered that the first 12-15 minutes more or less cover the same territory as my notes. So for more information, I recommend this video.

Finally, it would appear GitHub is now free both public and private repositories with unlimited collaborators

Links

  • Markdown cheat sheet
  • Friday 8 May 2020

    Python COM Component to get windows handles hierarchy

    In this blog I give a Python COM component that returns a windows handle hierarchy as found in Spy++. It returns the details in one large table.

    So I had cause to poke around the windows hierarchy for Excel and I had previously written code to query the Windows API and get all the windows handles in a tree just like Spy++ but I chose to revisit the code with Python. Also, I chose to return the results in tabular form.

    Here is the Python listing

    import pythoncom 
    import os
    import logging
    import win32gui
    import win32con 
    
    class LocalsEnhancedErrorMessager(object):
        @staticmethod
        def Enhance(ex, localsString):
            locals2 = "n Locals:{ " + (",n".join(localsString[1:-1].split(","))) + " }"
            if hasattr(ex,"message"):
                return "Error:" + ex.message + locals2
            else:
                return "Error:" + str(ex) + locals2
    
    
    class PythonFindWindow(object):
        _reg_clsid_ = "{490784B6-5174-4794-8888-769DE4688B2C}"
        _reg_progid_ = 'PythonInVBA.PythonFindWindow'
        _public_methods_ = ['FindAllXlMainWindows','FindXlMainWindowWithCaptionFragment','FindChildWindows']
        _reg_clsctx_ = pythoncom.CLSCTX_LOCAL_SERVER ## uncomment this for a separate COM Exe server instead of in-process DLL
    
        def FindAllXlMainWindows(self):
            try:
                logging.basicConfig(filename =  (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', 
                            format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
                windows = []
    
                hwnd = win32gui.FindWindowEx(0,0,"XLMAIN",None)
                while hwnd != 0:
                    windows.append(hwnd)
                    hwnd = win32gui.FindWindowEx(0,hwnd,"XLMAIN",None)
    
                logging.info('PythonFindWindow.FindAllXlMainWindows completed')
                return windows
            except Exception as ex:
                msg = "PythonFindWindow.FindAllXlMainWindows error:" + LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
        def FindXlMainWindowWithCaptionFragment(self, captionStringFragment):
            try:
                logging.basicConfig(filename =  (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', 
                            format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
                windows = []
    
                hwnd = win32gui.FindWindowEx(0,0,"XLMAIN",None)
                while hwnd != 0:
                    caption = win32gui.GetWindowText(hwnd)
                    if captionStringFragment in caption:
                        windows.append(hwnd)
                    hwnd = win32gui.FindWindowEx(0,hwnd,"XLMAIN",None)
    
                logging.info('PythonFindWindow.FindXlMainWindowWithCaptionFragment completed')
                return windows
            except Exception as ex:
                msg = "PythonFindWindow.FindXlMainWindowWithCaptionFragment error:" + LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
    
        def FindChildWindows(self, parentHandle, selectStyles):
            try:
                logging.basicConfig(filename =  (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', 
                            format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
                windows = []
                hwnd = parentHandle
                row = [hwnd,0,"{0:#0{1}x}".format(hwnd,8), 
                                    win32gui.GetWindowText(hwnd), 
                                    win32gui.GetClassName(hwnd),
                                    win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)]
                windows.append(row)
    
                self.FindChildWindowsInner(parentHandle,windows, selectStyles,0)
                
                logging.info('PythonFindWindow.FindChildWindows completed')
                return windows
            except Exception as ex:
                msg = "PythonFindWindow.FindChildWindows error:" + LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
        def FindChildWindowsInner(self, parentHandle, windows, selectStyles, depth):
            try:
    
                hwnd = win32gui.FindWindowEx(parentHandle,0,None,None)
                while hwnd != 0:
                    style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)
                    stylesSelected = True if selectStyles is None else (style & selectStyles)!=0
                    if stylesSelected:
                        row = [hwnd,parentHandle,"{0:#0{1}x}".format(hwnd,8), 
                                         win32gui.GetWindowText(hwnd), 
                                         win32gui.GetClassName(hwnd),
                                         style]
                        windows.append(row)
                        self.FindChildWindowsInner(hwnd, windows, selectStyles, depth+1)
                    hwnd = win32gui.FindWindowEx(parentHandle,hwnd,None,None)
                
                
                return windows
            except Exception as ex:
                msg = "PythonFindWindow.FindChildWindowsInner error:" + LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
    
    
    def run():
        # this code is to be run in Microsoft Visual Studio by pressing F5
        # it is a developer's entry.  for production instantiate the COM component
        try:
    
            print("Executing run")
            print((os.path.dirname(os.path.realpath(__file__))))
    
            logging.basicConfig(filename = (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', 
                            format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
            fw = PythonFindWindow()
    
            xlMains = fw.FindAllXlMainWindows()
            
            windowList = fw.FindChildWindows(xlMains[0], win32con.WS_VISIBLE)
    
            logging.info('called PythonFindWindow.FindChildWindows ...n')
    
            logging.info('finishing run()n')
        except Exception as ex:
            print(ex)
    
    def RegisterCOMServers():
        print("Registering COM servers...")
        import win32com.server.register
        win32com.server.register.UseCommandLine(PythonFindWindow)
    
    if __name__ == '__main__':
        
        RegisterCOMServers()
        run()

    and here is some sample VBA client code...

    Option Explicit
    
    Const WS_VISIBLE As Long = &H10000000   'WS_VISIBLE = 0x10000000
    
    Sub Test()
        Dim obj As Object
        Set obj = VBA.CreateObject("PythonInVBA.PythonFindWindow")
        
        Dim vXlMains
        vXlMains = obj.FindXlMainWindowWithCaptionFragment("MyWorkbook")
        
        Dim vWindows
    
        vWindows = obj.FindChildWindows(vXlMains(0), Empty)  '* No styles to select with, so selects all
        vWindows = obj.FindChildWindows(vXlMains(0), WS_VISIBLE)  '* Only shows those that are visible (and whose ancestors are visible)
        
        Dim lRow As Long
        For lRow = LBound(vWindows, 1) To UBound(vWindows, 1)
            If vWindows(lRow, 4) = "EXCEL7" Then
                Stop
            End If
        Next
        Stop
    End Sub

    Once the table is returned one can dig in and find what you want, much better to take a whole snapshot recursing down through the hierarchy then to piece together separate calls to FindWindow in my humble opinion.

    Thursday 7 May 2020

    VBA, Python - Python Web Server housed as a COM component

    In this post I give code for a Python web server housed as a COM component which is startable and stoppable from VBA or any other COM-enabled client. The code demonstrates COM server code, Python web server code, multi-threading and Python logging.

    Multithreading possible but ill-advised in VBA

    Multithreading in VBA is technically possible as VBA code can access Windows API functions such as CreateThread as well as the operating system artefacts used to manage concurrency and synchronization such as semaphores, critical sections and mutexs. Unfortunately, if you create threads in VBAs and then place breakpoints in the code to debug then Excel will crash because the Excel VBA IDE is not multi-threading aware/capable. Never mind, for multi-threading problems an Excel VBA developer can co-opt either C# (or other .NET languages) or Python to build a COM component callable from VBA. In this post I use Python.

    Code commentary - the COM server code

    The code below demonstrates COM server code which keen readers of this blog will have seen many times before so I will be brief. The StarterAndStopper class (excerpt given below) is the COM server gateway class, we can tell this from the _reg_clsid and _reg_progid attributes as well as the list of methods. Also there is a key line of code which determines how to implement the COM server's housing; _reg_clsctx_ which if omitted defaults to an in-process DLL pattern but if pythoncom.CLSCTX_ instead then the COM server will be housed in a separate .Exe. This is extremely useful during development for tearing down one instance and replace with another implementing the latest changes.

    class StarterAndStopper(object):
        ...
        _reg_clsid_ = "{2D23D974-73B1-4106-9096-DA6006BD84AA}"
        _reg_progid_ = 'PythonInVBA.StarterAndStopper'
        _public_methods_ = ['StartWebServer','StopWebServer','CheckThreadStatus','StopLogging']
        ##_reg_clsctx_ = pythoncom.CLSCTX_ ## uncomment this for a separate COM Exe server instead of in-process DLL server

    the registration code is given in the following lines, these need to be run once; if not with Admin rights then an escalation is requested.

    def RegisterCOMServers():
        print("Registering COM servers...")
        import win32com.server.register
        win32com.server.register.UseCommandLine(StarterAndStopper)
    
    if __name__ == '__main__':
        #run()
        RegisterCOMServers()

    then once registered the COM server is creatable with the following CreateObject line of code...

        Set mobjPythonWebServer = VBA.CreateObject("PythonInVBA.StarterAndStopper")

    I will give further commentary of this class later when talking about multi-threading.

    Code commentary - stoppable web server code

    So we utilize the Python library's basic web server, this is not for use unless behind a firewall but is usable for facilitating HTTP communication between programs on the same computer. For robust internet-facing industrial strength production web serving one should use Apache web server with a Python plug-in. For my purposes the basic web server is fine, I am planning some code where the browser on a machine calls into Excel.exe running on the same machine, i.e. we are not internet-facing.

    The base class http.server.HTTPServer has a serve_forever method which runs in an infinite loop which only interrupts when Ctrl+C is pressed on the keyboard in the console window in which the web server is running. If running in a COM server housing then there is no visible console and so we need a mechanism to stop the web server without a keyboard interrupt. The code in an article over on activestate.com gives the pattern for a stoppable web server by amending the standard implementation thus,

    1. Adding an additional HTTP verb handler to the class derived from SimpleHTTPRequestHandler to handle a QUIT request. The code here sets a Stop flag to True.
    2. Subclassing http.server.HTTPServer and providing overriding implementation of serve_forever that will acknowledge the stop and drop out of the (otherwise infinite) loop.
    3. In the shutdown code make a HTTP QUIT request to one's own webserver
    class MyRequestHandler(SimpleHTTPRequestHandler):
        ...
        def do_QUIT (self):
                # http://code.activestate.com/recipes/336012-stoppable-http-server/ 
                """send 200 OK response, and set server.stop to True"""
                self.send_response(200)
                self.end_headers()
                self.server.stop = True
                self.wfile.write("quit called".encode('utf-8'))
    class StoppableHttpServer(HTTPServer):
        # http://code.activestate.com/recipes/336012-stoppable-http-server/ 
        """http server that reacts to self.stop flag"""
    
        def serve_forever (self):
                """Handle one request at a time until stopped."""
                self.stop = False
                while not self.stop:
                    self.handle_request()
    class StarterAndStopper(object):
        def StopWebServer(self):
    
                        ## make a quit request to our own server 
                        quitRequest  = urllib.request.Request("http://" + self.server_name + ":" + str(self.server_port) + "/quit",
                                                          method="QUIT")
                        with urllib.request.urlopen(quitRequest ) as resp:
                            logging.info("StarterAndStopper.StopWebServer      : quit response '" + resp.read().decode("utf-8") + "'")

    Whilst on the subject of no visible console window, we have to redirect stdout and stderr to somewhere, e.g. a file otherwise the code complains and throws errors. So I found adding the following is sufficient to suppress such errors.

            sys.stderr = open((os.path.dirname(os.path.realpath(__file__))) + '\\logfile.txt', 'w', buffer)
            sys.stdout = open((os.path.dirname(os.path.realpath(__file__))) + '\\logfile.txt', 'w', buffer)

    Code commentary - multithreading

    Creating and starting a new thread in Python is quite simple using the Thread constructor threading.Thread(name, target, args) where target is a function or a class's method, in this case a standalone function called thread_function which itself simply calls the web server's serve_forever method given above. Once constructed, we call the Thread's start method.

    class StarterAndStopper(object):
        def StartWebServer(self,foo, bar: str, baz: str, server_name:str, server_port: int):
                self.running = False 
                
                self.httpd = StoppableHttpServer((server_name, server_port), MyRequestHandler)
    
                self.serverthread = threading.Thread(name="webserver", target=thread_function, args=(self,))
                self.serverthread.setDaemon(True)
                
                self.serverthread.start()
                ... 
    
    def thread_function(webserver):
        try:
            webserver.httpd.serve_forever()  #code enters into the subclass's implementation, an almost infinite loop
            ...
    

    When we come to stop the web server by sending the QUIT HTTP request notifying the web server thread of close down we then call the Thread.join method on the main thread to wait for the web server thread to drop off. In the code given we set the Thread to a daemon, which means the Thread's refusal to finish does not prevent unloading the code once the main thread has finished.

    Code commentary - developing a multithreaded COM component

    The code is meant to be executed as a COM component with execution beginning with a COM client such as VBA. Unfortunately such a scenario does not facilitate hitting break points and stepping through the source code. For this reason a separate run() function is found at the bottom of the code. This is to be run in Microsoft Visual Studio and doing this we get to hit break points and step through the code. Sometimes, it's necessary to comment out the setDaemon(True) line so that the code does not unload, allowing continued debugging. This can be a bit of pain but until I can get the breakpoints to hit in the original scenario I will have to persist with this.

    Code commentary - Python logging

    In addition to the lack of breakpoints in the primary one use case (see above) the code can be difficult to debug because of the nature of multithreading. One cannot always tell the order in what events occurred! To solve this I put in the code a ton of logging so that I could see just what precisely is happening. Here is a sample of my log which expresses the sequence of events for starting the web server, using a browser to make a HTTP GET, then stopping the web server. In fact this log says so much more than any prose that I could write.

    22:37:17: StarterAndStopper.StartWebServer     : server_name: localhost, server_port:8014
    22:37:17: StarterAndStopper.StartWebServer     : about to create thread
    22:37:17: StarterAndStopper.StartWebServer     : about to start thread
    22:37:17: StarterAndStopper.StartWebServer     : after call to start thread
    22:37:17: thread_function                      : about to enter webserver.httpd.serve_forever
    22:37:17: StoppableHttpServer.serve_forever    : entered
    22:37:20: MyRequestHandler.do_GET              : entered.  path=/testurl
    22:37:20: StoppableHttpServer.serve_forever    : request successfully handled self.stop=False
    22:37:22: StarterAndStopper.StopWebServer      : entered
    22:37:22: StarterAndStopper.StopWebServer      : call quit on own web server
    22:37:24: MyRequestHandler.do_QUIT             : entered
    22:37:24: MyRequestHandler.do_QUIT             : setting self.server.stop = True
    22:37:24: StoppableHttpServer.serve_forever    : request successfully handled self.stop=True
    22:37:24: StarterAndStopper.StopWebServer      : quit response 'quit called'
    22:37:24: StoppableHttpServer.serve_forever    : dropped out of the loop
    22:37:24: StarterAndStopper.StopWebServer      : about to join thread
    22:37:24: thread_function                      : returned from webserver.httpd.serve_forever
    22:37:24: thread_function                      : finished
    22:37:24: StarterAndStopper.StopWebServer      : thread joined
    22:37:24: StarterAndStopper.StopWebServer      : about to call httpd.server_close()
    22:37:24: StarterAndStopper.StopWebServer      : completed

    Full Code Listings

    So here is the full Python code listing which has all the full logging statements in it.

    import sys
    import time #sleep
    import http.server
    import threading
    import tempfile
    import os
    
    import win32com.client
    from io import BytesIO
    import pythoncom
    
    import urllib.request
    
    from http.server import HTTPServer, BaseHTTPRequestHandler, SimpleHTTPRequestHandler
    import logging
    
    class MyRequestHandler(SimpleHTTPRequestHandler):
    
        def do_GET(self):
            try:
                logging.info("MyRequestHandler.do_GET              : entered.  path=" + self.path)
                self.send_response(200)
                self.send_header('Content-type', 'text/html')
                self.end_headers()
                if (self.path != r"/favicon.ico"):
                    self.wfile.write("GET request for {}".format(self.path).encode('utf-8'))
                    self.wfile.write((" default response").encode('utf-8'))
            except Exception as ex:
                logging.info("MyRequestHandler.do_GET   error   : " + 
                    LocalsEnhancedErrorMessager.Enhance(ex,str(locals())))
    
        def do_POST(self):
            try:
                logging.info("MyRequestHandler.do_POST             : entered ")
                content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
                post_data = self.rfile.read(content_length) # <--- Gets the data itself
    
                self.send_response(200)
                self.send_header('Content-type', 'text/html')
                self.end_headers()
    
                msgBytesReceived = "POST body:" + str(len(post_data)) + " bytes received" 
    
                response = BytesIO()
                response.write(msgBytesReceived.encode('utf-8'))
    
                self.wfile.write(response.getvalue())
                self.wfile.flush()
    
                print(msgBytesReceived)
    
                logging.info("MyRequestHandler.do_POST             : " + msgBytesReceived)
    
            except Exception as ex:
                logging.info("MyRequestHandler.do_POST  error    : " + 
                    LocalsEnhancedErrorMessager.Enhance(ex,str(locals())))
    
        def do_QUIT (self):
            try:
                logging.info("MyRequestHandler.do_QUIT             : entered")
                """send 200 OK response, and set server.stop to True"""
                self.send_response(200)
                self.end_headers()
                logging.info("MyRequestHandler.do_QUIT             : setting self.server.stop = True")
                self.server.stop = True
                self.wfile.write("quit called".encode('utf-8'))
            except Exception as ex:
                logging.info("MyRequestHandler.do_QUIT  error    : " + 
                    LocalsEnhancedErrorMessager.Enhance(ex,str(locals())))
    
    class LocalsEnhancedErrorMessager(object):
        @staticmethod
        def Enhance(ex, localsString):
            locals2 = "n Locals:{ " + (",n".join(localsString[1:-1].split(","))) + " }"
            if hasattr(ex,"message"):
                return "Error:" + ex.message + locals2
            else:
                return "Error:" + str(ex) + locals2
    
    def thread_function(webserver):
        try:
            pythoncom.CoInitialize() # need this to tell the COM runtime that a new thread exists
            webserver.running = True 
    
            ## we need to pipe output to a file because whilst running as COM server there is no longer a console window to print to
            buffer = 1
            sys.stderr = open((os.path.dirname(os.path.realpath(__file__))) + '\logfile.txt', 'w', buffer)
            sys.stdout = open((os.path.dirname(os.path.realpath(__file__))) + '\logfile.txt', 'w', buffer)
    
            logging.info("thread_function                      : about to enter webserver.httpd.serve_forever")
            webserver.httpd.serve_forever()  #code enters into the subclass's implementation, an almost infinite loop
            logging.info("thread_function                      : returned from webserver.httpd.serve_forever")
            
            logging.info("thread_function                      : finished")
    
        except Exception as ex:
            logging.info("thread_function   error   : " + 
                LocalsEnhancedErrorMessager.Enhance(ex,str(locals())))
    
    class StoppableHttpServer(HTTPServer):
        # http://code.activestate.com/recipes/336012-stoppable-http-server/ 
        """http server that reacts to self.stop flag"""
    
        def serve_forever (self):
            try:
                logging.info("StoppableHttpServer.serve_forever    : entered")
                """Handle one request at a time until stopped."""
                self.stop = False
                while not self.stop:
                    self.handle_request()
                    logging.info("StoppableHttpServer.serve_forever    : request successfully handled self.stop=" + str(self.stop))
                logging.info("StoppableHttpServer.serve_forever    : dropped out of the loop")
            except Exception as ex:
                logging.info("StoppableHttpServer.serve_forever  error   : " + 
                    LocalsEnhancedErrorMessager.Enhance(ex,str(locals())))
                
    class StarterAndStopper(object):
        import logging
        import threading
        import time
        
        _reg_clsid_ = "{2D23D974-73B1-4106-9096-DA6006BD84AA}"
        _reg_progid_ = 'PythonInVBA.StarterAndStopper'
        _public_methods_ = ['StartWebServer','StopWebServer','CheckThreadStatus','StopLogging']
        ##_reg_clsctx_ = pythoncom.CLSCTX_ ## uncomment this for a separate COM Exe server instead of in-process DLL server
    
        def StopLogging(self):
            try:
                logging.shutdown()
                return "logging.shutdown() ran"
            except Exception as ex:
                msg = "StarterAndStopper.StopLogging error:" + LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
        def StartWebServer(self,foo, bar: str, baz: str, server_name:str, server_port: int):
            try:
                self.server_name = server_name
                self.server_port = server_port
    
                logging.basicConfig(filename =  (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
                logging.info("StarterAndStopper.StartWebServer     : server_name: " + server_name + ", server_port:" + str(server_port))
    
                self.running = False 
                
                self.httpd = StoppableHttpServer((server_name, server_port), MyRequestHandler)
    
                logging.info("StarterAndStopper.StartWebServer     : about to create thread")
    
                self.serverthread = threading.Thread(name="webserver", target=thread_function, args=(self,))
                self.serverthread.setDaemon(True)
                logging.info("StarterAndStopper.StartWebServer     : about to start thread")
    
                self.serverthread.start()
                logging.info("StarterAndStopper.StartWebServer     : after call to start thread")
                
                return "StartWebServer ran ok ( server_name: " + server_name + ", server_port:" + str(server_port) + ")"
    
            except Exception as ex:
                msg = "StarterAndStopper.StartWebServer error:" +  LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
        def CheckThreadStatus(self):
            try:
                # Clear the stream now that we have finished
                global callbackInfo
    
                if self.running:
                    if hasattr(self,'httpd') :
                        logging.info("StarterAndStopper.CheckThreadStatus    : checking thread status")
                        return self.serverthread.is_alive()
                    else:
                        return "StopWebServer ran ok, nothing to stop"
                else:
                    return "StopWebServer ran ok, nothing to stop"
    
            except Exception as ex:
                msg = "StarterAndStopper.CheckThreadStatus error:" +  LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                logging.info(msg)
                return msg
    
        def StopWebServer(self):
            try:
                retMsg = "StopWebServer ran (default)"
                logging.info("StarterAndStopper.StopWebServer      : entered")
    
                if self.running:
                    if hasattr(self,'httpd') :
    
                        logging.info("StarterAndStopper.StopWebServer      : call quit on own web server")
                        ## make a quit request to our own server 
                        quitRequest  = urllib.request.Request("http://" + self.server_name + ":" + str(self.server_port) + "/quit",
                                                          method="QUIT")
                        with urllib.request.urlopen(quitRequest ) as resp:
                            logging.info("StarterAndStopper.StopWebServer      : quit response '" + resp.read().decode("utf-8") + "'")
    
                        # web server should have exited loop and its thread should be ready to terminate
                        logging.info("StarterAndStopper.StopWebServer      : about to join thread")
                        self.serverthread.join()    # get the server thread to die and join this thread
                        self.running = False 
                        
                        logging.info("StarterAndStopper.StopWebServer      : thread joined")
    
                        logging.info("StarterAndStopper.StopWebServer      : about to call httpd.server_close()")
                        self.httpd.server_close()  #now we can close the server cleanly
                        
                        logging.info("StarterAndStopper.StopWebServer      : completed")
    
                        retMsg = "StopWebServer ran ok, web server stopped"
                    else:
                        retMsg = "StopWebServer ran ok, nothing to stop"
                else:
                    retMsg = "StopWebServer ran ok, nothing to stop"
                return retMsg
    
            except Exception as ex:
                msg = "StarterAndStopper.StopWebServer error:" +  LocalsEnhancedErrorMessager.Enhance(ex,str(locals()))
                print(msg)
                logging.info(msg)
                return msg
    
    def run():
        # this code is to be run in Microsoft Visual Studio by pressing F5
        # use this code to step through and debug the web server portion of code 
        try:
    
            print("Executing run")
            print((os.path.dirname(os.path.realpath(__file__))))
    
            logging.basicConfig(filename = (os.path.dirname(os.path.realpath(__file__))) + '\app2.log', format="%(asctime)s: %(message)s", 
                            level=logging.INFO, datefmt="%H:%M:%S")
    
            ws = StarterAndStopper()
            ws.StartWebServer(None,None, None,'localhost',8009)
    
            logging.info('called StarterAndStopper.StartWebServer ...n')
    
            if False:
    
                logging.info('what next? ...n')
                ws.StopWebServer()
    
                logging.info('finishing run()n')
        except Exception as ex:
            print(ex)
    
    def RegisterCOMServers():
        print("Registering COM servers...")
        import win32com.server.register
        win32com.server.register.UseCommandLine(StarterAndStopper)
    
    if __name__ == '__main__':
        run()
        #RegisterCOMServers()

    And here is the client VBA code which calls into the COM server (ensure it is registered!).

    Option Explicit
    Option Private Module
    
    Dim mobjPythonWebServer As Object
    
    Public Const PORT As Long = 8014
    
    Function TestPythonVBAWebserver_StartWebServer()
        Set mobjPythonWebServer = VBA.CreateObject("PythonInVBA.StarterAndStopper")
    
        Debug.Print mobjPythonWebServer.StartWebServer(Null, Null, Null, "localhost", PORT)
    
    End Function
    
    Sub TestPythonVBAWebserver_StopWebServer()
        If Not mobjPythonWebServer Is Nothing Then
            Debug.Print mobjPythonWebServer.StopWebServer
        End If
    End Sub
    
    Sub TestPythonVBAWebserver_StopLogging()
        '# This releases the log file so I can delete it occassionally
        If Not mobjPythonWebServer Is Nothing Then
            Debug.Print mobjPythonWebServer.StopLogging
        End If
    End Sub
    
    Sub PickupNewPythonScript()
        '# for development only to help pick up script changes we kill the python process
        Call CreateObject("WScript.Shell").Run("taskkill /f /im pythonw.exe", 0, True)
        Set mobjPythonWebServer = Nothing
    End Sub