¿Convertir una cadena en un nombre de archivo válido?

298

Tengo una cadena que quiero usar como nombre de archivo, por lo que quiero eliminar todos los caracteres que no se permitirían en los nombres de archivo, usando Python.

Prefiero ser estricto que lo contrario, así que digamos que quiero retener solo letras, dígitos y un pequeño conjunto de otros caracteres como "_-.() ". ¿Cuál es la solución más elegante?

El nombre de archivo debe ser válido en varios sistemas operativos (Windows, Linux y Mac OS): es un archivo MP3 en mi biblioteca con el título de la canción como nombre de archivo, y se comparte y realiza una copia de seguridad entre 3 máquinas.

Sophie Gage
fuente
17
¿No debería esto integrarse en el módulo os.path?
Endolith
2
Quizás, aunque su caso de uso requeriría una única ruta segura en todas las plataformas, no solo la actual, que es algo que os.path no está diseñado para manejar.
javawizard
2
Para ampliar el comentario anterior: el diseño actual de os.pathrealmente carga una biblioteca diferente según el sistema operativo (consulte la segunda nota en la documentación ). Entonces, si se implementó una función de os.pathcotización, solo podría citar la cadena para POSIX-safety cuando se ejecuta en un sistema POSIX o para windows-safety cuando se ejecuta en Windows. El nombre de archivo resultante no necesariamente sería válido tanto en Windows como en POSIX, que es lo que pide la pregunta.
dshepherd

Respuestas:

164

Puede ver el marco de Django para ver cómo crean una "babosa" a partir de texto arbitrario. Una babosa es compatible con URL y nombre de archivo.

Las utilidades de texto de Django definen una función, slugify()que probablemente sea el estándar de oro para este tipo de cosas. Esencialmente, su código es el siguiente.

def slugify(value):
    """
    Normalizes string, converts to lowercase, removes non-alpha characters,
    and converts spaces to hyphens.
    """
    import unicodedata
    value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore')
    value = unicode(re.sub('[^\w\s-]', '', value).strip().lower())
    value = unicode(re.sub('[-\s]+', '-', value))
    # ...
    return value

Hay más, pero lo dejé fuera, ya que no aborda la slugificación, sino el escape.

S.Lott
fuente
11
La última línea debe ser: value = unicode (re.sub ('[- \ s] +', '-', value))
Joseph Turian
1
Gracias, podría estar perdiendo algo, pero estoy obteniendo: "normalizar () argumento 2 debe ser unicode, no str"
Alex Cook
"normalizar () argumento 2". Significa el value. Si el valor debe ser Unicode, debe asegurarse de que realmente sea Unicode. O. Es posible que desee omitir la normalización Unicode si su valor real es en realidad una cadena ASCII.
S.Lott
8
En caso de que alguien no haya notado el lado positivo de este enfoque, es que no solo elimina los caracteres no alfa, sino que intenta encontrar buenos sustitutos primero (a través de la normalización NFKD), por lo que é se convierte en e, un superíndice 1 se convierte en un normal 1, etc. Gracias
Michael Scott Cuthbert
48
La slugifyfunción se ha movido a django / utils / text.py , y ese archivo también contiene una get_valid_filenamefunción.
Denilson Sá Maia
104

Este enfoque de la lista blanca (es decir, permitir solo los caracteres presentes en valid_chars) funcionará si no hay límites en el formato de los archivos o la combinación de caracteres válidos que son ilegales (como ".."), por ejemplo, lo que usted dice permitiría un nombre de archivo llamado ". txt" que creo que no es válido en Windows. Como este es el enfoque más simple, trataría de eliminar los espacios en blanco de los valid_chars y anteponer una cadena válida conocida en caso de error, cualquier otro enfoque tendrá que saber qué está permitido dónde hacer frente a las limitaciones de nomenclatura de archivos de Windows y así ser Mucho más complejo.

>>> import string
>>> valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
>>> valid_chars
'-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
>>> filename = "This Is a (valid) - filename%$&$ .txt"
>>> ''.join(c for c in filename if c in valid_chars)
'This Is a (valid) - filename .txt'
Vinko Vrsalovic
fuente
77
valid_chars = frozenset(valid_chars)no dolería Es 1.5 veces más rápido si se aplica a todos los caracteres.
jfs
2
Advertencia: Esto asigna dos cadenas diferentes a la misma cadena >>> cadena de importación >>> valid_chars = "- . ()% S% s"% (string.ascii_letters, string.digits) >>> valid_chars '- . () abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '>>> filename = "a.com/hello/world" >>>' '.join (c para c en el nombre de archivo si c en valid_chars)' a.comhelloworld '>>> filename = "a.com/helloworld ">>> '' .join (c para c en el nombre de archivo si c en valid_chars) 'a.comhelloworld' >>>
robert king
3
Sin mencionar que nombrar un archivo "CON"en Windows te meterá en problemas ...
Nathan Osman
2
Una ligera reorganización hace que especificar un personaje sustituto sea sencillo. Primero, la funcionalidad original: '' .join (c if c en valid_chars else '' para c en nombre de archivo) o con un carácter o cadena sustituido para cada carácter no válido: '' .join (c if c en valid_chars else '.' Para c en nombre de archivo)
PeterVermont
101

Puede usar la comprensión de listas junto con los métodos de cadena.

>>> s
'foo-bar#baz?qux@127/\\9]'
>>> "".join(x for x in s if x.isalnum())
'foobarbazqux1279'
John Mee
fuente
3
Tenga en cuenta que puede omitir los corchetes. En este caso , se pasa una expresión generadora para unirse, lo que ahorra el paso de crear una lista que de otro modo no se usaría.
Oben Sonne
31
+1 Me encantó esto. Ligera modificación que he hecho: "" .join ([x if x.isalnum () else "_" para x en s]) - produciría un resultado donde los elementos no válidos son _, como si estuvieran en blanco. Quizás thalps alguien más.
Eddie Parker
12
¡Esta solución es genial! Sin embargo, hice una ligera modificación:filename = "".join(i for i in s if i not in "\/:*?<>|")
Alex Krycek
1
Desafortunadamente, ni siquiera permite espacios y puntos, pero me gusta la idea.
tiktak
99
@tiktak: para (también) permitir espacios, puntos y guiones bajos a los que puede ir"".join( x for x in s if (x.isalnum() or x in "._- "))
hardmooth
95

¿Cuál es la razón para usar las cadenas como nombres de archivo? Si la legibilidad humana no es un factor, optaría por el módulo base64 que puede producir cadenas seguras del sistema de archivos. No será legible, pero no tendrá que lidiar con colisiones y es reversible.

import base64
file_name_string = base64.urlsafe_b64encode(your_string)

Actualización : modificado según el comentario de Matthew.

Igal Serban
fuente
1
Obviamente esta es la mejor respuesta si ese es el caso.
user32141
6060
¡Advertencia! La codificación base64 por defecto incluye el carácter "/" como salida válida que no es válida en los nombres de archivo en muchos sistemas. En su lugar, use base64.urlsafe_b64encode (your_string)
Matthew
15
En realidad, la legibilidad humana es casi siempre un factor, aunque solo sea para fines de depuración.
static_rtti
55
En Python 3 your_stringdebe ser una matriz de bytes o el resultado de encode('ascii')que esto funcione.
Noumenon
44
def url2filename(url): url = url.encode('UTF-8') return base64.urlsafe_b64encode(url).decode('UTF-8') def filename2url(f): return base64.urlsafe_b64decode(f).decode('UTF-8')
JeffProd
40

Para complicar aún más las cosas, no se garantiza que obtenga un nombre de archivo válido simplemente eliminando caracteres no válidos. Dado que los caracteres permitidos difieren en diferentes nombres de archivo, un enfoque conservador podría terminar convirtiendo un nombre válido en uno inválido. Es posible que desee agregar un manejo especial para los casos donde:

  • La cadena son todos caracteres no válidos (dejándolo con una cadena vacía)

  • Terminas con una cadena con un significado especial, por ejemplo, "." o ".."

  • En Windows, ciertos nombres de dispositivos están reservados. Por ejemplo, no puede crear un archivo llamado "nul", "nul.txt" (o nul.anything de hecho) Los nombres reservados son:

    CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, ​​COM5, COM6, COM7, COM8, COM9, LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8 y LPT9

Probablemente pueda solucionar estos problemas anteponiendo alguna cadena a los nombres de archivo que nunca pueden dar lugar a uno de estos casos y eliminando caracteres no válidos.

Brian
fuente
25

Hay un buen proyecto en Github llamado python-slugify :

Instalar en pc:

pip install python-slugify

Luego usa:

>>> from slugify import slugify
>>> txt = "This\ is/ a%#$ test ---"
>>> slugify(txt)
'this-is-a-test'
Shoham
fuente
2
Me gusta esta biblioteca pero no es tan buena como pensaba. La prueba inicial está bien, pero también convierte los puntos. Entonces se test.txtpone test-txtque es demasiado.
therealmarv
23

Al igual que S.Lott respondió, puede ver el Marco de Django para ver cómo convierten una cadena en un nombre de archivo válido.

La versión más reciente y actualizada se encuentra en utils / text.py, y define "get_valid_filename", que es el siguiente:

def get_valid_filename(s):
    s = str(s).strip().replace(' ', '_')
    return re.sub(r'(?u)[^-\w.]', '', s)

(Ver https://github.com/django/django/blob/master/django/utils/text.py )

cowlinator
fuente
44
para los perezosos que ya están en django:django.utils.text import get_valid_filename
anunciador el
2
En caso de que no esté familiarizado con la expresión regular, re.sub(r'(?u)[^-\w.]', '', s)elimina todos los caracteres que no sean letras, ni números (0-9), ni el guión bajo ('_'), ni el guión ('-'), ni el punto ('.' ) "Letras" aquí incluye todas las letras unicode, como 漢語.
Cowlinator
3
Es posible que también desee verificar la longitud: los nombres de archivo están limitados a 255 caracteres (o, ya sabe, 32; dependiendo del FS)
Matthias Winkelmann
19

Esta es la solución que finalmente utilicé:

import unicodedata

validFilenameChars = "-_.() %s%s" % (string.ascii_letters, string.digits)

def removeDisallowedFilenameChars(filename):
    cleanedFilename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore')
    return ''.join(c for c in cleanedFilename if c in validFilenameChars)

La llamada unicodedata.normalize reemplaza los caracteres acentuados con el equivalente sin acento, lo cual es mejor que simplemente eliminarlos. Después de eso, se eliminan todos los caracteres no permitidos.

Mi solución no antepone una cadena conocida para evitar posibles nombres de archivo no permitidos, porque sé que no pueden aparecer dado mi formato de nombre de archivo particular. Una solución más general necesitaría hacerlo.

Sophie Gage
fuente
debería poder usar uuid.uuid4 () para su prefijo único
slf
66
caso de camello .. ahh
erizo demente
¿Podría esto ser editado / actualizado para trabajar con Python 3.6?
Wavesailor
13

Tenga en cuenta que en realidad no hay restricciones para los nombres de archivos en sistemas Unix que no sean

  • Puede no contener \ 0
  • Puede no contener /

Todo lo demás es juego limpio.

$ touch "
> incluso multilínea
> jaja
> ^ [[31m rojo ^ [[0m
> maldad "
$ ls -la 
-rw-r - r-- 0 17 de noviembre 23:39? incluso multilínea? jaja ?? [31m rojo? [0m? malvado
$ ls -lab
-rw-r - r-- 0 17 de noviembre 23:39 \ neven \ multiline \ nhaha \ n \ 033 [31m \ red \ \ 033 [0m \ nevil
$ perl -e 'para mi $ i (glob (q {./* even *})) {print $ i; } '
./
incluso multilínea
jaja
 rojo 
mal

Sí, acabo de almacenar códigos de color ANSI en un nombre de archivo y los hice surtir efecto.

Para entretenerse, coloque un carácter BEL en el nombre de un directorio y vea la diversión que se produce cuando lo graba en CD;)

Kent Fredric
fuente
El OP dice que "El nombre del archivo debe ser válido en múltiples sistemas operativos"
cowlinator
1
@cowlinator esa aclaración se agregó 10 horas después de que se publique mi respuesta :) Verifique el registro de edición del OP.
Kent Fredric
12

En una linea:

valid_file_name = re.sub('[^\w_.)( -]', '', any_string)

También puede poner el carácter '_' para hacerlo más legible (por ejemplo, en caso de reemplazar barras)

mnach
fuente
7

Puede usar el método re.sub () para reemplazar cualquier cosa que no sea "similar a un archivo". Pero en efecto, cada personaje podría ser válido; así que no hay funciones preconstruidas (creo), para hacerlo.

import re

str = "File!name?.txt"
f = open(os.path.join("/tmp", re.sub('[^-a-zA-Z0-9_.() ]+', '', str))

Resultaría en un identificador de archivo para /tmp/filename.txt.

gx.
fuente
55
Necesita que el guión vaya primero en el grupo de comparación para que no aparezca como un rango. re.sub ('[^ - a-zA-Z0-9 _. ()] +', '', str)
phord
7
>>> import string
>>> safechars = bytearray(('_-.()' + string.digits + string.ascii_letters).encode())
>>> allchars = bytearray(range(0x100))
>>> deletechars = bytearray(set(allchars) - set(safechars))
>>> filename = u'#ab\xa0c.$%.txt'
>>> safe_filename = filename.encode('ascii', 'ignore').translate(None, deletechars).decode()
>>> safe_filename
'abc..txt'

No maneja cadenas vacías, nombres de archivo especiales ('nul', 'con', etc.).

jfs
fuente
+1 para las tablas de traducción, es, con mucho, el método más eficiente. Para los nombres de archivo especiales / vacíos, una simple comprobación previa será suficiente y para períodos extraños también es una corrección simple.
Christian Witts
1
Si bien la traducción es un poco más eficiente que una expresión regular, es muy probable que ese tiempo se vea reducido si realmente intenta abrir el archivo, lo que sin duda tiene la intención de hacer. Por lo tanto, prefiero más una solución expresión regular más legible que el desorden anterior
nosatalian
También estoy preocupado por la lista negra. De acuerdo, es una lista negra que se basa en una lista blanca, pero aún así. Parece menos ... seguro. ¿Cómo sabes que "allchars" está realmente completo?
isaaclw
@isaaclw: '.translate ()' acepta una cadena de 256 caracteres como una tabla de traducción (traducción de byte a byte). '.maketrans ()' crea dicha cadena. Todos los valores están cubiertos; es un enfoque de lista blanca pura
jfs
¿Qué pasa con el nombre de archivo '.' (un solo punto) Eso no funcionaría en Unixes ya que el directorio actual está usando ese nombre.
Finn Årup Nielsen
6

Aunque tienes que tener cuidado. No se dice claramente en su introducción, si solo está mirando el lenguaje latine. Algunas palabras pueden dejar de tener sentido u otro significado si las desinfecta con caracteres ascii solamente.

imagina que tienes "forêt poésie" (poesía forestal), tu desinfección podría dar "fort-posie" (fuerte + algo sin sentido)

Peor si tienes que lidiar con caracteres chinos.

"下 北 沢" su sistema podría terminar haciendo "---" que está condenado a fallar después de un tiempo y no es muy útil. Por lo tanto, si maneja solo archivos, le recomendaría que los llame una cadena genérica que usted controla o que mantenga los caracteres como están. Para los URI, casi lo mismo.

karlcow
fuente
6

¿Por qué no simplemente envolver el "osopen" con un try / except y dejar que el sistema operativo subyacente sepa si el archivo es válido?

Esto parece mucho menos trabajo y es válido sin importar qué sistema operativo use.

James Anderson
fuente
55
Sin embargo, ¿es válido el nombre? Quiero decir, si el sistema operativo no es feliz, entonces todavía tienes que hacer algo, ¿verdad?
jeromej
1
En algunos casos, el OS / Language puede silenciar silenciosamente su nombre de archivo en una forma alternativa, pero cuando hace una lista de directorio, obtendrá un nombre diferente. Y esto puede conducir a un problema de "cuando escribo el archivo está allí, pero cuando busco el archivo se llama algo diferente". (Estoy hablando del comportamiento del que he oído hablar en VAX ...)
Kent Fredric
Además, "El nombre de archivo debe ser válido en varios sistemas operativos", que no puede detectar con una osopenmáquina en ejecución.
LarsH
5

Otro problema que los otros comentarios aún no han abordado es la cadena vacía, que obviamente no es un nombre de archivo válido. También puede terminar con una cadena vacía al eliminar demasiados caracteres.

¿Qué pasa con los nombres de archivos reservados de Windows y los problemas con los puntos, la respuesta más segura a la pregunta "¿cómo normalizo un nombre de archivo válido a partir de la entrada arbitraria del usuario?" es "no te molestes en intentarlo": si puedes encontrar alguna otra forma de evitarlo (por ejemplo, usando claves primarias enteras de una base de datos como nombres de archivo), hazlo.

Si debe hacerlo, y realmente necesita permitir espacios y '.' para extensiones de archivo como parte del nombre, intente algo como:

import re
badchars= re.compile(r'[^A-Za-z0-9_. ]+|^\.|\.$|^ | $|^$')
badnames= re.compile(r'(aux|com[1-9]|con|lpt[1-9]|prn)(\.|$)')

def makeName(s):
    name= badchars.sub('_', s)
    if badnames.match(name):
        name= '_'+name
    return name

Incluso esto no se puede garantizar correctamente, especialmente en sistemas operativos inesperados, por ejemplo, el sistema operativo RISC odia los espacios y usa ''. como un separador de directorio

bobince
fuente
4

Me gustó el enfoque de pitón-slugify aquí, pero también estaba quitando puntos que no era deseable. Así que lo optimicé para subir un nombre de archivo limpio a s3 de esta manera:

pip install python-slugify

Código de ejemplo:

s = 'Very / Unsafe / file\nname hähä \n\r .txt'
clean_basename = slugify(os.path.splitext(s)[0])
clean_extension = slugify(os.path.splitext(s)[1][1:])
if clean_extension:
    clean_filename = '{}.{}'.format(clean_basename, clean_extension)
elif clean_basename:
    clean_filename = clean_basename
else:
    clean_filename = 'none' # only unclean characters

Salida:

>>> clean_filename
'very-unsafe-file-name-haha.txt'

Esto es tan seguro, funciona con nombres de archivo sin extensión e incluso funciona solo con nombres de archivos de caracteres inseguros (el resultado está noneaquí).

therealmarv
fuente
1
Me gusta esto, no reinventes la rueda, no importes todo el marco de Django si no lo necesitas, no pegues directamente el código si no vas a mantenerlo en el futuro, y los intentos de cadena generados para que coincida con letras similares a las seguras, por lo que la nueva cadena es más fácil de leer.
vicenteherrera
1
Para usar guión bajo en lugar de guión: nombre = slugify (s, separador = '_')
vicenteherrera
3

Respuesta modificada para python 3.6

import string
import unicodedata

validFilenameChars = "-_.() %s%s" % (string.ascii_letters, string.digits)
def removeDisallowedFilenameChars(filename):
    cleanedFilename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore')
    return ''.join(chr(c) for c in cleanedFilename if chr(c) in validFilenameChars)
Jean-Robin Tremblay
fuente
¿Podría explicar su respuesta en detalles?
Serenity
Es la misma respuesta aceptada por Sophie Gage. Pero se ha modificado para funcionar en Python 3.6
Jean-Robin Tremblay
2

Me doy cuenta de que hay muchas respuestas, pero en su mayoría se basan en expresiones regulares o módulos externos, por lo que me gustaría agregar mi propia respuesta. Una función de Python pura, no se necesita un módulo externo, no se utiliza ninguna expresión regular. Mi enfoque no es limpiar los caracteres inválidos, sino solo permitir los válidos.

def normalizefilename(fn):
    validchars = "-_.() "
    out = ""
    for c in fn:
      if str.isalpha(c) or str.isdigit(c) or (c in validchars):
        out += c
      else:
        out += "_"
    return out    

si lo desea, puede agregar sus propios caracteres válidos a la validchars variable al principio, como sus letras nacionales que no existen en el alfabeto inglés. Esto es algo que puede querer o no: algunos sistemas de archivos que no se ejecutan en UTF-8 podrían tener problemas con caracteres no ASCII.

Esta función es para probar la validez de un solo nombre de archivo, por lo que reemplazará los separadores de ruta con _ considerándolos caracteres no válidos. Si desea agregar eso, es trivial modificar el ifseparador de ruta para incluir OS.

Tuncay Göncüoğlu
fuente
1

La mayoría de estas soluciones no funcionan.

'/ hello / world' -> 'helloworld'

'/ helloworld' / -> 'helloworld'

En general, esto no es lo que desea, digamos que está guardando el html para cada enlace, va a sobrescribir el html para una página web diferente.

Me salmuera un dictado como:

{'helloworld': 
    (
    {'/hello/world': 'helloworld', '/helloworld/': 'helloworld1'},
    2)
    }

2 representa el número que debe agregarse al siguiente nombre de archivo.

Busco el nombre de archivo cada vez del dict. Si no está allí, creo uno nuevo, agregando el número máximo si es necesario.

robert king
fuente
nota, si se utiliza HelloWorld1, también es necesario comprobar HelloWorld1 no está en uso y así sucesivamente ..
Robert King
1

No es exactamente lo que estaba pidiendo OP, pero esto es lo que uso porque necesito conversiones únicas y reversibles:

# p3 code
def safePath (url):
    return ''.join(map(lambda ch: chr(ch) if ch in safePath.chars else '%%%02x' % ch, url.encode('utf-8')))
safePath.chars = set(map(lambda x: ord(x), '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+-_ .'))

El resultado es "algo" legible, al menos desde el punto de vista del administrador de sistemas.

makeroo
fuente
Un contenedor para esto sin espacios en los nombres de archivos:def safe_filename(filename): return safePath(filename.strip().replace(' ','_'))
SpeedCoder5
0

Estoy seguro de que esta no es una gran respuesta, ya que modifica la cadena sobre la que se repite, pero parece funcionar bien:

import string
for chr in your_string:
 if chr == ' ':
   your_string = your_string.replace(' ', '_')
 elif chr not in string.ascii_letters or chr not in string.digits:
    your_string = your_string.replace(chr, '')
TankorSmash
fuente
Encontré esto "".join( x for x in s if (x.isalnum() or x in "._- "))en los comentarios de esta publicación
SergioAraujo
0

ACTUALIZAR

Todos los enlaces rotos sin posibilidad de reparación en esta respuesta de 6 años.

Además, tampoco lo haría de esta manera, solo base64codificaría o soltaría caracteres inseguros. Ejemplo de Python 3:

import re
t = re.compile("[a-zA-Z0-9.,_-]")
unsafe = "abc∂éåß®∆˚˙©¬ñ√ƒµ©∆∫ø"
safe = [ch for ch in unsafe if t.match(ch)]
# => 'abc'

Con base64usted puede codificar y decodificar, para que pueda recuperar el nombre de archivo original nuevamente.

Pero dependiendo del caso de uso, es mejor que genere un nombre de archivo aleatorio y almacene los metadatos en un archivo separado o DB.

from random import choice
from string import ascii_lowercase, ascii_uppercase, digits
allowed_chr = ascii_lowercase + ascii_uppercase + digits

safe = ''.join([choice(allowed_chr) for _ in range(16)])
# => 'CYQ4JDKE9JfcRzAZ'

RESPUESTA ORIGINAL LINKROTTEN :

El bobcatproyecto contiene un módulo de Python que hace exactamente esto.

No es completamente robusto, mira esta publicación y esta respuesta .

Entonces, como se señaló: la base64codificación es probablemente una mejor idea si la legibilidad no importa.

alambres
fuente
Todos los enlaces están muertos. Hombre, haz algo.
The Peaceful Coder