I use python with KM all the time, so this is definitely possible.
It's been some time since I did the initial setup with homebrew, but I believe the first step was installing Python 3 globally (as opposed to a virtual environment), so apologies if that's the missing piece of the puzzle for you (let me know?)
Also, sorry if I missed any key details you might've already shared.
I have two flavours of using Python with KM.
- A self-contained Python script running in a KM shell action (i.e a .sh file)
- A Python script being executed by a shell action (i.e a .sh executing a .py)
I can provide you with at least the opening/closing of each of these scripts if that's helpful for you.
Option 1 - self-contained Python script
Exists in a .sh file I run and ends with a print statement (captured as a variable).

Installation Doc
1 Install the code command in PATH (if not done already): In VSCode, open the Command Palette (⌘+Shift+P), type Shell Command: Install 'code' command in PATH, and run that command. This makes code accessible within your user’s PATH in a normal shell environment. However, Keyboard Maestro might still not inherit this environment, depending on how the shell initialization is set up.
2 Use the full path to code: Find out where code is located by running which code in the terminal. For example, it might be located at /usr/local/bin/code. Then, update the script’s call this in the subprocess.run section
#!/Library/Frameworks/Python.framework/Versions/3.12/bin/python3
import subprocess
import sys
import os
from datetime import datetime
from typing import Optional
# ---------------------------------------------------------------
# Keyboard Maestro Variable Reading And Writing (No logging here)
# ---------------------------------------------------------------
def KmVar(variable_name, value=None):
"""
Get or set a Keyboard Maestro variable.
To **get** a variable's value, call `KmVar(variable_name)`.
To **set** a variable's value, call `KmVar(variable_name, value)`.
This function supports global, local, and instance variables.
Parameters:
variable_name (str): The name of the Keyboard Maestro variable.
value (str, optional): The value to set the variable to. If omitted, the function retrieves the variable's value.
Returns:
str: The value of the variable when getting. Returns an empty string when setting.
"""
km_instance = os.environ.get("KMINSTANCE", None) # Retrieve the KMINSTANCE environment variable
# Convert variable_name to lowercase for caseinsensitive comparison
variable_name_lower = variable_name.lower()
is_local_or_instance = variable_name_lower.startswith("local") or variable_name_lower.startswith("instance") # Check variable scope
if value is not None:
# **Setting** a Keyboard Maestro variable
if is_local_or_instance:
if km_instance:
# Set local/instance variable with the instance parameter
subprocess.run([
'osascript', '-e',
f'tell application "Keyboard Maestro Engine" to setvariable "{variable_name}" instance "{km_instance}" to "{value}"'
], check=True)
else:
# Handle missing KMINSTANCE for local/instance variable
print("Error: KMINSTANCE is not set. Cannot set local/instance variable.")
else:
# Set global variable directly
subprocess.run([
'osascript', '-e',
f'tell application "Keyboard Maestro Engine" to setvariable "{variable_name}" to "{value}"'
], check=True)
return "" # Return empty string after setting
else:
# **Getting** a Keyboard Maestro variable
if is_local_or_instance:
if km_instance:
# Get local/instance variable with the instance parameter
result = subprocess.run([
'osascript', '-e',
f'tell application "Keyboard Maestro Engine" to getvariable "{variable_name}" instance "{km_instance}"'
], capture_output=True, text=True, check=True)
return result.stdout.strip()
else:
# Handle missing KMINSTANCE for local/instance variable
print("Error: KMINSTANCE is not set. Cannot get local/instance variable.")
return ""
else:
# Get global variable directly
result = subprocess.run([
'osascript', '-e',
f'tell application "Keyboard Maestro Engine" to getvariable "{variable_name}"'
], capture_output=True, text=True, check=True)
return result.stdout.strip()
# ---------------------------------------------------------------
# Conditional Logging Setup
# ---------------------------------------------------------------
logging_enabled = (KmVar("CopiedPathOpenVSCodeLogWrite") == "true")
def active_log_message(message: str):
"""
Logs messages when logging is enabled.
Args:
message (str): The message to log.
"""
# Logging will only happen if LOGWRITEVARIABLE is "true".
# If logging is disabled, this function won't be called (we'll call noop_log_message).
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"{timestamp} - {message}"
current_log = KmVar("CopiedPathOpenVSCodeLog")
# Append the new log entry on a new line if current_log is not empty
updated_log = (current_log + "\n" + log_entry) if current_log else log_entry
KmVar("CopiedPathOpenVSCodeLog", updated_log)
except Exception:
# If logging fails for any reason, we silently pass so script doesn't crash
pass
def noop_log_message(message: str):
"""
No-operation logging function when logging is disabled.
Args:
message (str): The message to log (ignored).
"""
# When logging is disabled, calling this function does nothing.
pass
# Assign the appropriate logging function based on whether logging is enabled
log_message = active_log_message if logging_enabled else noop_log_message
# ---------------------------------------------------------------
# Main Functionality - Open Script In VSCode
# ---------------------------------------------------------------
def open_script_in_vscode(variable_name: str = "OpenInVSCodeScriptFilePath") -> None:
"""Reads a file path from a Keyboard Maestro variable and opens it in VSCode.
Args:
variable_name (str): The name of the Keyboard Maestro variable containing the file path.
"""
log_message("Starting open_script_in_vscode() function")
file_path = KmVar("LOCALOpenInVSCodeScriptFilePath")
if not file_path:
# If no file path is found, log the event and print an error message.
log_message("No file path found in Keyboard Maestro variable.")
print(f"No file path found in Keyboard Maestro variable '{variable_name}'.", file=sys.stderr)
return
# Expand '~' to ensure full path resolution
file_path = os.path.expanduser(file_path)
log_message(f"Resolved file path: {file_path}")
try:
# Attempt to open the file in VSCode using the 'code' CLI tool
log_message(f"Attempting to open file in VSCode: {file_path}")
subprocess.run(['/usr/local/bin/code', file_path], check=True)
# If successful, log success and print confirmation
log_message(f"Opened '{file_path}' in VSCode successfully.")
print(f"Opened '{file_path}' in VSCode successfully.")
except FileNotFoundError:
# If 'code' is not found in PATH, log and print the error
log_message("VSCode command-line tool 'code' not found.")
print("VSCode command-line tool 'code' not found. Please ensure VSCode is installed and 'code' is in your PATH.", file=sys.stderr)
except subprocess.CalledProcessError as e:
# If there was an error running 'code', log and print the error
log_message(f"Failed to open '{file_path}' in VSCode: {e}")
print(f"Failed to open '{file_path}' in VSCode: {e}", file=sys.stderr)
# ---------------------------------------------------------------
# Entry Point
# ---------------------------------------------------------------
if __name__ == "__main__":
open_script_in_vscode()
Option 2 - Shell script executes a Python script
Getting results returned to KM with this method is much more involved. The simplest way would be to have the .py output a file which you watch for and re-import into KM. I'll leave you to pick what's best for you in that regard.
Begins with this shell script in KM:

#!/bin/bash
# Define the path to your Python script
SCRIPT_PATH="$HOME/Library/[... PATH HERE ...]/Python-FavouriteActionReplacementTagManagement.py"
# Check if script.py is already running
if pgrep -f "$SCRIPT_PATH" >/dev/null; then
echo "Python-FavouriteActionReplacementTagManagement.py is already running."
exit 0
fi
# Run the Python script in the background
"$SCRIPT_PATH"
This then executes the .py script.
Below are my reminders/instructions for when I load this on a new Mac, which you might also find useful:
Overview
[ ... irrelevant text ... ]
On a clean mac setup, two main problems arise:
• Shell launcher errors (bad SCRIPT_PATH syntax, stray /dev / null, still activating an out-of-date virtualenv).
• Python import failures (script picking up the old venv’s site-packages, missing packages like nicegui, chardet, charset-normalizer, and architecture mismatches for compiled extensions).
⸻
1. Fixing the Shell-Launcher
File: Shell-LaunchFavouriteActionReplacementTagManagement.sh
1. Correct SCRIPT_PATH assignment
Bad:
SCRIPT_PATH = "/Users/[... PATH HERE ...]Python-FavouriteActionReplacementTagManagement.py"
Good:
SCRIPT_PATH="$HOME/Library/[... PATH HERE ...]/Python-FavouriteActionReplacementTagManagement.py"
2. Clean up the “already running” check
Bad:
if pgrep - f "$SCRIPT_PATH" > /dev / null
then
…
fi
Good:
if pgrep -f "$SCRIPT_PATH" >/dev/null; then
echo "Script is already running."
exit 0
fi
3. Invoke via shebang
Let the script’s own #!/usr/bin/env python3 take effect:
"$SCRIPT_PATH"
⸻
2. Patching the Python Script
File: Python-FavouriteActionReplacementTagManagement.py
2.1 Standardise the Shebang
Ensure the very first line is:
#!/usr/bin/env python3
so macOS uses your system Python 3.
2.2 Strip Out the Old Virtualenv
Immediately after the shebang, drop any paths under the old venv:
import sys, site, os
venv_path = '/Users/[... PATH HERE ...]/Python/venv'
sys.path = [p for p in sys.path if not p.startswith(venv_path)]
2.3 Add the Global Site-Packages
Compute where the system Python keeps its packages (not the venv) and add it:
base = getattr(sys, "base_prefix", sys.prefix)
version = f"{sys.version_info.major}.{sys.version_info.minor}"
system_site = os.path.join(base, "lib", f"python{version}", "site-packages")
site.addsitedir(system_site)
2.4 Lazy-Install NiceGUI
Wrap the import so that missing GUI support is automatically installed into the user site:
try:
from nicegui import ui, app
except ImportError:
import subprocess, sys
subprocess.check_call([
sys.executable, "-m", "pip", "install", "--user", "nicegui"
])
from nicegui import ui, app
⸻
3. Outcome
• No more weird shell syntax: the launcher cleanly hands off to the Python script.
• System Python is used (ARM64) instead of the old x86_64 venv, so compiled extensions load correctly.
• All dependencies (chardet, charset-normalizer, pydantic-core, nicegui, etc.) should (!) be pulled from the global site-packages or installed on demand.
#!/usr/bin/env python3
import os
import re
import json
import subprocess
import traceback
from typing import Dict, List, Any, Optional
from datetime import datetime
try:
from nicegui import ui, app
except ImportError:
import subprocess
import sys
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--user', 'nicegui'
])
from nicegui import ui, app
import sys
import site
import os
# Remove the virtualenv site-packages to force use of global packages
venv_path = '/Users/[... Path here ..] /Python/venv'
sys.path = [p for p in sys.path if not p.startswith(venv_path)]
# Compute the system Python's site-packages directory (not the venv's)
base = getattr(sys, "base_prefix", sys.prefix)
version = f"{sys.version_info.major}.{sys.version_info.minor}"
system_site = os.path.join(base, "lib", f"python{version}", "site-packages")
site.addsitedir(system_site)
# Favourite Action Replacement Tag Management
# V1 03/01/2025
... [ OTHER CODE ] ...
# --------------------------------------------
# Main Entry Point
# --------------------------------------------
def main() -> None:
"""
Determines the 'mode of operation' from KM variable "LOCALFavActionModeOfOperation"
and dispatches to the corresponding function. If the mode is unknown or an error
occurs, a JSON result is printed with error details.
"""
moo = KmVar("LOCALFavActionModeOfOperation").strip().lower()
log_message(f"Script invoked with MOO = '{moo}'.")
if not moo:
# If no mode specified, just exit with an error JSON
err_obj = {
"ScriptComplete": False,
"Error": "No mode specified",
}
write_temp_result(err_obj)
# write_temp_result(err_obj)
# print(json.dumps(err_obj, indent=2))
return
try:
if moo == "parse":
parse_mode_main()
elif moo == "intercept":
intercept_mode_main()
else:
err_obj = {
"ScriptComplete": False,
"Error": f"Unknown MOO: {moo}"
}
write_temp_result(err_obj)
# print(json.dumps(err_obj, indent=2))
except Exception as e:
# Log and print error
traceback_str = traceback.format_exc()
log_message(f"Error in main(): {traceback_str}")
err_obj = {
"ScriptComplete": False,
"Error": f"{e}"
}
write_temp_result(err_obj)
# print(json.dumps(err_obj, indent=2))
except Exception as e:
log_message(f"Error: {e}")
raise
# --------------------------------------------
# If run as standalone script
# --------------------------------------------
if __name__ == "__main__":
main()
Hopefully this helps!