7
\$\begingroup\$

Some coworkers and I sometimes want to delete folders, only to get the very informative: "Permission Denied" message. I had discovered Handle.exe and Process Explorer to help with this, but the method was still too manual and cumbersome.

Thus, I decided to write this Python CLI script to release locks on a given file. It runs Handle.exe under the hood and parses the output to prompt the user for confirmation to kill any given process (unless a quiet-like option flag is given).

We all work on Windows 7 using basically the same setup (IT takes care of setting up all the machines). The default Python is ActivateState's Python2.6.9. While some people use cmd.exe, a lot of guys use Bash or Cygwin, so I wanted to support all of these.

I am not sure if using logging solely for the purpose of adding a prefix to my output messages was a good idea, but it felt very redundant to add it to every print. Making a function that wraps print made the code look weird.

Also, I'm at a loss as to how to test this. I've added tests for abspath function (see repo), but I'm having trouble writing tests for the other functions.

"""Dammit let me delete this file!

Usage:
    dammit [-y | -s] <name>
    dammit (-h)

Options:
-y        Kill without permission.
-s        Enable hotkey
-h        Show this screen.

"""
from __future__ import print_function

from docopt import docopt
from functools import partial
import logging
import msvcrt
import os
import posixpath
import re
import subprocess
import sys
import time

log = logging.getLogger()
log.setLevel(logging.INFO)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
PREFIX = 'dammit:'
formatter = logging.Formatter(PREFIX + ' %(message)s')
ch.setFormatter(formatter)
log.addHandler(ch)

CYGPATH = ['C:\\GnuNT\\bin\\cygpath.exe']
HANDLE = ["handle64.exe"]
# handle.exe provides way to close specified handle, which would be preferable,
# but this requires administrator rights :(
KILL = ["TASKKILL", "/f", "/pid"]
ACTIVATE = ["activatePID.exe"]
EXPLORER = "explorer.exe"
CANT_EXECUTE = """Could not execute command: %s"""


def abspath(path):
    """Return the absolute path of given `path`.

    Taken to be relative from current working directory if input is not an
    absolute path.

    Args:
        path (str) : Pathname to absolutize.

    Notes:
        Should work for POSIX and Windows paths to accomodate
        different shells.
    """
    if posixpath.isabs(path):  # Starts with "/"
        if path[1:9] != 'cygdrive':
            path = posixpath.join('/cygdrive', path[1:])
        proc = subprocess.Popen(CYGPATH + ['-w', path],
                                stdout=subprocess.PIPE)
        return proc.stdout.read().rstrip()  # Remove trailing newline
    return os.path.abspath(path)


def find_locks(name):
    """Find which processes currently have a particular file/directory open.

    Args:
        name (str) : Name of the file which is locked.

    Returns:
        List of (process, PID)-pairs which are accessing the file.
    """
    try:
        output = subprocess.check_output(HANDLE + [name])
    except WindowsError:
        log.info(CANT_EXECUTE % HANDLE)
        sys.exit(1)
    except subprocess.CalledProcessError:
        return None
    pattern = r"([^\s]+)(?:\s+)pid: ([0-9]{4})"
    return frozenset(re.findall(pattern, output))


def kill(pid):
    """Kill process with given PID

    Args:
        pid (str) : PID to kill
    """
    try:
        with open(os.devnull, 'w') as nul:
            subprocess.Popen(KILL + [str(pid)], stdout=nul, stderr=nul)
    except WindowsError:
        log.info(CANT_EXECUTE % KILL)
        sys.exit(1)
    return


def _activatePID(pid, enable_hotkeys):
    """Try to activate window associated with given process ID

    Args:
        pid (str) : PID of process to activate.
        enable_hotkeys (bool) : If True, Autohotkey will send "y" and "n"
            key presses to the prompt upon activating requested window.
    """
    try:
        subprocess.Popen(ACTIVATE + [pid, str(enable_hotkeys)])
    except WindowsError:
        log.info(CANT_EXECUTE % ACTIVATE)
    return


def query(process, pid, show_callback):
    """Query user to kill or show process.

    No invalid answers allowed.

    Args:
        process (str) : Process name
        pid (str) : Process PID
        show_callback (func(x)) : Ran when user answers "show"


    Answers:
        y : Kill process
        n : Do not kill process
        s : Try to show (activate) window linked to the process.

    Answering "show" does not exit the prompt.

    Returns:
        bool: True if yes, False if no.
    """
    question = " Kill process %s with PID %s [y/n/s]? " % (process, pid)
    sys.stdout.write(PREFIX + question)
    while True:
        answer = msvcrt.getch().lower()
        if answer == 'y':
            sys.stdout.write(answer+'\n')
            return True
        elif answer == 'n':
            sys.stdout.write(answer+'\n')
            return False
        elif answer == 's':
            sys.stdout.write(answer)
            show_callback(pid)
        else:
            sys.exit(1)


def open_explorer():
    """Open explorer.exe process."""
    # Just opening 'explorer.exe' merely launches a Windows Explorer window,
    # which is not the desired effect here.
    log.info("Re-opening %s" % EXPLORER)
    windir = os.environ['windir']
    subprocess.Popen(os.path.join(windir, EXPLORER))


def main():
    arguments = docopt(__doc__, options_first=True)
    name = arguments['<name>']
    quiet = arguments['-y']
    hotkeys = arguments['-s']

    activatePID = partial(_activatePID, enable_hotkeys=hotkeys)
    name_abspath = abspath(name)
    log.debug("name_abspath=%s" % name_abspath)
    locks = find_locks(name_abspath)
    if not locks:
        log.info("Nothing locking %s" % name_abspath)
        sys.exit(0)

    for process, pid in locks:
        if not quiet:
            do_kill = query(process, pid, show_callback=activatePID)
            if do_kill is not True:
                continue
        kill(pid)
        log.debug("Killed PID %s" % pid)
        if process == EXPLORER:
            time.sleep(0.1)
            open_explorer()

if __name__ == '__main__':
    main()
\$\endgroup\$
  • 1
    \$\begingroup\$ I can only nitpick about return statements at the end of some functions that can be simply omitted, and a return None that can be simplified to simply return. \$\endgroup\$ – janos Jul 13 '15 at 20:06
  • \$\begingroup\$ Fixed in latest commit! I switched the return None to a return [] because I find it conveys a clearer "I've found no locks for that name" than return or return None. Personal preference I guess. \$\endgroup\$ – selimb Jul 13 '15 at 21:59
3
\$\begingroup\$

String formatting

Generally, if you're using Python 2.6 or higher, you should be using the function str.format to format strings, rather than %. For example, this line in your code:

question = " Kill process %s with PID %s [y/n/s]? " % (process, pid)

Could become this:

question = " Kill process {process} with PID {pid} [y/n/s]? ".format(
    process=process,
    pid=pid
)

Another example would be turning this:

log.debug("Killed PID %s" % pid)

Into this:

log.debug("Killed PID {pid}".format(pid=pid))

Miscellaneous

On a small note, strings like this:

'C:\\GnuNT\\bin\\cygpath.exe'

Can be prefixed with r, like this so that you can do this, and get rid of the double-backslashes:

r'C:\GnuNT\bin\cygpath.exe'

Anything like this:

return None

Can simply be changed to this:

return

In the case of the kill function, the return at the end can be omitted completely.


Other than that, your code looks really nice and clean. Good job!

\$\endgroup\$
  • \$\begingroup\$ Thanks! I'm actually never sure if it's good practice to use raw strings. \$\endgroup\$ – selimb Jul 26 '15 at 19:20
  • \$\begingroup\$ @Kreger51 No problem! Usually, when I'm working with file paths, I find it easier to use raw strings. \$\endgroup\$ – Ethan Bierlein Jul 26 '15 at 19:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.