Repository: ReproNim/reproman

Issue:
create: Default to :latest tag when pulling Docker image
`reproman create` callers that are familiar with the command-line interface to `docker pull` would reasonably expect that, if they omit a tag, the default `:latest` will be used.  Instead we download all tags:

```
$ docker images busybox
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

$ reproman create bb -t docker-container -b image=busybox >notag-out 2>&1

$ wc -l notag-out                      
84 notag-out                                    

$ grep -c "Download complete" notag-out
7                                               

$ tail -n3 notag-out                   
2019-11-05 15:14:27,259 [INFO] Downloading [====================>                              ]  924.4kB/2.202MB 
ERROR: 
Interrupted by user while doing magic

$ docker images busybox
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
busybox             1-musl              ff773d70e0ec        5 days ago          1.44MB
busybox             1-glibc             7636bfb4b772        5 days ago          5.2MB
busybox             1-uclibc            020584afccce        5 days ago          1.22MB
busybox             1-ubuntu            d34ea343a882        3 years ago         4.35MB
busybox             1.21-ubuntu         d34ea343a882        3 years ago         4.35MB
busybox             1.21.0-ubuntu       d34ea343a882        3 years ago         4.35MB
busybox             1.23                a84c36ecc374        4 years ago         1.1MB
```

Comments:

Patch:
diff --git a/reproman/interface/run.py b/reproman/interface/run.py
index 4654d00..e7f2edd 100644
--- a/reproman/interface/run.py
+++ b/reproman/interface/run.py
@@ -11,6 +11,7 @@
 
 from argparse import REMAINDER
 import collections
+from collections.abc import Mapping
 import glob
 import logging
 import itertools
@@ -56,7 +57,7 @@ def _combine_job_specs(specs):
         Taken from https://stackoverflow.com/a/3233356
         """
         for k, v in u.items():
-            if isinstance(v, collections.Mapping):
+            if isinstance(v, Mapping):
                 d[k] = update(d.get(k, {}), v)
             else:
                 d[k] = v
diff --git a/reproman/resource/docker_container.py b/reproman/resource/docker_container.py
index 080d6e6..d9d0b2e 100644
--- a/reproman/resource/docker_container.py
+++ b/reproman/resource/docker_container.py
@@ -30,6 +30,16 @@ import logging
 lgr = logging.getLogger('reproman.resource.docker_container')
 
 
+def _image_latest_default(image):
+    # Given the restricted character set for names, the presence of ":" or "@"
+    # should be a reliable indication of a tag or digest, respectively. See
+    # - https://docs.docker.com/engine/reference/commandline/tag/#extended-description
+    # - vendor/github.com/docker/distribution/reference/regexp.go
+    if ":" not in image and "@" not in image:
+        image += ":latest"
+    return image
+
+
 @attr.s
 class DockerContainer(Resource):
     """
@@ -43,8 +53,10 @@ class DockerContainer(Resource):
     id = attrib()
     type = attrib(default='docker-container')
 
-    image = attrib(default='ubuntu:latest',
-        doc="Docker base image ID from which to create the running instance")
+    image = attrib(
+        default='ubuntu:latest',
+        doc="Docker base image ID from which to create the running instance",
+        converter=_image_latest_default)
     engine_url = attrib(default='unix:///var/run/docker.sock',
         doc="Docker server URL where engine is listening for connections")
     seccomp_unconfined = attrib(default=False,
diff --git a/reproman/utils.py b/reproman/utils.py
index 2b435b4..ca84aba 100644
--- a/reproman/utils.py
+++ b/reproman/utils.py
@@ -7,6 +7,7 @@
 # ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
 
 import collections
+from collections.abc import Mapping
 import re
 
 import builtins
@@ -1431,7 +1432,7 @@ def parse_kv_list(params):
     ------
     ValueError if item in `params` does not match expected "key=value" format.
     """
-    if isinstance(params, collections.Mapping):
+    if isinstance(params, Mapping):
         res = params
     elif params:
         def check_fmt(item):


Test patch:
diff --git a/reproman/resource/tests/test_docker_container.py b/reproman/resource/tests/test_docker_container.py
index 1a9b5a7..3e7238b 100644
--- a/reproman/resource/tests/test_docker_container.py
+++ b/reproman/resource/tests/test_docker_container.py
@@ -175,3 +175,12 @@ def test_container_exists(setup_ubuntu):
     from ..docker_container import DockerContainer
     assert DockerContainer.is_container_running(setup_ubuntu['name'])
     assert not DockerContainer.is_container_running('foo')
+
+
+@mark.skipif_no_docker_dependencies
+def test_image_name_latest_default():
+    from ..docker_container import DockerContainer
+    for img, expected in [("debian:buster", "debian:buster"),
+                          ("busybox@ddeeaa", "busybox@ddeeaa"),
+                          ("busybox", "busybox:latest")]:
+        assert DockerContainer(name="cname", image=img).image == expected