Transformar uma string em um nome de arquivo válido?

298

Eu tenho uma string que quero usar como nome de arquivo, então quero remover todos os caracteres que não seriam permitidos nos nomes de arquivos, usando Python.

Prefiro ser rigoroso do que o contrário, então digamos que quero reter apenas letras, dígitos e um pequeno conjunto de outros caracteres, como "_-.() ". Qual é a solução mais elegante?

O nome do arquivo precisa ser válido em vários sistemas operacionais (Windows, Linux e Mac OS) - é um arquivo MP3 na minha biblioteca com o título da música como nome do arquivo e é compartilhado e copiado entre três máquinas.

Sophie Gage
fonte
17
Isso não deve ser incorporado ao módulo os.path?
endolith 10/03/09
2
Talvez, embora o caso de uso dela exija um único caminho seguro para todas as plataformas, não apenas o atual, que é algo que o os.path não foi projetado para lidar.
Javawizard #
2
Para expandir o comentário acima: o design atual de os.pathfato carrega uma biblioteca diferente, dependendo do sistema operacional (consulte a segunda nota na documentação ). Portanto, se uma função de citação foi implementada, os.pathela só poderia citar a sequência de caracteres para segurança POSIX ao executar em um sistema POSIX ou para segurança para janelas ao executar no Windows. O nome do arquivo resultante não seria necessariamente válido nas janelas e no POSIX, que é o que a pergunta pede.
dshepherd

Respostas:

164

Você pode olhar para o estrutura do Django para saber como eles criam uma "lesma" a partir de texto arbitrário. Uma lesma é compatível com URL e nome de arquivo.

Os utilitários de texto do Django definem uma função, slugify()que provavelmente é o padrão-ouro para esse tipo de coisa. Essencialmente, o código deles é o seguinte.

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

Há mais, mas deixei de fora, já que não trata de slugification, mas de escapar.

S.Lott
fonte
11
A última linha deve ser: value = unicode (re.sub ('[- \ s] +', '-', value)) '
Joseph Turian
1
Graças - Eu poderia estar faltando alguma coisa, mas eu estou recebendo: "normalize () argumento 2 deve ser unicode, não str"
Alex Cozinhe
"argumento normalize () 2". Significa o value. Se o valor deve ser Unicode, você deve ter certeza de que é realmente Unicode. Ou. Você pode deixar de fora a normalização unicode se o seu valor real for realmente uma sequência ASCII.
S.Lott
8
Caso alguém não tenha notado o lado positivo dessa abordagem, ela não remove apenas caracteres não-alfa, mas tenta encontrar bons substitutos primeiro (via normalização do NFKD), então é se torna e, um sobrescrito 1 se torna 1 normal, etc. Obrigado
Michael Scott Cuthbert
48
A slugifyfunção foi movida para django / utils / text.py , e esse arquivo também contém uma get_valid_filenamefunção.
Denilson Sá Maia
104

Essa abordagem da lista de permissões (ou seja, permitir apenas os caracteres presentes em valid_chars) funcionará se não houver limites na formatação dos arquivos ou na combinação de caracteres válidos que são ilegais (como ".."), por exemplo, o que você diz permitiria um nome de arquivo chamado ". txt" que eu acho que não é válido no Windows. Como esta é a abordagem mais simples, eu tentaria remover o espaço em branco dos valid_chars e acrescentar uma sequência válida conhecida em caso de erro, qualquer outra abordagem precisará saber sobre o que é permitido lidar com as limitações de nomeação de arquivos do Windows e, portanto, ser muito mais complexo.

>>> 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
fonte
7
valid_chars = frozenset(valid_chars)não machucaria. É 1,5 vezes mais rápido se aplicado a allchars.
JFS
2
Aviso: Isso mapeia duas cadeias diferentes para a mesma cadeia >>> importar cadeia >>> valid_chars = "- . ()% S% s"% (string.ascii_letters, string.digits) >>> valid_chars '- . () abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '>>> filename = "a.com/hello/world" >>>' '.join (c para c no nome do arquivo se c em valid_chars)' a.comhelloworld '>>> filename = "a.com/hellow" ">>> '' .join (c para c no nome do arquivo se c em valid_chars) 'a.comhelloworld' >>>
robert king
3
Sem mencionar que nomear um arquivo "CON"no Windows vai trazer-lhe problemas ...
Nathan Osman
2
Um leve rearranjo facilita a especificação de um caractere substituto. Primeiro, a funcionalidade original: '' .join (c se c em valid_chars else '' para c em filename) ou com um caractere ou sequência substituída por cada caractere inválido: '' .join (c se c em valid_chars else '.' Para c no nome do arquivo)
PeterVermont
101

Você pode usar a compreensão da lista junto com os métodos de string.

>>> s
'foo-bar#baz?qux@127/\\9]'
>>> "".join(x for x in s if x.isalnum())
'foobarbazqux1279'
John Mee
fonte
3
Observe que você pode omitir os colchetes. Nesse caso, uma expressão de gerador é passada para ingressar, o que salva a etapa de criação de uma lista não utilizada.
Oben Sonne
31
+1 Adorei isso. Uma pequena modificação que eu fiz: "" .join ([x if x.isalnum () else "_" para x em s])) - produziria um resultado em que itens inválidos são _, como se estivessem em branco. Talvez isso ajude alguém.
Eddie Parker
12
Esta solução é ótima! Fiz uma ligeira modificação:filename = "".join(i for i in s if i not in "\/:*?<>|")
Alex Krycek 23/06
1
Infelizmente, nem permite espaços e pontos, mas eu gosto da ideia.
21413 Tiktak
9
@tiktak: para (também) permitir espaços, pontos e sublinhados que você pode procurar"".join( x for x in s if (x.isalnum() or x in "._- "))
hardmooth
95

Qual é o motivo para usar as strings como nomes de arquivo? Se a legibilidade humana não for um fator, eu usaria o módulo base64, que pode produzir seqüências seguras do sistema de arquivos. Não será legível, mas você não terá que lidar com colisões e é reversível.

import base64
file_name_string = base64.urlsafe_b64encode(your_string)

Atualização : Alterada com base no comentário de Matthew.

Igal Serban
fonte
1
Obviamente, esta é a melhor resposta, se for esse o caso.
user32141
60
Aviso! A codificação base64 por padrão inclui o caractere "/" como saída válida, que não é válida em nomes de arquivos em muitos sistemas. Em vez disso utilização base64.urlsafe_b64encode (your_string)
Matthew
15
Na verdade, a legibilidade humana é quase sempre um fator, mesmo que seja apenas para fins de depuração.
static_rtti
5
No Python 3, your_stringprecisa ser uma matriz de bytes ou o resultado encode('ascii')para que isso funcione.
Noumenon
4
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

Só para complicar ainda mais as coisas, não é garantido que você obtenha um nome de arquivo válido apenas removendo caracteres inválidos. Como os caracteres permitidos diferem em nomes de arquivos diferentes, uma abordagem conservadora pode acabar transformando um nome válido em um nome inválido. Você pode adicionar um tratamento especial para os casos em que:

  • A sequência é composta por todos os caracteres inválidos (deixando uma sequência vazia)

  • Você acaba com uma string com um significado especial, por exemplo, "." ou ".."

  • No Windows, determinados nomes de dispositivos são reservados. Por exemplo, você não pode criar um arquivo chamado "nul", "nul.txt" (ou nul.anything, de fato). Os nomes reservados são:

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

Provavelmente, você pode solucionar esses problemas anexando alguma sequência aos nomes de arquivos que nunca podem resultar em um desses casos e eliminando caracteres inválidos.

Brian
fonte
24

Existe um bom projeto no Github chamado python-slugify :

Instalar:

pip install python-slugify

Então use:

>>> from slugify import slugify
>>> txt = "This\ is/ a%#$ test ---"
>>> slugify(txt)
'this-is-a-test'
Shoham
fonte
2
Eu gosto desta biblioteca, mas não é tão boa quanto eu pensava. Teste inicial ok, mas também converte pontos. Então test.txtfica o test-txtque é demais.
Therealmarv
23

Assim como S.Lott respondeu, você pode olhar para o Django Framework para saber como eles convertem uma string em um nome de arquivo válido.

A versão mais recente e atualizada é encontrada em utils / text.py e define "get_valid_filename", que é o seguinte:

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

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

cowlinator
fonte
4
para os preguiçosos já no django:django.utils.text import get_valid_filename
theannouncer
2
Caso você não esteja familiarizado com regex, re.sub(r'(?u)[^-\w.]', '', s)remove todos os caracteres que não são letras, nem números (0-9), nem o sublinhado ('_'), nem o traço ('-'), nem o ponto ('.' ) "Letras" aqui inclui todas as letras unicode, como 漢語.
cowlinator
3
Você pode querer verificar também para o comprimento: Nomes de arquivos estão limitados a 255 caracteres (ou, você sabe, 32; dependendo das FS)
Matthias Winkelmann
19

Esta é a solução que eu finalmente usei:

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)

A chamada unicodedata.normalize substitui caracteres acentuados pelo equivalente não acentuado, o que é melhor do que simplesmente removê-los. Depois disso, todos os caracteres não permitidos são removidos.

Minha solução não anexa uma sequência conhecida para evitar possíveis nomes de arquivos não permitidos, porque eu sei que eles não podem ocorrer devido ao meu formato de nome de arquivo específico. Uma solução mais geral precisaria fazê-lo.

Sophie Gage
fonte
você deve ser capaz de usar uuid.uuid4 () para o seu prefixo único
SLF
6
caso camel .. ahh
ouriço demente
Isso poderia ser editado / atualizado para funcionar com o Python 3.6?
Wavesailor
13

Lembre-se de que não há restrições sobre nomes de arquivos em sistemas Unix além de

  • Pode não conter \ 0
  • Pode não conter /

Tudo o resto é jogo justo.

$ touch "
> mesmo multilinha
> haha
> ^ [[31m vermelho ^ [[0m
> mal "
$ ls -la 
-rw-r - r-- 0 Nov 17 23:39? mesmo multilinha? haha ​​?? [31m vermelho? [0m? mal
$ ls -lab
-rw-r - r-- 0 17 de novembro 23:39 \ neven \ multiline \ nhaha \ n \ 033 [31m \ red \ \ 033 [0m \ nevil
$ perl -e 'para o meu $ i (glob (q {./* even *})) {print $ i; } '
./
mesmo multilinha
haha
 vermelho 
mal

Sim, eu apenas armazenei códigos de cores ANSI em um nome de arquivo e os fiz efeito.

Para entretenimento, coloque um caractere BEL em um nome de diretório e assista à diversão que se segue quando você faz o CD nele;)

Kent Fredric
fonte
Os estados do OP que "O nome do arquivo precisa ser válida em vários sistemas operacionais"
cowlinator
1
@ cowlinator que o esclarecimento foi adicionado 10 horas depois que minha resposta foi postada :) Verifique o log de edição do OP.
Kent Fredric
12

Em uma linha:

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

você também pode colocar o caractere '_' para torná-lo mais legível (no caso de substituir barras, por exemplo)

mnach
fonte
7

Você pode usar o método re.sub () para substituir qualquer coisa que não seja "semelhante a um arquivo". Mas, na verdade, todo personagem pode ser válido; então não há funções pré-construídas (acredito), para fazê-lo.

import re

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

Resultaria em uma manipulação de arquivo para /tmp/filename.txt.

gx.
fonte
5
Você precisa que o traço seja o primeiro no marcador de grupo, para que não apareça como um intervalo. 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'

Ele não lida com strings vazias, nomes de arquivos especiais ('nul', 'con' etc.).

jfs
fonte
+1 para tabelas de tradução, é de longe o método mais eficiente. Para os nomes de arquivos / vazios especiais, uma verificação simples de pré-condição será suficiente e, por períodos estranhos, também será uma correção simples.
Christian Witts
1
Embora a conversão seja um pouco mais eficiente do que uma regexp, esse tempo provavelmente será reduzido se você realmente tentar abrir o arquivo, o que sem dúvida você pretende fazer. Assim, eu prefiro mais uma solução regexp mais legível do que a bagunça acima
nosatalian
Também estou preocupado com a lista negra. É verdade que é uma lista negra baseada em uma lista de permissões, mas ainda assim. Parece menos ... seguro. Como você sabe que "allchars" está realmente completo?
Isaaclw
@isaaclw: '.translate ()' aceita uma string de 256 caracteres como uma tabela de tradução (tradução de byte a byte). '.maketrans ()' cria essa string. Todos os valores são cobertos; É uma abordagem whitelist pura
JFS
E o nome do arquivo '.' (um único ponto). Isso não funcionaria em Unixes, pois o diretório atual está usando esse nome.
Finn Årup Nielsen
6

Embora você tenha que ter cuidado. Não está claramente dito em sua introdução, se você estiver olhando apenas para o idioma latino. Algumas palavras podem se tornar sem sentido ou outro significado se você as higienizar apenas com caracteres ascii.

imagine que você tem "forêt poésie" (poesia florestal), sua sanitização pode dar "fort-posie" (forte + algo sem sentido)

Pior se você tiver que lidar com caracteres chineses.

"下 北 沢" seu sistema pode acabar fazendo "---", que está fadado a falhar depois de um tempo e não é muito útil. Portanto, se você lida apenas com arquivos, eu os incentivaria a chamá-los de uma cadeia genérica que você controla ou a manter os caracteres como são. Para URIs, aproximadamente o mesmo.

karlcow
fonte
6

Por que não apenas envolver o "osopen" com uma tentativa / exceção e deixar o sistema operacional subjacente determinar se o arquivo é válido?

Isso parece muito menos trabalho e é válido, independentemente do sistema operacional que você usa.

James Anderson
fonte
5
Valida o nome? Quero dizer, se o sistema operacional não estiver feliz, você ainda precisará fazer algo, certo?
jeromej
1
Em alguns casos, o SO / Idioma pode silenciosamente mover seu nome de arquivo para um formato alternativo, mas quando você faz uma listagem de diretório, obtém um nome diferente. E isso pode levar a um problema "quando eu escrevo o arquivo, mas quando procuro o arquivo é chamado de outra coisa". (Eu estou falando sobre o comportamento Ouvi falar sobre VAX ...)
Kent Fredric
Além disso, "O nome do arquivo precisa ser válido em vários sistemas operacionais", que você não pode detectar com a osopenexecução em uma máquina.
Larsh
5

Outro problema que os outros comentários ainda não abordaram é a string vazia, que obviamente não é um nome de arquivo válido. Você também pode acabar com uma string vazia, eliminando muitos caracteres.

O que com os nomes de arquivos reservados do Windows e os problemas com pontos, a resposta mais segura para a pergunta "como normalizar um nome de arquivo válido a partir de entradas arbitrárias do usuário?" é “nem se preocupe em tentar”: se você encontrar outra maneira de evitá-lo (por exemplo, usando chaves primárias inteiras de um banco de dados como nomes de arquivos), faça isso.

Se você precisar, e realmente precisa permitir espaços e '.' para extensões de arquivo como parte do nome, tente 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

Mesmo isso não pode ser garantido, especialmente em sistemas operacionais inesperados - por exemplo, o RISC OS odeia espaços e usa '.' como um separador de diretório.

bobince
fonte
4

Gostei da abordagem python-slugify aqui, mas ela também estava removendo pontos, o que não era desejado. Então, eu o otimizei para enviar um nome de arquivo limpo para o s3 desta maneira:

pip install python-slugify

Código de exemplo:

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

Resultado:

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

Isso é à prova de falhas, funciona com nomes de arquivos sem extensão e funciona apenas para nomes de arquivos de caracteres não seguros (o resultado está noneaqui).

therealmarv
fonte
1
Eu gosto disso, não reinvente a roda, não importe toda a estrutura do Django, se você não precisar dela, não cole diretamente o código se você não quiser mantê-lo no futuro e gerou tentativas de string para coincidir com letras semelhantes às seguras, para que a nova string seja mais fácil de ler.
vicenteherrera 23/03
1
Para usar sublinhado em vez de traço: name = slugify (s, separator = '_')
vicenteherrera 23/03
3

Resposta 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
fonte
Você poderia explicar sua resposta em detalhes?
Serenity
É a mesma resposta aceita por Sophie Gage. Mas foi modificado para trabalhar em python 3.6
Jean-Robin Tremblay
2

Sei que há muitas respostas, mas elas se baseiam principalmente em expressões regulares ou módulos externos, então eu gostaria de dar minha própria resposta. Uma função python pura, nenhum módulo externo necessário, nenhuma expressão regular usada. Minha abordagem não é limpar caracteres inválidos, mas apenas permitir caracteres 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    

se desejar, você pode adicionar seus próprios caracteres válidos à validcharsvariável no início, como as letras nacionais que não existem no alfabeto inglês. Isso é algo que você pode ou não querer: alguns sistemas de arquivos que não são executados no UTF-8 ainda podem ter problemas com caracteres não-ASCII.

Esta função é para testar a validade de um único arquivo, portanto substituirá os separadores de caminho por _ considerando-os como caracteres inválidos. Se você quiser adicionar isso, é trivial modificar o ifpara incluir o separador de caminho os.

Tuncay Göncüoğlu
fonte
1

A maioria dessas soluções não funciona.

'/ olá / mundo' -> 'helloworld'

'/ helloworld' / -> 'helloworld'

Isso não é o que você deseja em geral, digamos que você esteja salvando o html para cada link, você substituirá o html por uma página da web diferente.

Eu escolho um ditado como:

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

2 representa o número que deve ser anexado ao próximo nome do arquivo.

Eu procuro o nome do arquivo toda vez que ditar. Se não estiver lá, eu crio um novo, acrescentando o número máximo, se necessário.

Robert King
fonte
nota, se utilizando HelloWorld1, você também precisa verificar HelloWorld1 não estiver em uso e assim por diante ..
Robert King
1

Não é exatamente o que o OP estava pedindo, mas é isso que eu uso, porque preciso de conversões únicas e reversíveis:

# 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+-_ .'))

O resultado é "um pouco" legível, pelo menos do ponto de vista do administrador de sistemas.

makeroo
fonte
Um invólucro para isso sem espaços nos nomes dos arquivos:def safe_filename(filename): return safePath(filename.strip().replace(' ','_'))
SpeedCoder5
0

Tenho certeza de que essa não é uma ótima resposta, pois modifica a string em que está sendo repetida, mas parece funcionar bem:

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
fonte
Eu encontrei este comentário "".join( x for x in s if (x.isalnum() or x in "._- "))neste post
SergioAraujo
0

ATUALIZAR

Todos os links quebrados além do reparo nesta resposta de 6 anos.

Além disso, eu também não faria mais dessa maneira, apenas base64codificaria ou soltaria caracteres não seguros. Exemplo 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'

Com base64você pode codificar e decodificar, para recuperar o nome do arquivo original novamente.

Mas, dependendo do caso de uso, é melhor gerar um nome de arquivo aleatório e armazenar os metadados em um arquivo ou banco de dados separado.

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'

RESPOSTA ORIGINAL LINKROTTEN :

O bobcatprojeto contém um módulo python que faz exatamente isso.

Não é completamente robusto, veja este post e esta resposta .

Portanto, como observado: a base64codificação é provavelmente uma idéia melhor se a legibilidade não for importante.

fios
fonte
Todos os links estão mortos. Cara, faça alguma coisa.
The Peaceful Coder