Repository: docker/docker-py

Issue:
`Image.history()` returned wrong type.
At the document, image.history() return `str` type, but it return `list` type.

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 127d5b68..628c5350 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
             - uses: actions/setup-python@v4
               with:
                   python-version: '3.x'
-            - run: pip install -U ruff==0.0.284
+            - run: pip install -U ruff==0.1.8
             - name: Run ruff
               run: ruff docker tests
 
diff --git a/docker/api/image.py b/docker/api/image.py
index 5e1466ec..85109473 100644
--- a/docker/api/image.py
+++ b/docker/api/image.py
@@ -47,7 +47,7 @@ class ImageApiMixin:
             image (str): The image to show history for
 
         Returns:
-            (str): The history of the image
+            (list): The history of the image
 
         Raises:
             :py:class:`docker.errors.APIError`
diff --git a/docker/models/configs.py b/docker/models/configs.py
index 3588c8b5..5ef13778 100644
--- a/docker/models/configs.py
+++ b/docker/models/configs.py
@@ -30,6 +30,7 @@ class ConfigCollection(Collection):
 
     def create(self, **kwargs):
         obj = self.client.api.create_config(**kwargs)
+        obj.setdefault("Spec", {})["Name"] = kwargs.get("name")
         return self.prepare_model(obj)
     create.__doc__ = APIClient.create_config.__doc__
 
diff --git a/docker/models/containers.py b/docker/models/containers.py
index 4725d6f6..32676bb8 100644
--- a/docker/models/containers.py
+++ b/docker/models/containers.py
@@ -903,9 +903,9 @@ class ContainerCollection(Collection):
                 container, exit_status, command, image, out
             )
 
-        return out if stream or out is None else b''.join(
-            [line for line in out]
-        )
+        if stream or out is None:
+            return out
+        return b''.join(out)
 
     def create(self, image, command=None, **kwargs):
         """
diff --git a/docker/models/images.py b/docker/models/images.py
index b4777d8d..4f058d24 100644
--- a/docker/models/images.py
+++ b/docker/models/images.py
@@ -51,7 +51,7 @@ class Image(Model):
         Show the history of an image.
 
         Returns:
-            (str): The history of the image.
+            (list): The history of the image.
 
         Raises:
             :py:class:`docker.errors.APIError`
diff --git a/docker/utils/build.py b/docker/utils/build.py
index a5c4b0c2..86a4423f 100644
--- a/docker/utils/build.py
+++ b/docker/utils/build.py
@@ -10,8 +10,9 @@ from ..constants import IS_WINDOWS_PLATFORM
 
 _SEP = re.compile('/|\\\\') if IS_WINDOWS_PLATFORM else re.compile('/')
 _TAG = re.compile(
-    r"^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*" \
-        + "(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?$"
+    r"^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*"
+    r"(?::[0-9]+)?(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*"
+    r"(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?$"
 )
 
 
diff --git a/docker/utils/socket.py b/docker/utils/socket.py
index 2306ed07..c7cb584d 100644
--- a/docker/utils/socket.py
+++ b/docker/utils/socket.py
@@ -64,7 +64,7 @@ def read_exactly(socket, n):
     Reads exactly n bytes from socket
     Raises SocketError if there isn't enough data
     """
-    data = bytes()
+    data = b""
     while len(data) < n:
         next_data = read(socket, n - len(data))
         if not next_data:
@@ -152,7 +152,7 @@ def consume_socket_output(frames, demux=False):
     if demux is False:
         # If the streams are multiplexed, the generator returns strings, that
         # we just need to concatenate.
-        return bytes().join(frames)
+        return b"".join(frames)
 
     # If the streams are demultiplexed, the generator yields tuples
     # (stdout, stderr)
diff --git a/docker/utils/utils.py b/docker/utils/utils.py
index 759ddd2f..efe9f9a3 100644
--- a/docker/utils/utils.py
+++ b/docker/utils/utils.py
@@ -5,7 +5,7 @@ import os
 import os.path
 import shlex
 import string
-from datetime import datetime
+from datetime import datetime, timezone
 from packaging.version import Version
 
 from .. import errors
@@ -152,7 +152,7 @@ def convert_volume_binds(binds):
             ]
             if 'propagation' in v and v['propagation'] in propagation_modes:
                 if mode:
-                    mode = ','.join([mode, v['propagation']])
+                    mode = f"{mode},{v['propagation']}"
                 else:
                     mode = v['propagation']
 
@@ -394,8 +394,8 @@ def convert_filters(filters):
 
 
 def datetime_to_timestamp(dt):
-    """Convert a UTC datetime to a Unix timestamp"""
-    delta = dt - datetime.utcfromtimestamp(0)
+    """Convert a datetime to a Unix timestamp"""
+    delta = dt.astimezone(timezone.utc) - datetime(1970, 1, 1, tzinfo=timezone.utc)
     return delta.seconds + delta.days * 24 * 3600
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 0a672796..a64e120e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,14 +5,16 @@ requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
 write_to = 'docker/_version.py'
 
 [tool.ruff]
-target-version = "py37"
+target-version = "py38"
 extend-select = [
     "B",
     "C",
     "F",
+    "UP",
     "W",
 ]
 ignore = [
+    "UP012",  # unnecessary `UTF-8` argument (we want to be explicit)
     "C901",  # too complex (there's a whole bunch of these)
 ]
 


Test patch:
diff --git a/test-requirements.txt b/test-requirements.txt
index 031d0acf..2c0e3622 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,6 +1,6 @@
 setuptools==65.5.1
 coverage==7.2.7
-ruff==0.0.284
+ruff==0.1.8
 pytest==7.4.2
 pytest-cov==4.1.0
 pytest-timeout==2.1.0
diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py
index 2add2d87..540ef2b0 100644
--- a/tests/integration/api_build_test.py
+++ b/tests/integration/api_build_test.py
@@ -389,9 +389,7 @@ class BuildTest(BaseAPIIntegrationTest):
         lines = []
         for chunk in stream:
             lines.append(chunk.get('stream'))
-        expected = '{0}{2}\n{1}'.format(
-            control_chars[0], control_chars[1], snippet
-        )
+        expected = f'{control_chars[0]}{snippet}\n{control_chars[1]}'
         assert any(line == expected for line in lines)
 
     def test_build_gzip_encoding(self):
diff --git a/tests/ssh/api_build_test.py b/tests/ssh/api_build_test.py
index d060f465..3b542994 100644
--- a/tests/ssh/api_build_test.py
+++ b/tests/ssh/api_build_test.py
@@ -380,9 +380,7 @@ class BuildTest(BaseAPIIntegrationTest):
         lines = []
         for chunk in stream:
             lines.append(chunk.get('stream'))
-        expected = '{0}{2}\n{1}'.format(
-            control_chars[0], control_chars[1], snippet
-        )
+        expected = f'{control_chars[0]}{snippet}\n{control_chars[1]}'
         assert any(line == expected for line in lines)
 
     def test_build_gzip_encoding(self):
diff --git a/tests/unit/api_test.py b/tests/unit/api_test.py
index 7bc2ea8c..0ca9bbb9 100644
--- a/tests/unit/api_test.py
+++ b/tests/unit/api_test.py
@@ -82,7 +82,7 @@ def fake_delete(self, url, *args, **kwargs):
 
 
 def fake_read_from_socket(self, response, stream, tty=False, demux=False):
-    return bytes()
+    return b''
 
 
 url_base = f'{fake_api.prefix}/'
diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py
index 0524becd..03e53cc6 100644
--- a/tests/unit/fake_api.py
+++ b/tests/unit/fake_api.py
@@ -19,6 +19,8 @@ FAKE_VOLUME_NAME = 'perfectcherryblossom'
 FAKE_NODE_ID = '24ifsmvkjbyhk'
 FAKE_SECRET_ID = 'epdyrw4tsi03xy3deu8g8ly6o'
 FAKE_SECRET_NAME = 'super_secret'
+FAKE_CONFIG_ID = 'sekvs771242jfdjnvfuds8232'
+FAKE_CONFIG_NAME = 'super_config'
 
 # Each method is prefixed with HTTP method (get, post...)
 # for clarity and readability
@@ -512,6 +514,11 @@ def post_fake_secret():
     response = {'ID': FAKE_SECRET_ID}
     return status_code, response
 
+def post_fake_config():
+    status_code = 200
+    response = {'ID': FAKE_CONFIG_ID}
+    return status_code, response
+
 
 # Maps real api url to fake response callback
 prefix = 'http+docker://localhost'
@@ -630,4 +637,6 @@ fake_responses = {
     post_fake_network_disconnect,
     f'{prefix}/{CURRENT_VERSION}/secrets/create':
     post_fake_secret,
+    f'{prefix}/{CURRENT_VERSION}/configs/create':
+    post_fake_config,
 }
diff --git a/tests/unit/fake_api_client.py b/tests/unit/fake_api_client.py
index 95cf63b4..79799421 100644
--- a/tests/unit/fake_api_client.py
+++ b/tests/unit/fake_api_client.py
@@ -37,6 +37,7 @@ def make_fake_api_client(overrides=None):
         'create_host_config.side_effect': api_client.create_host_config,
         'create_network.return_value': fake_api.post_fake_network()[1],
         'create_secret.return_value': fake_api.post_fake_secret()[1],
+        'create_config.return_value': fake_api.post_fake_config()[1],
         'exec_create.return_value': fake_api.post_fake_exec_create()[1],
         'exec_start.return_value': fake_api.post_fake_exec_start()[1],
         'images.return_value': fake_api.get_fake_images()[1],
diff --git a/tests/unit/models_configs_test.py b/tests/unit/models_configs_test.py
new file mode 100644
index 00000000..6960397f
--- /dev/null
+++ b/tests/unit/models_configs_test.py
@@ -0,0 +1,10 @@
+import unittest
+
+from .fake_api_client import make_fake_client
+from .fake_api import FAKE_CONFIG_NAME
+
+class CreateConfigsTest(unittest.TestCase):
+    def test_create_config(self):
+        client = make_fake_client()
+        config = client.configs.create(name="super_config", data="config")
+        assert config.__repr__() == "<Config: '{}'>".format(FAKE_CONFIG_NAME)
diff --git a/tests/unit/utils_build_test.py b/tests/unit/utils_build_test.py
index fa7d833d..5f1bb1ec 100644
--- a/tests/unit/utils_build_test.py
+++ b/tests/unit/utils_build_test.py
@@ -6,11 +6,10 @@ import tarfile
 import tempfile
 import unittest
 
+import pytest
 
 from docker.constants import IS_WINDOWS_PLATFORM
-from docker.utils import exclude_paths, tar
-
-import pytest
+from docker.utils import exclude_paths, tar, match_tag
 
 from ..helpers import make_tree
 
@@ -489,3 +488,51 @@ class TarTest(unittest.TestCase):
                 assert member in names
             assert 'a/c/b' in names
             assert 'a/c/b/utils.py' not in names
+
+
+# selected test cases from https://github.com/distribution/reference/blob/8507c7fcf0da9f570540c958ea7b972c30eeaeca/reference_test.go#L13-L328
+@pytest.mark.parametrize("tag,expected", [
+    ("test_com", True),
+    ("test.com:tag", True),
+    # N.B. this implicitly means "docker.io/library/test.com:5000"
+    # i.e. the `5000` is a tag, not a port here!
+    ("test.com:5000", True),
+    ("test.com/repo:tag", True),
+    ("test:5000/repo", True),
+    ("test:5000/repo:tag", True),
+    ("test:5000/repo", True),
+    ("", False),
+    (":justtag", False),
+    ("Uppercase:tag", False),
+    ("test:5000/Uppercase/lowercase:tag", False),
+    ("lowercase:Uppercase", True),
+    # length limits not enforced
+    pytest.param("a/"*128 + "a:tag", False, marks=pytest.mark.xfail),
+    ("a/"*127 + "a:tag-puts-this-over-max", True),
+    ("aa/asdf$$^/aa", False),
+    ("sub-dom1.foo.com/bar/baz/quux", True),
+    ("sub-dom1.foo.com/bar/baz/quux:some-long-tag", True),
+    ("b.gcr.io/test.example.com/my-app:test.example.com", True),
+    ("xn--n3h.com/myimage:xn--n3h.com", True),
+    ("foo_bar.com:8080", True),
+    ("foo/foo_bar.com:8080", True),
+    ("192.168.1.1", True),
+    ("192.168.1.1:tag", True),
+    ("192.168.1.1:5000", True),
+    ("192.168.1.1/repo", True),
+    ("192.168.1.1:5000/repo", True),
+    ("192.168.1.1:5000/repo:5050", True),
+    # regex does not properly handle ipv6
+    pytest.param("[2001:db8::1]", False, marks=pytest.mark.xfail),
+    ("[2001:db8::1]:5000", False),
+    pytest.param("[2001:db8::1]/repo", True, marks=pytest.mark.xfail),
+    pytest.param("[2001:db8:1:2:3:4:5:6]/repo:tag", True, marks=pytest.mark.xfail),
+    pytest.param("[2001:db8::1]:5000/repo", True, marks=pytest.mark.xfail),
+    pytest.param("[2001:db8::1]:5000/repo:tag", True, marks=pytest.mark.xfail),
+    pytest.param("[2001:db8::]:5000/repo", True, marks=pytest.mark.xfail),
+    pytest.param("[::1]:5000/repo", True, marks=pytest.mark.xfail),
+    ("[fe80::1%eth0]:5000/repo", False),
+    ("[fe80::1%@invalidzone]:5000/repo", False),
+])
+def test_match_tag(tag: str, expected: bool):
+    assert match_tag(tag) == expected