Skip to content

Commit 55ceef5

Browse files
PENG-7708-python-sdk-zoneexport (#145)
* Add zone export functionality - Add export() method to REST API and Zone class - Add unit test for zone export - Add simple usage example Allows customers to export zones in BIND format for backup/migration. * zone name * ruff formatting * Fix black formatting for zone export code * Add put and get endpoints in sdk * Address PR review feedback - rename methods, consolidate export logic, update docs and version to 0.28.0 * Fix black formatting for zones.py * Update unit tests to use renamed zone export methods * add error handling, update status check to COMPLETED, update copyright * fixed the api endpoint * lint format * black format * fix err messages * copyright update * err handling * Removed unused ResourceException import * remove irrelevant commnds
1 parent 0f1655e commit 55ceef5

6 files changed

Lines changed: 196 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.29.0 (July 15th, 2026)
2+
3+
ENHANCEMENTS:
4+
* Add zone export functionality to export zones in BIND format for backup/migration
5+
16
## 0.28.0 (June 8th, 2026)
27

38
ENHANCEMENTS:

examples/zone-export.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#
2+
# Copyright IBM Corp. 2026
3+
#
4+
# License under The MIT License (MIT). See LICENSE in project root.
5+
#
6+
7+
from ns1 import NS1
8+
9+
# NS1 will use config in ~/.nsone by default
10+
api = NS1()
11+
12+
# to specify an apikey here instead, use:
13+
# api = NS1(apiKey='<<CLEARTEXT API KEY>>')
14+
15+
# to load an alternate configuration file:
16+
# api = NS1(configFile='/etc/ns1/api.json')
17+
18+
# Define the zone to export
19+
zone_name = "example.com"
20+
21+
# Export a zone to BIND format
22+
# The export() method will:
23+
# 1. Initiate the export job
24+
# 2. Poll the status until complete or failed
25+
# 3. Download and return the zone file content
26+
zone = api.loadZone(zone_name)
27+
28+
print(f"Exporting zone {zone_name}...")
29+
zone_file = zone.export()
30+
print("Export complete!")
31+
print(zone_file)
32+
33+
# Save to a file
34+
with open("example.com.txt", "w") as f:
35+
f.write(zone_file)
36+
print("Zone file saved to example.com.txt")

ns1/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#
2-
# Copyright (c) 2014, 2026 NSONE, Inc.
2+
# Copyright IBM Corp. 2014, 2026
33
#
44
# License under The MIT License (MIT). See LICENSE in project root.
55
#
66
from .config import Config
77

8-
version = "0.28.0"
8+
version = "0.29.0"
99

1010

1111
class NS1:

ns1/rest/zones.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# Copyright (c) 2014 NSONE, Inc.
2+
# Copyright IBM Corp. 2014, 2026
33
#
44
# License under The MIT License (MIT). See LICENSE in project root.
55
#
@@ -192,6 +192,50 @@ def delete_version(self, zone, version_id, callback=None, errback=None):
192192
errback=errback,
193193
)
194194

195+
def initiate_zonefile_export(self, zone, callback=None, errback=None):
196+
"""
197+
Initiate zone export job.
198+
199+
:param str zone: zone name
200+
:return: export status response
201+
"""
202+
return self._make_request(
203+
"PUT",
204+
f"export/zonefile/{zone}",
205+
body={},
206+
callback=callback,
207+
errback=errback,
208+
)
209+
210+
def status_zonefile_export(self, zone, callback=None, errback=None):
211+
"""
212+
Check zone export status.
213+
214+
:param str zone: zone name
215+
:return: export status response
216+
"""
217+
return self._make_request(
218+
"GET",
219+
f"export/zonefile/{zone}/status",
220+
callback=callback,
221+
errback=errback,
222+
)
223+
224+
def get_zonefile_export(self, zone, callback=None, errback=None):
225+
"""
226+
Download the exported zone file in BIND-compatible format.
227+
228+
:param str zone: zone name
229+
:return: zone file content as string
230+
"""
231+
return self._make_request(
232+
"GET",
233+
f"export/zonefile/{zone}",
234+
callback=callback,
235+
errback=errback,
236+
skip_json_parsing=True,
237+
)
238+
195239

196240
# successive pages just extend the list of zones
197241
def zone_list_pagination(curr_json, next_json):

ns1/zones.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#
2-
# Copyright (c) 2014 NSONE, Inc.
2+
# Copyright IBM Corp. 2014, 2026
33
#
44
# License under The MIT License (MIT). See LICENSE in project root.
55
#
6+
import time
7+
68
from ns1.rest.zones import Zones
79
from ns1.records import Record
810
from ns1.rest.stats import Stats
@@ -125,15 +127,15 @@ def success(result, *args):
125127
callback=success,
126128
errback=errback,
127129
name=name,
128-
**kwargs
130+
**kwargs,
129131
)
130132
else:
131133
return self._rest.create(
132134
self.zone,
133135
callback=success,
134136
errback=errback,
135137
name=name,
136-
**kwargs
138+
**kwargs,
137139
)
138140

139141
def __getattr__(self, item):
@@ -171,7 +173,7 @@ def linkRecord(
171173
rtype,
172174
callback=None,
173175
errback=None,
174-
**kwargs
176+
**kwargs,
175177
):
176178
"""
177179
Create a new linked record in this zone. These records use the
@@ -196,7 +198,7 @@ def linkRecord(
196198
link=existing_domain,
197199
callback=callback,
198200
errback=errback,
199-
**kwargs
201+
**kwargs,
200202
)
201203

202204
def cloneRecord(
@@ -289,3 +291,51 @@ def usage(self, callback=None, errback=None, **kwargs):
289291
return stats.usage(
290292
zone=self.zone, callback=callback, errback=errback, **kwargs
291293
)
294+
295+
def export(
296+
self, callback=None, errback=None, timeout=300, poll_interval=2
297+
):
298+
"""
299+
Export zone as a BIND-compatible zone file.
300+
301+
This method initiates the export, polls the status until complete or failed,
302+
and downloads the zone file.
303+
304+
:param callback: optional callback
305+
:param errback: optional error callback
306+
:param int timeout: maximum time to wait for export completion in seconds (default: 300)
307+
:param int poll_interval: time between status checks in seconds (default: 2)
308+
:return: zone file content as string
309+
:raises ZoneException: if export fails or times out
310+
"""
311+
# Initiate the export
312+
init_response = self._rest.initiate_zonefile_export(self.zone)
313+
if not init_response or init_response.get("status") == "FAILED":
314+
error_msg = init_response.get(
315+
"message", "Failed to initiate export"
316+
)
317+
raise ZoneException(f"Zone export initiation failed: {error_msg}")
318+
319+
# Poll the status until complete or failed
320+
start_time = time.time()
321+
while True:
322+
if time.time() - start_time > timeout:
323+
raise ZoneException(
324+
f"Zone export timed out after {timeout} seconds"
325+
)
326+
327+
status_response = self._rest.status_zonefile_export(self.zone)
328+
status = status_response.get("status")
329+
330+
if status == "COMPLETED":
331+
break
332+
elif status == "FAILED":
333+
error_msg = status_response.get("message", "Unknown error")
334+
raise ZoneException(f"Zone export failed: {error_msg}")
335+
336+
time.sleep(poll_interval)
337+
338+
# Download the zone file
339+
return self._rest.get_zonefile_export(
340+
self.zone, callback=callback, errback=errback
341+
)

tests/unit/test_zone.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
#
2+
# Copyright IBM Corp. 2014, 2026
3+
#
4+
# License under The MIT License (MIT). See LICENSE in project root.
5+
#
6+
17
import ns1.rest.zones
28
import pytest
39
import os
@@ -251,3 +257,50 @@ def test_rest_zone_buildbody(zones_config):
251257
"tags": {"foo": "bar", "hai": "bai"},
252258
}
253259
assert z._buildBody(zone, **kwargs) == body
260+
261+
262+
@pytest.mark.parametrize(
263+
"zone, url", [("test.zone", "export/zonefile/test.zone")]
264+
)
265+
def test_rest_zone_get_zonefile_export(zones_config, zone, url):
266+
z = ns1.rest.zones.Zones(zones_config)
267+
z._make_request = mock.MagicMock()
268+
z.get_zonefile_export(zone)
269+
z._make_request.assert_called_once_with(
270+
"GET",
271+
url,
272+
callback=None,
273+
errback=None,
274+
skip_json_parsing=True,
275+
)
276+
277+
278+
@pytest.mark.parametrize(
279+
"zone, url", [("test.zone", "export/zonefile/test.zone")]
280+
)
281+
def test_rest_zone_initiate_zonefile_export(zones_config, zone, url):
282+
z = ns1.rest.zones.Zones(zones_config)
283+
z._make_request = mock.MagicMock()
284+
z.initiate_zonefile_export(zone)
285+
z._make_request.assert_called_once_with(
286+
"PUT",
287+
url,
288+
body={},
289+
callback=None,
290+
errback=None,
291+
)
292+
293+
294+
@pytest.mark.parametrize(
295+
"zone, url", [("test.zone", "export/zonefile/test.zone/status")]
296+
)
297+
def test_rest_zone_status_zonefile_export(zones_config, zone, url):
298+
z = ns1.rest.zones.Zones(zones_config)
299+
z._make_request = mock.MagicMock()
300+
z.status_zonefile_export(zone)
301+
z._make_request.assert_called_once_with(
302+
"GET",
303+
url,
304+
callback=None,
305+
errback=None,
306+
)

0 commit comments

Comments
 (0)