Skip to content

Commit 3e78a74

Browse files
new: Bring Image-related functionality to API parity (#226)
This change adds missing fields to the `Image` object and adds two new functions: - `client.image_create_upload(...)` - Creates/returns and image and the corresponding upload URL - `client.image_upload(...)` - Takes a BinaryIO stream, creates an image, and uploads to the image. These are all of the necessary changes to bring image-related functionality to API parity.
1 parent 1378ca8 commit 3e78a74

10 files changed

+287
-4
lines changed

linode_api4/linode_client.py

+63
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import time
77
from datetime import datetime
8+
from typing import Tuple, BinaryIO
89

910
import pkg_resources
1011
import requests
@@ -15,6 +16,7 @@
1516

1617
from .common import SSH_KEY_TYPES, load_and_validate_keys
1718
from .paginated_list import PaginatedList
19+
from .util import drop_null_keys
1820

1921
package_version = pkg_resources.require("linode_api4")[0].version
2022

@@ -1618,6 +1620,67 @@ def image_create(self, disk, label=None, description=None):
16181620

16191621
return Image(self, result['id'], result)
16201622

1623+
def image_create_upload(self, label: str, region: str, description: str=None) -> Tuple[Image, str]:
1624+
"""
1625+
Creates a new Image and returns the corresponding upload URL.
1626+
https://www.linode.com/docs/api/images/#image-upload
1627+
1628+
:param label: The label of the Image to create.
1629+
:type label: str
1630+
:param region: The region to upload to. Once the image has been created, it can be used in any region.
1631+
:type region: str
1632+
:param description: The description for the new Image.
1633+
:type description: str
1634+
1635+
:returns: A tuple containing the new image and the image upload URL.
1636+
:rtype: (Image, str)
1637+
"""
1638+
params = {
1639+
"label": label,
1640+
"region": region,
1641+
"description": description
1642+
}
1643+
1644+
result = self.post("/images/upload", data=drop_null_keys(params))
1645+
1646+
if "image" not in result:
1647+
raise UnexpectedResponseError('Unexpected response when creating an '
1648+
'Image upload URL')
1649+
1650+
result_image = result["image"]
1651+
result_url = result["upload_to"]
1652+
1653+
return Image(self, result_image["id"], result_image), result_url
1654+
1655+
def image_upload(self, label: str, region: str, file: BinaryIO, description: str=None) -> Image:
1656+
"""
1657+
Creates and uploads a new image.
1658+
https://www.linode.com/docs/api/images/#image-upload
1659+
1660+
:param label: The label of the Image to create.
1661+
:type label: str
1662+
:param region: The region to upload to. Once the image has been created, it can be used in any region.
1663+
:type region: str
1664+
:param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb").
1665+
:param description: The description for the new Image.
1666+
:type description: str
1667+
1668+
:returns: The resulting image.
1669+
:rtype: Image
1670+
"""
1671+
1672+
image, url = self.image_create_upload(label, region, description=description)
1673+
1674+
requests.put(
1675+
url,
1676+
headers={"Content-Type": "application/octet-stream"},
1677+
data=file,
1678+
)
1679+
1680+
image._api_get()
1681+
1682+
return image
1683+
16211684
def domains(self, *filters):
16221685
"""
16231686
Retrieves all of the Domains the acting user has access to.

linode_api4/objects/image.py

+3
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ class Image(Base):
1111
"id": Property(identifier=True),
1212
"label": Property(mutable=True),
1313
"description": Property(mutable=True),
14+
"eol": Property(is_datetime=True),
15+
"expiry": Property(is_datetime=True),
1416
"status": Property(),
1517
"created": Property(is_datetime=True),
1618
"created_by": Property(),
19+
"updated": Property(is_datetime=True),
1720
"type": Property(),
1821
"is_public": Property(),
1922
"vendor": Property(),

linode_api4/util.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Contains various utility functions.
3+
"""
4+
from typing import Dict, Any
5+
6+
7+
def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]:
8+
"""
9+
Traverses a dict and drops any keys that map to None values.
10+
"""
11+
12+
if not recursive:
13+
return {k: v for k, v in data.items() if v is not None}
14+
15+
def recursive_helper(value: Any) -> Any:
16+
if isinstance(value, dict):
17+
return {k: recursive_helper(v) for k, v in value.items() if v is not None}
18+
19+
if isinstance(value, list):
20+
return [recursive_helper(v) for v in value]
21+
22+
return value
23+
24+
return recursive_helper(data)

requirements-dev.txt

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mock>=5.0.0
2+
tox>=4.4.0
3+
Sphinx>=6.0.0
4+
sphinx-autobuild>=2021.3.14
5+
sphinxcontrib-fulltoc>=1.2.0

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
httplib2
22
enum34
3+
requests

test/fixtures/images.json

+16-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
"size": 1100,
1515
"is_public": true,
1616
"type": "manual",
17-
"vendor": "Debian"
17+
"vendor": "Debian",
18+
"eol": "2026-07-01T04:00:00",
19+
"expiry": "2026-08-01T04:00:00",
20+
"updated": "2020-07-01T04:00:00"
1821
},
1922
{
2023
"created": "2017-01-01T00:01:01",
@@ -27,7 +30,10 @@
2730
"size": 1500,
2831
"is_public": true,
2932
"type": "manual",
30-
"vendor": "Ubuntu"
33+
"vendor": "Ubuntu",
34+
"eol": "2026-07-01T04:00:00",
35+
"expiry": "2026-08-01T04:00:00",
36+
"updated": "2020-07-01T04:00:00"
3137
},
3238
{
3339
"created": "2017-01-01T00:01:01",
@@ -40,7 +46,10 @@
4046
"size": 1500,
4147
"is_public": true,
4248
"type": "manual",
43-
"vendor": "Fedora"
49+
"vendor": "Fedora",
50+
"eol": "2026-07-01T04:00:00",
51+
"expiry": "2026-08-01T04:00:00",
52+
"updated": "2020-07-01T04:00:00"
4453
},
4554
{
4655
"created": "2017-08-20T14:01:01",
@@ -53,7 +62,10 @@
5362
"size": 650,
5463
"is_public": false,
5564
"type": "manual",
56-
"vendor": null
65+
"vendor": null,
66+
"eol": "2026-07-01T04:00:00",
67+
"expiry": "2026-08-01T04:00:00",
68+
"updated": "2020-07-01T04:00:00"
5769
}
5870
]
5971
}
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"created": "2021-08-14T22:44:02",
3+
"created_by": "someone",
4+
"deprecated": false,
5+
"description": "very real image upload.",
6+
"eol": "2026-07-01T04:00:00",
7+
"expiry": null,
8+
"id": "private/1337",
9+
"is_public": false,
10+
"label": "Realest Image Upload",
11+
"size": 2500,
12+
"status": "available",
13+
"type": "manual",
14+
"updated": "2021-08-14T22:44:02",
15+
"vendor": "Debian"
16+
}

test/fixtures/images_upload.json

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"image": {
3+
"created": "2021-08-14T22:44:02",
4+
"created_by": "someone",
5+
"deprecated": false,
6+
"description": "very real image upload.",
7+
"eol": "2026-07-01T04:00:00",
8+
"expiry": null,
9+
"id": "private/1337",
10+
"is_public": false,
11+
"label": "Realest Image Upload",
12+
"size": 2500,
13+
"status": "available",
14+
"type": "manual",
15+
"updated": "2021-08-14T22:44:02",
16+
"vendor": "Debian"
17+
},
18+
"upload_to": "https://linode.com/"
19+
}

test/objects/image_test.py

+74
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
from datetime import datetime
2+
from io import BytesIO
3+
from typing import Any, BinaryIO
4+
from unittest.mock import patch
5+
16
from test.base import ClientBaseCase
27

38
from linode_api4.objects import Image
49

510

11+
# A minimal gzipped image that will be accepted by the API
12+
TEST_IMAGE_CONTENT = (
13+
b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69"
14+
b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00"
15+
)
16+
17+
618
class ImageTest(ClientBaseCase):
719
"""
820
Tests methods of the Image class
@@ -24,3 +36,65 @@ def test_get_image(self):
2436
self.assertEqual(image.type, "manual")
2537
self.assertEqual(image.created_by, "linode")
2638
self.assertEqual(image.size, 1100)
39+
40+
self.assertEqual(image.eol, datetime(
41+
year=2026, month=7, day=1, hour=4, minute=0, second=0
42+
))
43+
44+
self.assertEqual(image.expiry, datetime(
45+
year=2026, month=8, day=1, hour=4, minute=0, second=0
46+
))
47+
48+
self.assertEqual(image.updated, datetime(
49+
year=2020, month=7, day=1, hour=4, minute=0, second=0
50+
))
51+
52+
def test_image_create_upload(self):
53+
"""
54+
Test that an image upload URL can be created successfully.
55+
"""
56+
57+
with self.mock_post("/images/upload") as m:
58+
image, url = self.client.image_create_upload(
59+
"Realest Image Upload",
60+
"us-southeast",
61+
description="very real image upload.",
62+
)
63+
64+
self.assertEqual(m.call_url, "/images/upload")
65+
self.assertEqual(m.method, "post")
66+
self.assertEqual(
67+
m.call_data,
68+
{
69+
"label": "Realest Image Upload",
70+
"region": "us-southeast",
71+
"description": "very real image upload."
72+
}
73+
)
74+
75+
self.assertEqual(image.id, "private/1337")
76+
self.assertEqual(image.label, "Realest Image Upload")
77+
self.assertEqual(image.description, "very real image upload.")
78+
79+
self.assertEqual(url, "https://linode.com/")
80+
81+
def test_image_upload(self):
82+
"""
83+
Test that an image can be uploaded.
84+
"""
85+
86+
def put_mock(url: str, data: BinaryIO = None, **kwargs):
87+
self.assertEqual(url, "https://linode.com/")
88+
self.assertEqual(data.read(), TEST_IMAGE_CONTENT)
89+
90+
with patch("requests.put", put_mock), self.mock_post("/images/upload"):
91+
image = self.client.image_upload(
92+
"Realest Image Upload",
93+
"us-southeast",
94+
BytesIO(TEST_IMAGE_CONTENT),
95+
description="very real image upload.",
96+
)
97+
98+
self.assertEqual(image.id, "private/1337")
99+
self.assertEqual(image.label, "Realest Image Upload")
100+
self.assertEqual(image.description, "very real image upload.")

test/util_test.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import unittest
2+
3+
from linode_api4.util import drop_null_keys
4+
5+
6+
class UtilTest(unittest.TestCase):
7+
"""
8+
Tests for utility functions.
9+
"""
10+
11+
def test_drop_null_keys_nonrecursive(self):
12+
"""
13+
Tests whether a non-recursive drop_null_keys call works as expected.
14+
"""
15+
value = {
16+
"foo": "bar",
17+
"test": None,
18+
"cool": {
19+
"test": "bar",
20+
"cool": None,
21+
}
22+
}
23+
24+
expected_output = {
25+
"foo": "bar",
26+
"cool": {
27+
"test": "bar",
28+
"cool": None
29+
}
30+
}
31+
32+
assert drop_null_keys(value, recursive=False) == expected_output
33+
34+
def test_drop_null_keys_recursive(self):
35+
"""
36+
Tests whether a recursive drop_null_keys call works as expected.
37+
"""
38+
39+
value = {
40+
"foo": "bar",
41+
"test": None,
42+
"cool": {
43+
"test": "bar",
44+
"cool": None,
45+
"list": [
46+
{
47+
"foo": "bar",
48+
"test": None
49+
}
50+
]
51+
}
52+
}
53+
54+
expected_output = {
55+
"foo": "bar",
56+
"cool": {
57+
"test": "bar",
58+
"list": [
59+
{
60+
"foo": "bar",
61+
}
62+
]
63+
}
64+
}
65+
66+
assert drop_null_keys(value) == expected_output

0 commit comments

Comments
 (0)