# Copyright (c) 2009 ActiveState Software Inc.
# See http://www.activestate.com/activepython/license/ for licensing
# information.

"""
    pypm.common.package
    ~~~~~~~~~~~~~~~~~~~~~~~

    Represent Python packages stored availble as source and in the binary
    repository.
"""

import os
import sys
import shutil
import tempfile
import json
import re
import tarfile
from collections import namedtuple
from contextlib import contextmanager

from pypm.common.util import xjoin, SimpleObject
from pypm.common import compression, console

__all__ = ['SourcePackage', 'BinaryPackage']


#
# SourcePackage
#

class SourcePackage(SimpleObject):
    """A source package

    This class primarily represents the complete metadata of a source dist.
    """

    FIELDS = ['name', 'version',
              'summary', 'description', 'keywords', 'home_page', 'license',
              'author', 'author_email', 'maintainer', 'maintainer_email',
              'install_requires']
    
    def __setattr__(self, key, value):
        if key == 'name':
            # Verify that`name` attribute is valid. it:
            # 1. must be 'safe' according to pkg_resources.safe_name
            # 2. can contain - (hyphen)
            # By adhering to setuptools' naming standards, we inherit its
            # hard-earned wisdom and, thus, avoid unexpected bugs related to
            # package naming.
            if not re.match('^[A-Za-z0-9.-]+$', value):
                raise ValueError, 'invalid name: %s' % value
        super(SourcePackage, self).__setattr__(key, value)
    
    @property
    def canonical_name(self):
        """Return the canonical package name meant to be *unique*

        Package names can be case insensitive (eg: ConfigObj vs configobj); so
        we provide a 'definite' version of it that is guaranteed to be unique
        regardless of where it is retrieved from.

        This is done by simply converting to lower case.
        """
        return self.name.lower()

    @property
    def full_name(self):
        """Return a descriptive name including version

        This property is typically used for display/diagnose/logging purposes.
        """
        return '{0.canonical_name}-{0.version}'.format(self)

    @property
    def full_version(self):
        """Return the full binary version

        version+pkg_version
        """
        return '{0.version}-{0.pkg_version}'.format(self)
        
    def __key__(self):
        """Return a tuple of comparable/hashabla elements unique to this object
        
        This method is typically to be used in __eq__ and __hash__
        """
        return self.canonical_name, self.version

    def __eq__(self, other):
        return self.__key__() == other.__key__()
        
    def __hash__(self):
        return hash(self.__key__())

    def __str__(self):
        return '{0.__class__.__name__}<{0.full_name}>'.format(self)
    __repr__ = __str__

#
# BinaryPackage
#
        
class BinaryPackage(SourcePackage):
    """A binary package

    A binary package, in addition to the fields of a SourcePackage, have extra
    fields corresponding to architecture, python version compiled for and the
    package verison itself
    """

    FIELDS = SourcePackage.FIELDS + ['osarch', 'pyver', 'pkg_version']
    SEPARATOR = '_'
    EXTENSION = 'pypm'

    def make_filename(self):
        """Make a package filename"""
        return self.SEPARATOR.join([
                '{0.canonical_name}-{0.version}'.format(self),
                self.osarch,
                self.pyver,
                str(self.pkg_version)]) + '.' + self.EXTENSION
    @classmethod
    def parse_filename(cls, filename):
        """Parse the filename generated by `make_filename`"""
        filename = os.path.basename(filename)
        prelude, ext = filename.rsplit('.', 1)
        assert ext == cls.EXTENSION
        
        # Since osarch may contain an underscore (eg: linux-x86_64) we split
        # smartly
        full_name, rest = prelude.split(cls.SEPARATOR, 1)
        osarch, pyver, pkg_version = rest.rsplit(cls.SEPARATOR, 2)

        # A version cannot contain a hyphen; so split accordingly
        canonical_name, version = full_name.rsplit('-', 1)
        return canonical_name, version, osarch, pyver, pkg_version
    
    def __key__(self):
        return super(BinaryPackage, self).__key__() + (self.pkg_version,)


# 
# RepoPackage
#

class RepoPackage(BinaryPackage):
    """A package that lives in the repository"""

    FIELDS = BinaryPackage.FIELDS + [
        'relpath',     # Relative path to the *.pypm file in remote repository
        'tags',        # Tags for this package (whitespace separated string)
    ]

    def __init__(self, *a, **kw):
        super(RepoPackage, self).__init__(*a, **kw)
        # once in a repository, name should be canonical_name
        self.name = self.canonical_name
        self.__download_url = None

    #
    # Methods for late binding of download URL
    # (RepoPackage has only 'relpath' field, which is not the absolute download
    #  URL)
    # 
    @property
    def download_url(self):
        """Return the download URL of this package, if available"""
        if self.__download_url is None:
            raise ValueError, 'download_url unavailable'
        return self.__download_url
    def set_download_url(self, url):
        assert url is not None
        self.__download_url = url
        
    @property
    def requires_be_license(self):
        """Return True of this package requires a Business Edition license"""
        return 'be' in self.tags.split()
    
#
# InstalledPackage
#

class InstalledPackage(BinaryPackage):
    """A package that was installed to a Python directory"""

    FIELDS = BinaryPackage.FIELDS + ['files_list']

    def get_files_list(self):
        """Return `files_list` in platform-specific format

        `files_list` always contains forward-slash separated path (like in
        Unix). On Windows, we have to use backward-slash .... which is the
        raison d'etre for this function.
        """
        if sys.platform.startswith('win'):
            return [path.replace('/', '\\') for path in self.files_list]
        else:
            return self.files_list
    

#
# PackageFile
#

class PackageFile(object):
    """Binary package file used in PyPM

    Package files are of the format .tar.gz containing two more files inside:

     - data.tar.gz: contains the actual files to be extracted over sys.prefix
     - info.json: package metadata which is a dump of BinaryPackage fields
    """

    def __init__(self, path):
        """
        path - path to the .pypm file
        """
        self.path = path

    def get_files_list(self):
        """Return the list files in the package data

        Note: this returns the contents of data.tar.gz (excluding info.json)
        """
        with self._open_data() as tf:
            return tf.getnames()

    def extract_over(self, target_dir):
        """Extract the package data over ``target_dir``"""
        with console.cd(target_dir):
            with self._open_data() as tf:
                tf.extractall()

    @contextmanager
    def extract_over2(self, target_dir):
        with console.cd(target_dir):
            with self._open_data() as tf:
                yield tf
                tf.extractall()

    @staticmethod
    def create(path, data_root, bpkg):
        """Create a package file at ``path`` containing files under
        ``data_root``

        Return the contents of info.json that was added to the package
        """
        assert type(bpkg) is BinaryPackage
        pkgroot = tempfile.mkdtemp('-pkgroot', 'pypm-')

        # generate info.json
        info_json = bpkg.to_json()

        # create the .tar.gz file (.pypm)
        compression.pack_contents(xjoin(pkgroot, 'data.tar.gz'), data_root)
        open(xjoin(pkgroot, 'info.json'), 'w').write(info_json)
        compression.pack_contents(path, pkgroot)

        shutil.rmtree(pkgroot)
        return info_json

    @contextmanager
    def _open_data(self):
        """Open data.tar.gz inside the package

        Return the tarfile handle
        """
        # tarfile will ignore all errors during extraction by default. we
        # consider this stupid. 
        errorlevel = 1
        
        tf = tarfile.open(self.path, 'r:gz', errorlevel=errorlevel)
        try:
            data_tf = tarfile.open(
                fileobj=tf.extractfile('data.tar.gz'),
                mode='r:gz',
                errorlevel=errorlevel)
            try:
                yield data_tf
            finally:
                data_tf.close()
        finally:
            tf.close()
            
