From 9d7c48b96f1fb6da9c89cfa0336019f80b1bacd6 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 17 Dec 2025 16:10:49 +0000 Subject: [PATCH 01/16] 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. --- examples/zone-export.py | 27 +++++++++++++++++++++++++++ ns1/rest/zones.py | 14 ++++++++++++++ ns1/zones.py | 12 ++++++++++++ tests/unit/test_zone.py | 14 ++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 examples/zone-export.py diff --git a/examples/zone-export.py b/examples/zone-export.py new file mode 100644 index 0000000..9bf5491 --- /dev/null +++ b/examples/zone-export.py @@ -0,0 +1,27 @@ +# +# Copyright (c) 2014 NSONE, Inc. +# +# License under The MIT License (MIT). See LICENSE in project root. +# + +from ns1 import NS1 + +# NS1 will use config in ~/.nsone by default +api = NS1() + +# to specify an apikey here instead, use: +# api = NS1(apiKey='<>') + +# to load an alternate configuration file: +# api = NS1(configFile='/etc/ns1/api.json') + +# export a zone to BIND format +zone = api.loadZone("example.com") +zone_file = zone.export() +print(zone_file) + +# save to a file +with open("example.com.zone", "w") as f: + f.write(zone_file) + +# Made with Bob diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 07f01fe..37c774f 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -188,6 +188,20 @@ def delete_version(self, zone, version_id, callback=None, errback=None): errback=errback, ) + def export(self, zone, callback=None, errback=None): + """ + Export zone as BIND-compatible zone file. + + :param str zone: zone name + :return: zone file content as string + """ + return self._make_request( + "GET", + f"{self.ROOT}/{zone}/export", + callback=callback, + errback=errback, + ) + # successive pages just extend the list of zones def zone_list_pagination(curr_json, next_json): diff --git a/ns1/zones.py b/ns1/zones.py index 3e5a8d8..5bcf0c1 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -287,3 +287,15 @@ def usage(self, callback=None, errback=None, **kwargs): return stats.usage( zone=self.zone, callback=callback, errback=errback, **kwargs ) + + def export(self, callback=None, errback=None): + """ + Export zone as a BIND-compatible zone file. + + :param callback: optional callback + :param errback: optional error callback + :return: zone file content as string + """ + return self._rest.export( + self.zone, callback=callback, errback=errback + ) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 5dae71d..97b7014 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -251,3 +251,17 @@ def test_rest_zone_buildbody(zones_config): "tags": {"foo": "bar", "hai": "bai"}, } assert z._buildBody(zone, **kwargs) == body + + + +@pytest.mark.parametrize("zone, url", [("test.zone", "zones/test.zone/export")]) +def test_rest_zone_export(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.export(zone) + z._make_request.assert_called_once_with( + "GET", + url, + callback=None, + errback=None, + ) From 12d4ad16e36e15e05edf9a9883c97d09bb808f10 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 17 Dec 2025 16:22:33 +0000 Subject: [PATCH 02/16] zone name --- examples/zone-export.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/zone-export.py b/examples/zone-export.py index 9bf5491..c907964 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright (c) 2025 NSONE, Inc. # # License under The MIT License (MIT). See LICENSE in project root. # @@ -24,4 +24,3 @@ with open("example.com.zone", "w") as f: f.write(zone_file) -# Made with Bob From 9d1500d199d8b4abc66abdbbc08fdd5e2de94bbb Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 17 Dec 2025 16:29:23 +0000 Subject: [PATCH 03/16] ruff formatting --- examples/zone-export.py | 1 - ns1/rest/zones.py | 2 +- ns1/zones.py | 2 +- tests/unit/test_zone.py | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/zone-export.py b/examples/zone-export.py index c907964..3c2a2b5 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -23,4 +23,3 @@ # save to a file with open("example.com.zone", "w") as f: f.write(zone_file) - diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 37c774f..a76e169 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -191,7 +191,7 @@ def delete_version(self, zone, version_id, callback=None, errback=None): def export(self, zone, callback=None, errback=None): """ Export zone as BIND-compatible zone file. - + :param str zone: zone name :return: zone file content as string """ diff --git a/ns1/zones.py b/ns1/zones.py index 5bcf0c1..e032aed 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -291,7 +291,7 @@ def usage(self, callback=None, errback=None, **kwargs): def export(self, callback=None, errback=None): """ Export zone as a BIND-compatible zone file. - + :param callback: optional callback :param errback: optional error callback :return: zone file content as string diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 97b7014..1b1dda2 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -253,7 +253,6 @@ def test_rest_zone_buildbody(zones_config): assert z._buildBody(zone, **kwargs) == body - @pytest.mark.parametrize("zone, url", [("test.zone", "zones/test.zone/export")]) def test_rest_zone_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) From f0d21b12b3ba71a52f1ed7c779b62f40f5ce67d2 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 17 Dec 2025 16:35:25 +0000 Subject: [PATCH 04/16] Fix black formatting for zone export code --- ns1/zones.py | 4 +--- tests/unit/test_zone.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ns1/zones.py b/ns1/zones.py index e032aed..1bd42ef 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -296,6 +296,4 @@ def export(self, callback=None, errback=None): :param errback: optional error callback :return: zone file content as string """ - return self._rest.export( - self.zone, callback=callback, errback=errback - ) + return self._rest.export(self.zone, callback=callback, errback=errback) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 1b1dda2..92dbb2b 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -253,7 +253,9 @@ def test_rest_zone_buildbody(zones_config): assert z._buildBody(zone, **kwargs) == body -@pytest.mark.parametrize("zone, url", [("test.zone", "zones/test.zone/export")]) +@pytest.mark.parametrize( + "zone, url", [("test.zone", "zones/test.zone/export")] +) def test_rest_zone_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() From c2b75da1fe8812ae6a20e682e0abe6bf594f28f4 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Tue, 6 Jan 2026 12:20:31 +0100 Subject: [PATCH 05/16] Add put and get endpoints in sdk --- ns1/rest/zones.py | 29 +++++++++++++++++++++++++++++ ns1/zones.py | 24 ++++++++++++++++++++++++ tests/unit/test_zone.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index a76e169..a9a2038 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -202,6 +202,35 @@ def export(self, zone, callback=None, errback=None): errback=errback, ) + def initiate_export(self, zone, callback=None, errback=None): + """ + Initiate zone export job. + + :param str zone: zone name + :return: export status response + """ + return self._make_request( + "PUT", + f"export/zonefile/{zone}", + body={}, + callback=callback, + errback=errback, + ) + + def export_status(self, zone, callback=None, errback=None): + """ + Check zone export status. + + :param str zone: zone name + :return: export status response + """ + return self._make_request( + "GET", + f"export/zonefile/{zone}/status", + callback=callback, + errback=errback, + ) + # successive pages just extend the list of zones def zone_list_pagination(curr_json, next_json): diff --git a/ns1/zones.py b/ns1/zones.py index 1bd42ef..4d1cdaf 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -297,3 +297,27 @@ def export(self, callback=None, errback=None): :return: zone file content as string """ return self._rest.export(self.zone, callback=callback, errback=errback) + + def initiate_export(self, callback=None, errback=None): + """ + Initiate zone export job. + + :param callback: optional callback + :param errback: optional error callback + :return: export status response + """ + return self._rest.initiate_export( + self.zone, callback=callback, errback=errback + ) + + def export_status(self, callback=None, errback=None): + """ + Check zone export status. + + :param callback: optional callback + :param errback: optional error callback + :return: export status response + """ + return self._rest.export_status( + self.zone, callback=callback, errback=errback + ) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 92dbb2b..c623e90 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -266,3 +266,34 @@ def test_rest_zone_export(zones_config, zone, url): callback=None, errback=None, ) + + +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone")] +) +def test_rest_zone_initiate_export(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.initiate_export(zone) + z._make_request.assert_called_once_with( + "PUT", + url, + body={}, + callback=None, + errback=None, + ) + + +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone/status")] +) +def test_rest_zone_export_status(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.export_status(zone) + z._make_request.assert_called_once_with( + "GET", + url, + callback=None, + errback=None, + ) From 2e7ecacb9616b4bfbf0418d7e565beef886ae1f4 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 7 Jan 2026 16:18:05 +0100 Subject: [PATCH 06/16] Address PR review feedback - rename methods, consolidate export logic, update docs and version to 0.28.0 --- CHANGELOG.md | 5 ++++ examples/zone-export.py | 16 ++++++++++--- ns1/__init__.py | 2 +- ns1/rest/zones.py | 28 +++++++++++----------- ns1/zones.py | 53 +++++++++++++++++++++++++---------------- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71dfa6e..ef2ba06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.28.0 (TBD) + +ENHANCEMENTS: +* Add zone export functionality to export zones in BIND format for backup/migration + ## 0.27.1 (December 3rd, 2025) FIXED: diff --git a/examples/zone-export.py b/examples/zone-export.py index 3c2a2b5..f618153 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -15,11 +15,21 @@ # to load an alternate configuration file: # api = NS1(configFile='/etc/ns1/api.json') -# export a zone to BIND format +# Export a zone to BIND format +# The export() method will: +# 1. Initiate the export job +# 2. Poll the status until complete or failed +# 3. Download and return the zone file content zone = api.loadZone("example.com") + +print("Exporting zone example.com...") zone_file = zone.export() +print("Export complete!") print(zone_file) -# save to a file -with open("example.com.zone", "w") as f: +# Save to a file +with open("example.com.txt", "w") as f: f.write(zone_file) +print("Zone file saved to example.com.txt") + +# Made with Bob diff --git a/ns1/__init__.py b/ns1/__init__.py index 9dbfb06..3956ff4 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -5,7 +5,7 @@ # from .config import Config -version = "0.27.1" +version = "0.28.0" class NS1: diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index a9a2038..ab24098 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -188,45 +188,45 @@ def delete_version(self, zone, version_id, callback=None, errback=None): errback=errback, ) - def export(self, zone, callback=None, errback=None): + def initiate_zonefile_export(self, zone, callback=None, errback=None): """ - Export zone as BIND-compatible zone file. + Initiate zone export job. :param str zone: zone name - :return: zone file content as string + :return: export status response """ return self._make_request( - "GET", - f"{self.ROOT}/{zone}/export", + "PUT", + f"export/zonefile/{zone}", + body={}, callback=callback, errback=errback, ) - def initiate_export(self, zone, callback=None, errback=None): + def status_zonefile_export(self, zone, callback=None, errback=None): """ - Initiate zone export job. + Check zone export status. :param str zone: zone name :return: export status response """ return self._make_request( - "PUT", - f"export/zonefile/{zone}", - body={}, + "GET", + f"export/zonefile/{zone}/status", callback=callback, errback=errback, ) - def export_status(self, zone, callback=None, errback=None): + def get_zonefile_export(self, zone, callback=None, errback=None): """ - Check zone export status. + Download the exported zone file in BIND-compatible format. :param str zone: zone name - :return: export status response + :return: zone file content as string """ return self._make_request( "GET", - f"export/zonefile/{zone}/status", + f"{self.ROOT}/{zone}/export", callback=callback, errback=errback, ) diff --git a/ns1/zones.py b/ns1/zones.py index 4d1cdaf..508dcfa 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -288,36 +288,49 @@ def usage(self, callback=None, errback=None, **kwargs): zone=self.zone, callback=callback, errback=errback, **kwargs ) - def export(self, callback=None, errback=None): + def export(self, callback=None, errback=None, timeout=300, poll_interval=2): """ Export zone as a BIND-compatible zone file. + + This method initiates the export, polls the status until complete or failed, + and downloads the zone file. :param callback: optional callback :param errback: optional error callback + :param int timeout: maximum time to wait for export completion in seconds (default: 300) + :param int poll_interval: time between status checks in seconds (default: 2) :return: zone file content as string + :raises ZoneException: if export fails or times out """ - return self._rest.export(self.zone, callback=callback, errback=errback) + import time - def initiate_export(self, callback=None, errback=None): - """ - Initiate zone export job. + # Initiate the export + self._rest.initiate_zonefile_export(self.zone) - :param callback: optional callback - :param errback: optional error callback - :return: export status response - """ - return self._rest.initiate_export( - self.zone, callback=callback, errback=errback - ) + # Poll the status until complete or failed + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise ZoneException( + f"Zone export timed out after {timeout} seconds" + ) - def export_status(self, callback=None, errback=None): - """ - Check zone export status. + status_response = self._rest.status_zonefile_export(self.zone) + status = status_response.get("status") - :param callback: optional callback - :param errback: optional error callback - :return: export status response - """ - return self._rest.export_status( + if status == "COMPLETE": + break + elif status == "FAILED": + error_msg = status_response.get("message", "Unknown error") + raise ZoneException(f"Zone export failed: {error_msg}") + + time.sleep(poll_interval) + + # Download the zone file + zone_file = self._rest.get_zonefile_export( self.zone, callback=callback, errback=errback ) + + if callback: + return callback(zone_file) + return zone_file From ccb60003ee96546f03b21cf07b32fdc3fe5f4f08 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 7 Jan 2026 16:24:44 +0100 Subject: [PATCH 07/16] Fix black formatting for zones.py --- ns1/zones.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ns1/zones.py b/ns1/zones.py index 508dcfa..134fb0b 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -123,7 +123,7 @@ def success(result, *args): callback=success, errback=errback, name=name, - **kwargs + **kwargs, ) else: return self._rest.create( @@ -131,7 +131,7 @@ def success(result, *args): callback=success, errback=errback, name=name, - **kwargs + **kwargs, ) def __getattr__(self, item): @@ -169,7 +169,7 @@ def linkRecord( rtype, callback=None, errback=None, - **kwargs + **kwargs, ): """ Create a new linked record in this zone. These records use the @@ -194,7 +194,7 @@ def linkRecord( link=existing_domain, callback=callback, errback=errback, - **kwargs + **kwargs, ) def cloneRecord( @@ -288,10 +288,12 @@ def usage(self, callback=None, errback=None, **kwargs): zone=self.zone, callback=callback, errback=errback, **kwargs ) - def export(self, callback=None, errback=None, timeout=300, poll_interval=2): + def export( + self, callback=None, errback=None, timeout=300, poll_interval=2 + ): """ Export zone as a BIND-compatible zone file. - + This method initiates the export, polls the status until complete or failed, and downloads the zone file. @@ -330,7 +332,7 @@ def export(self, callback=None, errback=None, timeout=300, poll_interval=2): zone_file = self._rest.get_zonefile_export( self.zone, callback=callback, errback=errback ) - + if callback: return callback(zone_file) return zone_file From ca1896b440b094a00bdb8c9b4959d316a127f8de Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Wed, 7 Jan 2026 17:17:28 +0100 Subject: [PATCH 08/16] Update unit tests to use renamed zone export methods --- tests/unit/test_zone.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index c623e90..43faa44 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -256,10 +256,10 @@ def test_rest_zone_buildbody(zones_config): @pytest.mark.parametrize( "zone, url", [("test.zone", "zones/test.zone/export")] ) -def test_rest_zone_export(zones_config, zone, url): +def test_rest_zone_get_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() - z.export(zone) + z.get_zonefile_export(zone) z._make_request.assert_called_once_with( "GET", url, @@ -271,10 +271,10 @@ def test_rest_zone_export(zones_config, zone, url): @pytest.mark.parametrize( "zone, url", [("test.zone", "export/zonefile/test.zone")] ) -def test_rest_zone_initiate_export(zones_config, zone, url): +def test_rest_zone_initiate_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() - z.initiate_export(zone) + z.initiate_zonefile_export(zone) z._make_request.assert_called_once_with( "PUT", url, @@ -287,10 +287,10 @@ def test_rest_zone_initiate_export(zones_config, zone, url): @pytest.mark.parametrize( "zone, url", [("test.zone", "export/zonefile/test.zone/status")] ) -def test_rest_zone_export_status(zones_config, zone, url): +def test_rest_zone_status_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() - z.export_status(zone) + z.status_zonefile_export(zone) z._make_request.assert_called_once_with( "GET", url, From c04cadf8f4bb5b0551268fb992dc1a3ed1a7d0c2 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Thu, 8 Jan 2026 14:39:23 +0100 Subject: [PATCH 09/16] add error handling, update status check to COMPLETED, update copyright --- examples/zone-export.py | 4 +--- ns1/zones.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/examples/zone-export.py b/examples/zone-export.py index f618153..ed14a54 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025 NSONE, Inc. +# Copyright (c) 2026 NSONE, Inc. # # License under The MIT License (MIT). See LICENSE in project root. # @@ -31,5 +31,3 @@ with open("example.com.txt", "w") as f: f.write(zone_file) print("Zone file saved to example.com.txt") - -# Made with Bob diff --git a/ns1/zones.py b/ns1/zones.py index 134fb0b..49f4985 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -3,6 +3,8 @@ # # License under The MIT License (MIT). See LICENSE in project root. # +import time + from ns1.rest.zones import Zones from ns1.records import Record from ns1.rest.stats import Stats @@ -304,10 +306,13 @@ def export( :return: zone file content as string :raises ZoneException: if export fails or times out """ - import time - # Initiate the export - self._rest.initiate_zonefile_export(self.zone) + init_response = self._rest.initiate_zonefile_export(self.zone) + if not init_response or init_response.get("status") == "FAILED": + error_msg = init_response.get( + "message", "Failed to initiate export" + ) + raise ZoneException(f"Zone export initiation failed: {error_msg}") # Poll the status until complete or failed start_time = time.time() @@ -320,7 +325,7 @@ def export( status_response = self._rest.status_zonefile_export(self.zone) status = status_response.get("status") - if status == "COMPLETE": + if status == "COMPLETED": break elif status == "FAILED": error_msg = status_response.get("message", "Unknown error") @@ -329,10 +334,6 @@ def export( time.sleep(poll_interval) # Download the zone file - zone_file = self._rest.get_zonefile_export( + return self._rest.get_zonefile_export( self.zone, callback=callback, errback=errback ) - - if callback: - return callback(zone_file) - return zone_file From 8cb5012643e12e59ba67ff76dbee62dab83dac99 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Thu, 8 Jan 2026 16:27:20 +0100 Subject: [PATCH 10/16] fixed the api endpoint --- ns1/rest/zones.py | 28 ++++++++++++++++++++++------ tests/unit/test_zone.py | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index ab24098..ca2a1c2 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -224,12 +224,28 @@ def get_zonefile_export(self, zone, callback=None, errback=None): :param str zone: zone name :return: zone file content as string """ - return self._make_request( - "GET", - f"{self.ROOT}/{zone}/export", - callback=callback, - errback=errback, - ) + # Note: This endpoint returns raw zone file text, not JSON + # The transport layer will try to parse it as JSON and fail + # We catch that exception and extract the raw body text + from ns1.rest.errors import ResourceException + + try: + return self._make_request( + "GET", + f"export/zonefile/{zone}", + callback=callback, + errback=errback, + ) + except ResourceException as e: + # If it's about invalid JSON, that's expected - extract the body + if "invalid json in response" in str(e): + # The body is the third argument in ResourceException + if hasattr(e, 'args') and len(e.args) >= 3: + body = e.args[2] + if callback: + return callback(body) + return body + raise # successive pages just extend the list of zones diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 43faa44..9aaeebc 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -254,7 +254,7 @@ def test_rest_zone_buildbody(zones_config): @pytest.mark.parametrize( - "zone, url", [("test.zone", "zones/test.zone/export")] + "zone, url", [("test.zone", "export/zonefile/test.zone")] ) def test_rest_zone_get_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) From 421d382155a0449dd095099b40dfd8e4e86d61d3 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Thu, 8 Jan 2026 16:31:52 +0100 Subject: [PATCH 11/16] lint format --- ns1/__init__.py | 22 ++++------------------ ns1/rest/zones.py | 16 +++++----------- ns1/zones.py | 32 ++++++++------------------------ tests/unit/test_zone.py | 12 +++--------- 4 files changed, 20 insertions(+), 62 deletions(-) diff --git a/ns1/__init__.py b/ns1/__init__.py index 3956ff4..278c125 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -36,9 +36,7 @@ def _loadConfig(self, apiKey, configFile): if apiKey: self.config.createFromAPIKey(apiKey) else: - configFile = ( - Config.DEFAULT_CONFIG_FILE if not configFile else configFile - ) + configFile = Config.DEFAULT_CONFIG_FILE if not configFile else configFile self.config.loadFromFile(configFile) # REST INTERFACE @@ -300,13 +298,7 @@ def searchZone( return rest_zone.search(query, type, expand, max, callback, errback) def createZone( - self, - zone, - zoneFile=None, - callback=None, - errback=None, - name=None, - **kwargs + self, zone, zoneFile=None, callback=None, errback=None, name=None, **kwargs ): """ Create a new zone, and return an associated high level Zone object. @@ -331,11 +323,7 @@ def createZone( zone = ns1.zones.Zone(self.config, zone) return zone.create( - zoneFile=zoneFile, - name=name, - callback=callback, - errback=errback, - **kwargs + zoneFile=zoneFile, name=name, callback=callback, errback=errback, **kwargs ) def loadRecord( @@ -367,9 +355,7 @@ def loadRecord( zone = ".".join(parts[1:]) z = ns1.zones.Zone(self.config, zone) - return z.loadRecord( - domain, type, callback=callback, errback=errback, **kwargs - ) + return z.loadRecord(domain, type, callback=callback, errback=errback, **kwargs) def loadMonitors(self, callback=None, errback=None, **kwargs): """ diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index ca2a1c2..ac04427 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -36,9 +36,7 @@ def _buildBody(self, zone, **kwargs): self._buildStdBody(body, kwargs) return body - def import_file( - self, zone, zoneFile, callback=None, errback=None, **kwargs - ): + def import_file(self, zone, zoneFile, callback=None, errback=None, **kwargs): files = [("zonefile", (zoneFile, open(zoneFile, "rb"), "text/plain"))] params = self._buildImportParams(kwargs) return self._make_request( @@ -147,9 +145,7 @@ def list_versions(self, zone, callback=None, errback=None): errback=errback, ) - def create_version( - self, zone, force=False, callback=None, errback=None, name=None - ): + def create_version(self, zone, force=False, callback=None, errback=None, name=None): if name is None: name = zone body = {} @@ -167,9 +163,7 @@ def create_version( ) def activate_version(self, zone, version_id, callback=None, errback=None): - request = "{}/{}/versions/{}/activate".format( - self.ROOT, zone, str(version_id) - ) + request = "{}/{}/versions/{}/activate".format(self.ROOT, zone, str(version_id)) return self._make_request( "POST", request, @@ -228,7 +222,7 @@ def get_zonefile_export(self, zone, callback=None, errback=None): # The transport layer will try to parse it as JSON and fail # We catch that exception and extract the raw body text from ns1.rest.errors import ResourceException - + try: return self._make_request( "GET", @@ -240,7 +234,7 @@ def get_zonefile_export(self, zone, callback=None, errback=None): # If it's about invalid JSON, that's expected - extract the body if "invalid json in response" in str(e): # The body is the third argument in ResourceException - if hasattr(e, 'args') and len(e.args) >= 3: + if hasattr(e, "args") and len(e.args) >= 3: body = e.args[2] if callback: return callback(body) diff --git a/ns1/zones.py b/ns1/zones.py index 49f4985..40b17ab 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -61,9 +61,7 @@ def success(result, *args): else: return self - return self._rest.retrieve( - self.zone, callback=success, errback=errback - ) + return self._rest.retrieve(self.zone, callback=success, errback=errback) def delete(self, callback=None, errback=None): """ @@ -88,13 +86,9 @@ def success(result, *args): else: return self - return self._rest.update( - self.zone, callback=success, errback=errback, **kwargs - ) + return self._rest.update(self.zone, callback=success, errback=errback, **kwargs) - def create( - self, zoneFile=None, callback=None, errback=None, name=None, **kwargs - ): + def create(self, zoneFile=None, callback=None, errback=None, name=None, **kwargs): """ Create a new zone. Pass a list of keywords and their values to configure. For the list of keywords available for zone configuration, @@ -150,9 +144,7 @@ def add_X(domain, answers, callback=None, errback=None, **kwargs): return add_X - def createLinkToSelf( - self, new_zone, callback=None, errback=None, **kwargs - ): + def createLinkToSelf(self, new_zone, callback=None, errback=None, **kwargs): """ Create a new linked zone, linking to ourselves. All records in this zone will then be available as "linked records" in the new zone. @@ -286,13 +278,9 @@ def usage(self, callback=None, errback=None, **kwargs): :return: usage information """ stats = Stats(self.config) - return stats.usage( - zone=self.zone, callback=callback, errback=errback, **kwargs - ) + return stats.usage(zone=self.zone, callback=callback, errback=errback, **kwargs) - def export( - self, callback=None, errback=None, timeout=300, poll_interval=2 - ): + def export(self, callback=None, errback=None, timeout=300, poll_interval=2): """ Export zone as a BIND-compatible zone file. @@ -309,18 +297,14 @@ def export( # Initiate the export init_response = self._rest.initiate_zonefile_export(self.zone) if not init_response or init_response.get("status") == "FAILED": - error_msg = init_response.get( - "message", "Failed to initiate export" - ) + error_msg = init_response.get("message", "Failed to initiate export") raise ZoneException(f"Zone export initiation failed: {error_msg}") # Poll the status until complete or failed start_time = time.time() while True: if time.time() - start_time > timeout: - raise ZoneException( - f"Zone export timed out after {timeout} seconds" - ) + raise ZoneException(f"Zone export timed out after {timeout} seconds") status_response = self._rest.status_zonefile_export(self.zone) status = status_response.get("status") diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 9aaeebc..59e3347 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -53,9 +53,7 @@ def test_rest_zone_retrieve(zones_config, zone, url): ) -@pytest.mark.parametrize( - "zone, url", [("test.zone", "zones/test.zone/versions")] -) +@pytest.mark.parametrize("zone, url", [("test.zone", "zones/test.zone/versions")]) def test_rest_zone_version_list(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() @@ -253,9 +251,7 @@ def test_rest_zone_buildbody(zones_config): assert z._buildBody(zone, **kwargs) == body -@pytest.mark.parametrize( - "zone, url", [("test.zone", "export/zonefile/test.zone")] -) +@pytest.mark.parametrize("zone, url", [("test.zone", "export/zonefile/test.zone")]) def test_rest_zone_get_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() @@ -268,9 +264,7 @@ def test_rest_zone_get_zonefile_export(zones_config, zone, url): ) -@pytest.mark.parametrize( - "zone, url", [("test.zone", "export/zonefile/test.zone")] -) +@pytest.mark.parametrize("zone, url", [("test.zone", "export/zonefile/test.zone")]) def test_rest_zone_initiate_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() From f371731a6bd5b1959ed2c2f10bef086c43be9900 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Thu, 8 Jan 2026 16:40:57 +0100 Subject: [PATCH 12/16] black format --- ns1/__init__.py | 22 ++++++++++++++++++---- ns1/rest/zones.py | 12 +++++++++--- ns1/zones.py | 32 ++++++++++++++++++++++++-------- tests/unit/test_zone.py | 12 +++++++++--- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/ns1/__init__.py b/ns1/__init__.py index 278c125..3956ff4 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -36,7 +36,9 @@ def _loadConfig(self, apiKey, configFile): if apiKey: self.config.createFromAPIKey(apiKey) else: - configFile = Config.DEFAULT_CONFIG_FILE if not configFile else configFile + configFile = ( + Config.DEFAULT_CONFIG_FILE if not configFile else configFile + ) self.config.loadFromFile(configFile) # REST INTERFACE @@ -298,7 +300,13 @@ def searchZone( return rest_zone.search(query, type, expand, max, callback, errback) def createZone( - self, zone, zoneFile=None, callback=None, errback=None, name=None, **kwargs + self, + zone, + zoneFile=None, + callback=None, + errback=None, + name=None, + **kwargs ): """ Create a new zone, and return an associated high level Zone object. @@ -323,7 +331,11 @@ def createZone( zone = ns1.zones.Zone(self.config, zone) return zone.create( - zoneFile=zoneFile, name=name, callback=callback, errback=errback, **kwargs + zoneFile=zoneFile, + name=name, + callback=callback, + errback=errback, + **kwargs ) def loadRecord( @@ -355,7 +367,9 @@ def loadRecord( zone = ".".join(parts[1:]) z = ns1.zones.Zone(self.config, zone) - return z.loadRecord(domain, type, callback=callback, errback=errback, **kwargs) + return z.loadRecord( + domain, type, callback=callback, errback=errback, **kwargs + ) def loadMonitors(self, callback=None, errback=None, **kwargs): """ diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index ac04427..260cb2c 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -36,7 +36,9 @@ def _buildBody(self, zone, **kwargs): self._buildStdBody(body, kwargs) return body - def import_file(self, zone, zoneFile, callback=None, errback=None, **kwargs): + def import_file( + self, zone, zoneFile, callback=None, errback=None, **kwargs + ): files = [("zonefile", (zoneFile, open(zoneFile, "rb"), "text/plain"))] params = self._buildImportParams(kwargs) return self._make_request( @@ -145,7 +147,9 @@ def list_versions(self, zone, callback=None, errback=None): errback=errback, ) - def create_version(self, zone, force=False, callback=None, errback=None, name=None): + def create_version( + self, zone, force=False, callback=None, errback=None, name=None + ): if name is None: name = zone body = {} @@ -163,7 +167,9 @@ def create_version(self, zone, force=False, callback=None, errback=None, name=No ) def activate_version(self, zone, version_id, callback=None, errback=None): - request = "{}/{}/versions/{}/activate".format(self.ROOT, zone, str(version_id)) + request = "{}/{}/versions/{}/activate".format( + self.ROOT, zone, str(version_id) + ) return self._make_request( "POST", request, diff --git a/ns1/zones.py b/ns1/zones.py index 40b17ab..49f4985 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -61,7 +61,9 @@ def success(result, *args): else: return self - return self._rest.retrieve(self.zone, callback=success, errback=errback) + return self._rest.retrieve( + self.zone, callback=success, errback=errback + ) def delete(self, callback=None, errback=None): """ @@ -86,9 +88,13 @@ def success(result, *args): else: return self - return self._rest.update(self.zone, callback=success, errback=errback, **kwargs) + return self._rest.update( + self.zone, callback=success, errback=errback, **kwargs + ) - def create(self, zoneFile=None, callback=None, errback=None, name=None, **kwargs): + def create( + self, zoneFile=None, callback=None, errback=None, name=None, **kwargs + ): """ Create a new zone. Pass a list of keywords and their values to configure. For the list of keywords available for zone configuration, @@ -144,7 +150,9 @@ def add_X(domain, answers, callback=None, errback=None, **kwargs): return add_X - def createLinkToSelf(self, new_zone, callback=None, errback=None, **kwargs): + def createLinkToSelf( + self, new_zone, callback=None, errback=None, **kwargs + ): """ Create a new linked zone, linking to ourselves. All records in this zone will then be available as "linked records" in the new zone. @@ -278,9 +286,13 @@ def usage(self, callback=None, errback=None, **kwargs): :return: usage information """ stats = Stats(self.config) - return stats.usage(zone=self.zone, callback=callback, errback=errback, **kwargs) + return stats.usage( + zone=self.zone, callback=callback, errback=errback, **kwargs + ) - def export(self, callback=None, errback=None, timeout=300, poll_interval=2): + def export( + self, callback=None, errback=None, timeout=300, poll_interval=2 + ): """ Export zone as a BIND-compatible zone file. @@ -297,14 +309,18 @@ def export(self, callback=None, errback=None, timeout=300, poll_interval=2): # Initiate the export init_response = self._rest.initiate_zonefile_export(self.zone) if not init_response or init_response.get("status") == "FAILED": - error_msg = init_response.get("message", "Failed to initiate export") + error_msg = init_response.get( + "message", "Failed to initiate export" + ) raise ZoneException(f"Zone export initiation failed: {error_msg}") # Poll the status until complete or failed start_time = time.time() while True: if time.time() - start_time > timeout: - raise ZoneException(f"Zone export timed out after {timeout} seconds") + raise ZoneException( + f"Zone export timed out after {timeout} seconds" + ) status_response = self._rest.status_zonefile_export(self.zone) status = status_response.get("status") diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 59e3347..9aaeebc 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -53,7 +53,9 @@ def test_rest_zone_retrieve(zones_config, zone, url): ) -@pytest.mark.parametrize("zone, url", [("test.zone", "zones/test.zone/versions")]) +@pytest.mark.parametrize( + "zone, url", [("test.zone", "zones/test.zone/versions")] +) def test_rest_zone_version_list(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() @@ -251,7 +253,9 @@ def test_rest_zone_buildbody(zones_config): assert z._buildBody(zone, **kwargs) == body -@pytest.mark.parametrize("zone, url", [("test.zone", "export/zonefile/test.zone")]) +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone")] +) def test_rest_zone_get_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() @@ -264,7 +268,9 @@ def test_rest_zone_get_zonefile_export(zones_config, zone, url): ) -@pytest.mark.parametrize("zone, url", [("test.zone", "export/zonefile/test.zone")]) +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone")] +) def test_rest_zone_initiate_zonefile_export(zones_config, zone, url): z = ns1.rest.zones.Zones(zones_config) z._make_request = mock.MagicMock() From 5161c1d01edf56cf4ef9d0ad722b7f81dc736165 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Mon, 12 Jan 2026 09:24:26 +0000 Subject: [PATCH 13/16] fix err messages --- examples/zone-export.py | 12 ++++++++---- ns1/rest/zones.py | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/examples/zone-export.py b/examples/zone-export.py index ed14a54..8636280 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -15,19 +15,23 @@ # to load an alternate configuration file: # api = NS1(configFile='/etc/ns1/api.json') +# Define the zone to export +zone_name = "example.com" + # Export a zone to BIND format # The export() method will: # 1. Initiate the export job # 2. Poll the status until complete or failed # 3. Download and return the zone file content -zone = api.loadZone("example.com") +zone = api.loadZone(zone_name) -print("Exporting zone example.com...") +print(f"Exporting zone {zone_name}...") zone_file = zone.export() print("Export complete!") print(zone_file) # Save to a file -with open("example.com.txt", "w") as f: +output_file = f"{zone_name}.txt" +with open(output_file, "w") as f: f.write(zone_file) -print("Zone file saved to example.com.txt") +print(f"Zone file saved to {output_file}") diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 260cb2c..9c7ea0f 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -5,6 +5,7 @@ # from . import resource +from .errors import ResourceException class Zones(resource.BaseResource): @@ -227,8 +228,6 @@ def get_zonefile_export(self, zone, callback=None, errback=None): # Note: This endpoint returns raw zone file text, not JSON # The transport layer will try to parse it as JSON and fail # We catch that exception and extract the raw body text - from ns1.rest.errors import ResourceException - try: return self._make_request( "GET", @@ -237,14 +236,15 @@ def get_zonefile_export(self, zone, callback=None, errback=None): errback=errback, ) except ResourceException as e: - # If it's about invalid JSON, that's expected - extract the body - if "invalid json in response" in str(e): - # The body is the third argument in ResourceException - if hasattr(e, "args") and len(e.args) >= 3: - body = e.args[2] - if callback: - return callback(body) - return body + # Check if this is a valid zonefile response (plain text) + # The response should be 200 OK with text/plain content + if e.response and e.response.getcode() == 200: + # Check content-type header for text/plain + content_type = e.response.getheader("Content-Type", "") + if "text/plain" in content_type or "text" in content_type: + # This is the expected plain text zonefile + return e.body if e.body else "" + # Otherwise, this is a real error - re-raise it raise From f7d72517214a257ea6513b107b617fe32fa7886c Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Mon, 12 Jan 2026 14:25:21 +0000 Subject: [PATCH 14/16] copyright update --- examples/zone-export.py | 2 +- ns1/__init__.py | 2 +- ns1/rest/zones.py | 30 ++++++++---------------------- ns1/zones.py | 2 +- tests/unit/test_zone.py | 6 ++++++ 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/examples/zone-export.py b/examples/zone-export.py index 8636280..5c100c2 100644 --- a/examples/zone-export.py +++ b/examples/zone-export.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2026 NSONE, Inc. +# Copyright IBM Corp. 2026 # # License under The MIT License (MIT). See LICENSE in project root. # diff --git a/ns1/__init__.py b/ns1/__init__.py index 3956ff4..f6eedc2 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014, 2025 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 9c7ea0f..5cd9f26 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # @@ -225,27 +225,13 @@ def get_zonefile_export(self, zone, callback=None, errback=None): :param str zone: zone name :return: zone file content as string """ - # Note: This endpoint returns raw zone file text, not JSON - # The transport layer will try to parse it as JSON and fail - # We catch that exception and extract the raw body text - try: - return self._make_request( - "GET", - f"export/zonefile/{zone}", - callback=callback, - errback=errback, - ) - except ResourceException as e: - # Check if this is a valid zonefile response (plain text) - # The response should be 200 OK with text/plain content - if e.response and e.response.getcode() == 200: - # Check content-type header for text/plain - content_type = e.response.getheader("Content-Type", "") - if "text/plain" in content_type or "text" in content_type: - # This is the expected plain text zonefile - return e.body if e.body else "" - # Otherwise, this is a real error - re-raise it - raise + return self._make_request( + "GET", + f"export/zonefile/{zone}", + callback=callback, + errback=errback, + skip_json_parsing=True, + ) # successive pages just extend the list of zones diff --git a/ns1/zones.py b/ns1/zones.py index 49f4985..174d38c 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 9aaeebc..2879998 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -1,3 +1,9 @@ +# +# Copyright IBM Corp. 2014, 2026 +# +# License under The MIT License (MIT). See LICENSE in project root. +# + import ns1.rest.zones import pytest import os From 477bb68430e0ebc5ff5a540ce1a153b9ded8a0a2 Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Mon, 12 Jan 2026 14:37:47 +0000 Subject: [PATCH 15/16] err handling --- ns1/rest/zones.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 5cd9f26..daf319f 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -5,7 +5,6 @@ # from . import resource -from .errors import ResourceException class Zones(resource.BaseResource): From f411f8b3dd76724d657eecab7d04b3da3afba0dc Mon Sep 17 00:00:00 2001 From: soniafrancisNS1 Date: Mon, 12 Jan 2026 14:42:37 +0000 Subject: [PATCH 16/16] Removed unused ResourceException import --- tests/unit/test_zone.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 2879998..2b5e0a2 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -271,6 +271,7 @@ def test_rest_zone_get_zonefile_export(zones_config, zone, url): url, callback=None, errback=None, + skip_json_parsing=True, )