diff --git a/docker/api/image.py b/docker/api/image.py index 85109473bc..4fdb89bea6 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -3,6 +3,7 @@ from .. import auth, errors, utils from ..constants import DEFAULT_DATA_CHUNK_SIZE +from ..types.image import Platform log = logging.getLogger(__name__) @@ -434,7 +435,7 @@ def pull(self, repository, tag=None, stream=False, auth_config=None, return self._result(response) def push(self, repository, tag=None, stream=False, auth_config=None, - decode=False): + decode=False, platform=None): """ Push an image or a repository to the registry. Similar to the ``docker push`` command. @@ -448,6 +449,7 @@ def push(self, repository, tag=None, stream=False, auth_config=None, ``username`` and ``password`` keys to be valid. decode (bool): Decode the JSON data from the server into dicts. Only applies with ``stream=True`` + platform (str): JSON-encoded OCI platform to select the platform-variant to push. If not provided, all available variants will attempt to be pushed. Returns: (generator or str): The output from the server. @@ -488,6 +490,16 @@ def push(self, repository, tag=None, stream=False, auth_config=None, log.debug('Sending supplied auth config') headers['X-Registry-Auth'] = auth.encode_header(auth_config) + if platform is not None: + if utils.version_lt(self._version, '1.46'): + raise errors.InvalidVersion( + 'platform was only introduced in API version 1.46' + ) + # Handle both Platform instances and dict inputs + if isinstance(platform, dict): + platform = Platform(**platform) + params['platform'] = platform + response = self._post_json( u, None, headers=headers, stream=stream, params=params ) diff --git a/docker/types/__init__.py b/docker/types/__init__.py index fbe247210b..4ce479b811 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -1,6 +1,7 @@ from .containers import ContainerConfig, DeviceRequest, HostConfig, LogConfig, Ulimit from .daemon import CancellableStream from .healthcheck import Healthcheck +from .image import Platform from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .services import ( ConfigReference, diff --git a/docker/types/image.py b/docker/types/image.py new file mode 100644 index 0000000000..a89f57c921 --- /dev/null +++ b/docker/types/image.py @@ -0,0 +1,34 @@ +from .base import DictType + + +class Platform(DictType): + def __init__(self, **kwargs): + architecture = kwargs.get('architecture') + os = kwargs.get('os') + + if architecture is None or os is None: + raise ValueError("Both 'architecture' and 'os' must be provided") + + super().__init__({ + 'architecture': architecture, + 'os': os, + 'os_version': kwargs.get('os_version'), + 'os_features': kwargs.get('os_features'), + 'variant': kwargs.get('variant') + }) + + @property + def architecture(self): + return self['architecture'] + + @property + def os(self): + return self['os'] + + @architecture.setter + def architecture(self, value): + self['architecture'] = value + + @os.setter + def os(self, value): + self['os'] = value diff --git a/tests/unit/api_image_test.py b/tests/unit/api_image_test.py index 148109d37e..db3a1a456c 100644 --- a/tests/unit/api_image_test.py +++ b/tests/unit/api_image_test.py @@ -5,6 +5,7 @@ import docker from docker import auth +from ..helpers import requires_api_version from . import fake_api from .api_test import ( DEFAULT_TIMEOUT_SECONDS, @@ -271,6 +272,55 @@ def test_push_image_with_auth(self): timeout=DEFAULT_TIMEOUT_SECONDS ) + @requires_api_version('1.46') + def test_push_image_with_platform(self): + with mock.patch('docker.auth.resolve_authconfig', + fake_resolve_authconfig): + self.client.push( + fake_api.FAKE_IMAGE_NAME, + platform=fake_api.FAKE_PLATFORM + ) + + fake_request.assert_called_with( + 'POST', + f"{url_prefix}images/test_image/push", + params={ + 'tag': None, + 'platform': fake_api.FAKE_PLATFORM + }, + data='{}', + headers={'Content-Type': 'application/json'}, + stream=False, + timeout=DEFAULT_TIMEOUT_SECONDS + ) + + @requires_api_version('1.46') + def test_push_image_with_platform_dict(self): + platform_dict = {'os': 'linux', 'architecture': 'arm', 'variant': 'v7'} + with mock.patch('docker.auth.resolve_authconfig', + fake_resolve_authconfig): + self.client.push( + fake_api.FAKE_IMAGE_NAME, + platform=platform_dict + ) + + # When passed as dict, it should be converted to Platform instance + from docker.types.image import Platform + expected_platform = Platform(**platform_dict) + + fake_request.assert_called_with( + 'POST', + f"{url_prefix}images/test_image/push", + params={ + 'tag': None, + 'platform': expected_platform + }, + data='{}', + headers={'Content-Type': 'application/json'}, + stream=False, + timeout=DEFAULT_TIMEOUT_SECONDS + ) + def test_push_image_stream(self): with mock.patch('docker.auth.resolve_authconfig', fake_resolve_authconfig): diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index 03e53cc648..9fb8a50ba3 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -1,4 +1,5 @@ from docker import constants +from docker.types.image import Platform from . import fake_stat @@ -22,6 +23,8 @@ FAKE_CONFIG_ID = 'sekvs771242jfdjnvfuds8232' FAKE_CONFIG_NAME = 'super_config' +FAKE_PLATFORM = Platform(os='linux', architecture='arm', variant='v5') + # Each method is prefixed with HTTP method (get, post...) # for clarity and readability