Skip to content

Commit 20e142f

Browse files
committed
Better support for .dockerignore
- Support all basic pattern forms: file, directory, *, ?, ! - Fix handling of wildcard patterns and subdirectories - `*/a.py` should match `foo/a.py`, but not `foo/bar/a.py` - Fix handling of directory patterns with a trailing slash - make sure they're handled equivalently to those without one - Fix handling of custom Dockerfiles - make sure they go in the tarball Signed-off-by: Aanand Prasad <[email protected]>
1 parent d60cb31 commit 20e142f

File tree

6 files changed

+273
-54
lines changed

6 files changed

+273
-54
lines changed

docker/client.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,7 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None,
9595
if os.path.exists(dockerignore):
9696
with open(dockerignore, 'r') as f:
9797
exclude = list(filter(bool, f.read().splitlines()))
98-
# These are handled by the docker daemon and should not be
99-
# excluded on the client
100-
if 'Dockerfile' in exclude:
101-
exclude.remove('Dockerfile')
102-
if '.dockerignore' in exclude:
103-
exclude.remove(".dockerignore")
104-
context = utils.tar(path, exclude=exclude)
98+
context = utils.tar(path, exclude=exclude, dockerfile=dockerfile)
10599

106100
if utils.compare_version('1.8', self._version) >= 0:
107101
stream = True

docker/utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .utils import (
22
compare_version, convert_port_bindings, convert_volume_binds,
3-
mkbuildcontext, tar, parse_repository_tag, parse_host,
3+
mkbuildcontext, tar, exclude_paths, parse_repository_tag, parse_host,
44
kwargs_from_env, convert_filters, create_host_config,
55
create_container_config, parse_bytes, ping_registry, parse_env_file
66
) # flake8: noqa

docker/utils/utils.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,39 +66,82 @@ def mkbuildcontext(dockerfile):
6666
return f
6767

6868

69-
def fnmatch_any(relpath, patterns):
70-
return any([fnmatch(relpath, pattern) for pattern in patterns])
71-
72-
73-
def tar(path, exclude=None):
69+
def tar(path, exclude=None, dockerfile=None):
7470
f = tempfile.NamedTemporaryFile()
7571
t = tarfile.open(mode='w', fileobj=f)
76-
for dirpath, dirnames, filenames in os.walk(path):
77-
relpath = os.path.relpath(dirpath, path)
78-
if relpath == '.':
79-
relpath = ''
80-
if exclude is None:
81-
fnames = filenames
82-
else:
83-
dirnames[:] = [d for d in dirnames
84-
if not fnmatch_any(os.path.join(relpath, d),
85-
exclude)]
86-
fnames = [name for name in filenames
87-
if not fnmatch_any(os.path.join(relpath, name),
88-
exclude)]
89-
dirnames.sort()
90-
for name in sorted(fnames):
91-
arcname = os.path.join(relpath, name)
92-
t.add(os.path.join(path, arcname), arcname=arcname)
93-
for name in dirnames:
94-
arcname = os.path.join(relpath, name)
95-
t.add(os.path.join(path, arcname),
96-
arcname=arcname, recursive=False)
72+
73+
root = os.path.abspath(path)
74+
exclude = exclude or []
75+
76+
for path in sorted(exclude_paths(root, exclude, dockerfile=dockerfile)):
77+
t.add(os.path.join(root, path), arcname=path, recursive=False)
78+
9779
t.close()
9880
f.seek(0)
9981
return f
10082

10183

84+
def exclude_paths(root, patterns, dockerfile=None):
85+
"""
86+
Given a root directory path and a list of .dockerignore patterns, return
87+
an iterator of all paths (both regular files and directories) in the root
88+
directory that do *not* match any of the patterns.
89+
90+
All paths returned are relative to the root.
91+
"""
92+
if dockerfile is None:
93+
dockerfile = 'Dockerfile'
94+
95+
exceptions = [p for p in patterns if p.startswith('!')]
96+
97+
include_patterns = [p[1:] for p in exceptions]
98+
include_patterns += [dockerfile, '.dockerignore']
99+
100+
exclude_patterns = list(set(patterns) - set(exceptions))
101+
102+
all_paths = get_paths(root)
103+
104+
# Remove all paths that are matched by any exclusion pattern
105+
paths = [
106+
p for p in all_paths
107+
if not any(match_path(p, pattern) for pattern in exclude_patterns)
108+
]
109+
110+
# Add back the set of paths that are matched by any inclusion pattern.
111+
# Include parent dirs - if we add back 'foo/bar', add 'foo' as well
112+
for p in all_paths:
113+
if any(match_path(p, pattern) for pattern in include_patterns):
114+
components = p.split('/')
115+
paths += [
116+
'/'.join(components[:end])
117+
for end in range(1, len(components)+1)
118+
]
119+
120+
return set(paths)
121+
122+
123+
def get_paths(root):
124+
paths = []
125+
126+
for parent, dirs, files in os.walk(root, followlinks=False):
127+
parent = os.path.relpath(parent, root)
128+
if parent == '.':
129+
parent = ''
130+
for path in dirs:
131+
paths.append(os.path.join(parent, path))
132+
for path in files:
133+
paths.append(os.path.join(parent, path))
134+
135+
return paths
136+
137+
138+
def match_path(path, pattern):
139+
pattern = pattern.rstrip('/')
140+
pattern_components = pattern.split('/')
141+
path_components = path.split('/')[:len(pattern_components)]
142+
return fnmatch('/'.join(path_components), pattern)
143+
144+
102145
def compare_version(v1, v2):
103146
"""Compare docker versions
104147

tests/helpers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
import os.path
3+
import tempfile
4+
5+
6+
def make_tree(dirs, files):
7+
base = tempfile.mkdtemp()
8+
9+
for path in dirs:
10+
os.makedirs(os.path.join(base, path))
11+
12+
for path in files:
13+
with open(os.path.join(base, path), 'w') as f:
14+
f.write("content")
15+
16+
return base

tests/test.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from . import base
3737
from . import fake_api
38+
from .helpers import make_tree
3839

3940
import pytest
4041

@@ -2054,26 +2055,50 @@ def test_load_config_with_random_name(self):
20542055
self.assertEqual(cfg.get('auth'), None)
20552056

20562057
def test_tar_with_excludes(self):
2057-
base = tempfile.mkdtemp()
2058+
dirs = [
2059+
'foo',
2060+
'foo/bar',
2061+
'bar',
2062+
]
2063+
2064+
files = [
2065+
'Dockerfile',
2066+
'Dockerfile.alt',
2067+
'.dockerignore',
2068+
'a.py',
2069+
'a.go',
2070+
'b.py',
2071+
'cde.py',
2072+
'foo/a.py',
2073+
'foo/b.py',
2074+
'foo/bar/a.py',
2075+
'bar/a.py',
2076+
]
2077+
2078+
exclude = [
2079+
'*.py',
2080+
'!b.py',
2081+
'!a.go',
2082+
'foo',
2083+
'Dockerfile*',
2084+
'.dockerignore',
2085+
]
2086+
2087+
expected_names = set([
2088+
'Dockerfile',
2089+
'.dockerignore',
2090+
'a.go',
2091+
'b.py',
2092+
'bar',
2093+
'bar/a.py',
2094+
])
2095+
2096+
base = make_tree(dirs, files)
20582097
self.addCleanup(shutil.rmtree, base)
2059-
for d in ['test/foo', 'bar']:
2060-
os.makedirs(os.path.join(base, d))
2061-
for f in ['a.txt', 'b.py', 'other.png']:
2062-
with open(os.path.join(base, d, f), 'w') as f:
2063-
f.write("content")
2064-
2065-
for exclude, names in (
2066-
(['*.py'], ['bar', 'bar/a.txt', 'bar/other.png',
2067-
'test', 'test/foo', 'test/foo/a.txt',
2068-
'test/foo/other.png']),
2069-
(['*.png', 'bar'], ['test', 'test/foo', 'test/foo/a.txt',
2070-
'test/foo/b.py']),
2071-
(['test/foo', 'a.txt'], ['bar', 'bar/a.txt', 'bar/b.py',
2072-
'bar/other.png', 'test']),
2073-
):
2074-
with docker.utils.tar(base, exclude=exclude) as archive:
2075-
tar = tarfile.open(fileobj=archive)
2076-
self.assertEqual(sorted(tar.getnames()), names)
2098+
2099+
with docker.utils.tar(base, exclude=exclude) as archive:
2100+
tar = tarfile.open(fileobj=archive)
2101+
assert sorted(tar.getnames()) == sorted(expected_names)
20772102

20782103
def test_tar_with_empty_directory(self):
20792104
base = tempfile.mkdtemp()

tests/utils_test.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import os
22
import os.path
3+
import shutil
34
import tempfile
45

56
from docker.client import Client
67
from docker.constants import DEFAULT_DOCKER_API_VERSION
78
from docker.errors import DockerException
89
from docker.utils import (
910
parse_repository_tag, parse_host, convert_filters, kwargs_from_env,
10-
create_host_config, Ulimit, LogConfig, parse_bytes, parse_env_file
11+
create_host_config, Ulimit, LogConfig, parse_bytes, parse_env_file,
12+
exclude_paths,
1113
)
1214
from docker.utils.ports import build_port_bindings, split_port
1315
from docker.auth import resolve_repository_name, resolve_authconfig
1416

1517
from . import base
18+
from .helpers import make_tree
1619

1720
import pytest
1821

@@ -472,3 +475,141 @@ def test_build_port_bindings_with_nonmatching_internal_port_ranges(self):
472475
["127.0.0.1:1000:1000", "127.0.0.1:2000:2000"])
473476
self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")])
474477
self.assertEqual(port_bindings["2000"], [("127.0.0.1", "2000")])
478+
479+
480+
class ExcludePathsTest(base.BaseTestCase):
481+
dirs = [
482+
'foo',
483+
'foo/bar',
484+
'bar',
485+
]
486+
487+
files = [
488+
'Dockerfile',
489+
'Dockerfile.alt',
490+
'.dockerignore',
491+
'a.py',
492+
'a.go',
493+
'b.py',
494+
'cde.py',
495+
'foo/a.py',
496+
'foo/b.py',
497+
'foo/bar/a.py',
498+
'bar/a.py',
499+
]
500+
501+
all_paths = set(dirs + files)
502+
503+
def setUp(self):
504+
self.base = make_tree(self.dirs, self.files)
505+
506+
def tearDown(self):
507+
shutil.rmtree(self.base)
508+
509+
def exclude(self, patterns, dockerfile=None):
510+
return set(exclude_paths(self.base, patterns, dockerfile=dockerfile))
511+
512+
def test_no_excludes(self):
513+
assert self.exclude(['']) == self.all_paths
514+
515+
def test_no_dupes(self):
516+
paths = exclude_paths(self.base, ['!a.py'])
517+
assert sorted(paths) == sorted(set(paths))
518+
519+
def test_wildcard_exclude(self):
520+
assert self.exclude(['*']) == set(['Dockerfile', '.dockerignore'])
521+
522+
def test_exclude_dockerfile_dockerignore(self):
523+
"""
524+
Even if the .dockerignore file explicitly says to exclude
525+
Dockerfile and/or .dockerignore, don't exclude them from
526+
the actual tar file.
527+
"""
528+
assert self.exclude(['Dockerfile', '.dockerignore']) == self.all_paths
529+
530+
def test_exclude_custom_dockerfile(self):
531+
"""
532+
If we're using a custom Dockerfile, make sure that's not
533+
excluded.
534+
"""
535+
assert self.exclude(['*'], dockerfile='Dockerfile.alt') == \
536+
set(['Dockerfile.alt', '.dockerignore'])
537+
538+
def test_single_filename(self):
539+
assert self.exclude(['a.py']) == self.all_paths - set(['a.py'])
540+
541+
# As odd as it sounds, a filename pattern with a trailing slash on the
542+
# end *will* result in that file being excluded.
543+
def test_single_filename_trailing_slash(self):
544+
assert self.exclude(['a.py/']) == self.all_paths - set(['a.py'])
545+
546+
def test_wildcard_filename_start(self):
547+
assert self.exclude(['*.py']) == self.all_paths - set([
548+
'a.py', 'b.py', 'cde.py',
549+
])
550+
551+
def test_wildcard_with_exception(self):
552+
assert self.exclude(['*.py', '!b.py']) == self.all_paths - set([
553+
'a.py', 'cde.py',
554+
])
555+
556+
def test_wildcard_with_wildcard_exception(self):
557+
assert self.exclude(['*.*', '!*.go']) == self.all_paths - set([
558+
'a.py', 'b.py', 'cde.py', 'Dockerfile.alt',
559+
])
560+
561+
def test_wildcard_filename_end(self):
562+
assert self.exclude(['a.*']) == self.all_paths - set(['a.py', 'a.go'])
563+
564+
def test_question_mark(self):
565+
assert self.exclude(['?.py']) == self.all_paths - set(['a.py', 'b.py'])
566+
567+
def test_single_subdir_single_filename(self):
568+
assert self.exclude(['foo/a.py']) == self.all_paths - set(['foo/a.py'])
569+
570+
def test_single_subdir_wildcard_filename(self):
571+
assert self.exclude(['foo/*.py']) == self.all_paths - set([
572+
'foo/a.py', 'foo/b.py',
573+
])
574+
575+
def test_wildcard_subdir_single_filename(self):
576+
assert self.exclude(['*/a.py']) == self.all_paths - set([
577+
'foo/a.py', 'bar/a.py',
578+
])
579+
580+
def test_wildcard_subdir_wildcard_filename(self):
581+
assert self.exclude(['*/*.py']) == self.all_paths - set([
582+
'foo/a.py', 'foo/b.py', 'bar/a.py',
583+
])
584+
585+
def test_directory(self):
586+
assert self.exclude(['foo']) == self.all_paths - set([
587+
'foo', 'foo/a.py', 'foo/b.py',
588+
'foo/bar', 'foo/bar/a.py',
589+
])
590+
591+
def test_directory_with_trailing_slash(self):
592+
assert self.exclude(['foo']) == self.all_paths - set([
593+
'foo', 'foo/a.py', 'foo/b.py',
594+
'foo/bar', 'foo/bar/a.py',
595+
])
596+
597+
def test_directory_with_single_exception(self):
598+
assert self.exclude(['foo', '!foo/bar/a.py']) == self.all_paths - set([
599+
'foo/a.py', 'foo/b.py',
600+
])
601+
602+
def test_directory_with_subdir_exception(self):
603+
assert self.exclude(['foo', '!foo/bar']) == self.all_paths - set([
604+
'foo/a.py', 'foo/b.py',
605+
])
606+
607+
def test_directory_with_wildcard_exception(self):
608+
assert self.exclude(['foo', '!foo/*.py']) == self.all_paths - set([
609+
'foo/bar', 'foo/bar/a.py',
610+
])
611+
612+
def test_subdirectory(self):
613+
assert self.exclude(['foo/bar']) == self.all_paths - set([
614+
'foo/bar', 'foo/bar/a.py',
615+
])

0 commit comments

Comments
 (0)