Should an IDE take over as the executor of .py files?

Yeah, I know this isn’t really the right place for this, but there’s a real question here that the audience might have some insight into.

Recently, VS Code seems to be installing itself as the program to run .py programs, and I can’t for the life of me find out where this is set. I can’t be more precise as to “recently”, but it may have been when the switch to pylance happened, I don’t think I recall it happening with the earlier language server.

I work on a project which has a large testsuite and a small number of the tests are now failing because instead of being run by Python, they pop up an instance of VS Code with the test script in the editor window. All five tests have in common that there’s a python script that mocks an executable. In the entire rest of the testsuite this is wrapped so as to not cause problems, in other words something like _python_ mock_cc.py ... where _python_ is the carefully obtained name of the current interpreter and all works fine. But for these tests, they are exercising a feature that hashes of executed command lines are cached for future comparisons (it’s a build tool) so it has to be the exact command line to be able to perform the tests, it can’t be fudged.

So the two-part question: is it reasonable for an IDE to install itself as the default program to run .py files on Windows (that’s the piece I’m using to tenuously link this post to the intent of this channel :-), and if so, how can a program (meaning: our test harness) detect that this is the case so we can skip tests that would effectively “hang” in this event? This isn’t a problem for our CI as we can ensure that “grabbers” aren’t part of the provisioning, but it’s still irritating for developer workstations. The settings applet “Choose default apps by file type” does clearly show Visual Studio Code as the default for .py and .pyi files, but I have no clue how to detect this programmatically.

1 Like

That happened in the 1.60 release which came out in early September.

It’s a Windows setting for file associations. See Visual Studio Code August 2021 for slightly more details about the change.

It has nothing to do with Pylance or the Python extension.

I wouldn’t say it’s a “run” association, it’s an association of what program should handle that file when you double-click it (which you could view as “running” the file). So from that perspective I think it’s reasonable to have an editor set up such that if I double-click on a Python file it will launch my editor (whether it’s by the editor doing it or me as a user).

How are you calling Python to execute your code that’s causing this? Some Windows API that uses the OS’s “open” functionality?

2 Likes

In this case it’s building an executable python script and launching it with os.spawnve

1 Like

… the spawnve thing is a historical result of the former project team sacrificing many chickens 15 years ago and wrapping the existing os.spawnve in a way they decided made it thread-safe-enough, and I haven’t been allowed to touch it (yet). It will move to a proper subprocess call someday.

In any case, if this constructed script that’s going to mock a real compiler is just run directly as a command from a cmd shell, the same thing happens: code pops it up in an editor window, and since the default assumption is untrusted workspace (boy do I hate that feature - sorry, way off topic for a Python list), it doesn’t try to run it, and it just sits.

It also acts as the action taken when you execute the file from the command line via a PATHEXT .py extension - which makes starting an editor very much the wrong thing to happen.

Personally, I don’t like editors that make “double click to edit” the default on executable script files like this. But I just say “no” when the editor offers to set associations, and don’t worry too much. I certainly hope VS Code doesn’t set those associations without giving the user the option to say no. (I have VS Code, and it didn’t set the associations that for me - maybe because I’ve had it since before that feature was added?)

turns out on that machine Code was installed via chocolatey, so when it updates, it must just have gotten however that August version was packaged, no questions asked - teaches me a disadvantage of provisioning that way, I guess. At least now I understand enough I can add to notes for the testsuite users to reduce any surprise factor.

Is there any way for Python code to inspect to find out that this is going to happen - if so, our test runner could set a flag that tests that are going to run into a problem should skip themselves?

1 Like

Regardless of PATHEXT, CMD will find and run a “.py” file if you include the extension in the name. Maybe you’re using PowerShell, which limits normal execution in the current console session to files with extensions in PATHEXT. For all other file extensions, PowerShell calls ShellExecuteExW() without the flag that makes the child process inherit the current console. It should still run the script, but the new console will be destroyed when the process exits.

import ctypes

shlwapi = ctypes.OleDLL('shlwapi')
shlwapi.AssocQueryStringW.argtypes = (
    ctypes.c_ulong, # flags
    ctypes.c_ulong, # str
    ctypes.c_wchar_p, # pszAssoc
    ctypes.c_wchar_p, # pszExtra
    ctypes.c_wchar_p, # pszOut
    ctypes.POINTER(ctypes.c_ulong), # pcchOut
)

ASSOCF_NOTRUNCATE = 0x00000020
ASSOCF_INIT_IGNOREUNKNOWN = 0x00000400
ASSOCSTR_COMMAND = 1
ASSOCSTR_EXECUTABLE = 2
E_POINTER = ctypes.c_long(0x80004003).value

def get_template_command(filetype, verb=None):
    flags = ASSOCF_INIT_IGNOREUNKNOWN | ASSOCF_NOTRUNCATE
    assoc_str = ASSOCSTR_COMMAND
    cch = ctypes.c_ulong(260)
    while True:
        buf = (ctypes.c_wchar * cch.value)()
        try:
            shlwapi.AssocQueryStringW(
                flags, assoc_str, filetype, verb,
                buf, ctypes.byref(cch))
        except OSError as e:
            if e.winerror != E_POINTER:
                raise
            continue
        break
    return buf.value

For example:

>>> print(get_template_command('.py'))
"C:\Windows\py.exe" "%L" %*
>>> print(get_template_command('.py', 'open'))
"C:\Windows\py.exe" "%L" %*
>>> print(get_template_command('.py', 'runas'))
"C:\Windows\py.exe" "%L" %*
>>> print(get_template_command('.py', r'editwithidle\shell\edit310'))
"C:\Program Files\Python310\pythonw.exe" -m idlelib "%L" %*

[eryksun] Eryk Sun https://discuss.python.org/u/eryksun

Regardless of |PATHEXT|, CMD will find and run a “.py” file if you
include the extension in the name. Maybe you’re using PowerShell, which
limits normal execution in the current console session to files with
extensions in |PATHEXT|.

No, it’s definitely cmd, and .py is in PATHEXT anyway. Here’s a capture
of the actual thing that gets executed:

os.spawnve(
file='C:\\WINDOWS\\System32\\cmd.exe',
args=['C:\\WINDOWS\\System32\\cmd.exe', '/C', 
'"C:\\Users\\mats\\AppData\\Local\\Temp\\testcmd.13088.un1bsxea\\fake_cc.py 
sub2 "sub1\\hello.obj" "sub1\\hello.c""'],
env={'SystemDrive': 'C:', 'SystemRoot': 'C:\\WINDOWS', 'TEMP': 
'C:\\Users\\mats\\AppData\\Local\\Temp', 'TMP': 
'C:\\Users\\mats\\AppData\\Local\\Temp', 'COMSPEC': 
'C:\\WINDOWS\\system32\\cmd.exe', ...  'PATHEXT': '.PY;.COM;.EXE;.BAT;.CMD',
...
)

And up pops vscode…

Is there any way for Python code to inspect to find out that this is
going to happen

import ctypes shlwapi = ctypes.OleDLL(‘shlwapi’)
shlwapi.AssocQueryStringW.argtypes = ( ctypes.c_ulong, # flags
ctypes.c_ulong, # str ctypes.c_wchar_p, # pszAssoc ctypes.c_wchar_p, #
pszExtra ctypes.c_wchar_p, # pszOut ctypes.POINTER(ctypes.c_ulong), #
pcchOut ) ASSOCF_NOTRUNCATE = 0x00000020 ASSOCF_INIT_IGNOREUNKNOWN =
0x00000400 ASSOCSTR_COMMAND = 1 ASSOCSTR_EXECUTABLE = 2 E_POINTER =
ctypes.c_long(0x80004003).value def get_template_command(filetype,
verb=None): flags = ASSOCF_INIT_IGNOREUNKNOWN | ASSOCF_NOTRUNCATE
assoc_str = ASSOCSTR_COMMAND cch = ctypes.c_ulong(260) while True: buf =
(ctypes.c_wchar * cch.value)() try: shlwapi.AssocQueryStringW( flags,
assoc_str, filetype, verb, buf, ctypes.byref(cch)) except OSError as e:
if e.winerror != E_POINTER: raise continue break return buf.value |

For example:

print(get_template_command(‘.py’)) “C:\Windows\py.exe” “%L” %* >>>
print(get_template_command(‘.py’, ‘open’)) “C:\Windows\py.exe” “%L” %*
print(get_template_command(‘.py’, ‘runas’)) “C:\Windows\py.exe”
“%L” %* >>> print(get_template_command(‘.py’,
r’editwithidle\shell\edit310’)) “C:\Program Files\Python310\pythonw.exe”
-m idlelib “%L” %* |

Thanks! I’ll definitely play with this.

This works fine for me. It takes an exception if there’s no association
which causes Python to print out the somewhat unhelpful error code
-2147923741 (kinda wish we’d get the hex code here - 0x80070483 is more
“traditional” on the Windows side) - but in any case it’s easy to work
with. Added this check to our test runner.

So should it be considered that an IDE/Editor taking over the
association for .py is a reasonable expectation?

1 Like

Personally, I don’t consider it “reasonable”, but being prepared for it to happen seems like a reasonable precaution to take, given that some editors do it.

Normally, if I were running a Python script, I’d explicitly run it with sys.executable, to avoid this sort of issue (among others) but I understand that you have a special case here.

2 Likes

Here’s an updated version that’s a bit more general. It defaults to getting the template command, but you can get just the application executable, or friendly name of the file type, or the friendly name of the application. In Windows 10, you can also get the program ID (progid) of the file type (e.g. “Python.File”) or the app user model ID of the application if it’s a UWP app (e.g. “PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0!Python”).

I added an E_NO_ASSOCIATION constant to compare with e.winerror when handling an OSError that was raised by the internal shlwapi.AssocQueryStringW() call. (E_NO_ASSOCIATION is a made up name, not from the SDK.) This is an HRESULT error for the Windows API facility (i.e. 0x8007XXXX), which wraps the error code ERROR_NO_ASSOCIATION (1155, i.e. 0x0483). An HRESULT value is a signed 32-bit integer (i.e. C long), and negative values indicate an error, but it’s typically formatted in strings as an unsigned hexadecimal value.

import sys
import ctypes

shlwapi = ctypes.OleDLL('shlwapi')
shlwapi.AssocQueryStringW.argtypes = (
    ctypes.c_ulong, # flags
    ctypes.c_ulong, # str
    ctypes.c_wchar_p, # pszAssoc
    ctypes.c_wchar_p, # pszExtra
    ctypes.c_wchar_p, # pszOut
    ctypes.POINTER(ctypes.c_ulong), # pcchOut
)

E_NO_ASSOCIATION = ctypes.c_long(0x80070000 | 1155).value

ASSOCSTR_COMMAND = 1
ASSOCSTR_EXECUTABLE = 2
ASSOCSTR_FRIENDLYDOCNAME = 3
ASSOCSTR_FRIENDLYAPPNAME = 4
if sys.getwindowsversion()[:2] >= (10, 0):
    ASSOCSTR_PROGID = 20
    ASSOCSTR_APPID = 21

def get_assoc_string(filetype, verb=None, assoc=ASSOCSTR_COMMAND):
    E_POINTER = ctypes.c_long(0x80004003).value
    flags = 0x0420 # ASSOCF_INIT_IGNOREUNKNOWN | ASSOCF_NOTRUNCATE
    cch = ctypes.c_ulong(260)
    while True:
        buf = (ctypes.c_wchar * cch.value)()
        try:
            shlwapi.AssocQueryStringW(
                flags, assoc, filetype, verb,
                buf, ctypes.byref(cch))
        except OSError as e:
            if e.winerror != E_POINTER:
                raise
            continue
        break
    return buf.value

If .py files are associated with the store app, then ASSOCSTR_COMMAND will fail with E_NO_ASSOCIATION, since the execution is delegated. You can get the program ID in this case, but it’s not a friendly name (e.g. “AppXm5ncqpg8qx3r257367vm5vwvzprqnpyt”).

It’s reasonable if the user allows it. If you right-click a “.py” file and select “open with” → “choose another app”, you can choose the app you want and select “always use this app…”. That defines and locks in the “UserChoice” in the shell’s filetype cache under “HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.py”. The ACL on the key has an entry that denies set-value access to the current user, which deters applications from modifying the user choice. It’s possible to delete the key, and let the shell recompute the association (from the zillions of registry keys that affect it), but this would be extremely bad form for an application to do automatically without the user’s consent.

[eryksun] Eryk Sun https://discuss.python.org/u/eryksun

If .py files are associated with the store app, then |ASSOCSTR_COMMAND|
will fail with |E_NO_ASSOCIATION|, since the execution is delegated. You
can get the program ID in this case, but it’s not a friendly name (e.g.
“AppXm5ncqpg8qx3r257367vm5vwvzprqnpyt”

okay. I had already seen that case, thanks for the explanation.

mwichmann:

So should it be considered that an IDE/Editor taking over the
association for .py is a reasonable expectation?

It’s reasonable if the user allows it. If you right-click a “.py” file
and select “open with” → “choose another app”, you can choose the app
you want and select “always use this app…”. That defines and locks in
the “UserChoice” in the shell’s filetype cache under
“HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts.py”.
The ACL on the key has an entry that denies set-value access to the
current user, which deters applications from modifying the user choice.
It’s possible to delete the key, and let the shell recompute the
association (from the zillions of registry keys that affect it), but
this would be extremely bad form for an application to do automatically
without the user’s consent.

Yeah, that makes sense. It doesn’t really help tests run without user
interaction/UI and placed in a tempfile-generated random temporary dir,
but such is life.

Appreciate all the help on this!

As was suggested, it would be better to use sys.executable instead of relying on the file association. For example: os.spawnv(os.P_WAIT, sys.executable, args).

Personally, I define some fallback operations under “HKCU\Software\Classes\SystemFileAssociations\.py\Shell”, including “Edit”, “Run”, “RunAs”, and “OpenAsUser”. These are available as long as they’re not overridden by the associated file type.

Here they are in .reg format:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell]

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\Edit]
"MUIVerb"="Edit with IDLE (default version)"

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\Edit\command]
@="\"C:\\Windows\\pyw.exe\" -m idlelib -e \"%1\""

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\OpenAsUser]
@="Open as &user"

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\OpenAsUser\command]
"DelegateExecute"="{EA72D00E-4960-42FA-BA92-7792A7944C1D}"

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\Run]

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\Run\command]
@="\"C:\\Windows\\py.exe\" \"%1\" %*"

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\RunAs]
"HasLUAShield"=""

[HKEY_CURRENT_USER\SOFTWARE\Classes\SystemFileAssociations\.py\Shell\RunAs\command]
@="\"C:\\Windows\\py.exe\" \"%1\" %*"

An operation is executed via the lpVerb parameter of ShellExecuteExW(), or with Python’s os.startfile(filepath, operation, arguments, cwd, show_cmd). The default operation to use is set by the file type as the default value of its “Shell” key. If the file type doesn’t set the default operation, the shell API prefers “open” if it’s defined.

The CMD shell falls back on ShellExecuteExW() with the default operation. PowerShell, on the other hand, tries to avoid calling ShellExecuteExW() by calling FindExecutableW(filename) (source link). This is equivalent to calling AssocQueryStringW(ASSOCF_INIT_IGNOREUNKNOWN, ASSOCSTR_EXECUTABLE, filename, NULL, ...). It’s quite wrong. If a file is executed without ShellExecuteExW(), its associated template command (i.e. ASSOCSTR_COMMAND) should be evaluated, not simply assumed to be "%1" %*.

The above “OpenAsUser” operation delegates the execution to the class ID “{EA72D00E-…}”, which invokes an instance of CRunAsNewUser in “shell32.dll”. This prompts for credentials and calls CreateProcessWithLogonW() in order to execute “rundll32.exe” in the context of the designated user. The “rundll32.exe” process executes the original command line (passed via shared memory) with the user’s default operation for the file type, which could be surprisingly different from that of the current user.

Yes, there was no argument from here. There are 1200-ish test files in
the project, and except for five, whenever they try to execute a python
script directly, they use sys.executable to do so. The exceptions are
testing a couple of features that need to have the precise command line
that would be executed, and in the situation where the command would
normally have been, say, “cl.exe [build args]” but is being mocked by a
Python script, modifying it so it’s “python testcc.py [build args]”
messes up the bit being tested. It’s a corner case, certainly and until
I think of some alternative scheme we’ll live with some funky checking
for that small number of cases.

I haven’t tested the original issue on Windows 11 yet, but the behaviour (for well-behaved programs) on Windows 10 should be to register as being available for the file type, and let Windows decide whether to make you the default app or not. Usually, Windows will prompt you to select an app if something new has been registered.

I’m not ruling out that VS Code is poorly behaved here, though. It’s still possible to forcibly register yourself as the default app by writing the association directly, and that was the “official” way back in Windows XP (maybe Vista?). I know VS Code didn’t exist then, but they may still be doing it.

Also, you may want to look into compiling PC/launcher.c with the SCRIPT_WRAPPER preprocessor variable defined (which we don’t build anymore). This should generate an executable spam.exe that will look for an adjacent spam-script.py and run it with whichever default Python version it finds (including in the shebang line of the script). Building that file once and keeping it with your tests may let you have a fake cl.exe that runs cl-script.py I think without messing up the command line.

2 Likes

It’s also possible to build a Python package with an entry point, or use the distlib library to build a script wrapper, either of which will give you a usable replacement cl.exe. Both are a little fiddly to do, though - this honestly should be something that’s easier than it currently is to achieve :slightly_frowning_face:

1 Like

I posted a few samples in this blog post a couple of years back. It’s before the initialization API was redesigned, but all of these should still work, and for simple cases ought to be just fine.

(Edit: “a couple” being five and a half… can’t really believe I’ve been doing this for so long :smiley: )

Yeah, if you write a simple wrapper it’s straightforward. I even added a note on how to do it in the zipapp docs¹. But I would like it if there were something that didn’t need a compiler.

Maybe we should ship the SCRIPT_WRAPPER version of the launcher. If it supported having an appended zipfile, zipapp.create_archive could write fully standalone executables. If I recall, I looked at doing this, but my C skills failed me :slightly_frowning_face:

¹ Note to self - that over-clever snippet using distutils should be removed or replaced, now that distutils is deprecated.

1 Like

The open-with window (i.e. OpenWith.exe) may be displayed when a newly installed application registers support for a file type. Note that AssocQueryStringW() will continue to return the user choice even if ShellExecuteExW() decides to show the open-with window in this case.

The user choice for a file type is defined in "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\<file extension>. This references ProgIds, so directly modifying ProgIds in HKCR can take immediate effect, including those in “HKCR\Applications” (ProgIds for executable files, e.g. “py.exe”) and “HKCR\SystemFileAssociations” (ProgIds for perceived types, e.g. “text”, and default operations for file types, e.g. “.py” ). If the subkey in “FileExts” has no locked “UserChoice” key, then the last user choice from the open-with menu is used, based on the “MRUList” value in the “OpenWithList” subkey.

If there’s no cached user choice, the association is defined by “HKCR\<file extension>”. This includes the default value (a ProgId, if set), the “OpenWithProgIds” subkey (a list of ProgIds, as string values), and the “PerceivedType” (e.g. “audio”, “text”, “video”). AssocQueryStringW() includes the perceived type in its search. If there’s no known association, AssocQueryStringW() fails if the flag ASSOCF_INIT_IGNOREUNKNOWN is used, else it returns the “OpenWith.exe” command. ShellExecuteExW() always shows the open-with window when there’s no known association, including when the association only defines a perceived type.

The open-with window pulls in file associations from additional locations in the registry. This includes “[HKCU|HKLM]\Software\RegisteredApplications”, which lists each application’s “Capabilities” key. The latter may contain a “FileAssociations” key. Applications can also register support for file types by executable name in “HKCR\Applications”. The subkey for an executable name is a ProgId that can optionally define a “SupportedTypes” key. The “open” operation of this ProgId is used as the template when a user browses the filesystem (i.e. “look for another app on this PC”) to create an automatic ProgId (e.g. “py_auto_file”). By default an automatic ProgId simply uses a "%1" command-line argument, but the application ProgId overrides this, e.g. to use "%1" %*.

It’s possible to take control of “HKCR\<file extension>”, but this doesn’t override the user’s cached or locked choice. Also, the shell’s “…\Explorer\FileExts\<file extension>\UserChoice” key has an ACL that denies set-value access to make it clear to applications that a locked-in user choice should not be modified. That said, an application is technically allowed to delete “…\Explorer\FileExts\<file extension>” to make the shell recompute the association based on the current HKCR settings. Doing that without user approval would be obnoxious.

1 Like