命令行上的数据科学第二版 四、创建命令行工具

2023-03-31 14:42:15 浏览数 (2)

原文:https://datascienceatthecommandline.com/2e/chapter-4-creating-command-line-tools.html

在整本书中,我将向您介绍许多基本上适合一行的命令和管道。这些被称为一行程序或管道。能够只用一行程序执行复杂的任务是命令行的强大之处。这是一种与编写和使用传统程序截然不同的体验。

有些任务你只执行一次,有些任务你执行得更频繁。有些任务非常具体,有些则可以概括。如果您需要定期重复某个命令行程序,那么将它变成自己的命令行工具是值得的。因此,一行程序和命令行工具都有它们的用途。识别机会需要练习和技巧。命令行工具的优点是您不必记住整个一行程序,并且如果您将它包含到其他管道中,它会提高可读性。在这个意义上,你可以把命令行工具想象成类似于编程语言中的一个函数。

然而,使用编程语言的好处是代码在一个或多个文件中。这意味着您可以轻松地编辑和重用这些代码。如果代码有参数,它甚至可以被一般化,并重新应用于遵循类似模式的问题。

命令行工具具有两个世界的优点:它们可以从命令行使用,接受参数,并且只需创建一次。在这一章中,你将熟悉用两种方式创建命令行工具。首先,我解释了如何将这些一行程序转换成可重用的命令行工具。通过在命令中添加参数,您可以增加编程语言提供的灵活性。随后,我将演示如何从用编程语言编写的代码中创建可重用的命令行工具。遵循 Unix 的理念,您的代码可以与其他命令行工具结合使用,这些工具可能是用完全不同的语言编写的。在这一章中,我将重点介绍三种编程语言:Bash、Python 和 R。

我相信,从长远来看,创建可重用的命令行工具会使您成为一名更高效的数据科学家。您将逐步构建自己的数据科学工具箱,从中可以提取现有工具,并将其应用于您之前遇到的问题。它需要实践来识别将一行程序或现有代码转化为命令行工具的机会。

为了将命令行变为 Shell 脚本, 我们会用一点 Shell 脚本语言. 这本书仅仅会展示一些较少的 Shell 变成概念, 包括变量, 判断和循环。完整的 Shell 变成教程应有一本专门的书来讲述它, 所以超出了这本书的范围. 如果你想更深入的了解 Shell 编程, 我推荐 Arnold Robbins 和 Nelson H. F. Beebe 写的《Shell 编程经典》这本书。

4.1 概述

在本章中,您将学习如何:

  • 将一行程序转换成参数化的 Shell 脚本
  • 将现有的 Python 和 R 代码转换成可重用的命令行工具

本章从以下文件开始:

代码语言:javascript复制
$ cd /data/ch04

$ l
total 32K
-rwxr-xr-x 1 dst dst 400 Mar  3 10:42 fizzbuzz.py*
-rwxr-xr-x 1 dst dst 391 Mar  3 10:42 fizzbuzz.R*
-rwxr-xr-x 1 dst dst 182 Mar  3 10:42 stream.py*
-rwxr-xr-x 1 dst dst 147 Mar  3 10:42 stream.R*
-rwxr-xr-x 1 dst dst 105 Mar  3 10:42 top-words-4.sh*
-rwxr-xr-x 1 dst dst 128 Mar  3 10:42 top-words-5.sh*
-rwxr-xr-x 1 dst dst 647 Mar  3 10:42 top-words.py*
-rwxr-xr-x 1 dst dst 584 Mar  3 10:42 top-words.R*

获取这些文件的说明在第二章中。任何其他文件都是使用命令行工具下载或生成的。

4.2 将一行程序转换成 Shell 脚本

在这一节中,我将解释如何把一行程序变成一个可重用的命令行工具。比方说,您想获得一段文本中使用频率最高的单词。以刘易斯·卡罗尔的《爱丽丝漫游仙境》为例,这本书和许多其他伟大的书籍一样,可以在古腾堡计划上免费获得。

代码语言:javascript复制
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" | trim
The Project Gutenberg eBook of Alice’s Adventures in Wonderland, by Lewis …

This eBook is for the use of anyone anywhere in the United States and
most other parts of the world at no cost and with almost no restrictions
whatsoever. You may copy it, give it away or re-use it under the terms
of the Project Gutenberg License included with this eBook or online at
www.gutenberg.org. If you are not located in the United States, you
will have to check the laws of the country where you are located before
using this eBook.

… with 3751 more lines

以下顺序的工具或管道应该可以完成这项工作:

代码语言:javascript复制
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" | # ➊
> tr '[:upper:]' '[:lower:]' | # ➋
> grep -oE "[a-z']{2,}" | # ➌
> sort | # ➍
> uniq -c | # ➎
> sort -nr | # ➏
> head -n 10 # ➐
   1839 the
    942 and
    811 to
    638 of
    610 it
    553 she
    486 you
    462 said
    435 in
    403 alice

➊ 使用curl下载电子书。

➋ 使用tr将整个文本转换成小写。

➌ 使用grep提取所有单词,并将每个单词放在单独的行上。

➍ 用sort将这些单词按字母顺序排序。

➎ 去掉所有重复的,用uniq统计每个单词在列表中出现的频率。

➏ 使用sort按计数降序排列这个独特单词列表。 使用head只保留前 10 行(即单词)。

这些词确实在文章中出现得最多。因为这些单词(除了单词alice)在许多英语文本中出现得非常频繁,所以它们没有什么意义。事实上,这些被称为停用词。如果我们去掉这些,我们会保留与这篇文章相关的最常用的词。

以下是我找到的停用词列表:

代码语言:javascript复制
$ curl -sL "https://raw.githubusercontent.com/stopwords-iso/stopwords-en/master/
stopwords-en.txt" |
> sort | tee stopwords | trim 20
10
39
a
able
ableabout
about
above
abroad
abst
accordance
according
accordingly
across
act
actually
ad
added
adj
adopted
ae
… with 1278 more lines

使用grep,我们可以在开始计数之前过滤掉停用词:

代码语言:javascript复制
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
> tr '[:upper:]' '[:lower:]' |
> grep -oE "[a-z']{2,}" |
> sort |
> grep -Fvwf stopwords | # ➊
> uniq -c |
> sort -nr |
> head -n 10
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

➊ 从一个文件中获取模式(在我们的例子中是停用词),每行一个,用-f。用-F将那些模式解释为固定字符串。只选择那些包含与-w构成完整单词的匹配的行。用-v选择不匹配的行。

每一个命令行都提供了一个帮助说明. 所以如果你想知道更多, 比如说, grep, 你可以运行man grep命令. 命令tr, grep, uniq, 和sort会在下章中讨论更加详细的用法。

只运行一次这个一行程序没有任何问题。然而,想象一下,如果你想拥有古腾堡计划中每本电子书的前 10 个单词。或者想象一下,你想要一个新闻网站每小时的前 10 个单词。在这种情况下,最好将这个一行程序作为一个单独的构建块,可以成为更大的东西的一部分。为了在参数方面给这个一行程序增加一些灵活性,让我们把它变成一个 Shell 脚本。

这允许我们以一行程序为起点,并逐步对其进行改进。为了将这个一行程序变成一个可重用的命令行工具,我将带您完成以下六个步骤:

  1. 将一行程序复制并粘贴到一个文件中。
  2. 添加执行权限。
  3. 定义一个所谓的 Shebang。
  4. 移除固定输入部分。
  5. 添加一个参数。
  6. 选择性地扩展您的路径。

4.2.1 第一步:创建文件

第一步是创建一个新文件。您可以打开您最喜欢的文本编辑器,复制并粘贴这个一行程序。让我们将这个文件命名为top-words-1.sh,以表明这是我们新的命令行工具的第一步。如果您喜欢呆在命令行,您可以使用内置的fc,它代表“修复命令”,并允许您修复或编辑上次运行的命令。

代码语言:javascript复制
$ fc

运行fc调用默认的文本编辑器,它存储在环境变量编辑器中。在 Docker 容器中,这被设置为nano,一个简单的文本编辑器。如您所见,该文件包含我们的一行程序:

代码语言:javascript复制
 GNU nano 5.4                     /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          
[ Read 8 lines ]                                
^G Help      ^O Write Out ^W Where Is  ^K Cut       ^T Execute   ^C Location    
^X Exit      ^R Read File ^ Replace   ^U Paste     ^J Justify   ^_ Go To Line  

让我们通过按下Ctrl-O,删除临时文件名,并键入top-words-1.sh来给这个临时文件一个合适的名称:

代码语言:javascript复制
 GNU nano 5.4 /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          

File Name to Write: top-words-1.sh                                              
^G Help             M-D DOS Format      M-A Append          M-B Backup File     
^C Cancel           M-M Mac Format      M-P Prepend         ^T Browse           

按下Enter :

代码语言:javascript复制
 GNU nano 5.4  /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          

Save file under DIFFERENT NAME?                                                 
 Y Yes                                                                          
 N No           ^C Cancel                                                       

按下Y确认您要以不同的文件名保存:

代码语言:javascript复制
 GNU nano 5.4 top-words-1.sh curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          
[ Wrote 8 lines ]                                
^G Help      ^O Write Out ^W Where Is  ^K Cut       ^T Execute   ^C Location    
^X Exit      ^R Read File ^ Replace   ^U Paste     ^J Justify   ^_ Go To Line 

按下Ctrl-X退出nano,回到你来的地方。

我们正在使用文件扩展名.sh说明我们正在创建一个 Shell 脚本。然而,命令行工具不需要有扩展。事实上,命令行工具很少有扩展。

确认文件的内容:

代码语言:javascript复制
$ pwd
/data/ch04

$ l
total 44K
-rwxr-xr-x 1 dst dst  400 Mar  3 10:42 fizzbuzz.py*
-rwxr-xr-x 1 dst dst  391 Mar  3 10:42 fizzbuzz.R*
-rw-r--r-- 1 dst dst 7.5K Mar  3 10:42 stopwords
-rwxr-xr-x 1 dst dst  182 Mar  3 10:42 stream.py*
-rwxr-xr-x 1 dst dst  147 Mar  3 10:42 stream.R*
-rw-r--r-- 1 dst dst  173 Mar  3 10:42 top-words-1.sh
-rwxr-xr-x 1 dst dst  105 Mar  3 10:42 top-words-4.sh*
-rwxr-xr-x 1 dst dst  128 Mar  3 10:42 top-words-5.sh*
-rwxr-xr-x 1 dst dst  647 Mar  3 10:42 top-words.py*
-rwxr-xr-x 1 dst dst  584 Mar  3 10:42 top-words.R*

$ bat top-words-1.sh ───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words-1.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
   2   │ tr '[:upper:]' '[:lower:]' |
   3   │ grep -oE "[a-z']{2,}" |
   4   │ sort |
   5   │ grep -Fvwf stopwords |
   6   │ uniq -c |
   7   │ sort -nr |
   8   │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────

你现在可以使用bash来解释和执行文件中的命令:

代码语言:javascript复制
$ bash top-words-1.sh
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

这可以避免您下次再次输入一行程序。

然而,因为该文件不能独立执行,所以它还不是一个真正的命令行工具。让我们在下一步中改变这一点。

4.2.2 第二步:给予执行权限

我们不能直接执行文件的原因是我们没有正确的访问权限。特别是,作为用户,您需要拥有执行该文件的权限。在本节中,我们将更改文件的访问权限。

为了比较步骤之间的差异,使用cp -v top-words-{1,2}.sh将文件复制到top-words-2.sh

如果你想验证括号扩展或者其他形式的文件扩展会导致什么, 用echo代替命令把结果打印出来. 比如, echo book_{draft,final}.md or echo agent-{001..007}.

要更改文件的访问权限,我们需要使用一个名为chmod的命令行工具,代表更改模式。它改变特定文件的文件模式位。以下命令授予用户你执行top-words-2.sh的权限:

代码语言:javascript复制
$ cp -v top-words-{1,2}.sh
'top-words-1.sh' -> 'top-words-2.sh'

$ chmod u x top-words-2.sh

参数u x由三个字符组成:(1)u表示我们要为拥有该文件的用户,也就是您,更改权限,因为您创建了该文件;(2) 表明我们要添加一个权限;以及(3)x,其指示执行的权限。

现在让我们来看看这两个文件的访问权限:

代码语言:javascript复制
$ l top-words-{1,2}.sh
-rw-r--r-- 1 dst dst 173 Mar  3 10:42 top-words-1.sh
-rwxr--r-- 1 dst dst 173 Mar  3 10:42 top-words-2.sh*

第一列显示每个文件的访问权限。对于top-words-2.sh,这里是-rwxrw-r--。第一个字符- (连字符)表示文件类型。一个-表示常规文件,一个d表示目录。接下来的三个字符,rwx,表示拥有该文件的用户的访问权限。rw分别表示 。(你可以看到,top-words-1.sh有一个-而不是一个x,这意味着我们不能执行那个文件。)接下来的三个字符rw-表示拥有该文件的组的所有成员的访问权限。最后,列中的最后三个字符,r--,表示所有其他用户的访问权限。

现在,您可以执行该文件,如下所示:

代码语言:javascript复制
$ ./top-words-2.sh
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

如果您试图执行一个您没有正确访问权限的文件,如top-words-1.sh,您将看到以下错误消息:

代码语言:javascript复制
$ ./top-words-1.sh
zsh: permission denied: ./top-words-1.sh

4.2.3 第三步:定义 Shebang

虽然我们已经可以单独执行文件,但是我们应该在文件中添加一个所谓的 Shebang。 Shebang 是脚本中的一个特殊行,它指示系统应该使用哪个可执行文件来解释命令。

Shebang 这个名字来源于前两个字:一个井号(She)和一个感叹号(Bang):#!。就像我们在上一步中所做的那样,忽略它并不是一个好主意,因为每个 Shell 都有不同的默认可执行文件。如果没有定义 Shebang,我们在整本书中使用的 ZShell 默认使用可执行文件/bin/sh。在这种情况下,我希望bash将命令解释为比sh给我们更多的功能。

同样,你可以随意使用任何你喜欢的编辑器,但我将坚持使用nano,它安装在 Docker 映像中。

代码语言:javascript复制
$ cp -v top-words-{2,3}.sh
'top-words-2.sh' -> 'top-words-3.sh'

$ nano top-words-3.sh
代码语言:javascript复制
 GNU nano 5.4                     top-words-3.sh curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          

[ Read 8 lines ]                                
^G Help      ^O Write Out ^W Where Is  ^K Cut       ^T Execute   ^C Location    
^X Exit      ^R Read File ^ Replace   ^U Paste     ^J Justify   ^_ Go To Line 

继续输入#!/usr/bin/env/bash,然后按Enter。准备好后,按Ctrl-X保存并退出。

代码语言:javascript复制
 GNU nano 5.4                     top-words-3.sh * #!/usr/bin/env bash  curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          

Save modified buffer?                                                           
 Y Yes                                                                          
 N No           ^C Cancel 

按下Y以表示您想要保存文件。

代码语言:javascript复制
 GNU nano 5.4 top-words-3.sh * #!/usr/bin/env bash  curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |                        
tr '[:upper:]' '[:lower:]' |            
grep -oE "[a-z']{2,}" |                
sort |              
grep -Fvwf stopwords |                  
uniq -c |           
sort -nr |          
head -n 10          

File Name to Write: top-words-3.sh                                              
^G Help             M-D DOS Format      M-A Append          M-B Backup File     
^C Cancel           M-M Mac Format      M-P Prepend         ^T Browse 

让我们确认一下top-words-3.sh是什么样子的:

代码语言:javascript复制
$ bat top-words-3.sh ───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words-3.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash 
   2   │ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
   3   │ tr '[:upper:]' '[:lower:]' |
   4   │ grep -oE "[a-z']{2,}" |
   5   │ sort |
   6   │ grep -Fvwf stopwords |
   7   │ uniq -c |
   8   │ sort -nr |
   9   │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────

这正是我们所需要的:我们的原始管道,前面有一个 Shebang。

有时,您会遇到以!/usr/bin/bash!/usr/bin/python形式出现的脚本(对于 Python,我们将在下一节中看到)。虽然这通常是可行的,但是如果将bashpython可执行文件安装在与/usr/bin不同的位置,那么该脚本将不再有效。最好使用我这里呈现的形式,即!/usr/bin/env bash!/usr/bin/env python,因为env可执行文件知道bashpython安装在哪里。简而言之,使用env使您的脚本更具可移植性。

4.2.4 第四步:移除固定输入

我们知道有一个有效的命令行工具,我们可以从命令行执行。但是我们可以做得更好。我们可以使我们的命令行工具更加可重用。我们文件中的第一个命令是curl,它下载我们希望从中获得前 10 个最常用单词的文本。所以,数据和操作合二为一。

如果我们想从另一本电子书或任何其他文本中获得 10 个最常用的单词,会怎么样呢?输入数据在工具本身中是固定的。最好将数据从命令行工具中分离出来。

如果我们假设命令行工具的用户将提供文本,那么该工具将变得普遍适用。因此,解决方案是从脚本中删除curl命令。下面是名为top-words-4.sh的更新脚本:

代码语言:javascript复制
$ cp -v top-words-{3,4}.sh
'top-words-3.sh' -> 'top-words-4.sh'

$ sed -i '2d' top-words-4.sh

$ bat top-words-4.sh ───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words-4.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash 
   2   │ tr '[:upper:]' '[:lower:]' |
   3   │ grep -oE "[a-z']{2,}" |
   4   │ sort |
   5   │ grep -Fvwf stopwords |
   6   │ uniq -c |
   7   │ sort -nr |
   8   │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────

这是因为如果一个脚本从一个需要来自标准输入的数据的命令开始,比如tr,它将接受提供给命令行工具的输入。例如:

代码语言:javascript复制
$ curl -sL 'https://www.gutenberg.org/files/11/11-0.txt' | ./top-words-4.sh
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

$ curl -sL 'https://www.gutenberg.org/files/12/12-0.txt' | ./top-words-4.sh
    469 alice
    189 queen
     98 gutenberg
     88 project
     72 time
     71 red
     70 white
     67 king
     63 head
     59 knight

$ man bash | ./top-words-4.sh
    585 command
    332 set
    313 word
    313 option
    304 file
    300 variable
    298 bash
    258 list
    257 expansion
    238 history

虽然我们没有在脚本里这样做, 但是我们仍然应该保存数据. 通常, 让用户使用输出重定向比在脚本里写明输出到哪个文件好. 当然, 如果你打算只在里自己的项目里使用命令行工具, 那么是否清楚的写明文件路径就没有什么限制了.

4.2.5 第五步:添加参数

为了使我们的命令行工具更加可重用,还有一个步骤:参数。在我们的命令行工具中,有许多固定的命令行参数,例如用-nr代表sort,用-n 10代表head。最好保持前一个论点不变。然而,允许head命令有不同的值是非常有用的。这将允许最终用户设置输出最常用的单词的数量。下面显示了我们的文件top-words-5.sh的样子:

代码语言:javascript复制
$ bat top-words-5.sh ───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words-5.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash
   2   │
   3   │ NUM_WORDS="${1:-10}"
   4   │
   5   │ tr '[:upper:]' '[:lower:]' |
   6   │ grep -oE "[a-z']{2,}" |
   7   │ sort |
   8   │ grep -Fvwf stopwords |
   9   │ uniq -c |
  10   │ sort -nr |
  11   │ head -n "${NUM_WORDS}"
───────┴────────────────────────────────────────────────────────────────────────
  • 变量NUM_WORDS被设置为$1的值,这是 Bash 中的一个特殊变量。它保存传递给我们的命令行工具的第一个命令行参数的值。下表列出了 Bash 提供的其他特殊变量。如果没有指定值,它将采用值10
  • 注意,为了使用$NUM_WORDS变量的值,您需要在它前面放一个美元符号。当你设置它的时候,你并没有写一个美元符号。

我们也可以直接使用1作为head的参数,而不必费心创建一个额外的变量,比如NUM_WORDS。然而,有了更大的脚本和更多的命令行参数,如2和

现在,如果您想查看我们文本中最常用的 20 个单词,我们将调用我们的命令行工具,如下所示:

代码语言:javascript复制
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" > alice.txt

$ < alice.txt ./top-words-5.sh 20
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon
     53 rabbit
     50 head
     48 voice
     45 looked
     44 mouse
     42 duchess
     40 tone
     40 dormouse
     37 cat
     34 march

如果用户没有指定数字,那么我们的脚本将显示前 10 个最常用的单词:

代码语言:javascript复制
$ < alice.txt ./top-words-5.sh
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

4.2.6 第六步:拓展你的人生道路

经过前面的五个步骤,我们终于完成了构建一个可重用的命令行工具。然而,还有一个非常有用的步骤。在这个可选步骤中,我们将确保您可以从任何地方执行您的命令行工具。

目前,当您想要执行您的命令行工具时,您要么必须导航到它所在的目录,要么包括完整的路径名,如步骤 2 所示。如果命令行工具是专门为某个项目而构建的,这是没问题的。然而,如果你的命令行工具可以应用于多种情况,那么从任何地方执行它都是有用的,就像 Ubuntu 自带的命令行工具一样。

为了实现这一点,Bash 需要知道在哪里可以找到您的命令行工具。它通过遍历存储在名为PATH的环境变量中的目录列表来实现这一点。在一个新的 Docker 容器中,PATH如下所示:

代码语言:javascript复制
$ echo $PATH
/usr/local/lib/R/site-library/rush/exec:/usr/bin/dsutils:/home/dst/.local/bin:/u
sr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

目录由冒号分隔。我们可以通过将冒号转换为换行符,将其打印为目录列表:

代码语言:javascript复制
$ echo $PATH | tr ':' 'n'
/usr/local/lib/R/site-library/rush/exec
/usr/bin/dsutils
/home/dst/.local/bin
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin
/bin

要永久更改PATH,您需要编辑位于您的主目录中的.bashrc.bash_profile。如果您将所有自定义命令行工具放在一个目录中,比如说,~/tools,那么您只需更改一次PATH。现在,您不再需要添加./,但也可以只用文件名。此外,您不再需要记住命令行工具的位置。

代码语言:javascript复制
$ cp -v top-words{-5.sh,}
'top-words-5.sh' -> 'top-words'

$ export PATH="${PATH}:/data/ch04"

$ echo $PATH
/usr/local/lib/R/site-library/rush/exec:/usr/bin/dsutils:/home/dst/.local/bin:/u
sr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/data/ch04

$ curl "https://www.gutenberg.org/files/11/11-0.txt" |
> top-words 10
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  170k  100  170k    0     0   145k      0  0:00:01  0:00:01 --:--:--  145k
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
     63 king
     60 turtle
     57 mock
     56 hatter
     55 gryphon

4.3 用 Python 和 R 创建命令行工具

我们在上一节创建的命令行工具是用 Bash 编写的。(当然,并不是 Bash 编程语言的所有特性都被采用了,但是解释器仍然是bash。)正如您现在所知道的,命令行是语言不可知的,所以我们不一定要使用 Bash 来创建命令行工具。

在这一节中,我将演示命令行工具也可以用其他编程语言创建。我将重点介绍 Python 和 R,因为这是数据科学社区中最流行的两种编程语言。我无法提供这两种语言的完整介绍,所以我假设您对 Python 或 R 有一定的了解。其他编程语言,如 Java、Go 和 Julia,在创建命令行工具时也遵循类似的模式。

用不同于 Bash 的另一种编程语言创建命令行工具有三个主要原因。首先,您可能已经有了一些希望能够从命令行使用的代码。其次,命令行工具最终会包含一百多行 Bash 代码。第三,命令行工具需要更加安全和健壮(Bash 缺少许多特性,比如类型检查)。

我在上一节中讨论的六个步骤也大致适用于用其他编程语言创建命令行工具。然而,第一步不是从命令行复制粘贴,而是将相关代码复制粘贴到一个新文件中。用 Python 和 R 写的命令行工具需要分别指定pythonRscript,作为 Shebang 之后的解释器。

当使用 Python 和 R 创建命令行工具时,还有两个方面需要特别注意。首先,处理标准输入(这是 Shell 脚本的天性)必须在 Python 和 R 中明确处理。其次,由于用 Python 和 R 编写的命令行工具往往更复杂,我们可能还希望为用户提供指定更复杂的命令行参数的能力。

4.3.1 移植 Shell 脚本

首先,让我们看看如何将刚刚创建的 Shell 脚本移植到 Python 和 R 中。换句话说,哪些 Python 和 R 代码为我们提供了标准输入中最常用的单词?我们将首先显示两个文件top-words.pytop-words.R然后讨论与 Shell 代码的区别。在 Python 中,代码如下所示:

代码语言:javascript复制
$ cd /data/ch04

$ bat top-words.py
───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words.py
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env python
   2   │ import re
   3   │ import sys
   4   │
   5   │ from collections import Counter
   6   │ from urllib.request import urlopen
   7   │
   8   │ def top_words(text, n):
   9   │     with urlopen("https://raw.githubusercontent.com/stopwords-iso/stopw
       │ ords-en/master/stopwords-en.txt") as f:
  10   │         stopwords = f.read().decode("utf-8").split("n")
  11   │
  12   │     words = re.findall("[a-z']{2,}", text.lower())
  13   │     words = (w for w in words if w not in stopwords)
  14   │
  15   │     for word, count in Counter(words).most_common(n):
  16   │         print(f"{count:>7}  {word}")
  17   │
  18   │
  19   │ if __name__ == "__main__":
  20   │     text = sys.stdin.read()
  21   │
  22   │     try:
  23   │         n = int(sys.argv[1])
  24   │     except:
  25   │         n = 10
  26   │
  27   │     top_words(text, n)
───────┴────────────────────────────────────────────────────────────────────────

注意,这个 Python 例子没有使用任何第三方包。如果你想做高级文本处理,那么我推荐你去看看 NLTK 包 。如果你要处理大量的数字数据,那么我推荐你使用 Pandas 包 。

在 R 语言中,代码看起来像这样:

代码语言:javascript复制
$ bat top-words.R
───────┬────────────────────────────────────────────────────────────────────────
       │ File: top-words.R
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env Rscript
   2   │ n <- as.integer(commandArgs(trailingOnly = TRUE))
   3   │ if (length(n) == 0) n <- 10
   4   │
   5   │ f_stopwords <- url("https://raw.githubusercontent.com/stopwords-iso/sto
       │ pwords-en/master/stopwords-en.txt")
   6   │ stopwords <- readLines(f_stopwords, warn = FALSE)
   7   │ close(f_stopwords)
   8   │
   9   │ f_text <- file("stdin")
  10   │ lines <- tolower(readLines(f_text))
  11   │
  12   │ words <- unlist(regmatches(lines, gregexpr("[a-z']{2,}", lines)))
  13   │ words <- words[is.na(match(words, stopwords))]
  14   │
  15   │ counts <- sort(table(words), decreasing = TRUE)
  16   │ cat(sprintf("} %sn", counts[1:n], names(counts[1:n])), sep = "")
  17   │ close(f_text)
───────┴────────────────────────────────────────────────────────────────────────

让我们检查所有三个实现(即 Bash、Python 和 R)是否返回了相同计数的前 5 个单词:

代码语言:javascript复制
$ time < alice.txt top-words 5
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
top-words 5 < alice.txt  0.56s user 0.04s system 139% cpu 0.427 total

$ time < alice.txt top-words.py 5
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
top-words.py 5 < alice.txt  2.15s user 0.03s system 97% cpu 2.232 total

$ time < alice.txt top-words.R 5
    403 alice
     98 gutenberg
     88 project
     76 queen
     71 time
top-words.R 5 < alice.txt  1.67s user 0.10s system 94% cpu 1.886 total

精彩!当然,输出本身并不令人兴奋。令人兴奋的是,我们可以用多种语言完成同样的任务。让我们看看这两种方法之间的区别。

首先,显而易见的是代码量的差异。对于这个特定的任务,Python 和 R 都比 Bash 需要更多的代码。这说明,对于某些任务,使用命令行更好。对于其他任务,您最好使用编程语言。随着您在命令行上获得更多的经验,您将开始认识到何时使用哪种方法。当一切都是命令行工具时,您甚至可以将任务拆分成子任务,并将 Bash 命令行工具与 Python 命令行工具结合使用。无论哪种方法最适合手头的任务。

4.3.2 处理来自标准输入的流数据

在前面的两个代码片段中,Python 和 R 都一次性读取了完整的标准输入。在命令行上,大多数工具以流的方式将数据传输到下一个命令行工具。有一些命令行工具在将数据写入标准输出之前需要完整的数据,比如sort。这意味着管道被这样的命令行工具阻塞了。当输入数据是有限的,比如一个文件时,这并不是一个问题。但是,当输入数据是一个不间断的流时,这样的阻塞命令行工具是没有用的。

幸运的是 Python 和 R 支持处理流数据。例如,您可以逐行应用函数。下面是两个最小的例子,分别演示了这在 Python 和 R 中是如何工作的。

Python 和 R 工具都解决了现在已经臭名昭著的 Fizz Buzz 问题,该问题定义如下:打印从 1 到 100 的数字,除非该数字能被 3 整除,否则打印Fizz;如果数字能被 5 整除,则改为打印buzz;如果这个数字能被 15 整除,就打印fizzbuzz。下面是 Python 代码 :

代码语言:javascript复制
$ bat fizzbuzz.py
───────┬────────────────────────────────────────────────────────────────────────
       │ File: fizzbuzz.py
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env python
   2   │ import sys
   3   │
   4   │ CYCLE_OF_15 = ["fizzbuzz", None, None, "fizz", None,
   5   │                "buzz", "fizz", None, None, "fizz",
   6   │                "buzz", None, "fizz", None, None]
   7   │
   8   │ def fizz_buzz(n: int) -> str:
   9   │     return CYCLE_OF_15[n % 15] or str(n)
  10   │
  11   │ if __name__ == "__main__":
  12   │     try:
  13   │         while (n:= sys.stdin.readline()):
  14   │             print(fizz_buzz(int(n)))
  15   │     except:
  16   │         pass
───────┴────────────────────────────────────────────────────────────────────────

这是 R 代码:

代码语言:javascript复制
$ bat fizzbuzz.R
───────┬────────────────────────────────────────────────────────────────────────
       │ File: fizzbuzz.R
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env Rscript
   2   │ cycle_of_15 <- c("fizzbuzz", NA, NA, "fizz", NA,
   3   │                  "buzz", "fizz", NA, NA, "fizz",
   4   │                  "buzz", NA, "fizz", NA, NA)
   5   │
   6   │ fizz_buzz <- function(n) {
   7   │   word <- cycle_of_15[as.integer(n) %% 15   1]
   8   │   ifelse(is.na(word), n, word)
   9   │ }
  10   │
  11   │ f <- file("stdin")
  12   │ open(f)
  13   │ while(length(n <- readLines(f, n = 1)) > 0) {
  14   │   write(fizz_buzz(n), stdout())
  15   │ }
  16   │ close(f)
───────┴────────────────────────────────────────────────────────────────────────

让我们测试这两个工具(为了节省空间,我将输出传送到column):

代码语言:javascript复制
$ seq 30 | fizzbuzz.py | column -x
1               2               fizz            4               buzz
fizz            7               8               fizz            buzz
11              fizz            13              14              fizzbuzz
16              17              fizz            19              buzz
fizz            22              23              fizz            buzz
26              fizz            28              29              fizzbuzz

$ seq 30 | fizzbuzz.R | column -x
1               2               fizz            4               buzz
fizz            7               8               fizz            buzz
11              fizz            13              14              fizzbuzz
16              17              fizz            19              buzz
fizz            22              23              fizz            buzz
26              fizz            28              29              fizzbuzz

这个输出在我看来是正确的!很难证明这两个工具实际上以流的方式工作。在将输入数据传输到 Python 或 R 工具之前,您可以通过将输入数据传输到sample -d 100来验证这一点。这样,您将在每一行之间添加一个小的延迟,以便更容易确认工具不会等待所有的输入数据,而是逐行操作。

4.4 总结

在 intermezzo 这一章中,我向您展示了如何构建自己的命令行工具。只需要六个步骤就可以将您的代码变成可重用的构建块。你会发现这会让你更有效率。我建议你留意创造自己工具的机会。下一章将介绍 OSEMN 数据科学模型的第二步,即清理数据。

4.5 进一步探索

  • 当工具需要记住许多选项时,向工具中添加帮助文档就变得非常重要,尤其是当您希望与他人共享您的工具时。是一个语言无关的框架,提供帮助并定义您的工具可以接受的可能选项。几乎任何编程语言都有可用的实现,包括 Bash、Python 和 R。
  • 如果你想学习更多关于 Bash 编程的知识,我推荐 Arnold Robbins 和 Nelson Beebe 的经典 Shell 编程和 Carl Albing 和 JP Vossen 的 Bash 食谱。
  • 编写一个健壮且安全的 Bash 脚本相当棘手。ShellCheck 是一个在线工具,可以检查你的 Bash 代码中的错误和漏洞。还有一个命令行工具可用。
  • Joel Grus 的《Fizz Buzz 的十篇文章》一书是一个很有见地和有趣的收藏,收集了用 Python 解决 Fizz Buzz 的十种不同方法。

0 人点赞