Python中如何实现类似于.gitignore的行为

17
我需要列出当前目录(.)中的所有文件(包括所有子目录),并排除一些文件,就像 .gitignore 的工作方式一样(http://git-scm.com/docs/gitignore
使用 fnmatch(https://docs.python.org/2/library/fnmatch.html),我将能够使用模式“过滤”文件。
ignore_files = ['*.jpg', 'foo/', 'bar/hello*']
matches = []
for root, dirnames, filenames in os.walk('.'):
  for filename in fnmatch.filter(filenames, '*'):
      matches.append(os.path.join(root, filename))

我该如何“过滤”并获取所有不与我的“ignore_files”中的一个或多个元素匹配的文件?

谢谢!


我不需要所有的规范,只需要从文件列表中使用模式来排除一些文件。 - fj123x
你为什么打上了正则表达式的标签?在你的代码中,这些明显是 glob 样式的模式,而不是正则表达式。虽然你可以使用 fnmatch.translate 将它们转换为正则表达式,但你有没有任何理由相信你可能需要这样做呢?如果有的话,那个原因应该在你的问题中提到。 - abarnert
2个回答

17

你走在正确的道路上:如果想使用 fnmatch 风格的模式,应该使用带有这种模式的fnmatch.filter

但是存在三个问题,使这并不是非常简单。

首先,你想应用多个过滤器。怎么做呢?多次调用 filter:

for ignore in ignore_files:
    filenames = fnmatch.filter(filenames, ignore)

其次,您实际上想做的是filter相反操作:返回不匹配的名称子集。正如文档所解释:

它与 [n for n in names if fnmatch(n, pattern)] 相同,但实现更有效。

因此,要执行相反操作,您只需添加not关键字:

for ignore in ignore_files:
    filenames = [n for n in filenames if not fnmatch(n, ignore)]

最后,您正在尝试过滤部分路径名而不仅仅是文件名,但在过滤之前还没有进行join操作。所以请改变顺序:

filenames = [os.path.join(root, filename) for filename in filenames]
for ignore in ignore_files:
    filenames = [n for n in filenames if not fnmatch(n, ignore)]
matches.extend(filenames)

有几种方法可以改进这个程序。

你可能会想使用生成器表达式而不是列表推导式(用圆括号代替方括号),这样如果你有大量文件名的列表,就可以使用惰性流水线而不是反复构建巨大的列表来节省时间和空间。

另外,如果你倒置循环的顺序,可能更容易理解,像这样:

filenames = (n for n in filenames 
             if not any(fnmatch(n, ignore) for ignore in ignore_files))

最后,如果您担心性能问题,您可以使用 fnmatch.translate 处理每个表达式,将它们转换为等效的正则表达式,然后将它们合并成一个大的正则表达式并编译它,而不是在 fnmatch 循环周围进行操作。如果您的模式允许比仅有的 *.jpg 更加复杂,这可能会变得棘手,并且除非您真的确定存在性能瓶颈,否则我不建议这样做。但如果您需要这样做,我曾在SO上看到至少有一个问题,其中某人花费了很多精力解决所有边缘情况,因此请搜索而不是尝试自己编写。


非常感谢,这是我的代码(端到端):ignore_files = ['foo', '*/foo']matches = [] for root, dirnames, filenames in os.walk('.'): for filename in fnmatch.filter(filenames, '*'): filename = os.path.join(root, filename)[2:] matches.append(filename)for ignore in ignore_files: matches_ = [n for n in matches if not fnmatch.filter([n], ignore)]有什么可以改进的吗? 谢谢 - fj123x
@fj123x:你不能在评论中发布代码,因为它们会消耗所有的格式。请发布一个新问题,将其编辑到现有问题中,或将其粘贴到类似于http://pastebin.com的地方并在此处发布链接。 - abarnert
@fj123x:但是有一个快速的评论:没有理由使用 fnmatch.filter(filenames, '*')。所有文件名都匹配 *,所以这只会返回 filenames 的副本。 - abarnert
5
这实际上无法处理像 **/a/ba/b/**a/**/b 这样的 .gitignore 规则,也似乎无法处理简单的 foo。例如,在 .gitignore 中的 foo 会匹配 fooa/foo,但 fnmatch 会在 a/foo 上失败。 - gman
是的,似乎完全不起作用,虽然可能我错过了什么。 - gman

-1
matches.extend([fn for fn if not filename in ignore_files])

对于简单的文件名应该可以解决问题,对于忽略模式可以使用类似以下的内容:

def reject(filename, filter):
    """ Takes a filename and a filter to reject files that match."""
    if len(filter)==0:
         return False
    else:
         return fnmatch.fnmach(filename, filter[0]) or reject(filename, filter[1:])

matches.extend([os.path.join(root, fn) for fn in filenames if not reject(fn, ignore_files)])

在从os.walk中的文件名构建列表时,以上内容将检查是否有任何过滤器提供匹配项-过滤器会被检查,直到没有剩余或找到第一个匹配项,因此应该非常快。

您还可以尝试类似以下的内容:

filenames = set(filenames)  # convert to a set
for filter in ignore_files:
   filenames = filenames - set(fnmatch.filter(filenames, filter)) # remove the matches
matches.extend([os.path.join(root, fn) for fn in filenames])  # Add to matches

2
只有当ignore_files是一个简单文件名列表时,.gitignorefnmatch才允许使用glob模式,这非常有用。甚至在OP的示例中也有一个(*.jpg)。 - user395760
命名可能会误导 - ignore_files 而不是 ignore_patterns - Steve Barnes
请注意,一些模式匹配的是路径,而不仅仅是文件名。 - Jason S
你能解释一下 matches.extend([fn for fn if not reject(filename, ignore_files)]) 的用法吗?句子中没有使用 "in",filename 是什么?谢谢。 - fj123x
@fj123x:在for fnif not之间缺少in filenames。你需要添加它才能使其工作。但正如delnan指出的那样,这对你也没有用。 - abarnert

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接