Python是一种非常强大和广泛使用的语言,具有功能齐全的标准库。人们说它是“电池已包含”,这意味着您将需要做的大部分工作都可以在标准库中找到。
这样庞大的功能集可能会让开发人员感到迷失,而且并不总是清楚哪些可用工具最适合解决特定任务。对于这些任务中的许多,也将提供外部库,您可以安装以解决相同的问题。因此,您可能不仅会想知道从标准库提供的所有功能中选择哪个类或函数来使用,还会想知道何时最好切换到外部库来实现您的目标。
本书试图提供Python标准库中可用工具的概述,以解决许多常见任务,并提供利用这些工具实现特定结果的配方。对于基于标准库的解决方案可能变得过于复杂或有限的情况,它还将尝试建议标准库之外的工具,以帮助您迈出下一步。
本书非常适合希望在Python中编写富有表现力、高度响应、可管理、可扩展和具有弹性的代码的开发人员。预期具有Python的先前编程知识。
第一章,“容器和数据结构”,涵盖了标准库提供的不太明显的数据结构和容器的情况。虽然像list和dict这样的基本容器被视为理所当然,但本章将深入探讨不太常见的容器和内置容器的更高级用法。
第二章,“文本管理”,涵盖了文本操作、字符串比较、匹配以及为基于文本的软件格式化输出时最常见的需求。
第三章,“命令行”,涵盖了如何编写基于终端/Shell的软件,解析参数,编写交互式Shell,并实现日志记录。
第六章,“读/写数据”,涵盖了如何读取和写入常见文件格式的数据,如CSV、XML和ZIP,以及如何正确管理编码文本文件。
第七章,“算法”,涵盖了一些常见的排序、搜索和压缩算法,以及您可能需要在任何类型的数据集上应用的常见操作。
第十章,“网络”,涵盖了标准库提供的实现基于网络的应用程序的功能,以及如何从一些常见协议(如FTP和IMAP)中读取数据,以及如何实现通用的TCP/IP应用程序。
第十一章,“Web开发”,涵盖了如何实现基于HTTP的应用程序、简单的HTTP服务器和功能齐全的Web应用程序。它还将涵盖如何通过HTTP与第三方软件进行交互。
第十二章,多媒体,涵盖了检测文件类型、检查图像和生成声音的基本操作。
第十三章,图形用户界面,涵盖了UI应用程序的最常见构建块,可以组合在一起创建桌面环境的简单应用程序。
第十四章,开发工具,涵盖了标准库提供的工具,帮助开发人员进行日常工作,如编写测试和调试软件。
读者预期已经具有Python和编程的先验知识。来自其他语言或对Python有中级了解的开发人员将从本书中获益。
本书假定读者已经安装了Python3.5+,并且大多数配方都展示了Unix系统(如macOS或Linux)的示例,但也可以在Windows系统上运行。Windows用户可以依赖于Windows子系统来完美地复制这些示例。
您可以按照以下步骤下载代码文件:
下载文件后,请确保使用最新版本的解压缩或提取文件夹:
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter句柄。例如:"我们还可以通过将ChainMap与defaultdict结合来摆脱最后的.get调用。"
代码块设置如下:
classBunch(dict):def__init__(self,**kwds):super().__init__(**kwds)self.__dict__=self任何命令行输入或输出都以以下方式编写:
>>>print(population['japan'])127粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。例如:"如果涉及持续集成系统"
警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。
本书中,您会发现一些经常出现的标题(准备工作,如何做,它是如何工作的,还有更多,和另请参阅)。
为了清晰地说明如何完成一个配方,使用以下各节:
本节告诉您配方中可以期待什么,并描述了如何设置配方所需的任何软件或任何预备设置。
本节包含遵循配方所需的步骤。
本节通常包括对前一节中发生的事情的详细解释。
本节包含有关配方的其他信息,以使您对配方更加了解。
在本章中,我们将涵盖以下食谱:
Python具有一组非常简单和灵活的内置容器。作为Python开发人员,您几乎可以用dict或list实现任何功能。Python字典和列表的便利性是如此之大,以至于开发人员经常忘记它们的限制。与任何数据结构一样,它们都经过了优化,并且设计用于特定用例,可能在某些情况下效率低下,甚至无法处理它们。
曾经试图在字典中两次放入一个键吗?好吧,你不能,因为Python字典被设计为具有唯一键的哈希表,但MultiDict食谱将向您展示如何做到这一点。曾经试图在不遍历整个列表的情况下从列表中获取最低/最高值吗?列表本身不能,但在优先处理条目食谱中,我们将看到如何实现这一点。
标准Python容器的限制对Python专家来说是众所周知的。因此,多年来,标准库已经发展出了克服这些限制的方法,经常有一些模式是如此常见,以至于它们的名称被广泛认可,即使它们没有正式定义。
对于这种需求,最明显的解决方案是保留我们需要计数的计数器。如果有两个、三个或四个,也许我们可以在一些专用变量中跟踪它们,但如果有数百个,保留这么多变量显然是不可行的,我们很快就会得到一个基于容器的解决方案来收集所有这些计数器。
以下是此食谱的步骤:
>>>txt="Thisisavastworldyoucan'ttraverseworldinaday">>>>>>fromcollectionsimportCounter>>>counts=Counter(txt.split())Counter({'a':2,'world':2,"can't":1,'day':1,'traverse':1,'is':1,'vast':1,'in':1,'you':1,'This':1})>>>counts.most_common(2)[('world',2),('a',2)]>>>counts['world']2或者,获取总出现次数:
>>>sum(counts.values())12>>>Counter(["hello","world"])+Counter(["hello","you"])Counter({'hello':2,'you':1,'world':1})>>>Counter(["hello","world"])&Counter(["hello","you"])Counter({'hello':1})它是如何工作的...我们的计数代码依赖于Counter只是一种特殊类型的字典,字典可以通过提供一个可迭代对象来构建。可迭代对象中的每个条目都将添加到字典中。
在计数器的情况下,添加一个元素意味着增加其计数;对于我们列表中的每个“单词”,我们会多次添加该单词(每次它在列表中出现一次),因此它在Counter中的值每次遇到该单词时都会继续增加。
依赖Counter实际上并不是跟踪频率的唯一方法;我们已经知道Counter是一种特殊类型的字典,因此复制Counter的行为应该是非常简单的。
我们每个人可能都会得到这种形式的字典:
counts=dict(hello=0,world=0,nice=0,day=0)每当我们遇到hello、world、nice或day的新出现时,我们就会增加字典中关联的值,并称之为一天:
forwordin'helloworldthisisaveryniceday'.split():ifwordincounts:counts[word]+=1通过依赖dict.get,我们也可以很容易地使其适应计算任何单词,而不仅仅是我们可以预见的那些:
forwordin'helloworldthisisaveryniceday'.split():counts[word]=counts.get(word,0)+1但标准库实际上提供了一个非常灵活的工具,我们可以使用它来进一步改进这段代码,那就是collections.defaultdict。
defaultdict是一个普通的字典,对于任何缺失的值都不会抛出KeyError,而是调用我们可以提供的函数来生成缺失的值。
因此,诸如defaultdict(int)这样的东西将创建一个字典,为任何它没有的键提供0,这对我们的计数目的非常方便:
fromcollectionsimportdefaultdictcounts=defaultdict(int)forwordin'helloworldthisisaveryniceday'.split():counts[word]+=1结果将会完全符合我们的期望:
defaultdict(
虽然这大致解决了我们的问题,但对于计数来说远非完整解决方案——我们跟踪频率,但在其他方面,我们是自己的。如果我们想知道我们的词袋中最常见的词是什么呢?
Counter的便利性基于其提供的一组专门用于计数的附加功能;它不仅仅是一个具有默认数值的字典,它是一个专门用于跟踪频率并提供方便的访问方式的类。
在处理配置值时,通常会在多个地方查找它们——也许我们从配置文件中加载它们——但我们可以用环境变量或命令行选项覆盖它们,如果没有提供选项,我们可以有一个默认值。
这很容易导致像这样的长链的if语句:
value=command_line_options.get('optname')ifvalueisNone:value=os.environ.get('optname')ifvalueisNone:value=config_file_options.get('optname')ifvalueisNone:value='default-value'这很烦人,而对于单个值来说可能只是烦人,但随着添加更多选项,它将变成一个庞大、令人困惑的条件列表。
命令行选项是一个非常常见的用例,但问题与链式作用域解析有关。在Python中,变量是通过查看locals()来解析的;如果找不到它们,解释器会查看globals(),如果还找不到,它会查找内置变量。
对于这一步,您需要按照以下步骤进行:
importosfromcollectionsimportChainMapoptions=ChainMap(command_line_options,os.environ,config_file_options)value=options.get('optname','default-value')importosfromcollectionsimportChainMap,defaultdictoptions=ChainMap(command_line_options,os.environ,config_file_options,defaultdict(lambda:'default-value'))value=options['optname']value2=options['other-option']optvaluedefault-valueoptname将从包含它的command_line_options中检索,而other-option最终将由defaultdict解析。
ChainMap类接收多个字典作为参数;每当向ChainMap请求一个键时,它实际上会逐个查看提供的字典,以检查该键是否在其中任何一个中可用。一旦找到键,它就会返回,就好像它是ChainMap自己拥有的键一样。
未提供的选项的默认值是通过将defaultdict作为提供给ChainMap的最后一个字典来实现的。每当在之前的任何字典中找不到键时,它会在defaultdict中查找,defaultdict使用提供的工厂函数为所有键返回默认值。
ChainMap的另一个很棒的功能是它也允许更新,但是它总是更新第一个字典,而不是更新找到键的字典。结果是一样的,因为在下一次查找该键时,我们会发现第一个字典覆盖了该键的任何其他值(因为它是检查该键的第一个地方)。优点是,如果我们将空字典作为提供给ChainMap的第一个映射,我们可以更改这些值而不触及原始容器:
>>>population=dict(italy=60,japan=127,uk=65)>>>changes=dict()>>>editablepop=ChainMap(changes,population)>>>print(editablepop['japan'])127>>>editablepop['japan']+=1>>>print(editablepop['japan'])128但即使我们将日本的人口更改为1.28亿,原始人口也没有改变:
>>>print(population['japan'])127我们甚至可以使用changes来找出哪些值被更改了,哪些值没有被更改:
>>>print(changes.keys())dict_keys(['japan'])>>>print(population.keys()-changes.keys()){'italy','uk'}顺便说一句,如果字典中包含的对象是可变的,并且我们直接对其进行改变,ChainMap无法避免改变原始对象。因此,如果我们在字典中存储的不是数字,而是列表,每当我们向字典追加值时,我们将改变原始字典:
>>>citizens=dict(torino=['Alessandro'],amsterdam=['Bert'],raleigh=['Joseph'])>>>changes=dict()>>>editablecits=ChainMap(changes,citizens)>>>editablecits['torino'].append('Simone')>>>print(editablecits['torino'])['Alessandro','Simone']>>>print(changes){}>>>print(citizens){'amsterdam':['Bert'],'torino':['Alessandro','Simone'],'raleigh':['Joseph']}解包多个关键字参数经常情况下,你会发现自己需要从字典中向函数提供参数。如果你曾经面临过这种需求,你可能也会发现自己需要从多个字典中获取参数。
通常,Python函数通过解包(**语法)从字典中接受参数,但到目前为止,在同一次调用中两次解包还不可能,也没有简单的方法来合并两个字典。
这个食谱的步骤是:
>>>deff(a,b,c,d):...print(a,b,c,d)...>>>d1=dict(a=5,b=6)>>>d2=dict(b=7,c=8,d=9)>>>f(**ChainMap(d1,d2))5689>>>f(**{**d1,**d2})5789由于涉及到大量的解包运算符,这种语法可能更难阅读,而使用ChainMap对于读者来说可能更加明确发生了什么。
正如我们已经从之前的示例中知道的那样,ChainMap在所有提供的字典中查找键,因此它就像所有字典的总和。解包运算符(**)通过将所有键放入容器,然后为每个键提供一个参数来工作。
由于ChainMap具有所有提供的字典键的总和,它将提供包含在所有字典中的键给解包运算符,从而允许我们从多个字典中提供关键字参数。
自Python3.5通过PEP448,现在可以解包多个映射以提供关键字参数:
>>>deff(a,b,c,d):...print(a,b,c,d)...>>>d1=dict(a=5,b=6)>>>d2=dict(c=7,d=8)>>>f(**d1,**d2)5678这种解决方案非常方便,但有两个限制:
如果你不知道你要解包的映射/字典来自哪里,很容易出现重复参数的问题:
对于新用户来说,Python字典最令人惊讶的一个方面是,它们的顺序是不可预测的,而且在不同的环境中可能会发生变化。因此,您在自己的系统上期望的键的顺序可能在朋友的计算机上完全不同。
这经常会在测试期间导致意外的失败;如果涉及到持续集成系统,则运行测试的系统上的字典键的排序可能与您的系统上的排序不同,这可能导致随机失败。
假设您有一小段代码,它生成了一个带有一些属性的HTML标签:
>>>attrs=dict(style="background-color:red",id="header")>>>''.format(''.join('%s="%s"'%aforainattrs.items()))'
'
'
键的排序是一个非常方便的功能,在某些情况下,它实际上是必需的,因此Python标准库提供了collections.OrderedDict容器。
在collections.OrderedDict的情况下,键始终按插入的顺序排列:
>>>attrs=OrderedDict([('id','header'),('style','background-color:red')])>>>''.format(''.join('%s="%s"'%aforainattrs.items()))'
因此,每当您查找键时,查找都会通过映射进行,但每当您想要列出键或对容器进行迭代时,您都会通过键列表来确保它们按照插入的顺序进行处理。
使用OrderedDict的主要问题是,Python在3.6之前的版本中没有保证关键字参数的任何特定顺序:
>>>attrs=OrderedDict(id="header",style="background-color:red")即使使用了OrderedDict,这将再次引入完全随机的键顺序。这不是因为OrderedDict没有保留这些键的顺序,而是因为它们可能以随机顺序接收到。
由于PEP468的原因,现在在Python3.6和更新版本中保证了参数的顺序(字典的顺序仍然不确定;请记住,它们是有序的只是偶然的)。因此,如果您使用的是Python3.6或更新版本,我们之前的示例将按预期工作,但如果您使用的是较旧版本的Python,您将得到一个随机的顺序。
因此,通过在元组中提供键和值,我们可以在任何Python版本中在构建时提供它们并保留顺序:
>>>OrderedDict((('id','header'),('style','background-color:red')))OrderedDict([('id','header'),('style','background-color:red')])还有更多...Python3.6引入了保留字典键顺序的保证,作为对字典的一些更改的副作用,但它被认为是一个内部实现细节,而不是语言保证。自Python3.7以来,它成为语言的一个官方特性,因此如果您使用的是Python3.6或更新版本,可以放心地依赖于字典的顺序。
如果您曾经需要提供一个反向映射,您可能已经发现Python缺乏一种方法来为字典中的每个键存储多个值。这是一个非常常见的需求,大多数语言都提供了某种形式的多映射容器。
Python倾向于有一种单一的做事方式,因为为键存储多个值意味着只是为键存储一个值列表,所以它不提供专门的容器。
存储值列表的问题在于,为了能够将值附加到我们的字典中,列表必须已经存在。
按照以下步骤进行此操作:
>>>fromcollectionsimportdefaultdict>>>rd=defaultdict(list)>>>forname,numin[('ichi',1),('one',1),('uno',1),('un',1)]:...rd[num].append(name)...>>>rddefaultdict(
在缺少键的情况下,将提供一个空列表,以便为该键添加值。
这是因为每次defaultdict遇到缺少的键时,它将插入一个由调用list生成的值。调用list实际上会提供一个空列表。因此,执行rd[v]将始终提供一个列表,取决于v是否是已经存在的键。一旦我们有了列表,添加新值只是追加它的问题。
Python中的字典是关联容器,其中键是唯一的。一个键只能出现一次,且只有一个值。
如果我们想要支持每个键多个值,实际上可以通过将list保存为键的值来满足需求。然后,该列表可以包含我们想要保留的所有值:
>>>rd={1:['one','uno','un','ichi'],...2:['two','due','deux','ni'],...3:['three','tre','trois','san']}>>>rd[2]['two','due','deux','ni']如果我们想要为2(例如西班牙语)添加新的翻译,我们只需追加该条目:
>>>rd[2].append('dos')>>>rd[2]['two','due','deux','ni','dos']当我们想要引入一个新的键时,问题就出现了:
>>>rd[4].append('four')Traceback(mostrecentcalllast):File"
>>>rd={}>>>fork,vind.items():...rd[v].append(k)Traceback(mostrecentcalllast):File"
选择一组值的第一个/顶部条目是一个非常频繁的需求;这通常意味着定义一个优先于其他值的值,并涉及排序。
但是排序可能很昂贵,并且每次添加条目到您的值时重新排序肯定不是一种非常方便的方式来从一组具有某种优先级的值中选择第一个条目。
堆是一切具有优先级的完美匹配,例如优先级队列:
importtimeimportheapqclassPriorityQueue:def__init__(self):self._q=[]defadd(self,value,priority=0):heapq.heappush(self._q,(priority,time.time(),value))defpop(self):returnheapq.heappop(self._q)[-1]然后,我们的PriorityQueue可以用于检索给定优先级的条目:
>>>deff1():print('hello')>>>deff2():print('world')>>>>>>pq=PriorityQueue()>>>pq.add(f2,priority=1)>>>pq.add(f1,priority=0)>>>pq.pop()()hello>>>pq.pop()()world它是如何工作的...PriorityQueue通过在堆中存储所有内容来工作。堆在检索排序集的顶部/第一个元素时特别高效,而无需实际对整个集进行排序。
我们的优先级队列将所有值存储在一个三元组中:priority,time.time()和value。
我们元组的第一个条目是priority(较低的优先级更好)。在示例中,我们记录了f1的优先级比f2更好,这确保了当我们使用heap.heappop获取要处理的任务时,我们首先得到f1,然后是f2,这样我们最终得到的是helloworld消息而不是worldhello。
然后,我们有值本身,这是我们要为任务调用的函数。
对于排序的一个非常常见的方法是将条目列表保存在一个元组中,其中第一个元素是我们正在排序的key,第二个元素是值本身。
对于记分牌,我们可以保留每个玩家的姓名和他们得到的分数:
scores=[(123,'Alessandro'),(143,'Chris'),(192,'Mark']将这些值存储在元组中有效,因为比较两个元组是通过将第一个元组的每个元素与另一个元组中相同索引位置的元素进行比较来执行的:
>>>(10,'B')>(10,'A')True>>>(11,'A')>(10,'B')True如果您考虑字符串,就可以很容易地理解发生了什么。'BB'>'BB'与('B','B')>('B','A')相同;最终,字符串只是字符列表。
我们可以利用这个属性对我们的scores进行排序,并检索比赛的获胜者:
>>>scores=sorted(scores)>>>scores[-1](192,'Mark')这种方法的主要问题是,每次我们向列表添加条目时,我们都必须重新对其进行排序,否则我们的计分板将变得毫无意义:
>>>scores.append((137,'Rick'))>>>scores[-1](137,'Rick')>>>scores=sorted(scores)>>>scores[-1](192,'Mark')这很不方便,因为如果我们有多个地方向列表添加元素,很容易错过重新排序的地方,而且每次对整个列表进行排序可能会很昂贵。
Python标准库提供了一种数据结构,当我们想要找出比赛的获胜者时,它是完美的匹配。
在heapq模块中,我们有一个完全工作的堆数据结构的实现,这是一种特殊类型的树,其中每个父节点都小于其子节点。这为我们提供了一个具有非常有趣属性的树:根元素始终是最小的。
并且它是建立在列表之上的,这意味着l[0]始终是heap中最小的元素:
>>>importheapq>>>l=[]>>>heapq.heappush(l,(192,'Mark'))>>>heapq.heappush(l,(123,'Alessandro'))>>>heapq.heappush(l,(137,'Rick'))>>>heapq.heappush(l,(143,'Chris'))>>>l[0](123,'Alessandro')顺便说一句,您可能已经注意到,堆找到了我们比赛的失败者,而不是获胜者,而我们对找到最好的玩家,即最高价值的玩家感兴趣。
这是一个我们可以通过将所有分数存储为负数来轻松解决的小问题。如果我们将每个分数存储为*-1,那么堆的头部将始终是获胜者:
>>>l=[]>>>heapq.heappush(l,(-143,'Chris'))>>>heapq.heappush(l,(-137,'Rick'))>>>heapq.heappush(l,(-123,'Alessandro'))>>>heapq.heappush(l,(-192,'Mark'))>>>l[0](-192,'Mark')BunchPython非常擅长变形对象。每个实例都可以有自己的属性,并且在运行时添加/删除对象的属性是完全合法的。
偶尔,我们的代码需要处理未知形状的数据。例如,在用户提交的数据的情况下,我们可能不知道用户提供了哪些字段;也许我们的一些用户有名字,一些有姓氏,一些有一个或多个中间名字段。
如果我们不是自己处理这些数据,而只是将其提供给其他函数,我们实际上并不关心数据的形状;只要我们的对象具有这些属性,我们就没问题。
一个非常常见的情况是在处理协议时,如果您是一个HTTP服务器,您可能希望向您后面运行的应用程序提供一个request对象。这个对象有一些已知的属性,比如host和path,还可能有一些可选的属性,比如query字符串或content类型。但是,它也可以有客户端提供的任何属性,因为HTTP在头部方面非常灵活,我们的客户端可能提供了一个x-totally-custom-header,我们可能需要将其暴露给我们的代码。
在表示这种类型的数据时,Python开发人员通常倾向于查看字典。最终,Python对象本身是建立在字典之上的,并且它们符合将任意值映射到名称的需求。
因此,我们可能最终会得到以下内容:
>>>request=dict(host='www.example.org',path='/index.html')这种方法的一个副作用在于,一旦我们不得不将这个对象传递给其他代码,特别是第三方代码时,就变得非常明显。函数通常使用对象工作,虽然它们不需要特定类型的对象,因为鸭子类型是Python中的标准,但它们会期望某些属性存在。
在这种情况下,使用字典是不可行的,因为它只能通过request['path']语法访问其值,而不能通过request.path访问,这可能是我们提供对象给函数时所期望的。
此外,我们访问这个值的次数越多,就越清楚使用点符号表示法传达了代码意图的实体协作的感觉,而字典传达了纯粹数据的感觉。
一旦我们记住Python对象可以随时改变形状,我们可能会尝试创建一个对象而不是字典。不幸的是,我们无法在初始化时提供属性:
>>>request=object(host='www.example.org',path='/index.html')Traceback(mostrecentcalllast):File"
>>>request=object()>>>request.host='www.example.org'Traceback(mostrecentcalllast):File"
>>>classBunch(dict):...def__getattribute__(self,key):...try:...returnself[key]...exceptKeyError:...raiseAttributeError(key)......def__setattr__(self,key,value):...self[key]=value...>>>b=Bunch(a=5)>>>b.a5>>>b['a']5它是如何工作的...Bunch类继承自dict,主要是为了提供一个值可以被存储的上下文,然后大部分工作由__getattribute__和__setattr__完成。因此,对于在对象上检索或设置的任何属性,它们只会检索或设置self中的一个键(记住我们继承自dict,所以self实际上是一个字典)。
这使得Bunch类能够将任何值存储和检索为对象的属性。方便的特性是它在大多数情况下既可以作为对象又可以作为dict来使用。
例如,可以找出它包含的所有值,就像任何其他字典一样:
>>>b.items()dict_items([('a',5)])它还能够将它们作为属性访问:
>>>b.c=7>>>b.c7>>>b.items()dict_items([('a',5),('c',7)])还有更多...我们的bunch实现还不完整,因为它将无法通过任何类名称测试(它总是被命名为Bunch),也无法通过任何继承测试,因此无法伪造其他对象。
第一步是使Bunch能够改变其属性,还能改变其名称。这可以通过每次创建Bunch时动态创建一个新类来实现。该类将继承自Bunch,除了提供一个新名称外不会做任何其他事情:
>>>classBunchBase(dict):...def__getattribute__(self,key):...try:...returnself[key]...exceptKeyError:...raiseAttributeError(key)......def__setattr__(self,key,value):...self[key]=value...>>>defBunch(_classname="Bunch",**attrs):...returntype(_classname,(BunchBase,),{})(**attrs)>>>Bunch函数从原来的类本身变成了一个工厂,将创建所有作为Bunch的对象,但可以有不同的类。每个Bunch将是BunchBase的子类,其中在创建Bunch时可以提供_classname名称:
>>>b=Bunch("Request",path="/index.html",host="www.example.org")>>>print(b){'path':'/index.html','host':'www.example.org'}>>>print(b.path)/index.html>>>print(b.host)www.example.org这将允许我们创建任意类型的Bunch对象,并且每个对象都将有自己的自定义类型:
>>>print(b.__class__)
我们需要回到我们的Bunch工厂,并使Bunch对象不仅具有自定义类名,还要看起来是从自定义父类继承而来。
classPerson(object):def__init__(name,surname):self.name=nameself.surname=surname@propertydeffullname(self):return'{}{}'.format(self.name,self.surname)具体来说,我们将通过一个自定义的print函数打印HelloYourName,该函数仅适用于Person:
defhello(p):ifnotisinstance(p,Person):raiseValueError("Sorry,canonlygreetpeople")print("Hello{}".format(p.fullname))我们希望改变我们的Bunch工厂,接受该类并创建一个新类型:
defBunch(_classname="Bunch",_parent=None,**attrs):parents=(_parent,)ifparentelsetuple()returntype(_classname,(BunchBase,)+parents,{})(**attrs)现在,我们的Bunch对象将显示为我们想要的类的实例,并且始终显示为_parent的子类:
>>>p=Bunch("Person",Person,fullname='AlessandroMolina')>>>hello(p)HelloAlessandroMolinaBunch可以是一种非常方便的模式;在其完整和简化版本中,它被广泛用于许多框架中,具有各种实现,但都可以实现几乎相同的结果。
展示的实现很有趣,因为它让我们清楚地知道发生了什么。有一些非常聪明的方法可以实现Bunch,但可能会让人难以猜测发生了什么并进行自定义。
实现Bunch模式的另一种可能的方法是通过修补包含类的所有属性的__dict__类:
classBunch(dict):def__init__(self,**kwds):super().__init__(**kwds)self.__dict__=self在这种形式下,每当创建Bunch时,它将以dict的形式填充其值(通过调用super().__init__,这是dict的初始化),然后,一旦所有提供的属性都存储在dict中,它就会用self交换__dict__对象,这是包含所有对象属性的字典。这使得刚刚填充了所有值的dict也成为了包含对象所有属性的dict。
我们之前的实现是通过替换我们查找属性的方式来工作的,而这个实现是替换我们查找属性的地方。
枚举是存储只能表示几种状态的值的常见方式。每个符号名称都绑定到一个特定的值,通常是数字,表示枚举可以具有的状态。
枚举在其他编程语言中非常常见,但直到最近,Python才没有对枚举提供明确的支持。
通常,枚举是通过将符号名称映射到数值来实现的;在Python中,通过enum.IntEnum是允许的:
>>>fromenumimportIntEnum>>>>>>classRequestType(IntEnum):...POST=1...GET=2>>>>>>request_type=RequestType.POST>>>print(request_type)RequestType.POST它是如何工作的...IntEnum是一个整数,除了在类定义时创建所有可能的值。IntEnum继承自int,因此它的值是真正的整数。
此外,enum提供了对特殊值auto的支持,它的意思是只是放一个值进去,我不在乎。通常你只关心它是POST还是GET,你通常不关心POST是1还是2。
最后但并非最不重要的是,如果枚举定义了至少一个可能的值,那么枚举就不能被子类化。
IntEnum的值在大多数情况下表现得像int,这通常很方便,但如果开发人员不注意类型,它们可能会引起问题。
例如,如果提供了另一个枚举或整数值,而不是正确的枚举值,函数可能会意外执行错误的操作:
>>>defdo_request(kind):...ifkind==RequestType.POST:...print('POST')...else:...print('OTHER')例如,使用RequestType.POST或1调用do_request将做完全相同的事情:
>>>do_request(RequestType.POST)POST>>>do_request(1)POST当我们不想将枚举视为数字时,可以使用enum.Enum,它提供了不被视为普通数字的枚举值:
>>>fromenumimportEnum>>>>>>classRequestType(Enum):...POST=1...GET=2>>>>>>do_request(RequestType.POST)POST>>>do_request(1)OTHER因此,一般来说,如果你需要一个简单的枚举值集合或依赖于enum的可能状态,Enum更安全,但如果你需要依赖于enum的一组数值,IntEnum将确保它们表现得像数字。
在本章中,我们将涵盖以下配方:
Python是为系统工程而生的,当与shell脚本和基于shell的软件一起工作时,经常需要创建和解析文本。这就是为什么Python有非常强大的工具来处理文本。
在文本中寻找模式时,正则表达式通常是解决这类问题的最常见方式。它们非常灵活和强大,尽管它们不能表达所有种类的语法,但它们通常可以处理大多数常见情况。
对于大多数常见情况,开发人员需要寻找非常简单的模式:例如,文件扩展名(它是否以.txt结尾?),分隔文本等等。
fnmatch模块提供了一个简化的模式匹配语言,对于大多数开发人员来说,语法非常快速和易于理解。
很少有字符具有特殊含义:
您可能会从系统shell中认出这个语法,所以很容易看出*.txt意味着每个具有.txt扩展名的名称:
>>>fnmatch.fnmatch('hello.txt','*.txt')True>>>fnmatch.fnmatch('hello.zip','*.txt')False还有更多...实际上,fnmatch可以用于识别由某种常量值分隔的文本片段。
>>>defdeclare(decl):...ifnotfnmatch.fnmatch(decl,'*:*:*'):...returnFalse...t,n,v=decl.split(':',2)...globals()[n]=getattr(__builtins__,t)(v)...returnTrue...>>>declare('int:somenum:3')True>>>somenum3>>>declare('bool:somebool:True')True>>>someboolTrue>>>declare('int:a')False显然,fnmatch在文件名方面表现出色。如果您有一个文件列表,很容易提取只匹配特定模式的文件:
>>>os.listdir()['.git','.gitignore','.vscode','algorithms.rst','concurrency.rst','conf.py','crypto.rst','datastructures.rst','datetimes.rst','devtools.rst','filesdirs.rst','gui.rst','index.rst','io.rst','make.bat','Makefile','multimedia.rst','networking.rst','requirements.txt','terminal.rst','text.rst','venv','web.rst']>>>fnmatch.filter(os.listdir(),'*.git*')['.git','.gitignore']虽然非常方便,fnmatch显然是有限的,但当一个工具达到其极限时,最好的事情之一就是提供与可以克服这些限制的替代工具兼容的兼容性。
fnmatch.translate在fnmatch模式和正则表达式之间建立桥梁,提供描述fnmatch模式的正则表达式,以便可以根据需要进行扩展。
例如,我们可以创建一个匹配这两种模式的正则表达式:
>>>reg='({})|({})'.format(fnmatch.translate('*.git*'),fnmatch.translate('*vs*'))>>>reg'(.*\.git.*\Z(ms))|(.*vs.*\Z(ms))'>>>importre>>>[sforsinos.listdir()ifre.match(reg,s)]['.git','.gitignore','.vscode']fnmatch的真正优势在于它是一种足够简单和安全的语言,可以向用户公开。假设您正在编写一个电子邮件客户端,并且希望提供搜索功能,如果您有来自JaneSmith和SmithLincoln的电子邮件,您如何让用户搜索名为Smith或姓为Smith的人?
使用fnmatch很容易,因为您可以将其提供给用户,让他们编写*Smith或Smith*,具体取决于他们是在寻找名为Smith的人还是姓氏为Smith的人:
>>>senders=['JaneSmith','SmithLincoln']>>>fnmatch.filter(senders,'Smith*')['SmithLincoln']>>>fnmatch.filter(senders,'*Smith')['JaneSmith']文本相似性在许多情况下,当处理文本时,我们可能需要识别与其他文本相似的文本,即使这两者并不相等。这在记录链接、查找重复条目或更正打字错误时非常常见。
查找文本相似性并不是一项简单的任务。如果您尝试自己去做,您很快就会意识到它很快变得复杂和缓慢。
Python库提供了在difflib模块中检测两个序列之间差异的工具。由于文本本身是一个序列(字符序列),我们可以应用提供的函数来检测字符串的相似性。
执行此食谱的以下步骤:
>>>s='Todaytheweatherisnice'>>>s2='Todaytheweaterisnice'>>>s3='Yesterdaytheweatherwasnice'>>>s4='Todaymydogatesteak'>>>importdifflib>>>difflib.SequenceMatcher(None,s,s2,False).ratio()0.9795918367346939>>>difflib.SequenceMatcher(None,s,s3,False).ratio()0.8>>>difflib.SequenceMatcher(None,s,s4,False).ratio()0.46808510638297873因此,SequenceMatcher能够检测到s和s2非常相似(98%),除了weather中的拼写错误之外,它们实际上是完全相同的短语。然后它指出Todaytheweatherisnice与Yesterdaytheweatherwasnice相似度为80%,最后指出Todaytheweatherisnice和Todaymydogatesteak几乎没有共同之处。
SequenceMatcher提供了对一些值标记为junk的支持。您可能期望这意味着这些值被忽略,但实际上并非如此。
使用和不使用垃圾计算比率在大多数情况下将返回相同的值:
>>>a='aaaaaaaaaaaaaXaaaaaaaaaa'>>>b='X'>>>difflib.SequenceMatcher(lambdac:c=='a',a,b,False).ratio()0.08>>>difflib.SequenceMatcher(None,a,b,False).ratio()0.08即使我们提供了一个报告所有a结果为垃圾的isjunk函数(SequenceMatcher的第一个参数),a的结果也没有被忽略。
您可以通过使用.get_matching_blocks()来看到,在这两种情况下,字符串匹配的唯一部分是X在位置13和0处的a和b:
>>>difflib.SequenceMatcher(None,a,b,False).get_matching_blocks()[Match(a=13,b=0,size=1),Match(a=24,b=1,size=0)]>>>difflib.SequenceMatcher(lambdac:c=='a',a,b,False).get_matching_blocks()[Match(a=13,b=0,size=1),Match(a=24,b=1,size=0)]如果您想在计算差异时忽略一些字符,您将需要在运行SequenceMatcher之前剥离它们,也许使用一个丢弃它们的翻译映射:
>>>discardmap=str.maketrans({"a":None})>>>difflib.SequenceMatcher(None,a.translate(discardmap),b.translate(discardmap),False).ratio()1.0文本建议在我们之前的食谱中,我们看到difflib如何计算两个字符串之间的相似度。这意味着我们可以计算两个单词之间的相似度,并向我们的用户提供建议更正。
如果已知正确单词的集合(通常对于任何语言都是如此),我们可以首先检查单词是否在这个集合中,如果不在,我们可以寻找最相似的单词建议给用户正确的拼写。
遵循此食谱的步骤是:
>>>suggest('beautifulart')(0,'beautifulart')模板向用户显示文本时,经常需要根据软件状态动态生成文本。
通常,这会导致这样的代码:
name='Alessandro'messages=['Message1','Message2']txt='Hello%s,Youhave%smessage'%(name,len(messages))iflen(messages)>1:txt+='s'txt+=':n'formsginmessages:txt+=msg+'n'print(txt)这使得很难预见消息的即将到来的结构,而且在长期内也很难维护。生成文本时,通常更方便的是反转这种方法,而不是将文本放入代码中,我们应该将代码放入文本中。这正是模板引擎所做的,虽然标准库提供了非常完整的格式化解决方案,但缺少一个开箱即用的模板引擎,但可以很容易地扩展为一个模板引擎。
本教程的步骤如下:
importstringclassTemplateFormatter(string.Formatter):defget_field(self,field_name,args,kwargs):iffield_name.startswith("$"):code=field_name[1:]val=eval(code,{},dict(kwargs))returnval,field_nameelse:returnsuper(TemplateFormatter,self).get_field(field_name,args,kwargs)messages=['Message1','Message2']tmpl=TemplateFormatter()txt=tmpl.format("Hello{name},""Youhave{$len(messages)}message{$len(messages)and's'}:n{$'\n'.join(messages)}",name='Alessandro',messages=messages)print(txt)结果应该是:
HelloAlessandro,Youhave2messages:Message1Message2它是如何工作的...string.Formatter支持与str.format方法支持的相同语言。实际上,它根据Python称为格式化字符串语法的内容解析包含在{}中的表达式。{}之外的所有内容保持不变,而{}中的任何内容都会被解析为field_name!conversion:format_spec规范。因此,由于我们的field_name不包含!或:,它可以是任何其他内容。
然后提取的field_name被提供给Formatter.get_field,以查找format方法提供的参数中该字段的值。
因此,例如,采用这样的表达式:
string.Formatter().format("Hello{name}",name='Alessandro')这导致:
HelloAlessandro因为{name}被识别为要解析的块,所以会在.format参数中查找名称,并保留其余部分不变。
这非常方便,可以解决大多数字符串格式化需求,但缺乏像循环和条件语句这样的真正模板引擎的功能。
我们所做的是扩展Formatter,不仅解析field_name中指定的变量,还评估Python表达式。
由于我们知道所有的field_name解析都要经过Formatter.get_field,在我们自己的自定义类中覆盖该方法将允许我们更改每当评估像{name}这样的field_name时发生的情况:
classTemplateFormatter(string.Formatter):defget_field(self,field_name,args,kwargs):为了区分普通变量和表达式,我们使用了$符号。由于Python变量永远不会以$开头,因此我们不会与提供给格式化的参数发生冲突(因为str.format($something=5实际上是Python中的语法错误)。因此,像{$something}这样的field_name不意味着查找''$something的值,而是评估something表达式:
iffield_name.startswith("$"):code=field_name[1:]val=eval(code,{},dict(kwargs))eval函数运行在字符串中编写的任何代码,并将执行限制为表达式(Python中的表达式总是导致一个值,与不导致值的语句不同),因此我们还进行了语法检查,以防止模板用户编写ifsomething:x='hi',这将不会提供任何值来显示在渲染模板后的文本中。
然后,由于我们希望用户能够查找到他们提供的表达式引用的任何变量(如{$len(messages)}),我们将kwargs提供给eval作为locals变量,以便任何引用变量的表达式都能正确解析。我们还提供一个空的全局上下文{},以便我们不会无意中触及软件的任何全局变量。
剩下的最后一部分就是将eval提供的表达式执行结果作为field_name解析的结果返回:
returnval,field_name真正有趣的部分是所有处理都发生在get_field阶段。转换和格式规范仍然受支持,因为它们是应用于get_field返回的值。
这使我们可以写出这样的东西:
{$3/2.0:.2f}我们得到的输出是1.50,而不是1.5。这是因为我们在我们专门的TemplateFormatter.get_field方法中首先评估了3/2.0,然后解析器继续应用格式规范(.2f)到结果值。
我们的简单模板引擎很方便,但仅限于我们可以将生成文本的代码表示为一组表达式和静态文本的情况。
问题在于更高级的模板并不总是可以表示。我们受限于简单的表达式,因此实际上任何不能用lambda表示的东西都不能由我们的模板引擎执行。
虽然有人会认为通过组合多个lambda可以编写非常复杂的软件,但大多数人会认为语句会导致更可读的代码。
因此,如果你需要处理非常复杂的文本,你应该使用功能齐全的模板引擎,并寻找像Jinja、Kajiki或Mako这样的解决方案。特别是对于生成HTML,像Kajiki这样的解决方案,它还能够验证你的HTML,非常方便,可以比我们的TemplateFormatter做得更多。
只需依赖shlex.split而不是str.split:
>>>importshlex>>>>>>text='Iwassleepingatthe"WindsdaleHotel"'>>>print(shlex.split(text))['I','was','sleeping','at','the','WindsdaleHotel']工作原理...shlex是最初用于解析Unixshell代码的模块。因此,它支持通过引号保留短语。通常在Unix命令行中,由空格分隔的单词被提供为调用命令的参数,但如果你想将多个单词作为单个参数提供,可以使用引号将它们分组。
这正是shlex所复制的,为我们提供了一个可靠的驱动拆分的方法。我们只需要用双引号或单引号包裹我们想要保留的所有内容。
在分析用户提供的文本时,我们通常只对有意义的单词感兴趣;标点、空格和连词可能很容易妨碍我们。假设你想要统计一本书中单词的频率,你不希望最后得到"world"和"world"被计为两个不同的单词。
你需要执行以下步骤:
txt="""Andhelookedoveratthealarmclock,tickingonthechestofdrawers."GodinHeaven!"hethought.Itwashalfpastsixandthehandswerequietlymovingforwards,itwasevenlaterthanhalfpast,morelikequartertoseven.HadthealarmclocknotrungHecouldseefromthebedthatithadbeensetforfouro'clockasitshouldhavebeen;itcertainlymusthaverung.Yes,butwasitpossibletoquietlysleepthroughthatfurniture-rattlingnoiseTrue,hehadnotsleptpeacefully,butprobablyallthemoredeeplybecauseofthat.""">>>importstring>>>trans=str.maketrans('','',string.punctuation)>>>txt=txt.lower().translate(trans)结果将是我们文本的清理版本:
"""andhelookedoveratthealarmclocktickingonthechestofdrawersgodinheavenhethoughtitwashalfpastsixandthehandswerequietlymovingforwardsitwasevenlaterthanhalfpastmorelikequartertosevenhadthealarmclocknotrunghecouldseefromthebedthatithadbeensetforfouroclockasitshouldhavebeenitcertainlymusthaverungyesbutwasitpossibletoquietlysleepthroughthatfurniturerattlingnoisetruehehadnotsleptpeacefullybutprobablyallthemoredeeplybecauseofthat"""工作原理...这个示例的核心是使用转换表。转换表是将字符链接到其替换的映射。像{'c':'A'}这样的转换表意味着任何'c'都必须替换为'A'。
str.maketrans是用于构建转换表的函数。第一个参数中的每个字符将映射到第二个参数中相同位置的字符。然后最后一个参数中的所有字符将映射到None:
>>>str.maketrans('a','b','c'){97:98,99:None}97,98和99是'a','b'和'c'的Unicode值:
>>>print(ord('a'),ord('b'),ord('c'))979899然后我们的映射可以传递给str.translate来应用到目标字符串上。有趣的是,任何映射到None的字符都将被删除:
>>>'ciao'.translate(str.maketrans('a','b','c'))'ibo'在我们之前的示例中,我们将string.punctuation作为str.maketrans的第三个参数。
string.punctuation是一个包含最常见标点字符的字符串:
>>>string.punctuation'!"#$%&\'()*+,-./:;<=>@[\\]^_`{|}~'通过这样做,我们建立了一个事务映射,将每个标点字符映射到None,并没有指定任何其他映射:
>>>str.maketrans('','',string.punctuation){64:None,124:None,125:None,91:None,92:None,93:None,94:None,95:None,96:None,33:None,34:None,35:None,36:None,37:None,38:None,39:None,40:None,41:None,42:None,43:None,44:None,45:None,46:None,47:None,123:None,126:None,58:None,59:None,60:None,61:None,62:None,63:None}这样一来,一旦应用了str.translate,标点字符就都被丢弃了,保留了所有其他字符:
>>>'This,is.Atest!'.translate(str.maketrans('','',string.punctuation))'ThisisAtest'文本规范化在许多情况下,一个单词可以用多种方式书写。例如,写"über"和"Uber"的用户可能意思相同。如果你正在为博客实现标记等功能,你肯定不希望最后得到两个不同的标记。
因此,在保存标签之前,您可能希望将它们标准化为普通的ASCII字符,以便它们最终被视为相同的标签。
我们需要的是一个翻译映射,将所有带重音的字符转换为它们的普通表示:
importunicodedata,sysclassunaccented_map(dict):def__missing__(self,key):ch=self.get(key)ifchisnotNone:returnchde=unicodedata.decomposition(chr(key))ifde:try:ch=int(de.split(None,1)[0],16)except(IndexError,ValueError):ch=keyelse:ch=keyself[key]=chreturnchunaccented_map=unaccented_map()然后我们可以将其应用于任何单词来进行规范化:
>>>'über'.translate(unaccented_map)Uber>>>'garon'.translate(unaccented_map)garcon它是如何工作的...我们已经知道如何解释清理文本食谱中解释的那样,str.translate是如何工作的:每个字符都在翻译表中查找,并且用表中指定的替换进行替换。
因此,我们需要的是一个翻译表,将"ü"映射到"U",将""映射到"c",依此类推。
但是我们如何知道所有这些映射呢?这些字符的一个有趣特性是它们可以被认为是带有附加符号的普通字符。就像à可以被认为是带有重音的a。
Unicode等价性知道这一点,并提供了多种写入被认为是相同字符的方法。我们真正感兴趣的是分解形式,这意味着将字符写成定义它的多个分隔符。例如,é将被分解为0065和0301,这是e和重音的代码点。
Python提供了一种通过unicodedata.decompostion函数知道字符分解版本的方法:
>>>importunicodedata>>>unicodedata.decomposition('é')'00650301'第一个代码点是基本字符的代码点,而第二个是添加的符号。因此,要规范化我们的è,我们将选择第一个代码点0065并丢弃符号:
>>>unicodedata.decomposition('é').split()[0]'0065'现在我们不能单独使用代码点,但我们想要它表示的字符。幸运的是,chr函数提供了一种从其代码点的整数表示中获取字符的方法。
unicodedata.decomposition函数提供的代码点是表示十六进制数字的字符串,因此首先我们需要将它们转换为整数:
>>>int('0065',16)101然后我们可以应用chr来知道实际的字符:
>>>chr(101)'e'现在我们知道如何分解这些字符并获得我们想要将它们全部标准化为的基本字符,但是我们如何为它们构建一个翻译映射呢?
答案是我们不需要。事先为所有字符构建翻译映射并不是很方便,因此我们可以使用字典提供的功能,在需要时动态地为字符构建翻译。
翻译映射是字典,每当字典需要查找它不知道的键时,它可以依靠__missing__方法为该键生成一个值。因此,我们的__missing__方法必须做我们刚才做的事情,并使用unicodedata.decomposition来获取字符的规范化版本,每当str.translate尝试在我们的翻译映射中查找它时。
一旦我们计算出所请求字符的翻译,我们只需将其存储在字典本身中,这样下次再被请求时,我们就不必再计算它。
因此,我们的食谱的unaccented_map只是一个提供__missing__方法的字典,该方法依赖于unicodedata.decompostion来检索每个提供的字符的规范化版本。
如果它无法找到字符的非规范化版本,它将只返回原始版本一次,以免字符串被损坏。
在打印表格数据时,通常非常重要的是确保文本正确对齐到固定长度,既不长也不短于我们为表格单元保留的空间。
如果文本太短,下一列可能会开始得太早;如果太长,它可能会开始得太晚。这会导致像这样的结果:
col1|col2-1col1-2|col2-2或者这样:
col1-000001|col2-1col1-2|col2-2这两者都很难阅读,并且远非显示正确表格的样子。
给定固定的列宽(20个字符),我们希望我们的文本始终具有确切的长度,以便它不会导致错位的表格。
cols=['helloworld','thisisalongtext,maybelongerthanexpected,surelylongenough','onemorecolumn']COLSIZE=20importtextwrap,itertoolsdefmaketable(cols):return'n'.join(map('|'.join,itertools.zip_longest(*[[s.ljust(COLSIZE)forsintextwrap.wrap(col,COLSIZE)]forcolincols],fillvalue=''*COLSIZE)))>>>print(maketable(cols))helloworld|thisisalongtext,|onemorecolumn|maybelongerthan||expected,surely||longenough|它是如何工作的...我们必须解决三个问题来实现我们的maketable函数:
如果我们分解我们的maketable函数,它的第一件事就是将长度超过20个字符的文本拆分为多行:
[textwrap.wrap(col,COLSIZE)forcolincols]将其应用于每一列,我们得到了一个包含列的列表,每个列包含一列行:
[['helloworld'],['thisisalongtext,','maybelongerthan','expected,surely','longenough'],['onemorecolumn']]然后我们需要确保每行长度小于20个字符的文本都扩展到恰好20个字符,以便我们的表保持形状,这是通过对每行应用ljust方法来实现的:
[[s.ljust(COLSIZE)forsintextwrap.wrap(col,COLSIZE)]forcolincols]将ljust与textwrap结合起来,就得到了我们想要的结果:包含每个20个字符的行的列的列表:
[['helloworld'],['thisisalongtext,','maybelongerthan','expected,surely','longenough'],['onemorecolumn']]现在我们需要找到一种方法来翻转行和列,因为在打印时,由于print函数一次打印一行,我们需要按行打印。此外,我们需要确保每列具有相同数量的行,因为按行打印时需要打印所有行。
这两个需求都可以通过itertools.zip_longest函数解决,它将生成一个新列表,通过交错提供的每个列表中包含的值,直到最长的列表用尽。由于zip_longest会一直进行,直到最长的可迭代对象用尽,它支持一个fillvalue参数,该参数可用于指定用于填充较短列表的值:
list(itertools.zip_longest(*[[s.ljust(COLSIZE)forsintextwrap.wrap(col,COLSIZE)]forcolincols],fillvalue=''*COLSIZE))结果将是一列包含一列的行的列表,对于没有值的行,将有空列:
[('helloworld','thisisalongtext,','onemorecolumn'),('','maybelongerthan',''),('','expected,surely',''),('','longenough','')]文本的表格形式现在清晰可见。我们函数中的最后两个步骤涉及在列之间添加|分隔符,并通过'|'.join将列合并成单个字符串:
map('|'.join,itertools.zip_longest(*[[s.ljust(COLSIZE)forsintextwrap.wrap(col,COLSIZE)]forcolincols],fillvalue=''*COLSIZE))这将导致一个包含所有三列文本的字符串列表:
['helloworld|thisisalongtext,|onemorecolumn','|maybelongerthan|','|expected,surely|','|longenough|']最后,行可以被打印。为了返回单个字符串,我们的函数应用了最后一步,并通过应用最终的'n'.join()将所有行连接成一个由换行符分隔的单个字符串,从而返回一个包含整个文本的单个字符串,准备打印:
'''helloworld|thisisalongtext,|onemorecolumn|maybelongerthan||expected,surely||longenough|'''第三章:命令行在本章中,我们将涵盖以下配方:
编写新工具时,首先出现的需求之一是使其能够与周围环境进行交互-显示结果,跟踪错误并接收输入。
用户习惯于命令行工具与他们和系统交互的某些标准方式,如果从头开始遵循这个标准可能是耗时且困难的。
这就是为什么Python标准库提供了工具来实现能够通过shell和文本进行交互的软件的最常见需求。
在本章中,我们将看到如何实现某些形式的日志记录,以便我们的程序可以保留日志文件;我们将看到如何实现基于选项和交互式软件,然后我们将看到如何基于文本实现更高级的图形输出。
控制台软件的首要要求之一是记录其所做的事情,即发生了什么以及任何警告或错误。特别是当我们谈论长期运行的软件或在后台运行的守护程序时。
遗憾的是,如果您曾经尝试使用Python的logging模块,您可能已经注意到除了错误之外,您无法获得任何输出。
这是因为默认启用级别是“警告”,因此只有警告和更严重的情况才会被跟踪。需要进行一些小的调整,使日志通常可用。
对于这个配方,步骤如下:
一旦我们配置了root记录器,任何我们选择的日志记录,如果没有特定的配置,都将使用root记录器。
因此,下一行logging.getLogger(__name__)会获得一个与执行的Python模块类似命名的记录器。如果您将代码保存到文件中,则记录器的名称将类似于dosum(假设您的文件名为dosum.py);如果没有,则记录器的名称将为__main__,就像前面的示例中一样。
Python记录器在使用logging.getLogger检索时首次创建,并且对getLogger的任何后续调用只会返回已经存在的记录器。对于非常简单的程序,名称可能并不重要,但在更大的软件中,通常最好抓取多个记录器,这样您可以区分消息来自软件的哪个子系统。
也许你会想知道为什么我们配置logging将其输出发送到stderr,而不是标准输出。这样可以将我们软件的输出(通过打印语句写入stdout)与日志信息分开。这通常是一个好的做法,因为您的工具的用户可能需要调用您的工具的输出,而不带有日志消息生成的所有噪音,这样做可以让我们以以下方式调用我们的脚本:
$pythondosum.py2>/dev/null81650我们只会得到结果,而不会有所有的噪音,因为我们将stderr重定向到/dev/null,这在Unix系统上会导致丢弃所有写入stderr的内容。
将日志保存到文件允许无限长度(只要我们的磁盘允许)并且可以使用grep等工具进行搜索。
默认情况下,Python日志配置为写入屏幕,但在配置日志时很容易提供一种方式来写入任何文件。
软件将提供计算出的数字作为输出,但我们还想记录计算到哪个数字以及何时运行:
importlogging,sysif__name__=='__main__':iflen(sys.argv)<2:print('Pleaseprovideloggingfilenameasargument')sys.exit(1)logging_file=sys.argv[1]logging.basicConfig(level=logging.INFO,filename=logging_file,format='%(asctime)s%(name)s%(levelname)s:%(message)s')log=logging.getLogger(__name__)deffibo(num):log.info('Computingupto%sthfibonaccinumber',num)a,b=0,1forninrange(num):a,b=b,a+bprint(b,'',end='')print(b)if__name__=='__main__':importdatetimefibo(datetime.datetime.now().second)工作原理...代码分为三个部分:初始化日志记录、fibo函数和我们工具的main函数。我们明确地以这种方式划分代码,因为fibo函数可能会在其他模块中使用,在这种情况下,我们不希望重新配置logging;我们只想使用程序提供的日志配置。因此,logging.basicConfig调用被包装在__name__=='__main__'中,以便只有在模块被直接调用为工具时才配置logging,而不是在被其他模块导入时。
当调用多个logging.basicConfig实例时,只有第一个会被考虑。如果我们在其他模块中导入时没有将日志配置包装在if中,它可能最终会驱动整个软件的日志配置,这取决于模块导入的顺序,这显然是我们不想要的。
与之前的方法不同,basicConfig是使用filename参数而不是stream参数进行配置的。这意味着将创建logging.FileHandler来处理日志消息,并且消息将被追加到该文件中。
代码的核心部分是fibo函数本身,最后一部分是检查代码是作为Python脚本调用还是作为模块导入。当作为模块导入时,我们只想提供fibo函数并避免运行它,但当作为脚本执行时,我们想计算斐波那契数。
也许你会想知道为什么我使用了两个if__name__=='__main__'部分;如果将两者合并成一个,脚本将继续工作。但通常最好确保在尝试使用日志之前配置logging,否则结果将是我们最终会使用logging.lastResort处理程序,它只会写入stderr直到日志被配置。
类Unix系统通常提供一种通过syslog协议收集日志消息的方法,这使我们能够将存储日志的系统与生成日志的系统分开。
这正是使用syslog允许我们做的事情;我们将看到如何将日志消息发送到运行在我们系统上的守护程序,但也可以将它们发送到任何系统。
虽然这个方法不需要syslog守护程序才能工作,但您需要一个来检查它是否正常工作,否则消息将无法被读取。在Linux或macOS系统的情况下,这通常是开箱即用的,但在Windows系统的情况下,您需要安装一个Syslog服务器或使用云解决方案。有许多选择,只需在Google上快速搜索,就可以找到一些便宜甚至免费的替代方案。
当使用一个定制程度很高的日志记录解决方案时,就不再能依赖于logging.basicConfig,因此我们将不得不手动设置日志记录环境:
importloggingimportlogging.config#OSXlogsthrough/var/run/syslogthisshouldbe/dev/log#onLinuxsystemoratuple('ADDRESS',PORT)tologtoaremoteserverSYSLOG_ADDRESS='/var/run/syslog'logging.config.dictConfig({'version':1,'formatters':{'default':{'format':'%(asctime)s%(name)s:%(levelname)s%(message)s'},},'handlers':{'syslog':{'class':'logging.handlers.SysLogHandler','formatter':'default','address':SYSLOG_ADDRESS}},'root':{'handlers':['syslog'],'level':'INFO'}})log=logging.getLogger()log.info('HelloSyslog!')如果这样操作正常,您的消息应该被Syslog记录,并且在macOS上运行syslog命令或在Linux上作为/var/log/syslog的tail命令时可见:
$syslog|tail-n2Feb1817:52:43PulsarGoogleChrome[294]
由于我们依赖于dictConfig,您会注意到我们的配置比以前的方法更复杂。这是因为我们自己配置了日志基础设施的部分。
每当您配置日志记录时,都要使用记录器写入您的消息。默认情况下,系统只有一个记录器:root记录器(如果您调用logging.getLogger而不提供任何特定名称,则会得到该记录器)。
记录器本身不处理消息,因为写入或打印日志消息是处理程序的职责。因此,如果您想要读取您发送的日志消息,您需要配置一个处理程序。在我们的情况下,我们使用SysLogHandler,它写入到Syslog。
最后但并非最不重要的是,您的日志配置可能非常复杂。您可以设置一些消息发送到本地文件,一些消息发送到Syslog,还有一些应该打印在屏幕上。这将涉及多个处理程序,它们应该知道哪些消息应该处理,哪些消息应该忽略。允许这种知识是过滤器的工作。一旦将过滤器附加到处理程序,就可以控制哪些消息应该由该处理程序保存,哪些应该被忽略。
Python日志系统现在可能看起来非常直观,这是因为它是一个非常强大的解决方案,可以以多种方式进行配置,但一旦您了解了可用的构建模块,就可以以非常灵活的方式将它们组合起来。
当编写命令行工具时,通常会根据提供给可执行文件的选项来改变其行为。这些选项通常与可执行文件名称一起在sys.argv中可用,但解析它们并不像看起来那么容易,特别是当必须支持多个参数时。此外,当选项格式不正确时,通常最好提供一个使用消息,以便通知用户正确使用工具的方法。
位置参数只需提供参数的名称:
parser.add_argument("number",help="Oneormorenumberstoperformanoperationon.",nargs='+',type=int)nargs选项告诉ArgumentParser我们期望该参数被指定的次数,+值表示至少一次或多次。然后type=int告诉我们参数应该被转换为整数。
一旦我们有了要应用操作的数字,我们需要知道操作本身:
作为最佳实践,我们的命令只打印结果;能够询问一些关于它将要做什么的日志是很方便的。因此,我们提供了verbose选项,它驱动了我们为命令启用的日志级别:
parser.add_argument("-v","--verbose",action="store_true",help="increaseoutputverbosity")如果提供了该选项,我们将只存储verbose模式已启用(action="store_true"使得True被存储在opts.verbose中),并且我们将相应地配置logging模块,这样我们的log.info只有在verbose被启用时才可见。
最后,我们可以实际解析命令行选项并将结果返回到opts对象中:
opts=parser.parse_args()一旦我们有了可用的选项,我们配置日志,以便我们可以读取verbose选项并相应地配置它:
logging.basicConfig(level=logging.INFOifopts.verboseelselogging.WARNING)一旦选项被解析并且logging被配置,剩下的就是在提供的数字集上执行预期的操作并打印结果:
operation=getattr(operator,opts.operation)log.info('Applying%sto%s',opts.operation,opts.number)print(functools.reduce(operation,opts.number))还有更多...如果你将命令行选项与第一章容器和数据结构中的带回退的字典食谱相结合,你可以扩展工具的行为,不仅可以从命令行读取选项,还可以从环境变量中读取,当你无法完全控制命令的调用方式但可以设置环境变量时,这通常非常方便。
有时,编写命令行工具是不够的,你需要能够提供某种交互。假设你想要编写一个邮件客户端。在这种情况下,必须要调用mymaillist来查看你的邮件,或者从你的shell中读取特定的邮件,等等,这是不太方便的。此外,如果你想要实现有状态的行为,比如一个mymailreply实例,它应该回复你正在查看的当前邮件,这甚至可能是不可能的。
在这些情况下,交互式程序更好,Python标准库通过cmd模块提供了编写这样一个程序所需的所有工具。
我们可以尝试为我们的mymail程序编写一个交互式shell;它不会读取真实的电子邮件,但我们将伪造足够的行为来展示一个功能齐全的shell。
此示例的步骤如下:
任何以do_*开头的方法都是一个命令,do_之后的部分是命令名称。如果在交互提示中使用help命令,则实现命令的方法的docstring将被报告在我们工具的文档中。
Cmd类不提供解析命令参数的功能,因此,如果您的命令有多个参数,您必须自己拆分它们。在我们的情况下,我们依赖于shlex,以便用户可以控制参数的拆分方式。这使我们能够解析主题和消息,同时提供了一种包含空格的方法。否则,我们将无法知道主题在哪里结束,消息从哪里开始。
send命令还支持自动完成收件人,通过complete_send方法。如果提供了complete_*方法,当按下Tab自动完成命令参数时,Cmd会调用它。该方法接收需要完成的文本以及有关整行文本和光标当前位置的一些详细信息。由于没有对参数进行解析,光标的位置和整行文本可以帮助提供不同的自动完成行为。在我们的情况下,我们只能自动完成收件人,因此无需区分各个参数。
最后但并非最不重要的是,do_EOF命令允许在按下Ctrl+D时退出命令行。否则,我们将无法退出交互式shell。这是Cmd提供的一个约定,如果do_EOF命令返回True,则表示shell可以退出。
我们在第二章的文本管理中看到了对齐文本的示例,其中展示了在固定空间内对齐文本的可能解决方案。可用空间的大小在COLSIZE常量中定义,选择适合大多数终端的三列(大多数终端适合80列)。
但是,如果用户的终端窗口小于60列会发生什么?我们的对齐会被严重破坏。此外,在非常大的窗口上,虽然文本不会被破坏,但与窗口相比会显得太小。
因此,每当显示应保持正确对齐属性的文本时,通常最好考虑用户终端窗口的大小。
步骤如下:
importshutilimporttextwrap,itertoolsdefmaketable(cols):term_size=shutil.get_terminal_size(fallback=(80,24))colsize=(term_size.columns//len(cols))-3ifcolsize<1:raiseValueError('Columntoosmall')return'\n'.join(map('|'.join,itertools.zip_longest(*[[s.ljust(colsize)forsintextwrap.wrap(col,colsize)]forcolincols],fillvalue=''*colsize)))COLUMNS=5TEXT=['Loremipsumdolorsitamet,consectetueradipiscingelit.''Aeneancommodoligulaegetdolor.Aeneanmassa.''Cumsociisnatoquepenatibusetmagnisdisparturientmontes,''nasceturridiculusmus']*COLUMNSprint(maketable(TEXT))如果尝试调整终端窗口大小并重新运行脚本,您会注意到文本现在总是以不同的方式对齐,以确保它适合可用的空间。
我们的maketable函数现在通过获取终端宽度(term_size.columns)并将其除以要显示的列数来计算列的大小,而不是依赖于列的大小的常量。
始终减去三个字符,因为我们要考虑|分隔符占用的空间。
终端的大小(term_size)通过shutil.get_terminal_size获取,它将查看stdout以检查连接终端的大小。
如果无法检索大小或连接的输出不是终端,则使用回退值。您可以通过将脚本的输出重定向到文件来检查回退值是否按预期工作:
$pythonmyscript.py>output.txt如果您打开output.txt,您应该会看到80个字符的回退值被用作文件没有指定宽度。
在某些情况下,特别是在编写系统工具时,可能有一些工作需要转移到另一个命令。例如,如果你需要解压文件,在许多情况下,将工作转移到gunzip/zip命令可能更合理,而不是尝试在Python中复制相同的行为。
在Python中有许多处理这项工作的方法,它们都有微妙的差异,可能会让任何开发人员的生活变得困难,因此最好有一个通常有效的解决方案来解决最常见的问题。
执行以下步骤:
importshleximportsubprocessdefrun(command):try:result=subprocess.check_output(shlex.split(command),stderr=subprocess.STDOUT)return0,resultexceptsubprocess.CalledProcessErrorase:returne.returncode,e.outputforpathin('/','/should_not_exist'):status,out=run('ls"{}"'.format(path))ifstatus==0:print('
传递stderr=subprocess.STDOUT选项,然后处理命令失败的情况(我们可以检测到,因为run函数将返回一个非零的状态),允许我们接收失败的描述。
调用我们的命令的繁重工作由subprocess.check_output执行,实际上,它是subprocess.Popen的包装器,将执行两件事:
需要注意的一点是,我们的run函数将寻找一个可满足请求命令的可执行文件,但不会运行任何shell表达式。因此,无法将shell脚本发送给它。如果需要,可以将shell=True选项传递给subprocess.check_output,但这是极不鼓励的,因为它允许将shell代码注入到我们的程序中。
假设您想编写一个命令,打印用户选择的目录的内容;一个非常简单的解决方案可能是以下内容:
importsysiflen(sys.argv)<2:print('Pleaseprovideadirectory')sys.exit(1)_,out=run('ls{}'.format(sys.argv[1]))print(out)现在,如果我们在run中允许shell=True,并且用户提供了诸如/var;rm-rf/这样的路径,会发生什么?用户可能最终会删除整个系统磁盘,尽管我们仍然依赖于shlex来分割参数,但通过shell运行命令仍然不安全。
配方步骤如下:
这非常方便,因为报告进度的代码与实际执行工作的代码是隔离的,这使我们能够在许多不同的情况下重用它。
为了创建一个装饰器,它在函数本身运行时与被装饰的函数交互,我们依赖于Python生成器。
gen=func(*args,**kwargs)whileTrue:try:progress=next(gen)exceptStopIterationasexc:sys.stdout.write('\n')returnexc.valueelse:#displaytheprogressbar当我们调用被装饰的函数(在我们的例子中是wait函数)时,实际上我们将调用装饰器中的_func_with_progress。该函数将要做的第一件事就是调用被装饰的函数。
gen=func(*args,**kwargs)由于被装饰的函数包含一个yieldprogress语句,每当它想显示一些进度(在wait中的for循环中的yieldi),函数将返回generator。
每当生成器遇到yieldprogress语句时,我们将其作为应用于生成器的下一个函数的返回值收到。
progress=next(gen)然后我们可以显示我们的进度并再次调用next(gen),这样被装饰的函数就可以继续前进并返回新的进度(被装饰的函数当前在yield处暂停,直到我们在其上调用next,这就是为什么我们的整个代码都包裹在whileTrue:中的原因,让函数永远继续,直到它完成它要做的工作)。
当被装饰的函数完成了所有它要做的工作时,它将引发一个StopIteration异常,该异常将包含被装饰函数在.value属性中返回的值。
由于我们希望将任何返回值传播给调用者,我们只需自己返回该值。如果被装饰的函数应该返回其完成的工作的某些结果,比如一个download(url)函数应该返回对下载文件的引用,这一点尤为重要。
在返回之前,我们打印一个新行。
sys.stdout.write('\n')这确保了进度条后面的任何内容不会与进度条本身重叠,而是会打印在新的一行上。
然后我们只需显示进度条本身。配方中进度条部分的核心基于只有两行代码:
sys.stdout.write((message+'\r')%bar)sys.stdout.flush()这两行将确保我们的消息在屏幕上打印,而不像print通常做的那样换行。相反,这将回到同一行的开头。尝试用'\n'替换'\r',你会立即看到区别。使用'\r',你会看到一个进度条从0到100%移动,而使用'\n',你会看到许多进度条被打印。
然后需要调用sys.stdout.flush()来确保进度条实际上被显示出来,因为通常只有在新的一行上才会刷新输出,而我们只是一遍又一遍地打印同一行,除非我们明确地刷新它,否则它不会被刷新。
现在我们知道如何绘制进度条并更新它,函数的其余部分涉及计算要显示的进度条:
message='[%s]{}%%'.format(progress)bar_width=max_width-len(message)+3#Add3characterstocopeforthe%sand%%filled=int(round(bar_width/100.0*progress))spaceleft=bar_width-filledbar='='*filled+''*spaceleft首先,我们计算message,这是我们想要显示在屏幕上的内容。消息是在没有进度条本身的情况下计算的,对于进度条,我们留下了一个%s占位符,以便稍后填充它。
我们这样做是为了知道在我们显示周围的括号和百分比后,进度条本身还有多少空间。这个值是bar_width,它是通过从屏幕宽度的最大值(在我们的函数开始时使用shutil.get_terminal_size()检索)中减去我们的消息的大小来计算的。我们必须添加的三个额外字符将解决在我们的消息中%s和%%消耗的空间,一旦消息显示到屏幕上,%s将被进度条本身替换,%%将解析为一个单独的%。
一旦我们知道了进度条本身有多少空间可用,我们就计算出应该用'='(已完成的部分)填充多少空间,以及应该用空格''(尚未完成的部分)填充多少空间。这是通过计算要填充和匹配我们的进度的百分比的屏幕大小来实现的:
filled=int(round(bar_width/100.0*progress))一旦我们知道要用'='填充多少,剩下的就只是空格:
spaceleft=bar_width-filled因此,我们可以用填充的等号和spaceleft空格来构建我们的进度条:
bar='='*filled+''*spaceleft一旦进度条准备好了,它将通过%字符串格式化操作符注入到在屏幕上显示的消息中:
sys.stdout.write((message+'\r')%bar)如果你注意到了,我混合了两种字符串格式化(str.format和%)。我这样做是因为我认为这样做可以更清楚地说明格式化的过程,而不是在每个格式化步骤上都要正确地进行转义。
尽管现在不太常见,但能够创建交互式基于字符的用户界面仍然具有很大的价值,特别是当只需要一个带有“确定”按钮的简单消息对话框或一个带有“确定/取消”对话框时;通过一个漂亮的文本对话框,可以更好地引导用户的注意力。
curses库只包括在Unix系统的Python中,因此Windows用户可能需要一个解决方案,比如CygWin或Linux子系统,以便能够拥有包括curses支持的Python设置。
对于这个配方,执行以下步骤:
这使我们能够在更复杂的程序中交错使用MessageBox类,而不必用curses编写整个程序。这是由curses.wrapper函数允许的,该函数在MessageBox.show类方法中用于包装实际显示框的MessageBox._show方法。
消息显示是在MessageBox初始化程序中准备的,通过MessageBox._build_message方法,以确保当消息太长时自动换行,并正确处理多行文本。消息框的高度取决于消息的长度和结果行数,再加上我们始终包括的六行,用于添加边框(占用两行)和按钮(占用四行)。
然后,MessageBox._show方法创建实际的框窗口,为其添加边框,并在其中显示消息。消息显示后,我们进入MessageBox._loop,等待用户在OK和取消之间做出选择。
MessageBox._loop方法通过win.derwin函数绘制所有必需的按钮及其边框。每个按钮宽10个字符,高3个字符,并根据allowedspace的值显示自身,该值为每个按钮保留了相等的框空间。然后,一旦绘制了按钮框,它将检查当前显示的按钮是否为所选按钮;如果是,则使用粗体文本显示按钮的标签。这使用户可以知道当前选择的选项。
绘制了两个按钮后,我们调用win.refresh()来实际在屏幕上显示我们刚刚绘制的内容。
然后我们等待用户按任意键以相应地更新屏幕;左/右箭头键将在OK/取消选项之间切换,Enter将确认当前选择。
如果用户更改了所选按钮(通过按左或右键),我们将再次循环并重新绘制按钮。我们只需要重新绘制按钮,因为屏幕的其余部分没有改变;窗口边框和消息仍然是相同的,因此无需覆盖它们。屏幕的内容始终保留,除非调用了win.erase()方法,因此我们永远不需要重新绘制不需要更新的屏幕部分。
通过这种方式,我们还可以避免重新绘制按钮本身。这是因为只有取消/确定文本在从粗体到普通体和反之时需要重新绘制。
用户按下Enter键后,我们退出循环,并返回当前选择的OK和取消之间的选择。这允许调用者根据用户的选择采取行动。
在编写基于控制台的软件时,有时需要要求用户提供无法通过命令选项轻松提供的长文本输入。
在Unix世界中有一些这样的例子,比如编辑crontab或一次调整多个配置选项。其中大多数依赖于启动一个完整的第三方编辑器,比如nano或vim,但是可以很容易地使用Python标准库滚动一个解决方案,这在许多情况下将足够满足我们的工具需要长或复杂的用户输入。
curses库仅包含在Unix系统的Python中,因此Windows用户可能需要一个解决方案,例如CygWin或Linux子系统,以便能够拥有包括curses支持的Python设置。
对于这个示例,执行以下步骤:
一旦绘制完成,它会创建一个专门用于Textbox的新窗口,因为文本框将自由地插入、删除和编辑该窗口的内容。
如果我们有现有的内容(content=参数),TextInput._load函数会负责在继续编辑之前将其插入到文本框中。提供的内容中的每个字符都通过Textbox._insert_printable_char函数注入到文本框窗口中。
然后我们最终可以进入编辑循环(TextInput._edit方法),在那里我们监听按键并做出相应反应。实际上,Textbox.do_command已经为我们完成了大部分工作,因此我们只需要将按下的键转发给它,以将字符插入到我们的文本中或对特殊命令做出反应。这个方法的特殊部分是我们检查字符127,它是Backspace,并将其替换为curses.KEY_BACKSPACE,因为并非所有终端在按下Backspace键时发送相同的代码。一旦字符被do_command处理,我们就可以刷新窗口,以便任何新文本出现并再次循环。
当用户按下Ctrl+G时,编辑器将认为文本已完成并退出编辑循环。在这之前,我们调用Textbox.gather来获取文本编辑器的全部内容并将其发送回调用者。
需要注意的是,内容实际上是从curses窗口的内容中获取的。因此,它实际上包括您屏幕上看到的所有空白空间。因此,Textbox.gather方法将剥离空白空间,以避免将大部分空白空间包围您的文本发送回给您。如果您尝试编写包含多个空行的内容,这一点就非常明显;它们将与其余空白空间一起被剥离。