Configuring Emacs, lsp-mode and Microsoft’s Visual Studio Code Python language server.

In a previous post I showed how to get Palantir’s Python Language Server working together with Emacs and lsp-mode.

In this post, we look at the brand new elephant in the room, Microsoft’s arguably far more powerful own Python Language Server, and how to integrate it with Emacs.

Motivation

Since that previous post on Palantir’s language server, I’ve been using Emacs far more intensively for Python coding in tmux on remote machines with GPUs for deep learning.

The interactive programming possibilities of Emacs (remember that Lisp programmers have been doing this since the 60s) make for a great development solution: I can interact with my remotely running neural network code, start a long-running training, and then detach from the running tmux. When I re-attach the next morning (or the next), I can continue interactively experimenting with the still-running Python instance, for example further fine-tuning the training.

Palantir’s Python Language Server can become sluggish at times, so I switched back to elpy which is usually quite snappy, and affords an impressive suite of code intelligence features for such a small package.

However, the elpy RPC Python process has a tendency to die quite often, and even M-x elpy-rpc-restart simply stops working at some point.

This, together with the fact that Microsoft’s own Python Language Server, the one used for both Visual Studio Code and also in Microsoft’s flagship Visual Studio IDE, has enjoyed and will continue to enjoy a far larger share of developer mindshare and attention, encouraged me to try and get this language server also working with Emacs.

This exercise cost many more hours than I was planning to spend, but everything seems to be working now.

/In this post, I will explain step-by-step how you too can enjoy the highly multi-threaded and actively developed Microsoft Python Language Server in your Emacs./

The Goal

If you follow the steps set out further down in this blog post, your Emacs too could end up looking like the one showed in the following screenshots.

emacs-ms-lsp-docbox-term_2018-11-19_16-21-42.png
LSP showing the blue documentation overlay box, with Emacs in a tmux, i.e. terminal mode, on a remote Linux machine, editing PyTorch / fastai training code.
emacs-ms-lsp-docbox-gui_2018-11-19_16-23-44.png
The same documentation showed locally, i.e. in GUI mode, on the macOS desktop.
emacs-ms-lsp-docbox-and-describe-term_2018-11-19_16-26-28.png
lsp-describe-thing-at-point on the right for more persistent documentation, blue overlay box on the left.
ms-pyls-threads_2018-11-19_16-28-45.png
C#’s strong multi-threading support comes in handy when parsing Python code! Ironic?

Four steps to combining Emacs with Microsoft’s Python Language Server

Read through all of the steps first, and then follow them carefully.

Let me know in the comments if anything should be further clarified.

Build the Microsoft Python Language Server using dotnet

If you don’t have this yet, download the .NET Core SDK for your platform from Microsoft’s .NET download site and install it.

Next, build the language server:

mkdir ~/build
cd ~/build
git clone https://github.com/Microsoft/python-language-server.git
cd python-language-server/src/LanguageServer/Impl
dotnet build -c Release

Install required Emacs packages

In Emacs, install the required and some optional packages using for example M-x package-install:

  • lsp-mode – the main language server protocol package
  • lsp-ui – UI-related LSP extras, such as the sideline info, docs, flycheck, etc.
  • company-lsp – company-backend for LSP-based code completion.
  • projectile or find-file-in-project – we use a single function from here to determine the root directory of a project.

Required change to lsp-ui-doc.el

Microsoft’s Python Language Server likes to replace spaces in the documentation it returns with   HTML entities.

Furthermore, there seems to be an additional misunderstanding between Emacs lsp-mode and the MS PyLS with regard to the interpretation of markdown and plaintext docstrings.

Both of these issues impact the blue documentation overlay, and should be worked around by editing the lsp-ui-doc-extract function in lsp-ui-doc.el.

Right before the line with

((gethash "kind" contents) (gethash "value" contents)) ;; MarkupContent

add the following sexp:

;; cpbotha: with numpy functions, e.g. np.array for example,
;; kind=markdown and docs are in markdown, but in default
;; lsp-ui-20181031 this is rendered as plaintext see
;; https://microsoft.github.io/language-server-protocol/specification#markupcontent

;; not only that, MS PyLS turns all spaces into   instances,
;; which we remove here this single additional cond clause fixes all
;; of this for hover

;; as if that was not enough: import pandas as pd - pandas is returned
;; with kind plaintext but contents markdown, whereas pd is returned
;; with kind markdown. fortunately, handling plaintext with the
;; markdown viewer still looks good, so here we are.
((member (gethash "kind" contents) '("markdown" "plaintext"))
 (replace-regexp-in-string " " " " (lsp-ui-doc--extract-marked-string contents)))

Emacs configuration

Add the following to your Emacs init.el, and don’t forget to read the comments.

If you’re not yet using use-package now would be a good time to upgrade.

(use-package lsp-mode
  :ensure t
  :config

  ;; change nil to 't to enable logging of packets between emacs and the LS
  ;; this was invaluable for debugging communication with the MS Python Language Server
  ;; and comparing this with what vs.code is doing
  (setq lsp-print-io nil)

  ;; lsp-ui gives us the blue documentation boxes and the sidebar info
  (use-package lsp-ui
    :ensure t
    :config
    (setq lsp-ui-sideline-ignore-duplicate t)
    (add-hook 'lsp-mode-hook 'lsp-ui-mode))

  ;; make sure we have lsp-imenu everywhere we have LSP
  (require 'lsp-imenu)
  (add-hook 'lsp-after-open-hook 'lsp-enable-imenu)

  ;; install LSP company backend for LSP-driven completion
  (use-package company-lsp
    :ensure t
    :config
    (push 'company-lsp company-backends))

  ;; dir containing Microsoft.Python.LanguageServer.dll
  (setq ms-pyls-dir (expand-file-name "~/build/python-language-server/output/bin/Release/"))

  ;; this gets called when we do lsp-describe-thing-at-point in lsp-methods.el
  ;; we remove all of the " " entities that MS PYLS adds
  ;; this is mostly harmless for other language servers
  (defun render-markup-content (kind content)
    (message kind)
    (replace-regexp-in-string " " " " content))
  (setq lsp-render-markdown-markup-content #'render-markup-content)

  ;; it's crucial that we send the correct Python version to MS PYLS, else it returns no docs in many cases
  ;; furthermore, we send the current Python's (can be virtualenv) sys.path as searchPaths
  (defun get-python-ver-and-syspath (workspace-root)
    "return list with pyver-string and json-encoded list of python search paths."
    (let ((python (executable-find python-shell-interpreter))
          (init "from __future__ import print_function; import sys; import json;")
          (ver "print(\"%s.%s\" % (sys.version_info[0], sys.version_info[1]));")
          (sp (concat "sys.path.insert(0, '" workspace-root "'); print(json.dumps(sys.path))")))
      (with-temp-buffer
        (call-process python nil t nil "-c" (concat init ver sp))
        (subseq (split-string (buffer-string) "\n") 0 2))))

  ;; I based most of this on the vs.code implementation:
  ;; https://github.com/Microsoft/vscode-python/blob/master/src/client/activation/languageServer/languageServer.ts#L219
  ;; (it still took quite a while to get right, but here we are!)
  (defun ms-pyls-extra-init-params (workspace)
    (destructuring-bind (pyver pysyspath) (get-python-ver-and-syspath (lsp--workspace-root workspace))
      `(:interpreter (
                      :properties (
                                   :InterpreterPath ,(executable-find python-shell-interpreter)
                                   ;; this database dir will be created if required
                                   :DatabasePath ,(expand-file-name (concat ms-pyls-dir "db/"))
                                   :Version ,pyver))
                     ;; preferredFormat "markdown" or "plaintext"
                     ;; experiment to find what works best -- over here mostly plaintext
                     :displayOptions (
                                      :preferredFormat "plaintext"
                                      :trimDocumentationLines :json-false
                                      :maxDocumentationLineLength 0
                                      :trimDocumentationText :json-false
                                      :maxDocumentationTextLength 0)
                     :searchPaths ,(json-read-from-string pysyspath))))  

  (lsp-define-stdio-client lsp-python "python"
                           #'ffip-get-project-root-directory
                           `("dotnet" ,(concat ms-pyls-dir "Microsoft.Python.LanguageServer.dll"))
                           :extra-init-params #'ms-pyls-extra-init-params)

  ;; lsp-python-enable is created by macro above 
  (add-hook 'python-mode-hook
            (lambda ()
              (lsp-python-enable)))

Conclusions

Although I would have preferred to do this with the two lsp-mode work-arounds, I am pretty satisfied with this setup.

With the number of users and development effort Microsoft’s Python Language Server has been enjoying and will probably continue to enjoy, it’s great knowing we can make use of this functionality in Emacs.

I am curious how well eglot, a smaller Emacs LSP package than lsp-mode, would do based on the integration above. (hint hint…)

Updates

2018-11-22

Uploaded new version of emacs-lisp init code with two improvements:

  • Thanks to reddit user cyanj for the print_function import suggestion, and commenter Erik Hetzner below for the old-style string interpolation suggestion, this setup should now also work with Python 2, in addition to 3.
  • The ms-pyls database directory is now created as a subdirectory of the bindir.

2018-11-20

So far, I have logged two issues, one with MS PyLS and one with lsp-mode, so that we can hopefully one day remove some of the work-arounds detailed above:

Configuring Emacs, lsp-mode and the python language server.

The language server protocol was proposed by Microsoft as a way for different editors and development environments to share language analysis backends

This post describes how to configure Emacs, lsp-mode and the palantir python-language-server for improved code intelligence when working on Python projects. (I’m planning a companion post for Emacs, C++ and the cquery language server.)

Goal

Before starting, it is motivating to see what we are working towards.

With a correctly configured setup, Emacs will sport, amongst others, improved completion with interactive documentation, imenu navigation, documentation on hover, and really snazzy find definitions (M-.) and find references.

See the following screenshots for some examples:

Screen Shot 2018-06-08 at 13.01.32_2018-06-08_14-29-25.png

lsp-python-imenu_2018-06-08_14-34-27.png

lsp-python-docs-on-hover_2018-06-08_14-34-43.png

Pre-requisites on the Python side

Install the python-language-server into the virtual environment, or user environment, that you’re planning to use.

These days, I tend to use pipenv:

cd my_project
pipenv install python-language-server[all]

The [all] means that it installs all optional providers, e.g. yapf formatting.

Pre-requisites on the Emacs side

In Emacs, install the required and some optional packages using for example M-x package-install:

  • lsp-mode – the main language server protocol package
  • lsp-ui – UI-related LSP extras, such as the sideline info, docs, flycheck, etc.
  • company-lsp – company-backend for LSP-based code completion.
  • projectile or find-file-in-project – we use a single function from here to determine the root directory of a project.

Emacs configuration

Add the following to your Emacs init.el, and don’t forget to read the comments.

If you’re not yet using use-package now would be a good time to upgrade.

(use-package lsp-mode
  :ensure t
  :config

  ;; make sure we have lsp-imenu everywhere we have LSP
  (require 'lsp-imenu)
  (add-hook 'lsp-after-open-hook 'lsp-enable-imenu)  
  ;; get lsp-python-enable defined
  ;; NB: use either projectile-project-root or ffip-get-project-root-directory
  ;;     or any other function that can be used to find the root directory of a project
  (lsp-define-stdio-client lsp-python "python"
                           #'projectile-project-root
                           '("pyls"))

  ;; make sure this is activated when python-mode is activated
  ;; lsp-python-enable is created by macro above 
  (add-hook 'python-mode-hook
            (lambda ()
              (lsp-python-enable)))

  ;; lsp extras
  (use-package lsp-ui
    :ensure t
    :config
    (setq lsp-ui-sideline-ignore-duplicate t)
    (add-hook 'lsp-mode-hook 'lsp-ui-mode))

  (use-package company-lsp
    :config
    (push 'company-lsp company-backends))

  ;; NB: only required if you prefer flake8 instead of the default
  ;; send pyls config via lsp-after-initialize-hook -- harmless for
  ;; other servers due to pyls key, but would prefer only sending this
  ;; when pyls gets initialised (:initialize function in
  ;; lsp-define-stdio-client is invoked too early (before server
  ;; start)) -- cpbotha
  (defun lsp-set-cfg ()
    (let ((lsp-cfg `(:pyls (:configurationSources ("flake8")))))
      ;; TODO: check lsp--cur-workspace here to decide per server / project
      (lsp--set-configuration lsp-cfg)))

  (add-hook 'lsp-after-initialize-hook 'lsp-set-cfg))

Putting it all together

Importantly, use pyvenv or something similar to switch to the relevant virtualenv before opening the first Python file.

When you open the file, the pyls should be automatically started up, and you can edit away with LSP-powered code intelligence.

This often gives better and more detailed results than elpy, probably because pyls uses a mix of static and dynamic (introspection-based) analysis.

Furthermore, the handling of LSP servers in Emacs can be unified, giving the same consistent level of support across a whole range of programming languages.

Use the Google Cloud Speech API to transcribe a podcast

As I was listening to the December 21 episode of the CPPCast, together with TWiML&AI my two most favourite podcasts, I couldn’t help but be a little bewildered by the number of times the guest used the word “like” during their interview.

Most of these were examples of speech disfluency, or filler words, but I have to admit that they detracted somewhat from an otherwise interesting discourse.

During another CPPCast episode which I recently listened to, the hosts coincidentally discussed the idea of making available transcriptions of the casts.

These two occurrences, namely the abundance of the “like” disfluency and the mention of transcription, connected in the back of my mind, and produced the idea of finding out how one could go about to use a publically available speech API to transcribe the podcast, and count the number of utterances of the word “like”.

Due to the golden age of information we find ourselves in, this was not that hard at all.

Selecting the API

After a short investigation of Microsoft’s offerings seemed to indicate that I would not be able to transcribe just under an hour of speech, I turned to Google.

The Google Cloud Speech API has specific support for the asynchronous transcription of speech recordings of up to 3 hours.

Setting up the project and service account

Make sure that you can access the Google Cloud Dashboard with your google account. I created a new project for this experiment called cppcast-speech-to-text.

Within that project, select APIs & Services dashboard from the menu on the left, and then enable the Speech API for that project by selecting the Enable APIs and Services link at the top.

Next, go to IAM & Admin and Service Accounts via the main menu, and create a service account for this project.

Remember to select the download JSON private key checkbox.

Transcode and upload the audio

For the Speech API, you will have to transcode the MP3 to FLAC, and you will have to upload the file to a Google Cloud Storage bucket.

I transcoded the MP3 to a 16kHz mono FLAC (preferred by the API) as follows:

ffmpeg -i cppcast-131.mp3 -y -vn -acodec flac -ar 16000 -ac 1 cppcast-131.flac

This turned my 39MB MP3 into a 61MB FLAC file.

Create a storage bucket via the cloud dashboard main menu’s StorageBrowser menus, and then upload the FLAC file to that bucket via the web interface.

Note down the BUCKETNAME and the FILENAME, you’ll need these later when starting the transcription job.

Transcribe!

I used the Asynchronous Speech Recognition API, as this is the only API supporting speech segments this long.

First startup the Google Cloud Shell by clicking on the boxed >_ icon at the top left. In this super convenient Debian Linux shell, gcloud is already installed, which is why I chose to use it.

Upload your service account JSON private key, and activate it by doing the following:

export GOOGLE_APPLICATION_CREDENTIALS=~/your-service-account-key.json

Using one of the installed editors, or just uploading, create a file called async-request.json in your home:

{
  "config": {
      "encoding": "FLAC",
      "sampleRateHertz": 16000,
      "language_code": "en-US"
  },
  "audio":{
    "uri":"gs://BUCKETNAME/FILENAME"
  }
}

You are now ready to make the request using curl, and the async-request.json file you created:

curl -X POST \
     -H "Authorization: Bearer "$(gcloud auth application-default print-access-token) \
     -H "Content-Type: application/json; charset=utf-8" \
     --data @async-request.json "https://speech.googleapis.com/v1/speech:longrunningrecognize"

You should see a response looking something like this:

{
  "name": "LONG_JOB_NUMBER"
}

Soon after this, you can start seeing how your job is progressing:

curl -H "Authorization: Bearer "$(gcloud auth application-default print-access-token) \
     -H "Content-Type: application/json; charset=utf-8" \
     "https://speech.googleapis.com/v1/operations/LONG_JOB_NUMBER"

The response will look like this while your request is being processed:

{
  "name": "LONG_JOB_NUMBER",
  "metadata": {
    "@type": "type.googleapis.com/google.cloud.speech.v1.LongRunningRecognizeMetadata",
    "progressPercent": 29,
    "startTime": "2018-02-14T20:17:05.885941Z",
    "lastUpdateTime": "2018-02-14T20:22:26.830868Z"
  }
}

In my case, the 56 minute podcast was transcribed in just under 17 minutes.

When the job is done, the response to the above curl request will contain the transcribed text. It looks something like this:

{
  "name": "LONG_JOB_NUMBER",
  "metadata": {
    "@type": "type.googleapis.com/google.cloud.speech.v1.LongRunningRecognizeMetadata",
    "progressPercent": 100,
    "startTime": "2018-02-14T20:17:05.885941Z",
    "lastUpdateTime": "2018-02-14T20:35:16.404144Z"
  },
  "done": true,
  "response": {
    "@type": "type.googleapis.com/google.cloud.speech.v1.LongRunningRecognizeResponse",
    "results": [
      {
        "alternatives": [
          {
            "transcript": "I said 130 want to see PP cast with guest Nicole mazzuca recorded September 14th 2017",
            "confidence": 0.8874592
          }
        ]
      },

// and so on for the whole recording

You can download the full transcription here.

Too many likes?

I wrote the following Python to tally up the total number of words, and the total number of “like” utterances.

import json

with open('/Users/cpbotha/Downloads/cppcast-131-text.json') as f:
    # results: a list of dicts, each with 'alternatives', which is a list of transcripts
    res = json.load(f)['response']['results']

num_like = 0
num_words = 0
for r in res:
    alts = r['alternatives']
    # ensure that we only have one alternative per result
    assert len(alts) == 1
    # break into lowercase words
    t = alts[0]['transcript'].strip().lower().split()
    # tally up total number of words
    num_words += len(t)
    # count the like utterances
    num_like += sum(1 for w in t if w == 'like')

In this 56 minute long episode of CPPCast, 7411 words were detected, 214 of which were the word “like”.

This is not quite as many as I imagined, but still comes down to 3.82 likes per minute, which is enough to be quite noticeable.

Conclusions

  • We should try to use “like” and other speech disfluencies far less often. Inserting a small pause makes more sense: The speaker and the listeners get a little break to process the ongoing speech, and the speech comes across as more measured.
  • All in all, it took me about 2 hours from idea to transcribed text. I find it wonderful that machine learning for speech-to-text has become so democratised.
  • After my transcription job was complete, I saw that it was possible to supply phrase hints to the API. I could have uploaded a list of words we expect to occur during this podcast, such as “CPPCast” and “C++”, and this would have been used by the API to further improve its transcription.

Creating a Django migration for a GiST / GIN index with a special index operator.

In order to add a GiST index on a Postgres database that could be used to accelerate trigram matches using the pg_trgm module and the special gist_trgm_ops operator, I had to code up a special Django Index

Django will hopefully soon support custom index operators, but if you need the functionality right now, this example will do the trick.

The special GiST index class looks like this:

from django.contrib.postgres.indexes import GistIndex

class GistIndexTrgrmOps(GistIndex):
    def create_sql(self, model, schema_editor):
        # - this Statement is instantiated by the _create_index_sql()
        #   method of django.db.backends.base.schema.BaseDatabaseSchemaEditor.
        #   using sql_create_index template from
        #   django.db.backends.postgresql.schema.DatabaseSchemaEditor
        # - the template has original value:
        #   "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s"
        statement = super().create_sql(model, schema_editor)
        # - however, we want to use a GIST index to accelerate trigram
        #   matching, so we want to add the gist_trgm_ops index operator
        #   class
        # - so we replace the template with:
        #   "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s gist_trgrm_ops)%(extra)s"
        statement.template =\
            "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s gist_trgm_ops)%(extra)s"

        return statement

Which you can then use in your model class like this:

class YourModel(models.Model):
    some_field = models.TextField(...)

    class Meta:
        indexes = [
            GistIndexTrgrmOps(fields=['some_field'])
        ]

The migration will then generate SQL (use manage.py sqlmigrate to inspect the SQL) which looks like this:

CREATE INDEX "app_somefield_139a28_gist" ON "app_yourmodel" USING gist ("some_field" gist_trgrm_ops);

You can easily modify this pattern for other (Postgres) indices and their operators.

How to debug PyInstaller DLL / PYD load failed issues on Windows

TL;DR

When debugging DLL load errors on Windows, use lucasg’s open source and more modern rewrite of the old Dependency Walker software. Very importantly, keep on drilling down through indirect dependencies until you find the missing DLLs.

The Problem

Recently I had to package up a wxPython and VTK-based app for standalone deployment on Windows. Because of great experience with PyInstaller, I opted to use this tool.

With the first try with the freshly built package on the deployment machine, it refused to start up due to an ImportError: DLL load failed: The specified module could not be found., and specifically with the vtk.vtkCommonCorePython.pyd Python extension DLL.

What was frustrating, is that the relevant file was definitely present and in the right place, namely the same folder as the exe file.

A test app that imports only wx (4.0.0rc) and vtk (8.0.1) generated the following traceback:

[3096] LOADER: Running pyiboot01_bootstrap.py
[3096] LOADER: Running t1.py
Traceback (most recent call last):
  File "site-packages\vtk\vtkCommonCore.py", line 5, in <module>
  File "C:\Users\cpbotha\Miniconda3\envs\env1\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 718, in load_module
ImportError: DLL load failed: The specified module could not be found.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t1.py", line 9, in <module>
  File "C:\Users\cpbotha\Miniconda3\envs\env1\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 631, in exec_module
  File "site-packages\vtk\__init__.py", line 41, in <module>
  File "C:\Users\cpbotha\Miniconda3\envs\env1\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 631, in exec_module
  File "site-packages\vtk\vtkCommonCore.py", line 9, in <module>
  File "C:\Users\cpbotha\Miniconda3\envs\env1\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 718, in load_module
ImportError: DLL load failed: The specified module could not be found.
[3096] Failed to execute script t1
[3096] LOADER: OK.
[3096] LOADER: Cleaning up Python interpreter.

Digging in using pdb and ctypes.WinDLL

By adding import pdb; pdb.set_trace() at a strategic point, I could use the Python debugger to try and investigate why it was not able to load the DLL which was clearly in the right place.

First I confirmed that it could load other namespaced PYDs in the same directory:

(Pdb) import ctypes
(Pdb) ctypes.WinDLL("wx.siplib.pyd")
<PyInstallerWinDLL 'C:\Users\cpbotha\Downloads\t1\wx.siplib.pyd', handle 7fff66320000 at 0x151a6781048>

However, using the same invocation on the offending PYD would still raise an error. By the way, I had to disable PyInstaller’s silly exceptions, because they masked the underlying OSError exception.

Great deal of good that did me, because Windows 10 gets me no additional information other than reporting “error 126” on the top-level DLL. Why can’t such a mature system not offer a little more guidance?

(Pdb) ctypes.WinDLL("vtk.vtkCommonCorePython.pyd")
>>> OSError: [WinError 126] The specified module could not be found
(Pdb) ctypes.WinDLL("C:\\Users\\cpbotha\\Downloads\\t1\\vtk.vtkCommonCorePython.pyd")
>>> OSError: [WinError 126] The specified module could not be found
(Pdb) import os
(Pdb) os.path.exists("C:\\Users\\cpbotha\\Downloads\\t1\\vtk.vtkCommonCorePython.pyd")
True
(Pdb) ctypes.WinDLL("vtkCommonCorePython.pyd")
>>> OSError: [WinError 126] The specified module could not be found
(Pdb) os.path.exists("vtkCommonCorePython.pyd")
True

I did spend more time than I should have in pdb tracing through the whole complicated PyInstaller and Python import code innards. The fact that some PYDs loaded and some did not should have more quickly pushed me in the direction of nested dependencies.

Seeing the light with lucasg’s Dependencies

Although I had at an earlier stage checked DLL loading first with Dependency Walker (this gets very confused by the new Windows api-ms-win-* DLLs) and later with lucasg’s improved utility, I did not drill down far enough into the dependency tree.

It’s important to keep on drilling down until you see missing DLLs, the application won’t automatically traverse the tree.

Anyways, drilling down from the offending vtk.vtkCommonCorePython.pyd soon enough led me to the culprit: The Intel Threading Building Blocks (TBB) DLL conda package I was using was accidentally built in debug mode, and the debug runtimes it relied one were obviously not being deployed:

screenshot_2017-12-06_13-52-48.png

After switching to a TBB conda package from a different channel, the app was finally able to start up and run on the deployment machine.