Skip to content

Commit

Permalink
Fix problems with command line handling in spawned admin process - us…
Browse files Browse the repository at this point in the history
…es `subprocess.list2cmdline`

Adding working unit tests. To run the tests fully automated with no interaction, UAC prompt has to be disabled at system level so it spawns process as admin without asking.
  • Loading branch information
Preston-Landers committed Nov 8, 2020
1 parent 3bec462 commit 72aab6b
Show file tree
Hide file tree
Showing 14 changed files with 540 additions and 247 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ venv27
.eggs
.tox
.pytest_cache
example_usage.log
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@
- Initial package release based on a cleaned up version of my Gist originally published here:

https://gist.github.com/Preston-Landers/267391562bc96959eb41

Compared to the original version of the Gist, this new version:

* Has been updated for modern Python 3 (and 2.7)

* Correctly handles complex script command lines, e.g. quoted spaces in parameters.

* Provides a decorator for your script's main function for easier usage.

* It has some basic tests.
79 changes: 72 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,69 @@ This package provides a way to invoke User Access Control (UAC) in Windows from
This allows a Python process to re-spawn a new process with Administrator level rights using
the UAC prompt. Note that the original process is not elevated; a new process is created.

## Quick Usage
The main purpose of pyuac is to allow command line Python scripts to ensure they are run
as Administrator on Windows. There is no ability to execute only parts of a program
as Administrator - the entire script is re-launched with the same command line. You can
also override the command line used for the admin process.

There are two main functions provided:
## Usage and examples

There are two basic ways to use this library. Perhaps the simplest way is to decorate your
Python command line script's main function. The other is to directly use the `isUserAdmin`
and `runAsAdmin` functions yourself. The decorator allows you to automatically capture
the output of the Admin process and return that output string to the non-admin parent process.

See also [tests/example_usage.py](tests/example_usage.py)

### Decorator

The decorator is an easy way to ensure your script's main() function will respawn itself
as Admin if necessary.

#### Decorator usage example

```python
from pyuac import main_requires_admin

@main_requires_admin
def main():
print("Do stuff here that requires being run as an admin.")
# The window will disappear as soon as the program exits!
input("Press enter to close the window. >")

if __name__ == "__main__":
main()
```

#### Capture stdout from admin process

You can also capture the stdout and stderr of your Admin sub-process if you need to check
it for errors from the non-admin parent. By default, unless you set scan_for_error=False on
the decorator, it will check the last line of both stdout and stderr for the words 'error'
or 'exception', and if it finds those, will raise RuntimeError on the parent non-admin side.

```python
from pyuac import main_requires_admin

@main_requires_admin(return_output=True)
def main():
print("Do stuff here that requires being run as an admin.")
# The window will disappear as soon as the program exits!
input("Press enter to close the window. >")

if __name__ == "__main__":
rv = main()
if not rv:
print("I must have already been Admin!")
else:
admin_stdout, admin_str, *_ = rv
if "Do stuff" in admin_stdout:
print("It worked.")
```

### Direct usage

There are two main direct usage functions provided:

isUserAdmin()
This returns a boolean to indicate whether the current user has elevated Administrator status.
Expand All @@ -16,7 +76,7 @@ This returns a boolean to indicate whether the current user has elevated Adminis
Re-launch the current process (or the given command line) as an Administrator.
This will trigger the UAC (User Access Control) prompt if necessary.

### Example Usage
#### Direct usage example

This shows a typical usage pattern:

Expand All @@ -25,27 +85,32 @@ import pyuac

def main():
print("Do stuff here that requires being run as an admin.")
# The window will disappear as soon as the program exits!
input("Press enter to close the window. >")

if __name__ == "__main__":
if not pyuac.isUserAdmin():
print("Re-launching as admin!")
pyuac.runAsAdmin()
else:
main()
else:
main() # Already an admin here.
```

## Requirements

* This package only supports Windows at the moment. The isUserAdmin function will work under
Linux / Posix, but the runAsAdmin command is currently Windows only.
Linux / Posix, but the runAsAdmin functionality is currently Windows only.

* This requires Python 2.7, or Python 3.3 or higher.

* This requires the PyWin32 package to be installed.
* This requires the [PyWin32](https://pypi.org/project/pywin32/) package to be installed.

https://pypi.org/project/pywin32/
https://github.com/mhammond/pywin32

* It also depends on the packages [decorator](https://pypi.org/project/decorator/)
and [tee](https://pypi.org/project/tee/)

## PyWin32 problems

The PyWin32 package is required by this library (pyuac).
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
filterwarnings =
ignore::DeprecationWarning:pywintypes
118 changes: 28 additions & 90 deletions pyuac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# -*- coding: utf-8; mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vim: fileencoding=utf-8 tabstop=4 expandtab shiftwidth=4

"""User Access Control for Microsoft Windows Vista and higher. This is only for the Windows
platform.
"""
User Access Control for Microsoft Windows Vista and higher. This is only for the Windows platform.
This will relaunch either the current script - with all the same command line parameters - or
else you can provide a different script/program to run. If the current user doesn't normally
Expand All @@ -12,102 +12,40 @@
Note that the prompt may simply shows a generic python.exe with "Publisher: Unknown" if the
python.exe is not signed. However, the standard python.org binaries are signed.
This is meant to be used something like this::
if not pyuac.isUserAdmin():
return pyuac.runAsAdmin()
# otherwise carry on doing whatever...
See also this utility function which runs a function as admin and captures the stdout/stderr:
run_function_as_admin(my_main_function)
"""

import os
import sys
from logging import getLogger
This is meant to be used something like this, where you decorate your command line script's
main function:
log = getLogger('pyuac')
>>> from pyuac import main_requires_admin
>>> @main_requires_admin
... def main():
... # your script main code here.
... return
def isUserAdmin():
"""Check if the current OS user is an Administrator or root.
>>> if __name__ == "__main__":
... main()
:return: True if the current user is an 'Administrator', otherwise False.
"""
if os.name == 'nt':
import win32security
Alternatively, you can do something like this:
try:
adminSid = win32security.CreateWellKnownSid(
win32security.WinBuiltinAdministratorsSid, None)
rv = win32security.CheckTokenMembership(None, adminSid)
log.info("isUserAdmin - CheckTokenMembership returned: %r", rv)
return rv
except Exception as e:
log.warning("Admin check failed, assuming not an admin.", exc_info=e)
return False
else:
# Check for root on Posix
return os.getuid() == 0
>>> import pyuac
>>> if __name__ == "__main__":
... if not pyuac.isUserAdmin():
... return pyuac.runAsAdmin()
... # otherwise carry on doing whatever...
... main()
def runAsAdmin(cmdLine=None, wait=True):
"""
Attempt to relaunch the current script as an admin using the same command line parameters.
WARNING: this function only works on Windows. Future support for Posix might be possible.
Calling this from other than Windows will raise a RuntimeError.
:param cmdLine: set to override the command line of the program being launched as admin.
Otherwise it defaults to the current process command line! It must be a list in
[command, arg1, arg2...] format.
:param wait: Set to False to avoid waiting for the sub-process to finish. You will not
be able to fetch the exit code of the process if wait is False.
:returns: the sub-process return code, unless wait is False, in which case it returns None.
"""

if os.name != 'nt':
raise RuntimeError("This function is only implemented on Windows.")

import win32con
import win32event
import win32process
# noinspection PyUnresolvedReferences
from win32com.shell.shell import ShellExecuteEx
# noinspection PyUnresolvedReferences
from win32com.shell import shellcon

if cmdLine is None:
cmdLine = [sys.executable] + sys.argv
log.debug("Defaulting to runAsAdmin command line: %r", cmdLine)
elif type(cmdLine) not in (tuple, list):
raise ValueError("cmdLine is not a sequence.")
cmd = '"%s"' % (cmdLine[0],)
# XXX TODO: isn't there a function or something we can call to massage command line params?
params = " ".join(['"%s"' % (x,) for x in cmdLine[1:]])
showCmd = win32con.SW_SHOWNORMAL
lpVerb = 'runas' # causes UAC elevation prompt.
See also this utility function which runs a function as admin and captures the stdout/stderr:
log.info("Running command %r - %r", cmd, params)
run_function_as_admin(my_main_function)
procInfo = ShellExecuteEx(
nShow=showCmd,
fMask=shellcon.SEE_MASK_NOCLOSEPROCESS,
lpVerb=lpVerb,
lpFile=cmd,
lpParameters=params)
"""

if wait:
procHandle = procInfo['hProcess']
_ = win32event.WaitForSingleObject(procHandle, win32event.INFINITE)
rc = win32process.GetExitCodeProcess(procHandle)
log.info("Process handle %s returned code %s", procHandle, rc)
else:
rc = None
from pyuac.admin import isUserAdmin, runAsAdmin
from pyuac.decorator import main_requires_admin

return rc
__all__ = [
'isUserAdmin',
'runAsAdmin',
'main_requires_admin'
]
2 changes: 1 addition & 1 deletion pyuac/__version__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
__title__ = 'pyuac'
__description__ = 'Python library for Windows User Access Control (UAC)'
__url__ = 'https://github.com/Preston-Landers/pyuac'
__version__ = '0.0.1'
__version__ = '0.0.2'
__author__ = 'Preston Landers'
__author_email__ = '[email protected]'
__license__ = 'MIT'
Expand Down
96 changes: 96 additions & 0 deletions pyuac/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python
# -*- coding: utf-8; mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vim: fileencoding=utf-8 tabstop=4 expandtab shiftwidth=4

"""
Contains the core functions. See package docs in __init__.py
"""

import os
import sys
from logging import getLogger
from subprocess import list2cmdline

log = getLogger('pyuac')


def isUserAdmin():
"""Check if the current OS user is an Administrator or root.
:return: True if the current user is an 'Administrator', otherwise False.
"""
if os.name == 'nt':
import win32security

try:
adminSid = win32security.CreateWellKnownSid(
win32security.WinBuiltinAdministratorsSid, None)
rv = win32security.CheckTokenMembership(None, adminSid)
log.info("isUserAdmin - CheckTokenMembership returned: %r", rv)
return rv
except Exception as e:
log.warning("Admin check failed, assuming not an admin.", exc_info=e)
return False
else:
# Check for root on Posix
return os.getuid() == 0


def runAsAdmin(cmdLine=None, wait=True):
"""
Attempt to relaunch the current script as an admin using the same command line parameters.
WARNING: this function only works on Windows. Future support for Posix might be possible.
Calling this from other than Windows will raise a RuntimeError.
:param cmdLine: set to override the command line of the program being launched as admin.
Otherwise it defaults to the current process command line! It must be a list in
[command, arg1, arg2...] format. Note that if you're overriding cmdLine, you normally should
make the first element of the list sys.executable
:param wait: Set to False to avoid waiting for the sub-process to finish. You will not
be able to fetch the exit code of the process if wait is False.
:returns: the sub-process return code, unless wait is False, in which case it returns None.
"""

if os.name != 'nt':
raise RuntimeError("This function is only implemented on Windows.")

import win32con
import win32event
import win32process
# noinspection PyUnresolvedReferences
from win32com.shell.shell import ShellExecuteEx
# noinspection PyUnresolvedReferences
from win32com.shell import shellcon

if not cmdLine:
cmdLine = [sys.executable] + sys.argv
log.debug("Defaulting to runAsAdmin command line: %r", cmdLine)
elif type(cmdLine) not in (tuple, list):
raise ValueError("cmdLine is not a sequence.")

showCmd = win32con.SW_SHOWNORMAL
lpVerb = 'runas' # causes UAC elevation prompt.

cmd = cmdLine[0]
params = list2cmdline(cmdLine[1:])

log.info("Running command %r - %r", cmd, params)
procInfo = ShellExecuteEx(
nShow=showCmd,
fMask=shellcon.SEE_MASK_NOCLOSEPROCESS,
lpVerb=lpVerb,
lpFile=cmd,
lpParameters=params)

if wait:
procHandle = procInfo['hProcess']
_ = win32event.WaitForSingleObject(procHandle, win32event.INFINITE)
rc = win32process.GetExitCodeProcess(procHandle)
log.info("Process handle %s returned code %s", procHandle, rc)
else:
rc = None

return rc
Loading

0 comments on commit 72aab6b

Please sign in to comment.