From ba505bf5a84f03500cd19f0c485b5592f3ce3f5d Mon Sep 17 00:00:00 2001 From: Khushiyant Date: Fri, 28 Mar 2025 19:19:08 +0530 Subject: [PATCH 1/4] feat: add platform parameter to image push method and corresponding test Signed-off-by: Khushiyant --- docker/api/image.py | 10 +++++++++- tests/unit/api_image_test.py | 23 +++++++++++++++++++++++ tests/unit/fake_api.py | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docker/api/image.py b/docker/api/image.py index 85109473bc..3c101fd57a 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -434,7 +434,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 +448,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 +489,13 @@ 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' + ) + params['platform'] = platform + response = self._post_json( u, None, headers=headers, stream=stream, params=params ) diff --git a/tests/unit/api_image_test.py b/tests/unit/api_image_test.py index 148109d37e..2e567293f7 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,28 @@ 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 + ) + 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..fd9936709b 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -21,6 +21,7 @@ FAKE_SECRET_NAME = 'super_secret' FAKE_CONFIG_ID = 'sekvs771242jfdjnvfuds8232' FAKE_CONFIG_NAME = 'super_config' +FAKE_PLATFORM = "{'os': 'linux','architecture': 'arm','variant': 'v5'}" # Each method is prefixed with HTTP method (get, post...) # for clarity and readability From 1cc084a6a816da8209f71c2a95e71257b75466ad Mon Sep 17 00:00:00 2001 From: Khushiyant Date: Mon, 30 Jun 2025 13:26:34 +0200 Subject: [PATCH 2/4] feat: introduce Platform class instead of JSON Signed-off-by: Khushiyant --- docker/api/image.py | 3 ++- docker/types/image.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docker/types/image.py diff --git a/docker/api/image.py b/docker/api/image.py index 3c101fd57a..eab0a771ce 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__) @@ -494,7 +495,7 @@ def push(self, repository, tag=None, stream=False, auth_config=None, raise errors.InvalidVersion( 'platform was only introduced in API version 1.46' ) - params['platform'] = platform + params['platform'] = Platform response = self._post_json( u, None, headers=headers, stream=stream, params=params diff --git a/docker/types/image.py b/docker/types/image.py new file mode 100644 index 0000000000..2533c0ec86 --- /dev/null +++ b/docker/types/image.py @@ -0,0 +1,35 @@ +from .base import DictType + + +class Platform(DictType): + def __init__(self, **kwargs): + architecture = kwargs.get('architecture', kwargs.get('Architecture')) + os = kwargs.get('os', kwargs.get('OS')) + + if architecture is None and os is None: + raise ValueError("At least one of 'architecture' or 'os' must be provided") + + + super().__init__({ + 'Architecture': architecture, + 'OS': os, + 'OSVersion': kwargs.get('os_version', kwargs.get('OSVersion')), + 'OSFeatures': kwargs.get('os_features', kwargs.get('OSFeatures')), + 'Variant': kwargs.get('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 From 3f89d1e7d393de2a95a712f1f6429988a7fd016e Mon Sep 17 00:00:00 2001 From: Khushiyant Date: Mon, 10 Nov 2025 13:29:56 +0100 Subject: [PATCH 3/4] fix: Platform--instance packing problem Signed-off-by: Khushiyant --- docker/api/image.py | 5 ++++- docker/types/__init__.py | 1 + docker/types/image.py | 22 +++++++++++----------- tests/unit/fake_api.py | 4 +++- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docker/api/image.py b/docker/api/image.py index eab0a771ce..4fdb89bea6 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -495,7 +495,10 @@ def push(self, repository, tag=None, stream=False, auth_config=None, raise errors.InvalidVersion( 'platform was only introduced in API version 1.46' ) - params['platform'] = Platform + # 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 index 2533c0ec86..9b4282bde1 100644 --- a/docker/types/image.py +++ b/docker/types/image.py @@ -3,33 +3,33 @@ class Platform(DictType): def __init__(self, **kwargs): - architecture = kwargs.get('architecture', kwargs.get('Architecture')) - os = kwargs.get('os', kwargs.get('OS')) + architecture = kwargs.get('architecture') + os = kwargs.get('os') if architecture is None and os is None: raise ValueError("At least one of 'architecture' or 'os' must be provided") super().__init__({ - 'Architecture': architecture, - 'OS': os, - 'OSVersion': kwargs.get('os_version', kwargs.get('OSVersion')), - 'OSFeatures': kwargs.get('os_features', kwargs.get('OSFeatures')), - 'Variant': kwargs.get('variant', kwargs.get('Variant')) + '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'] + return self['architecture'] @property def os(self): - return self['OS'] + return self['os'] @architecture.setter def architecture(self, value): - self['Architecture'] = value + self['architecture'] = value @os.setter def os(self, value): - self['OS'] = value + self['os'] = value diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index fd9936709b..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 @@ -21,7 +22,8 @@ FAKE_SECRET_NAME = 'super_secret' FAKE_CONFIG_ID = 'sekvs771242jfdjnvfuds8232' FAKE_CONFIG_NAME = 'super_config' -FAKE_PLATFORM = "{'os': 'linux','architecture': 'arm','variant': 'v5'}" + +FAKE_PLATFORM = Platform(os='linux', architecture='arm', variant='v5') # Each method is prefixed with HTTP method (get, post...) # for clarity and readability From 3f6152bf64c1cd6020a723cb00ef676129b9f4db Mon Sep 17 00:00:00 2001 From: Khushiyant Date: Mon, 10 Nov 2025 21:54:49 +0100 Subject: [PATCH 4/4] fix: update validation to require both 'architecture' and 'os' in Platform class; add test for pushing image with platform dict Signed-off-by: Khushiyant --- docker/types/image.py | 5 ++--- tests/unit/api_image_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docker/types/image.py b/docker/types/image.py index 9b4282bde1..a89f57c921 100644 --- a/docker/types/image.py +++ b/docker/types/image.py @@ -6,9 +6,8 @@ def __init__(self, **kwargs): architecture = kwargs.get('architecture') os = kwargs.get('os') - if architecture is None and os is None: - raise ValueError("At least one of 'architecture' or 'os' must be provided") - + if architecture is None or os is None: + raise ValueError("Both 'architecture' and 'os' must be provided") super().__init__({ 'architecture': architecture, diff --git a/tests/unit/api_image_test.py b/tests/unit/api_image_test.py index 2e567293f7..db3a1a456c 100644 --- a/tests/unit/api_image_test.py +++ b/tests/unit/api_image_test.py @@ -294,6 +294,33 @@ def test_push_image_with_platform(self): 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):