"""
A platform-independent path encapsulation class.

This module provides one class that hides the differences between path, all
depending on the platform-specific details.  For example, under Windows, the
filenames are compared case-insensitively, and a drive letter is used.  The
filenames can still be rendered according to how they were initialized.

In particular, the following details are abstracted:

1. Case-sensitiveness; the path stores case-sensitive paths, but compares
   case-insensitively for filesystems that don't support it (i.e. under MS
   Windows).  This also takes care of the case-sensitiveness for drive letters;

2. Path separators: slashes and backslashes are not kept and are add upon
   conversion to string only;

More why?

- os.path.isabs() under Windows does not work.
- Once we have a representation in components, path manipulations are faster.
- Removes the need for normalization.

"""
# Note: eventually we may support the host:/path/to/dir syntax, since we already
# have to deal with that shyte for windooze, it may be trivial and easy.
#
# FIXME: what is a NULL ipath?  Do we support the concept?


__author__ = 'Martin Blais <blais@furius.ca>'


# stdlib imports.
import os.path
import sys, re


__all__ = ['ipath', 'error']


class error(Exception):
    """
    Exception for ipath errors.
    """

class ipath:
    """
    Platform-independent path encapsulation.
    """

    def __init__(self, pathstr=None, absolute=False):
        """
        Initialize an ipath, optionally converting to an absolute path.
        """

        self.__root = None
        """Root dir, or drive letter or UNC host.  If this is a
        - None: the path is relative
        - True: the path is absolute
        - str: a host or UNC host-mount
        - int: a drive letter
        """

        self.__comps = []
        """A list of the components, with case, unicode strings.  The component
        list may contain leading .. components, but only at the beginning of the
        list."""

        # Initialization.
        if pathstr is not None:
            assert isinstance(pathstr, (str, unicode))
            self.__set(pathstr)
        if absolute:
            self.absolutize(pathstr)

    def root(self):
        """
        Returns the root of the object.
        This is mainly used for testing.
        """
        return self.__root

    def components(self):
        """
        Returns the components of the object.
        This is mainly used for testing.
        """
        return tuple(self.__comps)

    def __set(self, pathstr):
        """
        Sets this object to the given path.

        Note: we make this method private so that the object is mostly
        immutable (we want to be able to use it as dictionary keys).
        """
        # Remove host-mount.
        if hasattr(os.path, 'splitunc'):
            hostmount, rest = os.path.splitunc(pathstr)
            if hostmount:
                self.__root = hostmount
                # Don't accept unicode for drive.
                if isinstance(self.__root, unicode):
                    self.__root = self.__root.encode('ascii')
                pathstr = rest

        # Remove drive (if no host-mount).
        if self.__root is None and hasattr(os.path, 'splitdrive'):
            drive, tail = os.path.splitdrive(pathstr)
            if drive:
                assert len(drive) == 2
                self.__root = ord(drive[0].upper())
                pathstr = tail

        # Otherwise we're dealing with a UNIX absolute path.
        if self.__root is None:
            if pathstr.startswith('/') or pathstr.startswith('\\'):
                pathstr = pathstr[1:]
                self.__root = True
            else:
                self.__root = None

        # Convert the rest of the path to Unicode.
        if isinstance(pathstr, str):
            pathstr = pathstr.decode() # decode to Unicode

        # Split the rest of the path.
        pathstr = pathstr.replace(u'\\', u'/')
        self.__comps = []
        self.__add_components(pathstr.split(u'/'))

    def __add_components(self, components):
        """
        Add new components, reducing if necessary.
        """
        for comp in components:
            # Remove empty components and single dots.
            if not comp or comp == '.':
                continue

                # Note: the desired behaviour is to ignore double-slashes rather
                # than reset.

            # Don't pop if empty.
            if comp == '..':
                if self.__comps and self.__comps[-1] != '..':
                    self.__comps.pop()
                    continue

            # Append to components.
            self.__comps.append(comp)

    def isabs(self):
        return self.__root is not None

    def isroot(self):
        return not self.__comps

    def absolute(self):
        """
        Return an absolute version of this path.
        """
        if self.isabs():
            return self.copy()
        else:
            return ipath(os.getcwd()).join(self)

    def __repr__(self):
        if self.__root is None:
            proot = u''
        elif self.__root is True:
            proot = u'/'
        elif type(self.__root) is int:
            proot = unichr(self.__root)
        else:
            assert type(self.__root) is str
            proot = self.__root.decode()
        return u'<ipath %s %s>' % (proot, ','.join(self.__comps))

    def __str__(self):
        if self.__root is None:
            proot = u''
        elif self.__root is True:
            proot = unicode(os.sep)
        elif type(self.__root) is int:
            if sys.platform.startswith('win'):
                proot = u'%c:' % self.__root + os.sep
            else:
                proot = u'/cygdrive/%c' % self.__root.lower() + os.sep
        else:
            assert type(self.__root) is str
            if sys.platform.startswith('win'):
                proot = self.__root.decode() + os.sep
            else:
                proot = self.__root.decode() + u':' + os.sep
        return proot + os.sep.join(self.__comps)

    def __cmp__(self, other):
        if other is None:
            return 1
        c = cmp(self.__root, other.__root)
        if c != 0:
            return c
        else:
            if sys.platform.startswith('win'):
                for a, b in zip(self.__comps, other.__comps):
                    c = cmp(a.lower(), b.lower())
                    if c != 0:
                        return c
            else:
                for a, b in zip(self.__comps, other.__comps):
                    c = cmp(a, b)
                    if c != 0:
                        return c
            if len(self.__comps) == len(other.__comps):
                return 0
            elif len(self.__comps) > len(other.__comps):
                return 1
            else:
                return -1

    def __eq__(self, other):
        if other is None or self.__root != other.__root:
            return False
        if len(self.__comps) != len(other.__comps):
            return False
        if sys.platform.startswith('win'):
            for a, b in zip(self.__comps, other.__comps):
                if a.lower() != b.lower():
                    return False
            return True
        else:
            if len(self.__comps) != len(other.__comps):
                return False

            if sys.platform.startswith('win'):
                for a, b in zip(self.__comps, other.__comps):
                    if a.lower() != b.lower():
                        return False
            else:
                for a, b in zip(self.__comps, other.__comps):
                    if a != b:
                        return False
            return True

    def __ne__(self, other):
        return not self.__eq__(other)

    def host(self):
        if type(self.__root) is str:
            return self.__root
        return None

    def copy(self):
        p = ipath()
        p.__root = self.__root
        p.__comps = list(self.__comps)
        return p

    def join(self, other):
        """
        Return a new instance, with the given argument joined.
        """
        copy = self.copy()
        copy.append(other)
        return copy

    def basename(self):
        """
        Like os.path.basename.
        """
        if self.__comps:
            return ipath(self.__comps[-1])

    def dirname(self):
        """
        Like os.path.dirname, returns the parent.
        """
        if not self.__comps:
            raise error('Cannot get dirname of the root directory.')
        parent = self.copy()
        parent.__comps.pop()
        return parent

    def splitext(self):
        """
        Like os.path.splitext(), but returns an ipath and a str for the
        extension.
        """
        copy = self.copy()
        if self.__comps:
            base, ext = os.path.splitext(copy.__comps[-1])
            if base:
                copy.__comps[-1] = base
            else:
                copy.__comps.pop()
            return copy, ext
        else:
            return copy, None

    def __add__(self, other):
        return self.join(other)

    def __radd__(self, other):
        return self.join(other)

    def __hash__(self):
        # Not very efficient.
        return hash(self.__root) ^ hash(tuple(x.lower() for x in self.__comps))

    #---------------------------------------------------------------------------
    # Filesystem methods.

    def exists(self):
        return os.path.exists(str(self))

    def islink(self):
        return os.path.islink(str(self))

    def isfile(self):
        return os.path.isfile(str(self))

    def isdir(self):
        return os.path.isdir(str(self))

    #---------------------------------------------------------------------------
    # Mutable methods.

    def absolutize(self):
        """
        Make this path object absolute.
        """
        apath = self.absolute()
        self.__root, self.__comps = apath.__root, apath.__comps

    def __iadd__(self, other):
        self.append(other)
        return self

    def append(self, other):
        """
        Modifies this instance to add the 'other' argument.
        """
        # If the given path is a str, construct an ipath with it first.
        if isinstance(other, (str, unicode)):
            other = ipath(other)

        if other.isabs():
            raise error('Cannot join an absolute path to a path.')

        self.__add_components(other.__comps)


import unittest

class IPathTest(unittest.TestCase):

    def test_set(self):
        "Test initialization."

        tests_unix = (
            # Absolute.
            ('/home/blais/p/.emacs',
             True, ('home', 'blais', 'p', '.emacs'),
             None, True, False),

            # Root.
            ('/',
             True, (),
             None, True, True),

            # Relative
            ('conf/etc/emacsrc',
             None, ('conf', 'common', 'etc', 'emacsrc'),
             None, False, False),

            # Dotted
            ('../conf/etc/emacsrc',
             None, ('..', 'conf', 'common', 'etc', 'emacsrc'),
             None, False, False),

            ('../../etc/bashrc',
             None, ('..', '..', 'etc', 'bashrc'),
             None, False, False),

            ('config/../etc/bashrc',
             None, ('etc', 'bashrc'),
             None, False, False),

            ('home/p/conf/config/../../etc/bashrc',
             None, ('home', 'p', 'etc', 'bashrc'),
             None, False, False),

            # Trailing slash
            ('conf/etc/emacsrc/',
             None, ('conf', 'common', 'etc', 'emacsrc'),
             None, False, False),

            # Double slash
            ('/home/blais//bin',
             True, ('home', 'blais', 'bin'),
             None, True, False),

            )

        tests_windows = (

            # Windows absolute.
            (r'\home\blais\p\.emacs',
             True, ('home', 'blais', 'p', '.emacs'),
             None, True, False),

            # Windows root.
            ('C:/',
             ord('C'), (),
             None, True, True),
            ('C:',
             ord('C'), (),
             None, True, True),

            # Windows training slash
            ('C:\\Program Files\\Microsoft Platform SDK\\',
             ord('C'), ('Program Files', 'Microsoft Platform SDK'),
             None, True, False),

            # Windows relative.
            (r'p\.emacs',
             None, ('p', '.emacs'),
             None, False, False),

            # Windows drive-letter.
            (r'C:\Program Files\Microsoft Platform SDK',
             ord('C'), ('Program Files', 'Microsoft Platform SDK'),
             None, True, False),

            (r'c:\Program Files\Microsoft Platform SDK',
             ord('C'), ('Program Files', 'Microsoft Platform SDK'),
             None, True, False),

            # Windows UNC.
            (r'\\banane\shared\blaim',
             r'\\banane\shared', ('blaim',),
             r'\\banane\shared', True, False),

            (r'\\banane\shared',
             r'\\banane\shared', (),
             r'\\banane\shared', True, True),

            # (Ambiguous case.)
            (r'\\banane',
             True, ('banane',),
             None, True, False),

            )

        if sys.platform.startswith('win'):
            tests = tests_unix + tests_windows
        else:
            tests = tests_unix

        for init, root, comps, host, abso, rootp in tests:
            # (Ambiguous case.)
            p = ipath(init)
            self.assert_(p.root() == root)
            self.assert_(p.components() == comps)
            self.assert_(p.host() == host)
            self.assert_(p.isabs() == abso)
            self.assert_(p.isroot() == rootp)

    def test_cmp(self):
        "Test comparisons."
        p1 = ipath('/home/blais/p/.emacs')
        p2 = ipath('/home/blais/p')
        p3 = ipath('/home/blais/p/.emacs')
        p4 = ipath('/home/blais/p/.emacs.min')

        self.assert_(p1 == p3)
        self.assert_(p1 != p2)
        self.assert_(p1 != p4)

    def test_render(self):
        "Test rendering."

        paths = (r'/home/blais/p/.emacs',
                 r'conf/etc/emacsrc',
                 r'../../etc/bashrc',
                 r'\home\blais\p\.emacs',)

        rendered = ('/home/blais/p/.emacs',
                    'conf/etc/emacsrc',
                    '../../etc/bashrc',
                    '/home/blais/p/.emacs',)

        if not sys.platform.startswith('win'):

            for p, ren in zip(paths, rendered): 
                self.assert_(str(ipath(p)) == ren)

        else:
            for p, ren in zip(paths, rendered): 
                self.assert_(str(ipath(p)) == ren.replace('/', '\\'))

            paths = (r'p\.emacs',
                     r'C:\Program Files\Microsoft Platform SDK',
                     r'\\banane\shared\blaim',)

            rendered = ('p\\.emacs',
                        'C:\\Program Files\\Microsoft Platform SDK',
                        '\\\\banane\\shared\\blaim',)

            for p, ren in zip(paths, rendered):
                self.assert_(str(ipath(p)) == ren)


    def test_cmp(self):
        "Test comparisons."

        if sys.platform.startswith('win'):
            p1 = ipath(r'C:\Program Files\Microsoft Platform SDK')
            p2 = ipath(r'c:\program files\Microsoft platform sdk')
            self.assert_(p1 == p2)

        p1 = ipath(r'/home/blais/prout')
        p2 = ipath(r'/home/blais/prout/prout')
        self.assert_(p1 != p2)

        self.assert_(p1 != None)
        self.assert_(None != p1)
        
        reports = [ipath('C:/Projects/Rapports/rap1.rap')]
        self.assert_(ipath('C:/Projects/Rapports/rap1.rap') in reports)
        self.assert_(ipath('C:/projects/rapports/rap1.rap') in reports)

        reports = set([ipath('C:/Projects/Rapports/rap1.rap')])
        self.assert_(ipath('C:/Projects/Rapports/rap1.rap') in reports)
        self.assert_(ipath('C:/projects/rapports/rap1.rap') in reports)

    def test_dirname(self):
        p1 = ipath(r'/home/blais/prout/prout')
        p2 = p1.dirname()
        self.assert_(p2.root() == p1.root())
        self.assert_(p2.components() == ('home', 'blais', 'prout'))

        p1 = ipath(r'/home')
        p2 = p1.dirname()
        self.assert_(p2.root() == p1.root())
        self.assert_(p2.components() == ())

        p1 = ipath(r'/')
        self.assertRaises(error, p1.dirname)
        self.assert_(p2.root() == p1.root())
        self.assert_(p2.components() == ())

    def test_concatenation(self):
        "Test join and other methods for concatenation."

        # Normal concatenation.
        p = ipath('p/conf')
        p1 = p.copy()

        self.assert_(p1.join('etc').components() ==
                     ('p', 'conf', 'common', 'etc'))
        self.assert_(p1.join('etc/emacsrc').components() ==
                     ('p', 'conf', 'common', 'etc', 'emacsrc'))
        self.assert_(p == p1)

        # Concatenating a relative path with ..
        self.assert_(p.join('../init/xinitrc').components() ==
                     ('p', 'conf', 'init', 'xinitrc'))
        self.assert_(p.join('../../init/xinitrc').components() ==
                     ('p', 'init', 'xinitrc'))

        # Concatenating an absolute path.
        self.assertRaises(error, p.join, '/home/blais')

        # Test with operator.
        self.assert_((p + 'etc').components() ==
                     ('p', 'conf', 'common', 'etc'))
        self.assert_(('etc' + p).components() ==
                     ('p', 'conf', 'common', 'etc'))

        # Test mutable operator.
        p += 'lib'
        self.assert_(p.components() ==
                     ('p', 'conf', 'common', 'lib'))

    def test_mapkeys(self):
        "Test using as keys to a map."

        p = ipath('/home/blais/p/conf/bin')
        m = {}
        m[p] = 17
        self.assert_(m.get(p) == 17)

        p2 = ipath('/home/blais/p/conf/bin')
        self.assert_(m.get(p2) == 17)

        pne = ipath('/home/blais/p')
        self.assertRaises(KeyError, m.__getitem__, pne)


    def test_absolute(self):
        "Test absolutization."
        
        p = ipath('p/conf/bin')
        a = p.absolute()

        # Check that the object has not changed.
        self.assert_(p == ipath('p/conf/bin'))

        # Test the absolute path.
        self.assert_(a == ipath(os.getcwd()) + p)
        
        # Test that already absolute paths remain absolute.
        p2 = ipath('/home/blais/p/conf/bin')
        self.assert_(p2.absolute() == p2)


    def test_splitext(self):

        p, ext = ipath('document.txt').splitext()
        self.assert_(p == ipath('document'))
        self.assert_(ext == '.txt')

        p, ext = ipath('/home/blais/document.txt').splitext()
        self.assert_(p == ipath('/home/blais/document'))
        self.assert_(ext == '.txt')

        p, ext = ipath('.bashrc').splitext()
        self.assert_(p == ipath())
        self.assert_(ext == '.bashrc')

        
if __name__ == '__main__':
    unittest.main()

