Skip to content

Commit 4845dae

Browse files
committed
put/get archive implementation
Signed-off-by: Joffrey F <[email protected]>
1 parent eb869c0 commit 4845dae

File tree

8 files changed

+198
-2
lines changed

8 files changed

+198
-2
lines changed

docker/api/container.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ def containers(self, quiet=False, all=False, trunc=False, latest=False,
7575

7676
@utils.check_resource
7777
def copy(self, container, resource):
78+
if utils.version_gte(self._version, '1.20'):
79+
warnings.warn(
80+
'Client.copy() is deprecated for API version >= 1.20, '
81+
'please use get_archive() instead',
82+
DeprecationWarning
83+
)
7884
res = self._post_json(
7985
self._url("/containers/{0}/copy".format(container)),
8086
data={"Resource": resource},
@@ -145,6 +151,21 @@ def export(self, container):
145151
self._raise_for_status(res)
146152
return res.raw
147153

154+
@utils.check_resource
155+
@utils.minimum_version('1.20')
156+
def get_archive(self, container, path):
157+
params = {
158+
'path': path
159+
}
160+
url = self._url('/containers/{0}/archive', container)
161+
res = self._get(url, params=params, stream=True)
162+
self._raise_for_status(res)
163+
encoded_stat = res.headers.get('x-docker-container-path-stat')
164+
return (
165+
res.raw,
166+
utils.decode_json_header(encoded_stat) if encoded_stat else None
167+
)
168+
148169
@utils.check_resource
149170
def inspect_container(self, container):
150171
return self._result(
@@ -214,6 +235,15 @@ def port(self, container, private_port):
214235

215236
return h_ports
216237

238+
@utils.check_resource
239+
@utils.minimum_version('1.20')
240+
def put_archive(self, container, path, data):
241+
params = {'path': path}
242+
url = self._url('/containers/{0}/archive', container)
243+
res = self._put(url, params=params, data=data)
244+
self._raise_for_status(res)
245+
return res.status_code == 200
246+
217247
@utils.check_resource
218248
def remove_container(self, container, v=False, link=False, force=False):
219249
params = {'v': v, 'link': link, 'force': force}

docker/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ def _post(self, url, **kwargs):
109109
def _get(self, url, **kwargs):
110110
return self.get(url, **self._set_request_timeout(kwargs))
111111

112+
def _put(self, url, **kwargs):
113+
return self.put(url, **self._set_request_timeout(kwargs))
114+
112115
def _delete(self, url, **kwargs):
113116
return self.delete(url, **self._set_request_timeout(kwargs))
114117

docker/utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
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,
6-
version_lt, version_gte
6+
version_lt, version_gte, decode_json_header
77
) # flake8: noqa
88

99
from .types import Ulimit, LogConfig # flake8: noqa

docker/utils/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import base64
1516
import io
1617
import os
1718
import os.path
@@ -66,6 +67,13 @@ def mkbuildcontext(dockerfile):
6667
return f
6768

6869

70+
def decode_json_header(header):
71+
data = base64.b64decode(header)
72+
if six.PY3:
73+
data = data.decode('utf-8')
74+
return json.loads(data)
75+
76+
6977
def tar(path, exclude=None, dockerfile=None):
7078
f = tempfile.NamedTemporaryFile()
7179
t = tarfile.open(mode='w', fileobj=f)

docs/api.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ non-running ones
165165

166166
## copy
167167
Identical to the `docker cp` command. Get files/folders from the container.
168+
**Deprecated for API version >= 1.20** &ndash; Consider using
169+
[`get_archive`](#get_archive) **instead.**
168170

169171
**Params**:
170172

@@ -376,6 +378,27 @@ Export the contents of a filesystem as a tar archive to STDOUT.
376378

377379
**Returns** (str): The filesystem tar archive as a str
378380

381+
## get_archive
382+
383+
Retrieve a file or folder from a container in the form of a tar archive.
384+
385+
**Params**:
386+
387+
* container (str): The container where the file is located
388+
* path (str): Path to the file or folder to retrieve
389+
390+
**Returns** (tuple): First element is a raw tar data stream. Second element is
391+
a dict containing `stat` information on the specified `path`.
392+
393+
```python
394+
>>> import docker
395+
>>> c = docker.Client()
396+
>>> ctnr = c.create_container('busybox', 'true')
397+
>>> strm, stat = c.get_archive(ctnr, '/bin/sh')
398+
>>> print(stat)
399+
{u'linkTarget': u'', u'mode': 493, u'mtime': u'2015-09-16T12:34:23-07:00', u'name': u'sh', u'size': 962860}
400+
```
401+
379402
## get_image
380403

381404
Get an image from the docker daemon. Similar to the `docker save` command.
@@ -712,6 +735,20 @@ command.
712735
yourname/app/tags/latest}"}\\n']
713736
```
714737

738+
## put_archive
739+
740+
Insert a file or folder in an existing container using a tar archive as source.
741+
742+
**Params**:
743+
744+
* container (str): The container where the file(s) will be extracted
745+
* path (str): Path inside the container where the file(s) will be extracted.
746+
Must exist.
747+
* data (bytes): tar data to be extracted
748+
749+
**Returns** (bool): True if the call succeeds. `docker.errors.APIError` will
750+
be raised if an error occurs.
751+
715752
## remove_container
716753

717754
Remove a container. Similar to the `docker rm` command.

tests/helpers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import os.path
3+
import tarfile
34
import tempfile
45

56

@@ -14,3 +15,23 @@ def make_tree(dirs, files):
1415
f.write("content")
1516

1617
return base
18+
19+
20+
def simple_tar(path):
21+
f = tempfile.NamedTemporaryFile()
22+
t = tarfile.open(mode='w', fileobj=f)
23+
24+
abs_path = os.path.abspath(path)
25+
t.add(abs_path, arcname=os.path.basename(path), recursive=False)
26+
27+
t.close()
28+
f.seek(0)
29+
return f
30+
31+
32+
def untar_file(tardata, filename):
33+
with tarfile.open(mode='r', fileobj=tardata) as t:
34+
f = t.extractfile(filename)
35+
result = f.read()
36+
f.close()
37+
return result

tests/integration_test.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from docker.errors import APIError, NotFound
3838
from docker.utils import kwargs_from_env
3939

40+
from . import helpers
4041
from .base import requires_api_version
4142
from .test import Cleanup
4243

@@ -427,6 +428,90 @@ def test_valid_no_config_specified(self):
427428
self.assertEqual(container_log_config['Config'], {})
428429

429430

431+
@requires_api_version('1.20')
432+
class GetArchiveTest(BaseTestCase):
433+
def test_get_file_archive_from_container(self):
434+
data = 'The Maid and the Pocket Watch of Blood'
435+
ctnr = self.client.create_container(
436+
BUSYBOX, 'sh -c "echo {0} > /vol1/data.txt"'.format(data),
437+
volumes=['/vol1']
438+
)
439+
self.tmp_containers.append(ctnr)
440+
self.client.start(ctnr)
441+
self.client.wait(ctnr)
442+
with tempfile.NamedTemporaryFile() as destination:
443+
strm, stat = self.client.get_archive(ctnr, '/vol1/data.txt')
444+
for d in strm:
445+
destination.write(d)
446+
destination.seek(0)
447+
retrieved_data = helpers.untar_file(destination, 'data.txt')
448+
if six.PY3:
449+
retrieved_data = retrieved_data.decode('utf-8')
450+
self.assertEqual(data, retrieved_data.strip())
451+
452+
def test_get_file_stat_from_container(self):
453+
data = 'The Maid and the Pocket Watch of Blood'
454+
ctnr = self.client.create_container(
455+
BUSYBOX, 'sh -c "echo -n {0} > /vol1/data.txt"'.format(data),
456+
volumes=['/vol1']
457+
)
458+
self.tmp_containers.append(ctnr)
459+
self.client.start(ctnr)
460+
self.client.wait(ctnr)
461+
strm, stat = self.client.get_archive(ctnr, '/vol1/data.txt')
462+
self.assertIn('name', stat)
463+
self.assertEqual(stat['name'], 'data.txt')
464+
self.assertIn('size', stat)
465+
self.assertEqual(stat['size'], len(data))
466+
467+
468+
@requires_api_version('1.20')
469+
class PutArchiveTest(BaseTestCase):
470+
def test_copy_file_to_container(self):
471+
data = b'Deaf To All But The Song'
472+
with tempfile.NamedTemporaryFile() as test_file:
473+
test_file.write(data)
474+
test_file.seek(0)
475+
ctnr = self.client.create_container(
476+
BUSYBOX,
477+
'cat {0}'.format(
478+
os.path.join('/vol1', os.path.basename(test_file.name))
479+
),
480+
volumes=['/vol1']
481+
)
482+
self.tmp_containers.append(ctnr)
483+
with helpers.simple_tar(test_file.name) as test_tar:
484+
self.client.put_archive(ctnr, '/vol1', test_tar)
485+
self.client.start(ctnr)
486+
self.client.wait(ctnr)
487+
logs = self.client.logs(ctnr)
488+
if six.PY3:
489+
logs = logs.decode('utf-8')
490+
data = data.decode('utf-8')
491+
self.assertEqual(logs.strip(), data)
492+
493+
def test_copy_directory_to_container(self):
494+
files = ['a.py', 'b.py', 'foo/b.py']
495+
dirs = ['foo', 'bar']
496+
base = helpers.make_tree(dirs, files)
497+
ctnr = self.client.create_container(
498+
BUSYBOX, 'ls -p /vol1', volumes=['/vol1']
499+
)
500+
self.tmp_containers.append(ctnr)
501+
with docker.utils.tar(base) as test_tar:
502+
self.client.put_archive(ctnr, '/vol1', test_tar)
503+
self.client.start(ctnr)
504+
self.client.wait(ctnr)
505+
logs = self.client.logs(ctnr)
506+
if six.PY3:
507+
logs = logs.decode('utf-8')
508+
results = logs.strip().split()
509+
self.assertIn('a.py', results)
510+
self.assertIn('b.py', results)
511+
self.assertIn('foo/', results)
512+
self.assertIn('bar/', results)
513+
514+
430515
class TestCreateContainerReadOnlyFs(BaseTestCase):
431516
def runTest(self):
432517
if not exec_driver_is_native():

tests/utils_test.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
22

3+
import base64
4+
import json
35
import os
46
import os.path
57
import shutil
@@ -14,7 +16,7 @@
1416
from docker.utils import (
1517
parse_repository_tag, parse_host, convert_filters, kwargs_from_env,
1618
create_host_config, Ulimit, LogConfig, parse_bytes, parse_env_file,
17-
exclude_paths, convert_volume_binds,
19+
exclude_paths, convert_volume_binds, decode_json_header
1820
)
1921
from docker.utils.ports import build_port_bindings, split_port
2022
from docker.auth import resolve_repository_name, resolve_authconfig
@@ -370,6 +372,16 @@ def test_convert_filters(self):
370372
for filters, expected in tests:
371373
self.assertEqual(convert_filters(filters), expected)
372374

375+
def test_decode_json_header(self):
376+
obj = {'a': 'b', 'c': 1}
377+
data = None
378+
if six.PY3:
379+
data = base64.b64encode(bytes(json.dumps(obj), 'utf-8'))
380+
else:
381+
data = base64.b64encode(json.dumps(obj))
382+
decoded_data = decode_json_header(data)
383+
self.assertEqual(obj, decoded_data)
384+
373385
def test_resolve_repository_name(self):
374386
# docker hub library image
375387
self.assertEqual(

0 commit comments

Comments
 (0)