字符:5706
平移阅读时长:7分钟

今天在写一个小脚本来把自己写的一个html批量复制并在保持后缀不变的情况下随机命名。

先打算实现文件的拷贝。

Google了一下,发现了一个shutil的库(这个库的名字纠结了发音老半天,后面才理解是sh和util的结合,也就是shell-util)

这个库提供了与shell命令操作文件等同的所有方法:copy, copytree, rmtree, move

代码实现

有了这个库,代码很容易就实现了:

import shutil

src = 'sa-jumper.html'

randomStr = 'ad239sdfasdl2asdf9adsfi23dfladf'
strLen = len(randomStr)

shutil.copy(src, 'tmp/aa.html')

关于随机的部分,就只是提前做了准备。先用的shutil.copy来测试文件拷贝功能。

然后我先用的python3.6执行了这个文件,结果报错:

Traceback (most recent call last):
  File "copy.py", line 2, in <module>
    import shutil
  File "/usr/local/Cellar/python3/3.6.0_1/Frameworks/Python.framework/Versions/3.6/lib/python3.6/shutil.py", line 13, in <module>
    import tarfile
  File "/usr/local/Cellar/python3/3.6.0_1/Frameworks/Python.framework/Versions/3.6/lib/python3.6/tarfile.py", line 49, in <module>
    import copy
  File "/Library/WebServer/Documents/xxx/copy.py", line 13, in <module>
    shutil.copy(src, 'tmp/aa.html')
AttributeError: module 'shutil' has no attribute 'copy'

后面改用python2.7执行,顺利通过。

debug

打开Python的解释器提示符,执行上述代码,也是成功的。

这下我就没辙了,感觉个人的Python经验只能到此为止,启用Google大法。

在StackOverflow的一篇文章上,发现有人遇到类似的问题,链接如下:

https://stackoverflow.com/questions/22131139/attributeerror-module-object-has-no-attribute-x

在上述链接里面,最终的错误是因为提问的人,python脚本的名称是org.py跟系统的一个文件重名,导致了一个错误的引用。

所以很自然的,我就联想到了我的文件,名字是copy.py,而看上面的错误信息里面,是用一个import copy,估计也是同样错误的把我的脚本当作官方库给错误引用了。

修复

把文件名改成了copy-file.py,再用python3.6执行,一切顺利。

分析

从这个错误里面想到几个问题:

  • 为啥跟官方库里面的文件同名会导致引用错误
  • 为啥python2.7没报错
  • 如何避免这样的问题

关于引用错误

重新翻了一下之前看的《Python简明教程》

关于模块的索引

如果它不是一个已编译好的模块,即用 Python 编写的模块,那么 Python 解释器将从它的 sys.path 变量所提供的目录中进行搜索。如果找到了对应模块,则该模块中的语句将在开始运行,并能够为你所使用。在这里需要注意的是,初始化工作只需在我们第一次导入模块时完成。

关于索引的顺序

sys.path 内包含了导入模块的字典名称列表。你能观察到 sys.path 的第一段字符串是空的——这一空字符串代表当前目录也是 sys.path 的一部分,它与 PYTHONPATH 环境变量等同。这意味着你可以直接导入位于当前目录的模块。否则,你必须将你的模块放置在 sys.path 内所列出的目录中。

从这里可以看到,执行的脚本所在的目录也是在python搜索模块的范围内的,而且是第一位,也就是会优先搜索的,这也就是为啥同名会导致引用错误的原因了。

为啥2.7没错

按照上面StackOverflow那边文章的分析过程,我分别看了python3.6和python2.7版本里面shutil.pytarfile.py源码,发现shutil.py都引用了tarfile.py,而tarfile.py也都import copy

那就不可能是2.7里面没有引用copy了。

然后我又仔细的看了一下各自的实现,终于发现了问题,在2.7里面,不是在初始化的时候就import了tarfile,而是在一个_make_tarball 的函数里面。

我在这个函数的import tarfile前面加了一个print('import tarfile'),再去执行刚刚的copy.py(这块为了测试方便,把copy-file.py复制了一份重命名为copy.py)。

发现没有print任何内容。

而在3.6里面,是在文件头部,一开始就import了tarfile。这也就解释了为啥2.7没问题,3.6有问题了。同时也说明:python的import是按需加载的。写在函数里面的import,只有在函数被调用的时候才会真实的去import

为了反证这个推断是否有效,我找到了2.7里面shutil的一个会触发import tarfile的函数make_archive

使用了官方提供的example:

import shutil
import os
archive_name = os.path.expanduser(os.path.join('~', 'myarchive'))
root_dir = os.path.expanduser(os.path.join('~', '.ssh'))
shutil.make_archive(archive_name, 'gztar', root_dir)

执行python copy.py(我的环境里面,默认是2.7)

果然报错了:

import tarfile
import tarfile
Traceback (most recent call last):
  File "copy.py", line 20, in <module>
    shutil.make_archive(archive_name, 'gztar', root_dir)
  File "/usr/local/Cellar/python/2.7.13/Frameworks/Python.framework/Versions/2.7/lib/python2.7/shutil.py", line 561, in make_archive
    filename = func(base_name, base_dir, **kwargs)
  File "/usr/local/Cellar/python/2.7.13/Frameworks/Python.framework/Versions/2.7/lib/python2.7/shutil.py", line 376, in _make_tarball
    import tarfile  # late import so Python build itself doesn't break
  File "/usr/local/Cellar/python/2.7.13/Frameworks/Python.framework/Versions/2.7/lib/python2.7/tarfile.py", line 52, in <module>
    import copy
  File "/Library/WebServer/Documents/xxx/copy.py", line 20, in <module>
    shutil.make_archive(archive_name, 'gztar', root_dir)
  File "/usr/local/Cellar/python/2.7.13/Frameworks/Python.framework/Versions/2.7/lib/python2.7/shutil.py", line 561, in make_archive
    filename = func(base_name, base_dir, **kwargs)
  File "/usr/local/Cellar/python/2.7.13/Frameworks/Python.framework/Versions/2.7/lib/python2.7/shutil.py", line 394, in _make_tarball
    tar = tarfile.open(archive_name, 'w|%s' % tar_compression[compress])
AttributeError: 'module' object has no attribute 'open'

然后我尝试把文件名修改为copy-file.py,再执行,顺利通过。

看来2.7和3.6都是一样的索引模块的方式,只是因为shutil模块里面引用tarfile的方式不一样,才造成一开始的执行结果不一样。

如何避免这样的问题

我还是一个python新手,不知道python在命名的时候有没有提供一些合理的规范,比如不能使用的单词。

然后我又一次看了《简明Python教程》,里面关于标识符命名这块:

量是标识符的一个例子。标识符(Identifiers) 是为 某些东西 提供的给定名称。在你命名标识符时,你需要遵守以下规则:
• 第一个字符必须是字母表中的字母(大写 ASCII 字符或小写 ASCII 字符或 Unicode 字符)或下划线()。
• 标识符的其它部分可以由字符(大写 ASCII 字符或小写 ASCII 字符或 Unicode 字符)、下划线(
)、数字(0~9)组成。
• 标识符名称区分大小写。例如,myname 和 myName 并不等同。要注意到前者是小写字母 n 而后者是大写字母 N。
• 有效 的标识符名称可以是 i 或 name_2_3 ,无效 的标识符名称可能是 2things,this is spaced out,my-name 和 >a1b2_c3。

我想,如果我以非这个规范的方式定义文件名是不是就可以了。

然而用类似import copy-file的方式引用自己的模块,会报错,SyntaxError

也有看到有相关的解决方式:

  • python2.7 execfile('foo-bar.py')
  • python3.x exec(open(fn).read())

不过这些方法感觉都不是很自然,还是放弃这样的尝试了。

只能使用另一种方式了,就是统一文件名的前缀。如果你的项目是foo,那么可以给所有项目的文件定义以这个为前缀的名称: foo__copy.py, foo_shutil.py等等。

这样只要foo是一个不会和常见的库的前缀重名的字符串,就能保证项目里面不会再发生这样的名称冲突了。

参考资料

突然发现,我只是想实现一个文件拷贝,咋就绕了这么远呢?

前情

最近在做活动编辑器的时候,考虑到运营人员很多都没有图片上传的权限,所以想在活动编辑器里面内置一个这样的功能,让运营可以使用上传控件或者托拽的方式上传图片到服务器,并在上传成功之后,自动把地址放到文本框里。

第一步要做的就是尝试着实现用FTP来上传一个文件到服务器的指定目录,同时基于文件的sha1值来命名上传之后的文件。

手上现有的两种实现路径是NodeJsPython,不过想到最近刚学Python,正需要这样一个机会,就先尝试python的方式。

开发

先是Google了一下,知道了一个ftplib的内置模块可以用来做FTP相关的功能。然后是之前在即刻上有看到余弦发的一个文章,说到用Python一句话查看文件的哈希值,上去找了找,也很快找到了那篇文章里面的相关代码。

折腾一下之后,实现代码如下:

import ftplib
import hashlib
import os

host = 'YOUR HOST'
username = 'YOUR NAME'
pwd = 'YOUR PWD'
port = 21
filePath = 'some/path/to/img.jpg'
serverPath = '/where/to/store/your/file/on/the/server/'

session = ftplib.FTP()

session.connect(host, port)

session.login(username, pwd)

session.cwd(serverPath)

file = open(filePath, 'rb')

filename, file_extension = os.path.splitext(filePath)

str_hash = hashlib.sha1(file.read()).hexdigest()

target_file_name = str_hash[:16]+file_extension

session.storbinary('STOR '+target_file_name, file)

file.close()

session.quit()

代码写完,就顺手执行看了下效果:文件顺利按的传到了预设的目录,也重命名为了指定的文件名。但是,文件的体积是0

调试

在Python的IDEL环境里面使用help命令查看了file相关的各种文档,最后把问题的方向指向了file.read。因为当时在IDEL环境里面,使用file.read读取了要上传的文件内容之后,再次调用file.read时,返回的内容为空。

于是在Google上面用python file read twice作为搜索词搜索了一下。果然找到了问题,文件里面有一个指针指向上一次读取的位置,默认是在最开始,也就是0。file.read会读取文件的全部内容,完成之后,指针就指向文件的最末尾。

所以,在调用过一次file.read之后,再次调用file.read,会从文件最末尾开始获取内容,这获取的内容自然就是空了。而ftp.storbinary在上传文件的时候,内部应该也是会使用类似file.read类似的方法来获取要上传的文件内容并传递给服务器。这个时候也自然就没法正常获取正确的文件内容

解决方式是在file.read之后,使用file.seek(0)来把文件的指针指回最开始。

最终代码:

import ftplib
import hashlib
import os

host = 'YOUR HOST'
username = 'YOUR NAME'
pwd = 'YOUR PWD'
port = 21
filePath = 'some/path/to/img.jpg'
serverPath = '/where/to/store/your/file/on/the/server/'

session = ftplib.FTP()

session.connect(host, port)

session.login(username, pwd)

session.cwd(serverPath)

file = open(filePath, 'rb')

filename, file_extension = os.path.splitext(filePath)

str_hash = hashlib.sha1(file.read()).hexdigest()

target_file_name = str_hash[:16]+file_extension

# 前面的file.read使得file的read cursor指向了文件的尾部
# 如果不手动调整指针的话,后面的文件上传,就会是一个空文件
file.seek(0)

session.storbinary('STOR '+target_file_name, file)

file.close()

session.quit()

参考文档

  1. Python Script Uploading files via FTP
  2. Extracting extension from filename in Python
  3. Why can’t I call read() twice on an open file?
  4. 我是如何 Python 一句话校验软件哈希值的