Need Help Running Python Shell Script from KM

What exactly doesn’t work for you. I’ve checked my solution in practice. Could you run my proposal and show here what you see in KM window. Do you see any result I especially put only import module and print for dir of module to check what see the python Did you installed python from homebrew. Did you activated venv using full path as in my example? Please notice that I put invalid path in example, you jave to change user to your userid of course.

Id you put valid fullpath to venv created in previous step, there in other settings, everything is taken from virtual environment and the python, which was used in previous step to create that environment.
That is why I suggest to install homebrew’s python to be independent from conda (which id very capricious).

I hope that the other replies give cause for hope. Since the solution by @nutilius works for him, hopefully it will work for you, but if for any reason using execute shell script proves too frustrating, try other options, e.g.

  • Have the code output to a file.
  • Set up KM actions to:
    • activate the terminal;
    • paste in that code;
    • “press” Return.
  • Then use the KM action Read a file and proceed from there.

That is far from elegant but… should all else fail, that might perhaps be an acceptable way to get the job done for the time being..!

So I've back to home and put exactly your code to KM window (execute shell script) with full path to python which created this environment. It works!.

If you are not sure what should be in the first line of KM window, execute Terminal, go to subdirectory with project and type

ls -l venv/bin

In my system I see something like that:

-rw-r--r--@ 1 user  staff  2204 25 cze 21:05 activate
-rw-r--r--@ 1 user  staff   936 25 cze 21:05 activate.csh
-rw-r--r--@ 1 user  staff  2207 25 cze 21:05 activate.fish
-rw-r--r--@ 1 user  staff  9031 11 cze 17:36 Activate.ps1
-rwxr-xr-x@ 1 user  staff   267 25 cze 21:05 pip
-rwxr-xr-x@ 1 user  staff   267 25 cze 21:05 pip3
-rwxr-xr-x@ 1 user  staff   267 25 cze 21:05 pip3.13
lrwxr-xr-x@ 1 user  staff    10 25 cze 21:05 python -> python3.13
lrwxr-xr-x@ 1 user  staff    10 25 cze 21:05 python3 -> python3.13
lrwxr-xr-x@ 1 user  staff    44 25 cze 21:05 python3.13 -> /opt/homebrew/opt/python@3.13/bin/python3.13

The last portion show full path to python interpreter which should be used in KM window in first line

1 Like

I'm afraid I haven't kept up with the whole thread, so my apologies if this has already been suggested or addressed. Have you tried saving the script as a file and using the option for the action to execute the script file instead text script? I've had at least one script that only works when executing from the file.

image

The problem is that OP started off using Anaconda3 which, as you pointed out

is very capricious

...although I'd have used more swear words myself :wink:

Ideally OP would start again, using your method and avoiding conda altogether, but that may not be an option in their environment (it may be forced on them for compatibility, or some other reason).

2 Likes

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.

  1. A self-contained Python script running in a KM shell action (i.e a .sh file)
  2. 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).

image

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:

image

#!/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!

Short explanation about venv.

Since (as I remember) version 3.12 standard tehaviore in python don’t allow to install external packages globally (using pip). It is named „external controlled environment” and means that package manager (homebrew, apt, yum/dnf) should provide such package available for everyone. But in many cases you don’t have such package in system package set, so you are in a trap.
There are 3 possibilities:

  • force installation anyway - but it is always dangerous
  • create and use by hand venv like I explained before
  • use new tool pipx, which in practice setup venv for you (I don’t like pipx)

This is „new world” of python - we may not agree, but we don’t have the power to change „the progress” :wink:.

Thank you all. I tried @nutilius' suggestion but still got error messages. I have a second Mac with a non-Anaconda Python install. I get the same error messages there. I found the location of the pyperclip module and put it in the script like so:

import sys
sys.path.append('/Users/theostar/anaconda3/lib/python3.11/site-packages/pyperclip/init.py')
import pyperclip

I did the same on my other Mac, with a different path.

import sys
sys.path.append('/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/pyperclip/init.py')
import pyperclip

The error message says Keyboard Maestro cannot find pyperclip.

I also tried hemicyon's suggestion to to execute the script file instead text script. That made a lot of sense. Unfortunately, I got the same error message: pyperclip is not found.

We may conclude that getting Python to work from inside of Keyboard Maestro is way too complicated. But Python is a programming language (stating the obvious here). There has to be an easier way to get a Python script to work on a Mac. Otherwise, what would be the point?

Most of the scripts I’ve written work on text. The way I’d work with them is while writing, I’d execute a script and have the result pasted in my text. Anything else would slow me down to the point that I might as well do things manually. I have macros in Keyboard Maestro for this, but a Python script is cleaner and offers more possibilities.

This is very interesting, if you have some power for it yet, could you screenshot your script (in KM) and error message?

Did none of the formats/styles I posted work?

Those paths look too long -- you're telling it where to look for pyperclip so it can install it. So you'll need to stop at either site-packages or maybe site-packages/pyperclip.

Getting python to work, full stop, is complicated. It's made easier by various installers/managers such as pip and conda -- but they make it easier by messing around with your shell's environment.

That messing isn't present in the KM "Execute a Shell Script" action's environment so you have to recreate it. How you do that will depend on what messing has happened -- sourceing the ~/.bash_profile worked for me -- what works for you will depend on how you installed python, whether you are on Intel or Apple Silicon, your default shell, etc, etc, etc...

Just for my curiosity I made some tests. The results and conclusions below:

  • I've downloaded and installed (globally) the newest anaconda package
  • After installation anaconda added to my shell profiles (both = for bash and for zsh) additional part, which will initialise (if the shell is in run in login mode) base environment in conda, what is visible in command prompt as (base) prefix in command prompt
  • Standard environment don't have pyperclip package, so I have to install it in environment using conda install -c conda-forge pyperclip
  • I have confirmed that pyperclip is installed by running python in shell and interactively import pyperclip
  • To run program in conda environment we need to execute soemthing like conda run python but ...
    • conda captures output of running program (I don't know why)
    • to disable above behaviour I have to add --no-capture-output
  • So there are two conditions to see results in output window
    • execute bash in login mode like #!/bin/bash -l to establish anaconda environment
    • add --no-capture-output parameter to conda program

The final part in execute shell window in my system (which works with conda) is:

#!/bin/bash -l 
conda run --no-capture-output python <<EOF
import pyperclip

x = pyperclip.paste()
lijst = x.split()
x1 = lijst[0]
x2 = lijst[1]

if x1[0].isupper():
    x1 = x1.lower()
    x2 = x2.title()

lijst[0] = x2
lijst[1] = x1
x = " ".join(lijst)

pyperclip.copy(x)
print(x)
EOF
2 Likes

Okay so your code, swaps first and second word if the first letter of the first word is capital... and it converts it to lowercase and swap etc etc...

So, I got your code to work as follows.

  1. I created a file swap_words.py with the following content

import pyperclip
x = pyperclip.paste()
#print (x)
lijst = x.split()
x1 = lijst[0]
x2 = lijst[1]
if x1[0].isupper():
x1 = x1.lower()
x2 = x2.title()
lijst[0] = x2
lijst[1] = x1
x= " ".join(lijst)
pyperclip.copy(x)

I am sure you already have this file that you used for testing the code. Now just call this file using your local python. Make sure to give the complete path to Python as well as to the swap_words.py

BTW the display in this window is removing the indentation, so the code above is not correct Python. The two lines below the if statement should be indented.

You dont have to worry about passing the clipboard text to the file because pyperclip grabs it automatically. Also, if you really want to display the results in a window put a print statement right before your last line.

When posting code to these forums, place three backticks ``` on the line before your code and three backticks ``` on the line after your code so that it will be formatted properly. You can also highlight an entire code block and click the </> button on the toolbar to wrap the block for you.

import pyperclip
x = pyperclip.paste()
#print (x)
lijst = x.split()
x1 = lijst[0]
x2 = lijst[1]
if x1[0].isupper():
    x1 = x1.lower()
    x2 = x2.title()
lijst[0] = x2
lijst[1] = x1
x= " ".join(lijst)
pyperclip.copy(x)

I know, the code should be indented. But it works just the same. And the code I supplied is just an example. The real question is, "How can I get Python to work from inside Keyboard Maestro with pyperclip?" And despite all the wonderful help and tips on this forum, it still doesn’t work because it doesn’t find pyperclip whatever I do.

Did you try anything along the lines of my Plan B suggestion above? If KM can’t be persuaded to find pyperclip directly, it can still control the terminal, and that knows.

I've checked all in my system setup with conda and pyperclip. See my description above and all step which was done by me. Does you do that? The important thing is to start script with bash in login mode:

#!/bin/bash -l

to setup your conda environment.
In my case everything worked.

Please check my description.

Did you try what I suggested? I was talking about indentation in my code and not yours.

  • Pls follow my steps exactly as I described them.
  • Save Python script to a file
  • Run it outside Keyboard Maestro. Make sure it works first.
  • Call from within Keyboard Maestro.

Let me know where you fail. I can help you beyond that. Python is my bread and butter.

Thanks, to Roosterboy for letting me know how to share code on this forum.

I haven't read through this thread in detail, but I'll link to a previous posting with a subroutine that executes a KM string as a Python script. There is nothing fancy here, but some might find it useful.