Eliminar entradas duplicadas de $ PATH con el comando awk


48

Estoy tratando de escribir una función de shell bash que me permita eliminar copias duplicadas de directorios de mi variable de entorno PATH.

Me dijeron que es posible lograr esto con un comando de una línea usando el awkcomando, pero no puedo entender cómo hacerlo. Alguien sabe como?



Respuestas:


37

Si aún no tiene duplicados en el PATHy solo desea agregar directorios si aún no están allí, puede hacerlo fácilmente solo con el shell.

for x in /path/to/add …; do
  case ":$PATH:" in
    *":$x:"*) :;; # already there
    *) PATH="$x:$PATH";;
  esac
done

Y aquí hay un fragmento de shell que elimina los duplicados de $PATH. Revisa las entradas una por una y copia las que aún no se han visto.

if [ -n "$PATH" ]; then
  old_PATH=$PATH:; PATH=
  while [ -n "$old_PATH" ]; do
    x=${old_PATH%%:*}       # the first remaining entry
    case $PATH: in
      *:"$x":*) ;;          # already there
      *) PATH=$PATH:$x;;    # not there yet
    esac
    old_PATH=${old_PATH#*:}
  done
  PATH=${PATH#:}
  unset old_PATH x
fi

Sería mejor si itera los elementos en $ PATH a la inversa, porque los más recientes generalmente se agregan recientemente y pueden tener el valor actualizado.
Eric Wang

2
@EricWang No entiendo tu razonamiento. Los elementos de la RUTA se atraviesan de adelante hacia atrás, por lo que cuando hay duplicados, el segundo duplicado se ignora efectivamente. Iterar de atrás hacia adelante cambiaría el orden.
Gilles 'SO- deja de ser malvado'

@Gilles Cuando haya duplicado la variable en PATH, probablemente se agregue de esta manera: PATH=$PATH:x=bla x en PATH original podría tener el valor a, por lo tanto, cuando se itera en orden, el nuevo valor se ignorará, pero cuando esté en orden inverso, el nuevo El valor tendrá efecto.
Eric Wang

44
@EricWang En ese caso, el valor agregado no tiene efecto, por lo que debe ignorarse. Al retroceder, está logrando que el valor agregado sea anterior. Si se suponía que el valor agregado debía ir antes, se habría agregado como PATH=x:$PATH.
Gilles 'SO- deja de ser malvado'

@Gilles Cuando agrega algo, eso significa que todavía no está allí, o desea anular el valor anterior, por lo que debe hacer visible la nueva variable agregada. Y, por convención, generalmente se agrega de esta manera: PATH=$PATH:...no PATH=...:$PATH. Por lo tanto, es más apropiado iterar el orden inverso. Aunque tu camino también funcionaría, entonces las personas agregan en el camino inverso.
Eric Wang

23

Aquí hay una solución inteligible de una sola línea que hace todo lo correcto: elimina los duplicados, conserva el orden de las rutas y no agrega dos puntos al final. Por lo tanto, debería proporcionarle una RUTA deduplicada que ofrezca exactamente el mismo comportamiento que el original:

PATH="$(perl -e 'print join(":", grep { not $seen{$_}++ } split(/:/, $ENV{PATH}))')"

Simplemente se divide en dos puntos ( split(/:/, $ENV{PATH})), utiliza los usos grep { not $seen{$_}++ }para filtrar cualquier instancia repetida de rutas, excepto la primera aparición, y luego une las restantes de nuevo separadas por dos puntos e imprime el resultado ( print join(":", ...)).

Si desea algo más de estructura a su alrededor, así como también la capacidad de deduplicar otras variables, pruebe este fragmento, que actualmente estoy usando en mi propia configuración:

# Deduplicate path variables
get_var () {
    eval 'printf "%s\n" "${'"$1"'}"'
}
set_var () {
    eval "$1=\"\$2\""
}
dedup_pathvar () {
    pathvar_name="$1"
    pathvar_value="$(get_var "$pathvar_name")"
    deduped_path="$(perl -e 'print join(":",grep { not $seen{$_}++ } split(/:/, $ARGV[0]))' "$pathvar_value")"
    set_var "$pathvar_name" "$deduped_path"
}
dedup_pathvar PATH
dedup_pathvar MANPATH

Ese código deduplicará tanto PATH como MANPATH, y puede invocar fácilmente dedup_pathvarotras variables que contienen listas de rutas separadas por dos puntos (por ejemplo, PYTHONPATH).


Por alguna razón, tuve que agregar un chomppara eliminar una nueva línea final. Esto funcionó para mí:perl -ne 'chomp; print join(":", grep { !$seen{$_}++ } split(/:/))' <<<"$PATH"
Håkon Hægland

12

Aquí hay uno elegante:

printf %s "$PATH" | awk -v RS=: -v ORS=: '!arr[$0]++'

Más tiempo (para ver cómo funciona):

printf %s "$PATH" | awk -v RS=: -v ORS=: '{ if (!arr[$0]++) { print $0 } }'

Ok, como eres nuevo en Linux, aquí es cómo configurar la RUTA sin un ":" final

PATH=`printf %s "$PATH" | awk -v RS=: '{ if (!arr[$0]++) {printf("%s%s",!ln++?"":":",$0)}}'`

por cierto, asegúrese de NO tener directorios que contengan ":" en su RUTA, de lo contrario, será un desastre.

algún crédito a:


-1 esto no funciona. Todavía veo duplicados en mi camino.
dogbane

44
@ Dogbane: elimina los duplicados para mí. Sin embargo, tiene un problema sutil. La salida tiene un: en el extremo que, si se establece como su $ PATH, significa que el directorio actual se agrega a la ruta. Esto tiene implicaciones de seguridad en una máquina multiusuario.
camh

@dogbane, funciona y edité la publicación para tener un comando de una línea sin el final:
akostadinov

@dogbane su solución tiene un final: en la salida
akostadinov

hmm, tu tercer comando funciona, pero los dos primeros no funcionan a menos que lo use echo -n. Sus comandos no parecen funcionar con "cadenas aquí", por ejemplo, intente:awk -v RS=: -v ORS=: '!arr[$0]++' <<< ".:/foo/bin:/bar/bin:/foo/bin"
dogbane

6

Aquí hay un AWK one liner.

$ PATH=$(printf %s "$PATH" \
     | awk -vRS=: -vORS= '!a[$0]++ {if (NR>1) printf(":"); printf("%s", $0) }' )

dónde:

  • printf %s "$PATH"imprime el contenido $PATHsin una nueva línea final
  • RS=: cambia el carácter delimitador del registro de entrada (el valor predeterminado es nueva línea)
  • ORS= cambia el delimitador de registro de salida a la cadena vacía
  • a el nombre de una matriz creada implícitamente
  • $0 hace referencia al registro actual
  • a[$0] es una desreferencia de matriz asociativa
  • ++ es el operador posterior al incremento
  • !a[$0]++ protege el lado derecho, es decir, se asegura de que el registro actual solo se imprima, si no se imprimió antes
  • NR el número de registro actual, comenzando con 1

Eso significa que AWK se usa para dividir el PATHcontenido a lo largo de los :caracteres delimitadores y para filtrar entradas duplicadas sin modificar el orden.

Dado que las matrices asociativas AWK se implementan como tablas hash, el tiempo de ejecución es lineal (es decir, en O (n)).

Tenga en cuenta que no necesitamos buscar :caracteres entre comillas porque los shells no proporcionan comillas para admitir directorios con :su nombre en la PATHvariable.

Awk + pegar

Lo anterior se puede simplificar con pegar:

$ PATH=$(printf %s "$PATH" | awk -vRS=: '!a[$0]++' | paste -s -d:)

El pastecomando se utiliza para intercalar la salida awk con dos puntos. Esto simplifica la acción awk para imprimir (que es la acción predeterminada).

Pitón

Lo mismo que Python de dos líneas:

$ PATH=$(python3 -c 'import os; from collections import OrderedDict; \
    l=os.environ["PATH"].split(":"); print(":".join(OrderedDict.fromkeys(l)))' )

ok, pero ¿esto elimina los duplicados de una cadena delimitada por dos puntos existente, o evita que los duplicados se agreguen a una cadena?
Alexander Mills

1
parece el primero
Alexander Mills

2
@AlexanderMills, bueno, el OP acaba de preguntar sobre la eliminación de duplicados, así que esto es lo que hace la llamada awk.
maxschlepzig

1
El pastecomando no funciona para mí a menos que agregue un final -para usar STDIN.
wisbucky

2
Además, necesito agregar espacios después -vo, de lo contrario, obtengo un error. -v RS=: -v ORS=. Simplemente diferentes sabores de awksintaxis.
wisbucky

4

Ha habido una discusión similar sobre esto aquí .

Tomo un enfoque un poco diferente. En lugar de simplemente aceptar la RUTA que se establece a partir de todos los diferentes archivos de inicialización que se instalan, prefiero usar getconfpara identificar la ruta del sistema y colocarla primero, luego agregar mi orden de ruta preferida, luego usar awkpara eliminar cualquier duplicado. Esto puede o no acelerar realmente la ejecución del comando (y en teoría ser más seguro), pero me da calidez.

# I am entering my preferred PATH order here because it gets set,
# appended, reset, appended again and ends up in such a jumbled order.
# The duplicates get removed, preserving my preferred order.
#
PATH=$(command -p getconf PATH):/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH
# Remove duplicates
PATH="$(printf "%s" "${PATH}" | /usr/bin/awk -v RS=: -v ORS=: '!($0 in a) {a[$0]; print}')"
export PATH

[~]$ echo $PATH
/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin:/usr/lib64/ccache:/usr/games:/home/me/bin

3
Esto es muy peligroso porque agrega un final :al PATH(es decir, una entrada de cadena vacía), porque el directorio de trabajo actual es parte de su PATH.
maxschlepzig

3

Siempre y cuando estemos agregando líneas no awk:

PATH=$(zsh -fc "typeset -TU P=$PATH p; echo \$P")

(Podría ser tan simple como PATH=$(zsh -fc 'typeset -U path; echo $PATH')pero zsh siempre lee al menos un zshenvarchivo de configuración, que puede modificar PATH).

Utiliza dos características agradables de zsh:

  • escalares atados a matrices ( typeset -T)
  • y matrices que eliminan automáticamente valores duplicados ( typeset -U).

¡bonito! respuesta de trabajo más corta, y nativamente sin los dos puntos al final.
jaap

2
PATH=`perl -e 'print join ":", grep {!$h{$_}++} split ":", $ENV{PATH}'`
export PATH

Esto usa perl y tiene varios beneficios:

  1. Elimina duplicados.
  2. Se mantiene ordenado
  3. Mantiene la primera apariencia ( /usr/bin:/sbin:/usr/binresultará en /usr/bin:/sbin)

2

Además sed(aquí usando la sedsintaxis de GNU ) puede hacer el trabajo:

MYPATH=$(printf '%s\n' "$MYPATH" | sed ':b;s/:\([^:]*\)\(:.*\):\1/:\1\2/;tb')

este funciona bien solo en caso de que el primer camino sea .como en el ejemplo de dogbane.

En general, debe agregar otro scomando más:

MYPATH=$(printf '%s\n' "$MYPATH" | sed ':b;s/:\([^:]*\)\(:.*\):\1/:\1\2/;tb;s/^\([^:]*\)\(:.*\):\1/:\1\2/')

Funciona incluso en tal construcción:

$ echo "/bin:.:/foo/bar/bin:/usr/bin:/foo/bar/bin:/foo/bar/bin:/bar/bin:/usr/bin:/bin" \
| sed ':b;s/:\([^:]*\)\(:.*\):\1/:\1\2/;tb;s/^\([^:]*\)\(:.*\):\1/\1\2/'

/bin:.:/foo/bar/bin:/usr/bin:/bar/bin

2

Como otros han demostrado, es posible en una línea usar awk, sed, perl, zsh o bash, depende de su tolerancia para líneas largas y legibilidad. Aquí hay una función bash que

  • elimina duplicados
  • preserva el orden
  • permite espacios en los nombres de directorio
  • le permite especificar el delimitador (el valor predeterminado es ':')
  • se puede usar con otras variables, no solo RUTA
  • funciona en versiones bash <4, importante si usa OS X que para problemas de licencia no incluye la versión 4 de bash

función bash

remove_dups() {
    local D=${2:-:} path= dir=
    while IFS= read -d$D dir; do
        [[ $path$D =~ .*$D$dir$D.* ]] || path+="$D$dir"
    done <<< "$1$D"
    printf %s "${path#$D}"
}

uso

Para eliminar dups de PATH

PATH=$(remove_dups "$PATH")

1

Esta es mi versión

path_no_dup () 
{ 
    local IFS=: p=();

    while read -r; do
        p+=("$REPLY");
    done < <(sort -u <(read -ra arr <<< "$1" && printf '%s\n' "${arr[@]}"));

    # Do whatever you like with "${p[*]}"
    echo "${p[*]}"
}

Uso: path_no_dup "$PATH"

Salida de muestra:

rany$ v='a:a:a:b:b:b:c:c:c:a:a:a:b:c:a'; path_no_dup "$v"
a:b:c
rany$

1

Las versiones recientes de bash (> = 4) también de matrices asociativas, es decir, también puede usar un bash 'one liner' para ello:

PATH=$(IFS=:; set -f; declare -A a; NR=0; for i in $PATH; do NR=$((NR+1)); \
       if [ \! ${a[$i]+_} ]; then if [ $NR -gt 1 ]; then echo -n ':'; fi; \
                                  echo -n $i; a[$i]=1; fi; done)

dónde:

  • IFS cambia el separador de campo de entrada a :
  • declare -A declara una matriz asociativa
  • ${a[$i]+_}es un significado de expansión de parámetro: _se sustituye si y solo si a[$i]está configurado. Esto es similar a lo ${parameter:+word}que también prueba para no nulo. Por lo tanto, en la siguiente evaluación del condicional, la expresión _(es decir, una sola cadena de caracteres) se evalúa como verdadera (esto es equivalente a -n _), mientras que una expresión vacía se evalúa como falsa.

+1: buen estilo de script, pero ¿puedes explicar la sintaxis particular? ${a[$i]+_}Editando tu respuesta y agregando una viñeta. El resto es perfectamente comprensible, pero me perdiste allí. Gracias.
Cbhihe

1
@Cbhihe, agregué una viñeta que aborda esta expansión.
maxschlepzig

Muchas gracias. Muy interesante. No pensé que eso fuera posible con matrices (sin cadenas) ...
Cbhihe

1
PATH=`awk -F: '{for (i=1;i<=NF;i++) { if ( !x[$i]++ ) printf("%s:",$i); }}' <<< "$PATH"`

Explicación del código awk:

  1. Separe la entrada por dos puntos.
  2. Agregue nuevas entradas de ruta a la matriz asociativa para una búsqueda duplicada rápida.
  3. Imprime la matriz asociativa.

Además de ser conciso, este one-liner es rápido: awk utiliza una tabla hash de encadenamiento para lograr un rendimiento amortiguado de O (1).

basado en la eliminación de entradas duplicadas de $ PATH


Mensaje viejo, pero ¿podría explicar: if ( !x[$i]++ ). Gracias.
Cbhihe

0

Use awkpara dividir la ruta :, luego repita sobre cada campo y almacénelo en una matriz. Si se encuentra con un campo que ya está en la matriz, eso significa que lo ha visto antes, así que no lo imprima.

Aquí hay un ejemplo:

$ MYPATH=.:/foo/bar/bin:/usr/bin:/foo/bar/bin
$ awk -F: '{for(i=1;i<=NF;i++) if(!($i in arr)){arr[$i];printf s$i;s=":"}}' <<< "$MYPATH"
.:/foo/bar/bin:/usr/bin

(Actualizado para eliminar el final :).


0

Una solución, no tan elegante como las que cambian las variables * RS, pero quizás razonablemente clara:

PATH=`awk 'BEGIN {np="";split(ENVIRON["PATH"],p,":"); for(x=0;x<length(p);x++) {  pe=p[x]; if(e[pe] != "") continue; e[pe] = pe; if(np != "") np=np ":"; np=np pe}} END { print np }' /dev/null`

Todo el programa funciona en los bloques BEGIN y END . Extrae su variable PATH del entorno, dividiéndola en unidades. Luego itera sobre la matriz resultante p (que se crea en orden por split()). La matriz e es una matriz asociativa que se usa para determinar si hemos visto o no el elemento de ruta actual (por ejemplo, / usr / local / bin ) y, si no, se agrega a np , con lógica para agregar dos puntos a np si ya hay texto en np . El bloque END simplemente echos np . Esto podría simplificarse aún más agregando el-F:flag, eliminando el tercer argumento para split()(como el valor predeterminado es FS ), y cambiando np = np ":"a np = np FS, dándonos:

awk -F: 'BEGIN {np="";split(ENVIRON["PATH"],p); for(x=0;x<length(p);x++) {  pe=p[x]; if(e[pe] != "") continue; e[pe] = pe; if(np != "") np=np FS; np=np pe}} END { print np }' /dev/null

Ingenuamente, creía que eso for(element in array)preservaría el orden, pero no lo hace, por lo que mi solución original no funciona, ya que la gente se molestaría si alguien de repente revuelve el orden de sus $PATH:

awk 'BEGIN {np="";split(ENVIRON["PATH"],p,":"); for(x in p) { pe=p[x]; if(e[pe] != "") continue; e[pe] = pe; if(np != "") np=np ":"; np=np pe}} END { print np }' /dev/null

0
export PATH=$(echo -n "$PATH" | awk -v RS=':' '(!a[$0]++){if(b++)printf(RS);printf($0)}')

Solo se mantiene la primera aparición y se mantiene bien el orden relativo.


-1

Lo haría solo con herramientas básicas como tr, sort y uniq:

NEW_PATH=`echo $PATH | tr ':' '\n' | sort | uniq | tr '\n' ':'`

Si no hay nada especial o extraño en tu camino, debería funcionar


por cierto, puedes usar en sort -ulugar de sort | uniq.
prisa

11
Dado que el orden de los elementos PATH es significativo, esto no es muy útil.
maxschlepzig
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.