# py2deb: Python to Debian package converter.
#
# Author: Peter Odding <peter.odding@paylogic.com>
# Last Change: August 6, 2020
# URL: https://py2deb.readthedocs.io

"""
The :mod:`py2deb.hooks` module contains post-installation and pre-removal hooks.

This module is a bit special in the sense that it is a part of the py2deb code
base but it is embedded in the Debian binary packages generated by py2deb as a
post-installation and pre-removal hook.

Because this module is embedded in generated packages it can't use the external
dependencies of the py2deb project, it needs to restrict itself to Python's
standard library.

My reasons for including this Python script as a "proper module" inside the
py2deb project:

- It encourages proper documentation of the functionality in this module, which
  enables users to read through the documentation without having to dive into
  py2deb's source code.

- It makes it easier to unit test the individual functions in this script
  without jumping through too many hoops (I greatly value test suite coverage).

The :func:`~py2deb.package.PackageToConvert.generate_maintainer_script()`
method is responsible for converting this module into a post-installation or
pre-removal script. It does so by reading this module's source code and
appending a call to :func:`post_installation_hook()` or
:func:`pre_removal_hook()` at the bottom.
"""

# Standard library modules.
import errno
import imp
import json
import logging
import os
import py_compile
import subprocess

# Detect whether the Python implementation we're running on supports PEP 3147.
HAS_PEP_3147 = hasattr(imp, 'get_tag')

# Initialize a logger.
logger = logging.getLogger('py2deb.hooks')


def post_installation_hook(package_name, alternatives, modules_directory, namespaces, namespace_style):
    """
    Generic post-installation hook for packages generated by py2deb.

    :param package_name:

      The name of the system package (a string).

    :param alternatives:

      The relevant subset of values in
      :attr:`~py2deb.converter.PackageConverter.alternatives`.

    :param modules_directory:

      The absolute pathname of the directory where Python modules are installed
      (a string).

    :param namespaces:

      The namespaces used by the package (a list of tuples in the format
      generated by :attr:`~py2deb.package.PackageToConvert.namespaces`).

    :param namespace_style:

      The style of namespaces being used (one of the strings returned by
      :attr:`~py2deb.package.PackageToConvert.namespace_style`).

    Uses the following functions to implement everything py2deb needs from the
    post-installation maintainer script:

    - :func:`generate_bytecode_files()`
    - :func:`create_alternatives()`
    - :func:`initialize_namespaces()`
    """
    initialize_logging()
    installed_files = find_installed_files(package_name)
    generate_bytecode_files(package_name, installed_files)
    create_alternatives(package_name, alternatives)
    initialize_namespaces(package_name, modules_directory, namespaces, namespace_style)


def pre_removal_hook(package_name, alternatives, modules_directory, namespaces):
    """
    Generic pre-removal hook for packages generated by py2deb.

    :param package_name:

      The name of the system package (a string).

    :param alternatives:

      The relevant subset of values in
      :attr:`~py2deb.converter.PackageConverter.alternatives`.

    :param modules_directory:

      The absolute pathname of the directory where Python modules are installed
      (a string).

    :param namespaces:

      The namespaces used by the package (a list of tuples in the format
      generated by :attr:`py2deb.package.PackageToConvert.namespaces`).

    Uses the following functions to implement everything py2deb needs from the
    pre-removal maintainer script:

    - :func:`cleanup_bytecode_files()`
    - :func:`cleanup_alternatives()`
    - :func:`cleanup_namespaces()`
    """
    initialize_logging()
    installed_files = find_installed_files(package_name)
    cleanup_bytecode_files(package_name, installed_files)
    cleanup_alternatives(package_name, alternatives)
    cleanup_namespaces(package_name, modules_directory, namespaces)


def initialize_logging():
    """Initialize logging to the terminal and :man:`apt` log files."""
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s %(name)s[%(process)d] %(levelname)s %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S')


def find_installed_files(package_name):
    """
    Find the files installed by a Debian system package.

    :param package_name: The name of the system package (a string).
    :returns: A list of absolute filenames (strings).

    Uses the ``dpkg -L`` command.
    """
    dpkg = subprocess.Popen(['dpkg', '-L', package_name], stdout=subprocess.PIPE, universal_newlines=True)
    stdout, stderr = dpkg.communicate()
    return stdout.splitlines()


def generate_bytecode_files(package_name, installed_files):
    """
    Generate Python byte code files for the ``*.py`` files installed by a package.

    :param package_name:

        The name of the system package (a string).

    :param installed_files:

        A list of strings with the absolute pathnames of installed files.

    Uses :func:`py_compile.compile()` to generate bytecode files.
    """
    num_generated = 0
    for filename in installed_files:
        if filename.endswith('.py'):
            py_compile.compile(filename)
            num_generated += 1
    if num_generated > 0:
        logger.info("Generated %i Python bytecode file(s) for %s package.", num_generated, package_name)


def cleanup_bytecode_files(package_name, installed_files):
    """
    Cleanup Python byte code files generated when a package was installed.

    :param package_name:

        The name of the system package (a string).

    :param installed_files:

        A list of strings with the absolute pathnames of installed files.
    """
    num_removed = cleanup_bytecode_helper(installed_files)
    if num_removed > 0:
        logger.info("Cleaned up %i Python bytecode file(s) for %s package.", num_removed, package_name)


def cleanup_bytecode_helper(filenames):
    """
    Cleanup Python byte code files.

    :param filenames: A list of strings with the absolute pathnames of installed files.
    :returns: The number of files that were removed (an integer).
    """
    num_removed = 0
    for filename in filenames:
        if filename.endswith('.py'):
            for bytecode_file in find_bytecode_files(filename):
                os.unlink(bytecode_file)
                num_removed += 1
            if HAS_PEP_3147:
                remove_empty_directory(os.path.join(os.path.dirname(filename), '__pycache__'))
    return num_removed


def remove_empty_directory(directory):
    """
    Remove a directory if it is empty.

    :param directory: The pathname of the directory (a string).
    """
    try:
        os.rmdir(directory)
    except OSError as e:
        if e.errno not in (errno.ENOTEMPTY, errno.ENOENT):
            raise


def find_bytecode_files(python_file):
    """
    Find the byte code file(s) generated from a Python file.

    :param python_file: The pathname of a ``*.py`` file (a string).
    :returns: A generator of pathnames (strings).

    Starting from Python 3.2 byte code files are written according to `PEP
    3147`_ which also defines :func:`imp.cache_from_source()` to locate
    (optimized) byte code files. When this function is available it is used,
    when it's not available the corresponding ``*.pyc`` and/or ``*.pyo`` files
    are located manually by :func:`find_bytecode_files()`.

    .. _PEP 3147: https://www.python.org/dev/peps/pep-3147/
    """
    if HAS_PEP_3147:
        bytecode_file = imp.cache_from_source(python_file, True)
        if os.path.isfile(bytecode_file):
            yield bytecode_file
        optimized_bytecode_file = imp.cache_from_source(python_file, False)
        if os.path.isfile(optimized_bytecode_file):
            yield optimized_bytecode_file
    else:
        for suffix in ('c', 'o'):
            bytecode_file = python_file + suffix
            if os.path.isfile(bytecode_file):
                yield bytecode_file


def create_alternatives(package_name, alternatives):
    """
    Use :man:`update-alternatives` to install a global symbolic link to a program.

    :param package_name:

        The name of the system package (a string).

    :param alternatives:

        The relevant subset of values in
        :attr:`~py2deb.converter.PackageConverter.alternatives`.

    Install a program available inside the custom installation prefix in the
    system wide executable search path using the Debian alternatives system.
    """
    for link, path in alternatives:
        name = os.path.basename(link)
        subprocess.call(['update-alternatives', '--install', link, name, path, '0'])


def cleanup_alternatives(package_name, alternatives):
    """
    Cleanup the alternatives that were previously installed by :func:`create_alternatives()`.

    :param package_name:

        The name of the system package (a string).

    :param alternatives:

        The relevant subset of values in
        :attr:`~py2deb.converter.PackageConverter.alternatives`.
    """
    for link, path in alternatives:
        name = os.path.basename(link)
        subprocess.call(['update-alternatives', '--remove', name, path])


def initialize_namespaces(package_name, modules_directory, namespaces, namespace_style):
    """
    Initialize Python `namespace packages`_ so they can be imported in the normal way.

    Both pkgutil-style and pkg_resources-style namespace packages are supported
    (although support for the former was added in 2020 whereas support for the
    latter has existed since 2015).

    :param package_name:

      The name of the system package (a string).

    :param modules_directory:

      The absolute pathname of the directory where Python modules are installed
      (a string).

    :param namespaces:

      The namespaces used by the package (a list of tuples in the format
      generated by :attr:`~py2deb.package.PackageToConvert.namespaces`).

    :param namespace_style:

      The style of namespaces being used (one of the strings returned by
      :attr:`~py2deb.package.PackageToConvert.namespace_style`).

    .. _namespace packages: https://packaging.python.org/guides/packaging-namespace-packages/
    """
    if namespaces:
        with NameSpaceReferenceCount(modules_directory) as reference_counts:
            for components in namespaces:
                package_directory = os.path.join(modules_directory, *components)
                logger.debug("Initializing namespace %s (%s) ..", '.'.join(components), package_directory)
                if not os.path.isdir(package_directory):
                    os.makedirs(package_directory)
                package_file = os.path.join(package_directory, '__init__.py')
                with open(package_file, 'w') as handle:
                    if namespace_style == 'pkgutil':
                        handle.write("__path__ = __import__('pkgutil').extend_path(__path__, __name__)\n")
                    elif namespace_style == 'setuptools':
                        handle.write("__import__('pkg_resources').declare_namespace(__name__)\n")
                    else:
                        handle.write("# namespace package\n")
                reference_counts[components] += 1
        logger.info("Initialized %i namespaces for %s package.", len(namespaces), package_name)


def cleanup_namespaces(package_name, modules_directory, namespaces):
    """
    Clean up Python namespace packages previously initialized using :func:`initialize_namespaces()`.

    :param package_name:

        The name of the system package (a string).

    :param modules_directory:

        The absolute pathname of the directory where Python modules are
        installed (a string).

    :param namespaces:

        The namespaces used by the package (a list of tuples in the format
        generated by :attr:`~py2deb.package.PackageToConvert.namespaces`).
    """
    if namespaces:
        with NameSpaceReferenceCount(modules_directory) as reference_counts:
            num_cleaned = 0
            for components in reversed(list(namespaces)):
                package_directory = os.path.join(modules_directory, *components)
                init_file = os.path.join(package_directory, '__init__.py')
                if reference_counts[components] > 1:
                    logger.debug("Not yet de-initializing namespace %s (%s) ..",
                                 '.'.join(components), package_directory)
                elif reference_counts[components] == 1:
                    logger.debug("De-initializing namespace %s (%s) ..",
                                 '.'.join(components), package_directory)
                    cleanup_bytecode_helper([init_file])
                    os.unlink(init_file)
                    remove_empty_directory(package_directory)
                    num_cleaned += 1
                reference_counts[components] -= 1
            if num_cleaned > 0:
                logger.info("Cleaned up %i namespaces for %s package.", num_cleaned, package_name)


class NameSpaceReferenceCount(dict):

    """Persistent reference counting for initialization of namespace packages."""

    def __init__(self, modules_directory):
        """
        Initialize a :class:`NameSpaceReferenceCount` object.

        :param modules_directory:

            The absolute pathname of the directory where Python modules are
            installed (a string).
        """
        self.data_file = os.path.join(modules_directory, 'py2deb-namespaces.json')

    def __enter__(self):
        """Load the persistent data file (if it exists)."""
        if os.path.isfile(self.data_file):
            with open(self.data_file) as handle:
                self.update(json.load(handle))
        return self

    def __exit__(self, exc_type=None, exc_value=None, traceback=None):
        """Save the persistent data file."""
        if len(self) > 0:
            with open(self.data_file, 'w') as handle:
                json.dump(self, handle)
        elif os.path.isfile(self.data_file):
            os.unlink(self.data_file)

    def __getitem__(self, key):
        """Get the reference count of a namespace (defaults to zero)."""
        return dict.get(self, '.'.join(key), 0)

    def __setitem__(self, key, value):
        """Set the reference count of a namespace."""
        key = '.'.join(key)
        if value > 0:
            dict.__setitem__(self, key, value)
        else:
            self.pop(key, None)
