Skip to content

Commit

Permalink
Adds support for NetBox v3.5+ (#564)
Browse files Browse the repository at this point in the history
* Fix unused `PowerPorts` model (#535)

Updated models/mapper.py: set `PowerPorts` for "dcim.powerport" in the
map-dict.

* migrate from pkg_resources to importlib

* adds core app

* adds endpoints added in 3.5

* adds support for 3.5

* lint fixes

* updates openapi tests

* adds testing for 3.4 and 3.5

* updates pytest.skip

* updates docker tags for testing

* fixes superuser account creation for testing

* fixed requirements

* updates the docstring for render-config endpoint

* removes extra semicolon from content type value

* migrate from pkg_resources to importlib

* adds core app

* adds endpoints added in 3.5

* adds support for 3.5

* lint fixes

* updates openapi tests

* adds testing for 3.4 and 3.5

* updates pytest.skip

* updates docker tags for testing

* fixes superuser account creation for testing

* fixed requirements

* updates the docstring for render-config endpoint

* removes extra semicolon from content type value

---------

Co-authored-by: nautics889 <cyberukr@gmail.com>
  • Loading branch information
abhi1693 and nautics889 authored Aug 25, 2023
1 parent f32ed2f commit 98625ed
Show file tree
Hide file tree
Showing 17 changed files with 126 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/py3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
netbox: ["3.3"]
netbox: ["3.3", "3.4", "3.5"]

steps:
- uses: actions/checkout@v2
Expand Down
7 changes: 2 additions & 5 deletions pynetbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
from pkg_resources import get_distribution, DistributionNotFound
from importlib.metadata import metadata

from pynetbox.core.query import RequestError, AllocationError, ContentError
from pynetbox.core.api import Api as api

try:
__version__ = get_distribution(__name__).version
except DistributionNotFound:
pass
__version__ = metadata(__name__).get("Version")
2 changes: 2 additions & 0 deletions pynetbox/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Api:
you can specify which app and endpoint you wish to interact with.
Valid attributes currently are:
* core (NetBox 3.5+)
* dcim
* ipam
* circuits
Expand Down Expand Up @@ -74,6 +75,7 @@ def __init__(
self.base_url = base_url
self.http_session = requests.Session()
self.threading = threading
self.core = App(self, "core")
self.dcim = App(self, "dcim")
self.ipam = App(self, "ipam")
self.circuits = App(self, "circuits")
Expand Down
29 changes: 20 additions & 9 deletions pynetbox/core/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""
import concurrent.futures as cf
import json
from packaging import version


def calc_pages(limit, count):
Expand Down Expand Up @@ -153,12 +154,22 @@ def __init__(
def get_openapi(self):
"""Gets the OpenAPI Spec"""
headers = {
"Content-Type": "application/json;",
"Accept": "application/json",
"Content-Type": "application/json",
}
req = self.http_session.get(
"{}docs/?format=openapi".format(self.normalize_url(self.base)),
headers=headers,
)

current_version = version.parse(self.get_version())
if current_version >= version.parse("3.5"):
req = self.http_session.get(
"{}schema/".format(self.normalize_url(self.base)),
headers=headers,
)
else:
req = self.http_session.get(
"{}docs/?format=openapi".format(self.normalize_url(self.base)),
headers=headers,
)

if req.ok:
return req.json()
else:
Expand All @@ -175,7 +186,7 @@ def get_version(self):
present in the headers.
"""
headers = {
"Content-Type": "application/json;",
"Content-Type": "application/json",
}
req = self.http_session.get(
self.normalize_url(self.base),
Expand All @@ -192,7 +203,7 @@ def get_status(self):
:Returns: Dictionary as returned by NetBox.
:Raises: RequestError if request is not successful.
"""
headers = {"Content-Type": "application/json;"}
headers = {"Content-Type": "application/json"}
if self.token:
headers["authorization"] = "Token {}".format(self.token)
req = self.http_session.get(
Expand All @@ -213,9 +224,9 @@ def normalize_url(self, url):

def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
if verb in ("post", "put") or verb == "delete" and data:
headers = {"Content-Type": "application/json;"}
headers = {"Content-Type": "application/json"}
else:
headers = {"accept": "application/json;"}
headers = {"accept": "application/json"}

if self.token:
headers["authorization"] = "Token {}".format(self.token)
Expand Down
19 changes: 18 additions & 1 deletion pynetbox/models/dcim.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from pynetbox.core.query import Request
from pynetbox.core.response import Record, JsonField
from pynetbox.core.endpoint import RODetailEndpoint
from pynetbox.core.endpoint import RODetailEndpoint, DetailEndpoint
from pynetbox.models.ipam import IpAddresses
from pynetbox.models.circuits import Circuits

Expand Down Expand Up @@ -121,6 +121,23 @@ def napalm(self):
"""
return RODetailEndpoint(self, "napalm")

@property
def render_config(self):
"""
Represents the ``render-config`` detail endpoint.
Returns a DetailEndpoint object that is the interface for
viewing response from the render-config endpoint.
:returns: :py:class:`.DetailEndpoint`
:Examples:
>>> device = nb.ipam.devices.get(123)
>>> device.render_config.create()
"""
return DetailEndpoint(self, "render-config")


class InterfaceConnections(Record):
def __str__(self):
Expand Down
30 changes: 30 additions & 0 deletions pynetbox/models/ipam.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,33 @@ def available_vlans(self):
NewVLAN (10)
"""
return DetailEndpoint(self, "available-vlans", custom_return=Vlans)


class AsnRanges(Record):
@property
def available_asns(self):
"""
Represents the ``available-asns`` detail endpoint.
Returns a DetailEndpoint object that is the interface for
viewing and creating ASNs inside an ASN range.
:returns: :py:class:`.DetailEndpoint`
:Examples:
>>> asn_range = nb.ipam.asn_ranges.get(1)
>>> asn_range.available_asns.list()
[64512, 64513, 64514]
To create a new ASN:
>>> asn_range.available_asns.create()
64512
To create multiple ASNs:
>>> asn_range.available_asns.create([{} for i in range(2)])
[64513, 64514]
"""
return DetailEndpoint(self, "available-asns")
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
black~=22.10
pytest==7.1.*
pytest-docker==1.0.*
PyYAML==6.0
PyYAML==6.0.1
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
requests>=2.20.0,<3.0
packaging<24.0

This comment has been minimized.

Copy link
@he32

he32 Apr 24, 2024

Hm, in a world where backward compatibility is a thing, this looks like a strange requirement, especially given that the packaging 24.0 announcement also doesn't mention any backward incompatibility. So I need to ask: is this requirement really necessary and correct?

This comment has been minimized.

Copy link
@chkwok

chkwok Jul 17, 2024

The only usage is from packaging import version, then comparing results of version.parse(), there is no way packaging >= 24 will break that. Installing pynetbox now downgrades packaging and results in wrong version comparion in some cases, pypa/packaging#683, and the number of issues will increase whenever packaging is updated to fix bugs.

1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
packages=find_packages(exclude=["tests", "tests.*"]),
install_requires=[
"requests>=2.20.0,<3.0",
"packaging<24.0"
],
zip_safe=False,
keywords=["netbox"],
Expand Down
20 changes: 16 additions & 4 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ def get_netbox_docker_version_tag(netbox_version):
major, minor = netbox_version.major, netbox_version.minor

if (major, minor) == (3, 3):
tag = "2.2.0"
tag = "2.3.0"
elif (major, minor) == (3, 4):
tag = "2.5.3"
elif (major, minor) == (3, 5):
tag = "2.6.1"
else:
raise NotImplementedError(
"Version %s is not currently supported" % netbox_version
Expand All @@ -48,7 +52,7 @@ def git_toplevel():
try:
subp.check_call(["which", "git"])
except subp.CalledProcessError:
pytest.skip(msg="git executable was not found on the host")
pytest.skip(reason="git executable was not found on the host")
return (
subp.check_output(["git", "rev-parse", "--show-toplevel"])
.decode("utf-8")
Expand All @@ -73,7 +77,7 @@ def netbox_docker_repo_dirpaths(pytestconfig, git_toplevel):
try:
subp.check_call(["which", "docker"])
except subp.CalledProcessError:
pytest.skip(msg="docker executable was not found on the host")
pytest.skip(reason="docker executable was not found on the host")
netbox_versions_by_repo_dirpaths = {}
for netbox_version in pytestconfig.option.netbox_versions:
repo_version_tag = get_netbox_docker_version_tag(netbox_version=netbox_version)
Expand Down Expand Up @@ -248,6 +252,14 @@ def docker_compose_file(pytestconfig, netbox_docker_repo_dirpaths):
"netboxcommunity/netbox:v%s" % netbox_version
)

new_services[new_service_name]["environment"] = {
"SKIP_SUPERUSER": "false",
"SUPERUSER_API_TOKEN": "0123456789abcdef0123456789abcdef01234567",
"SUPERUSER_EMAIL": "admin@example.com",
"SUPERUSER_NAME": "admin",
"SUPERUSER_PASSWORD": "admin",
}

if service_name == "netbox":
# ensure the netbox container listens on a random port
new_services[new_service_name]["ports"] = ["8080"]
Expand Down Expand Up @@ -341,7 +353,7 @@ def docker_compose_file(pytestconfig, netbox_docker_repo_dirpaths):


def netbox_is_responsive(url):
"""Chack if the HTTP service is up and responsive."""
"""Check if the HTTP service is up and responsive."""
try:
response = requests.get(url)
if response.status_code == 200:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

nb = api.circuits

HEADERS = {"accept": "application/json;"}
HEADERS = {"accept": "application/json"}


class Generic:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_tenancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

nb = api.tenancy

HEADERS = {"accept": "application/json;"}
HEADERS = {"accept": "application/json"}


class Generic:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

nb = api.users

HEADERS = {"accept": "application/json;"}
HEADERS = {"accept": "application/json"}


class Generic:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_virtualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

nb = api.virtualization

HEADERS = {"accept": "application/json;"}
HEADERS = {"accept": "application/json"}


class Generic:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_wireless.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

nb_app = api.wireless

HEADERS = {"accept": "application/json;"}
HEADERS = {"accept": "application/json"}


class Generic:
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ def test_get_count(self):
expected = call(
"http://localhost:8001/api/dcim/devices/",
params={"q": "abcd", "limit": 1},
headers={"accept": "application/json;"},
headers={"accept": "application/json"},
)
test_obj.http_session.get.ok = True
test = test_obj.get_count()
self.assertEqual(test, 42)
test_obj.http_session.get.assert_called_with(
"http://localhost:8001/api/dcim/devices/",
params={"q": "abcd", "limit": 1},
headers={"accept": "application/json;"},
headers={"accept": "application/json"},
json=None,
)

Expand All @@ -49,7 +49,7 @@ def test_get_count_no_filters(self):
test_obj.http_session.get.assert_called_with(
"http://localhost:8001/api/dcim/devices/",
params={"limit": 1},
headers={"accept": "application/json;"},
headers={"accept": "application/json"},
json=None,
)

Expand All @@ -69,14 +69,14 @@ def test_get_manual_pagination(self):
expected = call(
"http://localhost:8001/api/dcim/devices/",
params={"offset": 20, "limit": 10},
headers={"accept": "application/json;"},
headers={"accept": "application/json"},
)
test_obj.http_session.get.ok = True
generator = test_obj.get()
self.assertEqual(len(list(generator)), 4)
test_obj.http_session.get.assert_called_with(
"http://localhost:8001/api/dcim/devices/",
params={"offset": 20, "limit": 10},
headers={"accept": "application/json;"},
headers={"accept": "application/json"},
json=None,
)
26 changes: 24 additions & 2 deletions tests/unit/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,32 @@


class RequestTestCase(unittest.TestCase):
def test_get_openapi(self):
def test_get_openapi_version_less_than_3_5(self):
test = Request("http://localhost:8080/api", Mock())
test.get_version = Mock(return_value="3.4")

# Mock the HTTP response
response_mock = Mock()
response_mock.ok = True
test.http_session.get.return_value = response_mock

test.get_openapi()
test.http_session.get.assert_called_with(
"http://localhost:8080/api/docs/?format=openapi",
headers={"Content-Type": "application/json;"},
headers={"Accept": "application/json", "Content-Type": "application/json"},
)

def test_get_openapi_version_3_5_or_greater(self):
test = Request("http://localhost:8080/api", Mock())
test.get_version = Mock(return_value="3.5")

# Mock the HTTP response
response_mock = Mock()
response_mock.ok = True
test.http_session.get.return_value = response_mock

test.get_openapi()
test.http_session.get.assert_called_with(
"http://localhost:8080/api/schema/",
headers={"Accept": "application/json", "Content-Type": "application/json"},
)

0 comments on commit 98625ed

Please sign in to comment.