web-dev-qa-db-fra.com

Vous parcourez des fichiers avec des espaces dans les noms?

J'ai écrit le script suivant pour différencier les sorties de deux répertoires contenant tous les mêmes fichiers en tant que tels:

#!/bin/bash

for file in `find . -name "*.csv"`  
do
     echo "file = $file";
     diff $file /some/other/path/$file;
     read char;
done

Je sais qu'il existe d'autres moyens d'y parvenir. Curieusement, ce script échoue lorsque les fichiers contiennent des espaces. Comment puis-je gérer cela?

Exemple de sortie de find:

./zQuery - abc - Do Not Prompt for Date.csv
160
Amir Afghani

Réponse courte (la plus proche de votre réponse, mais gère les espaces)

OIFS="$IFS"
IFS=$'\n'
for file in `find . -type f -name "*.csv"`  
do
     echo "file = $file"
     diff "$file" "/some/other/path/$file"
     read line
done
IFS="$OIFS"

Meilleure réponse (gère également les caractères génériques et les nouvelles lignes dans les noms de fichiers)

find . -type f -name "*.csv" -print0 | while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
done

Meilleure réponse (basée sur réponse de Gilles )

find . -type f -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read line </dev/tty
' {} ';'

Ou encore mieux, pour éviter d'exécuter un sh par fichier:

find . -type f -name '*.csv' -exec sh -c '
  for file do
    echo "$file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
  done
' sh {} +

Réponse longue

Vous avez trois problèmes:

  1. Par défaut, le shell divise la sortie d'une commande sur les espaces, les tabulations et les retours à la ligne
  2. Les noms de fichiers pourraient contenir des caractères génériques qui seraient développés
  3. Que faire s'il existe un répertoire dont le nom se termine par *.csv?

1. Fractionnement uniquement sur les nouvelles lignes

Pour savoir sur quoi définir file, le Shell doit prendre la sortie de find et l'interpréter d'une manière ou d'une autre, sinon file ne serait que la sortie entière de find.

Le shell lit la variable IFS, qui est définie par défaut sur <space><tab><newline>.

Ensuite, il examine chaque caractère dans la sortie de find. Dès qu'il voit un caractère qui se trouve dans IFS, il pense que marque la fin du nom de fichier, donc il définit file sur les caractères qu'il a vus jusqu'à présent et exécute la boucle. Ensuite, il commence là où il s'était arrêté pour obtenir le nom de fichier suivant et exécute la boucle suivante, etc., jusqu'à la fin de la sortie.

Il fait donc cela efficacement:

for file in "zquery" "-" "abc" ...

Pour lui dire de ne diviser l'entrée que sur les retours à la ligne, vous devez faire

IFS=$'\n'

avant votre commande for ... find.

Cela définit IFS sur une seule nouvelle ligne, donc elle ne se divise que sur les nouvelles lignes, et non pas sur les espaces et les tabulations.

Si vous utilisez sh ou dash au lieu de ksh93, bash ou zsh, vous devez écrire IFS=$'\n' comme ceci à la place:

IFS='
'

C'est probablement suffisant pour faire fonctionner votre script, mais si vous êtes intéressé à gérer correctement d'autres cas d'angle, lisez la suite ...

2. Extension de $file Sans caractères génériques

À l'intérieur de la boucle où vous faites

diff $file /some/other/path/$file

le Shell essaie de développer $file (encore!).

Il pourrait contenir des espaces, mais comme nous avons déjà défini IFS ci-dessus, ce ne sera pas un problème ici.

Mais il pourrait également contenir des caractères génériques tels que * Ou ?, Ce qui entraînerait un comportement imprévisible. (Merci à Gilles de l'avoir signalé.)

Pour dire au shell de ne pas développer les caractères génériques, placez la variable entre guillemets doubles, par ex.

diff "$file" "/some/other/path/$file"

Le même problème pourrait aussi nous mordre

for file in `find . -name "*.csv"`

Par exemple, si vous aviez ces trois fichiers

file1.csv
file2.csv
*.csv

(très peu probable, mais toujours possible)

Ce serait comme si tu avais couru

for file in file1.csv file2.csv *.csv

qui sera étendu à

for file in file1.csv file2.csv *.csv file1.csv file2.csv

entraînant le traitement de file1.csv et file2.csv deux fois.

Au lieu de cela, nous devons faire

find . -name "*.csv" -print | while IFS= read -r file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
done

read lit les lignes de l'entrée standard, divise la ligne en mots selon IFS et les stocke dans les noms de variables que vous spécifiez.

Ici, nous lui disons de ne pas diviser la ligne en mots et de la stocker dans $file.

Notez également que read line Est devenu read line </dev/tty.

En effet, à l'intérieur de la boucle, l'entrée standard provient de find via le pipeline.

Si nous venions de faire read, cela consommerait une partie ou la totalité d'un nom de fichier, et certains fichiers seraient ignorés.

/dev/tty Est le terminal à partir duquel l'utilisateur exécute le script. Notez que cela provoquera une erreur si le script est exécuté via cron, mais je suppose que ce n'est pas important dans ce cas.

Et si un nom de fichier contient des retours à la ligne?

Nous pouvons gérer cela en remplaçant -print Par -print0 Et en utilisant read -d '' À la fin d'un pipeline:

find . -name "*.csv" -print0 | while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read char </dev/tty
done

Cela fait que find met un octet nul à la fin de chaque nom de fichier. Les octets nuls sont les seuls caractères non autorisés dans les noms de fichiers, donc cela devrait gérer tous les noms de fichiers possibles, aussi bizarres soient-ils.

Pour obtenir le nom du fichier de l'autre côté, nous utilisons IFS= read -r -d ''.

Là où nous avons utilisé read ci-dessus, nous avons utilisé le délimiteur de ligne par défaut de la nouvelle ligne, mais maintenant, find utilise null comme délimiteur de ligne. Dans bash, vous ne pouvez pas passer un caractère NUL dans un argument à une commande (même celles intégrées), mais bash comprend -d '' Comme signifiant délimité NUL . Nous utilisons donc -d '' Pour que read utilise le même délimiteur de ligne que find. Notez que -d $'\0', D'ailleurs, fonctionne aussi, car bash ne prenant pas en charge les octets NUL le traite comme une chaîne vide.

Pour être correct, nous ajoutons également -r, Qui dit de ne pas gérer spécialement les antislashs dans les noms de fichiers. Par exemple, sans -r, \<newline> Sont supprimés et \n Est converti en n.

Une façon plus portable d'écrire cela qui ne nécessite pas bash ou zsh ou de se souvenir de toutes les règles ci-dessus concernant les octets nuls (encore une fois, grâce à Gilles):

find . -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read char </dev/tty
' {} ';'

3. Ignorer les répertoires dont les noms se terminent par * .csv

find . -name "*.csv"

correspondra également aux répertoires appelés something.csv.

Pour éviter cela, ajoutez -type f À la commande find.

find . -type f -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read line </dev/tty
' {} ';'

Comme le souligne glenn jackman , dans ces deux exemples, les commandes à exécuter pour chaque fichier sont exécutées dans un sous-shell, donc si vous modifiez des variables à l'intérieur de la boucle, elles seront oubliées.

Si vous devez définir des variables et les avoir encore définies à la fin de la boucle, vous pouvez le réécrire pour utiliser la substitution de processus comme ceci:

i=0
while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
    i=$((i+1))
done < <(find . -type f -name '*.csv' -print0)
echo "$i files processed"

Notez que si vous essayez de copier et coller cela sur la ligne de commande, read line Consommera le echo "$i files processed", De sorte que la commande ne sera pas exécutée.

Pour éviter cela, vous pouvez supprimer read line </dev/tty Et envoyer le résultat à un pager comme less.


[~ # ~] notes [~ # ~]

J'ai supprimé les points-virgules (;) À l'intérieur de la boucle. Vous pouvez les remettre si vous le souhaitez, mais ils ne sont pas nécessaires.

De nos jours, $(command) est plus courant que `command`. C'est principalement parce qu'il est plus facile d'écrire $(command1 $(command2)) que `command1 \`command2\``.

read char Ne lit pas vraiment un caractère. Il lit une ligne entière donc je l'ai changé en read line.

218
Mikel

Ce script échoue si un nom de fichier contient des espaces ou des caractères de globalisation Shell \[?*. La commande find génère un nom de fichier par ligne. Ensuite, la substitution de commande `find …` Est évaluée par le shell comme suit:

  1. Exécutez la commande find, récupérez sa sortie.
  2. Divisez la sortie find en mots séparés. Tout caractère d'espacement est un séparateur de mots.
  3. Pour chaque mot, s'il s'agit d'un modèle de globalisation, développez-le dans la liste des fichiers auxquels il correspond.

Par exemple, supposons qu'il y ait trois fichiers dans le répertoire actuel, appelés `foo* bar.csv, foo 1.txt Et foo 2.txt.

  1. La commande find renvoie ./foo* bar.csv.
  2. Le shell divise cette chaîne à l'espace, produisant deux mots: ./foo* Et bar.csv.
  3. Étant donné que ./foo* Contient un métacaractère de globalisation, il est étendu à la liste des fichiers correspondants: ./foo 1.txt Et ./foo 2.txt.
  4. Par conséquent, la boucle for est exécutée successivement avec ./foo 1.txt, ./foo 2.txt Et bar.csv.

Vous pouvez éviter la plupart des problèmes à ce stade en atténuant le fractionnement de Word et en désactivant la globalisation. Pour atténuer le fractionnement de Word, définissez la variable IFS sur un seul caractère de nouvelle ligne; de cette façon, la sortie de find ne sera divisée qu'aux nouvelles lignes et les espaces resteront. Pour désactiver la globalisation, exécutez set -f. Ensuite, cette partie du code fonctionnera tant qu'aucun nom de fichier ne contient de caractère de nouvelle ligne.

IFS='
'
set -f
for file in $(find . -name "*.csv"); do …

(Cela ne fait pas partie de votre problème, mais je recommande d'utiliser $(…) sur `…`. Ils ont la même signification, mais la version de la citation arrière a des règles de citation étranges.)

Il y a un autre problème ci-dessous: diff $file /some/other/path/$file Devrait être

diff "$file" "/some/other/path/$file"

Sinon, la valeur de $file Est divisée en mots et les mots sont traités comme des motifs globaux, comme avec la substitution de commande ci-dessus. Si vous devez vous rappeler une chose à propos de la programmation Shell, rappelez-vous ceci: tilisez toujours des guillemets doubles autour des extensions de variable ($foo) Et des substitutions de commande ($(bar)), sauf si vous savez que vous voulez vous séparer. (Ci-dessus, nous savions que nous voulions diviser la sortie find en lignes.)

Un moyen fiable d'appeler find lui dit d'exécuter une commande pour chaque fichier qu'il trouve:

find . -name '*.csv' -exec sh -c '
  echo "$0"
  diff "$0" "/some/other/path/$0"
' {} ';'

Dans ce cas, une autre approche consiste à comparer les deux répertoires, mais vous devez exclure explicitement tous les fichiers "ennuyeux".

diff -r -x '*.txt' -x '*.ods' -x '*.pdf' … . /some/other/path

Je suis surpris de ne pas voir readarray mentionné. Cela rend cela très facile lorsqu'il est utilisé en combinaison avec le <<< opérateur:

$ touch oneword "two words"

$ readarray -t files <<<"$(ls)"

$ for file in "${files[@]}"; do echo "|$file|"; done
|oneword|
|two words|

En utilisant le <<<"$expansion" construct vous permet également de diviser des variables contenant des sauts de ligne en tableaux, comme:

$ string=$(dmesg)
$ readarray -t lines <<<"$string"
$ echo "${lines[0]}"
[    0.000000] Initializing cgroup subsys cpuset

readarray est dans Bash depuis des années maintenant, donc cela devrait probablement être la manière canonique de le faire dans Bash.

6
blujay

Parcourez tous les fichiers ( tout caractère spécial inclus) avec recherche complètement sûre (voir le lien pour la documentation):

exec 9< <( find "$absolute_dir_path" -type f -print0 )
while IFS= read -r -d '' -u 9
do
    file_path="$(readlink -fn -- "$REPLY"; echo x)"
    file_path="${file_path%x}"
    echo "START${file_path}END"
done
6
l0b0

Afaik find a tout ce dont vous avez besoin.

find . -okdir diff {} /some/other/path/{} ";"

find prend soin de bien appeler les programmes. -okdir vous demandera avant le diff (êtes-vous sûr oui/non).

Aucun Shell impliqué, aucun globbing, jokers, pi, pa, po.

En guise de note: si vous combinez find avec for/while/do/xargs, dans la plupart des cas, vous vous trompez. :)

4
user unknown

Je suis surpris que personne n'ait mentionné la solution évidente de zsh ici:

for file (**/*.csv(ND.)) {
  do-something-with $file
}

((D) pour inclure également les fichiers cachés, (N) pour éviter l'erreur s'il n'y a pas de correspondance, (.) pour limiter à normal fichiers.)

bash4.3 et au-dessus le supporte désormais également partiellement:

shopt -s globstar nullglob dotglob
for file in **/*.csv; do
  [ -f "$file" ] || continue
  [ -L "$file" ] && continue
  do-something-with "$file"
done
4

Les noms de fichiers contenant des espaces ressemblent à plusieurs noms sur la ligne de commande s'ils ne sont pas cités. Si votre fichier est nommé "Hello World.txt", la ligne diff se développe pour:

diff Hello World.txt /some/other/path/Hello World.txt

qui ressemble à quatre noms de fichiers. Mettez simplement des guillemets autour des arguments:

diff "$file" "/some/other/path/$file"
2
Ross Smith

La double cotation est votre ami.

diff "$file" "/some/other/path/$file"

Sinon, le contenu de la variable est divisé en mots.

1
geekosaur

Avec bash4, vous pouvez également utiliser la fonction mapfile intégrée pour définir un tableau contenant chaque ligne et itérer sur ce tableau.

$ tree 
.
├── a
│   ├── a 1
│   └── a 2
├── b
│   ├── b 1
│   └── b 2
└── c
    ├── c 1
    └── c 2

3 directories, 6 files
$ mapfile -t files < <(find -type f)
$ for file in "${files[@]}"; do
> echo "file: $file"
> done
file: ./a/a 2
file: ./a/a 1
file: ./b/b 2
file: ./b/b 1
file: ./c/c 2
file: ./c/c 1
1
kitekat75