Skip to content

Commit e3810c1

Browse files
authored
feat(image): add image upload support (#716)
1 parent c70d57b commit e3810c1

File tree

3 files changed

+364
-7
lines changed

3 files changed

+364
-7
lines changed

docs/advanced.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,68 @@ nb = pynetbox.api(
6666
token='d6f4e314a5b5fefd164995169f28ae32d987704f'
6767
)
6868
nb.http_session = session
69-
```
69+
```
70+
71+
# File Uploads (Image Attachments)
72+
73+
Pynetbox supports file uploads for endpoints that accept them, such as image attachments. When you pass a file-like object (anything with a `.read()` method) to `create()`, pynetbox automatically detects it and uses multipart/form-data encoding instead of JSON.
74+
75+
## Creating an Image Attachment
76+
77+
```python
78+
import pynetbox
79+
80+
nb = pynetbox.api(
81+
'http://localhost:8000',
82+
token='d6f4e314a5b5fefd164995169f28ae32d987704f'
83+
)
84+
85+
# Attach an image to a device
86+
with open('/path/to/image.png', 'rb') as f:
87+
attachment = nb.extras.image_attachments.create(
88+
object_type='dcim.device',
89+
object_id=1,
90+
image=f,
91+
name='rack-photo.png'
92+
)
93+
```
94+
95+
## Using io.BytesIO
96+
97+
You can also use in-memory file objects:
98+
99+
```python
100+
import io
101+
import pynetbox
102+
103+
nb = pynetbox.api(
104+
'http://localhost:8000',
105+
token='d6f4e314a5b5fefd164995169f28ae32d987704f'
106+
)
107+
108+
# Create image from bytes
109+
image_data = b'...' # Your image bytes
110+
file_obj = io.BytesIO(image_data)
111+
file_obj.name = 'generated-image.png' # Optional: set filename
112+
113+
attachment = nb.extras.image_attachments.create(
114+
object_type='dcim.device',
115+
object_id=1,
116+
image=file_obj
117+
)
118+
```
119+
120+
## Custom Filename and Content-Type
121+
122+
For more control, pass a tuple instead of a file object:
123+
124+
```python
125+
with open('/path/to/image.png', 'rb') as f:
126+
attachment = nb.extras.image_attachments.create(
127+
object_type='dcim.device',
128+
object_id=1,
129+
image=('custom-name.png', f, 'image/png')
130+
)
131+
```
132+
133+
The tuple format is `(filename, file_object)` or `(filename, file_object, content_type)`.

pynetbox/core/query.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,54 @@
1515
"""
1616

1717
import concurrent.futures as cf
18+
import io
19+
import os
1820
import json
1921

2022
from packaging import version
2123

2224

25+
def _is_file_like(obj):
26+
if isinstance(obj, (str, bytes)):
27+
return False
28+
# Check if it's a standard library IO object OR has a callable read method
29+
return isinstance(obj, io.IOBase) or (
30+
hasattr(obj, "read") and callable(getattr(obj, "read"))
31+
)
32+
33+
34+
def _extract_files(data):
35+
"""Extract file-like objects from data dict.
36+
37+
Returns a tuple of (clean_data, files) where clean_data has file objects
38+
removed and files is a dict suitable for requests' files parameter.
39+
"""
40+
if not isinstance(data, dict):
41+
return data, None
42+
43+
files = {}
44+
clean_data = {}
45+
46+
for key, value in data.items():
47+
if _is_file_like(value):
48+
# Format: (filename, file_obj, content_type)
49+
# Try to get filename from file object, fallback to key
50+
filename = getattr(value, "name", None)
51+
if filename:
52+
# Extract just the filename, not the full path
53+
filename = os.path.basename(filename)
54+
else:
55+
filename = key
56+
files[key] = (filename, value)
57+
elif isinstance(value, tuple) and len(value) >= 2 and _is_file_like(value[1]):
58+
# Already in (filename, file_obj) or (filename, file_obj, content_type) format
59+
files[key] = value
60+
else:
61+
clean_data[key] = value
62+
63+
return clean_data, files if files else None
64+
65+
2366
def calc_pages(limit, count):
2467
"""Calculate number of pages required for full results set."""
2568
return int(count / limit) + (limit % count > 0)
@@ -257,10 +300,23 @@ def normalize_url(self, url):
257300
return url
258301

259302
def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
260-
if verb in ("post", "put") or verb == "delete" and data:
303+
# Extract any file-like objects from data
304+
files = None
305+
# Verbs that support request bodies with file uploads
306+
body_verbs = ("post", "put", "patch")
307+
headers = {"accept": "application/json"}
308+
309+
# Extract files from data for applicable verbs
310+
if data is not None and verb in body_verbs:
311+
data, files = _extract_files(data)
312+
313+
# Set headers based on request type
314+
should_be_json_body = not files and (
315+
verb in body_verbs or (verb == "delete" and data)
316+
)
317+
318+
if should_be_json_body:
261319
headers = {"Content-Type": "application/json"}
262-
else:
263-
headers = {"accept": "application/json"}
264320

265321
if self.token:
266322
headers["authorization"] = "Token {}".format(self.token)
@@ -272,9 +328,19 @@ def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
272328
if add_params:
273329
params.update(add_params)
274330

275-
req = getattr(self.http_session, verb)(
276-
url_override or self.url, headers=headers, params=params, json=data
277-
)
331+
if files:
332+
# Use multipart/form-data for file uploads
333+
req = getattr(self.http_session, verb)(
334+
url_override or self.url,
335+
headers=headers,
336+
params=params,
337+
data=data,
338+
files=files,
339+
)
340+
else:
341+
req = getattr(self.http_session, verb)(
342+
url_override or self.url, headers=headers, params=params, json=data
343+
)
278344

279345
if req.status_code == 409 and verb == "post":
280346
raise AllocationError(req)

0 commit comments

Comments
 (0)