Skip to content

Commit 7382eb1

Browse files
committed
Add support for recursive wildcard pattern in .dockerignore
Signed-off-by: Joffrey F <[email protected]>
1 parent b54a325 commit 7382eb1

File tree

5 files changed

+260
-137
lines changed

5 files changed

+260
-137
lines changed

docker/utils/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# flake8: noqa
2+
from .build import tar, exclude_paths
3+
from .decorators import check_resource, minimum_version, update_headers
24
from .utils import (
35
compare_version, convert_port_bindings, convert_volume_binds,
4-
mkbuildcontext, tar, exclude_paths, parse_repository_tag, parse_host,
6+
mkbuildcontext, parse_repository_tag, parse_host,
57
kwargs_from_env, convert_filters, datetime_to_timestamp,
68
create_host_config, parse_bytes, ping_registry, parse_env_file, version_lt,
79
version_gte, decode_json_header, split_command, create_ipam_config,
810
create_ipam_pool, parse_devices, normalize_links, convert_service_networks,
911
format_environment, create_archive
1012
)
1113

12-
from .decorators import check_resource, minimum_version, update_headers

docker/utils/build.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import os
2+
3+
from .fnmatch import fnmatch
4+
from .utils import create_archive
5+
6+
7+
def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False):
8+
root = os.path.abspath(path)
9+
exclude = exclude or []
10+
11+
return create_archive(
12+
files=sorted(exclude_paths(root, exclude, dockerfile=dockerfile)),
13+
root=root, fileobj=fileobj, gzip=gzip
14+
)
15+
16+
17+
def exclude_paths(root, patterns, dockerfile=None):
18+
"""
19+
Given a root directory path and a list of .dockerignore patterns, return
20+
an iterator of all paths (both regular files and directories) in the root
21+
directory that do *not* match any of the patterns.
22+
23+
All paths returned are relative to the root.
24+
"""
25+
if dockerfile is None:
26+
dockerfile = 'Dockerfile'
27+
28+
exceptions = [p for p in patterns if p.startswith('!')]
29+
30+
include_patterns = [p[1:] for p in exceptions]
31+
include_patterns += [dockerfile, '.dockerignore']
32+
33+
exclude_patterns = list(set(patterns) - set(exceptions))
34+
35+
paths = get_paths(root, exclude_patterns, include_patterns,
36+
has_exceptions=len(exceptions) > 0)
37+
38+
return set(paths).union(
39+
# If the Dockerfile is in a subdirectory that is excluded, get_paths
40+
# will not descend into it and the file will be skipped. This ensures
41+
# it doesn't happen.
42+
set([dockerfile])
43+
if os.path.exists(os.path.join(root, dockerfile)) else set()
44+
)
45+
46+
47+
def should_include(path, exclude_patterns, include_patterns):
48+
"""
49+
Given a path, a list of exclude patterns, and a list of inclusion patterns:
50+
51+
1. Returns True if the path doesn't match any exclusion pattern
52+
2. Returns False if the path matches an exclusion pattern and doesn't match
53+
an inclusion pattern
54+
3. Returns true if the path matches an exclusion pattern and matches an
55+
inclusion pattern
56+
"""
57+
for pattern in exclude_patterns:
58+
if match_path(path, pattern):
59+
for pattern in include_patterns:
60+
if match_path(path, pattern):
61+
return True
62+
return False
63+
return True
64+
65+
66+
def should_check_directory(directory_path, exclude_patterns, include_patterns):
67+
"""
68+
Given a directory path, a list of exclude patterns, and a list of inclusion
69+
patterns:
70+
71+
1. Returns True if the directory path should be included according to
72+
should_include.
73+
2. Returns True if the directory path is the prefix for an inclusion
74+
pattern
75+
3. Returns False otherwise
76+
"""
77+
78+
# To account for exception rules, check directories if their path is a
79+
# a prefix to an inclusion pattern. This logic conforms with the current
80+
# docker logic (2016-10-27):
81+
# https://github.com/docker/docker/blob/bc52939b0455116ab8e0da67869ec81c1a1c3e2c/pkg/archive/archive.go#L640-L671
82+
83+
def normalize_path(path):
84+
return path.replace(os.path.sep, '/')
85+
86+
path_with_slash = normalize_path(directory_path) + '/'
87+
possible_child_patterns = [
88+
pattern for pattern in map(normalize_path, include_patterns)
89+
if (pattern + '/').startswith(path_with_slash)
90+
]
91+
directory_included = should_include(
92+
directory_path, exclude_patterns, include_patterns
93+
)
94+
return directory_included or len(possible_child_patterns) > 0
95+
96+
97+
def get_paths(root, exclude_patterns, include_patterns, has_exceptions=False):
98+
paths = []
99+
100+
for parent, dirs, files in os.walk(root, topdown=True, followlinks=False):
101+
parent = os.path.relpath(parent, root)
102+
if parent == '.':
103+
parent = ''
104+
105+
# Remove excluded patterns from the list of directories to traverse
106+
# by mutating the dirs we're iterating over.
107+
# This looks strange, but is considered the correct way to skip
108+
# traversal. See https://docs.python.org/2/library/os.html#os.walk
109+
dirs[:] = [
110+
d for d in dirs if should_check_directory(
111+
os.path.join(parent, d), exclude_patterns, include_patterns
112+
)
113+
]
114+
115+
for path in dirs:
116+
if should_include(os.path.join(parent, path),
117+
exclude_patterns, include_patterns):
118+
paths.append(os.path.join(parent, path))
119+
120+
for path in files:
121+
if should_include(os.path.join(parent, path),
122+
exclude_patterns, include_patterns):
123+
paths.append(os.path.join(parent, path))
124+
125+
return paths
126+
127+
128+
def match_path(path, pattern):
129+
pattern = pattern.rstrip('/' + os.path.sep)
130+
if pattern:
131+
pattern = os.path.relpath(pattern)
132+
133+
if '**' not in pattern:
134+
pattern_components = pattern.split(os.path.sep)
135+
path_components = path.split(os.path.sep)[:len(pattern_components)]
136+
else:
137+
path_components = path.split(os.path.sep)
138+
return fnmatch('/'.join(path_components), pattern)

docker/utils/fnmatch.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Filename matching with shell patterns.
2+
3+
fnmatch(FILENAME, PATTERN) matches according to the local convention.
4+
fnmatchcase(FILENAME, PATTERN) always takes case in account.
5+
6+
The functions operate by translating the pattern into a regular
7+
expression. They cache the compiled regular expressions for speed.
8+
9+
The function translate(PATTERN) returns a regular expression
10+
corresponding to PATTERN. (It does not compile it.)
11+
"""
12+
13+
import re
14+
15+
__all__ = ["fnmatch", "fnmatchcase", "translate"]
16+
17+
_cache = {}
18+
_MAXCACHE = 100
19+
20+
21+
def _purge():
22+
"""Clear the pattern cache"""
23+
_cache.clear()
24+
25+
26+
def fnmatch(name, pat):
27+
"""Test whether FILENAME matches PATTERN.
28+
29+
Patterns are Unix shell style:
30+
31+
* matches everything
32+
? matches any single character
33+
[seq] matches any character in seq
34+
[!seq] matches any char not in seq
35+
36+
An initial period in FILENAME is not special.
37+
Both FILENAME and PATTERN are first case-normalized
38+
if the operating system requires it.
39+
If you don't want this, use fnmatchcase(FILENAME, PATTERN).
40+
"""
41+
42+
import os
43+
name = os.path.normcase(name)
44+
pat = os.path.normcase(pat)
45+
return fnmatchcase(name, pat)
46+
47+
48+
def fnmatchcase(name, pat):
49+
"""Test whether FILENAME matches PATTERN, including case.
50+
51+
This is a version of fnmatch() which doesn't case-normalize
52+
its arguments.
53+
"""
54+
55+
try:
56+
re_pat = _cache[pat]
57+
except KeyError:
58+
res = translate(pat)
59+
if len(_cache) >= _MAXCACHE:
60+
_cache.clear()
61+
_cache[pat] = re_pat = re.compile(res)
62+
return re_pat.match(name) is not None
63+
64+
65+
def translate(pat):
66+
"""Translate a shell PATTERN to a regular expression.
67+
68+
There is no way to quote meta-characters.
69+
"""
70+
71+
recursive_mode = False
72+
i, n = 0, len(pat)
73+
res = ''
74+
while i < n:
75+
c = pat[i]
76+
i = i + 1
77+
if c == '*':
78+
if i < n and pat[i] == '*':
79+
recursive_mode = True
80+
i = i + 1
81+
res = res + '.*'
82+
elif c == '?':
83+
res = res + '.'
84+
elif c == '[':
85+
j = i
86+
if j < n and pat[j] == '!':
87+
j = j + 1
88+
if j < n and pat[j] == ']':
89+
j = j + 1
90+
while j < n and pat[j] != ']':
91+
j = j + 1
92+
if j >= n:
93+
res = res + '\\['
94+
else:
95+
stuff = pat[i:j].replace('\\', '\\\\')
96+
i = j + 1
97+
if stuff[0] == '!':
98+
stuff = '^' + stuff[1:]
99+
elif stuff[0] == '^':
100+
stuff = '\\' + stuff
101+
res = '%s[%s]' % (res, stuff)
102+
elif recursive_mode and c == '/':
103+
res = res + '/?'
104+
else:
105+
res = res + re.escape(c)
106+
return res + '\Z(?ms)'

0 commit comments

Comments
 (0)