Welcome, guest | Sign In | My Account | Store | Cart

Another version of the relative path script already posted on the cookbook website. This one is somewhat shorter (at only 8 lines, excluding the data checks) and is (I hope!) clearer and more elegant. It also includes a unit testing script.

Python, 178 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
#!/usr/bin/python
#
# relpath.py
# R.Barran 30/08/2004

import os

def relpath(target, base=os.curdir):
    """
    Return a relative path to the target from either the current dir or an optional base dir.
    Base can be a directory specified either as absolute or relative to current dir.
    """

    if not os.path.exists(target):
        raise OSError, 'Target does not exist: '+target

    if not os.path.isdir(base):
        raise OSError, 'Base is not a directory or does not exist: '+base

    base_list = (os.path.abspath(base)).split(os.sep)
    target_list = (os.path.abspath(target)).split(os.sep)

    # On the windows platform the target may be on a completely different drive from the base.
    if os.name in ['nt','dos','os2'] and base_list[0] <> target_list[0]:
        raise OSError, 'Target is on a different drive to base. Target: '+target_list[0].upper()+', base: '+base_list[0].upper()

    # Starting from the filepath root, work out how much of the filepath is
    # shared by base and target.
    for i in range(min(len(base_list), len(target_list))):
        if base_list[i] <> target_list[i]: break
    else:
        # If we broke out of the loop, i is pointing to the first differing path elements.
        # If we didn't break out of the loop, i is pointing to identical path elements.
        # Increment i so that in all cases it points to the first differing path elements.
        i+=1

    rel_list = [os.pardir] * (len(base_list)-i) + target_list[i:]
    return os.path.join(*rel_list)


--- 8< --- snip --- 8< --- snip --- 8< --- snip --- 8< ---

#!/usr/bin/python
#
# relpath_test.py
# R.Barran 30/08/2004

"""Unit test the relative path function"""

import relpath,unittest
import tempfile,os,shutil,sys


def FmtPath(testpath):
    """Format a file path in os.specific format"""
    return testpath.replace('/',os.sep)

def CreateTempFile(filename):
    f = open(filename,'w')
    f.close()


class relpath_tests(unittest.TestCase):
  
    def setUp(self):
        """Prepare test environment (basic setup shared by all tests)"""
        
        # Determine where the temp directory is and run the tests there
        self.TempDir = tempfile.gettempdir() + os.sep + 'relpath_tests_dir'
        try:
            shutil.rmtree(self.TempDir)
        except:
            pass
        os.mkdir(self.TempDir)
        os.chdir(self.TempDir)
        # Create directory structure
        os.makedirs('a/b/c/')
        os.makedirs('a1/b1/c1/d1')
        # Create a couple of files to point to
        CreateTempFile('file1')
        CreateTempFile('a/b/file2')
        CreateTempFile('a1/b1/c1/d1/file3')

    def tearDown(self):
        """Bin the temp test dir and everything in it"""
        os.chdir(self.TempDir)
        os.chdir(os.pardir)
        shutil.rmtree('relpath_tests_dir')
        
    #
    # Checking for valid input
    #
    
    def testInvalidTargetName(self):                                          
        """Should fail if the target does not exist """
        self.assertRaises(OSError, relpath.relpath, 'a/nofilehere.txt')

    def testNoTargetName(self):                                          
        """Should fail if the target is not supplied """
        self.assertRaises(OSError, relpath.relpath, '')

    def testInvalidBasePath(self):                                          
        """Should fail if the base path specified does not exist """
        self.assertRaises(OSError, relpath.relpath, 'file1', 'this/path/does/not/exist')
        
    def testBasePathIsNotADir(self):                                          
        """Should fail if the base is anything other than a directory """        
        self.assertRaises(OSError, relpath.relpath, 'file1', 'a/b/file2')

    def testTargetOnDifferentDrive(self):                                          
        """On windows platform the target must be on the same drive as the base point."""
        if sys.platform == 'win32':
            self.assertRaises(OSError, relpath.relpath, 'z:/file99')

    #
    # Tests with only the target specified (no base)
    #
    
    def testTargetinCurrDir(self):                                          
        """Target is in the current directory""" 
        self.assertEqual(relpath.relpath('file1'), 'file1')
        self.assertEqual(relpath.relpath(os.path.abspath('file1')), 'file1')

    def testTarget2DirsDown(self):                                          
        """Target is 2 directories down"""  
        self.assertEqual(relpath.relpath('a/b/file2'), FmtPath('a/b/file2'))
        self.assertEqual(relpath.relpath(os.path.abspath('a/b/file2')), FmtPath('a/b/file2'))

    def testTarget2DirsUp(self):                                          
        """Target is 2 directories up"""  
        os.chdir('a/b')      
        self.assertEqual(relpath.relpath('../../file1'), FmtPath('../../file1'))
        self.assertEqual(relpath.relpath(os.path.abspath('../../file1')), FmtPath('../../file1'))

    def testTarget2Up4Down(self):                                          
        """Target is 2 directories up then down 4"""
        os.chdir('a/b')      
        self.assertEqual(relpath.relpath('../../a1/b1/c1/d1/file3'), FmtPath('../../a1/b1/c1/d1/file3'))
        self.assertEqual(relpath.relpath(os.path.abspath('../../a1/b1/c1/d1/file3')), FmtPath('../../a1/b1/c1/d1/file3'))
        self.assertEqual(relpath.relpath(self.TempDir+'/a1/b1/c1/d1/file3'), FmtPath('../../a1/b1/c1/d1/file3'))

    #
    # Tests with target and base specified
    #

    def testTargetinCurrDir_c1(self):                                          
        """Target is in the current directory, base is in c1"""        
        self.assertEqual(relpath.relpath('file1','a1/b1/c1'), FmtPath('../../../file1'))
 
    def testTarget1DirUp_c1(self):                                          
        """Target is 1 directory up from current dir, base is in c1"""
        # Result should be same as previous test, this is just a way of checking that
        # changing the curr dir has no influence on the result. 
        os.chdir('a')      
        self.assertEqual(relpath.relpath(self.TempDir+'/file1',self.TempDir+'/a1/b1/c1'), FmtPath('../../../file1'))
 
    def testTarget2DirsDown_c1(self):                                          
        """Target is 2 directories down from current dir, base is in c1"""
        os.chdir('a')  
        self.assertEqual(relpath.relpath('b/file2',self.TempDir+'/a1/b1/c1'), FmtPath('../../../a/b/file2'))

    #
    # This final test is a bit different, it loops relpath 10000 times
    # and can be used for rough performance testing of different versions of
    # relpath
    #
    
    #def testSpeedTests(self):
        
    #    for i in range(10000):
    #        os.chdir(self.TempDir)
    #        self.assertEqual(relpath.relpath('file1','a1/b1/c1'), FmtPath('../../../file1'))
    #        self.assertEqual(relpath.relpath('a/b/file2'), FmtPath('a/b/file2'))
    #        os.chdir('a')  
    #        self.assertEqual(relpath.relpath('b/file2',self.TempDir+'/a1/b1/c1'), FmtPath('../../../a/b/file2'))

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

Whilst looking for a script to calculate a relative filepath, I found one on the python cookbook website. It worked, but I thought I could see ways to improve it, so I rewrote my own version.

It is different from the script on the website in that it handles only filepaths (not urls), but hopefully does a better job with improved error-handling (for example, on a windows platform it refuses to create a path to d:\adir\somefile.txt from c:\anotherdir).

I also wrote a script to automatically test relpath based on the unittest module (included above). This is the first time I write a unit test script, and it is well worth the initial effort, as it allowed me to play around with the code and rewrite it several times without breaking it.

For example, I tried replacing the for..in..else block with the following code:

i=0 while True: try: if base_list[i] <> target_list[i]: break i+=1 except: break

Performance was very similar, so I stuck with the version that showed the use of for..in..else.

An added advantage of the unit test script, that I had not heard of before, is that it allowed me to quickly check that relpath worked correctly on Win98, Win2000 and Linux.

4 comments

Richard Philips 16 years, 6 months ago  # | flag

Correct for os.path.normcase. You should take the filename case into account:

instead of: if base_list[i] You should take the filename case into account:

instead of: if base_list[i]

Giles Brown 15 years, 5 months ago  # | flag

some assumptions are helpful, some are not. I think it is bad form to do any existance checking in this kind of routine. If you have been walking a directory structure and therefore know (as well as you can) the files exists it is just wasteful to check again.

I've written something which I think does the job and doesn't use recursion like the lisp-ish recipe also in the cookbook. See if you like some or all of this. :)

def relpath(path, reldir):
    """Returns 'path' relative to 'reldir'."""

    # use normpath to ensure path separators are uniform
    path = os.path.normpath(path)

    # find length of reldir as prefix of path (or zero if it isn't)
    prelen = len(os.path.commonprefix((
        os.path.normcase(path),
        # add a separator to get correct prefix length
        # (normpath removes trailing separators)
        os.path.normcase(os.path.normpath(reldir)) + os.sep
        )))
    return path[prelen:]
Doncho Gunchev 12 years, 8 months ago  # | flag

Incorrect when base is root directory:

Python 2.5.2 (r252:60911, Sep 30 2008, 15:42:03)
[GCC 4.3.2 20080917 (Red Hat 4.3.2-4)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import relpath
>>> relpath.relpath('/etc/passwd', '/etc')
'passwd'
>>> relpath.relpath('/etc', '/')
'../etc'
>>> _
Doncho Gunchev 12 years, 8 months ago  # | flag

What about:

#!/usr/bin/python
# -*- coding: utf-8 -*-

from os.path import abspath, dirname, normcase, normpath, splitdrive
from os.path import join as path_join, commonprefix
import os



def commonpath(a, b):
    """Returns the longest common to 'paths' path.

    Unlike the strange commonprefix:
    - this returns valid path
    - accepts only two arguments
    """
    a = normpath(normcase(a))
    b = normpath(normcase(b))

    if a == b:
        return a

    while len(a) > 0:
        if a == b:
            return a

        if len(a) > len(b):
            a = dirname(a)
        else:
            b = dirname(b)

    return None


def relpath(target, base_path=os.curdir):
    """\
    Return a relative path to the target from either the current directory
    or an optional base directory.

    Base can be a directory specified either as absolute or relative
    to current directory."""

    base_path = normcase(abspath(normpath(base_path)))
    target = normcase(abspath(normpath(target)))

    if base_path == target:
        return '.'

    # On the windows platform the target may be on a different drive.
    if splitdrive(base_path)[0] != splitdrive(target)[0]:
        return None

    common_path_len = len(commonpath(base_path, target))

    # If there's no common prefix decrease common_path_len should be less by 1
    base_drv, base_dir = splitdrive(base_path)
    if common_path_len == len(base_drv) + 1:
        common_path_len -= 1

    # if base_path is root directory - no directories up
    if base_dir == os.sep:
        dirs_up = 0
    else:
        dirs_up = base_path[common_path_len:].count(os.sep)

    ret = os.sep.join([os.pardir] * dirs_up)
    if len(target) > common_path_len:
        ret = path_join(ret, target[common_path_len + 1:])

    return ret

The test case shows 2 seconds (this version) vs 2.5 (the original version, no file/directory checks).

Created by richard barran on Mon, 30 Aug 2004 (PSF)
Python recipes (4591)
richard barran's recipes (1)

Required Modules

  • (none specified)

Other Information and Tasks