diff --git a/.github/workflows/py3.yml b/.github/workflows/py3.yml index af53b0e1..5a7f7322 100644 --- a/.github/workflows/py3.yml +++ b/.github/workflows/py3.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.10"] - netbox: ["2.11", "3.0", "3.1"] + python: ["3.8", "3.9", "3.10"] + netbox: ["3.3"] steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index 72ec8416..522550c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Pynetbox Python API client library for [NetBox](https://github.com/netbox-community/netbox). +> **Note:** Version 6.7 and later of the library only supports NetBox 3.3 and above. ## Installation diff --git a/pynetbox/core/query.py b/pynetbox/core/query.py index 5ab16e33..af9daf4b 100644 --- a/pynetbox/core/query.py +++ b/pynetbox/core/query.py @@ -57,7 +57,7 @@ def __init__(self, req): ) ) - super().__init__(message) + super().__init__(self.message) self.req = req self.request_body = req.request.body self.base = req.url diff --git a/pynetbox/core/response.py b/pynetbox/core/response.py index 96fcb8fd..85028320 100644 --- a/pynetbox/core/response.py +++ b/pynetbox/core/response.py @@ -354,6 +354,22 @@ def _parse_values(self, values): values within. """ + def generic_list_parser(key_name, list_item): + from pynetbox.models.mapper import CONTENT_TYPE_MAPPER + + if ( + isinstance(list_item, dict) + and "object_type" in list_item + and "object" in list_item + ): + lookup = list_item["object_type"] + model = None + model = CONTENT_TYPE_MAPPER.get(lookup) + if model: + return model(list_item["object"], self.api, self.endpoint) + + return list_item + def list_parser(key_name, list_item): if isinstance(list_item, dict): lookup = getattr(self.__class__, key_name, None) @@ -364,6 +380,7 @@ def list_parser(key_name, list_item): else: model = lookup[0] return model(list_item, self.api, self.endpoint) + return list_item for k, v in values.items(): @@ -382,8 +399,13 @@ def list_parser(key_name, list_item): self._add_cache((k, v)) elif isinstance(v, list): - v = [list_parser(k, i) for i in v] - to_cache = list(v) + # check if GFK + if len(v) and isinstance(v[0], dict) and "object_type" in v[0]: + v = [generic_list_parser(k, i) for i in v] + to_cache = list(v) + else: + v = [list_parser(k, i) for i in v] + to_cache = list(v) self._add_cache((k, to_cache)) else: diff --git a/pynetbox/models/dcim.py b/pynetbox/models/dcim.py index 5b1e2783..43c5dde2 100644 --- a/pynetbox/models/dcim.py +++ b/pynetbox/models/dcim.py @@ -23,6 +23,35 @@ class TraceableRecord(Record): + def _get_obj_class(self, url): + uri_to_obj_class_map = { + "dcim/cables": Cables, + "dcim/front-ports": FrontPorts, + "dcim/interfaces": Interfaces, + "dcim/rear-ports": RearPorts, + } + + # the url for this item will be something like: + # https://netbox/api/dcim/rear-ports/12761/ + # TODO: Move this to a more general function. + app_endpoint = "/".join( + urlsplit(url).path[len(urlsplit(self.api.base_url).path) :].split("/")[1:3] + ) + return uri_to_obj_class_map.get( + app_endpoint, + Record, + ) + + def _build_termination_data(self, termination_list): + terminations_data = [] + for hop_item_data in termination_list: + return_obj_class = self._get_obj_class(hop_item_data["url"]) + terminations_data.append( + return_obj_class(hop_item_data, self.endpoint.api, self.endpoint) + ) + + return terminations_data + def trace(self): req = Request( key=str(self.id) + "/trace", @@ -31,38 +60,18 @@ def trace(self): session_key=self.api.session_key, http_session=self.api.http_session, ).get() - uri_to_obj_class_map = { - "dcim/cables": Cables, - "dcim/front-ports": FrontPorts, - "dcim/interfaces": Interfaces, - "dcim/rear-ports": RearPorts, - } + ret = [] - for (termination_a_data, cable_data, termination_b_data) in req: - this_hop_ret = [] - for hop_item_data in (termination_a_data, cable_data, termination_b_data): - # if not fully terminated then some items will be None - if not hop_item_data: - this_hop_ret.append(hop_item_data) - continue - - # the url for this item will be something like: - # https://netbox/api/dcim/rear-ports/12761/ - # TODO: Move this to a more general function. - app_endpoint = "/".join( - urlsplit(hop_item_data["url"]) - .path[len(urlsplit(self.api.base_url).path) :] - .split("/")[1:3] - ) - return_obj_class = uri_to_obj_class_map.get( - app_endpoint, - Record, - ) - this_hop_ret.append( - return_obj_class(hop_item_data, self.endpoint.api, self.endpoint) + for (a_terminations_data, cable_data, b_terminations_data) in req: + ret.append(self._build_termination_data(a_terminations_data)) + if not cable_data: + ret.append(cable_data) + else: + return_obj_class = self._get_obj_class(cable_data["url"]) + ret.append( + return_obj_class(cable_data, self.endpoint.api, self.endpoint) ) - - ret.append(this_hop_ret) + ret.append(self._build_termination_data(b_terminations_data)) return ret @@ -225,14 +234,6 @@ def __str__(self): class Cables(Record): def __str__(self): - if all( - [ - isinstance(i, Termination) - for i in (self.termination_a, self.termination_b) - ] - ): - return "{} <> {}".format(self.termination_a, self.termination_b) + if len(self.a_terminations) == 1 and len(self.b_terminations) == 1: + return "{} <> {}".format(self.a_terminations[0], self.b_terminations[0]) return "Cable #{}".format(self.id) - - termination_a = Termination - termination_b = Termination diff --git a/pynetbox/models/mapper.py b/pynetbox/models/mapper.py new file mode 100644 index 00000000..a57c07f4 --- /dev/null +++ b/pynetbox/models/mapper.py @@ -0,0 +1,118 @@ +from .circuits import Circuits, CircuitTerminations +from .dcim import ( + DeviceTypes, + Devices, + Interfaces, + PowerOutlets, + PowerPorts, + ConsolePorts, + ConsoleServerPorts, + RackReservations, + VirtualChassis, + FrontPorts, + RearPorts, + Racks, + Termination, + Cables, +) +from .ipam import ( + IpAddresses, + Prefixes, + Aggregates, + Vlans, + VlanGroups, +) +from .virtualization import VirtualMachines +from .wireless import WirelessLans + + +CONTENT_TYPE_MAPPER = { + "circuits.circuit": Circuits, + "circuits.circuittermination": CircuitTerminations, + "dcim.cable": Cables, + "dcim.cablepath": None, + "dcim.cabletermination": Termination, + "dcim.consoleport": ConsolePorts, + "dcim.consoleporttemplate": None, + "dcim.consoleserverport": ConsoleServerPorts, + "dcim.consoleserverporttemplate": None, + "dcim.device": Devices, + "dcim.devicebay": None, + "dcim.devicebaytemplate": None, + "dcim.devicerole": None, + "dcim.devicetype": DeviceTypes, + "dcim.frontport": FrontPorts, + "dcim.frontporttemplate": None, + "dcim.interface": Interfaces, + "dcim.interfacetemplate": None, + "dcim.inventoryitem": None, + "dcim.inventoryitemrole": None, + "dcim.inventoryitemtemplate": None, + "dcim.location": None, + "dcim.manufacturer": None, + "dcim.module": None, + "dcim.modulebay": None, + "dcim.modulebaytemplate": None, + "dcim.moduletype": None, + "dcim.platform": None, + "dcim.powerfeed": None, + "dcim.poweroutlet": PowerOutlets, + "dcim.poweroutlettemplate": None, + "dcim.powerpanel": None, + "dcim.powerport": ConsolePorts, + "dcim.powerporttemplate": None, + "dcim.rack": Racks, + "dcim.rackreservation": RackReservations, + "dcim.rackrole": None, + "dcim.rearport": RearPorts, + "dcim.rearporttemplate": None, + "dcim.region": None, + "dcim.site": None, + "dcim.sitegroup": None, + "dcim.virtualchassis": VirtualChassis, + "extras.configcontext": None, + "extras.configrevision": None, + "extras.customfield": None, + "extras.customlink": None, + "extras.exporttemplate": None, + "extras.imageattachment": None, + "extras.jobresult": None, + "extras.journalentry": None, + "extras.objectchange": None, + "extras.report": None, + "extras.script": None, + "extras.tag": None, + "extras.taggeditem": None, + "extras.webhook": None, + "ipam.aggregate": Aggregates, + "ipam.ASN": None, + "ipam.FHRPgroup": None, + "ipam.FHRPgroupassignment": None, + "ipam.IPaddress": IpAddresses, + "ipam.IPrange": None, + "ipam.L2VPN": None, + "ipam.L2VPNtermination": None, + "ipam.prefix": Prefixes, + "ipam.RIR": None, + "ipam.role": None, + "ipam.routetarget": None, + "ipam.service": None, + "ipam.servicetemplate": None, + "ipam.VLAN": Vlans, + "ipam.VLANgroup": VlanGroups, + "ipam.VRF": None, + "tenancy.contact": None, + "tenancy.contactassignment": None, + "tenancy.contactgroup": None, + "tenancy.contactrole": None, + "tenancy.tenant": None, + "tenancy.tenantgroup": None, + "virtualization.cluster": None, + "virtualization.clustergroup": None, + "virtualization.clustertype": None, + "virtualization.interface": None, + "virtualization.virtualmachine": VirtualMachines, + "wireless.WirelessLAN": WirelessLans, + "wireless.WirelessLANGroup": None, + "wireless.wirelesslink": None, +} diff --git a/requirements-dev.txt b/requirements-dev.txt index f1cbd8c6..ce2b2e5f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ -black~=22.0 -pytest==6.2.* -pytest-docker==0.10.* +black~=22.10 +pytest==7.1.* +pytest-docker==1.0.* +PyYAML==6.0 diff --git a/setup.py b/setup.py index 61f02a0d..9e1d429c 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="pynetbox", description="NetBox API client library", - url="https://github.com/digitalocean/pynetbox", + url="https://github.com/netbox-community/netbox", author="Zach Moody", author_email="zmoody@do.co", license="Apache2", @@ -20,6 +20,8 @@ "Intended Audience :: Developers", "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], ) diff --git a/tests/conftest.py b/tests/conftest.py index bb202617..ee66593d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from packaging import version -DEFAULT_NETBOX_VERSIONS = "2.11, 3.0, 3.1" +DEFAULT_NETBOX_VERSIONS = "3.3" def pytest_addoption(parser): diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index aa0cc61f..c3e8d8c1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -28,12 +28,8 @@ def get_netbox_docker_version_tag(netbox_version): """ major, minor = netbox_version.major, netbox_version.minor - if (major, minor) == (3, 1): - tag = "1.5.1" - elif (major, minor) == (3, 0): - tag = "1.5.1" - elif (major, minor) == (2, 11): - tag = "1.2.0" + if (major, minor) == (3, 3): + tag = "2.2.0" else: raise NotImplementedError( "Version %s is not currently supported" % netbox_version @@ -385,14 +381,10 @@ def docker_netbox_service( """ netbox_integration_version = request.param - if netbox_integration_version >= version.Version("2.10"): - netbox_service_name = "netbox_v%s_netbox" % str( - netbox_integration_version - ).replace(".", "_") - else: - netbox_service_name = "netbox_v%s_nginx" % str( - netbox_integration_version - ).replace(".", "_") + netbox_service_name = "netbox_v%s_netbox" % str(netbox_integration_version).replace( + ".", "_" + ) + netbox_service_port = 8080 try: # `port_for` takes a container port and returns the corresponding host port diff --git a/tests/integration/test_dcim.py b/tests/integration/test_dcim.py index 6f5d2880..0f60bdc7 100644 --- a/tests/integration/test_dcim.py +++ b/tests/integration/test_dcim.py @@ -207,10 +207,12 @@ def power_port(self, api, device): @pytest.fixture(scope="class") def power_cable(self, api, power_outlet, power_port): cable = api.dcim.cables.create( - termination_a_id=power_port.id, - termination_a_type="dcim.powerport", - termination_b_id=power_outlet.id, - termination_b_type="dcim.poweroutlet", + a_terminations=[ + {"object_type": "dcim.powerport", "object_id": power_port.id}, + ], + b_terminations=[ + {"object_type": "dcim.poweroutlet", "object_id": power_outlet.id}, + ], ) yield cable cable.delete() @@ -247,10 +249,15 @@ def console_port(self, api, device): @pytest.fixture(scope="class") def console_cable(self, api, console_port, console_server_port): ret = api.dcim.cables.create( - termination_a_id=console_port.id, - termination_a_type="dcim.consoleport", - termination_b_id=console_server_port.id, - termination_b_type="dcim.consoleserverport", + a_terminations=[ + {"object_type": "dcim.consoleport", "object_id": console_port.id}, + ], + b_terminations=[ + { + "object_type": "dcim.consoleserverport", + "object_id": console_server_port.id, + }, + ], ) yield ret ret.delete() @@ -291,10 +298,12 @@ def interface_a(self, api, device): @pytest.fixture(scope="class") def interface_cable(self, api, interface_a, interface_b): ret = api.dcim.cables.create( - termination_a_id=interface_a.id, - termination_a_type="dcim.interface", - termination_b_id=interface_b.id, - termination_b_type="dcim.interface", + a_terminations=[ + {"object_type": "dcim.interface", "object_id": interface_a.id}, + ], + b_terminations=[ + {"object_type": "dcim.interface", "object_id": interface_b.id}, + ], ) yield ret ret.delete() @@ -313,4 +322,4 @@ def test_trace(self, interface_a): test = interface_a.trace() assert test assert test[0][0].name == "Ethernet1" - assert test[0][2].name == "Ethernet1" + assert test[2][0].name == "Ethernet1"