# -*- coding: utf-8 -*-
"""Lockfile operations

Acquires and releases an exclusive file-based process lock on
Unix-like platforms.

Examples:
    Simple acquirey and release:

        lockfile = Lockfile()
        lockfile.acquire()
        # (other process would raise LockfileEstablishError here)
        lockfile.release()

    Terminating a process from a second process:

        lockfile = Lockfile()
        lockfile.kill_process()
        lockfile.acquire()
        # (this process now has lock)
        lockfile.release()

"""

from __future__ import print_function

import os
import sys
import pwd
import fcntl
import signal
import time


class LockfileEstablishError(Exception): pass
class LockfileKillError(Exception): pass
class LockfileKillTimeoutError(Exception): pass
class LockfileLockedError(Exception): pass
class LockfileProcessNotRunningError(Exception): pass


class Lockfile(object):
    def __init__(self, app_name=None, instance_name=None,
                 system_lock_dir='/var/run', user='nobody'):
        """

        Args:
            app_name: The name of your application, used to create a
                subdirectory under system_lock_dir. Must not contain
                characters which are illegal in path nodes on your
                filesystem. Defaults to the basename of the executable.
            instance_name: Symbolic name of this particular process
                instance, used to create the lockfile itself. Must not
                contain characters which are illegal in path nodes on
                your filesystem. Defaults to app_name since the simple
                case is a single instance.
            system_lock_dir: System directory in which locks are kept
                for running processes. Older systems use '/var/lock'.
                Defaults to '/var/run'.
            user: Username for ownership of the lockfile. Defaults to
                'nobody'.
        """
        self.app_name = app_name or self.get_app_name()
        self.instance_name = instance_name or self.app_name
        self.system_lock_dir = system_lock_dir or '/var/run'
        self.user = user

        self.dir = os.path.join(self.system_lock_dir, self.app_name)
        self.filename = os.path.join(self.dir, self.instance_name) + '.pid'
        self.fd = None
        self.lock = None

    def acquire(self):
        """Acquire an exclusive lock

        Returns immediately if lock was already acquired within this process.

        Raises:
            LockfileLockedError: The lockfile is in use by another process.
            LockfileEstablishError: The lock could not be established for
                some other reason.
        """
        if self.fd:
            return
        if self.has_exlock():
            raise LockfileLockedError(
                'Lock file already locked by PID %s' % self.get_pid())

        pwent = pwd.getpwnam(self.user)

        if not os.path.exists(self.dir):
            os.mkdir(self.dir)
        os.chown(self.dir, pwent.pw_uid, pwent.pw_gid)
        os.chmod(self.dir, 0o755)

        self.fd = os.open(self.filename, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
        os.fchown(self.fd, pwent.pw_uid, pwent.pw_gid)
        os.fchmod(self.fd, 0o644)

        try:
            self.lock = fcntl.lockf(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except:
            os.close(self.fd)
            self.fd = None
            raise LockfileEstablishError("Could not establish lock %s" %
                                         self.filename)
        os.write(self.fd, "%s\n" % os.getpid())

    def release(self):
        """Release an exclusive lock

        Returns immediately if no lock had been acquired by this process.
        """
        if not self.fd:
            return

        fcntl.lockf(self.fd, fcntl.LOCK_UN)
        self.lock = None
        os.close(self.fd)
        self.fd = None
        os.remove(self.filename)

    def kill_process(self, sig=signal.SIGTERM, wait=True):
        """Terminate some other process holding an exclusive lock.

        Returns immediately if lockfile exists but is stale.

        Args:
            sig: the signal to send the other process. Defaults to SIGTERM.
            wait: Wait for the other process to exit. Default is True.

        Raises:
            LockfileProcessNotRunningError: No lockfile exists.
            LockfileKillError: PID is this process.
            LockfileKillTimeoutError: Timed out waiting for other process
                to finish and exit.

        """
        if not os.path.exists(self.filename):
            raise LockfileProcessNotRunningError("Process is not running")

        if not self.has_exlock():
            os.remove(self.filename)
            return

        pid = self.get_pid()
        if pid == os.getpid():
            raise LockfileKillError("Lockfile: would terminate this process")
        os.kill(pid, sig)

        if not wait:
            return
        for i in range(1, 13):
            if not os.path.exists(self.filename):
                return
            time.sleep(2**i/1000.)
        raise LockfileKillTimeoutError(
            "Timeout while waiting for process to exit")

    def has_exlock(self):
        """Determine if a lockfile has an exclusive lock

        Returns:
            bool:  True if the file has an exclusive lock.
        """
        if not os.path.exists(self.filename):
            return False

        with open(self.filename) as f:
            try:
                fcntl.lockf(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            except:
                return True
        return False

    def get_app_name(self):
        name = sys.argv[0]
        while os.path.islink(name) and os.path.islink(os.readlink(name)):
            name = os.readlink(name)
        return os.path.basename(name)

    def get_pid(self):
        """Return the PID from the lockfile"""
        with open(self.filename, "r") as lockfile:
            return int(lockfile.read())
