Python: obtenga una ruta relativa al comparar dos rutas absolutas

143

Digamos que tengo dos caminos absolutos. Necesito verificar si la ubicación a la que se refiere uno de los caminos es un descendiente del otro. Si es cierto, necesito encontrar la ruta relativa del descendiente del antepasado. ¿Cuál es una buena manera de implementar esto en Python? ¿Alguna biblioteca de la que me pueda beneficiar?

tamakisquare
fuente

Respuestas:

168

os.path.commonprefix () y os.path.relpath () son tus amigos:

>>> print os.path.commonprefix(['/usr/var/log', '/usr/var/security'])
'/usr/var'
>>> print os.path.commonprefix(['/tmp', '/usr/var'])  # No common prefix: the root is the common prefix
'/'

Por lo tanto, puede probar si el prefijo común es una de las rutas, es decir, si una de las rutas es un antepasado común:

paths = […, …, …]
common_prefix = os.path.commonprefix(list_of_paths)
if common_prefix in paths:
    

Luego puede encontrar las rutas relativas:

relative_paths = [os.path.relpath(path, common_prefix) for path in paths]

Incluso puede manejar más de dos rutas, con este método, y probar si todas las rutas están debajo de una de ellas.

PD : dependiendo de cómo se vean sus rutas, es posible que desee realizar alguna normalización primero (esto es útil en situaciones en las que uno no sabe si siempre terminan con '/' o no, o si algunas de las rutas son relativas). Las funciones relevantes incluyen os.path.abspath () y os.path.normpath () .

PPS : como Peter Briggs mencionó en los comentarios, el enfoque simple descrito anteriormente puede fallar:

>>> os.path.commonprefix(['/usr/var', '/usr/var2/log'])
'/usr/var'

aunque no/usr/var es un prefijo común de los caminos. Forzar que todas las rutas terminen con '/' antes de llamar resuelve este problema (específico).commonprefix()

PPPS : como se menciona en bluenote10, agregar una barra oblicua no resuelve el problema general. Aquí está su pregunta de seguimiento: ¿Cómo eludir la falacia del prefijo os.path.common de Python?

PPPPS : comenzando con Python 3.4, tenemos pathlib , un módulo que proporciona un entorno de manipulación de ruta más sano. Supongo que el prefijo común de un conjunto de rutas puede obtenerse obteniendo todos los prefijos de cada ruta (con PurePath.parents()), tomando la intersección de todos estos conjuntos principales y seleccionando el prefijo común más largo.

PPPPPS : Python 3.5 introdujo una solución adecuada a esta pregunta: os.path.commonpath()que devuelve una ruta válida.

Eric O Lebigot
fuente
Exactamente lo que necesito. Gracias por su pronta respuesta. Aceptará su respuesta una vez que se levante la restricción de tiempo.
tamakisquare
10
Tenga cuidado con commonprefix, por ejemplo, el prefijo común para /usr/var/logy /usr/var2/logse devuelve como /usr/var- que probablemente no sea lo que esperaría. (También es posible que devuelva rutas que no son directorios válidos)
Peter Briggs
@ PeterBriggs: Gracias, esta advertencia es importante. Agregué un PPS.
Eric O Lebigot
1
@EOL: Realmente no veo cómo solucionar el problema agregando una barra :(. ¿Qué pasa si lo tenemos ['/usr/var1/log/', '/usr/var2/log/']?
bluenote10
1
@EOL: Dado que no pude encontrar una solución atractiva para este problema, pensé que podría estar bien discutir este subtema en una pregunta separada .
bluenote10
86

os.path.relpath:

Devuelve una ruta de archivo relativa a la ruta desde el directorio actual o desde un punto de inicio opcional.

>>> from os.path import relpath
>>> relpath('/usr/var/log/', '/usr/var')
'log'
>>> relpath('/usr/var/log/', '/usr/var/sad/')
'../log'

Entonces, si la ruta relativa comienza con '..'- significa que la segunda ruta no es descendiente de la primera ruta.

En Python3 puedes usar PurePath.relative_to:

Python 3.5.1 (default, Jan 22 2016, 08:54:32)
>>> from pathlib import Path

>>> Path('/usr/var/log').relative_to('/usr/var/log/')
PosixPath('.')

>>> Path('/usr/var/log').relative_to('/usr/var/')
PosixPath('log')

>>> Path('/usr/var/log').relative_to('/etc/')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/pathlib.py", line 851, in relative_to
    .format(str(self), str(formatted)))
ValueError: '/usr/var/log' does not start with '/etc'
warvariuc
fuente
2
Verificar la presencia de os.pardires más robusto que verificarlo ..(de acuerdo, sin embargo, no hay muchas otras convenciones).
Eric O Lebigot
8
¿Estoy equivocado o es os.relpathmás poderoso ya que lo maneja ..y PurePath.relative_to()no lo hace? ¿Me estoy perdiendo de algo?
Ray Salemi
15

Otra opción es

>>> print os.path.relpath('/usr/var/log/', '/usr/var')
log

fuente
Esto siempre devuelve un camino relativo; esto no indica directamente si una de las rutas está por encima de la otra ( os.pardirsin embargo, se puede verificar la presencia frente a las dos posibles rutas relativas resultantes).
Eric O Lebigot
8

Una reseña de la sugerencia de jme, usando pathlib, en Python 3.

from pathlib import Path
parent = Path(r'/a/b')
son = Path(r'/a/b/c/d')            

if parent in son.parents or parent==son:
    print(son.relative_to(parent)) # returns Path object equivalent to 'c/d'
Tahlor
fuente
Entonces dir1.relative_to(dir2)dará PosixPath ('.') Si son lo mismo. Cuando lo usa if dir2 in dir1.parents, excluye el caso de identidad. Si alguien está comparando rutas y quiere ejecutar relative_to()si son compatibles con rutas, una mejor solución puede ser if dir2 in (dir1 / 'x').parentso if dir2 in dir1.parents or dir2 == dir1. Entonces, todos los casos de compatibilidad de ruta están cubiertos.
ingyhere
3

Python2 puro sin dep:

def relpath(cwd, path):
    """Create a relative path for path from cwd, if possible"""
    if sys.platform == "win32":
        cwd = cwd.lower()
        path = path.lower()
    _cwd = os.path.abspath(cwd).split(os.path.sep)
    _path = os.path.abspath(path).split(os.path.sep)
    eq_until_pos = None
    for i in xrange(min(len(_cwd), len(_path))):
        if _cwd[i] == _path[i]:
            eq_until_pos = i
        else:
            break
    if eq_until_pos is None:
        return path
    newpath = [".." for i in xrange(len(_cwd[eq_until_pos+1:]))]
    newpath.extend(_path[eq_until_pos+1:])
    return os.path.join(*newpath) if newpath else "."
Jan Stürtz
fuente
Este se ve bien, pero, como me encuentro, hay un problema cuando cwdy pathson lo mismo. primero debe verificar si esos dos son iguales y regresar ""o bien"."
Srđan Popić
1

Editar: Vea la respuesta de jme para la mejor manera con Python3.

Usando pathlib, tiene la siguiente solución:

Digamos que queremos verificar si sones un descendiente de parent, y ambos son Pathobjetos. Podemos obtener una lista de las partes en el camino con list(parent.parts). Luego, solo verificamos que el comienzo del hijo sea igual a la lista de segmentos del padre.

>>> lparent = list(parent.parts)
>>> lson = list(son.parts)
>>> if lson[:len(lparent)] == lparent:
>>> ... #parent is a parent of son :)

Si desea obtener la parte restante, simplemente puede hacer

>>> ''.join(lson[len(lparent):])

Es una cadena, pero por supuesto puede usarla como un constructor de otro objeto Path.

Jeremy Cochoy
fuente
44
Es incluso más fácil que eso: simplemente parent in son.parents, y si es así, obtener el resto con son.relative_to(parent).
jme
@jme Tu respuesta es aún mejor, ¿por qué no la publicas?
Jeremy Cochoy